@dbx-tools/cli 0.1.21 → 0.1.23

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/package.json CHANGED
@@ -6,8 +6,7 @@
6
6
  "directory": "workspaces/cli/dbx-tools"
7
7
  },
8
8
  "bin": {
9
- "dbxtools": "./bin/dbxtools.ts",
10
- "dbxtools.ts": "bin/dbxtools.ts"
9
+ "dbxtools": "./bin/dbxtools.ts"
11
10
  },
12
11
  "devDependencies": {
13
12
  "@types/node": "^24.6.0",
@@ -18,15 +17,15 @@
18
17
  "@clack/prompts": "^1.7.0",
19
18
  "commander": "^15.0.0",
20
19
  "pnpm": "^11.0.6",
21
- "@dbx-tools/core": "0.1.21",
22
- "@dbx-tools/shared-core": "0.1.21"
20
+ "@dbx-tools/core": "0.1.23",
21
+ "@dbx-tools/shared-core": "0.1.23"
23
22
  },
24
23
  "main": "index.ts",
25
24
  "license": "UNLICENSED",
26
25
  "publishConfig": {
27
26
  "access": "public"
28
27
  },
29
- "version": "0.1.21",
28
+ "version": "0.1.23",
30
29
  "types": "index.ts",
31
30
  "type": "module",
32
31
  "exports": {
package/src/bootstrap.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * Bootstrap a brand-new folder into a working dbx-tools workspace before projen runs.
3
3
  */
4
- import { existsSync, writeFileSync } from "node:fs";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { intro, outro } from "@clack/prompts";
7
7
  import { exec } from "@dbx-tools/core";
8
8
  import { resolvePnpmArgv, runPnpm } from "./pnpm";
9
9
  import { rootLabel } from "./root";
10
10
 
11
- const DEFAULT_PROJEN_SPECIFIER = "@dbx-tools/projen";
11
+ // Pin to `@latest` explicitly: a bare `@dbx-tools/projen` can land on a stray
12
+ // `0.0.0` (whose `^0.0.0` caret then can't reach any real release), leaving the
13
+ // workspace on a stale engine. `@latest` always takes the newest published.
14
+ const DEFAULT_PROJEN_SPECIFIER = "@dbx-tools/projen@latest";
12
15
 
13
16
  const PROJENRC_TEMPLATE = `import { DBXToolsNodeProject } from "@dbx-tools/projen";
14
17
 
@@ -23,11 +26,19 @@ allowBuilds:
23
26
  `;
24
27
 
25
28
  /**
26
- * Turn an empty folder into a functioning dbx-tools workspace: `pnpm init`, seed
29
+ * Turn a folder into a functioning dbx-tools workspace: `pnpm init`, seed
27
30
  * `pnpm-workspace.yaml`, add `projen`/`typescript`/`tsx` + the engine package,
28
31
  * write a minimal `.projenrc.ts`, synth once (with `PROJEN_DISABLE_POST`), then
29
32
  * install. Does not run barrels - run `dbxtools barrels` or a full projen synth
30
33
  * post-install to generate package barrels.
34
+ *
35
+ * Every step is idempotent and self-guarded, so this is safe to run against a
36
+ * folder that ALREADY has a hand-authored `.projenrc.ts` (and even a committed
37
+ * `package.json`) but is missing the installed toolchain - e.g. a freshly copied
38
+ * project whose generated files (including manifests) are gitignored. In that
39
+ * case `pnpm init` and the `.projenrc.ts` scaffold are skipped, but the engine +
40
+ * projen + tsx are (re)installed so the subsequent synth can regenerate
41
+ * everything. See {@link seedToolchain}.
31
42
  */
32
43
  export function bootstrapWorkspace(
33
44
  root: string,
@@ -35,8 +46,39 @@ export function bootstrapWorkspace(
35
46
  ): void {
36
47
  intro(`Bootstrapping dbx-tools workspace in ${rootLabel(root)}`);
37
48
 
38
- if (!existsSync(join(root, "package.json"))) {
49
+ seedToolchain(root, projenSpecifier);
50
+
51
+ const projenrc = join(root, ".projenrc.ts");
52
+ if (!existsSync(projenrc)) {
53
+ writeFileSync(projenrc, PROJENRC_TEMPLATE);
54
+ }
55
+
56
+ runInitialSynth(root);
57
+
58
+ runPnpm(["install", "--no-frozen-lockfile", "--force"], root);
59
+ outro("Workspace ready - re-run dbxtools or add packages under workspaces/");
60
+ }
61
+
62
+ /**
63
+ * Install the toolchain a synth needs (`projen`, `typescript`, `tsx`, and the
64
+ * dbx-tools engine), seeding a `package.json` and `pnpm-workspace.yaml` first
65
+ * when absent. Idempotent: run it whenever the engine is missing, so a copied
66
+ * project with a `.projenrc.ts` but no `node_modules`/manifests can be brought
67
+ * up to a synth-ready state without full bootstrapping.
68
+ */
69
+ export function seedToolchain(
70
+ root: string,
71
+ projenSpecifier: string = DEFAULT_PROJEN_SPECIFIER,
72
+ ): void {
73
+ const manifestPath = join(root, "package.json");
74
+ if (!existsSync(manifestPath)) {
39
75
  runPnpm(["init"], root);
76
+ // pnpm 11 `init` writes `devEngines.packageManager: { name: "pnpm",
77
+ // onFail: "download" }`. Any npm-based tool later in the chain (an
78
+ // `npx`/dlx fallback) then refuses with EBADDEVENGINES because its own
79
+ // runner is npm, not pnpm. projen regenerates the whole manifest at synth
80
+ // anyway, so drop the block from this throwaway seed.
81
+ stripDevEngines(manifestPath);
40
82
  }
41
83
 
42
84
  const workspaceFile = join(root, "pnpm-workspace.yaml");
@@ -45,19 +87,27 @@ export function bootstrapWorkspace(
45
87
  }
46
88
 
47
89
  runPnpm(["add", "-D", "projen", "typescript@^5.9.3", "tsx@^4.23.0", projenSpecifier], root);
90
+ }
48
91
 
49
- const projenrc = join(root, ".projenrc.ts");
50
- if (!existsSync(projenrc)) {
51
- writeFileSync(projenrc, PROJENRC_TEMPLATE);
92
+ /** Remove the `devEngines` block pnpm `init` seeds, so npm-based tooling doesn't reject the manifest. */
93
+ function stripDevEngines(manifestPath: string): void {
94
+ try {
95
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
96
+ if (manifest.devEngines === undefined) return;
97
+ delete manifest.devEngines;
98
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
99
+ } catch {
100
+ // A malformed/absent manifest here just means the later `pnpm add` recreates it.
52
101
  }
53
-
54
- runSynth(root);
55
-
56
- runPnpm(["install", "--no-frozen-lockfile", "--force"], root);
57
- outro("Workspace ready - re-run dbxtools or add packages under workspaces/");
58
102
  }
59
103
 
60
- function runSynth(root: string): void {
104
+ /**
105
+ * Run the initial synth by executing `.projenrc.ts` directly with tsx (with
106
+ * `PROJEN_DISABLE_POST` set), NOT `projen <task>`. Use right after seeding a
107
+ * fresh workspace: the projen TASKS (`sync`, `barrels`, ...) only exist once
108
+ * `.projenrc.ts` has run once, so `projen sync` can't be the bootstrapping step.
109
+ */
110
+ export function runInitialSynth(root: string): void {
61
111
  const [command, ...prefix] = resolvePnpmArgv();
62
112
  exec.spawnSync(command, [...prefix, "exec", "tsx", ".projenrc.ts"], {
63
113
  cwd: root,
package/src/cli.ts CHANGED
@@ -2,20 +2,36 @@
2
2
  * `dbxtools` commander entry: detect root, bootstrap or install, forward to projen.
3
3
  */
4
4
  import { Command } from "commander";
5
- import { bootstrapWorkspace } from "./bootstrap";
6
- import { ensureWorkspaceReady, runProjen } from "./pnpm";
7
- import { findWorkspaceRoot, needsBootstrap } from "./root";
5
+ import { bootstrapWorkspace, runInitialSynth, seedToolchain } from "./bootstrap";
6
+ import { ensureWorkspaceReady, runPnpm, runProjen } from "./pnpm";
7
+ import { findWorkspaceRoot, needsBootstrap, needsToolchain } from "./root";
8
8
 
9
9
  /**
10
10
  * Prepare the workspace at `root`, then run `pnpm exec projen` with `projenArgs`.
11
+ *
12
+ * Three cases, in order:
13
+ * - no `.projenrc.ts` at all -> full bootstrap (scaffold + install + synth),
14
+ * which already runs the initial synth; nothing more to forward.
15
+ * - a `.projenrc.ts` but the engine/toolchain isn't installed (e.g. a freshly
16
+ * copied project whose generated files + manifests are gitignored) -> seed
17
+ * the toolchain, then run the INITIAL synth directly (the projen tasks the
18
+ * args would name, like `sync`, don't exist until `.projenrc.ts` has run
19
+ * once), and install. Don't forward `projenArgs` - the synth is the work.
20
+ * - otherwise (established workspace) -> ensure deps, then forward to projen.
11
21
  */
12
22
  async function prepareAndRunProjen(projenArgs: string[], startDir?: string): Promise<void> {
13
23
  const root = await findWorkspaceRoot(startDir);
14
24
  if (needsBootstrap(root)) {
15
25
  bootstrapWorkspace(root);
16
- } else {
17
- ensureWorkspaceReady(root);
26
+ return;
18
27
  }
28
+ if (needsToolchain(root)) {
29
+ seedToolchain(root);
30
+ runInitialSynth(root);
31
+ runPnpm(["install", "--no-frozen-lockfile", "--force"], root);
32
+ return;
33
+ }
34
+ ensureWorkspaceReady(root);
19
35
  runProjen(projenArgs, root);
20
36
  }
21
37
 
package/src/pnpm.ts CHANGED
@@ -10,7 +10,24 @@ import { needsInstall } from "./root";
10
10
  /** A package.json `bin` field: either a single command string, or a name -> path map. */
11
11
  type BinField = string | Record<string, string>;
12
12
 
13
+ /** True when `pnpm --version` runs, i.e. pnpm is already on PATH. */
14
+ function pnpmOnPath(): boolean {
15
+ try {
16
+ exec.spawnSync("pnpm", ["--version"], {
17
+ stderr: "ignore",
18
+ stdin: "ignore",
19
+ stdout: "ignore",
20
+ check: true,
21
+ });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
13
28
  function resolvePnpmArgvImpl(): string[] {
29
+ // 1. A resolvable `pnpm` dependency (the normal in-workspace case): run its
30
+ // bin directly with the current node - no PATH or package-manager shim.
14
31
  try {
15
32
  const require = createRequire(import.meta.url);
16
33
  const pkgJsonPath = require.resolve("pnpm/package.json");
@@ -18,24 +35,32 @@ function resolvePnpmArgvImpl(): string[] {
18
35
  const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin.pnpm;
19
36
  return [process.execPath, join(dirname(pkgJsonPath), bin)];
20
37
  } catch {
21
- try {
22
- exec.spawnSync("corepack", ["enable", "pnpm"], {
23
- stderr: "ignore",
24
- stdin: "ignore",
25
- stdout: "ignore",
26
- check: true,
27
- });
28
- exec.spawnSync("pnpm", ["--version"], {
29
- stderr: "ignore",
30
- stdin: "ignore",
31
- stdout: "ignore",
32
- check: true,
33
- });
34
- return ["pnpm"];
35
- } catch {
36
- return ["npx", "-y", "pnpm"];
37
- }
38
+ // fall through
38
39
  }
40
+
41
+ // 2. A bare `pnpm` already on PATH (e.g. running under `pnpm dlx`). Prefer
42
+ // this over the corepack/npx fallbacks so we never shell through npm -
43
+ // `npx -y pnpm` runs under npm, which rejects a bootstrapped
44
+ // `devEngines.packageManager: pnpm` manifest with EBADDEVENGINES.
45
+ if (pnpmOnPath()) return ["pnpm"];
46
+
47
+ // 3. Try to enable pnpm via corepack, then use it.
48
+ try {
49
+ exec.spawnSync("corepack", ["enable", "pnpm"], {
50
+ stderr: "ignore",
51
+ stdin: "ignore",
52
+ stdout: "ignore",
53
+ check: true,
54
+ });
55
+ if (pnpmOnPath()) return ["pnpm"];
56
+ } catch {
57
+ // fall through
58
+ }
59
+
60
+ // 4. Last resort: fetch pnpm on demand via npx. Pass `--engine-strict=false`
61
+ // (and skip npm's devEngines gate) so npm doesn't refuse to run just
62
+ // because the target manifest declares `devEngines.packageManager: pnpm`.
63
+ return ["npx", "-y", "--engine-strict=false", "pnpm"];
39
64
  }
40
65
 
41
66
  /** Memoized `[command, ...prefix]` argv prefix to run pnpm (resolved install, else corepack, else npx). */
package/src/root.ts CHANGED
@@ -54,6 +54,23 @@ export function needsInstall(root: string): boolean {
54
54
  return false;
55
55
  }
56
56
 
57
+ /**
58
+ * True when the synth TOOLCHAIN isn't installed yet: no `node_modules`, or the
59
+ * dbx-tools engine / `projen` aren't resolvable under it. Distinct from a full
60
+ * bootstrap (which keys off a MISSING `.projenrc.ts`): here the projenrc exists
61
+ * but its dependencies don't - e.g. a copied project whose generated manifests
62
+ * and `node_modules` are gitignored. Seeding the toolchain (not scaffolding)
63
+ * makes it synth-ready without touching the hand-authored `.projenrc.ts`.
64
+ */
65
+ export function needsToolchain(root: string): boolean {
66
+ const modules = join(root, "node_modules");
67
+ if (!existsSync(modules)) return true;
68
+ return (
69
+ !existsSync(join(modules, "projen")) ||
70
+ !existsSync(join(modules, "@dbx-tools", "projen"))
71
+ );
72
+ }
73
+
57
74
  /** Async, memoized root lookup from the process cwd at first use. */
58
75
  export const workspaceRoot = functionModule.memoize(() => findWorkspaceRoot());
59
76