@yaawn/workflow 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -43
- package/agents/personas/yaawn-product-owner.md +3 -3
- package/agents/skills/yaawn-create-plan/SKILL.md +38 -11
- package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
- package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
- package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
- package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-story/SKILL.md +7 -0
- package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
- package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
- package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
- package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
- package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
- package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
- package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
- package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
- package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
- package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
- package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
- package/agents/skills/yaawn-write-other/SKILL.md +6 -5
- package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
- package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
- package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
- package/agents/skills/yaawn-write-user-story/SKILL.md +3 -2
- package/agents/yaawn-glossary.md +4 -3
- package/config/install-tasks.json +0 -1
- package/config/wizard.json +3 -8
- package/dist/cli/args.js +66 -0
- package/dist/cli/errors.js +103 -0
- package/dist/cli/exit-codes.js +3 -0
- package/dist/cli/index.js +11 -0
- package/dist/cli/install-report.js +92 -0
- package/dist/cli/install.js +124 -0
- package/dist/cli/main.js +17 -0
- package/dist/cli/package-root.js +17 -0
- package/dist/cli/project-root.js +11 -0
- package/dist/cli/run.js +141 -0
- package/dist/cli/session.js +1 -0
- package/dist/cli/terminal.js +5 -0
- package/dist/cli/uninstall.js +89 -0
- package/dist/cli/version.js +7 -0
- package/dist/config/agents.js +108 -0
- package/dist/config/config.js +118 -0
- package/dist/config/errors.js +45 -0
- package/dist/config/index.js +11 -0
- package/dist/config/manifest.js +28 -0
- package/dist/config/messages.js +86 -0
- package/dist/config/names.js +40 -0
- package/dist/config/paths.js +73 -0
- package/dist/config/settings.js +101 -0
- package/dist/config/strict-json.js +119 -0
- package/dist/config/tasks.js +77 -0
- package/dist/config/wizard.js +139 -0
- package/dist/display/index.js +3 -0
- package/dist/display/palette.js +61 -0
- package/dist/display/renderer.js +169 -0
- package/dist/display/report.js +15 -0
- package/dist/display/sanitize.js +10 -0
- package/dist/link/errors.js +34 -0
- package/dist/link/fs-utils.js +79 -0
- package/dist/link/index.js +4 -0
- package/dist/link/install.js +20 -0
- package/dist/link/linker.js +137 -0
- package/dist/link/memory.js +221 -0
- package/dist/link/names.js +30 -0
- package/dist/link/types.js +30 -0
- package/dist/link/uninstall.js +90 -0
- package/dist/sync/apply.js +74 -0
- package/dist/sync/changes.js +31 -0
- package/dist/sync/fs-utils.js +63 -0
- package/dist/sync/index.js +7 -0
- package/dist/sync/install.js +57 -0
- package/dist/sync/languages.js +80 -0
- package/dist/sync/plan.js +146 -0
- package/dist/sync/projects.js +294 -0
- package/dist/sync/uninstall.js +234 -0
- package/dist/wizard/build.js +89 -0
- package/dist/wizard/index.js +5 -0
- package/dist/wizard/items.js +83 -0
- package/dist/wizard/prompts.js +167 -0
- package/dist/wizard/section.js +143 -0
- package/dist/wizard/wizard.js +73 -0
- package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
- package/package.json +11 -10
- package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
- package/scripts/install.js +0 -73
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createSettingsDir, DEFAULT_AGENT, isAgent, PROJECT_DIR, readAgentSetting, writeAgentSetting, } from "./agents.js";
|
|
3
|
+
import { UnknownAgentError } from "./errors.js";
|
|
4
|
+
import { contain } from "./paths.js";
|
|
5
|
+
import { decodeSettings, SETTINGS_FILE, settingsError, validateSettings } from "./settings.js";
|
|
6
|
+
import { readJsonDocument, sortedKeys } from "./strict-json.js";
|
|
7
|
+
import { decodeInstallTasks, INSTALL_TASKS_FILE, isSeedOnly, validateInstallTasks } from "./tasks.js";
|
|
8
|
+
import { decodeWizard, singleSelectSection, validateWizard, WIZARD_FILE } from "./wizard.js";
|
|
9
|
+
/** The config directory inside the package root. */
|
|
10
|
+
export const CONFIG_DIR = "config";
|
|
11
|
+
/** A file the wizard appends to: seeded by a sync target and owned by the user afterwards. */
|
|
12
|
+
export class OutputFile {
|
|
13
|
+
key;
|
|
14
|
+
name;
|
|
15
|
+
target;
|
|
16
|
+
constructor(key, name, target) {
|
|
17
|
+
this.key = key;
|
|
18
|
+
this.name = name;
|
|
19
|
+
this.target = target;
|
|
20
|
+
}
|
|
21
|
+
/** The seed copy of the file inside the package. */
|
|
22
|
+
sourcePath(packageRoot) {
|
|
23
|
+
return contain(packageRoot, this.target.source, this.name);
|
|
24
|
+
}
|
|
25
|
+
/** The file inside the project. */
|
|
26
|
+
destinationPath(projectRoot) {
|
|
27
|
+
return contain(projectRoot, this.target.target, this.name);
|
|
28
|
+
}
|
|
29
|
+
/** The project-relative, slash-separated path. */
|
|
30
|
+
displayPath() {
|
|
31
|
+
return path.posix.join(this.target.target, this.name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The three validated config files. */
|
|
35
|
+
export class Config {
|
|
36
|
+
settings;
|
|
37
|
+
wizard;
|
|
38
|
+
tasks;
|
|
39
|
+
constructor(settings, wizard, tasks) {
|
|
40
|
+
this.settings = settings;
|
|
41
|
+
this.wizard = wizard;
|
|
42
|
+
this.tasks = tasks;
|
|
43
|
+
}
|
|
44
|
+
/** Looks up the output file registered under key. */
|
|
45
|
+
outputFile(key) {
|
|
46
|
+
if (!Object.hasOwn(this.settings.outputFileNames, key)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const name = this.settings.outputFileNames[key] ?? "";
|
|
50
|
+
const target = this.tasks.syncTargets.find((candidate) => isSeedOnly(candidate, name));
|
|
51
|
+
return target === undefined ? undefined : new OutputFile(key, name, target);
|
|
52
|
+
}
|
|
53
|
+
/** The output file names; the sync never deletes them even when a removeFirst pattern matches. */
|
|
54
|
+
userOwnedFiles() {
|
|
55
|
+
return sortedKeys(this.settings.outputFileNames).map((key) => this.settings.outputFileNames[key] ?? key);
|
|
56
|
+
}
|
|
57
|
+
/** The settings file inside projectRoot; throws PathEscapeError when it would escape. */
|
|
58
|
+
agentSettingsPath(projectRoot) {
|
|
59
|
+
return contain(projectRoot, PROJECT_DIR, this.settings.settingsFileName);
|
|
60
|
+
}
|
|
61
|
+
/** The agent saved in the project's settings file; undefined whenever it cannot be read safely. */
|
|
62
|
+
previousAgent(projectRoot) {
|
|
63
|
+
try {
|
|
64
|
+
return readAgentSetting(this.agentSettingsPath(projectRoot));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** requested when it is an allowed agent, else the project's previous choice, else the default agent. */
|
|
71
|
+
resolveAgent(projectRoot, requested) {
|
|
72
|
+
if (isAgent(requested)) {
|
|
73
|
+
return requested;
|
|
74
|
+
}
|
|
75
|
+
return this.previousAgent(projectRoot) ?? this.agentDefault();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Writes agent to the project's settings file, creating .yaawn/ when
|
|
79
|
+
* missing. The path is contained before and after the directory is
|
|
80
|
+
* created, so a symlink planted on the way cannot redirect the write.
|
|
81
|
+
*/
|
|
82
|
+
saveAgent(projectRoot, agent) {
|
|
83
|
+
if (!isAgent(agent)) {
|
|
84
|
+
throw new UnknownAgentError(agent);
|
|
85
|
+
}
|
|
86
|
+
createSettingsDir(this.agentSettingsPath(projectRoot));
|
|
87
|
+
writeAgentSetting(this.agentSettingsPath(projectRoot), agent);
|
|
88
|
+
}
|
|
89
|
+
/** The default of the single-select section, or DEFAULT_AGENT without one. */
|
|
90
|
+
agentDefault() {
|
|
91
|
+
const fallback = singleSelectSection(this.wizard)?.default;
|
|
92
|
+
return isAgent(fallback) ? fallback : DEFAULT_AGENT;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Reads and validates settings.json, wizard.json and install-tasks.json from the config directory of packageRoot. */
|
|
96
|
+
export function loadConfig(packageRoot) {
|
|
97
|
+
const dir = contain(packageRoot, CONFIG_DIR);
|
|
98
|
+
const settings = decodeSettings(readJsonDocument(contain(dir, SETTINGS_FILE), SETTINGS_FILE));
|
|
99
|
+
const wizard = decodeWizard(readJsonDocument(contain(dir, WIZARD_FILE), WIZARD_FILE));
|
|
100
|
+
const tasks = decodeInstallTasks(readJsonDocument(contain(dir, INSTALL_TASKS_FILE), INSTALL_TASKS_FILE));
|
|
101
|
+
return createConfig(settings, wizard, tasks);
|
|
102
|
+
}
|
|
103
|
+
/** Validates the three config parts, including the references between them. */
|
|
104
|
+
export function createConfig(settingsDocument, wizard, tasks) {
|
|
105
|
+
const settings = validateSettings(settingsDocument);
|
|
106
|
+
validateWizard(wizard, settings);
|
|
107
|
+
validateInstallTasks(tasks, settings.filePrefix);
|
|
108
|
+
const config = new Config(settings, wizard, tasks);
|
|
109
|
+
validateOutputFiles(config);
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
/** Requires every output file to be seed-only in a sync target, so it has a known location and is never deleted. */
|
|
113
|
+
function validateOutputFiles(config) {
|
|
114
|
+
const unseeded = sortedKeys(config.settings.outputFileNames).find((key) => config.outputFile(key) === undefined);
|
|
115
|
+
if (unseeded !== undefined) {
|
|
116
|
+
throw settingsError(`outputFileNames.${unseeded}`, `must be listed in a syncTargets seedOnly array of ${INSTALL_TASKS_FILE}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** A config value that breaks the schema, named by file and field path. */
|
|
2
|
+
export class ValidationError extends Error {
|
|
3
|
+
file;
|
|
4
|
+
field;
|
|
5
|
+
problem;
|
|
6
|
+
name = "ValidationError";
|
|
7
|
+
constructor(file, field, problem) {
|
|
8
|
+
super(`${file}: ${field} ${problem}`);
|
|
9
|
+
this.file = file;
|
|
10
|
+
this.field = field;
|
|
11
|
+
this.problem = problem;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** A config file that could not be read. */
|
|
15
|
+
export class LoadError extends Error {
|
|
16
|
+
file;
|
|
17
|
+
name = "LoadError";
|
|
18
|
+
constructor(file, cause) {
|
|
19
|
+
super(`${file}: ${describeCause(cause)}`, { cause });
|
|
20
|
+
this.file = file;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A path that resolves outside its root, directly or through a symlink. */
|
|
24
|
+
export class PathEscapeError extends Error {
|
|
25
|
+
path;
|
|
26
|
+
root;
|
|
27
|
+
name = "PathEscapeError";
|
|
28
|
+
constructor(path, root) {
|
|
29
|
+
super(`path ${path} escapes ${root}`);
|
|
30
|
+
this.path = path;
|
|
31
|
+
this.root = root;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** An agent outside the allow-list of coding agents. */
|
|
35
|
+
export class UnknownAgentError extends Error {
|
|
36
|
+
agent;
|
|
37
|
+
name = "UnknownAgentError";
|
|
38
|
+
constructor(agent) {
|
|
39
|
+
super("unknown coding agent");
|
|
40
|
+
this.agent = agent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function describeCause(cause) {
|
|
44
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
45
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { AGENT_CLAUDE, AGENT_COPILOT, AGENT_VIBE, DEFAULT_AGENT, MAX_SETTINGS_SIZE, PROJECT_DIR, agents, isAgent, readAgentSetting, writeAgentSetting, } from "./agents.js";
|
|
2
|
+
export { CONFIG_DIR, Config, OutputFile, createConfig, loadConfig } from "./config.js";
|
|
3
|
+
export { LoadError, PathEscapeError, UnknownAgentError, ValidationError } from "./errors.js";
|
|
4
|
+
export { PACKAGE_MANIFEST, readPackageName, readPackageVersion } from "./manifest.js";
|
|
5
|
+
export { fill } from "./messages.js";
|
|
6
|
+
export { isFileName, isRelativeDir, removeDir } from "./names.js";
|
|
7
|
+
export { contain } from "./paths.js";
|
|
8
|
+
export { SETTINGS_FILE } from "./settings.js";
|
|
9
|
+
export { DOCUMENT_FIELD } from "./strict-json.js";
|
|
10
|
+
export { INSTALL_TASKS_FILE, installedName, installedRenames, isSeedOnly, } from "./tasks.js";
|
|
11
|
+
export { KIND_MULTISELECT, KIND_PROJECTS_LIST, KIND_SINGLE_SELECT, KIND_TEXT_LIST, WIZARD_FILE, singleSelectSection, } from "./wizard.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.js";
|
|
2
|
+
import { contain } from "./paths.js";
|
|
3
|
+
import { JsonLocation, readJsonDocument, readObject, readString } from "./strict-json.js";
|
|
4
|
+
/** The package manifest inside the package root. */
|
|
5
|
+
export const PACKAGE_MANIFEST = "package.json";
|
|
6
|
+
const VERSION_PATTERN = /^[0-9A-Za-z.+-]+$/;
|
|
7
|
+
const PACKAGE_NAME_PATTERN = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
8
|
+
/** Reads the version field of the package manifest. */
|
|
9
|
+
export function readPackageVersion(packageRoot) {
|
|
10
|
+
return readManifestField(packageRoot, "version", VERSION_PATTERN);
|
|
11
|
+
}
|
|
12
|
+
/** Reads the name field of the package manifest; a missing name fails instead of guessing the npm uninstall command. */
|
|
13
|
+
export function readPackageName(packageRoot) {
|
|
14
|
+
return readManifestField(packageRoot, "name", PACKAGE_NAME_PATTERN);
|
|
15
|
+
}
|
|
16
|
+
function readManifestField(packageRoot, key, pattern) {
|
|
17
|
+
const manifestPath = contain(packageRoot, PACKAGE_MANIFEST);
|
|
18
|
+
const at = new JsonLocation(PACKAGE_MANIFEST);
|
|
19
|
+
const manifest = readObject(readJsonDocument(manifestPath, PACKAGE_MANIFEST), at);
|
|
20
|
+
const value = Object.hasOwn(manifest, key) ? readString(manifest[key], at.key(key)) : "";
|
|
21
|
+
if (value === "") {
|
|
22
|
+
throw new ValidationError(PACKAGE_MANIFEST, key, "is required");
|
|
23
|
+
}
|
|
24
|
+
if (!pattern.test(value)) {
|
|
25
|
+
throw new ValidationError(PACKAGE_MANIFEST, key, "has an invalid format");
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|