@dbx-tools/projen 0.1.1 → 0.3.42

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