@dench.com/cli 0.4.3 → 0.4.5

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,185 @@
1
+ import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+
4
+ export const DEFAULT_LIVE_HOME = "/tmp/.dench-home";
5
+ export const DEFAULT_SNAPSHOT_DIR = "/workspace/.dench/home/snapshots";
6
+ export const DEFAULT_DEBOUNCE_MS = 2_000;
7
+ export const DEFAULT_KEEP_SNAPSHOTS = 8;
8
+ export const DEFAULT_STATUS_PATH = "/tmp/dench-home-sync.status.json";
9
+
10
+ export type HomeSyncArgs = {
11
+ liveHome: string;
12
+ snapshotDir: string;
13
+ debounceMs: number;
14
+ keepSnapshots: number;
15
+ statusPath: string;
16
+ once: boolean;
17
+ };
18
+
19
+ export function parseHomeSyncArgs(argv: string[]): HomeSyncArgs {
20
+ const out: Partial<HomeSyncArgs> = {};
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const flag = argv[i];
23
+ const value = argv[i + 1];
24
+ switch (flag) {
25
+ case "--live-home":
26
+ out.liveHome = value;
27
+ i++;
28
+ break;
29
+ case "--snapshot-dir":
30
+ out.snapshotDir = value;
31
+ i++;
32
+ break;
33
+ case "--debounce-ms":
34
+ out.debounceMs = Number.parseInt(value, 10);
35
+ i++;
36
+ break;
37
+ case "--keep-snapshots":
38
+ out.keepSnapshots = Number.parseInt(value, 10);
39
+ i++;
40
+ break;
41
+ case "--status-path":
42
+ out.statusPath = value;
43
+ i++;
44
+ break;
45
+ case "--once":
46
+ out.once = true;
47
+ break;
48
+ default:
49
+ // Ignore unknown flags so newer bootstraps can run on older CLIs.
50
+ break;
51
+ }
52
+ }
53
+
54
+ return {
55
+ liveHome: out.liveHome ?? DEFAULT_LIVE_HOME,
56
+ snapshotDir:
57
+ out.snapshotDir ??
58
+ process.env.DENCH_HOME_SNAPSHOT_DIR?.trim() ??
59
+ DEFAULT_SNAPSHOT_DIR,
60
+ debounceMs: Number.isFinite(out.debounceMs)
61
+ ? Math.max(250, out.debounceMs ?? DEFAULT_DEBOUNCE_MS)
62
+ : DEFAULT_DEBOUNCE_MS,
63
+ keepSnapshots: Number.isFinite(out.keepSnapshots)
64
+ ? Math.max(1, out.keepSnapshots ?? DEFAULT_KEEP_SNAPSHOTS)
65
+ : DEFAULT_KEEP_SNAPSHOTS,
66
+ statusPath: out.statusPath ?? DEFAULT_STATUS_PATH,
67
+ once: out.once ?? false,
68
+ };
69
+ }
70
+
71
+ const EXCLUDED_HOME_PATTERNS = [
72
+ ".cache",
73
+ ".cache/**",
74
+ ".npm/_cacache",
75
+ ".npm/_cacache/**",
76
+ ".npm/_logs",
77
+ ".npm/_logs/**",
78
+ ".bun/install/cache",
79
+ ".bun/install/cache/**",
80
+ ".cargo/registry/cache",
81
+ ".cargo/registry/cache/**",
82
+ ".cargo/registry/index",
83
+ ".cargo/registry/index/**",
84
+ ".cargo/git/checkouts",
85
+ ".cargo/git/checkouts/**",
86
+ ".cargo/git/db",
87
+ ".cargo/git/db/**",
88
+ "go/pkg/mod/cache",
89
+ "go/pkg/mod/cache/**",
90
+ ".config/google-chrome",
91
+ ".config/google-chrome/**",
92
+ ".config/chromium",
93
+ ".config/chromium/**",
94
+ ];
95
+
96
+ export function homeSnapshotExcludes(): string[] {
97
+ return [...EXCLUDED_HOME_PATTERNS];
98
+ }
99
+
100
+ export function shouldIgnoreHomePath(path: string, liveHome: string): boolean {
101
+ const rel = relative(liveHome, path).replaceAll("\\", "/");
102
+ if (!rel || rel === ".") return false;
103
+ if (rel.startsWith("..")) return true;
104
+ return EXCLUDED_HOME_PATTERNS.some((pattern) => {
105
+ if (pattern.endsWith("/**")) {
106
+ const prefix = pattern.slice(0, -3);
107
+ return rel === prefix || rel.startsWith(`${prefix}/`);
108
+ }
109
+ return rel === pattern;
110
+ });
111
+ }
112
+
113
+ export function buildTarCreateArgs(args: {
114
+ liveHome: string;
115
+ archivePath: string;
116
+ }): string[] {
117
+ return [
118
+ "--create",
119
+ "--file",
120
+ args.archivePath,
121
+ "--directory",
122
+ args.liveHome,
123
+ ...EXCLUDED_HOME_PATTERNS.map((pattern) => `--exclude=${pattern}`),
124
+ ".",
125
+ ];
126
+ }
127
+
128
+ export function buildSnapshotName(now: Date, pid = process.pid): string {
129
+ const stamp = now
130
+ .toISOString()
131
+ .replaceAll("-", "")
132
+ .replaceAll(":", "")
133
+ .replace(".", "");
134
+ return `home-${stamp}-${pid}.tar`;
135
+ }
136
+
137
+ export async function ensureHomeSyncDirs(args: {
138
+ liveHome: string;
139
+ snapshotDir: string;
140
+ }): Promise<void> {
141
+ await mkdir(args.liveHome, { recursive: true });
142
+ await mkdir(args.snapshotDir, { recursive: true });
143
+ }
144
+
145
+ export async function listHomeSnapshots(
146
+ snapshotDir: string,
147
+ ): Promise<string[]> {
148
+ let entries: string[];
149
+ try {
150
+ entries = await readdir(snapshotDir);
151
+ } catch {
152
+ return [];
153
+ }
154
+
155
+ const snapshots = await Promise.all(
156
+ entries
157
+ .filter((entry) => /^home-\d{8}T\d{9}Z-\d+\.tar$/.test(entry))
158
+ .map(async (entry) => {
159
+ const path = join(snapshotDir, entry);
160
+ try {
161
+ const info = await stat(path);
162
+ return { path, mtimeMs: info.mtimeMs };
163
+ } catch {
164
+ return null;
165
+ }
166
+ }),
167
+ );
168
+
169
+ return snapshots
170
+ .filter((snapshot): snapshot is { path: string; mtimeMs: number } =>
171
+ Boolean(snapshot),
172
+ )
173
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
174
+ .map((snapshot) => snapshot.path);
175
+ }
176
+
177
+ export async function pruneHomeSnapshots(args: {
178
+ snapshotDir: string;
179
+ keepSnapshots: number;
180
+ }): Promise<string[]> {
181
+ const snapshots = await listHomeSnapshots(args.snapshotDir);
182
+ const stale = snapshots.slice(args.keepSnapshots);
183
+ await Promise.all(stale.map((path) => rm(path, { force: true })));
184
+ return stale;
185
+ }
package/home-sync.ts ADDED
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * dench home sync
4
+ *
5
+ * Persists the sandbox's live home overlay. The live tree lives on
6
+ * /tmp (real overlayfs) so tool installers can use rename(2), symlink(2),
7
+ * chmod, sqlite, keyrings, npm globals, etc. This daemon snapshots that
8
+ * tree into tar archives on /workspace, which avoids Mountpoint-for-S3's
9
+ * symlink/rename limitations while keeping future sandboxes restorable.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { createReadStream, createWriteStream } from "node:fs";
13
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { pipeline } from "node:stream/promises";
17
+ import {
18
+ buildSnapshotName,
19
+ buildTarCreateArgs,
20
+ ensureHomeSyncDirs,
21
+ type HomeSyncArgs,
22
+ parseHomeSyncArgs,
23
+ pruneHomeSnapshots,
24
+ shouldIgnoreHomePath,
25
+ } from "./home-sync-lib";
26
+
27
+ type HomeSyncStatus = {
28
+ pid: number;
29
+ liveHome: string;
30
+ snapshotDir: string;
31
+ lastSnapshotAt: string | null;
32
+ lastSnapshotPath: string | null;
33
+ lastError: string | null;
34
+ pending: boolean;
35
+ inFlight: boolean;
36
+ };
37
+
38
+ const EXIT_CONFIG_ERROR = 78;
39
+
40
+ function runCommand(command: string, args: string[]): Promise<void> {
41
+ return new Promise((resolve, reject) => {
42
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
43
+ let stdout = "";
44
+ let stderr = "";
45
+ child.stdout.on("data", (chunk) => {
46
+ stdout += String(chunk);
47
+ });
48
+ child.stderr.on("data", (chunk) => {
49
+ stderr += String(chunk);
50
+ });
51
+ child.on("error", reject);
52
+ child.on("close", (code) => {
53
+ if (code === 0) {
54
+ resolve();
55
+ return;
56
+ }
57
+ reject(
58
+ new Error(
59
+ `${command} exited ${code ?? "unknown"}: ${stderr || stdout}`.trim(),
60
+ ),
61
+ );
62
+ });
63
+ });
64
+ }
65
+
66
+ async function copyBytesOnly(
67
+ source: string,
68
+ destination: string,
69
+ ): Promise<void> {
70
+ await pipeline(
71
+ createReadStream(source),
72
+ createWriteStream(destination, { flags: "wx" }),
73
+ );
74
+ }
75
+
76
+ async function writeStatus(
77
+ path: string,
78
+ status: HomeSyncStatus,
79
+ ): Promise<void> {
80
+ await writeFile(path, `${JSON.stringify(status, null, 2)}\n`);
81
+ }
82
+
83
+ class HomeSyncDaemon {
84
+ private pendingTimer: NodeJS.Timeout | null = null;
85
+ private pending = false;
86
+ private inFlight = false;
87
+ private status: HomeSyncStatus;
88
+
89
+ constructor(private readonly args: HomeSyncArgs) {
90
+ this.status = {
91
+ pid: process.pid,
92
+ liveHome: args.liveHome,
93
+ snapshotDir: args.snapshotDir,
94
+ lastSnapshotAt: null,
95
+ lastSnapshotPath: null,
96
+ lastError: null,
97
+ pending: false,
98
+ inFlight: false,
99
+ };
100
+ }
101
+
102
+ async start(): Promise<void> {
103
+ await ensureHomeSyncDirs(this.args);
104
+ await writeStatus(this.args.statusPath, this.status);
105
+
106
+ if (this.args.once) {
107
+ await this.snapshotNow("once");
108
+ return;
109
+ }
110
+
111
+ let chokidar: typeof import("chokidar");
112
+ try {
113
+ chokidar = (await import("chokidar")) as never;
114
+ } catch (error) {
115
+ console.error(
116
+ `[dench-home-sync] chokidar not available; refusing to start (${
117
+ error instanceof Error ? error.message : String(error)
118
+ })`,
119
+ );
120
+ process.exit(EXIT_CONFIG_ERROR);
121
+ }
122
+
123
+ const watcher = chokidar.watch(this.args.liveHome, {
124
+ ignoreInitial: true,
125
+ persistent: true,
126
+ usePolling: false,
127
+ awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 },
128
+ ignored: (path: string) => shouldIgnoreHomePath(path, this.args.liveHome),
129
+ });
130
+
131
+ const schedule = (event: string, path: string) => {
132
+ if (shouldIgnoreHomePath(path, this.args.liveHome)) return;
133
+ this.scheduleSnapshot(`${event}:${path}`);
134
+ };
135
+
136
+ watcher.on("add", (path: string) => schedule("add", path));
137
+ watcher.on("change", (path: string) => schedule("change", path));
138
+ watcher.on("unlink", (path: string) => schedule("unlink", path));
139
+ watcher.on("addDir", (path: string) => schedule("addDir", path));
140
+ watcher.on("unlinkDir", (path: string) => schedule("unlinkDir", path));
141
+ watcher.on("error", (error) => {
142
+ console.error(`[dench-home-sync] watcher error: ${String(error)}`);
143
+ this.status.lastError = String(error);
144
+ void writeStatus(this.args.statusPath, this.status);
145
+ });
146
+
147
+ process.on("SIGTERM", () => {
148
+ void this.shutdown(0);
149
+ });
150
+ process.on("SIGINT", () => {
151
+ void this.shutdown(0);
152
+ });
153
+
154
+ console.log(
155
+ `[dench-home-sync] watching ${this.args.liveHome} -> ${this.args.snapshotDir}`,
156
+ );
157
+ }
158
+
159
+ private scheduleSnapshot(reason: string): void {
160
+ this.pending = true;
161
+ this.status.pending = true;
162
+ void writeStatus(this.args.statusPath, this.status);
163
+
164
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
165
+ this.pendingTimer = setTimeout(() => {
166
+ this.pendingTimer = null;
167
+ void this.snapshotNow(reason);
168
+ }, this.args.debounceMs);
169
+ }
170
+
171
+ private async snapshotNow(reason: string): Promise<void> {
172
+ if (this.inFlight) {
173
+ this.scheduleSnapshot(`queued:${reason}`);
174
+ return;
175
+ }
176
+
177
+ this.pending = false;
178
+ this.inFlight = true;
179
+ this.status.pending = false;
180
+ this.status.inFlight = true;
181
+ await writeStatus(this.args.statusPath, this.status);
182
+
183
+ const tempDir = await mkdtemp(join(tmpdir(), "dench-home-sync-"));
184
+ const archivePath = join(tempDir, "home.tar");
185
+ const snapshotName = buildSnapshotName(new Date());
186
+ const durablePath = join(this.args.snapshotDir, snapshotName);
187
+
188
+ try {
189
+ await runCommand(
190
+ "tar",
191
+ buildTarCreateArgs({ liveHome: this.args.liveHome, archivePath }),
192
+ );
193
+ await copyBytesOnly(archivePath, durablePath);
194
+ await pruneHomeSnapshots({
195
+ snapshotDir: this.args.snapshotDir,
196
+ keepSnapshots: this.args.keepSnapshots,
197
+ });
198
+ this.status.lastSnapshotAt = new Date().toISOString();
199
+ this.status.lastSnapshotPath = durablePath;
200
+ this.status.lastError = null;
201
+ console.log(
202
+ `[dench-home-sync] snapshotted home (${reason}) -> ${durablePath}`,
203
+ );
204
+ } catch (error) {
205
+ this.status.lastError =
206
+ error instanceof Error ? error.message : String(error);
207
+ console.error(
208
+ `[dench-home-sync] snapshot failed: ${this.status.lastError}`,
209
+ );
210
+ } finally {
211
+ await rm(tempDir, { recursive: true, force: true });
212
+ this.inFlight = false;
213
+ this.status.inFlight = false;
214
+ this.status.pending = this.pending;
215
+ await writeStatus(this.args.statusPath, this.status);
216
+ }
217
+ }
218
+
219
+ private async shutdown(exitCode: number): Promise<void> {
220
+ if (this.pendingTimer) {
221
+ clearTimeout(this.pendingTimer);
222
+ this.pendingTimer = null;
223
+ }
224
+ if (this.pending || this.inFlight) {
225
+ await this.snapshotNow("shutdown");
226
+ }
227
+ process.exit(exitCode);
228
+ }
229
+ }
230
+
231
+ async function main(): Promise<void> {
232
+ const args = parseHomeSyncArgs(process.argv.slice(2));
233
+ await new HomeSyncDaemon(args).start();
234
+ }
235
+
236
+ main().catch((error) => {
237
+ console.error(
238
+ `[dench-home-sync] fatal: ${
239
+ error instanceof Error ? (error.stack ?? error.message) : String(error)
240
+ }`,
241
+ );
242
+ process.exit(1);
243
+ });