@indigoai-us/hq-cloud 6.14.11 → 6.14.13
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/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +6 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +3 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +7 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +3 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +10 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +83 -33
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +93 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +23 -60
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +2 -2
- package/dist/cli/reindex.test.js +60 -109
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +42 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +5 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +3 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +14 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +8 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +44 -12
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +440 -76
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +174 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.js +683 -5
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +2 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +45 -9
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +77 -7
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +2 -1
- package/src/bin/sync-runner-company.ts +5 -0
- package/src/bin/sync-runner-planning.ts +2 -1
- package/src/bin/sync-runner-watch-loop.ts +7 -1
- package/src/bin/sync-runner-watch-routes.ts +2 -0
- package/src/bin/sync-runner.test.ts +122 -4
- package/src/bin/sync-runner.ts +92 -32
- package/src/cli/reindex.test.ts +63 -120
- package/src/cli/reindex.ts +23 -62
- package/src/cli/rescue-classify-ordering.test.ts +49 -0
- package/src/cli/rescue-core.ts +5 -1
- package/src/cli/rescue.reindex.test.ts +3 -2
- package/src/cli/share.test.ts +20 -0
- package/src/cli/share.ts +7 -1
- package/src/cli/sync.test.ts +14 -0
- package/src/cli/sync.ts +5 -1
- package/src/cognito-auth.test.ts +227 -0
- package/src/cognito-auth.ts +533 -71
- package/src/index.ts +5 -1
- package/src/machine-auth.test.ts +808 -5
- package/src/vault-client.test.ts +100 -11
- package/src/vault-client.ts +55 -10
- package/test/share-sync.integration.test.ts +12 -9
package/src/bin/sync-runner.ts
CHANGED
|
@@ -62,7 +62,9 @@ import * as fs from "fs";
|
|
|
62
62
|
import { randomUUID } from "node:crypto";
|
|
63
63
|
import { fileURLToPath } from "url";
|
|
64
64
|
import {
|
|
65
|
+
CognitoRefreshError,
|
|
65
66
|
getValidAccessToken,
|
|
67
|
+
invalidateCachedTokensByFingerprint,
|
|
66
68
|
loadCachedTokens,
|
|
67
69
|
VaultClient,
|
|
68
70
|
VaultAuthError,
|
|
@@ -532,6 +534,16 @@ export type { PullScope };
|
|
|
532
534
|
*/
|
|
533
535
|
const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
|
|
534
536
|
|
|
537
|
+
const REAUTH_MESSAGE =
|
|
538
|
+
"Your HQ session needs a quick refresh. Sign in again to keep sync moving.";
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Internal-only result used between a watch pass and its owning loop. The
|
|
542
|
+
* outer process still exits 0 (auth-required is a handled UX state), while
|
|
543
|
+
* the loop stops instead of retrying a known-unusable session forever.
|
|
544
|
+
*/
|
|
545
|
+
export const AUTH_REQUIRED_PASS_EXIT = 18;
|
|
546
|
+
|
|
535
547
|
/**
|
|
536
548
|
* Call `listMyMemberships()` with up to 3 attempts and a small linear
|
|
537
549
|
* backoff between them. The single network call that drives every cloud
|
|
@@ -589,6 +601,8 @@ export interface RunnerDeps {
|
|
|
589
601
|
stderr?: { write: (chunk: string) => boolean | void };
|
|
590
602
|
/** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
|
|
591
603
|
getAccessToken?: () => Promise<string>;
|
|
604
|
+
/** Invalidate only the rejected token generation. Injectable for tests. */
|
|
605
|
+
clearSession?: (tokenFingerprint?: string) => void;
|
|
592
606
|
/**
|
|
593
607
|
* Read the caller's identity claims (sub/email/name) off the cached Cognito
|
|
594
608
|
* idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
|
|
@@ -621,6 +635,8 @@ export interface RunnerDeps {
|
|
|
621
635
|
) => Promise<void>;
|
|
622
636
|
/** Internal: set when runRunner is invoked under the per-root operation lock. */
|
|
623
637
|
operationLockAlreadyHeld?: boolean;
|
|
638
|
+
/** Internal watch-loop result override; one-shot callers keep exit 0. */
|
|
639
|
+
authRequiredExitCode?: number;
|
|
624
640
|
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
625
641
|
share?: (options: ShareOptions) => Promise<ShareResult>;
|
|
626
642
|
/**
|
|
@@ -1109,14 +1125,27 @@ export async function runRunner(
|
|
|
1109
1125
|
const getAccessToken =
|
|
1110
1126
|
deps.getAccessToken ??
|
|
1111
1127
|
(() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
|
|
1128
|
+
const clearSession =
|
|
1129
|
+
deps.clearSession ?? invalidateCachedTokensByFingerprint;
|
|
1112
1130
|
try {
|
|
1113
1131
|
await getAccessToken();
|
|
1114
1132
|
} catch (err) {
|
|
1133
|
+
if (
|
|
1134
|
+
(err instanceof CognitoRefreshError && !err.requiresReauth) ||
|
|
1135
|
+
isTransientNetworkError(err)
|
|
1136
|
+
) {
|
|
1137
|
+
emit({
|
|
1138
|
+
type: "error",
|
|
1139
|
+
path: "(auth)",
|
|
1140
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1141
|
+
});
|
|
1142
|
+
return TRANSIENT_NETWORK_EXIT;
|
|
1143
|
+
}
|
|
1115
1144
|
emit({
|
|
1116
1145
|
type: "auth-error",
|
|
1117
|
-
message:
|
|
1146
|
+
message: REAUTH_MESSAGE,
|
|
1118
1147
|
});
|
|
1119
|
-
return 0;
|
|
1148
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1120
1149
|
}
|
|
1121
1150
|
|
|
1122
1151
|
// ---- vault client -----------------------------------------------------
|
|
@@ -1257,11 +1286,12 @@ export async function runRunner(
|
|
|
1257
1286
|
memberships = resolution.memberships;
|
|
1258
1287
|
} catch (err) {
|
|
1259
1288
|
if (err instanceof VaultAuthError) {
|
|
1289
|
+
clearSession(err.tokenFingerprint);
|
|
1260
1290
|
emit({
|
|
1261
1291
|
type: "auth-error",
|
|
1262
|
-
message:
|
|
1292
|
+
message: REAUTH_MESSAGE,
|
|
1263
1293
|
});
|
|
1264
|
-
return 0;
|
|
1294
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1265
1295
|
}
|
|
1266
1296
|
// Surface the failure as an error event for observability either way.
|
|
1267
1297
|
emit({
|
|
@@ -1279,15 +1309,31 @@ export async function runRunner(
|
|
|
1279
1309
|
return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
|
|
1280
1310
|
}
|
|
1281
1311
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1312
|
+
let targetPlan: Awaited<ReturnType<typeof buildFanoutPlan>>;
|
|
1313
|
+
try {
|
|
1314
|
+
targetPlan = await buildFanoutPlan({
|
|
1315
|
+
memberships,
|
|
1316
|
+
companies: parsed.companies,
|
|
1317
|
+
personal: parsed.personal,
|
|
1318
|
+
skipPersonal: parsed.skipPersonal,
|
|
1319
|
+
client,
|
|
1320
|
+
claims,
|
|
1321
|
+
resolveSkipPersonal,
|
|
1322
|
+
});
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
if (err instanceof VaultAuthError) {
|
|
1325
|
+
clearSession(err.tokenFingerprint);
|
|
1326
|
+
emit({ type: "auth-error", message: REAUTH_MESSAGE });
|
|
1327
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1328
|
+
}
|
|
1329
|
+
emit({
|
|
1330
|
+
type: "error",
|
|
1331
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1332
|
+
path: "(discovery)",
|
|
1333
|
+
});
|
|
1334
|
+
emitRunnerTerminalTelemetry({ status: "failed" });
|
|
1335
|
+
return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
|
|
1336
|
+
}
|
|
1291
1337
|
if (targetPlan.status === "setup-needed") {
|
|
1292
1338
|
emit({ type: "setup-needed" });
|
|
1293
1339
|
return 0;
|
|
@@ -1306,24 +1352,34 @@ export async function runRunner(
|
|
|
1306
1352
|
// ---- fanout -----------------------------------------------------------
|
|
1307
1353
|
const syncFn = deps.sync ?? defaultSync;
|
|
1308
1354
|
const shareFn = deps.share ?? defaultShare;
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1355
|
+
let fanout: Awaited<ReturnType<typeof executeCompanyFanout>>;
|
|
1356
|
+
try {
|
|
1357
|
+
fanout = await executeCompanyFanout({
|
|
1358
|
+
plan,
|
|
1359
|
+
direction: parsed.direction,
|
|
1360
|
+
hqRoot: parsed.hqRoot,
|
|
1361
|
+
onConflict: parsed.onConflict,
|
|
1362
|
+
client,
|
|
1363
|
+
vaultConfig,
|
|
1364
|
+
...(uploadAuthor ? { uploadAuthor } : {}),
|
|
1365
|
+
...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
|
|
1366
|
+
syncFn,
|
|
1367
|
+
shareFn,
|
|
1368
|
+
resolveDeletePolicy,
|
|
1369
|
+
emit,
|
|
1370
|
+
scopePaths: parsed.scopePaths,
|
|
1371
|
+
telemetryClient: client,
|
|
1372
|
+
telemetryClaims: claims,
|
|
1373
|
+
telemetrySessionId,
|
|
1374
|
+
});
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
if (err instanceof VaultAuthError) {
|
|
1377
|
+
clearSession(err.tokenFingerprint);
|
|
1378
|
+
emit({ type: "auth-error", message: REAUTH_MESSAGE });
|
|
1379
|
+
return deps.authRequiredExitCode ?? 0;
|
|
1380
|
+
}
|
|
1381
|
+
throw err;
|
|
1382
|
+
}
|
|
1327
1383
|
const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1328
1384
|
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1329
1385
|
|
|
@@ -1787,7 +1843,11 @@ export async function runRunnerWithLoop(
|
|
|
1787
1843
|
const parsed = parseArgs(argv);
|
|
1788
1844
|
const runtime = {
|
|
1789
1845
|
runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
|
|
1790
|
-
runRunner(passArgv, {
|
|
1846
|
+
runRunner(passArgv, {
|
|
1847
|
+
operationLockAlreadyHeld: true,
|
|
1848
|
+
authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
|
|
1849
|
+
}),
|
|
1850
|
+
authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
|
|
1791
1851
|
defaultGetIdTokenClaims,
|
|
1792
1852
|
defaultGetAccessToken: () =>
|
|
1793
1853
|
getValidAccessToken(DEFAULT_COGNITO, { interactive: false }),
|
package/src/cli/reindex.test.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The logic is implemented natively in TypeScript in ./reindex.ts; these tests
|
|
6
6
|
* exercise reindex() directly against a temp HQ tree, asserting the observable
|
|
7
|
-
* filesystem outcomes (skill wrappers, personal-overlay
|
|
8
|
-
* re-deriving the implementation internals.
|
|
7
|
+
* filesystem outcomes (skill wrappers, retired personal-overlay mirror cleanup)
|
|
8
|
+
* rather than re-deriving the implementation internals.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
@@ -112,26 +112,29 @@ describe("reindex", () => {
|
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
it("
|
|
115
|
+
it("does NOT mirror the personal overlay into core/<type>/ (read directly now)", () => {
|
|
116
116
|
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
117
117
|
fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
|
|
118
118
|
|
|
119
119
|
const { status } = reindex({ repoRoot: root });
|
|
120
120
|
expect(status).toBe(0);
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
// personal is the sole read source — reindex must NOT create a mirror
|
|
123
|
+
// symlink in core/policies. The HQ tooling reads personal/policies directly,
|
|
124
|
+
// so a mirror would only double-surface every entry.
|
|
125
|
+
expect(fs.existsSync(path.join(root, "core/policies/myrule.md"))).toBe(false);
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
it("collapses per-file
|
|
128
|
-
// A REAL
|
|
129
|
-
// no-op that used to spam one stderr line per entry and flood the
|
|
130
|
-
// breadcrumb ring, evicting the real per-company sync error.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
it("collapses per-file wrapper-skip spam into ONE summary line (HQ-SYNC-2)", () => {
|
|
129
|
+
// A REAL file sits where a managed skill-wrapper symlink would go — the
|
|
130
|
+
// legitimate no-op that used to spam one stderr line per entry and flood the
|
|
131
|
+
// Sentry breadcrumb ring, evicting the real per-company sync error.
|
|
132
|
+
writeSkill("core/skills/demo");
|
|
133
|
+
reindex({ repoRoot: root });
|
|
134
|
+
// Replace the managed per-file symlink with a real (non-symlink) file.
|
|
135
|
+
const slot = path.join(root, ".claude/skills/core:demo/SKILL.md");
|
|
136
|
+
fs.rmSync(slot);
|
|
137
|
+
fs.writeFileSync(slot, "REAL file, not a link\n");
|
|
135
138
|
|
|
136
139
|
const writes: string[] = [];
|
|
137
140
|
const spy = vi
|
|
@@ -150,7 +153,7 @@ describe("reindex", () => {
|
|
|
150
153
|
|
|
151
154
|
expect(status).toBe(0);
|
|
152
155
|
// The pre-existing real file is left untouched (never overwritten).
|
|
153
|
-
expect(fs.lstatSync(
|
|
156
|
+
expect(fs.lstatSync(slot).isSymbolicLink()).toBe(false);
|
|
154
157
|
// No per-file spam…
|
|
155
158
|
expect(stderr).not.toContain("already exists and is not a symlink; skipping");
|
|
156
159
|
// …just ONE collapsed summary naming the count.
|
|
@@ -158,11 +161,12 @@ describe("reindex", () => {
|
|
|
158
161
|
expect(stderr).toContain("HQ_REINDEX_VERBOSE=1");
|
|
159
162
|
});
|
|
160
163
|
|
|
161
|
-
it("keeps per-file
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
fs.
|
|
164
|
+
it("keeps per-file wrapper-skip detail under HQ_REINDEX_VERBOSE=1", () => {
|
|
165
|
+
writeSkill("core/skills/demo");
|
|
166
|
+
reindex({ repoRoot: root });
|
|
167
|
+
const slot = path.join(root, ".claude/skills/core:demo/SKILL.md");
|
|
168
|
+
fs.rmSync(slot);
|
|
169
|
+
fs.writeFileSync(slot, "REAL\n");
|
|
166
170
|
|
|
167
171
|
const writes: string[] = [];
|
|
168
172
|
const spy = vi
|
|
@@ -180,7 +184,7 @@ describe("reindex", () => {
|
|
|
180
184
|
}
|
|
181
185
|
const stderr = writes.join("");
|
|
182
186
|
expect(stderr).toContain(
|
|
183
|
-
"core/
|
|
187
|
+
".claude/skills/core:demo/SKILL.md already exists and is not a symlink; skipping",
|
|
184
188
|
);
|
|
185
189
|
});
|
|
186
190
|
|
|
@@ -203,75 +207,55 @@ describe("reindex", () => {
|
|
|
203
207
|
).toBe("../../../core/skills/demo/SKILL.md");
|
|
204
208
|
});
|
|
205
209
|
|
|
206
|
-
// ──
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// personal
|
|
210
|
+
// ── Migration cleanup: retired personal-overlay mirror symlinks ──────────
|
|
211
|
+
// reindex no longer CREATES core/<type>/<entry> -> ../../personal/<type>/<entry>
|
|
212
|
+
// mirror symlinks — personal is read directly. A managed mirror symlink a
|
|
213
|
+
// PRIOR release left behind must be pruned on the next reindex, WHETHER OR NOT
|
|
214
|
+
// its personal source still exists, so it can never double-surface alongside
|
|
215
|
+
// the direct read.
|
|
216
|
+
|
|
217
|
+
// Forge a legacy managed mirror symlink exactly as the old reindex would have.
|
|
218
|
+
function legacyMirror(type: string, entry: string): string {
|
|
219
|
+
const coreDir = path.join(root, "core", type);
|
|
220
|
+
fs.mkdirSync(coreDir, { recursive: true });
|
|
221
|
+
const link = path.join(coreDir, entry);
|
|
222
|
+
fs.symlinkSync(`../../personal/${type}/${entry}`, link);
|
|
223
|
+
return link;
|
|
224
|
+
}
|
|
211
225
|
|
|
212
|
-
it("prunes
|
|
226
|
+
it("prunes a legacy mirror symlink even when its personal source still exists", () => {
|
|
213
227
|
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
214
228
|
fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
|
|
215
|
-
|
|
216
|
-
const link = path.join(root, "core/policies/myrule.md");
|
|
229
|
+
const link = legacyMirror("policies", "myrule.md");
|
|
217
230
|
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
218
231
|
|
|
219
|
-
// Retire the personal source, then reindex again.
|
|
220
|
-
fs.rmSync(path.join(root, "personal/policies/myrule.md"));
|
|
221
232
|
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
222
233
|
|
|
223
|
-
//
|
|
234
|
+
// Pruned — personal/policies/myrule.md is read directly now, no mirror.
|
|
224
235
|
expect(fs.existsSync(link)).toBe(false);
|
|
225
236
|
expect(() => fs.lstatSync(link)).toThrow();
|
|
237
|
+
// The personal source itself is untouched.
|
|
238
|
+
expect(fs.existsSync(path.join(root, "personal/policies/myrule.md"))).toBe(true);
|
|
226
239
|
});
|
|
227
240
|
|
|
228
|
-
it("prunes
|
|
229
|
-
|
|
230
|
-
fs.
|
|
231
|
-
fs.writeFileSync(path.join(root, "personal/policies/b.md"), "b\n");
|
|
232
|
-
reindex({ repoRoot: root });
|
|
233
|
-
expect(fs.lstatSync(path.join(root, "core/policies/a.md")).isSymbolicLink()).toBe(true);
|
|
234
|
-
expect(fs.lstatSync(path.join(root, "core/policies/b.md")).isSymbolicLink()).toBe(true);
|
|
241
|
+
it("prunes a legacy mirror symlink whose personal source was deleted", () => {
|
|
242
|
+
const link = legacyMirror("policies", "gone.md"); // dangling: no source
|
|
243
|
+
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
235
244
|
|
|
236
|
-
// Nuke the whole personal/policies dir (the creation loop's isDir guard would
|
|
237
|
-
// skip the type entirely — the prune must still run and clean both links).
|
|
238
|
-
fs.rmSync(path.join(root, "personal/policies"), { recursive: true, force: true });
|
|
239
245
|
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
240
|
-
|
|
241
|
-
expect(fs.existsSync(path.join(root, "core/policies/a.md"))).toBe(false);
|
|
242
|
-
expect(fs.existsSync(path.join(root, "core/policies/b.md"))).toBe(false);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("keeps a LIVE personal-overlay mirror symlink (only orphans are pruned)", () => {
|
|
246
|
-
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
247
|
-
fs.writeFileSync(path.join(root, "personal/policies/keep.md"), "keep\n");
|
|
248
|
-
fs.writeFileSync(path.join(root, "personal/policies/drop.md"), "drop\n");
|
|
249
|
-
reindex({ repoRoot: root });
|
|
250
|
-
|
|
251
|
-
// Retire only one source; the other stays live.
|
|
252
|
-
fs.rmSync(path.join(root, "personal/policies/drop.md"));
|
|
253
|
-
reindex({ repoRoot: root });
|
|
254
|
-
|
|
255
|
-
const keep = path.join(root, "core/policies/keep.md");
|
|
256
|
-
expect(fs.lstatSync(keep).isSymbolicLink()).toBe(true);
|
|
257
|
-
expect(fs.readlinkSync(keep)).toBe("../../personal/policies/keep.md");
|
|
258
|
-
expect(fs.readFileSync(keep, "utf-8")).toBe("keep\n");
|
|
259
|
-
expect(fs.existsSync(path.join(root, "core/policies/drop.md"))).toBe(false);
|
|
246
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
260
247
|
});
|
|
261
248
|
|
|
262
|
-
it("prunes
|
|
263
|
-
// personal/knowledge/<kb>/ mirrors as a directory symlink; deleting the kb
|
|
264
|
-
// must prune the dangling directory symlink too (rmSync on a symlink, not
|
|
265
|
-
// recursive into a resolved dir).
|
|
249
|
+
it("prunes a legacy mirror DIRECTORY symlink without recursing into the source (DEV-1767 shape)", () => {
|
|
266
250
|
fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
|
|
267
251
|
fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
|
|
268
|
-
|
|
269
|
-
const link = path.join(root, "core/knowledge/my-kb");
|
|
252
|
+
const link = legacyMirror("knowledge", "my-kb"); // directory-shaped symlink
|
|
270
253
|
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
271
254
|
|
|
272
|
-
fs.rmSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true, force: true });
|
|
273
255
|
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
274
256
|
expect(fs.existsSync(link)).toBe(false);
|
|
257
|
+
// rmSync on the symlink must NOT recurse into the resolved directory.
|
|
258
|
+
expect(fs.existsSync(path.join(root, "personal/knowledge/my-kb/note.md"))).toBe(true);
|
|
275
259
|
});
|
|
276
260
|
|
|
277
261
|
it("leaves a real core file and a foreign symlink in core/<type>/ untouched (cross-check invariant)", () => {
|
|
@@ -369,24 +353,21 @@ describe("reindex", () => {
|
|
|
369
353
|
expect(fs.readlinkSync(agentsLink)).toBe("../../../core/skills/demo/agents");
|
|
370
354
|
});
|
|
371
355
|
|
|
372
|
-
it("
|
|
373
|
-
// personal/knowledge/<kb>/ is a directory of notes
|
|
374
|
-
//
|
|
356
|
+
it("does NOT mirror a personal/<type> DIRECTORY entry into core/ (read directly now)", () => {
|
|
357
|
+
// personal/knowledge/<kb>/ is a directory of notes. It used to mirror as a
|
|
358
|
+
// core/ directory symlink (the DEV-1767 shape rescue/update-hq choked on).
|
|
359
|
+
// The mirror is retired: qmd indexes personal/knowledge directly, so reindex
|
|
360
|
+
// must leave core/knowledge alone — nothing to choke on anymore.
|
|
375
361
|
fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
|
|
376
362
|
fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "owned by personal\n");
|
|
377
363
|
|
|
378
364
|
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
379
365
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
expect(fs.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
// Idempotent re-run: existing directory symlink recognised, left in place.
|
|
387
|
-
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
388
|
-
expect(fs.readlinkSync(link)).toBe("../../personal/knowledge/my-kb");
|
|
389
|
-
expect(fs.readFileSync(path.join(link, "note.md"), "utf-8")).toBe("owned by personal\n");
|
|
366
|
+
expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
|
|
367
|
+
// The personal source is untouched.
|
|
368
|
+
expect(fs.readFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "utf-8")).toBe(
|
|
369
|
+
"owned by personal\n",
|
|
370
|
+
);
|
|
390
371
|
});
|
|
391
372
|
|
|
392
373
|
// ── operation lock: reindex uses a SEPARATE scope from sync/rescue ──────
|
|
@@ -497,22 +478,6 @@ describe("reindex", () => {
|
|
|
497
478
|
}
|
|
498
479
|
});
|
|
499
480
|
|
|
500
|
-
it("does not abort reindex when a core/<type> mirror mkdir fails", () => {
|
|
501
|
-
writeSkill("core/skills/demo");
|
|
502
|
-
fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
|
|
503
|
-
fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
|
|
504
|
-
|
|
505
|
-
hoisted.failMkdirContaining = path.join("core", "knowledge");
|
|
506
|
-
try {
|
|
507
|
-
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
508
|
-
} finally {
|
|
509
|
-
hoisted.failMkdirContaining = "";
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// The mirror for the failed type is skipped; skill surfacing still happened.
|
|
513
|
-
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
|
|
514
|
-
});
|
|
515
|
-
|
|
516
481
|
// ── HQ-CLI-4: symlink creation is privileged on Windows. Without Developer
|
|
517
482
|
// Mode, fs.symlinkSync throws EPERM. The HQ-B0 work guarded every mkdir
|
|
518
483
|
// site but left the symlink sites unguarded, so `hq reindex` still aborted
|
|
@@ -536,28 +501,6 @@ describe("reindex", () => {
|
|
|
536
501
|
).toBe(false);
|
|
537
502
|
});
|
|
538
503
|
|
|
539
|
-
it("does not abort reindex when a personal-mirror symlink fails with EPERM, and other work still runs", () => {
|
|
540
|
-
writeSkill("core/skills/demo");
|
|
541
|
-
fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
|
|
542
|
-
fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
|
|
543
|
-
|
|
544
|
-
// Fail ONLY the core/<type> personal-mirror symlinks; wrapper symlinks
|
|
545
|
-
// succeed, proving the skip is scoped and the reindex completes.
|
|
546
|
-
hoisted.failSymlinkContaining = path.join("core", "knowledge");
|
|
547
|
-
try {
|
|
548
|
-
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
549
|
-
} finally {
|
|
550
|
-
hoisted.failSymlinkContaining = "";
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// The personal mirror was skipped (EPERM)...
|
|
554
|
-
expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
|
|
555
|
-
// ...but unaffected work still happened: the skill wrapper + its symlink.
|
|
556
|
-
expect(
|
|
557
|
-
fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
|
|
558
|
-
).toBe(true);
|
|
559
|
-
});
|
|
560
|
-
|
|
561
504
|
// --- multi-harness session-log capture into workspace/.session-logs/<harness>/
|
|
562
505
|
describe("session-log capture", () => {
|
|
563
506
|
let claudeDir: string; // CLAUDE_CONFIG_DIR
|
package/src/cli/reindex.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
|
|
3
|
-
* .claude/skills/<ns>:<skill>/ (one symlink per source file),
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* .claude/skills/<ns>:<skill>/ (one symlink per source file), prunes orphan
|
|
4
|
+
* wrappers + legacy command symlinks (plus a migration cleanup of retired
|
|
5
|
+
* personal/{knowledge,policies,workers,settings} mirror symlinks in core/<type>/
|
|
6
|
+
* — that overlay is now read directly from personal/, not mirrored), captures
|
|
7
|
+
* this HQ tree's coding-harness session logs (Claude Code / Codex / Grok) into
|
|
7
8
|
* workspace/.session-logs/<harness>/, and regenerates the workers registry.
|
|
8
9
|
*
|
|
9
10
|
* The logic historically lived in a bundled bash script (scripts/reindex.sh)
|
|
@@ -689,63 +690,24 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
689
690
|
}
|
|
690
691
|
}
|
|
691
692
|
|
|
692
|
-
// --- Personal
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
for (const entry of globEntries(personalDir)) {
|
|
702
|
-
const entryPath = path.join(personalDir, entry);
|
|
703
|
-
if (!existsFollow(entryPath)) continue;
|
|
704
|
-
|
|
705
|
-
const linkPath = path.join(coreDir, entry);
|
|
706
|
-
const relativeTarget = `../../personal/${type}/${entry}`;
|
|
707
|
-
|
|
708
|
-
const lst = lstatOrNull(linkPath);
|
|
709
|
-
if (lst && lst.isSymbolicLink()) {
|
|
710
|
-
const current = readlinkOrNull(linkPath);
|
|
711
|
-
if (current === relativeTarget) continue;
|
|
712
|
-
skippedPointsElsewhere++;
|
|
713
|
-
if (verbose) {
|
|
714
|
-
warn(
|
|
715
|
-
`reindex: core/${type}/${entry} already points to '${current}' (expected '${relativeTarget}'); leaving alone`,
|
|
716
|
-
);
|
|
717
|
-
}
|
|
718
|
-
continue;
|
|
719
|
-
} else if (lst) {
|
|
720
|
-
skippedNonSymlink++;
|
|
721
|
-
if (verbose) {
|
|
722
|
-
warn(`reindex: core/${type}/${entry} already exists and is not a symlink; skipping`);
|
|
723
|
-
}
|
|
724
|
-
continue;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
safeSymlink(relativeTarget, linkPath, `core/${type}/${entry}`);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// --- Cleanup pass C: drop orphan personal-overlay mirror symlinks ----------
|
|
732
|
-
// The mirror above creates core/<type>/<entry> -> ../../personal/<type>/<entry>
|
|
733
|
-
// for each LIVE personal entry, but historically never removed that symlink
|
|
734
|
-
// when its personal source was later deleted or renamed — leaving a dangling
|
|
735
|
-
// link in core/<type>/ (e.g. a retired personal policy that still surfaced via
|
|
736
|
-
// the policy-trigger hook until a human hand-deleted it). This pass prunes it.
|
|
693
|
+
// --- Personal-overlay mirroring: RETIRED + migration cleanup ---------------
|
|
694
|
+
// personal/{knowledge,policies,workers,settings} is now read DIRECTLY by the
|
|
695
|
+
// HQ tooling (the inject-policy-on-trigger hook reads personal/policies, the
|
|
696
|
+
// workers-registry generator walks personal/workers, qmd-reindex-after-sync
|
|
697
|
+
// registers a personal/knowledge collection, and repo-run-registry reads
|
|
698
|
+
// personal/settings/orchestrator.yaml). personal is the SOLE read source for
|
|
699
|
+
// the personal overlay, so reindex no longer SYMLINKS personal/<type>/<entry>
|
|
700
|
+
// into core/<type>/<entry> — a mirror would now double-surface every entry
|
|
701
|
+
// (read once directly, once via the link).
|
|
737
702
|
//
|
|
738
|
-
//
|
|
739
|
-
// core/<type>/<entry>
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
// precisely "the personal source was removed". This runs independently of the
|
|
747
|
-
// creation loop above (note: no `isDir(personalDir)` guard) so that deleting
|
|
748
|
-
// the ENTIRE personal/<type>/ directory still prunes every mirror it produced.
|
|
703
|
+
// Migration cleanup: prune any mirror symlink a PRIOR release created —
|
|
704
|
+
// core/<type>/<entry> whose target is EXACTLY `../../personal/<type>/<entry>`.
|
|
705
|
+
// That exact-target discriminator (policy wrapper-sync-orphan-cleanup-cross-
|
|
706
|
+
// check) means a real release-shipped core file (not a symlink) or a human-
|
|
707
|
+
// authored symlink pointing elsewhere is never touched. Unlike the old
|
|
708
|
+
// "Cleanup pass C", this prunes the link WHETHER OR NOT its personal source
|
|
709
|
+
// still exists: the mirror is retired, so every managed link is now stale.
|
|
710
|
+
// Kept for a few releases so existing trees self-heal on the next reindex.
|
|
749
711
|
for (const type of ["knowledge", "policies", "workers", "settings"]) {
|
|
750
712
|
const coreDir = path.join(root, "core", type);
|
|
751
713
|
for (const entry of globEntries(coreDir)) {
|
|
@@ -753,8 +715,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
753
715
|
const lst = lstatOrNull(linkPath);
|
|
754
716
|
if (!lst || !lst.isSymbolicLink()) continue; // real file/dir → not ours
|
|
755
717
|
if (readlinkOrNull(linkPath) !== `../../personal/${type}/${entry}`) continue; // not a managed mirror link
|
|
756
|
-
|
|
757
|
-
// Managed mirror symlink whose personal source is gone → prune.
|
|
718
|
+
// Managed mirror symlink from a prior release → prune (mirror is retired).
|
|
758
719
|
try {
|
|
759
720
|
fs.rmSync(linkPath);
|
|
760
721
|
} catch {
|
|
@@ -143,6 +143,10 @@ describe.skipIf(!gitAvailable)("rescue classify-before-delete + dir-symlink hand
|
|
|
143
143
|
git(workDir, "init", "-b", "main", "upstream");
|
|
144
144
|
fs.writeFileSync(path.join(upstream, "core/a.md"), "v1\n");
|
|
145
145
|
fs.writeFileSync(path.join(upstream, "core/b.md"), "beta\n");
|
|
146
|
+
// `.claude/settings.json` is intentionally not supplied by a personal
|
|
147
|
+
// vault pull. Rescue must lay it down from the release scaffold instead.
|
|
148
|
+
fs.mkdirSync(path.join(upstream, ".claude"), { recursive: true });
|
|
149
|
+
fs.writeFileSync(path.join(upstream, ".claude/settings.json"), '{"release":true}\n');
|
|
146
150
|
fs.mkdirSync(path.join(upstream, "core/blocker"), { recursive: true });
|
|
147
151
|
fs.writeFileSync(path.join(upstream, "core/blocker/second.md"), "upstream\n");
|
|
148
152
|
git(upstream, "add", "-A");
|
|
@@ -465,6 +469,51 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
465
469
|
}
|
|
466
470
|
});
|
|
467
471
|
|
|
472
|
+
it("preserves unreadable machine-local state while overlaying release-owned .claude files", () => {
|
|
473
|
+
const hqRoot = makeHqRoot();
|
|
474
|
+
const backupRoot = path.join(workDir, `local-state-backups-${caseSeq}`);
|
|
475
|
+
const deviceId = path.join(hqRoot, ".local/state/gh/device-id");
|
|
476
|
+
fs.mkdirSync(path.dirname(deviceId), { recursive: true });
|
|
477
|
+
fs.writeFileSync(deviceId, "machine-specific\n");
|
|
478
|
+
// SSM previously ran as root with an empty HOME, so gh could leave this
|
|
479
|
+
// exact path root-owned and unreadable to the outpost user. A mode-000
|
|
480
|
+
// fixture exercises the same copy failure without requiring root in CI.
|
|
481
|
+
fs.chmodSync(deviceId, 0o000);
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const r = runRescueCapture(
|
|
485
|
+
[
|
|
486
|
+
"--hq-root", hqRoot,
|
|
487
|
+
"--source", "test/repo",
|
|
488
|
+
"--ref", "main",
|
|
489
|
+
"--floor-sha", floorSha,
|
|
490
|
+
"--backup-dir", backupRoot,
|
|
491
|
+
"--yes",
|
|
492
|
+
],
|
|
493
|
+
baseEnv,
|
|
494
|
+
);
|
|
495
|
+
const out = `${r.stdout}\n${r.stderr}\n${String(r.threw ?? "")}`;
|
|
496
|
+
|
|
497
|
+
expect(r.threw, out).toBeUndefined();
|
|
498
|
+
expect(r.status, out).toBe(0);
|
|
499
|
+
// The core fixture proves a real rescue apply occurred, while this
|
|
500
|
+
// release-owned setting proves `.claude` reached the outpost.
|
|
501
|
+
expect(fs.readFileSync(path.join(hqRoot, "core/a.md"), "utf-8")).toBe("v2\n");
|
|
502
|
+
expect(fs.readFileSync(path.join(hqRoot, ".claude/settings.json"), "utf-8")).toBe(
|
|
503
|
+
'{"release":true}\n',
|
|
504
|
+
);
|
|
505
|
+
// `.local` survives untouched and is not copied into the safety snapshot.
|
|
506
|
+
expect(fs.statSync(deviceId).mode & 0o777).toBe(0o000);
|
|
507
|
+
const snapshots = fs.readdirSync(backupRoot).filter((name) => name.startsWith("pre-update-"));
|
|
508
|
+
expect(snapshots).toHaveLength(1);
|
|
509
|
+
expect(lexists(path.join(backupRoot, snapshots[0], ".local"))).toBe(false);
|
|
510
|
+
} finally {
|
|
511
|
+
// The fixture is owned by the test process, so restore readability before
|
|
512
|
+
// the enclosing temp-tree cleanup even when an assertion fails.
|
|
513
|
+
fs.chmodSync(deviceId, 0o600);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
468
517
|
it("dry-run never allocates or prunes snapshots", () => {
|
|
469
518
|
const hqRoot = makeHqRoot();
|
|
470
519
|
const backupRoot = path.join(workDir, `dry-backups-${caseSeq}`);
|