@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
package/README.md ADDED
@@ -0,0 +1,439 @@
1
+ # Summon
2
+
3
+ A code generator framework where generators are pure functions that return data, not side effects.
4
+
5
+ Write a generator once. Run it for real. Preview with `--dry-run`. Test without mocks. Same code, different interpreters.
6
+
7
+ ```typescript
8
+ generate: (answers) => writeFile(`src/${answers.name}.ts`, code)
9
+ // Returns a Task describing "write this file"
10
+ // Not a file write. Data.
11
+ ```
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ bun add @canonical/summon
17
+ ```
18
+
19
+ ## Using Generators
20
+
21
+ Summon discovers generators from installed packages automatically.
22
+
23
+ ```bash
24
+ # See what's available
25
+ summon
26
+
27
+ # Run a generator interactively
28
+ summon component react
29
+
30
+ # Pass answers as positional argument (when supported)
31
+ summon component react src/components/Button
32
+
33
+ # Or as a flag
34
+ summon component react --component-path=src/components/Button
35
+
36
+ # Preview first (nothing written to disk)
37
+ summon component react src/components/Button --dry-run
38
+
39
+ # Show debug output
40
+ summon component react src/components/Button --verbose
41
+ ```
42
+
43
+ Every prompt becomes a CLI flag. Boolean prompts with `default: true` use the `--no-` prefix to disable. Generators may also define a **positional argument** for their primary input (like a path), allowing cleaner command invocations.
44
+
45
+ ### CLI Options
46
+
47
+ | Flag | Description |
48
+ |------|-------------|
49
+ | `-d, --dry-run` | Preview without writing files |
50
+ | `-y, --yes` | Skip confirmation prompts |
51
+ | `-v, --verbose` | Show debug output |
52
+ | `--no-preview` | Skip the file preview |
53
+ | `--no-generated-stamp` | Disable generated file stamp comments |
54
+
55
+ ### Shell Autocompletion
56
+
57
+ Summon supports TAB completion for Bash, Zsh, and Fish shells. Completions are **dynamic** - they automatically detect newly installed generator packages without needing to re-run setup.
58
+
59
+ ```bash
60
+ # Automatic setup (recommended)
61
+ summon --setup-completion
62
+
63
+ # Or manual installation for each shell:
64
+
65
+ # Zsh
66
+ echo '. <(summon --completion)' >> ~/.zshrc
67
+
68
+ # Bash (may need: brew install bash-completion on macOS)
69
+ summon --completion >> ~/.summon-completion.sh
70
+ echo 'source ~/.summon-completion.sh' >> ~/.bash_profile
71
+
72
+ # Fish
73
+ echo 'summon --completion-fish | source' >> ~/.config/fish/config.fish
74
+ ```
75
+
76
+ After setup, restart your shell or source the config file. Then:
77
+
78
+ ```bash
79
+ summon <TAB> # Shows: component, init, ...
80
+ summon component <TAB> # Shows: react, svelte
81
+ summon component react <TAB> # Shows: --component-path, --no-with-styles, ...
82
+ summon component react --comp<TAB> # Completes to: --component-path
83
+ ```
84
+
85
+ Completion features:
86
+ - **Generator names** - Navigate the command tree with TAB
87
+ - **Generator flags** - All prompts become completable flags
88
+ - **Confirm prompts** - Shows `--no-X` for prompts with `default: true`
89
+ - **Select/multiselect** - Completes with available choices
90
+ - **Path prompts** - Filesystem path completion for prompts containing "path", "dir", "file", etc.
91
+
92
+ To remove autocompletion:
93
+
94
+ ```bash
95
+ summon --cleanup-completion
96
+ ```
97
+
98
+ ### Positional Arguments
99
+
100
+ Generators can define one prompt as a **positional argument**, allowing users to provide the primary input without a flag:
101
+
102
+ ```bash
103
+ # With positional argument support
104
+ summon component react src/components/Button
105
+
106
+ # Equivalent to
107
+ summon component react --component-path=src/components/Button
108
+ ```
109
+
110
+ Positional arguments also get filesystem path completion when using TAB:
111
+
112
+ ```bash
113
+ summon component react src/comp<TAB> # Completes to: src/components/
114
+ ```
115
+
116
+ ### Installing Generator Packages
117
+
118
+ Generator packages follow the naming convention `summon-*` or `@scope/summon-*`:
119
+
120
+ ```bash
121
+ # Install a generator package
122
+ bun add @canonical/summon-component
123
+
124
+ # Now available (completions work immediately!)
125
+ summon component react
126
+ summon component svelte
127
+ ```
128
+
129
+ ## Creating Generators
130
+
131
+ A generator is a pure function that takes answers and returns a `Task`—a data structure describing what to do.
132
+
133
+ ```typescript
134
+ // src/module/types.ts
135
+ interface ModuleAnswers {
136
+ name: string;
137
+ withTests: boolean;
138
+ }
139
+
140
+ // src/module/generator.ts
141
+ import type { GeneratorDefinition } from "@canonical/summon";
142
+ import { debug, info, writeFile, mkdir, sequence_, when } from "@canonical/summon";
143
+ import type { ModuleAnswers } from "./types.js";
144
+
145
+ export const generator = {
146
+ meta: {
147
+ name: "module",
148
+ description: "Creates a new module",
149
+ version: "0.1.0",
150
+ },
151
+
152
+ prompts: [
153
+ // positional: true allows `summon module src/utils` instead of `--name=src/utils`
154
+ { name: "name", type: "text", message: "Module name:", positional: true },
155
+ { name: "withTests", type: "confirm", message: "Include tests?", default: true },
156
+ ],
157
+
158
+ generate: (answers) => sequence_([
159
+ info(`Creating module: ${answers.name}`),
160
+
161
+ debug("Creating module directory"),
162
+ mkdir(`src/${answers.name}`),
163
+
164
+ debug("Creating index file"),
165
+ writeFile(`src/${answers.name}/index.ts`, `export const ${answers.name} = {};\n`),
166
+
167
+ when(answers.withTests, debug("Creating test file")),
168
+ when(answers.withTests,
169
+ writeFile(`src/${answers.name}/index.test.ts`, `test("works", () => {});\n`)
170
+ ),
171
+
172
+ info(`Created module at src/${answers.name}`),
173
+ ]),
174
+ } as const satisfies GeneratorDefinition<ModuleAnswers>;
175
+
176
+ // src/module/index.ts (barrel)
177
+ export { generator } from "./generator.js";
178
+ export type { ModuleAnswers } from "./types.js";
179
+
180
+ // src/index.ts (package barrel)
181
+ import type { AnyGenerator } from "@canonical/summon";
182
+ import { generator as moduleGenerator } from "./module/index.js";
183
+
184
+ export const generators = {
185
+ "module": moduleGenerator,
186
+ } as const satisfies Record<string, AnyGenerator>;
187
+ ```
188
+
189
+ ### Package Structure
190
+
191
+ Each generator should be split into three files for maintainability:
192
+
193
+ ```
194
+ my-summon-package/
195
+ ├── package.json
196
+ ├── src/
197
+ │ ├── index.ts # Package barrel - exports generators record
198
+ │ ├── module/
199
+ │ │ ├── index.ts # Generator barrel
200
+ │ │ ├── types.ts # Answer types
201
+ │ │ └── generator.ts # Generator definition
202
+ │ └── templates/ # EJS templates (optional)
203
+ └── README.md
204
+ ```
205
+
206
+ ```json
207
+ {
208
+ "name": "@myorg/summon-module",
209
+ "main": "src/index.ts",
210
+ "peerDependencies": {
211
+ "@canonical/summon": "workspace:*"
212
+ }
213
+ }
214
+ ```
215
+
216
+ ### Local Development
217
+
218
+ For developing generators locally, link them to make them globally available:
219
+
220
+ ```bash
221
+ # From your generator package directory
222
+ cd /path/to/my-summon-package
223
+ bun link # for bun
224
+ npm link # for npm
225
+
226
+ # Now available everywhere
227
+ summon module src/utils
228
+ ```
229
+
230
+ Project-local packages (in `./node_modules`) take precedence over globally linked ones, so you can override with project-specific versions.
231
+
232
+ ### Testing Generators
233
+
234
+ Because generators return data, testing is straightforward—no mocks needed:
235
+
236
+ ```typescript
237
+ import { dryRun, getAffectedFiles } from "@canonical/summon";
238
+ import { generators } from "./index";
239
+
240
+ const generator = generators["module"];
241
+
242
+ test("creates expected files", () => {
243
+ const task = generator.generate({ name: "utils", withTests: true });
244
+ const { effects } = dryRun(task);
245
+
246
+ expect(getAffectedFiles(effects)).toEqual([
247
+ "src/utils/index.ts",
248
+ "src/utils/index.test.ts",
249
+ ]);
250
+ });
251
+
252
+ test("skips test file when disabled", () => {
253
+ const task = generator.generate({ name: "utils", withTests: false });
254
+ const { effects } = dryRun(task);
255
+
256
+ expect(getAffectedFiles(effects)).not.toContain("src/utils/index.test.ts");
257
+ });
258
+ ```
259
+
260
+ The dry-run interpreter maintains a virtual filesystem, so conditional logic based on `exists()` works correctly even without touching the disk.
261
+
262
+ ## The Monadic Pattern
263
+
264
+ Summon uses a **monad** to compose tasks. If you've used Promises, you already understand the core idea.
265
+
266
+ ### What's a Monad?
267
+
268
+ A monad is a design pattern for chaining operations that have some context (like "might fail" or "has effects"). Think of it as a pipeline where each step can:
269
+
270
+ 1. **Transform values** (`map`) — Apply a function to the result
271
+ 2. **Chain operations** (`flatMap`) — Use the result to create the next step
272
+ 3. **Short-circuit on failure** — Errors propagate automatically
273
+
274
+ ```typescript
275
+ // Promise (async context)
276
+ fetchUser(id)
277
+ .then(user => fetchOrders(user.id)) // chain
278
+ .then(orders => orders.length) // transform
279
+ .catch(handleError); // recover
280
+
281
+ // Task (effect context)
282
+ task(readFile("config.json"))
283
+ .map(content => JSON.parse(content)) // transform
284
+ .flatMap(config => writeFile( // chain
285
+ config.outputPath,
286
+ generateCode(config)
287
+ ))
288
+ .recover(err => pure(defaultConfig)); // recover
289
+ ```
290
+
291
+ ### The Task Type
292
+
293
+ `Task<A>` represents a computation that:
294
+ - Describes effects (file I/O, shell commands, etc.)
295
+ - Eventually produces a value of type `A`
296
+ - May fail with an error
297
+
298
+ ```typescript
299
+ // A Task is one of three things:
300
+ type Task<A> =
301
+ | { _tag: "Pure"; value: A } // Done, here's the value
302
+ | { _tag: "Fail"; error: TaskError } // Failed with error
303
+ | { _tag: "Effect"; effect: Effect; cont: ... } // Do this effect, then continue
304
+ ```
305
+
306
+ ### Composing Tasks
307
+
308
+ The `TaskBuilder` provides a fluent API for composition:
309
+
310
+ ```typescript
311
+ import { task, pure } from "@canonical/summon";
312
+
313
+ // Build a pipeline
314
+ const pipeline = task(readFile("input.txt"))
315
+ .map(content => content.toUpperCase()) // Transform the value
316
+ .flatMap(upper => writeFile("output.txt", upper)) // Chain another effect
317
+ .andThen(info("Done!")) // Sequence (ignore previous value)
318
+ .recover(err => pure(void 0)); // Handle errors
319
+
320
+ // pipeline is still just data — nothing has executed yet
321
+ ```
322
+
323
+ ### Key Operations
324
+
325
+ | Operation | Description | Example |
326
+ |-----------|-------------|---------|
327
+ | `pure(value)` | Wrap a value in a Task | `pure(42)` |
328
+ | `map(fn)` | Transform the result | `.map(x => x * 2)` |
329
+ | `flatMap(fn)` | Chain with another Task | `.flatMap(x => writeFile(...))` |
330
+ | `andThen(task)` | Sequence, discard previous | `.andThen(info("next"))` |
331
+ | `recover(fn)` | Handle errors | `.recover(e => pure(default))` |
332
+ | `tap(fn)` | Side effect, keep value | `.tap(x => debug(x))` |
333
+
334
+ ### Why Monads for Generators?
335
+
336
+ 1. **Composable** — Small tasks combine into complex workflows
337
+ 2. **Predictable** — Errors propagate without explicit handling at each step
338
+ 3. **Testable** — The pipeline is data; inspect it without running effects
339
+ 4. **Declarative** — Describe *what* to do, not *how* to do it
340
+
341
+ ```typescript
342
+ // Complex workflow, reads like a recipe
343
+ const scaffoldFeature = (name: string) =>
344
+ task(mkdir(`src/features/${name}`))
345
+ .andThen(template({
346
+ source: "templates/feature.ts.ejs",
347
+ dest: `src/features/${name}/index.ts`,
348
+ vars: { name },
349
+ }))
350
+ .andThen(when(config.withTests,
351
+ template({
352
+ source: "templates/test.ts.ejs",
353
+ dest: `src/features/${name}/index.test.ts`,
354
+ vars: { name },
355
+ })
356
+ ))
357
+ .andThen(info(`Created feature: ${name}`))
358
+ .unwrap();
359
+ ```
360
+
361
+ For a deeper dive into the "effects as data" philosophy, see [Explanation](docs/explanation.md).
362
+
363
+ ## Templating Engine
364
+
365
+ Summon uses EJS by default for template rendering, but supports custom templating engines via the `TemplatingEngine` interface.
366
+
367
+ ### Default (EJS)
368
+
369
+ ```typescript
370
+ import { template, templateDir } from "@canonical/summon";
371
+
372
+ // Uses EJS by default
373
+ template({
374
+ source: "templates/component.tsx.ejs",
375
+ dest: "src/components/<%= name %>.tsx",
376
+ vars: { name: "Button" },
377
+ });
378
+ ```
379
+
380
+ ### Custom Engines
381
+
382
+ Implement `TemplatingEngine` to use Handlebars, Mustache, Nunjucks, or any other engine:
383
+
384
+ ```typescript
385
+ import type { TemplatingEngine } from "@canonical/summon";
386
+ import Handlebars from "handlebars";
387
+ import * as fs from "node:fs/promises";
388
+
389
+ const handlebarsEngine: TemplatingEngine = {
390
+ render(template, vars) {
391
+ return Handlebars.compile(template)(vars);
392
+ },
393
+
394
+ async renderAsync(template, vars) {
395
+ return Handlebars.compile(template)(vars);
396
+ },
397
+
398
+ async renderFile(templatePath, vars) {
399
+ const content = await fs.readFile(templatePath, "utf-8");
400
+ return Handlebars.compile(content)(vars);
401
+ },
402
+ };
403
+
404
+ // Use in templates
405
+ template({
406
+ source: "templates/component.hbs",
407
+ dest: "src/components/{{name}}.tsx",
408
+ vars: { name: "Button" },
409
+ engine: handlebarsEngine,
410
+ });
411
+ ```
412
+
413
+ ### Interface
414
+
415
+ ```typescript
416
+ interface TemplatingEngine {
417
+ /** Render a template string synchronously */
418
+ render(template: string, vars: Record<string, unknown>): string;
419
+
420
+ /** Render a template string asynchronously */
421
+ renderAsync(template: string, vars: Record<string, unknown>): Promise<string>;
422
+
423
+ /** Render a template file asynchronously */
424
+ renderFile(templatePath: string, vars: Record<string, unknown>): Promise<string>;
425
+ }
426
+ ```
427
+
428
+ The `engine` option is available on `template()`, `templateDir()`, `renderString()`, `renderStringAsync()`, and `renderFile()`.
429
+
430
+ ## Documentation
431
+
432
+ - **[Tutorial](docs/tutorial.md)** — Build your first generator from scratch
433
+ - **[How-To Guides](docs/how-to.md)** — Solve specific problems
434
+ - **[Reference](docs/reference.md)** — Complete API documentation
435
+ - **[Explanation](docs/explanation.md)** — Why effects as data? The ideas behind Summon
436
+
437
+ ## License
438
+
439
+ GPL-3.0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Hello World Generator (Demo)
3
+ *
4
+ * A simple example generator that demonstrates the core features of Summon,
5
+ * including EJS templates and the withHelpers utility.
6
+ *
7
+ * Use this as a reference when building your own generators.
8
+ */
9
+
10
+ import * as path from "node:path";
11
+ import {
12
+ info,
13
+ mkdir,
14
+ sequence_,
15
+ template,
16
+ when,
17
+ withHelpers,
18
+ } from "../../../src/index.js";
19
+ import type { GeneratorDefinition } from "../../../src/types.js";
20
+
21
+ // =============================================================================
22
+ // Types
23
+ // =============================================================================
24
+
25
+ interface HelloAnswers {
26
+ name: string;
27
+ description: string;
28
+ greeting: string;
29
+ withReadme: boolean;
30
+ }
31
+
32
+ // =============================================================================
33
+ // Generator Definition
34
+ // =============================================================================
35
+
36
+ export const generator: GeneratorDefinition<HelloAnswers> = {
37
+ meta: {
38
+ name: "hello",
39
+ description: "A demo generator that creates a simple hello world project",
40
+ version: "0.1.0",
41
+ help: `This generator demonstrates the core features of Summon:
42
+
43
+ - EJS templates for dynamic file generation
44
+ - The withHelpers utility for case transformations (camelCase, pascalCase, etc.)
45
+ - Conditional file generation with the 'when' combinator
46
+ - Sequential task composition with 'sequence_' or fluent '.then()' chains
47
+
48
+ Use this generator as a reference when building your own generators.`,
49
+ examples: [
50
+ // Zero-config: interactive prompts with defaults
51
+ "summon hello",
52
+ // Minimal: just the name
53
+ "summon hello --name=my-app",
54
+ // Partial: customize greeting
55
+ "summon hello --name=my-app --greeting=Hey",
56
+ // Skip optional file
57
+ "summon hello --name=my-app --no-withReadme",
58
+ // Preview without writing
59
+ "summon hello --dry-run",
60
+ // Full non-interactive
61
+ "summon hello --name=demo --greeting=Hello --description='A demo' --yes",
62
+ ],
63
+ },
64
+
65
+ prompts: [
66
+ {
67
+ name: "name",
68
+ type: "text",
69
+ message: "Project name:",
70
+ default: "my-project",
71
+ },
72
+ {
73
+ name: "description",
74
+ type: "text",
75
+ message: "Description (optional):",
76
+ default: "",
77
+ },
78
+ {
79
+ name: "greeting",
80
+ type: "select",
81
+ message: "Choose a greeting:",
82
+ choices: [
83
+ { label: "Hello", value: "Hello" },
84
+ { label: "Hi", value: "Hi" },
85
+ { label: "Hey", value: "Hey" },
86
+ { label: "Greetings", value: "Greetings" },
87
+ ],
88
+ default: "Hello",
89
+ },
90
+ {
91
+ name: "withReadme",
92
+ type: "confirm",
93
+ message: "Include README.md?",
94
+ default: true,
95
+ },
96
+ ],
97
+
98
+ generate: (answers) => {
99
+ const { name, withReadme } = answers;
100
+
101
+ // withHelpers adds camelCase, kebabCase, pascalCase, etc. to the template vars
102
+ const vars = withHelpers(answers);
103
+
104
+ return sequence_([
105
+ info(`Creating ${name}...`),
106
+
107
+ // Create project directory
108
+ mkdir(name),
109
+
110
+ // Create main file using EJS template
111
+ template({
112
+ source: path.join(__dirname, "templates", "index.ts.ejs"),
113
+ dest: path.join(name, "index.ts"),
114
+ vars,
115
+ }),
116
+
117
+ // Conditionally create README using EJS template
118
+ when(
119
+ withReadme,
120
+ template({
121
+ source: path.join(__dirname, "templates", "README.md.ejs"),
122
+ dest: path.join(name, "README.md"),
123
+ vars,
124
+ }),
125
+ ),
126
+
127
+ info(`Done! Run: cd ${name} && bun run index.ts`),
128
+ ]);
129
+ },
130
+ };
131
+
132
+ export default generator;
@@ -0,0 +1,20 @@
1
+ # <%= name %>
2
+
3
+ <%= greeting %>! This project was generated by `summon hello`.
4
+
5
+ <% if (description) { %>
6
+ ## About
7
+
8
+ <%= description %>
9
+
10
+ <% } %>
11
+ ## Getting Started
12
+
13
+ ```bash
14
+ bun run index.ts
15
+ ```
16
+
17
+ ## About Summon
18
+
19
+ Summon is a monadic task-centric code generator framework.
20
+ Learn more at: https://github.com/canonical/pragma
@@ -0,0 +1,9 @@
1
+ // Generated by summon hello
2
+ // <%= description %>
3
+
4
+ export const <%= camelCase(name) %> = {
5
+ name: "<%= name %>",
6
+ greet: () => "<%= greeting %>, <%= name %>!",
7
+ };
8
+
9
+ console.log(<%= camelCase(name) %>.greet());