pi-microsandbox 0.1.1 → 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.
- package/README.md +72 -57
- package/SECURITY.md +12 -8
- package/docs/commands.md +18 -14
- package/docs/configuration.md +49 -11
- package/docs/development.md +52 -10
- package/docs/getting-started.md +25 -8
- package/docs/images.md +18 -9
- package/docs/safety.md +43 -14
- package/docs/storage.md +63 -50
- package/docs/troubleshooting.md +54 -9
- package/extensions/pi-msb/command.ts +17 -189
- package/extensions/pi-msb/config.ts +191 -44
- package/extensions/pi-msb/control.ts +134 -302
- package/extensions/pi-msb/index.ts +2 -2
- package/extensions/pi-msb/labels.ts +46 -231
- package/extensions/pi-msb/locks.ts +4 -6
- package/extensions/pi-msb/prune.ts +16 -12
- package/extensions/pi-msb/sandbox-manager.ts +93 -143
- package/extensions/pi-msb/tools.ts +13 -26
- package/extensions/pi-msb/transport.ts +0 -18
- package/extensions/pi-msb/types.ts +42 -108
- package/extensions/pi-msb/workspace.ts +126 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
- package/extensions/pi-msb/git.ts +0 -256
- package/extensions/pi-msb/storage.ts +0 -332
|
@@ -12,8 +12,8 @@ import type {
|
|
|
12
12
|
|
|
13
13
|
// --------------------------------------------------------------- identity ----
|
|
14
14
|
|
|
15
|
-
export const STATE_SCHEMA_VERSION =
|
|
16
|
-
export const LOCKFILE_VERSION =
|
|
15
|
+
export const STATE_SCHEMA_VERSION = 2;
|
|
16
|
+
export const LOCKFILE_VERSION = 2;
|
|
17
17
|
|
|
18
18
|
export function resourceId(sessionId: string): string {
|
|
19
19
|
return createHash("sha256").update(sessionId).digest("hex").slice(0, 20);
|
|
@@ -24,20 +24,18 @@ export function displayId(sessionId: string): string {
|
|
|
24
24
|
export function sandboxNameFor(sessionId: string): string {
|
|
25
25
|
return `pi-msb-${resourceId(sessionId)}`;
|
|
26
26
|
}
|
|
27
|
-
export function volumeNameFor(sessionId: string): string {
|
|
28
|
-
return `pi-msb-vol-${resourceId(sessionId)}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
27
|
// ---------------------------------------------------------------- config ----
|
|
32
|
-
|
|
33
|
-
export type StorageMode = "git" | "direct" | "none";
|
|
34
|
-
export type ConfigStorageMode = "auto" | StorageMode;
|
|
35
28
|
export type NetworkMode = "default" | "open" | "allowlist" | "deny";
|
|
36
29
|
export type FallbackMode = "block" | "host";
|
|
37
30
|
export type BootstrapTools = "auto" | boolean;
|
|
38
31
|
export type PullPolicy = "always" | "if-missing" | "never";
|
|
39
32
|
export type MountType = "dir" | "file" | "named" | "tmpfs";
|
|
33
|
+
export type DockerMode = "auto" | "require" | "disabled";
|
|
40
34
|
|
|
35
|
+
export interface DockerConfig {
|
|
36
|
+
mode: DockerMode;
|
|
37
|
+
startupTimeoutMs: number;
|
|
38
|
+
}
|
|
41
39
|
export interface NetworkConfig {
|
|
42
40
|
mode: NetworkMode;
|
|
43
41
|
allowHosts: string[];
|
|
@@ -68,12 +66,8 @@ export interface Config {
|
|
|
68
66
|
replace: boolean;
|
|
69
67
|
replaceTimeoutMs: number;
|
|
70
68
|
sandboxName: string | null;
|
|
71
|
-
mode: ConfigStorageMode;
|
|
72
|
-
cloneBranch: "current" | string;
|
|
73
|
-
cloneDepth: number | "unlimited";
|
|
74
|
-
shallowArchive: boolean;
|
|
75
|
-
volumeQuotaMiB: number;
|
|
76
69
|
network: NetworkConfig;
|
|
70
|
+
docker: DockerConfig;
|
|
77
71
|
secrets: SecretConfig[];
|
|
78
72
|
mounts: MountConfig[];
|
|
79
73
|
blockThirdParty: boolean;
|
|
@@ -131,31 +125,21 @@ export const LABEL_KEYS = {
|
|
|
131
125
|
managed: "pi-msb.managed",
|
|
132
126
|
schema: "pi-msb.schema",
|
|
133
127
|
session: "pi-msb.session",
|
|
134
|
-
mode: "pi-msb.mode",
|
|
135
128
|
cwd: "pi-msb.cwd",
|
|
129
|
+
root: "pi-msb.root",
|
|
130
|
+
guestRoot: "pi-msb.guest-root",
|
|
136
131
|
pid: "pi-msb.pid",
|
|
137
|
-
volume: "pi-msb.volume",
|
|
138
132
|
image: "pi-msb.image",
|
|
139
133
|
keep: "pi-msb.keep",
|
|
140
|
-
seedBranch: "pi-msb.seed-branch",
|
|
141
|
-
seedSha: "pi-msb.seed-sha",
|
|
142
134
|
} as const;
|
|
143
135
|
|
|
144
136
|
export interface SandboxLabelInput {
|
|
145
137
|
sessionId: string;
|
|
146
|
-
mode: StorageMode;
|
|
147
138
|
cwd: string;
|
|
139
|
+
root: string;
|
|
140
|
+
guestRoot: string;
|
|
148
141
|
pid: number;
|
|
149
142
|
image: string;
|
|
150
|
-
volumeName?: string;
|
|
151
|
-
seedBranch?: string | null;
|
|
152
|
-
seedSha?: string | null;
|
|
153
|
-
}
|
|
154
|
-
export interface VolumeLabelInput {
|
|
155
|
-
sessionId: string;
|
|
156
|
-
cwd: string;
|
|
157
|
-
seedBranch?: string | null;
|
|
158
|
-
seedSha?: string | null;
|
|
159
143
|
}
|
|
160
144
|
export interface ManagedSandboxRecord {
|
|
161
145
|
name: string;
|
|
@@ -165,29 +149,18 @@ export interface ManagedSandboxRecord {
|
|
|
165
149
|
}
|
|
166
150
|
export interface ValidatedManagedSandbox extends ManagedSandboxRecord {
|
|
167
151
|
sessionId: string;
|
|
168
|
-
mode: StorageMode;
|
|
169
152
|
cwd: string;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
export interface VolumeRecord {
|
|
173
|
-
name: string;
|
|
174
|
-
hostPath: string;
|
|
175
|
-
labels: Record<string, string>;
|
|
176
|
-
kind?: string;
|
|
177
|
-
usedBytes?: number;
|
|
178
|
-
createdAt?: number;
|
|
153
|
+
root: string;
|
|
154
|
+
guestRoot: string;
|
|
179
155
|
}
|
|
180
156
|
export interface PersistedSandboxState {
|
|
181
157
|
version: typeof STATE_SCHEMA_VERSION;
|
|
182
158
|
sessionId: string;
|
|
183
159
|
sandboxName: string;
|
|
184
|
-
mode: StorageMode;
|
|
185
160
|
cwd: string;
|
|
161
|
+
root: string;
|
|
162
|
+
guestRoot: string;
|
|
186
163
|
image: string;
|
|
187
|
-
volumeName?: string;
|
|
188
|
-
volumeHostPath?: string;
|
|
189
|
-
seedBranch?: string | null;
|
|
190
|
-
seedSha?: string | null;
|
|
191
164
|
enabled: boolean;
|
|
192
165
|
createdAt: number;
|
|
193
166
|
}
|
|
@@ -198,9 +171,8 @@ export interface LockInfo {
|
|
|
198
171
|
version: typeof LOCKFILE_VERSION;
|
|
199
172
|
sessionId: string;
|
|
200
173
|
sandboxName: string;
|
|
201
|
-
volumeName?: string;
|
|
202
|
-
mode: StorageMode;
|
|
203
174
|
cwd: string;
|
|
175
|
+
root: string;
|
|
204
176
|
pid: number;
|
|
205
177
|
createdAt: number;
|
|
206
178
|
}
|
|
@@ -212,52 +184,18 @@ export interface LocksPort {
|
|
|
212
184
|
tryAcquire(sessionId: string): Promise<LockHandle | null>;
|
|
213
185
|
}
|
|
214
186
|
|
|
215
|
-
//
|
|
187
|
+
// -------------------------------------------------------------- workspace ----
|
|
216
188
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
/** Canonical host
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
unborn: boolean;
|
|
228
|
-
isLinkedWorktree: boolean;
|
|
229
|
-
}
|
|
230
|
-
export interface GitSeedBundle {
|
|
231
|
-
hostPath: string;
|
|
232
|
-
branch: string | null;
|
|
233
|
-
headSha: string;
|
|
234
|
-
cleanup(): Promise<void>;
|
|
235
|
-
}
|
|
236
|
-
export interface GitVolumePlan {
|
|
237
|
-
kind: "git-volume";
|
|
238
|
-
sessionId: string;
|
|
239
|
-
volumeName: string;
|
|
240
|
-
volumeQuotaMiB: number;
|
|
241
|
-
repoRoot: string;
|
|
242
|
-
mountGuestPath: string;
|
|
243
|
-
workdir: string;
|
|
244
|
-
branch: string | null;
|
|
245
|
-
headSha: string | null;
|
|
246
|
-
unborn: boolean;
|
|
247
|
-
depth: number | "unlimited";
|
|
248
|
-
seedRequired: boolean;
|
|
249
|
-
}
|
|
250
|
-
export type StoragePlan =
|
|
251
|
-
| GitVolumePlan
|
|
252
|
-
| { kind: "direct-mount"; hostPath: string; guestPath: string; workdir: string }
|
|
253
|
-
| { kind: "none"; guestPath: string; workdir: string };
|
|
254
|
-
export interface PreparedStorage {
|
|
255
|
-
plan: StoragePlan;
|
|
256
|
-
volume?: VolumeRecord;
|
|
257
|
-
bundle?: GitSeedBundle | null;
|
|
258
|
-
createdVolume: boolean;
|
|
259
|
-
}
|
|
260
|
-
export interface SeedResult { headSha: string | null }
|
|
189
|
+
/** One path-preserving read/write bind used by every sandbox. */
|
|
190
|
+
export interface Workspace {
|
|
191
|
+
/** Canonical host directory supplied to the bind mount. */
|
|
192
|
+
hostRoot: string;
|
|
193
|
+
/** Lexical absolute path used as the guest mount point. */
|
|
194
|
+
guestRoot: string;
|
|
195
|
+
/** Original lexical absolute cwd retained as the sandbox workdir. */
|
|
196
|
+
cwd: string;
|
|
197
|
+
fromGit: boolean;
|
|
198
|
+
}
|
|
261
199
|
|
|
262
200
|
// ------------------------------------------------------------- transport ----
|
|
263
201
|
|
|
@@ -300,8 +238,6 @@ export interface SandboxTransport {
|
|
|
300
238
|
exists(path: string): Promise<boolean>;
|
|
301
239
|
stat(path: string): Promise<StatResult>;
|
|
302
240
|
list(path: string): Promise<FsEntry[]>;
|
|
303
|
-
copyFromHost(hostPath: string, guestPath: string): Promise<void>;
|
|
304
|
-
copyToHost(guestPath: string, hostPath: string): Promise<void>;
|
|
305
241
|
exec(command: string, args: string[], options?: ExecOptions): Promise<TransportExecResult>;
|
|
306
242
|
execStream(command: string, args: string[], options?: ExecStreamOptions): Promise<{ exitCode: number }>;
|
|
307
243
|
dispose(): Promise<void>;
|
|
@@ -362,18 +298,26 @@ export type RuntimeStatus =
|
|
|
362
298
|
| "off"
|
|
363
299
|
| "host-fallback"
|
|
364
300
|
| "disabled";
|
|
301
|
+
export type DockerReadiness = "ready" | "missing" | "unavailable" | "disabled";
|
|
302
|
+
export interface DockerCapabilityStatus {
|
|
303
|
+
mode: DockerMode;
|
|
304
|
+
readiness: DockerReadiness;
|
|
305
|
+
version?: string;
|
|
306
|
+
storageDriver?: string;
|
|
307
|
+
reason?: string;
|
|
308
|
+
}
|
|
309
|
+
export interface RuntimePreparation {
|
|
310
|
+
docker: DockerCapabilityStatus;
|
|
311
|
+
}
|
|
365
312
|
export interface SandboxInfo {
|
|
366
313
|
name: string;
|
|
367
314
|
displayId: string;
|
|
368
|
-
mode: StorageMode;
|
|
369
315
|
image: string;
|
|
370
316
|
pid: number;
|
|
371
317
|
cwd: string;
|
|
372
|
-
|
|
373
|
-
volumeHostPath?: string;
|
|
374
|
-
seedBranch?: string | null;
|
|
375
|
-
seedSha?: string | null;
|
|
318
|
+
root: string;
|
|
376
319
|
createdAt: number;
|
|
320
|
+
docker: DockerCapabilityStatus;
|
|
377
321
|
}
|
|
378
322
|
export interface RuntimeState {
|
|
379
323
|
status: RuntimeStatus;
|
|
@@ -383,6 +327,7 @@ export interface RuntimeState {
|
|
|
383
327
|
export interface BootRequest {
|
|
384
328
|
sessionId: string;
|
|
385
329
|
cwd: string;
|
|
330
|
+
workspace: Workspace;
|
|
386
331
|
config: Config;
|
|
387
332
|
restored: PersistedSandboxState | null;
|
|
388
333
|
}
|
|
@@ -411,22 +356,11 @@ export type ExecFn = (
|
|
|
411
356
|
|
|
412
357
|
// ------------------------------------------------------- command/control ----
|
|
413
358
|
|
|
414
|
-
export interface ExportResult { source: string; destination: string }
|
|
415
359
|
export interface MsbControl {
|
|
416
360
|
getState(): RuntimeState;
|
|
417
361
|
setEnabled(enabled: boolean): Promise<void>;
|
|
418
362
|
reload(): Promise<void>;
|
|
419
363
|
pruneNow(): Promise<PruneReport>;
|
|
420
|
-
listVolumes(): Promise<VolumeRecord[]>;
|
|
421
|
-
describeVolume(name: string): Promise<{
|
|
422
|
-
volume: VolumeRecord;
|
|
423
|
-
branch?: string;
|
|
424
|
-
lastCommit?: string;
|
|
425
|
-
dirtyCount?: number;
|
|
426
|
-
mounted: boolean;
|
|
427
|
-
}>;
|
|
428
|
-
removeVolume(name: string): Promise<void>;
|
|
429
|
-
exportPaths(paths: string[], destination?: string): Promise<ExportResult[]>;
|
|
430
364
|
getLogs(tailLines?: number): Promise<string>;
|
|
431
365
|
getEffectiveConfig(): ResolvedConfig;
|
|
432
366
|
getEffectiveConfigToml(): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
import type { ExecFn, Workspace } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
interface ExecFileFailure {
|
|
11
|
+
code?: number | string;
|
|
12
|
+
killed?: boolean;
|
|
13
|
+
signal?: string | null;
|
|
14
|
+
stdout?: string | Buffer;
|
|
15
|
+
stderr?: string | Buffer;
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaultExec: ExecFn = async (command, args, options = {}) => {
|
|
20
|
+
try {
|
|
21
|
+
const result = await execFileAsync(command, args, {
|
|
22
|
+
cwd: options.cwd,
|
|
23
|
+
env: { ...process.env, ...options.env, LC_ALL: "C" },
|
|
24
|
+
timeout: options.timeout === undefined ? undefined : options.timeout * 1000,
|
|
25
|
+
maxBuffer: 1024 * 1024,
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
});
|
|
28
|
+
return { stdout: String(result.stdout), stderr: String(result.stderr), code: 0 };
|
|
29
|
+
} catch (error: unknown) {
|
|
30
|
+
const failure = error as ExecFileFailure;
|
|
31
|
+
return {
|
|
32
|
+
stdout: String(failure.stdout ?? ""),
|
|
33
|
+
stderr: String(failure.stderr ?? failure.message ?? ""),
|
|
34
|
+
code: typeof failure.code === "number" ? failure.code : 127,
|
|
35
|
+
killed: Boolean(failure.killed || failure.signal),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function lexicalRoot(cwd: string, canonicalRoot: string): Promise<string | null> {
|
|
41
|
+
let candidate = cwd;
|
|
42
|
+
while (true) {
|
|
43
|
+
let canonicalCandidate: string;
|
|
44
|
+
try {
|
|
45
|
+
canonicalCandidate = await realpath(candidate);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(`workspace path cannot be canonicalized: ${candidate}`, { cause: error });
|
|
48
|
+
}
|
|
49
|
+
if (canonicalCandidate === canonicalRoot) return candidate;
|
|
50
|
+
const parent = dirname(candidate);
|
|
51
|
+
if (parent === candidate) return null;
|
|
52
|
+
candidate = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isNotRepository(code: number, stderr: string): boolean {
|
|
57
|
+
return code === 128 && /(?:^|\n)fatal: (?:not a git (?:repository|work tree)|this operation must be run in a work tree)(?:\s|$)/i.test(stderr);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PROTECTED_GUEST_PATHS = [
|
|
61
|
+
"/bin",
|
|
62
|
+
"/dev",
|
|
63
|
+
"/etc/ld.so.cache",
|
|
64
|
+
"/etc/ld.so.preload",
|
|
65
|
+
"/lib",
|
|
66
|
+
"/lib64",
|
|
67
|
+
"/proc",
|
|
68
|
+
"/sbin",
|
|
69
|
+
"/sys",
|
|
70
|
+
"/usr",
|
|
71
|
+
"/run",
|
|
72
|
+
"/var/run",
|
|
73
|
+
"/var/lib/docker",
|
|
74
|
+
] as const;
|
|
75
|
+
|
|
76
|
+
function containsPath(parent: string, child: string): boolean {
|
|
77
|
+
const value = relative(parent, child);
|
|
78
|
+
return value === "" || (value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertSafeGuestRoot(root: string): void {
|
|
82
|
+
if (PROTECTED_GUEST_PATHS.some((path) => containsPath(root, path) || containsPath(path, root))) {
|
|
83
|
+
throw new Error(`workspace root cannot safely be mounted at the guest path: ${root}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Discover the single path-preserving project bind for a configure/boot cycle. */
|
|
88
|
+
export async function discoverWorkspace(cwd: string, exec: ExecFn = defaultExec): Promise<Workspace> {
|
|
89
|
+
const lexicalCwd = resolve(cwd);
|
|
90
|
+
let canonicalCwd: string;
|
|
91
|
+
try {
|
|
92
|
+
canonicalCwd = await realpath(lexicalCwd);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(`workspace cwd cannot be canonicalized: ${lexicalCwd}`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = await exec("git", ["-C", lexicalCwd, "rev-parse", "--show-toplevel"]);
|
|
98
|
+
if (result.killed) throw new Error("Git workspace discovery was interrupted");
|
|
99
|
+
if (result.code !== 0) {
|
|
100
|
+
if (isNotRepository(result.code, result.stderr)) {
|
|
101
|
+
assertSafeGuestRoot(lexicalCwd);
|
|
102
|
+
return { hostRoot: canonicalCwd, guestRoot: lexicalCwd, cwd: lexicalCwd, fromGit: false };
|
|
103
|
+
}
|
|
104
|
+
const detail = result.stderr.trim() || `git exited with code ${result.code}`;
|
|
105
|
+
throw new Error(`Git workspace discovery failed: ${detail}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const output = result.stdout.replace(/\r?\n$/, "");
|
|
109
|
+
if (!output || output.includes("\0") || output.includes("\n") || output.includes("\r") || !isAbsolute(output)) {
|
|
110
|
+
throw new Error("Git workspace discovery returned an invalid repository root");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let hostRoot: string;
|
|
114
|
+
try {
|
|
115
|
+
hostRoot = await realpath(output);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new Error(`Git workspace root cannot be canonicalized: ${output}`, { cause: error });
|
|
118
|
+
}
|
|
119
|
+
const guestRoot = await lexicalRoot(lexicalCwd, hostRoot);
|
|
120
|
+
if (guestRoot === null) {
|
|
121
|
+
throw new Error("Git workspace root cannot preserve the cwd guest path; unsupported symlink topology");
|
|
122
|
+
}
|
|
123
|
+
assertSafeGuestRoot(guestRoot);
|
|
124
|
+
|
|
125
|
+
return { hostRoot, guestRoot, cwd: lexicalCwd, fromGit: true };
|
|
126
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
package/extensions/pi-msb/git.ts
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { chmod, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
-
import { pathToFileURL } from "node:url";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
|
|
8
|
-
import type { ExecFn, GitRepoInfo, GitSeedBundle } from "./types.ts";
|
|
9
|
-
|
|
10
|
-
const execFileAsync = promisify(execFile);
|
|
11
|
-
const SEED_REF = "refs/pi-msb/seed";
|
|
12
|
-
|
|
13
|
-
interface ExecFileFailure {
|
|
14
|
-
code?: number | string;
|
|
15
|
-
killed?: boolean;
|
|
16
|
-
signal?: string | null;
|
|
17
|
-
stdout?: string | Buffer;
|
|
18
|
-
stderr?: string | Buffer;
|
|
19
|
-
message?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Execute a program without giving it a shell or interpolating its arguments. */
|
|
23
|
-
const defaultExec: ExecFn = async (command, args, options = {}) => {
|
|
24
|
-
try {
|
|
25
|
-
const result = await execFileAsync(command, args, {
|
|
26
|
-
cwd: options.cwd,
|
|
27
|
-
env: options.env,
|
|
28
|
-
timeout: options.timeout === undefined ? undefined : options.timeout * 1000,
|
|
29
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
30
|
-
encoding: "utf8",
|
|
31
|
-
});
|
|
32
|
-
return {
|
|
33
|
-
stdout: String(result.stdout),
|
|
34
|
-
stderr: String(result.stderr),
|
|
35
|
-
code: 0,
|
|
36
|
-
};
|
|
37
|
-
} catch (error: unknown) {
|
|
38
|
-
const failure = error as ExecFileFailure;
|
|
39
|
-
const numericCode = typeof failure.code === "number" ? failure.code : 127;
|
|
40
|
-
return {
|
|
41
|
-
stdout: String(failure.stdout ?? ""),
|
|
42
|
-
stderr: String(failure.stderr ?? failure.message ?? ""),
|
|
43
|
-
code: numericCode,
|
|
44
|
-
killed: Boolean(failure.killed || failure.signal),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const emptyRepo = (hostCwd?: string): GitRepoInfo => ({
|
|
50
|
-
isGitRepo: false,
|
|
51
|
-
...(hostCwd ? { hostCwd } : {}),
|
|
52
|
-
repoRoot: null,
|
|
53
|
-
branch: null,
|
|
54
|
-
headSha: null,
|
|
55
|
-
unborn: false,
|
|
56
|
-
isLinkedWorktree: false,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
function output(result: Awaited<ReturnType<ExecFn>>): string {
|
|
60
|
-
return result.stdout.trim();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function failureMessage(context: string, result: Awaited<ReturnType<ExecFn>>): Error {
|
|
64
|
-
const detail = result.stderr.trim() || `git exited with code ${result.code}`;
|
|
65
|
-
return new Error(`${context}: ${detail}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function canonicalPath(value: string): Promise<string> {
|
|
69
|
-
try {
|
|
70
|
-
return await realpath(value);
|
|
71
|
-
} catch {
|
|
72
|
-
return resolve(value);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function pathFromGitResult(value: string, base: string): string {
|
|
77
|
-
return isAbsolute(value) ? value : resolve(base, value);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function lexicalRepoRoot(requestedCwd: string, canonicalRepoRoot: string): Promise<string | null> {
|
|
81
|
-
let candidate = requestedCwd;
|
|
82
|
-
while (true) {
|
|
83
|
-
if (await canonicalPath(candidate) === canonicalRepoRoot) return candidate;
|
|
84
|
-
const parent = dirname(candidate);
|
|
85
|
-
if (parent === candidate) return null;
|
|
86
|
-
candidate = parent;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Return the immutable source state of cwd. A directory outside Git is an ordinary
|
|
92
|
-
* result rather than an error, which lets storage mode `auto` remain a direct alias.
|
|
93
|
-
*/
|
|
94
|
-
export async function detectGitRepo(cwd: string, exec: ExecFn = defaultExec): Promise<GitRepoInfo> {
|
|
95
|
-
const requestedCwd = resolve(cwd);
|
|
96
|
-
const hostCwd = await canonicalPath(requestedCwd);
|
|
97
|
-
const topLevel = await exec("git", ["-C", requestedCwd, "rev-parse", "--show-toplevel"]);
|
|
98
|
-
if (topLevel.code !== 0 || topLevel.killed || !output(topLevel)) return emptyRepo(hostCwd);
|
|
99
|
-
|
|
100
|
-
const repoRoot = await canonicalPath(output(topLevel));
|
|
101
|
-
const guestRepoRoot = await lexicalRepoRoot(requestedCwd, repoRoot);
|
|
102
|
-
const branchResult = await exec("git", [
|
|
103
|
-
"-C",
|
|
104
|
-
repoRoot,
|
|
105
|
-
"symbolic-ref",
|
|
106
|
-
"--quiet",
|
|
107
|
-
"--short",
|
|
108
|
-
"HEAD",
|
|
109
|
-
]);
|
|
110
|
-
const branch = branchResult.code === 0 && !branchResult.killed && output(branchResult)
|
|
111
|
-
? output(branchResult)
|
|
112
|
-
: null;
|
|
113
|
-
|
|
114
|
-
const headResult = await exec("git", ["-C", repoRoot, "rev-parse", "--verify", "HEAD"]);
|
|
115
|
-
const headSha = headResult.code === 0 && !headResult.killed && output(headResult)
|
|
116
|
-
? output(headResult)
|
|
117
|
-
: null;
|
|
118
|
-
|
|
119
|
-
// In a linked worktree --git-dir points at .git/worktrees/<name>, while
|
|
120
|
-
// --git-common-dir points at the shared .git directory.
|
|
121
|
-
const gitDirResult = await exec("git", ["-C", repoRoot, "rev-parse", "--git-dir"]);
|
|
122
|
-
const commonDirResult = await exec("git", ["-C", repoRoot, "rev-parse", "--git-common-dir"]);
|
|
123
|
-
let isLinkedWorktree = false;
|
|
124
|
-
if (gitDirResult.code === 0 && commonDirResult.code === 0) {
|
|
125
|
-
const gitDir = await canonicalPath(pathFromGitResult(output(gitDirResult), repoRoot));
|
|
126
|
-
const commonDir = await canonicalPath(pathFromGitResult(output(commonDirResult), repoRoot));
|
|
127
|
-
isLinkedWorktree = gitDir !== commonDir;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
isGitRepo: true,
|
|
132
|
-
hostCwd,
|
|
133
|
-
repoRoot,
|
|
134
|
-
guestRepoRoot,
|
|
135
|
-
branch,
|
|
136
|
-
headSha,
|
|
137
|
-
unborn: headSha === null && branch !== null,
|
|
138
|
-
isLinkedWorktree,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function bundleFailure(context: string, result: Awaited<ReturnType<ExecFn>>): never {
|
|
143
|
-
throw failureMessage(context, result);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function checkedGit(
|
|
147
|
-
exec: ExecFn,
|
|
148
|
-
args: string[],
|
|
149
|
-
context: string,
|
|
150
|
-
): Promise<Awaited<ReturnType<ExecFn>>> {
|
|
151
|
-
const result = await exec("git", args);
|
|
152
|
-
if (result.code !== 0 || result.killed) bundleFailure(context, result);
|
|
153
|
-
return result;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Make a disposable bundle from committed objects only. The temporary bare clone
|
|
158
|
-
* deliberately has no worktree, so ignored, modified, and untracked files cannot
|
|
159
|
-
* enter the seed.
|
|
160
|
-
*/
|
|
161
|
-
export async function createSeedBundle(
|
|
162
|
-
info: GitRepoInfo,
|
|
163
|
-
options: {
|
|
164
|
-
branch: "current" | string;
|
|
165
|
-
depth: number | "unlimited";
|
|
166
|
-
tempRoot?: string;
|
|
167
|
-
exec?: ExecFn;
|
|
168
|
-
},
|
|
169
|
-
): Promise<GitSeedBundle | null> {
|
|
170
|
-
if (!info.isGitRepo || info.unborn || !info.repoRoot || !info.headSha) return null;
|
|
171
|
-
if (options.depth !== "unlimited" && (!Number.isInteger(options.depth) || options.depth < 1)) {
|
|
172
|
-
throw new Error(`invalid Git bundle depth: ${String(options.depth)}`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const exec = options.exec ?? defaultExec;
|
|
176
|
-
const parent = resolve(options.tempRoot ?? tmpdir());
|
|
177
|
-
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
178
|
-
const tempDir = await mkdtemp(join(parent, "pi-msb-git-"));
|
|
179
|
-
await chmod(tempDir, 0o700);
|
|
180
|
-
let cleaned = false;
|
|
181
|
-
const cleanup = async (): Promise<void> => {
|
|
182
|
-
if (cleaned) return;
|
|
183
|
-
cleaned = true;
|
|
184
|
-
await rm(tempDir, { recursive: true, force: true });
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
const bareName = "source.git";
|
|
189
|
-
const barePath = join(tempDir, bareName);
|
|
190
|
-
const bundlePath = join(tempDir, "seed.bundle");
|
|
191
|
-
const source = fileUrl(info.repoRoot);
|
|
192
|
-
const depthArgs = options.depth === "unlimited" ? [] : ["--depth", String(options.depth)];
|
|
193
|
-
|
|
194
|
-
// A file:// URL forces Git's local transport while keeping the source repo
|
|
195
|
-
// read-only. --bare means no checkout, filters, or worktree are involved.
|
|
196
|
-
await checkedGit(
|
|
197
|
-
exec,
|
|
198
|
-
["-C", tempDir, "clone", "--bare", "--no-tags", ...depthArgs, source, bareName],
|
|
199
|
-
"creating temporary bare clone",
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
const objectCheck = await exec("git", ["-C", barePath, "cat-file", "-e", `${info.headSha}^{commit}`]);
|
|
203
|
-
if (objectCheck.code !== 0 || objectCheck.killed) {
|
|
204
|
-
// A branch may have moved between detection and clone. Fetch the captured
|
|
205
|
-
// object explicitly; never fall back to whatever HEAD the clone obtained.
|
|
206
|
-
await checkedGit(
|
|
207
|
-
exec,
|
|
208
|
-
["-C", barePath, "fetch", "--no-tags", ...depthArgs, source, info.headSha],
|
|
209
|
-
`fetching captured HEAD ${info.headSha}`,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const verifiedObject = await exec("git", ["-C", barePath, "cat-file", "-e", `${info.headSha}^{commit}`]);
|
|
214
|
-
if (verifiedObject.code !== 0 || verifiedObject.killed) {
|
|
215
|
-
bundleFailure(`captured HEAD ${info.headSha} is not present in temporary clone`, verifiedObject);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
await checkedGit(
|
|
219
|
-
exec,
|
|
220
|
-
["-C", barePath, "update-ref", SEED_REF, info.headSha],
|
|
221
|
-
"updating temporary seed ref",
|
|
222
|
-
);
|
|
223
|
-
await checkedGit(
|
|
224
|
-
exec,
|
|
225
|
-
["-C", barePath, "bundle", "create", bundlePath, SEED_REF],
|
|
226
|
-
"creating Git bundle",
|
|
227
|
-
);
|
|
228
|
-
await checkedGit(exec, ["-C", barePath, "bundle", "verify", bundlePath], "verifying Git bundle");
|
|
229
|
-
const heads = await checkedGit(
|
|
230
|
-
exec,
|
|
231
|
-
["-C", barePath, "bundle", "list-heads", bundlePath],
|
|
232
|
-
"listing Git bundle heads",
|
|
233
|
-
);
|
|
234
|
-
const expectedHead = heads.stdout
|
|
235
|
-
.split(/\r?\n/)
|
|
236
|
-
.map((line) => line.trim().split(/\s+/))
|
|
237
|
-
.find(([sha, ref]) => sha === info.headSha && ref === SEED_REF);
|
|
238
|
-
if (!expectedHead) {
|
|
239
|
-
throw new Error(`Git bundle did not contain captured HEAD ${info.headSha} at ${SEED_REF}`);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
hostPath: bundlePath,
|
|
244
|
-
branch: options.branch === "current" ? info.branch : options.branch,
|
|
245
|
-
headSha: info.headSha,
|
|
246
|
-
cleanup,
|
|
247
|
-
};
|
|
248
|
-
} catch (error: unknown) {
|
|
249
|
-
await cleanup().catch(() => undefined);
|
|
250
|
-
throw error;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function fileUrl(path: string): string {
|
|
255
|
-
return pathToFileURL(path).href;
|
|
256
|
-
}
|