@indigoai-us/hq-cli 5.5.0 → 5.5.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.
Files changed (103) hide show
  1. package/dist/bin/hq-auth-refresh.js +0 -0
  2. package/dist/commands/add.d.ts.map +1 -1
  3. package/dist/commands/add.js +2 -1
  4. package/dist/commands/add.js.map +1 -1
  5. package/dist/commands/auth.d.ts +0 -3
  6. package/dist/commands/auth.d.ts.map +1 -1
  7. package/dist/commands/auth.js +0 -3
  8. package/dist/commands/auth.js.map +1 -1
  9. package/dist/commands/cloud.d.ts +12 -2
  10. package/dist/commands/cloud.d.ts.map +1 -1
  11. package/dist/commands/cloud.js +112 -80
  12. package/dist/commands/cloud.js.map +1 -1
  13. package/dist/commands/list.d.ts.map +1 -1
  14. package/dist/commands/list.js +18 -1
  15. package/dist/commands/list.js.map +1 -1
  16. package/dist/commands/login.d.ts +1 -1
  17. package/dist/commands/login.d.ts.map +1 -1
  18. package/dist/commands/login.js +28 -9
  19. package/dist/commands/login.js.map +1 -1
  20. package/dist/commands/logout.d.ts +1 -1
  21. package/dist/commands/logout.d.ts.map +1 -1
  22. package/dist/commands/logout.js +8 -3
  23. package/dist/commands/logout.js.map +1 -1
  24. package/dist/commands/onboard.d.ts +23 -0
  25. package/dist/commands/onboard.d.ts.map +1 -0
  26. package/dist/commands/onboard.js +142 -0
  27. package/dist/commands/onboard.js.map +1 -0
  28. package/dist/commands/pack-install.d.ts +44 -0
  29. package/dist/commands/pack-install.d.ts.map +1 -0
  30. package/dist/commands/pack-install.js +513 -0
  31. package/dist/commands/pack-install.js.map +1 -0
  32. package/dist/commands/pkg-install.d.ts.map +1 -1
  33. package/dist/commands/pkg-install.js +20 -8
  34. package/dist/commands/pkg-install.js.map +1 -1
  35. package/dist/commands/pkg-list.js +4 -4
  36. package/dist/commands/pkg-list.js.map +1 -1
  37. package/dist/commands/pkg-update.js +3 -3
  38. package/dist/commands/pkg-update.js.map +1 -1
  39. package/dist/commands/secrets.d.ts +3 -0
  40. package/dist/commands/secrets.d.ts.map +1 -0
  41. package/dist/commands/secrets.js +521 -0
  42. package/dist/commands/secrets.js.map +1 -0
  43. package/dist/commands/sync.d.ts.map +1 -1
  44. package/dist/commands/sync.js +10 -2
  45. package/dist/commands/sync.js.map +1 -1
  46. package/dist/commands/team-sync.d.ts.map +1 -1
  47. package/dist/commands/team-sync.js +6 -6
  48. package/dist/commands/team-sync.js.map +1 -1
  49. package/dist/commands/whoami.d.ts +1 -1
  50. package/dist/commands/whoami.d.ts.map +1 -1
  51. package/dist/commands/whoami.js +23 -7
  52. package/dist/commands/whoami.js.map +1 -1
  53. package/dist/index.js +7 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/strategies/link.d.ts +2 -2
  56. package/dist/strategies/link.d.ts.map +1 -1
  57. package/dist/strategies/link.js.map +1 -1
  58. package/dist/strategies/merge.d.ts +2 -2
  59. package/dist/strategies/merge.d.ts.map +1 -1
  60. package/dist/strategies/merge.js.map +1 -1
  61. package/dist/types.d.ts +37 -3
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +6 -0
  64. package/dist/types.js.map +1 -1
  65. package/dist/utils/cognito-session.d.ts +18 -7
  66. package/dist/utils/cognito-session.d.ts.map +1 -1
  67. package/dist/utils/cognito-session.js +35 -8
  68. package/dist/utils/cognito-session.js.map +1 -1
  69. package/dist/utils/manifest.d.ts +13 -0
  70. package/dist/utils/manifest.d.ts.map +1 -1
  71. package/dist/utils/manifest.js +24 -1
  72. package/dist/utils/manifest.js.map +1 -1
  73. package/dist/utils/secrets-cache.d.ts +7 -0
  74. package/dist/utils/secrets-cache.d.ts.map +1 -0
  75. package/dist/utils/secrets-cache.js +134 -0
  76. package/dist/utils/secrets-cache.js.map +1 -0
  77. package/package.json +15 -5
  78. package/src/commands/add.ts +7 -6
  79. package/src/commands/auth.ts +0 -3
  80. package/src/commands/cloud.ts +214 -101
  81. package/src/commands/list.ts +20 -1
  82. package/src/commands/login.ts +28 -9
  83. package/src/commands/logout.ts +8 -3
  84. package/src/commands/onboard.ts +193 -0
  85. package/src/commands/pack-install.ts +667 -0
  86. package/src/commands/pkg-install.ts +34 -16
  87. package/src/commands/pkg-list.ts +4 -4
  88. package/src/commands/pkg-update.ts +3 -3
  89. package/src/commands/secrets.ts +670 -0
  90. package/src/commands/sync.ts +20 -4
  91. package/src/commands/team-sync.ts +7 -8
  92. package/src/commands/whoami.ts +22 -7
  93. package/src/index.ts +9 -1
  94. package/src/strategies/link.ts +2 -2
  95. package/src/strategies/merge.ts +2 -2
  96. package/src/types.ts +54 -4
  97. package/src/utils/cognito-session.ts +46 -14
  98. package/src/utils/manifest.test.ts +148 -0
  99. package/src/utils/manifest.ts +22 -1
  100. package/src/utils/secrets-cache.ts +129 -0
  101. package/tsconfig.json +2 -1
  102. package/src/utils/auth.ts +0 -193
  103. package/src/utils/token-store.ts +0 -83
@@ -17,8 +17,7 @@ import { Command } from 'commander';
17
17
  import chalk from 'chalk';
18
18
  import simpleGit from 'simple-git';
19
19
  import { findHqRoot } from '../utils/hq-root.js';
20
- import { getAuthToken } from '../utils/auth.js';
21
- import type { AuthToken } from '../utils/token-store.js';
20
+ import { ensureCognitoToken } from '../utils/cognito-session.js';
22
21
 
23
22
  // ─── Types ──────────────────────────────────────────────────────────────────
24
23
 
@@ -57,7 +56,7 @@ interface TeamSyncResult {
57
56
 
58
57
  // ─── API helpers ────────────────────────────────────────────────────────────
59
58
 
60
- const API_BASE = 'https://hq.indigoai.com/api';
59
+ const API_BASE = 'https://example.com/api';
61
60
 
62
61
  async function apiGet<T>(urlPath: string, token: string): Promise<T> {
63
62
  const res = await fetch(`${API_BASE}${urlPath}`, {
@@ -255,7 +254,7 @@ function discoverTeamDirs(hqRoot: string): { dir: string; meta: TeamMetadata }[]
255
254
  async function syncTeam(
256
255
  teamDir: string,
257
256
  meta: TeamMetadata,
258
- authToken: AuthToken
257
+ accessToken: string
259
258
  ): Promise<TeamSyncResult> {
260
259
  const result: TeamSyncResult = {
261
260
  slug: meta.team_slug,
@@ -286,7 +285,7 @@ async function syncTeam(
286
285
  try {
287
286
  const entitlements = await fetchTeamEntitlements(
288
287
  meta.team_id,
289
- authToken.clerk_session_token
288
+ accessToken
290
289
  );
291
290
  const entitledPaths = entitlements.flatMap((e) => e.paths);
292
291
 
@@ -339,7 +338,7 @@ async function syncTeam(
339
338
  try {
340
339
  repoConfig = await fetchRepoConfig(
341
340
  meta.team_id,
342
- authToken.clerk_session_token
341
+ accessToken
343
342
  );
344
343
  } catch (err) {
345
344
  result.message = `Failed to fetch repo credentials: ${err instanceof Error ? err.message : 'Unknown error'}`;
@@ -467,9 +466,9 @@ export function registerTeamSyncCommand(program: Command): void {
467
466
  }
468
467
 
469
468
  // 2. Get valid auth token (handles refresh / re-auth prompt)
470
- let authToken: AuthToken;
469
+ let authToken: string;
471
470
  try {
472
- authToken = await getAuthToken();
471
+ authToken = await ensureCognitoToken();
473
472
  } catch (err) {
474
473
  console.error(
475
474
  chalk.red(
@@ -1,10 +1,23 @@
1
1
  /**
2
- * hq whoami — displays current user or 'not logged in' (US-004)
2
+ * hq whoami — displays current user or 'not logged in'
3
3
  */
4
4
 
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
- import { loadToken, isTokenExpired } from '../utils/token-store.js';
7
+ import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
8
+
9
+ function peekIdToken(idToken: string): { email?: string; sub?: string } {
10
+ try {
11
+ const payload = idToken.split('.')[1];
12
+ if (!payload) return {};
13
+ const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
14
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
+ const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
16
+ return { email: decoded.email, sub: decoded.sub };
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
8
21
 
9
22
  export function registerWhoamiCommand(program: Command): void {
10
23
  program
@@ -12,21 +25,23 @@ export function registerWhoamiCommand(program: Command): void {
12
25
  .description('Show the currently authenticated user')
13
26
  .action(async () => {
14
27
  try {
15
- const token = await loadToken();
28
+ const cached = loadCachedTokens();
16
29
 
17
- if (!token) {
30
+ if (!cached) {
18
31
  console.log("Not logged in. Run 'hq login' to authenticate.");
19
32
  return;
20
33
  }
21
34
 
22
- if (isTokenExpired(token)) {
35
+ if (isExpiring(cached, 0)) {
36
+ const who = peekIdToken(cached.idToken).email ?? 'unknown';
23
37
  console.log(
24
- chalk.yellow(`Session expired for ${token.email}. Run 'hq login' to re-authenticate.`)
38
+ chalk.yellow(`Session expired for ${who}. Run 'hq login' to re-authenticate.`)
25
39
  );
26
40
  return;
27
41
  }
28
42
 
29
- console.log(`Logged in as ${token.email} (${token.user_id})`);
43
+ const { email, sub } = peekIdToken(cached.idToken);
44
+ console.log(`Logged in as ${email ?? 'unknown'}${sub ? ` (${sub})` : ''}`);
30
45
  } catch (error) {
31
46
  console.error(
32
47
  chalk.red('Error:'),
package/src/index.ts CHANGED
@@ -13,12 +13,14 @@ import { registerCloudCommands } from "./commands/cloud.js";
13
13
  import { registerLoginCommand } from "./commands/login.js";
14
14
  import { registerLogoutCommand } from "./commands/logout.js";
15
15
  import { registerWhoamiCommand } from "./commands/whoami.js";
16
+ import { registerOnboardCommand } from "./commands/onboard.js";
16
17
  import { registerPackageInstallCommand } from "./commands/pkg-install.js";
17
18
  import { registerPackageRemoveCommand } from "./commands/pkg-remove.js";
18
19
  import { registerPackageUpdateCommand } from "./commands/pkg-update.js";
19
20
  import { registerPackageListCommand } from "./commands/pkg-list.js";
20
21
  import { registerTeamSyncCommand } from "./commands/team-sync.js";
21
22
  import { registerAuthCommands } from "./commands/auth.js";
23
+ import { registerSecretsCommand } from "./commands/secrets.js";
22
24
 
23
25
  const program = new Command();
24
26
 
@@ -63,10 +65,16 @@ registerCloudCommands(syncCmd);
63
65
  // Team commands (top-level)
64
66
  registerTeamSyncCommand(program);
65
67
 
66
- // Auth commands (top-level)
68
+ // Auth commands (top-level — Cognito OAuth)
67
69
  registerLoginCommand(program);
68
70
  registerLogoutCommand(program);
69
71
  registerWhoamiCommand(program);
70
72
  registerAuthCommands(program);
71
73
 
74
+ // Secrets management (subcommand group — hq secrets set|get|list|delete|exec|generate-link|cache)
75
+ registerSecretsCommand(program);
76
+
77
+ // Onboarding (top-level — Cognito + vault-service provisioning)
78
+ registerOnboardCommand(program);
79
+
72
80
  program.parse();
@@ -5,10 +5,10 @@
5
5
 
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
- import type { ModuleDefinition, SyncResult } from '../types.js';
8
+ import type { LegacyModuleDefinition, SyncResult } from '../types.js';
9
9
 
10
10
  export async function linkSync(
11
- module: ModuleDefinition,
11
+ module: LegacyModuleDefinition,
12
12
  moduleDir: string,
13
13
  hqRoot: string
14
14
  ): Promise<SyncResult> {
@@ -6,7 +6,7 @@
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
  import * as crypto from 'crypto';
9
- import type { ModuleDefinition, SyncResult, SyncState } from '../types.js';
9
+ import type { LegacyModuleDefinition, SyncResult, SyncState } from '../types.js';
10
10
  import { readState, writeState } from '../utils/manifest.js';
11
11
 
12
12
  function hashFile(filePath: string): string {
@@ -69,7 +69,7 @@ function copyRecursive(
69
69
  }
70
70
 
71
71
  export async function mergeSync(
72
- module: ModuleDefinition,
72
+ module: LegacyModuleDefinition,
73
73
  moduleDir: string,
74
74
  hqRoot: string
75
75
  ): Promise<SyncResult> {
package/src/types.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  /**
2
2
  * HQ Module Manifest Types (US-001)
3
+ *
4
+ * `modules.yaml` hosts two kinds of entries, discriminated by `strategy`:
5
+ * - link | merge | copy → git-repo-based module sync (existing flow)
6
+ * - package → hq-pack content pack installed via `hq install`
7
+ * (new in hq-core v12.0.0; see
8
+ * knowledge/public/hq-core/package-yaml-spec.md)
3
9
  */
4
10
 
5
- export type SyncStrategy = 'link' | 'merge' | 'copy';
11
+ export type LegacyStrategy = 'link' | 'merge' | 'copy';
12
+ export type SyncStrategy = LegacyStrategy | 'package';
6
13
  export type AccessLevel = 'public' | 'team' | `role:${string}`;
7
14
 
8
15
  export interface PathMapping {
@@ -10,15 +17,28 @@ export interface PathMapping {
10
17
  dest: string; // Path in HQ tree (relative to HQ root)
11
18
  }
12
19
 
13
- export interface ModuleDefinition {
20
+ export interface LegacyModuleDefinition {
14
21
  name: string;
15
22
  repo: string; // Git URL (https or git@)
16
23
  branch?: string; // Default: main
17
- strategy: SyncStrategy; // link | merge | copy
24
+ strategy: LegacyStrategy;
18
25
  paths: PathMapping[]; // What to sync and where
19
- access?: AccessLevel; // For future RBAC
26
+ access?: AccessLevel;
20
27
  }
21
28
 
29
+ export interface PackModuleDefinition {
30
+ name: string; // e.g. 'hq-pack-gstack'
31
+ strategy: 'package';
32
+ source: string; // '@scope/name@ver' | git URL#sha | local path
33
+ version?: string; // semver from the pack's package.yaml
34
+ installed_at?: string; // e.g. 'packages/hq-pack-gstack'
35
+ installed_at_iso?: string; // ISO timestamp
36
+ resolved_sha?: string; // git transport only
37
+ access?: AccessLevel;
38
+ }
39
+
40
+ export type ModuleDefinition = LegacyModuleDefinition | PackModuleDefinition;
41
+
22
42
  export interface ModulesManifest {
23
43
  version: '1';
24
44
  modules: ModuleDefinition[];
@@ -45,3 +65,33 @@ export interface SyncResult {
45
65
  message?: string;
46
66
  filesChanged?: number;
47
67
  }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // hq-pack content-pack manifest (packages/{name}/package.yaml).
71
+ //
72
+ // Distinct from packages/hq-cli/src/schemas/hq-package.schema.json, which
73
+ // covers the entitlement-gated registry flow (`hq packages install <slug>`).
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export type PackContributeKey =
77
+ | 'workers'
78
+ | 'knowledge'
79
+ | 'skills'
80
+ | 'commands'
81
+ | 'hooks'
82
+ | 'policies'
83
+ | 'scripts';
84
+
85
+ export interface PackManifest {
86
+ name: string; // ^hq-pack-[a-z0-9][a-z0-9-]*$
87
+ version: string; // semver
88
+ publisher: string; // @scope
89
+ access: 'public' | 'private';
90
+ requires: { hqCore: string }; // semver range
91
+ contributes: Partial<Record<PackContributeKey, string[]>>;
92
+ description?: string;
93
+ license?: string;
94
+ repository?: string;
95
+ keywords?: string[];
96
+ conditional?: string; // bash predicate; skip install if exits non-zero
97
+ }
@@ -1,41 +1,56 @@
1
1
  /**
2
2
  * Shared Cognito session helpers for hq-cli commands.
3
3
  *
4
- * Consumed by `hq auth refresh` and the standalone `hq-auth-refresh` bin
5
- * invoked by the deploy skill (.claude/skills/deploy/SKILL.md step 4).
4
+ * Consumed by:
5
+ * - `hq onboard` and `hq sync push|pull` (need token + VaultServiceConfig)
6
+ * - `hq auth refresh` and the standalone `hq-auth-refresh` bin invoked by
7
+ * the deploy skill (.claude/skills/deploy/SKILL.md step 4)
6
8
  *
7
- * Defaults point at the shared hq-vault-dev Cognito pool. Override via env:
9
+ * Defaults point at the shared hq-vault-dev Cognito pool. They mirror
10
+ * tools/vlt-e2e/e2e-create-company-smoke.ts so the CLI and the in-tree demo script
11
+ * stay drift-free. Override any of them via env:
8
12
  *
9
13
  * AWS_REGION — e.g. us-east-1
10
14
  * HQ_COGNITO_DOMAIN — Cognito User Pool domain prefix
11
15
  * HQ_COGNITO_CLIENT_ID — App Client ID
12
16
  * HQ_COGNITO_CALLBACK_PORT — Loopback OAuth callback port
17
+ * HQ_VAULT_API_URL — vault-service API Gateway URL
13
18
  */
14
19
 
20
+ import * as os from "os";
21
+ import * as path from "path";
22
+ import chalk from "chalk";
15
23
  import {
16
24
  loadCachedTokens,
17
25
  isExpiring,
18
26
  refreshTokens,
19
27
  browserLogin,
20
28
  type CognitoAuthConfig,
29
+ type VaultServiceConfig,
21
30
  } from "@indigoai-us/hq-cloud";
22
31
 
23
32
  export const DEFAULT_COGNITO: CognitoAuthConfig = {
24
33
  region: process.env.AWS_REGION ?? "us-east-1",
25
34
  userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "hq-vault-dev",
26
- clientId:
27
- process.env.HQ_COGNITO_CLIENT_ID ?? "4mmujmjq3srakdueg656b9m0mp",
35
+ clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "4mmujmjq3srakdueg656b9m0mp",
28
36
  port: process.env.HQ_COGNITO_CALLBACK_PORT
29
37
  ? Number(process.env.HQ_COGNITO_CALLBACK_PORT)
30
38
  : 8765,
31
39
  };
32
40
 
41
+ export const DEFAULT_VAULT_API_URL =
42
+ process.env.HQ_VAULT_API_URL ??
43
+ "https://tqdwdqxv75.execute-api.us-east-1.amazonaws.com";
44
+
45
+ export const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
46
+
33
47
  /**
34
48
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
35
49
  * as needed. Cache lives at ~/.hq/cognito-tokens.json.
36
50
  *
37
- * Pass `interactive: false` from automated contexts where failing fast is
38
- * better than opening a browser.
51
+ * Pass `interactive: false` from automated contexts (e.g. the `hq-auth-refresh`
52
+ * bin invoked by the deploy skill) where failing fast is better than opening
53
+ * a browser.
39
54
  */
40
55
  export async function ensureCognitoToken(options: {
41
56
  interactive?: boolean;
@@ -49,13 +64,19 @@ export async function ensureCognitoToken(options: {
49
64
 
50
65
  if (cached) {
51
66
  try {
52
- const refreshed = await refreshTokens(
53
- DEFAULT_COGNITO,
54
- cached.refreshToken,
55
- );
67
+ if (interactive) {
68
+ console.error(chalk.dim(" Refreshing expiring HQ session..."));
69
+ }
70
+ const refreshed = await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
56
71
  return refreshed.accessToken;
57
- } catch {
58
- // fall through to browser login
72
+ } catch (err) {
73
+ if (interactive) {
74
+ console.error(
75
+ chalk.dim(
76
+ ` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`,
77
+ ),
78
+ );
79
+ }
59
80
  }
60
81
  }
61
82
 
@@ -65,14 +86,25 @@ export async function ensureCognitoToken(options: {
65
86
  );
66
87
  }
67
88
 
89
+ console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
68
90
  const tokens = await browserLogin(DEFAULT_COGNITO);
69
91
  return tokens.accessToken;
70
92
  }
71
93
 
94
+ /** Build a VaultServiceConfig with the given access token. */
95
+ export function buildVaultConfig(authToken: string): VaultServiceConfig {
96
+ return {
97
+ apiUrl: DEFAULT_VAULT_API_URL,
98
+ authToken,
99
+ region: DEFAULT_COGNITO.region,
100
+ };
101
+ }
102
+
72
103
  /**
73
104
  * Refresh the cached Cognito session once and return the result. Used by
74
105
  * `hq auth refresh` and the `hq-auth-refresh` bin. Never opens a browser —
75
- * if no cached tokens exist or the refresh fails, throws.
106
+ * if no cached tokens exist or the refresh fails, returns `refreshed: false`
107
+ * with a reason string so the caller can decide what to do.
76
108
  */
77
109
  export async function refreshCachedSession(): Promise<{
78
110
  refreshed: boolean;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Regression tests for manifest path resolution.
3
+ *
4
+ * Bug caught during hq-core-split S3 testing (2026-04-21): prior to this
5
+ * fix, `getManifestPath()` returned a flat path (`{hqRoot}/modules.yaml`),
6
+ * but real HQ installs use the nested layout (`{hqRoot}/modules/modules.yaml`).
7
+ * Running `hq install` against a real HQ would silently create a shadow
8
+ * manifest at the root, orphaning the canonical nested one.
9
+ *
10
+ * These tests lock in:
11
+ * 1. Nested layout is preferred when it exists.
12
+ * 2. Flat layout is honored when (and only when) it already exists.
13
+ * 3. Fresh HQs default to writing the nested form — never shadow-writing
14
+ * a root-level `modules.yaml`.
15
+ * 4. Read/write round-trips correctly against the nested layout.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
+ import * as fs from 'node:fs';
20
+ import * as os from 'node:os';
21
+ import * as path from 'node:path';
22
+ import * as yaml from 'js-yaml';
23
+
24
+ import {
25
+ addModule,
26
+ getManifestPath,
27
+ readManifest,
28
+ writeManifest,
29
+ } from './manifest.js';
30
+ import type { ModulesManifest } from '../types.js';
31
+
32
+ let tmpRoot: string;
33
+
34
+ beforeEach(() => {
35
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-manifest-test-'));
36
+ });
37
+
38
+ afterEach(() => {
39
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
40
+ });
41
+
42
+ const sampleManifest: ModulesManifest = {
43
+ version: '1',
44
+ modules: [
45
+ {
46
+ name: 'knowledge-public-hq-core',
47
+ repo: 'https://github.com/indigoai-us/knowledge-public-hq-core.git',
48
+ strategy: 'link',
49
+ paths: [
50
+ {
51
+ src: '.',
52
+ dest: 'knowledge/public/hq-core',
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ };
58
+
59
+ describe('getManifestPath', () => {
60
+ it('prefers nested modules/modules.yaml when it exists', () => {
61
+ const nested = path.join(tmpRoot, 'modules', 'modules.yaml');
62
+ fs.mkdirSync(path.dirname(nested), { recursive: true });
63
+ fs.writeFileSync(nested, yaml.dump(sampleManifest));
64
+
65
+ expect(getManifestPath(tmpRoot)).toBe(nested);
66
+ });
67
+
68
+ it('honors legacy flat modules.yaml when only the flat form exists', () => {
69
+ // Legacy fixtures / very old HQ instances may pre-date the nested layout.
70
+ const flat = path.join(tmpRoot, 'modules.yaml');
71
+ fs.writeFileSync(flat, yaml.dump(sampleManifest));
72
+
73
+ expect(getManifestPath(tmpRoot)).toBe(flat);
74
+ });
75
+
76
+ it('defaults to nested path on a fresh HQ (neither file exists)', () => {
77
+ // Regression: previously returned the flat path, which meant writeManifest
78
+ // created a shadow root-level manifest on real HQs.
79
+ expect(getManifestPath(tmpRoot)).toBe(
80
+ path.join(tmpRoot, 'modules', 'modules.yaml'),
81
+ );
82
+ });
83
+ });
84
+
85
+ describe('readManifest / writeManifest round-trip', () => {
86
+ it('round-trips against the nested layout', () => {
87
+ writeManifest(tmpRoot, sampleManifest);
88
+
89
+ const nested = path.join(tmpRoot, 'modules', 'modules.yaml');
90
+ expect(fs.existsSync(nested)).toBe(true);
91
+
92
+ const roundTripped = readManifest(tmpRoot);
93
+ expect(roundTripped).toEqual(sampleManifest);
94
+ });
95
+
96
+ it('writes into the nested modules/ dir on fresh HQs (creates dir)', () => {
97
+ // The nested dir does not exist yet — writeManifest must mkdir-p.
98
+ expect(fs.existsSync(path.join(tmpRoot, 'modules'))).toBe(false);
99
+
100
+ writeManifest(tmpRoot, sampleManifest);
101
+
102
+ expect(fs.existsSync(path.join(tmpRoot, 'modules'))).toBe(true);
103
+ expect(fs.existsSync(path.join(tmpRoot, 'modules', 'modules.yaml'))).toBe(
104
+ true,
105
+ );
106
+ });
107
+
108
+ it('does NOT shadow-write a root-level modules.yaml on fresh HQs', () => {
109
+ // This is the original bug: pre-fix, writeManifest on a fresh HQ created
110
+ // `{hqRoot}/modules.yaml`, which shadowed the canonical nested manifest.
111
+ writeManifest(tmpRoot, sampleManifest);
112
+
113
+ expect(fs.existsSync(path.join(tmpRoot, 'modules.yaml'))).toBe(false);
114
+ });
115
+
116
+ it('rewrites into the flat layout when flat already exists (legacy compat)', () => {
117
+ const flat = path.join(tmpRoot, 'modules.yaml');
118
+ fs.writeFileSync(flat, yaml.dump({ version: '1', modules: [] }));
119
+
120
+ writeManifest(tmpRoot, sampleManifest);
121
+
122
+ // Flat stays flat; no silent migration to nested, no shadow write.
123
+ expect(fs.existsSync(flat)).toBe(true);
124
+ expect(
125
+ fs.existsSync(path.join(tmpRoot, 'modules', 'modules.yaml')),
126
+ ).toBe(false);
127
+
128
+ const reread = readManifest(tmpRoot);
129
+ expect(reread).toEqual(sampleManifest);
130
+ });
131
+ });
132
+
133
+ describe('addModule', () => {
134
+ it('appends to the existing nested manifest without creating a flat shadow', () => {
135
+ writeManifest(tmpRoot, sampleManifest);
136
+
137
+ addModule(tmpRoot, {
138
+ name: 'knowledge-public-testing',
139
+ repo: 'https://github.com/indigoai-us/knowledge-public-testing.git',
140
+ strategy: 'link',
141
+ paths: [{ src: '.', dest: 'knowledge/public/testing' }],
142
+ });
143
+
144
+ const after = readManifest(tmpRoot);
145
+ expect(after?.modules).toHaveLength(2);
146
+ expect(fs.existsSync(path.join(tmpRoot, 'modules.yaml'))).toBe(false);
147
+ });
148
+ });
@@ -18,8 +18,25 @@ export function findHqRoot(): string {
18
18
  return process.cwd();
19
19
  }
20
20
 
21
+ /**
22
+ * Resolve the manifest path for a given HQ root.
23
+ *
24
+ * The canonical layout is nested: `{hqRoot}/modules/modules.yaml` (catalog)
25
+ * alongside `{hqRoot}/modules/<module-name>/` (clone targets).
26
+ *
27
+ * Legacy fixtures and very old HQ instances may have the flat layout
28
+ * (`{hqRoot}/modules.yaml`); we honor that if — and only if — the flat file
29
+ * already exists. On fresh HQs we default to the nested form so we never
30
+ * silently create a second shadow manifest at the root.
31
+ *
32
+ * Exported for tests.
33
+ */
21
34
  export function getManifestPath(hqRoot: string): string {
22
- return path.join(hqRoot, MANIFEST_FILE);
35
+ const nested = path.join(hqRoot, 'modules', MANIFEST_FILE);
36
+ const flat = path.join(hqRoot, MANIFEST_FILE);
37
+ if (fs.existsSync(nested)) return nested;
38
+ if (fs.existsSync(flat)) return flat;
39
+ return nested; // default for fresh HQs — see getModulesDir()
23
40
  }
24
41
 
25
42
  export function getLockPath(hqRoot: string): string {
@@ -45,6 +62,10 @@ export function readManifest(hqRoot: string): ModulesManifest | null {
45
62
 
46
63
  export function writeManifest(hqRoot: string, manifest: ModulesManifest): void {
47
64
  const manifestPath = getManifestPath(hqRoot);
65
+ // Ensure the parent directory exists — on a fresh HQ the nested
66
+ // `modules/` dir may not yet be present, and fs.writeFileSync does not
67
+ // auto-mkdir. Safe no-op when the dir already exists.
68
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
48
69
  const content = yaml.dump(manifest, { lineWidth: -1 });
49
70
  fs.writeFileSync(manifestPath, content);
50
71
  }