@yaawn/workflow 0.3.1 → 0.5.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 +22 -43
- package/agents/personas/yaawn-product-owner.md +4 -4
- package/agents/skills/yaawn-create-plan/SKILL.md +83 -50
- package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
- package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
- package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
- package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-story/SKILL.md +8 -1
- package/agents/skills/yaawn-grill-story/references/concept-challenge.md +12 -2
- package/agents/skills/yaawn-grill-story/references/story-checklist.md +2 -1
- package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
- package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
- package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
- package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
- package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
- package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
- package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
- package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
- package/agents/skills/yaawn-save-plan/SKILL.md +26 -7
- package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +75 -9
- package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +97 -1
- package/agents/skills/yaawn-split-story/SKILL.md +1 -1
- package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
- package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
- package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
- package/agents/skills/yaawn-write-other/SKILL.md +6 -5
- package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
- package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
- package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
- package/agents/skills/yaawn-write-user-story/SKILL.md +4 -3
- package/agents/yaawn-glossary.md +6 -5
- package/config/install-tasks.json +0 -1
- package/config/wizard.json +5 -10
- package/dist/cli/args.js +66 -0
- package/dist/cli/errors.js +103 -0
- package/dist/cli/exit-codes.js +3 -0
- package/dist/cli/index.js +11 -0
- package/dist/cli/install-report.js +92 -0
- package/dist/cli/install.js +124 -0
- package/dist/cli/main.js +17 -0
- package/dist/cli/package-root.js +17 -0
- package/dist/cli/project-root.js +11 -0
- package/dist/cli/run.js +141 -0
- package/dist/cli/session.js +1 -0
- package/dist/cli/terminal.js +5 -0
- package/dist/cli/uninstall.js +89 -0
- package/dist/cli/version.js +7 -0
- package/dist/config/agents.js +108 -0
- package/dist/config/config.js +118 -0
- package/dist/config/errors.js +45 -0
- package/dist/config/index.js +11 -0
- package/dist/config/manifest.js +28 -0
- package/dist/config/messages.js +86 -0
- package/dist/config/names.js +40 -0
- package/dist/config/paths.js +73 -0
- package/dist/config/settings.js +101 -0
- package/dist/config/strict-json.js +119 -0
- package/dist/config/tasks.js +77 -0
- package/dist/config/wizard.js +139 -0
- package/dist/display/index.js +3 -0
- package/dist/display/palette.js +61 -0
- package/dist/display/renderer.js +169 -0
- package/dist/display/report.js +15 -0
- package/dist/display/sanitize.js +10 -0
- package/dist/link/errors.js +34 -0
- package/dist/link/fs-utils.js +79 -0
- package/dist/link/index.js +4 -0
- package/dist/link/install.js +20 -0
- package/dist/link/linker.js +137 -0
- package/dist/link/memory.js +221 -0
- package/dist/link/names.js +30 -0
- package/dist/link/types.js +30 -0
- package/dist/link/uninstall.js +90 -0
- package/dist/sync/apply.js +74 -0
- package/dist/sync/changes.js +31 -0
- package/dist/sync/fs-utils.js +63 -0
- package/dist/sync/index.js +7 -0
- package/dist/sync/install.js +57 -0
- package/dist/sync/languages.js +80 -0
- package/dist/sync/plan.js +146 -0
- package/dist/sync/projects.js +294 -0
- package/dist/sync/uninstall.js +234 -0
- package/dist/wizard/build.js +89 -0
- package/dist/wizard/index.js +5 -0
- package/dist/wizard/items.js +83 -0
- package/dist/wizard/prompts.js +167 -0
- package/dist/wizard/section.js +143 -0
- package/dist/wizard/wizard.js +73 -0
- package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
- package/package.json +11 -10
- package/yaawn/yaawn-user-roles.md +1 -1
- package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
- package/scripts/install.js +0 -73
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readdir, realpath, rm, rmdir, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { contain, installedRenames, PROJECT_DIR } from "../config/index.js";
|
|
4
|
+
import { containEntry, lstatIfExists, pathExists, readSortedDir, toDisplayPath } from "./fs-utils.js";
|
|
5
|
+
/** The project-relative directory holding the user's plans; uninstall never removes it. */
|
|
6
|
+
export const PLANS_DIR = ".yaawn/plans";
|
|
7
|
+
/** A failed removal; result lists everything removed before the failure. There is no rollback. */
|
|
8
|
+
export class UninstallError extends Error {
|
|
9
|
+
display;
|
|
10
|
+
result;
|
|
11
|
+
name = "UninstallError";
|
|
12
|
+
constructor(display, result, cause) {
|
|
13
|
+
super(`remove ${display}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
14
|
+
this.display = display;
|
|
15
|
+
this.result = result;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Every planned file entry in removal order: the settings file first, then the target entries. */
|
|
19
|
+
export function uninstallEntries(plan) {
|
|
20
|
+
return [...plan.settings, ...plan.targets.flatMap((target) => target.entries)];
|
|
21
|
+
}
|
|
22
|
+
/** Reports whether no shipped file exists in the project. */
|
|
23
|
+
export function isUninstallPlanEmpty(plan) {
|
|
24
|
+
return uninstallEntries(plan).length === 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reports whether yaawn files are present. A planning error counts as not
|
|
28
|
+
* installed, so the install runs unchanged and reports the problem itself.
|
|
29
|
+
*/
|
|
30
|
+
export async function isYaawnInstalled(config, packageRoot, projectRoot) {
|
|
31
|
+
try {
|
|
32
|
+
return !isUninstallPlanEmpty(await planUninstall(config, packageRoot, projectRoot));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Lists the outermost prefixed entry of every path the package ships, per
|
|
40
|
+
* sync target, keeping only entries that exist in the project.
|
|
41
|
+
* Language-tagged files count regardless of the selection. It only reads
|
|
42
|
+
* the file system and fails when any destination escapes the project root.
|
|
43
|
+
*/
|
|
44
|
+
export async function planUninstall(config, packageRoot, projectRoot) {
|
|
45
|
+
const absoluteProject = path.resolve(projectRoot);
|
|
46
|
+
const planner = new UninstallPlanner(config, packageRoot, absoluteProject, await realpath(absoluteProject));
|
|
47
|
+
for (const [index, target] of config.tasks.syncTargets.entries()) {
|
|
48
|
+
await planner.planTarget(index, target);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
projectRoot: absoluteProject,
|
|
52
|
+
settings: await planSettings(config, absoluteProject),
|
|
53
|
+
targets: planner.targets,
|
|
54
|
+
keptPlans: await pathExists(contain(absoluteProject, PLANS_DIR)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Lists the agent settings file when it exists as a file or link; a directory of that name is not ours and stays. */
|
|
58
|
+
async function planSettings(config, projectRoot) {
|
|
59
|
+
const name = config.settings.settingsFileName;
|
|
60
|
+
const destination = path.join(contain(projectRoot, PROJECT_DIR), name);
|
|
61
|
+
const info = await lstatIfExists(destination);
|
|
62
|
+
if (info === undefined || info.isDirectory()) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return [{ path: destination, display: path.posix.join(PROJECT_DIR, name), parentRelative: PROJECT_DIR, fileOnly: true }];
|
|
66
|
+
}
|
|
67
|
+
class UninstallPlanner {
|
|
68
|
+
config;
|
|
69
|
+
packageRoot;
|
|
70
|
+
projectRoot;
|
|
71
|
+
realProject;
|
|
72
|
+
targets = [];
|
|
73
|
+
seen = new Map();
|
|
74
|
+
constructor(config, packageRoot, projectRoot, realProject) {
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.packageRoot = packageRoot;
|
|
77
|
+
this.projectRoot = projectRoot;
|
|
78
|
+
this.realProject = realProject;
|
|
79
|
+
}
|
|
80
|
+
async planTarget(index, target) {
|
|
81
|
+
this.targets.push({ target, targetDir: contain(this.projectRoot, target.target), entries: [] });
|
|
82
|
+
const sourceDir = contain(this.packageRoot, target.source);
|
|
83
|
+
if (!(await isDirectory(sourceDir))) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await this.walkPrefixed(index, target, sourceDir, "");
|
|
87
|
+
for (const name of installedRenames(target)) {
|
|
88
|
+
await this.addEntry(index, path.posix.join(target.target, name), true);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async walkPrefixed(index, target, sourceDir, relativeDir) {
|
|
92
|
+
const dir = relativeDir === "" ? sourceDir : path.join(sourceDir, ...relativeDir.split("/"));
|
|
93
|
+
for (const entry of (await readSortedDir(dir)) ?? []) {
|
|
94
|
+
await this.visit(index, target, sourceDir, path.posix.join(relativeDir, entry.name), entry);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async visit(index, target, sourceDir, relative, entry) {
|
|
98
|
+
if (!entry.name.startsWith(this.config.settings.filePrefix)) {
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
await this.walkPrefixed(index, target, sourceDir, relative);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (entry.isDirectory() || entry.isFile()) {
|
|
105
|
+
await this.addEntry(index, path.posix.join(target.target, relative), false);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Contains the entry's parent rather than the entry itself, so a
|
|
110
|
+
* symlinked entry is planned as the link, never as its target. The same
|
|
111
|
+
* file reached through a symlinked parent is listed once, preferring the
|
|
112
|
+
* path without symlinks.
|
|
113
|
+
*/
|
|
114
|
+
async addEntry(index, relative, fileOnly) {
|
|
115
|
+
const parentRelative = path.posix.dirname(relative);
|
|
116
|
+
const destination = containEntry(this.projectRoot, relative);
|
|
117
|
+
const info = await lstatIfExists(destination);
|
|
118
|
+
if (info === undefined || (fileOnly && info.isDirectory())) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const realParent = await realpath(path.dirname(destination));
|
|
122
|
+
const key = path.join(realParent, path.posix.basename(relative));
|
|
123
|
+
const viaSymlink = realParent !== path.join(this.realProject, ...parentRelative.split("/"));
|
|
124
|
+
const entry = { path: destination, display: relative, parentRelative, fileOnly };
|
|
125
|
+
const previous = this.seen.get(key);
|
|
126
|
+
if (previous !== undefined) {
|
|
127
|
+
this.preferDirectPath(key, previous, entry, viaSymlink);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const target = this.targets[index];
|
|
131
|
+
if (target === undefined) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.seen.set(key, { target: index, index: target.entries.length, viaSymlink });
|
|
135
|
+
target.entries.push(entry);
|
|
136
|
+
}
|
|
137
|
+
preferDirectPath(key, previous, entry, viaSymlink) {
|
|
138
|
+
const entries = this.targets[previous.target]?.entries;
|
|
139
|
+
if (entries === undefined || !previous.viaSymlink || viaSymlink) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
entries[previous.index] = entry;
|
|
143
|
+
this.seen.set(key, { ...previous, viaSymlink: false });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function isDirectory(target) {
|
|
147
|
+
try {
|
|
148
|
+
return (await stat(target)).isDirectory();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Removes the settings file and every planned target entry, pruning the
|
|
156
|
+
* directories it emptied below each target root. It stops at the first
|
|
157
|
+
* failure with an UninstallError listing what was removed so far.
|
|
158
|
+
*/
|
|
159
|
+
export async function applyUninstall(plan) {
|
|
160
|
+
const removed = [];
|
|
161
|
+
const pruned = [];
|
|
162
|
+
const resultSoFar = () => ({ removed, pruned, keptPlans: plan.keptPlans });
|
|
163
|
+
for (const entry of plan.settings) {
|
|
164
|
+
await attempt(entry.display, resultSoFar, async () => {
|
|
165
|
+
if (await removeEntry(plan.projectRoot, entry)) {
|
|
166
|
+
removed.push(entry.display);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
for (const target of plan.targets) {
|
|
171
|
+
for (const entry of target.entries) {
|
|
172
|
+
await attempt(entry.display, resultSoFar, async () => {
|
|
173
|
+
if (await removeEntry(plan.projectRoot, entry)) {
|
|
174
|
+
removed.push(entry.display);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
await attempt(entry.display, resultSoFar, () => pruneEmptyDirs(plan.projectRoot, target.targetDir, path.dirname(entry.path), pruned));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return resultSoFar();
|
|
181
|
+
}
|
|
182
|
+
async function attempt(display, resultSoFar, action) {
|
|
183
|
+
try {
|
|
184
|
+
await action();
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
throw new UninstallError(display, resultSoFar(), error);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Re-checks containment right before removing and removes the entry
|
|
192
|
+
* itself: a symlink is unlinked, never followed. A directory that took the
|
|
193
|
+
* place of a file-only entry stays.
|
|
194
|
+
*/
|
|
195
|
+
async function removeEntry(projectRoot, entry) {
|
|
196
|
+
const destination = containEntry(projectRoot, path.posix.join(entry.parentRelative, path.basename(entry.path)));
|
|
197
|
+
const info = await lstatIfExists(destination);
|
|
198
|
+
if (info === undefined) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (info.isDirectory()) {
|
|
202
|
+
if (entry.fileOnly) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
await rm(destination, { recursive: true, force: true });
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
await unlink(destination);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Removes dir and its ancestors while they are empty real directories
|
|
213
|
+
* strictly inside targetDir. Only directories that contained a removed
|
|
214
|
+
* entry are visited, so directories empty before the run stay.
|
|
215
|
+
*/
|
|
216
|
+
async function pruneEmptyDirs(projectRoot, targetDir, startDir, pruned) {
|
|
217
|
+
let dir = startDir;
|
|
218
|
+
while (isStrictlyWithin(targetDir, dir)) {
|
|
219
|
+
const info = await lstatIfExists(dir);
|
|
220
|
+
if (info === undefined || !info.isDirectory() || (await readdir(dir)).length > 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await rmdir(dir);
|
|
224
|
+
pruned.push(toDisplayPath(projectRoot, dir));
|
|
225
|
+
dir = path.dirname(dir);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function isStrictlyWithin(root, candidate) {
|
|
229
|
+
const relative = path.relative(root, candidate);
|
|
230
|
+
if (relative === "" || path.isAbsolute(relative)) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`);
|
|
234
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { KIND_MULTISELECT, KIND_PROJECTS_LIST, KIND_SINGLE_SELECT, KIND_TEXT_LIST, } from "../config/index.js";
|
|
3
|
+
import { formatFor, parseItems } from "./items.js";
|
|
4
|
+
import { Section } from "./section.js";
|
|
5
|
+
import { Wizard } from "./wizard.js";
|
|
6
|
+
/**
|
|
7
|
+
* Creates the wizard from the configured sections in wizard.json order.
|
|
8
|
+
* The multiselect section is left out when there are no options; a
|
|
9
|
+
* text-list section is left out when its file is missing. The agent
|
|
10
|
+
* section preselects the requested agent, else the previous choice, else
|
|
11
|
+
* its configured default. The projects-list section is bound to no file.
|
|
12
|
+
*/
|
|
13
|
+
export function buildWizard(config, input) {
|
|
14
|
+
const sections = [];
|
|
15
|
+
const skipped = [];
|
|
16
|
+
for (const spec of config.wizard.sections) {
|
|
17
|
+
switch (spec.kind) {
|
|
18
|
+
case KIND_SINGLE_SELECT:
|
|
19
|
+
sections.push(newAgentSelect(spec, input));
|
|
20
|
+
break;
|
|
21
|
+
case KIND_PROJECTS_LIST:
|
|
22
|
+
sections.push(new Section(spec));
|
|
23
|
+
break;
|
|
24
|
+
case KIND_MULTISELECT:
|
|
25
|
+
if (input.languages.length > 0) {
|
|
26
|
+
sections.push(new Section(spec, { options: input.languages }));
|
|
27
|
+
}
|
|
28
|
+
break;
|
|
29
|
+
case KIND_TEXT_LIST: {
|
|
30
|
+
const file = seededFileFor(config, spec, input.seededFiles);
|
|
31
|
+
if (file.content === undefined) {
|
|
32
|
+
skipped.push({ id: spec.id, fileName: file.fileName, path: file.path });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
sections.push(newTextList(spec, file.fileName, file.path, file.content));
|
|
36
|
+
}
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { wizard: new Wizard(sections), skipped };
|
|
42
|
+
}
|
|
43
|
+
function newAgentSelect(spec, input) {
|
|
44
|
+
const options = spec.options.map((option) => ({ value: option.value, label: option.label, selected: false }));
|
|
45
|
+
const section = new Section(spec, { options });
|
|
46
|
+
section.setSelected([input.requestedAgent, input.previousAgent, spec.default]);
|
|
47
|
+
return section;
|
|
48
|
+
}
|
|
49
|
+
function newTextList(spec, fileName, path, content) {
|
|
50
|
+
const format = formatFor(spec.targetFile);
|
|
51
|
+
return new Section(spec, {
|
|
52
|
+
existing: parseItems(content, format),
|
|
53
|
+
file: { fileName, path, content, format },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function seededFileFor(config, spec, seededFiles) {
|
|
57
|
+
const missing = { fileName: config.outputFile(spec.targetFile)?.name ?? spec.targetFile, path: "", content: undefined };
|
|
58
|
+
return seededFiles.get(spec.targetFile) ?? missing;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reads the output file of every text-list section from the project,
|
|
62
|
+
* falling back to the package's seed copy before the first sync. Paths
|
|
63
|
+
* are contained in their roots; read errors other than a missing file
|
|
64
|
+
* are thrown so the install fails closed.
|
|
65
|
+
*/
|
|
66
|
+
export function readSeededFiles(config, packageRoot, projectRoot) {
|
|
67
|
+
const files = new Map();
|
|
68
|
+
for (const spec of config.wizard.sections.filter((section) => section.kind === KIND_TEXT_LIST)) {
|
|
69
|
+
const output = config.outputFile(spec.targetFile);
|
|
70
|
+
if (output === undefined) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const destination = output.destinationPath(projectRoot);
|
|
74
|
+
const content = readOptional(destination) ?? readOptional(output.sourcePath(packageRoot));
|
|
75
|
+
files.set(spec.targetFile, { fileName: output.name, path: destination, content });
|
|
76
|
+
}
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
function readOptional(filePath) {
|
|
80
|
+
try {
|
|
81
|
+
return fs.readFileSync(filePath, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { buildWizard, readSeededFiles, } from "./build.js";
|
|
2
|
+
export { appendItems, ChecklistFormat, CommandFormat, formatFor, normalize, parseItems, PlainFormat, } from "./items.js";
|
|
3
|
+
export { clackPrompts, runWizard, } from "./prompts.js";
|
|
4
|
+
export { ControlCharacterError, DuplicateItemError, EmptyItemError, Section, } from "./section.js";
|
|
5
|
+
export { Wizard } from "./wizard.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const CHECKLIST_MARKER = "- [ ] ";
|
|
2
|
+
const BULLET_MARKER = "- ";
|
|
3
|
+
// The outputFileNames key whose items are commands rather than checklist tasks.
|
|
4
|
+
const COMMANDS_FILE_KEY = "verificationCommands";
|
|
5
|
+
// The outputFileNames key whose items are plain bullets rather than checklist tasks.
|
|
6
|
+
const USER_ROLES_FILE_KEY = "userRoles";
|
|
7
|
+
const BACKTICK_WRAPPED = /^`([^`]+)`$/;
|
|
8
|
+
const WHITESPACE_RUN = /\s+/;
|
|
9
|
+
/** Writes items as "- [ ] item". */
|
|
10
|
+
export class ChecklistFormat {
|
|
11
|
+
parse(line) {
|
|
12
|
+
return line.startsWith(CHECKLIST_MARKER) ? line.slice(CHECKLIST_MARKER.length) : undefined;
|
|
13
|
+
}
|
|
14
|
+
format(item) {
|
|
15
|
+
return CHECKLIST_MARKER + item;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Writes items as "- `command`", unless the command contains a backtick itself. */
|
|
19
|
+
export class CommandFormat {
|
|
20
|
+
parse(line) {
|
|
21
|
+
const item = parseBullet(line);
|
|
22
|
+
if (item === undefined) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return BACKTICK_WRAPPED.exec(item)?.[1] ?? item;
|
|
26
|
+
}
|
|
27
|
+
format(command) {
|
|
28
|
+
return command.includes("`") ? BULLET_MARKER + command : `${BULLET_MARKER}\`${command}\``;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Writes items as "- item"; line breaks inside an item are collapsed so one item always stays one bullet. */
|
|
32
|
+
export class PlainFormat {
|
|
33
|
+
parse(line) {
|
|
34
|
+
return parseBullet(line);
|
|
35
|
+
}
|
|
36
|
+
format(item) {
|
|
37
|
+
return BULLET_MARKER + collapseWhitespace(item);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function parseBullet(line) {
|
|
41
|
+
if (!line.startsWith(BULLET_MARKER)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const item = line.slice(BULLET_MARKER.length).trim();
|
|
45
|
+
return item === "" ? undefined : item;
|
|
46
|
+
}
|
|
47
|
+
function collapseWhitespace(text) {
|
|
48
|
+
return text
|
|
49
|
+
.split(WHITESPACE_RUN)
|
|
50
|
+
.filter((word) => word !== "")
|
|
51
|
+
.join(" ");
|
|
52
|
+
}
|
|
53
|
+
/** The list format of the output file registered under the given outputFileNames key. */
|
|
54
|
+
export function formatFor(targetFile) {
|
|
55
|
+
switch (targetFile) {
|
|
56
|
+
case COMMANDS_FILE_KEY:
|
|
57
|
+
return new CommandFormat();
|
|
58
|
+
case USER_ROLES_FILE_KEY:
|
|
59
|
+
return new PlainFormat();
|
|
60
|
+
default:
|
|
61
|
+
return new ChecklistFormat();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The list items found in content. */
|
|
65
|
+
export function parseItems(content, format) {
|
|
66
|
+
return content
|
|
67
|
+
.split("\n")
|
|
68
|
+
.map((line) => format.parse(line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
69
|
+
.filter((item) => item !== undefined);
|
|
70
|
+
}
|
|
71
|
+
/** content with the formatted items appended, one per line, ending in a newline; unchanged without items. */
|
|
72
|
+
export function appendItems(content, items, format) {
|
|
73
|
+
if (items.length === 0) {
|
|
74
|
+
return content;
|
|
75
|
+
}
|
|
76
|
+
const separator = content.endsWith("\n") ? "" : "\n";
|
|
77
|
+
const lines = items.map((item) => `${format.format(item)}\n`).join("");
|
|
78
|
+
return content + separator + lines;
|
|
79
|
+
}
|
|
80
|
+
/** The comparison key of an item: backticks removed, surrounding whitespace trimmed, lowercased. */
|
|
81
|
+
export function normalize(item) {
|
|
82
|
+
return item.replaceAll("`", "").trim().toLowerCase();
|
|
83
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { isCancel, log, multiselect, select, text } from "@clack/prompts";
|
|
3
|
+
import { fill, isAgent } from "../config/index.js";
|
|
4
|
+
import { sanitize } from "../display/index.js";
|
|
5
|
+
/** Throws the same error the guard recognises as a Ctrl+C cancellation. */
|
|
6
|
+
function cancelled() {
|
|
7
|
+
return Object.assign(new Error("cancelled"), { name: "ExitPromptError" });
|
|
8
|
+
}
|
|
9
|
+
function toClackOptions(choices) {
|
|
10
|
+
return choices.map((choice) => ({ value: choice.value, label: sanitize(choice.name) }));
|
|
11
|
+
}
|
|
12
|
+
/** Adapts the wizard's boolean|string validator to clack's string|undefined validator. */
|
|
13
|
+
function adaptValidate(validate) {
|
|
14
|
+
if (validate === undefined) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return async (value) => {
|
|
18
|
+
if (value === undefined) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const verdict = await validate(value);
|
|
22
|
+
if (verdict === true) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return typeof verdict === "string" ? verdict : "Invalid input";
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function streams(context) {
|
|
29
|
+
return {
|
|
30
|
+
input: context?.input,
|
|
31
|
+
output: context?.output,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export const clackPrompts = {
|
|
35
|
+
async select(config, context) {
|
|
36
|
+
const result = await select({
|
|
37
|
+
message: sanitize(config.message),
|
|
38
|
+
options: toClackOptions(config.choices),
|
|
39
|
+
initialValue: config.default,
|
|
40
|
+
...streams(context),
|
|
41
|
+
});
|
|
42
|
+
if (isCancel(result)) {
|
|
43
|
+
throw cancelled();
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
},
|
|
47
|
+
async input(config, context) {
|
|
48
|
+
const result = await text({
|
|
49
|
+
message: sanitize(config.message),
|
|
50
|
+
validate: adaptValidate(config.validate),
|
|
51
|
+
...streams(context),
|
|
52
|
+
});
|
|
53
|
+
if (isCancel(result)) {
|
|
54
|
+
throw cancelled();
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
},
|
|
58
|
+
async checkbox(config, context) {
|
|
59
|
+
const result = await multiselect({
|
|
60
|
+
message: sanitize(config.message),
|
|
61
|
+
options: config.choices.map((choice) => ({
|
|
62
|
+
value: choice.value,
|
|
63
|
+
label: sanitize(choice.name),
|
|
64
|
+
})),
|
|
65
|
+
initialValues: config.choices.filter((choice) => choice.checked).map((choice) => choice.value),
|
|
66
|
+
...streams(context),
|
|
67
|
+
});
|
|
68
|
+
if (isCancel(result)) {
|
|
69
|
+
throw cancelled();
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
// No wizard.json message exists for this rejection.
|
|
75
|
+
const CONTROL_CHARACTER_MESSAGE = "Remove the control characters from the item.";
|
|
76
|
+
const ITEM_INDENT = " ";
|
|
77
|
+
/**
|
|
78
|
+
* Asks one prompt per section in wizard.json order and returns what was
|
|
79
|
+
* chosen. Nothing is written; Ctrl+C rejects with an ExitPromptError,
|
|
80
|
+
* which is passed on unchanged.
|
|
81
|
+
*/
|
|
82
|
+
export async function runWizard(wizard, options) {
|
|
83
|
+
const session = new PromptSession(options.messages, options.prompts ?? clackPrompts, options.context ?? {});
|
|
84
|
+
const total = wizard.sections.length;
|
|
85
|
+
for (const [index, section] of wizard.sections.entries()) {
|
|
86
|
+
await session.ask(section, index + 1, total);
|
|
87
|
+
}
|
|
88
|
+
return wizard.result(options.fallback);
|
|
89
|
+
}
|
|
90
|
+
class PromptSession {
|
|
91
|
+
messages;
|
|
92
|
+
prompts;
|
|
93
|
+
context;
|
|
94
|
+
output;
|
|
95
|
+
constructor(messages, prompts, context) {
|
|
96
|
+
this.messages = messages;
|
|
97
|
+
this.prompts = prompts;
|
|
98
|
+
this.context = context;
|
|
99
|
+
this.output = (context.output ?? process.stdout);
|
|
100
|
+
}
|
|
101
|
+
async ask(section, step, total) {
|
|
102
|
+
this.printHeading(section, step, total);
|
|
103
|
+
if (section.isSingleSelect()) {
|
|
104
|
+
await this.askAgent(section);
|
|
105
|
+
}
|
|
106
|
+
else if (section.isMultiselect()) {
|
|
107
|
+
await this.askLanguages(section);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
await this.askItems(section);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async askAgent(section) {
|
|
114
|
+
const choices = section.options.map((option) => ({ value: option.value, name: sanitize(option.label) }));
|
|
115
|
+
const agent = await this.prompts.select({ message: sanitize(section.title), choices, default: section.selectedValue() }, this.context);
|
|
116
|
+
if (isAgent(agent)) {
|
|
117
|
+
section.setSelected([agent]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async askLanguages(section) {
|
|
121
|
+
const choices = section.options.map((option) => ({
|
|
122
|
+
value: option.value,
|
|
123
|
+
name: sanitize(option.label),
|
|
124
|
+
checked: option.selected,
|
|
125
|
+
}));
|
|
126
|
+
const selected = await this.prompts.checkbox({ message: sanitize(section.title), choices }, this.context);
|
|
127
|
+
section.setSelected(selected);
|
|
128
|
+
}
|
|
129
|
+
/** Asks for items until an empty submit; duplicates and control characters are rejected in the prompt. */
|
|
130
|
+
async askItems(section) {
|
|
131
|
+
this.printItems(section.existing);
|
|
132
|
+
const validate = (value) => this.validateItem(section, value);
|
|
133
|
+
for (;;) {
|
|
134
|
+
const value = await this.prompts.input({ message: sanitize(section.title), validate }, this.context);
|
|
135
|
+
if (section.checkItem(value).status === "empty") {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
section.add(value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
validateItem(section, value) {
|
|
142
|
+
const check = section.checkItem(value);
|
|
143
|
+
switch (check.status) {
|
|
144
|
+
case "duplicate":
|
|
145
|
+
return fill(this.messages.duplicateItem, { item: sanitize(check.item) });
|
|
146
|
+
case "control-character":
|
|
147
|
+
return CONTROL_CHARACTER_MESSAGE;
|
|
148
|
+
default:
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
printHeading(section, step, total) {
|
|
153
|
+
log.message("", { output: this.output });
|
|
154
|
+
log.step(`${step}/${total} ${sanitize(section.title)}`, { output: this.output });
|
|
155
|
+
log.message(sanitize(section.description), { output: this.output });
|
|
156
|
+
}
|
|
157
|
+
printItems(items) {
|
|
158
|
+
if (items.length === 0) {
|
|
159
|
+
log.message(sanitize(this.messages.noItems), { output: this.output });
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
log.message(sanitize(this.messages.currentItems), { output: this.output });
|
|
163
|
+
items.forEach((item) => log.message(ITEM_INDENT + sanitize(item), { output: this.output }));
|
|
164
|
+
}
|
|
165
|
+
log.message(sanitize(this.messages.emptySubmit), { output: this.output });
|
|
166
|
+
}
|
|
167
|
+
}
|