@stevecastaneda/paseo-progress 1.14.0 → 1.14.1
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 +7 -1
- package/dist/server/cli.js +54 -75
- package/package.json +1 -1
- package/server/open.ts +27 -5
- package/server/paths.ts +17 -5
- package/server/store.ts +48 -91
- package/shared/events.ts +0 -2
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ The choice covers every worktree of the repo and is kept in the repo's local git
|
|
|
80
80
|
|
|
81
81
|
### Upgrading from 0.1.x
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
Versions before 0.2.0 kept progress in `.scratch/` in each worktree. From 1.14.1, Progress never reads, moves or changes the files those versions left there, so such a run no longer shows in the panel. The file stays where it is. Versions 0.2.0 to 1.14.0 moved such a run outside the repo the first time they opened the worktree.
|
|
84
84
|
|
|
85
85
|
## Agent tools
|
|
86
86
|
|
|
@@ -178,6 +178,12 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
|
|
|
178
178
|
|
|
179
179
|
## Changelog
|
|
180
180
|
|
|
181
|
+
### 1.14.1
|
|
182
|
+
|
|
183
|
+
- [Fixed] **Run history stays in its folder.** This fixes a security issue in saving run history. Progress now saves a run only when the dashboard can read the line that started it, and only into a folder inside the repo and outside `.git`.
|
|
184
|
+
- [Fixed] **HTML previews keep ignored files and `.git` out.** This fixes a security issue in the HTML preview. No preview gets files in `.git` written into it, and a page that git tracks no longer gets files git ignores, like a `.env`. A page in an ignored folder, like an agent's report in `.scratch`, still shows the files in its own folder and the folders below it. If git can't run in the repo, a preview shows the page without the files it loads from the repo.
|
|
185
|
+
- [Fixed] **Progress no longer reads its old files in `.scratch`.** This fixes a security issue in how Progress handled a run that version 0.1.x left in `.scratch`. Progress now keeps its files outside the repo for every worktree and never reads, moves or changes the files 0.1.x left there. A 0.1.x run that was never opened since 0.2.0 stays there and no longer shows in the panel.
|
|
186
|
+
|
|
181
187
|
### 1.14.0
|
|
182
188
|
|
|
183
189
|
- [New] **Agents can record progress with Paseo tools.** On Paseo 0.14 and later, Progress hands agents a tool for each common command, like `progress_ticket_update` and `progress_question_ask`. Nothing needs installing, and every update names the agent that sent it. Agents get them once you turn on **Settings → your host → Agents → Enable Paseo tools**, which also lets them start, message and stop other agents. **Agent tools** in Progress settings shows whether it's on and how to turn it on. The skill now uses the tools when an agent has them and the `paseo-progress` command otherwise, so Paseo 0.13 and agents without the tools work as before.
|
package/dist/server/cli.js
CHANGED
|
@@ -19706,7 +19706,6 @@ var QUESTION_MODE_RULES = {
|
|
|
19706
19706
|
// shared/events.ts
|
|
19707
19707
|
var PROGRESS_FILE_NAME = "progress.jsonl";
|
|
19708
19708
|
var PANEL_OPENED_NAME = "progress-panel-opened";
|
|
19709
|
-
var LEGACY_FOLDER = ".scratch";
|
|
19710
19709
|
var EVENT_VERSION = 1;
|
|
19711
19710
|
var TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"];
|
|
19712
19711
|
var ticketStatusSchema = external_exports.enum(TICKET_STATUSES);
|
|
@@ -20270,10 +20269,16 @@ import { dirname, isAbsolute, join as join2, resolve, sep } from "node:path";
|
|
|
20270
20269
|
function outside(inside) {
|
|
20271
20270
|
return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
20272
20271
|
}
|
|
20272
|
+
function inGitFolder(inside) {
|
|
20273
|
+
return inside.split(/[\\/]/).some((name) => name.toLowerCase() === ".git");
|
|
20274
|
+
}
|
|
20275
|
+
function hasGitEntry(directory) {
|
|
20276
|
+
return existsSync(join2(directory, ".git"));
|
|
20277
|
+
}
|
|
20273
20278
|
function findRoot(cwd) {
|
|
20274
20279
|
let directory = resolve(cwd);
|
|
20275
20280
|
while (true) {
|
|
20276
|
-
if (
|
|
20281
|
+
if (hasGitEntry(directory)) return directory;
|
|
20277
20282
|
const parent = dirname(directory);
|
|
20278
20283
|
if (parent === directory) return resolve(cwd);
|
|
20279
20284
|
directory = parent;
|
|
@@ -20385,33 +20390,33 @@ async function installLauncher(path, platform = process.platform) {
|
|
|
20385
20390
|
import { execFile } from "node:child_process";
|
|
20386
20391
|
import { createHash, randomUUID } from "node:crypto";
|
|
20387
20392
|
import { realpathSync } from "node:fs";
|
|
20388
|
-
import {
|
|
20393
|
+
import { lstat, mkdir as mkdir4, readdir, realpath, rename, rm as rm2, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
|
|
20389
20394
|
import { homedir } from "node:os";
|
|
20390
|
-
import { basename as basename2, dirname as dirname3, join as join4, normalize, resolve as resolve2 } from "node:path";
|
|
20395
|
+
import { basename as basename2, dirname as dirname3, join as join4, normalize, resolve as resolve2, sep as sep2 } from "node:path";
|
|
20391
20396
|
import { promisify } from "node:util";
|
|
20392
|
-
var LOCK_NAME2 = `${PROGRESS_FILE_NAME}.lock`;
|
|
20393
20397
|
var SAVE_RUNS_KEY = "paseo-progress.saveRuns";
|
|
20394
|
-
|
|
20395
|
-
${PROGRESS_FILE_NAME}
|
|
20396
|
-
${LOCK_NAME2}/
|
|
20397
|
-
${PANEL_OPENED_NAME}
|
|
20398
|
-
.gitignore
|
|
20399
|
-
`;
|
|
20400
|
-
async function git(cwd, args) {
|
|
20398
|
+
async function git(root, args) {
|
|
20401
20399
|
try {
|
|
20402
|
-
const { stdout } = await promisify(execFile)("git", args, { cwd });
|
|
20400
|
+
const { stdout } = await promisify(execFile)("git", ["-C", resolve2(root), ...args], { cwd: dirname3(process.execPath) });
|
|
20403
20401
|
return { code: 0, stdout };
|
|
20404
20402
|
} catch (error62) {
|
|
20405
20403
|
const failed = error62;
|
|
20406
20404
|
return { code: typeof failed.code === "number" ? failed.code : -1, stdout: failed.stdout ?? "" };
|
|
20407
20405
|
}
|
|
20408
20406
|
}
|
|
20409
|
-
async function exists2(path) {
|
|
20410
|
-
return Boolean(await stat3(path).catch(() => null));
|
|
20411
|
-
}
|
|
20412
20407
|
function stateRoot(home = homedir()) {
|
|
20413
20408
|
return join4(home, ".local", "state", "paseo-progress");
|
|
20414
20409
|
}
|
|
20410
|
+
async function writeAtomic(path, text2) {
|
|
20411
|
+
await mkdir4(dirname3(path), { recursive: true });
|
|
20412
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
20413
|
+
try {
|
|
20414
|
+
await writeFile3(temp, text2);
|
|
20415
|
+
await rename(temp, path);
|
|
20416
|
+
} finally {
|
|
20417
|
+
await rm2(temp, { force: true });
|
|
20418
|
+
}
|
|
20419
|
+
}
|
|
20415
20420
|
function outsideDirectory(root, home = homedir()) {
|
|
20416
20421
|
let real = root;
|
|
20417
20422
|
try {
|
|
@@ -20421,51 +20426,18 @@ function outsideDirectory(root, home = homedir()) {
|
|
|
20421
20426
|
const hash2 = createHash("sha1").update(real).digest("hex").slice(0, 8);
|
|
20422
20427
|
return join4(stateRoot(home), `${basename2(real)}-${hash2}`);
|
|
20423
20428
|
}
|
|
20424
|
-
|
|
20425
|
-
async function legacyTracked(root, mtimeMs) {
|
|
20426
|
-
const cached2 = trackedLegacy.get(root);
|
|
20427
|
-
if (cached2 && cached2.mtimeMs === mtimeMs) return cached2.tracked;
|
|
20428
|
-
const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
|
|
20429
|
-
trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
|
|
20430
|
-
return code === 0;
|
|
20431
|
-
}
|
|
20432
|
-
async function findStore(root, home = homedir()) {
|
|
20429
|
+
function findStore(root, home = homedir()) {
|
|
20433
20430
|
const directory = outsideDirectory(root, home);
|
|
20434
|
-
|
|
20435
|
-
const legacy = join4(root, LEGACY_FOLDER);
|
|
20436
|
-
const info = await stat3(join4(legacy, PROGRESS_FILE_NAME)).catch(() => null);
|
|
20437
|
-
if (!info) return store;
|
|
20438
|
-
if (await legacyTracked(root, info.mtimeMs)) return { directory: legacy, shown: LEGACY_FOLDER };
|
|
20439
|
-
await moveLegacy(legacy, directory);
|
|
20440
|
-
return store;
|
|
20441
|
-
}
|
|
20442
|
-
async function moveLegacy(legacy, directory) {
|
|
20443
|
-
await mkdir4(directory, { recursive: true });
|
|
20444
|
-
await withLock(join4(legacy, LOCK_NAME2), async () => {
|
|
20445
|
-
const text2 = await readFile3(join4(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
|
|
20446
|
-
if (text2 === null) return;
|
|
20447
|
-
await withLock(join4(directory, LOCK_NAME2), async () => {
|
|
20448
|
-
const current = await readFile3(join4(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
|
|
20449
|
-
const lead = current && !current.endsWith("\n") ? "\n" : "";
|
|
20450
|
-
await appendFile2(join4(directory, PROGRESS_FILE_NAME), `${lead}${text2}`);
|
|
20451
|
-
});
|
|
20452
|
-
await rm2(join4(legacy, PROGRESS_FILE_NAME));
|
|
20453
|
-
if (await exists2(join4(legacy, PANEL_OPENED_NAME))) {
|
|
20454
|
-
await writeFile3(join4(directory, PANEL_OPENED_NAME), "");
|
|
20455
|
-
await rm2(join4(legacy, PANEL_OPENED_NAME));
|
|
20456
|
-
}
|
|
20457
|
-
});
|
|
20458
|
-
const ignore = await readFile3(join4(legacy, ".gitignore"), "utf8").catch(() => null);
|
|
20459
|
-
if (ignore === LEGACY_GITIGNORE) await rm2(join4(legacy, ".gitignore"));
|
|
20460
|
-
await rmdir(legacy).catch(() => {
|
|
20461
|
-
});
|
|
20431
|
+
return { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
|
|
20462
20432
|
}
|
|
20463
20433
|
var configFiles = /* @__PURE__ */ new Map();
|
|
20464
20434
|
var savedFolders = /* @__PURE__ */ new Map();
|
|
20465
20435
|
async function configFile(root) {
|
|
20436
|
+
if (!hasGitEntry(root)) return null;
|
|
20466
20437
|
if (!configFiles.has(root)) {
|
|
20467
20438
|
const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
|
|
20468
|
-
|
|
20439
|
+
if (code !== 0) return null;
|
|
20440
|
+
configFiles.set(root, join4(resolve2(root, stdout.trim()), "config"));
|
|
20469
20441
|
}
|
|
20470
20442
|
return configFiles.get(root);
|
|
20471
20443
|
}
|
|
@@ -20476,10 +20448,19 @@ async function historyFolder(root) {
|
|
|
20476
20448
|
const cached2 = savedFolders.get(file2);
|
|
20477
20449
|
if (cached2 && cached2.mtimeMs === mtimeMs) return cached2.folder;
|
|
20478
20450
|
const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
|
|
20479
|
-
const
|
|
20451
|
+
const checked = checkFolder(stdout);
|
|
20452
|
+
const folder = "folder" in checked ? checked.folder : null;
|
|
20480
20453
|
savedFolders.set(file2, { mtimeMs, folder });
|
|
20481
20454
|
return folder;
|
|
20482
20455
|
}
|
|
20456
|
+
function checkFolder(raw) {
|
|
20457
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
20458
|
+
if (!trimmed) return { error: "Choose a folder" };
|
|
20459
|
+
const folder = normalize(trimmed);
|
|
20460
|
+
if (outside(folder) || folder === ".") return { error: "Choose a folder inside the repo" };
|
|
20461
|
+
if (inGitFolder(folder)) return { error: "Choose a folder outside .git" };
|
|
20462
|
+
return { folder };
|
|
20463
|
+
}
|
|
20483
20464
|
function runFileName(startedAt, title) {
|
|
20484
20465
|
const stamp = startedAt.replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
|
|
20485
20466
|
const slug = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
|
|
@@ -20488,29 +20469,27 @@ function runFileName(startedAt, title) {
|
|
|
20488
20469
|
async function saveRunCopy(root, text2) {
|
|
20489
20470
|
const folder = await historyFolder(root);
|
|
20490
20471
|
if (!folder) return;
|
|
20491
|
-
const
|
|
20492
|
-
|
|
20493
|
-
|
|
20494
|
-
|
|
20495
|
-
|
|
20496
|
-
|
|
20497
|
-
if (parsed?.type === "run.start") {
|
|
20498
|
-
start = index;
|
|
20499
|
-
event = parsed;
|
|
20500
|
-
break;
|
|
20501
|
-
}
|
|
20502
|
-
} catch {
|
|
20503
|
-
}
|
|
20504
|
-
}
|
|
20505
|
-
if (start < 0 || !event?.ts || !event.title) return;
|
|
20506
|
-
const directory = join4(root, folder);
|
|
20507
|
-
await mkdir4(directory, { recursive: true });
|
|
20508
|
-
await writeFile3(join4(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}
|
|
20472
|
+
const [start] = latestRun(parseProgress(text2).events);
|
|
20473
|
+
if (start?.event.type !== "run.start") return;
|
|
20474
|
+
const lines = text2.split("\n").slice(start.line - 1).filter((line) => line.trim());
|
|
20475
|
+
const directory = await runsFolder(root, folder);
|
|
20476
|
+
if (!directory) return;
|
|
20477
|
+
await writeAtomic(join4(directory, runFileName(start.event.ts, start.event.title)), `${lines.join("\n")}
|
|
20509
20478
|
`);
|
|
20510
20479
|
}
|
|
20480
|
+
async function runsFolder(root, folder) {
|
|
20481
|
+
let directory = await realpath(root);
|
|
20482
|
+
for (const name of folder.split(sep2)) {
|
|
20483
|
+
directory = join4(directory, name);
|
|
20484
|
+
const info = await lstat(directory).catch(() => null);
|
|
20485
|
+
if (!info) await mkdir4(directory);
|
|
20486
|
+
else if (!info.isDirectory()) return null;
|
|
20487
|
+
}
|
|
20488
|
+
return directory;
|
|
20489
|
+
}
|
|
20511
20490
|
|
|
20512
20491
|
// server/question-mode.ts
|
|
20513
|
-
import { readFile as
|
|
20492
|
+
import { readFile as readFile3, rm as rm3 } from "node:fs/promises";
|
|
20514
20493
|
import { homedir as homedir2 } from "node:os";
|
|
20515
20494
|
import { join as join5 } from "node:path";
|
|
20516
20495
|
var mirrorSchema = external_exports.object({ questionMode: external_exports.enum(QUESTION_MODES) });
|
|
@@ -20519,7 +20498,7 @@ function questionModePath(home = homedir2()) {
|
|
|
20519
20498
|
}
|
|
20520
20499
|
async function readQuestionMode(home = homedir2()) {
|
|
20521
20500
|
try {
|
|
20522
|
-
return mirrorSchema.parse(JSON.parse(await
|
|
20501
|
+
return mirrorSchema.parse(JSON.parse(await readFile3(questionModePath(home), "utf8"))).questionMode;
|
|
20523
20502
|
} catch {
|
|
20524
20503
|
return DEFAULT_QUESTION_MODE;
|
|
20525
20504
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.1",
|
|
4
4
|
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the Progress tools or the paseo-progress command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steve Castaneda",
|
package/server/open.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { findAttachment, type FolderEntry, imageMimeType, isPieced, type PiecedKind, previewKind, splitRef, textFormat, videoMimeType } from "../shared/attachments.ts";
|
|
9
9
|
import { prepareMarkdown, previewText } from "../shared/markdown.ts";
|
|
10
10
|
import { readProgress } from "./progress-file.ts";
|
|
11
|
-
import { findRoot, outside } from "./paths.ts";
|
|
12
|
-
import { findStore } from "./store.ts";
|
|
11
|
+
import { findRoot, hasGitEntry, inGitFolder, outside } from "./paths.ts";
|
|
12
|
+
import { findStore, git } from "./store.ts";
|
|
13
13
|
|
|
14
14
|
export type Opener = (path: string) => Promise<void>;
|
|
15
15
|
|
|
@@ -127,7 +127,7 @@ export async function previewDeliverable(directory: string, id: string): Promise
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
|
|
130
|
-
if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(realTarget, "utf8"),
|
|
130
|
+
if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(realTarget, "utf8"), realTarget, realRoot), bytes: size };
|
|
131
131
|
const data = await readFile(realTarget);
|
|
132
132
|
return { kind, dataUri: `data:${imageMimeType(realTarget)};base64,${data.toString("base64")}`, bytes: size };
|
|
133
133
|
}
|
|
@@ -146,8 +146,29 @@ async function listFolder(target: string): Promise<{ entries: FolderEntry[]; tru
|
|
|
146
146
|
// The preview gets only the page, so the files beside it that it loads (its
|
|
147
147
|
// screenshots, a script, a stylesheet) are written into it. Only files inside the
|
|
148
148
|
// worktree, up to MAX_PREVIEW_BYTES in all; anything else is left as written and
|
|
149
|
-
// fails to load. Web addresses are left alone.
|
|
150
|
-
|
|
149
|
+
// fails to load. Web addresses are left alone. A cloned repo's page runs here,
|
|
150
|
+
// so it must not read and send .env or .git files: it gets a file git says it
|
|
151
|
+
// keeps, or, when git ignores the page, like an agent's report in .scratch, a
|
|
152
|
+
// file in the page's own folder or below. Never one in a .git folder. When git
|
|
153
|
+
// can't answer, as for a file in a submodule or when git fails, the file stays out.
|
|
154
|
+
async function inlineLocalFiles(html: string, page: string, root: string): Promise<string> {
|
|
155
|
+
const directory = dirname(page);
|
|
156
|
+
const inRepo = hasGitEntry(root);
|
|
157
|
+
// -v -n prints a line for every path git answers for, so a git that fails
|
|
158
|
+
// with no answer never counts as "not ignored". The "./" stops git reading a
|
|
159
|
+
// name like ":(glob)x" as a pattern.
|
|
160
|
+
const ignoreCheck = async (path: string): Promise<"ignored" | "kept" | "unknown"> => {
|
|
161
|
+
if (!inRepo) return "kept";
|
|
162
|
+
const { code, stdout } = await git(root, ["check-ignore", "-v", "-n", "--", `./${relative(root, path).split(nodePath.sep).join("/")}`]);
|
|
163
|
+
if (!stdout.trim()) return "unknown";
|
|
164
|
+
return code === 0 ? "ignored" : code === 1 ? "kept" : "unknown";
|
|
165
|
+
};
|
|
166
|
+
const pageIgnored = (await ignoreCheck(page)) === "ignored";
|
|
167
|
+
const allowed = async (file: string) => {
|
|
168
|
+
if (inGitFolder(relative(root, file))) return false;
|
|
169
|
+
if ((await ignoreCheck(file)) === "kept") return true;
|
|
170
|
+
return pageIgnored && !outside(relative(directory, file));
|
|
171
|
+
};
|
|
151
172
|
let room = MAX_PREVIEW_BYTES - Buffer.byteLength(html);
|
|
152
173
|
const inline = async (url: string, encode: (data: Buffer, file: string) => string): Promise<string | null> => {
|
|
153
174
|
if (!url || /^([a-z][a-z0-9+.-]*:|[/\\#?])/i.test(url)) return null;
|
|
@@ -155,6 +176,7 @@ async function inlineLocalFiles(html: string, directory: string, root: string):
|
|
|
155
176
|
const file = await realpath(resolve(directory, decodeURI(url.replace(/[?#].*$/, ""))));
|
|
156
177
|
const stats = await stat(file);
|
|
157
178
|
if (outside(relative(root, file)) || !stats.isFile() || stats.size > room) return null;
|
|
179
|
+
if (!(await allowed(file))) return null;
|
|
158
180
|
const encoded = encode(await readFile(file), file);
|
|
159
181
|
if (encoded.length > room) return null;
|
|
160
182
|
room -= encoded.length;
|
package/server/paths.ts
CHANGED
|
@@ -7,14 +7,26 @@ export function outside(inside: string): boolean {
|
|
|
7
7
|
return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
10
|
+
// Whether a path from relative(root, target) is in a .git folder, a nested
|
|
11
|
+
// checkout's included. Windows writes either separator, and Windows and macOS
|
|
12
|
+
// match names in any case.
|
|
13
|
+
export function inGitFolder(inside: string): boolean {
|
|
14
|
+
return inside.split(/[\\/]/).some((name) => name.toLowerCase() === ".git");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A `.git` entry: a folder in a checkout, a file in a linked worktree.
|
|
18
|
+
export function hasGitEntry(directory: string): boolean {
|
|
19
|
+
return existsSync(join(directory, ".git"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The worktree root is the nearest directory with a `.git` entry. Outside git,
|
|
23
|
+
// use the directory. The command and the panel both start from here, so a
|
|
24
|
+
// Paseo project set at a subfolder of the repo still reads the file the agent
|
|
25
|
+
// writes.
|
|
14
26
|
export function findRoot(cwd: string): string {
|
|
15
27
|
let directory = resolve(cwd);
|
|
16
28
|
while (true) {
|
|
17
|
-
if (
|
|
29
|
+
if (hasGitEntry(directory)) return directory;
|
|
18
30
|
const parent = dirname(directory);
|
|
19
31
|
if (parent === directory) return resolve(cwd);
|
|
20
32
|
directory = parent;
|
package/server/store.ts
CHANGED
|
@@ -5,30 +5,24 @@
|
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { realpathSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { lstat, mkdir, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { basename, dirname, join, normalize, resolve } from "node:path";
|
|
10
|
+
import { basename, dirname, join, normalize, resolve, sep } from "node:path";
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { outside } from "./paths.ts";
|
|
12
|
+
import { latestRun, parseProgress } from "../shared/dashboard.ts";
|
|
13
|
+
import { hasGitEntry, inGitFolder, outside } from "./paths.ts";
|
|
15
14
|
|
|
16
|
-
const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
|
|
17
15
|
const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
|
|
18
|
-
// Exactly what 0.1.x wrote into .scratch/.gitignore. Only this is ever removed.
|
|
19
|
-
const LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
|
|
20
|
-
${PROGRESS_FILE_NAME}
|
|
21
|
-
${LOCK_NAME}/
|
|
22
|
-
${PANEL_OPENED_NAME}
|
|
23
|
-
.gitignore
|
|
24
|
-
`;
|
|
25
16
|
|
|
26
17
|
// `directory` holds the working files. `shown` names it for people.
|
|
27
18
|
export type Store = { directory: string; shown: string };
|
|
28
19
|
|
|
29
|
-
|
|
20
|
+
// Git runs in the worktree through -C, started from the folder Node itself
|
|
21
|
+
// lives in: Windows looks for a program in the folder it starts in before
|
|
22
|
+
// PATH, and a cloned repo can commit a git.exe.
|
|
23
|
+
export async function git(root: string, args: string[]): Promise<{ code: number; stdout: string }> {
|
|
30
24
|
try {
|
|
31
|
-
const { stdout } = await promisify(execFile)("git", args, { cwd });
|
|
25
|
+
const { stdout } = await promisify(execFile)("git", ["-C", resolve(root), ...args], { cwd: dirname(process.execPath) });
|
|
32
26
|
return { code: 0, stdout };
|
|
33
27
|
} catch (error) {
|
|
34
28
|
const failed = error as { code?: number | string; stdout?: string };
|
|
@@ -36,10 +30,6 @@ export async function git(cwd: string, args: string[]): Promise<{ code: number;
|
|
|
36
30
|
}
|
|
37
31
|
}
|
|
38
32
|
|
|
39
|
-
async function exists(path: string): Promise<boolean> {
|
|
40
|
-
return Boolean(await stat(path).catch(() => null));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
33
|
// The plugin's per-user folder on this computer.
|
|
44
34
|
export function stateRoot(home = homedir()): string {
|
|
45
35
|
return join(home, ".local", "state", "paseo-progress");
|
|
@@ -68,76 +58,37 @@ export function outsideDirectory(root: string, home = homedir()): string {
|
|
|
68
58
|
return join(stateRoot(home), `${basename(real)}-${hash}`);
|
|
69
59
|
}
|
|
70
60
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
async function legacyTracked(root: string, mtimeMs: number): Promise<boolean> {
|
|
75
|
-
const cached = trackedLegacy.get(root);
|
|
76
|
-
if (cached && cached.mtimeMs === mtimeMs) return cached.tracked;
|
|
77
|
-
const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
|
|
78
|
-
trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
|
|
79
|
-
return code === 0;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// The worktree's working files. A run 0.1.x left in .scratch moves outside the
|
|
83
|
-
// first time anything asks, unless git tracks it: then it stays and is used in
|
|
84
|
-
// place, so nothing changes for a repo that commits it.
|
|
85
|
-
export async function findStore(root: string, home = homedir()): Promise<Store> {
|
|
61
|
+
// The worktree's working files, always outside the repo. Progress never reads
|
|
62
|
+
// a repo's .scratch, where 0.1.x kept them: a cloned repo could put anything there.
|
|
63
|
+
export function findStore(root: string, home = homedir()): Store {
|
|
86
64
|
const directory = outsideDirectory(root, home);
|
|
87
|
-
|
|
88
|
-
const legacy = join(root, LEGACY_FOLDER);
|
|
89
|
-
const info = await stat(join(legacy, PROGRESS_FILE_NAME)).catch(() => null);
|
|
90
|
-
if (!info) return store;
|
|
91
|
-
if (await legacyTracked(root, info.mtimeMs)) return { directory: legacy, shown: LEGACY_FOLDER };
|
|
92
|
-
await moveLegacy(legacy, directory);
|
|
93
|
-
return store;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Appends the old file's lines to the new one rather than replacing it, so a
|
|
97
|
-
// command that wrote to the old place mid-move loses nothing: the next move
|
|
98
|
-
// picks those lines up. Then tidies what 0.1.x left behind.
|
|
99
|
-
async function moveLegacy(legacy: string, directory: string): Promise<void> {
|
|
100
|
-
await mkdir(directory, { recursive: true });
|
|
101
|
-
await withLock(join(legacy, LOCK_NAME), async () => {
|
|
102
|
-
const text = await readFile(join(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
|
|
103
|
-
if (text === null) return;
|
|
104
|
-
await withLock(join(directory, LOCK_NAME), async () => {
|
|
105
|
-
const current = await readFile(join(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
|
|
106
|
-
const lead = current && !current.endsWith("\n") ? "\n" : "";
|
|
107
|
-
await appendFile(join(directory, PROGRESS_FILE_NAME), `${lead}${text}`);
|
|
108
|
-
});
|
|
109
|
-
await rm(join(legacy, PROGRESS_FILE_NAME));
|
|
110
|
-
if (await exists(join(legacy, PANEL_OPENED_NAME))) {
|
|
111
|
-
await writeFile(join(directory, PANEL_OPENED_NAME), "");
|
|
112
|
-
await rm(join(legacy, PANEL_OPENED_NAME));
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
const ignore = await readFile(join(legacy, ".gitignore"), "utf8").catch(() => null);
|
|
116
|
-
if (ignore === LEGACY_GITIGNORE) await rm(join(legacy, ".gitignore"));
|
|
117
|
-
// Only if nothing else is in there; the user's own files keep the folder.
|
|
118
|
-
await rmdir(legacy).catch(() => {});
|
|
65
|
+
return { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
|
|
119
66
|
}
|
|
120
67
|
|
|
121
68
|
// The repo's saved-history folder, kept in its local git config, which every
|
|
122
69
|
// worktree of the repo shares and nothing commits. Polls cost one stat; git
|
|
123
|
-
// runs again only when the config file changes.
|
|
124
|
-
|
|
70
|
+
// runs again only when the config file changes. A worktree with no .git
|
|
71
|
+
// entry is no repo; when git fails in one that has it, the next poll asks again.
|
|
72
|
+
const configFiles = new Map<string, string>();
|
|
125
73
|
const savedFolders = new Map<string, { mtimeMs: number; folder: string | null }>();
|
|
126
74
|
|
|
127
75
|
async function configFile(root: string): Promise<string | null> {
|
|
76
|
+
if (!hasGitEntry(root)) return null;
|
|
128
77
|
if (!configFiles.has(root)) {
|
|
129
78
|
const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
|
|
130
|
-
|
|
79
|
+
if (code !== 0) return null;
|
|
80
|
+
configFiles.set(root, join(resolve(root, stdout.trim()), "config"));
|
|
131
81
|
}
|
|
132
82
|
return configFiles.get(root)!;
|
|
133
83
|
}
|
|
134
84
|
|
|
135
|
-
// Whether
|
|
85
|
+
// Whether git answers for the worktree's repo, which saving run history needs.
|
|
136
86
|
export async function inGitRepo(root: string): Promise<boolean> {
|
|
137
87
|
return (await configFile(root)) !== null;
|
|
138
88
|
}
|
|
139
89
|
|
|
140
|
-
// The folder runs are saved to, relative to the worktree root, or null.
|
|
90
|
+
// The folder runs are saved to, relative to the worktree root, or null. A
|
|
91
|
+
// folder set outside Paseo gets the same check as one picked in it.
|
|
141
92
|
export async function historyFolder(root: string): Promise<string | null> {
|
|
142
93
|
const file = await configFile(root);
|
|
143
94
|
if (file === null) return null;
|
|
@@ -145,7 +96,8 @@ export async function historyFolder(root: string): Promise<string | null> {
|
|
|
145
96
|
const cached = savedFolders.get(file);
|
|
146
97
|
if (cached && cached.mtimeMs === mtimeMs) return cached.folder;
|
|
147
98
|
const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
|
|
148
|
-
const
|
|
99
|
+
const checked = checkFolder(stdout);
|
|
100
|
+
const folder = "folder" in checked ? checked.folder : null;
|
|
149
101
|
savedFolders.set(file, { mtimeMs, folder });
|
|
150
102
|
return folder;
|
|
151
103
|
}
|
|
@@ -156,7 +108,7 @@ export function checkFolder(raw: string): { folder: string } | { error: string }
|
|
|
156
108
|
if (!trimmed) return { error: "Choose a folder" };
|
|
157
109
|
const folder = normalize(trimmed);
|
|
158
110
|
if (outside(folder) || folder === ".") return { error: "Choose a folder inside the repo" };
|
|
159
|
-
if (folder
|
|
111
|
+
if (inGitFolder(folder)) return { error: "Choose a folder outside .git" };
|
|
160
112
|
return { folder };
|
|
161
113
|
}
|
|
162
114
|
|
|
@@ -189,27 +141,32 @@ export function runFileName(startedAt: string, title: string): string {
|
|
|
189
141
|
export async function saveRunCopy(root: string, text: string): Promise<void> {
|
|
190
142
|
const folder = await historyFolder(root);
|
|
191
143
|
if (!folder) return;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
144
|
+
// The run the dashboard shows. A start line it can't read is no run.
|
|
145
|
+
const [start] = latestRun(parseProgress(text).events);
|
|
146
|
+
if (start?.event.type !== "run.start") return;
|
|
147
|
+
const lines = text.split("\n").slice(start.line - 1).filter((line) => line.trim());
|
|
148
|
+
const directory = await runsFolder(root, folder);
|
|
149
|
+
if (!directory) return;
|
|
150
|
+
// A rename replaces a link at the run's file instead of writing through it.
|
|
151
|
+
await writeAtomic(join(directory, runFileName(start.event.ts, start.event.title)), `${lines.join("\n")}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The saved-runs folder in the real worktree, made one step at a time, or null
|
|
155
|
+
// when any step is a link or a file: a cloned repo can commit a link that leads
|
|
156
|
+
// out of it.
|
|
157
|
+
async function runsFolder(root: string, folder: string): Promise<string | null> {
|
|
158
|
+
let directory = await realpath(root);
|
|
159
|
+
for (const name of folder.split(sep)) {
|
|
160
|
+
directory = join(directory, name);
|
|
161
|
+
const info = await lstat(directory).catch(() => null);
|
|
162
|
+
if (!info) await mkdir(directory);
|
|
163
|
+
else if (!info.isDirectory()) return null;
|
|
204
164
|
}
|
|
205
|
-
|
|
206
|
-
const directory = join(root, folder);
|
|
207
|
-
await mkdir(directory, { recursive: true });
|
|
208
|
-
await writeFile(join(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}\n`);
|
|
165
|
+
return directory;
|
|
209
166
|
}
|
|
210
167
|
|
|
211
168
|
// The folders directly inside one folder of the worktree ("" is the top), for
|
|
212
|
-
// the folder picker. .git
|
|
169
|
+
// the folder picker. .git folders are left out; so are links, which could leave the repo.
|
|
213
170
|
export async function listFolders(root: string, folder: string): Promise<{ folder: string; folders: string[] }> {
|
|
214
171
|
const checked = folder === "" ? { folder: "" } : checkFolder(folder);
|
|
215
172
|
if ("error" in checked) throw new Error(checked.error);
|
|
@@ -217,6 +174,6 @@ export async function listFolders(root: string, folder: string): Promise<{ folde
|
|
|
217
174
|
if (error.code === "ENOENT") return [];
|
|
218
175
|
throw error;
|
|
219
176
|
});
|
|
220
|
-
const folders = entries.filter((entry) => entry.isDirectory() && !(
|
|
177
|
+
const folders = entries.filter((entry) => entry.isDirectory() && !inGitFolder(entry.name)).map((entry) => entry.name);
|
|
221
178
|
return { folder: checked.folder, folders: folders.sort((a, b) => a.localeCompare(b)) };
|
|
222
179
|
}
|
package/shared/events.ts
CHANGED
|
@@ -7,8 +7,6 @@ import { QUESTION_MODES } from "./question-modes.ts";
|
|
|
7
7
|
export const PROGRESS_FILE_NAME = "progress.jsonl";
|
|
8
8
|
// Present once someone has opened the Progress panel for this worktree.
|
|
9
9
|
export const PANEL_OPENED_NAME = "progress-panel-opened";
|
|
10
|
-
// Where 0.1.x kept each worktree's files. store.ts moves them out.
|
|
11
|
-
export const LEGACY_FOLDER = ".scratch";
|
|
12
10
|
export const EVENT_VERSION = 1;
|
|
13
11
|
|
|
14
12
|
export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"] as const;
|