@saadnvd1/agent-os 0.3.605 → 0.3.606
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/lib/env-setup.ts +27 -12
- package/lib/project-config/agentos.schema.json +14 -0
- package/lib/project-config/schema.ts +10 -0
- package/lib/worktree-deps/caches.ts +250 -0
- package/lib/worktree-deps/clone.ts +94 -9
- package/lib/worktree-deps/fastcopy.ts +89 -0
- package/lib/worktree-deps/index.ts +14 -5
- package/lib/worktree-deps/paths.ts +20 -0
- package/lib/worktree-deps/spare.ts +26 -8
- package/package.json +1 -1
- package/scripts/bench/worktree-setup.ts +101 -0
package/lib/env-setup.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import * as fs from "fs";
|
|
10
10
|
import * as path from "path";
|
|
11
11
|
import { bringDependencies, runCommand } from "./worktree-deps";
|
|
12
|
+
import { inside, realExisting } from "./worktree-deps/paths";
|
|
13
|
+
import { seedBuildCaches } from "./worktree-deps/caches";
|
|
12
14
|
import { loadProjectConfig, projectEnv, withPorts } from "./project-config";
|
|
13
15
|
import { recordPlaced } from "./worktree-placed";
|
|
14
16
|
import { databaseEnv, ensureSessionDatabase } from "./project-config/database";
|
|
@@ -79,18 +81,6 @@ export async function copyEnvFiles(
|
|
|
79
81
|
return copied;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
const inside = (root: string, p: string) =>
|
|
83
|
-
p === root || p.startsWith(root + path.sep);
|
|
84
|
-
|
|
85
|
-
// The real path of `p`'s deepest part that exists.
|
|
86
|
-
async function realExisting(p: string): Promise<string> {
|
|
87
|
-
for (let at = p; ; at = path.dirname(at)) {
|
|
88
|
-
const real = await fs.promises.realpath(at).catch(() => null);
|
|
89
|
-
if (real) return real;
|
|
90
|
-
if (path.dirname(at) === at) return at;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
84
|
/**
|
|
95
85
|
* agentos.json's `copy`: files or folders from the main checkout, each one
|
|
96
86
|
* resolved (symlinks included) to a place inside it, so a project can't
|
|
@@ -272,6 +262,31 @@ export async function setupWorktree(options: {
|
|
|
272
262
|
);
|
|
273
263
|
}
|
|
274
264
|
|
|
265
|
+
// Build caches, so the first typecheck, test or build is warm. An
|
|
266
|
+
// optimisation: it never fails a setup.
|
|
267
|
+
if (config.caches?.length !== 0 && sourcePath !== worktreePath) {
|
|
268
|
+
progress?.onStage?.("deps");
|
|
269
|
+
try {
|
|
270
|
+
const seeded = await seedBuildCaches({
|
|
271
|
+
sourcePath,
|
|
272
|
+
worktreePath,
|
|
273
|
+
paths: config.caches,
|
|
274
|
+
});
|
|
275
|
+
if (seeded.seeded.length || seeded.skipped.length)
|
|
276
|
+
result.steps.push({
|
|
277
|
+
name: "Seed build caches",
|
|
278
|
+
command: "cp (build caches)",
|
|
279
|
+
success: true,
|
|
280
|
+
output: [
|
|
281
|
+
...seeded.seeded.map((c) => `${c.rel} (${c.how})`),
|
|
282
|
+
...seeded.skipped.map((c) => `skipped ${c.rel}: ${c.why}`),
|
|
283
|
+
].join("\n"),
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error("Seeding build caches failed:", error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
275
290
|
// 4. The config's setup commands
|
|
276
291
|
if (config.setup && config.setup.length > 0) {
|
|
277
292
|
progress?.onStage?.("script");
|
|
@@ -48,6 +48,20 @@
|
|
|
48
48
|
"pattern": "^(?![/~\\\\])(?!(.*[/\\\\])?\\.\\.([/\\\\]|$)).+$"
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
|
+
"caches": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"items": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"allOf": [
|
|
56
|
+
{
|
|
57
|
+
"pattern": "^(?![/~\\\\])(?!(.*[/\\\\])?\\.\\.([/\\\\]|$)).+$"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"pattern": "^([^*]*[/\\\\])?[^*/\\\\]*\\*?[^*/\\\\]*$"
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
51
65
|
"dev": {
|
|
52
66
|
"type": "string",
|
|
53
67
|
"minLength": 1
|
|
@@ -28,6 +28,15 @@ const projectPath = z
|
|
|
28
28
|
"must be a path inside the project (relative, no '..')"
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
+
// Build caches to seed into a worktree: a path inside the project, with `*`
|
|
32
|
+
// allowed in the last segment (`*.tsbuildinfo`), never `.git`.
|
|
33
|
+
const cachePath = projectPath
|
|
34
|
+
.regex(
|
|
35
|
+
/^([^*]*[/\\])?[^*/\\]*\*?[^*/\\]*$/,
|
|
36
|
+
"`*` is allowed in the last segment only"
|
|
37
|
+
)
|
|
38
|
+
.refine((p) => !/(^|[/\\])\.git([/\\]|$)/.test(p), "not .git");
|
|
39
|
+
|
|
31
40
|
const port = z.number().int().min(1024).max(65000);
|
|
32
41
|
const notes = z.union([z.string(), z.array(z.string())]);
|
|
33
42
|
|
|
@@ -120,6 +129,7 @@ const shape = {
|
|
|
120
129
|
setup: z.array(z.string().min(1)).optional(),
|
|
121
130
|
copy: z.array(projectPath).optional(),
|
|
122
131
|
clone: z.array(projectPath).optional(),
|
|
132
|
+
caches: z.array(cachePath).optional(),
|
|
123
133
|
dev: z.string().min(1).optional(),
|
|
124
134
|
ready: ready.optional(),
|
|
125
135
|
deploy: deploy.optional(),
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build caches seeded into a new worktree from the main checkout, so its
|
|
3
|
+
* first typecheck, test and build start warm: `.next/cache`, `.next/dev/cache`
|
|
4
|
+
* and `*.tsbuildinfo` for a Node project, or exactly agentos.json's `caches`.
|
|
5
|
+
* (Vitest's own cache is not a default: it only orders tests, and seeding it
|
|
6
|
+
* measured no gain.) A cache is seeded only when it is fresh, built since the
|
|
7
|
+
* worktree's base commit, and not huge: an old one makes the first run
|
|
8
|
+
* slower, not faster. Best effort: a cache that can't be seeded is skipped and never
|
|
9
|
+
* fails a setup.
|
|
10
|
+
*
|
|
11
|
+
* Only ever a copy: an APFS clone, a reflink, or (on a filesystem with
|
|
12
|
+
* neither) a plain copy of a small cache. Never a hardlink or a symlink, so
|
|
13
|
+
* nothing a worktree writes can reach the main checkout, and never from a
|
|
14
|
+
* place other than the main checkout.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFile } from "child_process";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as path from "path";
|
|
20
|
+
import { promisify } from "util";
|
|
21
|
+
import { gitResult, isGitError } from "../git-exec";
|
|
22
|
+
import { findNodeModules } from "./clone";
|
|
23
|
+
import { fastCopyMode } from "./fastcopy";
|
|
24
|
+
import { inside, realExisting } from "./paths";
|
|
25
|
+
import { copyTree } from "./spare";
|
|
26
|
+
import type { CopyMode } from "./spare";
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
// Past these sizes a cache is skipped. A copy for real (no clone, no reflink)
|
|
31
|
+
// costs the disk; a clone of a huge one costs time, since the build then
|
|
32
|
+
// loads it: a main checkout's .next/cache that has grown to gigabytes built
|
|
33
|
+
// slower for a new worktree than starting empty (measured).
|
|
34
|
+
const DEFAULT_MAX_COPY_MB = 512;
|
|
35
|
+
const DEFAULT_MAX_CLONE_MB = 1024;
|
|
36
|
+
|
|
37
|
+
interface Stack {
|
|
38
|
+
name: string;
|
|
39
|
+
// Cache paths, relative to the checkout, for a project of this stack.
|
|
40
|
+
paths(sourcePath: string): Promise<string[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hasNext = (dir: string) => {
|
|
44
|
+
if (fs.readdirSync(dir).some((f) => /^next\.config\./.test(f))) return true;
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(
|
|
47
|
+
fs.readFileSync(path.join(dir, "package.json"), "utf-8")
|
|
48
|
+
);
|
|
49
|
+
return !!(pkg.dependencies?.next ?? pkg.devDependencies?.next);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
async function nodePaths(sourcePath: string): Promise<string[]> {
|
|
56
|
+
const dirs = new Set<string>();
|
|
57
|
+
for (const rel of await findNodeModules(sourcePath))
|
|
58
|
+
dirs.add(path.dirname(rel));
|
|
59
|
+
if (fs.existsSync(path.join(sourcePath, "package.json"))) dirs.add(".");
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
const abs = path.join(sourcePath, dir);
|
|
63
|
+
if (!fs.existsSync(path.join(abs, "package.json"))) continue;
|
|
64
|
+
out.push(path.join(dir, "*.tsbuildinfo"));
|
|
65
|
+
if (hasNext(abs))
|
|
66
|
+
out.push(
|
|
67
|
+
path.join(dir, ".next", "cache"),
|
|
68
|
+
// Next 16's dev server keeps its Turbopack cache here.
|
|
69
|
+
path.join(dir, ".next", "dev", "cache")
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One entry per stack. Add a stack here to give it defaults; a project can
|
|
76
|
+
// always name its own in agentos.json's `caches`.
|
|
77
|
+
export const STACKS: Stack[] = [
|
|
78
|
+
{ name: "node", paths: nodePaths },
|
|
79
|
+
// iOS (DerivedData, Pods, ccache) is the agentos-mobile "Faster native iOS
|
|
80
|
+
// builds" task's: it plugs in here.
|
|
81
|
+
{ name: "ios", paths: async () => [] },
|
|
82
|
+
// Rails is deliberately absent: bootsnap's load-path cache holds absolute
|
|
83
|
+
// paths, so a copy in another directory is stale on arrival.
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
export async function defaultCaches(sourcePath: string): Promise<string[]> {
|
|
87
|
+
const all = await Promise.all(STACKS.map((s) => s.paths(sourcePath)));
|
|
88
|
+
return all.flat();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `*` is allowed in the last segment only.
|
|
92
|
+
async function expand(sourcePath: string, rel: string): Promise<string[]> {
|
|
93
|
+
const base = path.basename(rel);
|
|
94
|
+
if (!base.includes("*")) return [rel];
|
|
95
|
+
const re = new RegExp(
|
|
96
|
+
"^" + base.split("*").map(escapeRe).join("[^/]*") + "$"
|
|
97
|
+
);
|
|
98
|
+
const dir = path.dirname(rel);
|
|
99
|
+
const names = await fs.promises
|
|
100
|
+
.readdir(path.join(sourcePath, dir))
|
|
101
|
+
.catch(() => [] as string[]);
|
|
102
|
+
return names.filter((n) => re.test(n)).map((n) => path.join(dir, n));
|
|
103
|
+
}
|
|
104
|
+
const escapeRe = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
105
|
+
|
|
106
|
+
// When the worktree's HEAD was committed; null when it can't be told.
|
|
107
|
+
async function headTimeMs(worktreePath: string): Promise<number | null> {
|
|
108
|
+
try {
|
|
109
|
+
const { stdout } = await gitResult(worktreePath, [
|
|
110
|
+
"log",
|
|
111
|
+
"-1",
|
|
112
|
+
"--format=%ct",
|
|
113
|
+
]);
|
|
114
|
+
const seconds = parseInt(stdout, 10);
|
|
115
|
+
return Number.isFinite(seconds) ? seconds * 1000 : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The last write to a file, or to a folder or its direct children.
|
|
122
|
+
async function newestMs(p: string): Promise<number> {
|
|
123
|
+
const st = await fs.promises.stat(p);
|
|
124
|
+
let newest = st.mtimeMs;
|
|
125
|
+
if (st.isDirectory())
|
|
126
|
+
for (const name of await fs.promises.readdir(p)) {
|
|
127
|
+
const child = await fs.promises
|
|
128
|
+
.stat(path.join(p, name))
|
|
129
|
+
.catch(() => null);
|
|
130
|
+
if (child) newest = Math.max(newest, child.mtimeMs);
|
|
131
|
+
}
|
|
132
|
+
return newest;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function sizeMb(p: string): Promise<number> {
|
|
136
|
+
const { stdout } = await execFileAsync("du", ["-sk", p]);
|
|
137
|
+
return parseInt(stdout, 10) / 1024;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Git ignores it, or seeding it would leave an uncommitted file behind.
|
|
141
|
+
// Outside a repository there is no answer, and it is allowed.
|
|
142
|
+
async function notIgnored(worktreePath: string, rel: string): Promise<boolean> {
|
|
143
|
+
try {
|
|
144
|
+
await gitResult(worktreePath, ["check-ignore", "-q", "--", rel]);
|
|
145
|
+
return false;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return isGitError(error) && error.exitCode === 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface SeedResult {
|
|
152
|
+
seeded: Array<{ rel: string; how: CopyMode }>;
|
|
153
|
+
skipped: Array<{ rel: string; why: string }>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function seedBuildCaches(opts: {
|
|
157
|
+
sourcePath: string;
|
|
158
|
+
worktreePath: string;
|
|
159
|
+
// agentos.json's `caches`; the stack defaults without it.
|
|
160
|
+
paths?: string[];
|
|
161
|
+
mode?: CopyMode;
|
|
162
|
+
platform?: NodeJS.Platform;
|
|
163
|
+
maxCopyMb?: number;
|
|
164
|
+
maxCloneMb?: number;
|
|
165
|
+
// Caches last written before this (ms) are stale. Default: the worktree's
|
|
166
|
+
// HEAD commit time; without one every cache counts as fresh.
|
|
167
|
+
freshAfterMs?: number;
|
|
168
|
+
}): Promise<SeedResult> {
|
|
169
|
+
const { sourcePath, worktreePath } = opts;
|
|
170
|
+
const result: SeedResult = { seeded: [], skipped: [] };
|
|
171
|
+
// The switch for measuring a start without seeding.
|
|
172
|
+
if (process.env.AGENTOS_NO_BUILD_CACHES) return result;
|
|
173
|
+
const declared = opts.paths ?? (await defaultCaches(sourcePath));
|
|
174
|
+
if (!declared.length) return result;
|
|
175
|
+
|
|
176
|
+
const fast = await fastCopyMode({
|
|
177
|
+
sourcePath,
|
|
178
|
+
worktreePath,
|
|
179
|
+
platform: opts.platform,
|
|
180
|
+
});
|
|
181
|
+
// Hardlinks are for dependencies from the store, never for a cache.
|
|
182
|
+
const mode: CopyMode =
|
|
183
|
+
opts.mode ?? (fast === "clone" || fast === "reflink" ? fast : "copy");
|
|
184
|
+
const max =
|
|
185
|
+
opts.maxCopyMb ??
|
|
186
|
+
Number(process.env.AGENTOS_CACHE_COPY_MAX_MB || DEFAULT_MAX_COPY_MB);
|
|
187
|
+
const maxClone =
|
|
188
|
+
opts.maxCloneMb ??
|
|
189
|
+
Number(process.env.AGENTOS_CACHE_CLONE_MAX_MB || DEFAULT_MAX_CLONE_MB);
|
|
190
|
+
const freshAfter = opts.freshAfterMs ?? (await headTimeMs(worktreePath));
|
|
191
|
+
const root = await fs.promises.realpath(sourcePath);
|
|
192
|
+
const target = await fs.promises.realpath(worktreePath);
|
|
193
|
+
|
|
194
|
+
const skip = (rel: string, why: string) => result.skipped.push({ rel, why });
|
|
195
|
+
|
|
196
|
+
for (const rel of (
|
|
197
|
+
await Promise.all(declared.map((d) => expand(sourcePath, d)))
|
|
198
|
+
).flat()) {
|
|
199
|
+
const real = await fs.promises
|
|
200
|
+
.realpath(path.join(sourcePath, rel))
|
|
201
|
+
.catch(() => null);
|
|
202
|
+
if (!real) continue; // not built in the main checkout yet
|
|
203
|
+
if (!inside(root, real)) {
|
|
204
|
+
skip(rel, "resolves outside the project");
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const dir = path.join(target, path.dirname(rel));
|
|
208
|
+
if (!inside(target, await realExisting(dir))) {
|
|
209
|
+
skip(rel, "its folder in the worktree resolves outside it");
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const dest = path.join(dir, path.basename(rel));
|
|
213
|
+
if (await fs.promises.lstat(dest).catch(() => null)) continue; // already there
|
|
214
|
+
if (await notIgnored(worktreePath, rel)) {
|
|
215
|
+
skip(rel, "not ignored by git");
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (freshAfter !== null && (await newestMs(real)) < freshAfter) {
|
|
219
|
+
skip(rel, "older than the worktree's base commit");
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const limit = mode === "copy" ? max : maxClone;
|
|
223
|
+
if ((await sizeMb(real).catch(() => Infinity)) > limit) {
|
|
224
|
+
skip(
|
|
225
|
+
rel,
|
|
226
|
+
mode === "copy"
|
|
227
|
+
? `bigger than ${limit} MB and this filesystem can't clone`
|
|
228
|
+
: `bigger than ${limit} MB`
|
|
229
|
+
);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
233
|
+
if (!inside(target, await fs.promises.realpath(dir))) {
|
|
234
|
+
skip(rel, "its folder in the worktree resolves outside it");
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const partial = `${dest}.aos-partial`;
|
|
238
|
+
await fs.promises.rm(partial, { recursive: true, force: true });
|
|
239
|
+
const ok =
|
|
240
|
+
(await copyTree(real, partial, mode)) &&
|
|
241
|
+
(await fs.promises.rename(partial, dest).then(
|
|
242
|
+
() => true,
|
|
243
|
+
() => false
|
|
244
|
+
));
|
|
245
|
+
await fs.promises.rm(partial, { recursive: true, force: true });
|
|
246
|
+
if (ok) result.seeded.push({ rel, how: mode });
|
|
247
|
+
else skip(rel, "the copy failed");
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
@@ -10,6 +10,8 @@ import { createHash } from "crypto";
|
|
|
10
10
|
import * as fs from "fs";
|
|
11
11
|
import * as path from "path";
|
|
12
12
|
import { LOCKFILES } from "./install";
|
|
13
|
+
import { STORE_ROOT, fastCopyMode, unlinkMutable } from "./fastcopy";
|
|
14
|
+
import type { FastMode } from "./fastcopy";
|
|
13
15
|
import {
|
|
14
16
|
SPARE_ROOT,
|
|
15
17
|
copyTree,
|
|
@@ -72,11 +74,15 @@ export async function findNodeModules(sourcePath: string): Promise<string[]> {
|
|
|
72
74
|
|
|
73
75
|
// Cloned beside the target and renamed into place, so a clone cut off by a
|
|
74
76
|
// restart or a failed cp never passes for a finished one.
|
|
75
|
-
async function cloneWhole(
|
|
77
|
+
async function cloneWhole(
|
|
78
|
+
from: string,
|
|
79
|
+
target: string,
|
|
80
|
+
mode: FastMode
|
|
81
|
+
): Promise<boolean> {
|
|
76
82
|
const partial = `${target}.aos-partial`;
|
|
77
83
|
await fs.promises.rm(partial, { recursive: true, force: true });
|
|
78
84
|
const ok =
|
|
79
|
-
(await copyTree(from, partial)) && (await takeSpare(partial, target));
|
|
85
|
+
(await copyTree(from, partial, mode)) && (await takeSpare(partial, target));
|
|
80
86
|
await fs.promises.rm(partial, { recursive: true, force: true });
|
|
81
87
|
return ok;
|
|
82
88
|
}
|
|
@@ -86,11 +92,14 @@ export interface CloneResult {
|
|
|
86
92
|
// Why it didn't clone, when it didn't.
|
|
87
93
|
reason?: string;
|
|
88
94
|
cloned: Array<{ rel: string; from: "spare" | "clone" }>;
|
|
95
|
+
mode?: FastMode;
|
|
96
|
+
skipped?: string[];
|
|
89
97
|
// The spares being made for the next start. Nothing waits on it but tests.
|
|
90
98
|
refill?: Promise<unknown>;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
|
-
export const
|
|
101
|
+
export const NO_FAST_COPY =
|
|
102
|
+
"no clone, reflink or hardlink store is possible here";
|
|
94
103
|
|
|
95
104
|
export async function cloneDeps(opts: {
|
|
96
105
|
sourcePath: string;
|
|
@@ -99,13 +108,26 @@ export async function cloneDeps(opts: {
|
|
|
99
108
|
// node_modules findNodeModules finds.
|
|
100
109
|
only?: string[];
|
|
101
110
|
spareRoot?: string;
|
|
111
|
+
// Where Linux keeps the hardlinked store (fastcopy.ts).
|
|
112
|
+
storeRoot?: string;
|
|
102
113
|
platform?: NodeJS.Platform;
|
|
114
|
+
// Forced instead of detected; null is "none works" (tests).
|
|
115
|
+
mode?: FastMode | null;
|
|
103
116
|
}): Promise<CloneResult> {
|
|
104
117
|
const { sourcePath, worktreePath, only } = opts;
|
|
105
118
|
const root = opts.spareRoot ?? SPARE_ROOT;
|
|
106
119
|
const cloned: CloneResult["cloned"] = [];
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
const storeRoot = opts.storeRoot ?? STORE_ROOT;
|
|
121
|
+
const mode =
|
|
122
|
+
opts.mode !== undefined
|
|
123
|
+
? opts.mode
|
|
124
|
+
: await fastCopyMode({
|
|
125
|
+
sourcePath,
|
|
126
|
+
worktreePath,
|
|
127
|
+
storeRoot,
|
|
128
|
+
platform: opts.platform,
|
|
129
|
+
});
|
|
130
|
+
if (!mode) return { ok: false, reason: NO_FAST_COPY, cloned };
|
|
109
131
|
|
|
110
132
|
const isModules = (rel: string) => path.basename(rel) === "node_modules";
|
|
111
133
|
const all = only
|
|
@@ -124,6 +146,13 @@ export async function cloneDeps(opts: {
|
|
|
124
146
|
};
|
|
125
147
|
if (!lock)
|
|
126
148
|
return { ok: false, reason: "the main checkout has no lockfile", cloned };
|
|
149
|
+
if (!(await nodeModulesMatchLock(sourcePath)))
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason:
|
|
153
|
+
"the main checkout's node_modules is not what its lockfile lists",
|
|
154
|
+
cloned,
|
|
155
|
+
};
|
|
127
156
|
if (lock !== (await lockfileHash(worktreePath)))
|
|
128
157
|
return {
|
|
129
158
|
ok: false,
|
|
@@ -133,16 +162,22 @@ export async function cloneDeps(opts: {
|
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
const spares: Array<{ rel: string; lock: string }> = [];
|
|
165
|
+
const skipped: string[] = [];
|
|
136
166
|
for (const rel of all) {
|
|
137
167
|
const from = path.join(sourcePath, rel);
|
|
138
168
|
const target = path.join(worktreePath, rel);
|
|
139
169
|
if (!(await isDir(from))) continue;
|
|
140
170
|
// A directory this branch doesn't have, or the files already there.
|
|
141
171
|
if (!(await isDir(path.dirname(target))) || fs.existsSync(target)) continue;
|
|
172
|
+
if (!isModules(rel) && mode === "hardlink") {
|
|
173
|
+
// Mutable, and hardlinks would write back: left to the setup commands.
|
|
174
|
+
skipped.push(`${rel} (not linked)`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
142
177
|
if (!isModules(rel)) {
|
|
143
178
|
// Anything else declared (a virtualenv, vendor/bundle) is cloned as
|
|
144
179
|
// is: there is no lockfile AgentOS knows to check it against.
|
|
145
|
-
if (!(await cloneWhole(from, target)))
|
|
180
|
+
if (!(await cloneWhole(from, target, mode)))
|
|
146
181
|
return { ok: false, reason: `cloning ${rel} failed`, cloned };
|
|
147
182
|
cloned.push({ rel, from: "clone" });
|
|
148
183
|
continue;
|
|
@@ -154,10 +189,30 @@ export async function cloneDeps(opts: {
|
|
|
154
189
|
if (rel !== "node_modules" && own !== ownHere) continue;
|
|
155
190
|
const key = own ?? lock;
|
|
156
191
|
if (!key) continue;
|
|
192
|
+
if (mode === "hardlink") {
|
|
193
|
+
// Linked from the store, never from the checkout; the store is made
|
|
194
|
+
// once per lockfile with a plain copy.
|
|
195
|
+
const stored = sparePath(storeRoot, sourcePath, rel, key);
|
|
196
|
+
if (!fs.existsSync(stored))
|
|
197
|
+
await replenishSpare(storeRoot, sourcePath, rel, key, "copy").catch(
|
|
198
|
+
() => undefined
|
|
199
|
+
);
|
|
200
|
+
if (!fs.existsSync(stored))
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: `making the store for ${rel} failed`,
|
|
204
|
+
cloned,
|
|
205
|
+
};
|
|
206
|
+
if (!(await cloneWhole(stored, target, mode)))
|
|
207
|
+
return { ok: false, reason: `linking ${rel} failed`, cloned };
|
|
208
|
+
await unlinkMutable(target).catch(() => undefined);
|
|
209
|
+
cloned.push({ rel, from: "clone" });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
157
212
|
const spare = sparePath(root, sourcePath, rel, key);
|
|
158
213
|
if (await takeSpare(spare, target)) {
|
|
159
214
|
cloned.push({ rel, from: "spare" });
|
|
160
|
-
} else if (await cloneWhole(from, target)) {
|
|
215
|
+
} else if (await cloneWhole(from, target, mode)) {
|
|
161
216
|
cloned.push({ rel, from: "clone" });
|
|
162
217
|
} else {
|
|
163
218
|
return { ok: false, reason: `cloning ${rel} failed`, cloned };
|
|
@@ -167,10 +222,40 @@ export async function cloneDeps(opts: {
|
|
|
167
222
|
|
|
168
223
|
const refill = Promise.all(
|
|
169
224
|
spares.map(({ rel, lock: key }) =>
|
|
170
|
-
replenishSpare(root, sourcePath, rel, key).catch((error) =>
|
|
225
|
+
replenishSpare(root, sourcePath, rel, key, mode).catch((error) =>
|
|
171
226
|
console.error(`Spare for ${rel} failed:`, error)
|
|
172
227
|
)
|
|
173
228
|
)
|
|
174
229
|
);
|
|
175
|
-
return { ok: true, cloned, refill };
|
|
230
|
+
return { ok: true, cloned, refill, mode, skipped };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Whether the checkout's node_modules is what its npm lockfile says: every
|
|
234
|
+
// package the lockfile lists (optional ones, which a platform may skip, aside)
|
|
235
|
+
// is installed at that version. A checkout pulled but never reinstalled is
|
|
236
|
+
// not, and cloning it would hand the worktree missing dependencies. Other
|
|
237
|
+
// package managers have no hidden lockfile to check, so they pass.
|
|
238
|
+
export async function nodeModulesMatchLock(dir: string): Promise<boolean> {
|
|
239
|
+
const read = (file: string) =>
|
|
240
|
+
fs.promises
|
|
241
|
+
.readFile(file, "utf-8")
|
|
242
|
+
.then(
|
|
243
|
+
(text) =>
|
|
244
|
+
JSON.parse(text) as {
|
|
245
|
+
packages?: Record<string, { version?: string; optional?: boolean }>;
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
.catch(() => null);
|
|
249
|
+
const lock = await read(path.join(dir, "package-lock.json"));
|
|
250
|
+
if (!lock?.packages) return true;
|
|
251
|
+
const installed = await read(
|
|
252
|
+
path.join(dir, "node_modules", ".package-lock.json")
|
|
253
|
+
);
|
|
254
|
+
if (!installed?.packages) return false;
|
|
255
|
+
return Object.entries(lock.packages).every(
|
|
256
|
+
([name, pkg]) =>
|
|
257
|
+
name === "" ||
|
|
258
|
+
pkg.optional ||
|
|
259
|
+
installed.packages![name]?.version === pkg.version
|
|
260
|
+
);
|
|
176
261
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a Linux (or macOS) worktree can be given a copy of the main checkout's
|
|
3
|
+
* files without paying for every byte: an APFS clone, a reflink where the
|
|
4
|
+
* filesystem has them, or hardlinks from a store AgentOS owns. Null when
|
|
5
|
+
* none works, and the caller installs or copies plainly instead.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "child_process";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as os from "os";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import { promisify } from "util";
|
|
13
|
+
import type { CopyMode } from "./spare";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
export type FastMode = Exclude<CopyMode, "copy">;
|
|
18
|
+
|
|
19
|
+
// Where hardlinked dependencies are kept: one real copy per lockfile, so a
|
|
20
|
+
// write through a link can only ever reach this, never the main checkout.
|
|
21
|
+
export const STORE_ROOT = path.join(os.homedir(), ".agent-os", "deps-store");
|
|
22
|
+
|
|
23
|
+
// Files tools rewrite in place. A hardlink would carry the write back into
|
|
24
|
+
// the store, so these are real copies in every worktree.
|
|
25
|
+
export const MUTABLE_IN_MODULES = [".package-lock.json", ".cache", ".vite"];
|
|
26
|
+
|
|
27
|
+
const probed = new Map<string, boolean>();
|
|
28
|
+
|
|
29
|
+
// Whether `dir`'s filesystem reflinks: tried on a scratch file made there.
|
|
30
|
+
export async function canReflink(dir: string): Promise<boolean> {
|
|
31
|
+
const dev = String((await fs.promises.stat(dir)).dev);
|
|
32
|
+
const known = probed.get(dev);
|
|
33
|
+
if (known !== undefined) return known;
|
|
34
|
+
const probe = path.join(dir, `.aos-reflink-probe-${process.pid}`);
|
|
35
|
+
let ok = false;
|
|
36
|
+
try {
|
|
37
|
+
await fs.promises.writeFile(probe, "x");
|
|
38
|
+
await execFileAsync("cp", ["--reflink=always", probe, `${probe}.copy`]);
|
|
39
|
+
ok = true;
|
|
40
|
+
} catch {
|
|
41
|
+
ok = false;
|
|
42
|
+
} finally {
|
|
43
|
+
await fs.promises.rm(probe, { force: true });
|
|
44
|
+
await fs.promises.rm(`${probe}.copy`, { force: true });
|
|
45
|
+
}
|
|
46
|
+
probed.set(dev, ok);
|
|
47
|
+
return ok;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const device = (p: string) =>
|
|
51
|
+
fs.promises.stat(p).then(
|
|
52
|
+
(s) => s.dev,
|
|
53
|
+
() => null
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export async function fastCopyMode(opts: {
|
|
57
|
+
sourcePath: string;
|
|
58
|
+
worktreePath: string;
|
|
59
|
+
storeRoot?: string;
|
|
60
|
+
platform?: NodeJS.Platform;
|
|
61
|
+
}): Promise<FastMode | null> {
|
|
62
|
+
const platform = opts.platform ?? process.platform;
|
|
63
|
+
if (platform === "darwin") return "clone";
|
|
64
|
+
if (platform !== "linux") return null;
|
|
65
|
+
const wt = await device(opts.worktreePath);
|
|
66
|
+
if (wt === null) return null;
|
|
67
|
+
if (
|
|
68
|
+
(await device(opts.sourcePath)) === wt &&
|
|
69
|
+
(await canReflink(opts.worktreePath))
|
|
70
|
+
)
|
|
71
|
+
return "reflink";
|
|
72
|
+
const store = opts.storeRoot ?? STORE_ROOT;
|
|
73
|
+
await fs.promises.mkdir(store, { recursive: true }).catch(() => undefined);
|
|
74
|
+
return (await device(store)) === wt ? "hardlink" : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Replace the entries of a hardlinked node_modules that tools rewrite in
|
|
78
|
+
// place with real copies.
|
|
79
|
+
export async function unlinkMutable(modules: string): Promise<void> {
|
|
80
|
+
for (const name of MUTABLE_IN_MODULES) {
|
|
81
|
+
const entry = path.join(modules, name);
|
|
82
|
+
if (!(await fs.promises.lstat(entry).catch(() => null))) continue;
|
|
83
|
+
const copy = `${entry}.aos-copy`;
|
|
84
|
+
await fs.promises.rm(copy, { recursive: true, force: true });
|
|
85
|
+
await fs.promises.cp(entry, copy, { recursive: true });
|
|
86
|
+
await fs.promises.rm(entry, { recursive: true, force: true });
|
|
87
|
+
await fs.promises.rename(copy, entry);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -6,11 +6,17 @@
|
|
|
6
6
|
import { exec } from "child_process";
|
|
7
7
|
import { promisify } from "util";
|
|
8
8
|
import type { SetupResult } from "../env-setup";
|
|
9
|
-
import {
|
|
9
|
+
import { NO_FAST_COPY, cloneDeps } from "./clone";
|
|
10
10
|
import { detectPackageManager, setupEnv } from "./install";
|
|
11
11
|
|
|
12
12
|
const execAsync = promisify(exec);
|
|
13
13
|
|
|
14
|
+
const COMMANDS = {
|
|
15
|
+
clone: "cp -Rc",
|
|
16
|
+
reflink: "cp -a --reflink=always",
|
|
17
|
+
hardlink: "cp -al (from the store)",
|
|
18
|
+
};
|
|
19
|
+
|
|
14
20
|
/**
|
|
15
21
|
* Run a setup command in the worktree directory
|
|
16
22
|
*/
|
|
@@ -57,15 +63,18 @@ export async function bringDependencies(
|
|
|
57
63
|
if (pm) result.packageManager = pm.name;
|
|
58
64
|
|
|
59
65
|
const clone = await deps.clone({ sourcePath, worktreePath, only });
|
|
60
|
-
//
|
|
61
|
-
if (!pm && !clone.ok && !clone.cloned.length && clone.reason ===
|
|
66
|
+
// Without a fast copy only node_modules has a fallback (the install below).
|
|
67
|
+
if (!pm && !clone.ok && !clone.cloned.length && clone.reason === NO_FAST_COPY)
|
|
62
68
|
return;
|
|
63
69
|
if (clone.ok || !pm) {
|
|
64
70
|
result.steps.push({
|
|
65
71
|
name: "Clone dependencies",
|
|
66
|
-
command: "cp
|
|
72
|
+
command: clone.mode ? COMMANDS[clone.mode] : "cp",
|
|
67
73
|
success: clone.ok,
|
|
68
|
-
output:
|
|
74
|
+
output: [
|
|
75
|
+
...clone.cloned.map((c) => `${c.rel} (${c.from})`),
|
|
76
|
+
...(clone.skipped ?? []).map((r) => `skipped ${r}`),
|
|
77
|
+
].join(", "),
|
|
69
78
|
error: clone.ok ? undefined : clone.reason,
|
|
70
79
|
});
|
|
71
80
|
if (!clone.ok) result.success = false;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path checks shared by what setup places into a worktree (agentos.json's
|
|
3
|
+
* `copy`, `clone` and `caches`): everything resolved through symlinks to a
|
|
4
|
+
* place inside its checkout, so a link can't carry a copy somewhere else.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
|
|
10
|
+
export const inside = (root: string, p: string) =>
|
|
11
|
+
p === root || p.startsWith(root + path.sep);
|
|
12
|
+
|
|
13
|
+
// The real path of `p`'s deepest part that exists.
|
|
14
|
+
export async function realExisting(p: string): Promise<string> {
|
|
15
|
+
for (let at = p; ; at = path.dirname(at)) {
|
|
16
|
+
const real = await fs.promises.realpath(at).catch(() => null);
|
|
17
|
+
if (real) return real;
|
|
18
|
+
if (path.dirname(at) === at) return at;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -49,18 +49,35 @@ export async function takeSpare(
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// How a tree is copied: an APFS clone (macOS), a reflink (Linux on a
|
|
53
|
+
// copy-on-write filesystem), hardlinks (Linux, from AgentOS's own store), or
|
|
54
|
+
// a plain copy.
|
|
55
|
+
export type CopyMode = "clone" | "reflink" | "hardlink" | "copy";
|
|
56
|
+
|
|
57
|
+
export const copyArgs = (mode: CopyMode, from: string, to: string) =>
|
|
58
|
+
mode === "clone"
|
|
59
|
+
? [process.platform === "darwin" ? "-Rc" : "-R", from, to]
|
|
60
|
+
: mode === "reflink"
|
|
61
|
+
? ["-a", "--reflink=always", from, to]
|
|
62
|
+
: mode === "hardlink"
|
|
63
|
+
? ["-al", from, to]
|
|
64
|
+
: ["-a", from, to];
|
|
65
|
+
|
|
66
|
+
// What a copy with no mode given does: clones on macOS, a plain copy elsewhere.
|
|
67
|
+
const defaultMode = (): CopyMode =>
|
|
68
|
+
process.platform === "darwin" ? "clone" : "copy";
|
|
56
69
|
|
|
57
70
|
// A clone that stalls (a locked or dying volume) is killed rather than
|
|
58
71
|
// holding a task's start forever.
|
|
59
72
|
const COPY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
60
73
|
|
|
61
|
-
export function copyTree(
|
|
74
|
+
export function copyTree(
|
|
75
|
+
from: string,
|
|
76
|
+
to: string,
|
|
77
|
+
mode: CopyMode = defaultMode()
|
|
78
|
+
): Promise<boolean> {
|
|
62
79
|
return new Promise((resolve) => {
|
|
63
|
-
const child = spawn("cp",
|
|
80
|
+
const child = spawn("cp", copyArgs(mode, from, to), {
|
|
64
81
|
stdio: "ignore",
|
|
65
82
|
timeout: COPY_TIMEOUT_MS,
|
|
66
83
|
killSignal: "SIGKILL",
|
|
@@ -104,7 +121,8 @@ export async function replenishSpare(
|
|
|
104
121
|
root: string,
|
|
105
122
|
sourcePath: string,
|
|
106
123
|
rel: string,
|
|
107
|
-
lockHash: string
|
|
124
|
+
lockHash: string,
|
|
125
|
+
mode?: CopyMode
|
|
108
126
|
): Promise<"made" | "exists" | "failed"> {
|
|
109
127
|
const spare = sparePath(root, sourcePath, rel, lockHash);
|
|
110
128
|
if (building.has(spare) || fs.existsSync(spare)) return "exists";
|
|
@@ -113,7 +131,7 @@ export async function replenishSpare(
|
|
|
113
131
|
await fs.promises.mkdir(root, { recursive: true });
|
|
114
132
|
await pruneSpares(root, sourcePath, rel, spare);
|
|
115
133
|
const tmp = `${spare}.${process.pid}-${Date.now()}`;
|
|
116
|
-
let made = await copyTree(path.join(sourcePath, rel), tmp);
|
|
134
|
+
let made = await copyTree(path.join(sourcePath, rel), tmp, mode);
|
|
117
135
|
if (made) made = await takeSpare(tmp, spare);
|
|
118
136
|
await fs.promises.rm(tmp, { recursive: true, force: true });
|
|
119
137
|
return made ? "made" : "failed";
|
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Times a new worktree's setup and its first typecheck, test and build, and
|
|
3
|
+
* the disk it takes, against a checkout. Run it with the seeding on, then
|
|
4
|
+
* with AGENTOS_NO_BUILD_CACHES=1 (and, to see an install on Linux, --install).
|
|
5
|
+
*
|
|
6
|
+
* npx tsx scripts/bench/worktree-setup.ts <checkout> [--test <file>]
|
|
7
|
+
* [--build] [--install] [--cwd <subdir>] [--drop <seeded path>]
|
|
8
|
+
*
|
|
9
|
+
* It makes a throwaway git worktree of the checkout's HEAD in the temp
|
|
10
|
+
* directory and removes it at the end; it touches nothing else.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as os from "os";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { setupWorktree } from "../../lib/env-setup";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const flag = (name: string) => args.includes(name);
|
|
21
|
+
const value = (name: string) => args[args.indexOf(name) + 1];
|
|
22
|
+
const source = path.resolve(args[0]);
|
|
23
|
+
const sub = flag("--cwd") ? value("--cwd") : ".";
|
|
24
|
+
|
|
25
|
+
const freeKb = (dir: string) =>
|
|
26
|
+
Number(
|
|
27
|
+
execFileSync("df", ["-Pk", dir], { encoding: "utf-8" })
|
|
28
|
+
.trim()
|
|
29
|
+
.split("\n")[1]
|
|
30
|
+
.split(/\s+/)[3]
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
function timed(command: string, cwd: string) {
|
|
34
|
+
const start = Date.now();
|
|
35
|
+
const run = spawnSync(command, { cwd, shell: true, stdio: "ignore" });
|
|
36
|
+
return { seconds: (Date.now() - start) / 1000, ok: run.status === 0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-bench-"));
|
|
41
|
+
const worktree = path.join(root, "wt");
|
|
42
|
+
execFileSync("git", ["worktree", "add", "-q", "--detach", worktree, "HEAD"], {
|
|
43
|
+
cwd: source,
|
|
44
|
+
});
|
|
45
|
+
const out: Record<string, unknown> = {
|
|
46
|
+
source: path.basename(source),
|
|
47
|
+
platform: process.platform,
|
|
48
|
+
seeding: process.env.AGENTOS_NO_BUILD_CACHES ? "off" : "on",
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
const before = freeKb(root);
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
let steps: string[] = [];
|
|
54
|
+
if (flag("--install")) {
|
|
55
|
+
const r = timed("npm ci --include=dev", worktree);
|
|
56
|
+
steps = [`npm ci ok=${r.ok}`];
|
|
57
|
+
} else {
|
|
58
|
+
const result = await setupWorktree({
|
|
59
|
+
worktreePath: worktree,
|
|
60
|
+
sourcePath: source,
|
|
61
|
+
});
|
|
62
|
+
steps = result.steps.map(
|
|
63
|
+
(s) => `${s.name}: ${s.success ? "ok" : "failed"}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
out.setupSeconds = (Date.now() - start) / 1000;
|
|
67
|
+
out.setupSteps = steps;
|
|
68
|
+
out.diskMbAfterSetup = Math.round((before - freeKb(root)) / 1024);
|
|
69
|
+
// Take a seeded cache back out to measure without it.
|
|
70
|
+
if (flag("--drop"))
|
|
71
|
+
fs.rmSync(path.join(worktree, value("--drop")), {
|
|
72
|
+
recursive: true,
|
|
73
|
+
force: true,
|
|
74
|
+
});
|
|
75
|
+
const dir = path.join(worktree, sub);
|
|
76
|
+
const pkg = path.join(dir, "package.json");
|
|
77
|
+
const typecheck = fs.existsSync(pkg)
|
|
78
|
+
? JSON.parse(fs.readFileSync(pkg, "utf-8")).scripts?.typecheck
|
|
79
|
+
: undefined;
|
|
80
|
+
if (typecheck) out.typecheck = timed("npm run typecheck", dir);
|
|
81
|
+
else if (fs.existsSync(path.join(dir, "tsconfig.json")))
|
|
82
|
+
out.typecheck = timed("npx tsc --noEmit", dir);
|
|
83
|
+
if (flag("--test"))
|
|
84
|
+
out.test = timed(`npx vitest run ${value("--test")}`, dir);
|
|
85
|
+
if (flag("--build")) {
|
|
86
|
+
out.build = timed("npx next build", dir);
|
|
87
|
+
out.diskMbAfterBuild = Math.round((before - freeKb(root)) / 1024);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
execFileSync("git", ["worktree", "remove", "--force", worktree], {
|
|
91
|
+
cwd: source,
|
|
92
|
+
});
|
|
93
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
console.log(JSON.stringify(out));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main().catch((error) => {
|
|
99
|
+
console.error(error);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|