@dbx-tools/projen 0.9.5 → 0.9.7

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 (50) hide show
  1. package/README.md +69 -17
  2. package/index.ts +7 -5
  3. package/package.json +9 -6
  4. package/src/_rust-project.ts +356 -0
  5. package/src/_rust-release-workflow.ts +954 -0
  6. package/src/_rust-release.ts +605 -0
  7. package/src/_task-command.ts +89 -0
  8. package/src/barrels.ts +12 -11
  9. package/src/bun-app.ts +96 -85
  10. package/src/bun-workflow.ts +60 -52
  11. package/src/engine-root.ts +5 -5
  12. package/src/openapi.ts +16 -9
  13. package/src/packages.ts +9 -9
  14. package/src/pnpm-workspace.ts +1 -1
  15. package/src/project-js.ts +19 -25
  16. package/src/project-py.ts +28 -23
  17. package/src/project-rs.ts +100 -1098
  18. package/src/projen-version.ts +9 -0
  19. package/src/publish.ts +3 -4
  20. package/src/release-catalog.ts +1 -1
  21. package/src/release.ts +212 -177
  22. package/src/scaffold.ts +1 -1
  23. package/src/uniffi.ts +7 -0
  24. package/src/watch.ts +2 -2
  25. package/src/workspace-version.ts +20 -15
  26. package/tasks/barrels.ts +4 -4
  27. package/tasks/build-rust-release.ts +68 -0
  28. package/tasks/bump.ts +3 -2
  29. package/tasks/clean.ts +3 -3
  30. package/tasks/local-publish.ts +21 -3
  31. package/tasks/openapi.ts +2 -2
  32. package/tasks/publish-npm.ts +12 -6
  33. package/tasks/publish-python.ts +2 -2
  34. package/tasks/publish-uniffi-local.ts +11 -15
  35. package/tasks/publish.ts +88 -88
  36. package/tasks/python-registry.ts +1 -1
  37. package/tasks/release-bootstrap.ts +2 -2
  38. package/tasks/release-plan.ts +1 -1
  39. package/tasks/release-please.ts +1 -1
  40. package/tasks/release-pr.ts +60 -115
  41. package/tasks/release-request.ts +8 -29
  42. package/tasks/release-summary-units.ts +2 -9
  43. package/tasks/release-summary.ts +35 -24
  44. package/tasks/release-version.ts +44 -0
  45. package/tasks/rust-release.d.ts +41 -0
  46. package/tasks/rust-release.mjs +1717 -0
  47. package/tasks/sync.ts +3 -3
  48. package/tasks/uniffi-python.js +1 -1
  49. package/tasks/uniffi.ts +7 -1
  50. package/tasks/version-check.ts +6 -5
package/tasks/barrels.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S bun
2
2
  import { sep } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { log, string } from "@dbx-tools/shared-core";
4
+ import { log, stringUtils } from "@dbx-tools/shared-core";
5
5
  import { generateBarrels } from "../src/barrels.ts";
6
6
  import { recordedPackages } from "../src/packages.ts";
7
7
  import { watchLoop, watchRoots } from "../src/watch.ts";
@@ -43,14 +43,14 @@ if (values.watch) {
43
43
  if (dirs.size === 0) {
44
44
  if (unowned.length) {
45
45
  logger.warn(
46
- `no recorded package owns ${string.pluralize(unowned.length, "change")}; ` +
46
+ `no recorded package owns ${stringUtils.pluralize(unowned.length, "change")}; ` +
47
47
  "run `bun run default` (or touch .projenrc.ts) to pick up a new package folder",
48
48
  );
49
49
  }
50
50
  return;
51
51
  }
52
52
  const n = generateBarrels({ dirs: [...dirs] });
53
- if (n) logger.success(`rebuilt ${string.pluralize(n, "barrel")}`);
53
+ if (n) logger.success(`rebuilt ${stringUtils.pluralize(n, "barrel")}`);
54
54
  });
55
55
  } else {
56
56
  const dirs = Array.isArray(values.dir)
@@ -58,6 +58,6 @@ if (values.watch) {
58
58
  : [];
59
59
  const n = generateBarrels(dirs.length ? { dirs } : undefined);
60
60
  logger.success(
61
- n === 0 ? "barrels already up to date" : `updated ${string.pluralize(n, "barrel")}`,
61
+ n === 0 ? "barrels already up to date" : `updated ${stringUtils.pluralize(n, "barrel")}`,
62
62
  );
63
63
  }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Bundle the owning Rust release helper source into a standalone Node module. */
3
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import * as exec from "@dbx-tools/core/exec";
7
+
8
+ const root = resolve(import.meta.dirname, "..");
9
+ const temporary = mkdtempSync(join(tmpdir(), "dbx-rust-release-bundle-"));
10
+ try {
11
+ const bundled = join(temporary, "rust-release.mjs");
12
+ const declarations = join(temporary, "declarations");
13
+ exec.spawnSync(
14
+ process.execPath,
15
+ [
16
+ "build",
17
+ resolve(root, "src/_rust-release.ts"),
18
+ "--target=node",
19
+ "--format=esm",
20
+ `--outfile=${bundled}`,
21
+ ],
22
+ {
23
+ cwd: root,
24
+ stdout: "inherit",
25
+ stderr: "inherit",
26
+ stdin: "ignore",
27
+ check: true,
28
+ },
29
+ );
30
+ const output = resolve(root, "tasks/rust-release.mjs");
31
+ const source = readFileSync(bundled, "utf8");
32
+ writeFileSync(
33
+ output,
34
+ `#!/usr/bin/env node\n// GENERATED from src/_rust-release.ts by tasks/build-rust-release.ts.\n${source.replace(/^#![^\n]*\n/, "")}`,
35
+ );
36
+ chmodSync(output, 0o755);
37
+ exec.spawnSync(
38
+ "bunx",
39
+ [
40
+ "tsc",
41
+ resolve(root, "src/_rust-release.ts"),
42
+ "--declaration",
43
+ "--emitDeclarationOnly",
44
+ "--module",
45
+ "ESNext",
46
+ "--moduleResolution",
47
+ "Bundler",
48
+ "--target",
49
+ "ES2022",
50
+ "--skipLibCheck",
51
+ "--outDir",
52
+ declarations,
53
+ ],
54
+ {
55
+ cwd: root,
56
+ stdout: "inherit",
57
+ stderr: "inherit",
58
+ stdin: "ignore",
59
+ check: true,
60
+ },
61
+ );
62
+ writeFileSync(
63
+ resolve(root, "tasks/rust-release.d.ts"),
64
+ readFileSync(join(declarations, "_rust-release.d.ts")),
65
+ );
66
+ } finally {
67
+ rmSync(temporary, { recursive: true, force: true });
68
+ }
package/tasks/bump.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * generated version surface. This task has no git, registry, or publication
5
5
  * side effects; release preparation owns those operations.
6
6
  */
7
- import { exec, project } from "@dbx-tools/core";
7
+ import * as exec from "@dbx-tools/core/exec";
8
+ import * as projectUtils from "@dbx-tools/core/project-utils";
8
9
  import { log } from "@dbx-tools/shared-core";
9
10
  import { Command } from "commander";
10
11
  import {
@@ -36,7 +37,7 @@ program
36
37
  arch: ReleaseArch[];
37
38
  synth: boolean;
38
39
  }) => {
39
- const root = project.root() ?? process.cwd();
40
+ const root = projectUtils.root() ?? process.cwd();
40
41
  const next = resolveNextVersion(root, [opts.prefix], opts.level);
41
42
  const releasePlatforms = releasePlatformFilter(opts.os, opts.arch);
42
43
 
package/tasks/clean.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S bun
2
2
  import { relative } from "node:path";
3
- import { log, string } from "@dbx-tools/shared-core";
3
+ import { log, stringUtils } from "@dbx-tools/shared-core";
4
4
  import { listGeneratedFiles, listNodeModulesDirs, removePaths } from "../src/clean.ts";
5
5
  import { repoRoot, toPosix } from "../src/packages.ts";
6
6
 
@@ -24,7 +24,7 @@ const regenHint = (removedNodeModules: boolean): string =>
24
24
  if (yes) {
25
25
  const n = removePaths(targets);
26
26
  logger.success(
27
- `removed ${string.pluralize(n, "path")} (${files.length} generated + ${nodeModules.length} node_modules) - ${regenHint(nodeModules.length > 0)}`,
27
+ `removed ${stringUtils.pluralize(n, "path")} (${files.length} generated + ${nodeModules.length} node_modules) - ${regenHint(nodeModules.length > 0)}`,
28
28
  );
29
29
  process.exit(0);
30
30
  }
@@ -64,4 +64,4 @@ if (picked.length === 0) {
64
64
 
65
65
  const removedNodeModules = picked.some((p) => nodeModules.includes(p));
66
66
  const n = removePaths(picked);
67
- clack.outro(`removed ${string.pluralize(n, "path")} - ${regenHint(removedNodeModules)}`);
67
+ clack.outro(`removed ${stringUtils.pluralize(n, "path")} - ${regenHint(removedNodeModules)}`);
@@ -3,7 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { exec, project } from "@dbx-tools/core";
6
+ import * as exec from "@dbx-tools/core/exec";
7
+ import * as projectUtils from "@dbx-tools/core/project-utils";
7
8
  import { log, net } from "@dbx-tools/shared-core";
8
9
  import { activePythonIndexes, resolveLocalPypi } from "./python-registry.ts";
9
10
  import { readDbxToolsConfig } from "../src/packages.ts";
@@ -18,13 +19,15 @@ export interface LocalPublishOptions {
18
19
  readonly localPypi: string;
19
20
  readonly pythonRoot?: string;
20
21
  readonly localCargo: boolean;
22
+ /** Reuse the root TypeScript compile that immediately preceded local publication. */
23
+ readonly reuseValidatedNodeCompile?: boolean;
21
24
  }
22
25
 
23
26
  function resolveLocalRegistry(value: string): string | undefined {
24
27
  const trimmed = value.trim();
25
28
  if (!trimmed || trimmed.toLowerCase() === "false") return undefined;
26
29
  if (trimmed.toLowerCase() === "auto") {
27
- const registry = project.npmRegistry();
30
+ const registry = projectUtils.npmRegistry();
28
31
  return registry && net.isLoopbackHost(registry) ? registry.href : undefined;
29
32
  }
30
33
  return trimmed;
@@ -45,6 +48,21 @@ function localCargoRegistry(): string | undefined {
45
48
  return undefined;
46
49
  }
47
50
 
51
+ /** Arguments for the workspace npm publisher used by local release preflight. */
52
+ export function localNodePublishArguments(
53
+ publishScript: string,
54
+ options: Pick<LocalPublishOptions, "version" | "reuseValidatedNodeCompile">,
55
+ registry: string,
56
+ ): string[] {
57
+ return [
58
+ publishScript,
59
+ options.version,
60
+ "--registry",
61
+ registry,
62
+ ...(options.reuseValidatedNodeCompile ? ["--skip-compile"] : []),
63
+ ];
64
+ }
65
+
48
66
  /** Publish the exact release candidate to local npm, PyPI, and Cargo mirrors. */
49
67
  export async function publishLocalRelease(options: LocalPublishOptions): Promise<void> {
50
68
  const localRegistry = resolveLocalRegistry(options.localRegistry);
@@ -68,7 +86,7 @@ export async function publishLocalRelease(options: LocalPublishOptions): Promise
68
86
  logger.info(`publishing ${options.version} to local registry ${localRegistry}`);
69
87
  localPublishes.push(
70
88
  exec
71
- .spawn(process.execPath, [publishScript, options.version, "--registry", localRegistry], {
89
+ .spawn(process.execPath, localNodePublishArguments(publishScript, options, localRegistry), {
72
90
  cwd: options.root,
73
91
  stdout: "inherit",
74
92
  stderr: "inherit",
package/tasks/openapi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env -S bun
2
- import { log, string } from "@dbx-tools/shared-core";
2
+ import { log, stringUtils } from "@dbx-tools/shared-core";
3
3
  import { generateBarrels } from "../src/barrels.ts";
4
4
  import { generateOpenapi, isTsoaController } from "../src/openapi.ts";
5
5
  import { runSynth } from "../src/scaffold.ts";
@@ -16,7 +16,7 @@ if (process.argv.includes("--watch")) {
16
16
  const dirs = await generateOpenapi();
17
17
  if (dirs.length) {
18
18
  generateBarrels({ dirs });
19
- logger.success(`regenerated openapi (${string.pluralize(dirs.length, "package")})`);
19
+ logger.success(`regenerated openapi (${stringUtils.pluralize(dirs.length, "package")})`);
20
20
  }
21
21
  });
22
22
  } else {
@@ -12,14 +12,15 @@ import {
12
12
  } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join, relative, resolve } from "node:path";
15
- import { exec } from "@dbx-tools/core";
15
+ import * as exec from "@dbx-tools/core/exec";
16
16
  import { log } from "@dbx-tools/shared-core";
17
17
  import { Command } from "commander";
18
18
 
19
19
  const DEFAULT_REGISTRY = "https://registry.npmjs.org";
20
- const logger = log.logger("dbx-tools:publish-npm");
20
+ const logger = log.logger("projen:publish-npm");
21
21
 
22
22
  export interface NpmReleaseIdentity {
23
+ readonly access?: "public" | "restricted";
23
24
  readonly contentDigest?: string;
24
25
  readonly integrity?: string;
25
26
  readonly name: string;
@@ -101,7 +102,7 @@ export async function publishedNpmRelease(
101
102
  if (!archive.ok) {
102
103
  throw new Error(`npm tarball lookup failed for ${name}@${version}: ${archive.status}`);
103
104
  }
104
- const temp = mkdtempSync(join(tmpdir(), "dbx-tools-published-npm-"));
105
+ const temp = mkdtempSync(join(tmpdir(), "projen-published-npm-"));
105
106
  const path = join(temp, "package.tgz");
106
107
  try {
107
108
  writeFileSync(path, Buffer.from(await archive.arrayBuffer()));
@@ -132,13 +133,19 @@ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
132
133
  }
133
134
  const manifest = JSON.parse(result.stdout) as {
134
135
  name?: string;
136
+ publishConfig?: { access?: unknown };
135
137
  repository?: unknown;
136
138
  version?: string;
137
139
  };
138
140
  if (!manifest.name || !manifest.version) {
139
141
  throw new Error(`npm archive has no package name or version: ${path}`);
140
142
  }
143
+ const access = manifest.publishConfig?.access;
144
+ if (access !== undefined && access !== "public" && access !== "restricted") {
145
+ throw new Error(`npm archive has invalid publishConfig.access: ${String(access)}`);
146
+ }
141
147
  return {
148
+ ...(access ? { access } : {}),
142
149
  contentDigest: npmArchiveContentDigest(path),
143
150
  integrity: `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`,
144
151
  name: manifest.name,
@@ -149,7 +156,7 @@ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
149
156
 
150
157
  /** Hash paths, executable bits, symlink targets, and bytes while ignoring tar metadata. */
151
158
  export function npmArchiveContentDigest(path: string): string {
152
- const temp = mkdtempSync(join(tmpdir(), "dbx-tools-npm-content-"));
159
+ const temp = mkdtempSync(join(tmpdir(), "projen-npm-content-"));
153
160
  try {
154
161
  exec.spawnSync("tar", ["-xzf", path, "-C", temp], {
155
162
  cwd: process.cwd(),
@@ -237,8 +244,7 @@ export async function publishNpmArchives(options: {
237
244
  [
238
245
  "publish",
239
246
  archive,
240
- "--access",
241
- "public",
247
+ ...(local.access ? ["--access", local.access] : []),
242
248
  ...(options.registry ? ["--registry", options.registry] : []),
243
249
  ...(options.dryRun ? ["--dry-run"] : []),
244
250
  ],
@@ -11,7 +11,7 @@ import {
11
11
  } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { basename, join, resolve } from "node:path";
14
- import { exec } from "@dbx-tools/core";
14
+ import * as exec from "@dbx-tools/core/exec";
15
15
  import { Command } from "commander";
16
16
  import { parse, stringify } from "smol-toml";
17
17
  import { pythonProjectInfo, stampPythonProject } from "./uniffi-python.js";
@@ -111,7 +111,7 @@ export function publishPythonProjects(options: {
111
111
  readonly plan?: ReleasePlan;
112
112
  }): void {
113
113
  const root = resolve(options.root);
114
- const output = mkdtempSync(join(tmpdir(), "dbx-tools-python-publish-"));
114
+ const output = mkdtempSync(join(tmpdir(), "projen-python-publish-"));
115
115
  const planned = new Map(
116
116
  options.plan?.pythonPackages.map((pkg) => [pkg.path.replace(/^.*\//, ""), pkg.version]) ?? [],
117
117
  );
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env -S bun
2
- import { spawnSync } from "node:child_process";
3
2
  import { chmodSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
3
  import { arch, platform } from "node:os";
5
4
  import { dirname, join, resolve } from "node:path";
@@ -7,6 +6,7 @@ import { fileURLToPath } from "node:url";
7
6
  import { parseArgs } from "node:util";
8
7
  import { log } from "@dbx-tools/shared-core";
9
8
  import { parse, stringify } from "smol-toml";
9
+ import { captureTaskCommand, runTaskCommand, taskCommandSucceeds } from "../src/_task-command.ts";
10
10
  import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
11
11
  import type { RustBindingMapping, RustWorkspaceMapping } from "../src/project-rs.ts";
12
12
 
@@ -22,17 +22,13 @@ const parsed = parseArgs({
22
22
  });
23
23
 
24
24
  function run(command: string, args: string[], capture = false): string {
25
- const result = spawnSync(command, args, {
26
- cwd: repoRoot,
27
- encoding: capture ? "utf8" : undefined,
28
- stdio: capture ? "pipe" : "inherit",
29
- });
30
- if (result.status !== 0) throw new Error(`${command} exited with ${result.status}`);
31
- return capture ? String(result.stdout).trim() : "";
25
+ if (capture) return captureTaskCommand(repoRoot, command, args, { check: true });
26
+ runTaskCommand(repoRoot, command, args);
27
+ return "";
32
28
  }
33
29
 
34
30
  function commandAvailable(command: string): boolean {
35
- return spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0;
31
+ return taskCommandSucceeds(repoRoot, command, ["--version"]);
36
32
  }
37
33
 
38
34
  function rustHost(): string {
@@ -90,12 +86,12 @@ function artifacts(directory: string, suffix: string): string[] {
90
86
  }
91
87
 
92
88
  function cargoVersionExists(crateName: string, version: string, registry: string): boolean {
93
- return (
94
- spawnSync("cargo", ["info", `${crateName}@${version}`, "--registry", registry], {
95
- cwd: repoRoot,
96
- stdio: "ignore",
97
- }).status === 0
98
- );
89
+ return taskCommandSucceeds(repoRoot, "cargo", [
90
+ "info",
91
+ `${crateName}@${version}`,
92
+ "--registry",
93
+ registry,
94
+ ]);
99
95
  }
100
96
 
101
97
  function publishCargo(config: RustWorkspaceMapping, registry: string, version: string): void {
package/tasks/publish.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /**
3
- * `bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run]`
3
+ * `bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run] [--skip-compile]`
4
4
  * - verify every workspace member and the Bun lock carry the release version,
5
- * then publish each package owned by the standard Node release with `bun publish`.
5
+ * then pack and publish each package owned by the standard Node release.
6
6
  *
7
7
  * Bun has no `pnpm -r publish`, so this loop is the recursive-publish stand-in.
8
8
  * It leans on native bun for everything bun already does:
@@ -26,14 +26,15 @@
26
26
  * its `#!/usr/bin/env node` shebang, node chokes on the `.ts`
27
27
  * (ERR_UNKNOWN_FILE_EXTENSION). We merge `publishConfig` onto the top-level
28
28
  * manifest before packing so the tarball advertises the compiled `lib/` tree;
29
- * - **compiled output** is emitted once, before publishing, by one root-level
29
+ * - **compiled output** is emitted once, before packing, by one root-level
30
30
  * filtered `bun run` that fans out to every publishable member in parallel.
31
- * The later `bun publish --ignore-scripts` calls therefore pack the already
32
- * compiled `lib/` trees instead of serially repeating each member's
33
- * `prepack`. Packages retain their `prepack` task for standalone publishes.
34
- * - **release recovery** packs each exact version before upload and compares
35
- * its integrity and repository identity with registry metadata. A matching
36
- * immutable version is skipped, while any mismatch fails the retry.
31
+ * Every package is then packed once with lifecycle scripts disabled. The
32
+ * exact validated archive is passed to `bun publish`, so upload never
33
+ * repacks or repeats a member's `prepack`. Packages retain `prepack` for
34
+ * standalone publishes.
35
+ * - **release recovery** compares each packed archive's integrity and
36
+ * repository identity with registry metadata. A matching immutable version
37
+ * is skipped, while any mismatch fails the retry.
37
38
  *
38
39
  * `--dry-run` forwards to `bun publish`: it packs + validates
39
40
  * but uploads nothing, so the `release` workflow is testable end-to-end via a
@@ -63,8 +64,7 @@ import {
63
64
  } from "node:fs";
64
65
  import { tmpdir } from "node:os";
65
66
  import { delimiter, dirname, join, resolve } from "node:path";
66
- import { exec } from "@dbx-tools/core";
67
- import { log } from "@dbx-tools/shared-core";
67
+ import { asyncUtils, log } from "@dbx-tools/shared-core";
68
68
  import ts from "typescript";
69
69
  import { parse } from "yaml";
70
70
  import {
@@ -73,9 +73,11 @@ import {
73
73
  publishedNpmRelease,
74
74
  readNpmArchiveIdentity,
75
75
  } from "./publish-npm.ts";
76
+ import { runTaskCommand, runTaskCommandAsync } from "../src/_task-command.ts";
77
+ import { toPosix } from "../src/packages.ts";
76
78
  import type { ReleasePlan } from "../src/release-plan.ts";
77
79
 
78
- const logger = log.logger("dbx-tools:publish");
80
+ const logger = log.logger("projen:publish");
79
81
 
80
82
  /** A manifest's on-disk bytes + mode, captured before this task edits it. */
81
83
  interface ManifestBackup {
@@ -115,7 +117,7 @@ function manifestsMatchVersions(
115
117
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
116
118
  version?: string;
117
119
  };
118
- const path = dir.slice(resolve(root).length + 1).replaceAll("\\", "/");
120
+ const path = toPosix(dir.slice(resolve(root).length + 1));
119
121
  return pkg.version === expected.get(path);
120
122
  });
121
123
  }
@@ -134,10 +136,7 @@ function lockfileMatchesManifestVersions(root: string, members: readonly string[
134
136
  const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
135
137
  version?: string;
136
138
  };
137
- const relative = dir
138
- .slice(resolve(root).length + 1)
139
- .split("\\")
140
- .join("/");
139
+ const relative = toPosix(dir.slice(resolve(root).length + 1));
141
140
  return lock.workspaces?.[relative]?.version === manifest.version;
142
141
  });
143
142
  } catch {
@@ -145,51 +144,6 @@ function lockfileMatchesManifestVersions(root: string, members: readonly string[
145
144
  }
146
145
  }
147
146
 
148
- /** Spawn `command` in `cwd` with `PATH` overridden, failing the task on non-zero. */
149
- function run(cwd: string, command: string, args: string[], path: string): void {
150
- const executable = command === "bun" && process.versions.bun ? process.execPath : command;
151
- exec.spawnSync(executable, args, {
152
- cwd,
153
- stdout: "inherit",
154
- stderr: "inherit",
155
- stdin: "ignore",
156
- check: true,
157
- env: { ...process.env, PATH: path },
158
- });
159
- }
160
-
161
- /** Asynchronous counterpart used for bounded parallel stamping and publishing. */
162
- async function runAsync(cwd: string, command: string, args: string[], path: string): Promise<void> {
163
- const executable = command === "bun" && process.versions.bun ? process.execPath : command;
164
- await exec.spawn(executable, args, {
165
- cwd,
166
- stdout: "inherit",
167
- stderr: "inherit",
168
- stdin: "ignore",
169
- check: true,
170
- env: { ...process.env, PATH: path },
171
- });
172
- }
173
-
174
- /** Run independent jobs with a small fixed worker pool. */
175
- async function runConcurrent<T>(
176
- values: readonly T[],
177
- concurrency: number,
178
- worker: (value: T) => Promise<void>,
179
- ): Promise<void> {
180
- let next = 0;
181
- const results = await Promise.allSettled(
182
- Array.from({ length: Math.min(concurrency, values.length) }, async () => {
183
- while (next < values.length) {
184
- const value = values[next++];
185
- await worker(value);
186
- }
187
- }),
188
- );
189
- const failure = results.find((result) => result.status === "rejected");
190
- if (failure?.status === "rejected") throw failure.reason;
191
- }
192
-
193
147
  /**
194
148
  * Make a projen-readonly manifest writable so the publishConfig projection can
195
149
  * edit it, recording its original bytes and mode for {@link restoreManifests}
@@ -228,6 +182,14 @@ function restoreManifests(): void {
228
182
  /** Entry-point fields projen writes as `.ts` source in-repo and rewrites to `lib/` for publish. */
229
183
  const PUBLISH_CONFIG_ENTRY_FIELDS = ["main", "types", "bin", "exports"] as const;
230
184
 
185
+ /** Compiled files referenced by a publishConfig entry-point tree. */
186
+ function compiledPublishTargets(value: unknown): string[] {
187
+ if (typeof value === "string") return value.startsWith("./lib/") ? [value] : [];
188
+ if (Array.isArray(value)) return value.flatMap(compiledPublishTargets);
189
+ if (!value || typeof value !== "object") return [];
190
+ return Object.values(value).flatMap(compiledPublishTargets);
191
+ }
192
+
231
193
  /**
232
194
  * Fold a package's `publishConfig` entry-point fields onto the top-level manifest,
233
195
  * the way pnpm/npm do at pack time but `bun publish` does NOT (see the module
@@ -272,13 +234,14 @@ const plan =
272
234
  : undefined;
273
235
  if (!version && !plan) {
274
236
  logger.error(
275
- "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--exclude <dir>] [--dry-run]",
237
+ "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--exclude <dir>] [--dry-run] [--skip-compile]",
276
238
  );
277
239
  process.exit(1);
278
240
  }
279
241
  const registryIdx = rest.indexOf("--registry");
280
242
  const registry = registryIdx >= 0 ? rest[registryIdx + 1] : undefined;
281
243
  const dryRun = rest.includes("--dry-run");
244
+ const skipCompile = rest.includes("--skip-compile");
282
245
  const concurrencyIdx = rest.indexOf("--concurrency");
283
246
  const parsedConcurrency = Number(concurrencyIdx >= 0 ? rest[concurrencyIdx + 1] : 4);
284
247
  if (!Number.isInteger(parsedConcurrency) || parsedConcurrency < 1) {
@@ -302,15 +265,10 @@ const allMembers = workspaceMembers(root)
302
265
  const expectedVersions = new Map(
303
266
  plan
304
267
  ? plan.nodePackages.map((pkg) => [pkg.path, pkg.version] as const)
305
- : allMembers.map((dir) => [
306
- dir.slice(resolve(root).length + 1).replaceAll("\\", "/"),
307
- version!,
308
- ]),
268
+ : allMembers.map((dir) => [toPosix(dir.slice(resolve(root).length + 1)), version!]),
309
269
  );
310
270
  const members = plan
311
- ? allMembers.filter((dir) =>
312
- expectedVersions.has(dir.slice(resolve(root).length + 1).replaceAll("\\", "/")),
313
- )
271
+ ? allMembers.filter((dir) => expectedVersions.has(toPosix(dir.slice(resolve(root).length + 1))))
314
272
  : allMembers;
315
273
 
316
274
  if (!manifestsMatchVersions(root, members, expectedVersions)) {
@@ -328,12 +286,10 @@ if (lockfileMatchesManifestVersions(root, allMembers)) {
328
286
  } else {
329
287
  if (existsSync(lockfile)) rmSync(lockfile);
330
288
  logger.info("refreshing lockfile so workspace deps resolve to the release version");
331
- run(root, "bun", ["install"], path);
289
+ runTaskCommand(root, "bun", ["install"], { env: { ...process.env, PATH: path } });
332
290
  }
333
291
 
334
292
  const publishArgs = [
335
- "--access",
336
- "public",
337
293
  "--ignore-scripts",
338
294
  ...(registry ? ["--registry", registry] : []),
339
295
  ...(dryRun ? ["--dry-run"] : []),
@@ -343,6 +299,8 @@ const publishable: Array<{
343
299
  name: string;
344
300
  version: string;
345
301
  compile: boolean;
302
+ compiledTargets: string[];
303
+ access?: "public" | "restricted";
346
304
  }> = [];
347
305
  for (const dir of members) {
348
306
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
@@ -350,7 +308,7 @@ for (const dir of members) {
350
308
  version?: string;
351
309
  private?: boolean;
352
310
  dbxToolsConfig?: { uniffi?: boolean };
353
- scripts?: Record<string, string>;
311
+ publishConfig?: { access?: unknown };
354
312
  };
355
313
  if (pkg.private) {
356
314
  logger.info(`skip private ${pkg.name ?? dirname(dir)}`);
@@ -361,18 +319,46 @@ for (const dir of members) {
361
319
  continue;
362
320
  }
363
321
  if (!pkg.version) throw new Error(`Missing package version for ${pkg.name ?? dirname(dir)}`);
322
+ const access = pkg.publishConfig?.access;
323
+ if (access !== undefined && access !== "public" && access !== "restricted") {
324
+ throw new Error(
325
+ `${pkg.name ?? dirname(dir)} has invalid publishConfig.access ${String(access)}`,
326
+ );
327
+ }
328
+ const compiledTargets = [...new Set(compiledPublishTargets(pkg.publishConfig))];
364
329
  publishable.push({
365
330
  dir,
366
331
  name: pkg.name ?? dirname(dir),
367
332
  version: pkg.version,
368
- compile: Boolean(pkg.scripts && typeof pkg.scripts === "object" && "prepack" in pkg.scripts),
333
+ compile: compiledTargets.length > 0,
334
+ compiledTargets,
335
+ ...(access ? { access } : {}),
369
336
  });
370
337
  }
371
338
 
372
339
  const compiled = publishable.filter((pkg) => pkg.compile);
373
340
  if (compiled.length > 0) {
374
- logger.info(`compiling ${compiled.length} publishable packages from the workspace root`);
375
- run(root, "bun", ["run", ...compiled.flatMap((pkg) => ["--filter", pkg.name]), "compile"], path);
341
+ if (skipCompile) {
342
+ const missing = compiled.flatMap((pkg) =>
343
+ pkg.compiledTargets
344
+ .filter((target) => !existsSync(resolve(pkg.dir, target)))
345
+ .map((target) => `${pkg.name}:${target}`),
346
+ );
347
+ if (missing.length > 0) {
348
+ throw new Error(
349
+ `--skip-compile requires validated compiled output; missing ${missing.join(", ")}`,
350
+ );
351
+ }
352
+ logger.info(`reusing validated compiled output for ${compiled.length} publishable packages`);
353
+ } else {
354
+ logger.info(`compiling ${compiled.length} publishable packages from the workspace root`);
355
+ runTaskCommand(
356
+ root,
357
+ "bun",
358
+ ["run", ...compiled.flatMap((pkg) => ["--filter", pkg.name]), "compile"],
359
+ { env: { ...process.env, PATH: path } },
360
+ );
361
+ }
376
362
  }
377
363
 
378
364
  // Compile against workspace source exports first. Switching manifests to their
@@ -387,9 +373,10 @@ for (const { dir } of publishable) {
387
373
  logger.info(
388
374
  `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
389
375
  );
390
- await runConcurrent(publishable, concurrency, async ({ dir, name, version: packageVersion }) => {
391
- if (!dryRun) {
392
- const packed = mkdtempSync(join(tmpdir(), "dbx-tools-npm-release-"));
376
+ await asyncUtils.mapConcurrent(
377
+ publishable,
378
+ async ({ dir, name, version: packageVersion, access }) => {
379
+ const packed = mkdtempSync(join(tmpdir(), "projen-npm-release-"));
393
380
  try {
394
381
  const archive = packNpmPackage(dir, packed, path);
395
382
  const local = readNpmArchiveIdentity(archive);
@@ -398,16 +385,29 @@ await runConcurrent(publishable, concurrency, async ({ dir, name, version: packa
398
385
  `Packed npm identity ${local.name}@${local.version} does not match ${name}@${packageVersion}`,
399
386
  );
400
387
  }
401
- const published = await publishedNpmRelease(local.name, local.version, registry);
402
- if (npmReleaseMatches(local, published)) {
403
- logger.info(`skip published ${name} @ ${packageVersion}`);
404
- return;
388
+ if (local.access !== access) {
389
+ throw new Error(
390
+ `Packed npm access ${String(local.access)} does not match ${String(access)} for ${name}`,
391
+ );
392
+ }
393
+ if (!dryRun) {
394
+ const published = await publishedNpmRelease(local.name, local.version, registry);
395
+ if (npmReleaseMatches(local, published)) {
396
+ logger.info(`skip published ${name} @ ${packageVersion}`);
397
+ return;
398
+ }
405
399
  }
400
+ logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${packageVersion}`);
401
+ await runTaskCommandAsync(
402
+ dir,
403
+ "bun",
404
+ ["publish", ...(access ? ["--access", access] : []), ...publishArgs, archive],
405
+ { env: { ...process.env, PATH: path } },
406
+ );
406
407
  } finally {
407
408
  rmSync(packed, { recursive: true, force: true });
408
409
  }
409
- }
410
- logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${packageVersion}`);
411
- await runAsync(dir, "bun", ["publish", ...publishArgs], path);
412
- });
410
+ },
411
+ { concurrency, errorMode: "settle" },
412
+ );
413
413
  logger.success(`${dryRun ? "dry-run: packed" : "published"} ${publishable.length} packages`);