Skip to content

Commit a1ea884

Browse files
authored
Handler-seam cleanup around the #844 sweep — cancelled status, dead shims, typesGen extraction (#863)
* feat(everything-dev): bos clean — the local-only rule's exit valve (ADR 0047, #844) One command that removes regenerable .bos state and refuses the artifacts that are truth or identity. The partition (planClean/executeClean) is the contract, unit-tested; the handler and renderer just run and show it. - removes: logs (except files touched within an hour — live sessions), bundles, regression, generated, bundle-cache, snapshot, sync-backup, import-backup, compose-retired, manifest cache, resolved config, build cache - never touches (reported as kept, with reason): db/ (PGlite data), session.json (credentials), infra-state.json (port prefs), deploy-state.json (cleaning costs full re-uploads) - --dry-run reports per-category bytes without deleting - route POST /clean in the CLI contract; smoke-tested end to end * feat(everything-dev): bos upgrade shows its plan — per-file consent, not refuse-or-force (#844) On a TTY, upgrade stops before writing: the version delta, then every file the sync will write (updates, additions, conflicts labeled) as a multi-select defaulting to overwrite-all — space toggles, enter confirms — plus an optional post-upgrade bos clean (backups kept), and a Write? gate. Cancelling leaves the tree untouched. - sync gains overwrite: string[] — the approved write set. Exhaustive filter over updated/added/conflicted: selected files written (backed up), deselected conflicts preserved (never an abort), and a preserved file keeps its OLD baseline — the skip never launders a local fork into the new normal; the next upgrade re-asks via the three-way comparison. - the selection rides the re-exec boundary (--overwrite to the migration child) so the migration phase's sync applies the same approved set - post-upgrade clean runs executeClean with sync/import backups skipped - --force keeps its meaning; non-TTY keeps refuse-on-conflict; the preflight abort names the interactive plan as the remedy * refactor(everything-dev): the handler seam cleans up around the #844 sweep - the dead cli/snapshot.ts and cli/upgrade.ts re-export shims die — importers resolve services/scaffold/{snapshot,upgrade} directly - declining the interactive upgrade plan reports a cancelled result through the normal pipeline (contract enum + renderer line) instead of process.exit from inside an Effect generator — exit decisions live at the CLI edge only, and the runtime stays embeddable - the typesGen handler moves out of handlers/upgrade.ts into its own handlers/types-gen.ts — pure move, registered identically
1 parent 5aa85a4 commit a1ea884

15 files changed

Lines changed: 274 additions & 219 deletions

‎.changeset/handler-cleanup.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"everything-dev": minor
3+
---
4+
5+
Handler-seam cleanup around the #844 sweep
6+
7+
- `bos upgrade --json` / the result contract gains a `cancelled` status: declining the interactive plan (`Write? no`, or ctrl-c) reports a cancelled result through the normal pipeline instead of calling `process.exit` from inside an Effect generator. Exit code stays 0; the renderer prints "Upgrade cancelled — nothing was written".
8+
- The dead `cli/snapshot.ts` and `cli/upgrade.ts` re-export shims are deleted — importers (handler, the upgrade plan, five tests) resolve `services/scaffold/{snapshot,upgrade}` directly.
9+
- The `typesGen` handler moves out of `handlers/upgrade.ts` (~705 → ~515 lines) into its own `handlers/types-gen.ts` — pure move, registered identically in the plugin router.

‎packages/everything-dev/src/cli/commands/scaffold.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ export const scaffoldRenderers: Record<string, ResultRenderer> = {
118118
return;
119119
}
120120
console.log();
121+
if (result.status === "cancelled") {
122+
console.log(colors.dim(`${icons.ok} Upgrade cancelled — nothing was written`));
123+
console.log();
124+
return;
125+
}
121126
if (result.status === "already-upgraded") {
122127
console.log(colors.green(`${icons.ok} Already upgraded · v2 — nothing to do`));
123128
for (const note of result.notes ?? []) console.log(` ${colors.dim(note)}`);

‎packages/everything-dev/src/cli/snapshot.ts‎

Lines changed: 0 additions & 4 deletions
This file was deleted.

‎packages/everything-dev/src/cli/upgrade-plan.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import process from "node:process";
22
import * as p from "@clack/prompts";
33
import type { UpgradeOptions } from "../contract";
44
import { syncTemplate } from "../services/scaffold/sync";
5+
import { upgradeTemplate } from "../services/scaffold/upgrade";
56
import { colors } from "../utils/theme";
6-
import { upgradeTemplate } from "./upgrade";
77

88
/**
99
* The interactive upgrade plan (`bos upgrade` on a TTY): show the version
@@ -19,10 +19,15 @@ export interface UpgradeInteractionDecision {
1919
cleanAfter?: boolean;
2020
}
2121

22+
/**
23+
* `null` — the user explicitly declined (cancel or "Write? no"): nothing was
24+
* written, and the caller reports a cancelled result instead of exiting the
25+
* runtime from inside an Effect generator.
26+
*/
2227
export async function planUpgradeInteraction(
2328
projectDir: string,
2429
input: UpgradeOptions,
25-
): Promise<UpgradeInteractionDecision> {
30+
): Promise<UpgradeInteractionDecision | null> {
2631
if (input.force || input.dryRun || input.check || input.noSync || input.migrationsOnly) {
2732
return {};
2833
}
@@ -65,23 +70,23 @@ export async function planUpgradeInteraction(
6570
required: false,
6671
});
6772
if (p.isCancel(selected)) {
68-
p.cancel("Cancelled — nothing was written");
69-
process.exit(0);
73+
p.cancel("Upgrade cancelled — nothing was written");
74+
return null;
7075
}
7176

7277
const cleanAfter = await p.confirm({
7378
message: "Prune logs and caches after the upgrade (bos clean — upgrade backups kept)?",
7479
initialValue: true,
7580
});
7681
if (p.isCancel(cleanAfter)) {
77-
p.cancel("Cancelled — nothing was written");
78-
process.exit(0);
82+
p.cancel("Upgrade cancelled — nothing was written");
83+
return null;
7984
}
8085

8186
const go = await p.confirm({ message: "Write?", initialValue: true });
8287
if (p.isCancel(go) || !go) {
83-
p.cancel("Cancelled — nothing was written");
84-
process.exit(0);
88+
p.cancel("Upgrade cancelled — nothing was written");
89+
return null;
8590
}
8691

8792
p.log.message(

‎packages/everything-dev/src/cli/upgrade.ts‎

Lines changed: 0 additions & 4 deletions
This file was deleted.

‎packages/everything-dev/src/contract.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,16 @@ export const UpgradeAuditManifestSchema = z.object({
848848
});
849849

850850
export const UpgradeResultSchema = z.object({
851-
status: z.enum(["upgraded", "dry-run", "error", "already-upgraded", "gate-blocked", "audit"]),
851+
status: z.enum([
852+
"upgraded",
853+
"dry-run",
854+
"error",
855+
"already-upgraded",
856+
"gate-blocked",
857+
"audit",
858+
/** The interactive plan's explicit decline — nothing was written. */
859+
"cancelled",
860+
]),
852861
packages: z.array(
853862
z.object({
854863
name: z.string(),
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { dirname, join, relative, resolve } from "node:path";
3+
import process from "node:process";
4+
import { Effect } from "effect";
5+
import { generateUiManifest } from "every-plugin/ui/manifest-generator";
6+
import { findConfigPath, MISSING_CONFIG_MESSAGE } from "../config";
7+
import { generateCodeArtifacts } from "../services/codegen/artifacts";
8+
import { checkFederationCompat, checkUiCompositionCompat } from "../services/federation/service";
9+
import { readInstalledFrameworkVersion } from "../services/framework-version";
10+
import {
11+
ManifestService,
12+
type PlatformCompatViolation,
13+
type ResolvedPlatformManifest,
14+
} from "../services/platform/manifest";
15+
import { resolvePublishedConfig } from "../services/registry/published";
16+
import { openResolution } from "../services/resolution/session";
17+
import { selectWorkspaceTargets } from "../services/workspace/build-train";
18+
import { readWorkspaceCatalog } from "../services/workspace/catalog";
19+
import { findWorkspaceRoot } from "../services/workspace/workspace";
20+
import type { BosConfig } from "../types";
21+
import { colors } from "../utils/theme";
22+
import type { BosBuilder } from "./shared";
23+
24+
const nodeEnvProduction = (): boolean => process.env.NODE_ENV === "production";
25+
const envOf = (name: string): string | undefined => process.env[name];
26+
27+
export function registerTypesGen(builder: BosBuilder) {
28+
return {
29+
typesGen: builder.typesGen.effect(function* (_opts, input) {
30+
return yield* Effect.tryPromise({
31+
try: async () => {
32+
const configPath = findConfigPath();
33+
if (!configPath) {
34+
return {
35+
status: "error" as const,
36+
generated: [],
37+
fetched: [],
38+
skipped: [],
39+
failed: [],
40+
error: `${MISSING_CONFIG_MESSAGE} in current directory`,
41+
};
42+
}
43+
44+
const projectDir = resolve(dirname(configPath));
45+
const env = input.env ?? (nodeEnvProduction() ? "production" : "development");
46+
47+
const refreshed = await openResolution({
48+
cwd: projectDir,
49+
env,
50+
remotePlugins: input.remotePlugins,
51+
});
52+
if (!refreshed?.config || !refreshed.runtime) {
53+
return {
54+
status: "error" as const,
55+
generated: [],
56+
fetched: [],
57+
skipped: [],
58+
failed: [],
59+
error: "Failed to load the authored config",
60+
};
61+
}
62+
63+
const runtime = refreshed.runtime;
64+
const resolvedConfig = refreshed.config;
65+
66+
if (input.dryRun) {
67+
const pluginEntries = Object.entries(runtime.plugins ?? {});
68+
const fetched: string[] = [];
69+
const skipped: string[] = [];
70+
const hasLocalApiWorkspace = existsSync(join(projectDir, "api", "src"));
71+
72+
if (runtime.api.source !== "local") {
73+
fetched.push(`api remote (${runtime.api.url})`);
74+
} else {
75+
const path = runtime.api.localPath
76+
? ` (${relative(projectDir, runtime.api.localPath)})`
77+
: "";
78+
skipped.push(`api local${path}`);
79+
}
80+
81+
if (runtime.auth) {
82+
if (runtime.auth.source !== "local") {
83+
fetched.push(`auth remote (${runtime.auth.url})`);
84+
} else {
85+
const path = runtime.auth.localPath
86+
? ` (${relative(projectDir, runtime.auth.localPath)})`
87+
: "";
88+
skipped.push(`auth local${path}`);
89+
}
90+
}
91+
92+
for (const [key, plugin] of pluginEntries) {
93+
if (plugin.url && plugin.source !== "local") {
94+
fetched.push(`${key} remote (${plugin.url})`);
95+
} else if (plugin.localPath) {
96+
skipped.push(`${key} local (${relative(projectDir, plugin.localPath)})`);
97+
} else {
98+
skipped.push(`${key} no URL resolved`);
99+
}
100+
}
101+
102+
const generated = [
103+
"ui/src/lib/api-types.gen.ts",
104+
"ui/src/lib/auth-types.gen.ts",
105+
".bos/bos.resolved-config.json",
106+
];
107+
if (hasLocalApiWorkspace) {
108+
generated.push("api/src/lib/plugins-types.gen.ts", "api/src/lib/auth-types.gen.ts");
109+
}
110+
if (existsSync(join(projectDir, "host", "src"))) {
111+
generated.push("host/src/lib/auth-types.gen.ts");
112+
}
113+
for (const [_key, plugin] of pluginEntries) {
114+
const localPath = plugin.localPath;
115+
if (!localPath) continue;
116+
const pluginSrc = join(localPath, "src", "lib", "plugins-client.gen.ts");
117+
if (existsSync(pluginSrc)) {
118+
generated.push(relative(projectDir, pluginSrc));
119+
}
120+
}
121+
if (runtime.auth?.localPath) {
122+
const authSrc = join(runtime.auth.localPath, "src", "lib", "plugins-client.gen.ts");
123+
if (existsSync(authSrc)) {
124+
generated.push(relative(projectDir, authSrc));
125+
}
126+
}
127+
128+
return {
129+
status: "success" as const,
130+
generated,
131+
fetched,
132+
skipped,
133+
failed: [],
134+
};
135+
}
136+
137+
const artifacts = await generateCodeArtifacts(projectDir, resolvedConfig, {
138+
env,
139+
extendsChain: [...refreshed.chain],
140+
runtimeConfig: runtime,
141+
forceRouteTree: true,
142+
// bos types gen is the strict runtime-map gate (#594): an
143+
// unresolvable remote manifest fails the run so typecheck
144+
// catches an upstream rename instead of shrinking the map
145+
runtimeMapStrict: true,
146+
});
147+
148+
const hasLocalApiWorkspace = existsSync(join(projectDir, "api", "src"));
149+
const generated = ["ui/src/lib/api-types.gen.ts"];
150+
if (hasLocalApiWorkspace) {
151+
generated.push("api/src/lib/plugins-types.gen.ts", "api/src/lib/auth-types.gen.ts");
152+
}
153+
if (runtime.auth && (runtime.auth.source !== "local" || runtime.auth.localPath)) {
154+
generated.push("ui/src/lib/auth-types.gen.ts");
155+
}
156+
if (existsSync(join(projectDir, "host", "src"))) {
157+
generated.push("host/src/lib/auth-types.gen.ts");
158+
}
159+
for (const [_key, plugin] of Object.entries(runtime.plugins ?? {})) {
160+
const localPath = plugin.localPath;
161+
if (!localPath) continue;
162+
const pluginSrc = join(localPath, "src", "lib", "plugins-client.gen.ts");
163+
if (existsSync(pluginSrc)) {
164+
generated.push(relative(projectDir, pluginSrc));
165+
}
166+
}
167+
if (runtime.auth?.localPath) {
168+
const authSrc = join(runtime.auth.localPath, "src", "lib", "plugins-client.gen.ts");
169+
if (existsSync(authSrc)) {
170+
generated.push(relative(projectDir, authSrc));
171+
}
172+
}
173+
174+
const contractStatus = artifacts?.contractStatus ?? [];
175+
const fetched: string[] = [];
176+
const skipped: string[] = [];
177+
const failed: string[] = [];
178+
for (const entry of contractStatus) {
179+
if (entry.source === "remote") {
180+
fetched.push(entry.url ? `${entry.key} remote (${entry.url})` : entry.key);
181+
} else if (entry.source === "local") {
182+
const path = entry.localPath ? ` (${relative(projectDir, entry.localPath)})` : "";
183+
skipped.push(`${entry.key} local${path}`);
184+
} else if (entry.source === "skipped") {
185+
skipped.push(`${entry.key} no URL resolved`);
186+
} else if (entry.source === "failed") {
187+
const detail = entry.error ? `: ${entry.error}` : "";
188+
failed.push(`${entry.key}${detail}`);
189+
}
190+
}
191+
192+
return {
193+
status: "success" as const,
194+
generated: [
195+
...generated,
196+
".bos/bos.resolved-config.json",
197+
// the composition inventory (#594) — emitted when the map's
198+
// sources resolve (skipped with a note otherwise)
199+
...(existsSync(join(projectDir, ".bos/generated/runtime-map.json"))
200+
? [".bos/generated/runtime-map.json"]
201+
: []),
202+
...(runtime.ui?.source === "local" &&
203+
runtime.ui.localPath &&
204+
existsSync(join(runtime.ui.localPath, "src/lib/runtime-map.gen.ts"))
205+
? ["ui/src/lib/runtime-map.gen.ts"]
206+
: []),
207+
],
208+
fetched,
209+
skipped,
210+
failed,
211+
};
212+
},
213+
catch: (error) => ({
214+
status: "error" as const,
215+
generated: [],
216+
fetched: [],
217+
skipped: [],
218+
failed: [],
219+
error: error instanceof Error ? error.message : "Unknown error",
220+
}),
221+
});
222+
}),
223+
};
224+
}

0 commit comments

Comments
 (0)