@dbx-tools/projen 0.6.195 → 0.6.196

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/tasks/bump.ts CHANGED
@@ -1,70 +1,16 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /**
3
- * `projen bump` - compute the next release version, write it to the workspace
4
- * `VERSION` file, synth so every manifest copies it, then (by default) commit,
5
- * and push. Pushing the release branch triggers the workflow, which creates the
6
- * annotated version tag before publishing.
7
- *
8
- * The base version is the HIGHEST published git tag across `<prefix>` and every
9
- * `--sibling` prefix (fetched from the remote so a release cut elsewhere wins),
10
- * falling back to the local `VERSION` file when the remote is unreachable or has
11
- * no matching tag. A local file that is ahead does NOT override an existing
12
- * remote tag. The base is then incremented by `--level` (patch | minor | major;
13
- * default patch). The remote is consulted only here and on VERSION bootstrap,
14
- * never on an ordinary synth.
15
- *
16
- * `--sibling <dir>:<tagPrefix>` (repeatable) releases an in-repo project that
17
- * publishes on its OWN tag namespace (e.g. `projen/`, tagged `projen-v*`) at the
18
- * SAME version as the root, in the same run: its `<tagPrefix><version>` tag is
19
- * cut and pushed (triggering its own workflow), and it is included in the
20
- * local-registry publish. Taking the base version from every prefix at once is
21
- * what keeps the two in lockstep: the engine sat at 0.1.24 while the packages
22
- * reached 0.3.41 precisely because each namespace only ever looked at its own
23
- * tags. Both draw the fallback from the one root `VERSION` file, so the engine
24
- * and the packages share a single source of truth.
25
- *
26
- * Flags default ON unless noted; negate with the `--no-` form, per commander:
27
- * --synth / --no-synth synth after writing VERSION so manifests copy it
28
- * --version / --no-version write the bumped version into `VERSION`
29
- * --commit / --no-commit commit the release (staged with `git add -A`)
30
- * --tag create the `<prefix><version>` git tag locally (default OFF)
31
- * --push / --no-push push the CURRENT branch to origin
32
- *
33
- * `--publish` / `--no-publish` is an alias for `--push`. A push publishes only
34
- * when the current branch is the configured release branch. The tag prefix
35
- * comes from `--prefix` (default `v`).
36
- *
37
- * `--local-registry <value>` publishes npm packages to a LOCAL registry (e.g.
38
- * verdaccio) right after the branch push. `--local-pypi <value>` does the same for
39
- * Python packages through a writable devpi index. Values for both:
40
- * - `auto` (default): publish only when `npm config get registry` is a
41
- * loopback host, or when ANY active Python index - the primary
42
- * `index-url` OR any `extra-index-url`, across uv and pip - is a loopback
43
- * devpi URL. Scanning the extras is what lets the corp proxy stay the
44
- * primary index while a local devpi added as an extra is the detected
45
- * publish target. The deploy endpoint for that index is taken from the
46
- * GLOBAL uv config - an explicit `publish-url` on the matching `[[index]]`
47
- * (or `UV_PUBLISH_URL`) - falling back to deriving it from the `+simple`
48
- * URL shape when no such setting exists.
49
- * - `false`: never publish locally.
50
- * - a URL: always publish to that registry.
3
+ * Compute the next workspace version, write VERSION, and synthesize every
4
+ * generated version surface. This task has no git, registry, or publication
5
+ * side effects; release preparation owns those operations.
51
6
  */
52
- import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
53
- import { homedir } from "node:os";
54
- import { join, resolve } from "node:path";
55
- import { fileURLToPath } from "node:url";
56
7
  import { exec, project } from "@dbx-tools/core";
57
- import { log, net } from "@dbx-tools/shared-core";
8
+ import { log } from "@dbx-tools/shared-core";
58
9
  import { Command, Option } from "commander";
59
- import { activePythonIndexes, resolveLocalPypi } from "./python-registry.ts";
60
10
  import { releasePlatformFilter } from "../src/_release-platform.ts";
61
- import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
62
11
  import {
63
- type Semver,
64
- compareSemver,
65
- latestTagVersion,
66
- parseSemver,
67
- resolveBaseVersion,
12
+ type VersionLevel,
13
+ resolveNextVersion,
68
14
  writeWorkspaceVersion,
69
15
  } from "../src/workspace-version.ts";
70
16
 
@@ -72,129 +18,20 @@ const logger = log.logger("projen:bump");
72
18
  const LEVELS = ["patch", "minor", "major"] as const;
73
19
  const RELEASE_OSES = ["darwin", "linux", "win32"] as const;
74
20
  const RELEASE_ARCHES = ["arm64", "x64"] as const;
75
- type Level = (typeof LEVELS)[number];
76
21
  type ReleaseOs = (typeof RELEASE_OSES)[number];
77
22
  type ReleaseArch = (typeof RELEASE_ARCHES)[number];
78
23
 
79
- /** A standalone in-repo project released alongside the root, on its own tag prefix. */
80
- interface Sibling {
81
- /** Repo-relative directory, e.g. `projen`. */
82
- readonly dir: string;
83
- /** Git tag prefix, disjoint from the root's, e.g. `projen-v`. */
84
- readonly prefix: string;
85
- }
86
-
87
- /**
88
- * Commander collector for the repeatable `--sibling <dir>:<tagPrefix>`. Split on
89
- * the LAST colon so a directory containing one still parses.
90
- */
91
- function parseSibling(value: string, previous: Sibling[]): Sibling[] {
92
- const at = value.lastIndexOf(":");
93
- if (at <= 0 || at === value.length - 1) {
94
- throw new Error(`--sibling expects <dir>:<tagPrefix>, got "${value}"`);
95
- }
96
- return [...previous, { dir: value.slice(0, at), prefix: value.slice(at + 1) }];
97
- }
98
-
99
24
  function collectValue<T extends string>(value: T, previous: T[]): T[] {
100
25
  return [...previous, value];
101
26
  }
102
27
 
103
- function increment(v: Semver, level: Level): Semver {
104
- if (level === "major") return [v[0] + 1, 0, 0];
105
- if (level === "minor") return [v[0], v[1] + 1, 0];
106
- return [v[0], v[1], v[2] + 1];
107
- }
108
-
109
- function git(args: string[], capture = false): string {
110
- const res = exec.spawnSync("git", args, {
111
- cwd: process.cwd(),
112
- stdout: capture ? "capture" : "inherit",
113
- stderr: capture ? "ignore" : "inherit",
114
- stdin: "ignore",
115
- check: !capture,
116
- });
117
- return res.stdout?.trim() ?? "";
118
- }
119
-
120
- function assertReleaseTagsPointToHead(tags: readonly string[]): void {
121
- const head = git(["rev-parse", "HEAD"], true);
122
- for (const tag of tags) {
123
- const target = git(["rev-parse", `${tag}^{commit}`], true);
124
- if (!head || target !== head) {
125
- throw new Error(`Release tag ${tag} does not point to the pushed HEAD commit`);
126
- }
127
- }
128
- }
129
-
130
- /**
131
- * Write ONLY the `version` field into a manifest projen owns (read-only, so
132
- * bracketed by a chmod that restores the mode). Used for the ROOT and the
133
- * standalone `projen/` sibling so the committed release marks the version.
134
- *
135
- * It deliberately does NOT rewrite `@scope/*` dep ranges. `projen/` is a workspace
136
- * member with `workspace:*` sibling deps; baking a `^version` for the just-bumped
137
- * (not-yet-published) version into the COMMITTED manifest breaks the release
138
- * workflow's initial `bun install`, which checks out that commit before anything
139
- * is published. Sibling versions are resolved transiently at publish time instead:
140
- * The bump stamps every workspace member before committing so independently
141
- * synthesized members and the Bun lock resolve the same release version.
142
- */
143
- function writeManifestVersion(pkgPath: string, version: string): void {
144
- const { mode } = statSync(pkgPath);
145
- chmodSync(pkgPath, mode | 0o200);
146
- try {
147
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
148
- pkg.version = version;
149
- writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
150
- } finally {
151
- chmodSync(pkgPath, mode);
152
- }
153
- }
154
-
155
- /**
156
- * Resolve the `--local-registry` value to a registry URL to publish to, or
157
- * `undefined` to skip. `false` skips; a URL is used as-is; `auto` uses the
158
- * active npm registry only when it is a loopback host (a local verdaccio etc.).
159
- */
160
- function resolveLocalRegistry(value: string): string | undefined {
161
- const trimmed = value.trim();
162
- if (!trimmed || trimmed.toLowerCase() === "false") return undefined;
163
- if (trimmed.toLowerCase() === "auto") {
164
- const registry = project.npmRegistry();
165
- return registry && net.isLoopbackHost(registry) ? registry.href : undefined;
166
- }
167
- return trimmed;
168
- }
169
-
170
- function localCargoRegistry(): string | undefined {
171
- if (process.env.LOCAL_CARGO_REGISTRY) return process.env.LOCAL_CARGO_REGISTRY;
172
- const config = join(homedir(), ".cargo", "config.toml");
173
- if (!existsSync(config)) return undefined;
174
- const source = readFileSync(config, "utf8");
175
- const sections = [
176
- ...source.matchAll(/^\[registries\.([^\]]+)\]\s*\n([\s\S]*?)(?=^\[|(?![\s\S]))/gm),
177
- ];
178
- for (const section of sections) {
179
- const index = section[2]?.match(/^\s*index\s*=\s*["']([^"']+)["']/m)?.[1];
180
- if (index && net.isLoopbackHost(index.replace(/^sparse\+/, ""))) return section[1];
181
- }
182
- return undefined;
183
- }
184
-
185
28
  const program = new Command();
186
29
  program
187
- .description("Bump the release version, then commit and push it")
30
+ .description("Increment VERSION and synchronize generated workspace versions")
188
31
  .addOption(
189
32
  new Option("-l, --level <level>", "semver increment").choices([...LEVELS]).default("patch"),
190
33
  )
191
- .option("--prefix <prefix>", "git tag prefix", "v")
192
- .option(
193
- "--sibling <dir:prefix>",
194
- "standalone in-repo project (not a workspace member) to release at the same version, repeatable",
195
- parseSibling,
196
- [] as Sibling[],
197
- )
34
+ .option("--prefix <prefix>", "git tag prefix used to resolve the published base", "v")
198
35
  .addOption(
199
36
  new Option("--os <os>", "release operating system, repeatable; crossed with every --arch")
200
37
  .choices([...RELEASE_OSES])
@@ -207,98 +44,28 @@ program
207
44
  .argParser((value, previous: ReleaseArch[]) => collectValue(value as ReleaseArch, previous))
208
45
  .default([] as ReleaseArch[]),
209
46
  )
210
- // Declared in the `--no-` form so commander creates a boolean that defaults to
211
- // `true` and is turned off by `--no-synth` / `--no-version` / ... (the
212
- // positive `--synth` etc. also work and are no-ops on the default).
213
- .option("--no-synth", "do not run `projen` (synth) before bumping")
214
- .option("--no-version", "do not write the bumped version into package.json")
215
- .option("--no-commit", "do not commit the version change")
216
- .option("--tag", "create the release tag locally instead of leaving it to the workflow")
217
- .option("--no-push", "do not push the current branch to origin")
218
- // `--publish` is a friendlier alias for `--push`.
219
- .option("--no-publish", "alias for --no-push")
220
- .option(
221
- "--local-registry <value>",
222
- "publish locally after the branch push: 'auto' (only a loopback npm registry), 'false', or a registry URL",
223
- "auto",
224
- )
225
- .option(
226
- "--local-pypi <value>",
227
- "publish Python packages locally: 'auto' (any active index-url or extra-index-url that is a loopback devpi +simple), 'false', or a devpi URL",
228
- "auto",
229
- )
230
- .option("--python-root <path>", "Python workspace package root", "packages/py")
47
+ .option("--no-synth", "write VERSION without synchronizing generated files")
231
48
  .action(
232
- async (opts: {
233
- level: Level;
49
+ (opts: {
50
+ level: VersionLevel;
234
51
  prefix: string;
235
- sibling: Sibling[];
236
52
  os: ReleaseOs[];
237
53
  arch: ReleaseArch[];
238
54
  synth: boolean;
239
- version: boolean;
240
- commit: boolean;
241
- tag: boolean;
242
- push: boolean;
243
- publish: boolean;
244
- localPypi: string;
245
- localRegistry: string;
246
- pythonRoot: string;
247
55
  }) => {
248
- const pkgPath = resolve(process.cwd(), "package.json");
249
- if (!existsSync(pkgPath)) throw new Error(`no package.json in ${process.cwd()}`);
250
- const releasePlatforms = releasePlatformFilter(opts.os, opts.arch);
251
-
252
- const siblings = opts.sibling.map((s) => ({ ...s, pkgPath: resolve(s.dir, "package.json") }));
253
- for (const s of siblings) {
254
- if (!existsSync(s.pkgPath)) throw new Error(`--sibling ${s.dir}: no package.json there`);
255
- }
256
-
257
- // The `VERSION` file is the workspace source of truth and lives at the repo
258
- // root, even when this task runs from a subdirectory (`cd projen && bun run
259
- // bump`), so the engine and packages always share one number.
260
56
  const root = project.root() ?? process.cwd();
57
+ const next = resolveNextVersion(root, [opts.prefix], opts.level);
58
+ const releasePlatforms = releasePlatformFilter(opts.os, opts.arch);
261
59
 
262
- // Base = the highest published tag across EVERY namespace being released
263
- // (fetched from the remote so a release cut elsewhere wins), falling back to
264
- // the local `VERSION` file when the remote is unreachable or has no tag. A
265
- // local file that happens to be ahead does NOT override an existing remote.
266
- const prefixes = [opts.prefix, ...siblings.map((s) => s.prefix)];
267
- const baseInfo = resolveBaseVersion(root, prefixes);
268
- const base = parseSemver(baseInfo.version) ?? [0, 0, 1];
269
- const next = increment(base, opts.level);
270
- const version = next.join(".");
271
- const tags = prefixes.map((prefix) => `${prefix}${version}`);
272
60
  logger.info(
273
- `bump ${base.join(".")} -> ${version} (${opts.level}); tags ${tags.join(", ")}` +
274
- `${baseInfo.source === "remote" ? "" : " [no remote tag; used local VERSION]"}`,
61
+ `bump ${next.base} -> ${next.version} (${opts.level})` +
62
+ `${next.source === "remote" ? "" : " [no remote tag; used local VERSION]"}`,
275
63
  );
276
- // Note any tag namespace that trailed the base so a lockstep catch-up is visible.
277
- for (const prefix of prefixes) {
278
- const tagged = latestTagVersion(root, prefix);
279
- if (tagged && compareSemver(tagged, base) < 0) {
280
- logger.info(`${prefix}* was behind at ${tagged.join(".")}, catching it up`);
281
- }
282
- }
283
-
284
- const push = opts.push && opts.publish;
285
- const createTags = opts.tag || siblings.length > 0;
286
-
287
- // Write the source of truth first, then synth so every generated manifest
288
- // COPIES it - synth never invents, resets, or drifts a version on its own.
289
- if (opts.version) {
290
- writeWorkspaceVersion(root, version);
291
- // Keep the writable root/sibling manifests coherent even when synth is
292
- // skipped (`--no-synth`); synth would otherwise set the same value.
293
- writeManifestVersion(pkgPath, version);
294
- for (const s of siblings) writeManifestVersion(s.pkgPath, version);
295
- logger.info(`wrote version ${version} to ${root}/VERSION`);
296
- }
64
+ writeWorkspaceVersion(root, next.version);
297
65
 
298
66
  if (opts.synth) {
299
- logger.info("synthesizing (projen)");
300
- exec.spawnSync("bun", [".projenrc.ts"], {
301
- cwd: process.cwd(),
67
+ exec.spawnSync(process.execPath, [".projenrc.ts"], {
68
+ cwd: root,
302
69
  stdout: "inherit",
303
70
  stderr: "inherit",
304
71
  stdin: "ignore",
@@ -309,194 +76,7 @@ program
309
76
  },
310
77
  });
311
78
  }
312
- if (opts.version) {
313
- const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
314
- exec.spawnSync("bun", [publishScript, version, "--stamp-only"], {
315
- cwd: process.cwd(),
316
- stdout: "inherit",
317
- stderr: "inherit",
318
- stdin: "ignore",
319
- check: true,
320
- });
321
- if (existsSync(join(root, "Cargo.toml"))) {
322
- logger.info("refreshing Cargo.lock workspace versions");
323
- exec.spawnSync("cargo", ["metadata", "--format-version", "1", "--no-deps"], {
324
- cwd: process.cwd(),
325
- stdout: "ignore",
326
- stderr: "inherit",
327
- stdin: "ignore",
328
- check: true,
329
- });
330
- }
331
- }
332
-
333
- if (opts.commit) {
334
- // Stage the whole tree so the release commit captures the version bump
335
- // plus anything synth regenerated. Skip the commit when nothing changed.
336
- git(["add", "-A"]);
337
- const staged = git(["diff", "--cached", "--name-only"], true);
338
- if (staged) {
339
- git([
340
- "commit",
341
- "-m",
342
- `chore(release): ${version}`,
343
- "-m",
344
- "Shipped with Kanna - https://kanna.sh",
345
- "-m",
346
- "Co-Authored-By: Kanna <noreply@kanna.sh>\nKanna-Agent: codex/databricks-gpt-5-6-sol",
347
- ]);
348
- } else {
349
- logger.info("nothing to commit");
350
- }
351
- }
352
-
353
- if (createTags) {
354
- for (const t of tags) git(["tag", "-a", t, "-m", t]);
355
- logger.info(`tagged ${tags.join(", ")}`);
356
- }
357
-
358
- if (push) {
359
- git(["push", "origin", "HEAD"]);
360
- if (createTags) {
361
- assertReleaseTagsPointToHead(tags);
362
- git(["push", "--no-verify", "origin", ...tags]);
363
- }
364
- logger.success(`pushed ${createTags ? tags.join(", ") : "HEAD"} to origin`);
365
- } else {
366
- logger.info("skipped push (--no-push / --no-publish)");
367
- }
368
-
369
- // Local registry publication starts after the branch push so the GitHub
370
- // release workflow can own public publication independently.
371
- const localRegistry = resolveLocalRegistry(opts.localRegistry);
372
- const publishToLocalRegistry = opts.version && localRegistry;
373
- if (opts.version === false && localRegistry) {
374
- logger.info("skipped local publish (--no-version left package.json unbumped)");
375
- }
376
- // Scan EVERY active index (primary index-url + every extra-index-url,
377
- // across uv and pip): auto-mode publishes to the first that is a loopback
378
- // devpi +simple, so the corp proxy stays primary and a local devpi added
379
- // as an extra index is the detected publish target.
380
- const activeIndexes = activePythonIndexes();
381
- const localPypi = resolveLocalPypi(opts.localPypi, activeIndexes);
382
- const pythonRoot = resolve(opts.pythonRoot);
383
- if (
384
- opts.localPypi.toLowerCase() === "auto" &&
385
- activeIndexes.some((index) => net.isLoopbackHost(index)) &&
386
- !localPypi
387
- ) {
388
- logger.info(
389
- `skipped local Python publish: no active index (${activeIndexes.join(", ")}) is a devpi +simple index`,
390
- );
391
- }
392
- if (opts.version === false && localPypi) {
393
- logger.info("skipped local Python publish (--no-version left packages unstamped)");
394
- }
395
-
396
- // npm and Python touch disjoint package trees and registries. Start both
397
- // mirrors together so local publishing takes the slower duration rather
398
- // than the sum of both. Each child still owns its internal build/upload
399
- // ordering and restores its temporary manifest edits on exit.
400
- const localPublishes: Promise<unknown>[] = [];
401
- if (publishToLocalRegistry) {
402
- logger.info(`publishing ${version} to local registry ${localRegistry}`);
403
- // The shared driver skips stamping/install when synth already made the
404
- // workspace release-current, compiles publishable members once from the
405
- // root, then publishes through a bounded pool. Temporary publishConfig
406
- // edits are restored before this child exits.
407
- const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
408
- localPublishes.push(
409
- exec
410
- .spawn("bun", [publishScript, version, "--registry", localRegistry], {
411
- cwd: process.cwd(),
412
- stdout: "inherit",
413
- stderr: "inherit",
414
- stdin: "ignore",
415
- check: true,
416
- })
417
- .then(() => logger.success(`published ${version} to ${localRegistry}`)),
418
- );
419
- }
420
- if (opts.version && localPypi && existsSync(pythonRoot)) {
421
- logger.info(`publishing Python ${version} to local devpi ${localPypi.publishUrl}`);
422
- const publishPythonScript = fileURLToPath(new URL("./publish-python.ts", import.meta.url));
423
- localPublishes.push(
424
- exec
425
- .spawn(
426
- "bun",
427
- [
428
- publishPythonScript,
429
- version,
430
- "--root",
431
- pythonRoot,
432
- "--index-url",
433
- localPypi.indexUrl,
434
- "--publish-url",
435
- localPypi.publishUrl,
436
- ],
437
- {
438
- cwd: process.cwd(),
439
- stdout: "inherit",
440
- stderr: "inherit",
441
- stdin: "ignore",
442
- check: true,
443
- },
444
- )
445
- .then(() => logger.success(`published Python ${version} to ${localPypi.publishUrl}`)),
446
- );
447
- }
448
- await Promise.all(localPublishes);
449
- if (opts.version && (publishToLocalRegistry || localPypi)) {
450
- const publishUniFFIScript = fileURLToPath(
451
- new URL("./publish-uniffi-local.ts", import.meta.url),
452
- );
453
- await exec.spawn(
454
- "bun",
455
- [
456
- publishUniFFIScript,
457
- "--version",
458
- version,
459
- ...(publishToLocalRegistry ? ["--registry", localRegistry] : []),
460
- ...(localPypi ? ["--pypi-publish-url", localPypi.publishUrl] : []),
461
- ],
462
- {
463
- cwd: process.cwd(),
464
- stdout: "inherit",
465
- stderr: "inherit",
466
- stdin: "ignore",
467
- check: true,
468
- },
469
- );
470
- logger.success(`published host-native bindings for ${version}`);
471
- }
472
- const rust = readDbxToolsConfig(repoRoot)?.rust;
473
- const hasRustCrates = Boolean(
474
- rust &&
475
- typeof rust === "object" &&
476
- !Array.isArray(rust) &&
477
- Array.isArray((rust as { crates?: unknown }).crates) &&
478
- (rust as { crates: unknown[] }).crates.length,
479
- );
480
- if (opts.version && hasRustCrates) {
481
- const cargoRegistry = localCargoRegistry();
482
- if (cargoRegistry) {
483
- const publishUniFFIScript = fileURLToPath(
484
- new URL("./publish-uniffi-local.ts", import.meta.url),
485
- );
486
- await exec.spawn(
487
- "bun",
488
- [publishUniFFIScript, "--version", version, "--cargo-registry", cargoRegistry],
489
- {
490
- cwd: process.cwd(),
491
- stdout: "inherit",
492
- stderr: "inherit",
493
- stdin: "ignore",
494
- check: true,
495
- },
496
- );
497
- logger.success(`published Rust ${version} to ${cargoRegistry}`);
498
- }
499
- }
79
+ logger.success(`workspace version synchronized at ${next.version}`);
500
80
  },
501
81
  );
502
82
 
package/tasks/clean.ts CHANGED
@@ -52,7 +52,7 @@ const picked = await clack.multiselect<string>({
52
52
  required: false,
53
53
  });
54
54
 
55
- if (clack.isCancel(picked)) {
55
+ if (typeof picked === "symbol") {
56
56
  clack.cancel("clean cancelled - nothing removed");
57
57
  process.exit(0);
58
58
  }
@@ -0,0 +1,166 @@
1
+ /** Local registry preflight shared by release preparation. */
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { exec, project } from "@dbx-tools/core";
7
+ import { log, net } from "@dbx-tools/shared-core";
8
+ import { activePythonIndexes, resolveLocalPypi } from "./python-registry.ts";
9
+ import { readDbxToolsConfig } from "../src/packages.ts";
10
+
11
+ const logger = log.logger("projen:local-publish");
12
+
13
+ /** Options for publishing one release candidate to configured loopback registries. */
14
+ export interface LocalPublishOptions {
15
+ readonly root: string;
16
+ readonly version: string;
17
+ readonly localRegistry: string;
18
+ readonly localPypi: string;
19
+ readonly pythonRoot: string;
20
+ readonly localCargo: boolean;
21
+ }
22
+
23
+ function resolveLocalRegistry(value: string): string | undefined {
24
+ const trimmed = value.trim();
25
+ if (!trimmed || trimmed.toLowerCase() === "false") return undefined;
26
+ if (trimmed.toLowerCase() === "auto") {
27
+ const registry = project.npmRegistry();
28
+ return registry && net.isLoopbackHost(registry) ? registry.href : undefined;
29
+ }
30
+ return trimmed;
31
+ }
32
+
33
+ function localCargoRegistry(): string | undefined {
34
+ if (process.env.LOCAL_CARGO_REGISTRY) return process.env.LOCAL_CARGO_REGISTRY;
35
+ const config = join(homedir(), ".cargo", "config.toml");
36
+ if (!existsSync(config)) return undefined;
37
+ const source = readFileSync(config, "utf8");
38
+ const sections = [
39
+ ...source.matchAll(/^\[registries\.([^\]]+)\]\s*\n([\s\S]*?)(?=^\[|(?![\s\S]))/gm),
40
+ ];
41
+ for (const section of sections) {
42
+ const index = section[2]?.match(/^\s*index\s*=\s*["']([^"']+)["']/m)?.[1];
43
+ if (index && net.isLoopbackHost(index.replace(/^sparse\+/, ""))) return section[1];
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /** Publish the exact release candidate to local npm, PyPI, and Cargo mirrors. */
49
+ export async function publishLocalRelease(options: LocalPublishOptions): Promise<void> {
50
+ const localRegistry = resolveLocalRegistry(options.localRegistry);
51
+ const activeIndexes = activePythonIndexes();
52
+ const localPypi = resolveLocalPypi(options.localPypi, activeIndexes);
53
+ const pythonRoot = resolve(options.root, options.pythonRoot);
54
+ const localPublishes: Promise<unknown>[] = [];
55
+
56
+ if (
57
+ options.localPypi.toLowerCase() === "auto" &&
58
+ activeIndexes.some((index) => net.isLoopbackHost(index)) &&
59
+ !localPypi
60
+ ) {
61
+ logger.info(
62
+ `skipped local Python publish: no active index (${activeIndexes.join(", ")}) is a devpi +simple index`,
63
+ );
64
+ }
65
+
66
+ if (localRegistry) {
67
+ const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
68
+ logger.info(`publishing ${options.version} to local registry ${localRegistry}`);
69
+ localPublishes.push(
70
+ exec
71
+ .spawn(process.execPath, [publishScript, options.version, "--registry", localRegistry], {
72
+ cwd: options.root,
73
+ stdout: "inherit",
74
+ stderr: "inherit",
75
+ stdin: "ignore",
76
+ check: true,
77
+ })
78
+ .then(() => logger.success(`published ${options.version} to ${localRegistry}`)),
79
+ );
80
+ }
81
+
82
+ if (localPypi && existsSync(pythonRoot)) {
83
+ const publishPythonScript = fileURLToPath(new URL("./publish-python.ts", import.meta.url));
84
+ logger.info(`publishing Python ${options.version} to local devpi ${localPypi.publishUrl}`);
85
+ localPublishes.push(
86
+ exec
87
+ .spawn(
88
+ process.execPath,
89
+ [
90
+ publishPythonScript,
91
+ options.version,
92
+ "--root",
93
+ pythonRoot,
94
+ "--index-url",
95
+ localPypi.indexUrl,
96
+ "--publish-url",
97
+ localPypi.publishUrl,
98
+ ],
99
+ {
100
+ cwd: options.root,
101
+ stdout: "inherit",
102
+ stderr: "inherit",
103
+ stdin: "ignore",
104
+ check: true,
105
+ },
106
+ )
107
+ .then(() =>
108
+ logger.success(`published Python ${options.version} to ${localPypi.publishUrl}`),
109
+ ),
110
+ );
111
+ }
112
+
113
+ await Promise.all(localPublishes);
114
+ if (localRegistry || localPypi) {
115
+ const publishUniFFIScript = fileURLToPath(
116
+ new URL("./publish-uniffi-local.ts", import.meta.url),
117
+ );
118
+ await exec.spawn(
119
+ process.execPath,
120
+ [
121
+ publishUniFFIScript,
122
+ "--version",
123
+ options.version,
124
+ ...(localRegistry ? ["--registry", localRegistry] : []),
125
+ ...(localPypi ? ["--pypi-publish-url", localPypi.publishUrl] : []),
126
+ ],
127
+ {
128
+ cwd: options.root,
129
+ stdout: "inherit",
130
+ stderr: "inherit",
131
+ stdin: "ignore",
132
+ check: true,
133
+ },
134
+ );
135
+ logger.success(`published host-native bindings for ${options.version}`);
136
+ }
137
+
138
+ const rust = readDbxToolsConfig(options.root)?.rust;
139
+ const hasRustCrates = Boolean(
140
+ rust &&
141
+ typeof rust === "object" &&
142
+ !Array.isArray(rust) &&
143
+ Array.isArray((rust as { crates?: unknown }).crates) &&
144
+ (rust as { crates: unknown[] }).crates.length > 0,
145
+ );
146
+ if (options.localCargo && hasRustCrates) {
147
+ const cargoRegistry = localCargoRegistry();
148
+ if (cargoRegistry) {
149
+ const publishUniFFIScript = fileURLToPath(
150
+ new URL("./publish-uniffi-local.ts", import.meta.url),
151
+ );
152
+ await exec.spawn(
153
+ process.execPath,
154
+ [publishUniFFIScript, "--version", options.version, "--cargo-registry", cargoRegistry],
155
+ {
156
+ cwd: options.root,
157
+ stdout: "inherit",
158
+ stderr: "inherit",
159
+ stdin: "ignore",
160
+ check: true,
161
+ },
162
+ );
163
+ logger.success(`published Rust ${options.version} to ${cargoRegistry}`);
164
+ }
165
+ }
166
+ }