@henols/vice-mcp 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/build.ts +268 -0
- package/container-guard.mts +226 -0
- package/containerpath.ts +296 -0
- package/hostpath.ts +318 -0
- package/incident-record.ts +442 -0
- package/install-resources.ts +532 -0
- package/package.json +71 -0
- package/refresh-manifest.ts +103 -0
- package/repo-root.ts +198 -0
- package/resources/broker-control.mjs +343 -0
- package/resources/broker-epoch.mjs +126 -0
- package/resources/broker-kill.mjs +491 -0
- package/resources/broker-launch.mjs +659 -0
- package/resources/broker-state.mjs +173 -0
- package/resources/container-guard.mjs +211 -0
- package/resources/vice-broker.mjs +855 -0
- package/resources/vice-launcher.sh +169 -0
- package/tools-manifest.json +1231 -0
- package/vice-broker-client.ts +899 -0
- package/vice-probe.ts +278 -0
- package/vice-proxy.ts +3093 -0
- package/vice-sync.ts +336 -0
- package/vice.ts +772 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deploys this skill's resources/ (the host-side shell launchers) into
|
|
3
|
+
// <repo>/tools/ the first time any skill .mjs entry point runs, so a copy of
|
|
4
|
+
// this skill directory alone is sufficient -- nobody has to remember to also
|
|
5
|
+
// copy three shell scripts from somewhere else (D-1, quick-260730-q4b).
|
|
6
|
+
//
|
|
7
|
+
// HOSTING CHOICE (D-3): this check lives in a DEDICATED module, triggered
|
|
8
|
+
// from repo-root.mjs, for two reasons. First, repo-root.mjs is a pure path
|
|
9
|
+
// resolver, and inlining filesystem-writing side effects into it would make
|
|
10
|
+
// every importer of a path function also a file writer. Second and decisive:
|
|
11
|
+
// this module needs the repo root, and repo-root.mjs is where the repo root
|
|
12
|
+
// is computed -- hosting this logic INSIDE repo-root.mjs and importing it
|
|
13
|
+
// back from there would be a module cycle. In that cycle, this module would
|
|
14
|
+
// evaluate while repo-root.mjs's `const HERE` is still in its temporal dead
|
|
15
|
+
// zone, and every entry point would die with
|
|
16
|
+
// "Cannot access 'HERE' before initialization".
|
|
17
|
+
//
|
|
18
|
+
// THE CYCLE IS AVOIDED STRUCTURALLY: this module takes the repo root as an
|
|
19
|
+
// ARGUMENT and imports NOTHING from repo-root.mjs. Do not "clean this up" by
|
|
20
|
+
// adding `import { repoRoot } from "./repo-root.mjs"` here -- that importable
|
|
21
|
+
// convenience is exactly the cycle described above.
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
copyFileSync,
|
|
29
|
+
statSync,
|
|
30
|
+
chmodSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { dirname, join, isAbsolute, resolve, sep } from "node:path";
|
|
36
|
+
|
|
37
|
+
import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
|
|
38
|
+
import { HOST_BOUND_ARTIFACTS } from "./build.ts";
|
|
39
|
+
|
|
40
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
|
|
42
|
+
/** true iff `value` is a well-formed, generic JSON object -- not null, not
|
|
43
|
+
* an array. Narrows JSON.parse()'s otherwise-`any` result before any field
|
|
44
|
+
* on it is touched, matching vice-broker.mts's isPlainObject() idiom
|
|
45
|
+
* exactly (PATTERNS.md "Narrowing unknown, not casting"). */
|
|
46
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
47
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The on-disk shape of `.vice-deployed.json`, as read/written by
|
|
51
|
+
* readDeployManifest()/writeDeployManifest() below. */
|
|
52
|
+
export interface DeployManifest {
|
|
53
|
+
entries: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-entry outcome of a copy attempt: which resources/ entries landed,
|
|
57
|
+
* were left alone (already present, or a hand-authored divergence refused
|
|
58
|
+
* without force -- see isGeneratedEntry() in installResources() for why a
|
|
59
|
+
* DIVERGED GENERATED entry lands in `installed` instead, not here), or
|
|
60
|
+
* failed -- returned by both installResources() and, for the prune half,
|
|
61
|
+
* pruneResources(). */
|
|
62
|
+
export interface InstallResourcesResult {
|
|
63
|
+
installed: string[];
|
|
64
|
+
skipped: string[];
|
|
65
|
+
diverged: string[];
|
|
66
|
+
failed: string[];
|
|
67
|
+
pruned: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PruneResourcesResult {
|
|
71
|
+
pruned: string[];
|
|
72
|
+
skipped: string[];
|
|
73
|
+
failed: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** missing | present (byte-identical to the resource) | diverged (exists,
|
|
77
|
+
* differs) -- statusForEntry()'s own return type, reused by resourcesStatus(). */
|
|
78
|
+
export type ResourceStatus = "missing" | "present" | "diverged";
|
|
79
|
+
|
|
80
|
+
/** This module directory's resources/ subdirectory -- a plain SIBLING of this
|
|
81
|
+
* module (scripts/ was flattened away in the .claude/mcp/vice/ move), the
|
|
82
|
+
* tracked source of truth every deployed tools/ file is copied from. Getting
|
|
83
|
+
* this hop wrong is silent and total: readdirSync() throws inside
|
|
84
|
+
* resourceEntries(), but ensureResourcesInstalled() catches everything by
|
|
85
|
+
* contract (D-3 above), so every command keeps reporting success while
|
|
86
|
+
* nothing is ever deployed. */
|
|
87
|
+
export const RESOURCES_DIR = join(HERE, "resources");
|
|
88
|
+
|
|
89
|
+
/** Where resources/ gets deployed to, for a given repo root. Always
|
|
90
|
+
* `<root>/tools` -- the host's existing muscle-memory location. */
|
|
91
|
+
export function installTargetDir(root: string): string {
|
|
92
|
+
return join(root, "tools");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Recursive walk of RESOURCES_DIR, returning the relative path (posix-style,
|
|
96
|
+
* "/"-joined) of every regular file underneath it -- a WALK, not a hardcoded
|
|
97
|
+
* list, so a file added under resources/lib/ later deploys with no code
|
|
98
|
+
* change here. */
|
|
99
|
+
function walk(dir: string, base = ""): string[] {
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
|
|
102
|
+
const rel = base ? `${base}/${dirent.name}` : dirent.name;
|
|
103
|
+
const abs = join(dir, dirent.name);
|
|
104
|
+
if (dirent.isDirectory()) {
|
|
105
|
+
out.push(...walk(abs, rel));
|
|
106
|
+
} else if (dirent.isFile()) {
|
|
107
|
+
out.push(rel);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resourceEntries(): string[] {
|
|
114
|
+
return walk(RESOURCES_DIR);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** missing | present (byte-identical to the resource) | diverged (exists,
|
|
118
|
+
* differs). An unreadable target is treated as "diverged" -- conservative on
|
|
119
|
+
* purpose, so a permissions oddity never gets silently reported as
|
|
120
|
+
* "present" and skipped. */
|
|
121
|
+
function statusForEntry(entry: string, root: string): ResourceStatus {
|
|
122
|
+
const src = join(RESOURCES_DIR, entry);
|
|
123
|
+
const target = join(installTargetDir(root), entry);
|
|
124
|
+
if (!existsSync(target)) return "missing";
|
|
125
|
+
try {
|
|
126
|
+
return readFileSync(src).equals(readFileSync(target)) ? "present" : "diverged";
|
|
127
|
+
} catch {
|
|
128
|
+
return "diverged";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Per-entry status against a given repo root, without writing anything. */
|
|
133
|
+
export function resourcesStatus({ root }: { root: string }): Record<string, ResourceStatus> {
|
|
134
|
+
const out: Record<string, ResourceStatus> = {};
|
|
135
|
+
for (const entry of resourceEntries()) {
|
|
136
|
+
out[entry] = statusForEntry(entry, root);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The D-4 host-launch instructions, as prose covering: the host path to
|
|
142
|
+
* run, that it cannot run inside the container and will refuse with exit 2,
|
|
143
|
+
* --check-container as the diagnostic, and that Ctrl-C stops it cleanly.
|
|
144
|
+
* hostPath() (devcontainer-host-path skill) translates the deployed
|
|
145
|
+
* launcher's container path into the HOST path a human should actually
|
|
146
|
+
* type -- the same cross-skill shape tools/recover.mjs already uses -- and
|
|
147
|
+
* degrades to the container path plus hostpath.mjs's own SET_ENV_HINT when
|
|
148
|
+
* translation fails (e.g. no /proc/self/mountinfo, or an unmapped mount).
|
|
149
|
+
*
|
|
150
|
+
* 01.6.2-09 (D-23, T-01.6.2-56): this used to return TWO paragraphs, one
|
|
151
|
+
* per script -- the broker launcher (start the on-demand broker) and the
|
|
152
|
+
* per-instance supervisor (a "standalone (non-MCP) recovery pipeline"). The
|
|
153
|
+
* supervisor paragraph is DELETED here, not repointed: the standalone
|
|
154
|
+
* pipeline it advertised (vice-pool.mjs/vice-session.mjs) was already
|
|
155
|
+
* deleted behind a zero-consumers gate in an earlier phase
|
|
156
|
+
* (01.6-CONTEXT.md D-02), so repointing its path would keep advertising a
|
|
157
|
+
* capability that no longer exists. Only ONE script survives
|
|
158
|
+
* (resources/vice-launcher.sh, deployed to tools/vice-launcher.sh), and its
|
|
159
|
+
* own broker now performs both jobs: on-demand acquisition (what the old
|
|
160
|
+
* broker paragraph promised) and launch/supervise/respawn-with-backoff
|
|
161
|
+
* (what the old supervisor paragraph promised). Exactly one resolved host
|
|
162
|
+
* path is returned as a result -- asserted directly in
|
|
163
|
+
* install-resources.test.ts. */
|
|
164
|
+
export function hostLaunchInstructions(root: string): string {
|
|
165
|
+
const target = join(installTargetDir(root), "vice-launcher.sh");
|
|
166
|
+
let displayPath: string;
|
|
167
|
+
try {
|
|
168
|
+
displayPath = hostPath(target, { workspaceRoot: root });
|
|
169
|
+
} catch {
|
|
170
|
+
displayPath = `${target}\n (host path could not be determined -- ${SET_ENV_HINT})`;
|
|
171
|
+
}
|
|
172
|
+
return [
|
|
173
|
+
`vice-mcp-selector: deployed host launcher scripts to ${installTargetDir(root)}`,
|
|
174
|
+
"vice-mcp-selector: for MCP-mediated access (mcp__vice__* tools), start the on-demand broker from the HOST workspace, e.g.:",
|
|
175
|
+
` ${displayPath}`,
|
|
176
|
+
"vice-mcp-selector: the broker launches a boot-fresh instance per session on demand, supervises it, and respawns a crashed one with backoff, while keeping a warm floor of spare instances ready.",
|
|
177
|
+
"vice-mcp-selector: it cannot run inside the container -- the container guard refuses with exit 2.",
|
|
178
|
+
"vice-mcp-selector: if it refuses when it should not, run it with --check-container for the full per-signal diagnostic.",
|
|
179
|
+
"vice-mcp-selector: press Ctrl-C to stop it -- SIGINT/SIGTERM are handled and it shuts down cleanly.",
|
|
180
|
+
].join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The manifest's basename -- a dotfile under the deployment target,
|
|
184
|
+
* gitignored alongside every other deployed path (see .gitignore's
|
|
185
|
+
* deployed-path block). It is the ONLY thing pruneResources() below is ever
|
|
186
|
+
* allowed to consult when deciding what to delete (T-01.6-11): never a
|
|
187
|
+
* directory walk of installTargetDir(), which also holds tracked reverse-
|
|
188
|
+
* engineering tooling sharing the same `tools/` directory. */
|
|
189
|
+
export const DEPLOY_MANIFEST_NAME = ".vice-deployed.json";
|
|
190
|
+
|
|
191
|
+
/** Where the manifest lives, for a given repo root -- always beneath
|
|
192
|
+
* installTargetDir(root), same as every other deployed entry. */
|
|
193
|
+
export function deployManifestPath(root: string): string {
|
|
194
|
+
return join(installTargetDir(root), DEPLOY_MANIFEST_NAME);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Reads and parses the deploy manifest, returning its recorded relative-path
|
|
198
|
+
* entries. Never throws (D-3, and this codebase's standing never-throw-on-
|
|
199
|
+
* untrusted-read discipline -- the manifest is untrusted input by
|
|
200
|
+
* construction, T-01.6-13): a missing file, an unreadable file, malformed
|
|
201
|
+
* JSON, a non-object top-level shape, and an `entries` field that isn't an
|
|
202
|
+
* array are ALL treated identically as "nothing has been recorded here yet"
|
|
203
|
+
* -- an empty array. Two nested try/catch layers: the outer catches a read
|
|
204
|
+
* failure (ENOENT, EACCES, a directory at that path, ...), the inner catches
|
|
205
|
+
* a JSON.parse failure -- matching readJsonMaybe()'s posture elsewhere in
|
|
206
|
+
* this module tree (vice-broker-client.mjs) exactly. */
|
|
207
|
+
export function readDeployManifest(root: string): string[] {
|
|
208
|
+
let raw: string;
|
|
209
|
+
try {
|
|
210
|
+
raw = readFileSync(deployManifestPath(root), "utf8");
|
|
211
|
+
} catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const parsed: unknown = JSON.parse(raw);
|
|
216
|
+
if (!isPlainObject(parsed)) return [];
|
|
217
|
+
if (!Array.isArray(parsed.entries)) return [];
|
|
218
|
+
return parsed.entries as string[];
|
|
219
|
+
} catch {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Writes the deploy manifest through the same tmp-sibling, mode-restricted,
|
|
225
|
+
* rename sequence every other state file in this subsystem uses
|
|
226
|
+
* (vice-broker.mts's writeBrokerRecord(): tmp file created empty, chmod 0600
|
|
227
|
+
* BEFORE any content reaches it, then content written, then renamed into
|
|
228
|
+
* place -- so the manifest is never briefly world-readable, V4). `entries`
|
|
229
|
+
* is sorted before being written so the on-disk manifest is stable and
|
|
230
|
+
* diff-friendly across runs that deploy the same resource set in a
|
|
231
|
+
* different enumeration order. */
|
|
232
|
+
export function writeDeployManifest(root: string, entries: Iterable<string>): void {
|
|
233
|
+
const target = deployManifestPath(root);
|
|
234
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
235
|
+
const tmpPath = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
236
|
+
writeFileSync(tmpPath, "");
|
|
237
|
+
chmodSync(tmpPath, 0o600);
|
|
238
|
+
writeFileSync(tmpPath, JSON.stringify({ entries: [...entries].sort() }, null, 2) + "\n");
|
|
239
|
+
renameSync(tmpPath, target);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** True iff `entry` is a safe manifest candidate: a plain relative path (no
|
|
243
|
+
* leading "/" and no drive-letter-style absolute form), containing no
|
|
244
|
+
* parent-directory ("..") path segment, whose resolved absolute location
|
|
245
|
+
* sits AT OR BENEATH `targetDir` -- rejecting an absolute path, a
|
|
246
|
+
* `..`-escaping path, and anything else that would resolve outside the
|
|
247
|
+
* deployment target (T-01.6-12). Every check here runs BEFORE any
|
|
248
|
+
* filesystem access is attempted on `entry`. */
|
|
249
|
+
function isSafeManifestCandidate(entry: unknown, targetDir: string): entry is string {
|
|
250
|
+
if (typeof entry !== "string" || entry.length === 0) return false;
|
|
251
|
+
if (isAbsolute(entry)) return false;
|
|
252
|
+
if (entry.split(/[\\/]/).includes("..")) return false;
|
|
253
|
+
const resolved = resolve(targetDir, entry);
|
|
254
|
+
return resolved === targetDir || resolved.startsWith(targetDir.endsWith(sep) ? targetDir : targetDir + sep);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Removes a deployed file this installer previously placed under
|
|
259
|
+
* installTargetDir(root) but which no longer corresponds to a current
|
|
260
|
+
* resources/ entry -- the delete half installResources() alone never had
|
|
261
|
+
* (RESEARCH.md's Runtime State Inventory: "install-resources.mjs's current
|
|
262
|
+
* installResources() only ever adds/overwrites -- it has no delete/prune
|
|
263
|
+
* step"; a retired executable would otherwise linger on the host forever).
|
|
264
|
+
*
|
|
265
|
+
* The candidate set is EXACTLY `readDeployManifest(root)` minus the current
|
|
266
|
+
* `resourceEntries()` -- never a directory walk of `installTargetDir(root)`,
|
|
267
|
+
* which is a MIXED directory also holding tracked reverse-engineering
|
|
268
|
+
* tooling (d64-parse.mjs, diff-images.mjs, watch-loads.mjs,
|
|
269
|
+
* recovery-schema.mjs, releases.mjs and their tests). A file present in the
|
|
270
|
+
* target but ABSENT from the manifest is therefore left untouched no matter
|
|
271
|
+
* what it is: the prune can only ever reach a path it recorded having placed
|
|
272
|
+
* there itself (T-01.6-11).
|
|
273
|
+
*
|
|
274
|
+
* Every candidate is validated by isSafeManifestCandidate() BEFORE any
|
|
275
|
+
* unlink is attempted; a rejected candidate is pushed to `skipped` (nothing
|
|
276
|
+
* was attempted) with the reason named in the warning, never to `failed`.
|
|
277
|
+
* Each unlink that IS attempted is individually wrapped in its own
|
|
278
|
+
* try/catch: a failure is pushed to `failed` and warned through `log`,
|
|
279
|
+
* never thrown (D-3) -- the same per-entry posture `installResources()`
|
|
280
|
+
* already applies to each copy. Never removes a directory.
|
|
281
|
+
*
|
|
282
|
+
* Returns { pruned, skipped, failed }, arrays of the candidate's manifest-
|
|
283
|
+
* recorded relative path.
|
|
284
|
+
*/
|
|
285
|
+
export function pruneResources({
|
|
286
|
+
root,
|
|
287
|
+
log = console.error,
|
|
288
|
+
}: {
|
|
289
|
+
root: string;
|
|
290
|
+
log?: (message: string) => void;
|
|
291
|
+
}): PruneResourcesResult {
|
|
292
|
+
const pruned: string[] = [];
|
|
293
|
+
const skipped: string[] = [];
|
|
294
|
+
const failed: string[] = [];
|
|
295
|
+
|
|
296
|
+
const manifestEntries = readDeployManifest(root);
|
|
297
|
+
const currentEntries = new Set(resourceEntries());
|
|
298
|
+
const targetDir = installTargetDir(root);
|
|
299
|
+
|
|
300
|
+
for (const entry of manifestEntries) {
|
|
301
|
+
if (currentEntries.has(entry)) continue; // still a current resource -- nothing to prune
|
|
302
|
+
|
|
303
|
+
if (!isSafeManifestCandidate(entry, targetDir)) {
|
|
304
|
+
skipped.push(entry);
|
|
305
|
+
log(
|
|
306
|
+
`warn: install-resources: prune refused manifest entry ${JSON.stringify(entry)} -- it is not a ` +
|
|
307
|
+
"plain relative path resolving beneath the deployment target (absolute path, parent-directory " +
|
|
308
|
+
"hop, or an escape outside the target). Refusing rather than acting on it (T-01.6-12)."
|
|
309
|
+
);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const resolvedTarget = resolve(targetDir, entry);
|
|
314
|
+
try {
|
|
315
|
+
unlinkSync(resolvedTarget);
|
|
316
|
+
pruned.push(entry);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
failed.push(entry);
|
|
319
|
+
log(
|
|
320
|
+
`warn: install-resources: prune failed to remove retired entry ${entry} from ${resolvedTarget} -- ` +
|
|
321
|
+
`${(e as Error).message}. Continuing; a failed prune must never break the caller.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { pruned, skipped, failed };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** True iff `entry` is one of the tsc-compiled, banner-carrying artifacts
|
|
330
|
+
* build.ts emits into resources/ -- the GENERATED half of this skill's
|
|
331
|
+
* resources/ directory, as distinct from the one hand-authored survivor,
|
|
332
|
+
* `vice-launcher.sh` (CLAUDE.md's three-tier `.claude/mcp/` rule: "resources/
|
|
333
|
+
* is generated, but committed ... The one exception is
|
|
334
|
+
* resources/vice-launcher.sh, which stays hand-authored").
|
|
335
|
+
*
|
|
336
|
+
* Derived from build.ts's own `HOST_BOUND_ARTIFACTS` rather than a
|
|
337
|
+
* hardcoded filename here, because that list is already the ENFORCED source
|
|
338
|
+
* of truth for "what tsc emits": build()'s own "build: emitted file set
|
|
339
|
+
* does not match HOST_BOUND_ARTIFACTS" assertion throws if the compiler's
|
|
340
|
+
* real output ever differs from it, and resources-sync.test.ts separately
|
|
341
|
+
* asserts committed resources/ matches a fresh build using this same list.
|
|
342
|
+
* A maintainer who adds a new compiled artifact must already extend this
|
|
343
|
+
* list for build() to succeed at all, so this check cannot silently drift
|
|
344
|
+
* out of sync with reality without also breaking the build.
|
|
345
|
+
*
|
|
346
|
+
* DISCLOSED LIMITATION (autonomy contract): this is a closed-list check,
|
|
347
|
+
* not a content-sniffed one -- it does not itself inspect the generated
|
|
348
|
+
* banner text (build.ts's GENERATED_BANNER()). A hypothetical future
|
|
349
|
+
* resources/ entry that is hand-authored but happens to share a relative
|
|
350
|
+
* path with something tsc emits would be misclassified as generated; there
|
|
351
|
+
* is no such collision today, and the one real hand-authored survivor
|
|
352
|
+
* (`vice-launcher.sh`) is a `.sh` file, so it cannot collide with the
|
|
353
|
+
* `.mjs`-only compiled set by construction. A more robust version would
|
|
354
|
+
* additionally require the source file's content to start with the
|
|
355
|
+
* GENERATED_BANNER() prefix; left as a follow-up rather than done here to
|
|
356
|
+
* avoid introducing a second, independently-driftable banner check. */
|
|
357
|
+
function isGeneratedEntry(entry: string): boolean {
|
|
358
|
+
return (HOST_BOUND_ARTIFACTS as readonly string[]).includes(entry);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Copies every `missing` entry, every `present`/`diverged` one when `force`
|
|
363
|
+
* is true, and -- new as of the 260805 stale-deploy fix -- every `diverged`
|
|
364
|
+
* GENERATED entry even WITHOUT force. `present` (force or not) leaves the
|
|
365
|
+
* target alone (D-5). Parent directories are created as needed, and each
|
|
366
|
+
* target's permission bits are set from its source.
|
|
367
|
+
*
|
|
368
|
+
* WHY diverged-but-generated is overwritten by default: CLAUDE.md's
|
|
369
|
+
* `.claude/mcp/` contract says resources/'s generated half (everything
|
|
370
|
+
* built by build.ts) and tools/ are "never hand-edited". If nothing may be
|
|
371
|
+
* hand-edited there, a `diverged` GENERATED entry cannot mean "a local edit
|
|
372
|
+
* worth protecting" -- staleness is the only thing divergence can mean for
|
|
373
|
+
* it, so refusing it (the old default) was silently no-op'ing on exactly
|
|
374
|
+
* the files that most needed refreshing (see
|
|
375
|
+
* .planning/todos/pending/2026-08-05-installresources-cannot-refresh-a-stale-deploy-without-force.md).
|
|
376
|
+
* The one HAND-AUTHORED entry, `vice-launcher.sh`, keeps the original
|
|
377
|
+
* refuse-on-divergence posture -- see isGeneratedEntry() above for how the
|
|
378
|
+
* two are told apart.
|
|
379
|
+
*
|
|
380
|
+
* Every copy is individually wrapped in its own try/catch: a failure is
|
|
381
|
+
* pushed to `failed` and warned through `log`, never thrown (D-3) -- a
|
|
382
|
+
* read-only filesystem must never turn a working `ping` into an error.
|
|
383
|
+
*
|
|
384
|
+
* After the copy loop, prunes any manifest-recorded entry that is no longer
|
|
385
|
+
* a current resource, then rewrites the manifest from the current
|
|
386
|
+
* `resourceEntries()` -- so the manifest always reflects "what this
|
|
387
|
+
* installer would deploy right now", ready for the NEXT call's prune to
|
|
388
|
+
* compare against. The manifest write itself is wrapped separately (never
|
|
389
|
+
* throws, D-3): an unwritable target that already made every copy above
|
|
390
|
+
* fail must not ALSO throw out of the manifest write.
|
|
391
|
+
*
|
|
392
|
+
* Returns { installed, skipped, diverged, failed, pruned }, arrays of the
|
|
393
|
+
* resource's relative path (or, for `pruned`, the manifest's recorded path).
|
|
394
|
+
* `diverged` now means "refused" (hand-authored divergence only) rather
|
|
395
|
+
* than "seen diverged, whether or not overwritten" -- nothing in this
|
|
396
|
+
* module tree ever read the old broader meaning (checked before this
|
|
397
|
+
* change), so this is not a breaking change to any known caller.
|
|
398
|
+
*/
|
|
399
|
+
export function installResources({
|
|
400
|
+
root,
|
|
401
|
+
force = false,
|
|
402
|
+
log = console.error,
|
|
403
|
+
}: {
|
|
404
|
+
root: string;
|
|
405
|
+
force?: boolean;
|
|
406
|
+
log?: (message: string) => void;
|
|
407
|
+
}): InstallResourcesResult {
|
|
408
|
+
const installed: string[] = [];
|
|
409
|
+
const skipped: string[] = [];
|
|
410
|
+
const diverged: string[] = [];
|
|
411
|
+
const failed: string[] = [];
|
|
412
|
+
|
|
413
|
+
for (const entry of resourceEntries()) {
|
|
414
|
+
const src = join(RESOURCES_DIR, entry);
|
|
415
|
+
const target = join(installTargetDir(root), entry);
|
|
416
|
+
const status = statusForEntry(entry, root);
|
|
417
|
+
|
|
418
|
+
if (!force && status === "present") {
|
|
419
|
+
skipped.push(entry);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (!force && status === "diverged" && !isGeneratedEntry(entry)) {
|
|
423
|
+
// Hand-authored (e.g. vice-launcher.sh): a divergence here MIGHT be a
|
|
424
|
+
// real local edit, so the original refuse-and-report posture stands.
|
|
425
|
+
diverged.push(entry);
|
|
426
|
+
log(
|
|
427
|
+
`warn: install-resources: refusing to overwrite ${entry} -- it diverges from resources/ and is NOT ` +
|
|
428
|
+
"a generated artifact (hand-authored; see resources/vice-launcher.sh's documented exception in " +
|
|
429
|
+
"CLAUDE.md's .claude/mcp/ contract). Nothing was deployed for this entry. Pass force:true to " +
|
|
430
|
+
"overwrite deliberately; this installer will never do so on its own for a hand-authored file."
|
|
431
|
+
);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!force && status === "diverged" && isGeneratedEntry(entry)) {
|
|
435
|
+
// Generated (tsc-compiled): divergence can only mean staleness here --
|
|
436
|
+
// CLAUDE.md says this half of resources/ (and all of tools/) is never
|
|
437
|
+
// hand-edited -- so fall through to the copy below instead of
|
|
438
|
+
// refusing it like the hand-authored branch above.
|
|
439
|
+
log(
|
|
440
|
+
`note: install-resources: ${entry} was diverged (stale) from resources/ -- refreshing it automatically ` +
|
|
441
|
+
"because it is a generated artifact, and a generated artifact can only diverge by going stale."
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Reached for status === "missing" (always copied, force or not), for
|
|
446
|
+
// "present"/"diverged" when force === true (the only overwrite path for
|
|
447
|
+
// a hand-authored file), and for a "diverged" GENERATED artifact even
|
|
448
|
+
// without force (staleness is the only thing divergence can mean for
|
|
449
|
+
// it, per the WHY note above).
|
|
450
|
+
try {
|
|
451
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
452
|
+
copyFileSync(src, target);
|
|
453
|
+
chmodSync(target, statSync(src).mode & 0o777);
|
|
454
|
+
installed.push(entry);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
failed.push(entry);
|
|
457
|
+
log(
|
|
458
|
+
`warn: install-resources: failed to deploy ${entry} to ${target} -- ${(e as Error).message}. ` +
|
|
459
|
+
"Continuing; a failed deployment must never break the caller."
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Make a refused (hand-authored) divergence impossible to skim past: this
|
|
465
|
+
// is the one remaining case where installResources() intentionally
|
|
466
|
+
// deploys nothing for an entry while reporting failed: [] -- the exact
|
|
467
|
+
// shape that read as silent success before this fix (installed: [],
|
|
468
|
+
// diverged: [N], failed: []). The per-entry warning above already names
|
|
469
|
+
// each one; this is the loud, count-carrying summary line.
|
|
470
|
+
if (diverged.length > 0) {
|
|
471
|
+
log(
|
|
472
|
+
`warn: install-resources: ${diverged.length} hand-authored entrie(s) refused (diverged, no force): ` +
|
|
473
|
+
`${JSON.stringify(diverged)}. Nothing was deployed for these -- resolve the divergence manually, or ` +
|
|
474
|
+
"pass force:true if overwriting is intentional."
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const { pruned } = pruneResources({ root, log });
|
|
479
|
+
|
|
480
|
+
// Never throws (D-3): an unwritable root that already made every copy
|
|
481
|
+
// above fail must not ALSO throw out of the manifest write. Nothing here
|
|
482
|
+
// is added to `failed` -- that array's existing meaning is "a resource
|
|
483
|
+
// copy failed", and a manifest-write failure is a distinct, best-effort
|
|
484
|
+
// bookkeeping step the next call's prune degrades gracefully from (an
|
|
485
|
+
// unwritten manifest reads back as empty, per readDeployManifest()).
|
|
486
|
+
try {
|
|
487
|
+
writeDeployManifest(root, resourceEntries());
|
|
488
|
+
} catch (e) {
|
|
489
|
+
log(
|
|
490
|
+
`warn: install-resources: failed to write the deploy manifest -- ${(e as Error).message}. ` +
|
|
491
|
+
"Continuing; a failed manifest write must never break the caller."
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// The default `log` is console.error, and NOTHING in this module writes to
|
|
496
|
+
// stdout (D-4): `tools --json` and `pool status` emit machine-readable
|
|
497
|
+
// output on stdout, and a stray banner there would corrupt it. This is the
|
|
498
|
+
// one place that condition matters -- only print when something actually
|
|
499
|
+
// changed (installed OR pruned).
|
|
500
|
+
if (installed.length > 0 || pruned.length > 0) {
|
|
501
|
+
log(hostLaunchInstructions(root));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return { installed, skipped, diverged, failed, pruned };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Fire-once latch: set BEFORE any work is attempted, so a throw partway
|
|
508
|
+
// through installResources() can never cause a second attempt in the same
|
|
509
|
+
// process. ES-module caching already makes this redundant for the import
|
|
510
|
+
// path (a module body runs once per process no matter how many times it is
|
|
511
|
+
// imported) -- this latch is what also makes a direct, repeated call to
|
|
512
|
+
// ensureResourcesInstalled() itself a no-op, which module caching alone does
|
|
513
|
+
// not guarantee.
|
|
514
|
+
let _resourcesInstallAttempted = false;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The fire-once entry point, wired from the bottom of repo-root.ts's module
|
|
518
|
+
* body. Never throws (D-3): the whole body runs inside a try/catch that
|
|
519
|
+
* degrades to a stderr warning. Does nothing at all when
|
|
520
|
+
* VICE_SKIP_RESOURCE_INSTALL=1 (D-7's env opt-out), and does nothing on any
|
|
521
|
+
* call after the first in this process.
|
|
522
|
+
*/
|
|
523
|
+
export function ensureResourcesInstalled({ root }: { root: string }): void {
|
|
524
|
+
if (_resourcesInstallAttempted) return;
|
|
525
|
+
_resourcesInstallAttempted = true;
|
|
526
|
+
if (process.env.VICE_SKIP_RESOURCE_INSTALL === "1") return;
|
|
527
|
+
try {
|
|
528
|
+
installResources({ root });
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.error(`warn: install-resources: ensureResourcesInstalled failed -- ${(e as Error).message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henols/vice-mcp",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "VICE emulator MCP server for C64 reverse-engineering: a stdio MCP server that proxies vice tools to a host VICE MCP server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vice-mcp": "vice-proxy.ts"
|
|
8
|
+
},
|
|
9
|
+
"main": "vice-proxy.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"vice-proxy.ts",
|
|
12
|
+
"vice.ts",
|
|
13
|
+
"vice-sync.ts",
|
|
14
|
+
"vice-probe.ts",
|
|
15
|
+
"vice-broker-client.ts",
|
|
16
|
+
"containerpath.ts",
|
|
17
|
+
"hostpath.ts",
|
|
18
|
+
"repo-root.ts",
|
|
19
|
+
"install-resources.ts",
|
|
20
|
+
"incident-record.ts",
|
|
21
|
+
"refresh-manifest.ts",
|
|
22
|
+
"build.ts",
|
|
23
|
+
"container-guard.mts",
|
|
24
|
+
"resources",
|
|
25
|
+
"tools-manifest.json",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.18.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": {
|
|
36
|
+
"name": "Henrik Olsson",
|
|
37
|
+
"url": "https://github.com/henols"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/henols/c64-re-tools.git",
|
|
42
|
+
"directory": ".claude/mcp/vice"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/henols/c64-re-tools#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/henols/c64-re-tools/issues"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"c64",
|
|
50
|
+
"commodore-64",
|
|
51
|
+
"vice",
|
|
52
|
+
"6502",
|
|
53
|
+
"6510",
|
|
54
|
+
"mcp",
|
|
55
|
+
"reverse-engineering"
|
|
56
|
+
],
|
|
57
|
+
"scripts": {
|
|
58
|
+
"test": "node --test '*.test.*'",
|
|
59
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
60
|
+
"build": "node build.ts",
|
|
61
|
+
"smoke": "node smoke.mjs"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@mastra/mcp": "1.15.0",
|
|
65
|
+
"@mastra/core": "1.55.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/node": "24.13.3",
|
|
69
|
+
"typescript": "7.0.2"
|
|
70
|
+
}
|
|
71
|
+
}
|