@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.
- package/dist/bin/hq-auth-refresh.js +1 -4
- package/dist/cli-version.js +1 -4
- package/dist/commands/__fixtures__/make-tar.js +1 -4
- package/dist/commands/_patterns.js +1 -4
- package/dist/commands/add.js +1 -4
- package/dist/commands/agents.js +1 -4
- package/dist/commands/api-keys.js +1 -4
- package/dist/commands/auth.js +1 -4
- package/dist/commands/billing.js +1 -4
- package/dist/commands/channels.js +1 -4
- package/dist/commands/cloud-demote.js +1 -4
- package/dist/commands/cloud-provision.js +1 -4
- package/dist/commands/cloud.js +1 -4
- package/dist/commands/company.js +1 -4
- package/dist/commands/creators.js +1 -4
- package/dist/commands/crm.js +1 -4
- package/dist/commands/db-migrate.js +1 -4
- package/dist/commands/db-provision.js +1 -4
- package/dist/commands/db-sql.js +1 -4
- package/dist/commands/db-status.js +1 -4
- package/dist/commands/db.js +1 -4
- package/dist/commands/dm.js +1 -4
- package/dist/commands/feedback.js +1 -4
- package/dist/commands/files-browse.js +1 -4
- package/dist/commands/files.js +1 -4
- package/dist/commands/group-grants.js +1 -4
- package/dist/commands/groups.js +1 -4
- package/dist/commands/integrations.js +1 -4
- package/dist/commands/list.js +1 -4
- package/dist/commands/login.js +1 -4
- package/dist/commands/logout.js +1 -4
- package/dist/commands/mcp-registration.js +1 -4
- package/dist/commands/mcp-status.js +1 -4
- package/dist/commands/meetings.js +1 -4
- package/dist/commands/members.js +1 -4
- package/dist/commands/onboard-identity-guard.js +1 -4
- package/dist/commands/onboard-join.js +1 -4
- package/dist/commands/onboard-warning.js +1 -4
- package/dist/commands/onboard.js +1 -4
- package/dist/commands/outposts.d.ts +30 -1
- package/dist/commands/outposts.js +221 -10
- package/dist/commands/pack-install.js +1 -4
- package/dist/commands/packs.js +1 -4
- package/dist/commands/people.js +1 -4
- package/dist/commands/pkg-install.js +1 -4
- package/dist/commands/pkg-list.js +1 -4
- package/dist/commands/pkg-remove.js +1 -4
- package/dist/commands/pkg-update.js +1 -4
- package/dist/commands/publish.js +1 -4
- package/dist/commands/reindex.js +1 -4
- package/dist/commands/rescue.js +1 -4
- package/dist/commands/run.js +1 -4
- package/dist/commands/safe-extract.js +1 -4
- package/dist/commands/secrets-scope.js +1 -4
- package/dist/commands/secrets.js +1 -4
- package/dist/commands/signals.js +1 -4
- package/dist/commands/skill.js +1 -4
- package/dist/commands/sources.js +1 -4
- package/dist/commands/sync-mode.js +1 -4
- package/dist/commands/sync-narrow.js +1 -4
- package/dist/commands/sync.js +1 -4
- package/dist/commands/team-sync.js +1 -4
- package/dist/commands/update.js +1 -4
- package/dist/commands/whoami.js +1 -4
- package/dist/commands/workers.js +1 -4
- package/dist/index.js +1 -4
- package/dist/lib/db/control-plane.js +1 -4
- package/dist/lib/db/local.js +1 -4
- package/dist/lib/db/migrate.js +1 -4
- package/dist/lib/db/paths.js +1 -4
- package/dist/lib/db/remote-engine.js +1 -4
- package/dist/lib/db/remote-sql.js +1 -4
- package/dist/lib/db/sql.js +1 -4
- package/dist/lib/local-tree-diff.js +1 -4
- package/dist/lib/narrow-hint-banner.js +1 -4
- package/dist/main.js +1 -4
- package/dist/node-preflight.js +1 -4
- package/dist/run/discover-schemas.js +1 -4
- package/dist/run/hq-plugin.js +1 -4
- package/dist/sentry-before-send.js +1 -4
- package/dist/sentry-dsn.generated.js +1 -4
- package/dist/sentry.js +1 -4
- package/dist/strategies/link.js +1 -4
- package/dist/strategies/merge.js +1 -4
- package/dist/types.js +1 -4
- package/dist/utils/billing-gate.js +1 -4
- package/dist/utils/breadcrumb-buffer.js +1 -4
- package/dist/utils/cli-telemetry.js +1 -4
- package/dist/utils/cognito-session.js +1 -4
- package/dist/utils/contribution-table.js +1 -4
- package/dist/utils/environmental-error.js +1 -4
- package/dist/utils/epipe.js +1 -4
- package/dist/utils/feedback-diagnostics.js +1 -4
- package/dist/utils/feedback-screenshots.js +1 -4
- package/dist/utils/feedback-versions.js +1 -4
- package/dist/utils/git.js +1 -4
- package/dist/utils/integrity.js +1 -4
- package/dist/utils/intercepted-process-exit.js +1 -4
- package/dist/utils/login-provider.js +1 -4
- package/dist/utils/manifest.js +1 -4
- package/dist/utils/pack-contributions.js +1 -4
- package/dist/utils/people.js +1 -4
- package/dist/utils/registry-client.js +1 -4
- package/dist/utils/registry.js +1 -4
- package/dist/utils/sandbox-runner-client.js +1 -4
- package/dist/utils/secrets-cache.js +1 -4
- package/dist/utils/sentry-identity.js +1 -4
- package/dist/utils/vault-api.js +1 -4
- package/dist/utils/version-check.js +1 -4
- package/dist/utils/version-gate.js +1 -4
- package/package.json +3 -2
- package/scripts/chmod-bins.mjs +49 -0
- package/src/commands/outposts-replica-sync.test.ts +272 -0
- package/src/commands/outposts.test.ts +32 -0
- package/src/commands/outposts.ts +330 -7
- package/src/packaging.test.ts +55 -0
package/dist/utils/people.js
CHANGED
|
@@ -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
|
package/dist/utils/registry.js
CHANGED
|
@@ -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
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -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.
|
|
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", () => {
|