@indigoai-us/hq-cloud 5.47.1 → 5.48.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.
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +6 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/master-sync.d.ts +22 -0
- package/dist/cli/master-sync.d.ts.map +1 -0
- package/dist/cli/master-sync.js +44 -0
- package/dist/cli/master-sync.js.map +1 -0
- package/dist/cli/master-sync.test.d.ts +11 -0
- package/dist/cli/master-sync.test.d.ts.map +1 -0
- package/dist/cli/master-sync.test.js +73 -0
- package/dist/cli/master-sync.test.js.map +1 -0
- package/dist/cli/rescue.d.ts +63 -0
- package/dist/cli/rescue.d.ts.map +1 -0
- package/dist/cli/rescue.js +88 -0
- package/dist/cli/rescue.js.map +1 -0
- package/dist/cli/rescue.test.d.ts +2 -0
- package/dist/cli/rescue.test.d.ts.map +1 -0
- package/dist/cli/rescue.test.js +60 -0
- package/dist/cli/rescue.test.js.map +1 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +5 -5
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +1 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +1 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.test.js +1 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/s3.d.ts +18 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +24 -0
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +33 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/watcher.js +2 -2
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/scripts/master-sync.sh +318 -0
- package/scripts/replace-rescue.sh +1400 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/master-sync.test.ts +94 -0
- package/src/cli/master-sync.ts +56 -0
- package/src/cli/rescue.test.ts +65 -0
- package/src/cli/rescue.ts +122 -0
- package/src/cli/share.test.ts +1 -0
- package/src/cli/share.ts +5 -4
- package/src/cli/sync-scope.test.ts +1 -0
- package/src/cli/sync.test.ts +1 -0
- package/src/index.ts +9 -0
- package/src/s3.test.ts +50 -0
- package/src/s3.ts +25 -0
- package/src/watcher.ts +2 -2
package/src/cli/index.ts
CHANGED
|
@@ -23,3 +23,13 @@ export type { AcceptOptions, AcceptResult } from "./accept.js";
|
|
|
23
23
|
|
|
24
24
|
export { promote } from "./promote.js";
|
|
25
25
|
export type { PromoteOptions, PromoteResult } from "./promote.js";
|
|
26
|
+
|
|
27
|
+
// Skill/personal-overlay mirroring + workers-registry regen (formerly the
|
|
28
|
+
// hq-core master-sync.sh hook).
|
|
29
|
+
export { masterSync, masterSyncScriptPath } from "./master-sync.js";
|
|
30
|
+
export type { MasterSyncOptions, MasterSyncResult } from "./master-sync.js";
|
|
31
|
+
|
|
32
|
+
// Drift-preserving HQ-core re-sync (formerly bundled only inside the HQ Sync
|
|
33
|
+
// menubar app). Now shared between the app and `hq rescue`.
|
|
34
|
+
export { rescue, rescueScriptPath, buildRescueArgs } from "./rescue.js";
|
|
35
|
+
export type { RescueOptions, RescueResult } from "./rescue.js";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq master-sync` (the formerly-bash hq-core hook, now shipped
|
|
3
|
+
* in this package and invoked via the CLI).
|
|
4
|
+
*
|
|
5
|
+
* The logic itself lives in scripts/master-sync.sh; these tests exercise it via
|
|
6
|
+
* the masterSync() wrapper against a temp HQ tree, asserting the observable
|
|
7
|
+
* filesystem outcomes (skill wrappers, personal-overlay mirroring) rather than
|
|
8
|
+
* re-deriving the bash internals.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import * as os from "os";
|
|
15
|
+
import { masterSync, masterSyncScriptPath } from "./master-sync.js";
|
|
16
|
+
|
|
17
|
+
describe("masterSyncScriptPath", () => {
|
|
18
|
+
it("resolves to the bundled script and the file exists", () => {
|
|
19
|
+
const p = masterSyncScriptPath();
|
|
20
|
+
expect(p.endsWith(path.join("scripts", "master-sync.sh"))).toBe(true);
|
|
21
|
+
expect(fs.existsSync(p)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("masterSync", () => {
|
|
26
|
+
let root: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "ms-test-"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function writeSkill(rel: string): void {
|
|
37
|
+
const dir = path.join(root, rel);
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
fs.writeFileSync(path.join(dir, "SKILL.md"), "skill\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it("surfaces namespaced skills as .claude/skills/<ns>:<skill>/ wrappers", () => {
|
|
43
|
+
writeSkill("core/skills/demo");
|
|
44
|
+
fs.writeFileSync(path.join(root, "core/skills/demo/helper.md"), "h\n");
|
|
45
|
+
writeSkill("companies/acme/skills/widget");
|
|
46
|
+
|
|
47
|
+
const { status } = masterSync({ repoRoot: root });
|
|
48
|
+
expect(status).toBe(0);
|
|
49
|
+
|
|
50
|
+
const coreWrapper = path.join(root, ".claude/skills/core:demo");
|
|
51
|
+
expect(fs.lstatSync(path.join(coreWrapper, "SKILL.md")).isSymbolicLink()).toBe(true);
|
|
52
|
+
expect(fs.readlinkSync(path.join(coreWrapper, "SKILL.md"))).toBe(
|
|
53
|
+
"../../../core/skills/demo/SKILL.md",
|
|
54
|
+
);
|
|
55
|
+
// Every source file is mirrored, not just SKILL.md.
|
|
56
|
+
expect(fs.existsSync(path.join(coreWrapper, "helper.md"))).toBe(true);
|
|
57
|
+
|
|
58
|
+
const acmeWrapper = path.join(root, ".claude/skills/acme:widget");
|
|
59
|
+
expect(fs.readlinkSync(path.join(acmeWrapper, "SKILL.md"))).toBe(
|
|
60
|
+
"../../../companies/acme/skills/widget/SKILL.md",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("mirrors the personal overlay into core/<type>/", () => {
|
|
65
|
+
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
|
|
67
|
+
|
|
68
|
+
const { status } = masterSync({ repoRoot: root });
|
|
69
|
+
expect(status).toBe(0);
|
|
70
|
+
|
|
71
|
+
const link = path.join(root, "core/policies/myrule.md");
|
|
72
|
+
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
73
|
+
expect(fs.readlinkSync(link)).toBe("../../personal/policies/myrule.md");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("prunes orphan managed wrappers when the source skill disappears", () => {
|
|
77
|
+
writeSkill("core/skills/demo");
|
|
78
|
+
masterSync({ repoRoot: root });
|
|
79
|
+
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
|
|
80
|
+
|
|
81
|
+
fs.rmSync(path.join(root, "core/skills/demo"), { recursive: true, force: true });
|
|
82
|
+
masterSync({ repoRoot: root });
|
|
83
|
+
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("is idempotent — a second run is a no-op", () => {
|
|
87
|
+
writeSkill("core/skills/demo");
|
|
88
|
+
expect(masterSync({ repoRoot: root }).status).toBe(0);
|
|
89
|
+
expect(masterSync({ repoRoot: root }).status).toBe(0);
|
|
90
|
+
expect(
|
|
91
|
+
fs.readlinkSync(path.join(root, ".claude/skills/core:demo/SKILL.md")),
|
|
92
|
+
).toBe("../../../core/skills/demo/SKILL.md");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq master-sync — surfaces namespaced skills as Claude Code skill wrappers,
|
|
3
|
+
* mirrors personal/{knowledge,policies,workers,settings} into core/, prunes
|
|
4
|
+
* orphan wrappers, and regenerates the workers registry.
|
|
5
|
+
*
|
|
6
|
+
* The implementation lives in scripts/master-sync.sh, shipped with this
|
|
7
|
+
* package. This module resolves that script relative to the package (dist/cli
|
|
8
|
+
* → package root) and execs it against the caller's HQ root. Historically the
|
|
9
|
+
* script ran directly as a Claude Code hook inside hq-core; it now lives here
|
|
10
|
+
* so a single copy is maintained, and the hq-core hook is a thin shim over
|
|
11
|
+
* `hq master-sync`.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "child_process";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import path from "path";
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Absolute path to the bundled master-sync.sh. From the compiled module at
|
|
22
|
+
* dist/cli/master-sync.js, the package root is two levels up; the script lives
|
|
23
|
+
* at <package-root>/scripts/master-sync.sh.
|
|
24
|
+
*/
|
|
25
|
+
export function masterSyncScriptPath(): string {
|
|
26
|
+
return path.resolve(__dirname, "..", "..", "scripts", "master-sync.sh");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MasterSyncOptions {
|
|
30
|
+
/** HQ root to operate on. Defaults to process.cwd(). */
|
|
31
|
+
repoRoot?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MasterSyncResult {
|
|
35
|
+
/** Exit status of the underlying script (0 = success). */
|
|
36
|
+
status: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run master-sync against an HQ root. Synchronous — the script is cheap and
|
|
41
|
+
* idempotent, and callers (the hook shim, tests) want the exit status.
|
|
42
|
+
* stdout/stderr from the script are forwarded to stderr so the caller's stdout
|
|
43
|
+
* stays clean (hooks must not emit stdout that the agent interprets).
|
|
44
|
+
*/
|
|
45
|
+
export function masterSync(opts: MasterSyncOptions = {}): MasterSyncResult {
|
|
46
|
+
const repoRoot = opts.repoRoot ?? process.cwd();
|
|
47
|
+
const script = masterSyncScriptPath();
|
|
48
|
+
const res = spawnSync("bash", [script, repoRoot], {
|
|
49
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
50
|
+
});
|
|
51
|
+
if (res.error) {
|
|
52
|
+
process.stderr.write(`master-sync: failed to run ${script}: ${res.error.message}\n`);
|
|
53
|
+
return { status: 1 };
|
|
54
|
+
}
|
|
55
|
+
return { status: res.status ?? 1 };
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildRescueArgs, rescueScriptPath } from "./rescue.js";
|
|
3
|
+
|
|
4
|
+
describe("rescueScriptPath", () => {
|
|
5
|
+
it("resolves to the bundled script at the package root", () => {
|
|
6
|
+
const p = rescueScriptPath();
|
|
7
|
+
expect(p.endsWith("scripts/replace-rescue.sh")).toBe(true);
|
|
8
|
+
// From dist/cli/rescue.js the package root is two levels up; ensure we
|
|
9
|
+
// don't accidentally point inside dist/.
|
|
10
|
+
expect(p).not.toContain("/dist/scripts/");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("buildRescueArgs", () => {
|
|
15
|
+
it("emits no args for an empty option set (script defaults apply)", () => {
|
|
16
|
+
expect(buildRescueArgs()).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("maps the prod-update shape the menubar app uses", () => {
|
|
20
|
+
const args = buildRescueArgs({
|
|
21
|
+
hqRoot: "/Users/x/HQ",
|
|
22
|
+
source: "indigoai-us/hq-core",
|
|
23
|
+
ref: "v12.3.0",
|
|
24
|
+
floorSha: "a".repeat(40),
|
|
25
|
+
assumeYes: true,
|
|
26
|
+
});
|
|
27
|
+
expect(args).toEqual([
|
|
28
|
+
"--hq-root",
|
|
29
|
+
"/Users/x/HQ",
|
|
30
|
+
"--source",
|
|
31
|
+
"indigoai-us/hq-core",
|
|
32
|
+
"--ref",
|
|
33
|
+
"v12.3.0",
|
|
34
|
+
"--floor-sha",
|
|
35
|
+
"a".repeat(40),
|
|
36
|
+
"--yes",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("joins narrow paths and repeats preserve flags", () => {
|
|
41
|
+
const args = buildRescueArgs({
|
|
42
|
+
paths: ["core", ".claude"],
|
|
43
|
+
preserve: ["foo", "bar"],
|
|
44
|
+
preserveSubpaths: ["core/keep.md"],
|
|
45
|
+
});
|
|
46
|
+
expect(args).toContain("--paths");
|
|
47
|
+
expect(args[args.indexOf("--paths") + 1]).toBe("core,.claude");
|
|
48
|
+
expect(args.filter((a) => a === "--preserve")).toHaveLength(2);
|
|
49
|
+
expect(args).toContain("foo");
|
|
50
|
+
expect(args).toContain("bar");
|
|
51
|
+
expect(args).toContain("--preserve-subpath");
|
|
52
|
+
expect(args).toContain("core/keep.md");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("passes dry-run and no-backup toggles through", () => {
|
|
56
|
+
const args = buildRescueArgs({ dryRun: true, noBackup: true, noHistoryCheck: true });
|
|
57
|
+
expect(args).toContain("--dry-run");
|
|
58
|
+
expect(args).toContain("--no-backup");
|
|
59
|
+
expect(args).toContain("--no-history-check");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not emit --paths for an empty path list", () => {
|
|
63
|
+
expect(buildRescueArgs({ paths: [] })).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq rescue — re-sync a local HQ tree to an upstream hq-core release (or a
|
|
3
|
+
* staging branch) WITHOUT destroying the user's local edits ("drift").
|
|
4
|
+
*
|
|
5
|
+
* The implementation lives in scripts/replace-rescue.sh, shipped with this
|
|
6
|
+
* package. This module resolves that script relative to the package (dist/cli
|
|
7
|
+
* → package root) and execs it against the caller's HQ root. The script was
|
|
8
|
+
* historically bundled inside the HQ Sync menubar app; it now lives here so a
|
|
9
|
+
* single copy is maintained and BOTH consumers — the menubar app (which
|
|
10
|
+
* bundles this package's copy) and `@indigoai-us/hq-cli`'s `hq rescue`
|
|
11
|
+
* command — drive the exact same rescue logic.
|
|
12
|
+
*
|
|
13
|
+
* The script is channel-agnostic: `--source <repo>` + `--ref <tag|branch>`
|
|
14
|
+
* select prod vs staging, and `--floor-sha` pins the three-way history floor.
|
|
15
|
+
* See scripts/replace-rescue.sh for the full classify/overlay/stamp algorithm.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "child_process";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
import path from "path";
|
|
20
|
+
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = path.dirname(__filename);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Absolute path to the bundled replace-rescue.sh. From the compiled module at
|
|
26
|
+
* dist/cli/rescue.js, the package root is two levels up; the script lives at
|
|
27
|
+
* <package-root>/scripts/replace-rescue.sh.
|
|
28
|
+
*/
|
|
29
|
+
export function rescueScriptPath(): string {
|
|
30
|
+
return path.resolve(__dirname, "..", "..", "scripts", "replace-rescue.sh");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RescueOptions {
|
|
34
|
+
/** HQ root to operate on. Passed as `--hq-root`. Defaults to the script's
|
|
35
|
+
* own resolution (cwd-based) when omitted. */
|
|
36
|
+
hqRoot?: string;
|
|
37
|
+
/** Upstream repo, e.g. `indigoai-us/hq-core`. Script default:
|
|
38
|
+
* `indigoai-us/hq-core-staging`. */
|
|
39
|
+
source?: string;
|
|
40
|
+
/** Git ref (tag or branch). Script default: `main`. */
|
|
41
|
+
ref?: string;
|
|
42
|
+
/** 40-char lowercase hex SHA pinning the three-way history floor. When
|
|
43
|
+
* omitted the script reads the on-disk sync stamp from core/core.yaml. */
|
|
44
|
+
floorSha?: string;
|
|
45
|
+
/** Narrow the wipe set to an explicit list of top-level paths (`--paths`,
|
|
46
|
+
* comma-joined). */
|
|
47
|
+
paths?: string[];
|
|
48
|
+
/** Extra top-level entries to always preserve (`--preserve`, repeatable). */
|
|
49
|
+
preserve?: string[];
|
|
50
|
+
/** Extra relative subpaths to carve out + restore (`--preserve-subpath`,
|
|
51
|
+
* repeatable). */
|
|
52
|
+
preserveSubpaths?: string[];
|
|
53
|
+
/** Plan only — classify + report, change nothing on disk (`--dry-run`). */
|
|
54
|
+
dryRun?: boolean;
|
|
55
|
+
/** Skip the interactive confirmation prompt (`--yes`). Required for any
|
|
56
|
+
* non-interactive caller. */
|
|
57
|
+
assumeYes?: boolean;
|
|
58
|
+
/** Skip the pre-op safety snapshot under ~/.hq/backups (`--no-backup`). */
|
|
59
|
+
noBackup?: boolean;
|
|
60
|
+
/** Override the backup root (`--backup-dir`). */
|
|
61
|
+
backupDir?: string;
|
|
62
|
+
/** Shallow-clone the source and skip the history-floor compare
|
|
63
|
+
* (`--no-history-check`). */
|
|
64
|
+
noHistoryCheck?: boolean;
|
|
65
|
+
/** Cloud-update mode (`--cloud-update`). */
|
|
66
|
+
cloudUpdate?: boolean;
|
|
67
|
+
/** GitHub token forwarded to the script as `GH_TOKEN` (avoids the
|
|
68
|
+
* anonymous-clone rate limit; required for private sources). */
|
|
69
|
+
ghToken?: string;
|
|
70
|
+
/** Escape hatch — additional raw args appended verbatim. */
|
|
71
|
+
extraArgs?: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RescueResult {
|
|
75
|
+
/** Exit status of the underlying script (0 = success). */
|
|
76
|
+
status: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the argv passed to bash (after the script path itself). Pure +
|
|
81
|
+
* exported so callers and tests can assert the flag mapping without spawning.
|
|
82
|
+
*/
|
|
83
|
+
export function buildRescueArgs(opts: RescueOptions = {}): string[] {
|
|
84
|
+
const args: string[] = [];
|
|
85
|
+
if (opts.hqRoot) args.push("--hq-root", opts.hqRoot);
|
|
86
|
+
if (opts.source) args.push("--source", opts.source);
|
|
87
|
+
if (opts.ref) args.push("--ref", opts.ref);
|
|
88
|
+
if (opts.floorSha) args.push("--floor-sha", opts.floorSha);
|
|
89
|
+
if (opts.paths && opts.paths.length > 0) args.push("--paths", opts.paths.join(","));
|
|
90
|
+
for (const p of opts.preserve ?? []) args.push("--preserve", p);
|
|
91
|
+
for (const sp of opts.preserveSubpaths ?? []) args.push("--preserve-subpath", sp);
|
|
92
|
+
if (opts.noHistoryCheck) args.push("--no-history-check");
|
|
93
|
+
if (opts.noBackup) args.push("--no-backup");
|
|
94
|
+
if (opts.backupDir) args.push("--backup-dir", opts.backupDir);
|
|
95
|
+
if (opts.cloudUpdate) args.push("--cloud-update");
|
|
96
|
+
if (opts.dryRun) args.push("--dry-run");
|
|
97
|
+
if (opts.assumeYes) args.push("--yes");
|
|
98
|
+
for (const extra of opts.extraArgs ?? []) args.push(extra);
|
|
99
|
+
return args;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run the rescue script against an HQ root. Synchronous — the script is
|
|
104
|
+
* long-running (clone + scan) and callers want the exit status + live output.
|
|
105
|
+
* stdout/stderr from the script are inherited so the user sees the scan log
|
|
106
|
+
* and the confirmation prompt (unless `assumeYes` is set).
|
|
107
|
+
*/
|
|
108
|
+
export function rescue(opts: RescueOptions = {}): RescueResult {
|
|
109
|
+
const script = rescueScriptPath();
|
|
110
|
+
const args = buildRescueArgs(opts);
|
|
111
|
+
const env = { ...process.env };
|
|
112
|
+
if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
|
|
113
|
+
const res = spawnSync("bash", [script, ...args], {
|
|
114
|
+
stdio: "inherit",
|
|
115
|
+
env,
|
|
116
|
+
});
|
|
117
|
+
if (res.error) {
|
|
118
|
+
process.stderr.write(`rescue: failed to run ${script}: ${res.error.message}\n`);
|
|
119
|
+
return { status: 1 };
|
|
120
|
+
}
|
|
121
|
+
return { status: res.status ?? 1 };
|
|
122
|
+
}
|
package/src/cli/share.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { VaultServiceConfig } from "../types.js";
|
|
|
15
15
|
// preserving sibling that puts a zero-byte object with target metadata
|
|
16
16
|
// instead of dereferencing the link.
|
|
17
17
|
vi.mock("../s3.js", () => ({
|
|
18
|
+
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
18
19
|
uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
|
|
19
20
|
uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
|
|
20
21
|
downloadFile: vi.fn().mockResolvedValue(undefined),
|
package/src/cli/share.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../c
|
|
|
12
12
|
import {
|
|
13
13
|
uploadFile,
|
|
14
14
|
uploadSymlink,
|
|
15
|
+
toPosixKey,
|
|
15
16
|
headRemoteFile,
|
|
16
17
|
deleteRemoteFile,
|
|
17
18
|
downloadFile,
|
|
@@ -1384,7 +1385,7 @@ function collectFiles(
|
|
|
1384
1385
|
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
1385
1386
|
continue;
|
|
1386
1387
|
}
|
|
1387
|
-
const relativePath = path.relative(syncRoot, absolutePath);
|
|
1388
|
+
const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
|
|
1388
1389
|
// Probe the filter with both isDir hints — we don't know whether
|
|
1389
1390
|
// the link's target is a file or a directory without
|
|
1390
1391
|
// stat-following the link, which we explicitly avoid (it would
|
|
@@ -1414,7 +1415,7 @@ function collectFiles(
|
|
|
1414
1415
|
if (!filter(absolutePath, true)) continue;
|
|
1415
1416
|
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
1416
1417
|
} else if (lstat.isFile()) {
|
|
1417
|
-
const relativePath = path.relative(syncRoot, absolutePath);
|
|
1418
|
+
const relativePath = toPosixKey(path.relative(syncRoot, absolutePath));
|
|
1418
1419
|
if (filter(absolutePath)) {
|
|
1419
1420
|
results.push({ kind: "file", absolutePath, relativePath });
|
|
1420
1421
|
}
|
|
@@ -1464,7 +1465,7 @@ function walkDir(
|
|
|
1464
1465
|
results.push({
|
|
1465
1466
|
kind: "symlink",
|
|
1466
1467
|
absolutePath,
|
|
1467
|
-
relativePath: path.relative(syncRoot, absolutePath),
|
|
1468
|
+
relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
|
|
1468
1469
|
target: fs.readlinkSync(absolutePath),
|
|
1469
1470
|
});
|
|
1470
1471
|
continue;
|
|
@@ -1480,7 +1481,7 @@ function walkDir(
|
|
|
1480
1481
|
results.push({
|
|
1481
1482
|
kind: "file",
|
|
1482
1483
|
absolutePath,
|
|
1483
|
-
relativePath: path.relative(syncRoot, absolutePath),
|
|
1484
|
+
relativePath: toPosixKey(path.relative(syncRoot, absolutePath)),
|
|
1484
1485
|
});
|
|
1485
1486
|
}
|
|
1486
1487
|
}
|
|
@@ -39,6 +39,7 @@ vi.mock("../s3.js", async () => {
|
|
|
39
39
|
const innerPath = await import("path");
|
|
40
40
|
const { vi: innerVi } = await import("vitest");
|
|
41
41
|
return {
|
|
42
|
+
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
42
43
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
43
44
|
downloadFile: innerVi
|
|
44
45
|
.fn()
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -21,6 +21,7 @@ vi.mock("../s3.js", async () => {
|
|
|
21
21
|
];
|
|
22
22
|
|
|
23
23
|
return {
|
|
24
|
+
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
24
25
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
25
26
|
downloadFile: innerVi.fn().mockImplementation(async (_ctx: unknown, _key: string, localPath: string) => {
|
|
26
27
|
const dir = innerPath.dirname(localPath);
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
listRemoteFiles,
|
|
19
19
|
deleteRemoteFile,
|
|
20
20
|
headRemoteFile,
|
|
21
|
+
toPosixKey,
|
|
21
22
|
} from "./s3.js";
|
|
22
23
|
|
|
23
24
|
export type { RemoteFile, UploadAuthor } from "./s3.js";
|
|
@@ -211,6 +212,14 @@ export type { AcceptOptions, AcceptResult } from "./cli/index.js";
|
|
|
211
212
|
export { promote } from "./cli/index.js";
|
|
212
213
|
export type { PromoteOptions, PromoteResult } from "./cli/index.js";
|
|
213
214
|
|
|
215
|
+
// Skill/personal-overlay mirroring + workers-registry regen (`hq master-sync`).
|
|
216
|
+
export { masterSync, masterSyncScriptPath } from "./cli/index.js";
|
|
217
|
+
export type { MasterSyncOptions, MasterSyncResult } from "./cli/index.js";
|
|
218
|
+
|
|
219
|
+
// Drift-preserving HQ-core re-sync — shared by the HQ Sync app and `hq rescue`.
|
|
220
|
+
export { rescue, rescueScriptPath, buildRescueArgs } from "./cli/index.js";
|
|
221
|
+
export type { RescueOptions, RescueResult } from "./cli/index.js";
|
|
222
|
+
|
|
214
223
|
export type {
|
|
215
224
|
EntityContext,
|
|
216
225
|
VaultCredentials,
|
package/src/s3.test.ts
CHANGED
|
@@ -82,6 +82,7 @@ vi.mock("@aws-sdk/client-s3", () => {
|
|
|
82
82
|
import {
|
|
83
83
|
uploadFile,
|
|
84
84
|
uploadSymlink,
|
|
85
|
+
toPosixKey,
|
|
85
86
|
downloadFile,
|
|
86
87
|
listRemoteFiles,
|
|
87
88
|
SYMLINK_BODY_PREFIX,
|
|
@@ -108,6 +109,55 @@ function makeCtx(): EntityContext {
|
|
|
108
109
|
};
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
describe("backslash key normalization (Windows client → POSIX S3 key)", () => {
|
|
113
|
+
// Regression for the Ridges vault bug: a non-POSIX (Windows) HQ Sync client
|
|
114
|
+
// built object keys from path.relative(), whose separator is "\\". Stored
|
|
115
|
+
// verbatim, `knowledge\books-eoi.md` had no "/", so the vault listing (which
|
|
116
|
+
// splits keys on "/") rendered it flat at the root as one oddly-named file.
|
|
117
|
+
let tmpFile: string;
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
sentCommands.length = 0;
|
|
121
|
+
tmpFile = path.join(
|
|
122
|
+
os.tmpdir(),
|
|
123
|
+
`s3-backslash-test-${Date.now()}-${Math.random()}.md`,
|
|
124
|
+
);
|
|
125
|
+
fs.writeFileSync(tmpFile, "hello");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("toPosixKey converts every backslash to a forward slash", () => {
|
|
129
|
+
expect(toPosixKey("knowledge\\books-eoi.md")).toBe("knowledge/books-eoi.md");
|
|
130
|
+
expect(toPosixKey("data\\sub\\boots-accounts.json")).toBe(
|
|
131
|
+
"data/sub/boots-accounts.json",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("toPosixKey leaves an already-POSIX key unchanged (idempotent)", () => {
|
|
136
|
+
expect(toPosixKey("knowledge/books-eoi.md")).toBe("knowledge/books-eoi.md");
|
|
137
|
+
expect(toPosixKey("flat.md")).toBe("flat.md");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("uploadFile stores a POSIX key when given a Windows-style backslash path", async () => {
|
|
141
|
+
await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
|
|
142
|
+
|
|
143
|
+
const put = sentCommands.find((c) => c.name === "PutObjectCommand");
|
|
144
|
+
expect(put).toBeDefined();
|
|
145
|
+
expect(put!.input.Key).toBe("knowledge/books-eoi.md");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("uploadSymlink stores a POSIX key when given a Windows-style backslash path", async () => {
|
|
149
|
+
await uploadSymlink(
|
|
150
|
+
makeCtx(),
|
|
151
|
+
"../knowledge-ridges/target.md",
|
|
152
|
+
"data\\boots-accounts.json",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const put = sentCommands.find((c) => c.name === "PutObjectCommand");
|
|
156
|
+
expect(put).toBeDefined();
|
|
157
|
+
expect(put!.input.Key).toBe("data/boots-accounts.json");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
111
161
|
describe("uploadFile", () => {
|
|
112
162
|
let tmpFile: string;
|
|
113
163
|
|
package/src/s3.ts
CHANGED
|
@@ -391,12 +391,35 @@ export async function primeUploads(
|
|
|
391
391
|
await io.prime("put", putKeys);
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Normalize an S3 object key to POSIX ("/") separators.
|
|
396
|
+
*
|
|
397
|
+
* S3 keys are always "/"-separated, and the vault listing reconstructs the
|
|
398
|
+
* folder tree by splitting keys on "/". A non-POSIX (Windows) sync client
|
|
399
|
+
* builds a key from `path.relative(...)`, whose separator is the native "\\";
|
|
400
|
+
* stored verbatim, a nested key like `knowledge\books-eoi.md` contains no "/"
|
|
401
|
+
* and the listing renders it flat at the vault root as one oddly-named file.
|
|
402
|
+
* Converting every "\\" to "/" makes the stored key POSIX regardless of the
|
|
403
|
+
* client OS.
|
|
404
|
+
*
|
|
405
|
+
* Mirrors the prefix guard in hq-pro files-acl (`FORBIDDEN_CHARS = /[?#\\[\]]/`,
|
|
406
|
+
* which already rejects "\\" on browse/grant prefixes). Uploads go direct to
|
|
407
|
+
* S3 via vended STS creds, so the server never re-validates the uploaded key —
|
|
408
|
+
* the upload primitives (uploadFile / uploadSymlink) are the only shared choke
|
|
409
|
+
* point, and they call this so a backslash key can never be stored again.
|
|
410
|
+
*/
|
|
411
|
+
export function toPosixKey(key: string): string {
|
|
412
|
+
return key.split("\\").join("/");
|
|
413
|
+
}
|
|
414
|
+
|
|
394
415
|
export async function uploadFile(
|
|
395
416
|
ctx: EntityContext,
|
|
396
417
|
localPath: string,
|
|
397
418
|
key: string,
|
|
398
419
|
author?: UploadAuthor,
|
|
399
420
|
): Promise<{ etag: string }> {
|
|
421
|
+
// Boundary guardrail: never store a non-POSIX key (see toPosixKey).
|
|
422
|
+
key = toPosixKey(key);
|
|
400
423
|
const io = resolveObjectIO(ctx);
|
|
401
424
|
const body = fs.readFileSync(localPath);
|
|
402
425
|
|
|
@@ -459,6 +482,8 @@ export async function uploadSymlink(
|
|
|
459
482
|
key: string,
|
|
460
483
|
author?: UploadAuthor,
|
|
461
484
|
): Promise<{ etag: string }> {
|
|
485
|
+
// Boundary guardrail: never store a non-POSIX key (see toPosixKey).
|
|
486
|
+
key = toPosixKey(key);
|
|
462
487
|
const io = resolveObjectIO(ctx);
|
|
463
488
|
const symlinkBody = encodeSymlinkBody(target);
|
|
464
489
|
|
package/src/watcher.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { FSWatcher } from "chokidar";
|
|
|
16
16
|
import type { EntityContext } from "./types.js";
|
|
17
17
|
import { createIgnoreFilter, isWithinSizeLimit } from "./ignore.js";
|
|
18
18
|
import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
|
|
19
|
-
import { uploadFile, deleteRemoteFile } from "./s3.js";
|
|
19
|
+
import { uploadFile, deleteRemoteFile, toPosixKey } from "./s3.js";
|
|
20
20
|
import type { UploadAuthor } from "./s3.js";
|
|
21
21
|
import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
|
|
22
22
|
import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL } from "./personal-vault.js";
|
|
@@ -259,7 +259,7 @@ export class SyncWatcher {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
private queueChange(type: "add" | "change" | "unlink", absolutePath: string): void {
|
|
262
|
-
const relativePath = path.relative(this.hqRoot, absolutePath);
|
|
262
|
+
const relativePath = toPosixKey(path.relative(this.hqRoot, absolutePath));
|
|
263
263
|
|
|
264
264
|
// Skip files that exceed size limit
|
|
265
265
|
if (type !== "unlink" && !isWithinSizeLimit(absolutePath)) {
|