@indigoai-us/hq-cli 5.77.3 → 5.77.5
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 +22 -0
- package/dist/commands/cloud-demote.js +3 -2
- package/dist/commands/cloud-provision.js +3 -2
- package/dist/commands/meetings.js +37 -0
- package/dist/commands/pack-install.js +5 -2
- package/dist/commands/people.js +2 -8
- package/dist/commands/pkg-install.js +3 -3
- package/dist/commands/pkg-update.js +2 -2
- package/dist/commands/publish.js +2 -1
- package/dist/commands/secrets.js +25 -19
- package/dist/commands/workers.js +3 -2
- package/dist/utils/manifest.js +3 -2
- package/dist/utils/people.js +2 -8
- package/dist/utils/registry-client.js +2 -2
- package/dist/utils/registry.js +2 -1
- package/dist/utils/user-yaml-error.d.ts +9 -0
- package/dist/utils/user-yaml-error.js +25 -0
- package/package.json +1 -1
- package/src/commands/cloud-demote.ts +3 -2
- package/src/commands/cloud-provision.ts +3 -2
- package/src/commands/meetings.test.ts +56 -0
- package/src/commands/meetings.ts +44 -0
- package/src/commands/pack-install.ts +5 -2
- package/src/commands/people.test.ts +12 -3
- package/src/commands/people.ts +5 -9
- package/src/commands/pkg-install.ts +4 -4
- package/src/commands/pkg-update.ts +3 -3
- package/src/commands/publish.ts +5 -1
- package/src/commands/secrets.test.ts +55 -6
- package/src/commands/secrets.ts +30 -31
- package/src/commands/workers.ts +9 -6
- package/src/utils/manifest.test.ts +19 -0
- package/src/utils/manifest.ts +3 -2
- package/src/utils/people.ts +2 -9
- package/src/utils/registry-client.ts +2 -2
- package/src/utils/registry.ts +2 -1
- package/src/utils/user-yaml-error.test.ts +24 -0
- package/src/utils/user-yaml-error.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.77.5]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Cross-platform meeting-bot invites.** Run
|
|
10
|
+
`hq meetings invite <meeting-url>` to schedule the meeting bot from any
|
|
11
|
+
terminal. Add `--company <slug-or-uid>` to route the transcript to a company
|
|
12
|
+
vault; omitting it keeps the invite in your personal vault. (#252)
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- High-security secret refusals now clearly direct callers to `hq secrets
|
|
17
|
+
sandbox` or the HQ secret proxy, including sandbox-only denials and
|
|
18
|
+
`--script` attempts. (#253)
|
|
19
|
+
|
|
20
|
+
## [5.77.4]
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Malformed user-managed YAML now reports the affected file and line/column as
|
|
25
|
+
an expected, actionable CLI error instead of creating a Sentry exception.
|
|
26
|
+
|
|
5
27
|
## [5.77.2]
|
|
6
28
|
|
|
7
29
|
### Fixed
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import * as fs from "node:fs";
|
|
31
31
|
import * as path from "node:path";
|
|
32
32
|
import * as yaml from "js-yaml";
|
|
33
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
33
34
|
import chalk from "chalk";
|
|
34
35
|
import { ProvisionError, companyConfigPath, companyDirPath, createDefaultVaultClient, manifestPath, validateManifestAndDir, validateSlug, } from "./cloud-provision.js";
|
|
35
36
|
import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
|
|
@@ -49,7 +50,7 @@ export function flipCompanyYamlCloudOff(hqRoot, slug) {
|
|
|
49
50
|
if (!fs.existsSync(yPath))
|
|
50
51
|
return false;
|
|
51
52
|
const raw = fs.readFileSync(yPath, "utf-8");
|
|
52
|
-
const parsed =
|
|
53
|
+
const parsed = parseUserYaml(raw, yPath) ?? {};
|
|
53
54
|
if (parsed.cloud === false)
|
|
54
55
|
return false;
|
|
55
56
|
parsed.cloud = false;
|
|
@@ -72,7 +73,7 @@ export function stripManifestCloudForSlug(hqRoot, slug) {
|
|
|
72
73
|
if (!fs.existsSync(mPath))
|
|
73
74
|
return false;
|
|
74
75
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
75
|
-
const parsed =
|
|
76
|
+
const parsed = parseUserYaml(raw, mPath) ?? {};
|
|
76
77
|
const companies = parsed.companies;
|
|
77
78
|
if (!companies || !(slug in companies))
|
|
78
79
|
return false;
|
|
@@ -31,6 +31,7 @@ import chalk from "chalk";
|
|
|
31
31
|
import * as fs from "node:fs";
|
|
32
32
|
import * as path from "node:path";
|
|
33
33
|
import * as yaml from "js-yaml";
|
|
34
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
34
35
|
import { share } from "@indigoai-us/hq-cloud";
|
|
35
36
|
import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
36
37
|
/** Custom error class so the CLI runner can map to exit codes. */
|
|
@@ -105,7 +106,7 @@ export function validateManifestAndDir(hqRoot, slug) {
|
|
|
105
106
|
throw new ProvisionError(2, `companies/manifest.yaml not found at ${mPath}`);
|
|
106
107
|
}
|
|
107
108
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
108
|
-
const parsed =
|
|
109
|
+
const parsed = parseUserYaml(raw, mPath);
|
|
109
110
|
if (!parsed ||
|
|
110
111
|
typeof parsed !== "object" ||
|
|
111
112
|
!("companies" in parsed) ||
|
|
@@ -225,7 +226,7 @@ export function ensureManifestEntryForProvision(hqRoot, slug) {
|
|
|
225
226
|
export function patchManifest(hqRoot, slug, cloudUid, bucketName) {
|
|
226
227
|
const mPath = manifestPath(hqRoot);
|
|
227
228
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
228
|
-
const parsed =
|
|
229
|
+
const parsed = parseUserYaml(raw, mPath) ?? { companies: {} };
|
|
229
230
|
if (!parsed.companies)
|
|
230
231
|
parsed.companies = {};
|
|
231
232
|
const existing = parsed.companies[slug];
|
|
@@ -208,6 +208,43 @@ export function registerMeetingsCommand(program) {
|
|
|
208
208
|
process.exit(1);
|
|
209
209
|
}
|
|
210
210
|
});
|
|
211
|
+
// ── hq meetings invite <meeting-url> ──────────────────────────────
|
|
212
|
+
meetings
|
|
213
|
+
.command("invite <meetingUrl>")
|
|
214
|
+
.description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
|
|
215
|
+
.action(async (meetingUrl) => {
|
|
216
|
+
try {
|
|
217
|
+
const token = await ensureCognitoToken();
|
|
218
|
+
const companySlug = meetings.opts().company;
|
|
219
|
+
const query = {};
|
|
220
|
+
if (companySlug)
|
|
221
|
+
query.companyId = await getCompanyUid(token, companySlug);
|
|
222
|
+
const res = await vaultApiFetch({
|
|
223
|
+
token,
|
|
224
|
+
method: "POST",
|
|
225
|
+
path: "/v1/bot/invite",
|
|
226
|
+
query,
|
|
227
|
+
body: { meetingUrl },
|
|
228
|
+
});
|
|
229
|
+
if (!res.ok)
|
|
230
|
+
await handleApiError(res);
|
|
231
|
+
const data = (await res.json());
|
|
232
|
+
if (meetings.opts().json) {
|
|
233
|
+
console.log(JSON.stringify(data, null, 2));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
console.log(chalk.green(`\n✓ Meeting bot invited to ${chalk.cyan(data.meetingUrl ?? meetingUrl)}.`));
|
|
237
|
+
if (data.botId)
|
|
238
|
+
console.log(chalk.dim(` Bot: ${data.botId}`));
|
|
239
|
+
if (data.status)
|
|
240
|
+
console.log(chalk.dim(` Status: ${data.status}`));
|
|
241
|
+
console.log();
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
211
248
|
// ── hq meetings get <id> ──────────────────────────────────────────
|
|
212
249
|
meetings
|
|
213
250
|
.command("get <meetingId>")
|
|
@@ -37,7 +37,7 @@ import * as fs from 'fs';
|
|
|
37
37
|
import * as os from 'os';
|
|
38
38
|
import * as path from 'path';
|
|
39
39
|
import * as readline from 'readline';
|
|
40
|
-
import
|
|
40
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
41
41
|
import { createHash, createPublicKey, verify as cryptoVerify, } from 'node:crypto';
|
|
42
42
|
import { execFileSync, spawnSync } from 'child_process';
|
|
43
43
|
import chalk from 'chalk';
|
|
@@ -940,9 +940,12 @@ export function validateManifest(payloadDir, hqVersion) {
|
|
|
940
940
|
}
|
|
941
941
|
let parsed;
|
|
942
942
|
try {
|
|
943
|
-
parsed =
|
|
943
|
+
parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
|
|
944
944
|
}
|
|
945
945
|
catch (e) {
|
|
946
|
+
if (e instanceof Error && e.expected === true) {
|
|
947
|
+
throw e;
|
|
948
|
+
}
|
|
946
949
|
throw new Error(`package.yaml invalid YAML: ${e.message}`);
|
|
947
950
|
}
|
|
948
951
|
const m = parsed;
|
package/dist/commands/people.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as fs from "fs";
|
|
15
15
|
import { Option } from "commander";
|
|
16
16
|
import chalk from "chalk";
|
|
17
|
-
import
|
|
17
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
18
18
|
import { findHqRoot } from "../utils/manifest.js";
|
|
19
19
|
import { manifestPath } from "./cloud-provision.js";
|
|
20
20
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
@@ -96,13 +96,7 @@ export function resolveCompanySlug(hqRoot, explicit) {
|
|
|
96
96
|
throw new Error("Could not determine the active company — companies/manifest.yaml not found. " +
|
|
97
97
|
"Re-run with --company <slug>.");
|
|
98
98
|
}
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
manifest = yaml.load(fs.readFileSync(mPath, "utf-8"));
|
|
102
|
-
}
|
|
103
|
-
catch (err) {
|
|
104
|
-
throw new Error(`companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`);
|
|
105
|
-
}
|
|
99
|
+
const manifest = parseUserYaml(fs.readFileSync(mPath, "utf-8"), mPath);
|
|
106
100
|
const slugs = activeCompanySlugs(manifest ?? {});
|
|
107
101
|
if (slugs.length === 1)
|
|
108
102
|
return slugs[0];
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import * as fs from 'fs';
|
|
16
16
|
import * as os from 'os';
|
|
17
17
|
import * as path from 'path';
|
|
18
|
-
import
|
|
18
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
19
19
|
import { execSync } from 'child_process';
|
|
20
20
|
import chalk from 'chalk';
|
|
21
21
|
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
@@ -117,7 +117,7 @@ async function installPackage(slug, company) {
|
|
|
117
117
|
const packageYamlPath = path.resolve(installDir, 'package.yaml');
|
|
118
118
|
if (fs.existsSync(packageYamlPath)) {
|
|
119
119
|
const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
|
|
120
|
-
const pkgMeta =
|
|
120
|
+
const pkgMeta = parseUserYaml(pkgContent, packageYamlPath);
|
|
121
121
|
if (pkgMeta?.slug && pkgMeta.slug !== slug) {
|
|
122
122
|
// Mismatch — clean up and abort
|
|
123
123
|
fs.rmSync(installDir, { recursive: true, force: true });
|
|
@@ -137,7 +137,7 @@ async function installPackage(slug, company) {
|
|
|
137
137
|
const packageYaml = path.resolve(installDir, 'package.yaml');
|
|
138
138
|
if (fs.existsSync(packageYaml)) {
|
|
139
139
|
const content = fs.readFileSync(packageYaml, 'utf-8');
|
|
140
|
-
const meta =
|
|
140
|
+
const meta = parseUserYaml(content, packageYaml);
|
|
141
141
|
if (meta?.version)
|
|
142
142
|
version = meta.version;
|
|
143
143
|
if (meta?.name)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as os from 'os';
|
|
9
9
|
import * as path from 'path';
|
|
10
|
-
import
|
|
10
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
@@ -92,7 +92,7 @@ async function updatePackages(slug) {
|
|
|
92
92
|
const packageYamlPath = path.resolve(installDir, 'package.yaml');
|
|
93
93
|
if (fs.existsSync(packageYamlPath)) {
|
|
94
94
|
const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
|
|
95
|
-
const pkgMeta =
|
|
95
|
+
const pkgMeta = parseUserYaml(pkgContent, packageYamlPath);
|
|
96
96
|
if (pkgMeta?.slug && pkgMeta.slug !== entry.slug) {
|
|
97
97
|
fs.rmSync(installDir, { recursive: true, force: true });
|
|
98
98
|
throw new Error(`Package slug mismatch: expected "${entry.slug}", got "${pkgMeta.slug}"`);
|
package/dist/commands/publish.js
CHANGED
|
@@ -31,6 +31,7 @@ import * as fs from 'fs';
|
|
|
31
31
|
import * as os from 'os';
|
|
32
32
|
import * as path from 'path';
|
|
33
33
|
import * as yaml from 'js-yaml';
|
|
34
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
34
35
|
import { execFileSync } from 'child_process';
|
|
35
36
|
import chalk from 'chalk';
|
|
36
37
|
import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
|
|
@@ -91,7 +92,7 @@ export function stampAuthorYaml(manifest, author) {
|
|
|
91
92
|
/** Read package.yaml, stamp author, write it back. Returns the stamped author. */
|
|
92
93
|
export function stampAuthorIntoPackage(payloadDir, author) {
|
|
93
94
|
const manifestPath = path.join(payloadDir, 'package.yaml');
|
|
94
|
-
const parsed =
|
|
95
|
+
const parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
|
|
95
96
|
fs.writeFileSync(manifestPath, stampAuthorYaml(parsed, author));
|
|
96
97
|
}
|
|
97
98
|
// ---------------------------------------------------------------------------
|
package/dist/commands/secrets.js
CHANGED
|
@@ -131,6 +131,12 @@ function promptSecretInteractively() {
|
|
|
131
131
|
// server (the legacy per-key GET path had no such cap).
|
|
132
132
|
const MAX_BATCH_NAMES = 100;
|
|
133
133
|
const SECRET_LOAD_TIMEOUT_MS = 30_000;
|
|
134
|
+
function highSecuritySandboxOnlyMessage(secretName) {
|
|
135
|
+
const sandboxExample = secretName
|
|
136
|
+
? `hq secrets sandbox --only ${secretName} -- <command>`
|
|
137
|
+
: "hq secrets sandbox --only <secret> -- <command>";
|
|
138
|
+
return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
|
|
139
|
+
}
|
|
134
140
|
function parseSecretAclPrincipal(principal) {
|
|
135
141
|
const p = principal.trim();
|
|
136
142
|
if (p === "@all") {
|
|
@@ -435,10 +441,13 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
435
441
|
if (!res.ok) {
|
|
436
442
|
const body = (await res.json().catch(() => ({})));
|
|
437
443
|
const message = extractApiMessage(body, res.statusText);
|
|
438
|
-
// High-security
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
444
|
+
// High-security refusal surfaced at the batch level (rather than
|
|
445
|
+
// per-name): point the caller at the sandbox/proxy and never leak
|
|
446
|
+
// plaintext. A local script attestation is not an override.
|
|
447
|
+
if (body.code === "high_security_denied" ||
|
|
448
|
+
body.code === "high_security_sandbox_only" ||
|
|
449
|
+
body.highSecurity === true) {
|
|
450
|
+
throw new Error(highSecuritySandboxOnlyMessage());
|
|
442
451
|
}
|
|
443
452
|
if (res.status >= 400 &&
|
|
444
453
|
res.status < 500 &&
|
|
@@ -508,14 +517,13 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
508
517
|
continue;
|
|
509
518
|
removeCacheEntry(companyUid, key);
|
|
510
519
|
const err = errorsByName.get(key);
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
firstFailure ??= new Error(`Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`);
|
|
520
|
+
// Every caller of loadRevealedSecrets injects or prints the plaintext
|
|
521
|
+
// locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
|
|
522
|
+
// high-security denial shapes therefore receive the same explicit
|
|
523
|
+
// sandbox/proxy guidance, even if the caller supplied `--script`.
|
|
524
|
+
if (err?.code === "high_security_denied" ||
|
|
525
|
+
err?.code === "high_security_sandbox_only") {
|
|
526
|
+
firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
|
|
519
527
|
continue;
|
|
520
528
|
}
|
|
521
529
|
const reason = err?.code === "not_found"
|
|
@@ -655,7 +663,7 @@ export function registerSecretsCommand(program) {
|
|
|
655
663
|
removeCacheEntry(companyUid, name);
|
|
656
664
|
console.log(chalk.green(formatSecretSaved(name, scopeLabel)));
|
|
657
665
|
if (opts.highSecurity) {
|
|
658
|
-
console.log(chalk.dim(` High-security: destination pinned to ${destinations?.[0]}. This value can
|
|
666
|
+
console.log(chalk.dim(` High-security: destination pinned to ${destinations?.[0]}. This value can run only in the sandbox (hq secrets sandbox) or through the HQ secret proxy; local injection and script locks are unsupported.`));
|
|
659
667
|
}
|
|
660
668
|
}
|
|
661
669
|
catch (err) {
|
|
@@ -677,13 +685,11 @@ export function registerSecretsCommand(program) {
|
|
|
677
685
|
});
|
|
678
686
|
if (!res.ok) {
|
|
679
687
|
const body = (await res.json().catch(() => ({})));
|
|
680
|
-
// High-security
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
// 4xx — the value can ONLY be used through the server-side proxy.
|
|
688
|
+
// High-security secrets cannot be revealed locally. Surface the
|
|
689
|
+
// hosted execution path rather than a raw 4xx; `--script` never
|
|
690
|
+
// overrides this boundary.
|
|
684
691
|
if (res.status === 403 && body.highSecurity === true) {
|
|
685
|
-
console.error(chalk.red(
|
|
686
|
-
console.error(chalk.dim(" It can only be used through the HQ secret proxy, which keeps the plaintext server-side."));
|
|
692
|
+
console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
|
|
687
693
|
process.exit(1);
|
|
688
694
|
}
|
|
689
695
|
console.error(chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`));
|
package/dist/commands/workers.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as yaml from "js-yaml";
|
|
5
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
5
6
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
6
7
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
7
8
|
import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
|
|
@@ -11,7 +12,7 @@ export function readWorkerRegistry(hqRoot) {
|
|
|
11
12
|
const p = path.join(hqRoot, "core/workers/registry.yaml");
|
|
12
13
|
if (!fs.existsSync(p))
|
|
13
14
|
return [];
|
|
14
|
-
const doc =
|
|
15
|
+
const doc = parseUserYaml(fs.readFileSync(p, "utf8"), p);
|
|
15
16
|
return doc?.workers ?? [];
|
|
16
17
|
}
|
|
17
18
|
/**
|
|
@@ -87,7 +88,7 @@ export function writeGrantSidecar(hqRoot, workerPath, principalLabel) {
|
|
|
87
88
|
const sidecar = path.join(dir, ".grants.yaml");
|
|
88
89
|
let grants = [];
|
|
89
90
|
if (fs.existsSync(sidecar)) {
|
|
90
|
-
const doc =
|
|
91
|
+
const doc = parseUserYaml(fs.readFileSync(sidecar, "utf8"), sidecar);
|
|
91
92
|
if (Array.isArray(doc?.grants))
|
|
92
93
|
grants = doc.grants.filter((g) => typeof g === "string");
|
|
93
94
|
}
|
package/dist/utils/manifest.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
|
+
import { parseUserYaml } from './user-yaml-error.js';
|
|
4
5
|
const MANIFEST_FILE = 'modules.yaml';
|
|
5
6
|
const LOCK_FILE = 'modules.lock';
|
|
6
7
|
const STATE_FILE = '.hq-sync-state.json';
|
|
@@ -51,7 +52,7 @@ export function readManifest(hqRoot) {
|
|
|
51
52
|
return null;
|
|
52
53
|
}
|
|
53
54
|
const content = fs.readFileSync(manifestPath, 'utf-8');
|
|
54
|
-
return
|
|
55
|
+
return parseUserYaml(content, manifestPath);
|
|
55
56
|
}
|
|
56
57
|
export function writeManifest(hqRoot, manifest) {
|
|
57
58
|
const manifestPath = getManifestPath(hqRoot);
|
|
@@ -68,7 +69,7 @@ export function readLock(hqRoot) {
|
|
|
68
69
|
return null;
|
|
69
70
|
}
|
|
70
71
|
const content = fs.readFileSync(lockPath, 'utf-8');
|
|
71
|
-
return
|
|
72
|
+
return parseUserYaml(content, lockPath);
|
|
72
73
|
}
|
|
73
74
|
export function writeLock(hqRoot, lock) {
|
|
74
75
|
const lockPath = getLockPath(hqRoot);
|
package/dist/utils/people.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as fs from "fs";
|
|
18
18
|
import * as path from "path";
|
|
19
|
-
import
|
|
19
|
+
import { parseUserYaml } from "./user-yaml-error.js";
|
|
20
20
|
/**
|
|
21
21
|
* Company slugs map directly onto a filesystem path segment, so we validate
|
|
22
22
|
* them before joining to keep a malicious or fat-fingered `--company` value
|
|
@@ -45,13 +45,7 @@ export function companyPeopleDir(hqRoot, companySlug) {
|
|
|
45
45
|
* surfaced as a half-row. Exported for unit testing.
|
|
46
46
|
*/
|
|
47
47
|
export function parsePersonMeta(raw, slug, source) {
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
doc = yaml.load(raw);
|
|
51
|
-
}
|
|
52
|
-
catch (err) {
|
|
53
|
-
throw new Error(`Failed to parse ${source}: ${err instanceof Error ? err.message : String(err)}`);
|
|
54
|
-
}
|
|
48
|
+
const doc = parseUserYaml(raw, source);
|
|
55
49
|
if (!doc || typeof doc !== "object")
|
|
56
50
|
return null;
|
|
57
51
|
const d = doc;
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import * as yaml from 'js-yaml';
|
|
10
9
|
import { resolveDefaultHqRoot } from './cognito-session.js';
|
|
10
|
+
import { parseUserYaml } from './user-yaml-error.js';
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
// URL helper (unchanged from US-004)
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
@@ -23,7 +23,7 @@ export function getRegistryUrl() {
|
|
|
23
23
|
throw new Error(`No packages/sources.yaml found at ${sourcesPath}. Is your HQ packages directory set up?`);
|
|
24
24
|
}
|
|
25
25
|
const content = fs.readFileSync(sourcesPath, 'utf-8');
|
|
26
|
-
const parsed =
|
|
26
|
+
const parsed = parseUserYaml(content, sourcesPath);
|
|
27
27
|
if (!parsed?.sources?.length) {
|
|
28
28
|
throw new Error('No sources defined in packages/sources.yaml');
|
|
29
29
|
}
|
package/dist/utils/registry.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import * as fs from 'fs';
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import * as yaml from 'js-yaml';
|
|
7
|
+
import { parseUserYaml } from './user-yaml-error.js';
|
|
7
8
|
function registryPath(hqRoot) {
|
|
8
9
|
return path.resolve(hqRoot, 'packages', 'registry.yaml');
|
|
9
10
|
}
|
|
@@ -17,7 +18,7 @@ export function readRegistry(hqRoot) {
|
|
|
17
18
|
return [];
|
|
18
19
|
}
|
|
19
20
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
20
|
-
const parsed =
|
|
21
|
+
const parsed = parseUserYaml(content, filePath);
|
|
21
22
|
return parsed?.packages ?? [];
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse YAML supplied from an on-disk HQ file.
|
|
3
|
+
*
|
|
4
|
+
* YAML syntax is user-correctable content, not an hq-cli defect. js-yaml's
|
|
5
|
+
* default error includes a source excerpt, which can inadvertently echo
|
|
6
|
+
* secrets, so replace it with a concise, actionable location instead.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseUserYaml<T>(content: string, filePath: string): T;
|
|
9
|
+
//# sourceMappingURL=user-yaml-error.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as yaml from "js-yaml";
|
|
2
|
+
/**
|
|
3
|
+
* Parse YAML supplied from an on-disk HQ file.
|
|
4
|
+
*
|
|
5
|
+
* YAML syntax is user-correctable content, not an hq-cli defect. js-yaml's
|
|
6
|
+
* default error includes a source excerpt, which can inadvertently echo
|
|
7
|
+
* secrets, so replace it with a concise, actionable location instead.
|
|
8
|
+
*/
|
|
9
|
+
export function parseUserYaml(content, filePath) {
|
|
10
|
+
try {
|
|
11
|
+
return yaml.load(content, { filename: filePath });
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (!(err instanceof yaml.YAMLException))
|
|
15
|
+
throw err;
|
|
16
|
+
const line = err.mark ? err.mark.line + 1 : undefined;
|
|
17
|
+
const column = err.mark ? err.mark.column + 1 : undefined;
|
|
18
|
+
const location = line !== undefined && column !== undefined
|
|
19
|
+
? `:${line}:${column}`
|
|
20
|
+
: "";
|
|
21
|
+
throw Object.assign(new Error(`Invalid YAML in ${filePath}${location}: ${err.reason}. ` +
|
|
22
|
+
"Fix the indentation or syntax and try again."), { expected: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=user-yaml-error.js.map
|
package/package.json
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import * as fs from "node:fs";
|
|
32
32
|
import * as path from "node:path";
|
|
33
33
|
import * as yaml from "js-yaml";
|
|
34
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
34
35
|
import { Command } from "commander";
|
|
35
36
|
import chalk from "chalk";
|
|
36
37
|
|
|
@@ -98,7 +99,7 @@ export function flipCompanyYamlCloudOff(hqRoot: string, slug: string): boolean {
|
|
|
98
99
|
const yPath = path.join(companyDirPath(hqRoot, slug), "company.yaml");
|
|
99
100
|
if (!fs.existsSync(yPath)) return false;
|
|
100
101
|
const raw = fs.readFileSync(yPath, "utf-8");
|
|
101
|
-
const parsed =
|
|
102
|
+
const parsed = parseUserYaml<Record<string, unknown> | null>(raw, yPath) ?? {};
|
|
102
103
|
if (parsed.cloud === false) return false;
|
|
103
104
|
parsed.cloud = false;
|
|
104
105
|
const dump = yaml.dump(parsed, { lineWidth: -1, noRefs: true });
|
|
@@ -120,7 +121,7 @@ export function stripManifestCloudForSlug(hqRoot: string, slug: string): boolean
|
|
|
120
121
|
const mPath = manifestPath(hqRoot);
|
|
121
122
|
if (!fs.existsSync(mPath)) return false;
|
|
122
123
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
123
|
-
const parsed =
|
|
124
|
+
const parsed = parseUserYaml<{ companies?: Record<string, unknown> } | null>(raw, mPath) ?? {};
|
|
124
125
|
const companies = parsed.companies;
|
|
125
126
|
if (!companies || !(slug in companies)) return false;
|
|
126
127
|
const entry = companies[slug];
|
|
@@ -33,6 +33,7 @@ import chalk from "chalk";
|
|
|
33
33
|
import * as fs from "node:fs";
|
|
34
34
|
import * as path from "node:path";
|
|
35
35
|
import * as yaml from "js-yaml";
|
|
36
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
36
37
|
|
|
37
38
|
import { share } from "@indigoai-us/hq-cloud";
|
|
38
39
|
|
|
@@ -255,7 +256,7 @@ export function validateManifestAndDir(
|
|
|
255
256
|
);
|
|
256
257
|
}
|
|
257
258
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
258
|
-
const parsed =
|
|
259
|
+
const parsed = parseUserYaml<unknown>(raw, mPath);
|
|
259
260
|
if (
|
|
260
261
|
!parsed ||
|
|
261
262
|
typeof parsed !== "object" ||
|
|
@@ -411,7 +412,7 @@ export function patchManifest(
|
|
|
411
412
|
): boolean {
|
|
412
413
|
const mPath = manifestPath(hqRoot);
|
|
413
414
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
414
|
-
const parsed = (
|
|
415
|
+
const parsed = parseUserYaml<ManifestDoc | null>(raw, mPath) ?? { companies: {} };
|
|
415
416
|
if (!parsed.companies) parsed.companies = {};
|
|
416
417
|
const existing = parsed.companies[slug];
|
|
417
418
|
// Preserve null / object / unknown — promote null → {} so we can write keys.
|
|
@@ -32,6 +32,7 @@ vi.mock("../utils/vault-api.js", async (importOriginal) => {
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
import { registerMeetingsCommand } from "./meetings.js";
|
|
35
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
35
36
|
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
36
37
|
|
|
37
38
|
let logSpy: MockInstance<typeof console.log>;
|
|
@@ -145,6 +146,61 @@ describe("meetings get — short id resolution", () => {
|
|
|
145
146
|
});
|
|
146
147
|
});
|
|
147
148
|
|
|
149
|
+
describe("meetings invite", () => {
|
|
150
|
+
it("authenticates and posts the meeting URL with the optional company routing", async () => {
|
|
151
|
+
const meetingUrl = "https://meet.google.com/abc-defg-hij";
|
|
152
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
153
|
+
jsonRes({
|
|
154
|
+
botId: "bot_123",
|
|
155
|
+
meetingUrl,
|
|
156
|
+
platform: "google_meet",
|
|
157
|
+
status: "scheduled",
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const program = buildProgram();
|
|
162
|
+
await program.parseAsync([
|
|
163
|
+
"node",
|
|
164
|
+
"hq",
|
|
165
|
+
"meetings",
|
|
166
|
+
"--company",
|
|
167
|
+
"indigo",
|
|
168
|
+
"invite",
|
|
169
|
+
meetingUrl,
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
expect(ensureCognitoToken).toHaveBeenCalledOnce();
|
|
173
|
+
expect(getCompanyUid).toHaveBeenCalledWith("test-token", "indigo");
|
|
174
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
175
|
+
token: "test-token",
|
|
176
|
+
method: "POST",
|
|
177
|
+
path: "/v1/bot/invite",
|
|
178
|
+
query: { companyId: "cmp_indigo" },
|
|
179
|
+
body: { meetingUrl },
|
|
180
|
+
});
|
|
181
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Meeting bot invited"));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("omits company routing so the backend uses the caller's personal vault", async () => {
|
|
185
|
+
const meetingUrl = "https://zoom.us/j/123456789";
|
|
186
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
187
|
+
jsonRes({ botId: "bot_123", meetingUrl, status: "scheduled" }),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const program = buildProgram();
|
|
191
|
+
await program.parseAsync(["node", "hq", "meetings", "invite", meetingUrl]);
|
|
192
|
+
|
|
193
|
+
expect(getCompanyUid).not.toHaveBeenCalled();
|
|
194
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
195
|
+
token: "test-token",
|
|
196
|
+
method: "POST",
|
|
197
|
+
path: "/v1/bot/invite",
|
|
198
|
+
query: {},
|
|
199
|
+
body: { meetingUrl },
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
148
204
|
describe("meetings set-company", () => {
|
|
149
205
|
it("POSTs the resolved company id and applies to the recurring series by default", async () => {
|
|
150
206
|
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
package/src/commands/meetings.ts
CHANGED
|
@@ -101,6 +101,13 @@ interface MeetingDocument {
|
|
|
101
101
|
notes: MeetingNotes | null;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
interface MeetingBotInviteResponse {
|
|
105
|
+
botId?: string;
|
|
106
|
+
meetingUrl?: string;
|
|
107
|
+
platform?: string;
|
|
108
|
+
status?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
function formatDuration(seconds: number): string {
|
|
105
112
|
const h = Math.floor(seconds / 3600);
|
|
106
113
|
const m = Math.floor((seconds % 3600) / 60);
|
|
@@ -344,6 +351,43 @@ export function registerMeetingsCommand(program: Command): void {
|
|
|
344
351
|
}
|
|
345
352
|
});
|
|
346
353
|
|
|
354
|
+
// ── hq meetings invite <meeting-url> ──────────────────────────────
|
|
355
|
+
|
|
356
|
+
meetings
|
|
357
|
+
.command("invite <meetingUrl>")
|
|
358
|
+
.description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
|
|
359
|
+
.action(async (meetingUrl: string) => {
|
|
360
|
+
try {
|
|
361
|
+
const token = await ensureCognitoToken();
|
|
362
|
+
const companySlug = meetings.opts().company as string | undefined;
|
|
363
|
+
const query: Record<string, string> = {};
|
|
364
|
+
if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
|
|
365
|
+
|
|
366
|
+
const res = await vaultApiFetch({
|
|
367
|
+
token,
|
|
368
|
+
method: "POST",
|
|
369
|
+
path: "/v1/bot/invite",
|
|
370
|
+
query,
|
|
371
|
+
body: { meetingUrl },
|
|
372
|
+
});
|
|
373
|
+
if (!res.ok) await handleApiError(res);
|
|
374
|
+
const data = (await res.json()) as MeetingBotInviteResponse;
|
|
375
|
+
|
|
376
|
+
if (meetings.opts().json) {
|
|
377
|
+
console.log(JSON.stringify(data, null, 2));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
console.log(chalk.green(`\n✓ Meeting bot invited to ${chalk.cyan(data.meetingUrl ?? meetingUrl)}.`));
|
|
382
|
+
if (data.botId) console.log(chalk.dim(` Bot: ${data.botId}`));
|
|
383
|
+
if (data.status) console.log(chalk.dim(` Status: ${data.status}`));
|
|
384
|
+
console.log();
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
347
391
|
// ── hq meetings get <id> ──────────────────────────────────────────
|
|
348
392
|
|
|
349
393
|
meetings
|
|
@@ -38,7 +38,7 @@ import * as fs from 'fs';
|
|
|
38
38
|
import * as os from 'os';
|
|
39
39
|
import * as path from 'path';
|
|
40
40
|
import * as readline from 'readline';
|
|
41
|
-
import
|
|
41
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
42
42
|
import {
|
|
43
43
|
createHash,
|
|
44
44
|
createPublicKey,
|
|
@@ -1261,8 +1261,11 @@ export function validateManifest(
|
|
|
1261
1261
|
}
|
|
1262
1262
|
let parsed: unknown;
|
|
1263
1263
|
try {
|
|
1264
|
-
parsed =
|
|
1264
|
+
parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
|
|
1265
1265
|
} catch (e) {
|
|
1266
|
+
if (e instanceof Error && (e as { expected?: unknown }).expected === true) {
|
|
1267
|
+
throw e;
|
|
1268
|
+
}
|
|
1266
1269
|
throw new Error(`package.yaml invalid YAML: ${(e as Error).message}`);
|
|
1267
1270
|
}
|
|
1268
1271
|
const m = parsed as Partial<PackManifest> & Record<string, unknown>;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveNameToEmail,
|
|
20
20
|
type PersonRecord,
|
|
21
21
|
} from "../utils/people.js";
|
|
22
|
+
import { isExpectedUserError } from "../utils/expected-cli-error.js";
|
|
22
23
|
import {
|
|
23
24
|
activeMemberToPersonRecord,
|
|
24
25
|
mergePeople,
|
|
@@ -138,9 +139,17 @@ describe("parsePersonMeta", () => {
|
|
|
138
139
|
});
|
|
139
140
|
|
|
140
141
|
it("throws on unparseable yaml", () => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
const error = (() => {
|
|
143
|
+
try {
|
|
144
|
+
parsePersonMeta("name: [unterminated\n", "x", "/x");
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return err;
|
|
147
|
+
}
|
|
148
|
+
throw new Error("expected malformed YAML to throw");
|
|
149
|
+
})();
|
|
150
|
+
|
|
151
|
+
expect(isExpectedUserError(error)).toBe(true);
|
|
152
|
+
expect((error as Error).message).toContain("Invalid YAML in /x:2:1");
|
|
144
153
|
});
|
|
145
154
|
});
|
|
146
155
|
|
package/src/commands/people.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import * as fs from "fs";
|
|
16
16
|
import { Command, Option } from "commander";
|
|
17
17
|
import chalk from "chalk";
|
|
18
|
-
import
|
|
18
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
19
19
|
import { findHqRoot } from "../utils/manifest.js";
|
|
20
20
|
import { manifestPath, type ManifestDoc } from "./cloud-provision.js";
|
|
21
21
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
@@ -146,14 +146,10 @@ export function resolveCompanySlug(
|
|
|
146
146
|
"Re-run with --company <slug>.",
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
throw new Error(
|
|
154
|
-
`companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
149
|
+
const manifest = parseUserYaml<ManifestDoc>(
|
|
150
|
+
fs.readFileSync(mPath, "utf-8"),
|
|
151
|
+
mPath,
|
|
152
|
+
);
|
|
157
153
|
const slugs = activeCompanySlugs(manifest ?? {});
|
|
158
154
|
if (slugs.length === 1) return slugs[0];
|
|
159
155
|
if (slugs.length === 0) {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import * as os from 'os';
|
|
18
18
|
import * as path from 'path';
|
|
19
|
-
import
|
|
19
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
20
20
|
import { execSync } from 'child_process';
|
|
21
21
|
import { Command } from 'commander';
|
|
22
22
|
import chalk from 'chalk';
|
|
@@ -157,7 +157,7 @@ async function installPackage(
|
|
|
157
157
|
const packageYamlPath = path.resolve(installDir, 'package.yaml');
|
|
158
158
|
if (fs.existsSync(packageYamlPath)) {
|
|
159
159
|
const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
|
|
160
|
-
const pkgMeta =
|
|
160
|
+
const pkgMeta = parseUserYaml<{ slug?: string } | null>(pkgContent, packageYamlPath);
|
|
161
161
|
if (pkgMeta?.slug && pkgMeta.slug !== slug) {
|
|
162
162
|
// Mismatch — clean up and abort
|
|
163
163
|
fs.rmSync(installDir, { recursive: true, force: true });
|
|
@@ -179,10 +179,10 @@ async function installPackage(
|
|
|
179
179
|
const packageYaml = path.resolve(installDir, 'package.yaml');
|
|
180
180
|
if (fs.existsSync(packageYaml)) {
|
|
181
181
|
const content = fs.readFileSync(packageYaml, 'utf-8');
|
|
182
|
-
const meta =
|
|
182
|
+
const meta = parseUserYaml<{
|
|
183
183
|
version?: string;
|
|
184
184
|
name?: string;
|
|
185
|
-
} | null;
|
|
185
|
+
} | null>(content, packageYaml);
|
|
186
186
|
if (meta?.version) version = meta.version;
|
|
187
187
|
if (meta?.name) name = meta.name;
|
|
188
188
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as os from 'os';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import
|
|
11
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
12
12
|
import { execSync } from 'child_process';
|
|
13
13
|
import { Command } from 'commander';
|
|
14
14
|
import chalk from 'chalk';
|
|
@@ -146,9 +146,9 @@ async function updatePackages(slug?: string): Promise<void> {
|
|
|
146
146
|
const packageYamlPath = path.resolve(installDir, 'package.yaml');
|
|
147
147
|
if (fs.existsSync(packageYamlPath)) {
|
|
148
148
|
const pkgContent = fs.readFileSync(packageYamlPath, 'utf-8');
|
|
149
|
-
const pkgMeta =
|
|
149
|
+
const pkgMeta = parseUserYaml<{
|
|
150
150
|
slug?: string;
|
|
151
|
-
} | null;
|
|
151
|
+
} | null>(pkgContent, packageYamlPath);
|
|
152
152
|
if (pkgMeta?.slug && pkgMeta.slug !== entry.slug) {
|
|
153
153
|
fs.rmSync(installDir, { recursive: true, force: true });
|
|
154
154
|
throw new Error(
|
package/src/commands/publish.ts
CHANGED
|
@@ -32,6 +32,7 @@ import * as fs from 'fs';
|
|
|
32
32
|
import * as os from 'os';
|
|
33
33
|
import * as path from 'path';
|
|
34
34
|
import * as yaml from 'js-yaml';
|
|
35
|
+
import { parseUserYaml } from '../utils/user-yaml-error.js';
|
|
35
36
|
import { execFileSync } from 'child_process';
|
|
36
37
|
import { Command } from 'commander';
|
|
37
38
|
import chalk from 'chalk';
|
|
@@ -124,7 +125,10 @@ export function stampAuthorIntoPackage(
|
|
|
124
125
|
author: ResolvedAuthor,
|
|
125
126
|
): void {
|
|
126
127
|
const manifestPath = path.join(payloadDir, 'package.yaml');
|
|
127
|
-
const parsed =
|
|
128
|
+
const parsed = parseUserYaml<Record<string, unknown>>(
|
|
129
|
+
fs.readFileSync(manifestPath, 'utf-8'),
|
|
130
|
+
manifestPath,
|
|
131
|
+
);
|
|
128
132
|
fs.writeFileSync(manifestPath, stampAuthorYaml(parsed, author));
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -138,6 +138,29 @@ describe("secrets sandbox", () => {
|
|
|
138
138
|
expect(channel).toBe("sandbox");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
it("runs a high-security secret in the sandbox without a local script attestation", async () => {
|
|
142
|
+
const program = buildProgram();
|
|
143
|
+
await program.parseAsync([
|
|
144
|
+
"node",
|
|
145
|
+
"hq",
|
|
146
|
+
"secrets",
|
|
147
|
+
"sandbox",
|
|
148
|
+
"--only",
|
|
149
|
+
"HIGH_SECURITY_KEY",
|
|
150
|
+
"--",
|
|
151
|
+
"env",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
expect(startJobSpy).toHaveBeenCalledWith("test-token", {
|
|
155
|
+
companyUid: "prs_alice",
|
|
156
|
+
secretNames: ["HIGH_SECURITY_KEY"],
|
|
157
|
+
command: "env",
|
|
158
|
+
});
|
|
159
|
+
expect(vaultApiFetch).not.toHaveBeenCalledWith(
|
|
160
|
+
expect.objectContaining({ path: expect.stringContaining("/load") }),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
141
164
|
it("parses --company, --only, and joins args after -- into a command", async () => {
|
|
142
165
|
const program = buildProgram();
|
|
143
166
|
await program.parseAsync([
|
|
@@ -534,9 +557,9 @@ describe("secrets exists (HQ-4H HEAD probe)", () => {
|
|
|
534
557
|
|
|
535
558
|
// US-003 (secrets-server-proxy): the CLI surfaces the SERVER's refusal of a
|
|
536
559
|
// high-security ("nuclear") secret on the local-injection path as a clear,
|
|
537
|
-
// actionable error
|
|
538
|
-
//
|
|
539
|
-
//
|
|
560
|
+
// actionable sandbox/proxy error — and never prints the value. The server-side
|
|
561
|
+
// deny is the real control (it returns 403 + highSecurity:true and NO
|
|
562
|
+
// plaintext); these tests assert the CLI's surfacing behavior.
|
|
540
563
|
describe("US-003 — CLI refuses high-security secrets on local injection", () => {
|
|
541
564
|
// The server's 403 refusal shape for a high-security secret.
|
|
542
565
|
function highSecurityDenied(): Response {
|
|
@@ -550,6 +573,20 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
|
|
|
550
573
|
);
|
|
551
574
|
}
|
|
552
575
|
|
|
576
|
+
function highSecuritySandboxOnly(): Response {
|
|
577
|
+
return jsonRes({
|
|
578
|
+
secrets: [],
|
|
579
|
+
errors: [
|
|
580
|
+
{
|
|
581
|
+
name: "ANTHROPIC_API_KEY",
|
|
582
|
+
code: "high_security_sandbox_only",
|
|
583
|
+
message:
|
|
584
|
+
"High-security-tier secrets can run only in the sandbox (`hq secrets sandbox`) or through the secret proxy. Local injection and script locks (`--script`) are not supported.",
|
|
585
|
+
},
|
|
586
|
+
],
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
553
590
|
let exitSpy: MockInstance<typeof process.exit>;
|
|
554
591
|
beforeEach(() => {
|
|
555
592
|
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
@@ -576,17 +613,22 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
|
|
|
576
613
|
const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
|
|
577
614
|
|
|
578
615
|
expect(exitCode).toBe(1);
|
|
579
|
-
// A clear, actionable error
|
|
616
|
+
// A clear, actionable error names the sandbox/proxy boundary and rules
|
|
617
|
+
// out script-lock bypasses.
|
|
580
618
|
const errText = errSpy.mock.calls.flat().join(" ");
|
|
581
619
|
expect(errText).toMatch(/high-security/i);
|
|
620
|
+
expect(errText).toMatch(/hq secrets sandbox/i);
|
|
582
621
|
expect(errText).toMatch(/proxy/i);
|
|
622
|
+
expect(errText).toMatch(/--script/);
|
|
583
623
|
// The value is NEVER printed — no "Value:" line carrying plaintext.
|
|
584
624
|
const logText = logSpy.mock.calls.flat().join(" ");
|
|
585
625
|
expect(logText).not.toMatch(/sk-ant/i);
|
|
586
626
|
});
|
|
587
627
|
|
|
588
|
-
it("E2E: `secrets exec --
|
|
589
|
-
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
628
|
+
it("E2E: `secrets exec --script <path>` is denied — sandbox guidance, command not run", async () => {
|
|
629
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecuritySandboxOnly());
|
|
630
|
+
const scriptPath = join(tempDir, "deploy.sh");
|
|
631
|
+
writeFileSync(scriptPath, "#!/usr/bin/env bash\necho deploy\n");
|
|
590
632
|
|
|
591
633
|
const program = buildProgram();
|
|
592
634
|
try {
|
|
@@ -597,6 +639,10 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
|
|
|
597
639
|
"exec",
|
|
598
640
|
"--only",
|
|
599
641
|
"ANTHROPIC_API_KEY",
|
|
642
|
+
"--script",
|
|
643
|
+
scriptPath,
|
|
644
|
+
"--script-id",
|
|
645
|
+
"script.deploy",
|
|
600
646
|
"--",
|
|
601
647
|
"env",
|
|
602
648
|
]);
|
|
@@ -608,7 +654,10 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
|
|
|
608
654
|
expect(exitCode).toBe(1);
|
|
609
655
|
const errText = errSpy.mock.calls.flat().join(" ");
|
|
610
656
|
expect(errText).toMatch(/high-security/i);
|
|
657
|
+
expect(errText).toMatch(/hq secrets sandbox/i);
|
|
611
658
|
expect(errText).toMatch(/proxy/i);
|
|
659
|
+
expect(errText).toMatch(/--script/);
|
|
660
|
+
expect(spawn).not.toHaveBeenCalled();
|
|
612
661
|
});
|
|
613
662
|
});
|
|
614
663
|
|
package/src/commands/secrets.ts
CHANGED
|
@@ -212,6 +212,13 @@ export interface SecretLoadResponse {
|
|
|
212
212
|
errors: Array<{ name: string; code: string; message?: string }>;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
function highSecuritySandboxOnlyMessage(secretName?: string): string {
|
|
216
|
+
const sandboxExample = secretName
|
|
217
|
+
? `hq secrets sandbox --only ${secretName} -- <command>`
|
|
218
|
+
: "hq secrets sandbox --only <secret> -- <command>";
|
|
219
|
+
return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
|
|
220
|
+
}
|
|
221
|
+
|
|
215
222
|
interface SecretGetResponse {
|
|
216
223
|
secret: {
|
|
217
224
|
name: string;
|
|
@@ -669,12 +676,15 @@ export async function loadRevealedSecrets(
|
|
|
669
676
|
if (!res.ok) {
|
|
670
677
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
671
678
|
const message = extractApiMessage(body, res.statusText);
|
|
672
|
-
// High-security
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
679
|
+
// High-security refusal surfaced at the batch level (rather than
|
|
680
|
+
// per-name): point the caller at the sandbox/proxy and never leak
|
|
681
|
+
// plaintext. A local script attestation is not an override.
|
|
682
|
+
if (
|
|
683
|
+
body.code === "high_security_denied" ||
|
|
684
|
+
body.code === "high_security_sandbox_only" ||
|
|
685
|
+
body.highSecurity === true
|
|
686
|
+
) {
|
|
687
|
+
throw new Error(highSecuritySandboxOnlyMessage());
|
|
678
688
|
}
|
|
679
689
|
if (
|
|
680
690
|
res.status >= 400 &&
|
|
@@ -752,16 +762,15 @@ export async function loadRevealedSecrets(
|
|
|
752
762
|
if (resolved.has(key)) continue;
|
|
753
763
|
removeCacheEntry(companyUid, key);
|
|
754
764
|
const err = errorsByName.get(key);
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
);
|
|
765
|
+
// Every caller of loadRevealedSecrets injects or prints the plaintext
|
|
766
|
+
// locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
|
|
767
|
+
// high-security denial shapes therefore receive the same explicit
|
|
768
|
+
// sandbox/proxy guidance, even if the caller supplied `--script`.
|
|
769
|
+
if (
|
|
770
|
+
err?.code === "high_security_denied" ||
|
|
771
|
+
err?.code === "high_security_sandbox_only"
|
|
772
|
+
) {
|
|
773
|
+
firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
|
|
765
774
|
continue;
|
|
766
775
|
}
|
|
767
776
|
const reason =
|
|
@@ -947,7 +956,7 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
947
956
|
if (opts.highSecurity) {
|
|
948
957
|
console.log(
|
|
949
958
|
chalk.dim(
|
|
950
|
-
` High-security: destination pinned to ${destinations?.[0]}. This value can
|
|
959
|
+
` High-security: destination pinned to ${destinations?.[0]}. This value can run only in the sandbox (hq secrets sandbox) or through the HQ secret proxy; local injection and script locks are unsupported.`,
|
|
951
960
|
),
|
|
952
961
|
);
|
|
953
962
|
}
|
|
@@ -979,21 +988,11 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
979
988
|
|
|
980
989
|
if (!res.ok) {
|
|
981
990
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
982
|
-
// High-security
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
// 4xx — the value can ONLY be used through the server-side proxy.
|
|
991
|
+
// High-security secrets cannot be revealed locally. Surface the
|
|
992
|
+
// hosted execution path rather than a raw 4xx; `--script` never
|
|
993
|
+
// overrides this boundary.
|
|
986
994
|
if (res.status === 403 && body.highSecurity === true) {
|
|
987
|
-
console.error(
|
|
988
|
-
chalk.red(
|
|
989
|
-
`Secret '${name}' is high-security and cannot be revealed locally.`,
|
|
990
|
-
),
|
|
991
|
-
);
|
|
992
|
-
console.error(
|
|
993
|
-
chalk.dim(
|
|
994
|
-
" It can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
|
|
995
|
-
),
|
|
996
|
-
);
|
|
995
|
+
console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
|
|
997
996
|
process.exit(1);
|
|
998
997
|
}
|
|
999
998
|
console.error(
|
package/src/commands/workers.ts
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from "chalk";
|
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import * as yaml from "js-yaml";
|
|
6
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
6
7
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
7
8
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
8
9
|
import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
|
|
@@ -26,9 +27,10 @@ export interface RegistryWorker {
|
|
|
26
27
|
export function readWorkerRegistry(hqRoot: string): RegistryWorker[] {
|
|
27
28
|
const p = path.join(hqRoot, "core/workers/registry.yaml");
|
|
28
29
|
if (!fs.existsSync(p)) return [];
|
|
29
|
-
const doc =
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const doc = parseUserYaml<{ workers?: RegistryWorker[] } | null>(
|
|
31
|
+
fs.readFileSync(p, "utf8"),
|
|
32
|
+
p,
|
|
33
|
+
);
|
|
32
34
|
return doc?.workers ?? [];
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -120,9 +122,10 @@ export function writeGrantSidecar(
|
|
|
120
122
|
const sidecar = path.join(dir, ".grants.yaml");
|
|
121
123
|
let grants: string[] = [];
|
|
122
124
|
if (fs.existsSync(sidecar)) {
|
|
123
|
-
const doc =
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
const doc = parseUserYaml<{ grants?: string[] } | null>(
|
|
126
|
+
fs.readFileSync(sidecar, "utf8"),
|
|
127
|
+
sidecar,
|
|
128
|
+
);
|
|
126
129
|
if (Array.isArray(doc?.grants)) grants = doc!.grants.filter((g) => typeof g === "string");
|
|
127
130
|
}
|
|
128
131
|
if (!grants.includes(principalLabel)) grants.push(principalLabel);
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
writeManifest,
|
|
29
29
|
} from './manifest.js';
|
|
30
30
|
import type { ModulesManifest } from '../types.js';
|
|
31
|
+
import { isExpectedUserError } from './expected-cli-error.js';
|
|
31
32
|
|
|
32
33
|
let tmpRoot: string;
|
|
33
34
|
|
|
@@ -83,6 +84,24 @@ describe('getManifestPath', () => {
|
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
describe('readManifest / writeManifest round-trip', () => {
|
|
87
|
+
it('surfaces malformed user YAML with the manifest file and location', () => {
|
|
88
|
+
const nested = path.join(tmpRoot, 'modules', 'modules.yaml');
|
|
89
|
+
fs.mkdirSync(path.dirname(nested), { recursive: true });
|
|
90
|
+
fs.writeFileSync(nested, 'modules:\n - name: hq\n bad: indentation\n');
|
|
91
|
+
|
|
92
|
+
const error = (() => {
|
|
93
|
+
try {
|
|
94
|
+
readManifest(tmpRoot);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return err;
|
|
97
|
+
}
|
|
98
|
+
throw new Error('expected malformed YAML to throw');
|
|
99
|
+
})();
|
|
100
|
+
|
|
101
|
+
expect(isExpectedUserError(error)).toBe(true);
|
|
102
|
+
expect((error as Error).message).toContain(`${nested}:3:2`);
|
|
103
|
+
});
|
|
104
|
+
|
|
86
105
|
it('round-trips against the nested layout', () => {
|
|
87
106
|
writeManifest(tmpRoot, sampleManifest);
|
|
88
107
|
|
package/src/utils/manifest.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
4
|
import type { ModulesManifest, ModuleDefinition, ModuleLock, SyncState } from '../types.js';
|
|
5
|
+
import { parseUserYaml } from './user-yaml-error.js';
|
|
5
6
|
|
|
6
7
|
const MANIFEST_FILE = 'modules.yaml';
|
|
7
8
|
const LOCK_FILE = 'modules.lock';
|
|
@@ -57,7 +58,7 @@ export function readManifest(hqRoot: string): ModulesManifest | null {
|
|
|
57
58
|
return null;
|
|
58
59
|
}
|
|
59
60
|
const content = fs.readFileSync(manifestPath, 'utf-8');
|
|
60
|
-
return
|
|
61
|
+
return parseUserYaml<ModulesManifest>(content, manifestPath);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export function writeManifest(hqRoot: string, manifest: ModulesManifest): void {
|
|
@@ -76,7 +77,7 @@ export function readLock(hqRoot: string): ModuleLock | null {
|
|
|
76
77
|
return null;
|
|
77
78
|
}
|
|
78
79
|
const content = fs.readFileSync(lockPath, 'utf-8');
|
|
79
|
-
return
|
|
80
|
+
return parseUserYaml<ModuleLock>(content, lockPath);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function writeLock(hqRoot: string, lock: ModuleLock): void {
|
package/src/utils/people.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
20
|
-
import
|
|
20
|
+
import { parseUserYaml } from "./user-yaml-error.js";
|
|
21
21
|
|
|
22
22
|
/** A single person/member record parsed from a `people/<slug>/meta.yaml`. */
|
|
23
23
|
export interface PersonRecord {
|
|
@@ -76,14 +76,7 @@ export function parsePersonMeta(
|
|
|
76
76
|
slug: string,
|
|
77
77
|
source: string,
|
|
78
78
|
): PersonRecord | null {
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
doc = yaml.load(raw);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
`Failed to parse ${source}: ${err instanceof Error ? err.message : String(err)}`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
79
|
+
const doc = parseUserYaml<unknown>(raw, source);
|
|
87
80
|
if (!doc || typeof doc !== "object") return null;
|
|
88
81
|
const d = doc as Record<string, unknown>;
|
|
89
82
|
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
|
-
import * as yaml from 'js-yaml';
|
|
11
10
|
import { resolveDefaultHqRoot } from './cognito-session.js';
|
|
11
|
+
import { parseUserYaml } from './user-yaml-error.js';
|
|
12
12
|
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// Types
|
|
@@ -86,7 +86,7 @@ export function getRegistryUrl(): string {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
const content = fs.readFileSync(sourcesPath, 'utf-8');
|
|
89
|
-
const parsed =
|
|
89
|
+
const parsed = parseUserYaml<SourcesFile>(content, sourcesPath);
|
|
90
90
|
|
|
91
91
|
if (!parsed?.sources?.length) {
|
|
92
92
|
throw new Error('No sources defined in packages/sources.yaml');
|
package/src/utils/registry.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import * as yaml from 'js-yaml';
|
|
8
|
+
import { parseUserYaml } from './user-yaml-error.js';
|
|
8
9
|
|
|
9
10
|
export interface RegistryEntry {
|
|
10
11
|
name: string;
|
|
@@ -35,7 +36,7 @@ export function readRegistry(hqRoot: string): RegistryEntry[] {
|
|
|
35
36
|
return [];
|
|
36
37
|
}
|
|
37
38
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
38
|
-
const parsed =
|
|
39
|
+
const parsed = parseUserYaml<RegistryFile | null>(content, filePath);
|
|
39
40
|
return parsed?.packages ?? [];
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isExpectedUserError } from "./expected-cli-error.js";
|
|
3
|
+
import { parseUserYaml } from "./user-yaml-error.js";
|
|
4
|
+
|
|
5
|
+
describe("parseUserYaml", () => {
|
|
6
|
+
it("marks malformed user YAML as expected and names its file and location", () => {
|
|
7
|
+
const filePath = "/tmp/HQ/modules/modules.yaml";
|
|
8
|
+
const error = (() => {
|
|
9
|
+
try {
|
|
10
|
+
parseUserYaml('apiKey: "secret-do-not-echo"\nmodule:\n name: hq\n bad: indentation\n', filePath);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
return err;
|
|
13
|
+
}
|
|
14
|
+
throw new Error("expected malformed YAML to throw");
|
|
15
|
+
})();
|
|
16
|
+
|
|
17
|
+
expect(isExpectedUserError(error)).toBe(true);
|
|
18
|
+
expect((error as Error).message).toContain(
|
|
19
|
+
`Invalid YAML in ${filePath}:4:2: bad indentation of a mapping entry.`,
|
|
20
|
+
);
|
|
21
|
+
expect((error as Error).message).toContain("Fix the indentation or syntax and try again.");
|
|
22
|
+
expect((error as Error).message).not.toContain("secret-do-not-echo");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as yaml from "js-yaml";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse YAML supplied from an on-disk HQ file.
|
|
5
|
+
*
|
|
6
|
+
* YAML syntax is user-correctable content, not an hq-cli defect. js-yaml's
|
|
7
|
+
* default error includes a source excerpt, which can inadvertently echo
|
|
8
|
+
* secrets, so replace it with a concise, actionable location instead.
|
|
9
|
+
*/
|
|
10
|
+
export function parseUserYaml<T>(content: string, filePath: string): T {
|
|
11
|
+
try {
|
|
12
|
+
return yaml.load(content, { filename: filePath }) as T;
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (!(err instanceof yaml.YAMLException)) throw err;
|
|
15
|
+
|
|
16
|
+
const line = err.mark ? err.mark.line + 1 : undefined;
|
|
17
|
+
const column = err.mark ? err.mark.column + 1 : undefined;
|
|
18
|
+
const location = line !== undefined && column !== undefined
|
|
19
|
+
? `:${line}:${column}`
|
|
20
|
+
: "";
|
|
21
|
+
|
|
22
|
+
throw Object.assign(
|
|
23
|
+
new Error(
|
|
24
|
+
`Invalid YAML in ${filePath}${location}: ${err.reason}. ` +
|
|
25
|
+
"Fix the indentation or syntax and try again.",
|
|
26
|
+
),
|
|
27
|
+
{ expected: true as const },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|