@indigoai-us/hq-cli 5.71.0 → 5.73.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 (116) hide show
  1. package/dist/bin/hq-auth-refresh.js +1 -4
  2. package/dist/cli-version.js +1 -4
  3. package/dist/commands/__fixtures__/make-tar.js +1 -4
  4. package/dist/commands/_patterns.js +1 -4
  5. package/dist/commands/add.js +1 -4
  6. package/dist/commands/agents.js +1 -4
  7. package/dist/commands/api-keys.js +1 -4
  8. package/dist/commands/auth.js +1 -4
  9. package/dist/commands/billing.js +1 -4
  10. package/dist/commands/channels.js +1 -4
  11. package/dist/commands/cloud-demote.js +1 -4
  12. package/dist/commands/cloud-provision.js +1 -4
  13. package/dist/commands/cloud.js +1 -4
  14. package/dist/commands/company.js +1 -4
  15. package/dist/commands/creators.js +1 -4
  16. package/dist/commands/crm.js +1 -4
  17. package/dist/commands/db-migrate.js +1 -4
  18. package/dist/commands/db-provision.js +1 -4
  19. package/dist/commands/db-sql.js +1 -4
  20. package/dist/commands/db-status.js +1 -4
  21. package/dist/commands/db.js +1 -4
  22. package/dist/commands/dm.js +1 -4
  23. package/dist/commands/feedback.js +1 -4
  24. package/dist/commands/files-browse.js +1 -4
  25. package/dist/commands/files.js +1 -4
  26. package/dist/commands/group-grants.js +1 -4
  27. package/dist/commands/groups.js +1 -4
  28. package/dist/commands/integrations.js +1 -4
  29. package/dist/commands/list.js +1 -4
  30. package/dist/commands/login.js +1 -4
  31. package/dist/commands/logout.js +1 -4
  32. package/dist/commands/mcp-registration.js +1 -4
  33. package/dist/commands/mcp-status.js +1 -4
  34. package/dist/commands/meetings.js +1 -4
  35. package/dist/commands/members.js +1 -4
  36. package/dist/commands/onboard-identity-guard.js +1 -4
  37. package/dist/commands/onboard-join.js +1 -4
  38. package/dist/commands/onboard-warning.js +1 -4
  39. package/dist/commands/onboard.js +1 -4
  40. package/dist/commands/outposts.d.ts +30 -1
  41. package/dist/commands/outposts.js +221 -10
  42. package/dist/commands/pack-install.js +1 -4
  43. package/dist/commands/packs.js +1 -4
  44. package/dist/commands/people.js +1 -4
  45. package/dist/commands/pkg-install.js +1 -4
  46. package/dist/commands/pkg-list.js +1 -4
  47. package/dist/commands/pkg-remove.js +1 -4
  48. package/dist/commands/pkg-update.js +1 -4
  49. package/dist/commands/publish.js +1 -4
  50. package/dist/commands/reindex.js +1 -4
  51. package/dist/commands/rescue.js +1 -4
  52. package/dist/commands/run.js +1 -4
  53. package/dist/commands/safe-extract.js +1 -4
  54. package/dist/commands/secrets-scope.js +1 -4
  55. package/dist/commands/secrets.js +1 -4
  56. package/dist/commands/signals.js +1 -4
  57. package/dist/commands/skill.js +1 -4
  58. package/dist/commands/sources.js +1 -4
  59. package/dist/commands/sync-mode.js +1 -4
  60. package/dist/commands/sync-narrow.js +1 -4
  61. package/dist/commands/sync.js +1 -4
  62. package/dist/commands/team-sync.js +1 -4
  63. package/dist/commands/update.js +1 -4
  64. package/dist/commands/whoami.js +1 -4
  65. package/dist/commands/workers.js +1 -4
  66. package/dist/index.js +1 -4
  67. package/dist/lib/db/control-plane.js +1 -4
  68. package/dist/lib/db/local.js +1 -4
  69. package/dist/lib/db/migrate.js +1 -4
  70. package/dist/lib/db/paths.js +1 -4
  71. package/dist/lib/db/remote-engine.js +1 -4
  72. package/dist/lib/db/remote-sql.js +1 -4
  73. package/dist/lib/db/sql.js +1 -4
  74. package/dist/lib/local-tree-diff.js +1 -4
  75. package/dist/lib/narrow-hint-banner.js +1 -4
  76. package/dist/main.js +1 -4
  77. package/dist/node-preflight.js +1 -4
  78. package/dist/run/discover-schemas.js +1 -4
  79. package/dist/run/hq-plugin.js +1 -4
  80. package/dist/sentry-before-send.js +1 -4
  81. package/dist/sentry-dsn.generated.js +1 -4
  82. package/dist/sentry.js +1 -4
  83. package/dist/strategies/link.js +1 -4
  84. package/dist/strategies/merge.js +1 -4
  85. package/dist/types.js +1 -4
  86. package/dist/utils/billing-gate.js +1 -4
  87. package/dist/utils/breadcrumb-buffer.js +1 -4
  88. package/dist/utils/cli-telemetry.js +1 -4
  89. package/dist/utils/cognito-session.js +1 -4
  90. package/dist/utils/contribution-table.js +1 -4
  91. package/dist/utils/environmental-error.js +1 -4
  92. package/dist/utils/epipe.js +1 -4
  93. package/dist/utils/feedback-diagnostics.js +1 -4
  94. package/dist/utils/feedback-screenshots.js +1 -4
  95. package/dist/utils/feedback-versions.js +1 -4
  96. package/dist/utils/git.js +1 -4
  97. package/dist/utils/integrity.js +1 -4
  98. package/dist/utils/intercepted-process-exit.js +1 -4
  99. package/dist/utils/login-provider.js +1 -4
  100. package/dist/utils/manifest.js +1 -4
  101. package/dist/utils/pack-contributions.js +1 -4
  102. package/dist/utils/people.js +1 -4
  103. package/dist/utils/registry-client.js +1 -4
  104. package/dist/utils/registry.js +1 -4
  105. package/dist/utils/sandbox-runner-client.js +1 -4
  106. package/dist/utils/secrets-cache.js +1 -4
  107. package/dist/utils/sentry-identity.js +1 -4
  108. package/dist/utils/vault-api.js +1 -4
  109. package/dist/utils/version-check.js +1 -4
  110. package/dist/utils/version-gate.js +1 -4
  111. package/package.json +3 -2
  112. package/scripts/chmod-bins.mjs +49 -0
  113. package/src/commands/outposts-replica-sync.test.ts +272 -0
  114. package/src/commands/outposts.test.ts +32 -0
  115. package/src/commands/outposts.ts +330 -7
  116. package/src/packaging.test.ts +55 -0
@@ -14,8 +14,6 @@
14
14
  * company slug up front; nothing in this module ever enumerates or reads across
15
15
  * company boundaries — HQ tenancy rules forbid cross-company member lookups.
16
16
  */
17
-
18
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="aac5adc3-c336-5d9a-8a1a-edaf16ef4c11")}catch(e){}}();
19
17
  import * as fs from "fs";
20
18
  import * as path from "path";
21
19
  import * as yaml from "js-yaml";
@@ -164,5 +162,4 @@ export function resolveNameToEmail(people, name) {
164
162
  return { status: "no_email", person };
165
163
  return { status: "found", email: person.email, person };
166
164
  }
167
- //# sourceMappingURL=people.js.map
168
- //# debugId=aac5adc3-c336-5d9a-8a1a-edaf16ef4c11
165
+ //# sourceMappingURL=people.js.map
@@ -4,8 +4,6 @@
4
4
  *
5
5
  * Auth tokens are NEVER written to stdout or logs.
6
6
  */
7
-
8
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="43d1600f-1bf7-5a52-819b-83e71ba30c73")}catch(e){}}();
9
7
  import * as fs from 'fs';
10
8
  import * as path from 'path';
11
9
  import * as yaml from 'js-yaml';
@@ -101,5 +99,4 @@ export class RegistryClient {
101
99
  return this.request('GET', `/packages/${encodeURIComponent(slug)}/download${qs ? `?${qs}` : ''}`, { auth: true });
102
100
  }
103
101
  }
104
- //# sourceMappingURL=registry-client.js.map
105
- //# debugId=43d1600f-1bf7-5a52-819b-83e71ba30c73
102
+ //# sourceMappingURL=registry-client.js.map
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * Registry YAML helpers — read/write packages/registry.yaml (US-005)
3
3
  */
4
-
5
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3fb6a06d-7cbe-5764-be4f-6c8189de649c")}catch(e){}}();
6
4
  import * as fs from 'fs';
7
5
  import * as path from 'path';
8
6
  import * as yaml from 'js-yaml';
@@ -57,5 +55,4 @@ export function removeFromRegistry(hqRoot, slug) {
57
55
  const entries = readRegistry(hqRoot).filter((e) => e.slug !== slug);
58
56
  writeRegistry(hqRoot, entries);
59
57
  }
60
- //# sourceMappingURL=registry.js.map
61
- //# debugId=3fb6a06d-7cbe-5764-be4f-6c8189de649c
58
+ //# sourceMappingURL=registry.js.map
@@ -1,5 +1,3 @@
1
-
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="590fc03f-67ee-5f1c-820f-d0bb5e256d78")}catch(e){}}();
3
1
  const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.hq.computer/sandbox";
4
2
  const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
5
3
  const DEFAULT_RETRY_OPTIONS = {
@@ -179,5 +177,4 @@ export class SandboxRunnerClient {
179
177
  throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
180
178
  }
181
179
  }
182
- //# sourceMappingURL=sandbox-runner-client.js.map
183
- //# debugId=590fc03f-67ee-5f1c-820f-d0bb5e256d78
180
+ //# sourceMappingURL=sandbox-runner-client.js.map
@@ -1,5 +1,3 @@
1
-
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="68c3f1d3-dac4-57d6-8e2b-650c10a807ba")}catch(e){}}();
3
1
  import * as crypto from "node:crypto";
4
2
  import * as fs from "node:fs";
5
3
  import * as path from "node:path";
@@ -184,5 +182,4 @@ export function clearAllCache() {
184
182
  }
185
183
  return { removed };
186
184
  }
187
- //# sourceMappingURL=secrets-cache.js.map
188
- //# debugId=68c3f1d3-dac4-57d6-8e2b-650c10a807ba
185
+ //# sourceMappingURL=secrets-cache.js.map
@@ -1,5 +1,3 @@
1
-
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1ca7cb4e-47e2-5cd0-a400-bbdaafa2b0d5")}catch(e){}}();
3
1
  import { loadCachedTokens, isMachineIdentity, loadMachineCreds, } from "@indigoai-us/hq-cloud";
4
2
  /** Decode the (untrusted) id-token payload — for attribution/display only. */
5
3
  function peekIdToken(idToken) {
@@ -39,5 +37,4 @@ export function getCachedSentryUser() {
39
37
  return null;
40
38
  }
41
39
  }
42
- //# sourceMappingURL=sentry-identity.js.map
43
- //# debugId=1ca7cb4e-47e2-5cd0-a400-bbdaafa2b0d5
40
+ //# sourceMappingURL=sentry-identity.js.map
@@ -1,5 +1,3 @@
1
-
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="6f96bd3e-9a86-54ba-ad09-951c57afac62")}catch(e){}}();
3
1
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
4
2
  import { Sentry } from '../sentry.js';
5
3
  export async function vaultApiFetch(opts) {
@@ -229,5 +227,4 @@ export async function getEntityUid(token, opts) {
229
227
  }
230
228
  return getCompanyUid(token, opts.companySlug);
231
229
  }
232
- //# sourceMappingURL=vault-api.js.map
233
- //# debugId=6f96bd3e-9a86-54ba-ad09-951c57afac62
230
+ //# sourceMappingURL=vault-api.js.map
@@ -1,5 +1,3 @@
1
-
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f7058fc6-251e-5e11-af6c-de15f6e256f3")}catch(e){}}();
3
1
  import * as fs from "fs";
4
2
  import * as os from "os";
5
3
  import * as path from "path";
@@ -152,5 +150,4 @@ export const __test__ = {
152
150
  CACHE_TTL_MS,
153
151
  isKnownNoninteractiveStatusProbe,
154
152
  };
155
- //# sourceMappingURL=version-check.js.map
156
- //# debugId=f7058fc6-251e-5e11-af6c-de15f6e256f3
153
+ //# sourceMappingURL=version-check.js.map
@@ -27,8 +27,6 @@
27
27
  * Opt-out: `HQ_NO_UPDATE_CHECK=1` (same env as `version-check.ts` — one knob
28
28
  * to silence both check + gate).
29
29
  */
30
-
31
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00fa2aa9-f5d1-57a7-92db-c441c6bef906")}catch(e){}}();
32
30
  import { spawnSync } from "node:child_process";
33
31
  import { readFileSync } from "node:fs";
34
32
  import path from "node:path";
@@ -278,5 +276,4 @@ export const __test__ = {
278
276
  runUpdateCommand,
279
277
  resolveRunningPrefix,
280
278
  };
281
- //# sourceMappingURL=version-gate.js.map
282
- //# debugId=00fa2aa9-f5d1-57a7-92db-c441c6bef906
279
+ //# sourceMappingURL=version-gate.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.71.0",
3
+ "version": "5.73.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  "hq-auth-refresh": "dist/bin/hq-auth-refresh.js"
9
9
  },
10
10
  "scripts": {
11
- "build": "node scripts/generate-dsn.mjs && tsc",
11
+ "build": "node scripts/generate-dsn.mjs && tsc && node scripts/chmod-bins.mjs",
12
+ "prepublishOnly": "npm run build",
12
13
  "typecheck": "tsc --noEmit",
13
14
  "lint": "eslint .",
14
15
  "test": "vitest run",
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Make every declared `bin` executable after `tsc` emits it.
4
+ *
5
+ * WHY: the build is plain `tsc`, and TypeScript does NOT set the executable
6
+ * bit on its output — `dist/index.js` lands as 0644 despite carrying a
7
+ * `#!/usr/bin/env node` shebang. Most `npm install` paths re-chmod bin targets
8
+ * to 0755 while linking, but not all do: a root global install on a fresh
9
+ * Amazon Linux outpost (`npm install -g @indigoai-us/hq-cli`) has been observed
10
+ * to leave the linked target at 0644, so `/usr/bin/hq` resolves to a
11
+ * non-executable file and EVERY `hq …` invocation on the box dies with
12
+ * "Permission denied" — the outpost never becomes CLI-ready.
13
+ *
14
+ * Fixing it here means the published tarball ships its bins already 0755, so
15
+ * the runnable bit no longer depends on the installer re-chmod'ing. Runs from
16
+ * `build` (local + CI) and `prepublishOnly` (guarantees a fresh publish is
17
+ * always executable, whatever state the publisher's dist was in).
18
+ *
19
+ * Cross-platform: uses fs.chmod (no `chmod` shell dependency); a no-op on
20
+ * Windows where the mode bits are ignored anyway.
21
+ */
22
+ import { chmodSync, existsSync, readFileSync } from "node:fs";
23
+ import { dirname, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const pkgPath = resolve(here, "..", "package.json");
28
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
29
+
30
+ const bins =
31
+ typeof pkg.bin === "string"
32
+ ? { [pkg.name]: pkg.bin }
33
+ : (pkg.bin ?? {});
34
+
35
+ let touched = 0;
36
+ for (const [name, relPath] of Object.entries(bins)) {
37
+ const target = resolve(here, "..", relPath);
38
+ if (!existsSync(target)) {
39
+ // Build not run yet / stale bin map — surface it loudly so a publish that
40
+ // would ship a missing bin fails fast instead of silently.
41
+ console.error(`chmod-bins: declared bin "${name}" not found at ${relPath}`);
42
+ process.exitCode = 1;
43
+ continue;
44
+ }
45
+ chmodSync(target, 0o755);
46
+ touched += 1;
47
+ }
48
+
49
+ console.log(`chmod-bins: made ${touched} bin(s) executable (0755)`);
@@ -0,0 +1,272 @@
1
+ import { Command } from "commander";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ type MockInstance,
10
+ } from "vitest";
11
+ import {
12
+ parseReposManifest,
13
+ registerOutpostsCommand,
14
+ type ReplicaSyncDependencies,
15
+ } from "./outposts.js";
16
+
17
+ const HQ_ROOT = "/home/ec2-user/hq";
18
+ const SECRET = "refresh-token-that-must-never-be-printed";
19
+
20
+ const REPOS_YAML = `repos:
21
+ - url: https://github.com/indigoai-us/hq-pro.git
22
+ visibility: private
23
+ name: hq-pro
24
+ - url: https://github.com/indigoai-us/public-widget.git
25
+ visibility: public
26
+ - url: https://github.com/indigoai-us/hq-cli.git
27
+ visibility: private
28
+ name: hq-cli
29
+ `;
30
+
31
+ type SpawnCall = {
32
+ command: string;
33
+ args: string[];
34
+ options: Parameters<ReplicaSyncDependencies["spawnSync"]>[2];
35
+ };
36
+
37
+ function ok(stdout = "") {
38
+ return {
39
+ status: 0,
40
+ stdout,
41
+ stderr: "",
42
+ error: undefined,
43
+ } as ReturnType<ReplicaSyncDependencies["spawnSync"]>;
44
+ }
45
+
46
+ function replicaDependencies(input: {
47
+ session?: { refreshToken?: string; idToken?: string } | undefined;
48
+ reposYaml?: string | null;
49
+ existing?: Set<string>;
50
+ failRescue?: boolean;
51
+ failAuthRefresh?: boolean;
52
+ } = {}): {
53
+ deps: Partial<ReplicaSyncDependencies>;
54
+ calls: SpawnCall[];
55
+ mkdirp: MockInstance;
56
+ } {
57
+ const calls: SpawnCall[] = [];
58
+ const existing = input.existing ?? new Set<string>();
59
+ const mkdirp = vi.fn();
60
+ const deps: Partial<ReplicaSyncDependencies> = {
61
+ defaultHqRoot: () => HQ_ROOT,
62
+ loadCachedTokens: () =>
63
+ "session" in input ? input.session : { refreshToken: SECRET },
64
+ readTextFile: (file: string) => {
65
+ if (file.endsWith("repos.yaml")) {
66
+ if (input.reposYaml === null) throw new Error("ENOENT");
67
+ return input.reposYaml ?? REPOS_YAML;
68
+ }
69
+ throw new Error(`unexpected readTextFile(${file})`);
70
+ },
71
+ pathExists: (p: string) => existing.has(p),
72
+ mkdirp: mkdirp as unknown as ReplicaSyncDependencies["mkdirp"],
73
+ spawnSync: ((command, args, options) => {
74
+ calls.push({ command, args, options });
75
+ if (input.failRescue && command === "hq" && args[0] === "rescue") {
76
+ return { ...ok(), status: 1 };
77
+ }
78
+ if (
79
+ input.failAuthRefresh &&
80
+ command === "hq" &&
81
+ args[0] === "auth" &&
82
+ args[1] === "refresh"
83
+ ) {
84
+ return { ...ok(), status: 1 };
85
+ }
86
+ return ok();
87
+ }) as ReplicaSyncDependencies["spawnSync"],
88
+ };
89
+ return { deps, calls, mkdirp };
90
+ }
91
+
92
+ function buildProgram(deps: Partial<ReplicaSyncDependencies>): Command {
93
+ const program = new Command();
94
+ program.name("hq").exitOverride();
95
+ registerOutpostsCommand(program, deps);
96
+ return program;
97
+ }
98
+
99
+ async function run(
100
+ deps: Partial<ReplicaSyncDependencies>,
101
+ args: string[],
102
+ ): Promise<void> {
103
+ await buildProgram(deps).parseAsync(["node", "hq", ...args]);
104
+ }
105
+
106
+ let logSpy: MockInstance<typeof console.log>;
107
+ let warnSpy: MockInstance<typeof console.warn>;
108
+ let fetchSpy: MockInstance<typeof fetch>;
109
+
110
+ beforeEach(() => {
111
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
112
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
113
+ fetchSpy = vi.spyOn(globalThis, "fetch");
114
+ });
115
+
116
+ afterEach(() => {
117
+ vi.restoreAllMocks();
118
+ });
119
+
120
+ function printed(): string {
121
+ return [
122
+ ...logSpy.mock.calls.map((c) => c.map(String).join(" ")),
123
+ ...warnSpy.mock.calls.map((c) => c.map(String).join(" ")),
124
+ ].join("\n");
125
+ }
126
+
127
+ function hqArgs(calls: SpawnCall[]): string[][] {
128
+ return calls.filter((c) => c.command === "hq").map((c) => c.args);
129
+ }
130
+
131
+ describe("parseReposManifest", () => {
132
+ it("resolves visibility + name, defaulting to public + URL basename", () => {
133
+ expect(parseReposManifest(REPOS_YAML)).toEqual([
134
+ { url: "https://github.com/indigoai-us/hq-pro.git", visibility: "private", name: "hq-pro" },
135
+ { url: "https://github.com/indigoai-us/public-widget.git", visibility: "public", name: "public-widget" },
136
+ { url: "https://github.com/indigoai-us/hq-cli.git", visibility: "private", name: "hq-cli" },
137
+ ]);
138
+ });
139
+
140
+ it("returns [] for junk, empty, or repo-less YAML", () => {
141
+ expect(parseReposManifest("not: valid: [")).toEqual([]);
142
+ expect(parseReposManifest("")).toEqual([]);
143
+ expect(parseReposManifest("other: 1")).toEqual([]);
144
+ });
145
+
146
+ it("skips entries with no url", () => {
147
+ expect(parseReposManifest("repos:\n - visibility: public\n - url: https://x/y.git")).toEqual([
148
+ { url: "https://x/y.git", visibility: "public", name: "y" },
149
+ ]);
150
+ });
151
+
152
+ it("rejects names that would escape repos/<visibility>/ (path traversal)", () => {
153
+ const yaml =
154
+ "repos:\n" +
155
+ " - url: https://x/evil.git\n name: ../../etc\n" +
156
+ " - url: https://x/evil2.git\n name: sub/dir\n" +
157
+ " - url: https://x/ok.git\n name: ok\n";
158
+ expect(parseReposManifest(yaml)).toEqual([
159
+ { url: "https://x/ok.git", visibility: "public", name: "ok" },
160
+ ]);
161
+ });
162
+ });
163
+
164
+ describe("hq outposts replica-sync", () => {
165
+ it("is hidden from outposts help but remains invokable", async () => {
166
+ const { deps } = replicaDependencies();
167
+ const program = buildProgram(deps);
168
+ const outposts = program.commands.find((c) => c.name() === "outposts");
169
+ expect(outposts?.helpInformation()).not.toContain("replica-sync");
170
+ await run(deps, ["outposts", "replica-sync"]);
171
+ });
172
+
173
+ it("no HQ session → replicates nothing and runs no commands", async () => {
174
+ const { deps, calls } = replicaDependencies({ session: undefined });
175
+ await run(deps, ["outposts", "replica-sync"]);
176
+ expect(calls).toEqual([]);
177
+ expect(printed()).toContain("nothing to replicate");
178
+ });
179
+
180
+ it("runs the full replica chain in order on a signed-in box", async () => {
181
+ const { deps, calls, mkdirp } = replicaDependencies();
182
+ await run(deps, ["outposts", "replica-sync"]);
183
+
184
+ const args = hqArgs(calls);
185
+ expect(args).toContainEqual(["auth", "refresh"]);
186
+ // The pull is pinned to the SAME --hq-root the command was invoked with,
187
+ // not the default root.
188
+ expect(args).toContainEqual([
189
+ "sync",
190
+ "pull",
191
+ "--personal",
192
+ "--hq-root",
193
+ HQ_ROOT,
194
+ "--on-conflict",
195
+ "keep",
196
+ ]);
197
+ expect(args).toContainEqual(["rescue", "--hq-root", HQ_ROOT, "--yes"]);
198
+ // rescue needs companies/ to pre-exist.
199
+ expect(mkdirp).toHaveBeenCalledWith(`${HQ_ROOT}/companies`);
200
+ });
201
+
202
+ it("clones only the missing repos, gh-authing via the vault for privates", async () => {
203
+ const { deps, calls } = replicaDependencies({
204
+ // hq-cli already present → should be skipped.
205
+ existing: new Set([`${HQ_ROOT}/repos/private/hq-cli/.git`]),
206
+ });
207
+ await run(deps, ["outposts", "replica-sync"]);
208
+
209
+ const clones = calls
210
+ .filter((c) => c.command === "git" && c.args[0] === "clone")
211
+ .map((c) => c.args[c.args.length - 1]);
212
+ expect(clones).toContain(`${HQ_ROOT}/repos/private/hq-pro`);
213
+ expect(clones).toContain(`${HQ_ROOT}/repos/public/public-widget`);
214
+ expect(clones).not.toContain(`${HQ_ROOT}/repos/private/hq-cli`);
215
+
216
+ // A private repo is missing → gh auth flows through the vault (token in ENV,
217
+ // never argv).
218
+ const vaultAuth = calls.find(
219
+ (c) => c.command === "hq" && c.args[0] === "secrets" && c.args.includes("GITHUB_TOKEN"),
220
+ );
221
+ expect(vaultAuth).toBeTruthy();
222
+ expect(vaultAuth?.args).toContain("--personal");
223
+ expect(vaultAuth?.args).toContain("--only");
224
+ });
225
+
226
+ it("skips the vault gh auth when every missing repo is public", async () => {
227
+ const { deps, calls } = replicaDependencies({
228
+ reposYaml: "repos:\n - url: https://github.com/x/pub.git\n visibility: public\n",
229
+ });
230
+ await run(deps, ["outposts", "replica-sync"]);
231
+ const vaultAuth = calls.find((c) => c.command === "hq" && c.args[0] === "secrets");
232
+ expect(vaultAuth).toBeUndefined();
233
+ });
234
+
235
+ it("no repos.yaml → skips the clone step without error", async () => {
236
+ const { deps, calls } = replicaDependencies({ reposYaml: null });
237
+ await run(deps, ["outposts", "replica-sync"]);
238
+ expect(calls.some((c) => c.command === "git")).toBe(false);
239
+ expect(printed()).toContain("skipping repo clone");
240
+ });
241
+
242
+ it("aborts the cycle when auth refresh fails — no pull, rescue, or clone", async () => {
243
+ const { deps, calls } = replicaDependencies({ failAuthRefresh: true });
244
+ await run(deps, ["outposts", "replica-sync"]);
245
+ const args = hqArgs(calls);
246
+ expect(args).toContainEqual(["auth", "refresh"]);
247
+ expect(args.some((a) => a[0] === "sync")).toBe(false);
248
+ expect(args.some((a) => a[0] === "rescue")).toBe(false);
249
+ expect(calls.some((c) => c.command === "git")).toBe(false);
250
+ expect(printed()).toContain("auth refresh failed");
251
+ });
252
+
253
+ it("a failed step never throws or hard-fails the unit", async () => {
254
+ const { deps } = replicaDependencies({ failRescue: true });
255
+ await expect(run(deps, ["outposts", "replica-sync"])).resolves.toBeUndefined();
256
+ expect(printed()).toContain("HQ kernel rescue did not complete cleanly");
257
+ });
258
+
259
+ it("never leaks the session token or makes hq-pro requests", async () => {
260
+ const { deps, calls } = replicaDependencies();
261
+ await run(deps, ["outposts", "replica-sync"]);
262
+ expect(JSON.stringify(calls)).not.toContain(SECRET);
263
+ expect(printed()).not.toContain(SECRET);
264
+ expect(fetchSpy).not.toHaveBeenCalled();
265
+ // The GITHUB_TOKEN never appears as a bare argv value — only the var name
266
+ // inside the bash script and the --only selector.
267
+ const bashScripts = calls
268
+ .filter((c) => c.command === "hq" && c.args.includes("bash"))
269
+ .flatMap((c) => c.args);
270
+ expect(bashScripts.some((a) => a.includes("gh auth login --with-token"))).toBe(true);
271
+ });
272
+ });
@@ -171,6 +171,38 @@ describe("hq outposts codex-enable / login", () => {
171
171
  expect(String(url)).toContain("/outpost/regenerate-login-url");
172
172
  expect(init?.method).toBe("POST");
173
173
  });
174
+
175
+ it("login-code POSTs /outpost/login-code with the code in the body", async () => {
176
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
177
+ await run(["outposts", "login-code", "ABC-123"]);
178
+ const [url, init] = fetchSpy.mock.calls[0];
179
+ expect(String(url)).toContain("/outpost/login-code");
180
+ expect(init?.method).toBe("POST");
181
+ expect(JSON.parse(String(init?.body))).toEqual({ code: "ABC-123" });
182
+ });
183
+
184
+ it("login-code targets a specific box via --id (outpostId query)", async () => {
185
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
186
+ await run(["outposts", "login-code", "CODE", "--id", "3"]);
187
+ const [url] = fetchSpy.mock.calls[0];
188
+ expect(String(url)).toContain("outpostId=3");
189
+ });
190
+
191
+ it("login-code trims whitespace and refuses an empty code without calling the API", async () => {
192
+ await expect(run(["outposts", "login-code", " "])).rejects.toThrow(
193
+ "process.exit(1)",
194
+ );
195
+ expect(fetchSpy).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("login-code surfaces an awaiting-login not-found error", async () => {
199
+ fetchSpy.mockResolvedValueOnce(
200
+ jsonResponse(404, { error: true, step: "not-found", message: "no outpost" }),
201
+ );
202
+ await expect(run(["outposts", "login-code", "CODE"])).rejects.toThrow(
203
+ "process.exit(1)",
204
+ );
205
+ });
174
206
  });
175
207
 
176
208
  describe("hq outposts destroy", () => {