pi-pstack-adapter 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { MANIFEST_RELATIVE_PATH } from "./manifest.ts";
6
+ import { cacheRoot, checkoutDir, stateFile, validateContainedPath } from "./paths.ts";
7
+ import type { AdapterConfig } from "./config.ts";
8
+ import { selectionOf, trustOf } from "./config.ts";
9
+ import type { ResolvedSource } from "./types.ts";
10
+
11
+ const run = promisify(execFile);
12
+
13
+ export interface GitResult {
14
+ readonly code: number;
15
+ readonly stdout: string;
16
+ readonly stderr: string;
17
+ }
18
+
19
+ export type GitRunner = (args: readonly string[], cwd?: string) => Promise<GitResult>;
20
+
21
+ export const systemGit: GitRunner = async (args, cwd) => {
22
+ try {
23
+ const { stdout, stderr } = await run("git", [...args], { cwd, maxBuffer: 32 * 1024 * 1024 });
24
+ return { code: 0, stdout, stderr };
25
+ } catch (error) {
26
+ const failure = error as { code?: number; stdout?: string; stderr?: string; message: string };
27
+ return { code: failure.code ?? 1, stdout: failure.stdout ?? "", stderr: failure.stderr ?? failure.message };
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Git treats some remote strings as executable transports (`ext::<shell command>`), and treats a
33
+ * leading dash as an option. The adapter accepts a repository from model-callable configuration,
34
+ * so both are rejected before any argv reaches git.
35
+ */
36
+ const ALLOWED_REPO = /^(https:\/\/|ssh:\/\/|git@[^\s@]+:|\/|\.\/|\.\.\/)[^\s]*$/;
37
+ /**
38
+ * Windows filesystem paths: a drive letter (`C:\x`, `C:/x`) or a `.\`/`..\` relative path.
39
+ * Git reads `C:/x` as an scp-style host on POSIX, so these are accepted only on win32. UNC
40
+ * paths (`\\host\share`) stay refused because opening one can send Windows credentials to a
41
+ * remote SMB host.
42
+ */
43
+ const ALLOWED_WINDOWS_REPO = /^([A-Za-z]:[\\/]|\.\\|\.\.\\)[^\s<>:"|?*]*$/;
44
+ const RESERVED_WINDOWS_NAME = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])(\..*)?$/i;
45
+
46
+ /**
47
+ * Git for Windows treats a drive path as local only when Windows accepts every component.
48
+ * Otherwise it falls back to scp-style parsing and opens an ssh connection to a host named by
49
+ * the drive letter (`C:/x::y`, `C:/x/aux`, `C:/x./y` all do). Mirrors Git's `is_valid_win32_path`.
50
+ */
51
+ function isValidWindowsPath(value: string): boolean {
52
+ return value
53
+ .split(/[\\/]/)
54
+ .slice(1)
55
+ .every(
56
+ (part) =>
57
+ part === "" ||
58
+ part === "." ||
59
+ part === ".." ||
60
+ (!/[\u0000-\u001f]/.test(part) && !/[. ]$/.test(part) && !RESERVED_WINDOWS_NAME.test(part)),
61
+ );
62
+ }
63
+ const ALLOWED_REF = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
64
+
65
+ export function validateRepo(repo: string, platform: NodeJS.Platform = process.platform): string {
66
+ const value = repo.trim();
67
+ const windows = platform === "win32";
68
+ const unc = windows && /^[\\/]{2}/.test(value);
69
+ const windowsPath = windows && ALLOWED_WINDOWS_REPO.test(value) && isValidWindowsPath(value);
70
+ const allowed = !unc && (ALLOWED_REPO.test(value) || windowsPath);
71
+ if (!allowed) {
72
+ throw new Error(
73
+ `Refusing the upstream repository ${JSON.stringify(repo)}. Use an https, ssh, scp-style, or filesystem path. Cleartext transports and transports such as \`ext::\` are never accepted.`,
74
+ );
75
+ }
76
+ if (value.startsWith("https://") && value.slice(8).split("/")[0]?.includes("@")) {
77
+ throw new Error(
78
+ `Refusing the upstream repository ${JSON.stringify(repo)} because it embeds credentials in the URL. Use a credential helper or an ssh remote.`,
79
+ );
80
+ }
81
+ return value;
82
+ }
83
+
84
+
85
+ export function validateRef(ref: string): string {
86
+ const value = ref.trim();
87
+ if (!ALLOWED_REF.test(value) || value.includes("..")) {
88
+ throw new Error(
89
+ `Refusing the upstream ref ${JSON.stringify(ref)}. Use a branch, tag, or commit made of letters, digits, dot, underscore, slash, and hyphen.`,
90
+ );
91
+ }
92
+ return value;
93
+ }
94
+
95
+ export class BootstrapRequiredError extends Error {
96
+ readonly repo: string;
97
+ readonly ref: string;
98
+
99
+ constructor(repo: string, ref: string) {
100
+ super(
101
+ `The pstack adapter has no cached checkout of ${repo}@${ref} and this run may not reach the network. ` +
102
+ `Set PISTACK_ALLOW_BOOTSTRAP=1, or run an interactive Pi session once to populate the cache.`,
103
+ );
104
+ this.name = "BootstrapRequiredError";
105
+ this.repo = repo;
106
+ this.ref = ref;
107
+ }
108
+ }
109
+
110
+ export interface ResolveOptions {
111
+ readonly config: AdapterConfig;
112
+ readonly git?: GitRunner;
113
+ /** True when the adapter may reach the network without further permission. */
114
+ readonly mayBootstrap: boolean;
115
+ }
116
+
117
+ export async function resolveSource(options: ResolveOptions): Promise<ResolvedSource> {
118
+ const { config } = options;
119
+ const git = options.git ?? systemGit;
120
+ const selection = selectionOf(config);
121
+ const trust = trustOf(config);
122
+
123
+ if (selection.kind === "local") {
124
+ const checkout = resolve(selection.path);
125
+ const pluginDir = locatePluginDir(checkout, config.pluginPath);
126
+ const head = await git(["rev-parse", "HEAD"], checkout);
127
+ return { selection, trust, commit: head.code === 0 ? head.stdout.trim() : "", checkoutDir: checkout, pluginDir };
128
+ }
129
+
130
+ const repo = validateRepo(config.repo);
131
+ const wanted = validateRef(selection.ref);
132
+ const pluginPath = validateContainedPath(config.pluginPath, "the upstream plugin path");
133
+ const root = cacheRoot(config.cacheDir);
134
+ const dir = checkoutDir(root, repo);
135
+
136
+ // A branch or tag name does not survive `fetch FETCH_HEAD`, so reuse the commit that the
137
+ // last successful bootstrap of this exact repo and ref resolved to. Without this a moving
138
+ // ref would refetch on every startup and silently advance.
139
+ const recorded = readRecordedCommit(root, repo, wanted);
140
+ for (const candidate of [wanted, recorded]) {
141
+ if (!candidate) continue;
142
+ const warm = await reuseCheckout(git, dir, candidate, pluginPath);
143
+ if (warm) {
144
+ recordCommit(root, repo, wanted, warm.commit);
145
+ return { selection, trust, commit: warm.commit, checkoutDir: dir, pluginDir: warm.pluginDir };
146
+ }
147
+ }
148
+
149
+ if (!options.mayBootstrap) throw new BootstrapRequiredError(repo, wanted);
150
+
151
+ await bootstrap(git, dir, repo, wanted, pluginPath);
152
+ const pluginDir = locatePluginDir(dir, pluginPath);
153
+ const commit = await headCommit(git, dir);
154
+ recordCommit(root, repo, wanted, commit);
155
+ return { selection, trust, commit, checkoutDir: dir, pluginDir };
156
+ }
157
+
158
+ type CommitRecord = Record<string, string>;
159
+
160
+ function recordKey(repo: string, ref: string): string {
161
+ return `${repo}#${ref}`;
162
+ }
163
+
164
+ function readRecordedCommit(root: string, repo: string, ref: string): string | undefined {
165
+ if (/^[0-9a-f]{40}$/i.test(ref)) return undefined;
166
+ try {
167
+ const parsed = JSON.parse(readFileSync(stateFile(root), "utf8")) as CommitRecord;
168
+ const commit = parsed[recordKey(repo, ref)];
169
+ return typeof commit === "string" && /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined;
170
+ } catch {
171
+ return undefined;
172
+ }
173
+ }
174
+
175
+ function recordCommit(root: string, repo: string, ref: string, commit: string): void {
176
+ if (!/^[0-9a-f]{40}$/i.test(commit) || /^[0-9a-f]{40}$/i.test(ref)) return;
177
+ let current: CommitRecord = {};
178
+ try {
179
+ current = JSON.parse(readFileSync(stateFile(root), "utf8")) as CommitRecord;
180
+ } catch {
181
+ current = {};
182
+ }
183
+ if (current[recordKey(repo, ref)] === commit) return;
184
+ try {
185
+ mkdirSync(root, { recursive: true });
186
+ writeFileSync(stateFile(root), `${JSON.stringify({ ...current, [recordKey(repo, ref)]: commit }, null, 2)}\n`, "utf8");
187
+ } catch {
188
+ // A read-only cache still works; it just refetches. Never fail startup over bookkeeping.
189
+ }
190
+ }
191
+
192
+ /**
193
+ * A cached commit object is not a usable checkout. An interrupted bootstrap can leave the
194
+ * object present and the worktree empty, so the manifest has to resolve before the cache counts
195
+ * as warm. Otherwise the caller falls through to a forced re-bootstrap instead of wedging.
196
+ */
197
+ async function reuseCheckout(
198
+ git: GitRunner,
199
+ dir: string,
200
+ wanted: string,
201
+ pluginPath: string,
202
+ ): Promise<{ commit: string; pluginDir: string } | undefined> {
203
+ if (!existsSync(join(dir, ".git"))) return undefined;
204
+ if ((await git(["cat-file", "-e", `${wanted}^{commit}`], dir)).code !== 0) return undefined;
205
+
206
+ // A read-only cache cannot take the setting. That is fine while its files are already LF, and
207
+ // the line-ending check below refuses it otherwise.
208
+ const pinError = await pinLineEndings(git, dir).then(
209
+ () => undefined,
210
+ (error: Error) => error,
211
+ );
212
+
213
+ // Skipping a no-op checkout avoids contending on .git/index.lock with a concurrent session.
214
+ const head = await headCommit(git, dir);
215
+ if (head.toLowerCase() !== wanted.toLowerCase() || !existsSync(join(dir, pluginPath))) {
216
+ if ((await git(["checkout", "--quiet", "--force", wanted], dir)).code !== 0) return undefined;
217
+ }
218
+
219
+ const commit = await headCommit(git, dir);
220
+ if (/^[0-9a-f]{40}$/i.test(wanted) && commit.toLowerCase() !== wanted.toLowerCase()) return undefined;
221
+ let pluginDir: string;
222
+ try {
223
+ pluginDir = locatePluginDir(dir, pluginPath);
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ await ensureUpstreamBytes(git, dir, pluginPath, pinError);
228
+ return { commit, pluginDir };
229
+ }
230
+
231
+ /**
232
+ * A cache checked out before line endings were pinned can hold CRLF copies of LF files. Git
233
+ * skips files whose index entries look clean, even with `--force`, so the repair drops those
234
+ * entries and checks the files out again. Each file is rewritten in place, so a concurrent
235
+ * reader never sees the plugin tree disappear. A repair interrupted between the two steps leaves
236
+ * the index behind HEAD, which the next start detects and finishes. When the cache cannot be
237
+ * verified or repaired, this throws rather than serve altered upstream bytes.
238
+ */
239
+ async function ensureUpstreamBytes(
240
+ git: GitRunner,
241
+ dir: string,
242
+ pluginPath: string,
243
+ pinError: Error | undefined,
244
+ ): Promise<void> {
245
+ const damaged = await filesNeedingRepair(git, dir, pluginPath);
246
+ if (damaged.length === 0) return;
247
+ let repairError = pinError?.message;
248
+ if (!pinError) {
249
+ const untracked = await git(["--literal-pathspecs", "rm", "-r", "--cached", "--quiet", "--ignore-unmatch", "--", ...damaged], dir);
250
+ const restored = untracked.code === 0
251
+ ? await git(["--literal-pathspecs", "checkout", "--quiet", "--force", "HEAD", "--", ...damaged], dir)
252
+ : untracked;
253
+ if (restored.code !== 0) repairError = restored.stderr.trim() || `git exited ${restored.code}`;
254
+ }
255
+ const remaining = await filesNeedingRepair(git, dir, pluginPath);
256
+ if (remaining.length === 0) return;
257
+ throw new Error(
258
+ `The pstack adapter cache at ${dir} holds upstream files with converted line endings, such as ${remaining[0]}, and could not repair them` +
259
+ `${repairError ? `: ${repairError}` : "."} Make the cache writable, or delete ${dir} and start Pi again to download a fresh copy.`,
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Plugin files that are not upstream's bytes: a CRLF working copy of an LF blob that upstream
265
+ * did not mark `eol=crlf`, or an index entry that differs from HEAD after an interrupted repair.
266
+ */
267
+ async function filesNeedingRepair(git: GitRunner, dir: string, pluginPath: string): Promise<string[]> {
268
+ const listing = await scan(git, dir, ["--literal-pathspecs", "ls-files", "-z", "--eol", "--", pluginPath]);
269
+ const converted = listing
270
+ .map((entry) => entry.match(/^i\/(\S*)\s+w\/(\S*)\s+attr\/([^\t]*)\t(.+)$/s))
271
+ .filter((match): match is RegExpMatchArray => match !== null)
272
+ .filter(([, index, worktree, attr]) => index === "lf" && worktree !== "lf" && worktree !== "" && !/eol=crlf/.test(attr ?? ""))
273
+ .map(([, , , , file]) => file!);
274
+ const unstaged = await scan(git, dir, ["--literal-pathspecs", "diff", "--cached", "--name-only", "-z", "HEAD", "--", pluginPath]);
275
+ return [...new Set([...converted, ...unstaged])].sort();
276
+ }
277
+
278
+ async function scan(git: GitRunner, dir: string, args: readonly string[]): Promise<string[]> {
279
+ const result = await git(args, dir);
280
+ if (result.code !== 0) {
281
+ throw new Error(
282
+ `The pstack adapter could not verify the line endings of its cache at ${dir}: ${result.stderr.trim() || `git exited ${result.code}`}. ` +
283
+ `Close other Pi sessions and start Pi again, or delete ${dir} to download a fresh copy.`,
284
+ );
285
+ }
286
+ return result.stdout.split("\0").filter((entry) => entry !== "");
287
+ }
288
+
289
+ /**
290
+ * Upstream files must reach generation byte for byte. Git for Windows installs with a system-wide
291
+ * `core.autocrlf=true`, which would rewrite every text file to CRLF, so the cache repository
292
+ * overrides it locally. An upstream `.gitattributes` `eol` setting still applies, because that
293
+ * is upstream's own choice.
294
+ */
295
+ async function pinLineEndings(git: GitRunner, dir: string): Promise<void> {
296
+ for (const [key, value] of [["core.autocrlf", "false"], ["core.eol", "lf"]] as const) {
297
+ const current = await git(["config", "--local", "--get", key], dir);
298
+ if (current.code === 0 && current.stdout.trim() === value) continue;
299
+ await expect(git(["config", "--local", key, value], dir), `set ${key} in the adapter cache checkout`);
300
+ }
301
+ }
302
+
303
+ async function headCommit(git: GitRunner, dir: string): Promise<string> {
304
+ const result = await git(["rev-parse", "HEAD"], dir);
305
+ return result.code === 0 ? result.stdout.trim() : "";
306
+ }
307
+
308
+ async function bootstrap(git: GitRunner, dir: string, repo: string, ref: string, pluginPath: string): Promise<void> {
309
+ mkdirSync(dir, { recursive: true });
310
+ if ((await git(["rev-parse", "--git-dir"], dir)).code === 0) {
311
+ await git(["remote", "set-url", "origin", repo], dir);
312
+ } else {
313
+ // A half-created or damaged cache is disposable. Start it over rather than repairing it.
314
+ rmSync(dir, { recursive: true, force: true });
315
+ mkdirSync(dir, { recursive: true });
316
+ await expect(git(["init", "--quiet", "."], dir), "initialize the adapter cache checkout");
317
+ await expect(git(["remote", "add", "origin", repo], dir), `add ${repo} as the cache remote`);
318
+ }
319
+ await pinLineEndings(git, dir);
320
+ await expect(git(["config", "core.sparseCheckout", "true"], dir), "enable sparse checkout");
321
+ mkdirSync(join(dir, ".git", "info"), { recursive: true });
322
+ writeFileSync(join(dir, ".git", "info", "sparse-checkout"), `/${pluginPath}/\n`, "utf8");
323
+ await expect(
324
+ git(["fetch", "--depth", "1", "--filter=blob:none", "origin", "--end-of-options", ref], dir),
325
+ `fetch ${repo}@${ref}`,
326
+ );
327
+ await expect(git(["checkout", "--quiet", "--force", "FETCH_HEAD"], dir), `check out ${ref}`);
328
+ }
329
+
330
+ async function expect(result: Promise<GitResult>, what: string): Promise<GitResult> {
331
+ const settled = await result;
332
+ if (settled.code !== 0) {
333
+ throw new Error(`The pstack adapter could not ${what}: ${settled.stderr.trim() || `git exited ${settled.code}`}`);
334
+ }
335
+ return settled;
336
+ }
337
+
338
+ function locatePluginDir(checkout: string, pluginPath: string): string {
339
+ const nested = join(checkout, pluginPath);
340
+ if (existsSync(join(nested, MANIFEST_RELATIVE_PATH))) return nested;
341
+ if (existsSync(join(checkout, MANIFEST_RELATIVE_PATH))) return checkout;
342
+ throw new Error(
343
+ `No pstack manifest under ${checkout}. Expected ${join(pluginPath, MANIFEST_RELATIVE_PATH)} or ${MANIFEST_RELATIVE_PATH}.`,
344
+ );
345
+ }
346
+
347
+ /** Resolve a branch or tag to the commit it currently points at. Used only by explicit setup. */
348
+ export async function resolveRefToCommit(repo: string, ref: string, git: GitRunner = systemGit): Promise<string> {
349
+ const safeRepo = validateRepo(repo);
350
+ const safeRef = validateRef(ref);
351
+ if (/^[0-9a-f]{40}$/i.test(safeRef)) return safeRef.toLowerCase();
352
+ const result = await git(["ls-remote", "--end-of-options", safeRepo, safeRef]);
353
+ if (result.code !== 0) {
354
+ throw new Error(`Could not reach ${safeRepo} to resolve ${safeRef}: ${result.stderr.trim()}`);
355
+ }
356
+ const line = result.stdout.split("\n").find((entry) => entry.trim() !== "");
357
+ const commit = line?.split(/\s+/)[0];
358
+ if (!commit || !/^[0-9a-f]{40}$/i.test(commit)) {
359
+ throw new Error(`${safeRepo} has no ref named ${safeRef}.`);
360
+ }
361
+ return commit.toLowerCase();
362
+ }