@sabaiway/agent-workflow-kit 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ # Setting up OpenAI Codex CLI (`codex`) on a clean machine
2
+
3
+ This setup is **secret-free**. `codex` itself is **not** bundled — it requires a binary install and a
4
+ one-time interactive sign-in with your own ChatGPT subscription. Do this once per machine, then the
5
+ skill works in any git repository that has a root `AGENTS.md`.
6
+
7
+ ## 1. Install the binary
8
+
9
+ Install the official OpenAI Codex CLI using the current official channel for your platform, then
10
+ confirm it is on `PATH`:
11
+
12
+ ```bash
13
+ npm install -g @openai/codex # or: brew install codex (use the current official channel)
14
+ codex --version # this skill was verified with codex-cli 0.140.0 or newer
15
+ ```
16
+
17
+ The binary is **`codex`**. If `codex --version` works but the wrappers can't find it, fix your
18
+ `PATH`. If the installed binary's help disagrees with this skill's references, the live binary wins.
19
+
20
+ ## 2. Sign in once (subscription only)
21
+
22
+ Run `codex login` once and complete the **ChatGPT** sign-in:
23
+
24
+ ```bash
25
+ codex login
26
+ codex login status # expect: Logged in using ChatGPT
27
+ ```
28
+
29
+ This caches credentials under `CODEX_HOME` (`~/.codex`, e.g. `~/.codex/auth.json`). That directory is
30
+ **personal** — never copy, commit, package, print, or share it. This skill needs **no API keys** and
31
+ must not be configured with api-key billing; both wrappers unset every `*_API_KEY` (and
32
+ `OPENAI_BASE_URL`) and pass `--ignore-user-config`, so billing can never silently fall back to
33
+ pay-as-you-go and a personal `~/.codex/config.toml` can never change behaviour.
34
+
35
+ ## 3. Put the wrappers on `PATH`
36
+
37
+ The skill ships two wrappers: `bin/codex-exec.sh` and `bin/codex-review.sh`. Expose them on `PATH`
38
+ under the stable names `codex-exec` / `codex-review` via idempotent managed symlinks (refuse to
39
+ clobber a non-symlink):
40
+
41
+ ```bash
42
+ mkdir -p "$HOME/.local/bin"
43
+ skill_dir="$HOME/.claude/skills/codex-cli-bridge" # adjust if installed elsewhere
44
+ for w in codex-exec codex-review; do
45
+ src="$skill_dir/bin/$w.sh"
46
+ dst="$HOME/.local/bin/$w"
47
+ if [ -e "$dst" ] && [ ! -L "$dst" ]; then
48
+ echo "STOP: $dst exists and is not a symlink"; exit 1
49
+ fi
50
+ chmod +x "$src"
51
+ ln -sfn "$src" "$dst"
52
+ done
53
+ export PATH="$HOME/.local/bin:$PATH" # add to ~/.bashrc / ~/.zshrc to persist
54
+ command -v codex-exec && command -v codex-review
55
+ ```
56
+
57
+ ## 4. Smoke test
58
+
59
+ ```bash
60
+ codex --version # version prints
61
+ env -u OPENAI_API_KEY -u CODEX_API_KEY -u OPENAI_BASE_URL codex login status
62
+ ```
63
+
64
+ Expected: the version prints, and login status includes exactly `Logged in using ChatGPT` (the
65
+ `env -u …` mirrors the wrappers, so stray keys can't mask the real auth mode). If the status does not
66
+ include that text, redo step 2. If a wrapper reports `'codex' not found`, fix your `PATH` (step 1);
67
+ if it reports a missing git work tree or root `AGENTS.md`, run it from a project root that has them.
68
+
69
+ ## Notes
70
+
71
+ - The wrappers are **subscription-only** by design and will not use api-key billing.
72
+ - `codex-exec` runs a **workspace-write** sandbox with **network OFF**; `codex-review` runs
73
+ **read-only**. See [`../references/sandbox-and-flags.md`](../references/sandbox-and-flags.md).
74
+ - `codex exec` requires a git repository, and the wrappers also require a root `AGENTS.md`. The
75
+ orchestrator commits, not codex. Re-run `codex login` only when the cached login expires or the
76
+ account changes.
77
+ - On Linux, install `bubblewrap` (`sudo apt install bubblewrap` or equivalent) to silence the
78
+ "could not find bubblewrap" warning; codex otherwise uses a bundled copy.
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "1.6.0",
6
+ "version": "1.8.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -49,7 +49,8 @@
49
49
  "references/",
50
50
  "launchers/",
51
51
  "migrations/",
52
- "tools/"
52
+ "tools/",
53
+ "bridges/"
53
54
  ],
54
55
  "engines": {
55
56
  "node": ">=18"
@@ -58,6 +58,9 @@ export const KNOWN_BACKENDS = [
58
58
  credential: { env: 'CODEX_HOME', default: '~/.codex', file: 'auth.json' },
59
59
  setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/codex-cli-bridge/setup/README.md',
60
60
  setupPathLocal: 'setup/README.md',
61
+ // The short canonical guided commands. Binary-install is platform-variant and longer, so it is
62
+ // REFERENCED via setupRef (§1 of that README), never duplicated here (would drift with the README).
63
+ guide: { setupRef: 'codex-cli-bridge/setup/README.md', loginCmd: 'codex login', verifyCmd: 'codex login status' },
61
64
  },
62
65
  {
63
66
  name: 'antigravity-cli-bridge',
@@ -66,6 +69,7 @@ export const KNOWN_BACKENDS = [
66
69
  credential: { env: null, default: '~/.gemini/antigravity-cli', file: 'antigravity-oauth-token' },
67
70
  setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/antigravity-cli-bridge/setup/README.md',
68
71
  setupPathLocal: 'setup/README.md',
72
+ guide: { setupRef: 'antigravity-cli-bridge/setup/README.md', loginCmd: 'agy', verifyCmd: 'echo "say OK" | agy-run -' },
69
73
  },
70
74
  ];
71
75
 
@@ -257,6 +261,38 @@ export const detectBackend = (entry, deps = {}) => {
257
261
 
258
262
  export const detectBackends = (deps = {}) => KNOWN_BACKENDS.map((entry) => detectBackend(entry, deps));
259
263
 
264
+ // ── guidance (axis-aware, for the `setup` flow) ───────────────────────────────
265
+
266
+ const registryEntry = (name) => KNOWN_BACKENDS.find((b) => b.name === name);
267
+
268
+ // The skill axis can't be auto-fixed in every state: an absent dir IS placeable from the bundled
269
+ // kit; any other non-ok state (stub/foreign/invalid/unsupported, or an `unknown` marker fs error)
270
+ // is a STOP — never overwrite a dir we don't provably own.
271
+ const skillHint = (status, guide) =>
272
+ status.manifestState === NOT_INSTALLED
273
+ ? `place the bundled bridge skill — run \`/agent-workflow-kit setup ${status.name}\``
274
+ : `bridge skill dir is "${status.manifestState}" — STOP and inspect ${status.skillDir ?? 'the skill dir'} (see ${guide?.setupRef ?? status.setupHint?.url})`;
275
+
276
+ // guideFor inspects the manifest/cli/credentials axes INDEPENDENTLY (never the collapsed readiness)
277
+ // and returns an ORDERED list of the manual steps still owed — possibly several at once (e.g. a
278
+ // fresh machine needs both the CLI and a login). `[]` ⇒ nothing manual left (the linker handles the
279
+ // wrappers). Each step is `{ need: 'skill'|'cli'|'credentials', hint }`. Pure; no fs, no side effects.
280
+ export const guideFor = (status) => {
281
+ const guide = registryEntry(status.name)?.guide;
282
+ const out = [];
283
+ if (status.manifestState !== OK) out.push({ need: 'skill', hint: skillHint(status, guide) });
284
+ if (status.cli.state !== PRESENT) {
285
+ out.push({ need: 'cli', hint: `install the "${status.cli.bin}" CLI — see ${guide?.setupRef ?? status.setupHint?.url} §1` });
286
+ }
287
+ if (status.credentials.state !== PRESENT) {
288
+ out.push({
289
+ need: 'credentials',
290
+ hint: `sign in once (subscription): ${guide?.loginCmd ?? 'see the setup README'} (verify: ${guide?.verifyCmd ?? 'see the setup README'})`,
291
+ });
292
+ }
293
+ return out;
294
+ };
295
+
260
296
  // ── report ───────────────────────────────────────────────────────────────────
261
297
 
262
298
  const MARK = { [PRESENT]: '✓', [MISSING]: '✗', [UNKNOWN]: '?' };
@@ -12,6 +12,7 @@ import {
12
12
  detectBackend,
13
13
  detectBackends,
14
14
  formatReport,
15
+ guideFor,
15
16
  KNOWN_BACKENDS,
16
17
  } from './detect-backends.mjs';
17
18
 
@@ -340,3 +341,104 @@ describe('detectBackends — live shape on this machine', () => {
340
341
  }
341
342
  });
342
343
  });
344
+
345
+ // ── guideFor (axis-aware manual steps) ────────────────────────────────────────
346
+
347
+ // A status with all axes satisfied; override per-case to drive each axis independently.
348
+ const okStatus = (over = {}) => ({
349
+ name: 'codex-cli-bridge',
350
+ manifestState: 'ok',
351
+ manifestReason: 'ok',
352
+ skillDir: '/skills/codex-cli-bridge',
353
+ cli: { bin: 'codex', state: 'present', path: '/usr/bin/codex' },
354
+ credentials: { state: 'present', path: '/home/u/.codex/auth.json' },
355
+ wrappers: [{ name: 'codex-exec', state: 'present' }, { name: 'codex-review', state: 'present' }],
356
+ readiness: 'ready',
357
+ setupHint: { local: 'setup/README.md', url: 'https://example.test/codex' },
358
+ ...over,
359
+ });
360
+
361
+ describe('guideFor — axis-aware manual steps', () => {
362
+ it('returns [] when the backend is ready', () => {
363
+ assert.deepEqual(guideFor(okStatus()), []);
364
+ });
365
+
366
+ it('returns [] for a degraded backend (wrappers are the linker\'s job, not a manual step)', () => {
367
+ const s = okStatus({ readiness: 'degraded', wrappers: [{ name: 'codex-exec', state: 'missing' }] });
368
+ assert.deepEqual(guideFor(s), []);
369
+ });
370
+
371
+ it('returns BOTH cli and credentials steps when both are missing (multiple simultaneous)', () => {
372
+ const s = okStatus({
373
+ cli: { bin: 'codex', state: 'missing', path: null },
374
+ credentials: { state: 'missing', path: '/c/auth.json' },
375
+ });
376
+ const needs = guideFor(s).map((g) => g.need);
377
+ assert.deepEqual(needs, ['cli', 'credentials']);
378
+ });
379
+
380
+ it('credentials step carries the canonical loginCmd + verifyCmd from the registry', () => {
381
+ const s = okStatus({ credentials: { state: 'missing', path: '/c/auth.json' } });
382
+ const step = guideFor(s).find((g) => g.need === 'credentials');
383
+ assert.match(step.hint, /codex login/);
384
+ assert.match(step.hint, /codex login status/);
385
+ });
386
+
387
+ it('cli step references the setup README (no duplicated install channels)', () => {
388
+ const s = okStatus({ cli: { bin: 'codex', state: 'missing', path: null } });
389
+ const step = guideFor(s).find((g) => g.need === 'cli');
390
+ assert.match(step.hint, /codex-cli-bridge\/setup\/README\.md/);
391
+ });
392
+
393
+ it('manifestState not-installed → a placeable bundled-skill hint (+ any cli/creds owed)', () => {
394
+ const s = okStatus({ manifestState: 'not-installed', skillDir: null });
395
+ const skill = guideFor(s).find((g) => g.need === 'skill');
396
+ assert.ok(skill, 'expected a skill step');
397
+ assert.match(skill.hint, /bundled bridge skill/);
398
+ assert.match(skill.hint, /setup codex-cli-bridge/);
399
+ });
400
+
401
+ it('manifestState foreign/stub → a STOP hint (never auto-overwritten)', () => {
402
+ for (const state of ['foreign', 'stub', 'invalid-manifest', 'unsupported-schema', 'unknown']) {
403
+ const skill = guideFor(okStatus({ manifestState: state })).find((g) => g.need === 'skill');
404
+ assert.ok(skill, `expected a skill step for ${state}`);
405
+ assert.match(skill.hint, /STOP/);
406
+ assert.match(skill.hint, new RegExp(state.replace(/[-]/g, '\\$&')));
407
+ }
408
+ });
409
+
410
+ it('every step is {need, hint} with a non-empty hint', () => {
411
+ const s = okStatus({
412
+ manifestState: 'not-installed',
413
+ skillDir: null,
414
+ cli: { bin: 'codex', state: 'missing', path: null },
415
+ credentials: { state: 'missing', path: '/c/auth.json' },
416
+ });
417
+ const steps = guideFor(s);
418
+ assert.equal(steps.length, 3);
419
+ for (const step of steps) {
420
+ assert.ok(typeof step.need === 'string' && step.need.length > 0);
421
+ assert.ok(typeof step.hint === 'string' && step.hint.length > 0);
422
+ }
423
+ });
424
+ });
425
+
426
+ describe('KNOWN_BACKENDS — each entry exposes a guide', () => {
427
+ it('guide carries setupRef + loginCmd + verifyCmd (non-empty strings)', () => {
428
+ for (const entry of KNOWN_BACKENDS) {
429
+ assert.ok(entry.guide, `${entry.name} missing guide`);
430
+ for (const key of ['setupRef', 'loginCmd', 'verifyCmd']) {
431
+ assert.ok(
432
+ typeof entry.guide[key] === 'string' && entry.guide[key].length > 0,
433
+ `${entry.name}.guide.${key} must be a non-empty string`,
434
+ );
435
+ }
436
+ }
437
+ });
438
+
439
+ it('guide.setupRef points at a real file in the repo', () => {
440
+ for (const entry of KNOWN_BACKENDS) {
441
+ assert.ok(existsSync(join(REPO, entry.guide.setupRef)), `${entry.guide.setupRef} (${entry.name})`);
442
+ }
443
+ });
444
+ });
@@ -0,0 +1,129 @@
1
+ // fs-safe.mjs — pure, dependency-injectable filesystem-safety primitives shared by the kit installer
2
+ // (bin/install.mjs) and the backend linker (tools/setup-backends.mjs). Importing this module has NO
3
+ // side effects: it runs nothing. Every fs primitive is injectable via `deps.*` so the guards are
4
+ // unit-testable without touching the real filesystem; the defaults are Node's SYNC fs (matching the
5
+ // tools/ detector style). Dependency-free, Node >= 18.
6
+ //
7
+ // Three primitives:
8
+ // assertContainedRealPath — refuse to write through/into a symlink, or to a dest outside a root.
9
+ // copyTreeRefresh — recursive copy that OVERWRITES regular files (refresh), SKIPS a symlink
10
+ // whose dest already exists (additive), and guards every dest component.
11
+ // linkManaged — create/keep ONLY a symlink we own; STOP (typed ManagedLinkConflict) on
12
+ // a foreign symlink or a non-symlink dest; refuse a symlinked source.
13
+
14
+ import {
15
+ lstatSync, existsSync, mkdirSync, readdirSync, copyFileSync, readlinkSync, symlinkSync,
16
+ } from 'node:fs';
17
+ import { dirname, join, resolve, relative, sep, isAbsolute } from 'node:path';
18
+
19
+ // A managed-link conflict is a distinct, expected outcome (a foreign/non-symlink dest we refuse to
20
+ // clobber) — callers branch on `.code`. Modelled as a tagged Error (no classes — §agent_rules 2.3),
21
+ // the same `Object.assign(new Error(), { code })` idiom the codebase already uses for typed errors.
22
+ export const MANAGED_LINK_CONFLICT = 'MANAGED_LINK_CONFLICT';
23
+ const managedLinkConflict = (message, fields = {}) =>
24
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), {
25
+ name: 'ManagedLinkConflict',
26
+ code: MANAGED_LINK_CONFLICT,
27
+ ...fields,
28
+ });
29
+
30
+ // lstat without following symlinks; null when absent. A non-ENOENT fs error (EACCES/EIO) must NOT
31
+ // fail open (be read as "not a symlink") — it propagates so the guard can never be bypassed.
32
+ const lstatNoFollow = (path, lstat) => {
33
+ try {
34
+ return lstat(path);
35
+ } catch (err) {
36
+ if (err && err.code === 'ENOENT') return null;
37
+ throw err;
38
+ }
39
+ };
40
+
41
+ // Symlink-traversal guard: refuse to write *through* any symlink at or above `dest` within `root`
42
+ // (root / intermediate dir / leaf, including a dangling one), or to a dest outside `root`.
43
+ export const assertContainedRealPath = (root, dest, deps = {}) => {
44
+ const lstat = deps.lstat ?? lstatSync;
45
+ const ln = (p) => lstatNoFollow(p, lstat);
46
+ const rel = relative(root, dest);
47
+ if (rel.startsWith('..') || isAbsolute(rel)) {
48
+ throw new Error(`[agent-workflow-kit] refusing to write outside the target dir: ${dest}`);
49
+ }
50
+ if (ln(root)?.isSymbolicLink()) {
51
+ throw new Error(`[agent-workflow-kit] refusing to install into a symlinked target dir: ${root}`);
52
+ }
53
+ const walk = (acc, part) => {
54
+ const cur = join(acc, part);
55
+ if (ln(cur)?.isSymbolicLink()) {
56
+ throw new Error(`[agent-workflow-kit] refusing to write through a symlink at ${cur} (would escape ${root}).`);
57
+ }
58
+ return cur;
59
+ };
60
+ rel.split(sep).filter(Boolean).reduce(walk, root);
61
+ };
62
+
63
+ // Recursive refresh copy. Guards every dest via assertContainedRealPath first, then:
64
+ // symlink src → additive: skip if dest exists, else mirror the link target.
65
+ // directory src → mkdir -p dest, recurse.
66
+ // regular file → mkdir -p parent, copyFile (OVERWRITE = refresh to the bundled version).
67
+ export const copyTreeRefresh = (src, dest, root, deps = {}) => {
68
+ const lstat = deps.lstat ?? lstatSync;
69
+ const exists = deps.exists ?? existsSync;
70
+ const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
71
+ const readdir = deps.readdir ?? readdirSync;
72
+ const copyFile = deps.copyFile ?? copyFileSync;
73
+ const readlink = deps.readlink ?? readlinkSync;
74
+ const symlink = deps.symlink ?? symlinkSync;
75
+
76
+ assertContainedRealPath(root, dest, deps);
77
+ const stat = lstat(src);
78
+ if (stat.isSymbolicLink()) {
79
+ if (exists(dest)) return;
80
+ symlink(readlink(src), dest);
81
+ } else if (stat.isDirectory()) {
82
+ mkdir(dest);
83
+ for (const entry of readdir(src)) {
84
+ copyTreeRefresh(join(src, entry), join(dest, entry), root, deps);
85
+ }
86
+ } else {
87
+ mkdir(dirname(dest));
88
+ copyFile(src, dest);
89
+ }
90
+ };
91
+
92
+ // Create/keep ONLY a symlink we own. `src` must be a real regular file (never a symlink); `dest`
93
+ // must stay within `root`. Outcomes: 'linked' (created), 'noop' (already points at our src), or a
94
+ // thrown ManagedLinkConflict (a non-symlink, or a symlink pointing elsewhere — never clobbered).
95
+ export const linkManaged = (src, dest, root, deps = {}) => {
96
+ const lstat = deps.lstat ?? lstatSync;
97
+ const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
98
+ const readlink = deps.readlink ?? readlinkSync;
99
+ const symlink = deps.symlink ?? symlinkSync;
100
+
101
+ const srcStat = lstat(src);
102
+ if (srcStat.isSymbolicLink()) {
103
+ throw new Error(`[agent-workflow-kit] refusing to link a symlinked source (would escape our ownership): ${src}`);
104
+ }
105
+ if (!srcStat.isFile()) {
106
+ throw new Error(`[agent-workflow-kit] link source is not a regular file: ${src}`);
107
+ }
108
+
109
+ // Guard the PARENT chain (root + intermediate dirs), not the leaf: managing the leaf symlink is
110
+ // exactly this function's job, so it inspects the leaf itself rather than letting the traversal
111
+ // guard reject every symlinked dest. `dirname(dest)` within `root` ⇒ `dest` within `root` too.
112
+ assertContainedRealPath(root, dirname(dest), deps);
113
+ const existing = lstatNoFollow(dest, lstat);
114
+ if (existing === null) {
115
+ mkdir(dirname(dest));
116
+ symlink(src, dest);
117
+ return 'linked';
118
+ }
119
+ if (!existing.isSymbolicLink()) {
120
+ throw managedLinkConflict(`refusing to replace a non-symlink at ${dest}`, { dest, found: 'file' });
121
+ }
122
+ const target = readlink(dest);
123
+ const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(dest), target);
124
+ if (resolvedTarget === resolve(src)) return 'noop';
125
+ throw managedLinkConflict(
126
+ `refusing to replace a foreign symlink at ${dest} (points at ${target}, not our ${src})`,
127
+ { dest, expected: src, found: target },
128
+ );
129
+ };
@@ -0,0 +1,200 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, readFileSync, existsSync, lstatSync,
5
+ } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import {
9
+ assertContainedRealPath,
10
+ copyTreeRefresh,
11
+ linkManaged,
12
+ MANAGED_LINK_CONFLICT,
13
+ } from './fs-safe.mjs';
14
+
15
+ // All three primitives are SYNC and operate on real tmp dirs here (the symlink behaviours are
16
+ // fiddly enough that real fs is the honest test). The out-of-root case needs no fs at all.
17
+ let dir;
18
+ beforeEach(() => {
19
+ dir = mkdtempSync(join(tmpdir(), 'awf-fs-safe-'));
20
+ });
21
+ afterEach(() => {
22
+ rmSync(dir, { recursive: true, force: true });
23
+ });
24
+
25
+ // ── assertContainedRealPath ───────────────────────────────────────────────────
26
+
27
+ describe('assertContainedRealPath', () => {
28
+ it('rejects a dest outside the root (no fs needed — the relative check fires first)', () => {
29
+ assert.throws(() => assertContainedRealPath('/root', '/root/../etc/passwd'), /outside/);
30
+ assert.throws(() => assertContainedRealPath('/root', '/etc/passwd'), /outside/);
31
+ });
32
+
33
+ it('rejects writing INTO a symlinked root', () => {
34
+ const real = join(dir, 'real');
35
+ const root = join(dir, 'root');
36
+ mkdirSync(real);
37
+ symlinkSync(real, root);
38
+ assert.throws(() => assertContainedRealPath(root, join(root, 'x')), /symlink/i);
39
+ });
40
+
41
+ it('rejects writing THROUGH a symlinked intermediate component', () => {
42
+ const root = join(dir, 'root');
43
+ const elsewhere = join(dir, 'elsewhere');
44
+ mkdirSync(root);
45
+ mkdirSync(elsewhere);
46
+ symlinkSync(elsewhere, join(root, 'sub'));
47
+ assert.throws(() => assertContainedRealPath(root, join(root, 'sub', 'file')), /symlink/i);
48
+ });
49
+
50
+ it('rejects a symlinked leaf dest', () => {
51
+ const root = join(dir, 'root');
52
+ mkdirSync(root);
53
+ symlinkSync(join(dir, 'target'), join(root, 'leaf'));
54
+ assert.throws(() => assertContainedRealPath(root, join(root, 'leaf')), /symlink/i);
55
+ });
56
+
57
+ it('accepts a clean dest within the root', () => {
58
+ const root = join(dir, 'root');
59
+ mkdirSync(root);
60
+ assert.doesNotThrow(() => assertContainedRealPath(root, join(root, 'a', 'b', 'c')));
61
+ });
62
+
63
+ it('lstat is injectable (closes the install.mjs injectability gap)', () => {
64
+ let seen = 0;
65
+ const lstat = (p) => { seen += 1; return { isSymbolicLink: () => false }; };
66
+ assert.doesNotThrow(() => assertContainedRealPath('/root', '/root/a/b', { lstat }));
67
+ assert.ok(seen > 0, 'the injected lstat was used');
68
+ });
69
+ });
70
+
71
+ // ── copyTreeRefresh ───────────────────────────────────────────────────────────
72
+
73
+ describe('copyTreeRefresh', () => {
74
+ it('overwrites an existing regular file (refresh)', () => {
75
+ const root = join(dir, 'dest');
76
+ mkdirSync(root);
77
+ const src = join(dir, 'src.txt');
78
+ const dest = join(root, 'f.txt');
79
+ writeFileSync(src, 'new');
80
+ writeFileSync(dest, 'old');
81
+ copyTreeRefresh(src, dest, root);
82
+ assert.equal(readFileSync(dest, 'utf8'), 'new');
83
+ });
84
+
85
+ it('copies a nested directory tree', () => {
86
+ const src = join(dir, 'src');
87
+ const root = join(dir, 'dest');
88
+ mkdirSync(join(src, 'a'), { recursive: true });
89
+ writeFileSync(join(src, 'top.txt'), 'T');
90
+ writeFileSync(join(src, 'a', 'deep.txt'), 'D');
91
+ mkdirSync(root);
92
+ copyTreeRefresh(src, join(root, 'src'), root);
93
+ assert.equal(readFileSync(join(root, 'src', 'top.txt'), 'utf8'), 'T');
94
+ assert.equal(readFileSync(join(root, 'src', 'a', 'deep.txt'), 'utf8'), 'D');
95
+ });
96
+
97
+ it('skips a symlink whose dest already exists (additive — never replace)', () => {
98
+ const root = join(dir, 'dest');
99
+ mkdirSync(root);
100
+ const linkSrc = join(dir, 'link');
101
+ symlinkSync(join(dir, 'whatever'), linkSrc); // src IS a symlink
102
+ const dest = join(root, 'f');
103
+ writeFileSync(dest, 'keep');
104
+ copyTreeRefresh(linkSrc, dest, root);
105
+ assert.equal(readFileSync(dest, 'utf8'), 'keep'); // untouched
106
+ assert.equal(lstatSync(dest).isSymbolicLink(), false);
107
+ });
108
+
109
+ it('STOPs on a symlinked dest component (never writes through it)', () => {
110
+ const root = join(dir, 'root');
111
+ const elsewhere = join(dir, 'elsewhere');
112
+ mkdirSync(root);
113
+ mkdirSync(elsewhere);
114
+ symlinkSync(elsewhere, join(root, 'sub'));
115
+ const src = join(dir, 's.txt');
116
+ writeFileSync(src, 'x');
117
+ assert.throws(() => copyTreeRefresh(src, join(root, 'sub', 'f.txt'), root), /symlink/i);
118
+ assert.equal(existsSync(join(elsewhere, 'f.txt')), false); // no leak
119
+ });
120
+ });
121
+
122
+ // ── linkManaged ───────────────────────────────────────────────────────────────
123
+
124
+ describe('linkManaged', () => {
125
+ const makeSrc = () => {
126
+ const src = join(dir, 'src.sh');
127
+ writeFileSync(src, '#!/bin/sh\n');
128
+ return src;
129
+ };
130
+
131
+ it('creates a symlink when the dest is absent', () => {
132
+ const src = makeSrc();
133
+ const root = join(dir, 'bin');
134
+ mkdirSync(root);
135
+ const dest = join(root, 'cmd');
136
+ const result = linkManaged(src, dest, root);
137
+ assert.equal(result, 'linked');
138
+ assert.equal(lstatSync(dest).isSymbolicLink(), true);
139
+ assert.equal(readlinkSync(dest), src);
140
+ });
141
+
142
+ it('creates the parent bindir if absent (mkdir -p)', () => {
143
+ const src = makeSrc();
144
+ const root = join(dir, 'base');
145
+ mkdirSync(root);
146
+ const dest = join(root, 'newbin', 'cmd');
147
+ linkManaged(src, dest, root);
148
+ assert.equal(readlinkSync(dest), src);
149
+ });
150
+
151
+ it('is idempotent — a second call is a no-op', () => {
152
+ const src = makeSrc();
153
+ const root = join(dir, 'bin');
154
+ mkdirSync(root);
155
+ const dest = join(root, 'cmd');
156
+ linkManaged(src, dest, root);
157
+ const again = linkManaged(src, dest, root);
158
+ assert.equal(again, 'noop');
159
+ assert.equal(readlinkSync(dest), src);
160
+ });
161
+
162
+ it('STOPs on a non-symlink dest (typed ManagedLinkConflict)', () => {
163
+ const src = makeSrc();
164
+ const root = join(dir, 'bin');
165
+ mkdirSync(root);
166
+ const dest = join(root, 'cmd');
167
+ writeFileSync(dest, 'someone-elses-file');
168
+ assert.throws(() => linkManaged(src, dest, root), (err) => err.code === MANAGED_LINK_CONFLICT);
169
+ assert.equal(readFileSync(dest, 'utf8'), 'someone-elses-file'); // untouched
170
+ });
171
+
172
+ it('STOPs on a foreign symlink (points elsewhere)', () => {
173
+ const src = makeSrc();
174
+ const root = join(dir, 'bin');
175
+ mkdirSync(root);
176
+ const dest = join(root, 'cmd');
177
+ const foreign = join(dir, 'foreign.sh');
178
+ writeFileSync(foreign, '#!/bin/sh\n');
179
+ symlinkSync(foreign, dest);
180
+ assert.throws(() => linkManaged(src, dest, root), (err) => err.code === MANAGED_LINK_CONFLICT);
181
+ assert.equal(readlinkSync(dest), foreign); // untouched
182
+ });
183
+
184
+ it('refuses a symlinked source (never links through a symlink)', () => {
185
+ const realSrc = makeSrc();
186
+ const linkSrc = join(dir, 'link.sh');
187
+ symlinkSync(realSrc, linkSrc);
188
+ const root = join(dir, 'bin');
189
+ mkdirSync(root);
190
+ assert.throws(() => linkManaged(linkSrc, join(root, 'cmd'), root), /symlink/i);
191
+ });
192
+
193
+ it('refuses a non-regular-file source (e.g. a directory)', () => {
194
+ const srcDir = join(dir, 'src-dir');
195
+ mkdirSync(srcDir);
196
+ const root = join(dir, 'bin');
197
+ mkdirSync(root);
198
+ assert.throws(() => linkManaged(srcDir, join(root, 'cmd'), root), /regular file/i);
199
+ });
200
+ });