@dbx-tools/projen 0.1.1 → 0.3.43

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/clean.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The `clean` task (`pnpm run clean`): enumerate the workspace's generated files (plus every
3
+ * `node_modules` directory) and delete a chosen subset. This is the pure filesystem
4
+ * half - reusable enumerate/remove helpers; the task that drives them (argv `-y`, the
5
+ * `@clack/prompts` multiselect picker with all preselected, the TTY guard) lives in
6
+ * `tasks/clean.ts`, which forwards to these functions.
7
+ *
8
+ * "Generated" is detected structurally, not by a hardcoded list: every file this
9
+ * toolchain writes is set READ-ONLY (projen's own config + the generated barrels; see
10
+ * {@link isReadonly}), while every hand-authored source stays writable. So a read-only
11
+ * file under the repo is a clean target - EXCEPT anything inside a dot-prefixed folder
12
+ * (`.projen`, `.vscode`, `.git`, ...) and `.gitignore` itself, which clean always leaves
13
+ * alone (projen re-syncs `.projen`/`.vscode` on the next synth). `node_modules` is
14
+ * enumerated separately (see {@link listNodeModulesDirs}) as whole directories.
15
+ *
16
+ * Deleting only generated files is never destructive to the ability to regenerate:
17
+ * `.projenrc.ts` imports the engine by SOURCE path (relative into the repo, e.g.
18
+ * `packages/node/projen/src/...`, or from an installed package such as
19
+ * `@dbx-tools/projen`), so even after deleting every barrel, manifest, and
20
+ * `.projen/*`, `pnpm exec projen` still rebuilds the whole tree. Removing `node_modules` additionally requires a
21
+ * `pnpm install` first - the engine's runtime deps live there - so a clean that takes
22
+ * `node_modules` must be followed by reinstall, then re-synth.
23
+ */
24
+ import { existsSync, rmSync, statSync } from "node:fs";
25
+ import { basename, join, relative } from "node:path";
26
+ import { find } from "@dbx-tools/path";
27
+ import { isReadonly, makeWritable } from "./generated";
28
+ import { repoRoot, toPosix } from "./packages";
29
+
30
+ /**
31
+ * Basenames `clean` never removes even when they are generated/read-only. `.gitignore`
32
+ * is hand-relevant git plumbing: nuking it would un-ignore `node_modules`/build output
33
+ * on the very next tool run, so it is always kept.
34
+ */
35
+ const CLEAN_SKIP_FILES: ReadonlySet<string> = new Set([".gitignore"]);
36
+
37
+ /**
38
+ * Every generated (read-only) file in the workspace, as absolute paths sorted by
39
+ * repo-relative posix path. Skips vendor/build/VCS dirs via node-path's built-in
40
+ * ignores AND every dot-prefixed folder (`.projen`, `.vscode`, `.github`, ...), and
41
+ * {@link CLEAN_SKIP_FILES} entry (`.gitignore`).
42
+ */
43
+ export function listGeneratedFiles(root: string = repoRoot): string[] {
44
+ const rel = (f: string): string => toPosix(relative(root, f));
45
+ return [...find.findFiles("**/*", { cwd: root })]
46
+ .map((f) => join(root, f))
47
+ .filter(isReadonly)
48
+ .filter((f) => !CLEAN_SKIP_FILES.has(basename(f)))
49
+ .sort((a, b) => rel(a).localeCompare(rel(b)));
50
+ }
51
+
52
+ /**
53
+ * Every `node_modules` directory in the workspace (the root's plus each package's), as
54
+ * absolute paths sorted by repo-relative posix path. The walk RECORDS a `node_modules`
55
+ * dir but never descends into it, so a nested store/symlink `node_modules`
56
+ * (`node_modules/.pnpm/x/node_modules`, a package's linked deps) is never listed on its
57
+ * own - removing the top-level dir takes it along. Other vendor/build/VCS dirs are
58
+ * skipped for speed.
59
+ */
60
+ export function listNodeModulesDirs(root: string = repoRoot): string[] {
61
+ if (!existsSync(root)) return [];
62
+ const rel = (f: string): string => toPosix(relative(root, f));
63
+ return [...find.findFiles("**/node_modules", { cwd: root, ignoreOptions: { dot: false } })]
64
+ .map((match) => join(root, match))
65
+ .sort((a, b) => rel(a).localeCompare(rel(b)));
66
+ }
67
+
68
+ /**
69
+ * Delete the given paths - generated files and/or whole directories (`node_modules`).
70
+ * A regular file has its read-only bit cleared first (so unlink also works on Windows);
71
+ * a directory is removed recursively and is NOT chmod'd (file mode `0o644` would strip
72
+ * a dir's traversal bit and break the recursive delete). Missing paths are ignored (a
73
+ * racing watcher may have already removed one). Returns the count actually removed.
74
+ */
75
+ export function removePaths(paths: readonly string[]): number {
76
+ let removed = 0;
77
+ for (const path of paths) {
78
+ try {
79
+ if (!existsSync(path)) continue;
80
+ if (statSync(path).isFile()) makeWritable(path);
81
+ rmSync(path, { recursive: true, force: true });
82
+ removed++;
83
+ } catch {
84
+ // Already gone, or racing the watcher - nothing to do.
85
+ }
86
+ }
87
+ return removed;
88
+ }
package/src/cli-bin.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Generated `bin/<name>.mjs` launchers for a CLI package's TypeScript entries.
3
+ *
4
+ * A CLI's real entry is a `.ts` file, which Node cannot run on its own, so something
5
+ * has to register tsx first. A `#!/usr/bin/env -S npx tsx` shebang does that only for
6
+ * a WORKSPACE checkout: `npx` resolves tsx from the current working directory, and a
7
+ * globally installed CLI (`npm i -g @dbx-tools/cli`) has no relationship to whatever
8
+ * directory the user happens to be in - so every first invocation stalls to fetch tsx
9
+ * from the network, or fails outright when offline.
10
+ *
11
+ * {@link CliBinLauncher} emits a tiny `.mjs` sibling that npm points its bin symlink
12
+ * at. Because the launcher reaches tsx through a bare `import` specifier, NODE
13
+ * resolves it relative to the launcher's own location - i.e. the CLI package's own
14
+ * `node_modules` - which is exactly where the `cli` tag's runtime tsx dependency
15
+ * installs it. The `.ts` entry stays the source of truth; the launcher only registers
16
+ * the loader and hands off.
17
+ *
18
+ * {@link addCliBinLaunchers} discovers the entries by scanning the package's `bin/`
19
+ * directory at synth, so a new CLI just needs its `.ts` file plus a `package.json`
20
+ * bin pointing at the matching `.mjs`.
21
+ */
22
+ import { existsSync, readdirSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { type Project, TextFile } from "projen";
25
+ import { header } from "./generated";
26
+
27
+ /** Directory (package-relative) holding a CLI's executable entries. */
28
+ const BIN_DIR = "bin";
29
+
30
+ /** Render the launcher source for a `.ts` entry sitting beside it. */
31
+ function renderLauncher(entryFileName: string): string {
32
+ return `#!/usr/bin/env node
33
+ ${header({
34
+ tool: "projen synth (cli tag)",
35
+ source: `./${entryFileName}`,
36
+ })}
37
+ // Resolved as a bare specifier so Node looks in THIS file's package rather than the
38
+ // caller's cwd - the only way a globally installed CLI finds its own tsx.
39
+ import { register } from "tsx/esm/api";
40
+
41
+ register();
42
+ await import(new URL(${JSON.stringify(`./${entryFileName}`)}, import.meta.url).href);
43
+ `;
44
+ }
45
+
46
+ /**
47
+ * A projen-owned, read-only `.mjs` launcher that registers tsx and defers to the
48
+ * `.ts` entry beside it.
49
+ */
50
+ export class CliBinLauncher extends TextFile {
51
+ constructor(project: Project, entryFileName: string) {
52
+ super(project, join(BIN_DIR, entryFileName.replace(/\.ts$/, ".mjs")), {
53
+ readonly: true,
54
+ executable: true,
55
+ lines: renderLauncher(entryFileName).split("\n"),
56
+ });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Emit a {@link CliBinLauncher} for every `.ts` entry in the package's `bin/`
62
+ * directory. A package with no `bin/` directory is left alone.
63
+ */
64
+ export function addCliBinLaunchers(project: Project): void {
65
+ const binDir = join(project.outdir, BIN_DIR);
66
+ if (!existsSync(binDir)) return;
67
+
68
+ for (const entry of readdirSync(binDir).sort()) {
69
+ if (!entry.endsWith(".ts") || entry.endsWith(".d.ts")) continue;
70
+ new CliBinLauncher(project, entry);
71
+ }
72
+ }
package/src/codegen.ts ADDED
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Codegen generator (ts-to-zod based).
3
+ *
4
+ * Scans every package whose `package.json` declares a `codegen`
5
+ * field and turns the listed upstream `.d.ts` inputs into read-only `src/`
6
+ * modules of zod schemas plus matching inferred TypeScript types. Each input
7
+ * emits one `src/<name>.ts` (schemas + `export type X = z.infer<typeof
8
+ * xSchema>` lines); the barrel generator then namespaces it into the package's
9
+ * root barrel like any other `src/` module (`sdkModel.dashboards.genieMessageSchema`).
10
+ *
11
+ * The single source of truth for "which packages get generated content, from
12
+ * which inputs" is each consumer's own `package.json`:
13
+ *
14
+ * {
15
+ * "name": "@dbx-tools/shared-sdk-model",
16
+ * "codegen": {
17
+ * "inputs": [
18
+ * "node_modules/@databricks/sdk-experimental/dist/apis/dashboards/model.d.ts"
19
+ * ]
20
+ * }
21
+ * }
22
+ *
23
+ * Each entry under `inputs` is a path (relative to the package for
24
+ * `node_modules/...`, else repo-root-relative), optionally suffixed with
25
+ * `=<name>` to override the auto-derived basename:
26
+ *
27
+ * - `apis/dashboards/model.d.ts` -> `src/dashboards.ts`
28
+ * - `apis/dashboards/model.d.ts=foo` -> `src/foo.ts`
29
+ *
30
+ * Generated modules are written read-only with the standard do-not-edit header
31
+ * (see `./generated`), so they are indistinguishable from any other generated
32
+ * file and hand-written `src/` modules (writable) are never touched. Stale
33
+ * generated modules from a removed input are cleaned up on each run.
34
+ *
35
+ * Each input is preprocessed with the TypeScript compiler API before ts-to-zod
36
+ * sees it: every `import` declaration is dropped, and any type reference whose
37
+ * root identifier was introduced by one of those imports is rewritten to
38
+ * `unknown` (codegen output is a pure data-shape surface; peer SDK runtime
39
+ * modules don't belong here).
40
+ *
41
+ * `ts-to-zod` and `typescript` are loaded lazily (heavy, and only needed when
42
+ * codegen actually runs), so importing this module stays cheap. Codegen runs as
43
+ * part of synth's post-synthesize pass (see `GeneratedSource` in `project.ts`);
44
+ * SDK `.d.ts` inputs change rarely, so there's no separate task or watcher.
45
+ */
46
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
47
+ import { createRequire } from "node:module";
48
+ import { basename, dirname, join, resolve } from "node:path";
49
+ import type * as ts from "typescript";
50
+ import { lazyRequire } from "./_lazy-require";
51
+ import { header, isReadonly, makeReadonly, makeWritable } from "./generated";
52
+ import { log, object } from "@dbx-tools/shared-core";
53
+ import { readPackageManifest, repoRoot, recordedPackages } from "./packages";
54
+
55
+ const logger = log.logger("projen:codegen");
56
+
57
+ /** Do-not-edit banner stamped on every generated codegen module. */
58
+ const HEADER = header({
59
+ tool: "projen synth (codegen: ts-to-zod)",
60
+ source: "the upstream .d.ts declared in package.json codegen.inputs",
61
+ });
62
+
63
+ interface CodegenInput {
64
+ /** Absolute path to the source `.d.ts` file. */
65
+ source: string;
66
+ /** Output basename (e.g. `dashboards` -> emits `src/dashboards.ts`). */
67
+ name: string;
68
+ }
69
+
70
+ /** Read a package's `package.json` `codegen.inputs`, or `undefined` if absent. */
71
+ function codegenInputs(dir: string): string[] | undefined {
72
+ const codegen = readPackageManifest(dir)?.codegen;
73
+ if (!object.isRecord(codegen)) return undefined;
74
+ const inputs = codegen.inputs;
75
+ return Array.isArray(inputs) && inputs.length > 0 ? (inputs as string[]) : undefined;
76
+ }
77
+
78
+ function deriveName(path: string): string {
79
+ const file = basename(path);
80
+ // SDK convention: `apis/<api>/model.d.ts`. Use the parent directory's name
81
+ // so `apis/dashboards/model.d.ts` -> `dashboards`.
82
+ if (file === "model.d.ts" || file === "model.ts") return basename(dirname(path));
83
+ return file.replace(/\.d\.ts$|\.ts$/, "");
84
+ }
85
+
86
+ function parseInputArg(value: string): CodegenInput {
87
+ const eq = value.indexOf("=");
88
+ if (eq === -1) return { source: value, name: deriveName(value) };
89
+ return { source: value.slice(0, eq), name: value.slice(eq + 1) };
90
+ }
91
+
92
+ /**
93
+ * Resolve a codegen input to an absolute path. A `node_modules/...` source is
94
+ * searched for in each `node_modules` from the consuming package up to the
95
+ * filesystem root, so it resolves whether the dependency is nested under the
96
+ * package or hoisted to the workspace root. Any other source is treated as
97
+ * repo-root-relative.
98
+ */
99
+ function resolveInputSource(source: string, fromDir: string): string {
100
+ if (source.startsWith("node_modules/")) {
101
+ let dir = fromDir;
102
+ for (;;) {
103
+ const candidate = resolve(dir, source);
104
+ if (existsSync(candidate)) return candidate;
105
+ const parent = dirname(dir);
106
+ if (parent === dir) break;
107
+ dir = parent;
108
+ }
109
+ }
110
+ return resolve(repoRoot, source);
111
+ }
112
+
113
+ /**
114
+ * Parse `entryPath` as TypeScript, drop every `import` declaration, and rewrite
115
+ * any type reference whose root identifier was introduced by one of those
116
+ * imports to the `unknown` keyword. ts-to-zod then sees a self-contained source
117
+ * where the dropped peer modules surface as `z.unknown()` schemas.
118
+ */
119
+ function stripImports(tsRuntime: typeof ts, entryPath: string): string {
120
+ const text = readFileSync(entryPath, "utf-8");
121
+ const sf = tsRuntime.createSourceFile(
122
+ entryPath,
123
+ text,
124
+ tsRuntime.ScriptTarget.Latest,
125
+ /*setParentNodes*/ true,
126
+ tsRuntime.ScriptKind.TS,
127
+ );
128
+
129
+ const namespaceAliases = new Set<string>();
130
+ const importedNames = new Set<string>();
131
+ for (const stmt of sf.statements) {
132
+ if (!tsRuntime.isImportDeclaration(stmt) || !stmt.importClause) continue;
133
+ const c = stmt.importClause;
134
+ if (c.name) importedNames.add(c.name.text);
135
+ const nb = c.namedBindings;
136
+ if (!nb) continue;
137
+ if (tsRuntime.isNamespaceImport(nb)) {
138
+ namespaceAliases.add(nb.name.text);
139
+ } else {
140
+ for (const el of nb.elements) importedNames.add(el.name.text);
141
+ }
142
+ }
143
+
144
+ const unknownType = (): ts.KeywordTypeNode =>
145
+ tsRuntime.factory.createKeywordTypeNode(tsRuntime.SyntaxKind.UnknownKeyword);
146
+
147
+ const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => (root) => {
148
+ const visitor: ts.Visitor = (node) => {
149
+ if (tsRuntime.isImportDeclaration(node)) return undefined;
150
+
151
+ // `ns.X` / `X` in TYPE position -> `unknown`, only when the root
152
+ // identifier came from an import; local declarations stay untouched.
153
+ if (tsRuntime.isTypeReferenceNode(node)) {
154
+ const tn = node.typeName;
155
+ if (
156
+ tsRuntime.isQualifiedName(tn) &&
157
+ tsRuntime.isIdentifier(tn.left) &&
158
+ namespaceAliases.has(tn.left.text)
159
+ ) {
160
+ return unknownType();
161
+ }
162
+ if (tsRuntime.isIdentifier(tn) && importedNames.has(tn.text)) {
163
+ return unknownType();
164
+ }
165
+ }
166
+
167
+ // `ns.X` in VALUE position: drop the namespace prefix so ts-to-zod sees a
168
+ // bare identifier.
169
+ if (
170
+ tsRuntime.isPropertyAccessExpression(node) &&
171
+ tsRuntime.isIdentifier(node.expression) &&
172
+ namespaceAliases.has(node.expression.text)
173
+ ) {
174
+ return node.name;
175
+ }
176
+
177
+ return tsRuntime.visitEachChild(node, visitor, context);
178
+ };
179
+ return tsRuntime.visitEachChild(root, visitor, context);
180
+ };
181
+
182
+ const result = tsRuntime.transform(sf, [transformer]);
183
+ const printer = tsRuntime.createPrinter({ removeComments: false });
184
+ const transformed = result.transformed[0] ?? sf;
185
+ const out = printer.printFile(transformed);
186
+ result.dispose();
187
+ return out;
188
+ }
189
+
190
+ /**
191
+ * Final shaping before ts-to-zod sees the source:
192
+ *
193
+ * 1. Promote every top-level `interface` / `type` to an `export` (ts-to-zod
194
+ * only emits schemas for exported declarations and bails on exported types
195
+ * referencing non-exported ones).
196
+ * 2. Rewrite each JSDoc block so its leading prose becomes a single
197
+ * `@description` tag (ts-to-zod emits a matching `.describe(...)` call).
198
+ * The original prose is dropped so it doesn't appear twice. Other tags
199
+ * (`@minimum`, `@format`, ...) flow through verbatim.
200
+ */
201
+ function preprocess(source: string): string {
202
+ const out = source.replace(/^(interface|type)\s/gm, "export $1 ");
203
+ return out.replace(/\/\*\*([\s\S]*?)\*\//g, (match, body: string) => {
204
+ if (/@description\b/.test(body)) return match;
205
+
206
+ const lines = body
207
+ .replace(/^\n/, "")
208
+ .split("\n")
209
+ .map((line) => line.replace(/^\s*\*\s?/, "").replace(/\s+$/, ""));
210
+
211
+ const firstTagIdx = lines.findIndex((line) => /^@\w+/.test(line));
212
+ const descLines = firstTagIdx === -1 ? lines : lines.slice(0, firstTagIdx);
213
+ const tagLines = firstTagIdx === -1 ? [] : lines.slice(firstTagIdx);
214
+
215
+ const description = descLines.join(" ").replace(/\s+/g, " ").trim();
216
+ if (!description) return match;
217
+
218
+ const rebuilt = [`@description ${description}`, ...tagLines];
219
+ return `/**\n${rebuilt.map((line) => ` * ${line}`.trimEnd()).join("\n")}\n */`;
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Take ts-to-zod's `getInferredTypes(...)` output - shaped for a separate file
225
+ * (banner, `import { z } from "zod"`, `import * as generated from "<schemas>"`,
226
+ * then `export type X = z.infer<typeof generated.xSchema>` lines) - and rewrite
227
+ * it for inclusion in the same file as the schemas: drop the banner and both
228
+ * imports, and drop the `generated.` namespace prefix from every reference so
229
+ * the type aliases bind to the colocated schema constants.
230
+ */
231
+ function inlineInferredTypes(inferredFile: string): string {
232
+ return inferredFile
233
+ .replace(/^\/\/ Generated by ts-to-zod\s*\n/, "")
234
+ .replace(/^import \{ z \} from "zod";\s*\n+/m, "")
235
+ .replace(/^import \* as generated from "[^"]*";\s*\n+/m, "")
236
+ .replace(/\bgenerated\.(\w+)/g, "$1")
237
+ .trim();
238
+ }
239
+
240
+ /** True if `<srcDir>/<file>` is a codegen-generated module (read-only + our header). */
241
+ function isGeneratedModule(srcDir: string, file: string): boolean {
242
+ if (!file.endsWith(".ts")) return false;
243
+ const path = join(srcDir, file);
244
+ if (!isReadonly(path)) return false;
245
+ try {
246
+ return readFileSync(path, "utf8").startsWith("// GENERATED by projen synth (codegen:");
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Regenerate the codegen `src/` modules for one consumer package. The
254
+ * package's `package.json` is read-only - the `inputs` list comes out, nothing
255
+ * flows back. Returns the package dir so the caller can rebuild its barrel.
256
+ */
257
+ function generatePackage(
258
+ tsRuntime: typeof ts,
259
+ generate: typeof import("ts-to-zod").generate,
260
+ dir: string,
261
+ inputs: string[],
262
+ ): string {
263
+ const parsed = inputs.map(parseInputArg);
264
+ const srcDir = resolve(dir, "src");
265
+ mkdirSync(srcDir, { recursive: true });
266
+
267
+ // Remove prior generated modules (read-only + our header) so a dropped input
268
+ // can't leave a stale module behind. Hand-written `src/` files stay writable
269
+ // and are never matched, so they're left alone.
270
+ const emitted = new Set(parsed.map((input) => `${input.name}.ts`));
271
+ for (const file of readdirSync(srcDir)) {
272
+ if (!emitted.has(file) && isGeneratedModule(srcDir, file)) {
273
+ makeWritable(join(srcDir, file));
274
+ rmSync(join(srcDir, file));
275
+ }
276
+ }
277
+
278
+ let warnings = 0;
279
+ for (const input of parsed) {
280
+ const sourcePath = resolveInputSource(input.source, dir);
281
+ if (!existsSync(sourcePath)) {
282
+ throw new Error(`codegen input not found: ${input.source}`);
283
+ }
284
+
285
+ const sourceText = preprocess(stripImports(tsRuntime, sourcePath));
286
+ const { getZodSchemasFile, getInferredTypes, errors } = generate({
287
+ sourceText,
288
+ // Carry JSDoc through so the `@description` tag stays visible alongside
289
+ // the matching `.describe(...)`.
290
+ keepComments: true,
291
+ });
292
+ if (errors.length) {
293
+ warnings += errors.length;
294
+ for (const err of errors) logger.warn(` ! ${err}`);
295
+ }
296
+
297
+ // ts-to-zod adds an import line only when the source references external
298
+ // types; bundling makes everything self-contained, so this is never read.
299
+ const importPath = `./${input.name}.js`;
300
+ const schemas = getZodSchemasFile(importPath);
301
+ const inferred = inlineInferredTypes(getInferredTypes(importPath));
302
+ const content = HEADER + "\n" + schemas.trimEnd() + "\n\n" + inferred + "\n";
303
+ const outPath = resolve(srcDir, `${input.name}.ts`);
304
+ makeWritable(outPath);
305
+ writeFileSync(outPath, content);
306
+ makeReadonly(outPath);
307
+ }
308
+
309
+ logger.success(
310
+ `${basename(dir)}: ${parsed.length} module(s)` + (warnings ? ` (${warnings} warning(s))` : ""),
311
+ );
312
+ return dir;
313
+ }
314
+
315
+ /**
316
+ * Regenerate the `generated/` tree for every package declaring a
317
+ * `codegen` field. Returns the package dirs it wrote so the caller can rebuild
318
+ * their barrels. `ts-to-zod` + `typescript` are lazy-loaded.
319
+ */
320
+ export function generateCodegen(): string[] {
321
+ const targets = recordedPackages()
322
+ .map((p) => ({ dir: p.dir, inputs: codegenInputs(p.dir) }))
323
+ .filter((t): t is { dir: string; inputs: string[] } => t.inputs !== undefined);
324
+
325
+ if (targets.length === 0) {
326
+ logger.info("no packages declare a `codegen` field");
327
+ return [];
328
+ }
329
+
330
+ const require = createRequire(import.meta.url);
331
+ const tsRuntime = lazyRequire<typeof ts>(require, "typescript", "codegen");
332
+ const { generate } = lazyRequire<typeof import("ts-to-zod")>(require, "ts-to-zod", "codegen");
333
+
334
+ const written: string[] = [];
335
+ for (const target of targets) {
336
+ written.push(generatePackage(tsRuntime, generate, target.dir, target.inputs));
337
+ }
338
+ return written;
339
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * In-memory `package.json` `dbxToolsConfig` record for a package.
3
+ *
4
+ * Values are read and written on the component's object; each write flushes the
5
+ * record to the manifest through `project.package.addField`.
6
+ */
7
+ import { object } from "@dbx-tools/shared-core";
8
+ import { Component, javascript } from "projen";
9
+
10
+ /** `package.json` field name for the dbx-tools config object. */
11
+ const DBX_TOOLS_CONFIG_KEY = "dbxToolsConfig";
12
+
13
+ /** Options for {@link DBXToolsConfig}. */
14
+ export interface DBXToolsConfigOptions {
15
+ /** Initial tags to record (distinct; order preserved). */
16
+ readonly tags?: string[];
17
+ }
18
+
19
+ function readDBXToolsConfig(pkg: javascript.NodePackage): Record<string, unknown> {
20
+ try {
21
+ const manifest = pkg.manifest as unknown;
22
+ if (object.isRecord(manifest)) {
23
+ const config = manifest[DBX_TOOLS_CONFIG_KEY];
24
+ if (object.isRecord(config)) {
25
+ return config;
26
+ }
27
+ }
28
+ } catch {}
29
+ return {};
30
+ }
31
+
32
+ function loadConfig(dbxToolsConfig: DBXToolsConfig, pkg: javascript.NodePackage) {
33
+ const { tags, ...config } = readDBXToolsConfig(pkg);
34
+ if (Array.isArray(tags))
35
+ object
36
+ .sequence(tags)
37
+ .filter((v: unknown) => typeof v === "string")
38
+ .forEach((v: string) => dbxToolsConfig.tags.push(v));
39
+ Object.entries(config).forEach(([key, value]) => {
40
+ dbxToolsConfig[key] = value;
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Owns a package's in-memory `dbxToolsConfig` object. Callers mutate it
46
+ * directly - push to {@link DBXToolsConfig.tags}, or assign any other key
47
+ * through the index signature. Every own key (i.e. not inherited from
48
+ * `Component`) is collected by {@link DBXToolsConfig.data} and written through
49
+ * `project.package.addField` at synth, so nothing is cached on a field.
50
+ */
51
+ export class DBXToolsConfig extends Component {
52
+ private readonly inheritedKeys: ReadonlySet<string>;
53
+ readonly tags: string[];
54
+ [key: string]: unknown;
55
+
56
+ constructor(
57
+ override readonly project: javascript.NodeProject,
58
+ options: DBXToolsConfigOptions = {},
59
+ ) {
60
+ super(project);
61
+ this.inheritedKeys = new Set(Object.keys(this));
62
+ this.tags = [];
63
+ loadConfig(this, project.package);
64
+ options.tags?.forEach((v: string) => this.tags.push(v));
65
+ }
66
+
67
+ data(): Record<string, unknown> {
68
+ const dataRecord: Record<string, unknown> = {};
69
+ for (const [key, value] of Object.entries(this)) {
70
+ if (this.inheritedKeys.has(key)) continue;
71
+ dataRecord[key] = value;
72
+ }
73
+ dataRecord.tags = object.sequence(this.tags).distinct().toArray();
74
+ return dataRecord;
75
+ }
76
+
77
+ preSynthesize(): void {
78
+ let dataRecord: Record<string, unknown> | undefined = this.data();
79
+ if (object.isEmpty(dataRecord, { recursive: true })) {
80
+ dataRecord = undefined;
81
+ }
82
+ this.project.package.addField(DBX_TOOLS_CONFIG_KEY, dataRecord);
83
+ }
84
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Resolution of the projen engine package root.
3
+ *
4
+ * Deliberately projen-free and dependency-light, so locating the engine's
5
+ * install never pulls in projen itself.
6
+ */
7
+ import { dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { project } from "@dbx-tools/core";
10
+ import { functionModule } from "@dbx-tools/shared-core";
11
+
12
+ const ENGINE_PKG = "@dbx-tools/projen";
13
+
14
+ /**
15
+ * Absolute path to the projen engine package root.
16
+ *
17
+ * Walks up from this module with shared-core's {@link project.root} (the nearest
18
+ * package bounded by the enclosing npm/git root), so it resolves both in-repo and
19
+ * when installed as a dependency. Memoized with shared-core's
20
+ * {@link functionModule.memoize}, which caches only a successful result - a
21
+ * throw is retried on the next call.
22
+ */
23
+ export const resolvePkgRoot = functionModule.memoize((): string => {
24
+ const found = project.root(dirname(fileURLToPath(import.meta.url)));
25
+ if (!found) throw new Error(`${ENGINE_PKG} package root not found`);
26
+ return found;
27
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Read-only / do-not-edit helpers for generated output projen does not own as a
3
+ * native `FileBase`: the barrels we write, openapi artifacts, and any other
4
+ * toolchain file stamped via {@link stampGenerated}.
5
+ *
6
+ * projen already writes its own generated files read-only with a marker; these give
7
+ * barrels and other watch-time output the same contract - a do-not-edit header and a
8
+ * read-only (0o444) bit. Rewriting one therefore goes through {@link makeWritable}
9
+ * first, then {@link stampGenerated}.
10
+ */
11
+ import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
+
13
+ const READONLY = 0o444;
14
+ const WRITABLE = 0o644;
15
+
16
+ /** Drop the read-only bit if the file exists (no-op otherwise). */
17
+ export function makeWritable(file: string): void {
18
+ if (existsSync(file)) chmodSync(file, WRITABLE);
19
+ }
20
+
21
+ /** Set the read-only bit if the file exists (no-op otherwise). */
22
+ export function makeReadonly(file: string): void {
23
+ if (existsSync(file)) chmodSync(file, READONLY);
24
+ }
25
+
26
+ /**
27
+ * True if the file exists and has no owner-write bit. This is the toolchain's
28
+ * generated-file signal: everything projen and the barrel generator write is set read-only
29
+ * (see {@link makeReadonly}), while hand-authored source stays writable - so a
30
+ * read-only file under the repo (outside vendor/build dirs) is a generated file.
31
+ */
32
+ export function isReadonly(file: string): boolean {
33
+ try {
34
+ return (statSync(file).mode & 0o200) === 0;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export interface HeaderOpts {
41
+ /** What produced the file, e.g. `"projen watch"`. */
42
+ readonly tool: string;
43
+ /** What the file is derived from, e.g. `"the exporting modules beside it"`. */
44
+ readonly source?: string;
45
+ }
46
+
47
+ /** Build the do-not-edit banner (line comments, for TS/JS). */
48
+ export function header(opts: HeaderOpts): string {
49
+ const lines = [`// GENERATED by ${opts.tool} - DO NOT EDIT.`];
50
+ if (opts.source) lines.push(`// Regenerated from ${opts.source}.`);
51
+ lines.push("// Hand edits are overwritten on the next watch; this file is read-only.");
52
+ return `${lines.join("\n")}\n`;
53
+ }
54
+
55
+ /**
56
+ * Prepend the do-not-edit header to a file some tool just wrote (e.g. a
57
+ * generated barrel `index.ts`), then set it read-only. Idempotent.
58
+ */
59
+ export function stampGenerated(file: string, opts: HeaderOpts): void {
60
+ makeWritable(file);
61
+ const body = readFileSync(file, "utf8");
62
+ const next = body.startsWith("// GENERATED by") ? body : `${header(opts)}\n${body}`;
63
+ writeFileSync(file, next);
64
+ makeReadonly(file);
65
+ }