Skip to content

Commit cdeedad

Browse files
refactor(install): split bootstrap ownership
1 parent c5c89d8 commit cdeedad

7 files changed

Lines changed: 192 additions & 101 deletions

File tree

docs/intentional-architecture-rewrite-2026-06-27/decision-log.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ Page-source indexing is indexer-owned, but it is not one bucket. `src/stores/wik
207207

208208
Viewer read models are route-payload owners, not one API bucket. `src/edges/viewer/read-model/api.ts` composes the public viewer API. `db.ts` owns freshness and index open/close mechanics for viewer reads. `overview.ts`, `page.ts`, `topic.ts`, and `search.ts` own the route payloads they return. `types.ts` owns the route-facing viewer API contracts.
209209

210+
Bare-`codealmanac` bootstrap is a flow coordinator, not a package-manager bucket. `src/platform/install/global.ts` owns the high-level bootstrap sequence. `bootstrap-process.ts` owns child-process spawn/capture mechanics. `bootstrap-package.ts` owns package-root detection, package version reads, root equality, and version comparison. `bootstrap-npm.ts` owns npm global-root discovery, global install execution, and npm failure text.
211+
210212
Codex app-server runtime has two layers. `app-server.ts` coordinates provider runtime state: request/config setup, JSON-RPC transport wiring, notification mapping, root-turn completion, turn watchdogs, and final result projection. `app-server-process.ts` owns child-process mechanics: spawning the Codex app-server, decoding stdout JSONL into protocol messages, collecting stderr for close failures, registering signal handlers, writing JSON-RPC messages to stdin, and terminating the managed child.
211213

212214
Codex app-server notifications are routed by notification kind. `app-notifications.ts` owns the top-level method router and generic notification categories. `app-agent-messages.ts` owns agent-message semantics: text deltas, root result capture, structured output parsing, invalid structured-output failure state, and helper-agent completion events. `app-terminal-events.ts` owns terminal event semantics: turn completion, warnings, app-server error notifications, terminal run-state success/failure mutation, and `classifyCodexFailure` calls. Tool display, usage parsing, actor tracing, root-turn detection, and process mechanics stay in their existing named files.

docs/intentional-architecture-rewrite-2026-06-27/status.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Branch: `codex/intentional-architecture-rewrite`
55

66
## Current State
77

8-
The branch has more than 340 committed rewrite commits past `dev`. The worklog records 288 production slices so far.
8+
The branch has more than 340 committed rewrite commits past `dev`. The worklog records 289 production slices so far.
99

1010
The diff is broad: more than 680 files changed, with tens of thousands of lines reshaped.
1111

@@ -147,6 +147,7 @@ This is no longer a small cleanup branch. It is a real ownership rewrite.
147147
- Split indexer page-source normalization into coordination, structured projection, legacy projection, source-id generation, and type contracts.
148148
- Split setup input controls into line prompts, single-choice select, raw input capability, multi-select, and interruption handling.
149149
- Split viewer read-model route payloads into overview, page, topic, search/file, DB freshness, and type owners.
150+
- Split bare-`codealmanac` install bootstrap into flow coordination, package-root/version, npm global install, and process-spawn owners.
150151
- Moved repeated store atomic-write temp-file mechanics into `src/stores/atomic-write.ts`, removing process-PID temp names from job and sync stores.
151152
- Split most command rendering into command-private render files.
152153
- Added architecture-boundary tests to stop old dependency leaks from returning.
@@ -163,12 +164,12 @@ This is no longer a small cleanup branch. It is a real ownership rewrite.
163164

164165
## Latest Checkpoint
165166

166-
The latest slice split the viewer read-model API bucket. `api.ts` now composes the route-facing viewer API, while `db.ts`, `overview.ts`, `page.ts`, `topic.ts`, `search.ts`, and `types.ts` own the individual read-model responsibilities.
167+
The latest slice split the bare-`codealmanac` install bootstrap bucket. `global.ts` now coordinates the bootstrap flow, while `bootstrap-package.ts`, `bootstrap-npm.ts`, and `bootstrap-process.ts` own package-root/version mechanics, npm mechanics, and child-process mechanics.
167168

168169
Verification passed:
169170

170171
- `npm run lint`
171-
- `npx vitest run test/viewer-api.test.ts test/viewer-global-api.test.ts test/architecture-wiki-command-boundaries.test.ts`
172+
- `npx vitest run test/global-bootstrap.test.ts test/architecture-setup-boundaries.test.ts`
172173
- `npx vitest run test/architecture-*-boundaries.test.ts`
173174
- `git diff --check`
174175
- `npm test`

docs/intentional-architecture-rewrite-2026-06-27/worklog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,3 +2068,11 @@ Two-hundred-eighty-eighth production slice:
20682068
- Added owner-named viewer read-model files for overview, page detail, topic detail, search/suggest/file mentions, and shared route-facing types.
20692069
- Kept `api.ts` focused on composing the public viewer API over the owner-named route payload builders.
20702070
- Strengthened viewer boundary tests so overview/page/search/topic payload logic and DB freshness mechanics do not collapse back into one API bucket.
2071+
2072+
Two-hundred-eighty-ninth production slice:
2073+
2074+
- Split install bootstrap package mechanics out of `src/platform/install/global.ts`.
2075+
- Added `src/platform/install/bootstrap-package.ts` for current package-root detection, package version reads, package-root equality, and version-comparison install decisions.
2076+
- Added `src/platform/install/bootstrap-npm.ts` for npm global-root discovery, global package install execution, and npm-install failure text.
2077+
- Kept `global.ts` focused on the bare-`codealmanac` bootstrap flow: local setup bypass, global-root resolution, global install decision, and rerun through the global launcher.
2078+
- Strengthened setup architecture tests so process spawning, package-root/version probing, and npm install mechanics stay in separate install-platform owners.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import path from "node:path";
2+
3+
import {
4+
defaultBootstrapSpawn,
5+
spawnCapturedProcess,
6+
spawnInheritedProcess,
7+
type BootstrapSpawnFn,
8+
} from "./bootstrap-process.js";
9+
10+
export interface GlobalPackageRootResolution {
11+
ok: true;
12+
path: string;
13+
}
14+
15+
export interface GlobalPackageRootResolutionError {
16+
ok: false;
17+
stderr: string;
18+
}
19+
20+
export type GlobalPackageRootResult =
21+
| GlobalPackageRootResolution
22+
| GlobalPackageRootResolutionError;
23+
24+
export async function resolveGlobalPackageRoot(
25+
spawnFn: BootstrapSpawnFn = defaultBootstrapSpawn,
26+
): Promise<GlobalPackageRootResult> {
27+
const result = await spawnCapturedProcess(spawnFn, "npm", ["root", "-g"]);
28+
if (result.exitCode !== 0) {
29+
return {
30+
ok: false,
31+
stderr:
32+
"almanac: could not find npm's global install directory.\n" +
33+
"Install Node.js + npm, or install the codealmanac package via your package manager.\n",
34+
};
35+
}
36+
37+
const root = result.stdout.trim();
38+
if (root.length === 0) {
39+
return {
40+
ok: false,
41+
stderr:
42+
"almanac: npm returned an empty global install directory.\n" +
43+
"Try: npm root -g\n",
44+
};
45+
}
46+
47+
return { ok: true, path: path.join(root, "codealmanac") };
48+
}
49+
50+
export async function installGlobalCodealmanacPackage(args: {
51+
spawnFn?: BootstrapSpawnFn;
52+
env: NodeJS.ProcessEnv;
53+
}): Promise<{ ok: true } | { ok: false; stderr: string; exitCode: number }> {
54+
const install = await spawnInheritedProcess(
55+
args.spawnFn ?? defaultBootstrapSpawn,
56+
"npm",
57+
["i", "-g", "codealmanac@latest"],
58+
args.env,
59+
);
60+
if (install.exitCode === 0) return { ok: true };
61+
62+
return {
63+
ok: false,
64+
stderr:
65+
`almanac: npm install failed (exit ${install.exitCode}).\n` +
66+
`If you see "EACCES" above, try: sudo npm i -g codealmanac@latest\n` +
67+
`Or install with a version manager (nvm, volta, fnm) to avoid sudo.\n`,
68+
exitCode: install.exitCode,
69+
};
70+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { readFile } from "node:fs/promises";
2+
import { createRequire } from "node:module";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
import { isNewerVersion } from "../../shared/version.js";
7+
8+
export async function shouldInstallGlobalPackage(args: {
9+
currentRoot: string;
10+
globalRoot: string;
11+
}): Promise<boolean> {
12+
const globalVersion = await readPackageVersion(args.globalRoot);
13+
if (globalVersion === null) return true;
14+
15+
const currentVersion = await readPackageVersion(args.currentRoot);
16+
if (currentVersion === null) return false;
17+
18+
return isNewerVersion(currentVersion, globalVersion);
19+
}
20+
21+
export function samePackageRoot(a: string, b: string): boolean {
22+
return path.resolve(a) === path.resolve(b);
23+
}
24+
25+
export async function readPackageVersion(root: string): Promise<string | null> {
26+
try {
27+
const raw = await readFile(path.join(root, "package.json"), "utf8");
28+
const parsed = JSON.parse(raw) as { version?: unknown };
29+
return typeof parsed.version === "string" && parsed.version.length > 0
30+
? parsed.version
31+
: null;
32+
} catch {
33+
return null;
34+
}
35+
}
36+
37+
export function findCurrentPackageRoot(): string {
38+
const here = path.dirname(fileURLToPath(import.meta.url));
39+
const candidates = [
40+
// Bundled: `.../codealmanac/dist/launcher.js` -> package root.
41+
path.resolve(here, ".."),
42+
// Old source/dist layout: `.../codealmanac/src/install/global.ts` -> package root.
43+
path.resolve(here, "..", ".."),
44+
// Source/dist platform layout: `.../codealmanac/src/platform/install/global.ts`.
45+
path.resolve(here, "..", "..", ".."),
46+
];
47+
48+
for (const candidate of candidates) {
49+
if (isCodealmanacPackageRoot(candidate)) return candidate;
50+
}
51+
52+
return path.resolve(here, "..", "..", "..");
53+
}
54+
55+
function isCodealmanacPackageRoot(candidate: string): boolean {
56+
try {
57+
const require = createRequire(import.meta.url);
58+
const pkg = require(path.join(candidate, "package.json")) as {
59+
name?: unknown;
60+
};
61+
return pkg.name === "codealmanac";
62+
} catch {
63+
return false;
64+
}
65+
}

src/platform/install/global.ts

Lines changed: 16 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { readFile } from "node:fs/promises";
2-
import { createRequire } from "node:module";
31
import path from "node:path";
4-
import { fileURLToPath } from "node:url";
52

6-
import { isNewerVersion } from "../../shared/version.js";
73
import {
84
defaultBootstrapSpawn,
9-
spawnCapturedProcess,
105
spawnInheritedProcess,
116
type BootstrapSpawnFn,
127
} from "./bootstrap-process.js";
8+
import {
9+
findCurrentPackageRoot,
10+
samePackageRoot,
11+
shouldInstallGlobalPackage,
12+
} from "./bootstrap-package.js";
13+
import {
14+
installGlobalCodealmanacPackage,
15+
resolveGlobalPackageRoot,
16+
} from "./bootstrap-npm.js";
1317

1418
/**
1519
* Bare `codealmanac` is the npm bootstrap surface. When it is invoked
@@ -65,24 +69,19 @@ export async function runCodealmanacBootstrap(
6569
}
6670

6771
const globalRoot = globalRootResult.path;
68-
if (samePath(currentRoot, globalRoot)) {
72+
if (samePackageRoot(currentRoot, globalRoot)) {
6973
return await opts.runLocalSetup();
7074
}
7175

72-
if (await shouldInstallGlobal(currentRoot, globalRoot)) {
73-
const install = await spawnInheritedProcess(
74-
opts.spawnFn ?? defaultBootstrapSpawn,
75-
"npm",
76-
["i", "-g", "codealmanac@latest"],
76+
if (await shouldInstallGlobalPackage({ currentRoot, globalRoot })) {
77+
const install = await installGlobalCodealmanacPackage({
78+
spawnFn: opts.spawnFn,
7779
env,
78-
);
79-
if (install.exitCode !== 0) {
80+
});
81+
if (!install.ok) {
8082
return {
8183
stdout: "",
82-
stderr:
83-
`almanac: npm install failed (exit ${install.exitCode}).\n` +
84-
`If you see "EACCES" above, try: sudo npm i -g codealmanac@latest\n` +
85-
`Or install with a version manager (nvm, volta, fnm) to avoid sudo.\n`,
84+
stderr: install.stderr,
8685
exitCode: install.exitCode,
8786
};
8887
}
@@ -105,84 +104,3 @@ export async function runCodealmanacBootstrap(
105104
exitCode: rerun.exitCode,
106105
};
107106
}
108-
109-
async function shouldInstallGlobal(
110-
currentRoot: string,
111-
globalRoot: string,
112-
): Promise<boolean> {
113-
const globalVersion = await readPackageVersion(globalRoot);
114-
if (globalVersion === null) return true;
115-
116-
const currentVersion = await readPackageVersion(currentRoot);
117-
if (currentVersion === null) return false;
118-
119-
return isNewerVersion(currentVersion, globalVersion);
120-
}
121-
122-
function samePath(a: string, b: string): boolean {
123-
return path.resolve(a) === path.resolve(b);
124-
}
125-
126-
async function readPackageVersion(root: string): Promise<string | null> {
127-
try {
128-
const raw = await readFile(path.join(root, "package.json"), "utf8");
129-
const parsed = JSON.parse(raw) as { version?: unknown };
130-
return typeof parsed.version === "string" && parsed.version.length > 0
131-
? parsed.version
132-
: null;
133-
} catch {
134-
return null;
135-
}
136-
}
137-
138-
function findCurrentPackageRoot(): string {
139-
const here = path.dirname(fileURLToPath(import.meta.url));
140-
const candidates = [
141-
// Bundled: `.../codealmanac/dist/launcher.js` -> package root.
142-
path.resolve(here, ".."),
143-
// Old source/dist layout: `.../codealmanac/src/install/global.ts` -> package root.
144-
path.resolve(here, "..", ".."),
145-
// Source/dist platform layout: `.../codealmanac/src/platform/install/global.ts`.
146-
path.resolve(here, "..", "..", ".."),
147-
];
148-
149-
for (const candidate of candidates) {
150-
try {
151-
const require = createRequire(import.meta.url);
152-
const pkg = require(path.join(candidate, "package.json")) as {
153-
name?: unknown;
154-
};
155-
if (pkg.name === "codealmanac") return candidate;
156-
} catch {
157-
// Try the next layout.
158-
}
159-
}
160-
161-
return path.resolve(here, "..", "..", "..");
162-
}
163-
164-
async function resolveGlobalPackageRoot(
165-
spawnFn: BootstrapSpawnFn,
166-
): Promise<{ ok: true; path: string } | { ok: false; stderr: string }> {
167-
const result = await spawnCapturedProcess(spawnFn, "npm", ["root", "-g"]);
168-
if (result.exitCode !== 0) {
169-
return {
170-
ok: false,
171-
stderr:
172-
"almanac: could not find npm's global install directory.\n" +
173-
"Install Node.js + npm, or install the codealmanac package via your package manager.\n",
174-
};
175-
}
176-
177-
const root = result.stdout.trim();
178-
if (root.length === 0) {
179-
return {
180-
ok: false,
181-
stderr:
182-
"almanac: npm returned an empty global install directory.\n" +
183-
"Try: npm root -g\n",
184-
};
185-
}
186-
187-
return { ok: true, path: path.join(root, "codealmanac") };
188-
}

test/architecture-setup-boundaries.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,17 +310,44 @@ describe("architecture boundaries: setup and uninstall", () => {
310310
const bootstrapProcess = await readSource(
311311
"src/platform/install/bootstrap-process.ts",
312312
);
313+
const bootstrapPackage = await readSource(
314+
"src/platform/install/bootstrap-package.ts",
315+
);
316+
const bootstrapNpm = await readSource("src/platform/install/bootstrap-npm.ts");
313317

314318
expect(existsSync(join(ROOT, "src/platform/install/bootstrap-process.ts")))
315319
.toBe(true);
320+
expect(existsSync(join(ROOT, "src/platform/install/bootstrap-package.ts")))
321+
.toBe(true);
322+
expect(existsSync(join(ROOT, "src/platform/install/bootstrap-npm.ts"))).toBe(
323+
true,
324+
);
316325
expect(bootstrap).toContain("bootstrap-process.js");
326+
expect(bootstrap).toContain("bootstrap-package.js");
327+
expect(bootstrap).toContain("bootstrap-npm.js");
317328
expect(bootstrap).not.toContain("node:child_process");
329+
expect(bootstrap).not.toContain("node:fs");
330+
expect(bootstrap).not.toContain("node:module");
331+
expect(bootstrap).not.toContain("fileURLToPath");
332+
expect(bootstrap).not.toContain("npm root -g");
333+
expect(bootstrap).not.toContain("npm install failed");
334+
expect(bootstrap).not.toContain("readPackageVersion");
335+
expect(bootstrap).not.toContain("isNewerVersion");
318336
expect(bootstrap).not.toContain("stdio: \"inherit\"");
319337
expect(bootstrap).not.toContain("stdio: [\"ignore\", \"pipe\", \"pipe\"]");
320338
expect(bootstrap).not.toContain("child.stdout");
321339
expect(bootstrapProcess).toContain("node:child_process");
322340
expect(bootstrapProcess).toContain("spawnInheritedProcess");
323341
expect(bootstrapProcess).toContain("spawnCapturedProcess");
342+
expect(bootstrapPackage).toContain("findCurrentPackageRoot");
343+
expect(bootstrapPackage).toContain("readPackageVersion");
344+
expect(bootstrapPackage).toContain("shouldInstallGlobalPackage");
345+
expect(bootstrapPackage).toContain("isNewerVersion");
346+
expect(bootstrapNpm).toContain("resolveGlobalPackageRoot");
347+
expect(bootstrapNpm).toContain("installGlobalCodealmanacPackage");
348+
expect(bootstrapNpm).toContain("npm");
349+
expect(bootstrapNpm).toContain("spawnInheritedProcess");
350+
expect(bootstrapNpm).toContain("spawnCapturedProcess");
324351
});
325352

326353
it("keeps setup provider login process execution in the platform layer", async () => {

0 commit comments

Comments
 (0)