@stevecastaneda/paseo-progress 1.14.2 → 1.14.4

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.
Files changed (67) hide show
  1. package/README.md +28 -17
  2. package/client/attachments.tsx +122 -28
  3. package/client/blocks.tsx +33 -8
  4. package/client/chat-row-view.tsx +19 -6
  5. package/client/chat-row.ts +13 -2
  6. package/client/dashboard-query.ts +9 -4
  7. package/client/deliverables.tsx +82 -17
  8. package/client/dialog-stack.tsx +104 -42
  9. package/client/directory.ts +1 -3
  10. package/client/errors.ts +6 -1
  11. package/client/history.tsx +233 -79
  12. package/client/i18n.ts +10 -5
  13. package/client/launcher.tsx +57 -15
  14. package/client/locales/en.ts +28 -22
  15. package/client/locales/zh-CN.ts +18 -17
  16. package/client/messages.ts +1 -0
  17. package/client/motion.tsx +38 -14
  18. package/client/panel-opened.ts +3 -1
  19. package/client/panel.tsx +442 -160
  20. package/client/pdf.tsx +18 -4
  21. package/client/pieces.ts +6 -1
  22. package/client/pill-popover.tsx +108 -23
  23. package/client/pills.tsx +35 -12
  24. package/client/preview.tsx +265 -79
  25. package/client/questions.tsx +173 -52
  26. package/client/row.tsx +14 -3
  27. package/client/section-title.tsx +11 -2
  28. package/client/settings.tsx +76 -24
  29. package/client/show-more.tsx +23 -6
  30. package/client/spinner.tsx +11 -7
  31. package/client/status-badge.tsx +9 -7
  32. package/client/tabs.tsx +98 -20
  33. package/client/ticket-dialog.tsx +241 -73
  34. package/client/time-bar.tsx +5 -2
  35. package/client/video.tsx +15 -6
  36. package/client/whats-new.tsx +94 -36
  37. package/client/when.tsx +2 -1
  38. package/client/write-up.tsx +79 -12
  39. package/dist/server/cli.js +199 -101
  40. package/index.client.tsx +28 -13
  41. package/index.server.ts +19 -1
  42. package/package.json +15 -14
  43. package/server/cli.ts +72 -32
  44. package/server/dashboard.ts +9 -13
  45. package/server/launcher.ts +33 -17
  46. package/server/lock.ts +14 -7
  47. package/server/open.ts +32 -98
  48. package/server/progress-file.ts +6 -6
  49. package/server/question-mode.ts +12 -6
  50. package/server/skill.ts +22 -16
  51. package/server/store.ts +26 -41
  52. package/server/system.ts +113 -0
  53. package/server/tool.ts +89 -44
  54. package/server/whats-new.ts +25 -13
  55. package/shared/attachments.ts +40 -8
  56. package/shared/blocks.ts +9 -6
  57. package/shared/dashboard.ts +46 -29
  58. package/shared/events.ts +12 -10
  59. package/shared/markdown.ts +16 -8
  60. package/shared/question-modes.ts +8 -5
  61. package/shared/rpc.ts +9 -3
  62. package/shared/show.ts +20 -8
  63. package/shared/ticket-story.ts +1 -3
  64. package/skills/paseo-progress/SKILL.md +7 -0
  65. package/tsconfig.json +3 -10
  66. package/client/page-opener.ts +0 -9
  67. package/client/page-screen.tsx +0 -97
package/server/open.ts CHANGED
@@ -1,15 +1,25 @@
1
1
  import type { PluginHandlerContext } from "@getpaseo/plugin/server";
2
- import { execFile } from "node:child_process";
3
2
  import { existsSync } from "node:fs";
4
- import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
5
4
  import { homedir, hostname } from "node:os";
6
5
  import nodePath, { delimiter, dirname, join, relative, resolve } from "node:path";
7
- import { promisify } from "node:util";
8
- import { findAttachment, type FolderEntry, imageMimeType, isPieced, type PiecedKind, previewKind, splitRef, textFormat, videoMimeType } from "../shared/attachments.ts";
6
+ import {
7
+ findAttachment,
8
+ type FolderEntry,
9
+ imageMimeType,
10
+ isPieced,
11
+ opensOnHost,
12
+ type PiecedKind,
13
+ previewKind,
14
+ splitRef,
15
+ textFormat,
16
+ videoMimeType,
17
+ } from "../shared/attachments.ts";
9
18
  import { prepareMarkdown, previewText } from "../shared/markdown.ts";
10
19
  import { readProgress } from "./progress-file.ts";
11
- import { findRoot, hasGitEntry, inGitFolder, outside } from "./paths.ts";
12
- import { findStore, git } from "./store.ts";
20
+ import { findRoot, outside } from "./paths.ts";
21
+ import { findStore } from "./store.ts";
22
+ import { openToRead, runProgram } from "./system.ts";
13
23
 
14
24
  export type Opener = (path: string) => Promise<void>;
15
25
 
@@ -28,7 +38,7 @@ export function openCommand(path: string, platform: NodeJS.Platform, browser: st
28
38
  async function defaultBrowser(): Promise<string | null> {
29
39
  try {
30
40
  const plist = join(homedir(), "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist");
31
- const { stdout } = await promisify(execFile)("plutil", ["-convert", "json", "-o", "-", plist]);
41
+ const { stdout } = await runProgram("plutil", ["-convert", "json", "-o", "-", plist]);
32
42
  const handlers = (JSON.parse(stdout).LSHandlers ?? []) as Array<{ LSHandlerURLScheme?: string; LSHandlerRoleAll?: string }>;
33
43
  return handlers.find((handler) => handler.LSHandlerURLScheme === "https")?.LSHandlerRoleAll ?? null;
34
44
  } catch {
@@ -47,14 +57,14 @@ export function hostOpener(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): O
47
57
  if (!canOpenFiles(platform, env)) throw new Error(`Can't open files on ${hostname()}`);
48
58
  const browser = platform === "darwin" ? await defaultBrowser() : null;
49
59
  const [command, args] = openCommand(path, platform, browser);
50
- await promisify(execFile)(command, args);
60
+ await runProgram(command, args);
51
61
  };
52
62
  }
53
63
 
54
64
  // A deliverable or question attachment the agent recorded, found by reference,
55
65
  // so the panel can never reach an arbitrary path. Only files inside the
56
66
  // worktree resolve.
57
- async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; realTarget: string; realRoot: string; worktree: string }> {
67
+ async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; realTarget: string; worktree: string }> {
58
68
  const { base, inner } = splitRef(ref);
59
69
  const worktree = findRoot(directory);
60
70
  const { dashboard } = await readProgress((await findStore(worktree)).directory);
@@ -67,11 +77,15 @@ async function resolveAttachment(directory: string, ref: string, verb: string):
67
77
  if (!realTarget) throw new Error(`${path} no longer exists.`);
68
78
  const inside = relative(realRoot, realTarget);
69
79
  if (outside(inside)) throw new Error(`${path} is outside this worktree, so it was not ${verb}.`);
70
- return { path, realTarget, realRoot, worktree };
80
+ return { path, realTarget, worktree };
71
81
  }
72
82
 
83
+ // The real file is checked, not the name the agent recorded, so a link named
84
+ // like a picture can't hand a script to the default app.
73
85
  export async function openDeliverable(directory: string, id: string, opener: Opener): Promise<{ opened: string }> {
74
- const { realTarget } = await resolveAttachment(directory, id, "opened");
86
+ const { path, realTarget } = await resolveAttachment(directory, id, "opened");
87
+ if (!(await stat(realTarget)).isFile() || !opensOnHost(realTarget))
88
+ throw new Error(`Progress opens only images, video, audio, PDFs and text files, not ${path}`);
75
89
  await opener(realTarget);
76
90
  return { opened: realTarget };
77
91
  }
@@ -98,15 +112,15 @@ export type Preview =
98
112
  | { kind: "pdf"; bytes: number }
99
113
  | { kind: "text"; text: string; bytes: number; truncated: boolean }
100
114
  | { kind: "markdown"; markdown: string; bytes: number; truncated: boolean }
101
- | { kind: "html"; html: string; bytes: number }
102
115
  | { kind: "folder"; entries: FolderEntry[]; truncated: boolean };
103
116
 
104
117
  export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
105
- const { path, realTarget, realRoot, worktree } = await resolveAttachment(directory, id, "previewed");
118
+ const { path, realTarget, worktree } = await resolveAttachment(directory, id, "previewed");
106
119
  const stats = await stat(realTarget);
107
120
  if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(realTarget)) };
121
+ // A page opens in Paseo's own preview, in a tab.
108
122
  const kind = previewKind(realTarget);
109
- if (!kind) throw new Error(`${path} isn't an image, video, PDF, text or HTML file, so it can't be previewed.`);
123
+ if (!kind || kind === "html") throw new Error(`${path} isn't an image, video, PDF or text file, so it can't be previewed.`);
110
124
  const { size } = stats;
111
125
  if (isPieced(kind)) {
112
126
  if (size > PIECED_LIMIT_BYTES[kind]) throw new Error(tooLarge(path, size));
@@ -114,7 +128,7 @@ export async function previewDeliverable(directory: string, id: string): Promise
114
128
  }
115
129
  if (kind === "text") {
116
130
  // Read only what's shown, so a huge log doesn't load whole.
117
- const file = await open(realTarget);
131
+ const file = await openToRead(realTarget);
118
132
  try {
119
133
  const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(Math.min(size, MAX_PREVIEW_TEXT_BYTES)), position: 0 });
120
134
  const bytes = buffer.subarray(0, bytesRead);
@@ -127,7 +141,6 @@ export async function previewDeliverable(directory: string, id: string): Promise
127
141
  }
128
142
  }
129
143
  if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
130
- if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(realTarget, "utf8"), realTarget, realRoot), bytes: size };
131
144
  const data = await readFile(realTarget);
132
145
  return { kind, dataUri: `data:${imageMimeType(realTarget)};base64,${data.toString("base64")}`, bytes: size };
133
146
  }
@@ -143,79 +156,6 @@ async function listFolder(target: string): Promise<{ entries: FolderEntry[]; tru
143
156
  return { entries: entries.slice(0, MAX_FOLDER_ENTRIES), truncated: entries.length > MAX_FOLDER_ENTRIES };
144
157
  }
145
158
 
146
- // The preview gets only the page, so the files beside it that it loads (its
147
- // screenshots, a script, a stylesheet) are written into it. Only files inside the
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. 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
- };
172
- let room = MAX_PREVIEW_BYTES - Buffer.byteLength(html);
173
- const inline = async (url: string, encode: (data: Buffer, file: string) => string): Promise<string | null> => {
174
- if (!url || /^([a-z][a-z0-9+.-]*:|[/\\#?])/i.test(url)) return null;
175
- try {
176
- const file = await realpath(resolve(directory, decodeURI(url.replace(/[?#].*$/, ""))));
177
- const stats = await stat(file);
178
- if (outside(relative(root, file)) || !stats.isFile() || stats.size > room) return null;
179
- if (!(await allowed(file))) return null;
180
- const encoded = encode(await readFile(file), file);
181
- if (encoded.length > room) return null;
182
- room -= encoded.length;
183
- return encoded;
184
- } catch {
185
- return null;
186
- }
187
- };
188
- const dataUri = (data: Buffer, file: string) => `data:${imageMimeType(file) ?? videoMimeType(file) ?? "application/octet-stream"};base64,${data.toString("base64")}`;
189
- // Its own end tag would close the element early.
190
- const body = (tag: string) => (data: Buffer) => data.toString("utf8").replace(new RegExp(`</${tag}`, "gi"), `<\\/${tag}`);
191
- html = await replaceEach(html, /<(?:img|source|video)\b[^>]*>/gi, (tag) =>
192
- replaceEach(tag[0], /(?<=\s)(src|poster)(\s*=\s*)(["'])([^"']*)\3/gi, async ([, name, equals, quote, url]) => {
193
- const uri = await inline(url, dataUri);
194
- return uri && `${name}${equals}${quote}${uri}${quote}`;
195
- }));
196
- html = await replaceEach(html, /<link\b[^>]*>/gi, async ([tag]) => {
197
- const href = tag.match(/\bhref\s*=\s*(["'])([^"']*)\1/i)?.[2];
198
- if (!href || !/\brel\s*=\s*(["'])\s*stylesheet\s*\1/i.test(tag)) return null;
199
- const css = await inline(href, body("style"));
200
- return css === null ? null : `<style>${css}</style>`;
201
- });
202
- return replaceEach(html, /<script\b([^>]*?)\s+src\s*=\s*(["'])([^"']*)\2([^>]*)>\s*<\/script\s*>/gi, async ([, before, , src, after]) => {
203
- const script = await inline(src, body("script"));
204
- return script === null ? null : `<script${before}${after}>${script}</script>`;
205
- });
206
- }
207
-
208
- // `replace` on each match in turn; a null keeps the match as it was.
209
- async function replaceEach(text: string, pattern: RegExp, replace: (match: RegExpMatchArray) => Promise<string | null>): Promise<string> {
210
- let result = "";
211
- let last = 0;
212
- for (const match of text.matchAll(pattern)) {
213
- result += text.slice(last, match.index) + ((await replace(match)) ?? match[0]);
214
- last = match.index! + match[0].length;
215
- }
216
- return result + text.slice(last);
217
- }
218
-
219
159
  function tooLarge(path: string, size: number): string {
220
160
  return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
221
161
  }
@@ -224,7 +164,7 @@ export async function readDeliverablePiece(directory: string, id: string, offset
224
164
  const { path, realTarget } = await resolveAttachment(directory, id, "previewed");
225
165
  const kind = previewKind(realTarget);
226
166
  if (!isPieced(kind)) throw new Error(`${path} isn't a video or PDF.`);
227
- const file = await open(realTarget);
167
+ const file = await openToRead(realTarget);
228
168
  try {
229
169
  const { size } = await file.stat();
230
170
  if (size > PIECED_LIMIT_BYTES[kind]) throw new Error(tooLarge(path, size));
@@ -237,10 +177,7 @@ export async function readDeliverablePiece(directory: string, id: string, offset
237
177
  }
238
178
  }
239
179
 
240
- export async function handlePreviewDeliverable(
241
- input: { workspaceId: string; workspaceDirectory: string; ref: string },
242
- _context: PluginHandlerContext,
243
- ) {
180
+ export async function handlePreviewDeliverable(input: { workspaceId: string; workspaceDirectory: string; ref: string }, _context: PluginHandlerContext) {
244
181
  return previewDeliverable(input.workspaceDirectory, input.ref);
245
182
  }
246
183
 
@@ -251,10 +188,7 @@ export async function handleReadDeliverablePiece(
251
188
  return readDeliverablePiece(input.workspaceDirectory, input.ref, input.offset);
252
189
  }
253
190
 
254
- export async function handleOpenDeliverable(
255
- input: { workspaceId: string; workspaceDirectory: string; ref: string },
256
- _context: PluginHandlerContext,
257
- ) {
191
+ export async function handleOpenDeliverable(input: { workspaceId: string; workspaceDirectory: string; ref: string }, _context: PluginHandlerContext) {
258
192
  return openDeliverable(input.workspaceDirectory, input.ref, hostOpener(process.platform, process.env));
259
193
  }
260
194
 
@@ -2,11 +2,12 @@
2
2
  // the command appends to and the panel-opened marker. Each function takes the
3
3
  // folder that holds them, which `findStore` in store.ts picks. The command and
4
4
  // the panel's RPC handlers go through here, so they agree on how they're read.
5
- import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import { access, readFile, stat } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { EMPTY_DASHBOARD, latestRun, parseProgress, reduceProgress, type Dashboard, type NextIds } from "../shared/dashboard.ts";
8
8
  import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME, type ProgressEvent } from "../shared/events.ts";
9
9
  import { withLock } from "./lock.ts";
10
+ import { appendFileIn, makeFolder, writeFileIn } from "./system.ts";
10
11
 
11
12
  const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
12
13
 
@@ -63,8 +64,8 @@ export async function appendProgress<T>(
63
64
  // Runs before the lock is released, with the whole file as it now is.
64
65
  after?: (text: string) => Promise<void>,
65
66
  ): Promise<T> {
66
- await mkdir(directory, { recursive: true });
67
- return withLock(join(directory, LOCK_NAME), async () => {
67
+ await makeFolder(directory);
68
+ return withLock(directory, LOCK_NAME, async () => {
68
69
  const text = (await readText(directory)) ?? "";
69
70
  const parsed = parseProgress(text);
70
71
  const { dashboard, nextIds } = reduceProgress(parsed, now());
@@ -76,15 +77,14 @@ export async function appendProgress<T>(
76
77
  // A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
77
78
  const lead = text && !text.endsWith("\n") ? "\n" : "";
78
79
  const line = `${lead}${JSON.stringify(checked.data)}\n`;
79
- await appendFile(join(directory, PROGRESS_FILE_NAME), line);
80
+ await appendFileIn(directory, PROGRESS_FILE_NAME, line);
80
81
  await after?.(text + line);
81
82
  return result;
82
83
  });
83
84
  }
84
85
 
85
86
  export async function markPanelOpened(directory: string): Promise<void> {
86
- await mkdir(directory, { recursive: true });
87
- await writeFile(join(directory, PANEL_OPENED_NAME), "");
87
+ await writeFileIn(directory, PANEL_OPENED_NAME, "");
88
88
  }
89
89
 
90
90
  async function readText(directory: string): Promise<string | null> {
@@ -1,19 +1,22 @@
1
1
  // The question mode lives in Paseo's settings, which only the plugin's server
2
2
  // process can read. It mirrors the mode into a file the paseo-progress command
3
3
  // can read on its own.
4
- import { readFile, rm } from "node:fs/promises";
4
+ import { readFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import type { PluginSettings } from "@getpaseo/plugin/server";
9
9
  import { DEFAULT_QUESTION_MODE, QUESTION_MODES, type QuestionMode } from "../shared/question-modes.ts";
10
10
  import type { questionSettings } from "../shared/settings.ts";
11
- import { stateRoot, writeAtomic } from "./store.ts";
11
+ import { stateRoot } from "./store.ts";
12
+ import { removeIn, writeFileIn } from "./system.ts";
12
13
 
13
14
  const mirrorSchema = z.object({ questionMode: z.enum(QUESTION_MODES) });
14
15
 
16
+ const SETTINGS_NAME = "settings.json";
17
+
15
18
  export function questionModePath(home = homedir()): string {
16
- return join(stateRoot(home), "settings.json");
19
+ return join(stateRoot(home), SETTINGS_NAME);
17
20
  }
18
21
 
19
22
  // The default unless the mirror says otherwise, so a missing or damaged file
@@ -27,7 +30,7 @@ export async function readQuestionMode(home = homedir()): Promise<QuestionMode>
27
30
  }
28
31
 
29
32
  export async function writeQuestionMode(mode: QuestionMode, home = homedir()): Promise<void> {
30
- await writeAtomic(questionModePath(home), `${JSON.stringify({ questionMode: mode })}\n`);
33
+ await writeFileIn(stateRoot(home), SETTINGS_NAME, `${JSON.stringify({ questionMode: mode })}\n`, { atomic: true });
31
34
  }
32
35
 
33
36
  type Settings = Pick<PluginSettings<(typeof questionSettings)["schema"]>, "read" | "subscribe">;
@@ -48,14 +51,17 @@ export function mirrorQuestionMode(settings: Settings, home = homedir()): { stop
48
51
  return write(state);
49
52
  });
50
53
  // A change heard while the first read is in flight is newer than that read.
51
- const settled = settings.read().catch(() => null).then((state) => (changed ? writes : write(state)));
54
+ const settled = settings
55
+ .read()
56
+ .catch(() => null)
57
+ .then((state) => (changed ? writes : write(state)));
52
58
  return {
53
59
  settled,
54
60
  async stop() {
55
61
  await unsubscribe();
56
62
  await settled;
57
63
  await writes;
58
- await rm(questionModePath(home), { force: true });
64
+ await removeIn(stateRoot(home), SETTINGS_NAME);
59
65
  },
60
66
  };
61
67
  }
package/server/skill.ts CHANGED
@@ -1,9 +1,8 @@
1
- import { exec, execFile } from "node:child_process";
2
- import { lstat, mkdir, readFile, readlink, symlink, unlink } from "node:fs/promises";
1
+ import { lstat, readFile, readlink } from "node:fs/promises";
3
2
  import { homedir } from "node:os";
4
3
  import { basename, dirname, join, resolve } from "node:path";
5
- import { promisify } from "node:util";
6
4
  import { paseoCommands, PLUGIN_ID } from "./launcher.ts";
5
+ import { linkFolderIn, removeLinkIn, runCommandLine, runProgram } from "./system.ts";
7
6
 
8
7
  export const SKILL_NAME = "paseo-progress";
9
8
 
@@ -37,8 +36,8 @@ export async function skillState(path: string, pluginDirectory: string): Promise
37
36
  export async function relinkSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
38
37
  let wrote = false;
39
38
  for (const path of paths) {
40
- if (await skillState(path, pluginDirectory) !== "outdated") continue;
41
- await unlink(path);
39
+ if ((await skillState(path, pluginDirectory)) !== "outdated") continue;
40
+ await removeLinkIn(dirname(path), basename(path));
42
41
  await link(path, pluginDirectory);
43
42
  wrote = true;
44
43
  }
@@ -60,19 +59,20 @@ export async function installSkill(paths: string[], pluginDirectory: string): Pr
60
59
  }
61
60
 
62
61
  async function link(path: string, pluginDirectory: string) {
63
- await mkdir(dirname(path), { recursive: true });
64
- // A junction on Windows, where a directory symlink needs admin rights or
65
- // Developer Mode. Other systems ignore the type and make a symlink.
66
- await symlink(skillSource(pluginDirectory), path, "junction");
62
+ await linkFolderIn(dirname(path), basename(path), skillSource(pluginDirectory));
67
63
  }
68
64
 
69
65
  // "current" once every folder that can take the link has it.
70
66
  export async function skillStatus(paths: string[], pluginDirectory: string) {
71
67
  const targets = await Promise.all(paths.map(async (path) => ({ path, state: await skillState(path, pluginDirectory) })));
72
68
  const states = targets.map((target) => target.state);
73
- const state: SkillState = states.includes("outdated") ? "outdated"
74
- : states.includes("missing") ? "missing"
75
- : states.includes("current") ? "current" : "foreign";
69
+ const state: SkillState = states.includes("outdated")
70
+ ? "outdated"
71
+ : states.includes("missing")
72
+ ? "missing"
73
+ : states.includes("current")
74
+ ? "current"
75
+ : "foreign";
76
76
  return { state, targets };
77
77
  }
78
78
 
@@ -96,8 +96,8 @@ export async function findPluginDirectory(): Promise<string> {
96
96
  function askPaseo(cli: string) {
97
97
  const options = { timeout: 10_000 };
98
98
  return process.platform === "win32"
99
- ? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`, options)
100
- : promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"], options);
99
+ ? runCommandLine(`"${cli}" plugin ls ${PLUGIN_ID} --json`, options)
100
+ : runProgram(cli, ["plugin", "ls", PLUGIN_ID, "--json"], options);
101
101
  }
102
102
 
103
103
  // Runs once per load, when the server starts: an update reloads the bundle.
@@ -106,7 +106,9 @@ function askPaseo(cli: string) {
106
106
  let repair: Promise<unknown> | null = null;
107
107
 
108
108
  export function repairSkill(): Promise<unknown> {
109
- repair ??= findPluginDirectory().then((directory) => relinkSkill(defaultSkillPaths(), directory)).catch(() => {});
109
+ repair ??= findPluginDirectory()
110
+ .then((directory) => relinkSkill(defaultSkillPaths(), directory))
111
+ .catch(() => {});
110
112
  return repair;
111
113
  }
112
114
 
@@ -118,7 +120,11 @@ export async function handleSkillStatus() {
118
120
  // SKILL.md's frontmatter holds one-line fields; only the description is shown.
119
121
  export function skillDocument(text: string): { markdown: string; description: string } {
120
122
  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
121
- const description = /^description:[ \t]*(.*)$/m.exec(frontmatter?.[1] ?? "")?.[1].trim().replace(/^(["'])(.*)\1$/, "$2") ?? "";
123
+ const description =
124
+ /^description:[ \t]*(.*)$/m
125
+ .exec(frontmatter?.[1] ?? "")?.[1]
126
+ .trim()
127
+ .replace(/^(["'])(.*)\1$/, "$2") ?? "";
122
128
  return { markdown: text.slice(frontmatter?.[0].length ?? 0).trimStart(), description };
123
129
  }
124
130
 
package/server/store.ts CHANGED
@@ -2,27 +2,24 @@
2
2
  // in to saving. The working files never touch the repo: they live outside it,
3
3
  // one folder per worktree, so git never sees them and nothing needs hiding.
4
4
  // Only runs the user asks to save are written into the repo, one file per run.
5
- import { execFile } from "node:child_process";
6
- import { createHash, randomUUID } from "node:crypto";
5
+ import { createHash } from "node:crypto";
7
6
  import { realpathSync } from "node:fs";
8
- import { lstat, mkdir, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { readdir, realpath, stat } from "node:fs/promises";
9
8
  import { homedir } from "node:os";
10
- import { basename, dirname, join, normalize, resolve, sep } from "node:path";
11
- import { promisify } from "node:util";
9
+ import { basename, join, normalize, resolve } from "node:path";
12
10
  import { latestRun, parseProgress } from "../shared/dashboard.ts";
13
11
  import { hasGitEntry, inGitFolder, outside } from "./paths.ts";
12
+ import { makeFoldersIn, runProgram, writeFileIn } from "./system.ts";
14
13
 
15
14
  const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
16
15
 
17
16
  // `directory` holds the working files. `shown` names it for people.
18
17
  export type Store = { directory: string; shown: string };
19
18
 
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.
19
+ // Git runs in the worktree through -C, never from it: a cloned repo can commit a git.exe.
23
20
  export async function git(root: string, args: string[]): Promise<{ code: number; stdout: string }> {
24
21
  try {
25
- const { stdout } = await promisify(execFile)("git", ["-C", resolve(root), ...args], { cwd: dirname(process.execPath) });
22
+ const { stdout } = await runProgram("git", ["-C", resolve(root), ...args]);
26
23
  return { code: 0, stdout };
27
24
  } catch (error) {
28
25
  const failed = error as { code?: number | string; stdout?: string };
@@ -35,18 +32,6 @@ export function stateRoot(home = homedir()): string {
35
32
  return join(home, ".local", "state", "paseo-progress");
36
33
  }
37
34
 
38
- // Readers see the old file or the new one, never half of one.
39
- export async function writeAtomic(path: string, text: string): Promise<void> {
40
- await mkdir(dirname(path), { recursive: true });
41
- const temp = `${path}.${randomUUID()}.tmp`;
42
- try {
43
- await writeFile(temp, text);
44
- await rename(temp, path);
45
- } finally {
46
- await rm(temp, { force: true });
47
- }
48
- }
49
-
50
35
  // One folder per worktree on this computer, named so a person can tell which
51
36
  // worktree it belongs to.
52
37
  export function outsideDirectory(root: string, home = homedir()): string {
@@ -131,8 +116,16 @@ export async function setHistoryFolder(root: string, raw: string | null): Promis
131
116
  // title as it was at the start, so the name never changes and two runs never
132
117
  // share one.
133
118
  export function runFileName(startedAt: string, title: string): string {
134
- const stamp = startedAt.replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
135
- const slug = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
119
+ const stamp = startedAt
120
+ .replace(/\.\d+Z$/, "")
121
+ .replace(/[-:]/g, "")
122
+ .replace("T", "-");
123
+ const slug = title
124
+ .toLowerCase()
125
+ .normalize("NFKD")
126
+ .replace(/[^a-z0-9]+/g, "-")
127
+ .replace(/^-|-$/g, "")
128
+ .slice(0, 60);
136
129
  return `${stamp}${slug ? `-${slug}` : ""}.jsonl`;
137
130
  }
138
131
 
@@ -144,25 +137,17 @@ export async function saveRunCopy(root: string, text: string): Promise<void> {
144
137
  // The run the dashboard shows. A start line it can't read is no run.
145
138
  const [start] = latestRun(parseProgress(text).events);
146
139
  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);
140
+ const lines = text
141
+ .split("\n")
142
+ .slice(start.line - 1)
143
+ .filter((line) => line.trim());
144
+ // The saved-runs folder in the real worktree, made one step at a time. None
145
+ // when any step is a link or a file: a cloned repo can commit a link that
146
+ // leads out of it.
147
+ const directory = await makeFoldersIn(await realpath(root), folder);
149
148
  if (!directory) return;
150
149
  // 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;
164
- }
165
- return directory;
150
+ await writeFileIn(directory, runFileName(start.event.ts, start.event.title), `${lines.join("\n")}\n`, { atomic: true });
166
151
  }
167
152
 
168
153
  // The folders directly inside one folder of the worktree ("" is the top), for
@@ -175,5 +160,5 @@ export async function listFolders(root: string, folder: string): Promise<{ folde
175
160
  throw error;
176
161
  });
177
162
  const folders = entries.filter((entry) => entry.isDirectory() && !inGitFolder(entry.name)).map((entry) => entry.name);
178
- return { folder: checked.folder, folders: folders.sort((a, b) => a.localeCompare(b)) };
163
+ return { folder: checked.folder, folders: folders.toSorted((a, b) => a.localeCompare(b)) };
179
164
  }
@@ -0,0 +1,113 @@
1
+ // The only module in Progress that starts programs or changes files. The lint
2
+ // config allows node:child_process and the fs functions that change files here
3
+ // and in tests, nowhere else, so each new call gets these defaults.
4
+ //
5
+ // A program starts in LAUNCH_FOLDER unless the caller names a folder it has a
6
+ // reason for. Windows looks for a program in the folder it starts in before
7
+ // PATH, and a relative PATH entry resolves against that folder, so a bare name
8
+ // started in a worktree could run a program the repo committed.
9
+ //
10
+ // A file change names a folder the caller trusts (the plugin's state folder, a
11
+ // folder in the user's home, a worktree's real path) and a relative name
12
+ // inside it. The name can't climb out with `..`, and no folder between the two
13
+ // may be a link, which a cloned repo could commit to lead the change elsewhere.
14
+ import { exec, execFile } from "node:child_process";
15
+ import { randomUUID } from "node:crypto";
16
+ import { appendFile, chmod, type FileHandle, lstat, mkdir, open, rename, rm, symlink, unlink, writeFile } from "node:fs/promises";
17
+ import { dirname, isAbsolute, join, sep } from "node:path";
18
+ import { promisify } from "node:util";
19
+
20
+ export const LAUNCH_FOLDER = dirname(process.execPath);
21
+
22
+ export type RunOptions = { cwd?: string; timeout?: number };
23
+
24
+ export function runProgram(program: string, args: readonly string[], { cwd = LAUNCH_FOLDER, ...options }: RunOptions = {}) {
25
+ return promisify(execFile)(program, args, { ...options, cwd, encoding: "utf8" });
26
+ }
27
+
28
+ // Through cmd.exe, for Windows .cmd programs, which Node can start only that way.
29
+ export function runCommandLine(line: string, { cwd = LAUNCH_FOLDER, ...options }: RunOptions = {}) {
30
+ return promisify(exec)(line, { ...options, cwd, encoding: "utf8" });
31
+ }
32
+
33
+ export function openToRead(path: string): Promise<FileHandle> {
34
+ return open(path, "r");
35
+ }
36
+
37
+ function stepsOf(folder: string, name: string): string[] {
38
+ if (isAbsolute(name) || name.split(/[\\/]/).includes("..")) throw new Error(`${name} is not a name inside ${folder}.`);
39
+ return name.split(sep);
40
+ }
41
+
42
+ async function placeIn(folder: string, name: string): Promise<string> {
43
+ const steps = stepsOf(folder, name);
44
+ let step = folder;
45
+ for (const part of steps.slice(0, -1)) {
46
+ step = join(step, part);
47
+ if ((await lstat(step).catch(() => null))?.isSymbolicLink()) throw new Error(`${step} is a link, so nothing was written through it.`);
48
+ }
49
+ return join(folder, name);
50
+ }
51
+
52
+ // `atomic` writes beside the file, then renames over it: readers see the old
53
+ // file or the new one, never half of one, and a link at the file is replaced
54
+ // instead of written through.
55
+ export async function writeFileIn(folder: string, name: string, text: string, { atomic = false, executable = false } = {}): Promise<void> {
56
+ const path = await placeIn(folder, name);
57
+ await mkdir(dirname(path), { recursive: true });
58
+ if (!atomic) {
59
+ await writeFile(path, text);
60
+ } else {
61
+ const temporary = `${path}.${randomUUID()}.tmp`;
62
+ try {
63
+ await writeFile(temporary, text);
64
+ await rename(temporary, path);
65
+ } finally {
66
+ await rm(temporary, { force: true });
67
+ }
68
+ }
69
+ if (executable) await chmod(path, 0o755);
70
+ }
71
+
72
+ export async function appendFileIn(folder: string, name: string, text: string): Promise<void> {
73
+ await appendFile(await placeIn(folder, name), text);
74
+ }
75
+
76
+ // The trusted folder itself, with any missing parents.
77
+ export async function makeFolder(folder: string): Promise<void> {
78
+ await mkdir(folder, { recursive: true });
79
+ }
80
+
81
+ // Fails with EEXIST when the folder is already there, so only one caller makes it.
82
+ export async function createFolderIn(folder: string, name: string): Promise<void> {
83
+ await mkdir(await placeIn(folder, name));
84
+ }
85
+
86
+ // Makes each missing folder of `name` one at a time. Null when any of them is
87
+ // a link or a file.
88
+ export async function makeFoldersIn(folder: string, name: string): Promise<string | null> {
89
+ let directory = folder;
90
+ for (const part of stepsOf(folder, name)) {
91
+ directory = join(directory, part);
92
+ const info = await lstat(directory).catch(() => null);
93
+ if (!info) await mkdir(directory);
94
+ else if (!info.isDirectory()) return null;
95
+ }
96
+ return directory;
97
+ }
98
+
99
+ export async function removeIn(folder: string, name: string, { recursive = false } = {}): Promise<void> {
100
+ await rm(await placeIn(folder, name), { recursive, force: true });
101
+ }
102
+
103
+ // A junction on Windows, where a folder symlink needs admin rights or
104
+ // Developer Mode. Other systems ignore the type and make a symlink.
105
+ export async function linkFolderIn(folder: string, name: string, target: string): Promise<void> {
106
+ const path = await placeIn(folder, name);
107
+ await mkdir(dirname(path), { recursive: true });
108
+ await symlink(target, path, "junction");
109
+ }
110
+
111
+ export async function removeLinkIn(folder: string, name: string): Promise<void> {
112
+ await unlink(await placeIn(folder, name));
113
+ }