@canonical/summon 0.1.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 +439 -0
- package/generators/example/hello/index.ts +132 -0
- package/generators/example/hello/templates/README.md.ejs +20 -0
- package/generators/example/hello/templates/index.ts.ejs +9 -0
- package/generators/example/webapp/index.ts +509 -0
- package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
- package/generators/example/webapp/templates/App.tsx.ejs +86 -0
- package/generators/example/webapp/templates/README.md.ejs +154 -0
- package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
- package/generators/example/webapp/templates/app.ts.ejs +132 -0
- package/generators/example/webapp/templates/feature.ts.ejs +264 -0
- package/generators/example/webapp/templates/index.html.ejs +20 -0
- package/generators/example/webapp/templates/main.tsx.ejs +43 -0
- package/generators/example/webapp/templates/styles.css.ejs +135 -0
- package/generators/init/index.ts +124 -0
- package/generators/init/templates/generator.ts.ejs +85 -0
- package/generators/init/templates/template-index.ts.ejs +9 -0
- package/generators/init/templates/template-test.ts.ejs +8 -0
- package/package.json +64 -0
- package/src/__tests__/combinators.test.ts +895 -0
- package/src/__tests__/dry-run.test.ts +927 -0
- package/src/__tests__/effect.test.ts +816 -0
- package/src/__tests__/interpreter.test.ts +673 -0
- package/src/__tests__/primitives.test.ts +970 -0
- package/src/__tests__/task.test.ts +929 -0
- package/src/__tests__/template.test.ts +666 -0
- package/src/cli-format.ts +165 -0
- package/src/cli-types.ts +53 -0
- package/src/cli.tsx +1322 -0
- package/src/combinators.ts +294 -0
- package/src/completion.ts +488 -0
- package/src/components/App.tsx +960 -0
- package/src/components/ExecutionProgress.tsx +205 -0
- package/src/components/FileTreePreview.tsx +97 -0
- package/src/components/PromptSequence.tsx +483 -0
- package/src/components/Spinner.tsx +36 -0
- package/src/components/index.ts +16 -0
- package/src/dry-run.ts +434 -0
- package/src/effect.ts +224 -0
- package/src/index.ts +266 -0
- package/src/interpreter.ts +463 -0
- package/src/primitives.ts +442 -0
- package/src/task.ts +245 -0
- package/src/template.ts +537 -0
- package/src/types.ts +453 -0
package/src/cli.tsx
ADDED
|
@@ -0,0 +1,1322 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Summon CLI
|
|
5
|
+
*
|
|
6
|
+
* A monadic task-centric code generator framework.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
import { Command } from "commander";
|
|
13
|
+
import { render } from "ink";
|
|
14
|
+
import {
|
|
15
|
+
type CompletionNode,
|
|
16
|
+
handleSetupRequest,
|
|
17
|
+
initCompletion,
|
|
18
|
+
isCompletionRequest,
|
|
19
|
+
isSetupRequest,
|
|
20
|
+
} from "./completion.js";
|
|
21
|
+
import { App } from "./components/App.js";
|
|
22
|
+
import type { StampConfig } from "./interpreter.js";
|
|
23
|
+
import type { GeneratorDefinition, PromptDefinition } from "./types.js";
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Generator Discovery (Tree Structure)
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/** Origin of a generator (for display purposes) */
|
|
30
|
+
type GeneratorOrigin = "local" | "global" | "package" | "builtin";
|
|
31
|
+
|
|
32
|
+
interface GeneratorNode {
|
|
33
|
+
name: string;
|
|
34
|
+
path: string; // Directory path
|
|
35
|
+
indexPath?: string; // Path to index.ts if this is a runnable generator
|
|
36
|
+
children: Map<string, GeneratorNode>;
|
|
37
|
+
origin?: GeneratorOrigin; // Where this generator came from
|
|
38
|
+
meta?: {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
version: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Merge a child node into a parent, combining children if the topic already exists.
|
|
47
|
+
*/
|
|
48
|
+
const mergeIntoTree = (parent: GeneratorNode, child: GeneratorNode): void => {
|
|
49
|
+
const existing = parent.children.get(child.name);
|
|
50
|
+
if (existing) {
|
|
51
|
+
// Merge children - existing (local) takes precedence for indexPath
|
|
52
|
+
for (const [name, grandchild] of child.children) {
|
|
53
|
+
if (!existing.children.has(name)) {
|
|
54
|
+
existing.children.set(name, grandchild);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Only set indexPath if existing doesn't have one (local takes precedence)
|
|
58
|
+
if (!existing.indexPath && child.indexPath) {
|
|
59
|
+
existing.indexPath = child.indexPath;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
parent.children.set(child.name, child);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a tree of generators from a directory.
|
|
68
|
+
* Supports nested structure like:
|
|
69
|
+
* generators/component/react/index.ts -> summon component react
|
|
70
|
+
* generators/component/svelte/index.ts -> summon component svelte
|
|
71
|
+
* generators/util/index.ts -> summon util
|
|
72
|
+
*/
|
|
73
|
+
const buildGeneratorTree = async (
|
|
74
|
+
dir: string,
|
|
75
|
+
node: GeneratorNode,
|
|
76
|
+
origin: GeneratorOrigin = "local",
|
|
77
|
+
): Promise<void> => {
|
|
78
|
+
try {
|
|
79
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
80
|
+
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
const childDir = path.join(dir, entry.name);
|
|
84
|
+
const indexPath = path.join(childDir, "index.ts");
|
|
85
|
+
|
|
86
|
+
const childNode: GeneratorNode = {
|
|
87
|
+
name: entry.name,
|
|
88
|
+
path: childDir,
|
|
89
|
+
children: new Map(),
|
|
90
|
+
origin,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Check if this directory has an index.ts (is a runnable generator)
|
|
94
|
+
try {
|
|
95
|
+
await fs.access(indexPath);
|
|
96
|
+
childNode.indexPath = indexPath;
|
|
97
|
+
} catch {
|
|
98
|
+
// No index.ts, might still have children
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Recursively discover children
|
|
102
|
+
await buildGeneratorTree(childDir, childNode, origin);
|
|
103
|
+
|
|
104
|
+
// Only add node if it has an index.ts or has children with generators
|
|
105
|
+
if (childNode.indexPath || childNode.children.size > 0) {
|
|
106
|
+
mergeIntoTree(node, childNode);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Directory doesn't exist
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract topic name from a summon package name.
|
|
117
|
+
* summon-component -> component
|
|
118
|
+
* @scope/summon-component -> component
|
|
119
|
+
*/
|
|
120
|
+
const _extractTopicFromPackageName = (pkgName: string): string | null => {
|
|
121
|
+
// Handle scoped packages: @scope/summon-topic
|
|
122
|
+
const scopedMatch = pkgName.match(/^@[^/]+\/summon-(.+)$/);
|
|
123
|
+
if (scopedMatch) return scopedMatch[1];
|
|
124
|
+
|
|
125
|
+
// Handle unscoped packages: summon-topic
|
|
126
|
+
const unscopedMatch = pkgName.match(/^summon-(.+)$/);
|
|
127
|
+
if (unscopedMatch) return unscopedMatch[1];
|
|
128
|
+
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if a path is a directory (follows symlinks).
|
|
134
|
+
*/
|
|
135
|
+
const isDirectory = async (filePath: string): Promise<boolean> => {
|
|
136
|
+
try {
|
|
137
|
+
const stat = await fs.stat(filePath); // stat follows symlinks
|
|
138
|
+
return stat.isDirectory();
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Discover summon-* packages in node_modules.
|
|
146
|
+
*/
|
|
147
|
+
const discoverNodeModulesPackages = async (
|
|
148
|
+
nodeModulesDir: string,
|
|
149
|
+
root: GeneratorNode,
|
|
150
|
+
): Promise<void> => {
|
|
151
|
+
try {
|
|
152
|
+
const entries = await fs.readdir(nodeModulesDir);
|
|
153
|
+
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
const entryPath = path.join(nodeModulesDir, entry);
|
|
156
|
+
|
|
157
|
+
if (entry.startsWith("@")) {
|
|
158
|
+
// Scoped packages - look inside @scope/
|
|
159
|
+
if (!(await isDirectory(entryPath))) continue;
|
|
160
|
+
try {
|
|
161
|
+
const scopedEntries = await fs.readdir(entryPath);
|
|
162
|
+
for (const scopedEntry of scopedEntries) {
|
|
163
|
+
if (scopedEntry.startsWith("summon-")) {
|
|
164
|
+
const pkgDir = path.join(entryPath, scopedEntry);
|
|
165
|
+
if (await isDirectory(pkgDir)) {
|
|
166
|
+
await processPackage(`${entry}/${scopedEntry}`, pkgDir, root);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Scope directory doesn't exist or can't be read
|
|
172
|
+
}
|
|
173
|
+
} else if (entry.startsWith("summon-")) {
|
|
174
|
+
// Unscoped summon-* package
|
|
175
|
+
if (await isDirectory(entryPath)) {
|
|
176
|
+
await processPackage(entry, entryPath, root);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// node_modules doesn't exist
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Process a potential summon package.
|
|
187
|
+
*/
|
|
188
|
+
/**
|
|
189
|
+
* Generator cache - stores generators loaded from package barrels.
|
|
190
|
+
* Key is the command path (e.g., "component/react").
|
|
191
|
+
*/
|
|
192
|
+
const generatorCache = new Map<string, GeneratorDefinition>();
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Insert a generator into the tree at the given path.
|
|
196
|
+
* Creates intermediate namespace nodes as needed.
|
|
197
|
+
*/
|
|
198
|
+
const insertGeneratorAtPath = (
|
|
199
|
+
root: GeneratorNode,
|
|
200
|
+
pathStr: string,
|
|
201
|
+
generator: GeneratorDefinition,
|
|
202
|
+
origin: GeneratorOrigin = "package",
|
|
203
|
+
): void => {
|
|
204
|
+
const segments = pathStr.split("/").filter(Boolean);
|
|
205
|
+
let current = root;
|
|
206
|
+
|
|
207
|
+
// Cache the generator for later lookup
|
|
208
|
+
generatorCache.set(pathStr, generator);
|
|
209
|
+
|
|
210
|
+
for (let i = 0; i < segments.length; i++) {
|
|
211
|
+
const segment = segments[i];
|
|
212
|
+
const isLast = i === segments.length - 1;
|
|
213
|
+
|
|
214
|
+
if (!current.children.has(segment)) {
|
|
215
|
+
current.children.set(segment, {
|
|
216
|
+
name: segment,
|
|
217
|
+
path: "",
|
|
218
|
+
children: new Map(),
|
|
219
|
+
origin,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const child = current.children.get(segment);
|
|
224
|
+
if (!child) continue; // Should never happen since we just set it
|
|
225
|
+
|
|
226
|
+
if (isLast) {
|
|
227
|
+
// Mark as having a generator (use path as synthetic indexPath)
|
|
228
|
+
child.indexPath = `cache:${pathStr}`;
|
|
229
|
+
child.origin = origin;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
current = child;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Process a summon-* package.
|
|
238
|
+
*
|
|
239
|
+
* Imports the package's main entry and looks for a `generators` export
|
|
240
|
+
* mapping command paths to generator definitions.
|
|
241
|
+
*/
|
|
242
|
+
const processPackage = async (
|
|
243
|
+
pkgName: string,
|
|
244
|
+
pkgDir: string,
|
|
245
|
+
root: GeneratorNode,
|
|
246
|
+
origin: GeneratorOrigin = "package",
|
|
247
|
+
): Promise<void> => {
|
|
248
|
+
// Read package.json to get the main entry
|
|
249
|
+
const pkgJsonPath = path.join(pkgDir, "package.json");
|
|
250
|
+
let mainEntry: string | undefined;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, "utf-8"));
|
|
254
|
+
mainEntry = pkgJson.main;
|
|
255
|
+
} catch {
|
|
256
|
+
return; // Can't read package.json
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!mainEntry) return;
|
|
260
|
+
|
|
261
|
+
// Import the package's main entry
|
|
262
|
+
const entryPath = path.join(pkgDir, mainEntry);
|
|
263
|
+
try {
|
|
264
|
+
const module = await import(entryPath);
|
|
265
|
+
const generators =
|
|
266
|
+
module.generators ?? module.default ?? ({} as Record<string, unknown>);
|
|
267
|
+
|
|
268
|
+
// Insert each generator into the tree
|
|
269
|
+
for (const [cmdPath, generator] of Object.entries(generators)) {
|
|
270
|
+
if (generator && typeof generator === "object" && "meta" in generator) {
|
|
271
|
+
insertGeneratorAtPath(
|
|
272
|
+
root,
|
|
273
|
+
cmdPath,
|
|
274
|
+
generator as GeneratorDefinition,
|
|
275
|
+
origin,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(
|
|
281
|
+
chalk.yellow(`Warning: Could not load generators from '${pkgName}':`),
|
|
282
|
+
(err as Error).message,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get bun's global node_modules directory.
|
|
289
|
+
* Default is ~/.bun/install/global/node_modules
|
|
290
|
+
*/
|
|
291
|
+
const getBunGlobalNodeModules = (): string => {
|
|
292
|
+
const bunInstallDir =
|
|
293
|
+
process.env.BUN_INSTALL ?? path.join(process.env.HOME ?? "~", ".bun");
|
|
294
|
+
return path.join(bunInstallDir, "install", "global", "node_modules");
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get npm's global node_modules directory.
|
|
299
|
+
* Uses `npm root -g` equivalent logic.
|
|
300
|
+
*/
|
|
301
|
+
const getNpmGlobalNodeModules = async (): Promise<string | null> => {
|
|
302
|
+
// Check common npm global locations
|
|
303
|
+
const npmPrefix = process.env.NPM_CONFIG_PREFIX;
|
|
304
|
+
if (npmPrefix) {
|
|
305
|
+
return path.join(npmPrefix, "lib", "node_modules");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Try NVM location
|
|
309
|
+
const nvmDir = process.env.NVM_DIR;
|
|
310
|
+
if (nvmDir) {
|
|
311
|
+
// NVM stores globals in the current node version's lib/node_modules
|
|
312
|
+
const nodeVersion = process.version;
|
|
313
|
+
const nvmNodeModules = path.join(
|
|
314
|
+
nvmDir,
|
|
315
|
+
"versions",
|
|
316
|
+
"node",
|
|
317
|
+
nodeVersion,
|
|
318
|
+
"lib",
|
|
319
|
+
"node_modules",
|
|
320
|
+
);
|
|
321
|
+
try {
|
|
322
|
+
await fs.access(nvmNodeModules);
|
|
323
|
+
return nvmNodeModules;
|
|
324
|
+
} catch {
|
|
325
|
+
// NVM path doesn't exist
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Fallback: check common system locations
|
|
330
|
+
const commonPaths = [
|
|
331
|
+
"/usr/local/lib/node_modules",
|
|
332
|
+
"/usr/lib/node_modules",
|
|
333
|
+
path.join(process.env.HOME ?? "~", ".npm-global", "lib", "node_modules"),
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
for (const p of commonPaths) {
|
|
337
|
+
try {
|
|
338
|
+
await fs.access(p);
|
|
339
|
+
return p;
|
|
340
|
+
} catch {
|
|
341
|
+
// Path doesn't exist
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return null;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Scan a node_modules directory for summon-* packages.
|
|
350
|
+
*/
|
|
351
|
+
const scanNodeModulesForSummonPackages = async (
|
|
352
|
+
nodeModulesDir: string,
|
|
353
|
+
root: GeneratorNode,
|
|
354
|
+
origin: GeneratorOrigin = "global",
|
|
355
|
+
): Promise<void> => {
|
|
356
|
+
try {
|
|
357
|
+
await fs.access(nodeModulesDir);
|
|
358
|
+
const entries = await fs.readdir(nodeModulesDir);
|
|
359
|
+
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
const entryPath = path.join(nodeModulesDir, entry);
|
|
362
|
+
|
|
363
|
+
if (entry.startsWith("@")) {
|
|
364
|
+
// Scoped packages
|
|
365
|
+
if (!(await isDirectory(entryPath))) continue;
|
|
366
|
+
try {
|
|
367
|
+
const scopedEntries = await fs.readdir(entryPath);
|
|
368
|
+
for (const scopedEntry of scopedEntries) {
|
|
369
|
+
if (scopedEntry.startsWith("summon-")) {
|
|
370
|
+
const pkgDir = path.join(entryPath, scopedEntry);
|
|
371
|
+
if (await isDirectory(pkgDir)) {
|
|
372
|
+
await processPackage(
|
|
373
|
+
`${entry}/${scopedEntry}`,
|
|
374
|
+
pkgDir,
|
|
375
|
+
root,
|
|
376
|
+
origin,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// Scope directory doesn't exist
|
|
383
|
+
}
|
|
384
|
+
} else if (entry.startsWith("summon-")) {
|
|
385
|
+
if (await isDirectory(entryPath)) {
|
|
386
|
+
await processPackage(entry, entryPath, root, origin);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
// Directory doesn't exist - that's fine
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Discover globally installed summon-* packages.
|
|
397
|
+
* Looks in global package manager locations:
|
|
398
|
+
* 1. Bun global packages (~/.bun/install/global/node_modules)
|
|
399
|
+
* 2. NPM global packages (npm root -g)
|
|
400
|
+
*
|
|
401
|
+
* Users can link packages globally using:
|
|
402
|
+
* bun link # from the package directory
|
|
403
|
+
* npm link # from the package directory
|
|
404
|
+
*/
|
|
405
|
+
const discoverGlobalPackages = async (root: GeneratorNode): Promise<void> => {
|
|
406
|
+
// 1. Bun global packages
|
|
407
|
+
const bunGlobalNodeModules = getBunGlobalNodeModules();
|
|
408
|
+
await scanNodeModulesForSummonPackages(bunGlobalNodeModules, root, "global");
|
|
409
|
+
|
|
410
|
+
// 2. NPM global packages
|
|
411
|
+
const npmGlobalNodeModules = await getNpmGlobalNodeModules();
|
|
412
|
+
if (npmGlobalNodeModules) {
|
|
413
|
+
await scanNodeModulesForSummonPackages(
|
|
414
|
+
npmGlobalNodeModules,
|
|
415
|
+
root,
|
|
416
|
+
"global",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Create the root generator tree from all sources.
|
|
423
|
+
*
|
|
424
|
+
* When `explicitPath` is provided, ONLY load from that path (for testing).
|
|
425
|
+
*
|
|
426
|
+
* Otherwise, priority (highest to lowest, later overrides earlier):
|
|
427
|
+
* 1. Built-in generators from @canonical/summon
|
|
428
|
+
* 2. Global packages (bun link / npm link locations)
|
|
429
|
+
* 3. Project ./node_modules/summon-* packages (highest priority)
|
|
430
|
+
*/
|
|
431
|
+
const discoverGeneratorTree = async (
|
|
432
|
+
explicitPath?: string,
|
|
433
|
+
): Promise<GeneratorNode> => {
|
|
434
|
+
const root: GeneratorNode = {
|
|
435
|
+
name: "root",
|
|
436
|
+
path: "",
|
|
437
|
+
children: new Map(),
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (explicitPath) {
|
|
441
|
+
// Explicit path mode: ONLY load from the specified path
|
|
442
|
+
const absolutePath = path.isAbsolute(explicitPath)
|
|
443
|
+
? explicitPath
|
|
444
|
+
: path.join(process.cwd(), explicitPath);
|
|
445
|
+
|
|
446
|
+
// Check if it's a package with package.json (barrel export)
|
|
447
|
+
const pkgJsonPath = path.join(absolutePath, "package.json");
|
|
448
|
+
try {
|
|
449
|
+
await fs.access(pkgJsonPath);
|
|
450
|
+
// It's a package - use processPackage to load from barrel
|
|
451
|
+
await processPackage(
|
|
452
|
+
path.basename(absolutePath),
|
|
453
|
+
absolutePath,
|
|
454
|
+
root,
|
|
455
|
+
"local",
|
|
456
|
+
);
|
|
457
|
+
} catch {
|
|
458
|
+
// Not a package - scan directory for generators
|
|
459
|
+
await buildGeneratorTree(absolutePath, root, "local");
|
|
460
|
+
}
|
|
461
|
+
return root;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Normal discovery mode (order matters: later sources override earlier)
|
|
465
|
+
|
|
466
|
+
// 1. Built-in generators (lowest priority)
|
|
467
|
+
await buildGeneratorTree(
|
|
468
|
+
path.join(__dirname, "..", "generators"),
|
|
469
|
+
root,
|
|
470
|
+
"builtin",
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// 2-3. Global packages and generators
|
|
474
|
+
await discoverGlobalPackages(root);
|
|
475
|
+
|
|
476
|
+
// 3. node_modules packages (project-level, highest priority)
|
|
477
|
+
await discoverNodeModulesPackages(
|
|
478
|
+
path.join(process.cwd(), "node_modules"),
|
|
479
|
+
root,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
return root;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Navigate the generator tree by path segments.
|
|
487
|
+
* Returns [node, remainingSegments] where node is as deep as we could go.
|
|
488
|
+
*/
|
|
489
|
+
const _navigateTree = (
|
|
490
|
+
root: GeneratorNode,
|
|
491
|
+
segments: string[],
|
|
492
|
+
): [GeneratorNode, string[]] => {
|
|
493
|
+
let current = root;
|
|
494
|
+
let i = 0;
|
|
495
|
+
|
|
496
|
+
for (; i < segments.length; i++) {
|
|
497
|
+
const child = current.children.get(segments[i]);
|
|
498
|
+
if (!child) break;
|
|
499
|
+
current = child;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return [current, segments.slice(i)];
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Load a generator from a path or cache.
|
|
507
|
+
*/
|
|
508
|
+
const loadGenerator = async (
|
|
509
|
+
generatorPath: string,
|
|
510
|
+
): Promise<GeneratorDefinition> => {
|
|
511
|
+
// Check if this is a cached generator from a package barrel
|
|
512
|
+
if (generatorPath.startsWith("cache:")) {
|
|
513
|
+
const cacheKey = generatorPath.slice(6);
|
|
514
|
+
const cached = generatorCache.get(cacheKey);
|
|
515
|
+
if (cached) return cached;
|
|
516
|
+
throw new Error(`Generator not found in cache: ${cacheKey}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const module = await import(generatorPath);
|
|
520
|
+
const generator = module.default ?? module.generator;
|
|
521
|
+
|
|
522
|
+
if (!generator) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`No default export or 'generator' export found in ${generatorPath}`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return generator as GeneratorDefinition;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// =============================================================================
|
|
532
|
+
// CLI Commands
|
|
533
|
+
// =============================================================================
|
|
534
|
+
|
|
535
|
+
const program = new Command();
|
|
536
|
+
|
|
537
|
+
// =============================================================================
|
|
538
|
+
// Display helpers
|
|
539
|
+
// =============================================================================
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Format origin badge for display.
|
|
543
|
+
*/
|
|
544
|
+
const formatOrigin = (origin?: GeneratorOrigin): string => {
|
|
545
|
+
switch (origin) {
|
|
546
|
+
case "local":
|
|
547
|
+
return chalk.green("[local]");
|
|
548
|
+
case "global":
|
|
549
|
+
return chalk.blue("[global]");
|
|
550
|
+
case "package":
|
|
551
|
+
return chalk.magenta("[pkg]");
|
|
552
|
+
case "builtin":
|
|
553
|
+
return chalk.dim("[builtin]");
|
|
554
|
+
default:
|
|
555
|
+
return "";
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Print the available generators/sub-generators at a node.
|
|
561
|
+
*/
|
|
562
|
+
const printNode = (node: GeneratorNode, pathSegments: string[]) => {
|
|
563
|
+
const prefix = pathSegments.length > 0 ? `${pathSegments.join(" ")} ` : "";
|
|
564
|
+
const isRoot = pathSegments.length === 0;
|
|
565
|
+
|
|
566
|
+
if (node.children.size === 0) {
|
|
567
|
+
console.log(chalk.yellow("No generators found"));
|
|
568
|
+
console.log(chalk.dim("\nInstall a generator package:"));
|
|
569
|
+
console.log(chalk.dim(" bun add @scope/summon-<name>"));
|
|
570
|
+
console.log(chalk.dim("\nOr link globally (from the package directory):"));
|
|
571
|
+
console.log(chalk.dim(" bun link # for bun users"));
|
|
572
|
+
console.log(chalk.dim(" npm link # for npm users"));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (isRoot) {
|
|
577
|
+
console.log(chalk.bold("\nAvailable topics:\n"));
|
|
578
|
+
} else {
|
|
579
|
+
console.log(chalk.bold(`\nAvailable under '${pathSegments.join(" ")}':\n`));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
for (const [name, child] of node.children) {
|
|
583
|
+
const hasChildren = child.children.size > 0;
|
|
584
|
+
const isRunnable = !!child.indexPath;
|
|
585
|
+
const originBadge = child.origin ? ` ${formatOrigin(child.origin)}` : "";
|
|
586
|
+
|
|
587
|
+
let suffix = "";
|
|
588
|
+
if (hasChildren && isRunnable) {
|
|
589
|
+
suffix = chalk.dim(" (runnable, has subtopics)");
|
|
590
|
+
} else if (hasChildren) {
|
|
591
|
+
suffix = chalk.dim(" (has subtopics)");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
console.log(chalk.cyan(` ${name}`) + originBadge + suffix);
|
|
595
|
+
|
|
596
|
+
// Show immediate children as hints
|
|
597
|
+
if (hasChildren) {
|
|
598
|
+
const childNames = [...child.children.keys()].slice(0, 5);
|
|
599
|
+
const more =
|
|
600
|
+
child.children.size > 5 ? `, +${child.children.size - 5} more` : "";
|
|
601
|
+
console.log(chalk.dim(` └─ ${childNames.join(", ")}${more}`));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
console.log(chalk.dim(`\nUsage: summon ${prefix}<topic>`));
|
|
606
|
+
console.log();
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Print detailed help for a generator (meta.help and examples).
|
|
611
|
+
*/
|
|
612
|
+
const _printGeneratorHelp = async (
|
|
613
|
+
node: GeneratorNode,
|
|
614
|
+
pathSegments: string[],
|
|
615
|
+
) => {
|
|
616
|
+
if (!node.indexPath) return;
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const generator = await loadGenerator(node.indexPath);
|
|
620
|
+
const { meta, prompts } = generator;
|
|
621
|
+
const commandPath = pathSegments.join(" ");
|
|
622
|
+
|
|
623
|
+
console.log();
|
|
624
|
+
console.log(chalk.bold.cyan(`summon ${commandPath}`));
|
|
625
|
+
console.log(chalk.dim(`v${meta.version}`));
|
|
626
|
+
console.log();
|
|
627
|
+
console.log(meta.description);
|
|
628
|
+
|
|
629
|
+
// Print extended help if available
|
|
630
|
+
if (meta.help) {
|
|
631
|
+
console.log();
|
|
632
|
+
console.log(meta.help);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Print available options (from prompts)
|
|
636
|
+
if (prompts.length > 0) {
|
|
637
|
+
console.log();
|
|
638
|
+
console.log(chalk.bold("Options:"));
|
|
639
|
+
console.log();
|
|
640
|
+
for (const prompt of prompts) {
|
|
641
|
+
const flagName = `--${prompt.name}`;
|
|
642
|
+
const typeHint =
|
|
643
|
+
prompt.type === "confirm"
|
|
644
|
+
? "[boolean]"
|
|
645
|
+
: prompt.type === "select"
|
|
646
|
+
? `[${prompt.choices?.map((c) => c.value).join("|")}]`
|
|
647
|
+
: prompt.type === "multiselect"
|
|
648
|
+
? "[value,value,...]"
|
|
649
|
+
: "<value>";
|
|
650
|
+
|
|
651
|
+
const defaultHint =
|
|
652
|
+
prompt.default !== undefined
|
|
653
|
+
? chalk.dim(` (default: ${JSON.stringify(prompt.default)})`)
|
|
654
|
+
: "";
|
|
655
|
+
|
|
656
|
+
console.log(` ${chalk.cyan(flagName)} ${typeHint}${defaultHint}`);
|
|
657
|
+
console.log(` ${prompt.message}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Print examples if available
|
|
662
|
+
if (meta.examples && meta.examples.length > 0) {
|
|
663
|
+
console.log();
|
|
664
|
+
console.log(chalk.bold("Examples:"));
|
|
665
|
+
console.log();
|
|
666
|
+
for (const example of meta.examples) {
|
|
667
|
+
console.log(` ${chalk.dim("$")} ${example}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Print available subtopics if any
|
|
672
|
+
if (node.children.size > 0) {
|
|
673
|
+
console.log();
|
|
674
|
+
console.log(chalk.bold("Subtopics:"));
|
|
675
|
+
for (const [name, child] of node.children) {
|
|
676
|
+
const desc = child.meta?.description ?? "";
|
|
677
|
+
console.log(` ${chalk.cyan(name)}${desc ? ` - ${desc}` : ""}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
console.log();
|
|
682
|
+
} catch {
|
|
683
|
+
// Couldn't load generator, fall back to basic node printing
|
|
684
|
+
printNode(node, pathSegments);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// =============================================================================
|
|
689
|
+
// Run generator helper
|
|
690
|
+
// =============================================================================
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Check if all required prompts have answers.
|
|
694
|
+
*/
|
|
695
|
+
const hasAllRequiredAnswers = (
|
|
696
|
+
prompts: GeneratorDefinition["prompts"],
|
|
697
|
+
answers: Record<string, unknown>,
|
|
698
|
+
): boolean => {
|
|
699
|
+
for (const prompt of prompts) {
|
|
700
|
+
// Skip prompts with `when` conditions - they may be optional
|
|
701
|
+
if (prompt.when) continue;
|
|
702
|
+
|
|
703
|
+
// Check if we have an answer (including falsy values like false, 0, "")
|
|
704
|
+
if (!(prompt.name in answers) && prompt.default === undefined) {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return true;
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Apply defaults for prompts that don't have answers.
|
|
713
|
+
*/
|
|
714
|
+
const applyDefaults = (
|
|
715
|
+
prompts: GeneratorDefinition["prompts"],
|
|
716
|
+
answers: Record<string, unknown>,
|
|
717
|
+
): Record<string, unknown> => {
|
|
718
|
+
const result = { ...answers };
|
|
719
|
+
for (const prompt of prompts) {
|
|
720
|
+
if (!(prompt.name in result) && prompt.default !== undefined) {
|
|
721
|
+
result[prompt.name] = prompt.default;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return result;
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// =============================================================================
|
|
728
|
+
// CLI Setup
|
|
729
|
+
// =============================================================================
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Build option info for a prompt (flags, description, default).
|
|
733
|
+
*/
|
|
734
|
+
interface OptionInfo {
|
|
735
|
+
flags: string;
|
|
736
|
+
description: string;
|
|
737
|
+
defaultValue?: string;
|
|
738
|
+
group?: string;
|
|
739
|
+
/** The original camelCase prompt name */
|
|
740
|
+
promptName: string;
|
|
741
|
+
/** The kebab-case flag name (without --) */
|
|
742
|
+
kebabName: string;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Convert camelCase to kebab-case.
|
|
747
|
+
* withTests -> with-tests
|
|
748
|
+
* installDeps -> install-deps
|
|
749
|
+
*/
|
|
750
|
+
const toKebabCase = (str: string): string =>
|
|
751
|
+
str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Convert kebab-case to camelCase.
|
|
755
|
+
* with-tests -> withTests
|
|
756
|
+
* install-deps -> installDeps
|
|
757
|
+
*/
|
|
758
|
+
const _toCamelCase = (str: string): string =>
|
|
759
|
+
str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
760
|
+
|
|
761
|
+
const buildOptionInfo = (prompt: PromptDefinition): OptionInfo => {
|
|
762
|
+
const kebabName = toKebabCase(prompt.name);
|
|
763
|
+
const flagName = `--${kebabName}`;
|
|
764
|
+
|
|
765
|
+
// NOTE: We intentionally do NOT pass defaultValue to Commander.
|
|
766
|
+
// Defaults are handled by applyDefaults() after prompting, so we can
|
|
767
|
+
// distinguish between "user didn't provide" vs "user provided default value".
|
|
768
|
+
|
|
769
|
+
switch (prompt.type) {
|
|
770
|
+
case "confirm": {
|
|
771
|
+
const defaultVal = prompt.default === true;
|
|
772
|
+
if (defaultVal) {
|
|
773
|
+
return {
|
|
774
|
+
flags: `--no-${kebabName}`,
|
|
775
|
+
description: `${prompt.message}`,
|
|
776
|
+
group: prompt.group,
|
|
777
|
+
promptName: prompt.name,
|
|
778
|
+
kebabName,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
flags: flagName,
|
|
783
|
+
description: `${prompt.message}`,
|
|
784
|
+
group: prompt.group,
|
|
785
|
+
promptName: prompt.name,
|
|
786
|
+
kebabName,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
case "select": {
|
|
790
|
+
const choices = prompt.choices?.map((c) => c.value).join("|") ?? "";
|
|
791
|
+
return {
|
|
792
|
+
flags: `${flagName} <value>`,
|
|
793
|
+
description: `${prompt.message} [${choices}]`,
|
|
794
|
+
group: prompt.group,
|
|
795
|
+
promptName: prompt.name,
|
|
796
|
+
kebabName,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
case "multiselect": {
|
|
800
|
+
return {
|
|
801
|
+
flags: `${flagName} <values>`,
|
|
802
|
+
description: `${prompt.message} (comma-separated)`,
|
|
803
|
+
group: prompt.group,
|
|
804
|
+
promptName: prompt.name,
|
|
805
|
+
kebabName,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
default: {
|
|
809
|
+
return {
|
|
810
|
+
flags: `${flagName} <value>`,
|
|
811
|
+
description: `${prompt.message}`,
|
|
812
|
+
group: prompt.group,
|
|
813
|
+
promptName: prompt.name,
|
|
814
|
+
kebabName,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Add prompt-based options to a Commander command.
|
|
822
|
+
*/
|
|
823
|
+
const addPromptOptions = (cmd: Command, prompts: PromptDefinition[]): void => {
|
|
824
|
+
for (const prompt of prompts) {
|
|
825
|
+
const info = buildOptionInfo(prompt);
|
|
826
|
+
cmd.option(info.flags, info.description, info.defaultValue);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Configure custom help with grouped options for a command.
|
|
832
|
+
*/
|
|
833
|
+
const configureGroupedHelp = (
|
|
834
|
+
cmd: Command,
|
|
835
|
+
prompts: PromptDefinition[],
|
|
836
|
+
): void => {
|
|
837
|
+
// Collect option info by group
|
|
838
|
+
const groups = new Map<string, OptionInfo[]>();
|
|
839
|
+
const defaultGroupName = "Generator Options";
|
|
840
|
+
|
|
841
|
+
for (const prompt of prompts) {
|
|
842
|
+
const info = buildOptionInfo(prompt);
|
|
843
|
+
const groupName = info.group ?? defaultGroupName;
|
|
844
|
+
if (!groups.has(groupName)) {
|
|
845
|
+
groups.set(groupName, []);
|
|
846
|
+
}
|
|
847
|
+
groups.get(groupName)?.push(info);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Only configure custom help if there are grouped options
|
|
851
|
+
if (groups.size <= 1 && !prompts.some((p) => p.group)) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Override help output using configureHelp
|
|
856
|
+
cmd.configureHelp({
|
|
857
|
+
formatHelp: (cmd, helper) => {
|
|
858
|
+
const termWidth = 28; // Fixed width for option flags column
|
|
859
|
+
|
|
860
|
+
const formatItem = (
|
|
861
|
+
term: string,
|
|
862
|
+
description: string,
|
|
863
|
+
defaultVal?: string,
|
|
864
|
+
): string => {
|
|
865
|
+
const fullDesc = defaultVal
|
|
866
|
+
? `${description} (default: ${JSON.stringify(defaultVal)})`
|
|
867
|
+
: description;
|
|
868
|
+
|
|
869
|
+
if (description) {
|
|
870
|
+
const padding = " ".repeat(Math.max(termWidth - term.length, 2));
|
|
871
|
+
return ` ${term}${padding}${fullDesc}`;
|
|
872
|
+
}
|
|
873
|
+
return ` ${term}`;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
let output = "";
|
|
877
|
+
|
|
878
|
+
// Usage
|
|
879
|
+
output += `Usage: ${helper.commandUsage(cmd)}\n`;
|
|
880
|
+
|
|
881
|
+
// Description
|
|
882
|
+
const desc = helper.commandDescription(cmd);
|
|
883
|
+
if (desc) {
|
|
884
|
+
output += `\n${desc}\n`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Built-in options (Global Options group)
|
|
888
|
+
output += "\nGlobal Options:\n";
|
|
889
|
+
output += formatItem("-d, --dry-run", "Preview without writing files");
|
|
890
|
+
output += "\n";
|
|
891
|
+
output += formatItem("-y, --yes", "Skip confirmation prompts");
|
|
892
|
+
output += "\n";
|
|
893
|
+
output += formatItem("-v, --verbose", "Show debug output");
|
|
894
|
+
output += "\n";
|
|
895
|
+
output += formatItem("--no-preview", "Skip the file preview");
|
|
896
|
+
output += "\n";
|
|
897
|
+
output += formatItem(
|
|
898
|
+
"--no-generated-stamp",
|
|
899
|
+
"Disable generated file stamp comments",
|
|
900
|
+
);
|
|
901
|
+
output += "\n";
|
|
902
|
+
output += formatItem("-h, --help", "display help for command");
|
|
903
|
+
output += "\n";
|
|
904
|
+
|
|
905
|
+
// Grouped prompt options
|
|
906
|
+
for (const [groupName, options] of groups) {
|
|
907
|
+
output += `\n${groupName}:\n`;
|
|
908
|
+
for (const opt of options) {
|
|
909
|
+
output += formatItem(opt.flags, opt.description, opt.defaultValue);
|
|
910
|
+
output += "\n";
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return output;
|
|
915
|
+
},
|
|
916
|
+
});
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Extract answers from Commander options based on prompts.
|
|
921
|
+
* Commander converts kebab-case flags to camelCase option keys automatically,
|
|
922
|
+
* so --with-tests becomes options.withTests.
|
|
923
|
+
*
|
|
924
|
+
* IMPORTANT: For confirm (boolean) prompts with default: true, Commander's
|
|
925
|
+
* --no-X flag pattern means the option is ALWAYS present (true by default,
|
|
926
|
+
* false when --no-X is used). We skip these unless the value differs from
|
|
927
|
+
* the prompt's default, indicating the user explicitly used the flag.
|
|
928
|
+
*/
|
|
929
|
+
const extractAnswers = (
|
|
930
|
+
options: Record<string, unknown>,
|
|
931
|
+
prompts: PromptDefinition[],
|
|
932
|
+
): Record<string, unknown> => {
|
|
933
|
+
const answers: Record<string, unknown> = {};
|
|
934
|
+
|
|
935
|
+
for (const prompt of prompts) {
|
|
936
|
+
// Commander auto-converts kebab-case to camelCase, so we can use the prompt name directly
|
|
937
|
+
const value = options[prompt.name];
|
|
938
|
+
|
|
939
|
+
if (value !== undefined) {
|
|
940
|
+
switch (prompt.type) {
|
|
941
|
+
case "confirm": {
|
|
942
|
+
const boolValue = Boolean(value);
|
|
943
|
+
// For confirm prompts, Commander always sets a value due to --no-X pattern.
|
|
944
|
+
// Only include if the value differs from the prompt's default,
|
|
945
|
+
// which indicates the user explicitly used the flag.
|
|
946
|
+
if (boolValue !== prompt.default) {
|
|
947
|
+
answers[prompt.name] = boolValue;
|
|
948
|
+
}
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case "multiselect":
|
|
952
|
+
answers[prompt.name] =
|
|
953
|
+
typeof value === "string"
|
|
954
|
+
? value.split(",").map((v) => v.trim())
|
|
955
|
+
: value;
|
|
956
|
+
break;
|
|
957
|
+
default:
|
|
958
|
+
answers[prompt.name] = value;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return answers;
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Command Barrel - A flattened representation of all commands to register.
|
|
968
|
+
* This separates command discovery from registration, making the process cleaner.
|
|
969
|
+
*/
|
|
970
|
+
interface CommandEntry {
|
|
971
|
+
/** Path segments to this command (e.g., ["component", "react"]) */
|
|
972
|
+
path: string[];
|
|
973
|
+
/** The generator definition if this is a runnable command */
|
|
974
|
+
generator?: GeneratorDefinition;
|
|
975
|
+
/** Description for namespace-only commands */
|
|
976
|
+
description?: string;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Build a command barrel from a generator tree.
|
|
981
|
+
* This flattens the tree into a list of commands, sorted by depth so parents are created first.
|
|
982
|
+
*/
|
|
983
|
+
const buildCommandBarrel = async (
|
|
984
|
+
node: GeneratorNode,
|
|
985
|
+
pathSegments: string[] = [],
|
|
986
|
+
): Promise<CommandEntry[]> => {
|
|
987
|
+
const entries: CommandEntry[] = [];
|
|
988
|
+
|
|
989
|
+
for (const [name, child] of node.children) {
|
|
990
|
+
const childPath = [...pathSegments, name];
|
|
991
|
+
|
|
992
|
+
if (child.indexPath) {
|
|
993
|
+
// Runnable generator
|
|
994
|
+
try {
|
|
995
|
+
const generator = await loadGenerator(child.indexPath);
|
|
996
|
+
entries.push({ path: childPath, generator });
|
|
997
|
+
|
|
998
|
+
// If it also has children, we need to ensure parent exists and recurse
|
|
999
|
+
if (child.children.size > 0) {
|
|
1000
|
+
const childEntries = await buildCommandBarrel(child, childPath);
|
|
1001
|
+
entries.push(...childEntries);
|
|
1002
|
+
}
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
console.error(
|
|
1005
|
+
chalk.yellow(`Warning: Could not load generator '${name}':`),
|
|
1006
|
+
(err as Error).message,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
} else if (child.children.size > 0) {
|
|
1010
|
+
// Namespace-only (no indexPath but has children)
|
|
1011
|
+
// Add a placeholder entry so we create the parent command
|
|
1012
|
+
entries.push({
|
|
1013
|
+
path: childPath,
|
|
1014
|
+
description: `${name} generators`,
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// Recurse into children
|
|
1018
|
+
const childEntries = await buildCommandBarrel(child, childPath);
|
|
1019
|
+
entries.push(...childEntries);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Sort by path length so parents are registered before children
|
|
1024
|
+
return entries.sort((a, b) => a.path.length - b.path.length);
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Register all commands from a command barrel.
|
|
1029
|
+
* Commands are registered in order (parents before children) using a map to track created commands.
|
|
1030
|
+
*/
|
|
1031
|
+
const registerFromBarrel = (rootCmd: Command, barrel: CommandEntry[]): void => {
|
|
1032
|
+
const commandMap = new Map<string, Command>();
|
|
1033
|
+
commandMap.set("", rootCmd);
|
|
1034
|
+
|
|
1035
|
+
for (const entry of barrel) {
|
|
1036
|
+
const name = entry.path[entry.path.length - 1];
|
|
1037
|
+
const parentPath = entry.path.slice(0, -1).join("/");
|
|
1038
|
+
const currentPath = entry.path.join("/");
|
|
1039
|
+
|
|
1040
|
+
// Skip if already registered (can happen with namespace + runnable at same path)
|
|
1041
|
+
const existingCmd = commandMap.get(currentPath);
|
|
1042
|
+
if (existingCmd) {
|
|
1043
|
+
// But if we now have a generator, update the command
|
|
1044
|
+
if (entry.generator) {
|
|
1045
|
+
configureGeneratorCommand(existingCmd, entry.generator);
|
|
1046
|
+
}
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Get or create parent command
|
|
1051
|
+
const parentCmd = commandMap.get(parentPath) ?? rootCmd;
|
|
1052
|
+
|
|
1053
|
+
if (entry.generator) {
|
|
1054
|
+
// Check if there's a positional prompt
|
|
1055
|
+
const positionalPrompt = entry.generator.prompts.find(
|
|
1056
|
+
(p) => p.positional,
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
// Create runnable generator command
|
|
1060
|
+
// If there's a positional prompt, add it as an optional argument
|
|
1061
|
+
const commandSpec = positionalPrompt
|
|
1062
|
+
? `${name} [${toKebabCase(positionalPrompt.name)}]`
|
|
1063
|
+
: name;
|
|
1064
|
+
|
|
1065
|
+
const cmd = parentCmd
|
|
1066
|
+
.command(commandSpec)
|
|
1067
|
+
.description(entry.generator.meta.description)
|
|
1068
|
+
.option("-d, --dry-run", "Preview without writing files")
|
|
1069
|
+
.option("-y, --yes", "Skip confirmation prompts")
|
|
1070
|
+
.option("-v, --verbose", "Show debug output")
|
|
1071
|
+
.option("--no-preview", "Skip the file preview")
|
|
1072
|
+
.option(
|
|
1073
|
+
"--no-generated-stamp",
|
|
1074
|
+
"Disable generated file stamp comments",
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
// Set custom usage to show positional arg before [options]
|
|
1078
|
+
if (positionalPrompt) {
|
|
1079
|
+
const positionalName = toKebabCase(positionalPrompt.name);
|
|
1080
|
+
cmd.usage(`[${positionalName}] [options]`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
configureGeneratorCommand(cmd, entry.generator, positionalPrompt);
|
|
1084
|
+
commandMap.set(currentPath, cmd);
|
|
1085
|
+
} else {
|
|
1086
|
+
// Create namespace-only command (just a container for subcommands)
|
|
1087
|
+
const cmd = parentCmd
|
|
1088
|
+
.command(name)
|
|
1089
|
+
.description(entry.description ?? `${name} commands`);
|
|
1090
|
+
|
|
1091
|
+
commandMap.set(currentPath, cmd);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Configure a command with generator options and action.
|
|
1098
|
+
*/
|
|
1099
|
+
const configureGeneratorCommand = (
|
|
1100
|
+
cmd: Command,
|
|
1101
|
+
generator: GeneratorDefinition,
|
|
1102
|
+
positionalPrompt?: PromptDefinition,
|
|
1103
|
+
): void => {
|
|
1104
|
+
// Add prompt-based options
|
|
1105
|
+
addPromptOptions(cmd, generator.prompts);
|
|
1106
|
+
|
|
1107
|
+
// Configure grouped help display
|
|
1108
|
+
configureGroupedHelp(cmd, generator.prompts);
|
|
1109
|
+
|
|
1110
|
+
// Add action
|
|
1111
|
+
// If there's a positional prompt, the first argument is the positional value
|
|
1112
|
+
cmd.action(
|
|
1113
|
+
async (
|
|
1114
|
+
positionalArg: string | Record<string, unknown> | undefined,
|
|
1115
|
+
cmdOptions?: Record<string, unknown>,
|
|
1116
|
+
) => {
|
|
1117
|
+
// Commander passes different argument patterns:
|
|
1118
|
+
// - No positional defined: (options)
|
|
1119
|
+
// - Positional defined, value provided: (positionalValue, options)
|
|
1120
|
+
// - Positional defined, no value: (undefined, options)
|
|
1121
|
+
let actualOptions: Record<string, unknown>;
|
|
1122
|
+
|
|
1123
|
+
if (positionalPrompt) {
|
|
1124
|
+
// When there's a positional prompt, cmdOptions is always the second arg
|
|
1125
|
+
actualOptions = cmdOptions ?? {};
|
|
1126
|
+
} else {
|
|
1127
|
+
// When there's no positional prompt, the first arg is options
|
|
1128
|
+
actualOptions = (positionalArg as Record<string, unknown>) ?? {};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Extract only explicitly provided CLI answers (not defaults)
|
|
1132
|
+
const cliAnswers = extractAnswers(actualOptions, generator.prompts);
|
|
1133
|
+
|
|
1134
|
+
// If positional argument was provided, add it to the answers
|
|
1135
|
+
if (
|
|
1136
|
+
positionalPrompt &&
|
|
1137
|
+
typeof positionalArg === "string" &&
|
|
1138
|
+
positionalArg
|
|
1139
|
+
) {
|
|
1140
|
+
cliAnswers[positionalPrompt.name] = positionalArg;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Apply defaults for checking if we have all required answers
|
|
1144
|
+
const answersWithDefaults = applyDefaults(generator.prompts, cliAnswers);
|
|
1145
|
+
|
|
1146
|
+
// Determine execution mode
|
|
1147
|
+
const hasAllAnswers = hasAllRequiredAnswers(
|
|
1148
|
+
generator.prompts,
|
|
1149
|
+
answersWithDefaults,
|
|
1150
|
+
);
|
|
1151
|
+
const isTTY = process.stdin.isTTY === true;
|
|
1152
|
+
const skipPrompts = actualOptions.yes === true;
|
|
1153
|
+
|
|
1154
|
+
// Build stamp config if stamps are enabled (default: enabled)
|
|
1155
|
+
// Commander's --no-X pattern sets generatedStamp to false when --no-generated-stamp is used
|
|
1156
|
+
const stampEnabled = actualOptions.generatedStamp !== false;
|
|
1157
|
+
const stamp: StampConfig | undefined = stampEnabled
|
|
1158
|
+
? {
|
|
1159
|
+
generator: generator.meta.name,
|
|
1160
|
+
version: generator.meta.version,
|
|
1161
|
+
}
|
|
1162
|
+
: undefined;
|
|
1163
|
+
|
|
1164
|
+
if (hasAllAnswers && actualOptions.dryRun && !isTTY) {
|
|
1165
|
+
// Batch dry-run mode (non-interactive)
|
|
1166
|
+
const { dryRun } = await import("./dry-run.js");
|
|
1167
|
+
const { isVisibleEffect, formatEffectLine } = await import(
|
|
1168
|
+
"./cli-format.js"
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
const verbose = actualOptions.verbose === true;
|
|
1172
|
+
|
|
1173
|
+
console.log();
|
|
1174
|
+
console.log(chalk.bold.magenta(generator.meta.name));
|
|
1175
|
+
console.log(chalk.dim(generator.meta.description));
|
|
1176
|
+
console.log();
|
|
1177
|
+
|
|
1178
|
+
const task = generator.generate(answersWithDefaults);
|
|
1179
|
+
const result = dryRun(task);
|
|
1180
|
+
|
|
1181
|
+
// Filter and deduplicate effects
|
|
1182
|
+
const seenDirPaths = new Set<string>();
|
|
1183
|
+
const visibleEffects = result.effects.filter((e) => {
|
|
1184
|
+
if (!isVisibleEffect(e, verbose)) return false;
|
|
1185
|
+
if (e._tag === "MakeDir") {
|
|
1186
|
+
if (seenDirPaths.has(e.path)) return false;
|
|
1187
|
+
seenDirPaths.add(e.path);
|
|
1188
|
+
}
|
|
1189
|
+
return true;
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
console.log(chalk.dim.bold("Plan:"));
|
|
1193
|
+
visibleEffects.forEach((effect, index) => {
|
|
1194
|
+
const isLast = index === visibleEffects.length - 1;
|
|
1195
|
+
console.log(formatEffectLine(effect, isLast));
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
console.log();
|
|
1199
|
+
console.log(chalk.dim("Dry-run complete. No files were modified."));
|
|
1200
|
+
} else {
|
|
1201
|
+
// Interactive mode
|
|
1202
|
+
// Only pass answers if:
|
|
1203
|
+
// 1. User explicitly used --yes to skip prompts, OR
|
|
1204
|
+
// 2. User provided CLI arguments (not just defaults)
|
|
1205
|
+
const shouldSkipPrompts =
|
|
1206
|
+
skipPrompts || Object.keys(cliAnswers).length > 0;
|
|
1207
|
+
const passedAnswers = shouldSkipPrompts
|
|
1208
|
+
? answersWithDefaults
|
|
1209
|
+
: undefined;
|
|
1210
|
+
|
|
1211
|
+
const { waitUntilExit } = render(
|
|
1212
|
+
<App
|
|
1213
|
+
generator={generator}
|
|
1214
|
+
preview={actualOptions.preview as boolean}
|
|
1215
|
+
dryRunOnly={actualOptions.dryRun as boolean}
|
|
1216
|
+
verbose={actualOptions.verbose as boolean}
|
|
1217
|
+
answers={passedAnswers}
|
|
1218
|
+
stamp={stamp}
|
|
1219
|
+
/>,
|
|
1220
|
+
);
|
|
1221
|
+
|
|
1222
|
+
await waitUntilExit();
|
|
1223
|
+
}
|
|
1224
|
+
},
|
|
1225
|
+
);
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Register all generator commands from the tree.
|
|
1230
|
+
* Uses the barrel pattern: build flat list → sort by depth → register in order.
|
|
1231
|
+
*/
|
|
1232
|
+
const registerGeneratorCommands = async (
|
|
1233
|
+
parentCmd: Command,
|
|
1234
|
+
node: GeneratorNode,
|
|
1235
|
+
_pathSegments: string[],
|
|
1236
|
+
): Promise<void> => {
|
|
1237
|
+
const barrel = await buildCommandBarrel(node);
|
|
1238
|
+
registerFromBarrel(parentCmd, barrel);
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Convert GeneratorNode to CompletionNode for the completion module.
|
|
1243
|
+
*/
|
|
1244
|
+
const toCompletionNode = (node: GeneratorNode): CompletionNode => ({
|
|
1245
|
+
name: node.name,
|
|
1246
|
+
indexPath: node.indexPath,
|
|
1247
|
+
children: new Map(
|
|
1248
|
+
[...node.children.entries()].map(([name, child]) => [
|
|
1249
|
+
name,
|
|
1250
|
+
toCompletionNode(child),
|
|
1251
|
+
]),
|
|
1252
|
+
),
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
// Main CLI setup
|
|
1256
|
+
const main = async () => {
|
|
1257
|
+
// Get generators path from args early (before full parse)
|
|
1258
|
+
const generatorsIdx = process.argv.indexOf("--generators");
|
|
1259
|
+
const gIdx = process.argv.indexOf("-g");
|
|
1260
|
+
const generatorsPath =
|
|
1261
|
+
generatorsIdx !== -1
|
|
1262
|
+
? process.argv[generatorsIdx + 1]
|
|
1263
|
+
: gIdx !== -1
|
|
1264
|
+
? process.argv[gIdx + 1]
|
|
1265
|
+
: undefined;
|
|
1266
|
+
|
|
1267
|
+
// Discover generators (needed for both completion and normal CLI)
|
|
1268
|
+
const root = await discoverGeneratorTree(generatorsPath);
|
|
1269
|
+
|
|
1270
|
+
// Initialize shell completion (must happen before commander parsing)
|
|
1271
|
+
// This intercepts --completion, --completion-fish, etc.
|
|
1272
|
+
if (isCompletionRequest() || isSetupRequest()) {
|
|
1273
|
+
const completionTree = toCompletionNode(root);
|
|
1274
|
+
const complete = await initCompletion(completionTree, loadGenerator);
|
|
1275
|
+
|
|
1276
|
+
// Handle setup/cleanup requests
|
|
1277
|
+
if (isSetupRequest()) {
|
|
1278
|
+
handleSetupRequest(complete);
|
|
1279
|
+
return; // handleSetupRequest calls process.exit
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// For completion requests, omelette handles everything via init()
|
|
1283
|
+
// We just need to exit cleanly
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
program
|
|
1288
|
+
.name("summon")
|
|
1289
|
+
.description("A monadic task-centric code generator framework")
|
|
1290
|
+
.version("0.1.0")
|
|
1291
|
+
.option(
|
|
1292
|
+
"-g, --generators <path>",
|
|
1293
|
+
"Load generators ONLY from this path (for testing)",
|
|
1294
|
+
)
|
|
1295
|
+
.option("--setup-completion", "Install shell autocompletion")
|
|
1296
|
+
.option("--cleanup-completion", "Remove shell autocompletion");
|
|
1297
|
+
|
|
1298
|
+
// If no arguments or just help, show available topics
|
|
1299
|
+
if (process.argv.length === 2 || process.argv.includes("--help")) {
|
|
1300
|
+
if (process.argv.length === 2) {
|
|
1301
|
+
printNode(root, []);
|
|
1302
|
+
console.log(
|
|
1303
|
+
chalk.dim(
|
|
1304
|
+
"Tip: Run 'summon --setup-completion' to enable TAB completion\n",
|
|
1305
|
+
),
|
|
1306
|
+
);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Register generator commands dynamically
|
|
1312
|
+
await registerGeneratorCommands(program, root, []);
|
|
1313
|
+
|
|
1314
|
+
// Parse and execute
|
|
1315
|
+
program.parse();
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
// Run main
|
|
1319
|
+
main().catch((err) => {
|
|
1320
|
+
console.error(chalk.red("Error:"), err.message);
|
|
1321
|
+
process.exit(1);
|
|
1322
|
+
});
|