@stevecastaneda/paseo-progress 1.13.1 → 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/OVERVIEW.md +5 -3
- package/README.md +51 -7
- package/client/chat-row-view.tsx +78 -0
- package/client/chat-row.ts +83 -0
- package/client/launcher.tsx +19 -2
- package/client/locales/en.ts +25 -0
- package/client/locales/zh-CN.ts +25 -0
- package/client/messages.ts +14 -0
- package/client/panel.tsx +23 -10
- package/client/settings.tsx +19 -3
- package/dist/server/cli.js +61 -75
- package/images/5-settings.png +0 -0
- package/images/6-chat-rows.png +0 -0
- package/images/7-empty-tools-note.png +0 -0
- package/index.client.tsx +11 -0
- package/index.server.ts +4 -1
- package/package.json +4 -4
- package/paseo-plugin.json +3 -1
- package/server/cli.ts +8 -0
- package/server/open.ts +27 -5
- package/server/paths.ts +17 -5
- package/server/store.ts +48 -91
- package/server/tool.ts +174 -0
- package/shared/events.ts +0 -2
- package/shared/rpc.ts +11 -0
- package/shared/tools.ts +23 -0
- package/skills/paseo-progress/SKILL.md +16 -8
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
|
}
|
|
@@ -21055,6 +21034,12 @@ function guessKind(target, isUrl, cwd) {
|
|
|
21055
21034
|
var SHOW_SUMMARY = "Print the dashboard as text: what the user sees in the Progress panel.";
|
|
21056
21035
|
var INSTALL_SUMMARY = "Write the paseo-progress launcher (default ~/.local/bin/paseo-progress). It runs this command from whichever copy of the plugin Paseo is running.";
|
|
21057
21036
|
var DEFAULT_LAUNCHER = "~/.local/bin/paseo-progress";
|
|
21037
|
+
function commandSummary(name) {
|
|
21038
|
+
if (name === "show") return SHOW_SUMMARY;
|
|
21039
|
+
const command = commands[name];
|
|
21040
|
+
if (!command) throw new Error(`No command named ${name}.`);
|
|
21041
|
+
return command.summary;
|
|
21042
|
+
}
|
|
21058
21043
|
function help() {
|
|
21059
21044
|
return [
|
|
21060
21045
|
`Usage: ${USAGE_NAME} <command>`,
|
|
@@ -21166,5 +21151,6 @@ if (isMain()) {
|
|
|
21166
21151
|
);
|
|
21167
21152
|
}
|
|
21168
21153
|
export {
|
|
21154
|
+
commandSummary,
|
|
21169
21155
|
runCli
|
|
21170
21156
|
};
|
package/images/5-settings.png
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/index.client.tsx
CHANGED
|
@@ -7,6 +7,8 @@ import { ProgressLanguageProvider } from "./client/language";
|
|
|
7
7
|
import { addPageScreen } from "./client/page-screen";
|
|
8
8
|
import { PdfPanel } from "./client/pdf";
|
|
9
9
|
import { showsPdfs } from "./shared/attachments";
|
|
10
|
+
import { CHAT_ROW_KIND, CHAT_ROW_VERSION, chatRowSchema, transformChatRow } from "./client/chat-row";
|
|
11
|
+
import { ChatRowView } from "./client/chat-row-view";
|
|
10
12
|
|
|
11
13
|
const SETTINGS_ID = "settings";
|
|
12
14
|
|
|
@@ -45,8 +47,17 @@ export default function contribute(client: PluginClientContext) {
|
|
|
45
47
|
icon: "Settings",
|
|
46
48
|
Component: (props) => <ProgressLanguageProvider><QuestionSettings {...props} /></ProgressLanguageProvider>,
|
|
47
49
|
});
|
|
50
|
+
const stopTransformer = client.addTimelineTransformer({ id: "tool-calls", query: { itemType: "tool_call" }, transform: transformChatRow });
|
|
51
|
+
const stopRenderer = client.addTimelineRenderer({
|
|
52
|
+
kind: CHAT_ROW_KIND,
|
|
53
|
+
version: CHAT_ROW_VERSION,
|
|
54
|
+
schema: chatRowSchema,
|
|
55
|
+
Component: (props) => <ProgressLanguageProvider><ChatRowView {...props} /></ProgressLanguageProvider>,
|
|
56
|
+
});
|
|
48
57
|
|
|
49
58
|
return () => {
|
|
59
|
+
void stopTransformer();
|
|
60
|
+
void stopRenderer();
|
|
50
61
|
stopPage();
|
|
51
62
|
void stopPdf();
|
|
52
63
|
void stopSettings();
|
package/index.server.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
|
|
|
4
4
|
import { handleCanOpenOnHost, handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
|
|
5
5
|
import { mirrorQuestionMode } from "./server/question-mode";
|
|
6
6
|
import { handleInstallSkill, handleReadSkill, handleSkillStatus, repairSkill } from "./server/skill";
|
|
7
|
+
import { attachProgressTools, handleToolsStatus } from "./server/tool";
|
|
7
8
|
import { handleDismissWhatsNew, handleGetWhatsNew } from "./server/whats-new";
|
|
8
|
-
import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
9
|
+
import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getToolsStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
9
10
|
import { displaySettings, questionSettings } from "./shared/settings";
|
|
10
11
|
|
|
11
12
|
export default function contribute(server: PluginServerContext) {
|
|
@@ -23,12 +24,14 @@ export default function contribute(server: PluginServerContext) {
|
|
|
23
24
|
server.handle(readSkill, handleReadSkill);
|
|
24
25
|
server.handle(listFolders, handleListFolders);
|
|
25
26
|
server.handle(setHistoryFolder, handleSetHistoryFolder);
|
|
27
|
+
server.handle(getToolsStatus, handleToolsStatus);
|
|
26
28
|
server.handle(getWhatsNew, handleGetWhatsNew);
|
|
27
29
|
server.handle(dismissWhatsNew, handleDismissWhatsNew);
|
|
28
30
|
// Settles a new install before any panel opens. Opening one makes a
|
|
29
31
|
// worktree folder, which would look like an earlier version's.
|
|
30
32
|
void handleGetWhatsNew().catch(() => {});
|
|
31
33
|
void repairSkill();
|
|
34
|
+
attachProgressTools(server);
|
|
32
35
|
server.registerSettings(displaySettings);
|
|
33
36
|
const mirror = mirrorQuestionMode(server.registerSettings(questionSettings));
|
|
34
37
|
return mirror.stop;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
3
|
+
"version": "1.14.1",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"postpack": "rm -rf dist"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@getpaseo/client": "0.
|
|
54
|
-
"@getpaseo/plugin": "0.
|
|
53
|
+
"@getpaseo/client": "0.14.0",
|
|
54
|
+
"@getpaseo/plugin": "0.14.0",
|
|
55
55
|
"@tanstack/react-query": "^5.90.11",
|
|
56
56
|
"@types/node": "^20.9.0",
|
|
57
57
|
"@types/react": "~19.2.0",
|
package/paseo-plugin.json
CHANGED
package/server/cli.ts
CHANGED
|
@@ -494,6 +494,14 @@ const SHOW_SUMMARY = "Print the dashboard as text: what the user sees in the Pro
|
|
|
494
494
|
const INSTALL_SUMMARY = "Write the paseo-progress launcher (default ~/.local/bin/paseo-progress). It runs this command from whichever copy of the plugin Paseo is running.";
|
|
495
495
|
const DEFAULT_LAUNCHER = "~/.local/bin/paseo-progress";
|
|
496
496
|
|
|
497
|
+
// The agent tools describe themselves with the summaries agents see in --help.
|
|
498
|
+
export function commandSummary(name: string): string {
|
|
499
|
+
if (name === "show") return SHOW_SUMMARY;
|
|
500
|
+
const command = commands[name];
|
|
501
|
+
if (!command) throw new Error(`No command named ${name}.`);
|
|
502
|
+
return command.summary;
|
|
503
|
+
}
|
|
504
|
+
|
|
497
505
|
function help(): string {
|
|
498
506
|
return [
|
|
499
507
|
`Usage: ${USAGE_NAME} <command>`,
|
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;
|