@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.
Files changed (45) hide show
  1. package/README.md +439 -0
  2. package/generators/example/hello/index.ts +132 -0
  3. package/generators/example/hello/templates/README.md.ejs +20 -0
  4. package/generators/example/hello/templates/index.ts.ejs +9 -0
  5. package/generators/example/webapp/index.ts +509 -0
  6. package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
  7. package/generators/example/webapp/templates/App.tsx.ejs +86 -0
  8. package/generators/example/webapp/templates/README.md.ejs +154 -0
  9. package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
  10. package/generators/example/webapp/templates/app.ts.ejs +132 -0
  11. package/generators/example/webapp/templates/feature.ts.ejs +264 -0
  12. package/generators/example/webapp/templates/index.html.ejs +20 -0
  13. package/generators/example/webapp/templates/main.tsx.ejs +43 -0
  14. package/generators/example/webapp/templates/styles.css.ejs +135 -0
  15. package/generators/init/index.ts +124 -0
  16. package/generators/init/templates/generator.ts.ejs +85 -0
  17. package/generators/init/templates/template-index.ts.ejs +9 -0
  18. package/generators/init/templates/template-test.ts.ejs +8 -0
  19. package/package.json +64 -0
  20. package/src/__tests__/combinators.test.ts +895 -0
  21. package/src/__tests__/dry-run.test.ts +927 -0
  22. package/src/__tests__/effect.test.ts +816 -0
  23. package/src/__tests__/interpreter.test.ts +673 -0
  24. package/src/__tests__/primitives.test.ts +970 -0
  25. package/src/__tests__/task.test.ts +929 -0
  26. package/src/__tests__/template.test.ts +666 -0
  27. package/src/cli-format.ts +165 -0
  28. package/src/cli-types.ts +53 -0
  29. package/src/cli.tsx +1322 -0
  30. package/src/combinators.ts +294 -0
  31. package/src/completion.ts +488 -0
  32. package/src/components/App.tsx +960 -0
  33. package/src/components/ExecutionProgress.tsx +205 -0
  34. package/src/components/FileTreePreview.tsx +97 -0
  35. package/src/components/PromptSequence.tsx +483 -0
  36. package/src/components/Spinner.tsx +36 -0
  37. package/src/components/index.ts +16 -0
  38. package/src/dry-run.ts +434 -0
  39. package/src/effect.ts +224 -0
  40. package/src/index.ts +266 -0
  41. package/src/interpreter.ts +463 -0
  42. package/src/primitives.ts +442 -0
  43. package/src/task.ts +245 -0
  44. package/src/template.ts +537 -0
  45. package/src/types.ts +453 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Production Interpreter
3
+ *
4
+ * This module implements the production interpreter that actually executes effects.
5
+ * It transforms the pure Task descriptions into real operations.
6
+ */
7
+
8
+ import * as fs from "node:fs/promises";
9
+ import * as path from "node:path";
10
+ import type { Effect, ExecResult, Task, TaskError } from "./types.js";
11
+
12
+ // =============================================================================
13
+ // Stamp Options
14
+ // =============================================================================
15
+
16
+ export interface StampConfig {
17
+ /** Generator name (e.g., "@canonical/summon-package") */
18
+ generator: string;
19
+ /** Generator version (e.g., "0.1.0") */
20
+ version: string;
21
+ }
22
+
23
+ // Comment styles for stamp generation
24
+ const COMMENT_STYLES: Record<
25
+ string,
26
+ {
27
+ single?: string;
28
+ blockStart?: string;
29
+ blockEnd?: string;
30
+ preferBlock?: boolean;
31
+ }
32
+ > = {
33
+ // JavaScript/TypeScript
34
+ ".ts": { single: "//" },
35
+ ".tsx": { single: "//" },
36
+ ".js": { single: "//" },
37
+ ".jsx": { single: "//" },
38
+ ".mjs": { single: "//" },
39
+ ".cjs": { single: "//" },
40
+ // CSS
41
+ ".css": { blockStart: "/*", blockEnd: "*/", preferBlock: true },
42
+ ".scss": { single: "//" },
43
+ ".sass": { single: "//" },
44
+ ".less": { single: "//" },
45
+ // HTML/XML
46
+ ".html": { blockStart: "<!--", blockEnd: "-->" },
47
+ ".htm": { blockStart: "<!--", blockEnd: "-->" },
48
+ ".xml": { blockStart: "<!--", blockEnd: "-->" },
49
+ ".svg": { blockStart: "<!--", blockEnd: "-->" },
50
+ ".vue": { blockStart: "<!--", blockEnd: "-->" },
51
+ ".svelte": { blockStart: "<!--", blockEnd: "-->" },
52
+ // Config (YAML/TOML support comments, JSON does not)
53
+ ".yaml": { single: "#" },
54
+ ".yml": { single: "#" },
55
+ ".toml": { single: "#" },
56
+ // Shell/scripting
57
+ ".sh": { single: "#" },
58
+ ".bash": { single: "#" },
59
+ ".zsh": { single: "#" },
60
+ ".py": { single: "#" },
61
+ ".rb": { single: "#" },
62
+ // Other languages
63
+ ".go": { single: "//" },
64
+ ".rs": { single: "//" },
65
+ ".java": { single: "//" },
66
+ ".kt": { single: "//" },
67
+ ".swift": { single: "//" },
68
+ ".c": { single: "//" },
69
+ ".cpp": { single: "//" },
70
+ ".h": { single: "//" },
71
+ ".php": { single: "//" },
72
+ // Documentation
73
+ ".md": { blockStart: "<!--", blockEnd: "-->" },
74
+ ".mdx": { blockStart: "{/*", blockEnd: "*/}" },
75
+ // SQL
76
+ ".sql": { single: "--" },
77
+ };
78
+
79
+ /**
80
+ * Apply a stamp to file content based on file extension.
81
+ * Returns the original content if the file type doesn't support comments.
82
+ */
83
+ const applyStamp = (
84
+ filePath: string,
85
+ content: string,
86
+ stamp: StampConfig,
87
+ ): string => {
88
+ const ext = path.extname(filePath).toLowerCase();
89
+ const style = COMMENT_STYLES[ext];
90
+
91
+ if (!style) {
92
+ return content;
93
+ }
94
+
95
+ const stampText = `Generated by ${stamp.generator} v${stamp.version}`;
96
+ let stampLine: string;
97
+
98
+ if (style.preferBlock || (!style.single && style.blockStart)) {
99
+ if (!style.blockStart || !style.blockEnd) return content;
100
+ stampLine = `${style.blockStart} ${stampText} ${style.blockEnd}`;
101
+ } else if (style.single) {
102
+ stampLine = `${style.single} ${stampText}`;
103
+ } else {
104
+ return content;
105
+ }
106
+
107
+ // Handle shebang - place stamp after it
108
+ if (content.startsWith("#!")) {
109
+ const firstNewline = content.indexOf("\n");
110
+ if (firstNewline !== -1) {
111
+ const shebang = content.slice(0, firstNewline + 1);
112
+ const rest = content.slice(firstNewline + 1);
113
+ return `${shebang}${stampLine}\n${rest}`;
114
+ }
115
+ }
116
+
117
+ return `${stampLine}\n${content}`;
118
+ };
119
+
120
+ // =============================================================================
121
+ // Task Execution Error
122
+ // =============================================================================
123
+
124
+ export class TaskExecutionError extends Error {
125
+ public readonly code: string;
126
+ public readonly taskError: TaskError;
127
+
128
+ constructor(error: TaskError) {
129
+ super(error.message);
130
+ this.name = "TaskExecutionError";
131
+ this.code = error.code;
132
+ this.taskError = error;
133
+
134
+ if (error.stack) {
135
+ this.stack = error.stack;
136
+ }
137
+ }
138
+ }
139
+
140
+ // =============================================================================
141
+ // Effect Executor
142
+ // =============================================================================
143
+
144
+ /**
145
+ * Execute a single effect and return the result.
146
+ * This is where the actual I/O happens.
147
+ */
148
+ export const executeEffect = async (
149
+ effect: Effect,
150
+ context: Map<string, unknown>,
151
+ promptHandler?: (question: Effect & { _tag: "Prompt" }) => Promise<unknown>,
152
+ onLog?: (level: "debug" | "info" | "warn" | "error", message: string) => void,
153
+ stamp?: StampConfig,
154
+ ): Promise<unknown> => {
155
+ switch (effect._tag) {
156
+ case "ReadFile": {
157
+ return fs.readFile(effect.path, "utf-8");
158
+ }
159
+
160
+ case "WriteFile": {
161
+ const dir = path.dirname(effect.path);
162
+ await fs.mkdir(dir, { recursive: true });
163
+ // Apply stamp if configured
164
+ const content = stamp
165
+ ? applyStamp(effect.path, effect.content, stamp)
166
+ : effect.content;
167
+ await fs.writeFile(effect.path, content, "utf-8");
168
+ return undefined;
169
+ }
170
+
171
+ case "AppendFile": {
172
+ const dir = path.dirname(effect.path);
173
+ await fs.mkdir(dir, { recursive: true });
174
+ if (effect.createIfMissing) {
175
+ // Create file if it doesn't exist, then append
176
+ try {
177
+ await fs.access(effect.path);
178
+ } catch {
179
+ // File doesn't exist, create it
180
+ await fs.writeFile(effect.path, "", "utf-8");
181
+ }
182
+ }
183
+ await fs.appendFile(effect.path, effect.content, "utf-8");
184
+ return undefined;
185
+ }
186
+
187
+ case "CopyFile": {
188
+ const destDir = path.dirname(effect.dest);
189
+ await fs.mkdir(destDir, { recursive: true });
190
+ await fs.copyFile(effect.source, effect.dest);
191
+ return undefined;
192
+ }
193
+
194
+ case "CopyDirectory": {
195
+ await fs.cp(effect.source, effect.dest, { recursive: true });
196
+ return undefined;
197
+ }
198
+
199
+ case "DeleteFile": {
200
+ await fs.unlink(effect.path);
201
+ return undefined;
202
+ }
203
+
204
+ case "DeleteDirectory": {
205
+ await fs.rm(effect.path, { recursive: true, force: true });
206
+ return undefined;
207
+ }
208
+
209
+ case "MakeDir": {
210
+ await fs.mkdir(effect.path, { recursive: effect.recursive });
211
+ return undefined;
212
+ }
213
+
214
+ case "Exists": {
215
+ try {
216
+ await fs.access(effect.path);
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ case "Glob": {
224
+ // Use Bun's glob if available, otherwise fall back to fs.readdir
225
+ if (typeof Bun !== "undefined" && Bun.Glob) {
226
+ const globber = new Bun.Glob(effect.pattern);
227
+ const matches: string[] = [];
228
+ for await (const file of globber.scan({ cwd: effect.cwd })) {
229
+ matches.push(file);
230
+ }
231
+ return matches;
232
+ }
233
+ // Fallback: simple recursive readdir (limited glob support)
234
+ return simpleGlob(effect.pattern, effect.cwd);
235
+ }
236
+
237
+ case "Exec": {
238
+ if (typeof Bun !== "undefined") {
239
+ const proc = Bun.spawn([effect.command, ...effect.args], {
240
+ cwd: effect.cwd,
241
+ stdout: "pipe",
242
+ stderr: "pipe",
243
+ });
244
+
245
+ const stdout = await new Response(proc.stdout).text();
246
+ const stderr = await new Response(proc.stderr).text();
247
+ const exitCode = await proc.exited;
248
+
249
+ return { stdout, stderr, exitCode } satisfies ExecResult;
250
+ }
251
+
252
+ // Node.js fallback
253
+ const { spawn } = await import("node:child_process");
254
+ return new Promise<ExecResult>((resolve, reject) => {
255
+ const child = spawn(effect.command, effect.args, {
256
+ cwd: effect.cwd,
257
+ shell: false,
258
+ });
259
+
260
+ let stdout = "";
261
+ let stderr = "";
262
+
263
+ child.stdout?.on("data", (data) => {
264
+ stdout += data.toString();
265
+ });
266
+ child.stderr?.on("data", (data) => {
267
+ stderr += data.toString();
268
+ });
269
+
270
+ child.on("close", (code) => {
271
+ resolve({ stdout, stderr, exitCode: code ?? 0 });
272
+ });
273
+ child.on("error", reject);
274
+ });
275
+ }
276
+
277
+ case "Prompt": {
278
+ if (promptHandler) {
279
+ return promptHandler(effect);
280
+ }
281
+ throw new TaskExecutionError({
282
+ code: "NO_PROMPT_HANDLER",
283
+ message: "No prompt handler provided for interactive prompts",
284
+ });
285
+ }
286
+
287
+ case "Log": {
288
+ if (onLog) {
289
+ onLog(effect.level, effect.message);
290
+ } else {
291
+ const prefix = {
292
+ debug: "[DEBUG]",
293
+ info: "[INFO]",
294
+ warn: "[WARN]",
295
+ error: "[ERROR]",
296
+ }[effect.level];
297
+ console.log(`${prefix} ${effect.message}`);
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ case "ReadContext": {
303
+ return context.get(effect.key);
304
+ }
305
+
306
+ case "WriteContext": {
307
+ context.set(effect.key, effect.value);
308
+ return undefined;
309
+ }
310
+
311
+ case "Parallel":
312
+ case "Race":
313
+ // These are handled specially in runTask to preserve options
314
+ throw new Error(
315
+ `${effect._tag} effect must be handled by runTask, not executeEffect directly`,
316
+ );
317
+ }
318
+ };
319
+
320
+ // =============================================================================
321
+ // Simple Glob Implementation (fallback)
322
+ // =============================================================================
323
+
324
+ const simpleGlob = async (pattern: string, cwd: string): Promise<string[]> => {
325
+ const results: string[] = [];
326
+
327
+ const walk = async (dir: string, prefix: string): Promise<void> => {
328
+ const entries = await fs.readdir(dir, { withFileTypes: true });
329
+ for (const entry of entries) {
330
+ const fullPath = path.join(dir, entry.name);
331
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
332
+
333
+ if (entry.isDirectory()) {
334
+ await walk(fullPath, relativePath);
335
+ } else if (matchesPattern(relativePath, pattern)) {
336
+ results.push(relativePath);
337
+ }
338
+ }
339
+ };
340
+
341
+ await walk(cwd, "");
342
+ return results;
343
+ };
344
+
345
+ const matchesPattern = (filepath: string, pattern: string): boolean => {
346
+ // Very simple glob matching - just handles * and **
347
+ const regex = pattern
348
+ .replace(/\*\*/g, "<<GLOBSTAR>>")
349
+ .replace(/\*/g, "[^/]*")
350
+ .replace(/<<GLOBSTAR>>/g, ".*")
351
+ .replace(/\./g, "\\.");
352
+ return new RegExp(`^${regex}$`).test(filepath);
353
+ };
354
+
355
+ // =============================================================================
356
+ // Task Runner Options
357
+ // =============================================================================
358
+
359
+ export interface RunTaskOptions {
360
+ /** Context for storing values between effects */
361
+ context?: Map<string, unknown>;
362
+ /** Handler for interactive prompts */
363
+ promptHandler?: (question: Effect & { _tag: "Prompt" }) => Promise<unknown>;
364
+ /** Called before each effect is executed */
365
+ onEffectStart?: (effect: Effect) => void;
366
+ /** Called after each effect completes */
367
+ onEffectComplete?: (effect: Effect, duration: number) => void;
368
+ /** Handler for log effects. If provided, log output goes here instead of console */
369
+ onLog?: (level: "debug" | "info" | "warn" | "error", message: string) => void;
370
+ /** Stamp configuration for generated files. If provided, all written files get a stamp comment. */
371
+ stamp?: StampConfig;
372
+ }
373
+
374
+ // =============================================================================
375
+ // Task Runner
376
+ // =============================================================================
377
+
378
+ /**
379
+ * Run a task to completion, executing all effects.
380
+ */
381
+ export const runTask = async <A>(
382
+ task: Task<A>,
383
+ options: RunTaskOptions = {},
384
+ ): Promise<A> => {
385
+ const {
386
+ context = new Map(),
387
+ promptHandler,
388
+ onEffectStart,
389
+ onEffectComplete,
390
+ onLog,
391
+ stamp,
392
+ } = options;
393
+
394
+ const runInternal = async <B>(t: Task<B>): Promise<B> => {
395
+ switch (t._tag) {
396
+ case "Pure":
397
+ return t.value;
398
+
399
+ case "Fail":
400
+ throw new TaskExecutionError(t.error);
401
+
402
+ case "Effect": {
403
+ const effect = t.effect;
404
+
405
+ // Handle Parallel and Race effects specially to preserve options
406
+ if (effect._tag === "Parallel") {
407
+ onEffectStart?.(effect);
408
+ const startTime = performance.now();
409
+
410
+ const results = await Promise.all(
411
+ effect.tasks.map((task) => runInternal(task)),
412
+ );
413
+
414
+ const duration = performance.now() - startTime;
415
+ onEffectComplete?.(effect, duration);
416
+
417
+ return runInternal(t.cont(results));
418
+ }
419
+
420
+ if (effect._tag === "Race") {
421
+ onEffectStart?.(effect);
422
+ const startTime = performance.now();
423
+
424
+ const result = await Promise.race(
425
+ effect.tasks.map((task) => runInternal(task)),
426
+ );
427
+
428
+ const duration = performance.now() - startTime;
429
+ onEffectComplete?.(effect, duration);
430
+
431
+ return runInternal(t.cont(result));
432
+ }
433
+
434
+ // Handle all other effects
435
+ onEffectStart?.(effect);
436
+ const startTime = performance.now();
437
+
438
+ const result = await executeEffect(
439
+ effect,
440
+ context,
441
+ promptHandler,
442
+ onLog,
443
+ stamp,
444
+ );
445
+
446
+ const duration = performance.now() - startTime;
447
+ onEffectComplete?.(effect, duration);
448
+
449
+ return runInternal(t.cont(result));
450
+ }
451
+ }
452
+ };
453
+
454
+ return runInternal(task);
455
+ };
456
+
457
+ /**
458
+ * Run a task with a fresh context (simple API for backward compatibility).
459
+ */
460
+ export const run = <A>(
461
+ task: Task<A>,
462
+ promptHandler?: (question: Effect & { _tag: "Prompt" }) => Promise<unknown>,
463
+ ): Promise<A> => runTask(task, { promptHandler });