@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.
Files changed (45) hide show
  1. package/dist/bin/sync-runner.d.ts +20 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +136 -8
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +178 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +26 -0
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +14 -4
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +137 -0
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/remote-pull.d.ts +51 -0
  16. package/dist/remote-pull.d.ts.map +1 -0
  17. package/dist/remote-pull.js +40 -0
  18. package/dist/remote-pull.js.map +1 -0
  19. package/dist/remote-pull.test.d.ts +2 -0
  20. package/dist/remote-pull.test.d.ts.map +1 -0
  21. package/dist/remote-pull.test.js +229 -0
  22. package/dist/remote-pull.test.js.map +1 -0
  23. package/dist/s3.d.ts +12 -1
  24. package/dist/s3.d.ts.map +1 -1
  25. package/dist/s3.js +44 -1
  26. package/dist/s3.js.map +1 -1
  27. package/dist/s3.test.d.ts +9 -0
  28. package/dist/s3.test.d.ts.map +1 -0
  29. package/dist/s3.test.js +164 -0
  30. package/dist/s3.test.js.map +1 -0
  31. package/dist/watcher.d.ts +3 -1
  32. package/dist/watcher.d.ts.map +1 -1
  33. package/dist/watcher.js +6 -2
  34. package/dist/watcher.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/bin/sync-runner.test.ts +234 -0
  37. package/src/bin/sync-runner.ts +143 -8
  38. package/src/cli/share.test.ts +170 -0
  39. package/src/cli/share.ts +40 -4
  40. package/src/index.ts +1 -1
  41. package/src/remote-pull.test.ts +241 -0
  42. package/src/remote-pull.ts +101 -0
  43. package/src/s3.test.ts +166 -0
  44. package/src/s3.ts +63 -0
  45. package/src/watcher.ts +7 -2
@@ -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. Uses the walk-everything-under-companies/{slug}/ entry
701
- // point with `skipUnchanged` so we don't re-upload files that haven't
702
- // changed since the last sync.
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: [path.join(parsed.hqRoot, "companies", target.slug)],
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
- runRunner(process.argv.slice(2))
1034
+ runRunnerWithLoop(process.argv.slice(2))
900
1035
  .then((code) => process.exit(code))
901
1036
  .catch((err) => {
902
1037
  process.stderr.write(
@@ -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
- const syncRoot = path.join(hqRoot, "companies", ctx.slug);
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 journal = readJournal(ctx.slug);
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 } = await uploadFile(ctx, absolutePath, relativePath);
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(ctx.slug, journal);
485
+ writeJournal(journalSlug, journal);
450
486
 
451
487
  return {
452
488
  filesUploaded,
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export {
20
20
  headRemoteFile,
21
21
  } from "./s3.js";
22
22
 
23
- export type { RemoteFile } from "./s3.js";
23
+ export type { RemoteFile, UploadAuthor } from "./s3.js";
24
24
 
25
25
  export {
26
26
  readJournal,