@dbx-tools/projen 0.6.76 → 0.6.78

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.
@@ -0,0 +1,1246 @@
1
+ /**
2
+ * The dbx-tools project surface plus package tooling: the single
3
+ * {@link DBXToolsJavaScriptProject} interface, the projen Node/TypeScript project classes,
4
+ * naming, guards, manifest fields, and the shared root init.
5
+ *
6
+ * {@link DBXToolsNodeProject} (monorepo root) and {@link DBXToolsTypeScriptProject}
7
+ * (a package, or a standalone compiling root) both implement {@link DBXToolsJavaScriptProject}.
8
+ */
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { dirname, join, relative, resolve } from "node:path";
11
+ import { project as coreProject } from "@dbx-tools/core";
12
+ import { ignore, match } from "@dbx-tools/path";
13
+ import { object, string, type OneOrMany } from "@dbx-tools/shared-core";
14
+ import { type IConstruct } from "constructs";
15
+ import { Component, IgnoreFile, Project, type TaskOptions, javascript, typescript } from "projen";
16
+ import { ReleaseTrigger } from "projen/lib/release";
17
+ import { mixin } from "..";
18
+ import { generateBarrels } from "./barrels.ts";
19
+ import {
20
+ BUN_APP_OVERRIDES,
21
+ BunBuildFile,
22
+ BunDevServerFile,
23
+ BunfigFile,
24
+ RootBunfigFile,
25
+ } from "./bun-app.ts";
26
+ import { codegenModulePaths, generateCodegen } from "./codegen.ts";
27
+ import { DBXToolsConfig, type DBXToolsConfigOptions } from "./dbx-tools-config.ts";
28
+ import { resolvePkgRoot } from "./engine-root.ts";
29
+ import {
30
+ DEFAULT_PACKAGE_ROOTS,
31
+ type DiscoveredPackage,
32
+ projectName,
33
+ readPackageManifest,
34
+ repoRoot,
35
+ scanPackages,
36
+ toPosix,
37
+ } from "./packages.ts";
38
+ import { PnpmWorkspaceState, type DBXToolsPNPMWorkspaceOptions } from "./pnpm-workspace.ts";
39
+ import { applyCompiledPublish } from "./publish.ts";
40
+ import { DBXToolsRelease, type StandaloneRelease } from "./release.ts";
41
+ import { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS, type PackageTag } from "./tags.ts";
42
+ import { DBXToolsRootTsconfig } from "./tsconfig.ts";
43
+ import { DBXToolsVsCode } from "./vscode.ts";
44
+ import type { DBXToolsProject, DBXToolsProjectOptions as CommonProjectOptions } from "./project.ts";
45
+
46
+ /**
47
+ * The dbx-tools project surface, backed by projen's Node toolchain. A single
48
+ * interface for both the monorepo root and each package: it carries the
49
+ * `dbxToolsConfig` component plus the npm-naming and root-only file components.
50
+ */
51
+ export interface DBXToolsJavaScriptProject extends DBXToolsProject, javascript.NodeProject {
52
+ /** The package's `dbxToolsConfig` component (tags + `package.json` config). */
53
+ readonly dbxToolsConfig: DBXToolsConfig;
54
+ /** npm scope (the `@scope` in `@scope/pkg`), without the leading `@`. */
55
+ readonly scope: string;
56
+
57
+ /**
58
+ * The `pnpm-workspace.yaml` catalog / member / build-allowance state - only a
59
+ * tree ROOT has one. The FILE itself is owned by projen's native
60
+ * `javascript.PnpmWorkspaceYaml`; this is the state it renders.
61
+ */
62
+ pnpmWorkspace?: PnpmWorkspaceState;
63
+ /** Root projenrc tsconfigs - only a tree ROOT has one. */
64
+ rootTsconfig?: DBXToolsRootTsconfig;
65
+ /** Root `.vscode/*` - only a tree ROOT has one. */
66
+ vsCode?: DBXToolsVsCode;
67
+ }
68
+
69
+ /** Parsed npm package identifier: optional scope plus the unscoped package name. */
70
+ export class PackageIdentifier {
71
+ public scope?: string;
72
+
73
+ public name: string;
74
+
75
+ constructor(scope: string | null | undefined, name: string) {
76
+ this.scope = scope || undefined;
77
+ this.name = name;
78
+ }
79
+
80
+ /** Full npm name (`@scope/name` or bare `name`). */
81
+ public get packageName(): string {
82
+ return this.scope ? `@${this.scope}/${this.name}` : this.name;
83
+ }
84
+
85
+ /**
86
+ * Parse an npm package name into scope and unscoped segments without rewriting them.
87
+ */
88
+ static parse(value: string): PackageIdentifier | undefined {
89
+ const trimmed = value?.trim();
90
+ if (!trimmed) return undefined;
91
+
92
+ if (trimmed.startsWith("@")) {
93
+ const slash = trimmed.indexOf("/", 1);
94
+ if (slash === -1) return new PackageIdentifier(trimmed.slice(1), "");
95
+ return new PackageIdentifier(trimmed.slice(1, slash), trimmed.slice(slash + 1));
96
+ }
97
+
98
+ const slash = trimmed.indexOf("/");
99
+ if (slash === -1) return new PackageIdentifier(undefined, trimmed);
100
+ return new PackageIdentifier(trimmed.slice(0, slash), trimmed.slice(slash + 1));
101
+ }
102
+
103
+ /**
104
+ * Build from ordered path parts. One segment stays bare; multiple become
105
+ * `@<first>/<rest joined by ->`.
106
+ *
107
+ * The leading segment is the npm `@scope`, kebab-cased with
108
+ * {@link string.toSlug} so a multi-word scope survives intact
109
+ * (`dbx-tools` -> `dbx-tools`, not `dbx`/`tools`). Every later path
110
+ * segment is tokenized with {@link string.tokenize}, so nested folders
111
+ * split into their own dash-joined name parts.
112
+ */
113
+ static of(...names: OneOrMany<string>): PackageIdentifier {
114
+ const segments = names.flatMap((part) => part.split("/")).filter(Boolean);
115
+ const scope = segments.length ? string.toSlug(segments[0]!) : "";
116
+ const nameParts = [
117
+ scope,
118
+ ...segments.slice(1).flatMap((segment) => [...string.tokenize(segment)]),
119
+ ].filter(Boolean);
120
+ if (!nameParts.length) throw new Error(`Invalid name: ${names.join(", ")}`);
121
+ if (nameParts.length === 1) return new PackageIdentifier(undefined, nameParts[0]!);
122
+ return new PackageIdentifier(nameParts[0], nameParts.slice(1).join("-"));
123
+ }
124
+ }
125
+
126
+ /** Parsed `package.json` `name` for a projen `NodeProject`. */
127
+ export function identifier(project: Project): PackageIdentifier {
128
+ return PackageIdentifier.parse(project.name) ?? new PackageIdentifier(undefined, project.name);
129
+ }
130
+
131
+ /** Root-only `package.json` fields. */
132
+ function configureRootPackage(project: javascript.NodeProject): void {
133
+ project.package.addField("type", "module");
134
+ project.package.addField("private", true);
135
+ }
136
+
137
+ /**
138
+ * Stamp `repository` on a package's manifest so npm provenance can validate the
139
+ * published source (without it, publish fails with E422). A child also carries the
140
+ * monorepo `directory` subpath (its path relative to the root); the root omits it.
141
+ * No-op when no git remote is detected and no `repository` override was supplied.
142
+ * The URL is auto-detected + cached by {@link coreProject.repositoryUrl} (gh, then
143
+ * a normalized git remote), in npm's `git+https://.../repo.git` form.
144
+ */
145
+ function applyRepository(project: javascript.NodeProject, override?: string): void {
146
+ const url = override && override.length ? override : coreProject.repositoryUrl(repoRoot, "npm");
147
+ if (!url) return;
148
+ const root = project.parent ?? project;
149
+ const directory = toPosix(relative(resolve(root.outdir), resolve(project.outdir)));
150
+ project.package.addField("repository", {
151
+ type: "git",
152
+ url,
153
+ ...(directory ? { directory } : {}),
154
+ });
155
+ }
156
+
157
+ /** Inherit a parent's package manager, else bun. */
158
+ function inheritedPackageManager(
159
+ parent: javascript.NodeProject | undefined,
160
+ ): javascript.NodePackageManager {
161
+ return parent?.package.packageManager ?? javascript.NodePackageManager.BUN;
162
+ }
163
+
164
+ /** Override a package's generated tsconfig `compilerOptions` (later-wins per key). */
165
+ export function applyCompilerOptions(
166
+ pkg: javascript.NodeProject,
167
+ compilerOptions: javascript.TypeScriptCompilerOptions,
168
+ ): void {
169
+ if (!(pkg instanceof typescript.TypeScriptProject)) return;
170
+ const file = pkg.tsconfig?.file;
171
+ if (!file) return;
172
+ for (const [key, value] of Object.entries(compilerOptions)) {
173
+ if (value === undefined) continue;
174
+ file.addOverride(`compilerOptions.${key}`, value);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Add `include` globs to a package's generated tsconfig. The tag defaults cover
180
+ * `src/**` only, so a package that compiles code OUTSIDE `src/` (its root
181
+ * `index.ts` barrel, a `bin/` or `tasks/` tree) needs the extra entries - pair
182
+ * this with a `rootDir: "."` in {@link applyCompilerOptions}.
183
+ */
184
+ export function applyIncludes(pkg: javascript.NodeProject, ...includes: string[]): void {
185
+ if (!(pkg instanceof typescript.TypeScriptProject)) return;
186
+ for (const include of includes) pkg.tsconfig?.addInclude(include);
187
+ }
188
+
189
+ /** Apply a tag's `tasks` through projen's task system. */
190
+ export function applyTasks(pkg: javascript.NodeProject, tasks?: Record<string, TaskOptions>): void {
191
+ if (!tasks) return;
192
+ for (const [name, options] of Object.entries(tasks)) {
193
+ const owned = name === "build" ? pkg.compileTask : pkg.tasks.tryFind(name);
194
+ if (owned) owned.reset(options.exec, options);
195
+ else pkg.addTask(name, options);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Set a package's `exports` subpath map (whole-field replace, so a later mixin
201
+ * that supplies a fuller surface wins over a tag default). Lets the `cli` / `ui`
202
+ * / `app` tags carry their standard export layout and a package only re-declare
203
+ * `exports` when it deviates.
204
+ */
205
+ export function applyExports(pkg: javascript.NodeProject, exports: Record<string, string>): void {
206
+ pkg.package.addField("exports", exports);
207
+ // Keep the `main`/`types` entry points consistent with the map. A subpath-only
208
+ // surface (the `ui` tag's `./react` + `./styles.css`) has no
209
+ // `.` export, so the constructor's `main`/`types` would keep advertising a
210
+ // root entry that every exports-aware resolver ignores - the contradiction
211
+ // publint reports as "exports is missing the root entrypoint".
212
+ if (!exports["."]) {
213
+ pkg.package.addField("main", undefined);
214
+ pkg.package.addField("types", undefined);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * MERGE extra subpaths onto a package's existing `exports` map (later keys win),
220
+ * preserving whatever a tag default already set. Use when a package just ADDS a
221
+ * subpath - e.g. the CLI tag's `.` + `./package.json` default plus dbx-tools'
222
+ * `./pnpm` - so the two common entries need not be re-listed. Contrast with
223
+ * {@link applyExports}, which replaces the whole field.
224
+ *
225
+ * New subpaths are inserted before the conventional trailing `./package.json`
226
+ * entry when present, so ordering stays `.` -> subpaths -> `./package.json`.
227
+ */
228
+ export function addExports(pkg: javascript.NodeProject, exports: Record<string, string>): void {
229
+ const current = (pkg.package.manifest.exports ?? {}) as Record<string, string>;
230
+ const { "./package.json": packageJson, ...rest } = current;
231
+ pkg.package.addField("exports", {
232
+ ...rest,
233
+ ...exports,
234
+ ...(packageJson !== undefined ? { "./package.json": packageJson } : {}),
235
+ });
236
+ }
237
+
238
+ /**
239
+ * MERGE entries onto a package's npm `files` allowlist - the only paths that
240
+ * ship in the published tarball. npm always includes `package.json`, `README`,
241
+ * and `LICENSE` on top of whatever is listed, so those are never declared here.
242
+ *
243
+ * The baseline (`index.ts` + `src`, set at construction) is the source-first
244
+ * entry surface the workspace's own `exports` map resolves to. A tag adds what
245
+ * its layout ships outside `src` - the `cli` tag its `bin/` launchers - and
246
+ * {@link applyCompiledPublish} adds `lib/`, which is what the PUBLISHED
247
+ * `exports` resolves to. Source ships alongside the compiled output rather than
248
+ * instead of it: it costs little, and it keeps stack traces and go-to-definition
249
+ * landing on real code for consumers that want it.
250
+ *
251
+ * Everything else the build leaves behind (`test/`, `.projen/`, `tsconfig*`) is
252
+ * unreachable through either map and is deliberately withheld.
253
+ */
254
+ export function addPackageFiles(pkg: javascript.NodeProject, ...entries: string[]): void {
255
+ const current = (pkg.package.manifest.files ?? []) as string[];
256
+ pkg.package.addField("files", [...new Set([...current, ...entries])]);
257
+ }
258
+
259
+ /**
260
+ * The `./<name>` -> `./src/<name>.ts` subpath map for a package's top-level `src`
261
+ * modules, skipping `_`-prefixed private modules and declaration files.
262
+ *
263
+ * This widens no API surface: the root `index.ts` barrel already re-exports every
264
+ * non-`_` module, so those names are public through `.` either way - the subpaths
265
+ * just add a narrower import path. Deriving the map is what lets a tag carry the
266
+ * whole export layout, instead of each package hand-listing its own modules.
267
+ */
268
+ export function srcModuleExports(pkg: javascript.NodeProject): Record<string, string> {
269
+ const srcDir = join(pkg.outdir, "src");
270
+ if (!existsSync(srcDir)) return {};
271
+
272
+ const exports: Record<string, string> = {};
273
+ for (const file of readdirSync(srcDir).sort()) {
274
+ if (file.startsWith("_") || !file.endsWith(".ts") || file.endsWith(".d.ts")) continue;
275
+ exports[`./${file.slice(0, -".ts".length)}`] = `./src/${file}`;
276
+ }
277
+ return exports;
278
+ }
279
+
280
+ /**
281
+ * ESM compiler options every package shares regardless of tag.
282
+ *
283
+ * Relative imports in this repo carry their REAL extension (`./http.ts`), which
284
+ * is what lets `tsc` rewrite them to `./http.js` on emit
285
+ * (`rewriteRelativeImportExtensions`) instead of a post-processing pass fixing up
286
+ * the emitted tree. Both flags belong here rather than on the publishing packages
287
+ * only: the specifier style is a property of the SOURCE, so a `ui` package (which
288
+ * publishes source and is excluded from the compiled surface) still has to accept
289
+ * and rewrite it.
290
+ *
291
+ * `jsx` is here for the same reason, and it is NOT a per-tag concern even though
292
+ * only React packages author `.tsx`. Packages resolve each other to SOURCE
293
+ * (`main: index.ts`), so a consumer type-checks its dependency's files under its
294
+ * OWN tsconfig: the moment any package re-exports a `.tsx` module, every package
295
+ * that imports it - however far down the graph, whatever its tag - fails with
296
+ * `TS6142: ... but '--jsx' is not set`. Setting it per consumer is the wrong fix
297
+ * (the consumer does not author JSX and has no way to know a transitive dependency
298
+ * started to), so the floor carries it. The option is inert for a package with no
299
+ * `.tsx` in its graph: it selects how JSX syntax COMPILES and adds no lib, no
300
+ * global, and no type dependency on its own.
301
+ */
302
+ const SHARED_COMPILER_OPTIONS: javascript.TypeScriptCompilerOptions & {
303
+ rewriteRelativeImportExtensions: boolean;
304
+ } = {
305
+ module: "ESNext",
306
+ moduleResolution: javascript.TypeScriptModuleResolution.BUNDLER,
307
+ skipLibCheck: true,
308
+ allowImportingTsExtensions: true,
309
+ rewriteRelativeImportExtensions: true,
310
+ jsx: javascript.TypeScriptJsxMode.REACT_JSX,
311
+ };
312
+
313
+ /** Shared formatting rules, applied by projen's Prettier on whichever project is root. */
314
+ const PRETTIER_SETTINGS: javascript.PrettierSettings = {
315
+ printWidth: 100,
316
+ tabWidth: 2,
317
+ useTabs: false,
318
+ semi: true,
319
+ singleQuote: false,
320
+ quoteProps: javascript.QuoteProps.ASNEEDED,
321
+ jsxSingleQuote: false,
322
+ trailingComma: javascript.TrailingComma.ALL,
323
+ bracketSpacing: true,
324
+ bracketSameLine: false,
325
+ arrowParens: javascript.ArrowParens.ALWAYS,
326
+ endOfLine: javascript.EndOfLine.LF,
327
+ };
328
+
329
+ /**
330
+ * The `projen` version every generated manifest pins.
331
+ *
332
+ * Kept as one constant so the root's devDependency and this engine's own
333
+ * dependency can never drift apart - a synth run loads the engine from one copy
334
+ * of projen and the tasks execute against another otherwise.
335
+ */
336
+ export const PROJEN_VERSION = "^0.101.16";
337
+
338
+ /**
339
+ * The engine's opinionated `NodeProject` defaults. A caller's own options override
340
+ * these (they are spread AFTER this). Root-only concerns key off `options.parent`,
341
+ * NOT the class: only the tree ROOT (no parent) turns on projen's built-in Prettier
342
+ * (the `prettier` devDep + `.prettierrc.json` + `.prettierignore`), so a child package
343
+ * inherits the root's config rather than emitting its own. `name`/`defaultReleaseBranch`
344
+ * are resolved/applied by the caller.
345
+ */
346
+ function defaultProjectOptions(
347
+ options: DBXToolsJavaScriptProjectOptions,
348
+ ): DBXToolsJavaScriptProjectOptions {
349
+ const isRoot = options.parent === undefined;
350
+ return {
351
+ // Bun owns install/run/build/test locally and in CI. projen renders
352
+ // `bun install`/`bunx` and a native `trustedDependencies` field from this.
353
+ // The engine still emits `pnpm-workspace.yaml` itself (see
354
+ // {@link PnpmWorkspaceState}) for the Databricks Apps platform, whose build
355
+ // phase installs with pnpm - so a deployed app keeps its catalog + build
356
+ // allowances even though the local/CI manager is bun.
357
+ packageManager: javascript.NodePackageManager.BUN,
358
+ // Pinned rather than left to projen's "latest": 0.101.16 is the first release
359
+ // whose `NodePackage` renders bun's `trustedDependencies` natively. Under bun,
360
+ // projen does NOT create the `pnpm-workspace.yaml` component itself (that call
361
+ // site is gated to pnpm), so the engine constructs it directly ({@link
362
+ // PnpmWorkspaceState}). Floating would let an install cross that boundary
363
+ // silently, so the co-tested version is stated here and bumped deliberately.
364
+ projenVersion: PROJEN_VERSION,
365
+ defaultReleaseBranch: "main",
366
+ projenrcJs: false,
367
+ // Every CHILD is a publishable package, so it needs
368
+ // `publishConfig.access: public` - projen renders that from `npmAccess`
369
+ // whenever the value differs from the name's default, and every child here is
370
+ // scoped (`@dbx-tools/*`), whose default is RESTRICTED. Root-only exclusion is
371
+ // deliberate: a root's name is unscoped, so PUBLIC *is* its default and projen
372
+ // would omit the key - except that `npmProvenance` then defaults on and forces
373
+ // the block to render, giving the root a `publishConfig` it does not have
374
+ // today. Provenance is never written to a manifest here (projen only reads it
375
+ // in its own `Publisher`, and `release: false` means none exists); the
376
+ // tag-driven `release` workflow opts in per-run via `npm_config_provenance`
377
+ // instead, so LOCAL publishes to a verdaccio still work with no CI OIDC
378
+ // provider. See {@link DBXToolsRelease}.
379
+ ...(isRoot ? {} : { npmAccess: javascript.NpmAccess.PUBLIC }),
380
+ buildWorkflow: false,
381
+ release: false,
382
+ // No `npm pack` step on any project. projen wires `package` into `build`, so
383
+ // `bunx projen build` would tarball all 36 manifests (root included) into
384
+ // gitignored `dist/js` on every CI run and never read them: publishing here
385
+ // is `bun publish` driving each package's own `prepack` (see
386
+ // {@link applyCompiledPublish} and the `publish` task), and `release: false`
387
+ // means no projen Publisher exists to consume the artifacts either.
388
+ package: false,
389
+ jest: false,
390
+ github: false,
391
+ npmignoreEnabled: false,
392
+ licensed: false,
393
+ entrypoint: "",
394
+ depsUpgrade: false,
395
+ // Bins are declared explicitly via `p.package.addBin(...)`. projen's default
396
+ // auto-detection scans the `bin/` dir and adds every EXECUTABLE file keyed by
397
+ // its filename, so an executable `bin/dbx-tools.ts` becomes a spurious second
398
+ // bin named `dbx-tools.ts` (breaking `pnpm dlx` with ERR_PNPM_DLX_MULTIPLE_BINS).
399
+ autoDetectBin: false,
400
+ peerDependencyOptions: { pinnedDevDependency: false },
401
+ addPackageManagerToDevEngines: false,
402
+ devDeps: ["@types/node@^24.6.0"],
403
+ ...(isRoot
404
+ ? {
405
+ prettier: true,
406
+ prettierOptions: {
407
+ settings: PRETTIER_SETTINGS,
408
+ ignoreFile: true,
409
+ ignoreFileOptions: { ignorePatterns: [...ignore.ignorePatterns({ test: false })] },
410
+ },
411
+ }
412
+ : {}),
413
+ ...options,
414
+ ...copiedGitIgnoreOptions(options),
415
+ };
416
+ }
417
+
418
+ /**
419
+ * `gitIgnoreOptions` with its `ignorePatterns` array CLONED, for handing to a
420
+ * projen `Project` constructor: projen's IgnoreFile ALIASES the array it is given
421
+ * (every later addPatterns call mutates it), so the throwaway default-laden
422
+ * `.gitignore` gets a copy - {@link swapChildGitignore} re-reads the caller's
423
+ * pristine array to seed a child's fresh one. Spread AFTER `...options`.
424
+ */
425
+ function copiedGitIgnoreOptions(
426
+ options: DBXToolsJavaScriptProjectOptions,
427
+ ): Pick<javascript.NodeProjectOptions, "gitIgnoreOptions"> {
428
+ if (!options.gitIgnoreOptions?.ignorePatterns) return {};
429
+ return {
430
+ gitIgnoreOptions: {
431
+ ...options.gitIgnoreOptions,
432
+ ignorePatterns: [...options.gitIgnoreOptions.ignorePatterns],
433
+ },
434
+ };
435
+ }
436
+
437
+ /**
438
+ * The engine's `TypeScriptProject` defaults - a superset of {@link defaultProjectOptions}.
439
+ * A DBXTools TS project can itself be the ROOT (a standalone compiling root), so the
440
+ * same parent-based root/child logic applies; this just layers on typescript +
441
+ * bun types and disables sample code. No `tsx`: bun runs `.ts` directly.
442
+ */
443
+ function defaultTypeScriptProjectOptions(
444
+ options: DBXToolsTypeScriptProjectOptions,
445
+ ): DBXToolsTypeScriptProjectOptions {
446
+ const base = defaultProjectOptions(options);
447
+ return {
448
+ ...base,
449
+ sampleCode: false,
450
+ entrypoint: undefined,
451
+ // ESLint is configured once on the ROOT (see initProject) and lints the whole
452
+ // tree, so packages don't emit their own config. A caller can still override.
453
+ eslint: false,
454
+ devDeps: [...(base.devDeps ?? []), "typescript@^5.9.3", "@types/bun@^1.3.14"],
455
+ ...options,
456
+ ...copiedGitIgnoreOptions(options),
457
+ };
458
+ }
459
+
460
+ // Pinned to match the subproject defaults so bun resolves a single typescript
461
+ // across the workspace (a bare name -> `*` could pull a second, newer major).
462
+ // `@types/bun` gives the `Bun.*` globals the server/app tags now use.
463
+ const DEV_DEPS_ROOT: string[] = ["typescript@^5.9.3", "@types/bun@^1.3.14"];
464
+
465
+ /** Options for {@link DBXToolsNodeProject} (the monorepo root). */
466
+ export interface DBXToolsJavaScriptProjectOptions
467
+ extends
468
+ CommonProjectOptions,
469
+ Partial<javascript.NodeProjectOptions>,
470
+ DBXToolsConfigOptions,
471
+ DBXToolsPNPMWorkspaceOptions {
472
+ /**
473
+ * The npm scope for generated package names (`@<scope>/<seg-...>`). Defaults to
474
+ * the (resolved) project name; a leading `@` is optional.
475
+ */
476
+ readonly scope?: string;
477
+ /**
478
+ * Roots scanned for packages (each `src`-bearing folder under a root is one).
479
+ * Only a ROOT scans. Defaults to {@link DEFAULT_PACKAGE_ROOTS}.
480
+ */
481
+ readonly packageRoots?: readonly string[];
482
+ /**
483
+ * Leading path segment(s) dropped from a discovered package's relative path
484
+ * before its npm name is derived, so a tier folder doesn't become a name
485
+ * prefix. E.g. with the default `"node"`, `packages/node/path` names as
486
+ * `@<scope>/path` instead of `@<scope>/node-path` (its `node` TAG still
487
+ * derives from the path). One or many segment names; a segment is only
488
+ * stripped when it is the FIRST segment of the relative path. Pass `[]` to
489
+ * disable. Defaults to `"node"`.
490
+ */
491
+ readonly omitRelativePrefix?: OneOrMany<string>;
492
+ /**
493
+ * Maps a path token / relPath / glob to tag(s), unioned into a package's
494
+ * path-derived tags. Defaults to an identity map over the known tag names; a
495
+ * `""`/`"."` key tags the root.
496
+ */
497
+ readonly packageTagPaths?: Record<string, string[]>;
498
+ /**
499
+ * Which built-in {@link PACKAGE_TAG_MIXINS} to apply and seed
500
+ * `packageTagPaths` identity entries for. Omitted = all; `false` = none;
501
+ * a list = only those tags.
502
+ */
503
+ readonly defaultTagMixins?: false | PackageTag[];
504
+ /**
505
+ * Extra repo-root paths that trigger a full re-synth during `sync --watch`
506
+ * (alongside `.projenrc.ts`). Repo-relative, e.g. `".example.projenrc.ts"`.
507
+ */
508
+ readonly syncResynthPaths?: readonly string[];
509
+ /**
510
+ * Standalone in-repo projects (NOT workspace members) that each get their own
511
+ * tag-driven release workflow authored alongside the root's `release`
512
+ * workflow - see {@link StandaloneRelease}. Use for a project that lives in a
513
+ * repo subdirectory but releases on its own tag prefix (e.g. the
514
+ * `@dbx-tools/projen` engine in `projen/`, tagged `projen-v*`).
515
+ */
516
+ readonly standaloneReleases?: readonly StandaloneRelease[];
517
+ /**
518
+ * Extra workspace member paths (repo-relative, POSIX) to list in the workspace
519
+ * config ALONGSIDE the discovered `packageRoots` members - for a package that
520
+ * is synthesized by its OWN `.projenrc.ts` (so it isn't a root subproject) but
521
+ * should still resolve as a workspace sibling. The `@dbx-tools/projen` engine in
522
+ * `projen/` is the case: it synthesizes itself (avoiding a dogfooding cycle) yet
523
+ * is a member of the single bun workspace, so the root links it from source.
524
+ */
525
+ readonly extraWorkspaceMembers?: readonly string[];
526
+ /**
527
+ * Install workspace dependencies once from the custom root instead of once
528
+ * per child project during post-synthesis. Defaults to `true`; set `false` to
529
+ * preserve projen's native per-project install tasks.
530
+ */
531
+ readonly rootInstallOnly?: boolean;
532
+ }
533
+
534
+ /** Options for {@link DBXToolsTypeScriptProject} (a package, or a compiling root). */
535
+ export interface DBXToolsTypeScriptProjectOptions
536
+ extends Partial<typescript.TypeScriptProjectOptions>, DBXToolsJavaScriptProjectOptions {
537
+ /** Emit the projen-owned bun app scaffolding (`bunfig.toml`/`dev.ts`/`build.ts`). */
538
+ readonly bunApp?: boolean;
539
+ }
540
+
541
+ /**
542
+ * A monorepo root. Scans `packageRoots` and appends a
543
+ * {@link DBXToolsTypeScriptProject} per `src`-bearing folder, then emits the
544
+ * shared config, tasks, `pnpm-workspace.yaml`, and barrels-on-synth.
545
+ */
546
+ export class DBXToolsNodeProject
547
+ extends javascript.NodeProject
548
+ implements DBXToolsJavaScriptProject
549
+ {
550
+ readonly language = "javascript" as const;
551
+ readonly scope: string;
552
+ readonly dbxToolsConfig: DBXToolsConfig;
553
+ pnpmWorkspace?: PnpmWorkspaceState;
554
+ rootTsconfig?: DBXToolsRootTsconfig;
555
+ vsCode?: DBXToolsVsCode;
556
+ private readonly extraWorkspaceMembers: readonly string[];
557
+ private readonly rootInstallOnly: boolean;
558
+
559
+ constructor(options: DBXToolsJavaScriptProjectOptions = {}) {
560
+ const { name, scope } = resolveIdentity(options);
561
+ const releaseDefaults =
562
+ options.release && options.releaseTrigger === undefined
563
+ ? { releaseTrigger: ReleaseTrigger.tagged({ tags: ["v*"] }) }
564
+ : {};
565
+ // Holds the workspace state (members/catalog/allowBuilds/overrides). Under
566
+ // bun, projen's base constructor does NOT create the `PnpmWorkspaceYaml`
567
+ // component (its `configurePnpm` call site is gated to pnpm), so this state's
568
+ // options are wired into a directly-constructed component below - AND mirrored
569
+ // into `package.json` (`workspaces`/`catalog`) for bun to read. The
570
+ // `pnpm-workspace.yaml` is still emitted for the Databricks Apps pnpm install.
571
+ const pnpmWorkspace = new PnpmWorkspaceState(options);
572
+ super({
573
+ ...defaultProjectOptions(options),
574
+ ...releaseDefaults,
575
+ pnpmOptions: {
576
+ ...options.pnpmOptions,
577
+ workspaceYamlOptions: pnpmWorkspace.options,
578
+ },
579
+ name,
580
+ });
581
+
582
+ this.pnpmWorkspace = pnpmWorkspace;
583
+ // Emit `pnpm-workspace.yaml` ourselves: under bun projen skips the native
584
+ // component, but the file is still required by the Databricks Apps platform
585
+ // (its build phase installs with pnpm and reads catalog + `allowBuilds`).
586
+ pnpmWorkspace.attachWorkspaceFile(this);
587
+ this.scope = scope;
588
+ this.extraWorkspaceMembers = options.extraWorkspaceMembers ?? [];
589
+ this.rootInstallOnly = options.rootInstallOnly !== false;
590
+ this.dbxToolsConfig = new DBXToolsConfig(this, options);
591
+ initProject(this, options);
592
+ }
593
+
594
+ public override preSynthesize(): void {
595
+ if (this.rootInstallOnly) this.with(ROOT_INSTALL_ONLY_MIXIN);
596
+ super.preSynthesize();
597
+ // Members come from the attached subprojects, which the root's scan appends
598
+ // after construction - so the list is filled here, not in the constructor.
599
+ // `extraWorkspaceMembers` adds self-synthesizing siblings (e.g. `projen/`).
600
+ this.pnpmWorkspace?.resolveMembers(this, this.extraWorkspaceMembers);
601
+ preSynthesizeProject(this);
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Root-owned workspace install policy.
607
+ *
608
+ * Every projen child has its own `NodePackage` post-synth hook, which otherwise
609
+ * runs `bun install` against the same root workspace once per package. Clear the
610
+ * child install tasks while leaving the root's real install/install:ci tasks
611
+ * intact. Applied in root `preSynthesize` so manually attached late children are
612
+ * included and repeated synths remain idempotent.
613
+ */
614
+ export const ROOT_INSTALL_ONLY_MIXIN = mixin.create(
615
+ (construct: IConstruct): construct is DBXToolsNodeProject | DBXToolsTypeScriptProject =>
616
+ (construct instanceof DBXToolsNodeProject || construct instanceof DBXToolsTypeScriptProject) &&
617
+ construct.parent !== undefined,
618
+ (child) => {
619
+ child.package.installTask.reset();
620
+ child.package.installCiTask.reset();
621
+ },
622
+ );
623
+
624
+ /**
625
+ * A single package (usually created by a root's scan), or a standalone
626
+ * compiling root. The agnostic tsconfig floor is applied at construction; the
627
+ * source-first package fields (`main`/`types`/`exports` -> `index.ts`) and optional
628
+ * Bun app scaffolding are applied after. Per-tag deps/tsconfig arrive later
629
+ * via the {@link PACKAGE_TAG_MIXINS} the root applies.
630
+ */
631
+ export class DBXToolsTypeScriptProject
632
+ extends typescript.TypeScriptProject
633
+ implements DBXToolsJavaScriptProject
634
+ {
635
+ readonly language = "javascript" as const;
636
+ readonly scope: string;
637
+ readonly dbxToolsConfig: DBXToolsConfig;
638
+ pnpmWorkspace?: PnpmWorkspaceState;
639
+ rootTsconfig?: DBXToolsRootTsconfig;
640
+ vsCode?: DBXToolsVsCode;
641
+
642
+ constructor(options: DBXToolsTypeScriptProjectOptions) {
643
+ const { name, scope } = resolveIdentity(options);
644
+ const parent = options?.parent;
645
+ const packageManager =
646
+ options.packageManager ??
647
+ inheritedPackageManager(parent instanceof javascript.NodeProject ? parent : undefined);
648
+
649
+ super({
650
+ ...defaultTypeScriptProjectOptions(options),
651
+ name: options.name ?? name,
652
+ packageManager,
653
+ tsconfig: {
654
+ ...options.tsconfig,
655
+ include: options.tsconfig?.include,
656
+ // Every package starts from the agnostic floor (ES2022, no DOM/node); a tag
657
+ // mixin layers its `lib`/`jsx`/`types` on top afterward via `project.with`.
658
+ compilerOptions: {
659
+ ...SHARED_COMPILER_OPTIONS,
660
+ ...AGNOSTIC_COMPILER_OPTIONS,
661
+ ...options.tsconfig?.compilerOptions,
662
+ },
663
+ },
664
+ });
665
+ this.scope = scope;
666
+ // Pairs with `jsx` in SHARED_COMPILER_OPTIONS: projen's default `include` is
667
+ // `src/**/*.ts` only, which silently omits a `.tsx` file from the program
668
+ // instead of failing, so authoring a React component would otherwise need
669
+ // per-package tsconfig config to be compiled at all.
670
+ this.tsconfig?.addInclude("src/**/*.tsx");
671
+ this.dbxToolsConfig = new DBXToolsConfig(this, options);
672
+ // Source-first entry: point the package at its package-ROOT `index.ts` barrel
673
+ // so packages resolve each other's `@scope/pkg` imports to source.
674
+ this.package.addField("type", "module");
675
+ this.package.addField("main", "index.ts");
676
+ this.package.addField("types", "index.ts");
677
+ this.package.addField("exports", {
678
+ ".": "./index.ts",
679
+ "./package.json": "./package.json",
680
+ });
681
+ addPackageFiles(this, "index.ts", "src");
682
+ // `bun test` intercepts `node:test` (the suites keep using node:test) and
683
+ // runs it with bun's own fast runner. Args are FILTERS, not globs; a bare
684
+ // directory auto-discovers `*.test.ts` recursively. But `bun test` EXITS 1
685
+ // when it matches no files, so guard it: only invoke when a `*.test.ts`
686
+ // exists, else succeed.
687
+ this.testTask.exec("bun test test", {
688
+ condition: 'find test -name "*.test.ts" 2>/dev/null | grep -q .',
689
+ });
690
+ if (options.bunApp ?? false) {
691
+ new BunfigFile(this);
692
+ new BunDevServerFile(this);
693
+ new BunBuildFile(this);
694
+ }
695
+ initProject(this, options);
696
+ }
697
+
698
+ public override preSynthesize(): void {
699
+ super.preSynthesize();
700
+ preSynthesizeProject(this);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Regenerates the repo's generated source after synth: first the codegen
706
+ * modules (ts-to-zod schemas from each `codegen`-declaring package's upstream
707
+ * `.d.ts`), then every package's root `index.ts` barrel - so a freshly
708
+ * generated module is namespaced into its barrel in the same pass. This is the
709
+ * "generate on resynth" path for plain `projen`; codegen inputs (SDK `.d.ts`)
710
+ * change rarely, so a synth-time regen is enough and there's no separate watch.
711
+ *
712
+ * projen only runs `postSynthesize` when `PROJEN_DISABLE_POST` is unset, so this
713
+ * is skipped during the watcher's fast `runSynth` (which sets it); there barrels
714
+ * are rebuilt explicitly. It also runs after `NodeProject`'s own post-synth
715
+ * install, so codegen's `node_modules/...` inputs resolve.
716
+ */
717
+ class GeneratedSource extends Component {
718
+ public override postSynthesize(): void {
719
+ generateCodegen();
720
+ generateBarrels();
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Make the ROOT `compile` / `test` tasks actually validate the workspace.
726
+ *
727
+ * projen gives a monorepo root empty `compile`/`test` tasks - a child's tasks
728
+ * are the child's business - so `bun run build` at the root type-checked nothing
729
+ * and ran no package tests. The fan-out is delegated to bun's own workspace
730
+ * filter rather than one `exec` per member, which matters three ways: bun runs
731
+ * the members in PARALLEL (measured ~2.5x faster across this repo than the
732
+ * sequential per-`cwd` form), a member that does not define the script is
733
+ * skipped instead of needing a guard, and the filter reads the workspace from
734
+ * `package.json` - so it stays correct when a package is added without a
735
+ * re-synth. A non-zero member exit still fails the run.
736
+ *
737
+ * `*` matches every workspace MEMBER and never the root itself, so the root
738
+ * task delegating to it cannot recurse. Members declared outside the scanned
739
+ * package roots (`extraWorkspaceMembers`) are workspace members too, so they are
740
+ * covered by the same filter.
741
+ */
742
+ class WorkspaceValidationTasks extends Component {
743
+ private configured = false;
744
+
745
+ public override preSynthesize(): void {
746
+ if (this.configured) return;
747
+ this.configured = true;
748
+ const project = this.project as javascript.NodeProject;
749
+ for (const task of [project.compileTask, project.testTask]) {
750
+ task.exec(`bun run --filter '*' ${task.name}`);
751
+ }
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Bound the default validation workflows when a root opts into them.
757
+ *
758
+ * Projen otherwise leaves jobs at GitHub's six-hour ceiling. Missing workflows
759
+ * are a no-op, so roots that keep the engine defaults (`github`/build workflow
760
+ * off) do not gain new files.
761
+ */
762
+ class WorkflowTimeouts extends Component {
763
+ public override preSynthesize(): void {
764
+ const build = this.project.tryFindObjectFile(".github/workflows/build.yml");
765
+ for (const job of ["build", "self-mutation"]) {
766
+ build?.addOverride(`jobs.${job}.timeout-minutes`, 30);
767
+ }
768
+ this.project
769
+ .tryFindObjectFile(".github/workflows/pull-request-lint.yml")
770
+ ?.addOverride("jobs.validate.timeout-minutes", 10);
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Ignore each `codegen`-declaring package's `src/` from the root ESLint config.
776
+ * Those modules are read-only (ts-to-zod); lint `--fix` otherwise EACCES-crashes
777
+ * on them. Runs in `preSynthesize` so mixin-added `codegen.inputs` are visible.
778
+ */
779
+ class EslintIgnoreCodegen extends Component {
780
+ public override preSynthesize(): void {
781
+ const eslint = javascript.Eslint.of(this.project);
782
+ if (!eslint) return;
783
+ const rootAbs = resolve(this.project.outdir);
784
+ for (const sub of this.project.subprojects) {
785
+ if (!(sub instanceof javascript.NodeProject)) continue;
786
+ const codegen = sub.package.manifest.codegen as { inputs?: string[] } | undefined;
787
+ if (!codegen?.inputs?.length) continue;
788
+ const rel = toPosix(relative(rootAbs, sub.outdir));
789
+ // Ignore the generated MODULES, not the package's whole `src/`. A codegen
790
+ // package may hold hand-written modules next to its generated ones
791
+ // (shared-genie generates `dashboards.ts` beside a hand-written
792
+ // `genie-model.ts`), and a blanket `src/**` would silently stop linting
793
+ // them - the failure mode being invisible, since ESLint just reports less.
794
+ for (const module of codegenModulePaths(codegen.inputs)) {
795
+ eslint.addIgnorePattern(`${rel}/${module}`);
796
+ }
797
+ }
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Keep generated package barrels and codegen modules out of root formatting.
803
+ *
804
+ * Their generators own the layout and may mark outputs read-only. Package paths
805
+ * are derived from attached subprojects so custom `packageRoots` need no manual
806
+ * Prettier patterns.
807
+ */
808
+ class PrettierIgnoreGenerated extends Component {
809
+ public override preSynthesize(): void {
810
+ const prettier = javascript.Prettier.of(this.project);
811
+ if (!prettier) return;
812
+ const rootAbs = resolve(this.project.outdir);
813
+ for (const sub of this.project.subprojects) {
814
+ if (!(sub instanceof javascript.NodeProject)) continue;
815
+ const rel = toPosix(relative(rootAbs, sub.outdir));
816
+ prettier.addIgnorePattern(`${rel}/index.ts`);
817
+ const codegen = sub.package.manifest.codegen as { inputs?: string[] } | undefined;
818
+ for (const module of codegenModulePaths(codegen?.inputs ?? [])) {
819
+ prettier.addIgnorePattern(`${rel}/${module}`);
820
+ }
821
+ }
822
+ }
823
+ }
824
+
825
+ /** Default leading path segment stripped from a package's name (not its tag). */
826
+ const DEFAULT_OMIT_RELATIVE_PREFIX = ["node"];
827
+
828
+ /** Normalize the {@link DBXToolsJavaScriptProjectOptions.omitRelativePrefix} option to a slug list. */
829
+ function resolveOmitRelativePrefix(option: OneOrMany<string> | undefined): string[] {
830
+ const raw = option === undefined ? DEFAULT_OMIT_RELATIVE_PREFIX : option;
831
+ const list = Array.isArray(raw) ? raw : [raw];
832
+ return list.map((segment) => string.toSlug(segment)).filter(Boolean);
833
+ }
834
+
835
+ /**
836
+ * Derive a package's npm name from its scope + relative path, dropping a leading
837
+ * `omitPrefixes` segment first (so a tier folder like `node/` doesn't become a
838
+ * name prefix). The full `relPath` is still used elsewhere for tags.
839
+ */
840
+ function packageNameFor(scope: string, relPath: string, omitPrefixes: string[]): string {
841
+ const segments = relPath.split("/").filter(Boolean);
842
+ if (segments.length > 1 && omitPrefixes.includes(string.toSlug(segments[0]!))) {
843
+ segments.shift();
844
+ }
845
+ return PackageIdentifier.of(scope, segments.join("/")).packageName;
846
+ }
847
+
848
+ /**
849
+ * Resolve `{ name, scope }` from options. `name` is `options.name`, else
850
+ * auto-detected (git remote/folder). `scope` is `options.scope`, else the name;
851
+ * either way it is parsed through {@link PackageIdentifier} so a scoped value
852
+ * (`@dbx-tools` or a full `@dbx-tools/root` name) yields the bare scope `dbx-tools`.
853
+ */
854
+ function resolveIdentity(options: { name?: string; scope?: string }): {
855
+ name: string;
856
+ scope: string;
857
+ } {
858
+ const name = options.name && options.name.length ? options.name : projectName();
859
+ const rawScope = options.scope && options.scope.length ? options.scope : name;
860
+ const identifier = PackageIdentifier.parse(rawScope);
861
+ return { name, scope: identifier?.scope ?? identifier?.name ?? rawScope };
862
+ }
863
+
864
+ /**
865
+ * A devDep entry that keeps the engine itself resolvable for the *next* synth (a
866
+ * consumer's `.projenrc.ts` imports the classes from it). Resolved from the
867
+ * engine's OWN nearby `package.json`; `undefined` when running as plain in-repo
868
+ * SOURCE (not under a `node_modules` segment). Reuses whatever specifier the
869
+ * consumer already has for it rather than computing one.
870
+ */
871
+ function engineSelfDependency(project: javascript.NodeProject): string | undefined {
872
+ const enginePkgJson = join(resolvePkgRoot(), "package.json");
873
+ if (!toPosix(enginePkgJson).includes("/node_modules/")) return undefined;
874
+ const engine = readPackageManifest(dirname(enginePkgJson));
875
+ const name = string.trimToNull(engine?.name);
876
+ if (!name) return undefined;
877
+ const version = string.trimToNull(engine?.version);
878
+
879
+ // No existing consumer manifest (or no entry) falls through to a computed pin.
880
+ const consumer = readPackageManifest(resolve(project.outdir));
881
+ const dependencyOf = (field: unknown): string | undefined =>
882
+ object.isRecord(field) ? (string.trimToNull(field[name]) ?? undefined) : undefined;
883
+ const existing = dependencyOf(consumer?.devDependencies) ?? dependencyOf(consumer?.dependencies);
884
+ if (existing) return `${name}@${existing}`;
885
+ return `${name}@^${version}`;
886
+ }
887
+
888
+ /** Resolve which {@link PACKAGE_TAG_MIXINS} keys to apply from `defaultTagMixins`. */
889
+ function resolveEnabledTagMixins(selection: false | PackageTag[] | undefined): PackageTag[] {
890
+ if (selection === false) return [];
891
+ if (selection === undefined) {
892
+ return Object.keys(PACKAGE_TAG_MIXINS) as PackageTag[];
893
+ }
894
+ return selection;
895
+ }
896
+
897
+ /** True if `key` matches a discovered package by candidate / relPath / memberPath / glob. */
898
+ function tagPathMatches(key: string, p: DiscoveredPackage): boolean {
899
+ // Fast path: an exact tag candidate or the package's rel/member path.
900
+ if (p.tagCandidates.includes(key) || key === p.relPath || key === p.memberPath) {
901
+ return true;
902
+ }
903
+ // Otherwise treat the key as a glob against the same targets.
904
+ const isMatch = match.toPathMatcher(key);
905
+ return isMatch(p.relPath) || isMatch(p.memberPath) || p.tagCandidates.some((c) => isMatch(c));
906
+ }
907
+
908
+ /** Resolve a discovered package's tags from the `tagPaths` map (union of matches). */
909
+ function resolveTags(p: DiscoveredPackage, tagPaths: Record<string, string[]>): string[] {
910
+ const tags: string[] = [];
911
+ for (const [key, value] of Object.entries(tagPaths)) {
912
+ if (tagPathMatches(key, p)) {
913
+ for (const tag of value) if (!tags.includes(tag)) tags.push(tag);
914
+ }
915
+ }
916
+ return tags;
917
+ }
918
+
919
+ /** Register the native projen tasks on the monorepo root. */
920
+ function registerRootTasks(project: javascript.NodeProject): void {
921
+ applyTasks(project, {
922
+ barrels: { exec: taskScript(project, "barrels.ts") },
923
+ openapi: { exec: taskScript(project, "openapi.ts") },
924
+ clean: { exec: taskScript(project, "clean.ts"), receiveArgs: true },
925
+ // `receiveArgs` forwards `--watch`, so `bun run sync -- --watch` syncs once
926
+ // then starts the single node-path watcher loop.
927
+ sync: { exec: taskScript(project, "sync.ts"), receiveArgs: true },
928
+ });
929
+ }
930
+
931
+ /**
932
+ * `bun node_modules/@dbx-tools/projen/tasks/<script>` command for a projen task.
933
+ *
934
+ * Use the stable package symlink, never `require.resolve()`'s physical store
935
+ * path. A later install can change the peer-hash directory while leaving the
936
+ * package symlink valid; persisting the physical path made every generated task
937
+ * fail with ERR_MODULE_NOT_FOUND after such an update. bun runs the `.ts`
938
+ * directly (no tsx, no build step).
939
+ */
940
+ export function taskScript(_project: javascript.NodeProject, script: string, args = ""): string {
941
+ const scriptPath = toPosix(join("node_modules", "@dbx-tools", "projen", "tasks", script));
942
+ return args ? `bun ${scriptPath} ${args}` : `bun ${scriptPath}`;
943
+ }
944
+
945
+ /**
946
+ * Shared init both classes call at the end of their constructor. Only the tree
947
+ * ROOT does anything: it attaches the projenrc runner, root devDeps/fields,
948
+ * `pnpm-workspace.yaml`, shared config, tasks, gitignore/`annotateGenerated`,
949
+ * scans + appends children, applies the built-in tag mixins across the subtree
950
+ * (via `project.with`), and adds the barrels-on-synth component. Non-root projects
951
+ * only swap in a fresh custom-patterns-only `.gitignore` and return.
952
+ */
953
+ function initProject(
954
+ project: DBXToolsNodeProject | DBXToolsTypeScriptProject,
955
+ options: DBXToolsJavaScriptProjectOptions,
956
+ ): void {
957
+ // projen's GithubProject seeds a `# replace this` SampleReadme on every
958
+ // project. READMEs are hand-written and owned outside projen, so drop the
959
+ // generated one (and never mark it read-only) - both root and child.
960
+ project.tryRemoveFile("README.md");
961
+
962
+ if (project.parent) {
963
+ project.package.file.readonly = true;
964
+ // Stamp `repository` (with this package's `directory` subpath) so a published
965
+ // package passes npm provenance validation.
966
+ applyRepository(project, options.repository);
967
+ // Only a ROOT configures the workspace; a child just swaps its default-laden
968
+ // `.gitignore` for a fresh one that carries package-specific patterns only.
969
+ swapChildGitignore(project, options);
970
+ return;
971
+ }
972
+ project.package.file.readonly = false;
973
+
974
+ // NodeProject has no built-in TS projenrc support (unlike TypeScriptProject), so
975
+ // wire `.projenrc.ts` through a runner - this also populates the `default` task
976
+ // that `bunx projen` runs (and that the `sync` watcher invokes to re-synth).
977
+ // The runner choice is immaterial since the exec is reset to plain `bun` below;
978
+ // `nodejs()` avoids declaring a `ts-node`/`tsx` dependency.
979
+ new typescript.ProjenrcTs(project, {
980
+ runner: typescript.TypeScriptRunner.nodejs(),
981
+ });
982
+ // bun runs `.projenrc.ts` directly (native TS, no loader to register). Reset to
983
+ // a plain `bun` exec rather than any wrapper: the default task is spawned by
984
+ // nested installs/synths, and a wrapper that exported `npm_config_*` broke them.
985
+ project.defaultTask?.reset("bun .projenrc.ts");
986
+
987
+ // Pin bun's hoisted linker workspace-wide (see RootBunfigFile) so a peer dep
988
+ // resolves to one copy and singletons/types stay coherent.
989
+ new RootBunfigFile(project);
990
+
991
+ // Only reached on a ROOT (early-returned above otherwise), so the root devDeps
992
+ // always apply; the self-dep is added only when the engine is an installed pkg.
993
+ const selfDep = engineSelfDependency(project);
994
+ if (selfDep) project.addDevDeps(selfDep);
995
+ project.addDevDeps(...DEV_DEPS_ROOT);
996
+ configureRootPackage(project);
997
+ // Root carries the bare `repository` (no `directory`); children add their subpath.
998
+ applyRepository(project, options.repository);
999
+
1000
+ if (options.syncResynthPaths?.length) {
1001
+ project.dbxToolsConfig.syncResynthPaths = [...options.syncResynthPaths];
1002
+ }
1003
+
1004
+ project.rootTsconfig = new DBXToolsRootTsconfig(project);
1005
+ project.vsCode = new DBXToolsVsCode(project);
1006
+
1007
+ registerRootTasks(project);
1008
+ if (options.prettier || project.prettier) {
1009
+ const formatTask = project.tasks.tryFind("format") ?? project.addTask("format");
1010
+ formatTask.prependExec("prettier . --write", { receiveArgs: true });
1011
+ }
1012
+
1013
+ // `dot: false` for the same reason as `test: false`: the dot group is a
1014
+ // SCANNING concern (skip `.git` and caches when walking the tree), and a
1015
+ // blanket `**/.*` in a `.gitignore` is both wrong and actively harmful. A repo
1016
+ // legitimately commits `.github/`, `.projen/tasks.json`, `.vscode/settings.json`,
1017
+ // `.editorconfig`. Worse, `**/.*` excludes those DIRECTORIES, and git refuses
1018
+ // to re-include a file whose parent directory is excluded - so every per-file
1019
+ // `!/.github/...` negation projen emits for its own generated files silently
1020
+ // does nothing, and the file cannot be added at all.
1021
+ project.gitignore.addPatterns(...[...ignore.ignorePatterns({ test: false, dot: false })]);
1022
+ // What the dot group was actually earning here, named explicitly: secrets and
1023
+ // local editor state. Both ignore CONTENTS (`.idea/*`) rather than the
1024
+ // directory, so a later `!` negation can still reach a file inside.
1025
+ project.gitignore.addPatterns(".env", ".env.*", "!.env.example", "!.env.sample", ".idea/*");
1026
+ const roots = options.packageRoots ?? DEFAULT_PACKAGE_ROOTS;
1027
+ for (const root of roots) {
1028
+ project.annotateGenerated(`/${root}/**/index.ts`);
1029
+ project.annotateGenerated(`/${root}/openapi/**`);
1030
+ }
1031
+
1032
+ // ESLint lives ONLY on the root and lints every package. `projectService` resolves
1033
+ // each file to its own package tsconfig (so type-aware rules work tree-wide), and
1034
+ // `import/no-extraneous-dependencies` still checks each file against its nearest
1035
+ // package.json. Formatting defers to the root Prettier to avoid rule/formatter
1036
+ // conflicts (e.g. quote style). The normal task is check-only so CI never
1037
+ // repairs the worktree it is meant to validate; `eslint:fix` is the explicit
1038
+ // local mutation path.
1039
+ const eslint = new javascript.Eslint(project, {
1040
+ dirs: [...roots, "projen"],
1041
+ fileExtensions: [".ts", ".tsx"],
1042
+ projectService: true,
1043
+ prettier: Boolean(project.prettier),
1044
+ tsconfigPath: "./tsconfig.json",
1045
+ commandOptions: { fix: false },
1046
+ });
1047
+ project.addTask("eslint:fix", {
1048
+ description: "Fix ESLint issues across the codebase",
1049
+ exec: "bun run eslint -- --fix",
1050
+ });
1051
+ // Generated read-only outputs (barrels, openapi clients, app scripts, codegen).
1052
+ // ESLint --fix cannot rewrite them; they are stamped by the barrel generator /
1053
+ // openapi / codegen / projen.
1054
+ for (const root of roots) {
1055
+ eslint.addIgnorePattern(`${root}/openapi/**`);
1056
+ eslint.addIgnorePattern(`${root}/**/index.ts`);
1057
+ }
1058
+ eslint.addIgnorePattern("projen/index.ts");
1059
+ // The generated bun app scripts + unmanaged overrides live at the package root,
1060
+ // outside any `src/**` tsconfig include, so the type-aware parser cannot resolve
1061
+ // them to a project. ESLint still cannot parse them.
1062
+ eslint.addIgnorePattern("**/dev.ts");
1063
+ eslint.addIgnorePattern("**/build.ts");
1064
+ // A deploy-staging helper that lives at a package root (outside any `src/**`
1065
+ // tsconfig), same parse-resolution problem as the bun app scripts above.
1066
+ eslint.addIgnorePattern("**/stage-deploy.ts");
1067
+ for (const override of BUN_APP_OVERRIDES) {
1068
+ eslint.addIgnorePattern(`**/${override}`);
1069
+ }
1070
+ // Codegen packages declare `codegen.inputs` via mixins after construction; ignore
1071
+ // their `src/` once manifests are known (preSynthesize), same reason as openapi.
1072
+ new EslintIgnoreCodegen(project);
1073
+ eslint.addRules({
1074
+ "import/no-relative-packages": "error",
1075
+ // Monorepo tooling legitimately uses devDeps (typescript, tsx, projen) in src.
1076
+ "import/no-extraneous-dependencies": [
1077
+ "error",
1078
+ { devDependencies: true, optionalDependencies: false, peerDependencies: true },
1079
+ ],
1080
+ "@typescript-eslint/no-shadow": "off",
1081
+ "no-bitwise": "off",
1082
+ "@typescript-eslint/member-ordering": "off",
1083
+ });
1084
+ eslint.addOverride({
1085
+ files: ["**/test/**/*.ts", "**/test/**/*.tsx"],
1086
+ // node:test `describe`/`it` return promises by design.
1087
+ rules: { "@typescript-eslint/no-floating-promises": "off" },
1088
+ });
1089
+ if (eslint.config?.settings) {
1090
+ // eslint-plugin-import knows Node built-ins but not Bun's test module.
1091
+ eslint.config.settings["import/core-modules"] = ["bun:test"];
1092
+ }
1093
+ // Point the TS import resolver at every package tsconfig, not just the root's
1094
+ // (which only includes `.projenrc.ts`), so `import/no-unresolved` resolves
1095
+ // cross-package imports.
1096
+ const tsResolver = eslint.config?.settings?.["import/resolver"]?.typescript;
1097
+ if (tsResolver) {
1098
+ tsResolver.project = ["tsconfig.json", ...roots.map((r) => `${r}/**/tsconfig.json`)];
1099
+ }
1100
+
1101
+ const enabledTagMixins = resolveEnabledTagMixins(options.defaultTagMixins);
1102
+ const omitPrefixes = resolveOmitRelativePrefix(options.omitRelativePrefix);
1103
+
1104
+ // path token/relPath/glob -> tag(s). Default: identity over the enabled tag names;
1105
+ // any packageTagPaths entries AUGMENT that. A `""`/`"."` key tags the root.
1106
+ const tagPaths: Record<string, string[]> = {
1107
+ ...Object.fromEntries(enabledTagMixins.map((k) => [k, [k]])),
1108
+ ...(options.packageTagPaths ?? {}),
1109
+ };
1110
+
1111
+ // Already-attached subprojects, keyed by repo-relative member path.
1112
+ const rootAbs = resolve(project.outdir);
1113
+ const existing = new Map<string, DBXToolsJavaScriptProject>();
1114
+ for (const sub of project.subprojects) {
1115
+ if (sub instanceof DBXToolsNodeProject || sub instanceof DBXToolsTypeScriptProject) {
1116
+ existing.set(toPosix(relative(rootAbs, sub.outdir)), sub);
1117
+ }
1118
+ }
1119
+
1120
+ // Discover + append a child per src-bearing folder. A root encapsulating an
1121
+ // already-attached project doesn't re-create it, it just unions the tags in. The
1122
+ // agnostic floor is set in the child's constructor; per-tag deps/tsconfig come from
1123
+ // the PACKAGE_TAG_MIXINS applied across the subtree below.
1124
+ for (const p of scanPackages(rootAbs, roots)) {
1125
+ const tags = [...new Set([...p.tagCandidates, ...resolveTags(p, tagPaths)])];
1126
+ const found = existing.get(p.memberPath);
1127
+ if (found) {
1128
+ found.dbxToolsConfig.tags.push(...tags);
1129
+ continue;
1130
+ }
1131
+ new DBXToolsTypeScriptProject({
1132
+ parent: project,
1133
+ outdir: p.memberPath,
1134
+ name: packageNameFor(project.scope, p.relPath, omitPrefixes),
1135
+ tags,
1136
+ });
1137
+ }
1138
+
1139
+ // The root project may itself carry tags (via a `""`/`"."` tag-path key).
1140
+ const rootTags = [...new Set([...(tagPaths[""] ?? []), ...(tagPaths["."] ?? [])])];
1141
+ if (rootTags.length) project.dbxToolsConfig.tags.push(...rootTags);
1142
+
1143
+ // Apply per-tag mixins across the whole subtree now that every child exists
1144
+ // (`construct.with` captures the tree at call time). User mixins run afterward
1145
+ // via the caller's own `project.with(...)`.
1146
+ if (enabledTagMixins.length) {
1147
+ project.with(...enabledTagMixins.map((t) => PACKAGE_TAG_MIXINS[t]));
1148
+ }
1149
+
1150
+ new WorkspaceValidationTasks(project);
1151
+ new WorkflowTimeouts(project);
1152
+ new PrettierIgnoreGenerated(project);
1153
+
1154
+ new GeneratedSource(project);
1155
+ // The `bump` task (compute next version + commit + tag + push) is useful on
1156
+ // any root; the actual publish is a tag-triggered GitHub workflow the caller
1157
+ // authors. Independent of projen's own `release` component.
1158
+ new DBXToolsRelease(project as DBXToolsNodeProject, {
1159
+ tagPrefix: options.releaseTagPrefix,
1160
+ standaloneReleases: options.standaloneReleases,
1161
+ });
1162
+ }
1163
+
1164
+ /**
1165
+ * A child's `.gitignore`, tracking whether any pattern was ever added so an
1166
+ * untouched (empty) file can be dropped at presynth. `exclude`/`include` and
1167
+ * constructor `ignorePatterns` all funnel through {@link addPatterns}, so the flag
1168
+ * sees every route - but seed patterns must be added AFTER construction (see
1169
+ * {@link swapChildGitignore}) because class fields initialize after `super()`.
1170
+ */
1171
+ class ChildGitignore extends IgnoreFile {
1172
+ /** True once any pattern landed (custom patterns => the file is emitted). */
1173
+ public hasPatterns = false;
1174
+
1175
+ public override addPatterns(...patterns: string[]): void {
1176
+ if (patterns.length) this.hasPatterns = true;
1177
+ super.addPatterns(...patterns);
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Swap a CHILD's default `.gitignore` - pre-populated by `NodeProject` with the
1183
+ * same defaults the root already carries (git applies the root's file to the whole
1184
+ * tree) - for a FRESH {@link ChildGitignore}. Caller-supplied patterns
1185
+ * (`gitignore` / `gitIgnoreOptions.ignorePatterns`) are re-seeded, and later
1186
+ * `project.gitignore.addPatterns(...)` calls (tag/user mixins) land here too, so a
1187
+ * package CAN carry package-specific ignores without inheriting the root noise.
1188
+ * Left empty, the file is dropped by {@link preSynthesizeProject}. Safe because
1189
+ * projen only writes gitignore defaults at construction time (`addDefaultGitIgnore`,
1190
+ * yarn-berry config), never during synth.
1191
+ */
1192
+ function swapChildGitignore(
1193
+ project: javascript.NodeProject,
1194
+ options: DBXToolsJavaScriptProjectOptions,
1195
+ ): void {
1196
+ project.tryRemoveFile(".gitignore");
1197
+ const fresh = new ChildGitignore(project, ".gitignore", {
1198
+ ...options.gitIgnoreOptions,
1199
+ // Re-added below so the custom-pattern flag sees them (not clobbered by the
1200
+ // subclass field initializer running after super()).
1201
+ ignorePatterns: undefined,
1202
+ });
1203
+ const seeds = [...(options.gitignore ?? []), ...(options.gitIgnoreOptions?.ignorePatterns ?? [])];
1204
+ if (seeds.length) fresh.addPatterns(...seeds);
1205
+ // `Project.gitignore` is readonly only at compile time; rebind it so every
1206
+ // subsequent `project.gitignore.*` call reaches the fresh file.
1207
+ (project as { gitignore: IgnoreFile }).gitignore = fresh;
1208
+ }
1209
+
1210
+ function preSynthesizeProject(project: javascript.NodeProject): void {
1211
+ // `Project.files` is OWN-project only (its `components` getter filters on the
1212
+ // project's own node path), so reaching a child's files means walking the tree.
1213
+ // `node.findAll()` is projen/constructs' native preorder walk - self first, then
1214
+ // descendants - which is the order the subproject recursion produced.
1215
+ const subtree = project.node.findAll().filter(Project.isProject);
1216
+ if (project.prettier) {
1217
+ const ignorePatterns = new Set<string>();
1218
+ for (const p of subtree) {
1219
+ p.files.forEach((file) => {
1220
+ if (file.readonly) ignorePatterns.add(file.path);
1221
+ });
1222
+ }
1223
+ ignorePatterns.forEach((pattern) => project.prettier!.addIgnorePattern(pattern));
1224
+ }
1225
+ for (const p of subtree) {
1226
+ if (!p.parent) continue;
1227
+ // Swap the source entry points for compiled ones in the PUBLISHED manifest
1228
+ // only. Runs here rather than in the constructor so the tags have already
1229
+ // installed their `exports` layouts for it to mirror.
1230
+ if (p instanceof javascript.NodeProject) applyCompiledPublish(p);
1231
+ // A child's `.gitignore` survives ONLY when it carries custom patterns (see
1232
+ // swapChildGitignore). `.gitattributes` is always dropped - the root's
1233
+ // annotateGenerated globs cover the children. Runs once from the root's
1234
+ // preSynthesize and again from each child's own; both passes agree, so the
1235
+ // second is a no-op.
1236
+ const keepGitignore = p.gitignore instanceof ChildGitignore && p.gitignore.hasPatterns;
1237
+ for (const path of keepGitignore ? [".gitattributes"] : [".gitignore", ".gitattributes"]) {
1238
+ if (p.tryRemoveFile(path)) {
1239
+ const rootPath = resolve(p.outdir, path);
1240
+ if (existsSync(rootPath)) {
1241
+ console.log(`Removed ${rootPath} from ${p.name}`);
1242
+ }
1243
+ }
1244
+ }
1245
+ }
1246
+ }