@wneng/create-keel 0.1.2 → 0.2.0
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 +37 -1
- package/dist/index.js +798 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/templates/docs-skeleton/files/usage-quickstart.md +2 -0
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/version.ts
|
|
4
|
-
var SCAFFOLDER_VERSION = "0.
|
|
4
|
+
var SCAFFOLDER_VERSION = "0.2.0";
|
|
5
5
|
|
|
6
6
|
// src/schema/options.ts
|
|
7
7
|
import Ajv from "ajv";
|
|
@@ -151,6 +151,189 @@ function formatError(err) {
|
|
|
151
151
|
\u5EFA\u8BAE\uFF1A\u8BF7\u5728 issue \u4E2D\u9644\u4E0A\u5B8C\u6574\u4E0A\u4E0B\u6587`;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// src/feature/layers.ts
|
|
155
|
+
var FEATURE_LAYERS = ["pm", "arch", "backend", "frontend", "test"];
|
|
156
|
+
var FEATURE_SLUG_PATTERN = PROJECT_NAME_PATTERN;
|
|
157
|
+
var FEATURE_SLUG_PATTERN_SOURCE = "^[a-z0-9][a-z0-9-]{0,62}$";
|
|
158
|
+
function isValidFeatureSlug(value) {
|
|
159
|
+
return FEATURE_SLUG_PATTERN.test(value);
|
|
160
|
+
}
|
|
161
|
+
function isFeatureLayer(value) {
|
|
162
|
+
return FEATURE_LAYERS.includes(value);
|
|
163
|
+
}
|
|
164
|
+
var LAYER_ARTIFACTS = {
|
|
165
|
+
pm: {
|
|
166
|
+
layer: "pm",
|
|
167
|
+
label: "PRD (docs/01-\u80CC\u666F\u4E0E\u9700\u6C42)",
|
|
168
|
+
placeholderTemplate: "pm/prd.md.eta",
|
|
169
|
+
outputPath: (slug) => `docs/01-\u80CC\u666F\u4E0E\u9700\u6C42/prd-${slug}.md`
|
|
170
|
+
},
|
|
171
|
+
arch: {
|
|
172
|
+
layer: "arch",
|
|
173
|
+
label: "ADR (docs/02-\u7CFB\u7EDF\u65B9\u6848\u4E0E\u67B6\u6784)",
|
|
174
|
+
placeholderTemplate: "arch/adr.md.eta",
|
|
175
|
+
// ADR file names also embed a 4-digit sequence number; the planner
|
|
176
|
+
// resolves that prefix at scaffold time and prepends it. The slug-only
|
|
177
|
+
// suffix is what this function returns; callers that need the full
|
|
178
|
+
// path must compose the prefix.
|
|
179
|
+
outputPath: (slug) => `docs/02-\u7CFB\u7EDF\u65B9\u6848\u4E0E\u67B6\u6784/adr-${slug}.md`
|
|
180
|
+
},
|
|
181
|
+
backend: {
|
|
182
|
+
layer: "backend",
|
|
183
|
+
label: "backend design (docs/04-\u540E\u7AEF\u8BE6\u7EC6\u8BBE\u8BA1)",
|
|
184
|
+
placeholderTemplate: "backend/design.md.eta",
|
|
185
|
+
outputPath: (slug) => `docs/04-\u540E\u7AEF\u8BE6\u7EC6\u8BBE\u8BA1/${slug}.md`
|
|
186
|
+
},
|
|
187
|
+
frontend: {
|
|
188
|
+
layer: "frontend",
|
|
189
|
+
label: "frontend design (docs/05-\u524D\u7AEF\u5BA2\u6237\u7AEF\u8BE6\u7EC6\u8BBE\u8BA1)",
|
|
190
|
+
placeholderTemplate: "frontend/design.md.eta",
|
|
191
|
+
outputPath: (slug) => `docs/05-\u524D\u7AEF\u5BA2\u6237\u7AEF\u8BE6\u7EC6\u8BBE\u8BA1/${slug}-web.md`
|
|
192
|
+
},
|
|
193
|
+
test: {
|
|
194
|
+
layer: "test",
|
|
195
|
+
label: "test plan (docs/07-\u8D28\u91CF\u4E0E\u6D4B\u8BD5/test-plans)",
|
|
196
|
+
placeholderTemplate: "test/test-plan.md.eta",
|
|
197
|
+
outputPath: (slug) => `docs/07-\u8D28\u91CF\u4E0E\u6D4B\u8BD5/test-plans/${slug}.md`
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
var LAYER_REQUIRED_ROLE = {
|
|
201
|
+
test: "qa"
|
|
202
|
+
};
|
|
203
|
+
function layerEnabledByRoles(layer, roles) {
|
|
204
|
+
const required = LAYER_REQUIRED_ROLE[layer];
|
|
205
|
+
if (required === void 0) return true;
|
|
206
|
+
return roles.includes(required);
|
|
207
|
+
}
|
|
208
|
+
function resolveLayers(requested, roles) {
|
|
209
|
+
const requestedSet = requested === void 0 || requested === "all" ? new Set(FEATURE_LAYERS) : new Set(requested);
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const layer of FEATURE_LAYERS) {
|
|
212
|
+
if (!requestedSet.has(layer)) continue;
|
|
213
|
+
if (!layerEnabledByRoles(layer, roles)) continue;
|
|
214
|
+
out.push(layer);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
function layersDroppedByRoles(requested, roles) {
|
|
219
|
+
const requestedSet = requested === void 0 || requested === "all" ? new Set(FEATURE_LAYERS) : new Set(requested);
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const layer of FEATURE_LAYERS) {
|
|
222
|
+
if (!requestedSet.has(layer)) continue;
|
|
223
|
+
if (layerEnabledByRoles(layer, roles)) continue;
|
|
224
|
+
out.push(layer);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/feature/subcommand.ts
|
|
230
|
+
var ACCEPTED_LAYER_VALUES = [...FEATURE_LAYERS, "all"];
|
|
231
|
+
function parseFeatureAdd(args) {
|
|
232
|
+
let slug;
|
|
233
|
+
let layer;
|
|
234
|
+
let force = false;
|
|
235
|
+
let quiet = false;
|
|
236
|
+
let verbose = false;
|
|
237
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
238
|
+
const a = args[i];
|
|
239
|
+
switch (a) {
|
|
240
|
+
case "--force":
|
|
241
|
+
force = true;
|
|
242
|
+
break;
|
|
243
|
+
case "--quiet":
|
|
244
|
+
quiet = true;
|
|
245
|
+
break;
|
|
246
|
+
case "--verbose":
|
|
247
|
+
verbose = true;
|
|
248
|
+
break;
|
|
249
|
+
case "--layer": {
|
|
250
|
+
const next = args[i + 1];
|
|
251
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
252
|
+
throw new UserInputError(
|
|
253
|
+
"--layer requires a comma-separated list",
|
|
254
|
+
`\u793A\u4F8B\uFF1A--layer pm,backend,frontend\uFF1B\u53EF\u9009\u503C\uFF1A${ACCEPTED_LAYER_VALUES.join(",")}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
layer = parseLayerArg(next);
|
|
258
|
+
i += 1;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
default: {
|
|
262
|
+
if (a.startsWith("--")) {
|
|
263
|
+
throw new UserInputError(
|
|
264
|
+
`unknown option: ${a}`,
|
|
265
|
+
"\u67E5\u770B\u53EF\u7528\u9009\u9879\uFF1Acreate-keel feature add --help"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (slug === void 0) {
|
|
269
|
+
slug = a;
|
|
270
|
+
} else {
|
|
271
|
+
throw new UserInputError(
|
|
272
|
+
`unexpected extra argument: ${JSON.stringify(a)}`,
|
|
273
|
+
"feature add \u6BCF\u6B21\u53EA\u80FD\u5904\u7406\u4E00\u4E2A slug"
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (slug === void 0) {
|
|
280
|
+
throw new UserInputError(
|
|
281
|
+
"missing <slug>",
|
|
282
|
+
"\u8BF7\u63D0\u4F9B feature slug\uFF0C\u4F8B\u5982\uFF1Acreate-keel feature add user-signup"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (!isValidFeatureSlug(slug)) {
|
|
286
|
+
throw new UserInputError(
|
|
287
|
+
`invalid feature slug: ${JSON.stringify(slug)}`,
|
|
288
|
+
`slug \u5E94\u5339\u914D ${FEATURE_SLUG_PATTERN_SOURCE}\uFF0C\u4F8B\u5982\uFF1Auser-signup`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const flags = {
|
|
292
|
+
force,
|
|
293
|
+
quiet,
|
|
294
|
+
verbose,
|
|
295
|
+
...layer !== void 0 ? { layer } : {}
|
|
296
|
+
};
|
|
297
|
+
return { slug, flags };
|
|
298
|
+
}
|
|
299
|
+
function parseLayerArg(raw) {
|
|
300
|
+
const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
301
|
+
if (parts.length === 0) {
|
|
302
|
+
throw new UserInputError(
|
|
303
|
+
`invalid --layer value: ${JSON.stringify(raw)}`,
|
|
304
|
+
`\u53EF\u9009\u503C\uFF1A${ACCEPTED_LAYER_VALUES.join(",")}`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (parts.includes("all")) {
|
|
308
|
+
if (parts.length > 1) {
|
|
309
|
+
throw new UserInputError(
|
|
310
|
+
`--layer all cannot be combined with other layer names`,
|
|
311
|
+
`\u4F20 'all' \u6216\u4E00\u4E2A\u660E\u786E\u5B50\u96C6\uFF0C\u4E0D\u8981\u6DF7\u7528`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return "all";
|
|
315
|
+
}
|
|
316
|
+
const seen = /* @__PURE__ */ new Set();
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const token of parts) {
|
|
319
|
+
if (!isFeatureLayer(token)) {
|
|
320
|
+
throw new UserInputError(
|
|
321
|
+
`invalid --layer value: ${JSON.stringify(token)}`,
|
|
322
|
+
`\u53EF\u9009\u503C\uFF1A${ACCEPTED_LAYER_VALUES.join(",")}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (seen.has(token)) {
|
|
326
|
+
throw new UserInputError(
|
|
327
|
+
`duplicate layer: ${token}`,
|
|
328
|
+
"--layer \u4E2D\u6BCF\u4E2A layer \u4EC5\u80FD\u51FA\u73B0\u4E00\u6B21"
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
seen.add(token);
|
|
332
|
+
out.push(token);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
154
337
|
// src/cli.ts
|
|
155
338
|
async function run(io) {
|
|
156
339
|
const args = io.argv.slice(2);
|
|
@@ -167,12 +350,15 @@ async function run(io) {
|
|
|
167
350
|
return 0;
|
|
168
351
|
}
|
|
169
352
|
const [command, ...rest] = args;
|
|
353
|
+
if (command === "feature") {
|
|
354
|
+
return await dispatchFeature(io, rest);
|
|
355
|
+
}
|
|
170
356
|
if (command !== "create") {
|
|
171
357
|
io.stderr(
|
|
172
358
|
formatError(
|
|
173
359
|
new UserInputError(
|
|
174
360
|
`unknown command: ${JSON.stringify(command)}`,
|
|
175
|
-
"\u53EF\u7528\u7684\u547D\u4EE4\uFF1Acreate\u3002\u67E5\u770B\u5E2E\u52A9\uFF1A--help"
|
|
361
|
+
"\u53EF\u7528\u7684\u547D\u4EE4\uFF1Acreate / feature\u3002\u67E5\u770B\u5E2E\u52A9\uFF1A--help"
|
|
176
362
|
)
|
|
177
363
|
)
|
|
178
364
|
);
|
|
@@ -200,6 +386,7 @@ function parseCreateArgs(args) {
|
|
|
200
386
|
let config;
|
|
201
387
|
let ci;
|
|
202
388
|
let roles;
|
|
389
|
+
let sampleFeature = false;
|
|
203
390
|
for (let i = 0; i < args.length; i += 1) {
|
|
204
391
|
const a = args[i];
|
|
205
392
|
switch (a) {
|
|
@@ -218,6 +405,9 @@ function parseCreateArgs(args) {
|
|
|
218
405
|
case "--verbose":
|
|
219
406
|
verbose = true;
|
|
220
407
|
break;
|
|
408
|
+
case "--sample-feature":
|
|
409
|
+
sampleFeature = true;
|
|
410
|
+
break;
|
|
221
411
|
case "--config": {
|
|
222
412
|
const next = args[i + 1];
|
|
223
413
|
if (next === void 0 || next.startsWith("--")) {
|
|
@@ -309,18 +499,66 @@ function parseCreateArgs(args) {
|
|
|
309
499
|
verbose,
|
|
310
500
|
...config !== void 0 ? { config } : {},
|
|
311
501
|
...ci !== void 0 ? { ci } : {},
|
|
312
|
-
...roles !== void 0 ? { roles } : {}
|
|
502
|
+
...roles !== void 0 ? { roles } : {},
|
|
503
|
+
...sampleFeature ? { sampleFeature: true } : {}
|
|
313
504
|
};
|
|
505
|
+
if (sampleFeature && dryRun) {
|
|
506
|
+
throw new UserInputError(
|
|
507
|
+
`--sample-feature cannot be combined with --dry-run`,
|
|
508
|
+
"\u8BF7\u53BB\u6389\u4E00\u9879\u540E\u91CD\u8BD5"
|
|
509
|
+
);
|
|
510
|
+
}
|
|
314
511
|
return { projectName, flags };
|
|
315
512
|
}
|
|
513
|
+
async function dispatchFeature(io, args) {
|
|
514
|
+
if (args.length === 0 || args.some((a) => a === "-h" || a === "--help")) {
|
|
515
|
+
io.stdout(featureHelpText());
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
const [sub, ...rest] = args;
|
|
519
|
+
if (sub !== "add") {
|
|
520
|
+
io.stderr(
|
|
521
|
+
formatError(
|
|
522
|
+
new UserInputError(
|
|
523
|
+
`unknown feature subcommand: ${JSON.stringify(sub)}`,
|
|
524
|
+
"\u5F53\u524D\u53EF\u7528\uFF1Afeature add <slug>"
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
);
|
|
528
|
+
return 1;
|
|
529
|
+
}
|
|
530
|
+
if (io.onFeatureAdd === void 0) {
|
|
531
|
+
io.stderr(
|
|
532
|
+
formatError(
|
|
533
|
+
new UserInputError(
|
|
534
|
+
`feature add is not wired in this entry point`,
|
|
535
|
+
"\u8FD9\u662F\u4E00\u4E2A scaffolder \u5185\u90E8\u9519\u8BEF\uFF1AonFeatureAdd \u672A\u6CE8\u5165"
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
);
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const parsed = parseFeatureAdd(rest);
|
|
543
|
+
await io.onFeatureAdd(parsed);
|
|
544
|
+
return 0;
|
|
545
|
+
} catch (err) {
|
|
546
|
+
io.stderr(formatError(err));
|
|
547
|
+
if (err instanceof ScaffolderError) {
|
|
548
|
+
return err.exitCode;
|
|
549
|
+
}
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
316
553
|
function helpText() {
|
|
317
554
|
return [
|
|
318
555
|
"create-keel \u2014 scaffold a Contract-First project",
|
|
319
556
|
"",
|
|
320
557
|
"USAGE",
|
|
321
558
|
" npx @wneng/create-keel create <project-name> [options]",
|
|
559
|
+
" npx @wneng/create-keel feature add <slug> [options]",
|
|
322
560
|
"",
|
|
323
|
-
"OPTIONS",
|
|
561
|
+
"OPTIONS (create)",
|
|
324
562
|
" --yes Skip interactive prompts and use defaults",
|
|
325
563
|
" --force Overwrite a non-empty target directory after confirmation",
|
|
326
564
|
" --dry-run Compute the plan without writing any files",
|
|
@@ -330,15 +568,42 @@ function helpText() {
|
|
|
330
568
|
" --ci <platform> Which CI platform to scaffold (gitee|github, default: gitee)",
|
|
331
569
|
" --roles <list> Comma-separated role directories to scaffold",
|
|
332
570
|
" (" + ROLE_KINDS.join("|") + "; default: none)",
|
|
571
|
+
" --sample-feature After create, scaffold the user-signup sample feature",
|
|
333
572
|
" -h, --help Show this help",
|
|
334
573
|
" -v, --version Show the scaffolder version",
|
|
335
574
|
"",
|
|
336
|
-
"
|
|
575
|
+
"OPTIONS (feature add)",
|
|
576
|
+
" --layer <list> Comma-separated layers to scaffold",
|
|
577
|
+
" (pm|arch|backend|frontend|test|all; default: all)",
|
|
578
|
+
" --force Overwrite existing Layer_Files for the requested layers",
|
|
579
|
+
" --quiet Only emit errors and the final summary",
|
|
580
|
+
" --verbose Emit per-file render/write logs",
|
|
581
|
+
"",
|
|
582
|
+
"See .kiro/specs/feature-subcommand/ for the full specification."
|
|
583
|
+
].join("\n");
|
|
584
|
+
}
|
|
585
|
+
function featureHelpText() {
|
|
586
|
+
return [
|
|
587
|
+
"create-keel feature \u2014 manage features inside an existing keel project",
|
|
588
|
+
"",
|
|
589
|
+
"USAGE",
|
|
590
|
+
" npx @wneng/create-keel feature add <slug> [options]",
|
|
591
|
+
"",
|
|
592
|
+
"OPTIONS",
|
|
593
|
+
" --layer <list> Comma-separated layers (pm|arch|backend|frontend|test|all)",
|
|
594
|
+
" --force Overwrite existing Layer_Files",
|
|
595
|
+
" --quiet Errors and summary only",
|
|
596
|
+
" --verbose Per-file render/write logs",
|
|
597
|
+
"",
|
|
598
|
+
"EXAMPLES",
|
|
599
|
+
" feature add user-signup",
|
|
600
|
+
" feature add billing-portal --layer pm,backend",
|
|
601
|
+
" feature add legacy-import --force"
|
|
337
602
|
].join("\n");
|
|
338
603
|
}
|
|
339
604
|
|
|
340
605
|
// src/orchestrator.ts
|
|
341
|
-
import * as
|
|
606
|
+
import * as path6 from "node:path";
|
|
342
607
|
|
|
343
608
|
// src/input/defaults.ts
|
|
344
609
|
var OPTION_DEFAULTS = {
|
|
@@ -399,8 +664,8 @@ var validate = ajv2.compile(
|
|
|
399
664
|
function formatIssues() {
|
|
400
665
|
const errs = validate.errors ?? [];
|
|
401
666
|
return errs.map((e) => {
|
|
402
|
-
const
|
|
403
|
-
return `${
|
|
667
|
+
const path10 = e.instancePath || (e.params && "missingProperty" in e.params ? `/${String(e.params.missingProperty)}` : "<root>");
|
|
668
|
+
return `${path10}: ${e.message ?? "validation error"}`;
|
|
404
669
|
}).join("; ");
|
|
405
670
|
}
|
|
406
671
|
async function loadConfigFile(filePath) {
|
|
@@ -897,9 +1162,21 @@ function buildRenderContext(input) {
|
|
|
897
1162
|
options: input.options,
|
|
898
1163
|
year: String(now.getUTCFullYear()),
|
|
899
1164
|
generatedAt: now.toISOString(),
|
|
900
|
-
scaffolderVersion: input.scaffolderVersion
|
|
1165
|
+
scaffolderVersion: input.scaffolderVersion,
|
|
1166
|
+
...input.feature !== void 0 ? { feature: input.feature } : {}
|
|
901
1167
|
};
|
|
902
1168
|
}
|
|
1169
|
+
function buildFeatureContext(slug, nextAdrNumber) {
|
|
1170
|
+
return {
|
|
1171
|
+
slug,
|
|
1172
|
+
title: slugToTitle(slug),
|
|
1173
|
+
nextAdrNumber,
|
|
1174
|
+
contractAnchor: `contracts/openapi/api.yaml#/paths/~1${slug}`
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function slugToTitle(slug) {
|
|
1178
|
+
return slug.split("-").filter((s) => s.length > 0).map((s) => s[0].toUpperCase() + s.slice(1)).join(" ");
|
|
1179
|
+
}
|
|
903
1180
|
|
|
904
1181
|
// src/plan/builder.ts
|
|
905
1182
|
import { promises as fs4 } from "node:fs";
|
|
@@ -923,7 +1200,11 @@ function toStrictContext(ctx) {
|
|
|
923
1200
|
options: ctx.options,
|
|
924
1201
|
year: ctx.year,
|
|
925
1202
|
generatedAt: ctx.generatedAt,
|
|
926
|
-
scaffolderVersion: ctx.scaffolderVersion
|
|
1203
|
+
scaffolderVersion: ctx.scaffolderVersion,
|
|
1204
|
+
// `feature` is intentionally only attached when the orchestrator
|
|
1205
|
+
// populated it; non-feature templates that try to read it will hit
|
|
1206
|
+
// the proxy's "undefined template variable" branch.
|
|
1207
|
+
...ctx.feature !== void 0 ? { feature: ctx.feature } : {}
|
|
927
1208
|
};
|
|
928
1209
|
return new Proxy(base, {
|
|
929
1210
|
get(target, prop, receiver) {
|
|
@@ -1072,9 +1353,12 @@ function lowerExt(p) {
|
|
|
1072
1353
|
// src/schema/metadata.ts
|
|
1073
1354
|
import Ajv4 from "ajv";
|
|
1074
1355
|
import addFormats2 from "ajv-formats";
|
|
1356
|
+
import { promises as fs5 } from "node:fs";
|
|
1357
|
+
import * as path4 from "node:path";
|
|
1075
1358
|
var METADATA_FILENAME = ".scaffolder.json";
|
|
1076
1359
|
var SEMVER_PATTERN2 = "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-.]+)?(?:\\+[0-9A-Za-z-.]+)?$";
|
|
1077
1360
|
var FRAGMENT_NAME_PATTERN2 = "^[a-z0-9][a-z0-9-]{0,62}$";
|
|
1361
|
+
var FEATURE_SLUG_PATTERN2 = "^[a-z0-9][a-z0-9-]{0,62}$";
|
|
1078
1362
|
var METADATA_SCHEMA = {
|
|
1079
1363
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1080
1364
|
$id: "https://cgsy.example.com/schemas/scaffolder/metadata.json",
|
|
@@ -1097,17 +1381,48 @@ var METADATA_SCHEMA = {
|
|
|
1097
1381
|
version: { type: "string", pattern: SEMVER_PATTERN2 }
|
|
1098
1382
|
}
|
|
1099
1383
|
}
|
|
1384
|
+
},
|
|
1385
|
+
features: {
|
|
1386
|
+
type: "array",
|
|
1387
|
+
items: { type: "string", pattern: FEATURE_SLUG_PATTERN2 },
|
|
1388
|
+
uniqueItems: true
|
|
1100
1389
|
}
|
|
1101
1390
|
}
|
|
1102
1391
|
};
|
|
1103
1392
|
var ajv4 = new Ajv4({ allErrors: true, strict: true });
|
|
1104
1393
|
addFormats2(ajv4);
|
|
1105
1394
|
var validateFn3 = ajv4.compile(METADATA_SCHEMA);
|
|
1395
|
+
var MetadataValidationError = class extends Error {
|
|
1396
|
+
issues;
|
|
1397
|
+
constructor(issues) {
|
|
1398
|
+
super(
|
|
1399
|
+
`Invalid ScaffolderMetadata:
|
|
1400
|
+
` + issues.map((i) => ` - ${i.path || "<root>"}: ${i.message}`).join("\n")
|
|
1401
|
+
);
|
|
1402
|
+
this.name = "MetadataValidationError";
|
|
1403
|
+
this.issues = issues;
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
function collectIssues3() {
|
|
1407
|
+
const errs = validateFn3.errors ?? [];
|
|
1408
|
+
return errs.map((e) => ({
|
|
1409
|
+
path: e.instancePath || (e.params && "missingProperty" in e.params ? `/${String(e.params.missingProperty)}` : ""),
|
|
1410
|
+
message: e.message ?? "validation error"
|
|
1411
|
+
}));
|
|
1412
|
+
}
|
|
1413
|
+
function assertMetadata(value) {
|
|
1414
|
+
if (!validateFn3(value)) {
|
|
1415
|
+
throw new MetadataValidationError(collectIssues3());
|
|
1416
|
+
}
|
|
1417
|
+
assertOptions(value.options);
|
|
1418
|
+
return value;
|
|
1419
|
+
}
|
|
1106
1420
|
var METADATA_TOP_ORDER = [
|
|
1107
1421
|
"scaffolderVersion",
|
|
1108
1422
|
"generatedAt",
|
|
1109
1423
|
"options",
|
|
1110
|
-
"templateFragments"
|
|
1424
|
+
"templateFragments",
|
|
1425
|
+
"features"
|
|
1111
1426
|
];
|
|
1112
1427
|
function canonicalOptions(options) {
|
|
1113
1428
|
const out = {};
|
|
@@ -1126,6 +1441,11 @@ function canonicalMetadata(metadata) {
|
|
|
1126
1441
|
out[key] = canonicalOptions(metadata.options);
|
|
1127
1442
|
} else if (key === "templateFragments") {
|
|
1128
1443
|
out[key] = metadata.templateFragments.map(canonicalFragmentRef);
|
|
1444
|
+
} else if (key === "features") {
|
|
1445
|
+
const features = metadata.features;
|
|
1446
|
+
if (features !== void 0 && features.length > 0) {
|
|
1447
|
+
out[key] = [...features];
|
|
1448
|
+
}
|
|
1129
1449
|
} else {
|
|
1130
1450
|
out[key] = metadata[key];
|
|
1131
1451
|
}
|
|
@@ -1135,13 +1455,35 @@ function canonicalMetadata(metadata) {
|
|
|
1135
1455
|
function serializeMetadata(metadata) {
|
|
1136
1456
|
return JSON.stringify(canonicalMetadata(metadata), null, 2) + "\n";
|
|
1137
1457
|
}
|
|
1458
|
+
function parseMetadata(input) {
|
|
1459
|
+
let raw;
|
|
1460
|
+
try {
|
|
1461
|
+
raw = JSON.parse(input);
|
|
1462
|
+
} catch (e) {
|
|
1463
|
+
throw new MetadataValidationError([
|
|
1464
|
+
{ path: "", message: `invalid JSON: ${e.message}` }
|
|
1465
|
+
]);
|
|
1466
|
+
}
|
|
1467
|
+
return assertMetadata(raw);
|
|
1468
|
+
}
|
|
1469
|
+
async function writeMetadataFile(targetDirectory, metadata) {
|
|
1470
|
+
const filePath = path4.join(targetDirectory, METADATA_FILENAME);
|
|
1471
|
+
await fs5.writeFile(filePath, serializeMetadata(metadata), "utf8");
|
|
1472
|
+
return filePath;
|
|
1473
|
+
}
|
|
1474
|
+
async function loadMetadataFile(targetDirectory) {
|
|
1475
|
+
const filePath = path4.join(targetDirectory, METADATA_FILENAME);
|
|
1476
|
+
const text = await fs5.readFile(filePath, "utf8");
|
|
1477
|
+
return parseMetadata(text);
|
|
1478
|
+
}
|
|
1138
1479
|
function buildMetadata(params) {
|
|
1139
1480
|
const now = params.now ?? /* @__PURE__ */ new Date();
|
|
1140
1481
|
return {
|
|
1141
1482
|
scaffolderVersion: params.scaffolderVersion,
|
|
1142
1483
|
generatedAt: now.toISOString(),
|
|
1143
1484
|
options: params.options,
|
|
1144
|
-
templateFragments: [...params.templateFragments]
|
|
1485
|
+
templateFragments: [...params.templateFragments],
|
|
1486
|
+
...params.features !== void 0 ? { features: [...params.features] } : {}
|
|
1145
1487
|
};
|
|
1146
1488
|
}
|
|
1147
1489
|
|
|
@@ -1165,14 +1507,14 @@ function appendScaffolderMetadata(input) {
|
|
|
1165
1507
|
}
|
|
1166
1508
|
|
|
1167
1509
|
// src/writer/writer.ts
|
|
1168
|
-
import { promises as
|
|
1169
|
-
import * as
|
|
1510
|
+
import { promises as fs6 } from "node:fs";
|
|
1511
|
+
import * as path5 from "node:path";
|
|
1170
1512
|
var FORCE_BACKUP_BYTE_LIMIT = 10 * 1024 * 1024;
|
|
1171
1513
|
async function writePlan(plan, opts) {
|
|
1172
|
-
const absTarget =
|
|
1514
|
+
const absTarget = path5.resolve(opts.targetDirectory);
|
|
1173
1515
|
const journal = { createdFiles: [], createdDirectories: [], overwrites: [] };
|
|
1174
1516
|
const targetState = await inspectTarget(absTarget);
|
|
1175
|
-
if (targetState.exists && targetState.nonEmpty && !opts.force) {
|
|
1517
|
+
if (targetState.exists && targetState.nonEmpty && !opts.force && !opts.allowNonEmptyTarget) {
|
|
1176
1518
|
throw new UserInputError(
|
|
1177
1519
|
`target directory is not empty: ${absTarget}`,
|
|
1178
1520
|
"\u4F7F\u7528\u4E0D\u540C\u8DEF\u5F84\uFF0C\u6216\u52A0 --force\uFF08\u5C0F\u5FC3\uFF1A\u4F1A\u8986\u76D6\u65E2\u6709\u6587\u4EF6\uFF09"
|
|
@@ -1183,13 +1525,13 @@ async function writePlan(plan, opts) {
|
|
|
1183
1525
|
}
|
|
1184
1526
|
try {
|
|
1185
1527
|
if (!targetState.exists) {
|
|
1186
|
-
await
|
|
1528
|
+
await fs6.mkdir(absTarget, { recursive: true });
|
|
1187
1529
|
journal.createdDirectories.push(absTarget);
|
|
1188
1530
|
}
|
|
1189
1531
|
for (const rel of plan.directories) {
|
|
1190
|
-
const abs =
|
|
1532
|
+
const abs = path5.join(absTarget, rel);
|
|
1191
1533
|
try {
|
|
1192
|
-
await
|
|
1534
|
+
await fs6.mkdir(abs);
|
|
1193
1535
|
journal.createdDirectories.push(abs);
|
|
1194
1536
|
} catch (err) {
|
|
1195
1537
|
if (err.code === "EEXIST") {
|
|
@@ -1199,18 +1541,18 @@ async function writePlan(plan, opts) {
|
|
|
1199
1541
|
}
|
|
1200
1542
|
}
|
|
1201
1543
|
for (const file of plan.files) {
|
|
1202
|
-
const abs =
|
|
1203
|
-
await
|
|
1544
|
+
const abs = path5.join(absTarget, file.targetPath);
|
|
1545
|
+
await fs6.mkdir(path5.dirname(abs), { recursive: true });
|
|
1204
1546
|
if (opts.force) {
|
|
1205
1547
|
try {
|
|
1206
|
-
const prev = await
|
|
1548
|
+
const prev = await fs6.readFile(abs, "utf8");
|
|
1207
1549
|
journal.overwrites.push({ absPath: abs, originalContent: prev });
|
|
1208
1550
|
} catch {
|
|
1209
1551
|
}
|
|
1210
1552
|
}
|
|
1211
|
-
await
|
|
1553
|
+
await fs6.writeFile(abs, file.content, "utf8");
|
|
1212
1554
|
if (file.mode !== void 0) {
|
|
1213
|
-
await
|
|
1555
|
+
await fs6.chmod(abs, file.mode);
|
|
1214
1556
|
}
|
|
1215
1557
|
journal.createdFiles.push(abs);
|
|
1216
1558
|
}
|
|
@@ -1230,7 +1572,7 @@ async function writePlan(plan, opts) {
|
|
|
1230
1572
|
}
|
|
1231
1573
|
async function inspectTarget(absTarget) {
|
|
1232
1574
|
try {
|
|
1233
|
-
const entries = await
|
|
1575
|
+
const entries = await fs6.readdir(absTarget);
|
|
1234
1576
|
return { exists: true, nonEmpty: entries.length > 0 };
|
|
1235
1577
|
} catch (err) {
|
|
1236
1578
|
const code = err.code;
|
|
@@ -1243,9 +1585,9 @@ async function inspectTarget(absTarget) {
|
|
|
1243
1585
|
}
|
|
1244
1586
|
async function preflightBackupCapacity(absTarget, plan) {
|
|
1245
1587
|
for (const file of plan.files) {
|
|
1246
|
-
const abs =
|
|
1588
|
+
const abs = path5.join(absTarget, file.targetPath);
|
|
1247
1589
|
try {
|
|
1248
|
-
const stat = await
|
|
1590
|
+
const stat = await fs6.stat(abs);
|
|
1249
1591
|
if (stat.isFile() && stat.size > FORCE_BACKUP_BYTE_LIMIT) {
|
|
1250
1592
|
throw new UserInputError(
|
|
1251
1593
|
`--force would overwrite ${abs} which is larger than the 10 MB rollback limit`,
|
|
@@ -1266,7 +1608,7 @@ async function rollback(journal) {
|
|
|
1266
1608
|
for (let i = journal.overwrites.length - 1; i >= 0; i -= 1) {
|
|
1267
1609
|
const { absPath, originalContent } = journal.overwrites[i];
|
|
1268
1610
|
try {
|
|
1269
|
-
await
|
|
1611
|
+
await fs6.writeFile(absPath, originalContent, "utf8");
|
|
1270
1612
|
} catch {
|
|
1271
1613
|
}
|
|
1272
1614
|
}
|
|
@@ -1275,14 +1617,14 @@ async function rollback(journal) {
|
|
|
1275
1617
|
const p = journal.createdFiles[i];
|
|
1276
1618
|
if (overwriteSet.has(p)) continue;
|
|
1277
1619
|
try {
|
|
1278
|
-
await
|
|
1620
|
+
await fs6.rm(p, { force: true });
|
|
1279
1621
|
} catch {
|
|
1280
1622
|
}
|
|
1281
1623
|
}
|
|
1282
1624
|
for (let i = journal.createdDirectories.length - 1; i >= 0; i -= 1) {
|
|
1283
1625
|
const dir = journal.createdDirectories[i];
|
|
1284
1626
|
try {
|
|
1285
|
-
await
|
|
1627
|
+
await fs6.rmdir(dir);
|
|
1286
1628
|
} catch {
|
|
1287
1629
|
}
|
|
1288
1630
|
}
|
|
@@ -1390,7 +1732,7 @@ async function runCreate(input) {
|
|
|
1390
1732
|
validatePlanSyntax(plan);
|
|
1391
1733
|
reporter.info(`planned ${plan.files.length} file(s) across ${plan.directories.length} directory`);
|
|
1392
1734
|
const cwd = input.io.cwd ?? process.cwd();
|
|
1393
|
-
const targetDirectory =
|
|
1735
|
+
const targetDirectory = path6.resolve(cwd, options.projectName);
|
|
1394
1736
|
if (input.flags.dryRun) {
|
|
1395
1737
|
reporter.stage("dry-run");
|
|
1396
1738
|
reporter.dryRunReport(plan);
|
|
@@ -1449,9 +1791,409 @@ async function resolveOptions(input) {
|
|
|
1449
1791
|
return assertOptions(prompted);
|
|
1450
1792
|
}
|
|
1451
1793
|
|
|
1794
|
+
// src/feature/orchestrator.ts
|
|
1795
|
+
import { promises as fs8 } from "node:fs";
|
|
1796
|
+
import * as path9 from "node:path";
|
|
1797
|
+
|
|
1798
|
+
// src/feature/plan.ts
|
|
1799
|
+
import { promises as fs7, accessSync as accessSync2 } from "node:fs";
|
|
1800
|
+
import * as path7 from "node:path";
|
|
1801
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1802
|
+
|
|
1803
|
+
// src/feature/openapi-edit.ts
|
|
1804
|
+
import { isMap, isScalar, parseDocument, YAMLMap } from "yaml";
|
|
1805
|
+
function addPathStub(input) {
|
|
1806
|
+
const insertedPath = input.path ?? `/${input.slug}`;
|
|
1807
|
+
const doc = parseAndValidate(input.currentText);
|
|
1808
|
+
const pathsNode = doc.get("paths", true);
|
|
1809
|
+
let pathsMap;
|
|
1810
|
+
if (pathsNode === void 0 || pathsNode === null || isScalar(pathsNode) && pathsNode.value === null) {
|
|
1811
|
+
pathsMap = new YAMLMap();
|
|
1812
|
+
pathsMap.flow = false;
|
|
1813
|
+
doc.set("paths", pathsMap);
|
|
1814
|
+
} else if (isMap(pathsNode)) {
|
|
1815
|
+
pathsMap = pathsNode;
|
|
1816
|
+
if (pathsMap.items.length === 0 && pathsMap.flow) {
|
|
1817
|
+
pathsMap.flow = false;
|
|
1818
|
+
}
|
|
1819
|
+
} else {
|
|
1820
|
+
throw new TemplateError(
|
|
1821
|
+
`contracts/openapi/api.yaml: 'paths' is present but is not a map`,
|
|
1822
|
+
"\u8BF7\u628A paths \u5B57\u6BB5\u5199\u6210 YAML \u6620\u5C04\uFF08\u4F8B\u5982 paths: {}\uFF09"
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
if (pathsMap.has(insertedPath)) {
|
|
1826
|
+
return {
|
|
1827
|
+
nextText: input.currentText,
|
|
1828
|
+
insertedPath,
|
|
1829
|
+
alreadyExisted: true
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
const operationKey = input.operation ?? "get";
|
|
1833
|
+
const responses = input.responses ?? { "200": { description: "OK" } };
|
|
1834
|
+
const operationBody = {
|
|
1835
|
+
summary: input.title,
|
|
1836
|
+
operationId: slugToOperationId(input.slug),
|
|
1837
|
+
...input.requestBody !== void 0 ? { requestBody: input.requestBody } : {},
|
|
1838
|
+
responses
|
|
1839
|
+
};
|
|
1840
|
+
const stub = doc.createNode({
|
|
1841
|
+
[operationKey]: operationBody
|
|
1842
|
+
});
|
|
1843
|
+
pathsMap.set(insertedPath, stub);
|
|
1844
|
+
const nextText = serializeDocument(doc);
|
|
1845
|
+
return {
|
|
1846
|
+
nextText,
|
|
1847
|
+
insertedPath,
|
|
1848
|
+
alreadyExisted: false
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
function parseAndValidate(currentText) {
|
|
1852
|
+
const doc = parseDocument(currentText, {
|
|
1853
|
+
// keep node identity stable across parse → toString
|
|
1854
|
+
keepSourceTokens: true
|
|
1855
|
+
});
|
|
1856
|
+
if (doc.errors.length > 0) {
|
|
1857
|
+
const first = doc.errors[0];
|
|
1858
|
+
throw yamlSyntaxError(first);
|
|
1859
|
+
}
|
|
1860
|
+
if (doc.contents === null) {
|
|
1861
|
+
throw new TemplateError(
|
|
1862
|
+
`contracts/openapi/api.yaml is empty; an OpenAPI 3.x document is required`,
|
|
1863
|
+
"\u8BF7\u4FDD\u7559\u81F3\u5C11 openapi/info/paths \u4E09\u4E2A\u9876\u5C42\u5B57\u6BB5"
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
if (!isMap(doc.contents)) {
|
|
1867
|
+
throw new TemplateError(
|
|
1868
|
+
`contracts/openapi/api.yaml root must be a YAML mapping`,
|
|
1869
|
+
"\u8BF7\u786E\u8BA4\u6587\u4EF6\u4EE5 openapi: ... \u5F00\u5934\uFF0C\u6839\u662F\u4E00\u4E2A\u6620\u5C04"
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
return doc;
|
|
1873
|
+
}
|
|
1874
|
+
function serializeDocument(doc) {
|
|
1875
|
+
return doc.toString({
|
|
1876
|
+
lineWidth: 0,
|
|
1877
|
+
indent: 2
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
function yamlSyntaxError(err) {
|
|
1881
|
+
const linePos = err.linePos;
|
|
1882
|
+
const where = linePos !== void 0 ? `line ${linePos[0].line}, column ${linePos[0].col}` : `offset ${err.pos[0]}`;
|
|
1883
|
+
return new UserInputError(
|
|
1884
|
+
`contracts/openapi/api.yaml: YAML syntax error at ${where}: ${err.message}`,
|
|
1885
|
+
"\u4FEE\u6B63 YAML \u8BED\u6CD5\u540E\u91CD\u8BD5\uFF0C\u5E38\u89C1\u539F\u56E0\uFF1A\u7F29\u8FDB / \u5F15\u53F7 / \u5192\u53F7\u540E\u7A7A\u683C\u7F3A\u5931"
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
function slugToOperationId(slug) {
|
|
1889
|
+
return slug.replace(/-([a-z0-9])/g, (_m, c) => c.toUpperCase());
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// src/feature/plan.ts
|
|
1893
|
+
async function buildFeaturePlan(input) {
|
|
1894
|
+
const templatesRoot = input.templatesRoot ?? defaultFeatureTemplatesRoot();
|
|
1895
|
+
const sourceRoot = input.source === "sample" ? path7.join(templatesRoot, "sample", input.slug) : templatesRoot;
|
|
1896
|
+
const nextAdrNumber = await computeAdrNumberForSlug(input.cwd, input.slug);
|
|
1897
|
+
const renderContext = buildRenderContext({
|
|
1898
|
+
options: input.options,
|
|
1899
|
+
scaffolderVersion: input.scaffolderVersion,
|
|
1900
|
+
feature: buildFeatureContext(input.slug, nextAdrNumber),
|
|
1901
|
+
...input.now !== void 0 ? { now: input.now } : {}
|
|
1902
|
+
});
|
|
1903
|
+
const files = [];
|
|
1904
|
+
const outputs = /* @__PURE__ */ new Map();
|
|
1905
|
+
for (const layer of input.layers) {
|
|
1906
|
+
const artifact = LAYER_ARTIFACTS[layer];
|
|
1907
|
+
const targetPath = computeTargetPath(layer, input.slug, nextAdrNumber);
|
|
1908
|
+
outputs.set(layer, targetPath);
|
|
1909
|
+
const templatePath = path7.join(sourceRoot, artifact.placeholderTemplate);
|
|
1910
|
+
const content = await renderFeatureTemplate(templatePath, renderContext, layer, input.source);
|
|
1911
|
+
files.push({
|
|
1912
|
+
targetPath,
|
|
1913
|
+
content,
|
|
1914
|
+
contributedBy: `feature:${layer}`
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
let openapiPathInserted;
|
|
1918
|
+
let openapiAlreadyExisted;
|
|
1919
|
+
if (input.layers.includes("backend") && input.options.contract !== "none") {
|
|
1920
|
+
const apiPath = "contracts/openapi/api.yaml";
|
|
1921
|
+
const apiAbs = path7.join(input.cwd, apiPath);
|
|
1922
|
+
let currentText;
|
|
1923
|
+
try {
|
|
1924
|
+
currentText = await fs7.readFile(apiAbs, "utf8");
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
throw new TemplateError(
|
|
1927
|
+
`expected ${apiPath} to exist when contract != 'none' but cannot read it: ${e.message}`,
|
|
1928
|
+
"\u8BF7\u786E\u8BA4 contracts/openapi/api.yaml \u5B58\u5728\uFF1B\u6216\u5C06 .scaffolder.json \u4E2D options.contract \u8BBE\u4E3A none"
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
const editResult = input.source === "sample" && input.slug === "user-signup" ? addPathStub({
|
|
1932
|
+
currentText,
|
|
1933
|
+
slug: input.slug,
|
|
1934
|
+
title: renderContext.feature.title,
|
|
1935
|
+
// The sample feature owns its own canonical path + schema,
|
|
1936
|
+
// satisfying Req 6.4 (request body schema + at least one
|
|
1937
|
+
// response body schema).
|
|
1938
|
+
path: "/users/signup",
|
|
1939
|
+
operation: "post",
|
|
1940
|
+
requestBody: {
|
|
1941
|
+
required: true,
|
|
1942
|
+
content: {
|
|
1943
|
+
"application/json": {
|
|
1944
|
+
schema: {
|
|
1945
|
+
type: "object",
|
|
1946
|
+
required: ["email", "password"],
|
|
1947
|
+
properties: {
|
|
1948
|
+
email: { type: "string", format: "email" },
|
|
1949
|
+
password: { type: "string", minLength: 8 }
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
},
|
|
1955
|
+
responses: {
|
|
1956
|
+
"201": {
|
|
1957
|
+
description: "User created in pending state; verification email sent",
|
|
1958
|
+
content: {
|
|
1959
|
+
"application/json": {
|
|
1960
|
+
schema: {
|
|
1961
|
+
type: "object",
|
|
1962
|
+
required: ["id", "email", "state"],
|
|
1963
|
+
properties: {
|
|
1964
|
+
id: { type: "string", format: "uuid" },
|
|
1965
|
+
email: { type: "string", format: "email" },
|
|
1966
|
+
state: { type: "string", enum: ["pending"] }
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
},
|
|
1972
|
+
"409": {
|
|
1973
|
+
description: "Email already registered (generic, does not leak existence)"
|
|
1974
|
+
},
|
|
1975
|
+
"429": { description: "Rate-limited" }
|
|
1976
|
+
}
|
|
1977
|
+
}) : addPathStub({
|
|
1978
|
+
currentText,
|
|
1979
|
+
slug: input.slug,
|
|
1980
|
+
title: renderContext.feature.title
|
|
1981
|
+
});
|
|
1982
|
+
files.push({
|
|
1983
|
+
targetPath: apiPath,
|
|
1984
|
+
content: editResult.nextText,
|
|
1985
|
+
contributedBy: "feature:openapi"
|
|
1986
|
+
});
|
|
1987
|
+
openapiPathInserted = editResult.insertedPath;
|
|
1988
|
+
openapiAlreadyExisted = editResult.alreadyExisted;
|
|
1989
|
+
}
|
|
1990
|
+
const sortedFiles = [...files].sort(
|
|
1991
|
+
(a, b) => a.targetPath < b.targetPath ? -1 : a.targetPath > b.targetPath ? 1 : 0
|
|
1992
|
+
);
|
|
1993
|
+
const directories = collectDirectories(sortedFiles);
|
|
1994
|
+
return {
|
|
1995
|
+
files: sortedFiles,
|
|
1996
|
+
directories,
|
|
1997
|
+
outputs,
|
|
1998
|
+
...openapiPathInserted !== void 0 ? { openapiPathInserted } : {},
|
|
1999
|
+
...openapiAlreadyExisted !== void 0 ? { openapiAlreadyExisted } : {}
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
function computeTargetPath(layer, slug, nextAdrNumber) {
|
|
2003
|
+
if (layer === "arch") {
|
|
2004
|
+
return `docs/02-\u7CFB\u7EDF\u65B9\u6848\u4E0E\u67B6\u6784/adr-${nextAdrNumber}-${slug}.md`;
|
|
2005
|
+
}
|
|
2006
|
+
return LAYER_ARTIFACTS[layer].outputPath(slug);
|
|
2007
|
+
}
|
|
2008
|
+
async function renderFeatureTemplate(templatePath, context, layer, source) {
|
|
2009
|
+
try {
|
|
2010
|
+
return await renderFile(templatePath, context);
|
|
2011
|
+
} catch (e) {
|
|
2012
|
+
throw new TemplateError(
|
|
2013
|
+
`feature template missing for layer "${layer}" (${source}): ${templatePath}: ${e.message}`,
|
|
2014
|
+
source === "sample" ? `\u8BF7\u786E\u8BA4 src/feature/templates/sample/<slug>/${LAYER_ARTIFACTS[layer].placeholderTemplate} \u5B58\u5728` : "\u8BF7\u786E\u8BA4 src/feature/templates/${layer}/ \u4E0B\u5B58\u5728 " + LAYER_ARTIFACTS[layer].placeholderTemplate
|
|
2015
|
+
);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
var ADR_FILE_RE = /^adr-(\d{4,})-/;
|
|
2019
|
+
async function computeAdrNumberForSlug(cwd, slug) {
|
|
2020
|
+
const dir = path7.join(cwd, "docs", "02-\u7CFB\u7EDF\u65B9\u6848\u4E0E\u67B6\u6784");
|
|
2021
|
+
let entries;
|
|
2022
|
+
try {
|
|
2023
|
+
entries = await fs7.readdir(dir);
|
|
2024
|
+
} catch {
|
|
2025
|
+
return "0001";
|
|
2026
|
+
}
|
|
2027
|
+
const slugRe = new RegExp(`^adr-(\\d{4,})-${escapeRegex(slug)}\\.md$`);
|
|
2028
|
+
let max = 0;
|
|
2029
|
+
for (const name of entries) {
|
|
2030
|
+
const slugMatch = slugRe.exec(name);
|
|
2031
|
+
if (slugMatch) {
|
|
2032
|
+
return slugMatch[1];
|
|
2033
|
+
}
|
|
2034
|
+
const m = ADR_FILE_RE.exec(name);
|
|
2035
|
+
if (m) {
|
|
2036
|
+
const n = parseInt(m[1], 10);
|
|
2037
|
+
if (!Number.isNaN(n) && n > max) max = n;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
return String(max + 1).padStart(4, "0");
|
|
2041
|
+
}
|
|
2042
|
+
function escapeRegex(s) {
|
|
2043
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2044
|
+
}
|
|
2045
|
+
function collectDirectories(files) {
|
|
2046
|
+
const set = /* @__PURE__ */ new Set();
|
|
2047
|
+
for (const f of files) {
|
|
2048
|
+
let cur = path7.posix.dirname(f.targetPath);
|
|
2049
|
+
while (cur && cur !== "." && cur !== "/") {
|
|
2050
|
+
set.add(cur);
|
|
2051
|
+
cur = path7.posix.dirname(cur);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
return [...set].sort();
|
|
2055
|
+
}
|
|
2056
|
+
function defaultFeatureTemplatesRoot() {
|
|
2057
|
+
const here = path7.dirname(fileURLToPath2(import.meta.url));
|
|
2058
|
+
const pkgRoot = findPackageRoot2(here);
|
|
2059
|
+
return path7.join(pkgRoot, "src", "feature", "templates");
|
|
2060
|
+
}
|
|
2061
|
+
function findPackageRoot2(start) {
|
|
2062
|
+
let dir = start;
|
|
2063
|
+
for (let i = 0; i < 10; i += 1) {
|
|
2064
|
+
const candidate = path7.join(dir, "package.json");
|
|
2065
|
+
try {
|
|
2066
|
+
accessSync2(candidate);
|
|
2067
|
+
return dir;
|
|
2068
|
+
} catch {
|
|
2069
|
+
}
|
|
2070
|
+
const parent = path7.dirname(dir);
|
|
2071
|
+
if (parent === dir) break;
|
|
2072
|
+
dir = parent;
|
|
2073
|
+
}
|
|
2074
|
+
throw new TemplateError(
|
|
2075
|
+
`unable to locate package.json above ${start}`,
|
|
2076
|
+
"\u8BF7\u786E\u8BA4 scaffolder \u5B89\u88C5\u5B8C\u6574\uFF0Csrc/feature/templates/ \u4E0E package.json \u4F4D\u4E8E\u540C\u4E00\u5C42\u7EA7"
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// src/feature/manifest-update.ts
|
|
2081
|
+
import * as path8 from "node:path";
|
|
2082
|
+
async function appendFeatureSlug(projectDirectory, slug) {
|
|
2083
|
+
const manifest = await loadMetadataFile(projectDirectory);
|
|
2084
|
+
const next = withFeatureSlug(manifest, slug);
|
|
2085
|
+
const metadataPath = path8.join(projectDirectory, METADATA_FILENAME);
|
|
2086
|
+
if (next === manifest) {
|
|
2087
|
+
return { updated: false, metadataPath };
|
|
2088
|
+
}
|
|
2089
|
+
await writeMetadataFile(projectDirectory, next);
|
|
2090
|
+
return { updated: true, metadataPath };
|
|
2091
|
+
}
|
|
2092
|
+
function withFeatureSlug(manifest, slug) {
|
|
2093
|
+
const existing = manifest.features ?? [];
|
|
2094
|
+
if (existing.includes(slug)) {
|
|
2095
|
+
return manifest;
|
|
2096
|
+
}
|
|
2097
|
+
return {
|
|
2098
|
+
...manifest,
|
|
2099
|
+
features: [...existing, slug]
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// src/feature/orchestrator.ts
|
|
2104
|
+
async function runFeatureAdd(input) {
|
|
2105
|
+
if (!isValidFeatureSlug(input.slug)) {
|
|
2106
|
+
throw new UserInputError(
|
|
2107
|
+
`invalid feature slug: ${JSON.stringify(input.slug)}`,
|
|
2108
|
+
"slug \u5E94\u5339\u914D ^[a-z0-9][a-z0-9-]{0,62}$\uFF0C\u4F8B\u5982\uFF1Auser-signup"
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
let manifest;
|
|
2112
|
+
try {
|
|
2113
|
+
manifest = await loadMetadataFile(input.cwd);
|
|
2114
|
+
} catch (e) {
|
|
2115
|
+
const code = e.code;
|
|
2116
|
+
if (code === "ENOENT") {
|
|
2117
|
+
throw new UserInputError(
|
|
2118
|
+
`no .scaffolder.json found at ${path9.join(input.cwd, METADATA_FILENAME)}`,
|
|
2119
|
+
"\u8BF7\u5728 keel \u9879\u76EE\u6839\u76EE\u5F55\u8FD0\u884C\uFF1B\u6216\u5148\u7528 create-keel create \u521B\u5EFA\u9879\u76EE"
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
throw e;
|
|
2123
|
+
}
|
|
2124
|
+
const requested = input.layers ?? "all";
|
|
2125
|
+
const resolved = resolveLayers(requested, manifest.options.roles);
|
|
2126
|
+
const droppedByRoles = layersDroppedByRoles(requested, manifest.options.roles);
|
|
2127
|
+
const featurePlan = await buildFeaturePlan({
|
|
2128
|
+
slug: input.slug,
|
|
2129
|
+
layers: resolved,
|
|
2130
|
+
source: input.source,
|
|
2131
|
+
options: manifest.options,
|
|
2132
|
+
cwd: input.cwd,
|
|
2133
|
+
scaffolderVersion: input.scaffolderVersion,
|
|
2134
|
+
...input.templatesRoot !== void 0 ? { templatesRoot: input.templatesRoot } : {},
|
|
2135
|
+
...input.now !== void 0 ? { now: input.now } : {}
|
|
2136
|
+
});
|
|
2137
|
+
const { toWrite, skipped } = await splitPlanByExistence(featurePlan, input.cwd, input.force);
|
|
2138
|
+
if (toWrite.files.length > 0) {
|
|
2139
|
+
await writePlan(toWrite, {
|
|
2140
|
+
targetDirectory: input.cwd,
|
|
2141
|
+
force: input.force,
|
|
2142
|
+
allowNonEmptyTarget: true
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
const anchorsInserted = [];
|
|
2146
|
+
if (featurePlan.openapiPathInserted !== void 0 && featurePlan.openapiAlreadyExisted === false && toWrite.files.some((f) => f.targetPath === "contracts/openapi/api.yaml")) {
|
|
2147
|
+
anchorsInserted.push({
|
|
2148
|
+
fromFile: "contracts/openapi/api.yaml",
|
|
2149
|
+
toTarget: featurePlan.openapiPathInserted
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
const manifestResult = await appendFeatureSlug(input.cwd, input.slug);
|
|
2153
|
+
return {
|
|
2154
|
+
slug: input.slug,
|
|
2155
|
+
created: toWrite.files.map((f) => f.targetPath),
|
|
2156
|
+
skipped,
|
|
2157
|
+
droppedByRoles,
|
|
2158
|
+
anchorsInserted,
|
|
2159
|
+
manifestUpdated: manifestResult.updated,
|
|
2160
|
+
metadataPath: manifestResult.metadataPath
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
async function splitPlanByExistence(plan, cwd, force) {
|
|
2164
|
+
if (force) {
|
|
2165
|
+
return {
|
|
2166
|
+
toWrite: { files: plan.files, directories: plan.directories },
|
|
2167
|
+
skipped: []
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
const toWrite = [];
|
|
2171
|
+
const skipped = [];
|
|
2172
|
+
for (const file of plan.files) {
|
|
2173
|
+
const exists = await fileExists(path9.join(cwd, file.targetPath));
|
|
2174
|
+
if (exists) {
|
|
2175
|
+
skipped.push(file.targetPath);
|
|
2176
|
+
} else {
|
|
2177
|
+
toWrite.push(file);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
return {
|
|
2181
|
+
toWrite: { files: toWrite, directories: plan.directories },
|
|
2182
|
+
skipped
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
async function fileExists(absPath) {
|
|
2186
|
+
try {
|
|
2187
|
+
await fs8.access(absPath);
|
|
2188
|
+
return true;
|
|
2189
|
+
} catch {
|
|
2190
|
+
return false;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
1452
2194
|
// src/index.ts
|
|
1453
2195
|
async function handleCreate(input) {
|
|
1454
|
-
await runCreate({
|
|
2196
|
+
const result = await runCreate({
|
|
1455
2197
|
projectName: input.projectName,
|
|
1456
2198
|
flags: input.flags,
|
|
1457
2199
|
io: {
|
|
@@ -1464,6 +2206,28 @@ async function handleCreate(input) {
|
|
|
1464
2206
|
isTTY: Boolean(process.stdout.isTTY)
|
|
1465
2207
|
}
|
|
1466
2208
|
});
|
|
2209
|
+
if (input.flags.sampleFeature === true && !result.dryRun) {
|
|
2210
|
+
await runFeatureAdd({
|
|
2211
|
+
slug: "user-signup",
|
|
2212
|
+
cwd: result.targetDirectory,
|
|
2213
|
+
source: "sample",
|
|
2214
|
+
// Force is safe here: nothing pre-existing in a brand new project,
|
|
2215
|
+
// so the writer's nonEmpty check is the only thing to bypass.
|
|
2216
|
+
force: true,
|
|
2217
|
+
scaffolderVersion: SCAFFOLDER_VERSION
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
async function handleFeatureAdd(input) {
|
|
2222
|
+
const cwd = process.cwd();
|
|
2223
|
+
await runFeatureAdd({
|
|
2224
|
+
slug: input.slug,
|
|
2225
|
+
cwd,
|
|
2226
|
+
...input.flags.layer !== void 0 ? { layers: input.flags.layer } : {},
|
|
2227
|
+
source: "placeholder",
|
|
2228
|
+
force: input.flags.force,
|
|
2229
|
+
scaffolderVersion: SCAFFOLDER_VERSION
|
|
2230
|
+
});
|
|
1467
2231
|
}
|
|
1468
2232
|
var exitCode = await run({
|
|
1469
2233
|
argv: process.argv,
|
|
@@ -1473,7 +2237,8 @@ var exitCode = await run({
|
|
|
1473
2237
|
stderr: (line) => {
|
|
1474
2238
|
console.error(line);
|
|
1475
2239
|
},
|
|
1476
|
-
onCreate: handleCreate
|
|
2240
|
+
onCreate: handleCreate,
|
|
2241
|
+
onFeatureAdd: handleFeatureAdd
|
|
1477
2242
|
});
|
|
1478
2243
|
process.exit(exitCode);
|
|
1479
2244
|
//# sourceMappingURL=index.js.map
|