@indigoai-us/hq-cli 5.77.2 → 5.77.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.77.4]
6
+
7
+ ### Fixed
8
+
9
+ - Malformed user-managed YAML now reports the affected file and line/column as
10
+ an expected, actionable CLI error instead of creating a Sentry exception.
11
+
5
12
  ## [5.77.2]
6
13
 
7
14
  ### 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 = yaml.load(raw) ?? {};
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 = yaml.load(raw) ?? {};
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 = yaml.load(raw);
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 = yaml.load(raw) ?? { companies: {} };
229
+ const parsed = parseUserYaml(raw, mPath) ?? { companies: {} };
229
230
  if (!parsed.companies)
230
231
  parsed.companies = {};
231
232
  const existing = parsed.companies[slug];
@@ -381,7 +381,7 @@ export function registerFilesCommand(program) {
381
381
  });
382
382
  files
383
383
  .command("versions <path>")
384
- .description("List prior content versions and delete markers for one exact vault key. Use --personal for your personal vault.")
384
+ .description("List prior content versions and delete markers for one exact vault key. Restore a listed version with restore --version-id <id>. Use --personal for your personal vault.")
385
385
  .option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
386
386
  .action(async (path, opts) => {
387
387
  try {
@@ -397,9 +397,9 @@ export function registerFilesCommand(program) {
397
397
  });
398
398
  files
399
399
  .command("restore <path>")
400
- .description("Restore a prior version or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.")
400
+ .description("Restore a prior version with --version-id or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.")
401
401
  .option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
402
- .option("--version <id>", "Content version ID to restore")
402
+ .option("--version-id <id>", "Content version ID to restore")
403
403
  .option("-y, --yes", "Skip the overwrite confirmation prompt (for scripts)")
404
404
  .action(async (path, opts) => {
405
405
  try {
@@ -408,7 +408,7 @@ export function registerFilesCommand(program) {
408
408
  assertRecoveryScope(personal, companySlug);
409
409
  await runFilesRestore({
410
410
  key: path,
411
- versionId: opts.version,
411
+ versionId: opts.versionId,
412
412
  yes: opts.yes === true,
413
413
  personal,
414
414
  companySlug,
@@ -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 * as yaml from 'js-yaml';
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 = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
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;
@@ -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 * as yaml from "js-yaml";
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
- let manifest;
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 * as yaml from 'js-yaml';
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 = yaml.load(pkgContent);
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 = yaml.load(content);
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 * as yaml from 'js-yaml';
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 = yaml.load(pkgContent);
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}"`);
@@ -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 = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
95
+ const parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
95
96
  fs.writeFileSync(manifestPath, stampAuthorYaml(parsed, author));
96
97
  }
97
98
  // ---------------------------------------------------------------------------
@@ -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 = yaml.load(fs.readFileSync(p, "utf8"));
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 = yaml.load(fs.readFileSync(sidecar, "utf8"));
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
  }
@@ -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 yaml.load(content);
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 yaml.load(content);
72
+ return parseUserYaml(content, lockPath);
72
73
  }
73
74
  export function writeLock(hqRoot, lock) {
74
75
  const lockPath = getLockPath(hqRoot);
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import * as fs from "fs";
18
18
  import * as path from "path";
19
- import * as yaml from "js-yaml";
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
- let doc;
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 = yaml.load(content);
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
  }
@@ -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 = yaml.load(content);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.77.2",
3
+ "version": "5.77.4",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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 = (yaml.load(raw) as Record<string, unknown> | null) ?? {};
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 = (yaml.load(raw) as { companies?: Record<string, unknown> } | null) ?? {};
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 = yaml.load(raw) as unknown;
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 = (yaml.load(raw) as ManifestDoc) ?? { companies: {} };
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.
@@ -308,18 +308,20 @@ describe("hq files trash", () => {
308
308
  });
309
309
 
310
310
  describe("hq files recovery command help", () => {
311
- it("documents the finalized --version and --prefix option forms", () => {
311
+ it("documents the finalized --version-id and --prefix option forms", () => {
312
312
  const program = buildProgram();
313
313
  const files = program.commands.find((command) => command.name() === "files");
314
314
  const restore = files?.commands.find((command) => command.name() === "restore");
315
315
  const trash = files?.commands.find((command) => command.name() === "trash");
316
316
 
317
317
  expect(files?.helpInformation()).toContain("versions [options] <path>");
318
- expect(restore?.helpInformation()).toContain("--version <id>");
318
+ expect(files?.helpInformation()).toContain("restore --version-id <id>");
319
+ expect(restore?.helpInformation()).toContain("--version-id <id>");
320
+ expect(restore?.helpInformation()).not.toContain("--version <id>");
319
321
  expect(trash?.helpInformation()).toContain("--prefix <prefix>");
320
322
  });
321
323
 
322
- it("passes --version to the restore request", async () => {
324
+ it("passes --version-id to the restore request", async () => {
323
325
  fetchSpy.mockResolvedValueOnce(membershipResponse());
324
326
  fetchSpy.mockResolvedValueOnce(
325
327
  jsonResponse(200, {
@@ -331,7 +333,14 @@ describe("hq files recovery command help", () => {
331
333
  );
332
334
 
333
335
  await buildProgram().parseAsync(
334
- ["files", "restore", "notes/a.md", "--version", "old-version", "--yes"],
336
+ [
337
+ "files",
338
+ "restore",
339
+ "notes/a.md",
340
+ "--version-id",
341
+ "old-version",
342
+ "--yes",
343
+ ],
335
344
  { from: "user" },
336
345
  );
337
346
 
@@ -515,7 +515,7 @@ export function registerFilesCommand(program: Command): Command {
515
515
  files
516
516
  .command("versions <path>")
517
517
  .description(
518
- "List prior content versions and delete markers for one exact vault key. Use --personal for your personal vault.",
518
+ "List prior content versions and delete markers for one exact vault key. Restore a listed version with restore --version-id <id>. Use --personal for your personal vault.",
519
519
  )
520
520
  .option(
521
521
  "--personal",
@@ -539,18 +539,18 @@ export function registerFilesCommand(program: Command): Command {
539
539
  files
540
540
  .command("restore <path>")
541
541
  .description(
542
- "Restore a prior version or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.",
542
+ "Restore a prior version with --version-id or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.",
543
543
  )
544
544
  .option(
545
545
  "--personal",
546
546
  "Target your own personal vault instead of a company vault (mutually exclusive with --company)",
547
547
  )
548
- .option("--version <id>", "Content version ID to restore")
548
+ .option("--version-id <id>", "Content version ID to restore")
549
549
  .option("-y, --yes", "Skip the overwrite confirmation prompt (for scripts)")
550
550
  .action(
551
551
  async (
552
552
  path: string,
553
- opts: { personal?: boolean; yes?: boolean; version?: string },
553
+ opts: { personal?: boolean; yes?: boolean; versionId?: string },
554
554
  ) => {
555
555
  try {
556
556
  const companySlug = files.opts().company as string | undefined;
@@ -558,7 +558,7 @@ export function registerFilesCommand(program: Command): Command {
558
558
  assertRecoveryScope(personal, companySlug);
559
559
  await runFilesRestore({
560
560
  key: path,
561
- versionId: opts.version,
561
+ versionId: opts.versionId,
562
562
  yes: opts.yes === true,
563
563
  personal,
564
564
  companySlug,
@@ -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 * as yaml from 'js-yaml';
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 = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
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
- expect(() => parsePersonMeta("name: [unterminated\n", "x", "/x")).toThrow(
142
- /Failed to parse/,
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
 
@@ -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 * as yaml from "js-yaml";
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
- let manifest: ManifestDoc;
150
- try {
151
- manifest = yaml.load(fs.readFileSync(mPath, "utf-8")) as ManifestDoc;
152
- } catch (err) {
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 * as yaml from 'js-yaml';
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 = yaml.load(pkgContent) as { slug?: string } | null;
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 = yaml.load(content) as {
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 * as yaml from 'js-yaml';
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 = yaml.load(pkgContent) as {
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(
@@ -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 = yaml.load(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, unknown>;
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
 
@@ -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 = yaml.load(fs.readFileSync(p, "utf8")) as
30
- | { workers?: RegistryWorker[] }
31
- | null;
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 = yaml.load(fs.readFileSync(sidecar, "utf8")) as
124
- | { grants?: string[] }
125
- | null;
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
 
@@ -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 yaml.load(content) as ModulesManifest;
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 yaml.load(content) as ModuleLock;
80
+ return parseUserYaml<ModuleLock>(content, lockPath);
80
81
  }
81
82
 
82
83
  export function writeLock(hqRoot: string, lock: ModuleLock): void {
@@ -17,7 +17,7 @@
17
17
 
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
- import * as yaml from "js-yaml";
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
- let doc: unknown;
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 = yaml.load(content) as SourcesFile;
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');
@@ -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 = yaml.load(content) as RegistryFile | null;
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
+ }