@yaawn/workflow 0.4.0 → 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 +3 -3
- package/agents/skills/yaawn-create-plan/SKILL.md +38 -11
- 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 +7 -0
- 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-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 +3 -2
- package/agents/yaawn-glossary.md +4 -3
- package/config/install-tasks.json +0 -1
- package/config/wizard.json +3 -8
- 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/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
- package/scripts/install.js +0 -73
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isFileName, isRelativeDir, isRemovePattern } from "./names.js";
|
|
2
|
+
import { JsonLocation, optional, readArray, readStrictObject, readString, readStringArray, readStringRecord, required, sortedKeys, toRecord, } from "./strict-json.js";
|
|
3
|
+
/** Name of the sync task config file. */
|
|
4
|
+
export const INSTALL_TASKS_FILE = "install-tasks.json";
|
|
5
|
+
const TASKS_KEYS = ["syncTargets"];
|
|
6
|
+
const SYNC_TARGET_KEYS = ["source", "target", "seedOnly", "removeFirst", "languageInstructions", "renames"];
|
|
7
|
+
export function decodeInstallTasks(document) {
|
|
8
|
+
const at = new JsonLocation(INSTALL_TASKS_FILE);
|
|
9
|
+
const object = readStrictObject(document, at, TASKS_KEYS);
|
|
10
|
+
return {
|
|
11
|
+
syncTargets: required(object, "syncTargets", at, (value, location) => readArray(value, location, decodeSyncTarget)),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function decodeSyncTarget(value, at) {
|
|
15
|
+
const object = readStrictObject(value, at, SYNC_TARGET_KEYS);
|
|
16
|
+
const languageInstructions = optional(object, "languageInstructions", at, readString, "");
|
|
17
|
+
return {
|
|
18
|
+
source: required(object, "source", at, readString),
|
|
19
|
+
target: required(object, "target", at, readString),
|
|
20
|
+
seedOnly: optional(object, "seedOnly", at, readStringArray, []),
|
|
21
|
+
removeFirst: optional(object, "removeFirst", at, readStringArray, []),
|
|
22
|
+
...(languageInstructions === "" ? {} : { languageInstructions }),
|
|
23
|
+
renames: optional(object, "renames", at, readStringRecord, toRecord([])),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function validateInstallTasks(tasks, ownedPrefix) {
|
|
27
|
+
const at = new JsonLocation(INSTALL_TASKS_FILE).key("syncTargets");
|
|
28
|
+
if (tasks.syncTargets.length === 0) {
|
|
29
|
+
throw at.error("must be a non-empty array");
|
|
30
|
+
}
|
|
31
|
+
tasks.syncTargets.forEach((target, position) => validateSyncTarget(target, at.index(position), ownedPrefix));
|
|
32
|
+
}
|
|
33
|
+
function validateSyncTarget(target, at, ownedPrefix) {
|
|
34
|
+
if (!isRelativeDir(target.source)) {
|
|
35
|
+
throw at.key("source").error("must be a relative directory inside the package");
|
|
36
|
+
}
|
|
37
|
+
if (!isRelativeDir(target.target)) {
|
|
38
|
+
throw at.key("target").error("must be a relative directory inside the project");
|
|
39
|
+
}
|
|
40
|
+
validateSeedOnly(target.seedOnly, at.key("seedOnly"));
|
|
41
|
+
validateRemoveFirst(target.removeFirst, at.key("removeFirst"), ownedPrefix);
|
|
42
|
+
if (target.languageInstructions !== undefined && !isRelativeDir(target.languageInstructions)) {
|
|
43
|
+
throw at.key("languageInstructions").error("must be a relative directory name");
|
|
44
|
+
}
|
|
45
|
+
validateRenames(target.renames, at.key("renames"));
|
|
46
|
+
}
|
|
47
|
+
function validateSeedOnly(seedOnly, at) {
|
|
48
|
+
const position = seedOnly.findIndex((name) => !isFileName(name));
|
|
49
|
+
if (position !== -1) {
|
|
50
|
+
throw at.index(position).error("must be a plain file name");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function validateRemoveFirst(removeFirst, at, ownedPrefix) {
|
|
54
|
+
const position = removeFirst.findIndex((pattern) => !isRemovePattern(pattern, ownedPrefix));
|
|
55
|
+
if (position !== -1) {
|
|
56
|
+
throw at.index(position).error(`must look like "dir/${ownedPrefix}*" (relative, no "..")`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Keeps both sides plain file names, so a rename can never point outside the target directory. */
|
|
60
|
+
function validateRenames(renames, at) {
|
|
61
|
+
const invalidSource = sortedKeys(renames).find((source) => !isFileName(source) || !isFileName(renames[source] ?? ""));
|
|
62
|
+
if (invalidSource !== undefined) {
|
|
63
|
+
throw at.key(invalidSource).error("must map a plain file name to a plain file name");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Reports whether a file with this name is only copied when it is missing in the project. */
|
|
67
|
+
export function isSeedOnly(target, name) {
|
|
68
|
+
return target.seedOnly.includes(name);
|
|
69
|
+
}
|
|
70
|
+
/** Returns the name a file directly inside the source is installed under. */
|
|
71
|
+
export function installedName(target, name) {
|
|
72
|
+
return Object.hasOwn(target.renames, name) ? (target.renames[name] ?? name) : name;
|
|
73
|
+
}
|
|
74
|
+
/** Lists the renamed destination names in stable order. */
|
|
75
|
+
export function installedRenames(target) {
|
|
76
|
+
return sortedKeys(target.renames).map((source) => target.renames[source] ?? source);
|
|
77
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { agents, isAgent } from "./agents.js";
|
|
2
|
+
import { decodeMessages } from "./messages.js";
|
|
3
|
+
import { SETTINGS_FILE } from "./settings.js";
|
|
4
|
+
import { JsonLocation, optional, readArray, readStrictObject, readString, required, } from "./strict-json.js";
|
|
5
|
+
/** Name of the wizard config file. */
|
|
6
|
+
export const WIZARD_FILE = "wizard.json";
|
|
7
|
+
export const KIND_TEXT_LIST = "text-list";
|
|
8
|
+
export const KIND_MULTISELECT = "multiselect";
|
|
9
|
+
export const KIND_SINGLE_SELECT = "single-select";
|
|
10
|
+
export const KIND_PROJECTS_LIST = "projects-list";
|
|
11
|
+
const SECTION_KINDS = [KIND_TEXT_LIST, KIND_MULTISELECT, KIND_SINGLE_SELECT, KIND_PROJECTS_LIST];
|
|
12
|
+
const SINGLETON_KINDS = [KIND_MULTISELECT, KIND_SINGLE_SELECT, KIND_PROJECTS_LIST];
|
|
13
|
+
const WIZARD_KEYS = ["sections", "messages"];
|
|
14
|
+
const SECTION_KEYS = ["id", "title", "description", "kind", "targetFile", "placeholder", "options", "default"];
|
|
15
|
+
const OPTION_KEYS = ["value", "label"];
|
|
16
|
+
export function decodeWizard(document) {
|
|
17
|
+
const at = new JsonLocation(WIZARD_FILE);
|
|
18
|
+
const object = readStrictObject(document, at, WIZARD_KEYS);
|
|
19
|
+
return {
|
|
20
|
+
sections: required(object, "sections", at, (value, location) => readArray(value, location, decodeSection)),
|
|
21
|
+
messages: required(object, "messages", at, decodeMessages),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function decodeSection(value, at) {
|
|
25
|
+
const object = readStrictObject(value, at, SECTION_KEYS);
|
|
26
|
+
return {
|
|
27
|
+
id: required(object, "id", at, readString),
|
|
28
|
+
title: required(object, "title", at, readString),
|
|
29
|
+
description: required(object, "description", at, readString),
|
|
30
|
+
kind: required(object, "kind", at, readSectionKind),
|
|
31
|
+
targetFile: optional(object, "targetFile", at, readString, ""),
|
|
32
|
+
placeholder: optional(object, "placeholder", at, readString, ""),
|
|
33
|
+
options: optional(object, "options", at, (options, location) => readArray(options, location, decodeOption), []),
|
|
34
|
+
default: optional(object, "default", at, readString, ""),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function readSectionKind(value, at) {
|
|
38
|
+
const kind = readString(value, at);
|
|
39
|
+
if (!SECTION_KINDS.includes(kind)) {
|
|
40
|
+
throw at.error(`must be ${SECTION_KINDS.map((name) => `"${name}"`).join(", ")}`);
|
|
41
|
+
}
|
|
42
|
+
return kind;
|
|
43
|
+
}
|
|
44
|
+
function decodeOption(value, at) {
|
|
45
|
+
const object = readStrictObject(value, at, OPTION_KEYS);
|
|
46
|
+
return {
|
|
47
|
+
value: required(object, "value", at, readAgent),
|
|
48
|
+
label: required(object, "label", at, readString),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function readAgent(value, at) {
|
|
52
|
+
const agent = readString(value, at);
|
|
53
|
+
if (!isAgent(agent)) {
|
|
54
|
+
throw at.error(`must be one of ${agents().map((name) => `"${name}"`).join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
return agent;
|
|
57
|
+
}
|
|
58
|
+
/** Validates the sections against the settings; messages are complete once decoded. */
|
|
59
|
+
export function validateWizard(wizard, settings) {
|
|
60
|
+
const at = new JsonLocation(WIZARD_FILE).key("sections");
|
|
61
|
+
if (wizard.sections.length === 0) {
|
|
62
|
+
throw at.error("must be a non-empty array");
|
|
63
|
+
}
|
|
64
|
+
wizard.sections.forEach((section, position) => validateSection(section, at.index(position), settings));
|
|
65
|
+
validateUniqueIds(wizard.sections, at);
|
|
66
|
+
validateSingletonKinds(wizard.sections, at);
|
|
67
|
+
}
|
|
68
|
+
function validateUniqueIds(sections, at) {
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
sections.forEach((section, position) => {
|
|
71
|
+
if (seen.has(section.id)) {
|
|
72
|
+
throw at.index(position).key("id").error("must be unique");
|
|
73
|
+
}
|
|
74
|
+
seen.add(section.id);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function validateSingletonKinds(sections, at) {
|
|
78
|
+
for (const kind of SINGLETON_KINDS) {
|
|
79
|
+
if (sections.filter((section) => section.kind === kind).length > 1) {
|
|
80
|
+
throw at.error(`must contain at most one ${kind} section`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function validateSection(section, at, settings) {
|
|
85
|
+
validateRequiredTexts(section, at);
|
|
86
|
+
if (section.kind === KIND_SINGLE_SELECT) {
|
|
87
|
+
validateSingleSelect(section, at);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
validateTargetFile(section, at, settings);
|
|
91
|
+
if (section.options.length > 0 || section.default !== "") {
|
|
92
|
+
throw at.key("options").error("are only allowed on a single-select section");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function validateRequiredTexts(section, at) {
|
|
96
|
+
for (const name of ["id", "title", "description"]) {
|
|
97
|
+
if (section[name] === "") {
|
|
98
|
+
throw at.key(name).error("is required");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function validateTargetFile(section, at, settings) {
|
|
103
|
+
if (section.kind === KIND_TEXT_LIST) {
|
|
104
|
+
if (!Object.hasOwn(settings.outputFileNames, section.targetFile)) {
|
|
105
|
+
throw at.key("targetFile").error(`must name a key of outputFileNames in ${SETTINGS_FILE}`);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (section.targetFile !== "") {
|
|
110
|
+
throw at.key("targetFile").error(`must be empty for a ${section.kind} section`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Requires a non-empty, unique set of allowed agents and a default that is one of them. */
|
|
114
|
+
function validateSingleSelect(section, at) {
|
|
115
|
+
if (section.targetFile !== "") {
|
|
116
|
+
throw at.key("targetFile").error(`must be empty for a ${KIND_SINGLE_SELECT} section`);
|
|
117
|
+
}
|
|
118
|
+
if (section.options.length === 0) {
|
|
119
|
+
throw at.key("options").error("must be a non-empty array");
|
|
120
|
+
}
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
section.options.forEach((option, position) => {
|
|
123
|
+
const optionAt = at.key("options").index(position);
|
|
124
|
+
if (seen.has(option.value)) {
|
|
125
|
+
throw optionAt.key("value").error("must be unique");
|
|
126
|
+
}
|
|
127
|
+
seen.add(option.value);
|
|
128
|
+
if (option.label === "") {
|
|
129
|
+
throw optionAt.key("label").error("is required");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
if (!seen.has(section.default)) {
|
|
133
|
+
throw at.key("default").error("must be the value of one of the options");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Returns the single-select section, if configured. */
|
|
137
|
+
export function singleSelectSection(wizard) {
|
|
138
|
+
return wizard.sections.find((section) => section.kind === KIND_SINGLE_SELECT);
|
|
139
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
const RESET_FOREGROUND = "\u001b[39m";
|
|
3
|
+
const BOLD = "\u001b[1m";
|
|
4
|
+
const RESET_BOLD_FAINT = "\u001b[22m";
|
|
5
|
+
const FAINT = "\u001b[2m";
|
|
6
|
+
const YELLOW = "\u001b[33m";
|
|
7
|
+
const TRUE_COLOR_DEPTH = 2 ** 24;
|
|
8
|
+
const ANSI_256_DEPTH = 256;
|
|
9
|
+
const HEX_COLOR = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/;
|
|
10
|
+
const plain = (text) => text;
|
|
11
|
+
/** Styles for the installer output; every style degrades to plain text on terminals without colour. */
|
|
12
|
+
export class Palette {
|
|
13
|
+
title;
|
|
14
|
+
brand;
|
|
15
|
+
muted;
|
|
16
|
+
warning;
|
|
17
|
+
constructor(brandColor, target, env = process.env) {
|
|
18
|
+
const colors = supportsColor(target, env);
|
|
19
|
+
const brandCode = colors ? foregroundCode(brandColor, target) : undefined;
|
|
20
|
+
this.brand = brandCode === undefined ? plain : wrap(brandCode, RESET_FOREGROUND);
|
|
21
|
+
this.title = colors ? (text) => wrap(BOLD, RESET_BOLD_FAINT)(this.brand(text)) : plain;
|
|
22
|
+
this.muted = colors ? wrap(FAINT, RESET_BOLD_FAINT) : plain;
|
|
23
|
+
this.warning = colors ? wrap(YELLOW, RESET_FOREGROUND) : plain;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function supportsColor(target, env) {
|
|
27
|
+
const noColor = env["NO_COLOR"];
|
|
28
|
+
if (noColor !== undefined && noColor !== "") {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return target.isTTY === true && target.hasColors?.() === true;
|
|
32
|
+
}
|
|
33
|
+
function foregroundCode(hex, target) {
|
|
34
|
+
const rgb = parseHex(hex);
|
|
35
|
+
if (rgb === undefined) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (target.hasColors?.(TRUE_COLOR_DEPTH) === true) {
|
|
39
|
+
return `\u001b[38;2;${rgb.join(";")}m`;
|
|
40
|
+
}
|
|
41
|
+
if (target.hasColors?.(ANSI_256_DEPTH) === true) {
|
|
42
|
+
return `\u001b[38;5;${toAnsi256(rgb)}m`;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function parseHex(hex) {
|
|
47
|
+
const match = HEX_COLOR.exec(hex);
|
|
48
|
+
if (match === null) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const [, red = "", green = "", blue = ""] = match;
|
|
52
|
+
return [Number.parseInt(red, 16), Number.parseInt(green, 16), Number.parseInt(blue, 16)];
|
|
53
|
+
}
|
|
54
|
+
/** Maps a colour onto the 6×6×6 cube of the 256-colour palette. */
|
|
55
|
+
function toAnsi256(rgb) {
|
|
56
|
+
const [red, green, blue] = rgb.map((channel) => Math.round((channel / 255) * 5));
|
|
57
|
+
return 16 + 36 * (red ?? 0) + 6 * (green ?? 0) + (blue ?? 0);
|
|
58
|
+
}
|
|
59
|
+
function wrap(open, close) {
|
|
60
|
+
return (text) => (text === "" ? text : `${open}${text}${close}`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
|
|
2
|
+
import { fill } from "../config/index.js";
|
|
3
|
+
import { Palette } from "./palette.js";
|
|
4
|
+
import { MODE_INSTALL, MODE_UNINSTALL, } from "./report.js";
|
|
5
|
+
import { sanitize } from "./sanitize.js";
|
|
6
|
+
const LIST_INDENT = " ";
|
|
7
|
+
/** Throws the same error the guard recognises as a Ctrl+C cancellation. */
|
|
8
|
+
function cancelled() {
|
|
9
|
+
return Object.assign(new Error("cancelled"), { name: "ExitPromptError" });
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Writes the installer's output with @clack/prompts: the left-rail guide,
|
|
13
|
+
* step symbols, animated spinner and interactive prompts. Every text comes
|
|
14
|
+
* from the config messages, and every printed string is sanitized.
|
|
15
|
+
*/
|
|
16
|
+
export class Renderer {
|
|
17
|
+
messages;
|
|
18
|
+
palette;
|
|
19
|
+
output;
|
|
20
|
+
constructor(messages, brandColor, output) {
|
|
21
|
+
this.messages = messages;
|
|
22
|
+
this.palette = new Palette(brandColor, output);
|
|
23
|
+
this.output = output;
|
|
24
|
+
}
|
|
25
|
+
/** Prints the branded title block for the given package version. */
|
|
26
|
+
intro(version) {
|
|
27
|
+
const values = { version };
|
|
28
|
+
intro(this.palette.title(this.text(this.messages.introTitle, values)), { output: this.output });
|
|
29
|
+
log.message(this.text(this.messages.introSubtitle, values), { output: this.output });
|
|
30
|
+
log.message(this.text(this.messages.intro, values), { output: this.output });
|
|
31
|
+
}
|
|
32
|
+
/** Prints the closing block with the next steps; values fill its placeholders. */
|
|
33
|
+
outro(values) {
|
|
34
|
+
outro(this.text(this.messages.outro, values), { output: this.output });
|
|
35
|
+
}
|
|
36
|
+
info(text) {
|
|
37
|
+
log.info(sanitize(text), { output: this.output });
|
|
38
|
+
}
|
|
39
|
+
/** Prints a skipped or unexpected step, marked by a symbol as well as colour. */
|
|
40
|
+
warn(text) {
|
|
41
|
+
log.warn(sanitize(text), { output: this.output });
|
|
42
|
+
}
|
|
43
|
+
/** Runs task behind the "installing" spinner. */
|
|
44
|
+
async installing(task) {
|
|
45
|
+
const s = spinner({ output: this.output });
|
|
46
|
+
s.start(this.text(this.messages.installing));
|
|
47
|
+
try {
|
|
48
|
+
const result = await task();
|
|
49
|
+
s.stop(this.text(this.messages.installing));
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
s.stop(this.text(this.messages.installing));
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Runs task behind the "removing" spinner. */
|
|
58
|
+
async uninstalling(task) {
|
|
59
|
+
const s = spinner({ output: this.output });
|
|
60
|
+
s.start(this.text(this.messages.uninstalling));
|
|
61
|
+
try {
|
|
62
|
+
const result = await task();
|
|
63
|
+
s.stop(this.text(this.messages.uninstalling));
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
s.stop(this.text(this.messages.uninstalling));
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Prints the warnings and install output, then the failure or, on success, the outro. */
|
|
72
|
+
installSummary(summary) {
|
|
73
|
+
summary.warnings.forEach((warning) => this.warn(warning));
|
|
74
|
+
summary.lines.forEach((line) => {
|
|
75
|
+
if (line.warning) {
|
|
76
|
+
this.warn(line.text);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
log.step(sanitize(line.text), { output: this.output });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (summary.failure !== undefined) {
|
|
83
|
+
log.error(sanitize(summary.failure), { output: this.output });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.outro(summary.outroValues);
|
|
87
|
+
}
|
|
88
|
+
/** Asks whether to install / update or uninstall, with install preselected. Ctrl+C rejects with ExitPromptError. */
|
|
89
|
+
async chooseMode() {
|
|
90
|
+
const result = await select({
|
|
91
|
+
message: this.text(this.messages.modeTitle),
|
|
92
|
+
options: [
|
|
93
|
+
{ value: MODE_INSTALL, label: this.text(this.messages.modeInstall) },
|
|
94
|
+
{ value: MODE_UNINSTALL, label: this.text(this.messages.modeUninstall) },
|
|
95
|
+
],
|
|
96
|
+
initialValue: MODE_INSTALL,
|
|
97
|
+
output: this.output,
|
|
98
|
+
});
|
|
99
|
+
if (isCancel(result)) {
|
|
100
|
+
throw cancelled();
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/** Lists entries and asks to remove them, with "No" preselected. Ctrl+C rejects with ExitPromptError. */
|
|
105
|
+
async confirmUninstall(entries) {
|
|
106
|
+
log.message(this.palette.title(this.text(this.messages.uninstallHeaderTitle)), { output: this.output });
|
|
107
|
+
log.message(this.pathList(this.messages.uninstallList, entries), { output: this.output });
|
|
108
|
+
const result = await confirm({
|
|
109
|
+
message: this.text(this.messages.uninstallConfirm),
|
|
110
|
+
active: this.text(this.messages.uninstallYes),
|
|
111
|
+
inactive: this.text(this.messages.uninstallNo),
|
|
112
|
+
initialValue: false,
|
|
113
|
+
output: this.output,
|
|
114
|
+
});
|
|
115
|
+
if (isCancel(result)) {
|
|
116
|
+
throw cancelled();
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
uninstallCancelled() {
|
|
121
|
+
log.warn(this.text(this.messages.uninstallCancelled), { output: this.output });
|
|
122
|
+
}
|
|
123
|
+
/** Prints that no yaawn files were found, followed by the npm hint. */
|
|
124
|
+
nothingToRemove(packageName) {
|
|
125
|
+
log.info(this.text(this.messages.nothingToRemove), { output: this.output });
|
|
126
|
+
log.message(this.npmHint(packageName), { output: this.output });
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Lists the removed entries, the pruned folders, the kept-plans note and the
|
|
130
|
+
* npm hint. After a failure it shows the error and what was removed so far
|
|
131
|
+
* instead, without the npm hint.
|
|
132
|
+
*/
|
|
133
|
+
uninstallSummary(report) {
|
|
134
|
+
if (report.failure !== undefined) {
|
|
135
|
+
log.error(fill(this.messages.uninstallFailed, { error: sanitize(report.failure) }), { output: this.output });
|
|
136
|
+
this.printPathList(this.messages.uninstallRemovedSoFar, report.summary.removed);
|
|
137
|
+
this.printPathList(this.messages.uninstallPruned, report.summary.pruned);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.printPathList(this.messages.uninstallRemoved, report.summary.removed);
|
|
141
|
+
this.printPathList(this.messages.uninstallPruned, report.summary.pruned);
|
|
142
|
+
if (report.summary.keptPlans) {
|
|
143
|
+
log.message(this.text(this.messages.uninstallKeptPlans), { output: this.output });
|
|
144
|
+
}
|
|
145
|
+
log.message(this.npmHint(report.packageName), { output: this.output });
|
|
146
|
+
}
|
|
147
|
+
printPathList(heading, paths) {
|
|
148
|
+
if (paths.length === 0) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const lines = paths.map((path) => LIST_INDENT + sanitize(path));
|
|
152
|
+
log.message([this.palette.title(sanitize(heading)), ...lines].join("\n"), { output: this.output });
|
|
153
|
+
}
|
|
154
|
+
/** Renders heading and the paths below it, or "" when there are none. */
|
|
155
|
+
pathList(heading, paths) {
|
|
156
|
+
if (paths.length === 0) {
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
const lines = paths.map((path) => LIST_INDENT + sanitize(path));
|
|
160
|
+
return [this.palette.title(sanitize(heading)), ...lines].join("\n");
|
|
161
|
+
}
|
|
162
|
+
npmHint(packageName) {
|
|
163
|
+
return this.text(this.messages.uninstallNpmHint, { package: packageName });
|
|
164
|
+
}
|
|
165
|
+
/** Fills template, then sanitizes the result so neither config nor values can drive the terminal. */
|
|
166
|
+
text(template, values = {}) {
|
|
167
|
+
return sanitize(fill(template, values));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Records progress lines while the spinner runs, so they print afterwards without garbling it. */
|
|
2
|
+
export class ReportCollector {
|
|
3
|
+
recorded = [];
|
|
4
|
+
get lines() {
|
|
5
|
+
return this.recorded;
|
|
6
|
+
}
|
|
7
|
+
info(text) {
|
|
8
|
+
this.recorded.push({ text, warning: false });
|
|
9
|
+
}
|
|
10
|
+
warn(text) {
|
|
11
|
+
this.recorded.push({ text, warning: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const MODE_INSTALL = "install";
|
|
15
|
+
export const MODE_UNINSTALL = "uninstall";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
// eslint-disable-next-line no-control-regex -- matches the C0/C1 control characters that could drive the terminal
|
|
3
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
4
|
+
/**
|
|
5
|
+
* Strips escape sequences and control characters other than line breaks
|
|
6
|
+
* and tabs, so text from the user's disk or config cannot drive the terminal.
|
|
7
|
+
*/
|
|
8
|
+
export function sanitize(text) {
|
|
9
|
+
return stripVTControlCharacters(text).replace(CONTROL_CHARACTERS, "");
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The managed block's start and end markers are incomplete, repeated or out of order. */
|
|
2
|
+
export class MalformedBlockError extends Error {
|
|
3
|
+
name = "MalformedBlockError";
|
|
4
|
+
constructor() {
|
|
5
|
+
super("the yaawn import markers are incomplete or repeated; fix them by hand");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** A memory file larger than the installer reads. */
|
|
9
|
+
export class MemoryTooLargeError extends Error {
|
|
10
|
+
limit;
|
|
11
|
+
name = "MemoryTooLargeError";
|
|
12
|
+
constructor(limit) {
|
|
13
|
+
super(`file is larger than ${limit} bytes`);
|
|
14
|
+
this.limit = limit;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A path that is not one of the memory files yaawn manages an instruction block in. */
|
|
18
|
+
export class UnmanagedFileError extends Error {
|
|
19
|
+
path;
|
|
20
|
+
name = "UnmanagedFileError";
|
|
21
|
+
constructor(path) {
|
|
22
|
+
super("not a file yaawn manages an instruction block in");
|
|
23
|
+
this.path = path;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** A path that is not one of the agent folders yaawn links, nor an entry directly inside one. */
|
|
27
|
+
export class UnmanagedLinkError extends Error {
|
|
28
|
+
path;
|
|
29
|
+
name = "UnmanagedLinkError";
|
|
30
|
+
constructor(path) {
|
|
31
|
+
super("not a link yaawn manages");
|
|
32
|
+
this.path = path;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmod, lstat, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { contain } from "../config/index.js";
|
|
5
|
+
export const DIR_MODE = 0o755;
|
|
6
|
+
export const FILE_MODE = 0o644;
|
|
7
|
+
const PERMISSION_BITS = 0o777;
|
|
8
|
+
export function isMissing(error) {
|
|
9
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
10
|
+
}
|
|
11
|
+
/** The entry's own stats without following a symlink, or undefined when it does not exist. */
|
|
12
|
+
export async function lstatIfExists(target) {
|
|
13
|
+
try {
|
|
14
|
+
return await lstat(target);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (isMissing(error)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function isDirectory(target) {
|
|
24
|
+
try {
|
|
25
|
+
return (await stat(target)).isDirectory();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Reports whether the slash-separated elements name a directory contained in root. */
|
|
32
|
+
export async function isContainedDirectory(root, ...elements) {
|
|
33
|
+
try {
|
|
34
|
+
return await isDirectory(contain(root, ...elements));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Contains the parent of the slash-separated, root-relative entry rather
|
|
42
|
+
* than the entry itself, so a symlinked entry resolves to the link, never
|
|
43
|
+
* to its target.
|
|
44
|
+
*/
|
|
45
|
+
export function containEntry(root, relative) {
|
|
46
|
+
const parent = contain(root, path.posix.dirname(relative));
|
|
47
|
+
return path.join(parent, path.posix.basename(relative));
|
|
48
|
+
}
|
|
49
|
+
/** The entries of dir sorted by name, in the order the Go installer listed them. */
|
|
50
|
+
export async function readSortedDir(dir) {
|
|
51
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
return entries.sort((left, right) => compareNames(left.name, right.name));
|
|
53
|
+
}
|
|
54
|
+
function compareNames(left, right) {
|
|
55
|
+
if (left === right) {
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
return left < right ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
export function permissionBits(stats) {
|
|
61
|
+
return stats.mode & PERMISSION_BITS;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Writes content to a sibling temp file and renames it over filePath, so
|
|
65
|
+
* a crash never leaves a half-written file and an entry that replaced
|
|
66
|
+
* filePath is swapped out rather than written through.
|
|
67
|
+
*/
|
|
68
|
+
export async function writeFileAtomically(filePath, content, mode) {
|
|
69
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(tempPath, content, { flag: "wx", mode });
|
|
72
|
+
await chmod(tempPath, mode);
|
|
73
|
+
await rename(tempPath, filePath);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
await rm(tempPath, { force: true });
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { MalformedBlockError, MemoryTooLargeError, UnmanagedFileError, UnmanagedLinkError } from "./errors.js";
|
|
2
|
+
export { linkAgent } from "./install.js";
|
|
3
|
+
export { findInstalledLinks, removeLink, removeLinks, } from "./uninstall.js";
|
|
4
|
+
export { AGENTS_DIR, countLinkFailures, failureReason, hasLinkFailure, KIND_IMPORTS, KIND_SYMLINK, LINK_CREATED, LINK_EXISTED, LINK_FAILED, LINK_SKIPPED, } from "./types.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isAgent, UnknownAgentError } from "../config/index.js";
|
|
3
|
+
import { Linker } from "./linker.js";
|
|
4
|
+
import { writeInstructionBlock } from "./memory.js";
|
|
5
|
+
/**
|
|
6
|
+
* Links the .agents payload for agent: relative symlinks into .agents
|
|
7
|
+
* and, for agents without a linked instructions folder, the managed
|
|
8
|
+
* instruction block in their memory file. A failure is recorded in its
|
|
9
|
+
* result and the remaining links are still attempted; only an unknown
|
|
10
|
+
* agent or an unusable project root rejects.
|
|
11
|
+
*/
|
|
12
|
+
export async function linkAgent(projectRoot, prefix, agent) {
|
|
13
|
+
if (!isAgent(agent)) {
|
|
14
|
+
throw new UnknownAgentError(agent);
|
|
15
|
+
}
|
|
16
|
+
const root = path.resolve(projectRoot);
|
|
17
|
+
const results = await new Linker(root, prefix).linkAgentFolders(agent);
|
|
18
|
+
const block = await writeInstructionBlock(root, agent);
|
|
19
|
+
return block === undefined ? results : [...results, block];
|
|
20
|
+
}
|