@indigoai-us/hq-cloud 5.11.3 → 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.
@@ -1082,4 +1082,126 @@ describe("share", () => {
1082
1082
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1083
1083
  expect(journal.files["flaky.md"]).toBeDefined();
1084
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
+ });
1085
1207
  });
package/src/cli/share.ts CHANGED
@@ -193,6 +193,23 @@ export interface ShareOptions {
193
193
  * this engine. The runner pipes Cognito idToken claims through here.
194
194
  */
195
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;
196
213
  }
197
214
 
198
215
  export interface ShareResult {
@@ -263,9 +280,17 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
263
280
  // Remote keys are company-relative; the on-disk scoping prefix is
264
281
  // companies/{slug}/. Anything outside this folder gets skipped to avoid
265
282
  // leaking cross-company state into the vault.
266
- 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);
267
291
  const shouldSync = createIgnoreFilter(hqRoot);
268
- const journal = readJournal(ctx.slug);
292
+ const journalSlug = options.journalSlug ?? ctx.slug;
293
+ const journal = readJournal(journalSlug);
269
294
 
270
295
  let filesUploaded = 0;
271
296
  let bytesUploaded = 0;
@@ -457,7 +482,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
457
482
  // See cli/sync.ts: stamp lastSync on completion so a no-op share still
458
483
  // ticks the "Last sync" indicator.
459
484
  journal.lastSync = new Date().toISOString();
460
- writeJournal(ctx.slug, journal);
485
+ writeJournal(journalSlug, journal);
461
486
 
462
487
  return {
463
488
  filesUploaded,