@dbx-tools/projen 0.9.47 → 0.9.51

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.
@@ -1,6 +1,468 @@
1
1
  #!/usr/bin/env -S bun
2
- import { main } from "./lib/publish-npm.ts";
2
+ /** Idempotent npm archive publication for release recovery. */
3
+ import { createHash } from "node:crypto";
4
+ import {
5
+ chmodSync,
6
+ lstatSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ readlinkSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join, relative, resolve } from "node:path";
16
+ import * as exec from "@dbx-tools/core/exec";
17
+ import * as projectUtils from "@dbx-tools/core/project-utils";
18
+ import { log } from "@dbx-tools/shared-core";
19
+ import { Command } from "commander";
20
+ import { runTaskCommand } from "../src/_task-command.ts";
3
21
 
4
- export * from "./lib/publish-npm.ts";
22
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
23
+ const logger = log.logger("projen:publish-npm");
24
+
25
+ /** Registry and archive identity used to verify idempotent npm publication. */
26
+ export interface NpmReleaseIdentity {
27
+ readonly access?: "public" | "restricted";
28
+ readonly contentDigest?: string;
29
+ readonly integrity?: string;
30
+ readonly name: string;
31
+ readonly repository?: unknown;
32
+ readonly version: string;
33
+ }
34
+
35
+ interface NpmArchiveManifest {
36
+ readonly [key: string]: unknown;
37
+ readonly dependencies?: Readonly<Record<string, string>>;
38
+ readonly optionalDependencies?: Readonly<Record<string, string>>;
39
+ readonly name?: string;
40
+ readonly publishConfig?: { readonly access?: unknown };
41
+ readonly repository?: unknown;
42
+ readonly version?: string;
43
+ }
44
+
45
+ /** Transform applied to a packed npm manifest before archive verification. */
46
+ export type NpmArchiveManifestTransform = (
47
+ manifest: NpmArchiveManifest,
48
+ ) => Readonly<Record<string, unknown>>;
49
+
50
+ /** Entry-point fields projected from publishConfig into a packed manifest. */
51
+ const PUBLISH_CONFIG_ENTRY_FIELDS = ["main", "types", "bin", "exports"] as const;
52
+ const DEPENDENCY_FIELDS = [
53
+ "dependencies",
54
+ "devDependencies",
55
+ "optionalDependencies",
56
+ "peerDependencies",
57
+ ] as const;
58
+
59
+ /** Replace workspace and catalogue protocols with publishable registry ranges. */
60
+ export function materializeWorkspaceManifest(
61
+ source: Readonly<Record<string, unknown>>,
62
+ workspace: Readonly<Record<string, unknown>>,
63
+ ): Readonly<Record<string, unknown>> {
64
+ const manifest = structuredClone(source) as Record<string, unknown>;
65
+ const version = workspace.version;
66
+ const catalog =
67
+ workspace.catalog && typeof workspace.catalog === "object"
68
+ ? (workspace.catalog as Readonly<Record<string, unknown>>)
69
+ : {};
70
+ if (typeof version !== "string" || !version) {
71
+ throw new Error("Workspace manifest has no version");
72
+ }
73
+ for (const field of DEPENDENCY_FIELDS) {
74
+ const dependencies = manifest[field];
75
+ if (!dependencies || typeof dependencies !== "object") continue;
76
+ manifest[field] = Object.fromEntries(
77
+ Object.entries(dependencies).map(([name, value]) => [
78
+ name,
79
+ materializeDependency(name, value, version, catalog),
80
+ ]),
81
+ );
82
+ }
83
+ return manifest;
84
+ }
85
+
86
+ function materializeDependency(
87
+ name: string,
88
+ value: unknown,
89
+ version: string,
90
+ catalog: Readonly<Record<string, unknown>>,
91
+ ): unknown {
92
+ if (value === "catalog:") {
93
+ const resolved = catalog[name];
94
+ if (typeof resolved !== "string" || !resolved) {
95
+ throw new Error(`Workspace catalog has no version for ${name}`);
96
+ }
97
+ return resolved;
98
+ }
99
+ if (typeof value !== "string" || !value.startsWith("workspace:")) return value;
100
+ const selector = value.slice("workspace:".length);
101
+ if (selector === "*" || !selector) return version;
102
+ if (selector === "^" || selector === "~") return `${selector}${version}`;
103
+ return selector;
104
+ }
105
+
106
+ /**
107
+ * Fold compiled publish entry points onto an archive manifest without changing
108
+ * the source checkout.
109
+ */
110
+ export function applyPublishConfig(
111
+ source: Readonly<Record<string, unknown>>,
112
+ ): Readonly<Record<string, unknown>> {
113
+ const manifest = { ...source };
114
+ const publishConfig = manifest.publishConfig as Record<string, unknown> | undefined;
115
+ if (!publishConfig) return manifest;
116
+ for (const field of PUBLISH_CONFIG_ENTRY_FIELDS) {
117
+ if (field in publishConfig) manifest[field] = publishConfig[field];
118
+ }
119
+ return manifest;
120
+ }
121
+
122
+ function archiveFileEntries(directory: string, prefix = ""): string[] {
123
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
124
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
125
+ return entry.isDirectory()
126
+ ? archiveFileEntries(join(directory, entry.name), relativePath)
127
+ : [relativePath];
128
+ });
129
+ }
130
+
131
+ function normalizedRepository(value: unknown): string | undefined {
132
+ const url =
133
+ typeof value === "string"
134
+ ? value
135
+ : value && typeof value === "object" && "url" in value
136
+ ? String(value.url)
137
+ : undefined;
138
+ return url
139
+ ?.replace(/^git\+/, "")
140
+ .replace(/\.git$/, "")
141
+ .replace(/\/$/, "");
142
+ }
143
+
144
+ function registryUrl(registry: string, name: string, version: string): string {
145
+ return `${registry.replace(/\/$/, "")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
146
+ }
147
+
148
+ /** Verify that registry metadata represents the exact locally packed release. */
149
+ export function npmReleaseMatches(
150
+ local: NpmReleaseIdentity,
151
+ published: NpmReleaseIdentity | undefined,
152
+ ): boolean {
153
+ if (!published) return false;
154
+ if (published.name !== local.name || published.version !== local.version) {
155
+ throw new Error(`Published npm identity does not match ${local.name}@${local.version}`);
156
+ }
157
+ const localRepository = normalizedRepository(local.repository);
158
+ const publishedRepository = normalizedRepository(published.repository);
159
+ if (localRepository && publishedRepository && localRepository !== publishedRepository) {
160
+ throw new Error(`Published npm repository does not match ${local.name}@${local.version}`);
161
+ }
162
+ if (
163
+ local.contentDigest &&
164
+ published.contentDigest &&
165
+ local.contentDigest !== published.contentDigest
166
+ ) {
167
+ throw new Error(`Published npm content does not match ${local.name}@${local.version}`);
168
+ }
169
+ if (
170
+ !(local.contentDigest && published.contentDigest) &&
171
+ local.integrity &&
172
+ published.integrity !== local.integrity
173
+ ) {
174
+ throw new Error(`Published npm integrity does not match ${local.name}@${local.version}`);
175
+ }
176
+ return true;
177
+ }
178
+
179
+ /** Read one published npm release identity, or `undefined` when it is absent. */
180
+ export async function publishedNpmRelease(
181
+ name: string,
182
+ version: string,
183
+ registry = process.env.NPM_CONFIG_REGISTRY ?? DEFAULT_REGISTRY,
184
+ ): Promise<NpmReleaseIdentity | undefined> {
185
+ const response = await fetch(registryUrl(registry, name, version), {
186
+ headers: { accept: "application/json" },
187
+ });
188
+ if (response.status === 404) return undefined;
189
+ if (!response.ok) {
190
+ throw new Error(`npm registry lookup failed for ${name}@${version}: ${response.status}`);
191
+ }
192
+ const metadata = (await response.json()) as {
193
+ dist?: { integrity?: string; tarball?: string };
194
+ name?: string;
195
+ repository?: unknown;
196
+ version?: string;
197
+ };
198
+ if (!metadata.name || !metadata.version) {
199
+ throw new Error(`npm registry returned an incomplete identity for ${name}@${version}`);
200
+ }
201
+ let contentDigest: string | undefined;
202
+ if (metadata.dist?.tarball) {
203
+ const archive = await fetch(metadata.dist.tarball);
204
+ if (!archive.ok) {
205
+ throw new Error(`npm tarball lookup failed for ${name}@${version}: ${archive.status}`);
206
+ }
207
+ const temp = mkdtempSync(join(tmpdir(), "projen-published-npm-"));
208
+ const path = join(temp, "package.tgz");
209
+ try {
210
+ writeFileSync(path, Buffer.from(await archive.arrayBuffer()));
211
+ contentDigest = npmArchiveContentDigest(path);
212
+ } finally {
213
+ rmSync(temp, { recursive: true, force: true });
214
+ }
215
+ }
216
+ return {
217
+ ...(contentDigest ? { contentDigest } : {}),
218
+ integrity: metadata.dist?.integrity,
219
+ name: metadata.name,
220
+ repository: metadata.repository,
221
+ version: metadata.version,
222
+ };
223
+ }
224
+
225
+ function readNpmArchiveManifest(path: string): NpmArchiveManifest {
226
+ const result = exec.spawnSync("tar", ["-xOf", path, "package/package.json"], {
227
+ cwd: process.cwd(),
228
+ stdout: "capture",
229
+ stderr: "capture",
230
+ stdin: "ignore",
231
+ check: false,
232
+ });
233
+ if (result.exitCode !== 0 || !result.stdout) {
234
+ throw new Error(`Cannot read npm package manifest from ${path}: ${result.stderr}`);
235
+ }
236
+ return JSON.parse(result.stdout) as NpmArchiveManifest;
237
+ }
238
+
239
+ /** Read and hash the release identity embedded in an npm archive. */
240
+ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
241
+ const manifest = readNpmArchiveManifest(path);
242
+ if (!manifest.name || !manifest.version) {
243
+ throw new Error(`npm archive has no package name or version: ${path}`);
244
+ }
245
+ const access = manifest.publishConfig?.access;
246
+ if (access !== undefined && access !== "public" && access !== "restricted") {
247
+ throw new Error(`npm archive has invalid publishConfig.access: ${String(access)}`);
248
+ }
249
+ return {
250
+ ...(access ? { access } : {}),
251
+ contentDigest: npmArchiveContentDigest(path),
252
+ integrity: `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`,
253
+ name: manifest.name,
254
+ repository: manifest.repository,
255
+ version: manifest.version,
256
+ };
257
+ }
258
+
259
+ function orderNpmArchives(archives: readonly string[]): string[] {
260
+ const byName = new Map(
261
+ archives.map((archive) => {
262
+ const manifest = readNpmArchiveManifest(archive);
263
+ if (!manifest.name) throw new Error(`npm archive has no package name: ${archive}`);
264
+ return [manifest.name, { archive, manifest }] as const;
265
+ }),
266
+ );
267
+ if (byName.size !== archives.length) throw new Error("Release contains duplicate npm packages");
268
+ const remaining = new Map(
269
+ [...byName].map(([name, value]) => [
270
+ name,
271
+ new Set(
272
+ [
273
+ ...Object.keys(value.manifest.dependencies ?? {}),
274
+ ...Object.keys(value.manifest.optionalDependencies ?? {}),
275
+ ].filter((dependency) => byName.has(dependency)),
276
+ ),
277
+ ]),
278
+ );
279
+ const ordered: string[] = [];
280
+ while (remaining.size > 0) {
281
+ const ready = [...remaining]
282
+ .filter(([, dependencies]) => dependencies.size === 0)
283
+ .map(([name]) => name)
284
+ .sort();
285
+ if (ready.length === 0) {
286
+ throw new Error(
287
+ `Cyclic npm release dependencies: ${[...remaining.keys()].sort().join(", ")}`,
288
+ );
289
+ }
290
+ for (const name of ready) {
291
+ ordered.push(byName.get(name)!.archive);
292
+ remaining.delete(name);
293
+ }
294
+ for (const dependencies of remaining.values()) {
295
+ for (const name of ready) dependencies.delete(name);
296
+ }
297
+ }
298
+ return ordered;
299
+ }
300
+
301
+ /** Hash paths, executable bits, symlink targets, and bytes while ignoring tar metadata. */
302
+ export function npmArchiveContentDigest(path: string): string {
303
+ const temp = mkdtempSync(join(tmpdir(), "projen-npm-content-"));
304
+ try {
305
+ exec.spawnSync("tar", ["-xzf", path, "-C", temp], {
306
+ cwd: process.cwd(),
307
+ stdout: "ignore",
308
+ stderr: "capture",
309
+ stdin: "ignore",
310
+ check: true,
311
+ });
312
+ const hash = createHash("sha512");
313
+ const visit = (directory: string): void => {
314
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
315
+ a.name.localeCompare(b.name),
316
+ )) {
317
+ const entryPath = join(directory, entry.name);
318
+ const name = relative(temp, entryPath).split("\\").join("/");
319
+ const stat = lstatSync(entryPath);
320
+ hash.update(`${entry.isDirectory() ? "D" : entry.isSymbolicLink() ? "L" : "F"}\0`);
321
+ hash.update(`${name}\0${stat.mode & 0o111}\0`);
322
+ if (entry.isDirectory()) visit(entryPath);
323
+ else if (entry.isSymbolicLink()) hash.update(readlinkSync(entryPath));
324
+ else hash.update(readFileSync(entryPath));
325
+ }
326
+ };
327
+ visit(temp);
328
+ return `sha512-${hash.digest("base64")}`;
329
+ } finally {
330
+ rmSync(temp, { recursive: true, force: true });
331
+ }
332
+ }
333
+
334
+ /** Pack one workspace package and optionally transform only its archived manifest. */
335
+ export function packNpmPackage(
336
+ directory: string,
337
+ destination: string,
338
+ path = process.env.PATH ?? "",
339
+ transformManifest?: NpmArchiveManifestTransform,
340
+ ): string {
341
+ const executable = process.versions.bun ? process.execPath : "bun";
342
+ const manifest = join(directory, "package.json");
343
+ const originalManifest = readFileSync(manifest, "utf8");
344
+ const workspaceRoot = projectUtils.root(directory) ?? directory;
345
+ const workspaceManifest = JSON.parse(
346
+ readFileSync(join(workspaceRoot, "package.json"), "utf8"),
347
+ ) as Record<string, unknown>;
348
+ const packedManifest = `${JSON.stringify(
349
+ materializeWorkspaceManifest(
350
+ JSON.parse(originalManifest) as Record<string, unknown>,
351
+ workspaceManifest,
352
+ ),
353
+ null,
354
+ 2,
355
+ )}\n`;
356
+ const manifestMode = lstatSync(manifest).mode & 0o777;
357
+ const restoreManifestMode = (manifestMode & 0o200) === 0;
358
+ const existingArchives = new Set(readdirSync(destination));
359
+ if (restoreManifestMode) chmodSync(manifest, manifestMode | 0o200);
360
+ try {
361
+ writeFileSync(manifest, packedManifest);
362
+ exec.spawnSync(
363
+ executable,
364
+ ["pm", "pack", "--destination", destination, "--ignore-scripts", "--quiet"],
365
+ {
366
+ cwd: directory,
367
+ env: { ...process.env, PATH: path },
368
+ stdout: "inherit",
369
+ stderr: "inherit",
370
+ stdin: "ignore",
371
+ check: true,
372
+ },
373
+ );
374
+ } finally {
375
+ writeFileSync(manifest, originalManifest);
376
+ if (restoreManifestMode) chmodSync(manifest, manifestMode);
377
+ }
378
+ const archives = readdirSync(destination).filter(
379
+ (file) => file.endsWith(".tgz") && !existingArchives.has(file),
380
+ );
381
+ if (archives.length !== 1) {
382
+ throw new Error(`Expected one packed npm archive, found ${archives.length}`);
383
+ }
384
+ const archive = join(destination, archives[0]);
385
+ if (!transformManifest) return archive;
386
+
387
+ const temp = mkdtempSync(join(tmpdir(), "projen-npm-project-"));
388
+ try {
389
+ exec.spawnSync("tar", ["-xzf", archive, "-C", temp], {
390
+ cwd: process.cwd(),
391
+ stdout: "ignore",
392
+ stderr: "inherit",
393
+ stdin: "ignore",
394
+ check: true,
395
+ });
396
+ const manifestPath = join(temp, "package", "package.json");
397
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as NpmArchiveManifest;
398
+ writeFileSync(manifestPath, `${JSON.stringify(transformManifest(manifest), null, 2)}\n`);
399
+ rmSync(archive, { force: true });
400
+ exec.spawnSync("tar", ["-czf", archive, "-C", temp, ...archiveFileEntries(temp)], {
401
+ cwd: process.cwd(),
402
+ stdout: "ignore",
403
+ stderr: "inherit",
404
+ stdin: "ignore",
405
+ check: true,
406
+ });
407
+ return archive;
408
+ } finally {
409
+ rmSync(temp, { recursive: true, force: true });
410
+ }
411
+ }
412
+
413
+ /** Publish dependency-ordered npm archives, skipping only exact registry matches. */
414
+ export async function publishNpmArchives(options: {
415
+ readonly directory: string;
416
+ readonly dryRun?: boolean;
417
+ readonly registry?: string;
418
+ readonly version?: string;
419
+ }): Promise<void> {
420
+ const directory = resolve(options.directory);
421
+ const archives = orderNpmArchives(
422
+ readdirSync(directory)
423
+ .filter((file) => file.endsWith(".tgz"))
424
+ .sort()
425
+ .map((file) => join(directory, file)),
426
+ );
427
+ if (archives.length === 0) throw new Error(`No npm archives found in ${directory}`);
428
+
429
+ for (const archive of archives) {
430
+ const local = readNpmArchiveIdentity(archive);
431
+ if (options.version && local.version !== options.version) {
432
+ throw new Error(
433
+ `npm archive ${archive} carries ${local.version}, expected ${options.version}`,
434
+ );
435
+ }
436
+ if (!options.dryRun) {
437
+ const published = await publishedNpmRelease(local.name, local.version, options.registry);
438
+ if (npmReleaseMatches(local, published)) {
439
+ logger.info(`skip published ${local.name}@${local.version}`);
440
+ continue;
441
+ }
442
+ }
443
+ runTaskCommand(process.cwd(), "npm", [
444
+ "publish",
445
+ archive,
446
+ ...(local.access ? ["--access", local.access] : []),
447
+ ...(options.registry ? ["--registry", options.registry] : []),
448
+ ...(options.dryRun ? ["--dry-run"] : []),
449
+ ]);
450
+ }
451
+ }
452
+
453
+ /** Parse npm archive publication options and execute the publisher. */
454
+ export async function main(): Promise<void> {
455
+ const program = new Command();
456
+ program
457
+ .requiredOption("--directory <path>", "Directory containing npm archives")
458
+ .option("--version <version>", "Expected npm release version")
459
+ .option("--registry <url>", "npm registry URL")
460
+ .option("--dry-run", "Validate archives without publishing")
461
+ .action(
462
+ (options: { directory: string; dryRun?: boolean; registry?: string; version?: string }) =>
463
+ publishNpmArchives(options),
464
+ );
465
+ await program.parseAsync();
466
+ }
5
467
 
6
468
  if (import.meta.main) await main();
@@ -1,6 +1,227 @@
1
1
  #!/usr/bin/env -S bun
2
- import { main } from "./lib/publish-python.ts";
2
+ /**
3
+ * Build selected uv workspace packages from isolated publication copies and
4
+ * optionally publish the resulting distributions.
5
+ */
6
+ import {
7
+ chmodSync,
8
+ cpSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ mkdtempSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
19
+ import * as projectUtils from "@dbx-tools/core/project-utils";
20
+ import { Command } from "commander";
21
+ import { parse, stringify } from "smol-toml";
22
+ import { preparePythonProjectForPublication, pythonProjectInfo } from "./python-release.ts";
23
+ import { runTaskCommand } from "../src/_task-command.ts";
3
24
 
4
- export * from "./lib/publish-python.ts";
25
+ interface PythonProjectFile {
26
+ readonly directory: string;
27
+ readonly name: string;
28
+ readonly version: string;
29
+ readonly path: string;
30
+ readonly private: boolean;
31
+ readonly source: string;
32
+ }
33
+
34
+ /** Python wheel and source archives accepted by Twine and package indexes. */
35
+ export function pythonDistributionPaths(directory: string): string[] {
36
+ return readdirSync(directory)
37
+ .filter((file) => file.endsWith(".whl") || file.endsWith(".tar.gz"))
38
+ .sort()
39
+ .map((file) => join(directory, file));
40
+ }
41
+
42
+ function removeTemporaryTree(root: string): void {
43
+ if (!existsSync(root)) return;
44
+ const makeWritable = (directory: string): void => {
45
+ chmodSync(directory, 0o700);
46
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
47
+ if (entry.isDirectory()) makeWritable(join(directory, entry.name));
48
+ }
49
+ };
50
+ makeWritable(root);
51
+ rmSync(root, { recursive: true, force: true });
52
+ }
53
+
54
+ function pythonProjects(root: string): PythonProjectFile[] {
55
+ const packageFiles = readdirSync(root, { withFileTypes: true })
56
+ .filter((entry) => entry.isDirectory())
57
+ .map((entry) => resolve(root, entry.name, "pyproject.toml"))
58
+ .filter(existsSync)
59
+ .sort();
60
+ const allProjects: PythonProjectFile[] = packageFiles.map((path) => {
61
+ const source = readFileSync(path, "utf8");
62
+ const info = pythonProjectInfo(source, { parse });
63
+ return {
64
+ directory: basename(resolve(path, "..")),
65
+ name: info.name,
66
+ version: info.version,
67
+ path,
68
+ private: info.private,
69
+ source,
70
+ };
71
+ });
72
+ return allProjects;
73
+ }
74
+
75
+ export function publishPythonProjects(options: {
76
+ readonly dryRun?: boolean;
77
+ readonly indexUrl: string;
78
+ readonly publishUrl: string;
79
+ readonly root: string;
80
+ readonly version: string;
81
+ }): void {
82
+ const root = resolve(options.root);
83
+ const workspace = projectUtils.root(root) ?? dirname(root);
84
+ const output = mkdtempSync(join(workspace, ".projen-python-publish-"));
85
+ try {
86
+ buildPythonProjects({ ...options, output });
87
+ runTaskCommand(
88
+ process.cwd(),
89
+ "uvx",
90
+ [
91
+ "--from",
92
+ "devpi-client",
93
+ "devpi",
94
+ "upload",
95
+ "--index",
96
+ options.publishUrl,
97
+ "--from-dir",
98
+ ...(options.dryRun ? ["--dry-run"] : []),
99
+ output,
100
+ ],
101
+ { env: { ...process.env, UV_DEFAULT_INDEX: options.indexUrl } },
102
+ );
103
+ } finally {
104
+ rmSync(output, { recursive: true, force: true });
105
+ }
106
+ }
107
+
108
+ /** Build the exact Python release distributions without publishing them. */
109
+ export function buildPythonProjects(options: {
110
+ readonly allowEmpty?: boolean;
111
+ readonly output: string;
112
+ readonly packages?: readonly string[];
113
+ /** Place each package's artifacts in its directory beneath output. */
114
+ readonly packageDirectories?: boolean;
115
+ readonly root: string;
116
+ readonly version: string;
117
+ }): void {
118
+ const root = resolve(options.root);
119
+ const output = resolve(options.output);
120
+ const workspace = projectUtils.root(root) ?? dirname(root);
121
+ const outputPath = relative(workspace, output);
122
+ if (!outputPath || outputPath.startsWith("..") || isAbsolute(outputPath)) {
123
+ throw new Error("Python release output must be a non-root directory inside the workspace");
124
+ }
125
+ rmSync(output, { recursive: true, force: true });
126
+ const allProjects = pythonProjects(root);
127
+ const requested = new Set(options.packages ?? []);
128
+ const missing = [...requested].filter(
129
+ (directory) => !allProjects.some((project) => project.directory === directory),
130
+ );
131
+ if (missing.length > 0) throw new Error(`Unknown Python packages: ${missing.join(", ")}`);
132
+ const projects = allProjects.filter(
133
+ (project) => !project.private && (requested.size === 0 || requested.has(project.directory)),
134
+ );
135
+ if (projects.length === 0 && options.allowEmpty) {
136
+ mkdirSync(output, { recursive: true });
137
+ return;
138
+ }
139
+ if (projects.length === 0) throw new Error(`No Python packages found under ${root}`);
140
+
141
+ const temporaryRoot = mkdtempSync(join(tmpdir(), "projen-python-release-"));
142
+ try {
143
+ mkdirSync(output, { recursive: true });
144
+ for (const project of projects) {
145
+ const packageRoot = join(temporaryRoot, project.directory);
146
+ cpSync(dirname(project.path), packageRoot, { recursive: true });
147
+ const manifestPath = join(packageRoot, "pyproject.toml");
148
+ chmodSync(manifestPath, 0o644);
149
+ writeFileSync(
150
+ manifestPath,
151
+ preparePythonProjectForPublication(project.source, {
152
+ packages: allProjects,
153
+ toml: { parse, stringify },
154
+ version: options.version,
155
+ }),
156
+ );
157
+ const packageOutput = options.packageDirectories ? join(output, project.directory) : output;
158
+ runTaskCommand(root, "uv", ["build", "--out-dir", packageOutput, packageRoot]);
159
+ if (pythonDistributionPaths(packageOutput).length === 0) {
160
+ throw new Error(`No Python distributions found for ${project.name}`);
161
+ }
162
+ }
163
+ const distributions = options.packageDirectories
164
+ ? projects.flatMap((project) => pythonDistributionPaths(join(output, project.directory)))
165
+ : pythonDistributionPaths(output);
166
+ if (distributions.length === 0) {
167
+ throw new Error(`No Python distributions found in ${output}`);
168
+ }
169
+ runTaskCommand(process.cwd(), "uvx", ["twine", "check", ...distributions]);
170
+ } finally {
171
+ removeTemporaryTree(temporaryRoot);
172
+ }
173
+ }
174
+
175
+ export async function main(): Promise<void> {
176
+ const program = new Command();
177
+ program
178
+ .argument("<version>", "Python package version")
179
+ .option("--index-url <url>", "devpi Simple API URL")
180
+ .option("--publish-url <url>", "devpi writable index URL")
181
+ .option("--root <path>", "Python workspace package root", "packages/py")
182
+ .option("--package <directory...>", "Build only selected package directories")
183
+ .option("--output <path>", "Build distributions into a directory without publishing")
184
+ .option(
185
+ "--package-directories",
186
+ "Keep each package's distributions in its own output directory",
187
+ )
188
+ .option("--dry-run", "build and inspect distributions without uploading")
189
+ .action(
190
+ (
191
+ version: string,
192
+ options: {
193
+ dryRun?: boolean;
194
+ indexUrl?: string;
195
+ package?: string[];
196
+ packageDirectories?: boolean;
197
+ publishUrl?: string;
198
+ root: string;
199
+ output?: string;
200
+ },
201
+ ) => {
202
+ if (options.output) {
203
+ buildPythonProjects({
204
+ output: options.output,
205
+ packages: options.package,
206
+ packageDirectories: options.packageDirectories,
207
+ root: options.root,
208
+ version,
209
+ });
210
+ } else {
211
+ if (!options.indexUrl || !options.publishUrl) {
212
+ throw new Error("--index-url and --publish-url are required when publishing");
213
+ }
214
+ publishPythonProjects({
215
+ dryRun: options.dryRun,
216
+ indexUrl: options.indexUrl,
217
+ publishUrl: options.publishUrl,
218
+ root: options.root,
219
+ version,
220
+ });
221
+ }
222
+ },
223
+ );
224
+ await program.parseAsync();
225
+ }
5
226
 
6
227
  if (import.meta.main) await main();