@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.
Files changed (89) hide show
  1. package/README.md +22 -43
  2. package/agents/personas/yaawn-product-owner.md +3 -3
  3. package/agents/skills/yaawn-create-plan/SKILL.md +38 -11
  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 +7 -0
  12. package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
  13. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
  14. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
  15. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
  16. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
  17. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
  18. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
  19. package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
  20. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
  21. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
  22. package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
  23. package/agents/skills/yaawn-write-other/SKILL.md +6 -5
  24. package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
  25. package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
  26. package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
  27. package/agents/skills/yaawn-write-user-story/SKILL.md +3 -2
  28. package/agents/yaawn-glossary.md +4 -3
  29. package/config/install-tasks.json +0 -1
  30. package/config/wizard.json +3 -8
  31. package/dist/cli/args.js +66 -0
  32. package/dist/cli/errors.js +103 -0
  33. package/dist/cli/exit-codes.js +3 -0
  34. package/dist/cli/index.js +11 -0
  35. package/dist/cli/install-report.js +92 -0
  36. package/dist/cli/install.js +124 -0
  37. package/dist/cli/main.js +17 -0
  38. package/dist/cli/package-root.js +17 -0
  39. package/dist/cli/project-root.js +11 -0
  40. package/dist/cli/run.js +141 -0
  41. package/dist/cli/session.js +1 -0
  42. package/dist/cli/terminal.js +5 -0
  43. package/dist/cli/uninstall.js +89 -0
  44. package/dist/cli/version.js +7 -0
  45. package/dist/config/agents.js +108 -0
  46. package/dist/config/config.js +118 -0
  47. package/dist/config/errors.js +45 -0
  48. package/dist/config/index.js +11 -0
  49. package/dist/config/manifest.js +28 -0
  50. package/dist/config/messages.js +86 -0
  51. package/dist/config/names.js +40 -0
  52. package/dist/config/paths.js +73 -0
  53. package/dist/config/settings.js +101 -0
  54. package/dist/config/strict-json.js +119 -0
  55. package/dist/config/tasks.js +77 -0
  56. package/dist/config/wizard.js +139 -0
  57. package/dist/display/index.js +3 -0
  58. package/dist/display/palette.js +61 -0
  59. package/dist/display/renderer.js +169 -0
  60. package/dist/display/report.js +15 -0
  61. package/dist/display/sanitize.js +10 -0
  62. package/dist/link/errors.js +34 -0
  63. package/dist/link/fs-utils.js +79 -0
  64. package/dist/link/index.js +4 -0
  65. package/dist/link/install.js +20 -0
  66. package/dist/link/linker.js +137 -0
  67. package/dist/link/memory.js +221 -0
  68. package/dist/link/names.js +30 -0
  69. package/dist/link/types.js +30 -0
  70. package/dist/link/uninstall.js +90 -0
  71. package/dist/sync/apply.js +74 -0
  72. package/dist/sync/changes.js +31 -0
  73. package/dist/sync/fs-utils.js +63 -0
  74. package/dist/sync/index.js +7 -0
  75. package/dist/sync/install.js +57 -0
  76. package/dist/sync/languages.js +80 -0
  77. package/dist/sync/plan.js +146 -0
  78. package/dist/sync/projects.js +294 -0
  79. package/dist/sync/uninstall.js +234 -0
  80. package/dist/wizard/build.js +89 -0
  81. package/dist/wizard/index.js +5 -0
  82. package/dist/wizard/items.js +83 -0
  83. package/dist/wizard/prompts.js +167 -0
  84. package/dist/wizard/section.js +143 -0
  85. package/dist/wizard/wizard.js +73 -0
  86. package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
  87. package/package.json +11 -10
  88. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
  89. package/scripts/install.js +0 -73
@@ -0,0 +1,3 @@
1
+ export const EXIT_OK = 0;
2
+ export const EXIT_ERROR = 1;
3
+ export const EXIT_INTERRUPTED = 130;
@@ -0,0 +1,11 @@
1
+ export { agentFlag, packageRootFlag, parseArgs } from "./args.js";
2
+ export { describeError, detailTemplates, fallbackMissingPackageRoot, fallbackTemplates, installProcessSafetyNet, isPromptCancellation, printError, runWithSafetyNet, templatesFrom, } from "./errors.js";
3
+ export { InstallReport } from "./install-report.js";
4
+ export { Installer } from "./install.js";
5
+ export { run } from "./run.js";
6
+ export { Uninstaller } from "./uninstall.js";
7
+ export { EXIT_ERROR, EXIT_INTERRUPTED, EXIT_OK } from "./exit-codes.js";
8
+ export { resolvePackageRoot } from "./package-root.js";
9
+ export { resolveProjectRoot } from "./project-root.js";
10
+ export { isInteractive } from "./terminal.js";
11
+ export { helpText, versionText } from "./version.js";
@@ -0,0 +1,92 @@
1
+ import path from "node:path";
2
+ import { fill } from "../config/index.js";
3
+ import { failureReason, KIND_IMPORTS, LINK_CREATED, LINK_EXISTED, LINK_SKIPPED } from "../link/index.js";
4
+ /** Turns the install results into progress lines on reporter; every text comes from the config messages. */
5
+ export class InstallReport {
6
+ messages;
7
+ reporter;
8
+ constructor(messages, reporter) {
9
+ this.messages = messages;
10
+ this.reporter = reporter;
11
+ }
12
+ /** Reports every synced target; with failure, the last target is reported as the one that failed. */
13
+ sync(result, failure) {
14
+ result.targets.forEach((target, index) => {
15
+ const values = {
16
+ source: target.source,
17
+ target: target.target,
18
+ path: target.source,
19
+ count: String(target.removed),
20
+ };
21
+ if (failure !== undefined && index === result.targets.length - 1) {
22
+ this.reporter.warn(fill(this.messages.syncFailed, { ...values, error: failure }));
23
+ }
24
+ else if (target.sourceMissing) {
25
+ this.reporter.warn(fill(this.messages.syncSourceMissing, values));
26
+ }
27
+ else {
28
+ this.reporter.info(fill(target.removed > 0 ? this.messages.syncedReplaced : this.messages.synced, values));
29
+ }
30
+ target.seedKept.forEach((kept) => this.reporter.info(fill(this.messages.fileKept, { file: path.posix.basename(kept) })));
31
+ });
32
+ }
33
+ languages(result) {
34
+ if (!result.hasLanguageFiles) {
35
+ return;
36
+ }
37
+ if (result.languages.length === 0) {
38
+ this.reporter.info(this.messages.languagesNone);
39
+ return;
40
+ }
41
+ const labels = result.languages.map((language) => language.label).join(", ");
42
+ this.reporter.info(fill(this.messages.languagesInstalled, { languages: labels }));
43
+ }
44
+ projects(result) {
45
+ result.updated.forEach((updated) => this.reporter.info(fill(this.messages.projectsUpdated, { path: updated })));
46
+ result.skipped.forEach((skipped) => this.reporter.warn(fill(this.messages.projectsSkipped, { path: skipped })));
47
+ }
48
+ changes(results) {
49
+ for (const change of results) {
50
+ const values = { file: change.fileName, count: String(change.added) };
51
+ this.reporter.info(fill(change.added > 0 ? this.messages.itemsAdded : this.messages.itemsUnchanged, values));
52
+ }
53
+ }
54
+ /** Reports every link; a failed symlink line carries the ln -s command to run by hand. */
55
+ links(results) {
56
+ results.forEach((result) => this.link(result));
57
+ }
58
+ link(result) {
59
+ const templates = this.linkTemplates(result);
60
+ const values = { path: result.path, target: result.target };
61
+ switch (result.status) {
62
+ case LINK_CREATED:
63
+ this.reporter.info(fill(templates.created, values));
64
+ return;
65
+ case LINK_EXISTED:
66
+ this.reporter.info(fill(templates.existed, values));
67
+ return;
68
+ case LINK_SKIPPED:
69
+ this.reporter.warn(fill(templates.skipped, values));
70
+ return;
71
+ default:
72
+ this.reporter.warn(fill(templates.failed, { ...values, error: failureReason(result.error), command: result.manualCommand ?? "" }));
73
+ }
74
+ }
75
+ linkTemplates(result) {
76
+ const messages = this.messages;
77
+ if (result.kind === KIND_IMPORTS) {
78
+ return {
79
+ created: messages.importsCreated,
80
+ existed: messages.importsExisted,
81
+ skipped: messages.importsSkipped,
82
+ failed: messages.importsFailed,
83
+ };
84
+ }
85
+ return {
86
+ created: messages.linkCreated,
87
+ existed: messages.linkExisted,
88
+ skipped: messages.linkSkipped,
89
+ failed: messages.linkFailed,
90
+ };
91
+ }
92
+ }
@@ -0,0 +1,124 @@
1
+ import { fill } from "../config/index.js";
2
+ import { ReportCollector } from "../display/index.js";
3
+ import { countLinkFailures, failureReason, linkAgent } from "../link/index.js";
4
+ import { applyInstall, detectLanguages, InstallError, installedLanguageTags, planInstall, } from "../sync/index.js";
5
+ import { buildWizard, readSeededFiles, runWizard } from "../wizard/index.js";
6
+ import { describeError } from "./errors.js";
7
+ import { EXIT_ERROR, EXIT_OK } from "./exit-codes.js";
8
+ import { InstallReport } from "./install-report.js";
9
+ /** Raised once everything else is done when at least one agent link failed. */
10
+ class LinksFailedError extends Error {
11
+ name = "LinksFailedError";
12
+ }
13
+ /**
14
+ * Runs one install: intro, wizard, then — only once the wizard has
15
+ * completed — the sync, the agent links and the saved agent choice behind
16
+ * a spinner, and finally the summary. A cancelled wizard leaves the
17
+ * project untouched.
18
+ */
19
+ export class Installer {
20
+ session;
21
+ wizardPrompts;
22
+ requestedAgent;
23
+ constructor(session, wizardPrompts, requestedAgent) {
24
+ this.session = session;
25
+ this.wizardPrompts = wizardPrompts;
26
+ this.requestedAgent = requestedAgent;
27
+ }
28
+ async run() {
29
+ const { config, packageRoot, projectRoot, ui } = this.session;
30
+ ui.intro(this.session.version);
31
+ const preparation = await this.prepare();
32
+ const choice = await runWizard(preparation.wizard, {
33
+ messages: config.wizard.messages,
34
+ fallback: preparation.fallback,
35
+ ...this.wizardPrompts,
36
+ });
37
+ const plan = await planInstall(config, packageRoot, projectRoot, choice);
38
+ const collector = new ReportCollector();
39
+ const failure = await ui.installing(() => this.apply(plan, choice, new InstallReport(config.wizard.messages, collector)));
40
+ ui.installSummary({
41
+ warnings: preparation.warnings,
42
+ lines: collector.lines,
43
+ outroValues: config.settings.outputFileNames,
44
+ ...(failure === undefined ? {} : { failure }),
45
+ });
46
+ return failure === undefined ? EXIT_OK : EXIT_ERROR;
47
+ }
48
+ async prepare() {
49
+ const { config, packageRoot, projectRoot } = this.session;
50
+ const languages = await detectLanguages(config, packageRoot, projectRoot);
51
+ const { wizard, skipped } = buildWizard(config, {
52
+ seededFiles: readSeededFiles(config, packageRoot, projectRoot),
53
+ languages: languages.options.map((language) => ({
54
+ value: language.tag,
55
+ label: language.label,
56
+ selected: language.installed,
57
+ })),
58
+ previousAgent: config.previousAgent(projectRoot),
59
+ requestedAgent: this.requestedAgent,
60
+ });
61
+ return {
62
+ wizard,
63
+ warnings: skipped.map((section) => this.skippedWarning(section)),
64
+ fallback: {
65
+ agent: config.resolveAgent(projectRoot, this.requestedAgent),
66
+ languages: installedLanguageTags(languages),
67
+ },
68
+ };
69
+ }
70
+ /** Writes everything and returns the described failure, or undefined on success. */
71
+ async apply(plan, choice, report) {
72
+ try {
73
+ await this.applyFiles(plan, report);
74
+ await this.linkAndSave(choice.agent, report);
75
+ return undefined;
76
+ }
77
+ catch (error) {
78
+ return describeError(this.session.templates, error);
79
+ }
80
+ }
81
+ async applyFiles(plan, report) {
82
+ try {
83
+ const result = await applyInstall(this.session.config, plan);
84
+ report.sync(result.sync);
85
+ report.languages(result.sync);
86
+ report.projects(result.projects);
87
+ report.changes(result.changes);
88
+ }
89
+ catch (error) {
90
+ if (error instanceof InstallError) {
91
+ this.reportPartial(error, report);
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ reportPartial(error, report) {
97
+ if (error.stage === "sync") {
98
+ report.sync(error.result.sync, syncFailureReason(error));
99
+ return;
100
+ }
101
+ report.sync(error.result.sync);
102
+ report.languages(error.result.sync);
103
+ report.projects(error.result.projects);
104
+ }
105
+ /** A failed link does not stop the other links or the saved choice; it fails the install only at the end. */
106
+ async linkAndSave(agent, report) {
107
+ const { config, projectRoot } = this.session;
108
+ const results = await linkAgent(projectRoot, config.settings.filePrefix, agent);
109
+ report.links(results);
110
+ config.saveAgent(projectRoot, agent);
111
+ const failed = countLinkFailures(results);
112
+ if (failed > 0) {
113
+ throw new LinksFailedError(fill(config.wizard.messages.linksFailed, { count: String(failed) }));
114
+ }
115
+ }
116
+ skippedWarning(section) {
117
+ return fill(this.session.config.wizard.messages.sectionFileMissing, { file: section.fileName, path: section.path });
118
+ }
119
+ }
120
+ /** The file system cause of a failed sync, without the absolute paths Node puts into its message. */
121
+ function syncFailureReason(error) {
122
+ const syncError = error.cause;
123
+ return syncError instanceof Error && syncError.cause !== undefined ? failureReason(syncError.cause) : error.message;
124
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { installProcessSafetyNet } from "./errors.js";
4
+ import { run } from "./run.js";
5
+ import { isInteractive } from "./terminal.js";
6
+ installProcessSafetyNet(process.stderr);
7
+ void run(process.argv.slice(2), {
8
+ stdin: process.stdin,
9
+ stdout: process.stdout,
10
+ stderr: process.stderr,
11
+ env: process.env,
12
+ cwd: () => process.cwd(),
13
+ isTTY: () => isInteractive(process),
14
+ entryUrl: import.meta.url,
15
+ }).then((code) => {
16
+ process.exitCode = code;
17
+ });
@@ -0,0 +1,17 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ /** This file compiles to dist/cli/, two levels below the package root. */
4
+ const compiledDirToPackageRoot = "../../";
5
+ /**
6
+ * Returns the package root, which holds config/, agents/, yaawn/, github/
7
+ * and package.json: the --package-root value when given, kept for
8
+ * compatibility with the old launcher, else the directory two levels above
9
+ * the compiled entry. Returns undefined when --package-root was given
10
+ * without a value.
11
+ */
12
+ export function resolvePackageRoot(flagValue, entryUrl = import.meta.url) {
13
+ if (flagValue !== undefined) {
14
+ return flagValue === "" ? undefined : path.resolve(flagValue);
15
+ }
16
+ return fileURLToPath(new URL(compiledDirToPackageRoot, entryUrl));
17
+ }
@@ -0,0 +1,11 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ const PROJECT_ROOT_ENV = "INIT_CWD";
4
+ /**
5
+ * Prefers INIT_CWD, which npm sets to the directory npx was started in,
6
+ * over the working directory.
7
+ */
8
+ export function resolveProjectRoot(env = process.env, workingDirectory = process.cwd()) {
9
+ const initCwd = env[PROJECT_ROOT_ENV];
10
+ return path.resolve(initCwd !== undefined && initCwd !== "" ? initCwd : workingDirectory);
11
+ }
@@ -0,0 +1,141 @@
1
+ import { agents, fill, loadConfig, readPackageVersion } from "../config/index.js";
2
+ import { MODE_UNINSTALL, Renderer } from "../display/index.js";
3
+ import { clackPrompts } from "../wizard/index.js";
4
+ import { parseArgs } from "./args.js";
5
+ import { describeError, detailTemplates, fallbackMissingPackageRoot, isPromptCancellation, printError, runWithSafetyNet, templatesFrom, } from "./errors.js";
6
+ import { EXIT_ERROR, EXIT_INTERRUPTED, EXIT_OK } from "./exit-codes.js";
7
+ import { Installer } from "./install.js";
8
+ import { resolvePackageRoot } from "./package-root.js";
9
+ import { resolveProjectRoot } from "./project-root.js";
10
+ import { Uninstaller } from "./uninstall.js";
11
+ import { helpText, versionText } from "./version.js";
12
+ /**
13
+ * Runs the installer with args (without the program name) and returns the
14
+ * exit code: 0 on success, 1 on any failure, 130 when cancelled. The
15
+ * config is loaded and validated, and a terminal required, before
16
+ * anything is written; an escaped error becomes one sanitized stderr line.
17
+ */
18
+ export function run(args, deps) {
19
+ return runWithSafetyNet(deps.stderr, () => runChecked(parseArgs(args), deps));
20
+ }
21
+ async function runChecked(options, deps) {
22
+ const packageRoot = resolvePackageRoot(options.packageRoot, deps.entryUrl);
23
+ if (packageRoot === undefined) {
24
+ printError(deps.stderr, fallbackMissingPackageRoot);
25
+ return EXIT_ERROR;
26
+ }
27
+ const config = loadConfig(packageRoot);
28
+ const messages = config.wizard.messages;
29
+ const templates = templatesFrom(messages);
30
+ const version = readVersion(packageRoot, templates, deps.stderr);
31
+ if (version === undefined) {
32
+ return EXIT_ERROR;
33
+ }
34
+ const early = earlyExit(options, messages, version, deps);
35
+ if (early !== undefined) {
36
+ return early;
37
+ }
38
+ const createUi = deps.createUi ?? createRenderer;
39
+ const session = {
40
+ config,
41
+ packageRoot,
42
+ projectRoot: resolveProjectRoot(deps.env, deps.cwd()),
43
+ version,
44
+ ui: createUi(messages, config.settings.brandColor, deps.stdout),
45
+ templates,
46
+ };
47
+ const wizardPrompts = {
48
+ prompts: deps.prompts ?? clackPrompts,
49
+ context: { input: deps.stdin, output: deps.stdout },
50
+ };
51
+ return new Dispatcher(session, wizardPrompts, deps.stderr).dispatch(options);
52
+ }
53
+ function createRenderer(messages, brandColor, output) {
54
+ return new Renderer(messages, brandColor, output);
55
+ }
56
+ function readVersion(packageRoot, templates, stderr) {
57
+ try {
58
+ return readPackageVersion(packageRoot);
59
+ }
60
+ catch (error) {
61
+ printError(stderr, describeError(templates, error));
62
+ return undefined;
63
+ }
64
+ }
65
+ /** Handles the runs that end before any flow starts: a removed flag, invalid --agent, help, version and the missing terminal. */
66
+ function earlyExit(options, messages, version, deps) {
67
+ if (options.removedFlag !== undefined) {
68
+ printError(deps.stderr, removedFlagMessage(options.removedFlag));
69
+ return EXIT_ERROR;
70
+ }
71
+ if (options.invalidAgent) {
72
+ printError(deps.stderr, fill(messages.invalidAgent, { agents: agents().join(", ") }));
73
+ return EXIT_ERROR;
74
+ }
75
+ if (options.help) {
76
+ deps.stdout.write(`${helpText(messages, version)}\n`);
77
+ return EXIT_OK;
78
+ }
79
+ if (options.version) {
80
+ deps.stdout.write(`${versionText(messages, version)}\n`);
81
+ return EXIT_OK;
82
+ }
83
+ if (!deps.isTTY()) {
84
+ printError(deps.stderr, options.uninstall ? messages.uninstallNoTerminal : messages.noTerminal);
85
+ return EXIT_ERROR;
86
+ }
87
+ return undefined;
88
+ }
89
+ function removedFlagMessage(flag) {
90
+ return `workflow: unknown option ${flag} — the installer is interactive only and needs a terminal`;
91
+ }
92
+ /**
93
+ * Runs the uninstall when asked for by --uninstall or, for a project that
94
+ * has yaawn installed, by the mode menu; every other run installs.
95
+ */
96
+ class Dispatcher {
97
+ session;
98
+ wizardPrompts;
99
+ stderr;
100
+ constructor(session, wizardPrompts, stderr) {
101
+ this.session = session;
102
+ this.wizardPrompts = wizardPrompts;
103
+ this.stderr = stderr;
104
+ }
105
+ async dispatch(options) {
106
+ const uninstaller = new Uninstaller(this.session);
107
+ if (options.uninstall) {
108
+ return this.guardUninstall(async () => uninstaller.run(await uninstaller.plan()));
109
+ }
110
+ const installed = await uninstaller.findInstalled();
111
+ if (installed !== undefined) {
112
+ const uninstalled = await this.guardUninstall(async () => (await this.session.ui.chooseMode()) === MODE_UNINSTALL ? uninstaller.run(installed) : undefined);
113
+ if (uninstalled !== undefined) {
114
+ return uninstalled;
115
+ }
116
+ }
117
+ return this.guardInstall(() => new Installer(this.session, this.wizardPrompts, options.agent).run());
118
+ }
119
+ guardInstall(action) {
120
+ const messages = this.session.config.wizard.messages;
121
+ return this.guard(action, messages.installCancelled, (error) => describeError(this.session.templates, error));
122
+ }
123
+ guardUninstall(action) {
124
+ const messages = this.session.config.wizard.messages;
125
+ return this.guard(action, messages.uninstallCancelled, (error) => fill(messages.uninstallFailed, { error: describeError(detailTemplates(this.session.templates), error) }));
126
+ }
127
+ /** Ctrl+C at a prompt prints cancelledText and exits 130; any other error prints one described line and exits 1. */
128
+ async guard(action, cancelledText, describe) {
129
+ try {
130
+ return await action();
131
+ }
132
+ catch (error) {
133
+ if (isPromptCancellation(error)) {
134
+ printError(this.stderr, cancelledText);
135
+ return EXIT_INTERRUPTED;
136
+ }
137
+ printError(this.stderr, describe(error));
138
+ return EXIT_ERROR;
139
+ }
140
+ }
141
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import process from "node:process";
2
+ /** The installer is interactive only, so both ends must be a terminal. */
3
+ export function isInteractive(streams = process) {
4
+ return streams.stdin.isTTY === true && streams.stdout.isTTY === true;
5
+ }
@@ -0,0 +1,89 @@
1
+ import { fill, readPackageName } from "../config/index.js";
2
+ import { failureReason, findInstalledLinks, KIND_IMPORTS, removeLinks } from "../link/index.js";
3
+ import { applyUninstall, planUninstall, uninstallEntries, UninstallError } from "../sync/index.js";
4
+ import { describeError, detailTemplates } from "./errors.js";
5
+ import { EXIT_ERROR, EXIT_INTERRUPTED, EXIT_OK } from "./exit-codes.js";
6
+ const FAILURE_SEPARATOR = "; ";
7
+ /**
8
+ * Runs one uninstall: list everything yaawn installed, ask with "No"
9
+ * preselected, then remove the agent links first — trying every one —
10
+ * and the files after them. Nothing is removed without confirmation.
11
+ */
12
+ export class Uninstaller {
13
+ session;
14
+ constructor(session) {
15
+ this.session = session;
16
+ }
17
+ /** Lists the links and files to remove; it only reads the file system. */
18
+ async plan() {
19
+ const { config, packageRoot, projectRoot } = this.session;
20
+ return {
21
+ links: await findInstalledLinks(projectRoot),
22
+ files: await planUninstall(config, packageRoot, projectRoot),
23
+ };
24
+ }
25
+ /**
26
+ * Returns the targets when yaawn is installed. A planning error counts
27
+ * as not installed, so the install runs unchanged and reports the
28
+ * problem itself.
29
+ */
30
+ async findInstalled() {
31
+ try {
32
+ const targets = await this.plan();
33
+ return this.entries(targets).length > 0 ? targets : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ async run(targets) {
40
+ const { packageRoot, ui } = this.session;
41
+ const packageName = readPackageName(packageRoot);
42
+ const entries = this.entries(targets);
43
+ if (entries.length === 0) {
44
+ ui.nothingToRemove(packageName);
45
+ return EXIT_OK;
46
+ }
47
+ if (!(await ui.confirmUninstall(entries))) {
48
+ ui.uninstallCancelled();
49
+ return EXIT_INTERRUPTED;
50
+ }
51
+ const outcome = await ui.uninstalling(() => this.remove(targets));
52
+ ui.uninstallSummary({ ...outcome, packageName });
53
+ return outcome.failure === undefined ? EXIT_OK : EXIT_ERROR;
54
+ }
55
+ /** The entries shown before asking, in removal order. */
56
+ entries(targets) {
57
+ return [
58
+ ...targets.links.map((entry) => this.linkDisplay(entry)),
59
+ ...uninstallEntries(targets.files).map((entry) => entry.display),
60
+ ];
61
+ }
62
+ async remove(targets) {
63
+ const links = await removeLinks(this.session.projectRoot, targets.links);
64
+ const linkFailures = links.failures.map(({ entry, error }) => `remove ${this.linkDisplay(entry)}: ${failureReason(error)}`);
65
+ const files = await this.removeFiles(targets.files);
66
+ const failures = files.failure === undefined ? linkFailures : [...linkFailures, files.failure];
67
+ const summary = {
68
+ removed: [...links.removed.map((entry) => this.linkDisplay(entry)), ...files.summary.removed],
69
+ pruned: files.summary.pruned,
70
+ keptPlans: files.summary.keptPlans,
71
+ };
72
+ return failures.length === 0 ? { summary } : { summary, failure: failures.join(FAILURE_SEPARATOR) };
73
+ }
74
+ /** Stops at the first failure; the outcome lists what was removed before it. There is no rollback. */
75
+ async removeFiles(plan) {
76
+ try {
77
+ return { summary: await applyUninstall(plan) };
78
+ }
79
+ catch (error) {
80
+ const summary = error instanceof UninstallError ? error.result : { removed: [], pruned: [], keptPlans: plan.keptPlans };
81
+ return { summary, failure: describeError(detailTemplates(this.session.templates), error) };
82
+ }
83
+ }
84
+ linkDisplay(entry) {
85
+ return entry.kind === KIND_IMPORTS
86
+ ? fill(this.session.config.wizard.messages.uninstallImportsEntry, { path: entry.path })
87
+ : entry.path;
88
+ }
89
+ }
@@ -0,0 +1,7 @@
1
+ import { fill } from "../config/index.js";
2
+ export function helpText(messages, version) {
3
+ return fill(messages.help, { version });
4
+ }
5
+ export function versionText(messages, version) {
6
+ return fill(messages.version, { version });
7
+ }
@@ -0,0 +1,108 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { UnknownAgentError } from "./errors.js";
5
+ /**
6
+ * Coding agents the installer can set up. They are the only values
7
+ * accepted for --agent, the wizard's single-select section and the
8
+ * project's settings file.
9
+ */
10
+ export const AGENT_COPILOT = "copilot";
11
+ export const AGENT_CLAUDE = "claude";
12
+ export const AGENT_VIBE = "vibe";
13
+ const AGENTS = [AGENT_COPILOT, AGENT_CLAUDE, AGENT_VIBE];
14
+ export const DEFAULT_AGENT = AGENT_COPILOT;
15
+ /** yaawn's runtime directory inside the project; the settings file lives there. */
16
+ export const PROJECT_DIR = ".yaawn";
17
+ /** Caps how much of the settings file is read, so a huge file cannot exhaust memory. */
18
+ export const MAX_SETTINGS_SIZE = 64 * 1024;
19
+ const SETTINGS_DIR_MODE = 0o755;
20
+ const SETTINGS_FILE_MODE = 0o644;
21
+ /** Returns the allowed coding agents in display order. */
22
+ export function agents() {
23
+ return [...AGENTS];
24
+ }
25
+ export function isAgent(value) {
26
+ return typeof value === "string" && AGENTS.includes(value);
27
+ }
28
+ /**
29
+ * Returns the agent saved in the settings file at filePath, or undefined
30
+ * when the file is missing, not a regular file, oversized, unparsable or
31
+ * names an agent outside the allow-list, so callers fall back safely.
32
+ */
33
+ export function readAgentSetting(filePath) {
34
+ const text = readBoundedText(filePath);
35
+ if (text === undefined) {
36
+ return undefined;
37
+ }
38
+ const agent = parseAgent(text);
39
+ return isAgent(agent) ? agent : undefined;
40
+ }
41
+ function readBoundedText(filePath) {
42
+ try {
43
+ if (!fs.statSync(filePath).isFile()) {
44
+ return undefined;
45
+ }
46
+ const buffer = readAtMost(filePath, MAX_SETTINGS_SIZE + 1);
47
+ return buffer.length > MAX_SETTINGS_SIZE ? undefined : buffer.toString("utf8");
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ function readAtMost(filePath, limit) {
54
+ const buffer = Buffer.alloc(limit);
55
+ const descriptor = fs.openSync(filePath, "r");
56
+ try {
57
+ let total = 0;
58
+ let bytesRead = -1;
59
+ while (total < limit && bytesRead !== 0) {
60
+ bytesRead = fs.readSync(descriptor, buffer, total, limit - total, null);
61
+ total += bytesRead;
62
+ }
63
+ return buffer.subarray(0, total);
64
+ }
65
+ finally {
66
+ fs.closeSync(descriptor);
67
+ }
68
+ }
69
+ function parseAgent(text) {
70
+ try {
71
+ const settings = JSON.parse(text);
72
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
73
+ return undefined;
74
+ }
75
+ return Object.hasOwn(settings, "agent") ? settings.agent : undefined;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Writes {"agent":"<agent>"} to filePath, creating its directory. The
83
+ * content goes to a sibling temp file first and is renamed into place,
84
+ * so a crash never leaves a half-written settings file. The caller must
85
+ * have contained filePath inside the project.
86
+ */
87
+ export function writeAgentSetting(filePath, agent) {
88
+ if (!isAgent(agent)) {
89
+ throw new UnknownAgentError(agent);
90
+ }
91
+ createSettingsDir(filePath);
92
+ writeFileAtomically(filePath, `${JSON.stringify({ agent })}\n`);
93
+ }
94
+ /** Creates the directory that holds the settings file at filePath. */
95
+ export function createSettingsDir(filePath) {
96
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: SETTINGS_DIR_MODE });
97
+ }
98
+ function writeFileAtomically(filePath, content) {
99
+ const tempPath = `${filePath}.${process.pid}.tmp`;
100
+ try {
101
+ fs.writeFileSync(tempPath, content, { flag: "wx", mode: SETTINGS_FILE_MODE });
102
+ fs.renameSync(tempPath, filePath);
103
+ }
104
+ catch (error) {
105
+ fs.rmSync(tempPath, { force: true });
106
+ throw error;
107
+ }
108
+ }