@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutionProgress Component
|
|
3
|
+
*
|
|
4
|
+
* Displays the progress of task execution with effect-by-effect feedback.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Box, Text } from "ink";
|
|
8
|
+
import type React from "react";
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { describeEffect } from "../effect.js";
|
|
11
|
+
import { runTask, type StampConfig } from "../interpreter.js";
|
|
12
|
+
import type { Effect, Task, TaskError } from "../types.js";
|
|
13
|
+
import { Spinner } from "./Spinner.js";
|
|
14
|
+
|
|
15
|
+
/** Effect with timing information for the completion timeline */
|
|
16
|
+
export interface TimedEffect {
|
|
17
|
+
effect: Effect;
|
|
18
|
+
/** Time in ms when this effect completed, relative to execution start */
|
|
19
|
+
timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecutionProgressProps {
|
|
23
|
+
/** The task to execute */
|
|
24
|
+
task: Task<void>;
|
|
25
|
+
/** Whether to run in dry-run mode */
|
|
26
|
+
dryRun?: boolean;
|
|
27
|
+
/** Called when execution completes */
|
|
28
|
+
onComplete: (effects: TimedEffect[], duration: number) => void;
|
|
29
|
+
/** Called when execution fails */
|
|
30
|
+
onError: (error: TaskError) => void;
|
|
31
|
+
/** Stamp configuration for generated files (undefined = no stamps) */
|
|
32
|
+
stamp?: StampConfig;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CompletedEffect {
|
|
36
|
+
id: number;
|
|
37
|
+
effect: Effect;
|
|
38
|
+
duration: number;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface LogMessage {
|
|
43
|
+
id: number;
|
|
44
|
+
level: "debug" | "info" | "warn" | "error";
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Effects that should be hidden from the progress display (internal/noisy) */
|
|
49
|
+
const isInternalEffect = (effect: Effect): boolean => {
|
|
50
|
+
// Hide internal coordination effects
|
|
51
|
+
if (
|
|
52
|
+
effect._tag === "Log" ||
|
|
53
|
+
effect._tag === "Exists" ||
|
|
54
|
+
effect._tag === "ReadContext" ||
|
|
55
|
+
effect._tag === "WriteContext" ||
|
|
56
|
+
effect._tag === "Parallel" ||
|
|
57
|
+
effect._tag === "Race"
|
|
58
|
+
) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// Hide template file reads (internal implementation detail)
|
|
62
|
+
if (effect._tag === "ReadFile" && effect.path.includes("/templates/")) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const ExecutionProgress: React.FC<ExecutionProgressProps> = ({
|
|
69
|
+
task,
|
|
70
|
+
dryRun: _dryRun = false,
|
|
71
|
+
onComplete,
|
|
72
|
+
onError,
|
|
73
|
+
stamp,
|
|
74
|
+
}) => {
|
|
75
|
+
const [currentEffect, setCurrentEffect] = useState<Effect | null>(null);
|
|
76
|
+
const [completedEffects, setCompletedEffects] = useState<CompletedEffect[]>(
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
const [logMessages, setLogMessages] = useState<LogMessage[]>([]);
|
|
80
|
+
const [isRunning, setIsRunning] = useState(true);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const collectedEffects: TimedEffect[] = [];
|
|
84
|
+
const startTime = performance.now();
|
|
85
|
+
let effectId = 0;
|
|
86
|
+
let logId = 0;
|
|
87
|
+
// Track seen directory paths to deduplicate MakeDir effects in live progress
|
|
88
|
+
const seenDirPaths = new Set<string>();
|
|
89
|
+
|
|
90
|
+
const executeWithProgress = async () => {
|
|
91
|
+
try {
|
|
92
|
+
await runTask(task, {
|
|
93
|
+
stamp,
|
|
94
|
+
onEffectStart: (effect) => {
|
|
95
|
+
// Skip showing duplicate MakeDir in spinner
|
|
96
|
+
if (effect._tag === "MakeDir" && seenDirPaths.has(effect.path)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setCurrentEffect(effect);
|
|
100
|
+
},
|
|
101
|
+
onEffectComplete: (effect, duration) => {
|
|
102
|
+
const id = effectId++;
|
|
103
|
+
const timestamp = performance.now() - startTime;
|
|
104
|
+
collectedEffects.push({ effect, timestamp });
|
|
105
|
+
// Skip duplicate MakeDir effects in live display
|
|
106
|
+
if (effect._tag === "MakeDir") {
|
|
107
|
+
if (seenDirPaths.has(effect.path)) {
|
|
108
|
+
setCurrentEffect(null);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
seenDirPaths.add(effect.path);
|
|
112
|
+
}
|
|
113
|
+
setCompletedEffects((prev) => [
|
|
114
|
+
...prev,
|
|
115
|
+
{ id, effect, duration, timestamp },
|
|
116
|
+
]);
|
|
117
|
+
setCurrentEffect(null);
|
|
118
|
+
},
|
|
119
|
+
onLog: (level, message) => {
|
|
120
|
+
const id = logId++;
|
|
121
|
+
setLogMessages((prev) => [...prev, { id, level, message }]);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const duration = performance.now() - startTime;
|
|
126
|
+
onComplete(collectedEffects, duration);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const taskError: TaskError =
|
|
129
|
+
err instanceof Error
|
|
130
|
+
? {
|
|
131
|
+
code: "EXECUTION_ERROR",
|
|
132
|
+
message: err.message,
|
|
133
|
+
stack: err.stack,
|
|
134
|
+
}
|
|
135
|
+
: {
|
|
136
|
+
code: "UNKNOWN_ERROR",
|
|
137
|
+
message: String(err),
|
|
138
|
+
};
|
|
139
|
+
onError(taskError);
|
|
140
|
+
} finally {
|
|
141
|
+
setIsRunning(false);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
executeWithProgress();
|
|
146
|
+
}, [task, onComplete, onError, stamp]);
|
|
147
|
+
|
|
148
|
+
const logColor = (level: LogMessage["level"]) => {
|
|
149
|
+
switch (level) {
|
|
150
|
+
case "debug":
|
|
151
|
+
return "gray";
|
|
152
|
+
case "info":
|
|
153
|
+
return "blue";
|
|
154
|
+
case "warn":
|
|
155
|
+
return "yellow";
|
|
156
|
+
case "error":
|
|
157
|
+
return "red";
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const logIcon = (level: LogMessage["level"]) => {
|
|
162
|
+
switch (level) {
|
|
163
|
+
case "debug":
|
|
164
|
+
return "·";
|
|
165
|
+
case "info":
|
|
166
|
+
return "›";
|
|
167
|
+
case "warn":
|
|
168
|
+
return "⚠";
|
|
169
|
+
case "error":
|
|
170
|
+
return "✗";
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Box flexDirection="column">
|
|
176
|
+
{/* Show log messages */}
|
|
177
|
+
{logMessages.map((log) => (
|
|
178
|
+
<Text key={`log-${log.id}`}>
|
|
179
|
+
<Text color={logColor(log.level)}>{logIcon(log.level)}</Text>{" "}
|
|
180
|
+
<Text>{log.message}</Text>
|
|
181
|
+
</Text>
|
|
182
|
+
))}
|
|
183
|
+
{/* Show completed file effects (excluding internal/noisy effects) */}
|
|
184
|
+
{completedEffects
|
|
185
|
+
.filter((item) => !isInternalEffect(item.effect))
|
|
186
|
+
.map((item) => (
|
|
187
|
+
<Text key={`effect-${item.id}`}>
|
|
188
|
+
<Text color="green">✓</Text> {describeEffect(item.effect)}{" "}
|
|
189
|
+
<Text dimColor>({item.duration.toFixed(0)}ms)</Text>
|
|
190
|
+
</Text>
|
|
191
|
+
))}
|
|
192
|
+
{currentEffect && !isInternalEffect(currentEffect) && (
|
|
193
|
+
<Box>
|
|
194
|
+
<Spinner color="blue" />
|
|
195
|
+
<Text> {describeEffect(currentEffect)}</Text>
|
|
196
|
+
</Box>
|
|
197
|
+
)}
|
|
198
|
+
{isRunning && !currentEffect && (
|
|
199
|
+
<Box>
|
|
200
|
+
<Spinner color="blue" label="Executing..." />
|
|
201
|
+
</Box>
|
|
202
|
+
)}
|
|
203
|
+
</Box>
|
|
204
|
+
);
|
|
205
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileTreePreview Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a list of files that will be created/modified.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Box, Text } from "ink";
|
|
8
|
+
import type { Effect } from "../types.js";
|
|
9
|
+
|
|
10
|
+
export interface FileTreePreviewProps {
|
|
11
|
+
/** Effects to display */
|
|
12
|
+
effects: Effect[];
|
|
13
|
+
/** Title for the preview */
|
|
14
|
+
title?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const formatBytes = (bytes: number): string => {
|
|
18
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
19
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
20
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const getEffectInfo = (
|
|
24
|
+
effect: Effect,
|
|
25
|
+
): { path: string; action: string; size?: number } | null => {
|
|
26
|
+
switch (effect._tag) {
|
|
27
|
+
case "WriteFile":
|
|
28
|
+
return {
|
|
29
|
+
path: effect.path,
|
|
30
|
+
action: "create",
|
|
31
|
+
size: effect.content.length,
|
|
32
|
+
};
|
|
33
|
+
case "MakeDir":
|
|
34
|
+
return { path: effect.path, action: "mkdir" };
|
|
35
|
+
case "CopyFile":
|
|
36
|
+
return { path: effect.dest, action: "copy" };
|
|
37
|
+
case "DeleteFile":
|
|
38
|
+
return { path: effect.path, action: "delete" };
|
|
39
|
+
case "DeleteDirectory":
|
|
40
|
+
return { path: effect.path, action: "rmdir" };
|
|
41
|
+
default:
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const FileTreePreview = ({
|
|
47
|
+
effects,
|
|
48
|
+
title = "Files to be created:",
|
|
49
|
+
}: FileTreePreviewProps) => {
|
|
50
|
+
// Deduplicate effects by path (keep first occurrence, which preserves WriteFile over MakeDir)
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
const fileEffects = effects
|
|
53
|
+
.map(getEffectInfo)
|
|
54
|
+
.filter((info): info is NonNullable<typeof info> => {
|
|
55
|
+
if (info === null) return false;
|
|
56
|
+
if (seen.has(info.path)) return false;
|
|
57
|
+
seen.add(info.path);
|
|
58
|
+
return true;
|
|
59
|
+
})
|
|
60
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
61
|
+
|
|
62
|
+
if (fileEffects.length === 0) {
|
|
63
|
+
return (
|
|
64
|
+
<Box>
|
|
65
|
+
<Text dimColor>No files will be created.</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Box flexDirection="column">
|
|
72
|
+
<Text bold color="cyan">
|
|
73
|
+
{title}
|
|
74
|
+
</Text>
|
|
75
|
+
<Box marginLeft={1} flexDirection="column">
|
|
76
|
+
{fileEffects.map((info, i) => {
|
|
77
|
+
const actionColor =
|
|
78
|
+
info.action === "delete" || info.action === "rmdir"
|
|
79
|
+
? "red"
|
|
80
|
+
: "green";
|
|
81
|
+
const actionIcon =
|
|
82
|
+
info.action === "delete" || info.action === "rmdir" ? "-" : "+";
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Box key={`${info.path}-${i}`}>
|
|
86
|
+
<Text color={actionColor}>{actionIcon} </Text>
|
|
87
|
+
<Text>{info.path}</Text>
|
|
88
|
+
{info.size !== undefined && (
|
|
89
|
+
<Text dimColor> ({formatBytes(info.size)})</Text>
|
|
90
|
+
)}
|
|
91
|
+
</Box>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</Box>
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
};
|