@indigoai-us/hq-cli 5.37.7 → 5.38.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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.38.0]
6
+
7
+ ### Fixed
8
+
9
+ - **`hq sync status` no longer reports "No sync journal yet" right after a
10
+ successful sync.** The command passed the HQ-root *path* where the engine
11
+ expects a journal *slug*, so it looked for `sync-journal._Users_<user>_hq.json`
12
+ — a slug the engine never writes — while the engine shards journals by slug
13
+ (`__hq_personal_vault__`, per-company). Status was blind to every real
14
+ journal. It now enumerates **all** shards via hq-cloud's `listJournals` and
15
+ prints a per-scope breakdown (personal vault + each company) plus an
16
+ all-scopes rollup; "No sync journal yet" appears only when none exist.
17
+ Tombstoned entries are excluded from tracked-file counts. Fixes
18
+ feedback_9fbf1f82 / DEV-1725 and feedback_46288b7b / DEV-1728.
19
+
20
+ ### Changed
21
+
22
+ - Bumps `@indigoai-us/hq-cloud` to `^6.4.0` (was `^6.3.5`) for the new
23
+ `listJournals` / `getStateDir` enumeration API.
24
+
5
25
  ## [5.25.0] — 2026-05-26
6
26
 
7
27
  ### Added
@@ -13,11 +13,11 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
 
16
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="856084be-3bbb-50cd-8e3b-1e19ea13f4fb")}catch(e){}}();
16
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1164bdd1-84e4-5bc5-80a8-6888a6f4313c")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
- import { share, sync, readJournal, getJournalPath, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, } from "@indigoai-us/hq-cloud";
20
+ import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, } from "@indigoai-us/hq-cloud";
21
21
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
22
22
  import { emitNarrowHint, isStrictRefusal, resolveBannerLevel, } from "../lib/narrow-hint-banner.js";
23
23
  /**
@@ -669,20 +669,20 @@ export function registerCloudCommands(program) {
669
669
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
670
670
  .action((options) => {
671
671
  try {
672
- const journalPath = getJournalPath(options.hqRoot);
673
- if (!fs.existsSync(journalPath)) {
674
- console.log(chalk.dim("No sync journal yet run `hq sync push` or `hq sync pull` to create one."));
675
- console.log(chalk.dim(` Expected at: ${journalPath}`));
672
+ // The engine SHARDS the journal by slug — the personal-vault fanout
673
+ // slot, one shard per cloud company, and the legacy "personal" shard.
674
+ // Enumerate EVERY shard the engine can write (via the engine's own
675
+ // `listJournals`) rather than reconstructing a single path: the old
676
+ // code passed the HQ-root PATH where a slug was expected, so it always
677
+ // looked at `sync-journal._Users_<user>_hq.json` — a slug the engine
678
+ // never writes — and printed "No sync journal yet" right after a
679
+ // successful sync (feedback_9fbf1f82 / feedback_46288b7b).
680
+ const journals = listJournals();
681
+ if (journals.length === 0) {
682
+ console.log(chalk.dim("No sync journal yet — run `hq sync now`, `hq sync push`, or `hq sync pull` to create one."));
683
+ console.log(chalk.dim(` Looked in: ${getStateDir()} (sync-journal.*.json)`));
676
684
  return;
677
685
  }
678
- const journal = readJournal(options.hqRoot);
679
- const entries = Object.entries(journal.files ?? {});
680
- const lastSyncTimes = entries
681
- .map(([, entry]) => entry.syncedAt)
682
- .filter((t) => typeof t === "string")
683
- .sort();
684
- const lastSync = lastSyncTimes.at(-1) ?? "never";
685
- const totalBytes = entries.reduce((acc, [, entry]) => acc + (entry.size ?? 0), 0);
686
686
  const configPath = path.join(options.hqRoot, ".hq", "config.json");
687
687
  let activeCompany;
688
688
  if (fs.existsSync(configPath)) {
@@ -694,13 +694,42 @@ export function registerCloudCommands(program) {
694
694
  // ignore
695
695
  }
696
696
  }
697
+ let grandFiles = 0;
698
+ let grandBytes = 0;
699
+ let grandLastSync = "";
697
700
  console.log(chalk.bold("\nHQ Sync — Status"));
698
701
  console.log(` HQ root: ${options.hqRoot}`);
699
702
  console.log(` Active company: ${activeCompany ?? chalk.dim("(none)")}`);
700
- console.log(` Tracked files: ${entries.length}`);
701
- console.log(` Total size: ${formatBytes(totalBytes)}`);
702
- console.log(` Last sync: ${lastSync}`);
703
- console.log(` Journal: ${journalPath}`);
703
+ console.log(` Journals: ${journals.length}`);
704
+ for (const j of journals) {
705
+ const entries = Object.entries(j.journal.files ?? {});
706
+ // Tombstones record removed files — exclude them from "tracked".
707
+ const live = entries.filter(([, entry]) => !entry.removedAt);
708
+ const bytes = live.reduce((acc, [, entry]) => acc + (entry.size ?? 0), 0);
709
+ const lastSync = j.journal.lastSync ||
710
+ live
711
+ .map(([, entry]) => entry.syncedAt)
712
+ .filter((t) => typeof t === "string")
713
+ .sort()
714
+ .at(-1) ||
715
+ "never";
716
+ grandFiles += live.length;
717
+ grandBytes += bytes;
718
+ if (lastSync !== "never" && lastSync > grandLastSync) {
719
+ grandLastSync = lastSync;
720
+ }
721
+ console.log(`\n • ${scopeLabel(j.slug)}`);
722
+ console.log(` Tracked files: ${live.length}`);
723
+ console.log(` Total size: ${formatBytes(bytes)}`);
724
+ console.log(` Last sync: ${lastSync}`);
725
+ console.log(chalk.dim(` Journal: ${j.path}`));
726
+ }
727
+ if (journals.length > 1) {
728
+ console.log(chalk.bold("\n All scopes"));
729
+ console.log(` Tracked files: ${grandFiles}`);
730
+ console.log(` Total size: ${formatBytes(grandBytes)}`);
731
+ console.log(` Last sync: ${grandLastSync || "never"}`);
732
+ }
704
733
  }
705
734
  catch (err) {
706
735
  console.error(chalk.red("✗ Status failed:"), err instanceof Error ? err.message : String(err));
@@ -1113,6 +1142,18 @@ function readActiveCompanySlug(hqRoot) {
1113
1142
  return undefined;
1114
1143
  }
1115
1144
  }
1145
+ /**
1146
+ * Human-friendly label for a journal slug in `hq sync status`. The
1147
+ * personal-vault fanout slot and the legacy `companies/personal` company both
1148
+ * carry opaque slugs; everything else is a real company slug shown as-is.
1149
+ */
1150
+ function scopeLabel(slug) {
1151
+ if (slug === PERSONAL_VAULT_JOURNAL_SLUG)
1152
+ return "personal vault";
1153
+ if (slug === "personal")
1154
+ return "personal (companies/personal)";
1155
+ return slug;
1156
+ }
1116
1157
  function formatBytes(bytes) {
1117
1158
  if (bytes === 0)
1118
1159
  return "0 B";
@@ -1168,4 +1209,4 @@ function resolveUploadAuthorFromCache() {
1168
1209
  }
1169
1210
  }
1170
1211
  //# sourceMappingURL=cloud.js.map
1171
- //# debugId=856084be-3bbb-50cd-8e3b-1e19ea13f4fb
1212
+ //# debugId=1164bdd1-84e4-5bc5-80a8-6888a6f4313c
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.37.7",
3
+ "version": "5.38.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.3.6",
18
+ "@indigoai-us/hq-cloud": "^6.4.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Regression tests for `hq sync status`.
3
+ *
4
+ * Bug (feedback_9fbf1f82 / DEV-1725, feedback_46288b7b / DEV-1728): status
5
+ * reported "No sync journal yet" immediately after a SUCCESSFUL sync. Root
6
+ * cause — the action passed the HQ-root PATH where the engine expects a journal
7
+ * SLUG, so it looked for `sync-journal._Users_<user>_hq.json`, a slug the
8
+ * engine never writes, while the engine shards journals by slug
9
+ * (`__hq_personal_vault__`, per-company). The fix enumerates EVERY shard via
10
+ * the engine's `listJournals` and only reports "no journal" when none exist.
11
+ *
12
+ * These tests drive the real registered `status` command against real journal
13
+ * shards under a temp `HQ_STATE_DIR`, capturing stdout.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+ import { Command } from "commander";
18
+ import * as fs from "fs";
19
+ import * as os from "os";
20
+ import * as path from "path";
21
+ import {
22
+ writeJournal,
23
+ PERSONAL_VAULT_JOURNAL_SLUG,
24
+ } from "@indigoai-us/hq-cloud";
25
+
26
+ import { registerCloudCommands } from "./cloud.js";
27
+
28
+ /** Build a fresh program with the cloud commands registered. */
29
+ function makeProgram(): Command {
30
+ const program = new Command();
31
+ program.exitOverride(); // throw instead of process.exit on parse errors
32
+ registerCloudCommands(program);
33
+ return program;
34
+ }
35
+
36
+ /** Run `status` and return everything written to console.log. */
37
+ async function runStatus(stateDir: string): Promise<string> {
38
+ const lines: string[] = [];
39
+ const spy = vi
40
+ .spyOn(console, "log")
41
+ .mockImplementation((...args: unknown[]) => {
42
+ lines.push(args.map(String).join(" "));
43
+ });
44
+ try {
45
+ await makeProgram().parseAsync(
46
+ ["status", "--hq-root", "/home/fake/hq"],
47
+ { from: "user" },
48
+ );
49
+ } finally {
50
+ spy.mockRestore();
51
+ }
52
+ return lines.join("\n");
53
+ }
54
+
55
+ describe("hq sync status", () => {
56
+ let stateDir: string;
57
+
58
+ beforeEach(() => {
59
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-status-test-"));
60
+ process.env.HQ_STATE_DIR = stateDir;
61
+ });
62
+
63
+ afterEach(() => {
64
+ fs.rmSync(stateDir, { recursive: true, force: true });
65
+ delete process.env.HQ_STATE_DIR;
66
+ });
67
+
68
+ function seed(slug: string, files: Record<string, unknown>): void {
69
+ writeJournal(slug, {
70
+ version: "2",
71
+ lastSync: "",
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ files: files as any,
74
+ pulls: [],
75
+ });
76
+ }
77
+
78
+ it("reports 'No sync journal yet' only when no shard exists", async () => {
79
+ const out = await runStatus(stateDir);
80
+ expect(out).toContain("No sync journal yet");
81
+ expect(out).toContain("sync-journal.*.json");
82
+ });
83
+
84
+ it("REGRESSION: shows the personal-vault journal after a `--personal` sync", async () => {
85
+ // This is the exact scenario both reporters hit: a successful personal
86
+ // sync, then status. It must NOT say "No sync journal yet".
87
+ seed(PERSONAL_VAULT_JOURNAL_SLUG, {
88
+ ".claude/skills/x/SKILL.md": {
89
+ hash: "a",
90
+ size: 1234,
91
+ syncedAt: "2026-06-08T10:00:00.000Z",
92
+ direction: "up",
93
+ },
94
+ });
95
+
96
+ const out = await runStatus(stateDir);
97
+ expect(out).not.toContain("No sync journal yet");
98
+ expect(out).toContain("personal vault");
99
+ expect(out).toContain("Tracked files: 1");
100
+ });
101
+
102
+ it("enumerates every shard with a per-scope breakdown + rollup", async () => {
103
+ seed(PERSONAL_VAULT_JOURNAL_SLUG, {
104
+ ".claude/a": {
105
+ hash: "a",
106
+ size: 1000,
107
+ syncedAt: "2026-06-08T10:00:00.000Z",
108
+ direction: "up",
109
+ },
110
+ });
111
+ seed("marshalops", {
112
+ "k/a.md": {
113
+ hash: "b",
114
+ size: 5000,
115
+ syncedAt: "2026-06-08T11:00:00.000Z",
116
+ direction: "down",
117
+ },
118
+ });
119
+ seed("glazeymedia", {
120
+ "k/b.md": {
121
+ hash: "c",
122
+ size: 777,
123
+ syncedAt: "2026-06-09T09:30:00.000Z",
124
+ direction: "down",
125
+ },
126
+ });
127
+
128
+ const out = await runStatus(stateDir);
129
+ expect(out).toContain("Journals: 3");
130
+ expect(out).toContain("personal vault");
131
+ expect(out).toContain("marshalops");
132
+ expect(out).toContain("glazeymedia");
133
+ expect(out).toContain("All scopes");
134
+ // Rollup last-sync is the max across shards.
135
+ expect(out).toContain("2026-06-09T09:30:00.000Z");
136
+ });
137
+
138
+ it("excludes tombstoned entries from tracked counts", async () => {
139
+ seed("glazeymedia", {
140
+ "k/live.md": {
141
+ hash: "c",
142
+ size: 100,
143
+ syncedAt: "2026-06-09T09:30:00.000Z",
144
+ direction: "down",
145
+ },
146
+ "k/removed.md": {
147
+ hash: "old",
148
+ size: 999,
149
+ syncedAt: "2026-05-01T00:00:00.000Z",
150
+ direction: "down",
151
+ removedAt: "2026-05-02T00:00:00.000Z",
152
+ removedReason: "scope_shrink",
153
+ },
154
+ });
155
+
156
+ const out = await runStatus(stateDir);
157
+ // One live file, not two — the tombstone is not "tracked".
158
+ expect(out).toContain("Tracked files: 1");
159
+ });
160
+ });
@@ -21,8 +21,8 @@ import * as path from "path";
21
21
  import {
22
22
  share,
23
23
  sync,
24
- readJournal,
25
- getJournalPath,
24
+ getStateDir,
25
+ listJournals,
26
26
  loadCachedTokens,
27
27
  VaultClient,
28
28
  computePersonalVaultPaths,
@@ -1102,25 +1102,28 @@ export function registerCloudCommands(program: Command): void {
1102
1102
  )
1103
1103
  .action((options: { hqRoot: string }) => {
1104
1104
  try {
1105
- const journalPath = getJournalPath(options.hqRoot);
1106
- if (!fs.existsSync(journalPath)) {
1107
- console.log(chalk.dim("No sync journal yet run `hq sync push` or `hq sync pull` to create one."));
1108
- console.log(chalk.dim(` Expected at: ${journalPath}`));
1105
+ // The engine SHARDS the journal by slug — the personal-vault fanout
1106
+ // slot, one shard per cloud company, and the legacy "personal" shard.
1107
+ // Enumerate EVERY shard the engine can write (via the engine's own
1108
+ // `listJournals`) rather than reconstructing a single path: the old
1109
+ // code passed the HQ-root PATH where a slug was expected, so it always
1110
+ // looked at `sync-journal._Users_<user>_hq.json` — a slug the engine
1111
+ // never writes — and printed "No sync journal yet" right after a
1112
+ // successful sync (feedback_9fbf1f82 / feedback_46288b7b).
1113
+ const journals = listJournals();
1114
+
1115
+ if (journals.length === 0) {
1116
+ console.log(
1117
+ chalk.dim(
1118
+ "No sync journal yet — run `hq sync now`, `hq sync push`, or `hq sync pull` to create one.",
1119
+ ),
1120
+ );
1121
+ console.log(
1122
+ chalk.dim(` Looked in: ${getStateDir()} (sync-journal.*.json)`),
1123
+ );
1109
1124
  return;
1110
1125
  }
1111
1126
 
1112
- const journal = readJournal(options.hqRoot);
1113
- const entries = Object.entries(journal.files ?? {});
1114
- const lastSyncTimes = entries
1115
- .map(([, entry]) => entry.syncedAt)
1116
- .filter((t): t is string => typeof t === "string")
1117
- .sort();
1118
- const lastSync = lastSyncTimes.at(-1) ?? "never";
1119
- const totalBytes = entries.reduce(
1120
- (acc, [, entry]) => acc + (entry.size ?? 0),
1121
- 0,
1122
- );
1123
-
1124
1127
  const configPath = path.join(options.hqRoot, ".hq", "config.json");
1125
1128
  let activeCompany: string | undefined;
1126
1129
  if (fs.existsSync(configPath)) {
@@ -1132,13 +1135,51 @@ export function registerCloudCommands(program: Command): void {
1132
1135
  }
1133
1136
  }
1134
1137
 
1138
+ let grandFiles = 0;
1139
+ let grandBytes = 0;
1140
+ let grandLastSync = "";
1141
+
1135
1142
  console.log(chalk.bold("\nHQ Sync — Status"));
1136
1143
  console.log(` HQ root: ${options.hqRoot}`);
1137
1144
  console.log(` Active company: ${activeCompany ?? chalk.dim("(none)")}`);
1138
- console.log(` Tracked files: ${entries.length}`);
1139
- console.log(` Total size: ${formatBytes(totalBytes)}`);
1140
- console.log(` Last sync: ${lastSync}`);
1141
- console.log(` Journal: ${journalPath}`);
1145
+ console.log(` Journals: ${journals.length}`);
1146
+
1147
+ for (const j of journals) {
1148
+ const entries = Object.entries(j.journal.files ?? {});
1149
+ // Tombstones record removed files — exclude them from "tracked".
1150
+ const live = entries.filter(([, entry]) => !entry.removedAt);
1151
+ const bytes = live.reduce(
1152
+ (acc, [, entry]) => acc + (entry.size ?? 0),
1153
+ 0,
1154
+ );
1155
+ const lastSync =
1156
+ j.journal.lastSync ||
1157
+ live
1158
+ .map(([, entry]) => entry.syncedAt)
1159
+ .filter((t): t is string => typeof t === "string")
1160
+ .sort()
1161
+ .at(-1) ||
1162
+ "never";
1163
+
1164
+ grandFiles += live.length;
1165
+ grandBytes += bytes;
1166
+ if (lastSync !== "never" && lastSync > grandLastSync) {
1167
+ grandLastSync = lastSync;
1168
+ }
1169
+
1170
+ console.log(`\n • ${scopeLabel(j.slug)}`);
1171
+ console.log(` Tracked files: ${live.length}`);
1172
+ console.log(` Total size: ${formatBytes(bytes)}`);
1173
+ console.log(` Last sync: ${lastSync}`);
1174
+ console.log(chalk.dim(` Journal: ${j.path}`));
1175
+ }
1176
+
1177
+ if (journals.length > 1) {
1178
+ console.log(chalk.bold("\n All scopes"));
1179
+ console.log(` Tracked files: ${grandFiles}`);
1180
+ console.log(` Total size: ${formatBytes(grandBytes)}`);
1181
+ console.log(` Last sync: ${grandLastSync || "never"}`);
1182
+ }
1142
1183
  } catch (err) {
1143
1184
  console.error(
1144
1185
  chalk.red("✗ Status failed:"),
@@ -1702,6 +1743,17 @@ function readActiveCompanySlug(hqRoot: string): string | undefined {
1702
1743
  }
1703
1744
  }
1704
1745
 
1746
+ /**
1747
+ * Human-friendly label for a journal slug in `hq sync status`. The
1748
+ * personal-vault fanout slot and the legacy `companies/personal` company both
1749
+ * carry opaque slugs; everything else is a real company slug shown as-is.
1750
+ */
1751
+ function scopeLabel(slug: string): string {
1752
+ if (slug === PERSONAL_VAULT_JOURNAL_SLUG) return "personal vault";
1753
+ if (slug === "personal") return "personal (companies/personal)";
1754
+ return slug;
1755
+ }
1756
+
1705
1757
  function formatBytes(bytes: number): string {
1706
1758
  if (bytes === 0) return "0 B";
1707
1759
  const units = ["B", "KB", "MB", "GB"];