@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/README.md +45 -16
- package/index.ts +34 -12
- package/package.json +13 -15
- package/src/_lazy-require.ts +46 -0
- package/src/barrels.ts +331 -0
- package/src/clean.ts +88 -0
- package/src/cli-bin.ts +72 -0
- package/src/codegen.ts +339 -0
- package/src/dbx-tools-config.ts +84 -0
- package/src/engine-root.ts +27 -0
- package/src/generated.ts +65 -0
- package/src/mixin.ts +47 -0
- package/src/module-exports.ts +120 -0
- package/src/openapi.ts +162 -0
- package/src/packages.ts +326 -0
- package/src/pnpm-workspace.ts +202 -0
- package/src/project-predicate.ts +124 -0
- package/src/project.ts +1125 -0
- package/src/release.ts +236 -0
- package/src/scaffold.ts +34 -0
- package/src/tags.ts +172 -0
- package/src/tsconfig.ts +60 -0
- package/src/vite.ts +91 -0
- package/src/vscode.ts +69 -0
- package/src/watch.ts +107 -0
- package/tasks/barrels.ts +2 -2
- package/tasks/bump.ts +340 -0
- package/tasks/clean.ts +1 -1
- package/tasks/projenrc.ts +1 -1
- package/tasks/sync.ts +69 -5
- package/tasks/publish.ts +0 -30
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
|
+
});
|
package/src/generated.ts
ADDED
|
@@ -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
|
+
}
|