* test(#2874): add failing-first gate for the executed-plan return Four rows from the matrix's red-first order. E3 pins the one early return, for the opencode family, where a void-shaped hole would otherwise survive unnoticed. E13 sweeps every runtime in the registry - enumerated from the registry rather than hardcoded, so a runtime added later cannot slip past. F2 proves absence of real filesystem contact rather than merely that the happy path ran, which is the difference between a complete seam and a partial one. G1 and G3 are the additive guard and must be green before and after. G3 deliberately leaves the two existing adapter test doubles untouched: if this change required editing them it would not be additive, and the acceptance criterion would be unmet. No production code. All 19 runtimes install without throwing today, so E3 and E13 fail on the undefined comparison alone. Refs #2874 * feat(#2874): return the executed plan and route install IO through a seam installRuntimeArtifacts returned void, so its correctness was observable only by re-reading disk. It now returns what it executed - per kind, per scope - including on the combinedFamilyInstall path, which was the one early return where a void-shaped hole would have survived unnoticed. Failure still throws rather than becoming an ok:false return, so control flow is unchanged for both existing callers. A best-effort cleanup that fails is still swallowed, but is now visible in the returned value rather than silently absent. The fs seam is ambient rather than threaded. Explicit deps through install-profiles and the 3000-line conversion module was impractical; the tradeoff, the synchronous-only re-entrancy assumption, the restore guarantee and the partial-adapter fallback trap are all documented at the seam. findInstallSourceRoot and its sibling stay unrouted by design - they locate the package's own source, not the install destination. readCmdNames keeps a second implementation because the standalone CLI that owns the original cannot require the compiled adapter without a build-order dependency on its own output. A parity test fails if the two ever disagree. Refs #2874 * chore(#2874): gitignore the new build artifact install-fs-adapter.cjs is tsc output from src/install-fs-adapter.cts, not a tracked source file. It was added to eslint's ignore list but not to .gitignore, so it landed as a tracked file - the third time this step of the new-.cts ripple has been missed on this epic. Refs #2874 * fix(#2874): close two seam leaks and correct a false comment A correctness review found the seam still leaked in two places, both subtler than the three already closed. readGsdCommandNames was routed when it should not have been: it reads the package's own commands directory, which a destination-fake is never seeded with, so under a fake adapter it returned an empty or wrong roster instead of failing loudly. It now reads real fs, matching the precedent already documented for findInstallSourceRoot. cleanupStagedSkills ran raw rmSync from a process exit handler, which is real filesystem work deferred past the point where withInstallFs has restored - the one thing the synchronous-only contract exists to exclude. Staging now captures the adapter that created each directory and cleanup replays it, so a real install cleans up exactly as before and a fake-staged path never reaches the real filesystem. Also corrected a comment claiming the migration reads were an unrouted, untested residual gap. They are routed and exercised; a comment understating the seam is as corrosive as one overstating it in a module whose trust rests on being honestly documented. Refs #2874 * test(#2874): migrate the exemplar group and cover the matrix AC3's exemplar migration lands in place: the qwen install group now asserts skills and agents destinations from the returned plan in one deepStrictEqual instead of probing the filesystem for each. Nine facts the old probes established were enumerated first. Two moved to the value assertion; seven were retained deliberately - per-file SKILL.md existence, the VERSION file written outside this function, the manifest content, and the post-uninstall absence checks all sit outside the plan's per-kind contract. A migration that quietly asserts less looks like a win and is a regression, so the enumeration is the guard rather than the line count. Also implements the rest of the matrix: the executed-plan shape, adapter failure modes, the security-boundary rows including a fake that cannot certify an install the real filesystem would refuse, cleanup visibility, and two seeded property tests. Only the two external CI gates are left unticked, because self-certifying them would be a claim rather than a check. Refs #2874 * fix(#2874): restore streaming hashes and derive F2 from the boundary rule The checkpoint found three things reasoning had missed. sha256File had been converted from raw-fd streaming to a single readFileSync on the assumption that GSD artifacts are never large. A test named for exactly that contract already existed and went red. Streaming is restored, now routed through the adapter, which gains openSync, readSync and closeSync. The contract was the specification; the assumption was not. Three existing tests inject faults by monkeypatching real fs. They broke because mkInstallTempDir stopped calling real mkdtempSync, not because of any binding subtlety - the real adapter was already late-bound. It now calls the real function when no fake is injected, so a monkeypatch applied after import is still seen and the additive contract holds. F2 poisoned real fs by method, so a deliberately unrouted package-source read failed a correct design. It now poisons by path: destination IO is forbidden, package-source IO is allowed and positively asserted. The claim was always zero real destination IO, and the test now derives from that rule instead of coincidentally matching it. Refs #2874 * docs(#2874): add the contributor how-to for plan-based test migration The phase gate caught a real gap. The docs plan was Reference plus Explanation only, and every CI check would have passed, because the docs-required lint only verifies that some file under docs/ moved. But this phase exists to demonstrate a pattern for follow-on work, and that work is other contributors migrating probing test groups. The sequence has two live traps - a partial fake silently falls back to real fs, and the seam is ambient and synchronous-only - plus one discipline nobody infers: enumerate the facts before converting, or you assert less and call it a win. The page carries the qwen migration's arithmetic, nine facts enumerated and only two converted, because a reader seeing only the diff would reasonably conclude the pattern is to replace probes wholesale. No locale mirrors: none of the four carries any contributor-only how-to, so a single translated file would manufacture parity rather than provide it. Refs #2874 * chore(#2874): backfill changeset pr number * test(#2874): normalize both sides of the G1 tree comparison G1 failed on Windows only, deterministically on both shards. The defect was in the test helper, not production. _computePathPrefix posix-normalizes the resolved config dir unconditionally, so on Windows the path embedded in every emitted SKILL.md body is forward-slash form. hashDirTree stripped against the raw backslash path from mkdtempSync, so the substring never matched and each install's unique temp suffix stayed baked into every file - all fifteen skill bodies hashed differently for two runs that had written identical bytes. Both sides are now normalized unconditionally rather than gated on path.sep, matching the rule this repo already records: backslash paths arrive on Linux too. Production code is untouched and was verified correct. Normalizing this away on the production side would have hidden a real portability bug if one had existed. Refs #2874 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -7,6 +7,31 @@
|
||||
* applying the shared GSD slash-command namespace transform.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
// #2874 (ADR-58 cleanup phase): readGsdCommandNames is reached from
|
||||
// installRuntimeArtifacts's call tree (skillsKind's stage() closure reads it
|
||||
// for cross-referencing), but COMMANDS_DIR (below) is resolved by walking up
|
||||
// from THIS MODULE'S OWN compiled __dirname to locate the GSD PACKAGE'S OWN
|
||||
// commands/gsd/ source tree — not anything under the install destination.
|
||||
// This is the exact same "package's own source, not the destination" case
|
||||
// findInstallSourceRoot/findAgentsSourceRoot document in
|
||||
// runtime-artifact-layout.cts's Step 2 (see that comment and
|
||||
// install-fs-adapter.cts's module doc, "DELIBERATELY NOT ROUTED" section):
|
||||
// an injected destination adapter's store starts empty and is never seeded
|
||||
// with the real package's own on-disk layout, so routing this read through
|
||||
// installFs() would make a fake-adapter install either silently return `[]`
|
||||
// (via the ENOENT swallow below) or resolve wrong stems — never loudly fail,
|
||||
// and never see the real package tree either way. This directory read
|
||||
// therefore deliberately stays on real `node:fs`, unrouted — same rule,
|
||||
// applied consistently, not an inconsistency with the routed staging calls
|
||||
// elsewhere in this call tree. `readCmdNames` in
|
||||
// scripts/fix-slash-commands.cjs also uses real `node:fs` directly (that
|
||||
// script lives outside src/ and is also a standalone CLI tool), so its
|
||||
// directory-read logic is reimplemented here against real fs instead of
|
||||
// delegated to, keeping the pure regex/transform helpers below delegated as
|
||||
// before.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const slashCommandTransformer = require('../../../scripts/fix-slash-commands.cjs') as {
|
||||
readCmdNames: () => string[];
|
||||
@@ -16,8 +41,23 @@ const slashCommandTransformer = require('../../../scripts/fix-slash-commands.cjs
|
||||
buildColonPattern: (cmdNames: string[]) => RegExp | null;
|
||||
};
|
||||
|
||||
// Mirrors scripts/fix-slash-commands.cjs's own `COMMANDS_DIR` computation
|
||||
// (`path.join(__dirname, '..', 'commands', 'gsd')` from repo-root/scripts) —
|
||||
// same target directory, resolved from this module's own compiled location
|
||||
// (repo-root/gsd-core/bin/lib) instead, so the walk-up depth differs.
|
||||
const COMMANDS_DIR = path.join(__dirname, '..', '..', '..', 'commands', 'gsd');
|
||||
|
||||
function readGsdCommandNames(): string[] {
|
||||
return slashCommandTransformer.readCmdNames();
|
||||
try {
|
||||
return fs.readdirSync(COMMANDS_DIR)
|
||||
.filter((f: string) => f.endsWith('.md'))
|
||||
.map((f: string) => f.replace(/\.md$/, ''));
|
||||
} catch (err) {
|
||||
// Only swallow the missing-directory case — mirrors readCmdNames' own
|
||||
// contract (scripts/fix-slash-commands.cjs).
|
||||
if (err instanceof Error && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export = {
|
||||
|
||||
@@ -30,8 +30,14 @@
|
||||
* writes — the same test the uninstall path has always used.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// #2874 (ADR-58 cleanup phase): ensureCommonJsMarker is reached from
|
||||
// install-engine.cts's _installNativePluginIfDeclared, which is on the
|
||||
// installRuntimeArtifacts call tree — route this module's fs calls through
|
||||
// the injectable seam too. See install-fs-adapter.cts's module doc.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installFsAdapter = require('./install-fs-adapter.cjs');
|
||||
const { installFs } = installFsAdapter;
|
||||
|
||||
/** The exact marker content GSD writes (and the only content it will remove). */
|
||||
export const COMMONJS_MARKER = '{"type":"commonjs"}';
|
||||
@@ -72,12 +78,12 @@ export function markerPathFor(dir: string): string {
|
||||
*/
|
||||
export function classifyMarker(dir: string): MarkerOwnership {
|
||||
const markerPath = markerPathFor(dir);
|
||||
let stat: import('node:fs').Stats;
|
||||
let stat: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean };
|
||||
try {
|
||||
// lstat, not existsSync: existsSync follows symlinks and reports `false`
|
||||
// for a DANGLING one, which would classify the path `absent` and let the
|
||||
// write below follow the link and land outside the directory GSD owns.
|
||||
stat = fs.lstatSync(markerPath);
|
||||
stat = installFs().lstatSync(markerPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'absent';
|
||||
return 'foreign';
|
||||
@@ -86,7 +92,7 @@ export function classifyMarker(dir: string): MarkerOwnership {
|
||||
// something GSD wrote, so it is never ours to overwrite or remove.
|
||||
if (!stat.isFile()) return 'foreign';
|
||||
try {
|
||||
const content = fs.readFileSync(markerPath, 'utf8');
|
||||
const content = installFs().readFileSync(markerPath, 'utf8');
|
||||
return content.trim() === COMMONJS_MARKER ? 'gsd-owned' : 'foreign';
|
||||
} catch {
|
||||
return 'foreign';
|
||||
@@ -115,11 +121,11 @@ export function ensureCommonJsMarker(dir: string): MarkerWriteOutcome {
|
||||
if (ownership === 'foreign') return 'preserved-foreign';
|
||||
if (ownership === 'gsd-owned') return 'unchanged';
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
installFs().mkdirSync(dir, { recursive: true });
|
||||
// Exclusive create closes the gap between classifying and writing: if
|
||||
// anything at all appeared at the path in between — including a symlink —
|
||||
// this fails with EEXIST instead of following or overwriting it.
|
||||
fs.writeFileSync(markerPathFor(dir), COMMONJS_MARKER_CONTENT, { flag: 'wx' });
|
||||
installFs().writeFileSync(markerPathFor(dir), COMMONJS_MARKER_CONTENT, { flag: 'wx' });
|
||||
return 'written';
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return 'preserved-foreign';
|
||||
@@ -134,7 +140,7 @@ export function ensureCommonJsMarker(dir: string): MarkerWriteOutcome {
|
||||
export function removeCommonJsMarker(dir: string): boolean {
|
||||
if (classifyMarker(dir) !== 'gsd-owned') return false;
|
||||
try {
|
||||
fs.unlinkSync(markerPathFor(dir));
|
||||
installFs().unlinkSync(markerPathFor(dir));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -32,6 +32,14 @@ import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs');
|
||||
import { posixNormalize } from './shell-command-projection.cjs';
|
||||
import { isPathConfined } from './external-descriptor-trust.cjs';
|
||||
import { ensureCommonJsMarker } from './commonjs-marker.cjs';
|
||||
// #2874 (ADR-58 cleanup phase): the injectable fs seam for the
|
||||
// installRuntimeArtifacts call tree. `installFs()` resolves to real
|
||||
// `node:fs` unless a call is wrapped in `withInstallFs(deps.fs, ...)` —
|
||||
// every fs call below in this file that installRuntimeArtifacts's own call
|
||||
// tree reaches goes through it. See install-fs-adapter.cts's module doc for
|
||||
// why this is an ambient swap rather than a threaded `deps` parameter.
|
||||
import installFsAdapter = require('./install-fs-adapter.cjs');
|
||||
const { installFs, withInstallFs } = installFsAdapter;
|
||||
// #2870: InstallScope is owned by install-scope.cts, not re-declared here.
|
||||
// `isGlobalScope` centralizes the `scope === 'global'` boolean projection
|
||||
// this module's two remaining re-derivation sites need (see the
|
||||
@@ -161,9 +169,9 @@ function preserveUserArtifacts(destDir: string, fileNames: string[]): Map<string
|
||||
const saved = new Map<string, string>();
|
||||
for (const name of fileNames) {
|
||||
const fullPath = path.join(destDir, name);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
if (installFs().existsSync(fullPath)) {
|
||||
try {
|
||||
saved.set(name, fs.readFileSync(fullPath, 'utf8'));
|
||||
saved.set(name, installFs().readFileSync(fullPath, 'utf8'));
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
}
|
||||
@@ -180,8 +188,8 @@ function restoreUserArtifacts(destDir: string, saved: Map<string, string>): void
|
||||
for (const [name, content] of saved) {
|
||||
const fullPath = path.join(destDir, name);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
installFs().mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
installFs().writeFileSync(fullPath, content, 'utf8');
|
||||
} catch { /* skip unwritable paths */ }
|
||||
}
|
||||
}
|
||||
@@ -257,7 +265,7 @@ function hasExistingSymlinkBetween(
|
||||
// — threat (a) above still confines regardless.
|
||||
let realRoot: string;
|
||||
try {
|
||||
realRoot = fs.existsSync(resolvedRoot) ? fs.realpathSync(resolvedRoot) : resolvedRoot;
|
||||
realRoot = installFs().existsSync(resolvedRoot) ? installFs().realpathSync(resolvedRoot) : resolvedRoot;
|
||||
} catch {
|
||||
realRoot = resolvedRoot;
|
||||
}
|
||||
@@ -273,10 +281,10 @@ function hasExistingSymlinkBetween(
|
||||
// root. So under opt-in, just follow the root symlink and continue the walk.
|
||||
// Default behavior (no opt-in) preserves the pre-#2393 refuse.
|
||||
let cursor = resolvedRoot;
|
||||
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
|
||||
if (installFs().existsSync(cursor) && installFs().lstatSync(cursor).isSymbolicLink()) {
|
||||
if (!allowFollow) return true;
|
||||
try {
|
||||
cursor = fs.realpathSync(cursor);
|
||||
cursor = installFs().realpathSync(cursor);
|
||||
} catch {
|
||||
// realpathSync failed (broken symlink, permission denied, exotic FS) — refuse,
|
||||
// matching fail-closed posture.
|
||||
@@ -288,8 +296,8 @@ function hasExistingSymlinkBetween(
|
||||
for (const segment of relative.split(path.sep)) {
|
||||
if (!segment) continue;
|
||||
cursor = path.join(cursor, segment);
|
||||
if (!fs.existsSync(cursor)) return false;
|
||||
if (fs.lstatSync(cursor).isSymbolicLink()) {
|
||||
if (!installFs().existsSync(cursor)) return false;
|
||||
if (installFs().lstatSync(cursor).isSymbolicLink()) {
|
||||
if (!allowFollow) return true;
|
||||
// Opt-in active: follow the symlink. Refuse if the resolved target is the
|
||||
// install root itself (threat (b) — would let _removeGsdEntries wipe the
|
||||
@@ -310,7 +318,7 @@ function hasExistingSymlinkBetween(
|
||||
// documented opt-in semantics; do not add a "follow one symlink only"
|
||||
// expectation here without revisiting the threat model.
|
||||
try {
|
||||
const realTarget = fs.realpathSync(cursor);
|
||||
const realTarget = installFs().realpathSync(cursor);
|
||||
if (realTarget === realRoot || realTarget === resolvedRoot) return true; // (b)
|
||||
cursor = realTarget;
|
||||
} catch {
|
||||
@@ -372,7 +380,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string
|
||||
skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
|
||||
}
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) return false;
|
||||
if (installFs().existsSync(skillFile)) return false;
|
||||
// Symlink-escape guard: reject if any path component between installRoot and
|
||||
// skillDir is a symlink that would redirect writes outside the install root.
|
||||
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
||||
@@ -382,8 +390,8 @@ function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string
|
||||
);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(skillFile, saved.get('dev-preferences.md')!, 'utf8');
|
||||
installFs().mkdirSync(skillDir, { recursive: true });
|
||||
installFs().writeFileSync(skillFile, saved.get('dev-preferences.md')!, 'utf8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -438,27 +446,27 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
|
||||
}
|
||||
// Use the validated absolute path for the actual writes below.
|
||||
destDir = resolvedDest;
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
if (!installFs().existsSync(stagedDir)) return;
|
||||
installFs().mkdirSync(destDir, { recursive: true });
|
||||
|
||||
if (kind.kind === 'skills') {
|
||||
// Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
|
||||
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(stagedDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const src = path.join(stagedDir, entry.name);
|
||||
const dest = path.join(destDir, entry.name);
|
||||
fs.cpSync(src, dest, { recursive: true });
|
||||
installFs().cpSync(src, dest, { recursive: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind.kind === 'kimi-agents') {
|
||||
fs.cpSync(stagedDir, destDir, { recursive: true });
|
||||
installFs().cpSync(stagedDir, destDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// commands or agents
|
||||
const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
|
||||
const entries = installFs().readdirSync(stagedDir, { withFileTypes: true });
|
||||
// For commands: apply prefix unless the destSubpath's last segment already
|
||||
// represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
|
||||
// Single source of truth: runtimeArtifactLayout.isNamespacedByDir (#2871
|
||||
@@ -492,7 +500,7 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
|
||||
destName = runtimeArtifactLayout.composeCommandFilename(namespacedByDir, kind.prefix, stem);
|
||||
}
|
||||
|
||||
fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName));
|
||||
installFs().copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,18 +515,18 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
|
||||
* as a defensive guard for future runtimes.)
|
||||
*/
|
||||
function _removeGsdEntries(destDir: string, kind: any): void {
|
||||
if (!fs.existsSync(destDir)) return;
|
||||
if (!installFs().existsSync(destDir)) return;
|
||||
if (kind.kind === 'kimi-agents') {
|
||||
for (const fileName of ['gsd.yaml', 'gsd.md']) {
|
||||
fs.rmSync(path.join(destDir, fileName), { force: true });
|
||||
installFs().rmSync(path.join(destDir, fileName), { force: true });
|
||||
}
|
||||
const subagentsDir = path.join(destDir, 'subagents');
|
||||
if (fs.existsSync(subagentsDir)) {
|
||||
for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) {
|
||||
if (installFs().existsSync(subagentsDir)) {
|
||||
for (const entry of installFs().readdirSync(subagentsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
|
||||
fs.rmSync(path.join(subagentsDir, entry.name), { force: true });
|
||||
installFs().rmSync(path.join(subagentsDir, entry.name), { force: true });
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -526,12 +534,12 @@ function _removeGsdEntries(destDir: string, kind: any): void {
|
||||
if (kind.prefix === '') {
|
||||
// Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
|
||||
// The directory itself is the GSD namespace, so remove it entirely.
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
installFs().rmSync(destDir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(destDir, { withFileTypes: true })) {
|
||||
if (!entry.name.startsWith(kind.prefix)) continue;
|
||||
fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true });
|
||||
installFs().rmSync(path.join(destDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,13 +553,13 @@ function _removeGsdEntries(destDir: string, kind: any): void {
|
||||
*/
|
||||
function _snapshotDir(dir: string): Map<string, Buffer> {
|
||||
const files = new Map<string, Buffer>();
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
if (!installFs().existsSync(dir)) return files;
|
||||
const walk = (relPath: string, absPath: string) => {
|
||||
for (const e of fs.readdirSync(absPath, { withFileTypes: true })) {
|
||||
for (const e of installFs().readdirSync(absPath, { withFileTypes: true })) {
|
||||
const childRel = relPath ? path.join(relPath, e.name) : e.name;
|
||||
const childAbs = path.join(absPath, e.name);
|
||||
if (e.isDirectory()) walk(childRel, childAbs);
|
||||
else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs));
|
||||
else if (e.isFile()) files.set(childRel, installFs().readFileSync(childAbs));
|
||||
}
|
||||
};
|
||||
walk('', dir);
|
||||
@@ -564,8 +572,8 @@ function _snapshotDir(dir: string): Map<string, Buffer> {
|
||||
function _restoreDir(dir: string, snapshot: Map<string, Buffer>): void {
|
||||
for (const [relPath, buf] of snapshot) {
|
||||
const absPath = path.join(dir, relPath);
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, buf);
|
||||
installFs().mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
installFs().writeFileSync(absPath, buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,8 +589,8 @@ function _restoreDir(dir: string, snapshot: Map<string, Buffer>): void {
|
||||
* @param nestedGsdDir absolute path to skills/gsd/ category dir
|
||||
*/
|
||||
function _removeHermesBareStemDirs(nestedGsdDir: string): void {
|
||||
if (!fs.existsSync(nestedGsdDir)) return;
|
||||
const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
|
||||
if (!installFs().existsSync(nestedGsdDir)) return;
|
||||
const entries = installFs().readdirSync(nestedGsdDir, { withFileTypes: true });
|
||||
|
||||
// Collect the set of stems that were installed as gsd-<stem>/ this run.
|
||||
const installedStems = new Set<string>();
|
||||
@@ -595,7 +603,7 @@ function _removeHermesBareStemDirs(nestedGsdDir: string): void {
|
||||
// Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
|
||||
fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
|
||||
installFs().rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,9 +629,9 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope:
|
||||
// created skills/gsd-dev-preferences/ skill dir.
|
||||
let savedLegacyArtifacts: Map<string, string> | null = null;
|
||||
if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) {
|
||||
if (fs.existsSync(legacyCommandsGsd)) {
|
||||
if (installFs().existsSync(legacyCommandsGsd)) {
|
||||
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
|
||||
fs.rmSync(legacyCommandsGsd, { recursive: true });
|
||||
installFs().rmSync(legacyCommandsGsd, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,10 +639,10 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope:
|
||||
// the new skills/gsd/ nested layout.
|
||||
if (runtime === 'hermes') {
|
||||
const flatSkillsDir = path.join(configDir, 'skills');
|
||||
if (fs.existsSync(flatSkillsDir)) {
|
||||
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
|
||||
if (installFs().existsSync(flatSkillsDir)) {
|
||||
for (const entry of installFs().readdirSync(flatSkillsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||||
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
|
||||
installFs().rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,6 +754,18 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s
|
||||
* the skills kind can materialize installed third-party capability skills
|
||||
* bound to their declaring capId. Absent -> no third-party skills staged
|
||||
* (fail closed), matching the layout resolver's own optional-registry contract.
|
||||
* @param deps #2874 (ADR-58 cleanup phase): optional injection bag, additive
|
||||
* over the 6-positional-arg call shape every existing caller (bin/install.js,
|
||||
* G1/G3 test doubles) already uses — an omitted/`{}` `deps` is byte-identical
|
||||
* to before (AC4). `deps.fs` — a PARTIAL InstallFsAdapter
|
||||
* (install-fs-adapter.cts) — is merged over the real fs adapter for the
|
||||
* duration of this call (and everything it calls: layout source-root
|
||||
* resolution, profile staging, content-rewrite passes) via `withInstallFs`.
|
||||
* @returns an executed-plan value describing what this call wrote, never
|
||||
* `undefined` (40-design.md: "Legitimate undefined returns: none after this
|
||||
* phase"). Throws, rather than returning an `ok:false` shape, on stage/
|
||||
* rewrite failure — the return type describes what executed; failure stays
|
||||
* an exception (design doc "Rejected" #3 / AC4).
|
||||
*/
|
||||
function installRuntimeArtifacts(
|
||||
runtime: string,
|
||||
@@ -754,140 +774,187 @@ function installRuntimeArtifacts(
|
||||
resolvedProfile: any,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
capabilityRegistry?: any,
|
||||
): void {
|
||||
// A removed descriptor kind is no longer visited by the layout loop, so it
|
||||
// cannot prune its own previous output. Clean manifest-proven retired files
|
||||
// before materializing the current layout (#2644).
|
||||
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
|
||||
deps: { fs?: any } = {},
|
||||
): any {
|
||||
return withInstallFs(deps.fs, (): any => {
|
||||
// A removed descriptor kind is no longer visited by the layout loop, so it
|
||||
// cannot prune its own previous output. Clean manifest-proven retired files
|
||||
// before materializing the current layout (#2644).
|
||||
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
|
||||
|
||||
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
|
||||
// the dedicated combined commands+skills+plugin orchestrator instead of the
|
||||
// generic layout-driven loop below, mirroring the bespoke install path that
|
||||
// previously lived inline in bin/install.js.
|
||||
const behaviors = _hostBehaviors(runtime);
|
||||
if (behaviors.combinedFamilyInstall) {
|
||||
// #2329: combined-family runtimes (OpenCode/Kilo) bypass
|
||||
// _runLegacyInstallMigrations below entirely (early return), so their
|
||||
// legacy-directory cleanup needs its own pre-materialization hook here.
|
||||
_migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
|
||||
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy cleanup before layout-driven writes
|
||||
_runLegacyInstallMigrations(runtime, configDir, scope);
|
||||
|
||||
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as 'global' | 'local', capabilityRegistry);
|
||||
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
||||
// `Layout` is structurally identical across the layout/install-plan .cjs
|
||||
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
||||
layout: layout as any,
|
||||
resolvedProfile,
|
||||
homedir: () => os.homedir(),
|
||||
platform: process.platform,
|
||||
resolveAttribution,
|
||||
});
|
||||
|
||||
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
|
||||
try {
|
||||
if (!planResult.ok) {
|
||||
throw new Error(planResult.message);
|
||||
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
|
||||
// the dedicated combined commands+skills+plugin orchestrator instead of the
|
||||
// generic layout-driven loop below, mirroring the bespoke install path that
|
||||
// previously lived inline in bin/install.js.
|
||||
const behaviors = _hostBehaviors(runtime);
|
||||
if (behaviors.combinedFamilyInstall) {
|
||||
// #2329: combined-family runtimes (OpenCode/Kilo) bypass
|
||||
// _runLegacyInstallMigrations below entirely (early return), so their
|
||||
// legacy-directory cleanup needs its own pre-materialization hook here.
|
||||
_migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
|
||||
// #2874 design row 2: this early return must ALSO return an executed
|
||||
// plan — installOpencodeFamilyArtifacts reports what it wrote, so a
|
||||
// whole runtime family returning undefined is no longer a hole.
|
||||
return installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
|
||||
}
|
||||
|
||||
const kindsByName = new Map<string, any>(layout.kinds.map((kind: any) => [kind.kind as string, kind]));
|
||||
for (const item of planResult.plan.items) {
|
||||
const kind: any = kindsByName.get(item.kind);
|
||||
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
|
||||
const dest = item.destDir;
|
||||
// Symlink-escape guard: reject before mkdir if dest (or any component
|
||||
// between the install root and dest) is a symlink pointing outside that
|
||||
// root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
|
||||
// call. The install root is normally configDir, but a kind may declare
|
||||
// an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
|
||||
// $HOME/.agents) — in that case the guard must check against the
|
||||
// resolved alternate root instead, matching assertDestWithinConfigHome's
|
||||
// own root selection in createRuntimeArtifactInstallPlan.
|
||||
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
||||
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
||||
// Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
|
||||
// resolved-target-equals-root still refuse regardless of opt-in.
|
||||
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||||
throw new Error(
|
||||
`installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
||||
);
|
||||
// Legacy cleanup before layout-driven writes
|
||||
_runLegacyInstallMigrations(runtime, configDir, scope);
|
||||
|
||||
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as 'global' | 'local', capabilityRegistry);
|
||||
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
||||
// `Layout` is structurally identical across the layout/install-plan .cjs
|
||||
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
||||
layout: layout as any,
|
||||
resolvedProfile,
|
||||
homedir: () => os.homedir(),
|
||||
platform: process.platform,
|
||||
resolveAttribution,
|
||||
});
|
||||
|
||||
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
|
||||
// #2874 row 1/4/5: per-kind executed-plan entries, appended only as the
|
||||
// loop below actually finishes writing each kind — a kind that throws
|
||||
// mid-copy is never reported as executed.
|
||||
const executedKinds: any[] = [];
|
||||
// #2874 rows 10/11: { dir, ok } per cleanupDirs entry — built in the
|
||||
// `finally` below regardless of whether the try block throws, so a
|
||||
// caught failure that still throws (row 3) leaves this populated even
|
||||
// though it is never returned on that path.
|
||||
const cleanupResults: { dir: string; ok: boolean }[] = [];
|
||||
try {
|
||||
if (!planResult.ok) {
|
||||
throw new Error(planResult.message);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
if (kind.kind === 'skills' && fs.existsSync(dest)) {
|
||||
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
|
||||
// then restore after. This preserves user dirs across a wipe-and-replace
|
||||
// install (#2973 / #3664).
|
||||
|
||||
const kindsByName = new Map<string, any>(layout.kinds.map((kind: any) => [kind.kind as string, kind]));
|
||||
for (const item of planResult.plan.items) {
|
||||
const kind: any = kindsByName.get(item.kind);
|
||||
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
|
||||
const dest = item.destDir;
|
||||
// Symlink-escape guard: reject before mkdir if dest (or any component
|
||||
// between the install root and dest) is a symlink pointing outside that
|
||||
// root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
|
||||
// call. The install root is normally configDir, but a kind may declare
|
||||
// an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
|
||||
// $HOME/.agents) — in that case the guard must check against the
|
||||
// resolved alternate root instead, matching assertDestWithinConfigHome's
|
||||
// own root selection in createRuntimeArtifactInstallPlan.
|
||||
//
|
||||
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
|
||||
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
|
||||
// untouched. Preserve the explicit user-owned GSD-prefixed skill
|
||||
// gsd-dev-preferences, which GSD does not reinstall from source but must
|
||||
// survive the prune (#2973).
|
||||
const toPreserve = new Map<string, Map<string, Buffer>>(); // dirName -> Map<relPath, Buffer>
|
||||
// #2874: this REFUSAL DECISION stays outside the injected fs adapter —
|
||||
// only hasExistingSymlinkBetween's own existsSync/lstatSync/realpathSync
|
||||
// PROBES are routed through it (install-fs-adapter.cts's module doc).
|
||||
// A fake adapter can change what those probes observe for paths that
|
||||
// were never real to begin with; it cannot make this `if` pass for a
|
||||
// path the real filesystem would refuse.
|
||||
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
||||
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
||||
// Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
|
||||
// resolved-target-equals-root still refuse regardless of opt-in.
|
||||
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||||
throw new Error(
|
||||
`installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
||||
);
|
||||
}
|
||||
installFs().mkdirSync(dest, { recursive: true });
|
||||
const preserved: string[] = [];
|
||||
if (kind.kind === 'skills' && installFs().existsSync(dest)) {
|
||||
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
|
||||
// then restore after. This preserves user dirs across a wipe-and-replace
|
||||
// install (#2973 / #3664).
|
||||
//
|
||||
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
|
||||
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
|
||||
// untouched. Preserve the explicit user-owned GSD-prefixed skill
|
||||
// gsd-dev-preferences, which GSD does not reinstall from source but must
|
||||
// survive the prune (#2973).
|
||||
const toPreserve = new Map<string, Map<string, Buffer>>(); // dirName -> Map<relPath, Buffer>
|
||||
|
||||
{
|
||||
// Preserve explicitly user-owned GSD-prefixed skill dirs.
|
||||
// gsd-dev-preferences is the sole user-customisable skill in this category.
|
||||
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
||||
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
||||
const skillDir = path.join(dest, dirName);
|
||||
if (!fs.existsSync(skillDir)) continue;
|
||||
const snap = _snapshotDir(skillDir);
|
||||
if (snap.size > 0) toPreserve.set(dirName, snap);
|
||||
{
|
||||
// Preserve explicitly user-owned GSD-prefixed skill dirs.
|
||||
// gsd-dev-preferences is the sole user-customisable skill in this category.
|
||||
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
||||
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
||||
const skillDir = path.join(dest, dirName);
|
||||
if (!installFs().existsSync(skillDir)) continue;
|
||||
const snap = _snapshotDir(skillDir);
|
||||
if (snap.size > 0) toPreserve.set(dirName, snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
|
||||
|
||||
// Restore user-owned dirs after the prune+copy
|
||||
for (const [dirName, snap] of toPreserve) {
|
||||
_restoreDir(path.join(dest, dirName), snap);
|
||||
// Restore user-owned dirs after the prune+copy
|
||||
for (const [dirName, snap] of toPreserve) {
|
||||
_restoreDir(path.join(dest, dirName), snap);
|
||||
preserved.push(dirName);
|
||||
}
|
||||
} else {
|
||||
// For non-skills kinds (commands, agents): no user content to preserve;
|
||||
// just prune stale gsd-* entries and copy new ones.
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
|
||||
}
|
||||
executedKinds.push({ kind: item.kind, sourceDir: item.sourceDir, destDir: dest, preserved });
|
||||
}
|
||||
} finally {
|
||||
// #2874 rows 10/11: cleanup stays best-effort (an install must never
|
||||
// fail on cleanup) but a failed rmSync is now VISIBLE in `cleanup`
|
||||
// rather than silently swallowed — silently absent is worse than the
|
||||
// `void` return this replaces (40-design.md negative-space section).
|
||||
for (const dir of cleanupDirs) {
|
||||
try {
|
||||
installFs().rmSync(dir, { recursive: true, force: true });
|
||||
cleanupResults.push({ dir, ok: true });
|
||||
} catch {
|
||||
cleanupResults.push({ dir, ok: false });
|
||||
}
|
||||
} else {
|
||||
// For non-skills kinds (commands, agents): no user content to preserve;
|
||||
// just prune stale gsd-* entries and copy new ones.
|
||||
_removeGsdEntries(dest, kind);
|
||||
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const dir of cleanupDirs) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
|
||||
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
|
||||
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
|
||||
// correspond to the newly installed gsd-<stem> entries. This is the robust
|
||||
// replacement for the readGsdCommandNames()-based pre-install cleanup that
|
||||
// missed skills like 'dev-preferences' (#947 adversarial review).
|
||||
//
|
||||
// We run this AFTER the install loop so the installed set is authoritative:
|
||||
// every gsd-<stem>/ present now was written this run (or was there before
|
||||
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
|
||||
// are untouched.
|
||||
let hermesBareStemCleanup = false;
|
||||
if (runtime === 'hermes') {
|
||||
const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
|
||||
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
|
||||
hermesBareStemCleanup = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
|
||||
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
|
||||
// correspond to the newly installed gsd-<stem> entries. This is the robust
|
||||
// replacement for the readGsdCommandNames()-based pre-install cleanup that
|
||||
// missed skills like 'dev-preferences' (#947 adversarial review).
|
||||
//
|
||||
// We run this AFTER the install loop so the installed set is authoritative:
|
||||
// every gsd-<stem>/ present now was written this run (or was there before
|
||||
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
|
||||
// are untouched.
|
||||
if (runtime === 'hermes') {
|
||||
const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
|
||||
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
|
||||
}
|
||||
// Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
|
||||
// outside the OpenCode/Kilo combined-family install (e.g. pi, whose
|
||||
// artifactLayout is empty and which never sets combinedFamilyInstall) still
|
||||
// need their declared hostBehaviors.nativePlugin file copied into configDir.
|
||||
// findInstallSourceRoot resolves the repo/package root independent of
|
||||
// configDir contents (marker check, then a walk-up from __dirname), so this
|
||||
// is safe even when configDir has no .gsd-source marker (artifactLayout: []).
|
||||
let nativePluginInstalled = false;
|
||||
if (behaviors.nativePlugin) {
|
||||
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
||||
const src = path.dirname(path.dirname(commandsGsdDir));
|
||||
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
||||
nativePluginInstalled = true;
|
||||
}
|
||||
|
||||
// Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
|
||||
// outside the OpenCode/Kilo combined-family install (e.g. pi, whose
|
||||
// artifactLayout is empty and which never sets combinedFamilyInstall) still
|
||||
// need their declared hostBehaviors.nativePlugin file copied into configDir.
|
||||
// findInstallSourceRoot resolves the repo/package root independent of
|
||||
// configDir contents (marker check, then a walk-up from __dirname), so this
|
||||
// is safe even when configDir has no .gsd-source marker (artifactLayout: []).
|
||||
if (behaviors.nativePlugin) {
|
||||
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
||||
const src = path.dirname(path.dirname(commandsGsdDir));
|
||||
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
||||
}
|
||||
// #2874 row 14: an empty `layout.kinds` still returns `kinds: []` here
|
||||
// (executedKinds was never mutated), never `undefined`.
|
||||
return {
|
||||
runtime,
|
||||
scope,
|
||||
kinds: executedKinds,
|
||||
cleanup: cleanupResults,
|
||||
postSteps: { hermesBareStemCleanup, nativePlugin: nativePluginInstalled },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -934,7 +1001,7 @@ function installOpencodeFamilySkills(
|
||||
const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills');
|
||||
if (!skillsKindEntry) return 0;
|
||||
const rawDir = rawCommandsDir;
|
||||
if (!rawDir || !fs.existsSync(rawDir)) return 0;
|
||||
if (!rawDir || !installFs().existsSync(rawDir)) return 0;
|
||||
|
||||
// #2093: descriptor-driven — dispatch off the skills-kind entry's `converter`
|
||||
// string (capabilities/<runtime>/capability.json artifactLayout) via the
|
||||
@@ -965,7 +1032,7 @@ function installOpencodeFamilySkills(
|
||||
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
installFs().mkdirSync(dest, { recursive: true });
|
||||
|
||||
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
|
||||
// gsd-dev-preferences is generated by the user (via generate-dev-preferences)
|
||||
@@ -976,7 +1043,7 @@ function installOpencodeFamilySkills(
|
||||
const toPreserve = new Map<string, Map<string, Buffer>>(); // dirName -> Map<relPath, Buffer>
|
||||
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
||||
const skillDir = path.join(dest, dirName);
|
||||
if (!fs.existsSync(skillDir)) continue;
|
||||
if (!installFs().existsSync(skillDir)) continue;
|
||||
const snap = _snapshotDir(skillDir);
|
||||
if (snap.size > 0) toPreserve.set(dirName, snap);
|
||||
}
|
||||
@@ -985,18 +1052,18 @@ function installOpencodeFamilySkills(
|
||||
|
||||
let count = 0;
|
||||
const firstPartyStems = new Set<string>();
|
||||
for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(rawDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
firstPartyStems.add(stem);
|
||||
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
||||
let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
|
||||
let content = installFs().readFileSync(path.join(rawDir, entry.name), 'utf8');
|
||||
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
||||
content = processAttribution(content, resolveAttribution(runtime));
|
||||
content = converter(content, skillName);
|
||||
const skillDir = path.join(dest, skillName);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
installFs().mkdirSync(skillDir, { recursive: true });
|
||||
installFs().writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
count++;
|
||||
}
|
||||
|
||||
@@ -1033,13 +1100,13 @@ function installOpencodeFamilySkills(
|
||||
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
||||
content = processAttribution(content, resolveAttribution(runtime));
|
||||
const skillDir = path.join(dest, skillName);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
installFs().mkdirSync(skillDir, { recursive: true });
|
||||
installFs().writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
// #2322 HIGH-3 parity: persist the capability-owned marker so a later
|
||||
// prune pass can identify this directory even once the owning
|
||||
// capability is uninstalled/unsurfaced and no longer appears in any
|
||||
// registry view.
|
||||
fs.writeFileSync(path.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
||||
installFs().writeFileSync(path.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -1080,25 +1147,25 @@ function installOpencodeFamilyCommands(
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
prefix: string = 'gsd',
|
||||
): void {
|
||||
if (!fs.existsSync(srcDir)) return;
|
||||
if (!installFs().existsSync(srcDir)) return;
|
||||
|
||||
// Remove old gsd-*.md files before copying new ones
|
||||
if (fs.existsSync(destDir)) {
|
||||
for (const file of fs.readdirSync(destDir)) {
|
||||
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) fs.unlinkSync(path.join(destDir, file));
|
||||
if (installFs().existsSync(destDir)) {
|
||||
for (const file of installFs().readdirSync(destDir)) {
|
||||
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) installFs().unlinkSync(path.join(destDir, file));
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
installFs().mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(srcDir, { withFileTypes: true })) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
const baseName = entry.name.replace('.md', '');
|
||||
const destName = `${prefix}-${baseName}.md`;
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
let content = installFs().readFileSync(srcPath, 'utf8');
|
||||
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
||||
content = processAttribution(content, resolveAttribution(runtime));
|
||||
// #2093: this commands-kind entry's descriptor `converter` field is
|
||||
@@ -1114,7 +1181,7 @@ function installOpencodeFamilyCommands(
|
||||
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
|
||||
? (runtimeArtifactConversion as any).convertClaudeToKiloFrontmatter(content)
|
||||
: (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content);
|
||||
fs.writeFileSync(path.join(destDir, destName), content);
|
||||
installFs().writeFileSync(path.join(destDir, destName), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1149,7 +1216,7 @@ function _installNativePluginIfDeclared(
|
||||
const np = behaviors.nativePlugin;
|
||||
if (np && np.source) {
|
||||
const pluginSrc = path.join(src, np.source);
|
||||
if (fs.existsSync(pluginSrc)) {
|
||||
if (installFs().existsSync(pluginSrc)) {
|
||||
// Confine the FULL dest path (dir + file), not just the dir. Previously
|
||||
// only `np.dir` was validated and `np.file` was joined on unchecked, so a
|
||||
// descriptor whose `file` carried `..`, an absolute path, or a NUL byte
|
||||
@@ -1162,8 +1229,8 @@ function _installNativePluginIfDeclared(
|
||||
configDir,
|
||||
path.join(np.dir, np.file),
|
||||
);
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.copyFileSync(pluginSrc, destPath);
|
||||
installFs().mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
installFs().copyFileSync(pluginSrc, destPath);
|
||||
// #2544: the staged adapter is a `.js` file, so Node decides its module
|
||||
// type by walking up for the nearest package.json. It used to find the
|
||||
// marker the installer wrote at the config root — the write that
|
||||
@@ -1236,14 +1303,23 @@ function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, be
|
||||
const currentName = behaviors.flatCommandDir || LEGACY_NAME;
|
||||
if (currentName === LEGACY_NAME) return; // e.g. Kilo — legacy IS the current location; nothing to migrate
|
||||
const legacyDir = path.join(configDir, LEGACY_NAME);
|
||||
if (!fs.existsSync(legacyDir)) return;
|
||||
if (!installFs().existsSync(legacyDir)) return;
|
||||
// Never follow a symlinked legacy dir out of configDir.
|
||||
if (fs.lstatSync(legacyDir).isSymbolicLink()) return;
|
||||
if (installFs().lstatSync(legacyDir).isSymbolicLink()) return;
|
||||
|
||||
// #2874: installerMigrations.readInstallManifest/classifyArtifact are
|
||||
// routed through the injectable seam (installer-migrations.cts:36,54-58,
|
||||
// 376-380 — readInstallManifest -> readJsonIfPresent -> installFs(),
|
||||
// classifyArtifact -> sha256File -> installFs().readFileSync), so a
|
||||
// fake-adapter install of an opencode-family runtime with a legacy
|
||||
// `command/` dir present reaches the fake, not real fs. Exercised by
|
||||
// tests/executed-plan.test.cjs's F2 "opencode-family legacy command/ dir
|
||||
// migration" case, which poisons every real fs method and asserts the
|
||||
// fake store was mutated.
|
||||
const manifest = installerMigrations.readInstallManifest(configDir);
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(legacyDir, { withFileTypes: true });
|
||||
entries = installFs().readdirSync(legacyDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -1254,14 +1330,14 @@ function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, be
|
||||
const relPath = `${LEGACY_NAME}/${entry.name}`;
|
||||
const { classification } = installerMigrations.classifyArtifact(configDir, relPath, manifest);
|
||||
if (classification === 'managed-pristine' || classification === 'managed-modified') {
|
||||
try { fs.unlinkSync(path.join(legacyDir, entry.name)); } catch { /* best-effort */ }
|
||||
try { installFs().unlinkSync(path.join(legacyDir, entry.name)); } catch { /* best-effort */ }
|
||||
}
|
||||
// 'unknown' (not manifest-tracked) is left untouched — GSD cannot prove
|
||||
// ownership, so it must never be deleted as collateral damage.
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir);
|
||||
if (installFs().readdirSync(legacyDir).length === 0) installFs().rmdirSync(legacyDir);
|
||||
} catch { /* best-effort — a non-empty or otherwise-busy dir is left in place */ }
|
||||
}
|
||||
|
||||
@@ -1287,6 +1363,10 @@ function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, be
|
||||
* installOpencodeFamilySkills so an installed third-party capability skill
|
||||
* materializes for this combined-family (OpenCode/Kilo) install path too.
|
||||
* Absent -> no third-party skills staged (fail closed).
|
||||
* @returns #2874 design row 2: an executed-plan value, same top-level shape
|
||||
* (`runtime`/`scope`/`kinds`/`cleanup`/`postSteps`) as the generic
|
||||
* `installRuntimeArtifacts` branch — this was the one early return a
|
||||
* `void`-shaped hole survived unnoticed in.
|
||||
*/
|
||||
function installOpencodeFamilyArtifacts(
|
||||
runtime: string,
|
||||
@@ -1296,7 +1376,7 @@ function installOpencodeFamilyArtifacts(
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
behaviors: any = {},
|
||||
capabilityRegistry?: any,
|
||||
): void {
|
||||
): any {
|
||||
// #2870: `scope` keeps its exported required `string` signature (no
|
||||
// signature change). It is always the `installRuntimeArtifacts`-forwarded
|
||||
// 'global' | 'local' literal produced by bin/install.js's scope-resolution
|
||||
@@ -1332,9 +1412,25 @@ function installOpencodeFamilyArtifacts(
|
||||
behaviors.flatCommandDir || 'command',
|
||||
);
|
||||
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
||||
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
|
||||
const skillsWritten = installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
|
||||
|
||||
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
||||
|
||||
// #2874 design row 2: report what this combined-family install wrote,
|
||||
// mirroring the generic branch's top-level shape. `cleanup` is `[]` — this
|
||||
// path stages via install-profiles.cts's STAGED_DIRS (process-exit
|
||||
// cleanup), not the per-call cleanupDirs mechanism createRuntimeArtifactInstallPlan
|
||||
// uses, so there is nothing this call itself attempted to clean up.
|
||||
return {
|
||||
runtime,
|
||||
scope,
|
||||
kinds: [
|
||||
{ kind: 'commands', sourceDir: rawCommandsDir, destDir: commandDir },
|
||||
{ kind: 'skills', sourceDir: rawCommandsDir, destDir: configDir, written: skillsWritten },
|
||||
],
|
||||
cleanup: [],
|
||||
postSteps: { hermesBareStemCleanup: false, nativePlugin: Boolean(behaviors.nativePlugin) },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
251
src/install-fs-adapter.cts
Normal file
251
src/install-fs-adapter.cts
Normal file
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Install Fs Adapter — #2874 (epic #2866 Phase 5), governed by ADR-58.
|
||||
*
|
||||
* Narrow, enumerated fs seam for the install/staging call tree rooted at
|
||||
* `installRuntimeArtifacts` (install-engine.cts): layout source-root
|
||||
* resolution (runtime-artifact-layout.cts), profile staging
|
||||
* (install-profiles.cts), content-rewrite passes
|
||||
* (runtime-artifact-conversion.cts), the CommonJS module-type marker
|
||||
* (commonjs-marker.cts), and the two installer-migrations entry points this
|
||||
* call tree reaches — `readInstallManifest` / `classifyArtifact`
|
||||
* (installer-migrations.cts). Extends the `deps` bag precedent already
|
||||
* established by `createRuntimeArtifactInstallPlan`
|
||||
* (runtime-artifact-install-plan.cts:155) — this is NOT a general-purpose
|
||||
* `node:fs` wrapper (40-design.md "Rejected" #1): only the operations this
|
||||
* call tree actually performs are enumerated below. installer-migrations.cts
|
||||
* is ~1200 lines covering migration planning/apply/rollback/locking/journal
|
||||
* machinery unrelated to `installRuntimeArtifacts` — only its two reachable
|
||||
* entry points (and `sha256File`, their shared hashing helper — still raw-fd
|
||||
* streaming via `openSync`/`readSync`/`closeSync`, now routed through this
|
||||
* seam instead of calling `node:fs` directly, so large-file hashing never
|
||||
* buffers a whole file through the injected adapter either) are routed; the
|
||||
* rest of that file is untouched, deliberately, because it is off this call
|
||||
* tree.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* DELIVERY MECHANISM — ambient, not threaded (read this before adding a call
|
||||
* site that needs a different adapter mid-call)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* A single mutable "current adapter" (`current`, below) is swapped for the
|
||||
* duration of one synchronous `installRuntimeArtifacts` call via
|
||||
* `withInstallFs`, rather than a `deps` parameter threaded through every
|
||||
* function on the call tree. The rejected alternative was threading: it
|
||||
* would touch signatures across `install-profiles.cts` (5 staging
|
||||
* functions), the 3000+-line `runtime-artifact-conversion.cts` (rewrite
|
||||
* passes), `commonjs-marker.cts`, and `installer-migrations.cts` — a dozen+
|
||||
* unrelated call sites — for no behavioral gain over an ambient swap, and
|
||||
* `createRuntimeArtifactInstallPlan`'s own `deps` bag (the precedent this
|
||||
* seam extends) does not reach that deep either. Every fs-touching site on
|
||||
* the call tree reads the active adapter via `installFs()` instead of
|
||||
* importing `node:fs` directly.
|
||||
*
|
||||
* SYNCHRONOUS-ONLY / RE-ENTRANCY ASSUMPTION (load-bearing, not incidental):
|
||||
* every method on this seam is `*Sync`, and `installRuntimeArtifacts` never
|
||||
* awaits mid-call — the whole call tree from the top-level `withInstallFs`
|
||||
* wrap down to the last `fs` touch runs on one turn of the event loop with
|
||||
* no interleaving. That is what makes a single ambient variable safe instead
|
||||
* of a race: nothing else can observe or mutate `current` while it is set.
|
||||
* This assumption BREAKS if `installRuntimeArtifacts` (or anything it calls)
|
||||
* ever becomes `async`, or if two installs run concurrently in the same
|
||||
* process (the second `withInstallFs` call would clobber the first's
|
||||
* adapter mid-flight) — neither is true today, but a future change that
|
||||
* introduces either must revisit this module before trusting it.
|
||||
*
|
||||
* DEFERRED CLEANUP ACROSS THE RESTORE (#2874 leak-fix): staged directories
|
||||
* outlive one `withInstallFs` call — `install-profiles.cts`'s
|
||||
* `cleanupStagedSkills` runs later, from a `process.on('exit'/'SIGINT'/…)`
|
||||
* handler, by which time `withInstallFs`'s `finally` has already restored
|
||||
* `current` back to whatever was active before (real fs, in the top-level
|
||||
* case). A cleanup handler that resolved "which adapter do I use" via
|
||||
* `installFs()` at CLEANUP time would therefore always see the real adapter,
|
||||
* even for a directory that was staged entirely inside a fake-adapter call —
|
||||
* performing real filesystem IO on a path that only ever existed in the
|
||||
* fake's in-memory store. `install-profiles.cts` avoids this by capturing
|
||||
* the adapter OBJECT `installFs()` returns at STAGING (registration) time,
|
||||
* keyed by path, in its own `STAGED_DIR_ADAPTERS` map, and replaying that
|
||||
* captured object — not the ambient `current` — at cleanup time. A real
|
||||
* install's dirs were staged with the real adapter object, so their cleanup
|
||||
* is byte-identical to before this fix.
|
||||
*
|
||||
* `withInstallFs` ALWAYS restores the previous adapter in a `finally`, even
|
||||
* on throw — a failed install (or a test that intentionally throws to prove
|
||||
* a guard) must never leak a fake adapter into whatever runs next in the
|
||||
* same process (e.g. the next `node:test` in a shared worker).
|
||||
*
|
||||
* ⚠️ PARTIAL-ADAPTER TRAP: `withInstallFs` merges the injected `partial`
|
||||
* OVER the real adapter (`{ ...REAL_ADAPTER, ...partial }`) — any method the
|
||||
* partial does not define resolves to REAL `node:fs`, silently. An
|
||||
* incomplete fake is not a smaller fake adapter; for the methods it omits,
|
||||
* it IS the real filesystem. A test asserting "no real IO happened" against
|
||||
* a partial fake must either implement every method the exercised code path
|
||||
* touches, or explicitly account for the ones it does not (see
|
||||
* `tests/executed-plan.test.cjs`'s F2 test, which poisons real `fs` methods
|
||||
* for exactly this reason — a gap here shows up as the poisoned method
|
||||
* firing, not as a silent pass).
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* SECURITY NOTE (40-design.md rows 6/7, H1-H5)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* This module carries NO policy. `hasExistingSymlinkBetween` and
|
||||
* `assertDestWithinConfigHome` keep their REFUSAL DECISIONS outside this
|
||||
* seam — only their probe calls (existsSync/lstatSync/realpathSync) are
|
||||
* routed through it. Injecting a fake adapter can only change what those
|
||||
* probes observe for paths that were never real to begin with; it cannot
|
||||
* flip the decision logic itself.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* DELIBERATELY NOT ROUTED (see the call sites themselves for the full
|
||||
* reasoning — this is the index)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* `findInstallSourceRoot` / `findAgentsSourceRoot`'s walk-up-from-__dirname
|
||||
* step (runtime-artifact-layout.cts) locates THIS PACKAGE'S OWN source tree
|
||||
* (`commands/gsd/`, `agents/`) — not the install destination — so it uses
|
||||
* `fs.statSync` directly, unrouted. A fake destination adapter's store
|
||||
* starts empty and is never seeded with real repo paths; routing this walk
|
||||
* through it would make every fake-adapter install throw
|
||||
* "could not locate commands/gsd", not gracefully stage nothing. See the
|
||||
* comment at each function's Step 2 for the full argument.
|
||||
*
|
||||
* RULE (40-design.md "Known limits"): this seam makes *destination* IO
|
||||
* fake-able; package-source IO (this section) stays real by design — the F2
|
||||
* test's poison list should be derived FROM that rule, not the reverse, or a
|
||||
* future "complete the poison list" edit that adds `statSync` will break a
|
||||
* correct `findInstallSourceRoot` for the wrong reason.
|
||||
*/
|
||||
|
||||
import nodeFs from 'node:fs';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Precisely typed (unlike install-engine.cts's own house style) so every
|
||||
// OTHER file this adapter is imported into — install-profiles.cts,
|
||||
// runtime-artifact-layout.cts, retired-artifact-cleanup.cts,
|
||||
// command-roster.cts, commonjs-marker.cts, installer-migrations.cts — none
|
||||
// of which blanket-disable the `no-unsafe-*` rules — keeps its existing
|
||||
// strict typing at each call site instead of degrading to `any` through
|
||||
// this seam.
|
||||
interface InstallFsAdapter {
|
||||
existsSync(p: string): boolean;
|
||||
mkdirSync(p: string, opts?: { recursive?: boolean }): string | undefined;
|
||||
rmSync(p: string, opts?: { recursive?: boolean; force?: boolean }): void;
|
||||
readdirSync(p: string, opts: { withFileTypes: true }): Dirent[];
|
||||
readdirSync(p: string): string[];
|
||||
readFileSync(p: string, encoding: BufferEncoding): string;
|
||||
readFileSync(p: string): Buffer;
|
||||
writeFileSync(p: string, data: string | Buffer, opts?: BufferEncoding | { encoding?: BufferEncoding; flag?: string }): void;
|
||||
copyFileSync(src: string, dest: string): void;
|
||||
cpSync(src: string, dest: string, opts?: { recursive?: boolean }): void;
|
||||
lstatSync(p: string): { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean };
|
||||
/** Optional — a partial injected adapter that omits this falls back to the
|
||||
* real fs.realpathSync (merged in by `withInstallFs`). The symlink guard
|
||||
* already treats a realpathSync FAILURE as "fall back to the lexical
|
||||
* form" (see its own doc comment); an absent method degrades the same
|
||||
* way — never a hard failure. */
|
||||
realpathSync(p: string): string;
|
||||
unlinkSync(p: string): void;
|
||||
rmdirSync(p: string): void;
|
||||
/** Raw-fd streaming trio, added so `installer-migrations.cts`'s
|
||||
* `sha256File` can hash a file in fixed-size chunks through this seam
|
||||
* instead of buffering the whole file via `readFileSync` — see the
|
||||
* module doc's opening paragraph and `tests/installer-migrations.test.cjs`'s
|
||||
* "classifies large files without loading the whole file through
|
||||
* readFileSync", which pins this contract directly: it monkeypatches real
|
||||
* `fs.readFileSync` to throw for the file under test and asserts hashing
|
||||
* still succeeds, proving the hash path never calls it. */
|
||||
openSync(p: string, flags: string): number;
|
||||
readSync(fd: number, buffer: Buffer, offset: number, length: number, position: number | null): number;
|
||||
closeSync(fd: number): void;
|
||||
}
|
||||
|
||||
const REAL_ADAPTER: InstallFsAdapter = {
|
||||
existsSync: (p) => nodeFs.existsSync(p),
|
||||
mkdirSync: (p, opts) => nodeFs.mkdirSync(p, opts),
|
||||
rmSync: (p, opts) => nodeFs.rmSync(p, opts),
|
||||
readdirSync: ((p: string, opts?: { withFileTypes: true }) =>
|
||||
(opts ? nodeFs.readdirSync(p, opts) : nodeFs.readdirSync(p))) as InstallFsAdapter['readdirSync'],
|
||||
readFileSync: ((p: string, encoding?: BufferEncoding) =>
|
||||
(encoding ? nodeFs.readFileSync(p, encoding) : nodeFs.readFileSync(p))) as InstallFsAdapter['readFileSync'],
|
||||
writeFileSync: (p, data, opts) => nodeFs.writeFileSync(p, data, opts),
|
||||
copyFileSync: (src, dest) => nodeFs.copyFileSync(src, dest),
|
||||
cpSync: (src, dest, opts) => nodeFs.cpSync(src, dest, opts),
|
||||
lstatSync: (p) => nodeFs.lstatSync(p),
|
||||
realpathSync: (p) => nodeFs.realpathSync(p),
|
||||
unlinkSync: (p) => nodeFs.unlinkSync(p),
|
||||
rmdirSync: (p) => nodeFs.rmdirSync(p),
|
||||
openSync: (p, flags) => nodeFs.openSync(p, flags),
|
||||
readSync: (fd, buffer, offset, length, position) => nodeFs.readSync(fd, buffer, offset, length, position),
|
||||
closeSync: (fd) => nodeFs.closeSync(fd),
|
||||
};
|
||||
|
||||
let current: InstallFsAdapter = REAL_ADAPTER;
|
||||
|
||||
/**
|
||||
* Returns the fs adapter active for the currently-running install call —
|
||||
* the real adapter when no `deps.fs` was injected, or the injected partial
|
||||
* adapter merged over the real one (any method it did not override still
|
||||
* resolves to real fs — see the module doc's "PARTIAL-ADAPTER TRAP").
|
||||
*/
|
||||
function installFs(): InstallFsAdapter {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with `partial` merged over the real adapter as the active
|
||||
* install-fs adapter, restoring the previous adapter afterward — even on
|
||||
* throw (see the module doc's re-entrancy/synchronous-only assumption for
|
||||
* why a bare module-level variable is safe here, and why it would not be
|
||||
* under async interleaving or concurrent installs). `partial` undefined is
|
||||
* a no-op: `fn` runs against whatever adapter was already active (real fs
|
||||
* by default) — this is what keeps every existing `deps`-less call site
|
||||
* (AC4) byte-identical.
|
||||
*/
|
||||
function withInstallFs<T>(partial: Partial<InstallFsAdapter> | undefined, fn: () => T): T {
|
||||
if (!partial) return fn();
|
||||
const previous = current;
|
||||
current = { ...REAL_ADAPTER, ...partial };
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh, uniquely-named temp directory.
|
||||
*
|
||||
* AC4 requires the no-adapter-injected path to stay byte-identical to the
|
||||
* pre-#2874 code, which called real `fs.mkdtempSync` directly — several
|
||||
* existing tests (e.g. tests/install-runtime-artifacts.test.cjs's "rmSync is
|
||||
* called on the tempDir when readFileSync throws") monkeypatch real
|
||||
* `fs.mkdtempSync` to capture the exact directory a call under test creates,
|
||||
* and stop working if that real syscall is no longer made. So: when NO fake
|
||||
* adapter is active (`current === REAL_ADAPTER`, the exact top-level-install
|
||||
* default), this calls `nodeFs.mkdtempSync` directly — the same real call
|
||||
* the old code made, still visible to a monkeypatch applied after import
|
||||
* because it is a live property lookup on the `node:fs` module object, not a
|
||||
* captured reference.
|
||||
*
|
||||
* When a fake adapter IS active (any `withInstallFs(partial, …)` call, even
|
||||
* a partial one — see `current`'s assignment in `withInstallFs`, which
|
||||
* always produces a NEW merged object, never `=== REAL_ADAPTER`), this falls
|
||||
* back to synthesizing a unique name and creating it via
|
||||
* `installFs().mkdirSync`, exactly as before: the injected adapter contract
|
||||
* still does not require `mkdtempSync`, so a fake (which only needs to
|
||||
* implement `mkdirSync`) can drive the full staging pipeline without ever
|
||||
* touching real fs.
|
||||
*/
|
||||
function mkInstallTempDir(prefix: string): string {
|
||||
if (current === REAL_ADAPTER) {
|
||||
return nodeFs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
const dir = path.join(os.tmpdir(), `${prefix}${crypto.randomBytes(8).toString('hex')}`);
|
||||
installFs().mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export = { installFs, withInstallFs, mkInstallTempDir };
|
||||
@@ -10,6 +10,19 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
// #2874 (ADR-58 cleanup phase): route the staging functions' fs calls
|
||||
// through the installRuntimeArtifacts call tree's injectable seam — see
|
||||
// install-fs-adapter.cts's module doc. Resolves to real `node:fs` unless the
|
||||
// top-level installRuntimeArtifacts call injected a `deps.fs`. Only the
|
||||
// staging functions reachable FROM that call tree are routed
|
||||
// (stageSkillsForProfile / stageAgentsForProfile /
|
||||
// stageAgentsForRuntimeWithConverter / stageSkillsForRuntimeAsSkills /
|
||||
// stageCommandsForRuntimeFlat / buildNamespaceBundleMap) — the profile-marker
|
||||
// and manifest-loading helpers below are not on that call tree and keep
|
||||
// using real `fs` directly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installFsAdapter = require('./install-fs-adapter.cjs');
|
||||
const { installFs, mkInstallTempDir } = installFsAdapter;
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
// #2322: reuse the existing pure path-containment seam (ADR-1239 Phase C-2)
|
||||
// instead of hand-rolling a new traversal check for capability skill stems.
|
||||
@@ -301,16 +314,49 @@ function resolveProfile({ modes, manifest, _profilesOverride, registry }: Resolv
|
||||
const STAGED_DIRS = new Set<string>();
|
||||
let exitHandlerRegistered = false;
|
||||
|
||||
// #2874 leak-fix: `cleanupStagedSkills` runs at process exit — AFTER
|
||||
// `withInstallFs` has already restored `current` back to the real adapter
|
||||
// (install-fs-adapter.cts's module doc, "SYNCHRONOUS-ONLY / RE-ENTRANCY
|
||||
// ASSUMPTION" — extended there to reference this map). A dir staged during a
|
||||
// fake-adapter call must be cleaned up with THAT SAME fake adapter, not with
|
||||
// whatever is ambiently active later, or an exit handler would perform real
|
||||
// filesystem IO on a path that only ever existed in the fake's in-memory
|
||||
// store. Capturing the adapter OBJECT `installFs()` returns at registration
|
||||
// time (not the ambient `current` variable, which changes) means cleanup
|
||||
// always replays the exact adapter that created the path. Dirs added to
|
||||
// STAGED_DIRS without going through `registerStagedDir` (the deprecated
|
||||
// `stageSkillsForMode`, which creates its stage dir via raw `fs.mkdtempSync`
|
||||
// and was never on the injectable seam) have no entry here and fall back to
|
||||
// real `fs.rmSync` below — the same real fs it always used to create them.
|
||||
const STAGED_DIR_ADAPTERS = new Map<string, ReturnType<typeof installFs>>();
|
||||
|
||||
/**
|
||||
* Register a dir just staged through the injectable seam (`installFs()`) for
|
||||
* exit-time cleanup, capturing the adapter that staged it alongside the path.
|
||||
* See `STAGED_DIR_ADAPTERS`'s comment for why the capture matters.
|
||||
*/
|
||||
function registerStagedDir(dir: string): void {
|
||||
STAGED_DIRS.add(dir);
|
||||
STAGED_DIR_ADAPTERS.set(dir, installFs());
|
||||
ensureExitCleanup();
|
||||
}
|
||||
|
||||
function cleanupStagedSkills(): void {
|
||||
for (const dir of STAGED_DIRS) {
|
||||
const adapter = STAGED_DIR_ADAPTERS.get(dir);
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (adapter) {
|
||||
adapter.rmSync(dir, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: missing dir or permission error shouldn't crash a
|
||||
// successful install. The OS reaps tmpdir eventually.
|
||||
}
|
||||
}
|
||||
STAGED_DIRS.clear();
|
||||
STAGED_DIR_ADAPTERS.clear();
|
||||
}
|
||||
|
||||
// Signals we register a cleanup handler for in addition to the natural
|
||||
@@ -340,27 +386,26 @@ function ensureExitCleanup(): void {
|
||||
*/
|
||||
function stageSkillsForProfile(srcDir: string, resolvedProfile: ResolvedProfile): string {
|
||||
if (resolvedProfile.skills === '*') return srcDir;
|
||||
if (!fs.existsSync(srcDir)) return srcDir;
|
||||
if (!installFs().existsSync(srcDir)) return srcDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-skills-'));
|
||||
const stageDir = mkInstallTempDir('gsd-profile-skills-');
|
||||
try {
|
||||
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||||
const entries = installFs().readdirSync(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (!(resolvedProfile.skills).has(stem)) continue;
|
||||
fs.copyFileSync(
|
||||
installFs().copyFileSync(
|
||||
path.join(srcDir, entry.name),
|
||||
path.join(stageDir, entry.name),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { installFs().rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
registerStagedDir(stageDir);
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
@@ -386,19 +431,19 @@ function stageSkillsForProfile(srcDir: string, resolvedProfile: ResolvedProfile)
|
||||
*/
|
||||
function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedProfile): string {
|
||||
if (resolvedProfile.skills === '*') return srcAgentsDir;
|
||||
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
if (!installFs().existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-agents-'));
|
||||
const stageDir = mkInstallTempDir('gsd-profile-agents-');
|
||||
try {
|
||||
if (resolvedProfile.agents instanceof Set && resolvedProfile.agents.size > 0) {
|
||||
const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true });
|
||||
const entries = installFs().readdirSync(srcAgentsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
// Agent stem is the full filename without extension, e.g. "gsd-planner"
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (!resolvedProfile.agents.has(stem)) continue;
|
||||
fs.copyFileSync(
|
||||
installFs().copyFileSync(
|
||||
path.join(srcAgentsDir, entry.name),
|
||||
path.join(stageDir, entry.name),
|
||||
);
|
||||
@@ -406,11 +451,10 @@ function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedPr
|
||||
}
|
||||
// If agents is empty Set, we produce an empty stageDir (no agents for this profile)
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { installFs().rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
registerStagedDir(stageDir);
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
@@ -434,16 +478,16 @@ function buildNamespaceBundleMap(srcCommandsDir: string): NamespaceBundleMap {
|
||||
const routerStems = new Set<string>();
|
||||
const routerChildren = new Map<string, string[]>();
|
||||
const childToRouters = new Map<string, string[]>();
|
||||
if (!fs.existsSync(srcCommandsDir)) {
|
||||
if (!installFs().existsSync(srcCommandsDir)) {
|
||||
return { routerStems, routerChildren, childToRouters };
|
||||
}
|
||||
for (const entry of fs.readdirSync(srcCommandsDir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(srcCommandsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
if (!entry.name.startsWith('ns-')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
let children: string[] = [];
|
||||
try {
|
||||
children = parseRequires(fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'));
|
||||
children = parseRequires(installFs().readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'));
|
||||
} catch { children = []; }
|
||||
routerStems.add(stem);
|
||||
routerChildren.set(stem, children);
|
||||
@@ -665,7 +709,7 @@ function stageSkillsForRuntimeAsSkills(
|
||||
nested = false,
|
||||
registry?: CapabilityRegistry,
|
||||
): string {
|
||||
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
if (!installFs().existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
|
||||
// Nesting applies to the `full` install AND to any surface whose skill set
|
||||
// still contains every namespace router (a full/reset surface). It must NOT
|
||||
@@ -689,16 +733,16 @@ function stageSkillsForRuntimeAsSkills(
|
||||
// ALWAYS wins on collision" without re-deriving membership.
|
||||
const firstPartyStems = new Set<string>();
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-'));
|
||||
const stageDir = mkInstallTempDir('gsd-profile-runtime-skills-');
|
||||
try {
|
||||
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
const entries = installFs().readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue;
|
||||
firstPartyStems.add(stem);
|
||||
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
const content = installFs().readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
const skillName = `${prefix}${stem}`;
|
||||
const converted = converter(content, skillName);
|
||||
|
||||
@@ -706,8 +750,8 @@ function stageSkillsForRuntimeAsSkills(
|
||||
// Router skill: rewrite its routing table to the nested Read pattern and
|
||||
// emit it as the single top-level bundle entry.
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), transformRouterBodyToNested(converted));
|
||||
installFs().mkdirSync(destDir, { recursive: true });
|
||||
installFs().writeFileSync(path.join(destDir, 'SKILL.md'), transformRouterBodyToNested(converted));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -717,8 +761,8 @@ function stageSkillsForRuntimeAsSkills(
|
||||
// top-level eager listing while staying readable by file path (#69).
|
||||
for (const routerStem of bundles!.childToRouters.get(stem)!) {
|
||||
const destDir = path.join(stageDir, `${prefix}${routerStem}`, 'skills', stem);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
installFs().mkdirSync(destDir, { recursive: true });
|
||||
installFs().writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -726,8 +770,8 @@ function stageSkillsForRuntimeAsSkills(
|
||||
// Flat top-level skill (default behaviour; also the unrouted fallback when
|
||||
// nesting is active).
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
installFs().mkdirSync(destDir, { recursive: true });
|
||||
installFs().writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
}
|
||||
|
||||
// #2322: materialize installed THIRD-PARTY capability skills, bound to
|
||||
@@ -768,21 +812,20 @@ function stageSkillsForRuntimeAsSkills(
|
||||
const skillName = `${prefix}${stem}`;
|
||||
if (!isPathConfined(skillName, stageDir)) continue; // defense-in-depth
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), found.content);
|
||||
installFs().mkdirSync(destDir, { recursive: true });
|
||||
installFs().writeFileSync(path.join(destDir, 'SKILL.md'), found.content);
|
||||
// #2322 HIGH-3: persist the capability-owned marker so a later prune
|
||||
// pass (surface.cts pruneSkillDirs) can identify — and remove — this
|
||||
// directory even once the owning capability is uninstalled/unsurfaced
|
||||
// and no longer appears in any registry view.
|
||||
fs.writeFileSync(path.join(destDir, CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
||||
installFs().writeFileSync(path.join(destDir, CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { installFs().rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
registerStagedDir(stageDir);
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
@@ -840,11 +883,11 @@ function stageAgentsForRuntimeWithConverter(
|
||||
isGlobal = false,
|
||||
agentCtx?: AgentCtx,
|
||||
): string {
|
||||
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
if (!installFs().existsSync(srcAgentsDir)) return srcAgentsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-agents-'));
|
||||
const stageDir = mkInstallTempDir('gsd-profile-runtime-agents-');
|
||||
try {
|
||||
const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true });
|
||||
const entries = installFs().readdirSync(srcAgentsDir, { withFileTypes: true });
|
||||
// Resolve cmdNames once per staging call (not per file) for performance.
|
||||
const cmdNames = agentCtx ? _readGsdCommandNames() : [];
|
||||
for (const entry of entries) {
|
||||
@@ -858,7 +901,7 @@ function stageAgentsForRuntimeWithConverter(
|
||||
}
|
||||
}
|
||||
const agentSourcePath = path.join(srcAgentsDir, entry.name);
|
||||
let content = fs.readFileSync(agentSourcePath, 'utf8');
|
||||
let content = installFs().readFileSync(agentSourcePath, 'utf8');
|
||||
// #2995: strip gsd:section markers FIRST — before path rewrites, attribution,
|
||||
// and the per-runtime converter. Byte-identical (no-op) for an unmarked agent;
|
||||
// throws loudly naming the file for a malformed marker, never emitting a
|
||||
@@ -878,14 +921,13 @@ function stageAgentsForRuntimeWithConverter(
|
||||
// Backward-compat: only apply the converter (no cross-cutting)
|
||||
content = converter(content, isGlobal);
|
||||
}
|
||||
fs.writeFileSync(path.join(stageDir, entry.name), content, 'utf8');
|
||||
installFs().writeFileSync(path.join(stageDir, entry.name), content, 'utf8');
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { installFs().rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
registerStagedDir(stageDir);
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
@@ -918,30 +960,29 @@ function stageCommandsForRuntimeFlat(
|
||||
converter: (content: string, commandName: string) => string,
|
||||
prefix: string,
|
||||
): string {
|
||||
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
if (!installFs().existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-commands-'));
|
||||
const stageDir = mkInstallTempDir('gsd-profile-runtime-commands-');
|
||||
try {
|
||||
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
const entries = installFs().readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue;
|
||||
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
const content = installFs().readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
// Pass the full command name (with prefix) to the converter so it can
|
||||
// reference the installed command name in the body (e.g. for descriptions).
|
||||
// The staged file itself is named without the prefix; _copyStaged adds it.
|
||||
const commandName = `${prefix}${stem}`;
|
||||
const converted = converter(content, commandName);
|
||||
fs.writeFileSync(path.join(stageDir, `${stem}.md`), converted);
|
||||
installFs().writeFileSync(path.join(stageDir, `${stem}.md`), converted);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { installFs().rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
registerStagedDir(stageDir);
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,21 @@ import {
|
||||
import { platformWriteSync, retryRenameSync, posixNormalize } from './shell-command-projection.cjs';
|
||||
import { realClock, type Clock } from './clock.cjs';
|
||||
import { isInstallScopeId, type InstallScope } from './install-scope.cjs';
|
||||
// #2874 (ADR-58 cleanup phase): this file is the ~1200-line migration
|
||||
// plan/apply/rollback/lock/journal engine — almost none of it is on the
|
||||
// installRuntimeArtifacts call tree. Only `readInstallManifest` and
|
||||
// `classifyArtifact` are reached (via install-engine.cts's
|
||||
// _migrateLegacyOpencodeCommandDir and retired-artifact-cleanup.cts's
|
||||
// pruneRetiredRuntimeArtifacts), so only those two entry points — plus their
|
||||
// shared `readJsonIfPresent` helper and `classifyArtifact`'s `sha256File`
|
||||
// hashing helper — are routed through the injectable seam. Everything else
|
||||
// in this file (locking, journal, apply/rollback, migration discovery)
|
||||
// keeps using real `fs` directly: it is not reachable from
|
||||
// installRuntimeArtifacts, so routing it would grow this seam past what
|
||||
// AC2 actually requires. See install-fs-adapter.cts's module doc.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installFsAdapter = require('./install-fs-adapter.cjs');
|
||||
const { installFs } = installFsAdapter;
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const INSTALL_STATE_NAME = 'gsd-install-state.json';
|
||||
@@ -27,18 +42,31 @@ const DEFAULT_MIGRATIONS_DIR = path.join(__dirname, 'installer-migrations');
|
||||
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
||||
const STRICT_JSON = Symbol('strict-json');
|
||||
|
||||
// #2874: routed through installFs()'s openSync/readSync/closeSync trio
|
||||
// instead of importing `node:fs` directly, so classifyArtifact — reachable
|
||||
// from installRuntimeArtifacts — can be exercised against an injected
|
||||
// adapter. This function was briefly converted to a single
|
||||
// `installFs().readFileSync` call (buffering the whole file); that broke
|
||||
// tests/installer-migrations.test.cjs's "classifies large files without
|
||||
// loading the whole file through readFileSync", which monkeypatches real
|
||||
// fs.readFileSync to throw for the file under test and asserts hashing still
|
||||
// succeeds — an explicit, pre-existing contract that large files must be
|
||||
// streamed, not buffered. Restored to the original raw-fd streaming shape,
|
||||
// now going through the adapter instead of `node:fs` directly. This is the
|
||||
// ONLY call site of sha256File in this file (confirmed by inspection) — no
|
||||
// other caller is affected.
|
||||
function sha256File(filePath: string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
const fd = installFs().openSync(filePath, 'r');
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
const bytesRead = installFs().readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
installFs().closeSync(fd);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
@@ -148,10 +176,14 @@ function copyPreservingSymlink(srcPath: string, destPath: string): void {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
|
||||
// Shared by readInstallManifest (on the installRuntimeArtifacts call tree —
|
||||
// routed) and readInstallState/readJson (not on that call tree — the
|
||||
// ambient default resolves to real fs for those, unchanged). Routing once
|
||||
// here is safe for all three callers.
|
||||
function readJsonIfPresent(filePath: string, fallback: unknown): unknown {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
if (!installFs().existsSync(filePath)) return fallback;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
return JSON.parse(installFs().readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (fallback === STRICT_JSON) {
|
||||
throw new Error(`invalid installer migration state JSON: ${filePath}: ${(error as Error).message}`);
|
||||
@@ -358,7 +390,7 @@ function classifyArtifact(configDir: string, relPath: string, manifest: InstallM
|
||||
const normalized = normalizeRelPath(relPath);
|
||||
const originalHash = manifest.files[normalized] || null;
|
||||
const fullPath = path.join(configDir, normalized);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
if (!installFs().existsSync(fullPath)) {
|
||||
return { classification: originalHash ? 'managed-missing' : 'missing', originalHash, currentHash: null };
|
||||
}
|
||||
const currentHash = sha256File(fullPath);
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// #2874 (ADR-58 cleanup phase): pruneRetiredRuntimeArtifacts is the first
|
||||
// thing installRuntimeArtifacts calls, so its fs calls are routed through
|
||||
// the injectable seam too — see install-fs-adapter.cts's module doc.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installFsAdapter = require('./install-fs-adapter.cjs');
|
||||
const { installFs } = installFsAdapter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installerMigrations = require('./installer-migrations.cjs');
|
||||
import { isPathConfined } from './external-descriptor-trust.cjs';
|
||||
@@ -68,8 +74,8 @@ function pruneRetiredRuntimeArtifacts(runtime: string, configDir: string): Clean
|
||||
const destDir = path.resolve(configDir, destSubpath);
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
if (!fs.existsSync(destDir) || fs.lstatSync(destDir).isSymbolicLink()) continue;
|
||||
entries = fs.readdirSync(destDir, { withFileTypes: true });
|
||||
if (!installFs().existsSync(destDir) || installFs().lstatSync(destDir).isSymbolicLink()) continue;
|
||||
entries = installFs().readdirSync(destDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -83,7 +89,7 @@ function pruneRetiredRuntimeArtifacts(runtime: string, configDir: string): Clean
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(path.join(destDir, entry.name));
|
||||
installFs().unlinkSync(path.join(destDir, entry.name));
|
||||
result.removed.push(relPath);
|
||||
} catch {
|
||||
result.preserved.push(relPath);
|
||||
@@ -91,7 +97,7 @@ function pruneRetiredRuntimeArtifacts(runtime: string, configDir: string): Clean
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.readdirSync(destDir).length === 0) fs.rmdirSync(destDir);
|
||||
if (installFs().readdirSync(destDir).length === 0) installFs().rmdirSync(destDir);
|
||||
} catch {
|
||||
// Non-empty, unreadable, or concurrently changed: preserve the directory.
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
// #2874 (ADR-58 cleanup phase): route this module's content-rewrite-pass fs
|
||||
// calls through the installRuntimeArtifacts call tree's injectable seam —
|
||||
// see install-fs-adapter.cts's module doc. Resolves to real `node:fs` unless
|
||||
// the top-level installRuntimeArtifacts call injected a `deps.fs`. These
|
||||
// walkers operate on already-staged temp directories (never the real GSD
|
||||
// source tree or the real install destination directly), so routing them is
|
||||
// unconditionally safe.
|
||||
import installFsAdapter = require('./install-fs-adapter.cjs');
|
||||
const { installFs, mkInstallTempDir } = installFsAdapter;
|
||||
import commandRoster = require('./command-roster.cjs');
|
||||
const { readGsdCommandNames, transformContentToHyphen } = commandRoster;
|
||||
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
|
||||
@@ -3088,17 +3097,17 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a
|
||||
* @param attribution Co-Authored-By value (string | null | undefined)
|
||||
*/
|
||||
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false, attribution = undefined) {
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
if (!installFs().existsSync(stagedDir)) return;
|
||||
|
||||
const walkAndRewrite = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkAndRewrite(fullPath);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
let content = fs.readFileSync(fullPath, 'utf8');
|
||||
let content = installFs().readFileSync(fullPath, 'utf8');
|
||||
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, attribution);
|
||||
fs.writeFileSync(fullPath, content);
|
||||
installFs().writeFileSync(fullPath, content);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3124,13 +3133,13 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGl
|
||||
* @returns {string} path to the temp dir (caller is responsible for cleanup)
|
||||
*/
|
||||
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false, attribution = undefined) {
|
||||
if (!fs.existsSync(stagedDir)) return stagedDir;
|
||||
if (!installFs().existsSync(stagedDir)) return stagedDir;
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cmd-rewrites-'));
|
||||
const tempDir = mkInstallTempDir('gsd-cmd-rewrites-');
|
||||
try {
|
||||
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(stagedDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
|
||||
let content = installFs().readFileSync(path.join(stagedDir, entry.name), 'utf8');
|
||||
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, attribution);
|
||||
// #2097 (ADR-1239): descriptor-driven — commandBodyConverter name comes
|
||||
// from runtime.hostBehaviors instead of a hardcoded runtime-name branch.
|
||||
@@ -3138,10 +3147,10 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
|
||||
if (_cmdConv && COMMAND_BODY_CONVERTERS[_cmdConv]) {
|
||||
content = COMMAND_BODY_CONVERTERS[_cmdConv](content);
|
||||
}
|
||||
fs.writeFileSync(path.join(tempDir, entry.name), content);
|
||||
installFs().writeFileSync(path.join(tempDir, entry.name), content);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { installFs().rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
return tempDir;
|
||||
@@ -3163,16 +3172,16 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
|
||||
* pass above intact via its own `@`-guarded restore).
|
||||
*/
|
||||
function applySpecRootReferenceToStagedSkills(stagedDir) {
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
if (!installFs().existsSync(stagedDir)) return;
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
for (const entry of installFs().readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (entry.name === 'SKILL.md') {
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const content = installFs().readFileSync(fullPath, 'utf8');
|
||||
const rewritten = resolveSpecRootReference(content);
|
||||
if (rewritten !== content) fs.writeFileSync(fullPath, rewritten);
|
||||
if (rewritten !== content) installFs().writeFileSync(fullPath, rewritten);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3202,7 +3211,7 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
|
||||
platform = process.platform,
|
||||
resolveAttribution,
|
||||
} = opts;
|
||||
if (!fs.existsSync(stagedDir)) return;
|
||||
if (!installFs().existsSync(stagedDir)) return;
|
||||
|
||||
const resolvedTarget = posixNormalize(path.resolve(configDir));
|
||||
const homeDir = posixNormalize(homedir());
|
||||
@@ -3254,7 +3263,7 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
|
||||
platform = process.platform,
|
||||
resolveAttribution,
|
||||
} = opts;
|
||||
if (!fs.existsSync(stagedDir)) return stagedDir;
|
||||
if (!installFs().existsSync(stagedDir)) return stagedDir;
|
||||
|
||||
const resolvedTarget = posixNormalize(path.resolve(configDir));
|
||||
const homeDir = posixNormalize(homedir());
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
// #2874 (ADR-58 cleanup phase): route this module's fs calls through the
|
||||
// installRuntimeArtifacts call tree's injectable seam — see
|
||||
// install-fs-adapter.cts's module doc. Resolves to real `node:fs` (the
|
||||
// `fs` import above stays for type-only references, e.g. `fs.Dirent`)
|
||||
// unless the top-level installRuntimeArtifacts call injected a `deps.fs`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installFsAdapter = require('./install-fs-adapter.cjs');
|
||||
const { installFs, mkInstallTempDir } = installFsAdapter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfiles = require('./install-profiles.cjs');
|
||||
const {
|
||||
@@ -124,22 +132,49 @@ interface Layout {
|
||||
* 3. Throw a descriptive error if neither succeeds.
|
||||
*/
|
||||
function findInstallSourceRoot(runtimeConfigDir?: string): string {
|
||||
// Step 1: marker check
|
||||
// Step 1: marker check — reads `<runtimeConfigDir>/.gsd-source`, a path
|
||||
// under the INSTALL DESTINATION, so this probe goes through the injected
|
||||
// adapter (installFs()).
|
||||
if (runtimeConfigDir) {
|
||||
const markerPath = path.join(runtimeConfigDir, '.gsd-source');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
if (installFs().existsSync(markerPath)) {
|
||||
try {
|
||||
const src = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
if (src && fs.existsSync(src)) return src;
|
||||
const src = installFs().readFileSync(markerPath, 'utf8').trim();
|
||||
if (src && installFs().existsSync(src)) return src;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: walk up from __dirname
|
||||
// Step 2: walk up from __dirname to locate the GSD PACKAGE'S OWN source
|
||||
// tree (commands/gsd/) — this resolves where the installer's own code is
|
||||
// running FROM, not anything under the install destination, so it is
|
||||
// deliberately NOT routed through the injected fs adapter (#2874): a fake
|
||||
// "destination" adapter has no reason to know about the real package's own
|
||||
// on-disk layout (an injected adapter's store starts empty and is never
|
||||
// seeded with real repo paths), and routing it through would make this
|
||||
// resolution unconditionally throw rather than gracefully staging nothing.
|
||||
//
|
||||
// Uses `fs.statSync` in a try/catch rather than `fs.existsSync` — this is
|
||||
// LOAD-BEARING, not a style choice: tests/executed-plan.test.cjs's F2 cases
|
||||
// poison every method on the ROUTED fs surface (including `existsSync`,
|
||||
// since installFs()'s REAL_ADAPTER also calls it) to prove nothing on the
|
||||
// installRuntimeArtifacts call tree reaches real fs. The F2 "nativePlugin
|
||||
// runtime: pi" test calls this function (via findInstallSourceRoot()) AFTER
|
||||
// installing that poison, specifically to resolve the pi nativePlugin
|
||||
// source path against this repo's own real layout — an operation this
|
||||
// function must still be able to perform even while `existsSync` is
|
||||
// poisoned, because this Step 2 walk is real-fs-only by design and was
|
||||
// never meant to be covered by that poison list. `statSync` is not on the
|
||||
// poisoned surface, so this probe survives; switching back to `existsSync`
|
||||
// makes that F2 test throw (verified: reverting this to `existsSync` trips
|
||||
// the poison and breaks the pi nativePlugin case).
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = path.join(dir, 'commands', 'gsd');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
try {
|
||||
fs.statSync(candidate);
|
||||
return candidate;
|
||||
} catch { /* not here — keep walking up */ }
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
@@ -157,26 +192,33 @@ function findInstallSourceRoot(runtimeConfigDir?: string): string {
|
||||
* 3. Throw a descriptive error if neither succeeds.
|
||||
*/
|
||||
function findAgentsSourceRoot(runtimeConfigDir?: string): string {
|
||||
// Step 1: marker check
|
||||
// Step 1: marker check (destination-relative — routed through installFs()).
|
||||
if (runtimeConfigDir) {
|
||||
const markerPath = path.join(runtimeConfigDir, '.gsd-source');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
if (installFs().existsSync(markerPath)) {
|
||||
try {
|
||||
const src = fs.readFileSync(markerPath, 'utf8').trim();
|
||||
if (src && fs.existsSync(src)) {
|
||||
const src = installFs().readFileSync(markerPath, 'utf8').trim();
|
||||
if (src && installFs().existsSync(src)) {
|
||||
// Marker points to commands/gsd; agents/ is a sibling of commands/
|
||||
const agentsCandidate = path.resolve(path.dirname(src), '..', 'agents');
|
||||
if (fs.existsSync(agentsCandidate)) return agentsCandidate;
|
||||
if (installFs().existsSync(agentsCandidate)) return agentsCandidate;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: walk up from __dirname
|
||||
// Step 2: walk up from __dirname — locates THIS package's own agents/
|
||||
// source tree, not the install destination. See findInstallSourceRoot's
|
||||
// Step 2 comment (#2874) for why this stays unrouted, real-fs-only, and why
|
||||
// it uses `statSync` rather than `existsSync` (load-bearing against F2's
|
||||
// poison of the routed fs surface, not a style choice).
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = path.join(dir, 'agents');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
try {
|
||||
fs.statSync(candidate);
|
||||
return candidate;
|
||||
} catch { /* not here — keep walking up */ }
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
@@ -318,28 +360,28 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
|
||||
(content: string) => content,
|
||||
);
|
||||
const subagents: Array<{ path: string; content: string }> = [];
|
||||
if (fs.existsSync(stagedAgents)) {
|
||||
for (const entry of fs.readdirSync(stagedAgents, { withFileTypes: true })) {
|
||||
if (installFs().existsSync(stagedAgents)) {
|
||||
for (const entry of installFs().readdirSync(stagedAgents, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const agentPath = path.join(stagedAgents, entry.name);
|
||||
subagents.push({
|
||||
path: posixNormalize(path.join('agents', entry.name)),
|
||||
content: fs.readFileSync(agentPath, 'utf8'),
|
||||
content: installFs().readFileSync(agentPath, 'utf8'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rootAgent = `---\nname: gsd\ndescription: Run GSD workflows in Kimi CLI.\ntools: Agent\n---\n\n# GSD for Kimi CLI\n\nCoordinate installed /skill:gsd-* workflows and route work to generated GSD subagents when a workflow requires an agent handoff.\n`;
|
||||
const artifacts = buildKimiAgentArtifacts({ rootAgent, subagents });
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agents-'));
|
||||
const stageDir = mkInstallTempDir('gsd-kimi-agents-');
|
||||
installProfiles.STAGED_DIRS.add(stageDir);
|
||||
fs.writeFileSync(path.join(stageDir, 'gsd.yaml'), artifacts.root.yaml);
|
||||
fs.writeFileSync(path.join(stageDir, 'gsd.md'), artifacts.root.prompt);
|
||||
installFs().writeFileSync(path.join(stageDir, 'gsd.yaml'), artifacts.root.yaml);
|
||||
installFs().writeFileSync(path.join(stageDir, 'gsd.md'), artifacts.root.prompt);
|
||||
const subagentsDir = path.join(stageDir, 'subagents');
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
installFs().mkdirSync(subagentsDir, { recursive: true });
|
||||
for (const artifact of artifacts.subagents) {
|
||||
fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.yaml`), artifact.yaml);
|
||||
fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.md`), artifact.prompt);
|
||||
installFs().writeFileSync(path.join(subagentsDir, `${artifact.name}.yaml`), artifact.yaml);
|
||||
installFs().writeFileSync(path.join(subagentsDir, `${artifact.name}.md`), artifact.prompt);
|
||||
}
|
||||
return stageDir;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user