@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.
Files changed (96) hide show
  1. package/README.md +22 -43
  2. package/agents/personas/yaawn-product-owner.md +4 -4
  3. package/agents/skills/yaawn-create-plan/SKILL.md +83 -50
  4. package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
  5. package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
  6. package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
  7. package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
  8. package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
  9. package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
  10. package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
  11. package/agents/skills/yaawn-grill-story/SKILL.md +8 -1
  12. package/agents/skills/yaawn-grill-story/references/concept-challenge.md +12 -2
  13. package/agents/skills/yaawn-grill-story/references/story-checklist.md +2 -1
  14. package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
  15. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
  16. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
  17. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
  18. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
  19. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
  20. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
  21. package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
  22. package/agents/skills/yaawn-save-plan/SKILL.md +26 -7
  23. package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +75 -9
  24. package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +97 -1
  25. package/agents/skills/yaawn-split-story/SKILL.md +1 -1
  26. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
  27. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
  28. package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
  29. package/agents/skills/yaawn-write-other/SKILL.md +6 -5
  30. package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
  31. package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
  32. package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
  33. package/agents/skills/yaawn-write-user-story/SKILL.md +4 -3
  34. package/agents/yaawn-glossary.md +6 -5
  35. package/config/install-tasks.json +0 -1
  36. package/config/wizard.json +5 -10
  37. package/dist/cli/args.js +66 -0
  38. package/dist/cli/errors.js +103 -0
  39. package/dist/cli/exit-codes.js +3 -0
  40. package/dist/cli/index.js +11 -0
  41. package/dist/cli/install-report.js +92 -0
  42. package/dist/cli/install.js +124 -0
  43. package/dist/cli/main.js +17 -0
  44. package/dist/cli/package-root.js +17 -0
  45. package/dist/cli/project-root.js +11 -0
  46. package/dist/cli/run.js +141 -0
  47. package/dist/cli/session.js +1 -0
  48. package/dist/cli/terminal.js +5 -0
  49. package/dist/cli/uninstall.js +89 -0
  50. package/dist/cli/version.js +7 -0
  51. package/dist/config/agents.js +108 -0
  52. package/dist/config/config.js +118 -0
  53. package/dist/config/errors.js +45 -0
  54. package/dist/config/index.js +11 -0
  55. package/dist/config/manifest.js +28 -0
  56. package/dist/config/messages.js +86 -0
  57. package/dist/config/names.js +40 -0
  58. package/dist/config/paths.js +73 -0
  59. package/dist/config/settings.js +101 -0
  60. package/dist/config/strict-json.js +119 -0
  61. package/dist/config/tasks.js +77 -0
  62. package/dist/config/wizard.js +139 -0
  63. package/dist/display/index.js +3 -0
  64. package/dist/display/palette.js +61 -0
  65. package/dist/display/renderer.js +169 -0
  66. package/dist/display/report.js +15 -0
  67. package/dist/display/sanitize.js +10 -0
  68. package/dist/link/errors.js +34 -0
  69. package/dist/link/fs-utils.js +79 -0
  70. package/dist/link/index.js +4 -0
  71. package/dist/link/install.js +20 -0
  72. package/dist/link/linker.js +137 -0
  73. package/dist/link/memory.js +221 -0
  74. package/dist/link/names.js +30 -0
  75. package/dist/link/types.js +30 -0
  76. package/dist/link/uninstall.js +90 -0
  77. package/dist/sync/apply.js +74 -0
  78. package/dist/sync/changes.js +31 -0
  79. package/dist/sync/fs-utils.js +63 -0
  80. package/dist/sync/index.js +7 -0
  81. package/dist/sync/install.js +57 -0
  82. package/dist/sync/languages.js +80 -0
  83. package/dist/sync/plan.js +146 -0
  84. package/dist/sync/projects.js +294 -0
  85. package/dist/sync/uninstall.js +234 -0
  86. package/dist/wizard/build.js +89 -0
  87. package/dist/wizard/index.js +5 -0
  88. package/dist/wizard/items.js +83 -0
  89. package/dist/wizard/prompts.js +167 -0
  90. package/dist/wizard/section.js +143 -0
  91. package/dist/wizard/wizard.js +73 -0
  92. package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
  93. package/package.json +11 -10
  94. package/yaawn/yaawn-user-roles.md +1 -1
  95. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
  96. package/scripts/install.js +0 -73
@@ -0,0 +1,221 @@
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { AGENT_CLAUDE, AGENT_VIBE, contain } from "../config/index.js";
5
+ import { MalformedBlockError, MemoryTooLargeError, UnmanagedFileError } from "./errors.js";
6
+ import { DIR_MODE, FILE_MODE, containEntry, isContainedDirectory, isDirectory, lstatIfExists, permissionBits, readSortedDir, writeFileAtomically, } from "./fs-utils.js";
7
+ import { isSafeName, relativeTarget } from "./names.js";
8
+ import { AGENTS_DIR, KIND_IMPORTS, LINK_CREATED, LINK_EXISTED, LINK_FAILED, LINK_SKIPPED, } from "./types.js";
9
+ /*
10
+ * Agents that read no linked instructions folder get the instruction
11
+ * files listed in one managed block of their memory file instead; yaawn
12
+ * never touches the rest of that file. Claude Code @-imports each file
13
+ * from .claude/CLAUDE.md; Vibe reads a list in the root AGENTS.md.
14
+ */
15
+ const CLAUDE_MEMORY = ".claude/CLAUDE.md";
16
+ const VIBE_MEMORY = "AGENTS.md";
17
+ const INSTRUCTIONS_DIR = "instructions";
18
+ const INSTRUCTION_SUFFIX = ".instructions.md";
19
+ const BLOCK_START = "<!-- yaawn:instructions:start -->";
20
+ const BLOCK_END = "<!-- yaawn:instructions:end -->";
21
+ const VIBE_INTRO = "Before editing a file, read each yaawn instruction file below whose `applyTo` matches it and follow it.";
22
+ /** Caps how much of a memory file is read. */
23
+ const MAX_MEMORY_SIZE = 1 << 20;
24
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
25
+ /*
26
+ * Memory files are handled as latin1 text: every byte maps to one
27
+ * character, so offsets stay byte offsets and bytes outside the block
28
+ * are written back unchanged even when they are not valid UTF-8.
29
+ */
30
+ const BYTE_ENCODING = "latin1";
31
+ /** The only memory files the installer writes to or removes a block from. */
32
+ const MANAGED_FILES = [
33
+ {
34
+ agent: AGENT_CLAUDE,
35
+ path: CLAUDE_MEMORY,
36
+ target: relativeTarget(CLAUDE_MEMORY, `${AGENTS_DIR}/${INSTRUCTIONS_DIR}`),
37
+ linePrefix: "@",
38
+ },
39
+ {
40
+ agent: AGENT_VIBE,
41
+ path: VIBE_MEMORY,
42
+ target: `${AGENTS_DIR}/${INSTRUCTIONS_DIR}`,
43
+ intro: VIBE_INTRO,
44
+ linePrefix: "- ",
45
+ },
46
+ ];
47
+ /** The project-relative memory files any agent may hold a managed block in. */
48
+ export function managedMemoryPaths() {
49
+ return MANAGED_FILES.map((file) => file.path);
50
+ }
51
+ /**
52
+ * Creates or refreshes the managed block in agent's memory file. Returns
53
+ * undefined when agent has no memory file or there is no instruction
54
+ * file to list. A symlink or anything but a regular file there is
55
+ * skipped, never written through.
56
+ */
57
+ export async function writeInstructionBlock(root, agent) {
58
+ const memory = MANAGED_FILES.find((file) => file.agent === agent);
59
+ if (memory === undefined) {
60
+ return undefined;
61
+ }
62
+ try {
63
+ const files = await instructionFiles(root);
64
+ if (files.length === 0) {
65
+ return undefined;
66
+ }
67
+ const status = await updateMemory(root, memory.path, blockFor(memory, files));
68
+ return importsResult(memory, status);
69
+ }
70
+ catch (error) {
71
+ return { ...importsResult(memory, LINK_FAILED), error };
72
+ }
73
+ }
74
+ function importsResult(memory, status) {
75
+ return { kind: KIND_IMPORTS, status, path: memory.path, target: memory.target };
76
+ }
77
+ async function updateMemory(root, relative, block) {
78
+ const file = containEntry(root, relative);
79
+ const stats = await lstatIfExists(file);
80
+ if (stats === undefined) {
81
+ await mkdir(path.dirname(file), { recursive: true, mode: DIR_MODE });
82
+ await writeFileAtomically(file, Buffer.from(block, BYTE_ENCODING), FILE_MODE);
83
+ return LINK_CREATED;
84
+ }
85
+ if (!stats.isFile()) {
86
+ return LINK_SKIPPED;
87
+ }
88
+ const content = await readMemory(file);
89
+ const updated = withBlock(content, block);
90
+ if (updated === content) {
91
+ return LINK_EXISTED;
92
+ }
93
+ await writeFileAtomically(file, Buffer.from(updated, BYTE_ENCODING), permissionBits(stats));
94
+ return LINK_CREATED;
95
+ }
96
+ /** Lists the top-level instruction files in .agents, sorted; a missing folder yields none. */
97
+ async function instructionFiles(root) {
98
+ if (!(await isContainedDirectory(root, AGENTS_DIR, INSTRUCTIONS_DIR))) {
99
+ return [];
100
+ }
101
+ const entries = await readSortedDir(contain(root, AGENTS_DIR, INSTRUCTIONS_DIR));
102
+ return entries
103
+ .filter((entry) => entry.isFile() && entry.name.endsWith(INSTRUCTION_SUFFIX) && isSafeName(entry.name))
104
+ .map((entry) => entry.name);
105
+ }
106
+ /** Reports whether the memory file at relative is a regular file holding a well-formed managed block. */
107
+ export async function hasInstructionBlock(root, relative) {
108
+ const file = containEntry(root, relative);
109
+ if (!(await isDirectory(path.dirname(file)))) {
110
+ return false;
111
+ }
112
+ const stats = await lstatIfExists(file);
113
+ if (stats === undefined || !stats.isFile()) {
114
+ return false;
115
+ }
116
+ return findBlock(await readMemory(file)).state === "found";
117
+ }
118
+ /**
119
+ * Cuts the managed block from the memory file at relative and deletes
120
+ * the file only when nothing but whitespace is left. Refuses any file
121
+ * outside the managed memory files. Reports whether anything was removed.
122
+ */
123
+ export async function removeInstructionBlock(root, relative) {
124
+ if (!MANAGED_FILES.some((file) => file.path === relative)) {
125
+ throw new UnmanagedFileError(relative);
126
+ }
127
+ const file = containEntry(root, relative);
128
+ const stats = await lstatIfExists(file);
129
+ if (stats === undefined || !stats.isFile()) {
130
+ return false;
131
+ }
132
+ const content = await readMemory(file);
133
+ const location = findBlock(content);
134
+ if (location.state === "malformed") {
135
+ throw new MalformedBlockError();
136
+ }
137
+ if (location.state === "absent") {
138
+ return false;
139
+ }
140
+ const rest = withoutBlock(content, location.start, location.end);
141
+ if (rest.trim() === "") {
142
+ await unlink(file);
143
+ return true;
144
+ }
145
+ await writeFileAtomically(file, Buffer.from(rest, BYTE_ENCODING), permissionBits(stats));
146
+ return true;
147
+ }
148
+ function blockFor(memory, files) {
149
+ const intro = memory.intro === undefined ? [] : [memory.intro];
150
+ const lines = files.map((file) => `${memory.linePrefix}${memory.target}/${toBytes(file)}`);
151
+ return [BLOCK_START, ...intro, ...lines, BLOCK_END, ""].join("\n");
152
+ }
153
+ /** Re-encodes a UTF-8 file name as the byte text the memory file is handled in. */
154
+ function toBytes(text) {
155
+ return Buffer.from(text, "utf8").toString(BYTE_ENCODING);
156
+ }
157
+ /** Replaces the managed block in content, or appends it after a blank line when content has none. */
158
+ function withBlock(content, block) {
159
+ const location = findBlock(content);
160
+ if (location.state === "malformed") {
161
+ throw new MalformedBlockError();
162
+ }
163
+ if (location.state === "found") {
164
+ return content.slice(0, location.start) + block + content.slice(location.end);
165
+ }
166
+ if (content === "") {
167
+ return block;
168
+ }
169
+ return content.endsWith("\n") ? `${content}\n${block}` : `${content}\n\n${block}`;
170
+ }
171
+ /** Drops content[start:end] and, when the block ended the file, the blank line withBlock put before it. */
172
+ function withoutBlock(content, start, end) {
173
+ let before = content.slice(0, start);
174
+ const after = content.slice(end);
175
+ if (after === "" && before.trim() !== "") {
176
+ before = `${before.replace(/\n+$/, "")}\n`;
177
+ }
178
+ return before + after;
179
+ }
180
+ /**
181
+ * Locates the managed block including the line break after its end
182
+ * marker. Exactly one start marker followed by one end marker counts as
183
+ * found; any other combination is malformed.
184
+ */
185
+ function findBlock(content) {
186
+ const starts = countOccurrences(content, BLOCK_START);
187
+ const ends = countOccurrences(content, BLOCK_END);
188
+ if (starts === 0 && ends === 0) {
189
+ return { state: "absent" };
190
+ }
191
+ const start = content.indexOf(BLOCK_START);
192
+ const endMarker = content.indexOf(BLOCK_END);
193
+ if (starts !== 1 || ends !== 1 || endMarker < start) {
194
+ return { state: "malformed" };
195
+ }
196
+ const end = endMarker + BLOCK_END.length;
197
+ return { state: "found", start, end: content.startsWith("\n", end) ? end + 1 : end };
198
+ }
199
+ function countOccurrences(content, marker) {
200
+ return content.split(marker).length - 1;
201
+ }
202
+ /** Reads the memory file without following a symlink, refusing files over MAX_MEMORY_SIZE. */
203
+ async function readMemory(file) {
204
+ const handle = await open(file, constants.O_RDONLY | NO_FOLLOW);
205
+ try {
206
+ const buffer = Buffer.alloc(MAX_MEMORY_SIZE + 1);
207
+ let total = 0;
208
+ let bytesRead = -1;
209
+ while (total < buffer.length && bytesRead !== 0) {
210
+ ({ bytesRead } = await handle.read(buffer, total, buffer.length - total, null));
211
+ total += bytesRead;
212
+ }
213
+ if (total > MAX_MEMORY_SIZE) {
214
+ throw new MemoryTooLargeError(MAX_MEMORY_SIZE);
215
+ }
216
+ return buffer.subarray(0, total).toString(BYTE_ENCODING);
217
+ }
218
+ finally {
219
+ await handle.close();
220
+ }
221
+ }
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+ const UNSAFE_NAME_CHARACTERS = /[/\\\p{Cc}\p{White_Space}]/u;
3
+ /**
4
+ * Accepts a single path element without separators, dot segments,
5
+ * control characters or whitespace, so it can neither escape its folder
6
+ * nor break a report or import line.
7
+ */
8
+ export function isSafeName(name) {
9
+ return name !== "" && name !== "." && name !== ".." && !UNSAFE_NAME_CHARACTERS.test(name);
10
+ }
11
+ /** Accepts a clean, relative, slash-separated path made of safe names only. */
12
+ export function isSafeRelative(relative) {
13
+ if (relative === "" || path.posix.isAbsolute(relative) || path.posix.normalize(relative) !== relative) {
14
+ return false;
15
+ }
16
+ return relative.split("/").every(isSafeName);
17
+ }
18
+ /** Returns the slash-separated path from the folder of linkRelative to targetRelative, both project-relative, clean and nested. */
19
+ export function relativeTarget(linkRelative, targetRelative) {
20
+ const depth = path.posix.dirname(linkRelative).split("/").length;
21
+ return "../".repeat(depth) + targetRelative;
22
+ }
23
+ /** Reports whether candidate lies inside root; both must be absolute and normalized. */
24
+ export function isWithin(root, candidate) {
25
+ const relative = path.relative(root, candidate);
26
+ if (path.isAbsolute(relative)) {
27
+ return false;
28
+ }
29
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`);
30
+ }
@@ -0,0 +1,30 @@
1
+ /** The project-relative directory every link points into. */
2
+ export const AGENTS_DIR = ".agents";
3
+ export const LINK_CREATED = "created";
4
+ export const LINK_EXISTED = "existed";
5
+ export const LINK_SKIPPED = "skipped";
6
+ export const LINK_FAILED = "failed";
7
+ export const KIND_SYMLINK = "symlink";
8
+ export const KIND_IMPORTS = "imports";
9
+ /** Reports whether any result failed. */
10
+ export function hasLinkFailure(results) {
11
+ return countLinkFailures(results) > 0;
12
+ }
13
+ export function countLinkFailures(results) {
14
+ return results.filter((result) => result.status === LINK_FAILED).length;
15
+ }
16
+ const NODE_ERROR_CAUSE = /^[A-Z0-9_]+: ([^,]+)(,|$)/;
17
+ /**
18
+ * Keeps only the cause of a file system error, without the absolute
19
+ * paths Node puts into its message; the report line names the path.
20
+ */
21
+ export function failureReason(error) {
22
+ if (error === undefined) {
23
+ return "";
24
+ }
25
+ if (!(error instanceof Error)) {
26
+ return String(error);
27
+ }
28
+ const match = "code" in error ? NODE_ERROR_CAUSE.exec(error.message) : null;
29
+ return match?.[1] ?? error.message;
30
+ }
@@ -0,0 +1,90 @@
1
+ import path from "node:path";
2
+ import { PathEscapeError } from "../config/index.js";
3
+ import { UnmanagedLinkError } from "./errors.js";
4
+ import { containEntry, isDirectory, readSortedDir } from "./fs-utils.js";
5
+ import { REMOVABLE_FOLDERS, Linker } from "./linker.js";
6
+ import { hasInstructionBlock, managedMemoryPaths, removeInstructionBlock } from "./memory.js";
7
+ import { isSafeName, isSafeRelative } from "./names.js";
8
+ import { KIND_IMPORTS, KIND_SYMLINK } from "./types.js";
9
+ /**
10
+ * Lists every link into .agents in the agent folders, at the top level
11
+ * or inside a real folder, and the managed block in every agent's memory
12
+ * file, whichever agent is chosen now: switching agents keeps the old
13
+ * links until uninstall. It only reads the file system.
14
+ */
15
+ export async function findInstalledLinks(projectRoot) {
16
+ const root = path.resolve(projectRoot);
17
+ const linker = new Linker(root);
18
+ const entries = [];
19
+ for (const folder of REMOVABLE_FOLDERS) {
20
+ entries.push(...(await installedIn(root, linker, folder)));
21
+ }
22
+ for (const memory of managedMemoryPaths()) {
23
+ if (await hasInstructionBlock(root, memory)) {
24
+ entries.push({ kind: KIND_IMPORTS, path: memory });
25
+ }
26
+ }
27
+ return entries;
28
+ }
29
+ async function installedIn(root, linker, folder) {
30
+ const linkPath = containEntry(root, folder);
31
+ if (!(await isDirectory(path.dirname(linkPath)))) {
32
+ return [];
33
+ }
34
+ const state = await linker.classify(linkPath);
35
+ if (state === "own") {
36
+ return [{ kind: KIND_SYMLINK, path: folder }];
37
+ }
38
+ return state === "realDir" ? ownLinksIn(linker, linkPath, folder) : [];
39
+ }
40
+ async function ownLinksIn(linker, dir, folder) {
41
+ const entries = [];
42
+ for (const child of await readSortedDir(dir)) {
43
+ if (!child.isSymbolicLink() || !isSafeName(child.name)) {
44
+ continue;
45
+ }
46
+ if (await linker.pointsIntoAgents(path.join(dir, child.name))) {
47
+ entries.push({ kind: KIND_SYMLINK, path: `${folder}/${child.name}` });
48
+ }
49
+ }
50
+ return entries;
51
+ }
52
+ /**
53
+ * Removes one entry found by findInstalledLinks. It re-checks the entry
54
+ * right before removing: a link is only unlinked, never followed, while
55
+ * it still points into .agents, and only the managed block is cut from a
56
+ * managed memory file. Reports whether anything was removed.
57
+ */
58
+ export async function removeLink(projectRoot, entry) {
59
+ const root = path.resolve(projectRoot);
60
+ if (entry.kind === KIND_IMPORTS) {
61
+ return removeInstructionBlock(root, entry.path);
62
+ }
63
+ if (!isSafeRelative(entry.path)) {
64
+ throw new PathEscapeError(entry.path, projectRoot);
65
+ }
66
+ if (!isManagedLinkPath(entry.path)) {
67
+ throw new UnmanagedLinkError(entry.path);
68
+ }
69
+ return new Linker(root).unlinkOwn(containEntry(root, entry.path));
70
+ }
71
+ /** Removes every entry, continuing past failures, and reports what was removed and what failed. */
72
+ export async function removeLinks(projectRoot, entries) {
73
+ const removed = [];
74
+ const failures = [];
75
+ for (const entry of entries) {
76
+ try {
77
+ if (await removeLink(projectRoot, entry)) {
78
+ removed.push(entry);
79
+ }
80
+ }
81
+ catch (error) {
82
+ failures.push({ entry, error });
83
+ }
84
+ }
85
+ return { removed, failures };
86
+ }
87
+ /** Accepts an agent folder yaawn links, or an entry directly inside one. */
88
+ function isManagedLinkPath(relative) {
89
+ return REMOVABLE_FOLDERS.includes(relative) || REMOVABLE_FOLDERS.includes(path.posix.dirname(relative));
90
+ }
@@ -0,0 +1,74 @@
1
+ import { chmod, copyFile, mkdir, rm, unlink } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { contain } from "../config/index.js";
4
+ import { containEntry, DIR_MODE, lstatIfExists } from "./fs-utils.js";
5
+ /** A sync target that failed; result lists every target applied so far, the failed one last. */
6
+ export class SyncApplyError extends Error {
7
+ source;
8
+ result;
9
+ name = "SyncApplyError";
10
+ constructor(source, result, cause) {
11
+ super(`sync ${source}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
12
+ this.source = source;
13
+ this.result = result;
14
+ }
15
+ }
16
+ /**
17
+ * Executes the plan target by target and stops at the first failure.
18
+ * Every destination is contained again right before it is written, so a
19
+ * symlink planted after planning cannot redirect a write or delete.
20
+ */
21
+ export async function applySync(plan) {
22
+ const targets = [];
23
+ const resultSoFar = () => ({ targets, languages: plan.languages, hasLanguageFiles: plan.hasLanguageFiles });
24
+ for (const target of plan.targets) {
25
+ const counter = { removed: 0, copied: 0 };
26
+ try {
27
+ await applyTarget(plan.projectRoot, target, counter);
28
+ }
29
+ catch (error) {
30
+ targets.push(targetResult(target, counter));
31
+ throw new SyncApplyError(target.target.source, resultSoFar(), error);
32
+ }
33
+ targets.push(targetResult(target, counter));
34
+ }
35
+ return resultSoFar();
36
+ }
37
+ function targetResult(plan, counter) {
38
+ return {
39
+ source: plan.target.source,
40
+ target: plan.target.target,
41
+ sourceMissing: plan.sourceMissing,
42
+ removed: counter.removed,
43
+ copied: counter.copied,
44
+ seedKept: plan.seedKept,
45
+ };
46
+ }
47
+ async function applyTarget(projectRoot, plan, counter) {
48
+ if (plan.sourceMissing) {
49
+ return;
50
+ }
51
+ for (const removal of plan.removals) {
52
+ await rm(containEntry(projectRoot, removal), { recursive: true, force: true });
53
+ counter.removed++;
54
+ }
55
+ await mkdir(contain(projectRoot, plan.target.target), { recursive: true, mode: DIR_MODE });
56
+ for (const dir of plan.dirs) {
57
+ await mkdir(contain(projectRoot, dir), { recursive: true, mode: DIR_MODE });
58
+ }
59
+ for (const file of plan.copies) {
60
+ await copyPlannedFile(projectRoot, file);
61
+ counter.copied++;
62
+ }
63
+ }
64
+ /** Replaces a symlink at the destination instead of writing through it. */
65
+ async function copyPlannedFile(projectRoot, file) {
66
+ const destination = containEntry(projectRoot, file.destination);
67
+ await mkdir(path.dirname(destination), { recursive: true, mode: DIR_MODE });
68
+ const existing = await lstatIfExists(destination);
69
+ if (existing?.isSymbolicLink() === true) {
70
+ await unlink(destination);
71
+ }
72
+ await copyFile(file.source, destination);
73
+ await chmod(destination, file.mode);
74
+ }
@@ -0,0 +1,31 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { contain, PathEscapeError } from "../config/index.js";
4
+ import { DIR_MODE, FILE_MODE } from "./fs-utils.js";
5
+ /** Validates every change before anything is written; throws PathEscapeError for a path outside the project. */
6
+ export function planFileChanges(projectRoot, changes) {
7
+ return changes.map((change) => {
8
+ const destination = projectRelative(projectRoot, change.path);
9
+ contain(projectRoot, destination);
10
+ return { fileName: change.fileName, destination, content: change.content, added: change.added };
11
+ });
12
+ }
13
+ function projectRelative(projectRoot, target) {
14
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(target));
15
+ if (relative === "" || path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
16
+ throw new PathEscapeError(target, projectRoot);
17
+ }
18
+ return relative.split(path.sep).join("/");
19
+ }
20
+ /** Writes every change that adds at least one item, containing each path again right before the write. */
21
+ export async function writeFileChanges(projectRoot, changes) {
22
+ const results = [];
23
+ for (const change of changes) {
24
+ if (change.added > 0) {
25
+ await mkdir(contain(projectRoot, path.posix.dirname(change.destination)), { recursive: true, mode: DIR_MODE });
26
+ await writeFile(contain(projectRoot, change.destination), change.content, { mode: FILE_MODE });
27
+ }
28
+ results.push({ fileName: change.fileName, added: change.added });
29
+ }
30
+ return results;
31
+ }
@@ -0,0 +1,63 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { contain } from "../config/index.js";
4
+ export const DIR_MODE = 0o755;
5
+ export const FILE_MODE = 0o644;
6
+ export const EXEC_MODE = 0o755;
7
+ export function isMissing(error) {
8
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
9
+ }
10
+ /** The entry's own stats without following a symlink, or undefined when it does not exist. */
11
+ export async function lstatIfExists(target) {
12
+ try {
13
+ return await lstat(target);
14
+ }
15
+ catch (error) {
16
+ if (isMissing(error)) {
17
+ return undefined;
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ /** Reports whether something exists at target; errors other than a missing entry count as existing. */
23
+ export async function pathExists(target) {
24
+ try {
25
+ await lstat(target);
26
+ return true;
27
+ }
28
+ catch (error) {
29
+ return !isMissing(error);
30
+ }
31
+ }
32
+ /** The entries of dir sorted by name, or undefined when dir does not exist. */
33
+ export async function readSortedDir(dir) {
34
+ try {
35
+ const entries = await readdir(dir, { withFileTypes: true });
36
+ return entries.sort((left, right) => compareNames(left.name, right.name));
37
+ }
38
+ catch (error) {
39
+ if (isMissing(error)) {
40
+ return undefined;
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ export function compareNames(left, right) {
46
+ if (left === right) {
47
+ return 0;
48
+ }
49
+ return left < right ? -1 : 1;
50
+ }
51
+ /**
52
+ * Contains the parent of the slash-separated, root-relative entry rather
53
+ * than the entry itself, so a symlinked entry resolves to the link, never
54
+ * to its target.
55
+ */
56
+ export function containEntry(root, relative) {
57
+ const parent = contain(root, path.posix.dirname(relative));
58
+ return path.join(parent, path.posix.basename(relative));
59
+ }
60
+ /** Returns target relative to root, slash-separated. */
61
+ export function toDisplayPath(root, target) {
62
+ return path.relative(root, target).split(path.sep).join("/");
63
+ }
@@ -0,0 +1,7 @@
1
+ export { applySync, SyncApplyError } from "./apply.js";
2
+ export { planFileChanges, writeFileChanges, } from "./changes.js";
3
+ export { applyInstall, InstallError, planInstall, } from "./install.js";
4
+ export { allLanguageTags, detectLanguages, expandLanguages, installedLanguageTags, languageLabel, } from "./languages.js";
5
+ export { planSync } from "./plan.js";
6
+ export { captureProjects, findProjectsLine, mergeProjects, parseFlowList, } from "./projects.js";
7
+ export { applyUninstall, isUninstallPlanEmpty, isYaawnInstalled, PLANS_DIR, planUninstall, uninstallEntries, UninstallError, } from "./uninstall.js";
@@ -0,0 +1,57 @@
1
+ import { applySync, SyncApplyError } from "./apply.js";
2
+ import { planFileChanges, writeFileChanges } from "./changes.js";
3
+ import { planSync } from "./plan.js";
4
+ import { captureProjects, mergeProjects } from "./projects.js";
5
+ /** A failed install step; result holds what was done before the failure. */
6
+ export class InstallError extends Error {
7
+ stage;
8
+ result;
9
+ name = "InstallError";
10
+ constructor(stage, result, cause) {
11
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
12
+ this.stage = stage;
13
+ this.result = result;
14
+ }
15
+ }
16
+ const NO_PROJECTS = { updated: [], skipped: [] };
17
+ /**
18
+ * Captures the issue templates' project names, plans the sync and
19
+ * validates the wizard's file changes. It only reads, so any error here
20
+ * leaves the project untouched.
21
+ */
22
+ export async function planInstall(config, packageRoot, projectRoot, input) {
23
+ return {
24
+ packageRoot,
25
+ projectRoot,
26
+ capture: await captureProjects(config, projectRoot),
27
+ sync: await planSync(config, packageRoot, projectRoot, input.languages),
28
+ changes: planFileChanges(projectRoot, input.changes),
29
+ projectNames: [...input.projectNames],
30
+ };
31
+ }
32
+ /**
33
+ * Syncs the payload, restores the issue templates' project names merged
34
+ * with the entered ones and writes the wizard changes, in that order.
35
+ * Stops at the first failure with an InstallError. Saving the agent and
36
+ * linking are left to the caller.
37
+ */
38
+ export async function applyInstall(config, plan) {
39
+ const sync = await runStage("sync", () => applySync(plan.sync), (error) => syncSoFar(plan, error));
40
+ const projects = await runStage("projects", () => mergeProjects(config, plan.projectRoot, plan.capture, plan.projectNames), () => ({ sync, projects: NO_PROJECTS, changes: [] }));
41
+ const changes = await runStage("changes", () => writeFileChanges(plan.projectRoot, plan.changes), () => ({ sync, projects, changes: [] }));
42
+ return { sync, projects, changes };
43
+ }
44
+ async function runStage(stage, action, resultSoFar) {
45
+ try {
46
+ return await action();
47
+ }
48
+ catch (error) {
49
+ throw new InstallError(stage, resultSoFar(error), error);
50
+ }
51
+ }
52
+ function syncSoFar(plan, error) {
53
+ const sync = error instanceof SyncApplyError
54
+ ? error.result
55
+ : { targets: [], languages: plan.sync.languages, hasLanguageFiles: plan.sync.hasLanguageFiles };
56
+ return { sync, projects: NO_PROJECTS, changes: [] };
57
+ }