@smoothbricks/cli 0.10.3 → 0.10.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.
Files changed (111) hide show
  1. package/README.md +9 -13
  2. package/dist/bun/preload.d.ts +13 -0
  3. package/dist/bun/preload.d.ts.map +1 -0
  4. package/dist/bun/preload.js +12 -0
  5. package/dist/cli.js +12 -4
  6. package/dist/generate/index.d.ts.map +1 -1
  7. package/dist/generate/index.js +0 -6
  8. package/dist/github-ci/index.d.ts.map +1 -1
  9. package/dist/github-ci/index.js +52 -16
  10. package/dist/github-ci/outputs.d.ts.map +1 -1
  11. package/dist/github-ci/outputs.js +1 -2
  12. package/dist/lib/cli-package.js +2 -2
  13. package/dist/lib/json.d.ts +118 -11
  14. package/dist/lib/json.d.ts.map +1 -1
  15. package/dist/lib/json.js +317 -29
  16. package/dist/lib/workspace.d.ts +7 -14
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +76 -82
  19. package/dist/monorepo/ci-workflow.d.ts +2 -0
  20. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  21. package/dist/monorepo/ci-workflow.js +27 -1
  22. package/dist/monorepo/git-config.js +2 -2
  23. package/dist/monorepo/index.d.ts +3 -1
  24. package/dist/monorepo/index.d.ts.map +1 -1
  25. package/dist/monorepo/index.js +6 -2
  26. package/dist/monorepo/lockfile.d.ts +13 -1
  27. package/dist/monorepo/lockfile.d.ts.map +1 -1
  28. package/dist/monorepo/lockfile.js +33 -21
  29. package/dist/monorepo/managed-files.d.ts +11 -0
  30. package/dist/monorepo/managed-files.d.ts.map +1 -1
  31. package/dist/monorepo/managed-files.js +52 -32
  32. package/dist/monorepo/package-policy.d.ts.map +1 -1
  33. package/dist/monorepo/package-policy.js +52 -45
  34. package/dist/monorepo/packed-manifest.d.ts +5 -2
  35. package/dist/monorepo/packed-manifest.d.ts.map +1 -1
  36. package/dist/monorepo/packed-manifest.js +16 -47
  37. package/dist/monorepo/packed-package.d.ts.map +1 -1
  38. package/dist/monorepo/packed-package.js +28 -16
  39. package/dist/monorepo/packs/index.d.ts.map +1 -1
  40. package/dist/monorepo/packs/index.js +13 -4
  41. package/dist/monorepo/publish-workflow.js +3 -1
  42. package/dist/monorepo/runtime.d.ts +15 -0
  43. package/dist/monorepo/runtime.d.ts.map +1 -1
  44. package/dist/monorepo/runtime.js +66 -12
  45. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  46. package/dist/monorepo/tool-validation.js +32 -23
  47. package/dist/monorepo/wrangler.d.ts.map +1 -1
  48. package/dist/monorepo/wrangler.js +13 -13
  49. package/dist/nx/index.d.ts +9 -7
  50. package/dist/nx/index.d.ts.map +1 -1
  51. package/dist/nx/index.js +48 -31
  52. package/dist/pr/index.d.ts.map +1 -1
  53. package/dist/pr/index.js +28 -32
  54. package/dist/release/candidates.d.ts +3 -2
  55. package/dist/release/candidates.d.ts.map +1 -1
  56. package/dist/release/candidates.js +4 -5
  57. package/dist/release/index.d.ts +1 -0
  58. package/dist/release/index.d.ts.map +1 -1
  59. package/dist/release/index.js +82 -59
  60. package/dist/wrangler/prepare-env.d.ts +0 -1
  61. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  62. package/dist/wrangler/prepare-env.js +100 -51
  63. package/dist/wrangler/scaffold.d.ts.map +1 -1
  64. package/dist/wrangler/scaffold.js +12 -12
  65. package/managed/raw/git-format-staged.yml +19 -1
  66. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +2 -1
  67. package/managed/raw/tooling/direnv/merge-newer-pins.sh +3 -3
  68. package/managed/raw/tooling/direnv/setup-environment.ts +150 -19
  69. package/managed/raw/tooling/git-hooks/pre-commit.sh +0 -6
  70. package/managed/templates/github/actions/cache-node-modules/action.yml +5 -2
  71. package/managed/templates/github/actions/cache-ttsc-plugins/action.yml +19 -0
  72. package/managed/templates/github/actions/setup-devenv/action.yml +5 -0
  73. package/managed/templates/github/workflows/managed-files.yml +80 -0
  74. package/package.json +15 -4
  75. package/src/bun/preload.ts +12 -0
  76. package/src/cli.ts +19 -7
  77. package/src/generate/index.ts +0 -6
  78. package/src/github-ci/index.ts +49 -18
  79. package/src/github-ci/outputs.ts +1 -2
  80. package/src/lib/cli-package.ts +2 -2
  81. package/src/lib/json.ts +291 -32
  82. package/src/lib/workspace.ts +88 -100
  83. package/src/monorepo/__tests__/ci-workflow.test.ts +3 -3
  84. package/src/monorepo/ci-workflow.ts +27 -1
  85. package/src/monorepo/git-config.ts +2 -2
  86. package/src/monorepo/index.ts +6 -2
  87. package/src/monorepo/lockfile.test.ts +35 -36
  88. package/src/monorepo/lockfile.ts +48 -24
  89. package/src/monorepo/managed-files.test.ts +1 -0
  90. package/src/monorepo/managed-files.ts +57 -38
  91. package/src/monorepo/package-policy.test.ts +20 -50
  92. package/src/monorepo/package-policy.ts +76 -59
  93. package/src/monorepo/packed-manifest.ts +18 -52
  94. package/src/monorepo/packed-package.ts +43 -22
  95. package/src/monorepo/packs/index.ts +13 -4
  96. package/src/monorepo/publish-workflow.ts +3 -1
  97. package/src/monorepo/runtime.test.ts +42 -3
  98. package/src/monorepo/runtime.ts +90 -12
  99. package/src/monorepo/tool-validation.ts +35 -25
  100. package/src/monorepo/wrangler.test.ts +11 -15
  101. package/src/monorepo/wrangler.ts +15 -15
  102. package/src/nx/index.test.ts +9 -5
  103. package/src/nx/index.ts +69 -42
  104. package/src/pr/index.ts +42 -36
  105. package/src/release/__tests__/candidates.test.ts +5 -4
  106. package/src/release/__tests__/trust-publisher.test.ts +7 -0
  107. package/src/release/candidates.ts +10 -17
  108. package/src/release/index.ts +104 -73
  109. package/src/wrangler/prepare-env.ts +75 -54
  110. package/src/wrangler/scaffold.test.ts +9 -12
  111. package/src/wrangler/scaffold.ts +13 -13
@@ -1,13 +1,23 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
1
2
  import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
3
  import { tmpdir } from 'node:os';
3
4
  import { join, resolve } from 'node:path';
4
5
  import { createInterface } from 'node:readline/promises';
5
6
  import { Writable } from 'node:stream';
6
7
  import { $ } from 'bun';
8
+ import typia from 'typia';
7
9
  import { githubCiApplyOutputs, githubCiNxRunMany } from '../github-ci/index.js';
8
10
  import { assertNoConflictMarkers } from '../lib/conflict-markers.js';
9
11
  import { withDevenvEnv } from '../lib/devenv.js';
10
- import { isRecord, readJsonObject, stringProperty } from '../lib/json.js';
12
+ import {
13
+ type NxJson,
14
+ type NxProjectJson,
15
+ type NxTargetConfig,
16
+ type PackageJson,
17
+ parseNxJsonText,
18
+ parseNxProjectJsonText,
19
+ parsePackageJsonText,
20
+ } from '../lib/json.js';
11
21
  import { decode, run, runInteractiveStatus, runResult } from '../lib/run.js';
12
22
  import { listReleasePackages, readPackageJson, repositoryInfo } from '../lib/workspace.js';
13
23
  import { readPackedPackageJson, validatePackedWorkspaceDependencies } from '../monorepo/packed-manifest.js';
@@ -572,7 +582,7 @@ async function publishPlaceholderPackage(
572
582
  name: pkg.name,
573
583
  version: NPM_BOOTSTRAP_VERSION,
574
584
  description: `Bootstrap placeholder for ${pkg.name}. Real releases are published by SmoothBricks CI.`,
575
- license: stringProperty(pkg.json, 'license') ?? 'UNLICENSED',
585
+ license: pkg.json.license ?? 'UNLICENSED',
576
586
  repository: pkg.json.repository,
577
587
  publishConfig: { access: 'public' },
578
588
  },
@@ -596,7 +606,7 @@ function safeTarballPrefix(name: string): string {
596
606
 
597
607
  async function assertPackedWorkspaceDependencies(root: string, tarball: string, pkg: ReleasePackage): Promise<void> {
598
608
  const manifest = await readPackedPackageJson(root, tarball, pkg.name);
599
- const failures = validatePackedWorkspaceDependencies(root, pkg, manifest);
609
+ const failures = validatePackedWorkspaceDependencies(root, pkg, manifest, { mode: 'publish' });
600
610
  if (failures.length > 0) {
601
611
  throw new Error(failures.join('\n'));
602
612
  }
@@ -783,8 +793,7 @@ async function packageVersionAtRef(root: string, packagePath: string, ref: strin
783
793
  return null;
784
794
  }
785
795
  try {
786
- const parsed = JSON.parse(decode(result.stdout));
787
- return isRecord(parsed) ? stringProperty(parsed, 'version') : null;
796
+ return parsePackageJsonText(decode(result.stdout))?.version ?? null;
788
797
  } catch {
789
798
  return null;
790
799
  }
@@ -1264,74 +1273,83 @@ async function packageChangedFilesSince(root: string, ref: string, packagePath:
1264
1273
  .map((path) => (path.startsWith(packagePrefix) ? path.slice(packagePrefix.length) : path));
1265
1274
  }
1266
1275
 
1267
- async function packageJsonAtRef(
1268
- root: string,
1269
- ref: string,
1270
- packagePath: string,
1271
- ): Promise<Record<string, unknown> | null> {
1276
+ async function packageJsonAtRef(root: string, ref: string, packagePath: string): Promise<PackageJson | null> {
1272
1277
  const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
1273
1278
  if (result.exitCode !== 0) {
1274
1279
  return null;
1275
1280
  }
1276
1281
  try {
1277
- const parsed = JSON.parse(decode(result.stdout));
1278
- return isRecord(parsed) ? parsed : null;
1282
+ return parsePackageJsonText(decode(result.stdout));
1279
1283
  } catch {
1280
1284
  return null;
1281
1285
  }
1282
1286
  }
1283
1287
 
1284
- async function currentPackageJson(root: string, packagePath: string): Promise<Record<string, unknown> | null> {
1288
+ async function currentPackageJson(root: string, packagePath: string): Promise<PackageJson | null> {
1285
1289
  return readPackageJson(join(root, packagePath, 'package.json'))?.json ?? null;
1286
1290
  }
1287
1291
 
1288
1292
  async function packageBuildInputPatterns(root: string, projectName: string, _packagePath: string): Promise<string[]> {
1289
1293
  const project = await nxProjectJson(root, projectName);
1290
- const nxJson = readJsonObject(join(root, 'nx.json')) ?? {};
1291
- return resolveBuildInputPatterns(project, nxJson);
1294
+ return resolveBuildInputPatterns(project, readNxJson(join(root, 'nx.json')));
1292
1295
  }
1293
1296
 
1294
- async function nxProjectJson(root: string, projectName: string): Promise<Record<string, unknown>> {
1297
+ async function nxProjectJson(root: string, projectName: string): Promise<NxProjectJson> {
1295
1298
  const result = await $`nx show project ${projectName} --json`.cwd(root).quiet();
1296
- const parsed = JSON.parse(decode(result.stdout));
1297
- if (!isRecord(parsed)) {
1299
+ const parsed = parseNxProjectJsonText(decode(result.stdout));
1300
+ if (!parsed) {
1298
1301
  throw new Error(`Unable to inspect Nx project ${projectName}.`);
1299
1302
  }
1300
1303
  return parsed;
1301
1304
  }
1302
1305
 
1303
- function resolveBuildInputPatterns(project: Record<string, unknown>, nxJson: Record<string, unknown>): string[] {
1304
- const targets = recordProperty(project, 'targets');
1306
+ function readNxJson(path: string): NxJson {
1307
+ if (!existsSync(path)) {
1308
+ return {};
1309
+ }
1310
+ try {
1311
+ return parseNxJsonText(readFileSync(path, 'utf8')) ?? {};
1312
+ } catch {
1313
+ return {};
1314
+ }
1315
+ }
1316
+
1317
+ function resolveBuildInputPatterns(project: NxProjectJson, nxJson: NxJson): string[] {
1318
+ const targets = project.targets;
1305
1319
  if (!targets) {
1306
1320
  return [];
1307
1321
  }
1308
1322
  return normalizeInputPatterns(collectBuildInputs(targets), nxJson);
1309
1323
  }
1310
1324
 
1311
- function collectBuildInputs(targets: Record<string, unknown>): string[] {
1312
- const build = recordProperty(targets, 'build');
1325
+ function collectBuildInputs(targets: Record<string, NxTargetConfig>): string[] {
1326
+ const build = targets.build;
1313
1327
  if (!build) {
1314
1328
  return ['production'];
1315
1329
  }
1316
- const directInputs = stringArrayProperty(build, 'inputs');
1330
+ const directInputs = stringInputs(build.inputs);
1317
1331
  if (directInputs.length > 0) {
1318
1332
  return directInputs;
1319
1333
  }
1320
1334
  const inputs: string[] = [];
1321
- for (const dependency of stringArrayProperty(build, 'dependsOn')) {
1322
- if (dependency.startsWith('^')) {
1335
+ for (const dependency of build.dependsOn ?? []) {
1336
+ if (typeof dependency !== 'string' || dependency.startsWith('^')) {
1323
1337
  continue;
1324
1338
  }
1325
1339
  const targetName = dependency.includes(':') ? dependency.split(':')[1] : dependency;
1326
1340
  if (!targetName) {
1327
1341
  continue;
1328
1342
  }
1329
- inputs.push(...stringArrayProperty(recordProperty(targets, targetName), 'inputs'));
1343
+ inputs.push(...stringInputs(targets[targetName]?.inputs));
1330
1344
  }
1331
1345
  return inputs.length > 0 ? inputs : ['production'];
1332
1346
  }
1333
1347
 
1334
- function normalizeInputPatterns(inputs: string[], nxJson: Record<string, unknown>): string[] {
1348
+ function stringInputs(inputs: NxTargetConfig['inputs'] | undefined): string[] {
1349
+ return Array.isArray(inputs) ? inputs.filter((entry): entry is string => typeof entry === 'string') : [];
1350
+ }
1351
+
1352
+ function normalizeInputPatterns(inputs: string[], nxJson: NxJson): string[] {
1335
1353
  const patterns: string[] = [];
1336
1354
  const seen = new Set<string>();
1337
1355
  for (const input of inputs) {
@@ -1342,14 +1360,13 @@ function normalizeInputPatterns(inputs: string[], nxJson: Record<string, unknown
1342
1360
  return patterns;
1343
1361
  }
1344
1362
 
1345
- function expandInputPattern(input: string, nxJson: Record<string, unknown>, seen: Set<string>): string[] {
1363
+ function expandInputPattern(input: string, nxJson: NxJson, seen: Set<string>): string[] {
1346
1364
  if (seen.has(input)) {
1347
1365
  return [];
1348
1366
  }
1349
1367
  seen.add(input);
1350
1368
  if (!input.includes('{')) {
1351
- const namedInputs = recordProperty(nxJson, 'namedInputs');
1352
- const namedInput = namedInputs?.[input];
1369
+ const namedInput = nxJson.namedInputs?.[input];
1353
1370
  if (Array.isArray(namedInput)) {
1354
1371
  return namedInput.flatMap((entry) => (typeof entry === 'string' ? expandInputPattern(entry, nxJson, seen) : []));
1355
1372
  }
@@ -1364,19 +1381,6 @@ function expandInputPattern(input: string, nxJson: Record<string, unknown>, seen
1364
1381
  return [`${excluded ? '!' : ''}${packageRelative}`];
1365
1382
  }
1366
1383
 
1367
- function recordProperty(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
1368
- if (!record) {
1369
- return null;
1370
- }
1371
- const value = record[key];
1372
- return isRecord(value) ? value : null;
1373
- }
1374
-
1375
- function stringArrayProperty(record: Record<string, unknown> | null, key: string): string[] {
1376
- const value = record?.[key];
1377
- return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
1378
- }
1379
-
1380
1384
  async function packageHasHistory(root: string, packagePath: string): Promise<boolean> {
1381
1385
  const result = await $`git log --format=%H -- ${packagePath}`.cwd(root).quiet().nothrow();
1382
1386
  if (result.exitCode !== 0) {
@@ -1615,7 +1619,7 @@ async function runLatestNpmTrust(
1615
1619
  if (status === 0) {
1616
1620
  return 'configured';
1617
1621
  }
1618
- throw new Error(`nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')} failed with exit code ${status}`);
1622
+ throw new Error(npmCommandFailedMessage(npmArgs, status));
1619
1623
  }
1620
1624
 
1621
1625
  async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'name'>): Promise<TrustedPublisherLookup> {
@@ -1624,7 +1628,7 @@ async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'na
1624
1628
  if (result.exitCode !== 0 && /\bEOTP\b/.test(`${result.stdout}\n${result.stderr}`)) {
1625
1629
  const status = await runInteractiveStatus('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1626
1630
  if (status !== 0) {
1627
- throw new Error(`nix shell nixpkgs#nodejs_latest -c npm ${args.join(' ')} failed with exit code ${status}`);
1631
+ throw new Error(npmCommandFailedMessage(args, status));
1628
1632
  }
1629
1633
  result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1630
1634
  }
@@ -1632,15 +1636,47 @@ async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'na
1632
1636
  return npmTrustAccessDetails(root, pkg.name);
1633
1637
  }
1634
1638
  if (result.exitCode !== 0) {
1635
- throw new Error(
1636
- `nix shell nixpkgs#nodejs_latest -c npm ${args.join(' ')} failed with exit code ${result.exitCode}`,
1637
- );
1639
+ throw new Error(npmCommandFailedMessage(args, result.exitCode, result.stdout, result.stderr));
1638
1640
  }
1639
1641
  return parseTrustedPublishers(result.stdout, pkg.name);
1640
1642
  }
1641
1643
 
1644
+ /** npm trust list/setup requires an authenticated package owner. */
1642
1645
  export function npmTrustListAccessDenied(stdout: string, stderr: string): boolean {
1643
- return /\bE403\b/.test(`${stdout}\n${stderr}`);
1646
+ const output = `${stdout}\n${stderr}`;
1647
+ // E403: authenticated but not an owner. E401 / login-required: no usable session.
1648
+ return (
1649
+ /\bE403\b/.test(output) ||
1650
+ /\bE401\b/.test(output) ||
1651
+ /You must be logged in to publish packages/i.test(output) ||
1652
+ /npm error 401 Unauthorized/i.test(output)
1653
+ );
1654
+ }
1655
+
1656
+ function npmCommandFailedMessage(npmArgs: string[], exitCode: number, stdout = '', stderr = ''): string {
1657
+ const command = `nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')}`;
1658
+ const detail = npmFailureDetail(stdout, stderr);
1659
+ return detail.length > 0
1660
+ ? `${command} failed with exit code ${exitCode}\n${detail}`
1661
+ : `${command} failed with exit code ${exitCode}`;
1662
+ }
1663
+
1664
+ function npmFailureDetail(stdout: string, stderr: string): string {
1665
+ const chunks = [stderr.trim(), stdout.trim()].filter((chunk) => chunk.length > 0);
1666
+ if (chunks.length === 0) {
1667
+ return '';
1668
+ }
1669
+ // Prefer the actionable npm error line over notice spam when present.
1670
+ const joined = chunks.join('\n');
1671
+ const errorLines = joined
1672
+ .split('\n')
1673
+ .map((line) => line.trimEnd())
1674
+ .filter((line) => /npm error|error code|401 Unauthorized|403 Forbidden|E401|E403|logged in/i.test(line));
1675
+ if (errorLines.length > 0) {
1676
+ return errorLines.slice(0, 8).join('\n');
1677
+ }
1678
+ // Fall back to a short tail of combined output.
1679
+ return joined.split('\n').slice(-12).join('\n');
1644
1680
  }
1645
1681
 
1646
1682
  async function npmTrustAccessDetails(root: string, packageName: string): Promise<NpmTrustAccessDenied> {
@@ -1665,36 +1701,31 @@ export function parseTrustedPublishers(stdout: string, packageName: string): Tru
1665
1701
  if (stdout.trim().length === 0) {
1666
1702
  return [];
1667
1703
  }
1668
- const parsed = JSON.parse(stdout) as unknown;
1704
+ // createIsParse returns null for non-matching shapes and bare `null`.
1705
+ const parsed = parseTrustedPublisherJson(stdout);
1669
1706
  if (parsed === null) {
1670
- return [];
1671
- }
1672
- if (Array.isArray(parsed)) {
1673
- return parsed.map((value) => parseTrustedPublisher(value, packageName));
1674
- }
1675
- if (isRecord(parsed)) {
1676
- return [parseTrustedPublisher(parsed, packageName)];
1707
+ if (stdout.trim() === 'null') {
1708
+ return [];
1709
+ }
1710
+ throw new Error(`${packageName}: npm trust list returned invalid trusted publisher JSON.`);
1677
1711
  }
1678
- throw new Error(`${packageName}: npm trust list returned invalid JSON.`);
1712
+ return (Array.isArray(parsed) ? parsed : [parsed]).map((value) => ({
1713
+ id: value.id,
1714
+ type: value.type,
1715
+ file: value.file,
1716
+ repository: value.repository,
1717
+ }));
1679
1718
  }
1680
1719
 
1681
- function parseTrustedPublisher(value: unknown, packageName: string): TrustedPublisher {
1682
- if (!isRecord(value)) {
1683
- throw new Error(`${packageName}: npm trust list returned invalid trusted publisher entry.`);
1684
- }
1685
- const id = stringProperty(value, 'id');
1686
- const type = stringProperty(value, 'type');
1687
- if (!id || !type) {
1688
- throw new Error(`${packageName}: npm trust list returned a trusted publisher without id or type.`);
1689
- }
1690
- return {
1691
- id,
1692
- type,
1693
- file: stringProperty(value, 'file') ?? undefined,
1694
- repository: stringProperty(value, 'repository') ?? undefined,
1695
- };
1720
+ interface TrustedPublisherJson {
1721
+ id: string;
1722
+ type: string;
1723
+ file?: string;
1724
+ repository?: string;
1696
1725
  }
1697
1726
 
1727
+ const parseTrustedPublisherJson = typia.json.createIsParse<TrustedPublisherJson | TrustedPublisherJson[]>();
1728
+
1698
1729
  async function runLatestNpmPublish(root: string, npmArgs: string[]): Promise<void> {
1699
1730
  await runLatestNpm(root, npmArgs, { NODE_AUTH_TOKEN: '', NPM_TOKEN: '' });
1700
1731
  }
@@ -20,13 +20,50 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { $ } from 'bun';
22
22
  import { type AST, getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
23
+ import typia from 'typia';
23
24
 
24
- // ── runtime narrowing (wrangler JSON is external data — no casts) ─────────────
25
+ // ── runtime narrowing (wrangler TOML/JSON is external data — no casts) ───────
25
26
 
26
- export function isRecord(v: unknown): v is Record<string, unknown> {
27
- return typeof v === 'object' && v !== null && !Array.isArray(v);
27
+ interface WranglerTomlRoot {
28
+ env?: Record<string, WranglerEnvBlock | undefined>;
28
29
  }
29
30
 
31
+ interface WranglerEnvBlock {
32
+ vars?: Record<string, unknown>;
33
+ kv_namespaces?: WranglerKvRow[];
34
+ d1_databases?: WranglerD1Row[];
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ interface WranglerKvRow {
39
+ binding?: string;
40
+ id?: string;
41
+ }
42
+
43
+ interface WranglerD1Row {
44
+ binding?: string;
45
+ database_id?: string;
46
+ database_name?: string;
47
+ }
48
+
49
+ interface WranglerSecretRow {
50
+ name?: string;
51
+ }
52
+
53
+ interface ShellErrorShape {
54
+ stderr?: unknown;
55
+ exitCode?: unknown;
56
+ }
57
+
58
+ const isWranglerTomlRoot = typia.createIs<WranglerTomlRoot>();
59
+ const isWranglerEnvBlock = typia.createIs<WranglerEnvBlock>();
60
+ const isWranglerKvRow = typia.createIs<WranglerKvRow>();
61
+ const isWranglerD1Row = typia.createIs<WranglerD1Row>();
62
+ const isWranglerSecretRow = typia.createIs<WranglerSecretRow>();
63
+ const isKvNamespaceRow = typia.createIs<{ id: string; title: string }>();
64
+ const isD1DatabaseRow = typia.createIs<{ uuid: string; name: string }>();
65
+ const isShellErrorShape = typia.createIs<ShellErrorShape>();
66
+
30
67
  // ── wrangler.toml editors (CST: read via getStaticTOMLValue, write via
31
68
  // node-range splice — comment/format-preserving by construction, no regex) ─────
32
69
 
@@ -36,15 +73,14 @@ function tables(toml: string): AST.TOMLTable[] {
36
73
  }
37
74
 
38
75
  /** Whole doc as a JS object (comments/format dropped — reads only). */
39
- function tomlValue(toml: string): Record<string, unknown> {
40
- const value = getStaticTOMLValue(parseTOML(toml));
41
- return isRecord(value) ? value : {};
76
+ function tomlValue(toml: string): WranglerTomlRoot {
77
+ const value: unknown = getStaticTOMLValue(parseTOML(toml));
78
+ return isWranglerTomlRoot(value) ? value : {};
42
79
  }
43
80
 
44
- function envRecord(toml: string, env: string): Record<string, unknown> | null {
45
- const envs = tomlValue(toml).env;
46
- const block = isRecord(envs) ? envs[env] : undefined;
47
- return isRecord(block) ? block : null;
81
+ function envRecord(toml: string, env: string): WranglerEnvBlock | null {
82
+ const block = tomlValue(toml).env?.[env];
83
+ return isWranglerEnvBlock(block) ? block : null;
48
84
  }
49
85
 
50
86
  function sameKey(a: (string | number)[], b: (string | number)[]): boolean {
@@ -68,8 +104,13 @@ function arrayTableKv(
68
104
  field: string,
69
105
  ): AST.TOMLKeyValue | null {
70
106
  const block = envRecord(toml, env);
71
- const rows = isRecord(block) && Array.isArray(block[arrayKey]) ? block[arrayKey] : [];
72
- const index = rows.findIndex((r) => isRecord(r) && r.binding === binding);
107
+ const rows = Array.isArray(block?.[arrayKey]) ? block[arrayKey] : [];
108
+ const index = rows.findIndex((row) => {
109
+ if (arrayKey === 'd1_databases') {
110
+ return isWranglerD1Row(row) && row.binding === binding;
111
+ }
112
+ return isWranglerKvRow(row) && row.binding === binding;
113
+ });
73
114
  if (index < 0) return null;
74
115
  const table = tables(toml).find((t) => t.kind === 'array' && sameKey(t.resolvedKey, ['env', env, arrayKey, index]));
75
116
  return table ? kvIn(toml, table, field) : null;
@@ -88,7 +129,7 @@ function endOfLine(toml: string, offset: number): number {
88
129
  /** First `[env.<name>]` (source order; also derived from a nested `[env.<name>.*]`), or null when no env declared. */
89
130
  export function firstWranglerEnv(toml: string): string | null {
90
131
  const envs = tomlValue(toml).env;
91
- if (!isRecord(envs)) return null;
132
+ if (!envs) return null;
92
133
  const [first] = Object.keys(envs);
93
134
  return first ?? null;
94
135
  }
@@ -96,14 +137,12 @@ export function firstWranglerEnv(toml: string): string | null {
96
137
  /** True if the toml declares `[env.<env>]` (or any `[env.<env>.*]` sub-table). */
97
138
  export function hasEnvBlock(toml: string, env: string): boolean {
98
139
  const envs = tomlValue(toml).env;
99
- return isRecord(envs) && env in envs;
140
+ return !!envs && env in envs;
100
141
  }
101
142
 
102
143
  /** A `[env.<env>.vars]` value, or null if unset/empty. Reads committed public config. */
103
144
  export function getVar(toml: string, env: string, name: string): string | null {
104
- const block = envRecord(toml, env);
105
- const vars = isRecord(block?.vars) ? block.vars : {};
106
- const value = vars[name];
145
+ const value = envRecord(toml, env)?.vars?.[name];
107
146
  return typeof value === 'string' && value ? value : null;
108
147
  }
109
148
 
@@ -117,10 +156,8 @@ export function setVar(toml: string, env: string, name: string, value: string):
117
156
 
118
157
  /** The id written for `[[env.<env>.kv_namespaces]]` binding, or null if absent/empty. */
119
158
  export function getKvId(toml: string, env: string, binding: string): string | null {
120
- const block = envRecord(toml, env);
121
- const rows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
122
- for (const row of rows) {
123
- if (isRecord(row) && row.binding === binding && typeof row.id === 'string') return row.id || null;
159
+ for (const row of envRecord(toml, env)?.kv_namespaces ?? []) {
160
+ if (row.binding === binding && typeof row.id === 'string') return row.id || null;
124
161
  }
125
162
  return null;
126
163
  }
@@ -166,21 +203,16 @@ export function blankKvIds(toml: string, env: string): string {
166
203
 
167
204
  /** The database_id written for `[[env.<env>.d1_databases]]` binding, or null if absent/empty. */
168
205
  export function getD1Id(toml: string, env: string, binding: string): string | null {
169
- const block = envRecord(toml, env);
170
- const rows = isRecord(block) && Array.isArray(block.d1_databases) ? block.d1_databases : [];
171
- for (const row of rows) {
172
- if (isRecord(row) && row.binding === binding && typeof row.database_id === 'string') return row.database_id || null;
206
+ for (const row of envRecord(toml, env)?.d1_databases ?? []) {
207
+ if (row.binding === binding && typeof row.database_id === 'string') return row.database_id || null;
173
208
  }
174
209
  return null;
175
210
  }
176
211
 
177
212
  /** The `database_name` for `[[env.<env>.d1_databases]]` binding, or null. Source of truth for which DB to create/reuse. */
178
213
  export function getD1Name(toml: string, env: string, binding: string): string | null {
179
- const block = envRecord(toml, env);
180
- const rows = isRecord(block) && Array.isArray(block.d1_databases) ? block.d1_databases : [];
181
- for (const row of rows) {
182
- if (isRecord(row) && row.binding === binding && typeof row.database_name === 'string')
183
- return row.database_name || null;
214
+ for (const row of envRecord(toml, env)?.d1_databases ?? []) {
215
+ if (row.binding === binding && typeof row.database_name === 'string') return row.database_name || null;
184
216
  }
185
217
  return null;
186
218
  }
@@ -260,12 +292,13 @@ export function parseDevVarsExample(text: string): string[] {
260
292
  export function readManifest(root: string, env: string): Manifest {
261
293
  const toml = readFileSync(join(root, 'wrangler.toml'), 'utf8');
262
294
  const block = envRecord(toml, env);
263
- const varsRec = isRecord(block?.vars) ? block.vars : {};
264
- const vars = Object.keys(varsRec);
265
- const kvRows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
266
- const kvBindings = kvRows.flatMap((row) => (isRecord(row) && typeof row.binding === 'string' ? [row.binding] : []));
267
- const d1Rows = isRecord(block) && Array.isArray(block.d1_databases) ? block.d1_databases : [];
268
- const d1Bindings = d1Rows.flatMap((row) => (isRecord(row) && typeof row.binding === 'string' ? [row.binding] : []));
295
+ const vars = Object.keys(block?.vars ?? {});
296
+ const kvBindings = (block?.kv_namespaces ?? []).flatMap((row) =>
297
+ typeof row.binding === 'string' ? [row.binding] : [],
298
+ );
299
+ const d1Bindings = (block?.d1_databases ?? []).flatMap((row) =>
300
+ typeof row.binding === 'string' ? [row.binding] : [],
301
+ );
269
302
  const examplePath = join(root, '.dev.vars.example');
270
303
  const secrets = existsSync(examplePath) ? parseDevVarsExample(readFileSync(examplePath, 'utf8')) : [];
271
304
  return { kvBindings, d1Bindings, vars, secrets };
@@ -300,25 +333,19 @@ export interface KvNamespace {
300
333
 
301
334
  function asKvNamespaces(json: unknown): KvNamespace[] {
302
335
  if (!Array.isArray(json)) return [];
303
- const out: KvNamespace[] = [];
304
- for (const row of json) {
305
- if (isRecord(row) && typeof row.id === 'string' && typeof row.title === 'string') {
306
- out.push({ id: row.id, title: row.title });
307
- }
308
- }
309
- return out;
336
+ return json.filter(isKvNamespaceRow);
310
337
  }
311
338
 
312
339
  function asSecretNames(json: unknown): string[] {
313
340
  if (!Array.isArray(json)) return [];
314
- return json.flatMap((row) => (isRecord(row) && typeof row.name === 'string' ? [row.name] : []));
341
+ return json.flatMap((row) => (isWranglerSecretRow(row) && typeof row.name === 'string' ? [row.name] : []));
315
342
  }
316
343
 
317
344
  /** Best-effort ERROR line from a Bun ShellError (thrown on non-zero exit). */
318
345
  export function errText(err: unknown): string {
319
- if (!isRecord(err)) return 'unknown error';
320
- const stderr = 'stderr' in err && err.stderr != null ? String(err.stderr) : '';
321
- const exit = 'exitCode' in err ? String(err.exitCode) : '?';
346
+ if (!isShellErrorShape(err)) return 'unknown error';
347
+ const stderr = err.stderr != null ? String(err.stderr) : '';
348
+ const exit = err.exitCode != null ? String(err.exitCode) : '?';
322
349
  return stderr.split('\n').find((l) => l.includes('ERROR')) ?? `exit ${exit}`;
323
350
  }
324
351
 
@@ -358,13 +385,7 @@ export interface D1Database {
358
385
 
359
386
  function asD1Databases(json: unknown): D1Database[] {
360
387
  if (!Array.isArray(json)) return [];
361
- const out: D1Database[] = [];
362
- for (const row of json) {
363
- if (isRecord(row) && typeof row.uuid === 'string' && typeof row.name === 'string') {
364
- out.push({ uuid: row.uuid, name: row.name });
365
- }
366
- }
367
- return out;
388
+ return json.filter(isD1DatabaseRow);
368
389
  }
369
390
 
370
391
  /** Live D1 databases on the account (`d1 list --json`); `[]` when offline/unauthenticated. */
@@ -2,6 +2,7 @@ import { describe, expect, it, spyOn } from 'bun:test';
2
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { type PackageJson, parsePackageJsonText } from '../lib/json.js';
5
6
  import { scaffold } from './scaffold.js';
6
7
 
7
8
  describe('scaffold', () => {
@@ -164,27 +165,23 @@ async function createWorkspace(
164
165
  return root;
165
166
  }
166
167
 
167
- function nxTargets(pkg: Record<string, unknown>): Record<string, unknown> {
168
- const nx = pkg.nx;
169
- if (!isRecord(nx) || !isRecord(nx.targets)) {
168
+ function nxTargets(pkg: PackageJson): NonNullable<NonNullable<PackageJson['nx']>['targets']> {
169
+ const targets = pkg.nx?.targets;
170
+ if (!targets) {
170
171
  throw new Error('nx.targets not found');
171
172
  }
172
- return nx.targets;
173
+ return targets;
173
174
  }
174
175
 
175
- function isRecord(value: unknown): value is Record<string, unknown> {
176
- return typeof value === 'object' && value !== null && !Array.isArray(value);
177
- }
178
-
179
- async function readJson(path: string): Promise<Record<string, unknown>> {
180
- const parsed: unknown = JSON.parse(await readFile(path, 'utf8'));
181
- if (!isRecord(parsed)) {
176
+ async function readJson(path: string): Promise<PackageJson> {
177
+ const parsed = parsePackageJsonText(await readFile(path, 'utf8'));
178
+ if (!parsed) {
182
179
  throw new Error('expected JSON object');
183
180
  }
184
181
  return parsed;
185
182
  }
186
183
 
187
- async function writeJson(path: string, value: Record<string, unknown>): Promise<void> {
184
+ async function writeJson(path: string, value: PackageJson): Promise<void> {
188
185
  await mkdir(join(path, '..'), { recursive: true });
189
186
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
190
187
  }
@@ -6,8 +6,8 @@
6
6
  * .dev.vars.example), so it never drifts from what the worker declares.
7
7
  */
8
8
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
9
- import { join, relative } from 'node:path';
10
- import { getOrCreateRecord, recordProperty, writeJsonObject } from '../lib/json.js';
9
+ import { join } from 'node:path';
10
+ import { ensureNx, ensureNxTargets, type NxTargetConfig, type PackageJson, writeJsonObject } from '../lib/json.js';
11
11
  import { getWorkspacePackageManifests } from '../lib/workspace.js';
12
12
 
13
13
  export interface ScaffoldOptions {
@@ -19,22 +19,21 @@ export interface ScaffoldOptions {
19
19
  function resolveProject(
20
20
  root: string,
21
21
  project: string,
22
- ): { dir: string; label: string; packageJsonPath: string; json: Record<string, unknown> } {
22
+ ): { dir: string; label: string; packageJsonPath: string; json: PackageJson } {
23
23
  const manifests = getWorkspacePackageManifests(root);
24
24
  const match = manifests.find((m) => {
25
- const nx = recordProperty(m.json, 'nx');
26
- const nxName = nx && typeof nx.name === 'string' ? nx.name : null;
27
- return nxName === project || m.name === project || relative(root, m.path) === project;
25
+ const nxName = m.json.nx?.name;
26
+ return nxName === project || m.name === project || m.path === project;
28
27
  });
29
28
  if (!match) {
30
- const known = manifests.map((m) => recordProperty(m.json, 'nx')?.name ?? m.name).join(', ');
29
+ const known = manifests.map((m) => m.json.nx?.name ?? m.name).join(', ');
31
30
  throw new Error(`project "${project}" not found. Known: ${known}`);
32
31
  }
33
- const dir = match.path; // WorkspacePackageManifest.path is absolute
32
+ const dir = join(root, match.path);
34
33
  if (!existsSync(join(dir, 'wrangler.toml'))) {
35
34
  throw new Error(`"${project}" has no wrangler.toml — not a wrangler project`);
36
35
  }
37
- return { dir, label: relative(root, dir), packageJsonPath: match.packageJsonPath, json: match.json };
36
+ return { dir, label: match.path, packageJsonPath: match.packageJsonPath, json: match.json };
38
37
  }
39
38
 
40
39
  /** The generated starter script — generic, manifest-driven, prompt-batteries included. */
@@ -226,13 +225,14 @@ export function scaffold(root: string, project: string, options: ScaffoldOptions
226
225
  console.log(`${existed ? 'overwrote' : 'created'} ${label}/scripts/prepare-env.ts`);
227
226
 
228
227
  // Wire the nx target (idempotent).
229
- const nx = getOrCreateRecord(json, 'nx');
230
- const targets = getOrCreateRecord(nx, 'targets');
231
- if (!recordProperty(targets, 'prepare-env')) {
232
- targets['prepare-env'] = {
228
+ const nx = ensureNx(json);
229
+ const targets = ensureNxTargets(nx);
230
+ if (!targets['prepare-env']) {
231
+ const prepareEnvTarget: NxTargetConfig = {
233
232
  executor: 'nx:run-commands',
234
233
  options: { command: 'bun scripts/prepare-env.ts', cwd: '{projectRoot}' },
235
234
  };
235
+ targets['prepare-env'] = prepareEnvTarget;
236
236
  writeJsonObject(packageJsonPath, json);
237
237
  console.log(`updated ${label}/package.json prepare-env target`);
238
238
  }