@indigoai-us/hq-cli 5.14.0 → 5.14.1

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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.14.1] — 2026-05-14
6
+
7
+ ### Fixed
8
+
9
+ - **`hq install <pack>` now writes to `core/packages/<name>/`** under the
10
+ v12 HQ layout, not top-level `packages/<name>/`. Pre-v12 behavior left
11
+ an orphan tree alongside the canonical `core/packages/` shipped by the
12
+ template (`hq-core` / `hq-core-staging`).
13
+ - **Post-install `scan-packages.sh` is now resolved at
14
+ `core/scripts/scan-packages.sh`** instead of top-level `scripts/`.
15
+ - **Stopped writing pack entries to `modules/modules.yaml`.** Packs are
16
+ tracked by filesystem presence under v12 (no separate registry file).
17
+ `hq update <pack>` will re-resolve source from each pack's
18
+ `package.yaml` or prompt; this is intentional. The legacy
19
+ knowledge-modules feature (`strategy: 'git-clone'`) is untouched.
20
+
21
+ (Surfaced during end-to-end testing of the hq-installer staging-channel
22
+ toggle — see indigoai-us/hq-installer#61.)
23
+
5
24
  ## [5.14.0] — 2026-05-14
6
25
 
7
26
  ### Changed
@@ -20,7 +20,10 @@
20
20
  * Exit codes:
21
21
  * 0 — success (and `initial_sync.ok=true`)
22
22
  * 1 — vault auth/network/API error (no entity provisioned)
23
- * 2 — invalid slug, company missing from manifest, or company dir missing
23
+ * 2 — invalid slug, company dir missing, or company is status=archived.
24
+ * Folder-only states (companies/<slug>/ exists on disk but no manifest
25
+ * entry) self-heal via ensureManifestEntryForProvision before
26
+ * validation runs, so a missing manifest entry alone is NOT an exit-2.
24
27
  * 3 — sync failure after entity provisioned (cloud_uid in JSON;
25
28
  * `initial_sync.ok=false`). Manifest + config may have been written.
26
29
  */
@@ -99,6 +102,15 @@ interface InitialSyncArgs {
99
102
  }
100
103
  /** Vault HTTP client interface — minimal surface for entity ops. */
101
104
  export interface VaultClient {
105
+ /**
106
+ * List every person entity visible to the caller (scoped server-side by the
107
+ * caller's Cognito identity). Used by `provisionCompany` as a pre-flight
108
+ * to verify the caller has been onboarded before mutating any cloud-side
109
+ * resources — the initial-sync step at the end of provision needs to vend
110
+ * STS credentials, which the server only does for callers with a person
111
+ * entity.
112
+ */
113
+ listMyPersonEntities(): Promise<VaultEntity[]>;
102
114
  findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
103
115
  createCompanyEntity(input: {
104
116
  slug: string;
@@ -114,8 +126,10 @@ export declare class ProvisionError extends Error {
114
126
  }
115
127
  /**
116
128
  * Validate a company slug per the contract: alphanumeric / dot / dash / underscore,
117
- * non-empty, and never `"personal"` (which is auto-provisioned per-user, not
118
- * promoted via this subcommand).
129
+ * non-empty, never `"."` or `".."` (path-traversal would resolve to
130
+ * `companies/` itself or `<hqRoot>` and write outside the intended company
131
+ * directory), and never `"personal"` (auto-provisioned per-user, not promoted
132
+ * via this subcommand).
119
133
  *
120
134
  * Throws ProvisionError with code=2 on failure.
121
135
  */
@@ -136,11 +150,34 @@ export declare function companyConfigPath(hqRoot: string, slug: string): string;
136
150
  * - company is `status: archived`
137
151
  * - `companies/<slug>/` does not exist
138
152
  *
153
+ * Strict on purpose — callers that want the "auto-insert a missing entry when
154
+ * the local folder exists" affordance (cloud-provision's fault-tolerant flow)
155
+ * MUST call `ensureManifestEntryForProvision` first. cloud-demote and other
156
+ * sensitive callers depend on this strict failure for typo-catching.
157
+ *
139
158
  * Returns the parsed manifest (so the caller can re-use it for the patch step).
140
159
  */
141
160
  export declare function validateManifestAndDir(hqRoot: string, slug: string): {
142
161
  manifest: ManifestDoc;
143
162
  };
163
+ /**
164
+ * Provision-only pre-step. If `companies/<slug>/` exists on disk but the slug
165
+ * is absent from `manifest.yaml`, atomically insert an empty entry
166
+ * (`<slug>: {}`) so the downstream `validateManifestAndDir` + `patchManifest`
167
+ * flow has something to operate on. Heals the common folder-only state
168
+ * (manual mkdir, or older tools that didn't patch manifest.yaml) that hq-sync's
169
+ * Connect button used to dead-end on with exit 2.
170
+ *
171
+ * No-op in every other case:
172
+ * - manifest file missing / malformed (validation will surface)
173
+ * - slug already present (any value, including null — preserved as-is)
174
+ * - `companies/<slug>/` does not exist (validation will surface as a typo)
175
+ *
176
+ * This affordance is provision-specific. `cloud-demote` and other strict
177
+ * callers MUST NOT call this — their typo-catching contract depends on
178
+ * `validateManifestAndDir` throwing on missing-slug.
179
+ */
180
+ export declare function ensureManifestEntryForProvision(hqRoot: string, slug: string): void;
144
181
  /**
145
182
  * Top-level manifest shape we touch. We preserve all unknown fields — only
146
183
  * `cloud_uid` and `bucket_name` under the target slug are mutated.
@@ -20,12 +20,15 @@
20
20
  * Exit codes:
21
21
  * 0 — success (and `initial_sync.ok=true`)
22
22
  * 1 — vault auth/network/API error (no entity provisioned)
23
- * 2 — invalid slug, company missing from manifest, or company dir missing
23
+ * 2 — invalid slug, company dir missing, or company is status=archived.
24
+ * Folder-only states (companies/<slug>/ exists on disk but no manifest
25
+ * entry) self-heal via ensureManifestEntryForProvision before
26
+ * validation runs, so a missing manifest entry alone is NOT an exit-2.
24
27
  * 3 — sync failure after entity provisioned (cloud_uid in JSON;
25
28
  * `initial_sync.ok=false`). Manifest + config may have been written.
26
29
  */
27
30
 
28
- !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]="aacb5bf2-8362-5336-b864-43296c30a1d6")}catch(e){}}();
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]="5573abae-ee71-5106-94e7-3485e3e0fbb2")}catch(e){}}();
29
32
  import chalk from "chalk";
30
33
  import * as fs from "node:fs";
31
34
  import * as path from "node:path";
@@ -45,11 +48,13 @@ export class ProvisionError extends Error {
45
48
  }
46
49
  // ── Validation ───────────────────────────────────────────────────────────────
47
50
  const SLUG_REGEX = /^[A-Za-z0-9._-]+$/;
48
- const FORBIDDEN_SLUGS = new Set(["personal"]);
51
+ const FORBIDDEN_SLUGS = new Set(["personal", ".", ".."]);
49
52
  /**
50
53
  * Validate a company slug per the contract: alphanumeric / dot / dash / underscore,
51
- * non-empty, and never `"personal"` (which is auto-provisioned per-user, not
52
- * promoted via this subcommand).
54
+ * non-empty, never `"."` or `".."` (path-traversal would resolve to
55
+ * `companies/` itself or `<hqRoot>` and write outside the intended company
56
+ * directory), and never `"personal"` (auto-provisioned per-user, not promoted
57
+ * via this subcommand).
53
58
  *
54
59
  * Throws ProvisionError with code=2 on failure.
55
60
  */
@@ -61,7 +66,10 @@ export function validateSlug(slug) {
61
66
  throw new ProvisionError(2, `Invalid slug "${slug}" — must match ${SLUG_REGEX.source}`);
62
67
  }
63
68
  if (FORBIDDEN_SLUGS.has(slug)) {
64
- throw new ProvisionError(2, `Slug "${slug}" is reserved (auto-provisioned per-user, not eligible for cloud promotion)`);
69
+ const reason = slug === "personal"
70
+ ? "auto-provisioned per-user, not eligible for cloud promotion"
71
+ : "path-traversal slug (would resolve to a directory outside the intended company folder)";
72
+ throw new ProvisionError(2, `Slug "${slug}" is reserved (${reason})`);
65
73
  }
66
74
  }
67
75
  /** Path to the top-level companies manifest file. */
@@ -86,6 +94,11 @@ export function companyConfigPath(hqRoot, slug) {
86
94
  * - company is `status: archived`
87
95
  * - `companies/<slug>/` does not exist
88
96
  *
97
+ * Strict on purpose — callers that want the "auto-insert a missing entry when
98
+ * the local folder exists" affordance (cloud-provision's fault-tolerant flow)
99
+ * MUST call `ensureManifestEntryForProvision` first. cloud-demote and other
100
+ * sensitive callers depend on this strict failure for typo-catching.
101
+ *
89
102
  * Returns the parsed manifest (so the caller can re-use it for the patch step).
90
103
  */
91
104
  export function validateManifestAndDir(hqRoot, slug) {
@@ -115,6 +128,69 @@ export function validateManifestAndDir(hqRoot, slug) {
115
128
  }
116
129
  return { manifest };
117
130
  }
131
+ /**
132
+ * Provision-only pre-step. If `companies/<slug>/` exists on disk but the slug
133
+ * is absent from `manifest.yaml`, atomically insert an empty entry
134
+ * (`<slug>: {}`) so the downstream `validateManifestAndDir` + `patchManifest`
135
+ * flow has something to operate on. Heals the common folder-only state
136
+ * (manual mkdir, or older tools that didn't patch manifest.yaml) that hq-sync's
137
+ * Connect button used to dead-end on with exit 2.
138
+ *
139
+ * No-op in every other case:
140
+ * - manifest file missing / malformed (validation will surface)
141
+ * - slug already present (any value, including null — preserved as-is)
142
+ * - `companies/<slug>/` does not exist (validation will surface as a typo)
143
+ *
144
+ * This affordance is provision-specific. `cloud-demote` and other strict
145
+ * callers MUST NOT call this — their typo-catching contract depends on
146
+ * `validateManifestAndDir` throwing on missing-slug.
147
+ */
148
+ export function ensureManifestEntryForProvision(hqRoot, slug) {
149
+ const mPath = manifestPath(hqRoot);
150
+ if (!fs.existsSync(mPath))
151
+ return;
152
+ // Defense-in-depth against path-traversal slugs (`.`, `..`, anything that
153
+ // resolves outside companies/). validateSlug rejects these upstream, but
154
+ // this helper is exported and might be called directly by future code —
155
+ // never auto-insert based on a path that isn't a literal direct child of
156
+ // `companies/`. fs.realpathSync would also catch symlink-escapes, but the
157
+ // directory may legitimately not exist yet on disk; path.resolve gives us
158
+ // the canonical lexical form without a stat call.
159
+ const companiesDir = path.resolve(hqRoot, "companies");
160
+ const expected = path.resolve(companiesDir, slug);
161
+ const expectedParent = path.dirname(expected);
162
+ if (expectedParent !== companiesDir)
163
+ return;
164
+ if (path.basename(expected) !== slug)
165
+ return;
166
+ const dir = companyDirPath(hqRoot, slug);
167
+ if (!fs.existsSync(dir))
168
+ return;
169
+ const raw = fs.readFileSync(mPath, "utf-8");
170
+ let parsed;
171
+ try {
172
+ parsed = yaml.load(raw);
173
+ }
174
+ catch {
175
+ return;
176
+ }
177
+ if (!parsed ||
178
+ typeof parsed !== "object" ||
179
+ !("companies" in parsed) ||
180
+ typeof parsed.companies !== "object") {
181
+ return;
182
+ }
183
+ const doc = parsed;
184
+ if (!doc.companies)
185
+ doc.companies = {};
186
+ if (doc.companies[slug] !== undefined)
187
+ return;
188
+ doc.companies[slug] = {};
189
+ const dump = yaml.dump(doc, { lineWidth: -1, noRefs: true });
190
+ const tmp = `${mPath}.tmp.${process.pid}`;
191
+ fs.writeFileSync(tmp, dump);
192
+ fs.renameSync(tmp, mPath);
193
+ }
118
194
  /**
119
195
  * Atomically patch `companies/manifest.yaml` to set `cloud_uid` + `bucket_name`
120
196
  * under the target slug. Read → mutate → temp-write → rename so concurrent
@@ -179,6 +255,18 @@ export function createDefaultVaultClient(apiUrl, accessToken) {
179
255
  Authorization: `Bearer ${accessToken}`,
180
256
  };
181
257
  return {
258
+ async listMyPersonEntities() {
259
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/by-type/person`;
260
+ const res = await fetch(url, { method: "GET", headers });
261
+ if (res.status === 404)
262
+ return [];
263
+ if (!res.ok) {
264
+ const body = await safeBody(res);
265
+ throw new ProvisionError(1, `Vault GET /entity/by-type/person failed: ${res.status} ${res.statusText} — ${body}`);
266
+ }
267
+ const data = (await res.json());
268
+ return data.entities ?? [];
269
+ },
182
270
  async findCompanyBySlug(slug) {
183
271
  const url = `${apiUrl.replace(/\/$/, "")}/entity/by-slug/company/${encodeURIComponent(slug)}`;
184
272
  const res = await fetch(url, { method: "GET", headers });
@@ -258,8 +346,13 @@ async function defaultRunInitialSync(args) {
258
346
  */
259
347
  export async function provisionCompany(options) {
260
348
  const log = options.log ?? ((msg) => process.stderr.write(`[hq cloud provision] ${msg}\n`));
261
- // Step 1+2+3: validate slug, manifest, dir
349
+ // Step 1+2+3: validate slug, auto-heal a folder-only manifest, then validate
350
+ // the resulting manifest + dir. The auto-heal is provision-specific: it
351
+ // inserts an empty entry when companies/<slug>/ exists on disk but the slug
352
+ // is missing from manifest.yaml. validateManifestAndDir stays strict so
353
+ // other callers (cloud-demote) keep their typo-catching guard.
262
354
  validateSlug(options.slug);
355
+ ensureManifestEntryForProvision(options.hqRoot, options.slug);
263
356
  validateManifestAndDir(options.hqRoot, options.slug);
264
357
  log(`validated slug=${options.slug}`);
265
358
  // Step 4: auth — defer to injected resolver (default: ensureCognitoToken)
@@ -270,6 +363,23 @@ export async function provisionCompany(options) {
270
363
  // Step 5: GET-then-POST for idempotency
271
364
  const vaultClient = options.vaultClient ??
272
365
  createDefaultVaultClient(options.vaultApiUrl, accessToken);
366
+ // Pre-flight: caller MUST have a registered person entity. Without one,
367
+ // the initial-sync step at the end of this flow (STS /sts/vend) returns
368
+ // 403 "no person entity" and leaves the operator with a half-built cloud
369
+ // company — vault entity created, S3 bucket provisioned, manifest patched,
370
+ // .hq/config.json written, but nothing actually syncing. This was the
371
+ // primary failure mode in the 2026-05-14 setup-session deep dive
372
+ // (Joey Muller / sum-digital).
373
+ //
374
+ // Failing here, BEFORE any cloud-side resource creation, leaves the operator
375
+ // with no cleanup work — they fix the underlying onboarding gap (run
376
+ // `hq onboard` / sign in with the correct federated identity) and re-run
377
+ // `hq cloud provision company <slug>` cleanly.
378
+ const persons = await vaultClient.listMyPersonEntities();
379
+ if (persons.length === 0) {
380
+ throw new ProvisionError(2, 'No person entity found for this Cognito identity. Run `hq onboard` first to create your HQ identity, then re-run `hq cloud provision company`. (Provision was halted before any cloud-side resources were created.)');
381
+ }
382
+ log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
273
383
  let entity = await vaultClient.findCompanyBySlug(options.slug);
274
384
  let createdEntity = false;
275
385
  if (entity) {
@@ -426,4 +536,4 @@ export function registerCloudProvisionCommands(program) {
426
536
  });
427
537
  }
428
538
  //# sourceMappingURL=cloud-provision.js.map
429
- //# debugId=aacb5bf2-8362-5336-b864-43296c30a1d6
539
+ //# debugId=5573abae-ee71-5106-94e7-3485e3e0fbb2
@@ -26,16 +26,44 @@
26
26
  * 3. Parse + validate package.yaml (10 checks from spec)
27
27
  * 4. Evaluate `conditional` predicate — skip if exits non-zero
28
28
  * 5. Confirm hooks if `contributes.hooks` non-empty (unless --allow-hooks)
29
- * 6. Move into packages/{name}/
30
- * 7. Append entry to modules.yaml with strategy: package
31
- * 8. Run scan-packages.sh to wire contributions into host paths
29
+ * 6. Move into core/packages/{name}/
30
+ * 7. Run core/scripts/scan-packages.sh to wire contributions into host paths
31
+ *
32
+ * Installed packs are tracked by filesystem presence — there's no separate
33
+ * registry file under the v12 layout. (`hq update <pack>` re-resolves source
34
+ * from each pack's package.yaml; rationale lives in the layout-fix PR.)
32
35
  */
36
+ import type { PackManifest } from '../types.js';
33
37
  /**
34
38
  * sourceMatchesPackPattern — exported for the dispatcher in pkg-install.ts
35
39
  * so it can decide whether to route to the new content-pack handler or fall
36
40
  * back to the legacy registry flow.
37
41
  */
38
42
  export declare function sourceMatchesPackPattern(source: string): boolean;
43
+ /**
44
+ * Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
45
+ * v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
46
+ * `core/packages/` as the canonical pack root; writing to top-level
47
+ * `packages/` would leave an orphan tree alongside the real one.
48
+ *
49
+ * Re-installs replace the existing destination so stale contributions don't
50
+ * linger (the post-install `scan-packages.sh` would otherwise wire them
51
+ * back into host paths).
52
+ *
53
+ * Exported for tests in pack-install.test.ts — see that file for the
54
+ * contract this function pins.
55
+ */
56
+ export declare function installToPackages(payloadDir: string, pkg: PackManifest, hqRoot: string): string;
57
+ /**
58
+ * Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
59
+ * pack's contributions into the host paths (skills, hooks, policies, etc.).
60
+ * Skipping with a dim warning if the script is missing keeps fresh HQs (or
61
+ * older templates) usable — the next session start picks them up via its
62
+ * own scan.
63
+ *
64
+ * Exported for tests.
65
+ */
66
+ export declare function runScanPackages(hqRoot: string): void;
39
67
  export interface InstallPackOptions {
40
68
  allowHooks?: boolean;
41
69
  followBranch?: boolean;
@@ -26,12 +26,15 @@
26
26
  * 3. Parse + validate package.yaml (10 checks from spec)
27
27
  * 4. Evaluate `conditional` predicate — skip if exits non-zero
28
28
  * 5. Confirm hooks if `contributes.hooks` non-empty (unless --allow-hooks)
29
- * 6. Move into packages/{name}/
30
- * 7. Append entry to modules.yaml with strategy: package
31
- * 8. Run scan-packages.sh to wire contributions into host paths
29
+ * 6. Move into core/packages/{name}/
30
+ * 7. Run core/scripts/scan-packages.sh to wire contributions into host paths
31
+ *
32
+ * Installed packs are tracked by filesystem presence — there's no separate
33
+ * registry file under the v12 layout. (`hq update <pack>` re-resolves source
34
+ * from each pack's package.yaml; rationale lives in the layout-fix PR.)
32
35
  */
33
36
 
34
- !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]="9c0fc9f1-9a26-5a3f-a795-7254534316d5")}catch(e){}}();
37
+ !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]="5dea7752-f10c-553c-a419-cf05441b3c13")}catch(e){}}();
35
38
  import * as fs from 'fs';
36
39
  import * as os from 'os';
37
40
  import * as path from 'path';
@@ -42,7 +45,7 @@ import chalk from 'chalk';
42
45
  import semverSatisfies from 'semver/functions/satisfies.js';
43
46
  import semverValid from 'semver/functions/valid.js';
44
47
  import semverValidRange from 'semver/ranges/valid.js';
45
- import { findHqRoot, readManifest, writeManifest, } from '../utils/manifest.js';
48
+ import { findHqRoot } from '../utils/manifest.js';
46
49
  function classify(source) {
47
50
  if (source.startsWith('@'))
48
51
  return 'npm';
@@ -415,10 +418,23 @@ function evalConditional(expr) {
415
418
  return r.status === 0;
416
419
  }
417
420
  // ---------------------------------------------------------------------------
418
- // Move into packages/ + update modules.yaml + scan
421
+ // Move into core/packages/ + run core/scripts/scan-packages.sh
419
422
  // ---------------------------------------------------------------------------
420
- function installToPackages(payloadDir, pkg, hqRoot) {
421
- const packagesDir = path.join(hqRoot, 'packages');
423
+ /**
424
+ * Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
425
+ * v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
426
+ * `core/packages/` as the canonical pack root; writing to top-level
427
+ * `packages/` would leave an orphan tree alongside the real one.
428
+ *
429
+ * Re-installs replace the existing destination so stale contributions don't
430
+ * linger (the post-install `scan-packages.sh` would otherwise wire them
431
+ * back into host paths).
432
+ *
433
+ * Exported for tests in pack-install.test.ts — see that file for the
434
+ * contract this function pins.
435
+ */
436
+ export function installToPackages(payloadDir, pkg, hqRoot) {
437
+ const packagesDir = path.join(hqRoot, 'core', 'packages');
422
438
  fs.mkdirSync(packagesDir, { recursive: true });
423
439
  const destDir = path.join(packagesDir, pkg.name);
424
440
  if (fs.existsSync(destDir)) {
@@ -430,28 +446,19 @@ function installToPackages(payloadDir, pkg, hqRoot) {
430
446
  execFileSync('rsync', ['-a', srcSlashed, destSlashed], { stdio: 'inherit' });
431
447
  return destDir;
432
448
  }
433
- function updateModulesYaml(hqRoot, pkg, fetched) {
434
- const manifest = readManifest(hqRoot) ?? { version: '1', modules: [] };
435
- // Drop any existing entry for this pack (idempotent re-install)
436
- manifest.modules = manifest.modules.filter((m) => m.name !== pkg.name);
437
- const entry = {
438
- name: pkg.name,
439
- strategy: 'package',
440
- source: fetched.resolvedSource,
441
- version: pkg.version,
442
- installed_at: path.posix.join('packages', pkg.name),
443
- installed_at_iso: new Date().toISOString(),
444
- access: pkg.access === 'public' ? 'public' : undefined,
445
- };
446
- if (fetched.resolvedSha)
447
- entry.resolved_sha = fetched.resolvedSha;
448
- manifest.modules.push(entry);
449
- writeManifest(hqRoot, manifest);
450
- }
451
- function runScanPackages(hqRoot) {
452
- const script = path.join(hqRoot, 'scripts', 'scan-packages.sh');
449
+ /**
450
+ * Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
451
+ * pack's contributions into the host paths (skills, hooks, policies, etc.).
452
+ * Skipping with a dim warning if the script is missing keeps fresh HQs (or
453
+ * older templates) usable — the next session start picks them up via its
454
+ * own scan.
455
+ *
456
+ * Exported for tests.
457
+ */
458
+ export function runScanPackages(hqRoot) {
459
+ const script = path.join(hqRoot, 'core', 'scripts', 'scan-packages.sh');
453
460
  if (!fs.existsSync(script)) {
454
- console.log(chalk.dim(` (scripts/scan-packages.sh not present — skipping auto-wire; ` +
461
+ console.log(chalk.dim(` (core/scripts/scan-packages.sh not present — skipping auto-wire; ` +
455
462
  `will run on next session start)`));
456
463
  return;
457
464
  }
@@ -502,7 +509,12 @@ export async function installPack(source, opts = {}) {
502
509
  return;
503
510
  }
504
511
  const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
505
- updateModulesYaml(hqRoot, pkg, fetched);
512
+ // Under the v12 HQ layout, packs live at `core/packages/<name>/` and are
513
+ // tracked by filesystem presence alone — no `modules.yaml` write. That
514
+ // removes the side effect that created a top-level `modules/` directory
515
+ // alongside the canonical `core/`. `hq update <pack>` will re-resolve a
516
+ // pack's source from its on-disk package.yaml or prompt for it, but that
517
+ // tradeoff is intentional — see the layout-fix PR for rationale.
506
518
  runScanPackages(hqRoot);
507
519
  console.log(chalk.green(`\n✓ Installed ${pkg.name}@${pkg.version} → ${path.relative(hqRoot, destDir)}/`));
508
520
  console.log(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
@@ -513,4 +525,4 @@ export async function installPack(source, opts = {}) {
513
525
  }
514
526
  }
515
527
  //# sourceMappingURL=pack-install.js.map
516
- //# debugId=9c0fc9f1-9a26-5a3f-a795-7254534316d5
528
+ //# debugId=5dea7752-f10c-553c-a419-cf05441b3c13
@@ -21,6 +21,31 @@
21
21
  import { type CognitoAuthConfig, type VaultServiceConfig } from "@indigoai-us/hq-cloud";
22
22
  export declare const DEFAULT_COGNITO: CognitoAuthConfig;
23
23
  export declare const DEFAULT_VAULT_API_URL: string;
24
+ /**
25
+ * Resolve the default HQ tree root for cloud-aware subcommands.
26
+ *
27
+ * Priority order:
28
+ * 1. `$HQ_ROOT` env var (explicit user override)
29
+ * 2. Walk up from `process.cwd()` to the nearest dir containing BOTH a
30
+ * `core.yaml` file AND a `companies/` directory (root-unique marker
31
+ * pair — see note below).
32
+ * 3. Fall back to `~/hq` (the historical default)
33
+ *
34
+ * Why both markers?
35
+ * The HQ root has `core.yaml` AND a sibling `companies/` directory. The
36
+ * synced `core/` subtree (which is itself part of the root's personal-vault
37
+ * scope) ALSO contains a `core.yaml` (the template's version-source-of-
38
+ * truth), but does NOT contain `companies/`. Single-marker `core.yaml`
39
+ * detection would stop at `<hqRoot>/core/` when the CLI is launched from
40
+ * somewhere inside that subtree, and downstream `companies/` lookups would
41
+ * silently miss the real content. Requiring `companies/` as well guarantees
42
+ * we resolve to the actual HQ root (Codex P2 on hq#146).
43
+ *
44
+ * Evaluated once at module load — commander.js `.option()` callers pin the
45
+ * value at registration time, which matches the user's actual cwd at process
46
+ * start. Re-importable as a function for tests and command-time resolution.
47
+ */
48
+ export declare function resolveDefaultHqRoot(): string;
24
49
  export declare const DEFAULT_HQ_ROOT: string;
25
50
  /**
26
51
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
@@ -19,7 +19,8 @@
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
21
 
22
- !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]="71d8f748-f249-50df-aa1f-eae381ea608d")}catch(e){}}();
22
+ !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]="6a8ac157-0903-513d-b4c5-45fb532b8026")}catch(e){}}();
23
+ import * as fs from "fs";
23
24
  import * as os from "os";
24
25
  import * as path from "path";
25
26
  import chalk from "chalk";
@@ -41,7 +42,53 @@ export const DEFAULT_COGNITO = {
41
42
  : "Google",
42
43
  };
43
44
  export const DEFAULT_VAULT_API_URL = process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
44
- export const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
45
+ /**
46
+ * Resolve the default HQ tree root for cloud-aware subcommands.
47
+ *
48
+ * Priority order:
49
+ * 1. `$HQ_ROOT` env var (explicit user override)
50
+ * 2. Walk up from `process.cwd()` to the nearest dir containing BOTH a
51
+ * `core.yaml` file AND a `companies/` directory (root-unique marker
52
+ * pair — see note below).
53
+ * 3. Fall back to `~/hq` (the historical default)
54
+ *
55
+ * Why both markers?
56
+ * The HQ root has `core.yaml` AND a sibling `companies/` directory. The
57
+ * synced `core/` subtree (which is itself part of the root's personal-vault
58
+ * scope) ALSO contains a `core.yaml` (the template's version-source-of-
59
+ * truth), but does NOT contain `companies/`. Single-marker `core.yaml`
60
+ * detection would stop at `<hqRoot>/core/` when the CLI is launched from
61
+ * somewhere inside that subtree, and downstream `companies/` lookups would
62
+ * silently miss the real content. Requiring `companies/` as well guarantees
63
+ * we resolve to the actual HQ root (Codex P2 on hq#146).
64
+ *
65
+ * Evaluated once at module load — commander.js `.option()` callers pin the
66
+ * value at registration time, which matches the user's actual cwd at process
67
+ * start. Re-importable as a function for tests and command-time resolution.
68
+ */
69
+ export function resolveDefaultHqRoot() {
70
+ if (process.env.HQ_ROOT)
71
+ return path.resolve(process.env.HQ_ROOT);
72
+ let cur = path.resolve(process.cwd());
73
+ while (cur !== path.dirname(cur)) {
74
+ if (isHqRoot(cur))
75
+ return cur;
76
+ cur = path.dirname(cur);
77
+ }
78
+ return path.join(os.homedir(), "hq");
79
+ }
80
+ /** True iff `dir` looks like an HQ root (has core.yaml + companies/ dir). */
81
+ function isHqRoot(dir) {
82
+ if (!fs.existsSync(path.join(dir, "core.yaml")))
83
+ return false;
84
+ try {
85
+ return fs.statSync(path.join(dir, "companies")).isDirectory();
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export const DEFAULT_HQ_ROOT = resolveDefaultHqRoot();
45
92
  /**
46
93
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
47
94
  * as needed. Cache lives at ~/.hq/cognito-tokens.json.
@@ -108,4 +155,4 @@ export async function refreshCachedSession() {
108
155
  }
109
156
  }
110
157
  //# sourceMappingURL=cognito-session.js.map
111
- //# debugId=71d8f748-f249-50df-aa1f-eae381ea608d
158
+ //# debugId=6a8ac157-0903-513d-b4c5-45fb532b8026
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.14.0",
3
+ "version": "5.14.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {