@indigoai-us/hq-cloud 5.11.2 → 5.12.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/bin/sync-runner.d.ts +20 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +136 -8
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +178 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +26 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +14 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +137 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/remote-pull.d.ts +51 -0
- package/dist/remote-pull.d.ts.map +1 -0
- package/dist/remote-pull.js +40 -0
- package/dist/remote-pull.js.map +1 -0
- package/dist/remote-pull.test.d.ts +2 -0
- package/dist/remote-pull.test.d.ts.map +1 -0
- package/dist/remote-pull.test.js +229 -0
- package/dist/remote-pull.test.js.map +1 -0
- package/dist/s3.d.ts +12 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +44 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.d.ts +9 -0
- package/dist/s3.test.d.ts.map +1 -0
- package/dist/s3.test.js +164 -0
- package/dist/s3.test.js.map +1 -0
- package/dist/watcher.d.ts +3 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +6 -2
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +234 -0
- package/src/bin/sync-runner.ts +143 -8
- package/src/cli/share.test.ts +170 -0
- package/src/cli/share.ts +40 -4
- package/src/index.ts +1 -1
- package/src/remote-pull.test.ts +241 -0
- package/src/remote-pull.ts +101 -0
- package/src/s3.test.ts +166 -0
- package/src/s3.ts +63 -0
- package/src/watcher.ts +7 -2
package/src/bin/sync-runner.ts
CHANGED
|
@@ -78,6 +78,7 @@ import type {
|
|
|
78
78
|
import { share as defaultShare } from "../cli/share.js";
|
|
79
79
|
import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
80
80
|
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
81
|
+
import type { UploadAuthor } from "../s3.js";
|
|
81
82
|
|
|
82
83
|
/**
|
|
83
84
|
* Sync direction for a run.
|
|
@@ -114,6 +115,58 @@ const DEFAULT_VAULT_API_URL =
|
|
|
114
115
|
|
|
115
116
|
const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
116
117
|
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Personal-vault scope (exclusion list)
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
//
|
|
122
|
+
// Top-level directories under `hq_root/` that the personal-vault push MUST
|
|
123
|
+
// NOT upload. Mirrors the Rust constant of the same name in
|
|
124
|
+
// `hq-sync/src-tauri/src/commands/personal.rs` so the Tauri menubar's
|
|
125
|
+
// first-push and this Node runner's steady-state push enforce identical
|
|
126
|
+
// scope. Every other top-level entry under hq_root (e.g. `.claude/`,
|
|
127
|
+
// `knowledge/`, `modules/`, `README.md`, `.codex/`) is included, subject
|
|
128
|
+
// to the gitignore/hqignore filter that share() applies per-file.
|
|
129
|
+
//
|
|
130
|
+
// Rationale per entry:
|
|
131
|
+
// - `.git`: a repo's internal state is large, opaque, and useless after
|
|
132
|
+
// sync; .gitignore alone doesn't cover `.git/` because it's the repo
|
|
133
|
+
// itself, not a tracked path.
|
|
134
|
+
// - `companies/`: synced separately by the runner's per-membership fanout;
|
|
135
|
+
// do not double-write into the personal vault.
|
|
136
|
+
// - `core/`, `data/`, `personal/`, `repos/`, `workspace/`: per user
|
|
137
|
+
// directive — heavy local-only content (machine-state, datasets, cloned
|
|
138
|
+
// remotes, session threads) that has no business in the personal vault.
|
|
139
|
+
export const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[] = [
|
|
140
|
+
".git",
|
|
141
|
+
"companies",
|
|
142
|
+
"core",
|
|
143
|
+
"data",
|
|
144
|
+
"personal",
|
|
145
|
+
"repos",
|
|
146
|
+
"workspace",
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Compute absolute paths to share for the personal vault: every top-level
|
|
151
|
+
* entry under `hqRoot` whose basename is NOT in `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`.
|
|
152
|
+
* Mirrors the Rust `is_personal_vault_path` predicate (just hoisted to the
|
|
153
|
+
* top-level step). Order is whatever `fs.readdirSync` returns — share()
|
|
154
|
+
* doesn't care, and the per-file walk inside share() handles recursion
|
|
155
|
+
* uniformly. Missing hqRoot returns []; callers treat that as "no personal
|
|
156
|
+
* content to push" rather than a hard error.
|
|
157
|
+
*/
|
|
158
|
+
export function computePersonalVaultPaths(hqRoot: string): string[] {
|
|
159
|
+
let entries: string[];
|
|
160
|
+
try {
|
|
161
|
+
entries = fs.readdirSync(hqRoot);
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
return entries
|
|
166
|
+
.filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
|
|
167
|
+
.map((name) => path.join(hqRoot, name));
|
|
168
|
+
}
|
|
169
|
+
|
|
117
170
|
// ---------------------------------------------------------------------------
|
|
118
171
|
// Event protocol
|
|
119
172
|
// ---------------------------------------------------------------------------
|
|
@@ -348,6 +401,10 @@ interface ParsedArgs {
|
|
|
348
401
|
onConflict: ConflictStrategy;
|
|
349
402
|
hqRoot: string;
|
|
350
403
|
direction: Direction;
|
|
404
|
+
/** Auto-sync (Beta): keep the runner alive after the first pass. */
|
|
405
|
+
watch: boolean;
|
|
406
|
+
/** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
|
|
407
|
+
pollRemoteMs?: number;
|
|
351
408
|
}
|
|
352
409
|
|
|
353
410
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
@@ -356,6 +413,8 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
356
413
|
let onConflict: ConflictStrategy = "abort";
|
|
357
414
|
let hqRoot = DEFAULT_HQ_ROOT;
|
|
358
415
|
let direction: Direction = "pull";
|
|
416
|
+
let watch = false;
|
|
417
|
+
let pollRemoteMs: number | undefined;
|
|
359
418
|
|
|
360
419
|
for (let i = 0; i < argv.length; i++) {
|
|
361
420
|
const arg = argv[i];
|
|
@@ -391,6 +450,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
391
450
|
hqRoot = argv[++i];
|
|
392
451
|
if (!hqRoot) return { error: "--hq-root requires a value" };
|
|
393
452
|
break;
|
|
453
|
+
case "--watch":
|
|
454
|
+
watch = true;
|
|
455
|
+
break;
|
|
456
|
+
case "--poll-remote-ms": {
|
|
457
|
+
const val = argv[++i];
|
|
458
|
+
if (!val) return { error: "--poll-remote-ms requires a value" };
|
|
459
|
+
const n = Number(val);
|
|
460
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
461
|
+
return {
|
|
462
|
+
error: `--poll-remote-ms must be a positive integer (ms), got: ${val}`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
pollRemoteMs = n;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
394
468
|
case "--json":
|
|
395
469
|
// Accepted but ignored — ndjson is the only output mode.
|
|
396
470
|
break;
|
|
@@ -405,8 +479,11 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
405
479
|
if (!companies && !company) {
|
|
406
480
|
return { error: "Pass --companies or --company <slug>" };
|
|
407
481
|
}
|
|
482
|
+
if (pollRemoteMs !== undefined && !watch) {
|
|
483
|
+
return { error: "--poll-remote-ms requires --watch" };
|
|
484
|
+
}
|
|
408
485
|
|
|
409
|
-
return { companies, company, onConflict, hqRoot, direction };
|
|
486
|
+
return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
|
|
410
487
|
}
|
|
411
488
|
|
|
412
489
|
// ---------------------------------------------------------------------------
|
|
@@ -490,6 +567,22 @@ export async function runRunner(
|
|
|
490
567
|
const client =
|
|
491
568
|
deps.createVaultClient?.(vaultConfig) ?? new VaultClient(vaultConfig);
|
|
492
569
|
|
|
570
|
+
// ---- resolve identity claims -----------------------------------------
|
|
571
|
+
// Read the cached idToken claims once. Two consumers downstream:
|
|
572
|
+
// 1. The claim-dance (only fires in `--companies` mode for setup-needed
|
|
573
|
+
// invitees).
|
|
574
|
+
// 2. The S3 upload author (every share() call stamps `Metadata['created-by']`
|
|
575
|
+
// with `claims.email` so the hq-console vault UI's CREATED BY column
|
|
576
|
+
// attributes the file to the syncing user).
|
|
577
|
+
// Resolved here (not inside `parsed.companies`) so single-company runs also
|
|
578
|
+
// get author attribution. `null` is fine — share() simply omits the metadata.
|
|
579
|
+
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
580
|
+
const claims = getClaims();
|
|
581
|
+
const uploadAuthor: UploadAuthor | undefined =
|
|
582
|
+
claims?.sub && claims?.email
|
|
583
|
+
? { userSub: claims.sub, email: claims.email }
|
|
584
|
+
: undefined;
|
|
585
|
+
|
|
493
586
|
// ---- resolve targets --------------------------------------------------
|
|
494
587
|
let memberships: Pick<Membership, "companyUid">[];
|
|
495
588
|
try {
|
|
@@ -497,8 +590,6 @@ export async function runRunner(
|
|
|
497
590
|
// Before giving up on memberships, run the claim-dance: new users signed
|
|
498
591
|
// in via the tray may have email-keyed invites waiting for them. Without
|
|
499
592
|
// this, an invited user would see "setup-needed" on every tray click.
|
|
500
|
-
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
501
|
-
const claims = getClaims();
|
|
502
593
|
if (claims) {
|
|
503
594
|
await runClaimDance(client, claims, stderr);
|
|
504
595
|
}
|
|
@@ -697,13 +788,17 @@ export async function runRunner(
|
|
|
697
788
|
};
|
|
698
789
|
|
|
699
790
|
// Push first so a subsequent pull doesn't overwrite files we were about
|
|
700
|
-
// to broadcast.
|
|
701
|
-
//
|
|
702
|
-
//
|
|
791
|
+
// to broadcast. Company targets walk `companies/{slug}/`; the personal
|
|
792
|
+
// target walks every top-level entry under hqRoot minus the exclusion
|
|
793
|
+
// list (see PERSONAL_VAULT_EXCLUDED_TOP_LEVEL). `skipUnchanged: true`
|
|
794
|
+
// keeps both cases efficient on re-runs.
|
|
703
795
|
if (doPush) {
|
|
704
796
|
activePhase = "push";
|
|
797
|
+
const pushPaths = target.personalMode === true
|
|
798
|
+
? computePersonalVaultPaths(parsed.hqRoot)
|
|
799
|
+
: [path.join(parsed.hqRoot, "companies", target.slug)];
|
|
705
800
|
pushResult = await shareFn({
|
|
706
|
-
paths:
|
|
801
|
+
paths: pushPaths,
|
|
707
802
|
company: target.uid,
|
|
708
803
|
vaultConfig,
|
|
709
804
|
hqRoot: parsed.hqRoot,
|
|
@@ -715,6 +810,13 @@ export async function runRunner(
|
|
|
715
810
|
// next pull because the remote object is still listable.
|
|
716
811
|
propagateDeletes: true,
|
|
717
812
|
onEvent: tagAndEmit,
|
|
813
|
+
...(uploadAuthor ? { author: uploadAuthor } : {}),
|
|
814
|
+
// Mirror the pull-side seam: only spread these for the personal
|
|
815
|
+
// slot so company-target args stay identical to the pre-Slice-2
|
|
816
|
+
// shape (the "no personalMode/journalSlug keys" regression test
|
|
817
|
+
// in sync-runner.test.ts pins that contract).
|
|
818
|
+
...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
|
|
819
|
+
...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
|
|
718
820
|
});
|
|
719
821
|
}
|
|
720
822
|
|
|
@@ -895,8 +997,41 @@ const isDirectInvocation = (() => {
|
|
|
895
997
|
}
|
|
896
998
|
})();
|
|
897
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* Auto-sync (Beta) watch loop. Re-runs the one-shot runner every
|
|
1002
|
+
* `pollRemoteMs` until the process is killed (SIGTERM from the menubar's
|
|
1003
|
+
* stop_daemon command) or until a pass returns a non-zero exit code (hard
|
|
1004
|
+
* error worth surfacing to the operator). `setup-needed` and `auth-error`
|
|
1005
|
+
* exit 0 today and so will retry — acceptable noise for the beta; deal with
|
|
1006
|
+
* it via a richer return shape if it shows up in Sentry.
|
|
1007
|
+
*/
|
|
1008
|
+
export async function runRunnerWithLoop(argv: string[]): Promise<number> {
|
|
1009
|
+
if (!argv.includes("--watch")) {
|
|
1010
|
+
return runRunner(argv);
|
|
1011
|
+
}
|
|
1012
|
+
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
1013
|
+
const pollMs =
|
|
1014
|
+
pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
1015
|
+
|
|
1016
|
+
// Strip --watch / --poll-remote-ms before delegating: the parser inside
|
|
1017
|
+
// runRunner accepts them, but we don't want runRunner to think it's
|
|
1018
|
+
// re-entering watch mode each iteration.
|
|
1019
|
+
const passArgv = argv.filter((a, i) => {
|
|
1020
|
+
if (a === "--watch") return false;
|
|
1021
|
+
if (a === "--poll-remote-ms") return false;
|
|
1022
|
+
if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
|
|
1023
|
+
return true;
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
while (true) {
|
|
1027
|
+
const code = await runRunner(passArgv);
|
|
1028
|
+
if (code !== 0) return code;
|
|
1029
|
+
await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
898
1033
|
if (isDirectInvocation) {
|
|
899
|
-
|
|
1034
|
+
runRunnerWithLoop(process.argv.slice(2))
|
|
900
1035
|
.then((code) => process.exit(code))
|
|
901
1036
|
.catch((err) => {
|
|
902
1037
|
process.stderr.write(
|
package/src/cli/share.test.ts
CHANGED
|
@@ -481,6 +481,54 @@ describe("share", () => {
|
|
|
481
481
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
482
482
|
});
|
|
483
483
|
|
|
484
|
+
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
485
|
+
// Regression: hq-console vault UI's CREATED BY column was always blank
|
|
486
|
+
// because the sync engine never stamped Metadata['created-by'] on PUT.
|
|
487
|
+
// share() now accepts an `author` and threads it to s3.uploadFile so
|
|
488
|
+
// every synced file lands in S3 with the syncer's identity attached.
|
|
489
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
490
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
491
|
+
const testFile = path.join(companyRoot, "attribution.md");
|
|
492
|
+
fs.writeFileSync(testFile, "attributed content");
|
|
493
|
+
|
|
494
|
+
await share({
|
|
495
|
+
paths: [testFile],
|
|
496
|
+
company: "acme",
|
|
497
|
+
vaultConfig: mockConfig,
|
|
498
|
+
hqRoot: tmpDir,
|
|
499
|
+
author: { userSub: "abc-123", email: "alice@example.com" },
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
503
|
+
expect.anything(),
|
|
504
|
+
testFile,
|
|
505
|
+
"attribution.md",
|
|
506
|
+
{ userSub: "abc-123", email: "alice@example.com" },
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("omits author arg when not provided (back-compat)", async () => {
|
|
511
|
+
// share() must remain a 3-arg call to uploadFile when no author is
|
|
512
|
+
// configured — older test stubs and external integrations rely on it.
|
|
513
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
514
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
515
|
+
const testFile = path.join(companyRoot, "no-author.md");
|
|
516
|
+
fs.writeFileSync(testFile, "anonymous");
|
|
517
|
+
|
|
518
|
+
await share({
|
|
519
|
+
paths: [testFile],
|
|
520
|
+
company: "acme",
|
|
521
|
+
vaultConfig: mockConfig,
|
|
522
|
+
hqRoot: tmpDir,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
526
|
+
expect.anything(),
|
|
527
|
+
testFile,
|
|
528
|
+
"no-author.md",
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
484
532
|
it("skipUnchanged=false (default) uploads even when hash matches", async () => {
|
|
485
533
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
486
534
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -1034,4 +1082,126 @@ describe("share", () => {
|
|
|
1034
1082
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1035
1083
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
1036
1084
|
});
|
|
1085
|
+
|
|
1086
|
+
// ── personalMode ───────────────────────────────────────────────────────────
|
|
1087
|
+
//
|
|
1088
|
+
// The personal vault (slug "personal" in the runner's fanout plan) shares
|
|
1089
|
+
// files from hqRoot DIRECTLY — not from hqRoot/companies/<slug>/. Mirrors
|
|
1090
|
+
// the Rust hq-sync first-push contract in src-tauri/src/commands/personal.rs:
|
|
1091
|
+
// syncRoot = hqRoot, journal slug = "personal", remote keys are hq-root-
|
|
1092
|
+
// relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md"). The
|
|
1093
|
+
// exclusion list itself is enforced by the runner (sync-runner.ts) by only
|
|
1094
|
+
// passing in the allowed top-level directories — share() trusts its
|
|
1095
|
+
// `paths` input.
|
|
1096
|
+
describe("personalMode", () => {
|
|
1097
|
+
it("personalMode=true keys files hq-root-relative, not companies/{slug}/-relative", async () => {
|
|
1098
|
+
fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
|
|
1099
|
+
fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
|
|
1100
|
+
fs.writeFileSync(path.join(tmpDir, ".claude", "skills", "foo.md"), "skill");
|
|
1101
|
+
fs.writeFileSync(path.join(tmpDir, "knowledge", "notes.md"), "note");
|
|
1102
|
+
|
|
1103
|
+
const result = await share({
|
|
1104
|
+
paths: [
|
|
1105
|
+
path.join(tmpDir, ".claude"),
|
|
1106
|
+
path.join(tmpDir, "knowledge"),
|
|
1107
|
+
],
|
|
1108
|
+
company: "acme",
|
|
1109
|
+
vaultConfig: mockConfig,
|
|
1110
|
+
hqRoot: tmpDir,
|
|
1111
|
+
personalMode: true,
|
|
1112
|
+
journalSlug: "personal",
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
expect(result.filesUploaded).toBe(2);
|
|
1116
|
+
// Remote keys must be hq-root-relative, NOT prefixed with companies/personal/
|
|
1117
|
+
const keys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
1118
|
+
expect(keys.sort()).toEqual([".claude/skills/foo.md", "knowledge/notes.md"]);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it("personalMode=true writes journal under the personal journalSlug", async () => {
|
|
1122
|
+
fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
|
|
1123
|
+
fs.writeFileSync(path.join(tmpDir, "knowledge", "notes.md"), "note");
|
|
1124
|
+
|
|
1125
|
+
await share({
|
|
1126
|
+
paths: [path.join(tmpDir, "knowledge")],
|
|
1127
|
+
company: "acme",
|
|
1128
|
+
vaultConfig: mockConfig,
|
|
1129
|
+
hqRoot: tmpDir,
|
|
1130
|
+
personalMode: true,
|
|
1131
|
+
journalSlug: "personal",
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Personal journal is keyed "personal", NOT the company's ctx.slug ("acme")
|
|
1135
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1136
|
+
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1137
|
+
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
1138
|
+
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
1139
|
+
|
|
1140
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1141
|
+
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
1145
|
+
// Same fixture as the "skips files outside the company folder" test
|
|
1146
|
+
// above — file at hqRoot root, NOT under companies/acme/. Without
|
|
1147
|
+
// personalMode this is rejected with a "outside company folder" warning;
|
|
1148
|
+
// with personalMode=true the file IS uploaded because syncRoot=hqRoot.
|
|
1149
|
+
const outsideFile = path.join(tmpDir, "stray.md");
|
|
1150
|
+
fs.writeFileSync(outsideFile, "stray");
|
|
1151
|
+
|
|
1152
|
+
const result = await share({
|
|
1153
|
+
paths: [outsideFile],
|
|
1154
|
+
company: "acme",
|
|
1155
|
+
vaultConfig: mockConfig,
|
|
1156
|
+
hqRoot: tmpDir,
|
|
1157
|
+
personalMode: true,
|
|
1158
|
+
journalSlug: "personal",
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
expect(result.filesUploaded).toBe(1);
|
|
1162
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), outsideFile, "stray.md");
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it("personalMode=true + skipUnchanged honors the personal-journal hash", async () => {
|
|
1166
|
+
fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
|
|
1167
|
+
const testFile = path.join(tmpDir, "knowledge", "stable.md");
|
|
1168
|
+
fs.writeFileSync(testFile, "stable content");
|
|
1169
|
+
|
|
1170
|
+
const { hashFile } = await import("../journal.js");
|
|
1171
|
+
const hash = hashFile(testFile);
|
|
1172
|
+
|
|
1173
|
+
// Pre-seed the PERSONAL journal (not the per-company one) so the
|
|
1174
|
+
// skipUnchanged short-circuit fires for the right slug.
|
|
1175
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1176
|
+
fs.writeFileSync(
|
|
1177
|
+
personalJournalPath,
|
|
1178
|
+
JSON.stringify({
|
|
1179
|
+
version: "1",
|
|
1180
|
+
lastSync: new Date().toISOString(),
|
|
1181
|
+
files: {
|
|
1182
|
+
"knowledge/stable.md": {
|
|
1183
|
+
hash,
|
|
1184
|
+
size: 15,
|
|
1185
|
+
syncedAt: new Date().toISOString(),
|
|
1186
|
+
direction: "up",
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
}),
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
const result = await share({
|
|
1193
|
+
paths: [path.join(tmpDir, "knowledge")],
|
|
1194
|
+
company: "acme",
|
|
1195
|
+
vaultConfig: mockConfig,
|
|
1196
|
+
hqRoot: tmpDir,
|
|
1197
|
+
personalMode: true,
|
|
1198
|
+
journalSlug: "personal",
|
|
1199
|
+
skipUnchanged: true,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
expect(result.filesUploaded).toBe(0);
|
|
1203
|
+
expect(result.filesSkipped).toBe(1);
|
|
1204
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1037
1207
|
});
|
package/src/cli/share.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as path from "path";
|
|
|
10
10
|
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
12
|
import { uploadFile, headRemoteFile, deleteRemoteFile } from "../s3.js";
|
|
13
|
+
import type { UploadAuthor } from "../s3.js";
|
|
13
14
|
import { readJournal, writeJournal, hashFile, updateEntry, removeEntry, normalizeEtag } from "../journal.js";
|
|
14
15
|
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
15
16
|
import { resolveConflict } from "./conflict.js";
|
|
@@ -184,6 +185,31 @@ export interface ShareOptions {
|
|
|
184
185
|
* full-tree bidirectional runner opts in.
|
|
185
186
|
*/
|
|
186
187
|
propagateDeletes?: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* Identity stamped onto each uploaded object's S3 user metadata
|
|
190
|
+
* (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
|
|
191
|
+
* reads `Metadata['created-by']` for its "CREATED BY" column; uploads
|
|
192
|
+
* without an author leave that column blank for every file synced via
|
|
193
|
+
* this engine. The runner pipes Cognito idToken claims through here.
|
|
194
|
+
*/
|
|
195
|
+
author?: UploadAuthor;
|
|
196
|
+
/**
|
|
197
|
+
* When true, share() targets the caller's person-entity bucket: syncRoot
|
|
198
|
+
* is `hqRoot` itself (NOT `hqRoot/companies/<slug>/`), so remote keys are
|
|
199
|
+
* hq-root-relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md") to
|
|
200
|
+
* match the Rust hq-sync first-push contract in
|
|
201
|
+
* `src-tauri/src/commands/personal.rs`. The exclusion of top-level dirs
|
|
202
|
+
* (.git, companies, core, data, personal, repos, workspace) is enforced
|
|
203
|
+
* by the runner — share() trusts its `paths` input.
|
|
204
|
+
*/
|
|
205
|
+
personalMode?: boolean;
|
|
206
|
+
/**
|
|
207
|
+
* Override for the per-slug journal file name. Defaults to `ctx.slug`. The
|
|
208
|
+
* runner passes `journalSlug: "personal"` for the personal slot so the TS
|
|
209
|
+
* push and the Rust personal first-push share idempotency state under one
|
|
210
|
+
* `sync-journal.personal.json` file.
|
|
211
|
+
*/
|
|
212
|
+
journalSlug?: string;
|
|
187
213
|
}
|
|
188
214
|
|
|
189
215
|
export interface ShareResult {
|
|
@@ -254,9 +280,17 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
254
280
|
// Remote keys are company-relative; the on-disk scoping prefix is
|
|
255
281
|
// companies/{slug}/. Anything outside this folder gets skipped to avoid
|
|
256
282
|
// leaking cross-company state into the vault.
|
|
257
|
-
|
|
283
|
+
//
|
|
284
|
+
// In personalMode the syncRoot is `hqRoot` itself — remote keys are
|
|
285
|
+
// hq-root-relative to match the Rust personal first-push (which uploads
|
|
286
|
+
// every non-excluded top-level dir under ~/HQ). The exclusion list is
|
|
287
|
+
// enforced upstream by the runner; share() just trusts `paths`.
|
|
288
|
+
const syncRoot = options.personalMode === true
|
|
289
|
+
? hqRoot
|
|
290
|
+
: path.join(hqRoot, "companies", ctx.slug);
|
|
258
291
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
259
|
-
const
|
|
292
|
+
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
293
|
+
const journal = readJournal(journalSlug);
|
|
260
294
|
|
|
261
295
|
let filesUploaded = 0;
|
|
262
296
|
let bytesUploaded = 0;
|
|
@@ -383,7 +417,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
383
417
|
try {
|
|
384
418
|
const stat = fs.statSync(absolutePath);
|
|
385
419
|
|
|
386
|
-
const { etag } =
|
|
420
|
+
const { etag } = options.author
|
|
421
|
+
? await uploadFile(ctx, absolutePath, relativePath, options.author)
|
|
422
|
+
: await uploadFile(ctx, absolutePath, relativePath);
|
|
387
423
|
|
|
388
424
|
// Update journal with optional message; capture the post-upload ETag
|
|
389
425
|
// so the next sync can distinguish "remote moved since we last wrote"
|
|
@@ -446,7 +482,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
446
482
|
// See cli/sync.ts: stamp lastSync on completion so a no-op share still
|
|
447
483
|
// ticks the "Last sync" indicator.
|
|
448
484
|
journal.lastSync = new Date().toISOString();
|
|
449
|
-
writeJournal(
|
|
485
|
+
writeJournal(journalSlug, journal);
|
|
450
486
|
|
|
451
487
|
return {
|
|
452
488
|
filesUploaded,
|