@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,86 @@
1
+ import { readStrictObject, readString } from "./strict-json.js";
2
+ const MESSAGE_KEYS = [
3
+ "help",
4
+ "version",
5
+ "headerTitle",
6
+ "introTitle",
7
+ "introSubtitle",
8
+ "intro",
9
+ "outroTitle",
10
+ "outro",
11
+ "noTerminal",
12
+ "configLoadFailed",
13
+ "invalidConfig",
14
+ "installFailed",
15
+ "installCancelled",
16
+ "pathOutsideRoot",
17
+ "installing",
18
+ "fileKept",
19
+ "syncSourceMissing",
20
+ "syncFailed",
21
+ "synced",
22
+ "syncedReplaced",
23
+ "languagesInstalled",
24
+ "languagesNone",
25
+ "languagesFailed",
26
+ "sectionFileMissing",
27
+ "currentItems",
28
+ "noItems",
29
+ "emptySubmit",
30
+ "duplicateItem",
31
+ "itemsAdded",
32
+ "itemsUnchanged",
33
+ "selectedCount",
34
+ "noneSelected",
35
+ "modeTitle",
36
+ "modeInstall",
37
+ "modeUninstall",
38
+ "uninstallHeaderTitle",
39
+ "uninstallList",
40
+ "uninstallConfirm",
41
+ "uninstallYes",
42
+ "uninstallNo",
43
+ "uninstalling",
44
+ "uninstallCancelled",
45
+ "nothingToRemove",
46
+ "uninstallRemoved",
47
+ "uninstallPruned",
48
+ "uninstallKeptPlans",
49
+ "uninstallFailed",
50
+ "uninstallRemovedSoFar",
51
+ "uninstallNpmHint",
52
+ "uninstallNoTerminal",
53
+ "invalidAgent",
54
+ "linkCreated",
55
+ "linkExisted",
56
+ "linkSkipped",
57
+ "linkFailed",
58
+ "importsCreated",
59
+ "importsExisted",
60
+ "importsSkipped",
61
+ "importsFailed",
62
+ "linksFailed",
63
+ "uninstallImportsEntry",
64
+ "projectsUpdated",
65
+ "projectsSkipped",
66
+ ];
67
+ /** Reads the messages object: every message is required and must be a non-empty string; unknown keys are rejected. */
68
+ export function decodeMessages(value, at) {
69
+ const object = readStrictObject(value, at, MESSAGE_KEYS);
70
+ const messages = {};
71
+ for (const key of MESSAGE_KEYS) {
72
+ messages[key] = readMessage(object[key], at.key(key));
73
+ }
74
+ return messages;
75
+ }
76
+ function readMessage(value, at) {
77
+ if (value === undefined || value === "") {
78
+ throw at.error("is required");
79
+ }
80
+ return readString(value, at);
81
+ }
82
+ const PLACEHOLDER = /\{([^{}]+)\}/g;
83
+ /** Replaces every {name} placeholder in template with its value in one pass; unknown placeholders stay. */
84
+ export function fill(template, values) {
85
+ return template.replace(PLACEHOLDER, (placeholder, name) => Object.hasOwn(values, name) ? (values[name] ?? placeholder) : placeholder);
86
+ }
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ const FORBIDDEN_NAME_CHARACTERS = /[/\\*]/;
3
+ const NUL = "\u0000";
4
+ const WINDOWS_DRIVE = /^[A-Za-z]:/;
5
+ /** Reports whether name is a single plain path segment. */
6
+ export function isFileName(name) {
7
+ return name !== "" && name !== "." && name !== ".." && !FORBIDDEN_NAME_CHARACTERS.test(name) && !name.includes(NUL);
8
+ }
9
+ /** Reports whether candidate is absolute on any platform, including a Windows drive. */
10
+ export function isAbsolutePath(candidate) {
11
+ return path.posix.isAbsolute(candidate) || path.win32.isAbsolute(candidate) || WINDOWS_DRIVE.test(candidate);
12
+ }
13
+ /**
14
+ * Reports whether dir is a slash-separated relative path without empty,
15
+ * "." or ".." segments, so it can only point inside its root.
16
+ */
17
+ export function isRelativeDir(dir) {
18
+ if (dir === "" || isAbsolutePath(dir)) {
19
+ return false;
20
+ }
21
+ return dir.split("/").every(isFileName);
22
+ }
23
+ /**
24
+ * Reports whether pattern looks like "<dir>/<prefix>*": relative, no "..",
25
+ * and scoped to the owned prefix, so the delete step can never touch
26
+ * entries the project owns.
27
+ */
28
+ export function isRemovePattern(pattern, ownedPrefix) {
29
+ const separator = pattern.lastIndexOf("/");
30
+ const dir = pattern.slice(0, separator + 1);
31
+ const last = pattern.slice(separator + 1);
32
+ if (last !== `${ownedPrefix}*`) {
33
+ return false;
34
+ }
35
+ return dir === "" || isRelativeDir(dir.slice(0, -1));
36
+ }
37
+ /** Returns the directory part of a removeFirst pattern, relative to the sync target ("." for the target itself). */
38
+ export function removeDir(pattern) {
39
+ return path.posix.dirname(pattern);
40
+ }
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { PathEscapeError } from "./errors.js";
4
+ import { isAbsolutePath } from "./names.js";
5
+ /**
6
+ * Joins the slash-separated elements onto root and returns the absolute
7
+ * result. Rejects absolute elements, results outside root, and existing
8
+ * symlinks that resolve outside root.
9
+ */
10
+ export function contain(root, ...elements) {
11
+ const relative = path.posix.join(...elements);
12
+ if (isAbsolutePath(relative)) {
13
+ throw new PathEscapeError(relative, root);
14
+ }
15
+ const absoluteRoot = path.resolve(root);
16
+ const joined = path.join(absoluteRoot, ...relative.split("/"));
17
+ if (!isWithin(absoluteRoot, joined)) {
18
+ throw new PathEscapeError(relative, root);
19
+ }
20
+ assertNoSymlinkEscape(absoluteRoot, joined);
21
+ return joined;
22
+ }
23
+ function isWithin(root, candidate) {
24
+ const relative = path.relative(root, candidate);
25
+ if (path.isAbsolute(relative)) {
26
+ return false;
27
+ }
28
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`);
29
+ }
30
+ /** Resolves the deepest existing part of candidate and verifies it still lies inside the resolved root. */
31
+ function assertNoSymlinkEscape(root, candidate) {
32
+ const realRoot = realpathIfExists(root);
33
+ if (realRoot === undefined) {
34
+ return;
35
+ }
36
+ const resolved = fs.realpathSync(deepestExisting(root, candidate));
37
+ if (!isWithin(realRoot, resolved)) {
38
+ throw new PathEscapeError(candidate, root);
39
+ }
40
+ }
41
+ function realpathIfExists(target) {
42
+ try {
43
+ return fs.realpathSync(target);
44
+ }
45
+ catch (error) {
46
+ if (isMissingEntry(error)) {
47
+ return undefined;
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+ function deepestExisting(root, candidate) {
53
+ let current = candidate;
54
+ while (current !== root && path.dirname(current) !== current) {
55
+ if (existsWithoutFollowing(current)) {
56
+ return current;
57
+ }
58
+ current = path.dirname(current);
59
+ }
60
+ return root;
61
+ }
62
+ function existsWithoutFollowing(target) {
63
+ try {
64
+ fs.lstatSync(target);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function isMissingEntry(error) {
72
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
73
+ }
@@ -0,0 +1,101 @@
1
+ import { JsonLocation, optional, readStrictObject, readString, readStringArrayRecord, readStringRecord, required, sortedKeys, toRecord, } from "./strict-json.js";
2
+ import { isFileName } from "./names.js";
3
+ import { ValidationError } from "./errors.js";
4
+ /** Name of the settings config file. */
5
+ export const SETTINGS_FILE = "settings.json";
6
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
7
+ const OWNED_PREFIX = /^[a-z0-9]+(-[a-z0-9]+)*-$/;
8
+ const LANGUAGE_TAG = /^[a-z0-9]+$/;
9
+ const SETTINGS_KEYS = [
10
+ "brandColor",
11
+ "filePrefix",
12
+ "outputFileNames",
13
+ "settingsFileName",
14
+ "languagePattern",
15
+ "languageLabels",
16
+ "languageImplications",
17
+ ];
18
+ export function decodeSettings(document) {
19
+ const at = new JsonLocation(SETTINGS_FILE);
20
+ const object = readStrictObject(document, at, SETTINGS_KEYS);
21
+ return {
22
+ brandColor: required(object, "brandColor", at, readString),
23
+ filePrefix: required(object, "filePrefix", at, readString),
24
+ outputFileNames: required(object, "outputFileNames", at, readStringRecord),
25
+ settingsFileName: required(object, "settingsFileName", at, readString),
26
+ languagePattern: required(object, "languagePattern", at, readString),
27
+ languageLabels: required(object, "languageLabels", at, readStringRecord),
28
+ languageImplications: optional(object, "languageImplications", at, readStringArrayRecord, toRecord([])),
29
+ };
30
+ }
31
+ export function validateSettings(settings) {
32
+ if (!HEX_COLOR.test(settings.brandColor)) {
33
+ throw settingsError("brandColor", "must be a hex colour like #0096ff");
34
+ }
35
+ if (!OWNED_PREFIX.test(settings.filePrefix)) {
36
+ throw settingsError("filePrefix", 'must be lowercase letters, digits and hyphens ending in "-"');
37
+ }
38
+ validateOutputFileNames(settings.outputFileNames);
39
+ if (!isFileName(settings.settingsFileName)) {
40
+ throw settingsError("settingsFileName", "must be a plain file name");
41
+ }
42
+ validateLanguageLabels(settings.languageLabels);
43
+ validateLanguageImplications(settings.languageImplications);
44
+ return { ...settings, languageRegExp: compileLanguagePattern(settings.languagePattern) };
45
+ }
46
+ function validateOutputFileNames(outputFileNames) {
47
+ const keys = sortedKeys(outputFileNames);
48
+ if (keys.length === 0) {
49
+ throw settingsError("outputFileNames", "must be a non-empty object");
50
+ }
51
+ const invalidKey = keys.find((key) => !isFileName(outputFileNames[key] ?? ""));
52
+ if (invalidKey !== undefined) {
53
+ throw settingsError(`outputFileNames.${invalidKey}`, "must be a plain file name");
54
+ }
55
+ }
56
+ function compileLanguagePattern(pattern) {
57
+ if (pattern === "") {
58
+ throw settingsError("languagePattern", "is required");
59
+ }
60
+ let compiled;
61
+ try {
62
+ compiled = new RegExp(pattern);
63
+ }
64
+ catch {
65
+ throw settingsError("languagePattern", "must be a valid regular expression");
66
+ }
67
+ if (countCaptureGroups(pattern) !== 1) {
68
+ throw settingsError("languagePattern", "must have exactly one capture group");
69
+ }
70
+ return compiled;
71
+ }
72
+ // An empty alternative always matches, so the match array has one slot per group.
73
+ function countCaptureGroups(pattern) {
74
+ const match = new RegExp(`${pattern}|`).exec("");
75
+ return match === null ? 0 : match.length - 1;
76
+ }
77
+ function validateLanguageLabels(languageLabels) {
78
+ const invalidKey = sortedKeys(languageLabels).find((key) => key === "" || languageLabels[key] === "");
79
+ if (invalidKey !== undefined) {
80
+ throw settingsError(`languageLabels.${invalidKey}`, "must map a tag to a non-empty label");
81
+ }
82
+ }
83
+ /** Accepts no implications, but rejects any tag that is not lowercase letters and digits. */
84
+ function validateLanguageImplications(implications) {
85
+ for (const key of sortedKeys(implications)) {
86
+ const field = `languageImplications.${key}`;
87
+ if (!LANGUAGE_TAG.test(key)) {
88
+ throw settingsError(field, "must be keyed by a language tag of lowercase letters and digits");
89
+ }
90
+ const implied = implications[key] ?? [];
91
+ if (implied.length === 0) {
92
+ throw settingsError(field, "must list at least one language tag");
93
+ }
94
+ if (!implied.every((tag) => LANGUAGE_TAG.test(tag))) {
95
+ throw settingsError(field, "must list language tags of lowercase letters and digits");
96
+ }
97
+ }
98
+ }
99
+ export function settingsError(field, problem) {
100
+ return new ValidationError(SETTINGS_FILE, field, problem);
101
+ }
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import { LoadError, ValidationError } from "./errors.js";
3
+ /** Field name used for problems with the document as a whole. */
4
+ export const DOCUMENT_FIELD = "document";
5
+ /** Where a value sits: its config file and the field path inside it. */
6
+ export class JsonLocation {
7
+ file;
8
+ field;
9
+ constructor(file, field = "") {
10
+ this.file = file;
11
+ this.field = field;
12
+ }
13
+ key(name) {
14
+ return new JsonLocation(this.file, this.field === "" ? name : `${this.field}.${name}`);
15
+ }
16
+ index(position) {
17
+ return new JsonLocation(this.file, `${this.field}[${position}]`);
18
+ }
19
+ error(problem) {
20
+ return new ValidationError(this.file, this.field === "" ? DOCUMENT_FIELD : this.field, problem);
21
+ }
22
+ }
23
+ /** Reads and parses one JSON document; file names it in errors. */
24
+ export function readJsonDocument(filePath, file) {
25
+ const text = readText(filePath, file);
26
+ try {
27
+ return JSON.parse(text);
28
+ }
29
+ catch {
30
+ throw new ValidationError(file, DOCUMENT_FIELD, "must contain exactly one valid JSON value");
31
+ }
32
+ }
33
+ function readText(filePath, file) {
34
+ try {
35
+ return fs.readFileSync(filePath, "utf8");
36
+ }
37
+ catch (error) {
38
+ throw new LoadError(file, error);
39
+ }
40
+ }
41
+ /** Reads an object whose keys must all be in allowedKeys. */
42
+ export function readStrictObject(value, at, allowedKeys) {
43
+ const object = readObject(value, at);
44
+ const unknownKey = Object.keys(object).find((key) => !allowedKeys.includes(key));
45
+ if (unknownKey !== undefined) {
46
+ throw at.key(unknownKey).error("is not an allowed key");
47
+ }
48
+ return object;
49
+ }
50
+ /** Reads an object with arbitrary keys. */
51
+ export function readObject(value, at) {
52
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
53
+ throw at.error(typeProblem("an object", value));
54
+ }
55
+ return value;
56
+ }
57
+ export function readString(value, at) {
58
+ if (typeof value !== "string") {
59
+ throw at.error(typeProblem("a string", value));
60
+ }
61
+ return value;
62
+ }
63
+ export function readArray(value, at, readItem) {
64
+ if (!Array.isArray(value)) {
65
+ throw at.error(typeProblem("an array", value));
66
+ }
67
+ return value.map((item, position) => readItem(item, at.index(position)));
68
+ }
69
+ export function readStringArray(value, at) {
70
+ return readArray(value, at, readString);
71
+ }
72
+ export function readStringRecord(value, at) {
73
+ return readRecord(value, at, readString);
74
+ }
75
+ export function readStringArrayRecord(value, at) {
76
+ return readRecord(value, at, readStringArray);
77
+ }
78
+ function readRecord(value, at, readEntry) {
79
+ const entries = Object.entries(readObject(value, at));
80
+ return toRecord(entries.map(([key, entry]) => [key, readEntry(entry, at.key(key))]));
81
+ }
82
+ /** Reads a key that must be present. */
83
+ export function required(object, key, at, read) {
84
+ if (!Object.hasOwn(object, key)) {
85
+ throw at.key(key).error("is required");
86
+ }
87
+ return read(object[key], at.key(key));
88
+ }
89
+ /** Reads a key that may be absent; fallback stands in for it. */
90
+ export function optional(object, key, at, read, fallback) {
91
+ return Object.hasOwn(object, key) ? read(object[key], at.key(key)) : fallback;
92
+ }
93
+ /**
94
+ * Builds a record without a prototype, so keys such as "__proto__" or
95
+ * "constructor" are plain data and lookups never hit inherited members.
96
+ */
97
+ export function toRecord(entries) {
98
+ const record = Object.create(null);
99
+ for (const [key, value] of entries) {
100
+ Object.defineProperty(record, key, { value, enumerable: true });
101
+ }
102
+ return record;
103
+ }
104
+ /** Returns the keys of record in sorted order, for deterministic validation. */
105
+ export function sortedKeys(record) {
106
+ return Object.keys(record).sort();
107
+ }
108
+ function typeProblem(expected, value) {
109
+ return `must be ${expected}, not ${describeKind(value)}`;
110
+ }
111
+ function describeKind(value) {
112
+ if (value === null) {
113
+ return "null";
114
+ }
115
+ if (Array.isArray(value)) {
116
+ return "an array";
117
+ }
118
+ return typeof value === "object" ? "an object" : `a ${typeof value}`;
119
+ }
@@ -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,3 @@
1
+ export { Renderer } from "./renderer.js";
2
+ export { MODE_INSTALL, MODE_UNINSTALL, ReportCollector, } from "./report.js";
3
+ export { sanitize } from "./sanitize.js";