@dbx-tools/projen 0.9.154 → 0.9.157

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 (49) hide show
  1. package/README.md +36 -47
  2. package/index.ts +42 -20
  3. package/package.json +8 -8
  4. package/src/_task-command.ts +7 -7
  5. package/src/appkit-workspace.ts +278 -104
  6. package/src/barrels.ts +147 -259
  7. package/src/bun-app.ts +54 -10
  8. package/src/clean.ts +6 -6
  9. package/src/engine-root.ts +4 -4
  10. package/src/mixin.ts +0 -2
  11. package/src/module-exports.ts +4 -5
  12. package/src/npm-packaging.ts +128 -0
  13. package/src/npm-release.ts +200 -0
  14. package/src/packages.ts +11 -11
  15. package/src/project-js.ts +209 -103
  16. package/src/project-predicate.ts +5 -12
  17. package/src/project-py.ts +6 -6
  18. package/src/project.ts +7 -14
  19. package/src/publish.ts +3 -3
  20. package/src/python-node-bindings.ts +16 -12
  21. package/src/python-packaging.ts +177 -0
  22. package/src/release-options.ts +2 -2
  23. package/src/release.ts +3 -3
  24. package/src/shared-core-usage.ts +17 -4
  25. package/src/tags.ts +10 -9
  26. package/src/watch.ts +7 -9
  27. package/src/workspace-lock.ts +2 -2
  28. package/src/workspace-manifest.ts +2 -2
  29. package/tasks/appkit-deploy.ts +35 -15
  30. package/tasks/barrels.ts +6 -9
  31. package/tasks/bump.ts +1 -1
  32. package/tasks/clean.ts +3 -3
  33. package/tasks/cli.ts +3 -3
  34. package/tasks/compile-workspace.ts +8 -8
  35. package/tasks/dev-watch.ts +11 -11
  36. package/tasks/local-publish.ts +6 -6
  37. package/tasks/publish-npm.ts +11 -319
  38. package/tasks/publish-python.ts +2 -180
  39. package/tasks/publish.ts +6 -11
  40. package/tasks/python-node-bindings.ts +2 -2
  41. package/tasks/python-registry.ts +6 -6
  42. package/tasks/python-sync.ts +2 -3
  43. package/tasks/release-version.ts +1 -1
  44. package/tasks/release.ts +11 -11
  45. package/tasks/sync.ts +2 -2
  46. package/tasks/test-workspace.ts +9 -9
  47. package/tasks/verify-publications.ts +9 -5
  48. package/tasks/version-check.ts +5 -5
  49. /package/{tasks → src}/python-release.ts +0 -0
package/src/bun-app.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * `src/`), they are outside the package's `tsconfig` include, so their `Bun.*`
23
23
  * usage never trips the `app` package's DOM-only `compile`.
24
24
  */
25
- import { stringUtils } from "@dbx-tools/shared-core";
25
+ import { dedentLines } from "@dbx-tools/shared-core";
26
26
  import { type Project, TextFile } from "projen";
27
27
  import { header, headerLines } from "./generated.ts";
28
28
 
@@ -35,6 +35,17 @@ export const BUN_APP_OVERRIDES = [BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE];
35
35
  const PROJEN_HEADER = { tool: "projen", source: ".projenrc.ts" } as const;
36
36
  const PROJEN_TS_HEADER = header(PROJEN_HEADER).trimEnd().replaceAll("\n", "\n ");
37
37
 
38
+ /** Paths owned by a generated Bun browser build. */
39
+ export interface BunBuildFileOptions {
40
+ readonly entrypoint?: string;
41
+ readonly outdir?: string;
42
+ readonly publicDir?: string;
43
+ /** Root resolved by the conventional `@/` source import alias. */
44
+ readonly sourceRoot?: string;
45
+ /** TypeScript configuration used by Bun for path aliases and JSX settings. */
46
+ readonly tsconfig?: string;
47
+ }
48
+
38
49
  /**
39
50
  * Root `bunfig.toml`: pin bun's HOISTED linker workspace-wide.
40
51
  *
@@ -126,14 +137,17 @@ export class BunDevServerFile extends TextFile {
126
137
  super(project, "dev.ts", {
127
138
  marker: true,
128
139
  readonly: true,
129
- lines: stringUtils.dedentLines(source, { trimEnd: false }),
140
+ lines: dedentLines(source, { trimEnd: false }),
130
141
  });
131
142
  }
132
143
  }
133
144
 
134
145
  /** `build.ts`: a `Bun.build()` production bundle (Tailwind via the plugin API). */
135
146
  export class BunBuildFile extends TextFile {
136
- constructor(project: Project) {
147
+ constructor(project: Project, options: BunBuildFileOptions = {}) {
148
+ const entrypoint = options.entrypoint ?? "index.html";
149
+ const outdir = options.outdir ?? "dist";
150
+ const publicDir = options.publicDir ?? "public";
137
151
  // Raw template preserves backslashes in generated regular expression literals.
138
152
  // prettier-ignore
139
153
  const source = (
@@ -143,21 +157,51 @@ export class BunBuildFile extends TextFile {
143
157
  ${PROJEN_TS_HEADER}
144
158
  import { existsSync } from "node:fs";
145
159
  import { cp, rm } from "node:fs/promises";
146
- import { basename } from "node:path";
160
+ import { createRequire } from "node:module";
161
+ import { basename${options.sourceRoot ? ", resolve" : ""} } from "node:path";
162
+ import type { BunPlugin } from "bun";
147
163
  import tailwind from "bun-plugin-tailwind";
148
164
 
165
+ const require = createRequire(import.meta.url);
166
+ const reactEntries = new Map(
167
+ ["react", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom", "react-dom/client"].map(
168
+ (specifier) => [specifier, require.resolve(specifier)],
169
+ ),
170
+ );
171
+ const dedupeReact: BunPlugin = {
172
+ name: "dedupe-react",
173
+ setup(build) {
174
+ build.onResolve({ filter: /^react(?:-dom)?(?:\/.*)?$/ }, ({ path }) => {
175
+ const resolved = reactEntries.get(path);
176
+ return resolved ? { path: resolved } : undefined;
177
+ });
178
+ },
179
+ };
180
+ ${options.sourceRoot ? /*ts*/ `
181
+ const sourceRoot = resolve(import.meta.dirname, ${JSON.stringify(options.sourceRoot)});
182
+ const sourceAlias: BunPlugin = {
183
+ name: "source-alias",
184
+ setup(build) {
185
+ build.onResolve({ filter: new RegExp("^@/") }, ({ path }) => ({
186
+ path: Bun.resolveSync(\`./\${path.slice(2)}\`, sourceRoot),
187
+ }));
188
+ },
189
+ };
190
+ ` : ""}
191
+
149
192
  // Unmanaged override (${BUN_BUILD_OVERRIDE}): its default export is merged over
150
193
  // the generated build options - later wins.
151
194
  let options: Bun.BuildConfig = {
152
- entrypoints: ["./index.html"],
153
- outdir: "./dist",
195
+ entrypoints: [${JSON.stringify(entrypoint)}],
196
+ outdir: ${JSON.stringify(outdir)},
154
197
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
155
198
  minify: true,
156
199
  splitting: true,
157
200
  publicPath: "/",
158
201
  external: ["/fonts/*"],
159
202
  sourcemap: "none",
160
- plugins: [tailwind],
203
+ plugins: [tailwind, dedupeReact${options.sourceRoot ? ", sourceAlias" : ""}],
204
+ ${options.tsconfig ? ` tsconfig: ${JSON.stringify(options.tsconfig)},` : ""}
161
205
  };
162
206
 
163
207
  const overrideUrl = new URL("./${BUN_BUILD_OVERRIDE}", import.meta.url);
@@ -167,7 +211,7 @@ export class BunBuildFile extends TextFile {
167
211
  options = { ...options, ...override, plugins: [...(options.plugins ?? []), ...(override.plugins ?? [])] };
168
212
  }
169
213
 
170
- const outdir = options.outdir ?? "./dist";
214
+ const outdir = options.outdir ?? ${JSON.stringify(outdir)};
171
215
  await rm(outdir, { recursive: true, force: true });
172
216
 
173
217
  const result = await Bun.build(options);
@@ -195,7 +239,7 @@ export class BunBuildFile extends TextFile {
195
239
  await Bun.write(htmlOut.path, html);
196
240
  }
197
241
 
198
- const publicDir = "./public";
242
+ const publicDir = ${JSON.stringify(publicDir)};
199
243
  if (existsSync(publicDir)) {
200
244
  await cp(publicDir, outdir, { recursive: true });
201
245
  }
@@ -207,7 +251,7 @@ export class BunBuildFile extends TextFile {
207
251
  super(project, "build.ts", {
208
252
  marker: true,
209
253
  readonly: true,
210
- lines: stringUtils.dedentLines(source, { trimEnd: false }),
254
+ lines: dedentLines(source, { trimEnd: false }),
211
255
  });
212
256
  }
213
257
  }
package/src/clean.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  */
20
20
  import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
21
21
  import { basename, dirname, join, relative, resolve } from "node:path";
22
- import { find } from "@dbx-tools/path";
23
- import { json } from "@dbx-tools/shared-core";
22
+ import { findFiles } from "@dbx-tools/path";
23
+ import { parseRecord } from "@dbx-tools/shared-core";
24
24
  import { isGenerated, makeWritable } from "./generated.ts";
25
25
  import { resolveRepoRoot, toPosix } from "./packages.ts";
26
26
 
@@ -39,11 +39,11 @@ const CLEAN_SKIP_FILES: ReadonlySet<string> = new Set([".gitignore"]);
39
39
  */
40
40
  async function nativeGeneratedFiles(root: string): Promise<string[]> {
41
41
  const files = new Set<string>();
42
- for await (const manifestPath of find.findFiles("**/.projen/files.json", {
42
+ for await (const manifestPath of findFiles("**/.projen/files.json", {
43
43
  cwd: root,
44
44
  ignoreOptions: { dot: false },
45
45
  })) {
46
- const manifest = json.parseRecord(readFileSync(resolve(root, manifestPath), "utf8"));
46
+ const manifest = parseRecord(readFileSync(resolve(root, manifestPath), "utf8"));
47
47
  const projectRoot = dirname(dirname(resolve(root, manifestPath)));
48
48
  const entries = manifest?.files;
49
49
  if (!Array.isArray(entries)) continue;
@@ -57,7 +57,7 @@ async function nativeGeneratedFiles(root: string): Promise<string[]> {
57
57
  export async function listGeneratedFiles(root: string = resolveRepoRoot()): Promise<string[]> {
58
58
  const rel = (f: string): string => toPosix(relative(root, f));
59
59
  const custom: string[] = [];
60
- for await (const file of find.findFiles("**/*", { cwd: root })) {
60
+ for await (const file of findFiles("**/*", { cwd: root })) {
61
61
  const absolute = join(root, file);
62
62
  if (isGenerated(absolute)) custom.push(absolute);
63
63
  }
@@ -85,7 +85,7 @@ export async function listNodeModulesDirs(root: string = resolveRepoRoot()): Pro
85
85
  if (!existsSync(root)) return [];
86
86
  const rel = (f: string): string => toPosix(relative(root, f));
87
87
  const directories: string[] = [];
88
- for await (const match of find.findFiles("**/node_modules", {
88
+ for await (const match of findFiles("**/node_modules", {
89
89
  cwd: root,
90
90
  ignoreOptions: { dot: false },
91
91
  })) {
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import { dirname } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import * as projectUtils from "@dbx-tools/core/project-utils";
10
- import { functionUtils } from "@dbx-tools/shared-core";
9
+ import { root } from "@dbx-tools/core";
10
+ import { memoize } from "@dbx-tools/shared-core";
11
11
 
12
12
  const ENGINE_PKG = "@dbx-tools/projen";
13
13
 
@@ -20,8 +20,8 @@ const ENGINE_PKG = "@dbx-tools/projen";
20
20
  * {@link functionUtils.memoize}, which caches only a successful result - a
21
21
  * throw is retried on the next call.
22
22
  */
23
- export const resolvePkgRoot = functionUtils.memoize((): string => {
24
- const found = projectUtils.root(dirname(fileURLToPath(import.meta.url)));
23
+ export const resolvePkgRoot = memoize((): string => {
24
+ const found = root(dirname(fileURLToPath(import.meta.url)));
25
25
  if (!found) throw new Error(`${ENGINE_PKG} package root not found`);
26
26
  return found;
27
27
  });
package/src/mixin.ts CHANGED
@@ -9,8 +9,6 @@
9
9
  import type { Predicate } from "@dbx-tools/shared-core";
10
10
  import type { IConstruct, IMixin as ConstructsMixin } from "constructs";
11
11
 
12
- export type { ConstructsMixin };
13
-
14
12
  /**
15
13
  * Builds a {@link ConstructsMixin} from `supports` and `applyTo` callbacks.
16
14
  *
@@ -86,13 +86,12 @@ const FUNCTION_DECLARATIONS = new Set(["FunctionDeclaration", "TSDeclareFunction
86
86
  export function moduleExports(file: string): ModuleExport[] {
87
87
  const body = moduleStatements(file);
88
88
 
89
- // Dedupe within the module: an overloaded `export function f(...)` declares
90
- // `f` once per signature, but it's a single exported name. First occurrence
91
- // wins (a value declaration and a same-named type would be unusual and are
92
- // collapsed to whichever appears first).
89
+ // Dedupe within the module. When TypeScript merges a type and value with the
90
+ // same name, the value export carries both namespaces and must win.
93
91
  const byName = new Map<string, ModuleExport>();
94
92
  const push = (e: ModuleExport): void => {
95
- if (!byName.has(e.name)) byName.set(e.name, e);
93
+ const existing = byName.get(e.name);
94
+ if (!existing || (existing.isType && !e.isType)) byName.set(e.name, e);
96
95
  };
97
96
  for (const stmt of body) {
98
97
  if (stmt.type !== "ExportNamedDeclaration") continue;
@@ -0,0 +1,128 @@
1
+ /** Side-effect-free npm archive packing helpers. */
2
+ import {
3
+ chmodSync,
4
+ lstatSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { spawnSync, root } from "@dbx-tools/core";
15
+
16
+ import type { NpmArchiveManifest } from "./npm-release.ts";
17
+ import { materializeWorkspaceManifest } from "./workspace-manifest.ts";
18
+
19
+ /** Transform applied to a packed npm manifest before archive verification. */
20
+ export type NpmArchiveManifestTransform = (
21
+ manifest: NpmArchiveManifest,
22
+ ) => Readonly<Record<string, unknown>>;
23
+
24
+ const PUBLISH_CONFIG_ENTRY_FIELDS = ["main", "types", "bin", "exports"] as const;
25
+
26
+ /** Fold compiled publish entry points onto an archive manifest. */
27
+ export function applyPublishConfig(
28
+ source: Readonly<Record<string, unknown>>,
29
+ ): Readonly<Record<string, unknown>> {
30
+ const manifest = { ...source };
31
+ const publishConfig = manifest.publishConfig as Record<string, unknown> | undefined;
32
+ if (!publishConfig) return manifest;
33
+ for (const field of PUBLISH_CONFIG_ENTRY_FIELDS) {
34
+ if (field in publishConfig) manifest[field] = publishConfig[field];
35
+ }
36
+ return manifest;
37
+ }
38
+
39
+ function archiveFileEntries(directory: string, prefix = ""): string[] {
40
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
41
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
42
+ return entry.isDirectory()
43
+ ? archiveFileEntries(join(directory, entry.name), relativePath)
44
+ : [relativePath];
45
+ });
46
+ }
47
+
48
+ /** Pack one workspace package and optionally transform only its archived manifest. */
49
+ export function packNpmPackage(
50
+ directory: string,
51
+ destination: string,
52
+ path = process.env.PATH ?? "",
53
+ transformManifest?: NpmArchiveManifestTransform,
54
+ ): string {
55
+ const executable = process.versions.bun ? process.execPath : "bun";
56
+ const manifest = join(directory, "package.json");
57
+ const originalManifest = readFileSync(manifest, "utf8");
58
+ const workspaceRoot = root(directory) ?? directory;
59
+ const workspaceManifest = JSON.parse(
60
+ readFileSync(join(workspaceRoot, "package.json"), "utf8"),
61
+ ) as Record<string, unknown>;
62
+ const packedManifest = `${JSON.stringify(
63
+ materializeWorkspaceManifest(
64
+ JSON.parse(originalManifest) as Record<string, unknown>,
65
+ workspaceManifest,
66
+ ),
67
+ null,
68
+ 2,
69
+ )}\n`;
70
+ const manifestMode = lstatSync(manifest).mode & 0o777;
71
+ const restoreManifestMode = (manifestMode & 0o200) === 0;
72
+ const existingArchives = new Set(readdirSync(destination));
73
+ if (restoreManifestMode) chmodSync(manifest, manifestMode | 0o200);
74
+ try {
75
+ writeFileSync(manifest, packedManifest);
76
+ spawnSync(
77
+ executable,
78
+ ["pm", "pack", "--destination", destination, "--ignore-scripts", "--quiet"],
79
+ {
80
+ cwd: directory,
81
+ env: { ...process.env, PATH: path },
82
+ stdout: "inherit",
83
+ stderr: "inherit",
84
+ stdin: "ignore",
85
+ check: true,
86
+ },
87
+ );
88
+ } finally {
89
+ writeFileSync(manifest, originalManifest);
90
+ if (restoreManifestMode) chmodSync(manifest, manifestMode);
91
+ }
92
+ const archives = readdirSync(destination).filter(
93
+ (file) => file.endsWith(".tgz") && !existingArchives.has(file),
94
+ );
95
+ if (archives.length !== 1) {
96
+ throw new Error(`Expected one packed npm archive, found ${archives.length}`);
97
+ }
98
+ const archive = join(destination, archives[0]!);
99
+ if (!transformManifest) return archive;
100
+
101
+ const temp = mkdtempSync(join(tmpdir(), "projen-npm-project-"));
102
+ try {
103
+ spawnSync("tar", ["-xzf", archive, "-C", temp], {
104
+ cwd: process.cwd(),
105
+ stdout: "ignore",
106
+ stderr: "inherit",
107
+ stdin: "ignore",
108
+ check: true,
109
+ });
110
+ const manifestPath = join(temp, "package", "package.json");
111
+ const archivedManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as NpmArchiveManifest;
112
+ writeFileSync(
113
+ manifestPath,
114
+ `${JSON.stringify(transformManifest(archivedManifest), null, 2)}\n`,
115
+ );
116
+ rmSync(archive, { force: true });
117
+ spawnSync("tar", ["-czf", archive, "-C", temp, ...archiveFileEntries(temp)], {
118
+ cwd: process.cwd(),
119
+ stdout: "ignore",
120
+ stderr: "inherit",
121
+ stdin: "ignore",
122
+ check: true,
123
+ });
124
+ } finally {
125
+ rmSync(temp, { recursive: true, force: true });
126
+ }
127
+ return archive;
128
+ }
@@ -0,0 +1,200 @@
1
+ /** Side-effect-free npm release identity and public registry helpers. */
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ lstatSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ readlinkSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, relative } from "node:path";
14
+ import { spawnSync } from "@dbx-tools/core";
15
+ import { PUBLIC_NPM_REGISTRY_URL } from "./release-context.ts";
16
+
17
+ /** Registry and archive identity used to verify idempotent npm publication. */
18
+ export interface NpmReleaseIdentity {
19
+ readonly access?: "public" | "restricted";
20
+ readonly contentDigest?: string;
21
+ readonly integrity?: string;
22
+ readonly name: string;
23
+ readonly repository?: unknown;
24
+ readonly version: string;
25
+ }
26
+
27
+ /** npm package fields read directly from one release archive. */
28
+ export interface NpmArchiveManifest {
29
+ readonly [key: string]: unknown;
30
+ readonly dependencies?: Readonly<Record<string, string>>;
31
+ readonly optionalDependencies?: Readonly<Record<string, string>>;
32
+ readonly name?: string;
33
+ readonly publishConfig?: { readonly access?: unknown };
34
+ readonly repository?: unknown;
35
+ readonly version?: string;
36
+ }
37
+
38
+ /** Verify that registry metadata represents the exact locally packed release. */
39
+ export function npmReleaseMatches(
40
+ local: NpmReleaseIdentity,
41
+ published: NpmReleaseIdentity | undefined,
42
+ ): boolean {
43
+ if (!published) return false;
44
+ if (published.name !== local.name || published.version !== local.version) {
45
+ throw new Error(`Published npm identity does not match ${local.name}@${local.version}`);
46
+ }
47
+ const localRepository = normalizedRepository(local.repository);
48
+ const publishedRepository = normalizedRepository(published.repository);
49
+ if (localRepository && publishedRepository && localRepository !== publishedRepository) {
50
+ throw new Error(`Published npm repository does not match ${local.name}@${local.version}`);
51
+ }
52
+ if (
53
+ local.contentDigest &&
54
+ published.contentDigest &&
55
+ local.contentDigest !== published.contentDigest
56
+ ) {
57
+ throw new Error(`Published npm content does not match ${local.name}@${local.version}`);
58
+ }
59
+ if (
60
+ !(local.contentDigest && published.contentDigest) &&
61
+ local.integrity &&
62
+ published.integrity !== local.integrity
63
+ ) {
64
+ throw new Error(`Published npm integrity does not match ${local.name}@${local.version}`);
65
+ }
66
+ return true;
67
+ }
68
+
69
+ /** Read one published npm release identity, or `undefined` when it is absent. */
70
+ export async function publishedNpmRelease(
71
+ name: string,
72
+ version: string,
73
+ registry = process.env.NPM_CONFIG_REGISTRY ?? PUBLIC_NPM_REGISTRY_URL,
74
+ signal?: AbortSignal,
75
+ ): Promise<NpmReleaseIdentity | undefined> {
76
+ const response = await fetch(registryUrl(registry, name, version), {
77
+ headers: { accept: "application/json" },
78
+ signal,
79
+ });
80
+ if (response.status === 404) return undefined;
81
+ if (!response.ok) {
82
+ throw new Error(`npm registry lookup failed for ${name}@${version}: ${response.status}`);
83
+ }
84
+ const metadata = (await response.json()) as {
85
+ dist?: { integrity?: string; tarball?: string };
86
+ name?: string;
87
+ repository?: unknown;
88
+ version?: string;
89
+ };
90
+ if (!metadata.name || !metadata.version) {
91
+ throw new Error(`npm registry returned an incomplete identity for ${name}@${version}`);
92
+ }
93
+ let contentDigest: string | undefined;
94
+ if (metadata.dist?.tarball) {
95
+ const archive = await fetch(metadata.dist.tarball, { signal });
96
+ if (!archive.ok) {
97
+ throw new Error(`npm tarball lookup failed for ${name}@${version}: ${archive.status}`);
98
+ }
99
+ const temp = mkdtempSync(join(tmpdir(), "projen-published-npm-"));
100
+ const path = join(temp, "package.tgz");
101
+ try {
102
+ writeFileSync(path, Buffer.from(await archive.arrayBuffer()));
103
+ contentDigest = npmArchiveContentDigest(path);
104
+ } finally {
105
+ rmSync(temp, { recursive: true, force: true });
106
+ }
107
+ }
108
+ return {
109
+ ...(contentDigest ? { contentDigest } : {}),
110
+ integrity: metadata.dist?.integrity,
111
+ name: metadata.name,
112
+ repository: metadata.repository,
113
+ version: metadata.version,
114
+ };
115
+ }
116
+
117
+ /** Read one npm package manifest directly from its archive. */
118
+ export function readNpmArchiveManifest(path: string): NpmArchiveManifest {
119
+ const result = spawnSync("tar", ["-xOf", path, "package/package.json"], {
120
+ cwd: process.cwd(),
121
+ stdout: "capture",
122
+ stderr: "capture",
123
+ stdin: "ignore",
124
+ check: false,
125
+ });
126
+ if (result.exitCode !== 0 || !result.stdout) {
127
+ throw new Error(`Cannot read npm package manifest from ${path}: ${result.stderr}`);
128
+ }
129
+ return JSON.parse(result.stdout) as NpmArchiveManifest;
130
+ }
131
+
132
+ /** Read and hash the release identity embedded in an npm archive. */
133
+ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
134
+ const manifest = readNpmArchiveManifest(path);
135
+ if (!manifest.name || !manifest.version) {
136
+ throw new Error(`npm archive has no package name or version: ${path}`);
137
+ }
138
+ const access = manifest.publishConfig?.access;
139
+ if (access !== undefined && access !== "public" && access !== "restricted") {
140
+ throw new Error(`npm archive has invalid publishConfig.access: ${String(access)}`);
141
+ }
142
+ return {
143
+ ...(access ? { access } : {}),
144
+ contentDigest: npmArchiveContentDigest(path),
145
+ integrity: `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`,
146
+ name: manifest.name,
147
+ repository: manifest.repository,
148
+ version: manifest.version,
149
+ };
150
+ }
151
+
152
+ /** Hash paths, executable bits, symlink targets, and bytes while ignoring tar metadata. */
153
+ export function npmArchiveContentDigest(path: string): string {
154
+ const temp = mkdtempSync(join(tmpdir(), "projen-npm-content-"));
155
+ try {
156
+ spawnSync("tar", ["-xzf", path, "-C", temp], {
157
+ cwd: process.cwd(),
158
+ stdout: "ignore",
159
+ stderr: "capture",
160
+ stdin: "ignore",
161
+ check: true,
162
+ });
163
+ const hash = createHash("sha512");
164
+ const visit = (directory: string): void => {
165
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
166
+ a.name.localeCompare(b.name),
167
+ )) {
168
+ const entryPath = join(directory, entry.name);
169
+ const name = relative(temp, entryPath).split("\\").join("/");
170
+ const stat = lstatSync(entryPath);
171
+ hash.update(`${entry.isDirectory() ? "D" : entry.isSymbolicLink() ? "L" : "F"}\0`);
172
+ hash.update(`${name}\0${stat.mode & 0o111}\0`);
173
+ if (entry.isDirectory()) visit(entryPath);
174
+ else if (entry.isSymbolicLink()) hash.update(readlinkSync(entryPath));
175
+ else hash.update(readFileSync(entryPath));
176
+ }
177
+ };
178
+ visit(temp);
179
+ return `sha512-${hash.digest("base64")}`;
180
+ } finally {
181
+ rmSync(temp, { recursive: true, force: true });
182
+ }
183
+ }
184
+
185
+ function normalizedRepository(value: unknown): string | undefined {
186
+ const url =
187
+ typeof value === "string"
188
+ ? value
189
+ : value && typeof value === "object" && "url" in value
190
+ ? String(value.url)
191
+ : undefined;
192
+ return url
193
+ ?.replace(/^git\+/, "")
194
+ .replace(/\.git$/, "")
195
+ .replace(/\/$/, "");
196
+ }
197
+
198
+ function registryUrl(registry: string, name: string, version: string): string {
199
+ return `${registry.replace(/\/$/, "")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
200
+ }
package/src/packages.ts CHANGED
@@ -25,9 +25,9 @@
25
25
  */
26
26
  import { existsSync, readFileSync } from "node:fs";
27
27
  import { extname, relative, resolve, sep } from "node:path";
28
- import * as projectUtils from "@dbx-tools/core/project-utils";
29
- import { find } from "@dbx-tools/path";
30
- import { json, object, stringUtils } from "@dbx-tools/shared-core";
28
+ import { projectUtils } from "@dbx-tools/core";
29
+ import { findFilesSync } from "@dbx-tools/path";
30
+ import { parseRecord, isRecord, toSlug, parseList } from "@dbx-tools/shared-core";
31
31
  import { parse } from "yaml";
32
32
  import { DBXToolsConfigDataSchema, type DBXToolsConfigData } from "./dbx-tools-config.ts";
33
33
  import { isGenerated } from "./generated.ts";
@@ -77,7 +77,7 @@ function nestingTagsFromSegments(segments: readonly string[]): string[] {
77
77
  const out: string[] = [];
78
78
  let acc = "";
79
79
  for (const segment of prefix) {
80
- const token = stringUtils.toSlug(segment);
80
+ const token = toSlug(segment);
81
81
  if (!token) continue;
82
82
  acc = acc ? `${acc}-${token}` : token;
83
83
  out.push(acc);
@@ -154,7 +154,7 @@ export class DiscoveredPackage {
154
154
  * Matched against `packageTagPaths` to resolve applied mixin tags.
155
155
  */
156
156
  get tagCandidates(): string[] {
157
- const leaf = stringUtils.toSlug(this.name);
157
+ const leaf = toSlug(this.name);
158
158
  if (leaf === "app" || leaf === "server") return [leaf];
159
159
  return nestingTagsFromSegments(this.relSegments);
160
160
  }
@@ -189,7 +189,7 @@ function packageOfMember(projectRoot: string, member: string): DiscoveredPackage
189
189
  */
190
190
  function collectPackageDirs(rootAbs: string): string[] {
191
191
  const owners = new Set<string>();
192
- for (const file of find.findFilesSync(SRC_MODULE_GLOB, { cwd: rootAbs })) {
192
+ for (const file of findFilesSync(SRC_MODULE_GLOB, { cwd: rootAbs })) {
193
193
  if (!isModuleFile(file)) continue;
194
194
  const segs = toPosix(file).split("/");
195
195
  const srcIdx = segs.indexOf("src");
@@ -255,7 +255,7 @@ export function readPackageManifest(dir: string): Record<string, unknown> | unde
255
255
  const path = resolve(dir, "package.json");
256
256
  if (!existsSync(path)) return undefined;
257
257
  try {
258
- return json.parseRecord(readFileSync(path, "utf8"));
258
+ return parseRecord(readFileSync(path, "utf8"));
259
259
  } catch {
260
260
  return undefined; // unreadable (permissions, race with a concurrent write)
261
261
  }
@@ -282,13 +282,13 @@ function readManifestTags(dir: string): string[] | undefined {
282
282
  */
283
283
  export function syncResynthPaths(projectRoot: string = resolveRepoRoot()): string[] {
284
284
  const paths = readDbxToolsConfig(projectRoot)?.syncResynthPaths;
285
- return Array.isArray(paths) ? stringUtils.parseList(paths.map((p) => String(p))) : [];
285
+ return Array.isArray(paths) ? parseList(paths.map((p) => String(p))) : [];
286
286
  }
287
287
 
288
288
  /** Projen task names supervised as focused processes by `sync --watch`. */
289
289
  export function syncWatchTasks(projectRoot: string = resolveRepoRoot()): string[] {
290
290
  const tasks = readDbxToolsConfig(projectRoot)?.syncWatchTasks;
291
- return Array.isArray(tasks) ? stringUtils.parseList(tasks.map((task) => String(task))) : [];
291
+ return Array.isArray(tasks) ? parseList(tasks.map((task) => String(task))) : [];
292
292
  }
293
293
 
294
294
  /**
@@ -338,7 +338,7 @@ export function workspaceDependencyDirectories(
338
338
  directories.add(pkg.dir);
339
339
  for (const field of ["dependencies", "optionalDependencies", "peerDependencies"] as const) {
340
340
  const dependencies = pkg.manifest[field];
341
- if (!object.isRecord(dependencies)) continue;
341
+ if (!isRecord(dependencies)) continue;
342
342
  for (const dependency of Object.keys(dependencies)) visit(dependency);
343
343
  }
344
344
  };
@@ -354,7 +354,7 @@ export function workspaceDependencyDirectories(
354
354
  export function recordedRoots(projectRoot: string = resolveRepoRoot()): string[] {
355
355
  const configured = readDbxToolsConfig(projectRoot)?.packageRoots;
356
356
  const configuredRoots = Array.isArray(configured)
357
- ? stringUtils.parseList(configured.map((root) => String(root)))
357
+ ? parseList(configured.map((root) => String(root)))
358
358
  : [];
359
359
  const roots = new Set<string>(configuredRoots.length ? configuredRoots : DEFAULT_PACKAGE_ROOTS);
360
360
  for (const member of readRecordedMembers(projectRoot)) {