@anhvupt/tito 0.1.3 → 0.2.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 +77 -17
- package/dist/cli.js +136 -0
- package/dist/core/admin.js +241 -0
- package/dist/core/config.js +185 -2
- package/dist/core/feedback.js +68 -0
- package/dist/core/git-flow.js +174 -0
- package/dist/core/init.js +10 -1
- package/dist/core/plan.js +1 -0
- package/dist/core/profiles.js +6 -6
- package/dist/core/retro.js +444 -0
- package/dist/core/specialists.js +14 -8
- package/dist/core/upgrade.js +6 -1
- package/dist/core/work-plan.js +6 -5
- package/package.json +2 -2
- package/templates/cursor/skills/tito/SKILL.md +62 -14
- package/templates/cursor/skills/tito-admin-retro/SKILL.md +12 -0
package/README.md
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
**Quiet orchestration. Trusted continuity.**
|
|
4
4
|
|
|
5
5
|
Tito is a chat-first engineering coordinator for solo builders. You talk to one
|
|
6
|
-
coordinator
|
|
7
|
-
|
|
8
|
-
scope you approved.
|
|
6
|
+
coordinator. Tito confirms what you want, writes the plan as the spec, and
|
|
7
|
+
sends only the approved slice to a sub-agent.
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
The spec records the decisions and the conventions behind them, the test cases
|
|
10
|
+
you review before they are written, and the docs that must change. Tito is
|
|
11
|
+
local-first and Git-friendly. It coordinates your documentation, rules, and
|
|
12
|
+
specialists, and it updates the docs named in the plan before a pull request.
|
|
12
13
|
|
|
13
14
|
## What Tito helps with
|
|
14
15
|
|
|
@@ -17,9 +18,16 @@ replacing your project documentation, rules, or specialists.
|
|
|
17
18
|
- **The right amount of process:** Tito recommends Ask for discovery, Plan for
|
|
18
19
|
uncertain or critical work, and Agent only for an approved implementation
|
|
19
20
|
slice.
|
|
20
|
-
- **Plans that make decisions:** the
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
- **Plans that make decisions:** Tito confirms the goal and what is out of
|
|
22
|
+
scope, then asks one batch of clarifying questions, lighter on `solo-fast`.
|
|
23
|
+
One obvious reading continues after a one-line confirmation. Tito still asks
|
|
24
|
+
before locking a technical decision or a product-vision change. The plan
|
|
25
|
+
records that decision and includes signatures, schemas, pseudocode, or
|
|
26
|
+
guidance code when that makes implementation clearer. Decisions name the
|
|
27
|
+
convention they follow. Test cases carry a layer tag: `[unit]`,
|
|
28
|
+
`[integration]`, or `[e2e]`. The plan also lists its docs impact.
|
|
29
|
+
- **A light joke, sometimes:** after `Hola, Tito here!`, Tito may add one short
|
|
30
|
+
joke. It does not replace the answer, and it stays out of the CLI.
|
|
23
31
|
- **Smaller reviews:** work is split into bounded slices with acceptance
|
|
24
32
|
criteria, tests, forbidden changes, risks, and a stop condition.
|
|
25
33
|
- **Safer changes:** Tito preserves uncommitted work, refuses silent overwrites,
|
|
@@ -52,11 +60,15 @@ mode decides whether it may change anything:
|
|
|
52
60
|
| Tech docs writer | — | Update technical documentation |
|
|
53
61
|
| User docs writer | — | Update user documentation |
|
|
54
62
|
|
|
55
|
-
Several read-only specialists may work together.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
Several read-only specialists may work together. Coding sub-agents follow the
|
|
64
|
+
profile cap: `client-careful` 3, `solo-balanced` 6, and `solo-fast` 12. Each
|
|
65
|
+
has its own plan and branch. Tito does not code in the chat. Every change,
|
|
66
|
+
including a small one, goes to a sub-agent, and Tito returns to the user.
|
|
67
|
+
Backend and frontend work can run together inside that cap. Docs named in
|
|
68
|
+
the plan are updated, or you waive them, before Tito offers a pull request.
|
|
69
|
+
After a module is finished, Tito still schedules the tech docs writer and then
|
|
70
|
+
the user docs writer, one at a time, before calling that module done. Skip
|
|
71
|
+
that handoff only when you explicitly waive it for that module. Each specialist carries triggers,
|
|
60
72
|
knowledge references, handoffs, and a stop condition. The detailed prompt stays
|
|
61
73
|
lean and loads knowledge only when that mode needs it.
|
|
62
74
|
|
|
@@ -103,10 +115,11 @@ npx tito upgrade --confirm
|
|
|
103
115
|
agents, skills, and the Tito section of `AGENTS.md`. Consumer rules, consumer
|
|
104
116
|
agents, and other project skills stay untouched.
|
|
105
117
|
|
|
106
|
-
Tito is installed in each project. It is not a required global command
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
Tito is installed in each project. It is not a required global command.
|
|
119
|
+
An opt-in local admin index (`tito admin add|list|remove|refresh`) can register
|
|
120
|
+
repos under `~/.config/tito/admin/` and store recent commit subjects, dates, and
|
|
121
|
+
touched paths — never diffs. A project `tito.yaml` still overrides any personal
|
|
122
|
+
default. The safety floor cannot be weakened.
|
|
110
123
|
|
|
111
124
|
## What works today
|
|
112
125
|
|
|
@@ -142,6 +155,11 @@ node dist/cli.js inspect
|
|
|
142
155
|
node dist/cli.js inspect --root .
|
|
143
156
|
node dist/cli.js apply --dry-run --profile solo-balanced
|
|
144
157
|
node dist/cli.js init --profile solo-balanced
|
|
158
|
+
node dist/cli.js admin add
|
|
159
|
+
node dist/cli.js admin list
|
|
160
|
+
node dist/cli.js admin refresh
|
|
161
|
+
node dist/cli.js admin remove --root /path/to/repo
|
|
162
|
+
node dist/cli.js admin retro --week 2026-W40
|
|
145
163
|
```
|
|
146
164
|
|
|
147
165
|
`inspect` reads `tito.yaml` when it exists and checks whether `AGENTS.md` is
|
|
@@ -155,6 +173,15 @@ specialist agent files. It writes those files only with `--confirm`, and it
|
|
|
155
173
|
refuses when an existing file would be overwritten. In chat, `/tito-init` runs
|
|
156
174
|
this same command.
|
|
157
175
|
|
|
176
|
+
`admin` is opt-in. `add` registers the current repo (or `--root`), `list` prints
|
|
177
|
+
registered paths, `remove` drops one path, and `refresh` writes local branch plus
|
|
178
|
+
the last 50 commit subjects, dates, and file paths (secrets like `.env` skipped;
|
|
179
|
+
no diffs) for chat and CLI to share.
|
|
180
|
+
|
|
181
|
+
`retro` builds that week's report from local git and saves `retros/<week>.json`
|
|
182
|
+
and `retros/<week>.md` under the admin root. Review-fix counts, docs-blank, and
|
|
183
|
+
merge duration say unavailable until GitHub data is added.
|
|
184
|
+
|
|
158
185
|
## How work moves
|
|
159
186
|
|
|
160
187
|
The normal careful path is:
|
|
@@ -165,6 +192,39 @@ A small, obvious change can move from discovery directly to an explicitly
|
|
|
165
192
|
approved slice. Review feedback can return an in-scope slice to implementation.
|
|
166
193
|
Other lifecycle skips are rejected.
|
|
167
194
|
|
|
195
|
+
## Git flow
|
|
196
|
+
|
|
197
|
+
Tito suggests the branch before checkout, for example `Suggested branch: feat/short-slug from develop`. You accept it or name another base or type. The base is `dev`, `develop`, `main`, or `master`. `dev` and `develop` are interchangeable. `main` and `master` are interchangeable. The type is `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`.
|
|
198
|
+
|
|
199
|
+
`tito.yaml` may set the default base:
|
|
200
|
+
|
|
201
|
+
```yaml
|
|
202
|
+
schemaVersion: 1
|
|
203
|
+
profile: client-careful
|
|
204
|
+
git:
|
|
205
|
+
defaultBase: develop
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`product.screenLanguage` is optional and accepts only `vi` or `en`. A missing product block is valid. A missing `screenLanguage` is valid. Unknown product fields are rejected. `product.tenancy` is optional (`single` or `multi`). `product.surfaces` is an optional list of `{ id }` entries. Missing `tenancy` and missing `surfaces` stay valid.
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
product:
|
|
212
|
+
screenLanguage: vi
|
|
213
|
+
tenancy: single
|
|
214
|
+
surfaces:
|
|
215
|
+
- id: admin
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- Code stays English.
|
|
219
|
+
- In a Vietnamese app (`screenLanguage: vi`), routes and slugs are Vietnamese first. The public path is native Vietnamese, for example `/tien-ich/ca-phe`. Do not invent that Vietnamese by translating an English slug word for word. If the product is bilingual, the English route comes second.
|
|
220
|
+
- An English app (`screenLanguage: en`) keeps English routes and slugs.
|
|
221
|
+
- In a Vietnamese app (`screenLanguage: vi`), every string a person reads is Vietnamese: tables, labels, buttons, headings, and messages. Write native Vietnamese first. Do not invent it by translating English word for word. English may exist as a second field only when the product is bilingual, and it comes after the Vietnamese.
|
|
222
|
+
- An English app (`screenLanguage: en`) stays English on screen.
|
|
223
|
+
- When `screenLanguage` is missing and the screen language is unclear, Tito asks once. One obvious reading continues without a question.
|
|
224
|
+
- Globalized apps store timestamps in UTC and show them in the user's timezone. There is no switch to turn that off.
|
|
225
|
+
|
|
226
|
+
A commit subject is `<type>: <sentence>`. The type is the same token as the branch type: `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`. Examples: `feat: add commit message rule`, `fix: reject a duplicate surface id`, `chores: record the commit prefix rule`, and `hot-fix: stop a bad release build`. The words after the colon are one finished sentence of at most 70 words. The type is not counted in those 70 words. The body is a separate description. When you review a plan, Tito saves that plan and your edit as separate files under `.tito/feedback/<slug>/`. `review.md` starts with front matter `outcome: accepted | edited | expanded`. Tito indexes only the plan name. The plan body stays in Cursor's plan file. After an approved slice is coded, every review fix goes into one plan named `review/<slug>` on the same branch. Tito asks before opening a pull request only after that plan is coded, or when you accept the code with no changes. The pull request description has four parts within 2 to 50 lines: a one-line problem, what changed, review fixes, and checks for lint, code quality, conventions, tests, build, and docs. Init creates `.github/pull_request_template.md` from Tito's template when it is missing. Upgrade replaces that file with Tito's template. Tito does not approve a pull request. Tito merges only when you call for the merge and the pull request already has an approval. After a pull request is merged, Tito asks before the next slice. Tito switches back to the base branch only when you say so clearly. Tito pushes directly to the base branch only when you clearly instruct that push.
|
|
227
|
+
|
|
168
228
|
## Development
|
|
169
229
|
|
|
170
230
|
Requirements:
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ import { InspectionError, formatInspection, inspectRepository, } from "./core/in
|
|
|
9
9
|
import { PlanError, formatAdoptionPlan, planAdoption } from "./core/plan.js";
|
|
10
10
|
import { InitError, applyInitialization, formatInitialization, planInitialization, promptForProfile, } from "./core/init.js";
|
|
11
11
|
import { applyUpgrade, formatUpgrade, planUpgrade, titoOwnedFiles, } from "./core/upgrade.js";
|
|
12
|
+
import { AdminError, addRepo, formatContexts, formatRepos, listRepos, refreshRepos, removeRepo, resolveAdminRoot, } from "./core/admin.js";
|
|
13
|
+
import { RetroError, buildRetro, formatRetro, retroJson, saveRetro, } from "./core/retro.js";
|
|
12
14
|
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
13
15
|
const help = `Tito — quiet orchestration for solo builders
|
|
14
16
|
|
|
@@ -18,6 +20,11 @@ Usage:
|
|
|
18
20
|
tito apply --dry-run --profile <id> [--root <path>]
|
|
19
21
|
tito init [--profile <id>] [--root <path>] [--confirm]
|
|
20
22
|
tito upgrade [--root <path>] [--confirm]
|
|
23
|
+
tito admin add [--root <path>] [--admin-root <path>]
|
|
24
|
+
tito admin list [--admin-root <path>]
|
|
25
|
+
tito admin remove --root <path> [--admin-root <path>]
|
|
26
|
+
tito admin refresh [--admin-root <path>]
|
|
27
|
+
tito admin retro [--week <YYYY-Www>] [--timezone <IANA>] [--json] [--alerts] [--admin-root <path>]
|
|
21
28
|
|
|
22
29
|
Options:
|
|
23
30
|
-h, --help Show help
|
|
@@ -28,6 +35,7 @@ Commands:
|
|
|
28
35
|
apply Dry-run an adoption plan. Writes are not available.
|
|
29
36
|
init Install Tito files and specialist agents. Confirm before writing.
|
|
30
37
|
upgrade Install the latest Tito and replace Tito-owned files only.
|
|
38
|
+
admin Opt-in local repo index: add, list, remove, refresh, retro.
|
|
31
39
|
`;
|
|
32
40
|
function fail(message) {
|
|
33
41
|
process.stderr.write(`${message}\n`);
|
|
@@ -238,6 +246,130 @@ function isTitoSource(root) {
|
|
|
238
246
|
return false;
|
|
239
247
|
}
|
|
240
248
|
}
|
|
249
|
+
function runAdmin(args) {
|
|
250
|
+
if (args.includes("-h") || args.includes("--help") || args.length === 0) {
|
|
251
|
+
process.stdout.write(help);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const action = args[0];
|
|
255
|
+
const rest = args.slice(1);
|
|
256
|
+
if (action === "retro") {
|
|
257
|
+
runRetro(rest);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (action !== "add" &&
|
|
261
|
+
action !== "list" &&
|
|
262
|
+
action !== "remove" &&
|
|
263
|
+
action !== "refresh") {
|
|
264
|
+
fail(`Unknown admin action: ${action}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let root = process.cwd();
|
|
268
|
+
let adminRoot = resolveAdminRoot();
|
|
269
|
+
let rootProvided = false;
|
|
270
|
+
try {
|
|
271
|
+
const { values } = parseArgs({
|
|
272
|
+
args: rest,
|
|
273
|
+
options: {
|
|
274
|
+
root: { type: "string" },
|
|
275
|
+
"admin-root": { type: "string" },
|
|
276
|
+
},
|
|
277
|
+
strict: true,
|
|
278
|
+
allowPositionals: false,
|
|
279
|
+
});
|
|
280
|
+
if (values.root !== undefined) {
|
|
281
|
+
root = values.root;
|
|
282
|
+
rootProvided = true;
|
|
283
|
+
}
|
|
284
|
+
if (values["admin-root"] !== undefined) {
|
|
285
|
+
adminRoot = resolveAdminRoot(values["admin-root"]);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
fail(error instanceof Error ? error.message : "Invalid admin arguments.");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
if (action === "add") {
|
|
294
|
+
const result = addRepo(adminRoot, root);
|
|
295
|
+
process.stdout.write(result.created
|
|
296
|
+
? `added: ${result.repo.path}\n`
|
|
297
|
+
: `kept: ${result.repo.path}\n`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (action === "list") {
|
|
301
|
+
process.stdout.write(formatRepos(listRepos(adminRoot)));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (action === "remove") {
|
|
305
|
+
if (!rootProvided) {
|
|
306
|
+
fail("Missing required option --root.");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const removed = removeRepo(adminRoot, root);
|
|
310
|
+
process.stdout.write(`removed: ${removed.path}\n`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
process.stdout.write(formatContexts(refreshRepos(adminRoot)));
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (error instanceof AdminError) {
|
|
317
|
+
fail(`${error.code}: ${error.message}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function runRetro(args) {
|
|
324
|
+
let week;
|
|
325
|
+
let timezone;
|
|
326
|
+
let json = false;
|
|
327
|
+
let alerts = false;
|
|
328
|
+
let adminRoot = resolveAdminRoot();
|
|
329
|
+
try {
|
|
330
|
+
const { values } = parseArgs({
|
|
331
|
+
args,
|
|
332
|
+
options: {
|
|
333
|
+
week: { type: "string" },
|
|
334
|
+
timezone: { type: "string" },
|
|
335
|
+
json: { type: "boolean" },
|
|
336
|
+
alerts: { type: "boolean" },
|
|
337
|
+
"admin-root": { type: "string" },
|
|
338
|
+
},
|
|
339
|
+
strict: true,
|
|
340
|
+
allowPositionals: false,
|
|
341
|
+
});
|
|
342
|
+
if (values.week !== undefined)
|
|
343
|
+
week = values.week;
|
|
344
|
+
if (values.timezone !== undefined)
|
|
345
|
+
timezone = values.timezone;
|
|
346
|
+
json = values.json === true;
|
|
347
|
+
alerts = values.alerts === true;
|
|
348
|
+
if (values["admin-root"] !== undefined) {
|
|
349
|
+
adminRoot = resolveAdminRoot(values["admin-root"]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
fail(error instanceof Error ? error.message : "Invalid admin arguments.");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const report = buildRetro({
|
|
358
|
+
adminRoot,
|
|
359
|
+
...(week !== undefined ? { week } : {}),
|
|
360
|
+
...(timezone !== undefined ? { timezone } : {}),
|
|
361
|
+
});
|
|
362
|
+
saveRetro(adminRoot, report);
|
|
363
|
+
process.stdout.write(json ? retroJson(report) : formatRetro(report, { alerts }));
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
if (error instanceof RetroError || error instanceof AdminError) {
|
|
367
|
+
fail(`${error.code}: ${error.message}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
241
373
|
async function run(args) {
|
|
242
374
|
if (args[0] === "inspect") {
|
|
243
375
|
runInspect(args.slice(1));
|
|
@@ -253,6 +385,10 @@ async function run(args) {
|
|
|
253
385
|
if (args[0] === "upgrade") {
|
|
254
386
|
return runUpgrade(args.slice(1));
|
|
255
387
|
}
|
|
388
|
+
if (args[0] === "admin") {
|
|
389
|
+
runAdmin(args.slice(1));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
256
392
|
let helpRequested = false;
|
|
257
393
|
let versionRequested = false;
|
|
258
394
|
try {
|
|
@@ -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
|
+
}
|