Skip to content

Commit 62789b1

Browse files
authored
Merge pull request #6 from pashifika/feat/dev-install-script
feat: add a development install that leaves nothing half-done
2 parents 5ac368d + 32e80b0 commit 62789b1

3 files changed

Lines changed: 354 additions & 0 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,27 @@ regenerates nothing on your machine.
351351

352352
```bash
353353
bun install # --frozen-lockfile in CI
354+
bun run dev:install # link the checkout, register the catalog, wire MCP
354355
bun run typecheck
355356
bun run test:unit # no CBM executable, no network
356357
bun run test:packaging # rebuilds both bundles, then loads them
357358
bun run build # commit the result
358359
```
359360

361+
`bun run dev:install` is the whole setup step for a checkout. It rebuilds both
362+
bundles, runs `omp plugin link .`, registers this repository's own marketplace
363+
catalog so `omp plugin discover` and `omp plugin upgrade` have something to
364+
read, writes the owned MCP entry without waiting for the next session start, and
365+
then reports what resolves. It is idempotent, and it refuses rather than
366+
replacing a registration that came from somewhere other than your checkout.
367+
368+
Two things it will not do. It never downloads a CBM executable: `/cbm install`
369+
asks for confirmation when a system copy already resolves, and answering that
370+
for you would decide which executable your account's index belongs to. It never
371+
installs from the catalog it registers, because that resolves the catalog's ref
372+
and so needs a published release. Add `--no-build`, `--no-marketplace`,
373+
`--no-mcp`, or `--force` to skip or override a step.
374+
360375
Run `bun run build` after changing anything under `src/`. CI rejects a committed
361376
bundle that is not byte-identical to one built from the current source.
362377

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
},
2626
"scripts": {
2727
"build": "bun build src/index.ts --target=bun --format=esm --outfile=dist/index.js && bun build src/augment-entry.ts --target=bun --format=esm --outfile=dist/augment.js",
28+
"dev:install": "bun run scripts/dev-install.ts",
2829
"harvest": "bun run scripts/harvest.ts",
2930
"test": "bun test",
3031
"test:unit": "bun test test/unit",

scripts/dev-install.ts

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Brings a development checkout to the state a finished install actually has.
4+
*
5+
* bun run dev:install [--no-build] [--no-marketplace] [--no-mcp] [--force]
6+
*
7+
* `omp plugin link .` is one of four things a complete install is, and doing
8+
* only that leaves a half-installed machine: the bundles may be stale against
9+
* `src/`, the catalog is not registered so `omp plugin discover` and
10+
* `omp plugin upgrade` have nothing to read, and the owned MCP entry is not
11+
* written until some later session happens to start. Each of those is a
12+
* separate command an operator has to remember, so this script is the list.
13+
*
14+
* It is not a second implementation of any of them. The plugin registry and the
15+
* catalog are touched only through OMP's own CLI, and the MCP entry only through
16+
* this package's own `syncEntry` -- the same call the extension makes at session
17+
* start, against the same single owned key. No path is derived here: every one
18+
* comes from `src/paths.ts`, so this script cannot disagree with the extension
19+
* about where anything lives.
20+
*
21+
* What it deliberately does not do:
22+
*
23+
* - It never acquires a CBM executable. `/cbm install` asks for confirmation
24+
* when a system copy already resolves, and a script that answered that
25+
* question for you would be deciding which executable your account's index
26+
* belongs to. When nothing resolves, this reports it and names the command.
27+
* - It never installs from the catalog it registers. That resolves the catalog's
28+
* `source.ref`, so it needs a published release; the script says so rather
29+
* than failing at it.
30+
* - It refuses instead of replacing a registration this checkout does not own,
31+
* so a git-spec install is never silently swapped for a link.
32+
*/
33+
import { realpath } from "node:fs/promises";
34+
35+
import { run } from "../src/exec.ts";
36+
import { syncEntry, type Lifecycle } from "../src/lifecycle.ts";
37+
import { entryStatus } from "../src/mcp-config.ts";
38+
import { agentDir, processHost, type Host } from "../src/paths.ts";
39+
import { hostTarget, UnsupportedPlatformError } from "../src/platform.ts";
40+
import { githubReleaseSource } from "../src/release.ts";
41+
import { managedCopy, resolveExecutable, resolvedVersion } from "../src/resolve.ts";
42+
import { readState } from "../src/state.ts";
43+
44+
/** The plugin name OMP registers this checkout under, from the manifest. */
45+
const PACKAGE_NAME = "omp-codebase-memory";
46+
47+
/** The catalog this repository publishes itself through, in `owner/repo` form. */
48+
const MARKETPLACE_SOURCE = "pashifika/omp-codebase-memory";
49+
50+
/** Accepted options, so an unknown one fails rather than being ignored. */
51+
const KNOWN_FLAGS: Record<string, true> = {
52+
"--no-build": true,
53+
"--no-marketplace": true,
54+
"--no-mcp": true,
55+
"--force": true,
56+
};
57+
58+
interface Options {
59+
readonly build: boolean;
60+
readonly marketplace: boolean;
61+
readonly mcp: boolean;
62+
readonly force: boolean;
63+
}
64+
65+
/**
66+
* What the catalog says about itself.
67+
*
68+
* Both fields are read rather than repeated here: the marketplace name is what
69+
* `omp plugin marketplace list` prints and what `<plugin>@<marketplace>`
70+
* addresses, and the ref is what an install from the catalog resolves. A rename
71+
* in the file would otherwise leave this script looking for a marketplace
72+
* nobody has, or naming a ref nobody installs.
73+
*/
74+
interface Catalog {
75+
readonly name: string;
76+
readonly ref: string;
77+
}
78+
79+
/** One labelled step, so a partial run says where it stopped. */
80+
function step(n: number, title: string): void {
81+
console.log(`\n=== ${n}. ${title}`);
82+
}
83+
84+
/**
85+
* One aligned fact under a step.
86+
*
87+
* The pad is one short of the column so a label at or past the column width
88+
* still gets a separator instead of running into its value.
89+
*/
90+
function detail(label: string, value: string): void {
91+
console.log(` ${label.padEnd(11)} ${value}`);
92+
}
93+
94+
/**
95+
* Runs an `omp` subcommand, failing the script on anything but success.
96+
*
97+
* Through `run` rather than a second spawn helper: the timeout, the output cap,
98+
* and the missing-executable answer are already decided there.
99+
*/
100+
async function omp(argv: readonly string[], timeoutMs = 120_000): Promise<string> {
101+
const result = await run(["omp", ...argv], { timeoutMs });
102+
if (result.spawnError !== undefined) {
103+
throw new Error(`omp ${argv.join(" ")}: ${result.spawnError}`);
104+
}
105+
const output = `${result.stdout}${result.stderr}`.trim();
106+
if (!result.ok) {
107+
throw new Error(`omp ${argv.join(" ")} exited ${result.exitCode}\n${output}`);
108+
}
109+
return output;
110+
}
111+
112+
/**
113+
* Where OMP currently thinks this plugin lives, or `null` when it is unknown.
114+
*
115+
* The registry is OMP's, so it is read through OMP: `plugin list --json` reports
116+
* the resolved path, which for a linked checkout is the symlink under the plugin
117+
* root. Every group in that document is scanned rather than just `npm`, because
118+
* which group a plugin lands in is the installer's choice and this question is
119+
* about the name, not the route.
120+
*/
121+
async function registeredPath(): Promise<string | null> {
122+
const result = await run(["omp", "plugin", "list", "--json"], { timeoutMs: 60_000 });
123+
if (result.spawnError !== undefined || !result.ok) return null;
124+
125+
let listing: unknown;
126+
try {
127+
listing = JSON.parse(result.stdout);
128+
} catch {
129+
return null;
130+
}
131+
if (typeof listing !== "object" || listing === null) return null;
132+
133+
for (const group of Object.values(listing)) {
134+
if (!Array.isArray(group)) continue;
135+
for (const entry of group) {
136+
if (typeof entry !== "object" || entry === null) continue;
137+
if (!("name" in entry) || entry.name !== PACKAGE_NAME) continue;
138+
return "path" in entry && typeof entry.path === "string" ? entry.path : null;
139+
}
140+
}
141+
return null;
142+
}
143+
144+
/**
145+
* Reports which optional features will actually load, computed the way OMP does.
146+
*
147+
* `omp plugin features <name>` is not quoted here, and the reason is a real trap
148+
* rather than a formatting preference. Its renderer builds its enabled set from
149+
* `getEnabledFeatures`, so the `enabledFeatures: null` case -- no explicit
150+
* selection recorded, which is what a plain install leaves -- yields an empty
151+
* set and prints the disabled glyph beside every feature, including one whose
152+
* manifest default is `true` and which the loader does load. A development
153+
* install that pasted that output would report the augmentation as off while it
154+
* is demonstrably appending to tool results.
155+
*
156+
* The rule reproduced here is `resolvePluginManifestEntries`
157+
* (`@oh-my-pi/pi-coding-agent`, `extensibility/plugins/loader.ts`): an explicit
158+
* array selects exactly its members, and `null` selects every feature whose
159+
* `default` is true.
160+
*/
161+
async function reportFeatures(root: string): Promise<void> {
162+
const manifest = await Bun.file(`${root}/package.json`).json();
163+
const features = manifest.omp?.features;
164+
if (typeof features !== "object" || features === null) {
165+
console.log(" features none declared in the manifest");
166+
return;
167+
}
168+
169+
const state = JSON.parse(await omp(["plugin", "features", PACKAGE_NAME, "--json"]));
170+
const selection: unknown = state.enabledFeatures;
171+
const explicit = Array.isArray(selection) ? new Set(selection.map(String)) : null;
172+
173+
for (const [name, feature] of Object.entries(features)) {
174+
const byDefault =
175+
typeof feature === "object" && feature !== null && "default" in feature
176+
? feature.default === true
177+
: false;
178+
const on = explicit === null ? byDefault : explicit.has(name);
179+
const why =
180+
explicit === null
181+
? `manifest default ${byDefault ? "on" : "off"}, no explicit selection recorded`
182+
: `explicitly ${explicit.has(name) ? "selected" : "declined"} at install`;
183+
detail(name, `${on ? "on" : "off"}${why}`);
184+
}
185+
}
186+
187+
async function main(): Promise<void> {
188+
const argv = process.argv.slice(2);
189+
for (const arg of argv) {
190+
if (KNOWN_FLAGS[arg] !== true) throw new Error(`unknown option ${arg}`);
191+
}
192+
const options: Options = {
193+
build: !argv.includes("--no-build"),
194+
marketplace: !argv.includes("--no-marketplace"),
195+
mcp: !argv.includes("--no-mcp"),
196+
force: argv.includes("--force"),
197+
};
198+
199+
const root = await realpath(new URL("..", import.meta.url).pathname);
200+
const host: Host = processHost();
201+
const document = await Bun.file(`${root}/.omp-plugin/marketplace.json`).json();
202+
const catalogName = document.name;
203+
const catalogRef = document.plugins?.[0]?.source?.ref;
204+
if (typeof catalogName !== "string" || catalogName === "") {
205+
throw new Error(".omp-plugin/marketplace.json declares no marketplace name");
206+
}
207+
const catalog: Catalog = {
208+
name: catalogName,
209+
ref: typeof catalogRef === "string" && catalogRef !== "" ? catalogRef : "(none declared)",
210+
};
211+
212+
console.log(`development install of ${PACKAGE_NAME}`);
213+
detail("checkout", root);
214+
detail("agent dir", agentDir(host));
215+
216+
step(1, "check what currently owns the plugin name");
217+
const existing = await registeredPath();
218+
const existingRoot = existing === null ? null : await realpath(existing).catch(() => existing);
219+
if (existing === null) {
220+
console.log(" nothing registered under this name yet");
221+
} else if (existingRoot === root) {
222+
console.log(` already registered from this checkout: ${existing}`);
223+
} else if (options.force) {
224+
console.log(` registered elsewhere (${existing}); --force given, relinking anyway`);
225+
} else {
226+
throw new Error(
227+
`${PACKAGE_NAME} is registered from ${existing}, which is not this checkout.\n` +
228+
"Replacing it would swap a real install for a link without saying so. Run\n" +
229+
` omp plugin uninstall ${PACKAGE_NAME}\n` +
230+
"first, or pass --force if that is what you meant.",
231+
);
232+
}
233+
234+
step(2, "build both committed bundles");
235+
if (options.build) {
236+
const built = await run(["bun", "run", "build"], { cwd: root, timeoutMs: 300_000 });
237+
if (built.spawnError !== undefined) throw new Error(`bun run build: ${built.spawnError}`);
238+
if (!built.ok) {
239+
throw new Error(`bun run build exited ${built.exitCode}\n${built.stdout}${built.stderr}`);
240+
}
241+
console.log(" dist/index.js and dist/augment.js rebuilt from src/");
242+
console.log(" commit the result if it differs; CI compares both byte-for-byte");
243+
} else {
244+
console.log(" skipped (--no-build); the committed bundles are used as they are");
245+
}
246+
247+
step(3, "register the checkout");
248+
console.log(` ${await omp(["plugin", "link", "."])}`);
249+
const linked = await registeredPath();
250+
if (linked === null) {
251+
throw new Error("omp plugin link reported success but the plugin is not registered");
252+
}
253+
detail("path", linked);
254+
await reportFeatures(root);
255+
256+
step(4, "register the marketplace catalog");
257+
if (options.marketplace) {
258+
// Matched on the source rather than the catalog's name: `marketplace list`
259+
// ignores `--json` and prints `<name> <source>` per line, and this
260+
// repository's catalog name and package name are the same string, so a name
261+
// match would also accept some other marketplace that merely mentions it.
262+
const listed = await omp(["plugin", "marketplace", "list"]);
263+
if (listed.includes(MARKETPLACE_SOURCE)) {
264+
console.log(` already registered: ${catalog.name} ${MARKETPLACE_SOURCE}`);
265+
} else {
266+
console.log(` ${await omp(["plugin", "marketplace", "add", MARKETPLACE_SOURCE])}`);
267+
}
268+
console.log(
269+
` omp plugin discover and omp plugin upgrade read the catalog and work now.\n` +
270+
` Installing from it resolves ${catalog.ref}, so that needs a published release;\n` +
271+
" until then the git spec is the route that resolves.",
272+
);
273+
} else {
274+
console.log(" skipped (--no-marketplace)");
275+
}
276+
277+
step(5, "wire the owned MCP entry");
278+
let lifecycle: Lifecycle | null = null;
279+
try {
280+
lifecycle = { host, target: hostTarget(), source: githubReleaseSource() };
281+
} catch (error) {
282+
console.log(
283+
` skipped: ${
284+
error instanceof UnsupportedPlatformError
285+
? error.message
286+
: `platform detection failed: ${error instanceof Error ? error.message : String(error)}`
287+
}`,
288+
);
289+
}
290+
if (lifecycle === null) {
291+
// Nothing to wire and nothing to report: the reason is already printed.
292+
} else if (options.mcp) {
293+
const sync = await syncEntry(lifecycle);
294+
console.log(` ${sync.kind}: ${sync.message}`);
295+
} else {
296+
console.log(" skipped (--no-mcp); the next session start writes it");
297+
}
298+
299+
step(6, "report what resolves");
300+
const state = await readState(host);
301+
const resolution = await resolveExecutable(host, state);
302+
if (!resolution.ok) {
303+
detail("executable", `none — ${resolution.reason}`);
304+
} else {
305+
detail("executable", resolution.resolved.executable);
306+
detail("source", `${resolution.resolved.source} (${resolution.resolved.origin})`);
307+
detail("version", (await resolvedVersion(resolution.resolved)) ?? "unknown (it did not run)");
308+
}
309+
const managed = await managedCopy(host, state);
310+
detail("managed", managed === null ? "none under this package's root" : managed.version);
311+
312+
const entry = await entryStatus(host, resolution.ok ? resolution.resolved.executable : null);
313+
detail(
314+
"mcp entry",
315+
entry.problem !== undefined
316+
? `unreadable — ${entry.problem}`
317+
: !entry.present
318+
? `absent from ${entry.path}`
319+
: entry.current
320+
? `current in ${entry.path}`
321+
: `stale, names ${entry.command ?? "(no command)"}`,
322+
);
323+
324+
console.log(
325+
"\nWhat remains is not this script's to do:\n" +
326+
" - Indexing is the agent's work. Ask it to index this repository; nothing here\n" +
327+
" ships an index command.\n" +
328+
" - `/cbm status` in a session reports the index half, which needs a CBM process\n" +
329+
" this script does not open.",
330+
);
331+
}
332+
333+
try {
334+
await main();
335+
} catch (error) {
336+
console.error(`dev-install: ${error instanceof Error ? error.message : String(error)}`);
337+
process.exit(1);
338+
}

0 commit comments

Comments
 (0)