@indigoai-us/hq-cli 5.77.14 → 5.78.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 (308) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. package/vitest.e2e.config.ts +0 -7
@@ -1,2130 +0,0 @@
1
- /**
2
- * `hq sync` commands — push/pull files between the local HQ tree and the
3
- * company's S3 vault bucket.
4
- *
5
- * VLT-5 model: each command resolves a Cognito access token, asks
6
- * vault-service for the company's bucket + STS-vended credentials, and
7
- * runs the operation. No daemon, no init step (handled by `hq onboard`),
8
- * no long-lived background process — every invocation is self-contained.
9
- *
10
- * Subcommands:
11
- * hq sync push [paths...] — broadcast local file(s) to the vault
12
- * hq sync pull — pull all permitted files from the vault
13
- * hq sync status — show local journal summary
14
- */
15
-
16
- import { Command } from "commander";
17
- import chalk from "chalk";
18
- import * as fs from "fs";
19
- import * as path from "path";
20
-
21
- import {
22
- share,
23
- sync,
24
- getStateDir,
25
- listJournals,
26
- loadCachedTokens,
27
- VaultClient,
28
- computePersonalVaultPaths,
29
- PERSONAL_VAULT_JOURNAL_SLUG,
30
- resolvePullScope,
31
- type ConflictStrategy,
32
- type EntityContext,
33
- type MembershipSyncConfig,
34
- type SyncMode,
35
- type PullScope,
36
- type ExplicitGrant,
37
- type SyncProgressEvent,
38
- type UploadAuthor,
39
- } from "@indigoai-us/hq-cloud";
40
-
41
- import {
42
- DEFAULT_HQ_ROOT,
43
- ensureCognitoToken,
44
- buildVaultConfig,
45
- } from "../utils/cognito-session.js";
46
- import {
47
- emitNarrowHint,
48
- isStrictRefusal,
49
- resolveBannerLevel,
50
- type BannerLevel,
51
- } from "../lib/narrow-hint-banner.js";
52
-
53
- /**
54
- * Build a loud, human-readable warning when a push dropped files because
55
- * they fell outside the caller's granted write scope. Returns null when
56
- * nothing was scope-excluded. Keeping this pure makes the "never silently
57
- * succeed when files were dropped" guarantee unit-testable.
58
- */
59
- export function scopeExcludedWarning(count: number): string | null {
60
- if (count <= 0) return null;
61
- return (
62
- `⚠ ${count} file(s) were NOT uploaded — they fall outside the ` +
63
- `prefixes you have write access to (company-wide or direct grants). ` +
64
- `They were skipped, not synced. Re-run with --json to list them, or ` +
65
- `ask an admin to grant you write on those paths.`
66
- );
67
- }
68
-
69
- /**
70
- * Resolve the `propagateDeletePolicy` for share() calls.
71
- *
72
- * Mirrors `@indigoai-us/hq-cloud`'s `bin/sync-runner.js` `resolveDeletePolicy`
73
- * — the same function the AppBar HQ Sync menubar uses — so the CLI and the
74
- * menubar agree on the policy by default. Inlined here (rather than
75
- * deep-imported from `dist/bin/sync-runner.js`) to avoid coupling to a
76
- * non-public subpath.
77
- *
78
- * Default is `"currency-gated"` (etag-verified safe delete). Override via
79
- * the `HQ_SYNC_DELETE_POLICY` env var: `owned-only` (legacy direction-of-
80
- * origin filter, multi-user safe but stranded down-stream litter on
81
- * personal vaults until hq-cloud 6.0.1's prs_ override), `currency-gated`
82
- * (the menubar default), or `all` (emergency-reconcile, no safety gates).
83
- *
84
- * Prior to this helper, the CLI passed nothing → share() fell through to
85
- * its own `"owned-only"` default, leaving CLI users on a stricter (and
86
- * sometimes wrong, on personal vault) policy than menubar users for
87
- * months. See `workspace/reports/owned-only-delete-policy-purpose-debug.md`.
88
- */
89
- function resolveDeletePolicy(): "owned-only" | "currency-gated" | "all" {
90
- const env = process.env.HQ_SYNC_DELETE_POLICY;
91
- if (env === "owned-only" || env === "all" || env === "currency-gated") {
92
- return env;
93
- }
94
- return "currency-gated";
95
- }
96
-
97
- interface CommonSyncOptions {
98
- hqRoot: string;
99
- company?: string;
100
- lockTimeout?: string;
101
- }
102
-
103
- const DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 300;
104
-
105
- /**
106
- * Bound foreground waits for a watcher/manual sync that currently owns the
107
- * per-root operation lock. The cloud engine reads this environment value on
108
- * every lock acquisition. A command-line value wins; otherwise preserve a
109
- * valid explicit caller environment value and supply a finite CLI default.
110
- */
111
- export function configureSyncLockTimeout(raw: string | undefined): void {
112
- const value = raw ?? process.env.HQ_OP_LOCK_TIMEOUT ?? String(DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS);
113
- const seconds = Number(value);
114
- if (!Number.isInteger(seconds) || seconds < 0) {
115
- throw new Error("--lock-timeout must be a non-negative integer number of seconds");
116
- }
117
- process.env.HQ_OP_LOCK_TIMEOUT = String(seconds);
118
- }
119
-
120
- // ─────────────────────────────────────────────────────────────────────────────
121
- // `hq sync pull --all` orchestrator
122
- //
123
- // Mirrors the fanout that `hq-sync-runner --companies --direction pull` does
124
- // in the menubar app: list every membership, append the canonical person
125
- // entity, sync each target into <hq-root>. Extracted as a pure function with
126
- // injectable deps so cloud.pull-all.test.ts can drive it without network.
127
- // ─────────────────────────────────────────────────────────────────────────────
128
-
129
- export interface PullAllVaultClient {
130
- listMyMemberships(): Promise<
131
- Array<{ companyUid: string; membershipKey?: string }>
132
- >;
133
- listPersonEntities(): Promise<
134
- Array<{
135
- uid: string;
136
- type: string;
137
- slug: string;
138
- bucketName?: string;
139
- createdAt: string;
140
- }>
141
- >;
142
- getEntity(uid: string): Promise<{ slug?: string; name?: string } | null>;
143
- /**
144
- * US-011: optional — when present, `pullAll` calls it once per
145
- * membership to surface the narrow-hint banner for all-mode owners.
146
- * Absent on legacy adapters (push-all et al.) where the banner is not
147
- * applicable.
148
- */
149
- getMembershipSyncConfig?: (
150
- membershipId: string,
151
- ) => Promise<MembershipSyncConfig>;
152
- /**
153
- * Caller's explicit grants for a company — consumed by `resolvePullScope`
154
- * to build a `shared`-mode prefix set. Optional so legacy adapters degrade
155
- * to `all` (the safe direction).
156
- */
157
- listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
158
- }
159
-
160
- export interface SyncCallOptions {
161
- company: string;
162
- hqRoot: string;
163
- onConflict?: ConflictStrategy;
164
- personalMode?: boolean;
165
- journalSlug?: string;
166
- /**
167
- * Effective pull scope (DEV-1768). When set, `sync()` materializes only the
168
- * in-scope keys and scope-shrinks the rest — instead of defaulting to
169
- * `syncMode: "all"` and stamping an all-mode PullRecord that wedges the next
170
- * menubar sync. Resolved per-company by the shared `resolvePullScope`.
171
- */
172
- syncMode?: SyncMode;
173
- prefixSet?: string[];
174
- /**
175
- * Company-relative prefixes the pull must NOT materialize even when the
176
- * caller's STS scope is wide enough to read them (sessions-exclusion /
177
- * company-work-corpus). Derived by `resolvePullScope` in the hq-cloud release
178
- * carrying the sessions/ pull-exclusion, forwarded to `sync()` here so the CLI
179
- * honors the same "session transcripts never sync DOWN" invariant the
180
- * background sync engine enforces. A company OWNER can read `sessions/` keys,
181
- * so without this the CLI would bulk-download everyone's full-content session
182
- * transcripts to local disk. Undefined/empty ⇒ nothing excluded (legacy).
183
- */
184
- excludePrefixes?: string[];
185
- /** Honor a `--force-scope-shrink` on a foreground pull (dirty files kept). */
186
- forceScopeShrink?: boolean;
187
- }
188
-
189
- export interface SyncCallResult {
190
- filesDownloaded: number;
191
- bytesDownloaded: number;
192
- filesSkipped: number;
193
- conflicts: number;
194
- conflictPaths: string[];
195
- aborted: boolean;
196
- }
197
-
198
- export interface PullAllDeps {
199
- vaultClient: PullAllVaultClient;
200
- sync: (options: SyncCallOptions) => Promise<SyncCallResult>;
201
- /**
202
- * Resolve a company's effective pull scope (DEV-1768). Injected so `pullAll`
203
- * stays pure/testable; the real entry point wires it to the shared
204
- * `resolvePullScope` over a live `VaultClient`. When absent, the per-company
205
- * leg falls back to `all` (legacy behavior) — only the real path injects it.
206
- */
207
- resolveScope?: (companyUid: string, slug: string) => Promise<PullScope>;
208
- }
209
-
210
- export interface PullAllOptions {
211
- hqRoot: string;
212
- onConflict?: ConflictStrategy;
213
- /**
214
- * US-011: banner level for the narrow-hint nudge. Defaults to `'hint'`
215
- * — see `resolveBannerLevel` for the env-driven override. The
216
- * `'strict'` level causes `pullAll` to refuse to sync any membership
217
- * still on `syncMode: 'all'` unless `modeAllOverride` is true.
218
- *
219
- * TODO(hq-core-staging release N+2): default flips to 'warning'.
220
- * TODO(hq-core-staging release N+3): default flips to 'strict'.
221
- */
222
- narrowHintLevel?: BannerLevel;
223
- /**
224
- * US-011: when `true`, strict-mode does NOT refuse all-mode
225
- * memberships — the operator has explicitly opted into keeping the
226
- * legacy behavior for this run via `--mode-all`.
227
- */
228
- modeAllOverride?: boolean;
229
- /**
230
- * When `true`, skip the canonical-person-entity leg entirely — the
231
- * fanout only visits the caller's company memberships. Mirrors the
232
- * `--skip-personal` flag and `HQ_SYNC_SKIP_PERSONAL` env var that
233
- * `@indigoai-us/hq-cloud`'s `sync-runner` exposes in `--companies`
234
- * mode (see hq-cloud 5.25.0 `resolveSkipPersonal`). Surfaced on the
235
- * CLI as `hq sync pull --all --no-personal` so the AppBar HQ Sync
236
- * menubar toggle (and CLI users opting out one-off) can drop the
237
- * personal vault from the run without touching the rest of the plan.
238
- */
239
- skipPersonal?: boolean;
240
- /**
241
- * Forward `--force-scope-shrink` to each company leg: when a scope shrink
242
- * would un-track locally-modified files, proceed anyway (dirty files KEPT on
243
- * disk, only un-tracked). Makes the foreground "block" advice followable.
244
- */
245
- forceScopeShrink?: boolean;
246
- }
247
-
248
- export interface PullAllRow {
249
- slug: string;
250
- result?: SyncCallResult;
251
- error?: string;
252
- }
253
-
254
- // ─────────────────────────────────────────────────────────────────────────────
255
- // `hq sync push --all` orchestrator — symmetric with pullAll.
256
- //
257
- // Plan: every membership → company target (paths = the company's local
258
- // subtree); plus the canonical person entity → personal target (paths =
259
- // computePersonalVaultPaths(hqRoot)). Each target calls share() with
260
- // skipUnchanged + propagateDeletes so re-runs are cheap and on-disk deletes
261
- // propagate to the vault — same defaults the menubar runner uses.
262
- // ─────────────────────────────────────────────────────────────────────────────
263
-
264
- export interface ShareCallOptions {
265
- company: string;
266
- hqRoot: string;
267
- paths: string[];
268
- onConflict?: ConflictStrategy;
269
- personalMode?: boolean;
270
- journalSlug?: string;
271
- message?: string;
272
- skipUnchanged?: boolean;
273
- propagateDeletes?: boolean;
274
- propagateDeletePolicy?: "owned-only" | "currency-gated" | "all";
275
- }
276
-
277
- export interface ShareCallResult {
278
- filesUploaded: number;
279
- bytesUploaded: number;
280
- filesSkipped: number;
281
- filesDeleted: number;
282
- filesExcludedByScope: number;
283
- conflictPaths: string[];
284
- aborted: boolean;
285
- }
286
-
287
- export interface PushAllDeps {
288
- vaultClient: PullAllVaultClient;
289
- share: (options: ShareCallOptions) => Promise<ShareCallResult>;
290
- }
291
-
292
- export interface PushAllOptions {
293
- hqRoot: string;
294
- onConflict?: ConflictStrategy;
295
- message?: string;
296
- /**
297
- * When `true`, skip the canonical-person-entity leg entirely — the
298
- * fanout only visits the caller's company memberships. Symmetric with
299
- * `PullAllOptions.skipPersonal`; surfaced on the CLI as
300
- * `hq sync push --all --no-personal`.
301
- */
302
- skipPersonal?: boolean;
303
- }
304
-
305
- export interface PushAllRow {
306
- slug: string;
307
- result?: ShareCallResult;
308
- error?: string;
309
- }
310
-
311
- export interface PushAllResult {
312
- attempted: number;
313
- filesUploaded: number;
314
- bytesUploaded: number;
315
- filesDeleted: number;
316
- filesExcludedByScope: number;
317
- errors: Array<{ company: string; message: string }>;
318
- perCompany: PushAllRow[];
319
- }
320
-
321
- export interface PullAllResult {
322
- attempted: number;
323
- filesDownloaded: number;
324
- bytesDownloaded: number;
325
- conflicts: number;
326
- errors: Array<{ company: string; message: string }>;
327
- perCompany: PullAllRow[];
328
- }
329
-
330
- interface PlanEntry {
331
- slug: string;
332
- syncOptions: SyncCallOptions;
333
- /** US-011: tracked so we can resolve sync-config + emit the narrow hint. */
334
- companyUid?: string;
335
- membershipKey?: string;
336
- }
337
-
338
- // Oldest-first by createdAt, ties broken by uid lexicographic — matches
339
- // `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
340
- // the same person bucket that `hq-sync-runner` picks.
341
- function pickCanonicalPerson<
342
- E extends { uid: string; type: string; createdAt: string },
343
- >(persons: E[]): E | null {
344
- const onlyPersons = persons.filter((e) => e.type === "person");
345
- if (onlyPersons.length === 0) return null;
346
- return [...onlyPersons].sort((a, b) => {
347
- if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
348
- return a.uid < b.uid ? -1 : 1;
349
- })[0];
350
- }
351
-
352
- /**
353
- * Read the sessions-exclusion prefix set off a resolved {@link PullScope}.
354
- *
355
- * `resolvePullScope` in hq-cloud derives the sessions/ pull-exclusion and
356
- * derives `excludePrefixes` (e.g. `["sessions/"]`) so a company OWNER — whose STS
357
- * scope is wide enough to read `sessions/` keys — does not bulk-download
358
- * everyone's full-content session transcripts to local disk, upholding the
359
- * "session transcripts never sync DOWN" invariant hq-cloud enforces for the
360
- * background engine. Keep the small runtime shape check even though PullScope
361
- * now declares the field: it protects the CLI when an older external resolver
362
- * implementation is injected by tests or downstream callers.
363
- */
364
- function readScopeExcludePrefixes(
365
- scope: PullScope | undefined,
366
- ): string[] | undefined {
367
- const raw: unknown = scope?.excludePrefixes;
368
- if (!Array.isArray(raw)) return undefined;
369
- const prefixes = raw.filter(
370
- (p): p is string => typeof p === "string" && p.length > 0,
371
- );
372
- return prefixes.length > 0 ? prefixes : undefined;
373
- }
374
-
375
- export async function pullAll(
376
- options: PullAllOptions,
377
- deps: PullAllDeps,
378
- ): Promise<PullAllResult> {
379
- const memberships = await deps.vaultClient.listMyMemberships();
380
- const persons = await deps.vaultClient.listPersonEntities();
381
-
382
- const narrowHintLevel: BannerLevel = options.narrowHintLevel ?? "hint";
383
- const getSyncConfig = deps.vaultClient.getMembershipSyncConfig;
384
-
385
- const plan: PlanEntry[] = [];
386
- for (const m of memberships) {
387
- let slug = m.companyUid;
388
- try {
389
- const info = await deps.vaultClient.getEntity(m.companyUid);
390
- if (info?.slug) slug = info.slug;
391
- } catch {
392
- // Best-effort — keep UID as the row label rather than aborting the run.
393
- }
394
- plan.push({
395
- slug,
396
- companyUid: m.companyUid,
397
- ...(m.membershipKey ? { membershipKey: m.membershipKey } : {}),
398
- syncOptions: {
399
- company: m.companyUid,
400
- hqRoot: options.hqRoot,
401
- ...(options.onConflict ? { onConflict: options.onConflict } : {}),
402
- },
403
- });
404
- }
405
-
406
- const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
407
- if (personal) {
408
- // Canonical slug, NOT the literal "personal": that legacy slug is the
409
- // companies/personal company's journal. hq-sync-runner moved the vault
410
- // slot to PERSONAL_VAULT_JOURNAL_SLUG; hq-cli journaling the same vault
411
- // under the old slug split the bookkeeping across two files — every CLI
412
- // sync re-flagged the menubar's already-synced files as conflicts.
413
- plan.push({
414
- slug: "personal",
415
- syncOptions: {
416
- company: personal.uid,
417
- hqRoot: options.hqRoot,
418
- personalMode: true,
419
- journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
420
- ...(options.onConflict ? { onConflict: options.onConflict } : {}),
421
- },
422
- });
423
- }
424
-
425
- const result: PullAllResult = {
426
- attempted: 0,
427
- filesDownloaded: 0,
428
- bytesDownloaded: 0,
429
- conflicts: 0,
430
- errors: [],
431
- perCompany: [],
432
- };
433
-
434
- for (const entry of plan) {
435
- result.attempted += 1;
436
-
437
- // Resolve the membership's effective sync scope. DEV-1768: this must drive
438
- // the actual pull (mode + prefixSet), not just the narrow-hint banner — the
439
- // old code resolved the mode for the banner and then called sync() with NO
440
- // scope, so every CLI pull ran `syncMode: "all"` and stamped an all-mode
441
- // PullRecord that wedged the next menubar sync (all→shared scope-shrink).
442
- // The shared `resolvePullScope` (via deps.resolveScope) degrades to `all`
443
- // on any failure, so a transient blip never narrows scope.
444
- let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
445
- if (entry.companyUid && deps.resolveScope) {
446
- try {
447
- const scope = await deps.resolveScope(entry.companyUid, entry.slug);
448
- resolvedMode = scope.syncMode;
449
- entry.syncOptions.syncMode = scope.syncMode;
450
- if (scope.prefixSet !== undefined) {
451
- entry.syncOptions.prefixSet = scope.prefixSet;
452
- }
453
- // Sessions-exclusion: thread the resolver's excludePrefixes into the
454
- // pull so a wide-STS OWNER never bulk-downloads session transcripts.
455
- const excludePrefixes = readScopeExcludePrefixes(scope);
456
- if (excludePrefixes) {
457
- entry.syncOptions.excludePrefixes = excludePrefixes;
458
- }
459
- } catch {
460
- resolvedMode = undefined;
461
- }
462
- } else if (entry.membershipKey && getSyncConfig) {
463
- // Fallback when no scope resolver is injected (legacy/test paths):
464
- // banner-only mode resolution, preserving the pre-DEV-1768 behavior.
465
- try {
466
- const cfg = await getSyncConfig(entry.membershipKey);
467
- resolvedMode = cfg.syncMode;
468
- } catch {
469
- resolvedMode = undefined;
470
- }
471
- }
472
- if (options.forceScopeShrink && entry.companyUid) {
473
- entry.syncOptions.forceScopeShrink = true;
474
- }
475
-
476
- if (
477
- resolvedMode === "all" &&
478
- isStrictRefusal(resolvedMode, narrowHintLevel) &&
479
- !options.modeAllOverride &&
480
- entry.companyUid
481
- ) {
482
- // Emit the strict-level banner once, then mark the leg as errored
483
- // without invoking sync(). The operator either narrows the
484
- // membership (`hq sync narrow --apply`) or passes `--mode-all` to
485
- // opt back in.
486
- emitNarrowHint({
487
- companyUid: entry.companyUid,
488
- syncMode: resolvedMode,
489
- level: narrowHintLevel,
490
- });
491
- const message =
492
- "Refusing to pull all-mode membership in strict mode. " +
493
- "Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
494
- result.errors.push({ company: entry.slug, message });
495
- result.perCompany.push({ slug: entry.slug, error: message });
496
- continue;
497
- }
498
-
499
- try {
500
- const r = await deps.sync(entry.syncOptions);
501
- result.filesDownloaded += r.filesDownloaded;
502
- result.bytesDownloaded += r.bytesDownloaded;
503
- result.conflicts += r.conflicts;
504
- result.perCompany.push({ slug: entry.slug, result: r });
505
-
506
- // Banner emitted AFTER the leg succeeds so it appears alongside
507
- // the per-company summary line and doesn't get scrolled off by
508
- // sync chatter.
509
- if (resolvedMode === "all" && entry.companyUid) {
510
- emitNarrowHint({
511
- companyUid: entry.companyUid,
512
- syncMode: resolvedMode,
513
- level: narrowHintLevel,
514
- });
515
- }
516
- } catch (err) {
517
- const message = err instanceof Error ? err.message : String(err);
518
- result.errors.push({ company: entry.slug, message });
519
- result.perCompany.push({ slug: entry.slug, error: message });
520
- }
521
- }
522
-
523
- return result;
524
- }
525
-
526
- interface PushPlanEntry {
527
- slug: string;
528
- shareOptions: ShareCallOptions;
529
- }
530
-
531
- /**
532
- * Drives `hq sync push --all`: same membership + canonical-person fanout as
533
- * pullAll, but each leg calls share() with the runner's bulk defaults
534
- * (skipUnchanged: true, propagateDeletes: true). Pure function with injected
535
- * deps so tests can drive it without network or filesystem.
536
- */
537
- export async function pushAll(
538
- options: PushAllOptions,
539
- deps: PushAllDeps,
540
- ): Promise<PushAllResult> {
541
- const memberships = await deps.vaultClient.listMyMemberships();
542
- const persons = await deps.vaultClient.listPersonEntities();
543
-
544
- const plan: PushPlanEntry[] = [];
545
- for (const m of memberships) {
546
- let slug = m.companyUid;
547
- try {
548
- const info = await deps.vaultClient.getEntity(m.companyUid);
549
- if (info?.slug) slug = info.slug;
550
- } catch {
551
- // Best-effort — keep UID as the row label.
552
- }
553
- plan.push({
554
- slug,
555
- shareOptions: {
556
- company: m.companyUid,
557
- hqRoot: options.hqRoot,
558
- paths: [path.join(options.hqRoot, "companies", slug)],
559
- skipUnchanged: true,
560
- propagateDeletes: true,
561
- propagateDeletePolicy: resolveDeletePolicy(),
562
- ...(options.onConflict ? { onConflict: options.onConflict } : {}),
563
- ...(options.message ? { message: options.message } : {}),
564
- },
565
- });
566
- }
567
-
568
- const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
569
- if (personal) {
570
- plan.push({
571
- slug: "personal",
572
- shareOptions: {
573
- company: personal.uid,
574
- hqRoot: options.hqRoot,
575
- paths: computePersonalVaultPaths(options.hqRoot),
576
- personalMode: true,
577
- journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
578
- skipUnchanged: true,
579
- propagateDeletes: true,
580
- propagateDeletePolicy: resolveDeletePolicy(),
581
- ...(options.onConflict ? { onConflict: options.onConflict } : {}),
582
- ...(options.message ? { message: options.message } : {}),
583
- },
584
- });
585
- }
586
-
587
- const result: PushAllResult = {
588
- attempted: 0,
589
- filesUploaded: 0,
590
- bytesUploaded: 0,
591
- filesDeleted: 0,
592
- filesExcludedByScope: 0,
593
- errors: [],
594
- perCompany: [],
595
- };
596
-
597
- for (const entry of plan) {
598
- result.attempted += 1;
599
- try {
600
- const r = await deps.share(entry.shareOptions);
601
- result.filesUploaded += r.filesUploaded;
602
- result.bytesUploaded += r.bytesUploaded;
603
- result.filesDeleted += r.filesDeleted;
604
- result.filesExcludedByScope += r.filesExcludedByScope;
605
- result.perCompany.push({ slug: entry.slug, result: r });
606
- } catch (err) {
607
- const message = err instanceof Error ? err.message : String(err);
608
- result.errors.push({ company: entry.slug, message });
609
- result.perCompany.push({ slug: entry.slug, error: message });
610
- }
611
- }
612
-
613
- return result;
614
- }
615
-
616
- /**
617
- * Resolve the canonical person entity UID for the logged-in user. Used by
618
- * `hq sync {push,pull,now} --personal` to target the personal vault without
619
- * the caller needing to know the UID. Throws a clean error if the user has
620
- * no person entity (typically means they haven't run `hq onboard`).
621
- */
622
- export async function resolveCanonicalPersonUid(
623
- vaultClient: PullAllVaultClient,
624
- ): Promise<string> {
625
- const persons = await vaultClient.listPersonEntities();
626
- const pick = pickCanonicalPerson(persons);
627
- if (!pick) {
628
- throw new Error(
629
- "No personal vault found for the logged-in user. Run `hq onboard` " +
630
- "first, or check `hq whoami` to confirm you're signed in to the " +
631
- "right account.",
632
- );
633
- }
634
- return pick.uid;
635
- }
636
-
637
- /**
638
- * Refuse `hq sync push --personal <path>` — the combination silently
639
- * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
640
- * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
641
- * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
642
- * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
643
- * objects to a personal vault before being killed. Cleanup required a
644
- * hand-rolled S3 sweep. Closes hq-cli#25.
645
- *
646
- * Refusal — not silent filtering — is intentional: explicit is better than
647
- * implicit guesswork, and the legitimate "I want to push a subset of my
648
- * personal vault" use case has a clean workaround (drop `--personal`, the
649
- * subset upload targets the active company via standard semantics).
650
- */
651
- export function assertNoPersonalPositionalPaths(opts: {
652
- personal?: boolean;
653
- }, paths: string[] | undefined): void {
654
- if (opts.personal && paths && paths.length > 0) {
655
- throw new Error(
656
- "`--personal` cannot be combined with explicit [paths]: " +
657
- "positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
658
- "guard (skips .git/, companies/, repos/, workspace/), risking " +
659
- "cross-scope upload of company data to the personal vault. " +
660
- "Use bare `--personal` to push the whole personal scope, OR " +
661
- "drop `--personal` to push specific paths to the active company.",
662
- );
663
- }
664
- }
665
-
666
- /**
667
- * Refuse ambiguous selector combinations. `--all`, `--personal`, and
668
- * `--company` are mutually exclusive — at most one may be set per
669
- * invocation; zero means "use the active company from .hq/config.json".
670
- */
671
- export function assertSingleSelector(opts: {
672
- all?: boolean;
673
- personal?: boolean;
674
- company?: string;
675
- }, command: string): void {
676
- const selectors: string[] = [];
677
- if (opts.all) selectors.push("--all");
678
- if (opts.personal) selectors.push("--personal");
679
- if (opts.company) selectors.push(`--company ${opts.company}`);
680
- if (selectors.length > 1) {
681
- throw new Error(
682
- `\`hq sync ${command}\` accepts at most one of --all, --personal, ` +
683
- `--company; got: ${selectors.join(", ")}.`,
684
- );
685
- }
686
- }
687
-
688
- /**
689
- * Per-company pull resolution helper used by `hq sync pull --company <slug>`
690
- * (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
691
- * does for sync-now. Pulled out so the action handler stays thin AND so
692
- * unit tests can exercise the banner / strict-refusal decision without
693
- * spinning up commander + a real VaultClient.
694
- *
695
- * Input shape:
696
- * - `targetCompany` — slug or UID the caller passed to `--company`. If
697
- * undefined, the helper short-circuits to a "no resolution" result
698
- * (the action handler falls back to .hq/config.json via sync()).
699
- * - `client` — minimal VaultClient surface: listMyMemberships + entity.get
700
- * + getMembershipSyncConfig.
701
- *
702
- * Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
703
- * if the membership / sync-config call failed. Both undefined is a clean
704
- * degradation — the caller pulls without a banner.
705
- */
706
- export interface PerCompanyPullResolveClient {
707
- listMyMemberships(): Promise<Array<{ companyUid: string; membershipKey: string }>>;
708
- getMembershipSyncConfig(
709
- membershipKey: string,
710
- ): Promise<{ syncMode: MembershipSyncConfig["syncMode"] }>;
711
- entity: { get(uid: string): Promise<{ slug?: string }> };
712
- }
713
-
714
- export interface PerCompanyPullResolveResult {
715
- resolvedCompanyUid: string | undefined;
716
- resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
717
- }
718
-
719
- export async function resolvePerCompanyPullPlan(
720
- client: PerCompanyPullResolveClient,
721
- targetCompany: string | undefined,
722
- ): Promise<PerCompanyPullResolveResult> {
723
- if (!targetCompany) return { resolvedCompanyUid: undefined, resolvedMode: undefined };
724
- try {
725
- const memberships = await client.listMyMemberships();
726
- // Direct UID / membershipKey match first (cheapest).
727
- const direct = memberships.find(
728
- (m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
729
- );
730
- if (direct) {
731
- let mode: MembershipSyncConfig["syncMode"] | undefined;
732
- try {
733
- const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
734
- mode = cfg.syncMode;
735
- } catch {
736
- mode = undefined;
737
- }
738
- return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
739
- }
740
- // Slug match — listMyMemberships returns companyUid only, so fan out
741
- // entity.get to find the row whose slug matches the caller's input.
742
- for (const m of memberships) {
743
- try {
744
- const entity = await client.entity.get(m.companyUid);
745
- if (entity.slug === targetCompany) {
746
- let mode: MembershipSyncConfig["syncMode"] | undefined;
747
- try {
748
- const cfg = await client.getMembershipSyncConfig(m.membershipKey);
749
- mode = cfg.syncMode;
750
- } catch {
751
- mode = undefined;
752
- }
753
- return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
754
- }
755
- } catch {
756
- // Entity not visible — skip and continue. Worst case the loop ends
757
- // with no match and we return undefined for both — the pull still
758
- // proceeds, banner just stays quiet.
759
- }
760
- }
761
- } catch {
762
- // listMyMemberships failed — degrade silently. Sync still works without
763
- // the banner; this matches the runPullAll catch behavior.
764
- }
765
- return { resolvedCompanyUid: undefined, resolvedMode: undefined };
766
- }
767
-
768
- /** Read the active company slug/uid from `.hq/config.json` (best-effort). */
769
- function readActiveCompany(hqRoot: string): string | undefined {
770
- try {
771
- const cfg = JSON.parse(
772
- fs.readFileSync(path.join(hqRoot, ".hq", "config.json"), "utf-8"),
773
- ) as { activeCompany?: unknown };
774
- return typeof cfg?.activeCompany === "string" ? cfg.activeCompany : undefined;
775
- } catch {
776
- return undefined;
777
- }
778
- }
779
-
780
- /**
781
- * Resolve the effective PULL scope (DEV-1768) for a single foreground pull
782
- * (`hq sync pull` / `hq sync now`), so the membership's REAL syncMode +
783
- * prefixSet are threaded into `sync()`. Without this the CLI ran `syncMode:
784
- * "all"` and stamped an all-mode PullRecord that wedged the next menubar sync.
785
- *
786
- * Delegates to the shared `resolvePullScope` (same resolver the runner uses),
787
- * which degrades to `all` on any failure — so a transient blip never narrows
788
- * scope. Returns `undefined` only when no company can be resolved at all (then
789
- * `sync()` resolves the active company itself and pulls `all`, as before).
790
- */
791
- async function resolveCliPullScope(
792
- client: VaultClient,
793
- companyRef: string | undefined,
794
- hqRoot: string,
795
- ): Promise<PullScope | undefined> {
796
- const ref = companyRef ?? readActiveCompany(hqRoot);
797
- if (!ref) return undefined;
798
- // Map ref (slug OR uid) → { uid, slug }: resolvePullScope needs the slug to
799
- // normalize slug-anchored grant paths in shared mode.
800
- let companyUid = ref;
801
- let slug = ref;
802
- try {
803
- const memberships = await client.listMyMemberships();
804
- const direct = memberships.find(
805
- (m) => m.companyUid === ref || m.membershipKey === ref,
806
- );
807
- if (direct) {
808
- companyUid = direct.companyUid;
809
- const ent = await client.entity.get(direct.companyUid).catch(() => null);
810
- if (ent?.slug) slug = ent.slug;
811
- } else {
812
- for (const m of memberships) {
813
- const ent = await client.entity.get(m.companyUid).catch(() => null);
814
- if (ent?.slug === ref) {
815
- companyUid = m.companyUid;
816
- slug = ent.slug;
817
- break;
818
- }
819
- }
820
- }
821
- } catch {
822
- // Can't resolve the membership set — degrade to no explicit scope.
823
- return undefined;
824
- }
825
- return resolvePullScope(client, companyUid, slug, hqRoot);
826
- }
827
-
828
- export function registerCloudCommands(program: Command): void {
829
- program
830
- .command("push")
831
- .description("Push local file(s) to the company vault on S3")
832
- .argument("[paths...]", "Paths to push (defaults to current directory)")
833
- .option(
834
- "--hq-root <path>",
835
- `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
836
- DEFAULT_HQ_ROOT,
837
- )
838
- .option(
839
- "--company <slug>",
840
- "Company slug or UID (defaults to active company in .hq/config.json)",
841
- )
842
- .option(
843
- "--lock-timeout <seconds>",
844
- "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
845
- )
846
- .option(
847
- "--message <msg>",
848
- "Optional message attached to journal entries for these uploads",
849
- )
850
- .option(
851
- "--on-conflict <strategy>",
852
- "Conflict strategy: overwrite | keep | abort (omit for interactive)",
853
- )
854
- .option(
855
- "--creds-from-stdin",
856
- "Read a pre-vended EntityContext as JSON from stdin instead of vending " +
857
- "via the cached Cognito session. Use when the caller (e.g. AppBar HQ " +
858
- "Sync) has its own STS pipeline (`/sts/vend-child` with task scope) " +
859
- "and just needs share()'s upload mechanics. The caller is responsible " +
860
- "for vending credentials with enough TTL for the run.",
861
- )
862
- .option(
863
- "--json",
864
- "Emit each share()-level event as a JSON Lines record on stderr (one " +
865
- "JSON object per line) instead of human-readable console output. A " +
866
- "synthetic `{type:\"complete\",...}` line is appended at the end with " +
867
- "the final ShareResult. Subprocess callers parse these to render their " +
868
- "own UI (e.g. AppBar Tauri events).",
869
- )
870
- .option(
871
- "--all",
872
- "Push every company you are a member of plus your personal vault. " +
873
- "Company targets push `<hq-root>/companies/<slug>/`; personal pushes " +
874
- "every top-level entry under <hq-root> minus the excluded set " +
875
- "(.git, companies, repos, workspace). Implies --skip-unchanged + " +
876
- "--propagate-deletes. Mutually exclusive with --company, --personal, " +
877
- "and any explicit [paths].",
878
- )
879
- .option(
880
- "--personal",
881
- "Push to the caller's canonical personal vault (resolved from the " +
882
- "cached Cognito session). When no [paths] are given, defaults to " +
883
- "every top-level entry under <hq-root> minus the excluded set " +
884
- "(.git, companies, repos, workspace) — same scope as `--all`'s " +
885
- "personal slot. Mutually exclusive with --company and --all.",
886
- )
887
- .option(
888
- "--no-personal",
889
- "In `--all` mode, skip the canonical-person leg of the fanout — " +
890
- "only push to the caller's company memberships. Mirrors the " +
891
- "upstream `hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0); " +
892
- "wired so the AppBar HQ Sync menubar can drop personal sync via " +
893
- "a toggle, and so CLI users can opt out one-off. Ignored outside " +
894
- "`--all`.",
895
- )
896
- .action(
897
- async (
898
- paths: string[],
899
- options: CommonSyncOptions & {
900
- message?: string;
901
- onConflict?: ConflictStrategy;
902
- credsFromStdin?: boolean;
903
- json?: boolean;
904
- all?: boolean;
905
- personal?: boolean;
906
- },
907
- ) => {
908
- try {
909
- configureSyncLockTimeout(options.lockTimeout);
910
- assertSingleSelector(options, "push");
911
- } catch (err) {
912
- console.error(
913
- chalk.red("\n✗ Push failed:"),
914
- err instanceof Error ? err.message : String(err),
915
- );
916
- process.exit(1);
917
- }
918
- if (options.all) {
919
- if (paths && paths.length > 0) {
920
- console.error(
921
- chalk.red("\n✗ Push failed:"),
922
- "`--all` cannot be combined with explicit [paths]. " +
923
- "Drop the paths to fan out to every membership + personal, " +
924
- "or drop --all to push specific paths to a single target.",
925
- );
926
- process.exit(1);
927
- }
928
- if (options.credsFromStdin) {
929
- console.error(
930
- chalk.red("\n✗ Push failed:"),
931
- "`--all` cannot be combined with --creds-from-stdin (fanout " +
932
- "needs to vend per-target credentials via the cached Cognito " +
933
- "session). Run separate `--creds-from-stdin` invocations per " +
934
- "target instead.",
935
- );
936
- process.exit(1);
937
- }
938
- // `options.personal === false` happens when the user passed
939
- // `--no-personal` (Commander's auto-negation of the `--personal`
940
- // selector). In `--all` mode that means "skip the personal leg of
941
- // the fanout" — wired through to `pushAll.skipPersonal`. Outside
942
- // `--all` the flag has no effect (logged above as part of the
943
- // option's help text).
944
- await runPushAll(
945
- options.hqRoot,
946
- options.message,
947
- options.onConflict,
948
- options.personal === false,
949
- );
950
- return;
951
- }
952
- const jsonMode = options.json === true;
953
- // Suppress the human banner/result output in JSON mode — the parent
954
- // process renders its own UI from the stderr ndjson stream.
955
- const log = (msg: string): void => {
956
- if (!jsonMode) console.log(msg);
957
- };
958
- const emitJson = (event: Record<string, unknown>): void => {
959
- process.stderr.write(JSON.stringify(event) + "\n");
960
- };
961
-
962
- try {
963
- if (options.personal && options.credsFromStdin) {
964
- throw new Error(
965
- "`--personal` cannot be combined with --creds-from-stdin: " +
966
- "--personal resolves the canonical person UID via the cached " +
967
- "Cognito session, while --creds-from-stdin expects the caller " +
968
- "to have already resolved entity + credentials. Pick one.",
969
- );
970
- }
971
- // Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
972
- assertNoPersonalPositionalPaths(options, paths);
973
-
974
- log(chalk.bold("\nHQ Sync — Push"));
975
- log(` HQ root: ${options.hqRoot}`);
976
-
977
- // Resolve credentials. Two paths:
978
- // 1. --creds-from-stdin: parse JSON EntityContext from stdin (the
979
- // AppBar shell-out contract — vend-child upstream, pipe in here).
980
- // 2. default: vend via cached Cognito session (the human CLI path).
981
- let entityContext: EntityContext | undefined;
982
- let vaultConfig: ReturnType<typeof buildVaultConfig> | undefined;
983
-
984
- if (options.credsFromStdin) {
985
- if (process.stdin.isTTY) {
986
- throw new Error(
987
- "--creds-from-stdin requires JSON on stdin, but stdin is a " +
988
- "TTY. Pipe the EntityContext JSON via subprocess stdin " +
989
- "(e.g. `echo '{...}' | hq sync push --creds-from-stdin ...`).",
990
- );
991
- }
992
- const raw = await readAllStdin();
993
- try {
994
- entityContext = JSON.parse(raw) as EntityContext;
995
- } catch (e) {
996
- throw new Error(
997
- `--creds-from-stdin: failed to parse stdin as JSON: ${
998
- e instanceof Error ? e.message : String(e)
999
- }`,
1000
- );
1001
- }
1002
- } else {
1003
- const accessToken = await ensureCognitoToken();
1004
- vaultConfig = buildVaultConfig(accessToken);
1005
- }
1006
-
1007
- // Resolve the target. For `--personal`, look up the caller's
1008
- // canonical person entity and force personalMode + journalSlug so
1009
- // share() lands files at hqRoot directly (no companies/<slug>/
1010
- // prefix). For everything else, the company is whatever the user
1011
- // passed or the active company from .hq/config.json.
1012
- let targetCompany = options.company;
1013
- let personalMode = false;
1014
- let journalSlug: string | undefined;
1015
- if (options.personal) {
1016
- const client = new VaultClient(vaultConfig!);
1017
- targetCompany = await resolveCanonicalPersonUid({
1018
- listMyMemberships: () => client.listMyMemberships(),
1019
- listPersonEntities: () => client.entity.listByType("person"),
1020
- getEntity: async () => null,
1021
- });
1022
- personalMode = true;
1023
- journalSlug = PERSONAL_VAULT_JOURNAL_SLUG;
1024
- }
1025
-
1026
- // Default paths. For `--personal` with no explicit paths, default to
1027
- // the canonical personal-vault top-level scope so re-runs from a
1028
- // user shell push the same surface as `--all`'s personal slot.
1029
- // Otherwise preserve the historical "default to cwd" semantics so
1030
- // `hq sync push <file>` and bare `hq sync push` from inside the
1031
- // company tree both work as before.
1032
- const targetPaths =
1033
- paths && paths.length > 0
1034
- ? paths
1035
- : options.personal
1036
- ? computePersonalVaultPaths(options.hqRoot)
1037
- : [process.cwd()];
1038
-
1039
- log(
1040
- ` Company: ${
1041
- options.personal
1042
- ? `(personal: ${targetCompany})`
1043
- : (options.company ?? "(from .hq/config.json or stdin)")
1044
- }`,
1045
- );
1046
- log(` Paths: ${targetPaths.join(", ")}\n`);
1047
-
1048
- // In JSON mode, forward every share() event verbatim to stderr as
1049
- // ndjson. In human mode, share()'s defaultConsoleLogger handles the
1050
- // rendering (no onEvent → falls through to stdout/stderr printing).
1051
- const onEvent = jsonMode
1052
- ? (event: SyncProgressEvent): void =>
1053
- emitJson(event as unknown as Record<string, unknown>)
1054
- : undefined;
1055
-
1056
- // Stamp every uploaded object's S3 user metadata with the syncing
1057
- // user's Cognito identity (`Metadata['created-by']`). The hq-console
1058
- // vault UI's CREATED BY column reads this back via HEAD; without it,
1059
- // every row renders `—`. Resolved best-effort from the cached
1060
- // idToken — pre-vended `--creds-from-stdin` paths still get author
1061
- // attribution as long as the caller is logged in locally.
1062
- const author = resolveUploadAuthorFromCache();
1063
-
1064
- const result = await share({
1065
- paths: targetPaths,
1066
- company: targetCompany,
1067
- message: options.message,
1068
- onConflict: options.onConflict,
1069
- vaultConfig,
1070
- entityContext,
1071
- hqRoot: options.hqRoot,
1072
- onEvent,
1073
- ...(personalMode ? { personalMode: true } : {}),
1074
- ...(journalSlug !== undefined ? { journalSlug } : {}),
1075
- ...(author ? { author } : {}),
1076
- });
1077
-
1078
- if (jsonMode) {
1079
- // Synthetic terminal event so subprocess consumers can read final
1080
- // counts without summing per-file events. Distinguished from
1081
- // SyncProgressEvent by `type:"complete"` (not in the share()
1082
- // event schema — added at the CLI seam).
1083
- emitJson({
1084
- type: "complete",
1085
- filesUploaded: result.filesUploaded,
1086
- bytesUploaded: result.bytesUploaded,
1087
- filesSkipped: result.filesSkipped,
1088
- conflictPaths: result.conflictPaths,
1089
- aborted: result.aborted,
1090
- });
1091
- }
1092
-
1093
- if (result.aborted) {
1094
- log(
1095
- chalk.yellow(
1096
- `\n⚠ Push aborted (${result.filesUploaded} uploaded, ${result.filesSkipped} skipped)`,
1097
- ),
1098
- );
1099
- process.exit(1);
1100
- }
1101
-
1102
- if (result.filesExcludedByScope > 0) {
1103
- log(
1104
- chalk.yellow(
1105
- `\n⚠ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ` +
1106
- `${result.filesSkipped} skipped, ${result.filesExcludedByScope} scope-excluded)`,
1107
- ),
1108
- );
1109
- log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)!));
1110
- } else {
1111
- log(
1112
- chalk.green(
1113
- `\n✓ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ${result.filesSkipped} skipped)`,
1114
- ),
1115
- );
1116
- }
1117
- } catch (err) {
1118
- const message = err instanceof Error ? err.message : String(err);
1119
- if (jsonMode) {
1120
- // In JSON mode, the parent process is parsing stderr for ndjson —
1121
- // human-formatted error lines would corrupt the stream. Emit a
1122
- // structured `fatal` event instead and let the parent surface it.
1123
- emitJson({ type: "fatal", message });
1124
- } else {
1125
- console.error(chalk.red("\n✗ Push failed:"), message);
1126
- }
1127
- process.exit(1);
1128
- }
1129
- },
1130
- );
1131
-
1132
- program
1133
- .command("pull")
1134
- .description("Pull permitted files from the company vault to local HQ")
1135
- .option(
1136
- "--hq-root <path>",
1137
- `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
1138
- DEFAULT_HQ_ROOT,
1139
- )
1140
- .option(
1141
- "--company <slug>",
1142
- "Company slug or UID (defaults to active company in .hq/config.json)",
1143
- )
1144
- .option(
1145
- "--lock-timeout <seconds>",
1146
- "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
1147
- )
1148
- .option(
1149
- "--on-conflict <strategy>",
1150
- "Conflict strategy: overwrite | keep | abort (omit for interactive)",
1151
- )
1152
- .option(
1153
- "--all",
1154
- "Pull every company you are a member of plus your personal vault " +
1155
- "into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
1156
- "the personal vault syncs at <hq-root>. Mutually exclusive with " +
1157
- "--company and --personal.",
1158
- )
1159
- .option(
1160
- "--personal",
1161
- "Pull the caller's canonical personal vault into <hq-root> directly " +
1162
- "(no companies/<slug>/ prefix). Resolves the person UID automatically " +
1163
- "from the cached Cognito session. Mutually exclusive with --company " +
1164
- "and --all.",
1165
- )
1166
- .option(
1167
- "--no-personal",
1168
- "In `--all` mode, skip the canonical-person leg of the fanout — " +
1169
- "only pull the caller's company memberships. Mirrors the upstream " +
1170
- "`hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0). Ignored " +
1171
- "outside `--all`.",
1172
- )
1173
- .option(
1174
- "--mode-all",
1175
- "US-011: opt out of the strict narrow-hint refusal for this run. " +
1176
- "Has no effect today (default narrow-hint level is 'hint'); " +
1177
- "wired so future hq-core-staging releases can flip the default to " +
1178
- "'strict' without re-touching this command.",
1179
- )
1180
- .option(
1181
- "--force-scope-shrink",
1182
- "When a scope shrink would un-track locally-modified files (e.g. after " +
1183
- "narrowing from all → shared), proceed anyway. Dirty files are KEPT on " +
1184
- "disk and only un-tracked from sync; clean out-of-scope files are " +
1185
- "quarantined under .hq/scope-quarantine/ (recoverable).",
1186
- )
1187
- .action(
1188
- async (
1189
- options: CommonSyncOptions & {
1190
- onConflict?: ConflictStrategy;
1191
- all?: boolean;
1192
- personal?: boolean;
1193
- modeAll?: boolean;
1194
- forceScopeShrink?: boolean;
1195
- },
1196
- ) => {
1197
- try {
1198
- configureSyncLockTimeout(options.lockTimeout);
1199
- assertSingleSelector(options, "pull");
1200
- } catch (err) {
1201
- console.error(
1202
- chalk.red("\n✗ Pull failed:"),
1203
- err instanceof Error ? err.message : String(err),
1204
- );
1205
- process.exit(1);
1206
- }
1207
- if (options.all) {
1208
- // `options.personal === false` is Commander's auto-negation of
1209
- // `--personal`; in `--all` mode that means "drop the personal
1210
- // leg from the fanout" (see `--no-personal` option above).
1211
- await runPullAll(
1212
- options.hqRoot,
1213
- options.onConflict,
1214
- options.modeAll === true,
1215
- options.personal === false,
1216
- options.forceScopeShrink === true,
1217
- );
1218
- return;
1219
- }
1220
- if (options.personal) {
1221
- await runPullPersonal(options.hqRoot, options.onConflict);
1222
- return;
1223
- }
1224
- try {
1225
- console.log(chalk.bold("\nHQ Sync — Pull"));
1226
- console.log(` HQ root: ${options.hqRoot}`);
1227
- console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
1228
-
1229
- const accessToken = await ensureCognitoToken();
1230
- const vaultConfig = buildVaultConfig(accessToken);
1231
-
1232
- // US-011 (2026-05-21 fix): resolve the caller's sync-config for
1233
- // the targeted membership BEFORE the pull, so we can (a) emit the
1234
- // narrow-hint banner after success if still on all-mode and
1235
- // (b) respect strict-mode refusal mirror of the --all + sync-now
1236
- // paths. Failure to resolve degrades silently — pull still works,
1237
- // banner just stays quiet (same as the catch in runPullAll).
1238
- const narrowHintLevel: BannerLevel = resolveBannerLevel();
1239
- const pullClient = new VaultClient(vaultConfig);
1240
- const { resolvedCompanyUid, resolvedMode } =
1241
- await resolvePerCompanyPullPlan(pullClient, options.company);
1242
- // DEV-1768: resolve the REAL effective scope (mode + prefixSet) and
1243
- // thread it into the pull below — not just the banner. Best-effort;
1244
- // degrades to "all" inside the resolver on any failure.
1245
- const pullScope = await resolveCliPullScope(
1246
- pullClient,
1247
- options.company,
1248
- options.hqRoot,
1249
- );
1250
-
1251
- // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
1252
- // Default banner level is 'hint' which never triggers refusal —
1253
- // wired now so future hq-core-staging releases can flip the
1254
- // default to 'strict' without re-touching this command.
1255
- if (
1256
- resolvedMode === "all" &&
1257
- isStrictRefusal(resolvedMode, narrowHintLevel) &&
1258
- options.modeAll !== true &&
1259
- resolvedCompanyUid
1260
- ) {
1261
- emitNarrowHint({
1262
- companyUid: resolvedCompanyUid,
1263
- syncMode: resolvedMode,
1264
- level: narrowHintLevel,
1265
- });
1266
- console.error(
1267
- chalk.red(
1268
- "\n✗ Pull refused: strict narrow-hint mode is on and this " +
1269
- "membership still pulls everything. Run `hq sync narrow --apply` " +
1270
- "to migrate, or re-run with --mode-all.",
1271
- ),
1272
- );
1273
- process.exit(1);
1274
- }
1275
-
1276
- const excludePrefixes = readScopeExcludePrefixes(pullScope);
1277
- const result = await sync({
1278
- company: options.company,
1279
- onConflict: options.onConflict,
1280
- vaultConfig,
1281
- hqRoot: options.hqRoot,
1282
- ...(pullScope?.syncMode !== undefined
1283
- ? { syncMode: pullScope.syncMode }
1284
- : {}),
1285
- ...(pullScope?.prefixSet !== undefined
1286
- ? { prefixSet: pullScope.prefixSet }
1287
- : {}),
1288
- // Sessions-exclusion: never materialize excluded prefixes even for
1289
- // a wide-STS owner.
1290
- ...(excludePrefixes ? { excludePrefixes } : {}),
1291
- ...(options.forceScopeShrink ? { forceScopeShrink: true } : {}),
1292
- });
1293
-
1294
- if (result.aborted) {
1295
- console.log(
1296
- chalk.yellow(
1297
- `\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
1298
- ),
1299
- );
1300
- process.exit(1);
1301
- }
1302
-
1303
- console.log(
1304
- chalk.green(
1305
- `\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
1306
- ),
1307
- );
1308
-
1309
- // US-011 (2026-05-21 fix): emit the hint banner after success
1310
- // so it appears alongside the summary line. Mirrors the wiring
1311
- // in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
1312
- if (resolvedMode === "all" && resolvedCompanyUid) {
1313
- emitNarrowHint({
1314
- companyUid: resolvedCompanyUid,
1315
- syncMode: resolvedMode,
1316
- level: narrowHintLevel,
1317
- });
1318
- }
1319
- } catch (err) {
1320
- console.error(
1321
- chalk.red("\n✗ Pull failed:"),
1322
- err instanceof Error ? err.message : String(err),
1323
- );
1324
- process.exit(1);
1325
- }
1326
- },
1327
- );
1328
-
1329
- program
1330
- .command("status")
1331
- .description("Show local sync journal summary")
1332
- .option(
1333
- "--hq-root <path>",
1334
- `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
1335
- DEFAULT_HQ_ROOT,
1336
- )
1337
- .action((options: { hqRoot: string }) => {
1338
- try {
1339
- // The engine SHARDS the journal by slug — the personal-vault fanout
1340
- // slot, one shard per cloud company, and the legacy "personal" shard.
1341
- // Enumerate EVERY shard the engine can write (via the engine's own
1342
- // `listJournals`) rather than reconstructing a single path: the old
1343
- // code passed the HQ-root PATH where a slug was expected, so it always
1344
- // looked at `sync-journal._Users_<user>_hq.json` — a slug the engine
1345
- // never writes — and printed "No sync journal yet" right after a
1346
- // successful sync (feedback_9fbf1f82 / feedback_46288b7b).
1347
- const journals = listJournals();
1348
-
1349
- if (journals.length === 0) {
1350
- console.log(
1351
- chalk.dim(
1352
- "No sync journal yet — run `hq sync now`, `hq sync push`, or `hq sync pull` to create one.",
1353
- ),
1354
- );
1355
- console.log(
1356
- chalk.dim(` Looked in: ${getStateDir()} (sync-journal.*.json)`),
1357
- );
1358
- return;
1359
- }
1360
-
1361
- const configPath = path.join(options.hqRoot, ".hq", "config.json");
1362
- let activeCompany: string | undefined;
1363
- if (fs.existsSync(configPath)) {
1364
- try {
1365
- const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1366
- activeCompany = cfg.activeCompany;
1367
- } catch {
1368
- // ignore
1369
- }
1370
- }
1371
-
1372
- let grandFiles = 0;
1373
- let grandBytes = 0;
1374
- let grandLastSync = "";
1375
-
1376
- console.log(chalk.bold("\nHQ Sync — Status"));
1377
- console.log(` HQ root: ${options.hqRoot}`);
1378
- console.log(` Active company: ${activeCompany ?? chalk.dim("(none)")}`);
1379
- console.log(` Journals: ${journals.length}`);
1380
-
1381
- for (const j of journals) {
1382
- const entries = Object.entries(j.journal.files ?? {});
1383
- // Tombstones record removed files — exclude them from "tracked".
1384
- const live = entries.filter(([, entry]) => !entry.removedAt);
1385
- const bytes = live.reduce(
1386
- (acc, [, entry]) => acc + (entry.size ?? 0),
1387
- 0,
1388
- );
1389
- const lastSync =
1390
- j.journal.lastSync ||
1391
- live
1392
- .map(([, entry]) => entry.syncedAt)
1393
- .filter((t): t is string => typeof t === "string")
1394
- .sort()
1395
- .at(-1) ||
1396
- "never";
1397
-
1398
- grandFiles += live.length;
1399
- grandBytes += bytes;
1400
- if (lastSync !== "never" && lastSync > grandLastSync) {
1401
- grandLastSync = lastSync;
1402
- }
1403
-
1404
- console.log(`\n • ${scopeLabel(j.slug)}`);
1405
- console.log(` Tracked files: ${live.length}`);
1406
- console.log(` Total size: ${formatBytes(bytes)}`);
1407
- console.log(` Last sync: ${lastSync}`);
1408
- console.log(chalk.dim(` Journal: ${j.path}`));
1409
- }
1410
-
1411
- if (journals.length > 1) {
1412
- console.log(chalk.bold("\n All scopes"));
1413
- console.log(` Tracked files: ${grandFiles}`);
1414
- console.log(` Total size: ${formatBytes(grandBytes)}`);
1415
- console.log(` Last sync: ${grandLastSync || "never"}`);
1416
- }
1417
- } catch (err) {
1418
- console.error(
1419
- chalk.red("✗ Status failed:"),
1420
- err instanceof Error ? err.message : String(err),
1421
- );
1422
- process.exit(1);
1423
- }
1424
- });
1425
-
1426
- program
1427
- .command("now")
1428
- .description(
1429
- "Bidirectional sync: push local changes, then pull remote updates " +
1430
- "(mirrors AppBar HQ Sync's \"Sync Now\" button)",
1431
- )
1432
- .option(
1433
- "--hq-root <path>",
1434
- `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
1435
- DEFAULT_HQ_ROOT,
1436
- )
1437
- .option(
1438
- "--company <slug>",
1439
- "Company slug or UID (defaults to active company in .hq/config.json)",
1440
- )
1441
- .option(
1442
- "--lock-timeout <seconds>",
1443
- "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
1444
- )
1445
- .option(
1446
- "--message <msg>",
1447
- "Optional message attached to journal entries for the push leg",
1448
- )
1449
- .option(
1450
- "--on-conflict <strategy>",
1451
- "Conflict strategy: overwrite | keep | abort (omit for interactive)",
1452
- )
1453
- .option(
1454
- "--all",
1455
- "Sync every company you are a member of plus your personal vault " +
1456
- "(pushAll then pullAll). Mutually exclusive with --company and " +
1457
- "--personal.",
1458
- )
1459
- .option(
1460
- "--personal",
1461
- "Sync the caller's canonical personal vault bidirectionally. " +
1462
- "Mutually exclusive with --company and --all.",
1463
- )
1464
- .option(
1465
- "--no-personal",
1466
- "In `--all` mode, skip the canonical-person leg of both the push " +
1467
- "and pull fanouts — only sync the caller's company memberships. " +
1468
- "Mirrors the upstream `hq-sync-runner --skip-personal` flag " +
1469
- "(hq-cloud 5.25.0). Ignored outside `--all`.",
1470
- )
1471
- .option(
1472
- "--mode-all",
1473
- "US-011: opt out of the strict narrow-hint refusal for this run. " +
1474
- "No-op today; wired so future hq-core-staging releases can flip " +
1475
- "the default narrow-hint level to 'strict'.",
1476
- )
1477
- .option(
1478
- "--force-scope-shrink",
1479
- "When a scope shrink would un-track locally-modified files, proceed " +
1480
- "anyway. Dirty files are KEPT on disk (only un-tracked); clean " +
1481
- "out-of-scope files are quarantined under .hq/scope-quarantine/.",
1482
- )
1483
- .action(
1484
- async (
1485
- options: CommonSyncOptions & {
1486
- onConflict?: ConflictStrategy;
1487
- message?: string;
1488
- all?: boolean;
1489
- personal?: boolean;
1490
- modeAll?: boolean;
1491
- forceScopeShrink?: boolean;
1492
- },
1493
- ) => {
1494
- try {
1495
- configureSyncLockTimeout(options.lockTimeout);
1496
- assertSingleSelector(options, "now");
1497
- if (options.all) {
1498
- // `options.personal === false` is Commander's auto-negation
1499
- // of `--personal`; in `--all` mode that means "drop the
1500
- // personal leg from both legs of the bidirectional fanout"
1501
- // (see `--no-personal` option above).
1502
- await runNowAll(
1503
- options.hqRoot,
1504
- options.message,
1505
- options.onConflict,
1506
- options.modeAll === true,
1507
- options.personal === false,
1508
- options.forceScopeShrink === true,
1509
- );
1510
- return;
1511
- }
1512
- await runNowSingle(
1513
- options.hqRoot,
1514
- options.company,
1515
- options.personal === true,
1516
- options.message,
1517
- options.onConflict,
1518
- options.modeAll === true,
1519
- options.forceScopeShrink === true,
1520
- );
1521
- } catch (err) {
1522
- console.error(
1523
- chalk.red("\n✗ Sync now failed:"),
1524
- err instanceof Error ? err.message : String(err),
1525
- );
1526
- process.exit(1);
1527
- }
1528
- },
1529
- );
1530
- }
1531
-
1532
- async function runPullAll(
1533
- hqRoot: string,
1534
- onConflict?: ConflictStrategy,
1535
- modeAllOverride?: boolean,
1536
- skipPersonal?: boolean,
1537
- forceScopeShrink?: boolean,
1538
- ): Promise<void> {
1539
- console.log(chalk.bold("\nHQ Sync — Pull (all)"));
1540
- console.log(` HQ root: ${hqRoot}`);
1541
- console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
1542
- if (skipPersonal) {
1543
- console.log(` Personal: skipped (--no-personal)`);
1544
- }
1545
- console.log("");
1546
-
1547
- let result: PullAllResult;
1548
- try {
1549
- const accessToken = await ensureCognitoToken();
1550
- const vaultConfig = buildVaultConfig(accessToken);
1551
- const realClient = new VaultClient(vaultConfig);
1552
-
1553
- const adapter: PullAllVaultClient = {
1554
- listMyMemberships: () => realClient.listMyMemberships(),
1555
- listPersonEntities: () => realClient.entity.listByType("person"),
1556
- getEntity: async (uid: string) => {
1557
- try {
1558
- return await realClient.entity.get(uid);
1559
- } catch {
1560
- return null;
1561
- }
1562
- },
1563
- getMembershipSyncConfig: (id: string) =>
1564
- realClient.getMembershipSyncConfig(id),
1565
- listMyExplicitGrants: (companyUid: string) =>
1566
- realClient.listMyExplicitGrants(companyUid),
1567
- };
1568
-
1569
- result = await pullAll(
1570
- {
1571
- hqRoot,
1572
- ...(onConflict ? { onConflict } : {}),
1573
- narrowHintLevel: resolveBannerLevel(),
1574
- ...(modeAllOverride ? { modeAllOverride: true } : {}),
1575
- ...(skipPersonal ? { skipPersonal: true } : {}),
1576
- ...(forceScopeShrink ? { forceScopeShrink: true } : {}),
1577
- },
1578
- {
1579
- vaultClient: adapter,
1580
- // DEV-1768: resolve each company's REAL pull scope (mode + prefixSet)
1581
- // via the shared resolver, so the actual pull is scoped — the CLI no
1582
- // longer seeds an all-mode PullRecord that wedges the menubar runner.
1583
- resolveScope: (companyUid: string, slug: string) =>
1584
- resolvePullScope(realClient, companyUid, slug, hqRoot),
1585
- sync: (opts) =>
1586
- sync({
1587
- company: opts.company,
1588
- hqRoot: opts.hqRoot,
1589
- vaultConfig,
1590
- ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
1591
- ...(opts.personalMode !== undefined
1592
- ? { personalMode: opts.personalMode }
1593
- : {}),
1594
- ...(opts.journalSlug !== undefined
1595
- ? { journalSlug: opts.journalSlug }
1596
- : {}),
1597
- ...(opts.syncMode !== undefined ? { syncMode: opts.syncMode } : {}),
1598
- ...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
1599
- // Sessions-exclusion: forward the per-company excludePrefixes that
1600
- // pullAll stamped onto SyncCallOptions into the real hq-cloud sync().
1601
- ...(opts.excludePrefixes?.length
1602
- ? { excludePrefixes: opts.excludePrefixes }
1603
- : {}),
1604
- ...(opts.forceScopeShrink ? { forceScopeShrink: true } : {}),
1605
- }),
1606
- },
1607
- );
1608
- } catch (err) {
1609
- console.error(
1610
- chalk.red("\n✗ Pull-all failed:"),
1611
- err instanceof Error ? err.message : String(err),
1612
- );
1613
- process.exit(1);
1614
- }
1615
-
1616
- for (const row of result.perCompany) {
1617
- if (row.error) {
1618
- console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
1619
- } else if (row.result) {
1620
- const r = row.result;
1621
- const status = r.aborted ? chalk.yellow("⚠") : chalk.green("✓");
1622
- console.log(
1623
- ` ${status} ${row.slug}: ${r.filesDownloaded} file(s), ` +
1624
- `${formatBytes(r.bytesDownloaded)}, ` +
1625
- `${r.filesSkipped} skipped, ${r.conflicts} conflict(s)` +
1626
- (r.aborted ? " — aborted" : ""),
1627
- );
1628
- }
1629
- }
1630
-
1631
- const errored = result.errors.length;
1632
- const summary =
1633
- `\nPulled ${result.filesDownloaded} file(s) ` +
1634
- `(${formatBytes(result.bytesDownloaded)}) across ${result.attempted} ` +
1635
- `target(s); ${result.conflicts} conflict(s); ${errored} error(s)`;
1636
- console.log(errored > 0 ? chalk.yellow(summary) : chalk.green(summary));
1637
- if (errored > 0) process.exit(1);
1638
- }
1639
-
1640
- async function runPullPersonal(
1641
- hqRoot: string,
1642
- onConflict?: ConflictStrategy,
1643
- ): Promise<void> {
1644
- console.log(chalk.bold("\nHQ Sync — Pull (personal)"));
1645
- console.log(` HQ root: ${hqRoot}`);
1646
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1647
-
1648
- try {
1649
- const accessToken = await ensureCognitoToken();
1650
- const vaultConfig = buildVaultConfig(accessToken);
1651
- const client = new VaultClient(vaultConfig);
1652
- const personalUid = await resolveCanonicalPersonUid({
1653
- listMyMemberships: () => client.listMyMemberships(),
1654
- listPersonEntities: () => client.entity.listByType("person"),
1655
- getEntity: async () => null,
1656
- });
1657
-
1658
- const result = await sync({
1659
- company: personalUid,
1660
- ...(onConflict ? { onConflict } : {}),
1661
- vaultConfig,
1662
- hqRoot,
1663
- personalMode: true,
1664
- journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
1665
- });
1666
-
1667
- if (result.aborted) {
1668
- console.log(
1669
- chalk.yellow(
1670
- `\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
1671
- ),
1672
- );
1673
- process.exit(1);
1674
- }
1675
-
1676
- console.log(
1677
- chalk.green(
1678
- `\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
1679
- ),
1680
- );
1681
- } catch (err) {
1682
- console.error(
1683
- chalk.red("\n✗ Pull (personal) failed:"),
1684
- err instanceof Error ? err.message : String(err),
1685
- );
1686
- process.exit(1);
1687
- }
1688
- }
1689
-
1690
- async function runPushAll(
1691
- hqRoot: string,
1692
- message?: string,
1693
- onConflict?: ConflictStrategy,
1694
- skipPersonal?: boolean,
1695
- ): Promise<void> {
1696
- console.log(chalk.bold("\nHQ Sync — Push (all)"));
1697
- console.log(` HQ root: ${hqRoot}`);
1698
- console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
1699
- if (skipPersonal) {
1700
- console.log(` Personal: skipped (--no-personal)`);
1701
- }
1702
- console.log("");
1703
-
1704
- let result: PushAllResult;
1705
- try {
1706
- const accessToken = await ensureCognitoToken();
1707
- const vaultConfig = buildVaultConfig(accessToken);
1708
- const realClient = new VaultClient(vaultConfig);
1709
- const author = resolveUploadAuthorFromCache();
1710
-
1711
- const adapter: PullAllVaultClient = {
1712
- listMyMemberships: () => realClient.listMyMemberships(),
1713
- listPersonEntities: () => realClient.entity.listByType("person"),
1714
- getEntity: async (uid: string) => {
1715
- try {
1716
- return await realClient.entity.get(uid);
1717
- } catch {
1718
- return null;
1719
- }
1720
- },
1721
- };
1722
-
1723
- result = await pushAll(
1724
- {
1725
- hqRoot,
1726
- ...(onConflict ? { onConflict } : {}),
1727
- ...(message ? { message } : {}),
1728
- ...(skipPersonal ? { skipPersonal: true } : {}),
1729
- },
1730
- {
1731
- vaultClient: adapter,
1732
- share: (opts) =>
1733
- share({
1734
- paths: opts.paths,
1735
- company: opts.company,
1736
- vaultConfig,
1737
- hqRoot: opts.hqRoot,
1738
- ...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
1739
- ...(opts.personalMode !== undefined
1740
- ? { personalMode: opts.personalMode }
1741
- : {}),
1742
- ...(opts.journalSlug !== undefined
1743
- ? { journalSlug: opts.journalSlug }
1744
- : {}),
1745
- ...(opts.message !== undefined ? { message: opts.message } : {}),
1746
- ...(opts.skipUnchanged !== undefined
1747
- ? { skipUnchanged: opts.skipUnchanged }
1748
- : {}),
1749
- ...(opts.propagateDeletes !== undefined
1750
- ? { propagateDeletes: opts.propagateDeletes }
1751
- : {}),
1752
- ...(opts.propagateDeletePolicy !== undefined
1753
- ? { propagateDeletePolicy: opts.propagateDeletePolicy }
1754
- : {}),
1755
- ...(author ? { author } : {}),
1756
- }),
1757
- },
1758
- );
1759
- } catch (err) {
1760
- console.error(
1761
- chalk.red("\n✗ Push-all failed:"),
1762
- err instanceof Error ? err.message : String(err),
1763
- );
1764
- process.exit(1);
1765
- }
1766
-
1767
- for (const row of result.perCompany) {
1768
- if (row.error) {
1769
- console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
1770
- } else if (row.result) {
1771
- const r = row.result;
1772
- const status =
1773
- r.aborted || r.filesExcludedByScope > 0
1774
- ? chalk.yellow("⚠")
1775
- : chalk.green("✓");
1776
- console.log(
1777
- ` ${status} ${row.slug}: ${r.filesUploaded} file(s), ` +
1778
- `${formatBytes(r.bytesUploaded)}, ${r.filesSkipped} skipped, ` +
1779
- `${r.filesDeleted} deleted, ${r.filesExcludedByScope} scope-excluded, ` +
1780
- `${r.conflictPaths.length} conflict(s)` +
1781
- (r.aborted ? " — aborted" : ""),
1782
- );
1783
- }
1784
- }
1785
-
1786
- const errored = result.errors.length;
1787
- const summary =
1788
- `\nPushed ${result.filesUploaded} file(s) ` +
1789
- `(${formatBytes(result.bytesUploaded)}) across ${result.attempted} ` +
1790
- `target(s); ${result.filesDeleted} deleted; ${errored} error(s); ` +
1791
- `${result.filesExcludedByScope} scope-excluded`;
1792
- console.log(
1793
- errored > 0 || result.filesExcludedByScope > 0
1794
- ? chalk.yellow(summary)
1795
- : chalk.green(summary),
1796
- );
1797
- if (result.filesExcludedByScope > 0) {
1798
- console.log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)!));
1799
- }
1800
- if (errored > 0) process.exit(1);
1801
- }
1802
-
1803
- async function runNowSingle(
1804
- hqRoot: string,
1805
- company: string | undefined,
1806
- personal: boolean,
1807
- message?: string,
1808
- onConflict?: ConflictStrategy,
1809
- modeAllOverride?: boolean,
1810
- forceScopeShrink?: boolean,
1811
- ): Promise<void> {
1812
- console.log(chalk.bold("\nHQ Sync — Now"));
1813
- console.log(` HQ root: ${hqRoot}`);
1814
- console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
1815
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1816
-
1817
- try {
1818
- const accessToken = await ensureCognitoToken();
1819
- const vaultConfig = buildVaultConfig(accessToken);
1820
- const author = resolveUploadAuthorFromCache();
1821
-
1822
- // Resolve the target. For --personal, look up the canonical person and
1823
- // route paths/journal through personalMode. Otherwise use the company arg
1824
- // (or fall back to the active company inside share()/sync()).
1825
- let targetCompany = company;
1826
- let personalMode = false;
1827
- let journalSlug: string | undefined;
1828
- let pushPaths: string[];
1829
-
1830
- if (personal) {
1831
- const client = new VaultClient(vaultConfig);
1832
- targetCompany = await resolveCanonicalPersonUid({
1833
- listMyMemberships: () => client.listMyMemberships(),
1834
- listPersonEntities: () => client.entity.listByType("person"),
1835
- getEntity: async () => null,
1836
- });
1837
- personalMode = true;
1838
- journalSlug = PERSONAL_VAULT_JOURNAL_SLUG;
1839
- pushPaths = computePersonalVaultPaths(hqRoot);
1840
- } else {
1841
- // For company targets we need a concrete slug to compute the push path.
1842
- // share() can resolve `company` itself for the upload, but the path
1843
- // computation must happen here. Use the explicit company if given;
1844
- // otherwise fall back to the active-company resolution inside the
1845
- // engine and compute paths from `hqRoot/companies` (share() will refuse
1846
- // anything outside that subtree anyway).
1847
- const slug = company ?? readActiveCompanySlug(hqRoot);
1848
- if (!slug) {
1849
- throw new Error(
1850
- "No company specified and no active company found. " +
1851
- "Use --company <slug>, --personal, or set up .hq/config.json.",
1852
- );
1853
- }
1854
- pushPaths = [path.join(hqRoot, "companies", slug)];
1855
- }
1856
-
1857
- // Push first so the subsequent pull doesn't redownload files we were
1858
- // about to broadcast (matches hq-sync-runner ordering).
1859
- console.log(chalk.dim(" → push leg"));
1860
- const pushResult = await share({
1861
- paths: pushPaths,
1862
- company: targetCompany,
1863
- vaultConfig,
1864
- hqRoot,
1865
- skipUnchanged: true,
1866
- propagateDeletes: true,
1867
- propagateDeletePolicy: resolveDeletePolicy(),
1868
- ...(onConflict ? { onConflict } : {}),
1869
- ...(message ? { message } : {}),
1870
- ...(personalMode ? { personalMode: true } : {}),
1871
- ...(journalSlug !== undefined ? { journalSlug } : {}),
1872
- ...(author ? { author } : {}),
1873
- });
1874
- const pushStatus =
1875
- pushResult.aborted || pushResult.filesExcludedByScope > 0
1876
- ? chalk.yellow("⚠")
1877
- : chalk.green("✓");
1878
- console.log(
1879
- ` ${pushStatus} ` +
1880
- `${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
1881
- `${pushResult.filesDeleted} deleted, ${pushResult.filesExcludedByScope} scope-excluded` +
1882
- (pushResult.aborted ? " — aborted" : ""),
1883
- );
1884
- if (pushResult.filesExcludedByScope > 0) {
1885
- console.log(chalk.yellow(scopeExcludedWarning(pushResult.filesExcludedByScope)!));
1886
- }
1887
- if (pushResult.aborted) {
1888
- console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
1889
- process.exit(1);
1890
- }
1891
-
1892
- // US-011: resolve membership sync-config so we can either nudge an
1893
- // all-mode owner or refuse the pull when strict-mode is on. Skipped
1894
- // for personal targets (personal vault has no membership row) and
1895
- // for resolution failures (best-effort — never block sync). The
1896
- // lookup runs BEFORE the pull leg so strict refusal can short-circuit
1897
- // without burning a sync.
1898
- const narrowHintLevel: BannerLevel = resolveBannerLevel();
1899
- let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
1900
- let resolvedCompanyUid: string | undefined;
1901
- if (!personalMode && targetCompany) {
1902
- try {
1903
- const client = new VaultClient(vaultConfig);
1904
- const memberships = await client.listMyMemberships();
1905
- const match = memberships.find(
1906
- (m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
1907
- );
1908
- if (match) {
1909
- resolvedCompanyUid = match.companyUid;
1910
- try {
1911
- const cfg = await client.getMembershipSyncConfig(
1912
- match.membershipKey,
1913
- );
1914
- resolvedMode = cfg.syncMode;
1915
- } catch {
1916
- resolvedMode = undefined;
1917
- }
1918
- }
1919
- } catch {
1920
- resolvedMode = undefined;
1921
- }
1922
- }
1923
-
1924
- if (
1925
- resolvedMode === "all" &&
1926
- isStrictRefusal(resolvedMode, narrowHintLevel) &&
1927
- !modeAllOverride &&
1928
- resolvedCompanyUid
1929
- ) {
1930
- emitNarrowHint({
1931
- companyUid: resolvedCompanyUid,
1932
- syncMode: resolvedMode,
1933
- level: narrowHintLevel,
1934
- });
1935
- console.error(
1936
- chalk.red(
1937
- "\n✗ Sync now refused: strict narrow-hint mode is on and this " +
1938
- "membership still pulls everything. Run `hq sync narrow --apply` " +
1939
- "to migrate, or re-run with --mode-all.",
1940
- ),
1941
- );
1942
- process.exit(1);
1943
- }
1944
-
1945
- // DEV-1768: resolve the membership's real scope and thread it into the
1946
- // pull leg, so `hq sync now` stops seeding all-mode PullRecords. Personal
1947
- // targets have no membership scope — they stay full ("all").
1948
- let pullScope: PullScope | undefined;
1949
- if (!personalMode) {
1950
- try {
1951
- pullScope = await resolveCliPullScope(
1952
- new VaultClient(vaultConfig),
1953
- targetCompany,
1954
- hqRoot,
1955
- );
1956
- } catch {
1957
- pullScope = undefined;
1958
- }
1959
- }
1960
-
1961
- console.log(chalk.dim(" → pull leg"));
1962
- const excludePrefixes = readScopeExcludePrefixes(pullScope);
1963
- const pullResult = await sync({
1964
- company: targetCompany,
1965
- vaultConfig,
1966
- hqRoot,
1967
- ...(onConflict ? { onConflict } : {}),
1968
- ...(personalMode ? { personalMode: true } : {}),
1969
- ...(journalSlug !== undefined ? { journalSlug } : {}),
1970
- ...(pullScope?.syncMode !== undefined
1971
- ? { syncMode: pullScope.syncMode }
1972
- : {}),
1973
- ...(pullScope?.prefixSet !== undefined
1974
- ? { prefixSet: pullScope.prefixSet }
1975
- : {}),
1976
- // Sessions-exclusion: honor the resolver's excludePrefixes on the
1977
- // `hq sync now` pull leg too.
1978
- ...(excludePrefixes ? { excludePrefixes } : {}),
1979
- ...(forceScopeShrink ? { forceScopeShrink: true } : {}),
1980
- });
1981
- console.log(
1982
- ` ${pullResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
1983
- `${pullResult.filesDownloaded} downloaded, ${pullResult.filesSkipped} skipped, ` +
1984
- `${pullResult.conflicts} conflict(s)` +
1985
- (pullResult.aborted ? " — aborted" : ""),
1986
- );
1987
-
1988
- if (pullResult.aborted) {
1989
- console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
1990
- process.exit(1);
1991
- }
1992
-
1993
- // US-011: emit the hint banner after a successful pull so it
1994
- // appears at the bottom of the summary rather than mid-stream.
1995
- if (resolvedMode === "all" && resolvedCompanyUid) {
1996
- emitNarrowHint({
1997
- companyUid: resolvedCompanyUid,
1998
- syncMode: resolvedMode,
1999
- level: narrowHintLevel,
2000
- });
2001
- }
2002
-
2003
- console.log(chalk.green("\n✓ Sync now complete"));
2004
- } catch (err) {
2005
- console.error(
2006
- chalk.red("\n✗ Sync now failed:"),
2007
- err instanceof Error ? err.message : String(err),
2008
- );
2009
- process.exit(1);
2010
- }
2011
- }
2012
-
2013
- async function runNowAll(
2014
- hqRoot: string,
2015
- message?: string,
2016
- onConflict?: ConflictStrategy,
2017
- modeAllOverride?: boolean,
2018
- skipPersonal?: boolean,
2019
- forceScopeShrink?: boolean,
2020
- ): Promise<void> {
2021
- console.log(chalk.bold("\nHQ Sync — Now (all)"));
2022
- console.log(` HQ root: ${hqRoot}`);
2023
- console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
2024
- if (skipPersonal) {
2025
- console.log(` Personal: skipped (--no-personal)`);
2026
- }
2027
- console.log("");
2028
-
2029
- // Push first (matches runner), then pull. Re-uses the per-leg orchestrators
2030
- // so the per-target rendering, error isolation, and exit codes are
2031
- // identical to running `push --all` then `pull --all` back-to-back.
2032
- console.log(chalk.dim("→ push --all"));
2033
- await runPushAll(hqRoot, message, onConflict, skipPersonal);
2034
- console.log(chalk.dim("\n→ pull --all"));
2035
- // US-011: forward --mode-all so the strict refusal applies to the
2036
- // pull leg (push doesn't need a narrow-hint — the narrow ritual is
2037
- // pull-side).
2038
- await runPullAll(
2039
- hqRoot,
2040
- onConflict,
2041
- modeAllOverride,
2042
- skipPersonal,
2043
- forceScopeShrink,
2044
- );
2045
- }
2046
-
2047
- /**
2048
- * Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
2049
- * Returns undefined when the file is missing, malformed, or has no
2050
- * `activeCompany` field — `runNowSingle` surfaces a clean error in that case.
2051
- */
2052
- function readActiveCompanySlug(hqRoot: string): string | undefined {
2053
- const configPath = path.join(hqRoot, ".hq", "config.json");
2054
- if (!fs.existsSync(configPath)) return undefined;
2055
- try {
2056
- const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8")) as {
2057
- activeCompany?: string;
2058
- };
2059
- return cfg.activeCompany;
2060
- } catch {
2061
- return undefined;
2062
- }
2063
- }
2064
-
2065
- /**
2066
- * Human-friendly label for a journal slug in `hq sync status`. The
2067
- * personal-vault fanout slot and the legacy `companies/personal` company both
2068
- * carry opaque slugs; everything else is a real company slug shown as-is.
2069
- */
2070
- function scopeLabel(slug: string): string {
2071
- if (slug === PERSONAL_VAULT_JOURNAL_SLUG) return "personal vault";
2072
- if (slug === "personal") return "personal (companies/personal)";
2073
- return slug;
2074
- }
2075
-
2076
- function formatBytes(bytes: number): string {
2077
- if (bytes === 0) return "0 B";
2078
- const units = ["B", "KB", "MB", "GB"];
2079
- const exponent = Math.min(
2080
- Math.floor(Math.log(bytes) / Math.log(1024)),
2081
- units.length - 1,
2082
- );
2083
- const value = bytes / Math.pow(1024, exponent);
2084
- return `${value.toFixed(value >= 100 || exponent === 0 ? 0 : 1)} ${units[exponent]}`;
2085
- }
2086
-
2087
- /**
2088
- * Read all of stdin as a UTF-8 string. Used by `--creds-from-stdin` to
2089
- * receive a JSON-serialized EntityContext from the parent process (e.g.
2090
- * AppBar HQ Sync). Returns the empty string when stdin closes immediately.
2091
- *
2092
- * Caller is expected to detect TTY first — this function will block forever
2093
- * waiting for stdin to close if invoked interactively.
2094
- */
2095
- async function readAllStdin(): Promise<string> {
2096
- const chunks: Buffer[] = [];
2097
- for await (const chunk of process.stdin) {
2098
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2099
- }
2100
- return Buffer.concat(chunks).toString("utf8");
2101
- }
2102
-
2103
- /**
2104
- * Resolve the syncing user's `UploadAuthor` (sub + email) from the cached
2105
- * Cognito idToken. Returns `undefined` when no tokens are cached or the
2106
- * token is missing the required claims — share() then skips the metadata
2107
- * stamp gracefully (not an error).
2108
- *
2109
- * We deliberately decode the JWT here instead of verifying it: Cognito
2110
- * already verified at issuance, and we only use the public claims to
2111
- * label the upload's S3 user metadata (no auth decision rides on it).
2112
- */
2113
- function resolveUploadAuthorFromCache(): UploadAuthor | undefined {
2114
- const tokens = loadCachedTokens();
2115
- if (!tokens?.idToken) return undefined;
2116
- const parts = tokens.idToken.split(".");
2117
- if (parts.length !== 3) return undefined;
2118
- try {
2119
- const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
2120
- const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
2121
- const json = Buffer.from(padded, "base64").toString("utf-8");
2122
- const claims = JSON.parse(json) as { sub?: string; email?: string };
2123
- if (claims.sub && claims.email) {
2124
- return { userSub: claims.sub, email: claims.email };
2125
- }
2126
- return undefined;
2127
- } catch {
2128
- return undefined;
2129
- }
2130
- }