@indigoai-us/hq-cloud 6.11.10 → 6.11.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.test.js +65 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-core.js +91 -0
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +10 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +125 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/personal-vault.d.ts +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +9 -1
- package/dist/personal-vault.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +98 -0
- package/src/cli/reindex.test.ts +47 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-core.ts +87 -0
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/sync.test.ts +159 -0
- package/src/cli/sync.ts +12 -1
- package/src/personal-vault.ts +10 -1
package/src/cli/reindex.test.ts
CHANGED
|
@@ -8,13 +8,36 @@
|
|
|
8
8
|
* re-deriving the implementation internals.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
12
12
|
import * as fs from "fs";
|
|
13
13
|
import * as path from "path";
|
|
14
14
|
import * as os from "os";
|
|
15
15
|
import { reindex } from "./reindex.js";
|
|
16
16
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
17
17
|
|
|
18
|
+
// HQ-B0: simulate the Windows filesystem rejecting a ':' in a path segment.
|
|
19
|
+
// The flag is off by default, so every other test sees the real `mkdirSync`;
|
|
20
|
+
// the regression test below flips it on only for its own run.
|
|
21
|
+
const hoisted = vi.hoisted(() => ({ failNamespacedMkdir: false }));
|
|
22
|
+
vi.mock("fs", async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal<typeof import("fs")>();
|
|
24
|
+
const mkdirSync = ((p: unknown, opts: unknown) => {
|
|
25
|
+
if (
|
|
26
|
+
hoisted.failNamespacedMkdir &&
|
|
27
|
+
typeof p === "string" &&
|
|
28
|
+
/[:][^/\\]*$/.test(p) // a colon in the final path segment
|
|
29
|
+
) {
|
|
30
|
+
throw Object.assign(
|
|
31
|
+
new Error(`ENOENT: no such file or directory, mkdir '${p}'`),
|
|
32
|
+
{ code: "ENOENT" },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return (actual.mkdirSync as (p: unknown, opts: unknown) => unknown)(p, opts);
|
|
36
|
+
}) as typeof actual.mkdirSync;
|
|
37
|
+
const patched = { ...actual, mkdirSync };
|
|
38
|
+
return { ...patched, default: patched };
|
|
39
|
+
});
|
|
40
|
+
|
|
18
41
|
describe("reindex", () => {
|
|
19
42
|
let root: string;
|
|
20
43
|
let stateDir: string;
|
|
@@ -223,4 +246,27 @@ describe("reindex", () => {
|
|
|
223
246
|
reindex({ repoRoot: root });
|
|
224
247
|
expect(fs.existsSync(lockPathFor(root))).toBe(false);
|
|
225
248
|
});
|
|
249
|
+
|
|
250
|
+
// ── HQ-B0: wrapper dir name uses ':' which is illegal on Windows ─────────
|
|
251
|
+
// `<ns>:<skill>` wrapper dirs contain a colon — fine on macOS/Linux, but a
|
|
252
|
+
// reserved drive/ADS separator on Windows, where mkdirSync throws ENOENT.
|
|
253
|
+
// A single un-creatable wrapper must not abort the entire reindex run.
|
|
254
|
+
|
|
255
|
+
it("does not abort reindex when a namespaced wrapper dir cannot be created (':' illegal on Windows)", () => {
|
|
256
|
+
writeSkill("core/skills/demo");
|
|
257
|
+
writeSkill("companies/acme/skills/widget");
|
|
258
|
+
|
|
259
|
+
hoisted.failNamespacedMkdir = true;
|
|
260
|
+
try {
|
|
261
|
+
// Without the guard the wrapper mkdir throws ENOENT and aborts the whole
|
|
262
|
+
// command; with it, the run completes and the bad wrappers are skipped.
|
|
263
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
264
|
+
} finally {
|
|
265
|
+
hoisted.failNamespacedMkdir = false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The un-creatable wrappers are skipped (not half-written).
|
|
269
|
+
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(false);
|
|
270
|
+
expect(fs.existsSync(path.join(root, ".claude/skills/acme:widget"))).toBe(false);
|
|
271
|
+
});
|
|
226
272
|
});
|
package/src/cli/reindex.ts
CHANGED
|
@@ -270,7 +270,23 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
270
270
|
/* best-effort */
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
-
|
|
273
|
+
try {
|
|
274
|
+
fs.mkdirSync(wrapper, { recursive: true });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
// Namespaced wrappers embed a ':' in the directory name
|
|
277
|
+
// (`<ns>:<skill>`). That is a legal filename character on macOS/Linux
|
|
278
|
+
// but a reserved drive/ADS separator on Windows, so mkdir there fails
|
|
279
|
+
// with ENOENT. Skip this one wrapper with a clear message instead of
|
|
280
|
+
// aborting the whole reindex (the skill's source folder is untouched).
|
|
281
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
282
|
+
warn(
|
|
283
|
+
`reindex: could not create skill wrapper '${wrapperName}' ` +
|
|
284
|
+
`(${code ?? "error"}: ${(err as Error).message}). Namespaced wrappers ` +
|
|
285
|
+
`use a ':' in the directory name, which is not a legal filename ` +
|
|
286
|
+
`character on this platform (e.g. Windows); skipping this skill.`,
|
|
287
|
+
);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
274
290
|
|
|
275
291
|
// Symlink every (non-hidden) entry in the source skill folder. The
|
|
276
292
|
// wrapper lives three levels below REPO_ROOT.
|
package/src/cli/rescue-core.ts
CHANGED
|
@@ -869,6 +869,16 @@ function doRescue(
|
|
|
869
869
|
}
|
|
870
870
|
}
|
|
871
871
|
|
|
872
|
+
// --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
|
|
873
|
+
// rsync -a already propagated each file's upstream git tree mode; this guards
|
|
874
|
+
// the case where upstream itself committed an executable script as 0644 -- a
|
|
875
|
+
// hook the runtime exec's then dies with EACCES and is silently disabled. A
|
|
876
|
+
// shebang is an unambiguous "meant to be run" marker. See restoreShebangExecBits.
|
|
877
|
+
const execBitFixes = restoreShebangExecBits(srcDir, hqRoot, env);
|
|
878
|
+
if (execBitFixes > 0) {
|
|
879
|
+
out(`==> Restored executable bit on ${execBitFixes} shipped shebang script(s) lacking +x\n`);
|
|
880
|
+
}
|
|
881
|
+
|
|
872
882
|
// --- Stamp sync-point provenance into core/core.yaml ---
|
|
873
883
|
// `last_sync_at` = the source commit's committer time, NOT wall-clock now:
|
|
874
884
|
// the stamp must be a pure function of srcSha so every machine rescuing the
|
|
@@ -1269,6 +1279,83 @@ function diffAppendClaudeMd(ctx: WalkCtx): void {
|
|
|
1269
1279
|
ctx.counts.claudeDiffAppend += 1;
|
|
1270
1280
|
}
|
|
1271
1281
|
|
|
1282
|
+
/**
|
|
1283
|
+
* Defense-in-depth for executable scaffold scripts. `rescue` rebuilds the tree
|
|
1284
|
+
* with `git clone` + `rsync -a`, faithfully propagating each file's git tree
|
|
1285
|
+
* mode (100755 vs 100644). So a script accidentally committed upstream WITHOUT
|
|
1286
|
+
* its executable bit (e.g. a `.claude/hooks/*.sh` checked in as 100644) is
|
|
1287
|
+
* delivered non-executable to every machine, and a hook the runtime exec's
|
|
1288
|
+
* directly then fails with EACCES ("Permission denied") and is silently
|
|
1289
|
+
* disabled.
|
|
1290
|
+
*
|
|
1291
|
+
* A `#!` shebang is an unambiguous "this file is meant to be run" marker, so
|
|
1292
|
+
* after the overlay we ensure every shipped shebang script is executable,
|
|
1293
|
+
* regardless of the (possibly wrong) upstream mode bit. Execute is added only
|
|
1294
|
+
* where read is already granted (0644 -> 0755, 0640 -> 0750), mirroring the
|
|
1295
|
+
* umask convention; files that already carry any exec bit, non-files, and
|
|
1296
|
+
* symlinks are left untouched. Paths are enumerated from the SOURCE repo's git
|
|
1297
|
+
* index, so this only ever touches release-shipped scaffold -- never user
|
|
1298
|
+
* content the rescue leaves in place.
|
|
1299
|
+
*
|
|
1300
|
+
* Best-effort and non-fatal: a failed `git ls-files` or chmod (read-only FS,
|
|
1301
|
+
* EPERM) is swallowed, matching the rest of rescue's posture. Returns the count
|
|
1302
|
+
* of files whose mode was changed, for the run summary.
|
|
1303
|
+
*/
|
|
1304
|
+
function restoreShebangExecBits(
|
|
1305
|
+
srcDir: string,
|
|
1306
|
+
hqRoot: string,
|
|
1307
|
+
env: NodeJS.ProcessEnv,
|
|
1308
|
+
): number {
|
|
1309
|
+
let listing: string;
|
|
1310
|
+
try {
|
|
1311
|
+
const r = spawnSync("git", ["-C", srcDir, "ls-files", "-z"], {
|
|
1312
|
+
encoding: "utf-8",
|
|
1313
|
+
env,
|
|
1314
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
1315
|
+
});
|
|
1316
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return 0;
|
|
1317
|
+
listing = r.stdout;
|
|
1318
|
+
} catch {
|
|
1319
|
+
return 0;
|
|
1320
|
+
}
|
|
1321
|
+
let fixed = 0;
|
|
1322
|
+
for (const rel of listing.split("\0")) {
|
|
1323
|
+
if (!rel) continue;
|
|
1324
|
+
const dest = path.join(hqRoot, rel);
|
|
1325
|
+
let st: fs.Stats;
|
|
1326
|
+
try {
|
|
1327
|
+
st = fs.lstatSync(dest);
|
|
1328
|
+
} catch {
|
|
1329
|
+
continue; // not delivered here (preserve-excluded, ignored, narrowed out)
|
|
1330
|
+
}
|
|
1331
|
+
if (!st.isFile()) continue; // skip dirs and symlinks (lstat: a link is not a file)
|
|
1332
|
+
if ((st.mode & 0o111) !== 0) continue; // already executable
|
|
1333
|
+
// Sniff the first two bytes for a shebang.
|
|
1334
|
+
let head = "";
|
|
1335
|
+
try {
|
|
1336
|
+
const fd = fs.openSync(dest, "r");
|
|
1337
|
+
try {
|
|
1338
|
+
const buf = Buffer.alloc(2);
|
|
1339
|
+
const n = fs.readSync(fd, buf, 0, 2, 0);
|
|
1340
|
+
head = buf.subarray(0, n).toString("latin1");
|
|
1341
|
+
} finally {
|
|
1342
|
+
fs.closeSync(fd);
|
|
1343
|
+
}
|
|
1344
|
+
} catch {
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (head !== "#!") continue;
|
|
1348
|
+
const execBits = (st.mode & 0o444) >> 2; // copy r-bits into the x-bit slots
|
|
1349
|
+
try {
|
|
1350
|
+
fs.chmodSync(dest, st.mode | execBits);
|
|
1351
|
+
fixed += 1;
|
|
1352
|
+
} catch {
|
|
1353
|
+
// read-only FS / EPERM -- non-fatal.
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return fixed;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1272
1359
|
function readFileOrEmpty(p: string): string {
|
|
1273
1360
|
try {
|
|
1274
1361
|
return fs.readFileSync(p, "utf-8");
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for the rescue executable-bit guarantee in src/cli/rescue-core.ts
|
|
3
|
+
* (restoreShebangExecBits).
|
|
4
|
+
*
|
|
5
|
+
* rescue rebuilds the tree with `git clone` + `rsync -a`, which faithfully
|
|
6
|
+
* propagates the upstream git tree mode. So a script committed upstream WITHOUT
|
|
7
|
+
* its executable bit (a `.sh` checked in as 100644) is delivered non-executable
|
|
8
|
+
* to every machine -- and a hook the runtime exec's directly then fails with
|
|
9
|
+
* "Permission denied". After the overlay, rescue restores +x on any shipped
|
|
10
|
+
* file that begins with a `#!` shebang (and leaves non-shebang files alone).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors rescue-mtime-preserve.test.ts: shim `git clone` to a local fixture
|
|
13
|
+
* and run the real rescue (non-dry-run, --no-backup) so the overlay lays files
|
|
14
|
+
* down for real.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
17
|
+
import { execFileSync } from "child_process";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as os from "os";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { runRescue } from "./rescue-core.js";
|
|
22
|
+
|
|
23
|
+
function has(bin: string, ...args: string[]): boolean {
|
|
24
|
+
try {
|
|
25
|
+
execFileSync(bin, args, { stdio: "ignore" });
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const toolsAvailable = has("git", "--version") && has("rsync", "--version");
|
|
32
|
+
|
|
33
|
+
const FLOOR_EPOCH = 1577836800; // 2020-01-01
|
|
34
|
+
const HEAD_EPOCH = 1609459200; // 2021-01-01
|
|
35
|
+
|
|
36
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
37
|
+
let stdout = "";
|
|
38
|
+
let stderr = "";
|
|
39
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
40
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
41
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
42
|
+
stdout += String(chunk);
|
|
43
|
+
return true;
|
|
44
|
+
}) as typeof process.stdout.write;
|
|
45
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
46
|
+
stderr += String(chunk);
|
|
47
|
+
return true;
|
|
48
|
+
}) as typeof process.stderr.write;
|
|
49
|
+
let status: number;
|
|
50
|
+
try {
|
|
51
|
+
status = runRescue(argv, { env }).status;
|
|
52
|
+
} finally {
|
|
53
|
+
process.stdout.write = origOut;
|
|
54
|
+
process.stderr.write = origErr;
|
|
55
|
+
}
|
|
56
|
+
return { status, stdout, stderr };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe.skipIf(!toolsAvailable)("rescue restores +x on shipped shebang scripts", () => {
|
|
60
|
+
let workDir: string;
|
|
61
|
+
let upstream: string;
|
|
62
|
+
let hqRoot: string;
|
|
63
|
+
let shimDir: string;
|
|
64
|
+
let floorSha: string;
|
|
65
|
+
let env: NodeJS.ProcessEnv;
|
|
66
|
+
|
|
67
|
+
const gitAt = (cwd: string, epoch: number, ...args: string[]) =>
|
|
68
|
+
execFileSync("git", args, {
|
|
69
|
+
cwd,
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
GIT_AUTHOR_NAME: "t",
|
|
74
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
75
|
+
GIT_COMMITTER_NAME: "t",
|
|
76
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
77
|
+
GIT_AUTHOR_DATE: `${epoch} +0000`,
|
|
78
|
+
GIT_COMMITTER_DATE: `${epoch} +0000`,
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
.toString()
|
|
82
|
+
.trim();
|
|
83
|
+
|
|
84
|
+
const isExec = (p: string) => (fs.statSync(p).mode & 0o111) !== 0;
|
|
85
|
+
const mode = (p: string) => fs.statSync(p).mode & 0o777;
|
|
86
|
+
|
|
87
|
+
beforeAll(() => {
|
|
88
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-exec-"));
|
|
89
|
+
|
|
90
|
+
// --- "upstream" repo ----------------------------------------------------
|
|
91
|
+
upstream = path.join(workDir, "upstream");
|
|
92
|
+
fs.mkdirSync(path.join(upstream, "core/scripts"), { recursive: true });
|
|
93
|
+
fs.mkdirSync(path.join(upstream, "core/docs"), { recursive: true });
|
|
94
|
+
gitAt(workDir, FLOOR_EPOCH, "init", "-b", "main", "upstream");
|
|
95
|
+
|
|
96
|
+
// Shebang script committed WITHOUT +x (the bug: arrives non-executable).
|
|
97
|
+
const needs = path.join(upstream, "core/scripts/needs-exec.sh");
|
|
98
|
+
fs.writeFileSync(needs, "#!/bin/bash\necho needs\n", { mode: 0o644 });
|
|
99
|
+
fs.chmodSync(needs, 0o644);
|
|
100
|
+
// Shebang script committed WITH +x (control: rsync -a already preserves it).
|
|
101
|
+
const already = path.join(upstream, "core/scripts/already-exec.sh");
|
|
102
|
+
fs.writeFileSync(already, "#!/bin/bash\necho already\n");
|
|
103
|
+
fs.chmodSync(already, 0o755);
|
|
104
|
+
// Non-shebang data file (control: must NOT be made executable).
|
|
105
|
+
fs.writeFileSync(path.join(upstream, "core/docs/note.md"), "# note\n", { mode: 0o644 });
|
|
106
|
+
|
|
107
|
+
gitAt(upstream, FLOOR_EPOCH, "add", "-A");
|
|
108
|
+
gitAt(upstream, FLOOR_EPOCH, "commit", "-m", "floor");
|
|
109
|
+
floorSha = gitAt(upstream, FLOOR_EPOCH, "rev-parse", "HEAD");
|
|
110
|
+
// A second commit so floor != HEAD (mirrors the mtime fixture).
|
|
111
|
+
fs.writeFileSync(path.join(upstream, "core/docs/head.md"), "head\n");
|
|
112
|
+
gitAt(upstream, HEAD_EPOCH, "add", "-A");
|
|
113
|
+
gitAt(upstream, HEAD_EPOCH, "commit", "-m", "head");
|
|
114
|
+
|
|
115
|
+
// Confirm the fixture actually stored the intended tree modes.
|
|
116
|
+
const lsFloor = gitAt(upstream, HEAD_EPOCH, "ls-tree", "-r", "HEAD");
|
|
117
|
+
if (!/100644\s+blob\s+\S+\s+core\/scripts\/needs-exec\.sh/.test(lsFloor)) {
|
|
118
|
+
throw new Error(`fixture needs-exec.sh not stored as 100644:\n${lsFloor}`);
|
|
119
|
+
}
|
|
120
|
+
if (!/100755\s+blob\s+\S+\s+core\/scripts\/already-exec\.sh/.test(lsFloor)) {
|
|
121
|
+
throw new Error(`fixture already-exec.sh not stored as 100755:\n${lsFloor}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- local HQ root being rescued (fresh; upstream files are brand-new) --
|
|
125
|
+
hqRoot = path.join(workDir, "hq");
|
|
126
|
+
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
127
|
+
fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
|
|
128
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
129
|
+
|
|
130
|
+
// --- git shim: redirect `git clone <github-url>` to the local fixture ---
|
|
131
|
+
const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
132
|
+
shimDir = path.join(workDir, "shim");
|
|
133
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
134
|
+
const shim = `#!/usr/bin/env bash
|
|
135
|
+
if [ "$1" = "clone" ]; then
|
|
136
|
+
args=()
|
|
137
|
+
for a in "$@"; do
|
|
138
|
+
case "$a" in
|
|
139
|
+
https://github.com/*) a=${JSON.stringify(upstream)} ;;
|
|
140
|
+
esac
|
|
141
|
+
args+=("$a")
|
|
142
|
+
done
|
|
143
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
144
|
+
fi
|
|
145
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
146
|
+
`;
|
|
147
|
+
fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
|
|
148
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
|
|
149
|
+
|
|
150
|
+
const r = runRescueCapture(
|
|
151
|
+
[
|
|
152
|
+
"--hq-root", hqRoot,
|
|
153
|
+
"--source", "test/repo",
|
|
154
|
+
"--ref", "main",
|
|
155
|
+
"--floor-sha", floorSha,
|
|
156
|
+
"--yes",
|
|
157
|
+
"--no-backup",
|
|
158
|
+
],
|
|
159
|
+
env,
|
|
160
|
+
);
|
|
161
|
+
if (r.status !== 0) {
|
|
162
|
+
throw new Error(`rescue failed (${r.status}):\n${r.stdout}\n${r.stderr}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterAll(() => {
|
|
167
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("restores +x on a shebang script shipped as 0644 (the bug)", () => {
|
|
171
|
+
const f = path.join(hqRoot, "core/scripts/needs-exec.sh");
|
|
172
|
+
expect(fs.readFileSync(f, "utf-8")).toBe("#!/bin/bash\necho needs\n");
|
|
173
|
+
expect(isExec(f)).toBe(true);
|
|
174
|
+
expect(mode(f)).toBe(0o755); // 0644 + (read-bits -> exec-bits)
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("leaves an already-executable shebang script executable", () => {
|
|
178
|
+
const f = path.join(hqRoot, "core/scripts/already-exec.sh");
|
|
179
|
+
expect(isExec(f)).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("never makes a non-shebang data file executable", () => {
|
|
183
|
+
const f = path.join(hqRoot, "core/docs/note.md");
|
|
184
|
+
expect(fs.existsSync(f)).toBe(true);
|
|
185
|
+
expect(isExec(f)).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -611,6 +611,165 @@ describe("sync", () => {
|
|
|
611
611
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
612
612
|
});
|
|
613
613
|
|
|
614
|
+
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping other companies/* keys", async () => {
|
|
615
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
616
|
+
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
617
|
+
{ key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"man111"' },
|
|
618
|
+
]);
|
|
619
|
+
|
|
620
|
+
const result = await sync({
|
|
621
|
+
company: "acme",
|
|
622
|
+
vaultConfig: mockConfig,
|
|
623
|
+
hqRoot: tmpDir,
|
|
624
|
+
personalMode: true,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// The manifest is the lone companies/* exemption: it downloads; other
|
|
628
|
+
// companies/* keys are still dropped.
|
|
629
|
+
expect(result.filesSkipped).toBe(1);
|
|
630
|
+
expect(result.filesDownloaded).toBe(1);
|
|
631
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
|
|
632
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
|
|
633
|
+
|
|
634
|
+
// The whole point: it now gets a journal baseline, so the push side stops
|
|
635
|
+
// re-firing a transient conflict every sync (the bug this fix closes).
|
|
636
|
+
const journaledManifest = fs
|
|
637
|
+
.readdirSync(stateDir)
|
|
638
|
+
.filter((f) => f.startsWith("sync-journal."))
|
|
639
|
+
.some((f) => {
|
|
640
|
+
const j = JSON.parse(fs.readFileSync(path.join(stateDir, f), "utf8"));
|
|
641
|
+
return j.files?.["companies/manifest.yaml"] != null;
|
|
642
|
+
});
|
|
643
|
+
expect(journaledManifest).toBe(true);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("personalMode pull lands the session-continuity pointer + active thread file under <hqRoot>/workspace/threads/ so a handoff resumes on machine B (DEV-1778)", async () => {
|
|
647
|
+
// End-to-end download leg of the cross-machine handoff. Machine A pushed
|
|
648
|
+
// workspace/threads/handoff.json + the thread file it points to into the
|
|
649
|
+
// personal bucket; machine B pulls and both must land hq-root-relative
|
|
650
|
+
// (NOT under companies/<slug>/) with the pointer still resolving to the
|
|
651
|
+
// thread file that also landed — that is what lets /startwork resume.
|
|
652
|
+
const handoffKey = "workspace/threads/handoff.json";
|
|
653
|
+
const threadKey = "workspace/threads/T-20260619-1200-resume-me.json";
|
|
654
|
+
|
|
655
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
656
|
+
{ key: handoffKey, size: 80, lastModified: new Date(), etag: '"h1"' },
|
|
657
|
+
{ key: threadKey, size: 40, lastModified: new Date(), etag: '"t1"' },
|
|
658
|
+
]);
|
|
659
|
+
|
|
660
|
+
// Materialize realistic bytes per key (the default mock writes a fixed
|
|
661
|
+
// string, but the pointer must be valid JSON referencing the thread).
|
|
662
|
+
const origDownload = vi.mocked(s3Module.downloadFile).getMockImplementation();
|
|
663
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
664
|
+
async (_ctx: unknown, key: string, localPath: string) => {
|
|
665
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
666
|
+
const body = key.endsWith("handoff.json")
|
|
667
|
+
? JSON.stringify({ thread_path: threadKey, message: "from machine A" })
|
|
668
|
+
: JSON.stringify({ conversation_summary: "pick up here" });
|
|
669
|
+
fs.writeFileSync(localPath, body);
|
|
670
|
+
return { metadata: {} };
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
const result = await sync({
|
|
676
|
+
company: "acme",
|
|
677
|
+
vaultConfig: mockConfig,
|
|
678
|
+
hqRoot: tmpDir,
|
|
679
|
+
personalMode: true,
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
expect(result.filesDownloaded).toBe(2);
|
|
683
|
+
|
|
684
|
+
const handoffLocal = path.join(tmpDir, "workspace", "threads", "handoff.json");
|
|
685
|
+
const threadLocal = path.join(
|
|
686
|
+
tmpDir,
|
|
687
|
+
"workspace",
|
|
688
|
+
"threads",
|
|
689
|
+
"T-20260619-1200-resume-me.json",
|
|
690
|
+
);
|
|
691
|
+
expect(fs.existsSync(handoffLocal)).toBe(true);
|
|
692
|
+
expect(fs.existsSync(threadLocal)).toBe(true);
|
|
693
|
+
|
|
694
|
+
// Pointer round-trips and resolves to the thread file that also landed.
|
|
695
|
+
const pointer = JSON.parse(fs.readFileSync(handoffLocal, "utf-8"));
|
|
696
|
+
expect(pointer.thread_path).toBe(threadKey);
|
|
697
|
+
expect(fs.existsSync(path.join(tmpDir, pointer.thread_path))).toBe(true);
|
|
698
|
+
|
|
699
|
+
// Must NOT be misfiled under companies/<slug>/.
|
|
700
|
+
expect(
|
|
701
|
+
fs.existsSync(path.join(tmpDir, "companies", "acme", handoffKey)),
|
|
702
|
+
).toBe(false);
|
|
703
|
+
} finally {
|
|
704
|
+
if (origDownload) {
|
|
705
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(origDownload);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("personalMode pull does NOT clobber a newer local session-continuity pointer (conflict → keep local) (DEV-1778)", async () => {
|
|
711
|
+
// Machine B did its OWN /handoff after machine A's push, so B's local
|
|
712
|
+
// handoff.json is newer than the remote. The pull must preserve B's
|
|
713
|
+
// pointer rather than overwrite it with A's stale one — the brief's
|
|
714
|
+
// "download cleanly without clobbering a newer local pointer".
|
|
715
|
+
const threadsLocal = path.join(tmpDir, "workspace", "threads");
|
|
716
|
+
fs.mkdirSync(threadsLocal, { recursive: true });
|
|
717
|
+
fs.writeFileSync(
|
|
718
|
+
path.join(threadsLocal, "handoff.json"),
|
|
719
|
+
JSON.stringify({
|
|
720
|
+
thread_path: "workspace/threads/T-machineB.json",
|
|
721
|
+
message: "newer local from B",
|
|
722
|
+
}),
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
// Journal records a prior synced baseline (stale hash, no remoteEtag) so
|
|
726
|
+
// the planner sees local-changed AND remote-changed → conflict. Keys are
|
|
727
|
+
// hq-root-relative in personalMode.
|
|
728
|
+
fs.writeFileSync(
|
|
729
|
+
journalPath,
|
|
730
|
+
JSON.stringify({
|
|
731
|
+
version: "1",
|
|
732
|
+
lastSync: new Date().toISOString(),
|
|
733
|
+
files: {
|
|
734
|
+
"workspace/threads/handoff.json": {
|
|
735
|
+
hash: "old-hash-from-last-sync",
|
|
736
|
+
size: 10,
|
|
737
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
738
|
+
direction: "down",
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
}),
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
745
|
+
{
|
|
746
|
+
key: "workspace/threads/handoff.json",
|
|
747
|
+
size: 50,
|
|
748
|
+
lastModified: new Date(),
|
|
749
|
+
etag: '"remote-from-A"',
|
|
750
|
+
},
|
|
751
|
+
]);
|
|
752
|
+
|
|
753
|
+
const result = await sync({
|
|
754
|
+
company: "acme",
|
|
755
|
+
onConflict: "keep",
|
|
756
|
+
vaultConfig: mockConfig,
|
|
757
|
+
hqRoot: tmpDir,
|
|
758
|
+
personalMode: true,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
expect(result.conflicts).toBe(1);
|
|
762
|
+
expect(result.conflictPaths).toEqual(["workspace/threads/handoff.json"]);
|
|
763
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
764
|
+
|
|
765
|
+
// B's newer local pointer is preserved verbatim — not clobbered by A's.
|
|
766
|
+
const kept = JSON.parse(
|
|
767
|
+
fs.readFileSync(path.join(threadsLocal, "handoff.json"), "utf-8"),
|
|
768
|
+
);
|
|
769
|
+
expect(kept.message).toBe("newer local from B");
|
|
770
|
+
expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
|
|
771
|
+
});
|
|
772
|
+
|
|
614
773
|
it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
|
|
615
774
|
// The symmetric flip for the cloud:false → personal-bucket fallback.
|
|
616
775
|
// Machine A pushed `companies/free-co/notes.md` to the personal bucket
|
package/src/cli/sync.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
36
36
|
migratePersonalVaultJournal,
|
|
37
37
|
} from "../journal.js";
|
|
38
|
+
import { PERSONAL_VAULT_MANIFEST_KEY } from "../personal-vault.js";
|
|
38
39
|
import {
|
|
39
40
|
buildScopeShrinkPlan,
|
|
40
41
|
applyScopeShrink,
|
|
@@ -1751,7 +1752,17 @@ function computePullPlan(
|
|
|
1751
1752
|
continue;
|
|
1752
1753
|
}
|
|
1753
1754
|
|
|
1754
|
-
if (
|
|
1755
|
+
if (
|
|
1756
|
+
personalMode &&
|
|
1757
|
+
remoteFile.key.startsWith("companies/") &&
|
|
1758
|
+
// EXEMPTION: companies/manifest.yaml is the routing source-of-truth
|
|
1759
|
+
// carved INTO the personal vault on the push side
|
|
1760
|
+
// (computePersonalVaultPaths). It must round-trip on the pull leg too —
|
|
1761
|
+
// skipping it here leaves it forever unjournaled, which re-fires a
|
|
1762
|
+
// transient push-side conflict every sync (no journal baseline). Let it
|
|
1763
|
+
// fall through to download + journal like any personal file.
|
|
1764
|
+
remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY
|
|
1765
|
+
) {
|
|
1755
1766
|
// Default: drop every `companies/...` key — the legacy contract
|
|
1756
1767
|
// is that the personal bucket should never contain them.
|
|
1757
1768
|
//
|
package/src/personal-vault.ts
CHANGED
|
@@ -94,6 +94,15 @@ export interface PersonalVaultOptions {
|
|
|
94
94
|
* hqRoot returns []; callers treat that as "no personal content to push"
|
|
95
95
|
* rather than a hard error.
|
|
96
96
|
*/
|
|
97
|
+
/**
|
|
98
|
+
* S3 key (hq-root-relative, forward-slash) of the companies manifest — the
|
|
99
|
+
* routing source-of-truth — carved into the personal vault even though
|
|
100
|
+
* `companies/` is otherwise excluded. Exported so the PULL plan applies the
|
|
101
|
+
* SAME exemption: skipping it on the pull leaves it unjournaled, which re-fires
|
|
102
|
+
* a transient push-side conflict every sync (no journal baseline).
|
|
103
|
+
*/
|
|
104
|
+
export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
|
|
105
|
+
|
|
97
106
|
export function computePersonalVaultPaths(
|
|
98
107
|
hqRoot: string,
|
|
99
108
|
opts: PersonalVaultOptions = {},
|
|
@@ -114,7 +123,7 @@ export function computePersonalVaultPaths(
|
|
|
114
123
|
// because the parent `companies/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL
|
|
115
124
|
// (we never enumerate the whole companies tree wholesale).
|
|
116
125
|
const manifest: string[] = [];
|
|
117
|
-
const manifestPath = path.join(hqRoot,
|
|
126
|
+
const manifestPath = path.join(hqRoot, PERSONAL_VAULT_MANIFEST_KEY);
|
|
118
127
|
try {
|
|
119
128
|
if (fs.statSync(manifestPath).isFile()) {
|
|
120
129
|
manifest.push(manifestPath);
|