@zmdb/cli 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +19 -0
- package/dist/__fixtures__/http-client/src/contract.d.ts +38 -0
- package/dist/__fixtures__/http-client/src/contract.d.ts.map +1 -0
- package/dist/__fixtures__/http-client/src/contract.js +121 -0
- package/dist/__fixtures__/http-client/src/contract.js.map +1 -0
- package/dist/__fixtures__/http-client/src/models.d.ts +11 -0
- package/dist/__fixtures__/http-client/src/models.d.ts.map +1 -0
- package/dist/__fixtures__/http-client/src/models.js +2 -0
- package/dist/__fixtures__/http-client/src/models.js.map +1 -0
- package/dist/__fixtures__/http-client/src/schema.d.ts +4 -0
- package/dist/__fixtures__/http-client/src/schema.d.ts.map +1 -0
- package/dist/__fixtures__/http-client/src/schema.js +2 -0
- package/dist/__fixtures__/http-client/src/schema.js.map +1 -0
- package/dist/__fixtures__/http-client/src/unrelated.d.ts +2 -0
- package/dist/__fixtures__/http-client/src/unrelated.d.ts.map +1 -0
- package/dist/__fixtures__/http-client/src/unrelated.js +2 -0
- package/dist/__fixtures__/http-client/src/unrelated.js.map +1 -0
- package/dist/__fixtures__/http-client/zmdb.config.d.ts +16 -0
- package/dist/__fixtures__/http-client/zmdb.config.d.ts.map +1 -0
- package/dist/__fixtures__/http-client/zmdb.config.js +12 -0
- package/dist/__fixtures__/http-client/zmdb.config.js.map +1 -0
- package/dist/__fixtures__/project/src/schema.d.ts +6 -0
- package/dist/__fixtures__/project/src/schema.d.ts.map +1 -0
- package/dist/__fixtures__/project/src/schema.js +2 -0
- package/dist/__fixtures__/project/src/schema.js.map +1 -0
- package/dist/__fixtures__/project/zmdb.config.d.ts +22 -0
- package/dist/__fixtures__/project/zmdb.config.d.ts.map +1 -0
- package/dist/__fixtures__/project/zmdb.config.js +55 -0
- package/dist/__fixtures__/project/zmdb.config.js.map +1 -0
- package/dist/application-loader.d.ts +4 -0
- package/dist/application-loader.d.ts.map +1 -0
- package/dist/application-loader.js +102 -0
- package/dist/application-loader.js.map +1 -0
- package/dist/args.d.ts +23 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +353 -0
- package/dist/args.js.map +1 -0
- package/dist/atomic.d.ts +13 -0
- package/dist/atomic.d.ts.map +1 -0
- package/dist/atomic.js +28 -0
- package/dist/atomic.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/check.d.ts +5 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +7 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/client.d.ts +38 -0
- package/dist/commands/client.d.ts.map +1 -0
- package/dist/commands/client.js +347 -0
- package/dist/commands/client.js.map +1 -0
- package/dist/commands/codegen.d.ts +9 -0
- package/dist/commands/codegen.d.ts.map +1 -0
- package/dist/commands/codegen.js +56 -0
- package/dist/commands/codegen.js.map +1 -0
- package/dist/commands/embed.d.ts +8 -0
- package/dist/commands/embed.d.ts.map +1 -0
- package/dist/commands/embed.js +17 -0
- package/dist/commands/embed.js.map +1 -0
- package/dist/commands/export.d.ts +5 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +6 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/generate.d.ts +5 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +6 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/migrate.d.ts +9 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +18 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/new.d.ts +4 -0
- package/dist/commands/new.d.ts.map +1 -0
- package/dist/commands/new.js +35 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/pull.d.ts +5 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/pull.js +14 -0
- package/dist/commands/pull.js.map +1 -0
- package/dist/commands/push.d.ts +6 -0
- package/dist/commands/push.d.ts.map +1 -0
- package/dist/commands/push.js +11 -0
- package/dist/commands/push.js.map +1 -0
- package/dist/commands/studio.d.ts +31 -0
- package/dist/commands/studio.d.ts.map +1 -0
- package/dist/commands/studio.js +154 -0
- package/dist/commands/studio.js.map +1 -0
- package/dist/commands/upgrade.d.ts +5 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +11 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/database.d.ts +4 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +8 -0
- package/dist/database.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +20 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +626 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +6 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +75 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/migration-project.d.ts +14 -0
- package/dist/migration-project.d.ts.map +1 -0
- package/dist/migration-project.js +74 -0
- package/dist/migration-project.js.map +1 -0
- package/dist/output.d.ts +27 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +60 -0
- package/dist/output.js.map +1 -0
- package/dist/repl.d.ts +48 -0
- package/dist/repl.d.ts.map +1 -0
- package/dist/repl.js +265 -0
- package/dist/repl.js.map +1 -0
- package/dist/scaffold.d.ts +28 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +414 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/studio/index.d.ts +20 -0
- package/dist/studio/index.d.ts.map +1 -0
- package/dist/studio/index.js +607 -0
- package/dist/studio/index.js.map +1 -0
- package/dist/templates/command.d.ts +3 -0
- package/dist/templates/command.d.ts.map +1 -0
- package/dist/templates/command.js +65 -0
- package/dist/templates/command.js.map +1 -0
- package/dist/templates/controller.d.ts +3 -0
- package/dist/templates/controller.d.ts.map +1 -0
- package/dist/templates/controller.js +41 -0
- package/dist/templates/controller.js.map +1 -0
- package/dist/templates/index.d.ts +4 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +18 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/templates/module.d.ts +3 -0
- package/dist/templates/module.d.ts.map +1 -0
- package/dist/templates/module.js +41 -0
- package/dist/templates/module.js.map +1 -0
- package/dist/templates/project.d.ts +3 -0
- package/dist/templates/project.d.ts.map +1 -0
- package/dist/templates/project.js +280 -0
- package/dist/templates/project.js.map +1 -0
- package/dist/templates/repository.d.ts +3 -0
- package/dist/templates/repository.d.ts.map +1 -0
- package/dist/templates/repository.js +66 -0
- package/dist/templates/repository.js.map +1 -0
- package/dist/templates/schema.d.ts +3 -0
- package/dist/templates/schema.d.ts.map +1 -0
- package/dist/templates/schema.js +36 -0
- package/dist/templates/schema.js.map +1 -0
- package/dist/templates/types.d.ts +25 -0
- package/dist/templates/types.d.ts.map +1 -0
- package/dist/templates/types.js +17 -0
- package/dist/templates/types.js.map +1 -0
- package/package.json +72 -0
- package/src/__fixtures__/http-client/package.json +5 -0
- package/src/__fixtures__/http-client/src/contract.ts +85 -0
- package/src/__fixtures__/http-client/src/models.ts +12 -0
- package/src/__fixtures__/http-client/src/schema.ts +3 -0
- package/src/__fixtures__/http-client/src/unrelated.ts +1 -0
- package/src/__fixtures__/http-client/tsconfig.json +17 -0
- package/src/__fixtures__/http-client/zmdb.config.ts +12 -0
- package/src/__fixtures__/project/package.json +5 -0
- package/src/__fixtures__/project/src/schema.ts +6 -0
- package/src/__fixtures__/project/tsconfig.json +24 -0
- package/src/__fixtures__/project/zmdb.config.ts +57 -0
- package/src/application-loader.ts +108 -0
- package/src/args.ts +391 -0
- package/src/atomic.ts +43 -0
- package/src/bin.ts +5 -0
- package/src/commands/check.ts +11 -0
- package/src/commands/client.ts +415 -0
- package/src/commands/codegen.ts +73 -0
- package/src/commands/embed.ts +36 -0
- package/src/commands/export.ts +10 -0
- package/src/commands/generate.ts +14 -0
- package/src/commands/migrate.ts +38 -0
- package/src/commands/new.ts +48 -0
- package/src/commands/pull.ts +32 -0
- package/src/commands/push.ts +21 -0
- package/src/commands/studio.ts +200 -0
- package/src/commands/upgrade.ts +14 -0
- package/src/database.ts +9 -0
- package/src/errors.ts +19 -0
- package/src/index.ts +710 -0
- package/src/lifecycle.ts +73 -0
- package/src/migration-project.ts +91 -0
- package/src/output.ts +77 -0
- package/src/repl.ts +328 -0
- package/src/scaffold.ts +485 -0
- package/src/studio/index.ts +674 -0
- package/src/templates/command.ts +66 -0
- package/src/templates/controller.ts +42 -0
- package/src/templates/index.ts +29 -0
- package/src/templates/module.ts +42 -0
- package/src/templates/project.ts +281 -0
- package/src/templates/repository.ts +67 -0
- package/src/templates/schema.ts +37 -0
- package/src/templates/types.ts +46 -0
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { glob, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import type { format as formatWithOxfmt } from 'oxfmt';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
templateFor,
|
|
10
|
+
type ScaffoldKind,
|
|
11
|
+
type ScaffoldName,
|
|
12
|
+
type TemplateFile,
|
|
13
|
+
type TemplatePlan,
|
|
14
|
+
} from './templates/index.js';
|
|
15
|
+
import { FORMAT_OPTIONS } from './templates/types.js';
|
|
16
|
+
|
|
17
|
+
const SCAFFOLD_KINDS: readonly ScaffoldKind[] = ['project', 'schema', 'controller', 'module', 'repository', 'command'];
|
|
18
|
+
|
|
19
|
+
const PACKAGE_VERSION = packageVersion();
|
|
20
|
+
let formatter: Promise<{ readonly format: typeof formatWithOxfmt }> | undefined;
|
|
21
|
+
|
|
22
|
+
export interface ScaffoldOptions {
|
|
23
|
+
readonly cwd: string;
|
|
24
|
+
readonly kind: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly package?: string;
|
|
27
|
+
readonly dryRun?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ScaffoldFile {
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly source: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ScaffoldResult {
|
|
36
|
+
readonly kind: ScaffoldKind;
|
|
37
|
+
readonly name: ScaffoldName;
|
|
38
|
+
readonly target: string;
|
|
39
|
+
readonly files: readonly ScaffoldFile[];
|
|
40
|
+
readonly instructions: readonly string[];
|
|
41
|
+
readonly dryRun: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface WorkspacePackage {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly relativePath: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Workspace {
|
|
51
|
+
readonly root: string;
|
|
52
|
+
readonly packages: readonly WorkspacePackage[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Target {
|
|
56
|
+
readonly root: string;
|
|
57
|
+
readonly displayRoot: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ScaffoldUsageError extends Error {}
|
|
61
|
+
|
|
62
|
+
export class ScaffoldConflictError extends Error {}
|
|
63
|
+
|
|
64
|
+
/** Resolve, format and write one scaffold without ever editing an existing file. */
|
|
65
|
+
export async function scaffold(options: ScaffoldOptions): Promise<ScaffoldResult> {
|
|
66
|
+
const kind = scaffoldKind(options.kind);
|
|
67
|
+
const name = scaffoldName(options.name);
|
|
68
|
+
const cwd = resolve(options.cwd);
|
|
69
|
+
const target = await resolveTarget(cwd, kind, name, options.package);
|
|
70
|
+
const plan = templateFor(kind)({ name, packageVersion: PACKAGE_VERSION });
|
|
71
|
+
const files = await formattedFiles(plan);
|
|
72
|
+
const absoluteFiles = files.map(file => ({
|
|
73
|
+
...file,
|
|
74
|
+
absolutePath: confinedPath(target.root, file.path),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
for (const file of absoluteFiles) await rejectSymlinkParents(target.root, file.absolutePath);
|
|
78
|
+
|
|
79
|
+
const conflicts = absoluteFiles.filter(file => existsSync(file.absolutePath));
|
|
80
|
+
if (conflicts.length > 0) {
|
|
81
|
+
throw new ScaffoldConflictError(
|
|
82
|
+
`refusing to overwrite existing ${plural(conflicts.length, 'file')}: ${conflicts
|
|
83
|
+
.map(file => file.path)
|
|
84
|
+
.join(', ')}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (options.dryRun !== true) {
|
|
89
|
+
for (const file of absoluteFiles) {
|
|
90
|
+
await mkdir(dirname(file.absolutePath), { recursive: true });
|
|
91
|
+
try {
|
|
92
|
+
await writeFile(file.absolutePath, file.source, { flag: 'wx' });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (errorCode(error) === 'EEXIST') {
|
|
95
|
+
throw new ScaffoldConflictError(`refusing to overwrite existing file: ${file.path}`, { cause: error });
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
kind,
|
|
104
|
+
name,
|
|
105
|
+
target: target.displayRoot,
|
|
106
|
+
files: files.map(file => ({
|
|
107
|
+
path: kind === 'project' ? `${name.fileStem}/${file.path}` : file.path,
|
|
108
|
+
source: file.source,
|
|
109
|
+
})),
|
|
110
|
+
instructions: plan.instructions ?? [],
|
|
111
|
+
dryRun: options.dryRun === true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function renderScaffold(result: ScaffoldResult): string {
|
|
116
|
+
if (result.dryRun) {
|
|
117
|
+
return result.files.map(file => `--- ${file.path}\n${file.source}`).join('');
|
|
118
|
+
}
|
|
119
|
+
const created = result.files.map(file => `created ${file.path}`).join('\n');
|
|
120
|
+
const instructions = result.instructions.length === 0 ? '' : `\n\n${result.instructions.join('\n\n')}`;
|
|
121
|
+
return `${created}${instructions}\n`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scaffoldKind(value: string): ScaffoldKind {
|
|
125
|
+
const kind = SCAFFOLD_KINDS.find(candidate => candidate === value);
|
|
126
|
+
if (kind !== undefined) return kind;
|
|
127
|
+
throw new ScaffoldUsageError(
|
|
128
|
+
`unknown scaffold kind "${value}"; expected ${SCAFFOLD_KINDS.map(candidate => `"${candidate}"`).join(', ')}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function scaffoldName(input: string): ScaffoldName {
|
|
133
|
+
if (input.includes('/') || input.includes('\\') || input === '.' || input === '..') {
|
|
134
|
+
throw new ScaffoldUsageError(`name "${input}" must not contain a path`);
|
|
135
|
+
}
|
|
136
|
+
const words = input
|
|
137
|
+
.trim()
|
|
138
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
139
|
+
.split(/[^A-Za-z0-9]+/)
|
|
140
|
+
.filter(word => word.length > 0);
|
|
141
|
+
const fileStem = words.map(word => word.toLowerCase()).join('-');
|
|
142
|
+
const pascal = words.map(capitalize).join('');
|
|
143
|
+
if (fileStem.length === 0 || !/^[$A-Z_a-z][$\w]*$/.test(pascal)) {
|
|
144
|
+
throw new ScaffoldUsageError(`name "${input}" cannot become a valid TypeScript identifier`);
|
|
145
|
+
}
|
|
146
|
+
const first = pascal[0];
|
|
147
|
+
if (first === undefined) {
|
|
148
|
+
throw new ScaffoldUsageError(`name "${input}" cannot become a valid TypeScript identifier`);
|
|
149
|
+
}
|
|
150
|
+
const camel = `${first.toLowerCase()}${pascal.slice(1)}`;
|
|
151
|
+
const snake = words.map(word => word.toLowerCase()).join('_');
|
|
152
|
+
return {
|
|
153
|
+
input,
|
|
154
|
+
fileStem,
|
|
155
|
+
pascal,
|
|
156
|
+
camel,
|
|
157
|
+
constant: words.map(word => word.toUpperCase()).join('_'),
|
|
158
|
+
table: pluralTable(snake),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function capitalize(value: string): string {
|
|
163
|
+
const first = value[0];
|
|
164
|
+
return first === undefined ? value : `${first.toUpperCase()}${value.slice(1).toLowerCase()}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pluralTable(value: string): string {
|
|
168
|
+
if (value.endsWith('s')) return value;
|
|
169
|
+
if (value.endsWith('ch') || value.endsWith('sh') || /[xz]$/.test(value)) return `${value}es`;
|
|
170
|
+
if (/[^aeiou]y$/.test(value)) return `${value.slice(0, -1)}ies`;
|
|
171
|
+
return `${value}s`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function formattedFiles(plan: TemplatePlan): Promise<readonly ScaffoldFile[]> {
|
|
175
|
+
const files: ScaffoldFile[] = [];
|
|
176
|
+
for (const file of plan.files) {
|
|
177
|
+
files.push({
|
|
178
|
+
path: normalizeTemplatePath(file.path),
|
|
179
|
+
source: await formatTemplate(file),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return files;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function formatTemplate(file: TemplateFile): Promise<string> {
|
|
186
|
+
if (file.path.endsWith('.ts') || file.path.endsWith('.mjs') || file.path.endsWith('.json')) {
|
|
187
|
+
formatter ??= import('oxfmt');
|
|
188
|
+
const { format } = await formatter;
|
|
189
|
+
const result = await format(file.path, file.source, FORMAT_OPTIONS);
|
|
190
|
+
if (result.errors.length > 0) {
|
|
191
|
+
throw new TypeError(`oxfmt could not format generated ${file.path}: ${JSON.stringify(result.errors)}`);
|
|
192
|
+
}
|
|
193
|
+
return result.code;
|
|
194
|
+
}
|
|
195
|
+
return `${file.source.replaceAll(/\r\n?/g, '\n').replaceAll(/\n+$/g, '')}\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeTemplatePath(path: string): string {
|
|
199
|
+
const normalized = path.replaceAll('\\', '/').replaceAll(/^\.\/+/g, '');
|
|
200
|
+
if (normalized.length === 0 || normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
|
201
|
+
throw new TypeError(`template path must stay below the target: ${JSON.stringify(path)}`);
|
|
202
|
+
}
|
|
203
|
+
return normalized;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function rejectSymlinkParents(root: string, target: string): Promise<void> {
|
|
207
|
+
const actualRoot = await resolvedExistingParent(root);
|
|
208
|
+
const actualParent = await resolvedExistingParent(dirname(target));
|
|
209
|
+
if (!inside(actualRoot, actualParent)) {
|
|
210
|
+
throw new ScaffoldUsageError(`template path crosses a symlink outside the target: ${target}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function resolvedExistingParent(path: string): Promise<string> {
|
|
215
|
+
try {
|
|
216
|
+
return await realpath(path);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (errorCode(error) !== 'ENOENT') throw error;
|
|
219
|
+
const parent = dirname(path);
|
|
220
|
+
if (parent === path) throw error;
|
|
221
|
+
return resolve(await resolvedExistingParent(parent), relative(parent, path));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function confinedPath(root: string, path: string): string {
|
|
226
|
+
const absolute = resolve(root, ...path.split('/'));
|
|
227
|
+
if (!inside(root, absolute)) {
|
|
228
|
+
throw new TypeError(`template path escapes the target: ${JSON.stringify(path)}`);
|
|
229
|
+
}
|
|
230
|
+
return absolute;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function resolveTarget(
|
|
234
|
+
cwd: string,
|
|
235
|
+
kind: ScaffoldKind,
|
|
236
|
+
name: ScaffoldName,
|
|
237
|
+
requestedPackage: string | undefined,
|
|
238
|
+
): Promise<Target> {
|
|
239
|
+
const [workspace, nearestPackage] = await Promise.all([discoverWorkspace(cwd), nearestPackageRoot(cwd)]);
|
|
240
|
+
let base: string;
|
|
241
|
+
|
|
242
|
+
if (workspace === undefined) {
|
|
243
|
+
if (requestedPackage !== undefined) {
|
|
244
|
+
throw new ScaffoldUsageError(`--package "${requestedPackage}" was supplied, but no workspace was found`);
|
|
245
|
+
}
|
|
246
|
+
if (kind === 'project') {
|
|
247
|
+
base = cwd;
|
|
248
|
+
} else if (nearestPackage === undefined) {
|
|
249
|
+
throw new ScaffoldUsageError(`no package.json was found from ${cwd}`);
|
|
250
|
+
} else {
|
|
251
|
+
base = nearestPackage;
|
|
252
|
+
}
|
|
253
|
+
} else if (requestedPackage !== undefined) {
|
|
254
|
+
base = namedWorkspacePackage(workspace, requestedPackage).path;
|
|
255
|
+
} else {
|
|
256
|
+
const enclosing = workspace.packages.filter(candidate => inside(candidate.path, cwd));
|
|
257
|
+
if (enclosing.length === 1) {
|
|
258
|
+
const selected = enclosing[0];
|
|
259
|
+
if (selected === undefined) throw new Error('unreachable workspace package selection');
|
|
260
|
+
base = selected.path;
|
|
261
|
+
} else if (
|
|
262
|
+
nearestPackage !== undefined &&
|
|
263
|
+
nearestPackage !== workspace.root &&
|
|
264
|
+
inside(workspace.root, nearestPackage) &&
|
|
265
|
+
!workspace.packages.some(candidate => inside(candidate.path, nearestPackage))
|
|
266
|
+
) {
|
|
267
|
+
// A package nested below an unrelated workspace root is still its own boundary.
|
|
268
|
+
// This is how generated fixtures and vendored projects avoid being mistaken for
|
|
269
|
+
// undeclared packages of the outer checkout.
|
|
270
|
+
base = nearestPackage;
|
|
271
|
+
} else {
|
|
272
|
+
throw ambiguousWorkspace(workspace);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const root = kind === 'project' ? resolve(base, name.fileStem) : base;
|
|
277
|
+
return {
|
|
278
|
+
root,
|
|
279
|
+
displayRoot: relative(cwd, root) || '.',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function namedWorkspacePackage(workspace: Workspace, requested: string): WorkspacePackage {
|
|
284
|
+
const matches = workspace.packages.filter(
|
|
285
|
+
candidate => candidate.name === requested || candidate.relativePath === requested,
|
|
286
|
+
);
|
|
287
|
+
if (matches.length !== 1) {
|
|
288
|
+
const qualifier = matches.length === 0 ? 'was not found' : 'is ambiguous';
|
|
289
|
+
throw new ScaffoldUsageError(
|
|
290
|
+
`workspace package "${requested}" ${qualifier}; candidates:\n${workspaceCandidates(workspace)}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const match = matches[0];
|
|
294
|
+
if (match === undefined) throw new Error('unreachable named workspace package selection');
|
|
295
|
+
return match;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ambiguousWorkspace(workspace: Workspace): ScaffoldUsageError {
|
|
299
|
+
return new ScaffoldUsageError(
|
|
300
|
+
`refusing to guess a workspace package; pass --package <name>. Candidates:\n${workspaceCandidates(workspace)}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function workspaceCandidates(workspace: Workspace): string {
|
|
305
|
+
if (workspace.packages.length === 0) return ' (none found)';
|
|
306
|
+
return workspace.packages.map(candidate => ` ${candidate.name} (${candidate.relativePath})`).join('\n');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function discoverWorkspace(cwd: string): Promise<Workspace | undefined> {
|
|
310
|
+
let directory = cwd;
|
|
311
|
+
while (true) {
|
|
312
|
+
const manifest = await readJson(resolve(directory, 'package.json'));
|
|
313
|
+
const packagePatterns = workspacePatterns(manifest);
|
|
314
|
+
const pnpmPatterns = packagePatterns === undefined ? await readPnpmWorkspace(directory) : undefined;
|
|
315
|
+
const patterns = packagePatterns ?? pnpmPatterns;
|
|
316
|
+
if (patterns !== undefined) {
|
|
317
|
+
return {
|
|
318
|
+
root: directory,
|
|
319
|
+
packages: await workspacePackages(directory, patterns),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const parent = dirname(directory);
|
|
324
|
+
if (parent === directory) return undefined;
|
|
325
|
+
directory = parent;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function workspacePackages(root: string, patterns: readonly string[]): Promise<readonly WorkspacePackage[]> {
|
|
330
|
+
const include = patterns
|
|
331
|
+
.filter(pattern => !pattern.startsWith('!'))
|
|
332
|
+
.map(packageManifestPattern)
|
|
333
|
+
.filter(pattern => pattern.length > 0);
|
|
334
|
+
const exclude = patterns
|
|
335
|
+
.filter(pattern => pattern.startsWith('!'))
|
|
336
|
+
.map(pattern => packageManifestPattern(pattern.slice(1)))
|
|
337
|
+
.filter(pattern => pattern.length > 0);
|
|
338
|
+
if (include.length === 0) return [];
|
|
339
|
+
|
|
340
|
+
const rootPath = await realpath(root);
|
|
341
|
+
const packages = new Map<string, WorkspacePackage>();
|
|
342
|
+
for await (const manifestPath of glob(include, {
|
|
343
|
+
cwd: root,
|
|
344
|
+
...(exclude.length === 0 ? {} : { exclude }),
|
|
345
|
+
})) {
|
|
346
|
+
const lexicalPath = resolve(root, dirname(manifestPath));
|
|
347
|
+
const packagePath = await realpath(lexicalPath);
|
|
348
|
+
if (!inside(rootPath, packagePath)) continue;
|
|
349
|
+
const manifest = await readJson(resolve(packagePath, 'package.json'));
|
|
350
|
+
const relativePath = relative(rootPath, packagePath).split(sep).join('/');
|
|
351
|
+
const named = stringProperty(manifest, 'name');
|
|
352
|
+
packages.set(packagePath, {
|
|
353
|
+
name: named ?? relativePath,
|
|
354
|
+
path: packagePath,
|
|
355
|
+
relativePath,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return [...packages.values()].toSorted((left, right) => {
|
|
359
|
+
const byName = left.name.localeCompare(right.name);
|
|
360
|
+
return byName === 0 ? left.relativePath.localeCompare(right.relativePath) : byName;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function packageManifestPattern(pattern: string): string {
|
|
365
|
+
const trimmed = pattern.trim().replaceAll('\\', '/').replaceAll(/\/+$/g, '');
|
|
366
|
+
if (trimmed.length === 0) return '';
|
|
367
|
+
return trimmed.endsWith('package.json') ? trimmed : `${trimmed}/package.json`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function workspacePatterns(manifest: unknown): readonly string[] | undefined {
|
|
371
|
+
if (!record(manifest)) return undefined;
|
|
372
|
+
const workspaces = Reflect.get(manifest, 'workspaces');
|
|
373
|
+
if (stringArray(workspaces)) return workspaces;
|
|
374
|
+
if (!record(workspaces)) return undefined;
|
|
375
|
+
const packages = Reflect.get(workspaces, 'packages');
|
|
376
|
+
return stringArray(packages) ? packages : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function readPnpmWorkspace(directory: string): Promise<readonly string[] | undefined> {
|
|
380
|
+
const path = resolve(directory, 'pnpm-workspace.yaml');
|
|
381
|
+
let text: string;
|
|
382
|
+
try {
|
|
383
|
+
text = await readFile(path, 'utf8');
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (errorCode(error) === 'ENOENT') return undefined;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const lines = text.split(/\r?\n/);
|
|
390
|
+
const patterns: string[] = [];
|
|
391
|
+
let packageIndent: number | undefined;
|
|
392
|
+
for (const line of lines) {
|
|
393
|
+
const packages = /^(\s*)packages:\s*(.*)$/.exec(line);
|
|
394
|
+
if (packages !== null) {
|
|
395
|
+
packageIndent = packages[1]?.length ?? 0;
|
|
396
|
+
patterns.push(...quotedValues(packages[2] ?? ''));
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (packageIndent === undefined || /^\s*(?:#.*)?$/.test(line)) continue;
|
|
400
|
+
const indent = /^\s*/.exec(line)?.[0].length ?? 0;
|
|
401
|
+
if (indent <= packageIndent) break;
|
|
402
|
+
const item = /^\s*-\s*(.+?)\s*$/.exec(line);
|
|
403
|
+
if (item !== null) {
|
|
404
|
+
const value = unquote(stripYamlComment(item[1] ?? ''));
|
|
405
|
+
if (value.length > 0) patterns.push(value);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return packageIndent === undefined ? undefined : patterns;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function quotedValues(value: string): readonly string[] {
|
|
412
|
+
const matches = value.matchAll(/['"]([^'"]+)['"]/g);
|
|
413
|
+
return [...matches].map(match => match[1] ?? '').filter(item => item.length > 0);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function stripYamlComment(value: string): string {
|
|
417
|
+
const comment = value.indexOf(' #');
|
|
418
|
+
return comment === -1 ? value.trim() : value.slice(0, comment).trim();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function unquote(value: string): string {
|
|
422
|
+
if (value.length < 2) return value;
|
|
423
|
+
const first = value[0];
|
|
424
|
+
const last = value.at(-1);
|
|
425
|
+
return (first === "'" && last === "'") || (first === '"' && last === '"') ? value.slice(1, -1) : value;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function nearestPackageRoot(cwd: string): Promise<string | undefined> {
|
|
429
|
+
let directory = cwd;
|
|
430
|
+
while (true) {
|
|
431
|
+
if (existsSync(resolve(directory, 'package.json'))) return directory;
|
|
432
|
+
const parent = dirname(directory);
|
|
433
|
+
if (parent === directory) return undefined;
|
|
434
|
+
directory = parent;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function inside(root: string, candidate: string): boolean {
|
|
439
|
+
const path = relative(root, candidate);
|
|
440
|
+
return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function readJson(path: string): Promise<unknown> {
|
|
444
|
+
try {
|
|
445
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (errorCode(error) === 'ENOENT') return undefined;
|
|
448
|
+
throw new ScaffoldUsageError(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
449
|
+
cause: error,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function record(value: unknown): value is object {
|
|
455
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function stringArray(value: unknown): value is readonly string[] {
|
|
459
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function stringProperty(value: unknown, key: string): string | undefined {
|
|
463
|
+
if (!record(value)) return undefined;
|
|
464
|
+
const property = Reflect.get(value, key);
|
|
465
|
+
return typeof property === 'string' && property.length > 0 ? property : undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function errorCode(error: unknown): string | undefined {
|
|
469
|
+
if (!record(error)) return undefined;
|
|
470
|
+
const code = Reflect.get(error, 'code');
|
|
471
|
+
return typeof code === 'string' ? code : undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function plural(count: number, word: string): string {
|
|
475
|
+
return `${String(count)} ${word}${count === 1 ? '' : 's'}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function packageVersion(): string {
|
|
479
|
+
const manifest: unknown = JSON.parse(
|
|
480
|
+
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
|
481
|
+
);
|
|
482
|
+
const version = stringProperty(manifest, 'version');
|
|
483
|
+
if (version === undefined) throw new TypeError('zmdb package.json has no version');
|
|
484
|
+
return version;
|
|
485
|
+
}
|