@indigoai-us/hq-cli 5.68.0 → 5.68.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.68.1]
6
+
7
+ ### Fixed
8
+
9
+ - **`hq outposts exec` now runs from the box's HQ folder by default.** The
10
+ command was sent with no working directory, so cwd depended on the transport:
11
+ SSM boxes (root, no `$HOME`) ran from `/usr/bin` and Lightsail/SSH boxes ran
12
+ from the login home. `exec -- pwd` printed `/usr/bin` on EC2, and relative
13
+ paths resolved against the wrong place. The command is now wrapped to `cd`
14
+ into the box's HQ checkout first (`$HOME/hq`, falling back to ec2-user's home
15
+ for the SSM/root path), with a `|| true` so exec still runs when no HQ folder
16
+ is present. Applies to both the SSM and SSH-fallback paths.
17
+
5
18
  ## [5.62.1]
6
19
 
7
20
  ### Added
@@ -94,6 +94,20 @@ export interface OutpostExecResult {
94
94
  * `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
95
95
  */
96
96
  export declare function execOutpost(token: string, command: string, outpostId?: string): Promise<OutpostExecResult>;
97
+ /**
98
+ * Prefix that best-effort `cd`s into the box's HQ checkout before running the
99
+ * caller's command. `exec` runs over two transports with two different default
100
+ * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
101
+ * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
102
+ * printed an unhelpful, transport-dependent directory. We resolve the HQ folder
103
+ * in-shell: `$HOME/hq` for the login/SSH user, falling back to ec2-user's home
104
+ * for the SSM/root path. The trailing `|| true` keeps the command running from
105
+ * the default directory when no HQ checkout is present, so exec never fails
106
+ * merely because the box has no HQ folder.
107
+ */
108
+ export declare const REMOTE_HQ_DIR_PREFIX = "cd \"$HOME/hq\" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true";
109
+ /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
110
+ export declare function withRemoteHqDir(command: string): string;
97
111
  /** SSH connection details vended by `POST /outpost/ssh-access`. */
98
112
  export interface OutpostSshAccess {
99
113
  outpostId: string;
@@ -21,7 +21,7 @@
21
21
  * and status routes that exist. Renaming an Outpost is not a backend capability.
22
22
  */
23
23
 
24
- !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]="951a0bc6-7dc3-5b10-b8f6-728df1074817")}catch(e){}}();
24
+ !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]="c4ce2b27-be72-5025-a4e7-2d4725c67c26")}catch(e){}}();
25
25
  import chalk from "chalk";
26
26
  import { spawnSync } from "node:child_process";
27
27
  import * as fs from "node:fs";
@@ -138,6 +138,22 @@ export async function execOutpost(token, command, outpostId) {
138
138
  query: outpostId ? { outpostId } : undefined,
139
139
  });
140
140
  }
141
+ /**
142
+ * Prefix that best-effort `cd`s into the box's HQ checkout before running the
143
+ * caller's command. `exec` runs over two transports with two different default
144
+ * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
145
+ * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
146
+ * printed an unhelpful, transport-dependent directory. We resolve the HQ folder
147
+ * in-shell: `$HOME/hq` for the login/SSH user, falling back to ec2-user's home
148
+ * for the SSM/root path. The trailing `|| true` keeps the command running from
149
+ * the default directory when no HQ checkout is present, so exec never fails
150
+ * merely because the box has no HQ folder.
151
+ */
152
+ export const REMOTE_HQ_DIR_PREFIX = 'cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
153
+ /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
154
+ export function withRemoteHqDir(command) {
155
+ return `${REMOTE_HQ_DIR_PREFIX}; ${command}`;
156
+ }
141
157
  /**
142
158
  * Fetch SSH connection info + key for the caller's Outpost and open the caller's
143
159
  * IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
@@ -365,8 +381,10 @@ export function registerOutpostsCommand(program) {
365
381
  process.exit(1);
366
382
  }
367
383
  const token = await ensureCognitoToken();
384
+ // Run from the box's HQ folder by default (works over both SSM and SSH).
385
+ const remoteCommand = withRemoteHqDir(command);
368
386
  try {
369
- const result = await execOutpost(token, command, opts.id);
387
+ const result = await execOutpost(token, remoteCommand, opts.id);
370
388
  if (opts.json) {
371
389
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
372
390
  }
@@ -392,7 +410,7 @@ export function registerOutpostsCommand(program) {
392
410
  if (err instanceof OutpostHttpError &&
393
411
  err.step === "platform-unsupported") {
394
412
  const access = await getOutpostSshAccess(token, opts.id);
395
- const ssh = execViaSsh(access, command);
413
+ const ssh = execViaSsh(access, remoteCommand);
396
414
  if (ssh.error) {
397
415
  console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
398
416
  process.exit(1);
@@ -496,4 +514,4 @@ export function registerOutpostsCommand(program) {
496
514
  });
497
515
  }
498
516
  //# sourceMappingURL=outposts.js.map
499
- //# debugId=951a0bc6-7dc3-5b10-b8f6-728df1074817
517
+ //# debugId=c4ce2b27-be72-5025-a4e7-2d4725c67c26
@@ -6,10 +6,9 @@
6
6
  * hq packs update Re-install the latest of an installed pack.
7
7
  * hq packs uninstall Un-wire + archive a pack (clean, no dangling symlinks).
8
8
  *
9
- * Distinct from `hq packages ...` (the entitlement-gated REGISTRY system tracked
10
- * in packages/registry.yaml). Content packs have no registry file -- the
11
- * filesystem under core/packages/ is the source of truth. Today `hq install`
12
- * is the only clean content-pack op; this adds the rest.
9
+ * Also exposed through `hq packages list`, because package installs use this
10
+ * content-pack system. Content packs have no registry file -- the filesystem
11
+ * under core/packages/ is the source of truth.
13
12
  *
14
13
  * Every subcommand supports `--json` for machine consumers (the HQ Sync
15
14
  * menubar app). JSON is also the default when stdout is not a TTY, matching
@@ -50,6 +49,7 @@ interface InstalledPackView {
50
49
  error?: string;
51
50
  }
52
51
  export declare function buildInstalledView(hqRoot: string, hqVersion: string | null, pack: InstalledPack, installedSources: Set<string>, checkUpdates: boolean, latestProbe?: LatestResult): InstalledPackView;
52
+ export declare function registerPacksListCommand(parent: Command): void;
53
53
  export declare function registerPacksCommand(parent: Command): void;
54
54
  export {};
55
55
  //# sourceMappingURL=packs.d.ts.map
@@ -6,10 +6,9 @@
6
6
  * hq packs update Re-install the latest of an installed pack.
7
7
  * hq packs uninstall Un-wire + archive a pack (clean, no dangling symlinks).
8
8
  *
9
- * Distinct from `hq packages ...` (the entitlement-gated REGISTRY system tracked
10
- * in packages/registry.yaml). Content packs have no registry file -- the
11
- * filesystem under core/packages/ is the source of truth. Today `hq install`
12
- * is the only clean content-pack op; this adds the rest.
9
+ * Also exposed through `hq packages list`, because package installs use this
10
+ * content-pack system. Content packs have no registry file -- the filesystem
11
+ * under core/packages/ is the source of truth.
13
12
  *
14
13
  * Every subcommand supports `--json` for machine consumers (the HQ Sync
15
14
  * menubar app). JSON is also the default when stdout is not a TTY, matching
@@ -18,7 +17,7 @@
18
17
  * Spec: knowledge/public/hq-core/package-yaml-spec.md.
19
18
  */
20
19
 
21
- !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]="f8ee3de6-a097-5e0f-8afa-63381f29032d")}catch(e){}}();
20
+ !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]="b743f774-8d9b-594b-9ce4-e7f5a44faca0")}catch(e){}}();
22
21
  import * as fs from 'fs';
23
22
  import * as path from 'path';
24
23
  import * as readline from 'readline';
@@ -368,9 +367,8 @@ async function runUninstall(name, opts) {
368
367
  // ---------------------------------------------------------------------------
369
368
  // Registration
370
369
  // ---------------------------------------------------------------------------
371
- export function registerPacksCommand(parent) {
372
- const packs = parent.command('packs').description('Content-pack lifecycle (install via `hq install`)');
373
- packs
370
+ export function registerPacksListCommand(parent) {
371
+ parent
374
372
  .command('list')
375
373
  .alias('ls')
376
374
  .description('List installed content packs and the curated catalog')
@@ -392,6 +390,10 @@ export function registerPacksCommand(parent) {
392
390
  process.exit(1);
393
391
  }
394
392
  });
393
+ }
394
+ export function registerPacksCommand(parent) {
395
+ const packs = parent.command('packs').description('Content-pack lifecycle (install via `hq install`)');
396
+ registerPacksListCommand(packs);
395
397
  packs
396
398
  .command('update [name]')
397
399
  .description('Update an installed content pack (re-install latest)')
@@ -468,4 +470,4 @@ export function registerPacksCommand(parent) {
468
470
  });
469
471
  }
470
472
  //# sourceMappingURL=packs.js.map
471
- //# debugId=f8ee3de6-a097-5e0f-8afa-63381f29032d
473
+ //# debugId=b743f774-8d9b-594b-9ce4-e7f5a44faca0
@@ -1,7 +1,8 @@
1
1
  /**
2
- * hq packages list — show installed packages and available entitlements (US-005)
2
+ * hq packages list — list filesystem-backed content packs.
3
3
  *
4
- * Graceful offline: if registry is unreachable, show cached data with a note.
4
+ * Package installs use the content-pack installer, so this command delegates
5
+ * to the same list view as `hq packs list`.
5
6
  */
6
7
  import { Command } from 'commander';
7
8
  export declare function registerPackageListCommand(parent: Command): void;
@@ -1,99 +1,14 @@
1
1
  /**
2
- * hq packages list — show installed packages and available entitlements (US-005)
2
+ * hq packages list — list filesystem-backed content packs.
3
3
  *
4
- * Graceful offline: if registry is unreachable, show cached data with a note.
4
+ * Package installs use the content-pack installer, so this command delegates
5
+ * to the same list view as `hq packs list`.
5
6
  */
6
7
 
7
- !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]="a2386adb-d4c5-5725-b406-4246ac40425a")}catch(e){}}();
8
- import chalk from 'chalk';
9
- import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
10
- import { readRegistry } from '../utils/registry.js';
11
- import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
12
- import { getRegistryUrl, RegistryClient, } from '../utils/registry-client.js';
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]="96eb060c-c1d0-5874-918f-e818f92c6242")}catch(e){}}();
9
+ import { registerPacksListCommand } from './packs.js';
13
10
  export function registerPackageListCommand(parent) {
14
- parent
15
- .command('list')
16
- .alias('ls')
17
- .description('List installed and available packages')
18
- .option('--json', 'Machine-readable JSON output')
19
- .action(async (opts) => {
20
- try {
21
- await listPackages(opts.json === true || !process.stdout.isTTY);
22
- }
23
- catch (error) {
24
- console.error(chalk.red('List failed:'), error instanceof Error ? error.message : 'Unknown error');
25
- process.exit(1);
26
- }
27
- });
28
- }
29
- async function gatherRegistryPackages() {
30
- const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
31
- const installed = readRegistry(hqRoot);
32
- let entitlements = [];
33
- let offline = false;
34
- try {
35
- const cached = loadCachedTokens();
36
- if (cached && !isExpiring(cached, 120)) {
37
- const client = new RegistryClient(getRegistryUrl(), cached.accessToken);
38
- const result = await client.getMyEntitlements();
39
- entitlements = result.entitlements;
40
- }
41
- }
42
- catch {
43
- offline = true;
44
- }
45
- const installedSlugs = new Set(installed.map((p) => p.slug));
46
- const available = entitlements.filter((e) => !installedSlugs.has(e.slug));
47
- return { installed, available, offline };
48
- }
49
- async function listPackages(json) {
50
- if (json) {
51
- const { installed, available, offline } = await gatherRegistryPackages();
52
- process.stdout.write(JSON.stringify({ installed, available, offline }, null, 2) + '\n');
53
- return;
54
- }
55
- const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
56
- const installed = readRegistry(hqRoot);
57
- // Print installed packages
58
- if (installed.length > 0) {
59
- console.log(chalk.bold('Installed packages:\n'));
60
- for (const pkg of installed) {
61
- const scope = pkg.scope ? chalk.dim(` (${pkg.scope})`) : '';
62
- console.log(` ${chalk.green(pkg.slug)}@${pkg.version}${scope}`);
63
- console.log(chalk.dim(` Installed: ${pkg.installed_at.slice(0, 10)} Updated: ${pkg.updated_at.slice(0, 10)}`));
64
- }
65
- console.log();
66
- }
67
- else {
68
- console.log(chalk.dim('No packages installed.\n'));
69
- }
70
- // Try to show available (but not installed) packages from entitlements
71
- let entitlements = [];
72
- let offline = false;
73
- try {
74
- const cached = loadCachedTokens();
75
- if (cached && !isExpiring(cached, 120)) {
76
- const registryUrl = getRegistryUrl();
77
- const client = new RegistryClient(registryUrl, cached.accessToken);
78
- const result = await client.getMyEntitlements();
79
- entitlements = result.entitlements;
80
- }
81
- }
82
- catch {
83
- offline = true;
84
- }
85
- const installedSlugs = new Set(installed.map((p) => p.slug));
86
- const available = entitlements.filter((e) => !installedSlugs.has(e.slug));
87
- if (available.length > 0) {
88
- console.log(chalk.bold('Available (not installed):\n'));
89
- for (const ent of available) {
90
- console.log(` ${chalk.cyan(ent.slug)} ${chalk.dim(`[${ent.tier}]`)} ${chalk.yellow('available')}`);
91
- }
92
- console.log(chalk.dim('\nRun "hq packages install <slug>" to install.\n'));
93
- }
94
- if (offline) {
95
- console.log(chalk.yellow('Note: Could not reach registry — showing cached data only.'));
96
- }
11
+ registerPacksListCommand(parent);
97
12
  }
98
13
  //# sourceMappingURL=pkg-list.js.map
99
- //# debugId=a2386adb-d4c5-5725-b406-4246ac40425a
14
+ //# debugId=96eb060c-c1d0-5874-918f-e818f92c6242
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.68.0",
3
+ "version": "5.68.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -52,7 +52,7 @@ vi.mock("node:child_process", async (importOriginal) => {
52
52
 
53
53
  import { spawnSync } from "node:child_process";
54
54
  import { ensureCognitoToken } from "../utils/cognito-session.js";
55
- import { registerOutpostsCommand } from "./outposts.js";
55
+ import { registerOutpostsCommand, withRemoteHqDir } from "./outposts.js";
56
56
 
57
57
  const mockSpawnSync = vi.mocked(spawnSync);
58
58
 
@@ -262,6 +262,18 @@ describe("hq outposts exec", () => {
262
262
  process.exitCode = undefined;
263
263
  });
264
264
 
265
+ it("wraps the command to run from the box's HQ folder, then runs it", () => {
266
+ const wrapped = withRemoteHqDir("pwd");
267
+ // cd into $HOME/hq (SSH/login user) …
268
+ expect(wrapped).toContain('cd "$HOME/hq"');
269
+ // … falling back to ec2-user's home for the SSM/root path (no $HOME) …
270
+ expect(wrapped).toContain("cd ~ec2-user/hq");
271
+ // … never failing merely because the box has no HQ folder …
272
+ expect(wrapped).toContain("|| true");
273
+ // … and the caller's command still runs after the cd.
274
+ expect(wrapped.endsWith("; pwd")).toBe(true);
275
+ });
276
+
265
277
  it("POSTs /outpost/exec with the joined command and streams stdout", async () => {
266
278
  const stdoutSpy = vi
267
279
  .spyOn(process.stdout, "write")
@@ -285,7 +297,10 @@ describe("hq outposts exec", () => {
285
297
  const [url, init] = fetchSpy.mock.calls[0];
286
298
  expect(String(url)).toContain("/outpost/exec");
287
299
  expect(init?.method).toBe("POST");
288
- expect(JSON.parse(init?.body as string)).toEqual({ command: "echo hello world" });
300
+ // The command is wrapped so it runs from the box's HQ folder.
301
+ expect(JSON.parse(init?.body as string)).toEqual({
302
+ command: withRemoteHqDir("echo hello world"),
303
+ });
289
304
  const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
290
305
  expect(printed).toContain("hello world\n");
291
306
  expect(process.exitCode).toBe(0);
@@ -416,7 +431,7 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
416
431
  const args = mockSpawnSync.mock.calls[0][1] as string[];
417
432
  expect(mockSpawnSync.mock.calls[0][0]).toBe("ssh");
418
433
  expect(args).toContain("ec2-user@52.2.2.2");
419
- expect(args[args.length - 1]).toBe("uname -a");
434
+ expect(args[args.length - 1]).toBe(withRemoteHqDir("uname -a"));
420
435
  expect(args).toContain("BatchMode=yes");
421
436
  const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
422
437
  expect(printed).toContain("lightsail-out\n");
@@ -227,6 +227,25 @@ export async function execOutpost(
227
227
  });
228
228
  }
229
229
 
230
+ /**
231
+ * Prefix that best-effort `cd`s into the box's HQ checkout before running the
232
+ * caller's command. `exec` runs over two transports with two different default
233
+ * working directories — SSM runs as root with no `$HOME` (cwd `/usr/bin`) and
234
+ * SSH lands in the login user's home — so without this, `hq outposts exec -- pwd`
235
+ * printed an unhelpful, transport-dependent directory. We resolve the HQ folder
236
+ * in-shell: `$HOME/hq` for the login/SSH user, falling back to ec2-user's home
237
+ * for the SSM/root path. The trailing `|| true` keeps the command running from
238
+ * the default directory when no HQ checkout is present, so exec never fails
239
+ * merely because the box has no HQ folder.
240
+ */
241
+ export const REMOTE_HQ_DIR_PREFIX =
242
+ 'cd "$HOME/hq" 2>/dev/null || cd ~ec2-user/hq 2>/dev/null || true';
243
+
244
+ /** Wrap `command` so it runs from the box's HQ folder (see REMOTE_HQ_DIR_PREFIX). */
245
+ export function withRemoteHqDir(command: string): string {
246
+ return `${REMOTE_HQ_DIR_PREFIX}; ${command}`;
247
+ }
248
+
230
249
  /** SSH connection details vended by `POST /outpost/ssh-access`. */
231
250
  export interface OutpostSshAccess {
232
251
  outpostId: string;
@@ -519,8 +538,11 @@ export function registerOutpostsCommand(program: Command): void {
519
538
  }
520
539
  const token = await ensureCognitoToken();
521
540
 
541
+ // Run from the box's HQ folder by default (works over both SSM and SSH).
542
+ const remoteCommand = withRemoteHqDir(command);
543
+
522
544
  try {
523
- const result = await execOutpost(token, command, opts.id);
545
+ const result = await execOutpost(token, remoteCommand, opts.id);
524
546
  if (opts.json) {
525
547
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
526
548
  } else {
@@ -546,7 +568,7 @@ export function registerOutpostsCommand(program: Command): void {
546
568
  err.step === "platform-unsupported"
547
569
  ) {
548
570
  const access = await getOutpostSshAccess(token, opts.id);
549
- const ssh = execViaSsh(access, command);
571
+ const ssh = execViaSsh(access, remoteCommand);
550
572
  if (ssh.error) {
551
573
  console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
552
574
  process.exit(1);
@@ -6,10 +6,9 @@
6
6
  * hq packs update Re-install the latest of an installed pack.
7
7
  * hq packs uninstall Un-wire + archive a pack (clean, no dangling symlinks).
8
8
  *
9
- * Distinct from `hq packages ...` (the entitlement-gated REGISTRY system tracked
10
- * in packages/registry.yaml). Content packs have no registry file -- the
11
- * filesystem under core/packages/ is the source of truth. Today `hq install`
12
- * is the only clean content-pack op; this adds the rest.
9
+ * Also exposed through `hq packages list`, because package installs use this
10
+ * content-pack system. Content packs have no registry file -- the filesystem
11
+ * under core/packages/ is the source of truth.
13
12
  *
14
13
  * Every subcommand supports `--json` for machine consumers (the HQ Sync
15
14
  * menubar app). JSON is also the default when stdout is not a TTY, matching
@@ -521,10 +520,8 @@ async function runUninstall(name: string, opts: UninstallOpts): Promise<Uninstal
521
520
  // Registration
522
521
  // ---------------------------------------------------------------------------
523
522
 
524
- export function registerPacksCommand(parent: Command): void {
525
- const packs = parent.command('packs').description('Content-pack lifecycle (install via `hq install`)');
526
-
527
- packs
523
+ export function registerPacksListCommand(parent: Command): void {
524
+ parent
528
525
  .command('list')
529
526
  .alias('ls')
530
527
  .description('List installed content packs and the curated catalog')
@@ -550,6 +547,12 @@ export function registerPacksCommand(parent: Command): void {
550
547
  }
551
548
  },
552
549
  );
550
+ }
551
+
552
+ export function registerPacksCommand(parent: Command): void {
553
+ const packs = parent.command('packs').description('Content-pack lifecycle (install via `hq install`)');
554
+
555
+ registerPacksListCommand(packs);
553
556
 
554
557
  packs
555
558
  .command('update [name]')
@@ -0,0 +1,79 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Regression: `hq packages list` must use the same filesystem-backed pack
4
+ * discovery as `hq packs list`, matching the live package install path.
5
+ */
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import * as path from 'path';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { Command } from 'commander';
11
+
12
+ vi.mock('./pack-install.js', () => ({
13
+ classify: vi.fn(() => 'local'),
14
+ resolveLatest: vi.fn(),
15
+ resolveLatestMarketplace: vi.fn(),
16
+ runScanPackages: vi.fn(),
17
+ installPack: vi.fn(),
18
+ }));
19
+
20
+ import { registerPackageListCommand } from './pkg-list.js';
21
+ import { registerPacksCommand } from './packs.js';
22
+
23
+ describe('hq packages list content-pack discovery', () => {
24
+ let hqRoot: string;
25
+ let previousCwd: string;
26
+
27
+ beforeEach(() => {
28
+ hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-pkg-list-'));
29
+ previousCwd = process.cwd();
30
+ fs.mkdirSync(path.join(hqRoot, '.claude'));
31
+ fs.mkdirSync(path.join(hqRoot, 'companies'));
32
+ fs.mkdirSync(path.join(hqRoot, 'core', 'packages', 'hq-pack-fixture'), {
33
+ recursive: true,
34
+ });
35
+ fs.writeFileSync(path.join(hqRoot, 'core', 'core.yaml'), 'hqVersion: 14.0.0\n');
36
+ fs.writeFileSync(
37
+ path.join(hqRoot, 'core', 'packages', 'hq-pack-fixture', 'package.yaml'),
38
+ 'name: hq-pack-fixture\nversion: 1.2.3\n',
39
+ );
40
+ process.chdir(hqRoot);
41
+ });
42
+
43
+ afterEach(() => {
44
+ process.chdir(previousCwd);
45
+ fs.rmSync(hqRoot, { recursive: true, force: true });
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ async function captureJson(register: (program: Command) => void, args: string[]) {
50
+ const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
51
+ const program = new Command();
52
+ program.exitOverride();
53
+ register(program);
54
+ await program.parseAsync(args, { from: 'user' });
55
+ const output = stdout.mock.calls.map((call) => String(call[0])).join('');
56
+ stdout.mockRestore();
57
+ return JSON.parse(output) as {
58
+ installed: Array<{ name: string; version?: string }>;
59
+ };
60
+ }
61
+
62
+ it('reports a pack installed under core/packages through both list commands', async () => {
63
+ const packagesView = await captureJson(
64
+ (program) => {
65
+ const packages = program.command('packages');
66
+ registerPackageListCommand(packages);
67
+ },
68
+ ['packages', 'list', '--json'],
69
+ );
70
+ const packsView = await captureJson(registerPacksCommand, ['packs', 'list', '--json']);
71
+
72
+ expect(packagesView.installed).toContainEqual(
73
+ expect.objectContaining({ name: 'hq-pack-fixture', version: '1.2.3' }),
74
+ );
75
+ expect(packsView.installed).toContainEqual(
76
+ expect.objectContaining({ name: 'hq-pack-fixture', version: '1.2.3' }),
77
+ );
78
+ });
79
+ });
@@ -1,129 +1,13 @@
1
1
  /**
2
- * hq packages list — show installed packages and available entitlements (US-005)
2
+ * hq packages list — list filesystem-backed content packs.
3
3
  *
4
- * Graceful offline: if registry is unreachable, show cached data with a note.
4
+ * Package installs use the content-pack installer, so this command delegates
5
+ * to the same list view as `hq packs list`.
5
6
  */
6
7
 
7
8
  import { Command } from 'commander';
8
- import chalk from 'chalk';
9
- import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
10
- import { readRegistry } from '../utils/registry.js';
11
- import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
12
- import {
13
- getRegistryUrl,
14
- RegistryClient,
15
- type EntitlementEntry,
16
- } from '../utils/registry-client.js';
9
+ import { registerPacksListCommand } from './packs.js';
17
10
 
18
11
  export function registerPackageListCommand(parent: Command): void {
19
- parent
20
- .command('list')
21
- .alias('ls')
22
- .description('List installed and available packages')
23
- .option('--json', 'Machine-readable JSON output')
24
- .action(async (opts: { json?: boolean }) => {
25
- try {
26
- await listPackages(opts.json === true || !process.stdout.isTTY);
27
- } catch (error) {
28
- console.error(
29
- chalk.red('List failed:'),
30
- error instanceof Error ? error.message : 'Unknown error'
31
- );
32
- process.exit(1);
33
- }
34
- });
35
- }
36
-
37
- async function gatherRegistryPackages(): Promise<{
38
- installed: ReturnType<typeof readRegistry>;
39
- available: EntitlementEntry[];
40
- offline: boolean;
41
- }> {
42
- const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
43
- const installed = readRegistry(hqRoot);
44
- let entitlements: EntitlementEntry[] = [];
45
- let offline = false;
46
- try {
47
- const cached = loadCachedTokens();
48
- if (cached && !isExpiring(cached, 120)) {
49
- const client = new RegistryClient(getRegistryUrl(), cached.accessToken);
50
- const result = await client.getMyEntitlements();
51
- entitlements = result.entitlements;
52
- }
53
- } catch {
54
- offline = true;
55
- }
56
- const installedSlugs = new Set(installed.map((p) => p.slug));
57
- const available = entitlements.filter((e) => !installedSlugs.has(e.slug));
58
- return { installed, available, offline };
59
- }
60
-
61
- async function listPackages(json: boolean): Promise<void> {
62
- if (json) {
63
- const { installed, available, offline } = await gatherRegistryPackages();
64
- process.stdout.write(JSON.stringify({ installed, available, offline }, null, 2) + '\n');
65
- return;
66
- }
67
-
68
- const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
69
- const installed = readRegistry(hqRoot);
70
-
71
- // Print installed packages
72
- if (installed.length > 0) {
73
- console.log(chalk.bold('Installed packages:\n'));
74
- for (const pkg of installed) {
75
- const scope = pkg.scope ? chalk.dim(` (${pkg.scope})`) : '';
76
- console.log(` ${chalk.green(pkg.slug)}@${pkg.version}${scope}`);
77
- console.log(
78
- chalk.dim(
79
- ` Installed: ${pkg.installed_at.slice(0, 10)} Updated: ${pkg.updated_at.slice(0, 10)}`
80
- )
81
- );
82
- }
83
- console.log();
84
- } else {
85
- console.log(chalk.dim('No packages installed.\n'));
86
- }
87
-
88
- // Try to show available (but not installed) packages from entitlements
89
- let entitlements: EntitlementEntry[] = [];
90
- let offline = false;
91
-
92
- try {
93
- const cached = loadCachedTokens();
94
- if (cached && !isExpiring(cached, 120)) {
95
- const registryUrl = getRegistryUrl();
96
- const client = new RegistryClient(
97
- registryUrl,
98
- cached.accessToken
99
- );
100
- const result = await client.getMyEntitlements();
101
- entitlements = result.entitlements;
102
- }
103
- } catch {
104
- offline = true;
105
- }
106
-
107
- const installedSlugs = new Set(installed.map((p) => p.slug));
108
- const available = entitlements.filter((e) => !installedSlugs.has(e.slug));
109
-
110
- if (available.length > 0) {
111
- console.log(chalk.bold('Available (not installed):\n'));
112
- for (const ent of available) {
113
- console.log(
114
- ` ${chalk.cyan(ent.slug)} ${chalk.dim(`[${ent.tier}]`)} ${chalk.yellow('available')}`
115
- );
116
- }
117
- console.log(
118
- chalk.dim('\nRun "hq packages install <slug>" to install.\n')
119
- );
120
- }
121
-
122
- if (offline) {
123
- console.log(
124
- chalk.yellow(
125
- 'Note: Could not reach registry — showing cached data only.'
126
- )
127
- );
128
- }
12
+ registerPacksListCommand(parent);
129
13
  }