@indigoai-us/hq-cli 5.5.1 → 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.
- package/dist/bin/hq-auth-refresh.js +0 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +2 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/auth.d.ts +0 -3
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +0 -3
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +18 -1
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +28 -9
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logout.d.ts +1 -1
- package/dist/commands/logout.d.ts.map +1 -1
- package/dist/commands/logout.js +8 -3
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/pack-install.d.ts +44 -0
- package/dist/commands/pack-install.d.ts.map +1 -0
- package/dist/commands/pack-install.js +513 -0
- package/dist/commands/pack-install.js.map +1 -0
- package/dist/commands/pkg-install.d.ts.map +1 -1
- package/dist/commands/pkg-install.js +20 -8
- package/dist/commands/pkg-install.js.map +1 -1
- package/dist/commands/pkg-list.js +4 -4
- package/dist/commands/pkg-list.js.map +1 -1
- package/dist/commands/pkg-update.js +3 -3
- package/dist/commands/pkg-update.js.map +1 -1
- package/dist/commands/secrets.d.ts +3 -0
- package/dist/commands/secrets.d.ts.map +1 -0
- package/dist/commands/secrets.js +521 -0
- package/dist/commands/secrets.js.map +1 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +10 -2
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/team-sync.d.ts.map +1 -1
- package/dist/commands/team-sync.js +6 -6
- package/dist/commands/team-sync.js.map +1 -1
- package/dist/commands/whoami.d.ts +1 -1
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +23 -7
- package/dist/commands/whoami.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/strategies/link.d.ts +2 -2
- package/dist/strategies/link.d.ts.map +1 -1
- package/dist/strategies/link.js.map +1 -1
- package/dist/strategies/merge.d.ts +2 -2
- package/dist/strategies/merge.d.ts.map +1 -1
- package/dist/strategies/merge.js.map +1 -1
- package/dist/types.d.ts +37 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/cognito-session.js +3 -3
- package/dist/utils/cognito-session.js.map +1 -1
- package/dist/utils/manifest.d.ts +13 -0
- package/dist/utils/manifest.d.ts.map +1 -1
- package/dist/utils/manifest.js +24 -1
- package/dist/utils/manifest.js.map +1 -1
- package/dist/utils/secrets-cache.d.ts +7 -0
- package/dist/utils/secrets-cache.d.ts.map +1 -0
- package/dist/utils/secrets-cache.js +134 -0
- package/dist/utils/secrets-cache.js.map +1 -0
- package/package.json +7 -4
- package/src/commands/add.ts +7 -6
- package/src/commands/auth.ts +0 -3
- package/src/commands/list.ts +20 -1
- package/src/commands/login.ts +28 -9
- package/src/commands/logout.ts +8 -3
- package/src/commands/pack-install.ts +667 -0
- package/src/commands/pkg-install.ts +34 -16
- package/src/commands/pkg-list.ts +4 -4
- package/src/commands/pkg-update.ts +3 -3
- package/src/commands/secrets.ts +670 -0
- package/src/commands/sync.ts +20 -4
- package/src/commands/team-sync.ts +7 -8
- package/src/commands/whoami.ts +22 -7
- package/src/index.ts +5 -1
- package/src/strategies/link.ts +2 -2
- package/src/strategies/merge.ts +2 -2
- package/src/types.ts +54 -4
- package/src/utils/cognito-session.ts +3 -3
- package/src/utils/manifest.test.ts +148 -0
- package/src/utils/manifest.ts +22 -1
- package/src/utils/secrets-cache.ts +129 -0
- package/tsconfig.json +2 -1
- package/src/utils/auth.ts +0 -193
- 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 {
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
469
|
+
let authToken: string;
|
|
471
470
|
try {
|
|
472
|
-
authToken = await
|
|
471
|
+
authToken = await ensureCognitoToken();
|
|
473
472
|
} catch (err) {
|
|
474
473
|
console.error(
|
|
475
474
|
chalk.red(
|
package/src/commands/whoami.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hq whoami — displays current user or 'not logged in'
|
|
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 {
|
|
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
|
|
28
|
+
const cached = loadCachedTokens();
|
|
16
29
|
|
|
17
|
-
if (!
|
|
30
|
+
if (!cached) {
|
|
18
31
|
console.log("Not logged in. Run 'hq login' to authenticate.");
|
|
19
32
|
return;
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
if (
|
|
35
|
+
if (isExpiring(cached, 0)) {
|
|
36
|
+
const who = peekIdToken(cached.idToken).email ?? 'unknown';
|
|
23
37
|
console.log(
|
|
24
|
-
chalk.yellow(`Session expired for ${
|
|
38
|
+
chalk.yellow(`Session expired for ${who}. Run 'hq login' to re-authenticate.`)
|
|
25
39
|
);
|
|
26
40
|
return;
|
|
27
41
|
}
|
|
28
42
|
|
|
29
|
-
|
|
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
|
@@ -20,6 +20,7 @@ import { registerPackageUpdateCommand } from "./commands/pkg-update.js";
|
|
|
20
20
|
import { registerPackageListCommand } from "./commands/pkg-list.js";
|
|
21
21
|
import { registerTeamSyncCommand } from "./commands/team-sync.js";
|
|
22
22
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
23
|
+
import { registerSecretsCommand } from "./commands/secrets.js";
|
|
23
24
|
|
|
24
25
|
const program = new Command();
|
|
25
26
|
|
|
@@ -64,12 +65,15 @@ registerCloudCommands(syncCmd);
|
|
|
64
65
|
// Team commands (top-level)
|
|
65
66
|
registerTeamSyncCommand(program);
|
|
66
67
|
|
|
67
|
-
// Auth commands (top-level —
|
|
68
|
+
// Auth commands (top-level — Cognito OAuth)
|
|
68
69
|
registerLoginCommand(program);
|
|
69
70
|
registerLogoutCommand(program);
|
|
70
71
|
registerWhoamiCommand(program);
|
|
71
72
|
registerAuthCommands(program);
|
|
72
73
|
|
|
74
|
+
// Secrets management (subcommand group — hq secrets set|get|list|delete|exec|generate-link|cache)
|
|
75
|
+
registerSecretsCommand(program);
|
|
76
|
+
|
|
73
77
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
74
78
|
registerOnboardCommand(program);
|
|
75
79
|
|
package/src/strategies/link.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
|
-
import type {
|
|
8
|
+
import type { LegacyModuleDefinition, SyncResult } from '../types.js';
|
|
9
9
|
|
|
10
10
|
export async function linkSync(
|
|
11
|
-
module:
|
|
11
|
+
module: LegacyModuleDefinition,
|
|
12
12
|
moduleDir: string,
|
|
13
13
|
hqRoot: string
|
|
14
14
|
): Promise<SyncResult> {
|
package/src/strategies/merge.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
24
|
+
strategy: LegacyStrategy;
|
|
18
25
|
paths: PathMapping[]; // What to sync and where
|
|
19
|
-
access?: AccessLevel;
|
|
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
|
+
}
|
|
@@ -65,13 +65,13 @@ export async function ensureCognitoToken(options: {
|
|
|
65
65
|
if (cached) {
|
|
66
66
|
try {
|
|
67
67
|
if (interactive) {
|
|
68
|
-
console.
|
|
68
|
+
console.error(chalk.dim(" Refreshing expiring HQ session..."));
|
|
69
69
|
}
|
|
70
70
|
const refreshed = await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
|
|
71
71
|
return refreshed.accessToken;
|
|
72
72
|
} catch (err) {
|
|
73
73
|
if (interactive) {
|
|
74
|
-
console.
|
|
74
|
+
console.error(
|
|
75
75
|
chalk.dim(
|
|
76
76
|
` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`,
|
|
77
77
|
),
|
|
@@ -86,7 +86,7 @@ export async function ensureCognitoToken(options: {
|
|
|
86
86
|
);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
console.
|
|
89
|
+
console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
|
|
90
90
|
const tokens = await browserLogin(DEFAULT_COGNITO);
|
|
91
91
|
return tokens.accessToken;
|
|
92
92
|
}
|
|
@@ -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
|
+
});
|
package/src/utils/manifest.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
|
|
6
|
+
const CACHE_DIR = path.join(os.homedir(), ".hq", "secrets-cache");
|
|
7
|
+
const KEY_PATH = path.join(CACHE_DIR, ".key");
|
|
8
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
const ALGORITHM = "aes-256-gcm";
|
|
10
|
+
const IV_BYTES = 12;
|
|
11
|
+
const AUTH_TAG_BYTES = 16;
|
|
12
|
+
|
|
13
|
+
function ensureCacheDir(companyUid: string): void {
|
|
14
|
+
const dir = path.join(CACHE_DIR, companyUid);
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateInputs(companyUid: string, name: string): boolean {
|
|
19
|
+
if (!companyUid || companyUid.includes("/") || companyUid.includes("..")) return false;
|
|
20
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getOrCreateKey(): Buffer {
|
|
25
|
+
try {
|
|
26
|
+
const key = fs.readFileSync(KEY_PATH);
|
|
27
|
+
if (key.length === 32) {
|
|
28
|
+
const mode = fs.statSync(KEY_PATH).mode & 0o777;
|
|
29
|
+
if (mode !== 0o600) {
|
|
30
|
+
process.stderr.write(`Warning: secrets cache key has permissions ${mode.toString(8)}, expected 600\n`);
|
|
31
|
+
}
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Key doesn't exist or is unreadable — generate a new one
|
|
36
|
+
}
|
|
37
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
38
|
+
const key = crypto.randomBytes(32);
|
|
39
|
+
const tmpPath = `${KEY_PATH}.tmp.${process.pid}`;
|
|
40
|
+
fs.writeFileSync(tmpPath, key, { mode: 0o600 });
|
|
41
|
+
fs.renameSync(tmpPath, KEY_PATH);
|
|
42
|
+
return fs.readFileSync(KEY_PATH);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readCache(companyUid: string, name: string): string | null {
|
|
46
|
+
if (!validateInputs(companyUid, name)) return null;
|
|
47
|
+
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
48
|
+
let raw: Buffer;
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(filePath);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Format: [8 bytes timestamp][12 bytes IV][16 bytes authTag][...ciphertext]
|
|
56
|
+
const headerLen = 8 + IV_BYTES + AUTH_TAG_BYTES;
|
|
57
|
+
if (raw.length < headerLen) return null;
|
|
58
|
+
|
|
59
|
+
const timestampMs = Number(raw.readBigInt64BE(0));
|
|
60
|
+
if (Date.now() - timestampMs > TTL_MS) {
|
|
61
|
+
try { fs.unlinkSync(filePath); } catch { /* ok */ }
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const iv = raw.subarray(8, 8 + IV_BYTES);
|
|
66
|
+
const authTag = raw.subarray(8 + IV_BYTES, headerLen);
|
|
67
|
+
const ciphertext = raw.subarray(headerLen);
|
|
68
|
+
|
|
69
|
+
let key: Buffer;
|
|
70
|
+
try {
|
|
71
|
+
key = getOrCreateKey();
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
78
|
+
decipher.setAuthTag(authTag);
|
|
79
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
80
|
+
return decrypted.toString("utf8");
|
|
81
|
+
} catch {
|
|
82
|
+
try { fs.unlinkSync(filePath); } catch { /* ok */ }
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeCache(companyUid: string, name: string, value: string): void {
|
|
88
|
+
try {
|
|
89
|
+
if (!validateInputs(companyUid, name)) return;
|
|
90
|
+
ensureCacheDir(companyUid);
|
|
91
|
+
const key = getOrCreateKey();
|
|
92
|
+
const iv = crypto.randomBytes(IV_BYTES);
|
|
93
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
94
|
+
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
95
|
+
const authTag = cipher.getAuthTag();
|
|
96
|
+
|
|
97
|
+
const timestamp = Buffer.alloc(8);
|
|
98
|
+
timestamp.writeBigInt64BE(BigInt(Date.now()));
|
|
99
|
+
|
|
100
|
+
const out = Buffer.concat([timestamp, iv, authTag, encrypted]);
|
|
101
|
+
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
102
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
103
|
+
fs.writeFileSync(tmpPath, out, { mode: 0o600 });
|
|
104
|
+
fs.renameSync(tmpPath, filePath);
|
|
105
|
+
} catch {
|
|
106
|
+
// Cache write failure is non-fatal
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function removeCacheEntry(companyUid: string, name: string): void {
|
|
111
|
+
if (!validateInputs(companyUid, name)) return;
|
|
112
|
+
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
113
|
+
try {
|
|
114
|
+
fs.unlinkSync(filePath);
|
|
115
|
+
} catch {
|
|
116
|
+
// Cache entry may not exist
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function clearAllCache(): { removed: number } {
|
|
121
|
+
let removed = 0;
|
|
122
|
+
try {
|
|
123
|
+
fs.rmSync(CACHE_DIR, { recursive: true, force: true });
|
|
124
|
+
removed = 1;
|
|
125
|
+
} catch {
|
|
126
|
+
// Cache dir may not exist
|
|
127
|
+
}
|
|
128
|
+
return { removed };
|
|
129
|
+
}
|