@dbx-tools/projen 0.9.40 → 0.9.46

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 (52) hide show
  1. package/README.md +58 -67
  2. package/index.ts +6 -8
  3. package/package.json +9 -10
  4. package/shims/python-node/bootstrap.ts +129 -0
  5. package/shims/python-node/child-process.ts +96 -0
  6. package/shims/python-node/crypto.ts +60 -0
  7. package/shims/python-node/fs-promises.ts +179 -0
  8. package/shims/python-node/fs.ts +173 -0
  9. package/shims/python-node/headers.ts +75 -0
  10. package/shims/python-node/host.ts +341 -0
  11. package/shims/python-node/os.ts +20 -0
  12. package/shims/python-node/process.ts +13 -0
  13. package/shims/python-node/readline.ts +37 -0
  14. package/shims/python-node/tsconfig.json +24 -0
  15. package/src/_lazy-require.ts +7 -25
  16. package/src/bun-app.ts +2 -2
  17. package/src/bun-workflow.ts +2 -170
  18. package/src/codegen.ts +23 -20
  19. package/src/dbx-tools-config.ts +52 -9
  20. package/src/generated.ts +4 -17
  21. package/src/mixin.ts +1 -5
  22. package/src/packages.ts +18 -15
  23. package/src/pnpm-workspace.ts +1 -4
  24. package/src/project-js.ts +65 -196
  25. package/src/project-predicate.ts +7 -3
  26. package/src/project-py.ts +127 -37
  27. package/src/publish.ts +13 -10
  28. package/src/python-node-bindings.ts +2 -0
  29. package/src/python-node-bundle.ts +6 -0
  30. package/src/release-options.ts +17 -17
  31. package/src/release.ts +31 -14
  32. package/src/scaffold.ts +4 -7
  33. package/src/tags.ts +6 -16
  34. package/src/vscode.ts +1 -1
  35. package/src/watch.ts +5 -5
  36. package/src/workspace-lock.ts +1 -0
  37. package/src/workspace-version.ts +4 -4
  38. package/tasks/barrels.ts +3 -5
  39. package/tasks/clean.ts +1 -0
  40. package/tasks/lib/compile-workspace.ts +41 -19
  41. package/tasks/lib/local-publish.ts +0 -28
  42. package/tasks/lib/publish-npm.ts +8 -0
  43. package/tasks/lib/publish-python.ts +4 -0
  44. package/tasks/lib/python-node-bindings.ts +107 -26
  45. package/tasks/lib/python-registry.ts +4 -0
  46. package/tasks/lib/python-release.ts +2 -0
  47. package/tasks/lib/release.ts +2 -2
  48. package/tasks/projenrc.ts +1 -0
  49. package/tasks/sync.ts +4 -4
  50. package/src/openapi.ts +0 -293
  51. package/tasks/local-publish.ts +0 -6
  52. package/tasks/openapi.ts +0 -35
@@ -0,0 +1,341 @@
1
+ interface PythonBridge {
2
+ eval(source: string): unknown;
3
+ }
4
+
5
+ interface PythonCompletedProcess {
6
+ returncode: number;
7
+ stdout: string | null;
8
+ stderr: string | null;
9
+ }
10
+
11
+ interface PythonHttpResponse {
12
+ status_code: number;
13
+ headers: Record<string, string>;
14
+ content: Iterable<number>;
15
+ }
16
+
17
+ export interface PythonFileHost {
18
+ appendBytes(path: string, content: number[]): Promise<void>;
19
+ chmod(path: string, mode: number): Promise<void>;
20
+ copy(source: string, destination: string): Promise<void>;
21
+ exists(path: string): boolean;
22
+ mkdir(path: string, recursive: boolean): Promise<boolean>;
23
+ mkdtemp(prefix: string): Promise<string>;
24
+ readBytes(path: string): Promise<number[]>;
25
+ readDirectory(path: string): Promise<{ name: string; directory: boolean; file: boolean }[]>;
26
+ readTextSync(path: string): string;
27
+ readDirectorySync(path: string): { name: string; directory: boolean; file: boolean }[];
28
+ readLinkSync(path: string): string;
29
+ realpath(path: string): Promise<string>;
30
+ realpathSync(path: string): string;
31
+ remove(path: string, recursive: boolean, force: boolean): Promise<void>;
32
+ rename(source: string, destination: string): Promise<void>;
33
+ stat(path: string): Promise<{
34
+ directory: boolean;
35
+ file: boolean;
36
+ mode: number;
37
+ mtimeMs: number;
38
+ size: number;
39
+ }>;
40
+ statSync(path: string): {
41
+ directory: boolean;
42
+ file: boolean;
43
+ mode: number;
44
+ mtimeMs: number;
45
+ size: number;
46
+ };
47
+ touch(path: string, atimeMs: number, mtimeMs: number): Promise<void>;
48
+ writeBytes(path: string, content: number[], mode?: number): Promise<void>;
49
+ }
50
+
51
+ export interface PythonPathHost {
52
+ basename(path: string): string;
53
+ dirname(path: string): string;
54
+ fileUrlToPath(url: string): string;
55
+ isAbsolute(path: string): boolean;
56
+ join(parts: string[]): string;
57
+ relative(from: string, to: string): string;
58
+ resolve(parts: string[]): string;
59
+ }
60
+
61
+ export interface PythonProcessResult {
62
+ exitCode: number;
63
+ stdout?: string;
64
+ stderr?: string;
65
+ }
66
+
67
+ /** A started subprocess: await {@link wait} for its result, or {@link kill} it. */
68
+ export interface PythonProcessHandle {
69
+ /** Terminate the child (SIGKILL). No-op once it has already exited. */
70
+ kill(): void;
71
+ /** Resolve with the child's result once it exits (runs off the main thread). */
72
+ wait(): Promise<PythonProcessResult>;
73
+ }
74
+
75
+ export interface PythonRuntimeHost {
76
+ crypto: {
77
+ digest(algorithm: string, content: number[], encoding?: "hex" | "base64"): string | number[];
78
+ hashes(): string[];
79
+ randomBytes(length: number): number[];
80
+ };
81
+ file: PythonFileHost;
82
+ os: {
83
+ homedir(): string;
84
+ tmpdir(): string;
85
+ };
86
+ path: PythonPathHost;
87
+ http: {
88
+ fetch(
89
+ url: string,
90
+ method?: string,
91
+ headers?: Record<string, string>,
92
+ body?: string,
93
+ timeoutMs?: number,
94
+ ): Promise<{ status: number; headers: Record<string, string>; body: number[] }>;
95
+ };
96
+ process: {
97
+ start(
98
+ command: string,
99
+ args: string[],
100
+ environment: Record<string, string> | undefined,
101
+ input: string | undefined,
102
+ ): PythonProcessHandle;
103
+ };
104
+ }
105
+
106
+ type PythonFunction = (...args: any[]) => any;
107
+
108
+ const python = (globalThis as typeof globalThis & { python?: PythonBridge }).python;
109
+ if (!python) throw new Error("PythonMonkey globalThis.python is unavailable");
110
+
111
+ function evaluate<T extends PythonFunction>(source: string): T {
112
+ return python!.eval(source) as T;
113
+ }
114
+
115
+ const toThread = evaluate<PythonFunction>("__import__('asyncio').to_thread");
116
+ const osPath = {
117
+ basename: evaluate<PythonFunction>("__import__('os').path.basename"),
118
+ dirname: evaluate<PythonFunction>("__import__('os').path.dirname"),
119
+ exists: evaluate<PythonFunction>("__import__('os').path.exists"),
120
+ isAbsolute: evaluate<PythonFunction>("__import__('os').path.isabs"),
121
+ join: evaluate<PythonFunction>("lambda parts: __import__('os').path.join(*list(parts))"),
122
+ realpath: evaluate<PythonFunction>("__import__('os').path.realpath"),
123
+ relative: evaluate<PythonFunction>("__import__('os').path.relpath"),
124
+ resolve: evaluate<PythonFunction>(
125
+ "lambda parts: __import__('os').path.abspath(__import__('os').path.join(*list(parts))) if list(parts) else __import__('os').getcwd()",
126
+ ),
127
+ };
128
+ const readBytes = evaluate<PythonFunction>("lambda path: list(open(path, 'rb').read())");
129
+ const readText = evaluate<PythonFunction>("lambda path: open(path, encoding='utf-8').read()");
130
+ const writeBytes = evaluate<PythonFunction>(
131
+ "lambda path, content: open(path, 'wb').write(bytes(int(value) for value in content))",
132
+ );
133
+ const appendBytes = evaluate<PythonFunction>(
134
+ "lambda path, content: open(path, 'ab').write(bytes(int(value) for value in content))",
135
+ );
136
+ const mkdir = evaluate<PythonFunction>(
137
+ "lambda path, recursive: __import__('os').makedirs(path, exist_ok=recursive) if recursive else __import__('os').mkdir(path)",
138
+ );
139
+ const listDirectory = evaluate<PythonFunction>(
140
+ "lambda path: [{'name': entry.name, 'directory': entry.is_dir(), 'file': entry.is_file()} for entry in __import__('pathlib').Path(path).iterdir()]",
141
+ );
142
+ const readLink = evaluate<PythonFunction>("__import__('os').readlink");
143
+ const statPath = evaluate<PythonFunction>(
144
+ "lambda path: {'directory': __import__('os').path.isdir(path), 'file': __import__('os').path.isfile(path), 'mode': __import__('os').stat(path).st_mode, 'mtimeMs': __import__('os').stat(path).st_mtime * 1000, 'size': __import__('os').stat(path).st_size}",
145
+ );
146
+ const popen = evaluate<PythonFunction>(
147
+ "lambda command, args, environment, input_text: __import__('subprocess').Popen([command, *list(args)], env=dict(environment) if environment is not None else None, stdin=(__import__('subprocess').PIPE if input_text is not None else None), stdout=__import__('subprocess').PIPE, stderr=__import__('subprocess').PIPE, text=True)",
148
+ );
149
+ const communicate = evaluate<PythonFunction>(
150
+ "lambda proc, input_text: (lambda out: {'returncode': proc.returncode, 'stdout': out[0], 'stderr': out[1]})(proc.communicate(input=input_text))",
151
+ );
152
+ const killProcess = evaluate<PythonFunction>("lambda proc: proc.kill()");
153
+ const requestHttp = evaluate<PythonFunction>(
154
+ "lambda url, method, headers, body, timeout: __import__('httpx').request(method or 'GET', url, headers=dict(headers) if headers is not None else None, content=body, timeout=(timeout / 1000) if timeout is not None else 30, follow_redirects=True)",
155
+ );
156
+ const chmod = evaluate<PythonFunction>(
157
+ "lambda path, mode: __import__('os').chmod(path, int(mode))",
158
+ );
159
+ const isDir = evaluate<PythonFunction>("__import__('os').path.isdir");
160
+ const rmtree = evaluate<PythonFunction>("__import__('shutil').rmtree");
161
+ const rmdir = evaluate<PythonFunction>("__import__('os').rmdir");
162
+ const unlink = evaluate<PythonFunction>("__import__('os').unlink");
163
+
164
+ const host: PythonRuntimeHost = {
165
+ crypto: {
166
+ digest: evaluate<PythonFunction>(
167
+ "lambda algorithm, content, encoding: (lambda value: value.hexdigest() if encoding == 'hex' else __import__('base64').b64encode(value.digest()).decode('ascii') if encoding == 'base64' else list(value.digest()))(__import__('hashlib').new(algorithm, bytes(int(item) for item in content)))",
168
+ ),
169
+ hashes: evaluate<PythonFunction>("lambda: sorted(__import__('hashlib').algorithms_available)"),
170
+ randomBytes: evaluate<PythonFunction>(
171
+ "lambda length: list(__import__('os').urandom(int(length)))",
172
+ ),
173
+ },
174
+ file: {
175
+ async appendBytes(path, content) {
176
+ await toThread(appendBytes, path, content);
177
+ },
178
+ async chmod(path, mode) {
179
+ await toThread(chmod, path, mode);
180
+ },
181
+ async copy(source, destination) {
182
+ await toThread(
183
+ evaluate<PythonFunction>("__import__('shutil').copyfile"),
184
+ source,
185
+ destination,
186
+ );
187
+ },
188
+ exists: (path) => Boolean(osPath.exists(path)),
189
+ async mkdir(path, recursive) {
190
+ if (await toThread(osPath.exists, path)) return false;
191
+ await toThread(mkdir, path, recursive);
192
+ return true;
193
+ },
194
+ async mkdtemp(prefix) {
195
+ return toThread(
196
+ evaluate<PythonFunction>("__import__('tempfile').mkdtemp"),
197
+ undefined,
198
+ prefix,
199
+ );
200
+ },
201
+ async readBytes(path) {
202
+ return toThread(readBytes, path);
203
+ },
204
+ async readDirectory(path) {
205
+ return toThread(listDirectory, path);
206
+ },
207
+ readDirectorySync: (path) => listDirectory(path),
208
+ readLinkSync: (path) => String(readLink(path)),
209
+ readTextSync: (path) => String(readText(path)),
210
+ async realpath(path) {
211
+ return String(await toThread(osPath.realpath, path));
212
+ },
213
+ realpathSync: (path) => String(osPath.realpath(path)),
214
+ async remove(path, recursive, force) {
215
+ if (!(await toThread(osPath.exists, path))) {
216
+ if (!force) throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
217
+ return;
218
+ }
219
+ const isDirectory = await toThread(isDir, path);
220
+ const operation = isDirectory ? (recursive ? rmtree : rmdir) : unlink;
221
+ await toThread(operation, path);
222
+ },
223
+ async rename(source, destination) {
224
+ await toThread(evaluate<PythonFunction>("__import__('os').replace"), source, destination);
225
+ },
226
+ async stat(path) {
227
+ return toThread(statPath, path);
228
+ },
229
+ statSync: (path) => statPath(path),
230
+ async touch(path, atimeMs, mtimeMs) {
231
+ await toThread(
232
+ evaluate<PythonFunction>(
233
+ "lambda path, atime, mtime: __import__('os').utime(path, (atime / 1000, mtime / 1000))",
234
+ ),
235
+ path,
236
+ atimeMs,
237
+ mtimeMs,
238
+ );
239
+ },
240
+ async writeBytes(path, content, mode) {
241
+ await toThread(writeBytes, path, content);
242
+ if (mode !== undefined) await toThread(chmod, path, mode);
243
+ },
244
+ },
245
+ http: {
246
+ async fetch(url, method, headers, body, timeoutMs) {
247
+ const response = (await toThread(
248
+ requestHttp,
249
+ url,
250
+ method,
251
+ headers,
252
+ body,
253
+ timeoutMs,
254
+ )) as PythonHttpResponse;
255
+ return {
256
+ status: response.status_code,
257
+ headers: Object.fromEntries(Object.entries(response.headers)),
258
+ body: Array.from(response.content),
259
+ };
260
+ },
261
+ },
262
+ os: {
263
+ homedir: evaluate<PythonFunction>("lambda: __import__('pathlib').Path.home().as_posix()"),
264
+ tmpdir: evaluate<PythonFunction>("__import__('tempfile').gettempdir"),
265
+ },
266
+ path: {
267
+ basename: (path) => String(osPath.basename(path)),
268
+ dirname: (path) => String(osPath.dirname(path)),
269
+ fileUrlToPath: evaluate<PythonFunction>(
270
+ "lambda url: __import__('urllib.parse', fromlist=['urlparse']).unquote(__import__('urllib.parse', fromlist=['urlparse']).urlparse(url).path)",
271
+ ),
272
+ isAbsolute: (path) => Boolean(osPath.isAbsolute(path)),
273
+ join: (parts) => String(osPath.join(parts)),
274
+ relative: (from, to) => String(osPath.relative(to, from)),
275
+ resolve: (parts) => String(osPath.resolve(parts)),
276
+ },
277
+ process: {
278
+ start(command, args, environment, input) {
279
+ // Popen (not subprocess.run) so the child is killable: an aborted caller
280
+ // can terminate it instead of leaking a background process. There is no
281
+ // imposed timeout - the child runs to completion (an interactive login may
282
+ // legitimately take minutes); the caller owns cancellation.
283
+ const proc = popen(command, args, environment, input);
284
+ let killed = false;
285
+ return {
286
+ kill() {
287
+ if (killed) return;
288
+ killed = true;
289
+ try {
290
+ killProcess(proc);
291
+ } catch {
292
+ // The child already exited; there is nothing to signal.
293
+ }
294
+ },
295
+ async wait() {
296
+ // communicate() blocks until exit, so run it off the main thread.
297
+ const result = (await toThread(communicate, proc, input)) as PythonCompletedProcess;
298
+ return {
299
+ exitCode: result.returncode,
300
+ ...(result.stdout?.trim() ? { stdout: result.stdout.trim() } : {}),
301
+ ...(result.stderr?.trim() ? { stderr: result.stderr.trim() } : {}),
302
+ };
303
+ },
304
+ };
305
+ },
306
+ },
307
+ };
308
+
309
+ export function installPythonGlobals(): void {
310
+ const globals = globalThis as typeof globalThis & {
311
+ global?: typeof globalThis;
312
+ self?: typeof globalThis;
313
+ window?: typeof globalThis;
314
+ process?: Record<string, unknown>;
315
+ };
316
+ globals.global = globalThis;
317
+ globals.self = globalThis;
318
+ globals.window = globalThis;
319
+ globals.process ??= {
320
+ arch: String(evaluate<PythonFunction>("__import__('platform').machine")())
321
+ .replace("aarch64", "arm64")
322
+ .replace("x86_64", "x64"),
323
+ argv: [],
324
+ browser: true,
325
+ cwd: evaluate<PythonFunction>("__import__('os').getcwd"),
326
+ env: evaluate<PythonFunction>("lambda: dict(__import__('os').environ)")(),
327
+ nextTick: (callback: (...args: unknown[]) => void, ...args: unknown[]) =>
328
+ Promise.resolve().then(() => callback(...args)),
329
+ platform: { Darwin: "darwin", Linux: "linux", Windows: "win32" }[
330
+ String(evaluate<PythonFunction>("__import__('platform').system")())
331
+ ],
332
+ version: "v22.0.0",
333
+ versions: {},
334
+ };
335
+ }
336
+
337
+ installPythonGlobals();
338
+
339
+ export function pythonHost(): PythonRuntimeHost {
340
+ return host;
341
+ }
@@ -0,0 +1,20 @@
1
+ import { pythonHost } from "./host.ts";
2
+
3
+ export const constants = {
4
+ errno: {
5
+ EEXIST: 17,
6
+ ENOTDIR: 20,
7
+ EISDIR: 21,
8
+ EINVAL: 22,
9
+ },
10
+ } as const;
11
+
12
+ export function homedir(): string {
13
+ return pythonHost().os.homedir();
14
+ }
15
+
16
+ export function tmpdir(): string {
17
+ return pythonHost().os.tmpdir();
18
+ }
19
+
20
+ export default { constants, homedir, tmpdir };
@@ -0,0 +1,13 @@
1
+ import { installPythonGlobals } from "./host.ts";
2
+
3
+ installPythonGlobals();
4
+
5
+ export default process;
6
+ export const arch = process.arch;
7
+ export const argv = process.argv;
8
+ export const cwd = process.cwd;
9
+ export const env = process.env;
10
+ export const nextTick = process.nextTick;
11
+ export const platform = process.platform;
12
+ export const version = process.version;
13
+ export const versions = process.versions;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Async line reader used by subprocess output capture in PythonMonkey bundles.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ interface ReadLineOptions {
8
+ readonly input: AsyncIterable<unknown>;
9
+ }
10
+
11
+ class PythonReadLine implements AsyncIterable<string> {
12
+ private closed = false;
13
+
14
+ constructor(private readonly input: AsyncIterable<unknown>) {}
15
+
16
+ async *[Symbol.asyncIterator](): AsyncIterator<string> {
17
+ let buffered = "";
18
+ for await (const chunk of this.input) {
19
+ if (this.closed) break;
20
+ buffered += String(chunk);
21
+ const lines = buffered.split(/\r?\n/);
22
+ buffered = lines.pop() ?? "";
23
+ for (const line of lines) yield line;
24
+ }
25
+ if (!this.closed && buffered) yield buffered.replace(/\r$/, "");
26
+ }
27
+
28
+ close(): void {
29
+ this.closed = true;
30
+ }
31
+ }
32
+
33
+ export function createInterface(options: ReadLineOptions): PythonReadLine {
34
+ return new PythonReadLine(options.input);
35
+ }
36
+
37
+ export default { createInterface };
@@ -0,0 +1,24 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "bunx projen".
2
+ {
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM"
10
+ ],
11
+ "types": [
12
+ "node"
13
+ ],
14
+ "noEmit": true,
15
+ "skipLibCheck": true,
16
+ "strict": false
17
+ },
18
+ "include": [
19
+ "*.ts"
20
+ ],
21
+ "exclude": [
22
+ "node_modules"
23
+ ]
24
+ }
@@ -1,30 +1,14 @@
1
1
  /**
2
- * Loading heavy generator tools out of the CONSUMING workspace.
2
+ * Load heavy generator tools only when code generation has work to do.
3
3
  *
4
- * The generators (`openapi.ts`, `codegen.ts`) each drive a large toolchain that
5
- * only matters when that generator actually has work to do, so none of them are
6
- * imported at module scope and several are not engine dependencies at all. Node
7
- * resolves a bare specifier from the engine's own location by walking up, and under
8
- * pnpm that walk passes through `node_modules/.pnpm/node_modules` - the hidden
9
- * directory holding every package installed ANYWHERE in the workspace. So a tool
10
- * declared by some member package (`tsoa`, via the `server` tag) or by the root
11
- * (`typescript`) resolves fine from here without the engine shipping its own copy.
12
- *
13
- * The failure mode is a consumer whose workspace never installed the tool, which
14
- * surfaces as a bare MODULE_NOT_FOUND naming a package they never asked for. The
15
- * helper below turns that into the install command instead.
4
+ * Code generation drives a large toolchain only when a package declares inputs,
5
+ * so those tools are not imported at module scope. This helper identifies a
6
+ * broken engine installation instead of exposing a bare MODULE_NOT_FOUND from a
7
+ * lazy require.
16
8
  */
17
9
 
18
- /** How to obtain each lazily-loaded tool, for the error message. */
19
- const INSTALL_HINTS: Record<string, string> = {
20
- tsoa: "pnpm add tsoa",
21
- typescript: "pnpm add -D typescript",
22
- "ts-to-zod": "pnpm add -D ts-to-zod",
23
- };
24
-
25
10
  /**
26
- * `require` a generator tool from the workspace, reporting a missing one as an
27
- * actionable error rather than a raw MODULE_NOT_FOUND.
11
+ * `require` an engine-owned generator dependency only when codegen needs it.
28
12
  *
29
13
  * @param require - a `createRequire(import.meta.url)` bound to the calling module,
30
14
  * so the resolution walk starts at the engine and reaches the consumer's install.
@@ -36,10 +20,8 @@ export function lazyRequire<T>(require: NodeJS.Require, name: string, reason: st
36
20
  return require(name) as T;
37
21
  } catch (cause) {
38
22
  if ((cause as NodeJS.ErrnoException)?.code !== "MODULE_NOT_FOUND") throw cause;
39
- const hint = INSTALL_HINTS[name] ?? `pnpm add -D ${name}`;
40
23
  throw new Error(
41
- `${reason} needs the \`${name}\` package, which is not installed in this workspace. ` +
42
- `Add it to the package that needs it (\`${hint}\`) and re-run.`,
24
+ `${reason} requires the \`${name}\` dependency shipped by @dbx-tools/projen; reinstall workspace dependencies and re-run`,
43
25
  { cause },
44
26
  );
45
27
  }
package/src/bun-app.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  * imports `index.html` and serves the SPA with React hot module reloading.
12
12
  * - {@link BunBuildFile} - `build.ts`: a Databricks-safe `Bun.build()` production
13
13
  * bundle of `index.html` (the `bun-plugin-tailwind` plugin must run through the
14
- * JS API, NOT the `bun build` CLI, which does not load it), plus Vite-compatible
15
- * staging of an optional `public/` directory.
14
+ * JS API, NOT the `bun build` CLI, which does not load it), plus copying an
15
+ * optional `public/` directory.
16
16
  *
17
17
  * The dev and build scripts support unmanaged `bun-dev.override.ts` and
18
18
  * `bun-build.override.ts` modules beside them. Each script imports the override's
@@ -1,172 +1,4 @@
1
- /** Shared Bun setup and package-cache steps for generated workflows. */
2
- import { stringUtils } from "@dbx-tools/shared-core";
3
- import { TextFile, javascript } from "projen";
1
+ /** Shared Bun setup for generated workflows. */
4
2
 
3
+ /** Bun version used by package manifests, generated workflows, and CLI services. */
5
4
  export const BUN_VERSION = "1.3.14";
6
-
7
- const DEFAULT_CACHE_IGNORE_PATHS = [
8
- ".git",
9
- ".venv",
10
- ".worktrees",
11
- "coverage",
12
- "dist",
13
- "lib",
14
- "node_modules",
15
- "target",
16
- ] as const;
17
-
18
- function cacheKeyScript(extraIgnorePaths: readonly string[]): string {
19
- const ignorePaths = [...new Set(extraIgnorePaths)].sort();
20
- // prettier-ignore
21
- const source = (
22
- // ============================================================================
23
- /*js*/`
24
- #!/usr/bin/env node
25
- import { createHash } from "node:crypto";
26
- import { readdirSync, readFileSync } from "node:fs";
27
- import { join, sep } from "node:path";
28
-
29
- const root = process.cwd();
30
- const ignoredNames = new Set(${JSON.stringify(DEFAULT_CACHE_IGNORE_PATHS)});
31
- const ignoredPaths = new Set(${JSON.stringify(ignorePaths)});
32
- const manifests = [];
33
- const walk = (directory) => {
34
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
35
- const path = join(directory, entry.name);
36
- const relativePath = path.slice(root.length + 1).split(sep).join("/");
37
- if (ignoredNames.has(entry.name) || ignoredPaths.has(relativePath)) continue;
38
- if (entry.isDirectory()) walk(path);
39
- else if (entry.name === "package.json") manifests.push(path);
40
- }
41
- };
42
- walk(root);
43
-
44
- const dependencyFields = [
45
- "catalog",
46
- "dependencies",
47
- "devDependencies",
48
- "optionalDependencies",
49
- "overrides",
50
- "peerDependencies",
51
- "peerDependenciesMeta",
52
- "resolutions",
53
- "trustedDependencies",
54
- ];
55
- const canonical = (value) => {
56
- if (Array.isArray(value)) return value.map(canonical);
57
- if (!value || typeof value !== "object") return value;
58
- return Object.fromEntries(
59
- Object.entries(value)
60
- .sort(([left], [right]) => left.localeCompare(right))
61
- .map(([key, child]) => [key, canonical(child)]),
62
- );
63
- };
64
- const dependencies = manifests
65
- .sort()
66
- .map((path) => {
67
- const manifest = JSON.parse(readFileSync(path, "utf8"));
68
- return [
69
- path.slice(root.length + 1),
70
- Object.fromEntries(
71
- dependencyFields
72
- .filter((field) => manifest[field] !== undefined)
73
- .map((field) => [field, canonical(manifest[field])]),
74
- ),
75
- ];
76
- });
77
- process.stdout.write(createHash("sha256").update(JSON.stringify(dependencies)).digest("hex"));
78
- `
79
- // ============================================================================
80
- );
81
- return stringUtils.dedent(source, { trimEnd: false });
82
- }
83
-
84
- const configured = new WeakMap<javascript.NodeProject, string>();
85
-
86
- function ensureCacheKeyScript(
87
- project: javascript.NodeProject,
88
- ignorePaths: readonly string[],
89
- ): void {
90
- const root = project.root as javascript.NodeProject;
91
- const key = JSON.stringify([...new Set(ignorePaths)].sort());
92
- const existing = configured.get(root);
93
- if (existing !== undefined) {
94
- if (existing !== key) {
95
- throw new Error("Bun workflow cache ignore paths must be consistent across one project");
96
- }
97
- return;
98
- }
99
- new TextFile(root, ".projen/bun-cache-key.mjs", {
100
- lines: cacheKeyScript(ignorePaths).trimEnd().split("\n"),
101
- });
102
- configured.set(root, key);
103
- }
104
-
105
- function stepCondition(condition: string | undefined, cacheMiss = false): string | undefined {
106
- const expressions = [
107
- condition,
108
- ...(cacheMiss ? ["steps.bun_cache.outputs.cache-hit != 'true'"] : []),
109
- ].filter(Boolean);
110
- return expressions.length ? `\${{ ${expressions.join(" && ")} }}` : undefined;
111
- }
112
-
113
- export interface BunWorkflowCacheOptions {
114
- readonly setupCondition?: string;
115
- readonly condition?: string;
116
- /** Repository-relative output paths excluded from dependency manifest hashing. */
117
- readonly ignorePaths?: readonly string[];
118
- }
119
-
120
- /** Set up Bun and restore its global package cache. */
121
- export function bunCacheRestoreSteps(
122
- project: javascript.NodeProject,
123
- options: BunWorkflowCacheOptions = {},
124
- ): readonly Record<string, unknown>[] {
125
- ensureCacheKeyScript(project, options.ignorePaths ?? []);
126
- const condition = stepCondition(options.condition);
127
- const setupCondition = stepCondition(options.setupCondition);
128
- return [
129
- {
130
- name: "Setup Bun",
131
- ...(setupCondition ? { if: setupCondition } : {}),
132
- uses: "oven-sh/setup-bun@v2",
133
- with: { "bun-version": "${{ env.BUN_VERSION }}" },
134
- },
135
- {
136
- name: "Resolve Bun cache",
137
- id: "bun_cache_metadata",
138
- ...(condition ? { if: condition } : {}),
139
- shell: "bash",
140
- run: [
141
- 'echo "path=$(bun pm cache)" >> "$GITHUB_OUTPUT"',
142
- 'echo "dependency_hash=$(node .projen/bun-cache-key.mjs)" >> "$GITHUB_OUTPUT"',
143
- ].join("\n"),
144
- },
145
- {
146
- name: "Restore Bun cache",
147
- id: "bun_cache",
148
- ...(condition ? { if: condition } : {}),
149
- uses: "actions/cache/restore@v5",
150
- with: {
151
- path: "${{ steps.bun_cache_metadata.outputs.path }}",
152
- key: `bun-\${{ runner.os }}-\${{ runner.arch }}-\${{ env.BUN_VERSION }}-\${{ steps.bun_cache_metadata.outputs.dependency_hash }}`,
153
- "restore-keys": `bun-\${{ runner.os }}-\${{ runner.arch }}-\${{ env.BUN_VERSION }}-`,
154
- },
155
- },
156
- ];
157
- }
158
-
159
- /** Save Bun's global package cache immediately after installation. */
160
- export function bunCacheSaveStep(
161
- options: BunWorkflowCacheOptions = {},
162
- ): Readonly<Record<string, unknown>> {
163
- return {
164
- name: "Save Bun cache",
165
- if: stepCondition(options.condition, true),
166
- uses: "actions/cache/save@v5",
167
- with: {
168
- path: "${{ steps.bun_cache_metadata.outputs.path }}",
169
- key: "${{ steps.bun_cache.outputs.cache-primary-key }}",
170
- },
171
- };
172
- }