@anhvupt/tito 0.1.3 → 0.3.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 +109 -24
- package/dist/cli.js +195 -8
- package/dist/core/admin.js +241 -0
- package/dist/core/agents.js +4 -1
- package/dist/core/config.js +225 -3
- package/dist/core/feedback.js +68 -0
- package/dist/core/git-flow.js +174 -0
- package/dist/core/init.js +124 -3
- package/dist/core/models.js +116 -0
- package/dist/core/plan.js +4 -0
- package/dist/core/profiles.js +6 -6
- package/dist/core/retro.js +442 -0
- package/dist/core/specialists.js +27 -15
- package/dist/core/terminal.js +9 -0
- package/dist/core/upgrade.js +65 -2
- package/dist/core/work-plan.js +6 -5
- package/package.json +2 -2
- package/templates/cursor/skills/tito/SKILL.md +73 -14
- package/templates/cursor/skills/tito-admin-retro/SKILL.md +12 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
export class AdminError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
path;
|
|
8
|
+
constructor(code, path, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "AdminError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.path = path;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const SECRET_BASENAMES = new Set([".env", "credentials.json", "id_rsa"]);
|
|
16
|
+
export function isSecretFilename(filePath) {
|
|
17
|
+
const name = basename(filePath);
|
|
18
|
+
if (SECRET_BASENAMES.has(name))
|
|
19
|
+
return true;
|
|
20
|
+
return name.startsWith(".env.");
|
|
21
|
+
}
|
|
22
|
+
export function resolveAdminRoot(explicit) {
|
|
23
|
+
if (explicit !== undefined)
|
|
24
|
+
return resolve(explicit);
|
|
25
|
+
return join(homedir(), ".config", "tito", "admin");
|
|
26
|
+
}
|
|
27
|
+
export function reposFilePath(adminRoot) {
|
|
28
|
+
return join(resolve(adminRoot), "repos.json");
|
|
29
|
+
}
|
|
30
|
+
export function contextFilePath(adminRoot) {
|
|
31
|
+
return join(resolve(adminRoot), "context.json");
|
|
32
|
+
}
|
|
33
|
+
function errno(error) {
|
|
34
|
+
if (typeof error === "object" &&
|
|
35
|
+
error !== null &&
|
|
36
|
+
"code" in error &&
|
|
37
|
+
typeof error.code === "string") {
|
|
38
|
+
return error.code;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function assertDirectory(path) {
|
|
43
|
+
const resolved = resolve(path);
|
|
44
|
+
try {
|
|
45
|
+
if (!statSync(resolved).isDirectory()) {
|
|
46
|
+
throw new AdminError("not-a-directory", resolved, `${resolved} is not a directory.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof AdminError)
|
|
51
|
+
throw error;
|
|
52
|
+
const code = errno(error);
|
|
53
|
+
if (code === "ENOENT") {
|
|
54
|
+
throw new AdminError("not-a-directory", resolved, `${resolved} is not a directory.`);
|
|
55
|
+
}
|
|
56
|
+
throw new AdminError("filesystem", resolved, `Cannot inspect ${resolved}.`);
|
|
57
|
+
}
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
function writeJsonAtomic(path, value) {
|
|
61
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
62
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
65
|
+
renameSync(tmp, path);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot write ${path}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function readJsonFile(path) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const code = errno(error);
|
|
77
|
+
if (code === "ENOENT")
|
|
78
|
+
return null;
|
|
79
|
+
throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot read ${path}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function isAdminRepo(value) {
|
|
83
|
+
if (typeof value !== "object" || value === null)
|
|
84
|
+
return false;
|
|
85
|
+
const record = value;
|
|
86
|
+
return typeof record.path === "string" && typeof record.addedAt === "string";
|
|
87
|
+
}
|
|
88
|
+
export function loadRepos(adminRoot) {
|
|
89
|
+
const path = reposFilePath(adminRoot);
|
|
90
|
+
if (!existsSync(path))
|
|
91
|
+
return [];
|
|
92
|
+
const raw = readJsonFile(path);
|
|
93
|
+
if (!Array.isArray(raw)) {
|
|
94
|
+
throw new AdminError("filesystem", path, `${path} must contain a JSON array.`);
|
|
95
|
+
}
|
|
96
|
+
const repos = [];
|
|
97
|
+
for (const item of raw) {
|
|
98
|
+
if (!isAdminRepo(item)) {
|
|
99
|
+
throw new AdminError("filesystem", path, `${path} has an invalid repo entry.`);
|
|
100
|
+
}
|
|
101
|
+
repos.push({ path: item.path, addedAt: item.addedAt });
|
|
102
|
+
}
|
|
103
|
+
return repos;
|
|
104
|
+
}
|
|
105
|
+
export function saveRepos(adminRoot, repos) {
|
|
106
|
+
writeJsonAtomic(reposFilePath(adminRoot), repos);
|
|
107
|
+
}
|
|
108
|
+
export function loadContexts(adminRoot) {
|
|
109
|
+
const path = contextFilePath(adminRoot);
|
|
110
|
+
if (!existsSync(path))
|
|
111
|
+
return [];
|
|
112
|
+
const raw = readJsonFile(path);
|
|
113
|
+
if (!Array.isArray(raw)) {
|
|
114
|
+
throw new AdminError("filesystem", path, `${path} must contain a JSON array.`);
|
|
115
|
+
}
|
|
116
|
+
return raw;
|
|
117
|
+
}
|
|
118
|
+
export function saveContexts(adminRoot, contexts) {
|
|
119
|
+
writeJsonAtomic(contextFilePath(adminRoot), contexts);
|
|
120
|
+
}
|
|
121
|
+
const defaultClock = () => new Date().toISOString();
|
|
122
|
+
export function defaultGitRunner(cwd, args) {
|
|
123
|
+
const result = spawnSync("git", [...args], {
|
|
124
|
+
cwd,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
});
|
|
127
|
+
if (result.error) {
|
|
128
|
+
throw new AdminError("git", cwd, result.error.message);
|
|
129
|
+
}
|
|
130
|
+
if (result.status !== 0) {
|
|
131
|
+
const detail = (result.stderr || result.stdout || "git command failed").trim();
|
|
132
|
+
throw new AdminError("git", cwd, detail);
|
|
133
|
+
}
|
|
134
|
+
return result.stdout ?? "";
|
|
135
|
+
}
|
|
136
|
+
export function addRepo(adminRoot, repoPath, options = {}) {
|
|
137
|
+
const path = assertDirectory(repoPath);
|
|
138
|
+
const repos = loadRepos(adminRoot);
|
|
139
|
+
const existing = repos.find((repo) => repo.path === path);
|
|
140
|
+
if (existing !== undefined) {
|
|
141
|
+
return { repo: existing, created: false };
|
|
142
|
+
}
|
|
143
|
+
const clock = options.clock ?? defaultClock;
|
|
144
|
+
const repo = { path, addedAt: clock() };
|
|
145
|
+
repos.push(repo);
|
|
146
|
+
saveRepos(adminRoot, repos);
|
|
147
|
+
return { repo, created: true };
|
|
148
|
+
}
|
|
149
|
+
export function listRepos(adminRoot) {
|
|
150
|
+
return loadRepos(adminRoot);
|
|
151
|
+
}
|
|
152
|
+
export function removeRepo(adminRoot, repoPath) {
|
|
153
|
+
const path = resolve(repoPath);
|
|
154
|
+
const repos = loadRepos(adminRoot);
|
|
155
|
+
const index = repos.findIndex((repo) => repo.path === path);
|
|
156
|
+
if (index < 0) {
|
|
157
|
+
throw new AdminError("not-registered", path, `${path} is not registered.`);
|
|
158
|
+
}
|
|
159
|
+
const [removed] = repos.splice(index, 1);
|
|
160
|
+
if (removed === undefined) {
|
|
161
|
+
throw new AdminError("not-registered", path, `${path} is not registered.`);
|
|
162
|
+
}
|
|
163
|
+
saveRepos(adminRoot, repos);
|
|
164
|
+
return removed;
|
|
165
|
+
}
|
|
166
|
+
function parseCommitLog(stdout) {
|
|
167
|
+
const commits = [];
|
|
168
|
+
const blocks = stdout.split(/\n(?=◆)/);
|
|
169
|
+
for (const block of blocks) {
|
|
170
|
+
const trimmed = block.trim();
|
|
171
|
+
if (trimmed.length === 0)
|
|
172
|
+
continue;
|
|
173
|
+
const lines = trimmed.split("\n");
|
|
174
|
+
const header = lines[0];
|
|
175
|
+
if (header === undefined || !header.startsWith("◆"))
|
|
176
|
+
continue;
|
|
177
|
+
const parts = header.slice(1).split("\0");
|
|
178
|
+
const subject = parts[0] ?? "";
|
|
179
|
+
const date = parts[1] ?? "";
|
|
180
|
+
const files = [];
|
|
181
|
+
for (const line of lines.slice(1)) {
|
|
182
|
+
const file = line.trim();
|
|
183
|
+
if (file.length === 0)
|
|
184
|
+
continue;
|
|
185
|
+
if (isSecretFilename(file))
|
|
186
|
+
continue;
|
|
187
|
+
files.push(file);
|
|
188
|
+
}
|
|
189
|
+
commits.push({ subject, date, files });
|
|
190
|
+
}
|
|
191
|
+
return commits;
|
|
192
|
+
}
|
|
193
|
+
export function readRepoContext(repoPath, options = {}) {
|
|
194
|
+
const path = assertDirectory(repoPath);
|
|
195
|
+
const git = options.git ?? defaultGitRunner;
|
|
196
|
+
const branch = git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
197
|
+
const log = git(path, [
|
|
198
|
+
"log",
|
|
199
|
+
"-n",
|
|
200
|
+
"50",
|
|
201
|
+
"--pretty=format:◆%s%x00%cI",
|
|
202
|
+
"--name-only",
|
|
203
|
+
]);
|
|
204
|
+
return {
|
|
205
|
+
path,
|
|
206
|
+
branch,
|
|
207
|
+
recentCommits: parseCommitLog(log),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export function refreshRepos(adminRoot, options = {}) {
|
|
211
|
+
const repos = loadRepos(adminRoot);
|
|
212
|
+
const contexts = [];
|
|
213
|
+
for (const repo of repos) {
|
|
214
|
+
contexts.push(readRepoContext(repo.path, options));
|
|
215
|
+
}
|
|
216
|
+
saveContexts(adminRoot, contexts);
|
|
217
|
+
return contexts;
|
|
218
|
+
}
|
|
219
|
+
export function formatRepos(repos) {
|
|
220
|
+
if (repos.length === 0)
|
|
221
|
+
return "No registered repositories.\n";
|
|
222
|
+
return `${repos.map((repo) => `${repo.path}\t${repo.addedAt}`).join("\n")}\n`;
|
|
223
|
+
}
|
|
224
|
+
export function formatContexts(contexts) {
|
|
225
|
+
if (contexts.length === 0)
|
|
226
|
+
return "No repository context.\n";
|
|
227
|
+
const lines = [];
|
|
228
|
+
for (const context of contexts) {
|
|
229
|
+
lines.push(`path: ${context.path}`);
|
|
230
|
+
lines.push(`branch: ${context.branch}`);
|
|
231
|
+
lines.push(`commits: ${context.recentCommits.length}`);
|
|
232
|
+
for (const commit of context.recentCommits) {
|
|
233
|
+
lines.push(`- ${commit.date}\t${commit.subject}`);
|
|
234
|
+
for (const file of commit.files) {
|
|
235
|
+
lines.push(` ${file}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
lines.push("");
|
|
239
|
+
}
|
|
240
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
241
|
+
}
|
package/dist/core/agents.js
CHANGED
|
@@ -24,6 +24,9 @@ export function compileCursorAgent(id) {
|
|
|
24
24
|
const knowledge = manifest.knowledge
|
|
25
25
|
.map((item) => `- \`${item.id}\` (${item.load})`)
|
|
26
26
|
.join("\n");
|
|
27
|
+
const stackGuidance = manifest.knowledge.some((item) => item.load === "stack")
|
|
28
|
+
? "For a stack entry, use the stack owner's official resource for the project's version. Judge it before use. Do not copy it into the repo.\n\n"
|
|
29
|
+
: "";
|
|
27
30
|
return `---
|
|
28
31
|
name: ${AGENT_FILE_NAMES[id].replace(/\.md$/, "")}
|
|
29
32
|
description: >
|
|
@@ -47,7 +50,7 @@ Use only the mode named in Tito's task packet. A read-only mode must not edit fi
|
|
|
47
50
|
|
|
48
51
|
${knowledge}
|
|
49
52
|
|
|
50
|
-
Do not restate project documentation. Load it only when the task needs it.
|
|
53
|
+
${stackGuidance}Do not restate project documentation. Load it only when the task needs it.
|
|
51
54
|
|
|
52
55
|
## Handoffs
|
|
53
56
|
|
package/dist/core/config.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { isMap, isScalar, parseAllDocuments } from "yaml";
|
|
1
|
+
import { isMap, isScalar, isSeq, parseAllDocuments } from "yaml";
|
|
2
|
+
import { BRANCH_BASES } from "./git-flow.js";
|
|
2
3
|
import { ACTIVE_RISK_PROFILE_IDS, isActiveRiskProfileId, isFutureRiskProfileId, } from "./profiles.js";
|
|
3
4
|
export const CONFIG_SCHEMA_VERSION = 1;
|
|
4
|
-
const FIELDS = new Set(["schemaVersion", "profile"]);
|
|
5
|
+
const FIELDS = new Set(["schemaVersion", "profile", "git", "product"]);
|
|
6
|
+
export const SCREEN_LANGUAGES = ["vi", "en"];
|
|
7
|
+
export const TENANCY_VALUES = ["single", "multi"];
|
|
8
|
+
const PRODUCT_FIELDS = new Set(["screenLanguage", "tenancy", "surfaces"]);
|
|
9
|
+
const SURFACE_ID = /^[a-z][a-z0-9-]*$/;
|
|
5
10
|
const NON_SCALAR = Symbol("non-scalar");
|
|
6
11
|
export class ConfigValidationError extends Error {
|
|
7
12
|
issues;
|
|
@@ -25,6 +30,202 @@ function shown(value) {
|
|
|
25
30
|
}
|
|
26
31
|
return "a non-scalar value";
|
|
27
32
|
}
|
|
33
|
+
function isBranchBase(value) {
|
|
34
|
+
return typeof value === "string" && BRANCH_BASES.includes(value);
|
|
35
|
+
}
|
|
36
|
+
export function isSurfaceId(value) {
|
|
37
|
+
return SURFACE_ID.test(value);
|
|
38
|
+
}
|
|
39
|
+
function assembleGit(parts) {
|
|
40
|
+
const git = {};
|
|
41
|
+
if (parts.defaultBase !== undefined)
|
|
42
|
+
git.defaultBase = parts.defaultBase;
|
|
43
|
+
if (parts.autoPullRequest !== undefined)
|
|
44
|
+
git.autoPullRequest = parts.autoPullRequest;
|
|
45
|
+
return Object.keys(git).length > 0 ? git : undefined;
|
|
46
|
+
}
|
|
47
|
+
function readGit(node) {
|
|
48
|
+
if (!isMap(node)) {
|
|
49
|
+
return { issues: [issue("unknown-field", 'Field "git" must be a mapping.', "git")] };
|
|
50
|
+
}
|
|
51
|
+
const issues = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
let defaultBase;
|
|
54
|
+
let autoPullRequest = undefined;
|
|
55
|
+
for (const item of node.items) {
|
|
56
|
+
const key = isScalar(item.key) && typeof item.key.value === "string" ? item.key.value : null;
|
|
57
|
+
if (key === null) {
|
|
58
|
+
issues.push(issue("unknown-field", "Mapping keys must be strings.", "git"));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(key)) {
|
|
62
|
+
issues.push(issue("duplicate-key", `Duplicate key "${key}".`, `git.${key}`));
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
seen.add(key);
|
|
66
|
+
if (key === "defaultBase") {
|
|
67
|
+
const value = isScalar(item.value) ? item.value.value : null;
|
|
68
|
+
if (!isBranchBase(value)) {
|
|
69
|
+
issues.push(issue("unknown-field", 'Unknown field "git.defaultBase".', "git.defaultBase"));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
defaultBase = value;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (key === "autoPullRequest") {
|
|
77
|
+
const value = isScalar(item.value) ? item.value.value : NON_SCALAR;
|
|
78
|
+
if (typeof value !== "boolean") {
|
|
79
|
+
issues.push(issue("unknown-field", 'Field "git.autoPullRequest" must be a boolean.', "git.autoPullRequest"));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
autoPullRequest = value;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
issues.push(issue("unknown-field", `Unknown field "git.${key}".`, `git.${key}`));
|
|
87
|
+
}
|
|
88
|
+
if (issues.length > 0)
|
|
89
|
+
return { issues };
|
|
90
|
+
const git = assembleGit({ defaultBase, autoPullRequest });
|
|
91
|
+
return git ? { git, issues } : { issues };
|
|
92
|
+
}
|
|
93
|
+
function isScreenLanguage(value) {
|
|
94
|
+
return typeof value === "string" && SCREEN_LANGUAGES.includes(value);
|
|
95
|
+
}
|
|
96
|
+
function isTenancy(value) {
|
|
97
|
+
return typeof value === "string" && TENANCY_VALUES.includes(value);
|
|
98
|
+
}
|
|
99
|
+
function mappingKey(key) {
|
|
100
|
+
return isScalar(key) && typeof key.value === "string" ? key.value : null;
|
|
101
|
+
}
|
|
102
|
+
function assembleProduct(parts) {
|
|
103
|
+
const product = {};
|
|
104
|
+
if (parts.screenLanguage !== undefined)
|
|
105
|
+
product.screenLanguage = parts.screenLanguage;
|
|
106
|
+
if (parts.tenancy !== undefined)
|
|
107
|
+
product.tenancy = parts.tenancy;
|
|
108
|
+
if (parts.surfaces !== undefined)
|
|
109
|
+
product.surfaces = parts.surfaces;
|
|
110
|
+
return Object.keys(product).length > 0 ? product : undefined;
|
|
111
|
+
}
|
|
112
|
+
function readSurfaces(node) {
|
|
113
|
+
if (!isSeq(node)) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
issues: [
|
|
117
|
+
issue("unknown-field", 'Field "product.surfaces" must be a list of ids.', "product.surfaces"),
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (node.items.length === 0) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
issues: [
|
|
125
|
+
issue("unknown-field", 'Field "product.surfaces" must not be empty. Omit the key instead.', "product.surfaces"),
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const issues = [];
|
|
130
|
+
const surfaces = [];
|
|
131
|
+
const ids = new Set();
|
|
132
|
+
for (const entry of node.items) {
|
|
133
|
+
if (!isMap(entry)) {
|
|
134
|
+
issues.push(issue("unknown-field", "Each surface must be a mapping with an id.", "product.surfaces"));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
let id;
|
|
138
|
+
let sawId = false;
|
|
139
|
+
for (const pair of entry.items) {
|
|
140
|
+
const key = mappingKey(pair.key);
|
|
141
|
+
if (key !== "id") {
|
|
142
|
+
issues.push(issue("unknown-field", `Unknown field "product.surfaces.${key ?? ""}".`, "product.surfaces"));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (sawId) {
|
|
146
|
+
issues.push(issue("duplicate-key", 'Duplicate key "id".', "product.surfaces"));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
sawId = true;
|
|
150
|
+
id = isScalar(pair.value) ? pair.value.value : NON_SCALAR;
|
|
151
|
+
}
|
|
152
|
+
if (!sawId) {
|
|
153
|
+
issues.push(issue("missing-field", "Missing surface id.", "product.surfaces"));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (typeof id !== "string") {
|
|
157
|
+
issues.push(issue("unknown-field", "Surface id must be a string.", "product.surfaces"));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!SURFACE_ID.test(id)) {
|
|
161
|
+
issues.push(issue("unknown-field", `Surface id ${JSON.stringify(id)} must match ^[a-z][a-z0-9-]*$.`, "product.surfaces"));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ids.has(id)) {
|
|
165
|
+
issues.push(issue("duplicate-key", `Duplicate surface id "${id}".`, "product.surfaces"));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
ids.add(id);
|
|
169
|
+
surfaces.push({ id });
|
|
170
|
+
}
|
|
171
|
+
return issues.length > 0 ? { ok: false, issues } : { ok: true, surfaces };
|
|
172
|
+
}
|
|
173
|
+
function readProduct(node) {
|
|
174
|
+
if (!isMap(node)) {
|
|
175
|
+
return { issues: [issue("unknown-field", 'Unknown field "product".', "product")] };
|
|
176
|
+
}
|
|
177
|
+
const issues = [];
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
let screenLanguage;
|
|
180
|
+
let tenancy;
|
|
181
|
+
let surfaces;
|
|
182
|
+
for (const item of node.items) {
|
|
183
|
+
const key = mappingKey(item.key);
|
|
184
|
+
if (key === null) {
|
|
185
|
+
issues.push(issue("unknown-field", "Mapping keys must be strings.", "product"));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (seen.has(key)) {
|
|
189
|
+
issues.push(issue("duplicate-key", `Duplicate key "${key}".`, `product.${key}`));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
seen.add(key);
|
|
193
|
+
if (!PRODUCT_FIELDS.has(key)) {
|
|
194
|
+
issues.push(issue("unknown-field", `Unknown field "product.${key}".`, `product.${key}`));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (key === "screenLanguage") {
|
|
198
|
+
const value = isScalar(item.value) ? item.value.value : null;
|
|
199
|
+
if (!isScreenLanguage(value)) {
|
|
200
|
+
issues.push(issue("unknown-field", 'Unknown field "product.screenLanguage".', "product.screenLanguage"));
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
screenLanguage = value;
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (key === "tenancy") {
|
|
208
|
+
const value = isScalar(item.value) ? item.value.value : null;
|
|
209
|
+
if (!isTenancy(value)) {
|
|
210
|
+
issues.push(issue("unknown-field", `Unknown tenancy ${shown(value)}.`, "product.tenancy"));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
tenancy = value;
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const parsed = readSurfaces(item.value);
|
|
218
|
+
if (!parsed.ok) {
|
|
219
|
+
issues.push(...parsed.issues);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
surfaces = parsed.surfaces;
|
|
223
|
+
}
|
|
224
|
+
if (issues.length > 0)
|
|
225
|
+
return { issues };
|
|
226
|
+
const product = assembleProduct({ screenLanguage, tenancy, surfaces });
|
|
227
|
+
return product ? { product, issues } : { issues };
|
|
228
|
+
}
|
|
28
229
|
function profileIssue(value) {
|
|
29
230
|
if (typeof value !== "string") {
|
|
30
231
|
return issue("unknown-profile", "Profile must be a string.", "profile");
|
|
@@ -65,6 +266,8 @@ export function parseConfig(text) {
|
|
|
65
266
|
const issues = [];
|
|
66
267
|
const occurrences = new Map();
|
|
67
268
|
const values = new Map();
|
|
269
|
+
let git;
|
|
270
|
+
let product;
|
|
68
271
|
for (const item of document.contents.items) {
|
|
69
272
|
const key = isScalar(item.key) && typeof item.key.value === "string" ? item.key.value : null;
|
|
70
273
|
if (key === null) {
|
|
@@ -81,6 +284,20 @@ export function parseConfig(text) {
|
|
|
81
284
|
issues.push(issue("unknown-field", `Unknown field "${key}".`, key));
|
|
82
285
|
continue;
|
|
83
286
|
}
|
|
287
|
+
if (key === "git") {
|
|
288
|
+
const parsed = readGit(item.value);
|
|
289
|
+
issues.push(...parsed.issues);
|
|
290
|
+
if (parsed.git)
|
|
291
|
+
git = parsed.git;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (key === "product") {
|
|
295
|
+
const parsed = readProduct(item.value);
|
|
296
|
+
issues.push(...parsed.issues);
|
|
297
|
+
if (parsed.product)
|
|
298
|
+
product = parsed.product;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
84
301
|
values.set(key, isScalar(item.value) ? item.value.value : NON_SCALAR);
|
|
85
302
|
}
|
|
86
303
|
if ((occurrences.get("schemaVersion") ?? 0) === 0) {
|
|
@@ -104,5 +321,10 @@ export function parseConfig(text) {
|
|
|
104
321
|
issue("unknown-profile", "Profile must be a string.", "profile"),
|
|
105
322
|
]);
|
|
106
323
|
}
|
|
107
|
-
|
|
324
|
+
const result = { schemaVersion: CONFIG_SCHEMA_VERSION, profile };
|
|
325
|
+
if (git)
|
|
326
|
+
result.git = git;
|
|
327
|
+
if (product)
|
|
328
|
+
result.product = product;
|
|
329
|
+
return result;
|
|
108
330
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
4
|
+
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
5
|
+
const OUTCOME_LINE = /^outcome:[ \t]*(\S+)[ \t]*$/m;
|
|
6
|
+
export const FEEDBACK_OUTCOMES = ["accepted", "edited", "expanded"];
|
|
7
|
+
export class FeedbackError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "FeedbackError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function errno(error) {
|
|
16
|
+
if (typeof error === "object" &&
|
|
17
|
+
error !== null &&
|
|
18
|
+
"code" in error &&
|
|
19
|
+
typeof error.code === "string") {
|
|
20
|
+
return error.code;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function isFeedbackOutcome(value) {
|
|
25
|
+
return typeof value === "string" && FEEDBACK_OUTCOMES.includes(value);
|
|
26
|
+
}
|
|
27
|
+
export function readFeedbackOutcome(reviewText) {
|
|
28
|
+
const frontMatter = FRONT_MATTER.exec(reviewText)?.[1];
|
|
29
|
+
if (frontMatter === undefined) {
|
|
30
|
+
return "unknown";
|
|
31
|
+
}
|
|
32
|
+
const value = OUTCOME_LINE.exec(frontMatter.replace(/\r/g, ""))?.[1];
|
|
33
|
+
return isFeedbackOutcome(value) ? value : "unknown";
|
|
34
|
+
}
|
|
35
|
+
export function saveFeedback(root, slug, record) {
|
|
36
|
+
if (!SLUG.test(slug)) {
|
|
37
|
+
throw new FeedbackError("invalid-slug", "Feedback slug must be a lowercase hyphenated name.");
|
|
38
|
+
}
|
|
39
|
+
if (!isFeedbackOutcome(record.outcome)) {
|
|
40
|
+
throw new FeedbackError("invalid-outcome", `Feedback outcome must be one of: ${FEEDBACK_OUTCOMES.join(", ")}.`);
|
|
41
|
+
}
|
|
42
|
+
const dir = join(root, ".tito", "feedback", slug);
|
|
43
|
+
const planPath = join(dir, "plan.md");
|
|
44
|
+
const reviewPath = join(dir, "review.md");
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(planPath, record.plan, { encoding: "utf8", flag: "wx" });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (errno(error) === "EEXIST") {
|
|
51
|
+
throw new FeedbackError("already-saved", "The Tito plan is already saved.");
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(reviewPath, `---\noutcome: ${record.outcome}\n---\n${record.review}`, {
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
flag: "wx",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (errno(error) === "EEXIST") {
|
|
63
|
+
throw new FeedbackError("already-saved", "The review is already saved.");
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return { planPath, reviewPath };
|
|
68
|
+
}
|