@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/mixin.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixin factory for `constructs` {@link ConstructsMixin}.
|
|
3
|
+
*
|
|
4
|
+
* Apply with `construct.with(...)` across the subtree. Package-targeting mixins
|
|
5
|
+
* compose the `projectPredicate.hasIdentifierName` / `hasTag` / `hasPath`
|
|
6
|
+
* builders from `./project-predicate`; `project.applyToProjects` is the
|
|
7
|
+
* ergonomic front-end that assembles those and applies the mixin for you.
|
|
8
|
+
*/
|
|
9
|
+
import type { Predicate } from "@dbx-tools/shared-core";
|
|
10
|
+
import type { IConstruct, IMixin as ConstructsMixin } from "constructs";
|
|
11
|
+
|
|
12
|
+
export type { ConstructsMixin };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds a {@link ConstructsMixin} from `supports` and `applyTo` callbacks.
|
|
16
|
+
*
|
|
17
|
+
* When `supports` is a type guard (`construct is U`) or a {@link Predicate},
|
|
18
|
+
* `applyTo` receives the narrowed `U`.
|
|
19
|
+
*/
|
|
20
|
+
export function create<U extends IConstruct>(
|
|
21
|
+
supports: Predicate<IConstruct, U>,
|
|
22
|
+
applyTo: (construct: U) => void,
|
|
23
|
+
): ConstructsMixin;
|
|
24
|
+
|
|
25
|
+
export function create<U extends IConstruct>(
|
|
26
|
+
supports: (construct: IConstruct) => construct is U,
|
|
27
|
+
applyTo: (construct: U) => void,
|
|
28
|
+
): ConstructsMixin;
|
|
29
|
+
|
|
30
|
+
export function create(
|
|
31
|
+
supports: (construct: IConstruct) => boolean,
|
|
32
|
+
applyTo: (construct: IConstruct) => void,
|
|
33
|
+
): ConstructsMixin;
|
|
34
|
+
|
|
35
|
+
export function create(
|
|
36
|
+
supports: (construct: IConstruct) => boolean,
|
|
37
|
+
applyTo: (construct: IConstruct) => void,
|
|
38
|
+
): ConstructsMixin {
|
|
39
|
+
return {
|
|
40
|
+
supports,
|
|
41
|
+
applyTo: (construct: IConstruct): void => {
|
|
42
|
+
if (supports(construct)) {
|
|
43
|
+
(applyTo as (construct: IConstruct) => void)(construct);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static extraction of a module's own top-level named exports, via oxc-parser
|
|
3
|
+
* (a fast, TypeScript-aware parser). Used by the barrel generator to hoist
|
|
4
|
+
* names that are unique across a package to the top level of its barrel.
|
|
5
|
+
*
|
|
6
|
+
* Only a module's OWN declared names are returned - names it declares with
|
|
7
|
+
* `export const/function/class/enum/interface/type` or names it re-labels in a
|
|
8
|
+
* local `export { local as exported }`. Deliberately excluded:
|
|
9
|
+
*
|
|
10
|
+
* - `export default` (no stable importable name);
|
|
11
|
+
* - `export * from "..."` / `export * as ns from "..."` (opaque or already a
|
|
12
|
+
* namespace);
|
|
13
|
+
* - any `export { ... } from "..."` re-export with a `source` (the name is
|
|
14
|
+
* owned by another module, so hoisting it here would double-count).
|
|
15
|
+
*
|
|
16
|
+
* Each name carries whether it is TYPE-only (`interface` / `type` alias /
|
|
17
|
+
* `export type { ... }`), so the barrel can emit `export type { ... }` for it -
|
|
18
|
+
* required under `isolatedModules`, where re-exporting a type through a value
|
|
19
|
+
* `export { ... }` is a hard error (TS1205).
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import type { parseSync as OxcParseSync } from "oxc-parser";
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
/** oxc's `parseSync`, loaded lazily so importing this module stays cheap. */
|
|
28
|
+
let parseSyncFn: typeof OxcParseSync | undefined;
|
|
29
|
+
function parseSync(filename: string, source: string): ReturnType<typeof OxcParseSync> {
|
|
30
|
+
parseSyncFn ??= (require("oxc-parser") as typeof import("oxc-parser")).parseSync;
|
|
31
|
+
return parseSyncFn(filename, source);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A parsed top-level statement, narrowed to the discriminant every caller reads. */
|
|
35
|
+
export type ModuleStatement = { readonly type: string };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `file`'s top-level statements, or `[]` when it cannot be read or parsed. The
|
|
39
|
+
* single parse entry point for the whole engine: the barrel generator reads the
|
|
40
|
+
* same oxc AST to decide whether a file exports anything at all and to read a
|
|
41
|
+
* hand-authored `exports.ts`, so there is exactly one TypeScript parser here.
|
|
42
|
+
*/
|
|
43
|
+
export function moduleStatements(file: string): readonly ModuleStatement[] {
|
|
44
|
+
let source: string;
|
|
45
|
+
try {
|
|
46
|
+
source = readFileSync(file, "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return parseSync(file, source).program.body;
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One exported name plus whether it is type-only (needs `export type`). */
|
|
58
|
+
export interface ModuleExport {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly isType: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Declaration node types that are inherently type-only. */
|
|
64
|
+
const TYPE_DECLARATIONS = new Set(["TSInterfaceDeclaration", "TSTypeAliasDeclaration"]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse `file` and return its own top-level named exports (see the module
|
|
68
|
+
* docstring for what's included). Returns `[]` on a read/parse error - a
|
|
69
|
+
* module the parser chokes on simply contributes no hoisted names.
|
|
70
|
+
*/
|
|
71
|
+
export function moduleExports(file: string): ModuleExport[] {
|
|
72
|
+
const body = moduleStatements(file);
|
|
73
|
+
|
|
74
|
+
// Dedupe within the module: an overloaded `export function f(...)` declares
|
|
75
|
+
// `f` once per signature, but it's a single exported name. First occurrence
|
|
76
|
+
// wins (a value declaration and a same-named type would be unusual and are
|
|
77
|
+
// collapsed to whichever appears first).
|
|
78
|
+
const byName = new Map<string, ModuleExport>();
|
|
79
|
+
const push = (e: ModuleExport): void => {
|
|
80
|
+
if (!byName.has(e.name)) byName.set(e.name, e);
|
|
81
|
+
};
|
|
82
|
+
for (const stmt of body) {
|
|
83
|
+
if (stmt.type !== "ExportNamedDeclaration") continue;
|
|
84
|
+
// Narrow to the fields we read; oxc's union is wider than what we touch.
|
|
85
|
+
const node = stmt as {
|
|
86
|
+
exportKind?: "value" | "type";
|
|
87
|
+
source?: { value?: string } | null;
|
|
88
|
+
declaration?: {
|
|
89
|
+
type: string;
|
|
90
|
+
id?: { name?: string } | null;
|
|
91
|
+
declarations?: { id?: { type?: string; name?: string } | null }[];
|
|
92
|
+
} | null;
|
|
93
|
+
specifiers?: {
|
|
94
|
+
exported?: { name?: string; value?: string };
|
|
95
|
+
exportKind?: "value" | "type";
|
|
96
|
+
}[];
|
|
97
|
+
};
|
|
98
|
+
// `export { ... } from "..."` re-exports another module's names; skip.
|
|
99
|
+
if (node.source) continue;
|
|
100
|
+
|
|
101
|
+
const stmtIsType = node.exportKind === "type";
|
|
102
|
+
const decl = node.declaration;
|
|
103
|
+
if (decl) {
|
|
104
|
+
if (decl.id?.name) {
|
|
105
|
+
push({ name: decl.id.name, isType: stmtIsType || TYPE_DECLARATIONS.has(decl.type) });
|
|
106
|
+
}
|
|
107
|
+
for (const d of decl.declarations ?? []) {
|
|
108
|
+
if (d.id?.type === "Identifier" && d.id.name) {
|
|
109
|
+
push({ name: d.id.name, isType: stmtIsType });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const spec of node.specifiers ?? []) {
|
|
114
|
+
const name = spec.exported?.name ?? spec.exported?.value;
|
|
115
|
+
if (!name) continue;
|
|
116
|
+
push({ name, isType: stmtIsType || spec.exportKind === "type" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...byName.values()];
|
|
120
|
+
}
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI generator (tsoa-based).
|
|
3
|
+
*
|
|
4
|
+
* Scans `server`/`node` packages for modules that **import tsoa**
|
|
5
|
+
* (`from 'tsoa'` / `from '@tsoa/runtime'`) and, for each package that has them,
|
|
6
|
+
* generates a read-only `<root>/openapi/<name>` package:
|
|
7
|
+
*
|
|
8
|
+
* - `openapi.json` - the OpenAPI 3 spec (tsoa `generateSpec`, from the types).
|
|
9
|
+
* - `src/schema.ts` - types generated from the spec (openapi-typescript).
|
|
10
|
+
* - `src/client.ts` - a typed `openapi-fetch` client, usable client-side.
|
|
11
|
+
*
|
|
12
|
+
* `generateSpec` then reads the actual controller decorators + TypeScript types from
|
|
13
|
+
* those files, so the API surface is annotated on the methods and nothing is
|
|
14
|
+
* hand-written twice. The generated client stack is openapi-typescript +
|
|
15
|
+
* openapi-fetch (openapi-ts.dev), the best-of-2026 choice since AppKit ships no
|
|
16
|
+
* OpenAPI client generator.
|
|
17
|
+
*
|
|
18
|
+
* `tsoa`, `typescript`, and `openapi-typescript` are loaded lazily (heavy, and only
|
|
19
|
+
* needed for `pnpm run openapi`), so importing this module stays cheap. `tsoa` and
|
|
20
|
+
* `typescript` are not engine dependencies at all - both are resolved out of the
|
|
21
|
+
* consuming workspace, which is where they already live.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import type * as ts from "typescript";
|
|
27
|
+
import { find } from "@dbx-tools/path";
|
|
28
|
+
import { lazyRequire } from "./_lazy-require";
|
|
29
|
+
import { makeReadonly, makeWritable, stampGenerated } from "./generated";
|
|
30
|
+
import { log } from "@dbx-tools/shared-core";
|
|
31
|
+
import {
|
|
32
|
+
type RecordedPackage,
|
|
33
|
+
isModuleFile,
|
|
34
|
+
repoRoot,
|
|
35
|
+
toPosix,
|
|
36
|
+
recordedPackages,
|
|
37
|
+
} from "./packages";
|
|
38
|
+
|
|
39
|
+
const logger = log.logger("projen:openapi");
|
|
40
|
+
|
|
41
|
+
/** The tag (and folder) the generated openapi client packages are written under. */
|
|
42
|
+
const OPENAPI_TAG = "openapi";
|
|
43
|
+
/** Heuristic: a module file whose source imports tsoa's runtime package. */
|
|
44
|
+
const TSOA_IMPORT = /from\s+['"](?:tsoa|@tsoa\/runtime)['"]/;
|
|
45
|
+
|
|
46
|
+
const CLIENT_SRC = `import createClient, { type ClientOptions } from "openapi-fetch";
|
|
47
|
+
import type { paths } from "./schema";
|
|
48
|
+
|
|
49
|
+
/** Create a typed OpenAPI client (openapi-fetch); safe to use in the browser. */
|
|
50
|
+
export function createApiClient(options?: ClientOptions) {
|
|
51
|
+
return createClient<paths>(options);
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/** True if any module file in `<pkg>/src` matches {@link TSOA_IMPORT}. */
|
|
56
|
+
function hasTsoaControllers(pkg: Pick<RecordedPackage, "dir">): boolean {
|
|
57
|
+
const srcDir = join(pkg.dir, "src");
|
|
58
|
+
return [...find.findFiles("**/*", { cwd: srcDir })]
|
|
59
|
+
.filter(isModuleFile)
|
|
60
|
+
.some((f) => TSOA_IMPORT.test(readFileSync(join(srcDir, f), "utf8")));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `server`/`node` packages (never the generated `openapi` tag) with a tsoa import. */
|
|
64
|
+
function controllerPackages(): RecordedPackage[] {
|
|
65
|
+
return recordedPackages().filter(
|
|
66
|
+
(p) => (p.tags.includes("server") || p.tags.includes("node")) && hasTsoaControllers(p),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True if the changed path is a source file that matches {@link TSOA_IMPORT}. */
|
|
71
|
+
export function isTsoaController(path: string): boolean {
|
|
72
|
+
const posix = toPosix(path);
|
|
73
|
+
return (
|
|
74
|
+
!posix.includes(`/${OPENAPI_TAG}/`) &&
|
|
75
|
+
isModuleFile(path) &&
|
|
76
|
+
existsSync(path) &&
|
|
77
|
+
TSOA_IMPORT.test(readFileSync(path, "utf8"))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Regenerate the `openapi` packages from every server/node package with a tsoa
|
|
83
|
+
* import. Returns the package dirs it wrote so the caller can rebuild their barrels.
|
|
84
|
+
* A separate projen synth is still needed before new openapi folders become workspace
|
|
85
|
+
* members in `pnpm-workspace.yaml`.
|
|
86
|
+
*/
|
|
87
|
+
export async function generateOpenapi(): Promise<string[]> {
|
|
88
|
+
const pkgs = controllerPackages();
|
|
89
|
+
if (pkgs.length === 0) {
|
|
90
|
+
logger.info("no tsoa controllers found in any server/node package");
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Lazy, resilient loads: tsoa + typescript are CJS (require), openapi-typescript
|
|
95
|
+
// is ESM (dynamic import).
|
|
96
|
+
const require = createRequire(import.meta.url);
|
|
97
|
+
const { generateSpec } = lazyRequire<typeof import("tsoa")>(require, "tsoa", "openapi generation");
|
|
98
|
+
const tsRuntime = lazyRequire<typeof ts>(require, "typescript", "openapi generation");
|
|
99
|
+
const { default: openapiTS, astToString } = await import("openapi-typescript");
|
|
100
|
+
|
|
101
|
+
// Read tsoa's controllers with decorator support; skipLibCheck keeps third-party
|
|
102
|
+
// `.d.ts` out of the spec-generation compile.
|
|
103
|
+
const compilerOptions: ts.CompilerOptions = {
|
|
104
|
+
experimentalDecorators: true,
|
|
105
|
+
target: tsRuntime.ScriptTarget.ES2022,
|
|
106
|
+
module: tsRuntime.ModuleKind.ESNext,
|
|
107
|
+
moduleResolution: tsRuntime.ModuleResolutionKind.Bundler,
|
|
108
|
+
esModuleInterop: true,
|
|
109
|
+
skipLibCheck: true,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const written: string[] = [];
|
|
113
|
+
for (const p of pkgs) {
|
|
114
|
+
// The generated package's folder is the source's leaf folder name (`api`), not
|
|
115
|
+
// its npm name - `p.name` is now the (possibly-overridden) manifest name.
|
|
116
|
+
const leaf = p.relPath.split("/").pop() ?? p.relPath;
|
|
117
|
+
const outDir = join(repoRoot, p.root, OPENAPI_TAG, leaf);
|
|
118
|
+
const srcDir = join(outDir, "src");
|
|
119
|
+
mkdirSync(srcDir, { recursive: true });
|
|
120
|
+
|
|
121
|
+
// 1) tsoa writes <outDir>/openapi.json from the controllers' decorators + types.
|
|
122
|
+
const specPath = join(outDir, "openapi.json");
|
|
123
|
+
makeWritable(specPath);
|
|
124
|
+
await generateSpec(
|
|
125
|
+
{
|
|
126
|
+
entryFile: "",
|
|
127
|
+
noImplicitAdditionalProperties: "throw-on-extras",
|
|
128
|
+
controllerPathGlobs: [join(p.dir, "src/**/*.ts")],
|
|
129
|
+
outputDirectory: outDir,
|
|
130
|
+
specFileBaseName: "openapi",
|
|
131
|
+
specVersion: 3,
|
|
132
|
+
name: `${p.relPath} API`,
|
|
133
|
+
version: "0.0.0",
|
|
134
|
+
},
|
|
135
|
+
compilerOptions,
|
|
136
|
+
);
|
|
137
|
+
makeReadonly(specPath);
|
|
138
|
+
|
|
139
|
+
// 2) src/schema.ts: types generated from the spec (openapi-typescript).
|
|
140
|
+
const spec = JSON.parse(readFileSync(specPath, "utf8"));
|
|
141
|
+
const schemaPath = join(srcDir, "schema.ts");
|
|
142
|
+
makeWritable(schemaPath);
|
|
143
|
+
writeFileSync(schemaPath, astToString(await openapiTS(spec)));
|
|
144
|
+
stampGenerated(schemaPath, {
|
|
145
|
+
tool: "projen openapi (tsoa + openapi-typescript)",
|
|
146
|
+
source: `the tsoa controllers in ${p.relPath}`,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 3) src/client.ts: a typed openapi-fetch client over those types.
|
|
150
|
+
const clientPath = join(srcDir, "client.ts");
|
|
151
|
+
makeWritable(clientPath);
|
|
152
|
+
writeFileSync(clientPath, CLIENT_SRC);
|
|
153
|
+
stampGenerated(clientPath, {
|
|
154
|
+
tool: "projen openapi (openapi-fetch)",
|
|
155
|
+
source: "./schema",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
written.push(outDir);
|
|
159
|
+
logger.success(`openapi/${leaf} (from ${p.relPath})`);
|
|
160
|
+
}
|
|
161
|
+
return written;
|
|
162
|
+
}
|
package/src/packages.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package discovery + shared filesystem helpers.
|
|
3
|
+
*
|
|
4
|
+
* Terminology (Bit-style): a **tag** names a target environment
|
|
5
|
+
* (React/Vite, Node, agnostic, ...); a workspace **package** is a folder with a
|
|
6
|
+
* `src/` holding at least one module file (`.ts`/`.tsx`/`.js`/`.jsx`). "Scope" is
|
|
7
|
+
* reserved for the npm `@scope/` in package identifiers (e.g. `@dbx-tools/ui-app`).
|
|
8
|
+
*
|
|
9
|
+
* A package is discovered by scanning the {@link packageRoots} (default
|
|
10
|
+
* `["packages"]`). Its path *relative to the root* drives everything: the path
|
|
11
|
+
* segments join with `-` cumulatively into {@link DiscoveredPackage.tagCandidates}
|
|
12
|
+
* (e.g. `shared/path/coolDude/another` -> `[shared, shared-path, shared-path-cool-dude]`:
|
|
13
|
+
* each ancestor folder under the root, kebab-cased, excluding the leaf package
|
|
14
|
+
* folder), and those candidates are matched against `packageTagPaths` to decide which tag(s)
|
|
15
|
+
* apply. The match may yield NO tags - that is fine (the package still gets the
|
|
16
|
+
* agnostic default).
|
|
17
|
+
*
|
|
18
|
+
* Two discovery entry points. {@link scanPackages} walks the filesystem under the
|
|
19
|
+
* roots (synth time): it returns each package's path plus the tags implied by its
|
|
20
|
+
* path relative to the root, reading NO manifest. {@link recordedPackages} reads
|
|
21
|
+
* the recorded members from `pnpm-workspace.yaml` - the SOURCE OF TRUTH - and
|
|
22
|
+
* augments each with the `name` and `tags` read back from its own `package.json`
|
|
23
|
+
* (post-synth: barrels, watch, openapi), which is authoritative and so reflects any
|
|
24
|
+
* synth-time name override or resolved tag set.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { extname, relative, resolve, sep } from "node:path";
|
|
28
|
+
import { project as coreProject } from "@dbx-tools/core";
|
|
29
|
+
import { json, object, string } from "@dbx-tools/shared-core";
|
|
30
|
+
import { find } from "@dbx-tools/path";
|
|
31
|
+
import { parse } from "yaml";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The repo root: the nearest package/projenrc root, falling back to the current
|
|
35
|
+
* working directory. Detection (npm prefix, git top-level, root markers) lives
|
|
36
|
+
* in `@dbx-tools/core`'s {@link coreProject.root}.
|
|
37
|
+
*/
|
|
38
|
+
export const repoRoot = coreProject.root() ?? process.cwd();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default package roots. Each is scanned for packages; override via the
|
|
42
|
+
* `packageRoots` option on a DBXTools project.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_PACKAGE_ROOTS = ["packages"] as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A project name: the root `package.json` name, else the git remote's repo
|
|
48
|
+
* name, else the root folder name. Delegates to `@dbx-tools/core`'s
|
|
49
|
+
* {@link coreProject.name}, which is also what a consuming repo's own tooling
|
|
50
|
+
* sees, so the engine and its host agree on the name.
|
|
51
|
+
*/
|
|
52
|
+
export function projectName(): string {
|
|
53
|
+
return coreProject.name(repoRoot);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MODULE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
57
|
+
|
|
58
|
+
/** Glob for module files under any `src/`, built from {@link MODULE_EXTS} exts. */
|
|
59
|
+
const SRC_MODULE_GLOB = `**/src/**/*.{${[...MODULE_EXTS].map((e) => e.slice(1)).join(",")}}`;
|
|
60
|
+
|
|
61
|
+
export function toPosix(p: string): string {
|
|
62
|
+
return p.split(sep).join("/");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cumulative nesting tags from a package's path segments relative to its discovery
|
|
67
|
+
* root. Each segment is kebab-cased with {@link string.toSlug} (`coolDude` ->
|
|
68
|
+
* `cool-dude`). The leaf folder (the package name) is excluded when there are two
|
|
69
|
+
* or more segments; a lone segment tags itself.
|
|
70
|
+
*/
|
|
71
|
+
function nestingTagsFromSegments(segments: readonly string[]): string[] {
|
|
72
|
+
if (segments.length === 0) return [];
|
|
73
|
+
const prefix = segments.length === 1 ? segments : segments.slice(0, -1);
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
let acc = "";
|
|
76
|
+
for (const segment of prefix) {
|
|
77
|
+
const token = string.toSlug(segment);
|
|
78
|
+
if (!token) continue;
|
|
79
|
+
acc = acc ? `${acc}-${token}` : token;
|
|
80
|
+
out.push(acc);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Matches a barrel `index.<ext>` (as a basename or a posix path tail). */
|
|
86
|
+
const BARREL_RE = /(^|\/)index\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
87
|
+
|
|
88
|
+
/** Basenames this toolchain generates (projen manifests/tsconfigs + vite config). */
|
|
89
|
+
const GENERATED_BASENAMES = new Set([
|
|
90
|
+
"package.json",
|
|
91
|
+
"tsconfig.json",
|
|
92
|
+
"tsconfig.dev.json",
|
|
93
|
+
"vite.config.ts",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True if the file matches the watcher's generated-file heuristic: projen manifest
|
|
98
|
+
* basenames, package-root barrels (`index.ts`), vite config, or declaration files.
|
|
99
|
+
* Other read-only toolchain output (e.g. openapi artifacts) is not covered here.
|
|
100
|
+
*/
|
|
101
|
+
export function isGeneratedFile(file: string): boolean {
|
|
102
|
+
const base = file.split(sep).pop() ?? "";
|
|
103
|
+
return GENERATED_BASENAMES.has(base) || BARREL_RE.test(base) || base.endsWith(".d.ts");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A re-exportable source module: ts/tsx/js/jsx/mjs/cjs, not a barrel/test/decl. */
|
|
107
|
+
export function isModuleFile(file: string): boolean {
|
|
108
|
+
if (file.endsWith(".d.ts")) return false;
|
|
109
|
+
if (!MODULE_EXTS.has(extname(file))) return false;
|
|
110
|
+
// Accept both OS-native paths and posix (glob) inputs.
|
|
111
|
+
const base = toPosix(file).split("/").pop()!;
|
|
112
|
+
if (BARREL_RE.test(base)) return false;
|
|
113
|
+
if (/\.(test|spec)\./.test(base)) return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One discovered package: a `src`-bearing folder somewhere under a
|
|
119
|
+
* package root, identified by that root plus the segments of its path
|
|
120
|
+
* *relative to the root*. For `packages/ui/app` the root is `packages` and the
|
|
121
|
+
* segments are `["ui", "app"]`.
|
|
122
|
+
*
|
|
123
|
+
* The relative segments drive everything downstream: the npm name
|
|
124
|
+
* (`@<scope>/<segments joined by -`), the `memberPath`/`dir`, and the
|
|
125
|
+
* {@link tagCandidates} used to resolve which tag(s) apply.
|
|
126
|
+
*/
|
|
127
|
+
export class DiscoveredPackage {
|
|
128
|
+
constructor(
|
|
129
|
+
/** Absolute repo root. */
|
|
130
|
+
readonly projectRoot: string,
|
|
131
|
+
/** Repo-relative package root, e.g. `packages`. */
|
|
132
|
+
readonly root: string,
|
|
133
|
+
/** Path segments relative to `root`, e.g. `["ui", "app"]`. */
|
|
134
|
+
readonly relSegments: readonly string[],
|
|
135
|
+
) {}
|
|
136
|
+
|
|
137
|
+
/** Posix path relative to the root, e.g. `ui/app`. */
|
|
138
|
+
get relPath(): string {
|
|
139
|
+
return this.relSegments.join("/");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Repo-relative posix member path: `packages/ui/app` (pnpm member + `outdir`). */
|
|
143
|
+
get memberPath(): string {
|
|
144
|
+
return [this.root, ...this.relSegments].join("/");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Absolute package directory. */
|
|
148
|
+
get dir(): string {
|
|
149
|
+
return resolve(this.projectRoot, this.root, ...this.relSegments);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The package folder name (last segment), e.g. `app`. */
|
|
153
|
+
get name(): string {
|
|
154
|
+
return this.relSegments[this.relSegments.length - 1] ?? this.root;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Tag candidates from nesting under the discovery root: cumulative kebab-case join
|
|
159
|
+
* of every ancestor folder, excluding the leaf package folder when depth >= 2
|
|
160
|
+
* (`shared/path/coolDude/another` -> `[shared, shared-path, shared-path-cool-dude]`).
|
|
161
|
+
* Matched against `packageTagPaths` to resolve applied mixin tags.
|
|
162
|
+
*/
|
|
163
|
+
get tagCandidates(): string[] {
|
|
164
|
+
return nestingTagsFromSegments(this.relSegments);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Read the raw workspace member globs from `pnpm-workspace.yaml` (source of truth). */
|
|
169
|
+
function readRecordedMembers(projectRoot: string = repoRoot): string[] {
|
|
170
|
+
const file = resolve(projectRoot, "pnpm-workspace.yaml");
|
|
171
|
+
if (!existsSync(file)) return [];
|
|
172
|
+
const doc = parse(readFileSync(file, "utf8")) as {
|
|
173
|
+
packages?: string[];
|
|
174
|
+
} | null;
|
|
175
|
+
return doc?.packages ?? [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A member path `<root>/<...rel>` (>= 2 segments) as a {@link DiscoveredPackage}. */
|
|
179
|
+
function packageOfMember(projectRoot: string, member: string): DiscoveredPackage | undefined {
|
|
180
|
+
const segs = toPosix(member).split("/").filter(Boolean);
|
|
181
|
+
if (segs.length < 2) return undefined;
|
|
182
|
+
return new DiscoveredPackage(projectRoot, segs[0]!, segs.slice(1));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Package dirs under `rootAbs`, found with a single `find.findFiles` scan from
|
|
187
|
+
* `@dbx-tools/path` for module files beneath any `src/`. A package is the
|
|
188
|
+
* folder that OWNS the `src/` - the segments before the FIRST `src/` - so a package's
|
|
189
|
+
* own subfolders never become nested packages (outermost wins). Barrels/tests/decls
|
|
190
|
+
* don't count (see {@link isModuleFile}), so a `src/` holding only an `index.ts`
|
|
191
|
+
* barrel is not a package. Depth is unbounded: `<root>/a/b/c/src` is discovered as
|
|
192
|
+
* `a/b/c`.
|
|
193
|
+
*/
|
|
194
|
+
function collectPackageDirs(rootAbs: string): string[] {
|
|
195
|
+
const owners = new Set<string>();
|
|
196
|
+
for (const file of find.findFiles(SRC_MODULE_GLOB, { cwd: rootAbs })) {
|
|
197
|
+
if (!isModuleFile(file)) continue;
|
|
198
|
+
const segs = toPosix(file).split("/");
|
|
199
|
+
const srcIdx = segs.indexOf("src");
|
|
200
|
+
if (srcIdx > 0) owners.add(segs.slice(0, srcIdx).join("/"));
|
|
201
|
+
}
|
|
202
|
+
const rels = [...owners];
|
|
203
|
+
// Outermost wins: drop any owner nested under another discovered owner.
|
|
204
|
+
return rels
|
|
205
|
+
.filter((d) => !rels.some((o) => o !== d && d.startsWith(`${o}/`)))
|
|
206
|
+
.map((rel) => resolve(rootAbs, rel));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Scan the filesystem for packages under `roots` (synth time): every `src`-bearing
|
|
211
|
+
* folder, at any depth, is one. Returns each as a {@link DiscoveredPackage} - its
|
|
212
|
+
* path plus the tags implied by its path relative to the root
|
|
213
|
+
* ({@link DiscoveredPackage.tagCandidates}); no `package.json` is read. Used by
|
|
214
|
+
* the root project's scan at synth, and by the watcher to compare disk against the
|
|
215
|
+
* recorded set. Sorted by member path.
|
|
216
|
+
*/
|
|
217
|
+
export function scanPackages(
|
|
218
|
+
projectRoot: string = repoRoot,
|
|
219
|
+
roots: readonly string[] = DEFAULT_PACKAGE_ROOTS,
|
|
220
|
+
): DiscoveredPackage[] {
|
|
221
|
+
const out: DiscoveredPackage[] = [];
|
|
222
|
+
for (const root of roots) {
|
|
223
|
+
const rootAbs = resolve(projectRoot, root);
|
|
224
|
+
for (const pkgDir of collectPackageDirs(rootAbs)) {
|
|
225
|
+
const rel = toPosix(relative(rootAbs, pkgDir)).split("/").filter(Boolean);
|
|
226
|
+
out.push(new DiscoveredPackage(projectRoot, root, rel));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out.sort((a, b) => a.memberPath.localeCompare(b.memberPath));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A recorded package: its path, plus the `name` and `tags` read back from
|
|
234
|
+
* its own `package.json` (both written at synth, and possibly overridden by a consumer
|
|
235
|
+
* `packageMixin` - e.g. a name override). `name`/`tags` fall back to the folder
|
|
236
|
+
* name / path candidates when the manifest is missing or carries none (a package
|
|
237
|
+
* added but not yet synthesized).
|
|
238
|
+
*/
|
|
239
|
+
export interface RecordedPackage {
|
|
240
|
+
/** Repo-relative posix member path, e.g. `packages/ui/app`. */
|
|
241
|
+
readonly path: string;
|
|
242
|
+
/** Repo-relative package root, e.g. `packages`. */
|
|
243
|
+
readonly root: string;
|
|
244
|
+
/** Posix path relative to the root, e.g. `ui/app`. */
|
|
245
|
+
readonly relPath: string;
|
|
246
|
+
/** Absolute package directory. */
|
|
247
|
+
readonly dir: string;
|
|
248
|
+
/** Resolved tags from `package.json` `dbxToolsConfig.tags`, else the path candidates. */
|
|
249
|
+
readonly tags: string[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `<dir>/package.json` parsed as a record, or `undefined` when the file is
|
|
254
|
+
* absent or malformed. The single manifest reader for the engine - every
|
|
255
|
+
* caller that pokes at a `package.json` field goes through this so a missing or
|
|
256
|
+
* half-written manifest degrades the same way everywhere.
|
|
257
|
+
*/
|
|
258
|
+
export function readPackageManifest(dir: string): Record<string, unknown> | undefined {
|
|
259
|
+
const path = resolve(dir, "package.json");
|
|
260
|
+
if (!existsSync(path)) return undefined;
|
|
261
|
+
try {
|
|
262
|
+
return json.parseRecord(readFileSync(path, "utf8"));
|
|
263
|
+
} catch {
|
|
264
|
+
return undefined; // unreadable (permissions, race with a concurrent write)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A package's `dbxToolsConfig` object, or `undefined` when absent. */
|
|
269
|
+
function readDbxToolsConfig(dir: string): Record<string, unknown> | undefined {
|
|
270
|
+
const config = readPackageManifest(dir)?.dbxToolsConfig;
|
|
271
|
+
return object.isRecord(config) ? config : undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Read `<dir>/package.json`'s `dbxToolsConfig.tags` (`undefined` if absent). */
|
|
275
|
+
function readManifestTags(dir: string): string[] | undefined {
|
|
276
|
+
const tags = readDbxToolsConfig(dir)?.tags;
|
|
277
|
+
return Array.isArray(tags) ? (tags as string[]) : undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Extra repo-root paths that trigger a full re-synth during `sync --watch`, read from
|
|
282
|
+
* the root `package.json` `dbxToolsConfig.syncResynthPaths` (set via the
|
|
283
|
+
* {@link DBXToolsProjectOptions.syncResynthPaths} option at synth).
|
|
284
|
+
*/
|
|
285
|
+
export function syncResynthPaths(projectRoot: string = repoRoot): string[] {
|
|
286
|
+
const paths = readDbxToolsConfig(projectRoot)?.syncResynthPaths;
|
|
287
|
+
return Array.isArray(paths) ? string.parseList(paths.map((p) => String(p))) : [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The recorded workspace members from `pnpm-workspace.yaml` (the source of truth),
|
|
292
|
+
* each augmented with the `tags` read back from its `package.json`. This is what
|
|
293
|
+
* every post-synth command (barrels, watch, openapi) uses: the manifest is
|
|
294
|
+
* authoritative, so it reflects the resolved tag set.
|
|
295
|
+
* Sorted by path.
|
|
296
|
+
*/
|
|
297
|
+
export function recordedPackages(projectRoot: string = repoRoot): RecordedPackage[] {
|
|
298
|
+
const out: RecordedPackage[] = [];
|
|
299
|
+
for (const member of readRecordedMembers(projectRoot)) {
|
|
300
|
+
const pkg = packageOfMember(projectRoot, member);
|
|
301
|
+
if (!pkg) continue;
|
|
302
|
+
out.push({
|
|
303
|
+
path: pkg.memberPath,
|
|
304
|
+
root: pkg.root,
|
|
305
|
+
relPath: pkg.relPath,
|
|
306
|
+
dir: pkg.dir,
|
|
307
|
+
tags: readManifestTags(pkg.dir) ?? pkg.tagCandidates,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The roots to scan for a live filesystem check: the distinct first segment of
|
|
315
|
+
* every recorded member, unioned with the defaults. Lets a command compare disk
|
|
316
|
+
* against the recorded truth without knowing the `packageRoots` the last
|
|
317
|
+
* synth was configured with.
|
|
318
|
+
*/
|
|
319
|
+
export function recordedRoots(projectRoot: string = repoRoot): string[] {
|
|
320
|
+
const roots = new Set<string>(DEFAULT_PACKAGE_ROOTS);
|
|
321
|
+
for (const member of readRecordedMembers(projectRoot)) {
|
|
322
|
+
const pkg = packageOfMember(projectRoot, member);
|
|
323
|
+
if (pkg) roots.add(pkg.root);
|
|
324
|
+
}
|
|
325
|
+
return [...roots];
|
|
326
|
+
}
|