@dench.com/cli 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Binary-content detection for `dench-fs-daemon`.
3
+ *
4
+ * Background: the daemon's `commitFileUpload` historically inlined every
5
+ * file ≤ `MAX_INLINE_TEXT_BYTES` (100 KB) into `fileContents.text` via
6
+ * `TextDecoder("utf-8", { fatal: false }).decode(bytes)`. The
7
+ * `fatal: false` mode silently substitutes U+FFFD (replacement char)
8
+ * for any invalid byte sequence — perfect for ad-hoc text rendering,
9
+ * disastrous when a small PNG / PDF / .zip lands in the cache:
10
+ * • The storage blob is fine (bytes preserved verbatim in `_storage`).
11
+ * • The inline text cache is corrupt (replacement chars everywhere).
12
+ * • The viewer that pulls from `fileContents.text` for fast paths
13
+ * renders garbled mojibake until something forces a re-write.
14
+ *
15
+ * The agent's `commitConvexFileBytes` in chat-turn.ts has had a sibling
16
+ * `detectBinaryBuffer` for a while — this file ports it verbatim so
17
+ * the daemon can route binary blobs to the no-inline path. Keeping it
18
+ * in its own module (no chokidar / Convex deps) means the unit tests
19
+ * can pin behavior without booting the daemon.
20
+ */
21
+
22
+ const BINARY_PROBE_BYTES = 4096;
23
+
24
+ /**
25
+ * Sniff a buffer for binary content. PNG / PDF / Office / .zip all
26
+ * contain NUL bytes within the first KB; valid UTF-8 text never does.
27
+ * We deliberately limit the scan to the first 4 KiB so the check stays
28
+ * cheap on multi-MB blobs, and treat invalid UTF-8 in that prefix as a
29
+ * binary signal too — silently round-tripping garbage Unicode through
30
+ * the inline-text cache is what corrupts the rendered text for
31
+ * downstream viewers.
32
+ *
33
+ * Returns `true` when the buffer looks binary (skip inline text cache),
34
+ * `false` when it looks like clean UTF-8 text (safe to inline).
35
+ */
36
+ export function detectBinaryBuffer(buffer: Uint8Array): boolean {
37
+ const slice =
38
+ buffer.byteLength > BINARY_PROBE_BYTES
39
+ ? buffer.subarray(0, BINARY_PROBE_BYTES)
40
+ : buffer;
41
+ for (const byte of slice) {
42
+ if (byte === 0) return true;
43
+ }
44
+ try {
45
+ new TextDecoder("utf-8", { fatal: true }).decode(slice);
46
+ return false;
47
+ } catch {
48
+ return true;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Convenience: decode bytes to a UTF-8 string ONLY when the buffer
54
+ * looks like clean text. Returns `undefined` for binary content so
55
+ * callers can pass it straight into `commitFileUpload({ text })` —
56
+ * Convex schema marks `text` optional and skips the inline cache when
57
+ * it's missing.
58
+ */
59
+ export function decodeInlineTextOrUndefined(
60
+ buffer: Uint8Array,
61
+ ): string | undefined {
62
+ if (detectBinaryBuffer(buffer)) return undefined;
63
+ return new TextDecoder("utf-8", { fatal: false }).decode(buffer);
64
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pure polling loop for `dench-fs-daemon flush`.
3
+ *
4
+ * Lives in its own module so the unit tests can pull `runFlushPolling`
5
+ * without importing `cli/fs-daemon.ts`'s top-level `main()` (which
6
+ * would try to start the actual daemon and crash the test runner).
7
+ *
8
+ * The flush command's whole job is to make `execute_bash` writes
9
+ * deterministically visible in Convex BEFORE chat-turn returns the
10
+ * tool result to the model. The polling loop is responsible for:
11
+ *
12
+ * 1. Bouncing immediately when no daemon is registered or the
13
+ * registered pid is dead. Otherwise chat-turn would hang for
14
+ * timeoutMs every turn on a host where the daemon never came up.
15
+ * 2. Sending exactly one SIGUSR1 (the daemon's runDaemon registers a
16
+ * handler that triggers an immediate reconcile tick).
17
+ * 3. Treating the flush as complete only when BOTH conditions hold:
18
+ * a) `lastReconcile.finishedAt > t0` (proves the signal-driven
19
+ * tick actually ran and not just an old scheduled tick).
20
+ * b) `pendingDrain.{pendingPaths, inFlightUploads}` are both 0
21
+ * (proves every per-path debounce timer fired AND the upload
22
+ * it triggered drained — without (b) we'd race a pending
23
+ * 300 ms debounce or an in-flight Convex Storage POST).
24
+ * 4. Surfacing a structured timeout result with the last-seen drain
25
+ * counts so the caller (chat-turn) can decide whether to flag
26
+ * drift to the user.
27
+ */
28
+
29
+ /**
30
+ * Snapshot of how much work the daemon still has in flight. The
31
+ * `flush` subcommand polls this via the status file and only declares
32
+ * success when both numbers are zero AND a fresh reconcile has
33
+ * completed since the flush request.
34
+ */
35
+ export type DrainStatus = {
36
+ /** Paths with a debounce timer set but the upload hasn't started. */
37
+ pendingPaths: number;
38
+ /** Upload calls currently mid-flight (between fn() start and finally). */
39
+ inFlightUploads: number;
40
+ };
41
+
42
+ /**
43
+ * Subset of the daemon status file the flush poller needs. The full
44
+ * shape lives in `cli/fs-daemon.ts`; we accept any superset here so
45
+ * upstream changes to the status file don't ripple into this module.
46
+ */
47
+ export type FlushPollingStatus = {
48
+ pid: number;
49
+ lastReconcile: { finishedAt: number } | null;
50
+ pendingDrain: DrainStatus;
51
+ };
52
+
53
+ export type FlushOutcome =
54
+ | { ok: true; reason: "flushed"; pid: number; durationMs: number }
55
+ | { ok: false; reason: "no_daemon"; pid: number | null; durationMs: number }
56
+ | {
57
+ ok: false;
58
+ reason: "signal_failed";
59
+ pid: number;
60
+ durationMs: number;
61
+ error: string;
62
+ }
63
+ | {
64
+ ok: false;
65
+ reason: "timeout";
66
+ pid: number;
67
+ durationMs: number;
68
+ drain: DrainStatus;
69
+ };
70
+
71
+ export const FLUSH_POLL_INTERVAL_MS = 50;
72
+ export const FLUSH_DEFAULT_TIMEOUT_MS = 5_000;
73
+
74
+ export type FlushPollingArgs = {
75
+ timeoutMs: number;
76
+ readStatus: () => Promise<FlushPollingStatus | null>;
77
+ isPidAlive: (pid: number) => boolean;
78
+ signal: (pid: number) => void;
79
+ now?: () => number;
80
+ sleep?: (ms: number) => Promise<void>;
81
+ pollIntervalMs?: number;
82
+ };
83
+
84
+ export async function runFlushPolling(
85
+ args: FlushPollingArgs,
86
+ ): Promise<FlushOutcome> {
87
+ const now = args.now ?? (() => Date.now());
88
+ const sleep =
89
+ args.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
90
+ const pollMs = args.pollIntervalMs ?? FLUSH_POLL_INTERVAL_MS;
91
+ const t0 = now();
92
+
93
+ const initial = await args.readStatus();
94
+ if (!initial) {
95
+ return {
96
+ ok: false,
97
+ reason: "no_daemon",
98
+ pid: null,
99
+ durationMs: now() - t0,
100
+ };
101
+ }
102
+ const pid = initial.pid;
103
+ if (!args.isPidAlive(pid)) {
104
+ return { ok: false, reason: "no_daemon", pid, durationMs: now() - t0 };
105
+ }
106
+ try {
107
+ args.signal(pid);
108
+ } catch (error) {
109
+ return {
110
+ ok: false,
111
+ reason: "signal_failed",
112
+ pid,
113
+ durationMs: now() - t0,
114
+ error: error instanceof Error ? error.message : String(error),
115
+ };
116
+ }
117
+
118
+ const deadline = t0 + args.timeoutMs;
119
+ let lastDrain: DrainStatus = initial.pendingDrain;
120
+ while (now() < deadline) {
121
+ await sleep(pollMs);
122
+ const current = await args.readStatus();
123
+ if (!current) continue;
124
+ lastDrain = current.pendingDrain;
125
+ const finishedAfterT0 = (current.lastReconcile?.finishedAt ?? 0) > t0;
126
+ const drained =
127
+ current.pendingDrain.pendingPaths === 0 &&
128
+ current.pendingDrain.inFlightUploads === 0;
129
+ if (finishedAfterT0 && drained) {
130
+ return { ok: true, reason: "flushed", pid, durationMs: now() - t0 };
131
+ }
132
+ }
133
+ return {
134
+ ok: false,
135
+ reason: "timeout",
136
+ pid,
137
+ durationMs: now() - t0,
138
+ drain: lastDrain,
139
+ };
140
+ }
package/fs-daemon.ts CHANGED
@@ -108,6 +108,7 @@ function shouldIgnore(relPath: string): boolean {
108
108
  const parts = relPath.split("/").filter(Boolean);
109
109
  const blockedDirs = new Set([
110
110
  ".chrome-profile",
111
+ ".dench",
111
112
  ".git",
112
113
  ".next",
113
114
  ".vercel",
package/home-sync ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ const entrypoint = new URL("./home-sync.ts", import.meta.url).href;
4
+
5
+ if (process.versions.bun) {
6
+ await import(entrypoint);
7
+ } else {
8
+ const { tsImport } = await import("tsx/esm/api");
9
+ await tsImport(entrypoint, import.meta.url);
10
+ }
@@ -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,231 @@
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 { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import {
16
+ buildSnapshotName,
17
+ buildTarCreateArgs,
18
+ ensureHomeSyncDirs,
19
+ type HomeSyncArgs,
20
+ parseHomeSyncArgs,
21
+ pruneHomeSnapshots,
22
+ shouldIgnoreHomePath,
23
+ } from "./home-sync-lib";
24
+
25
+ type HomeSyncStatus = {
26
+ pid: number;
27
+ liveHome: string;
28
+ snapshotDir: string;
29
+ lastSnapshotAt: string | null;
30
+ lastSnapshotPath: string | null;
31
+ lastError: string | null;
32
+ pending: boolean;
33
+ inFlight: boolean;
34
+ };
35
+
36
+ const EXIT_CONFIG_ERROR = 78;
37
+
38
+ function runCommand(command: string, args: string[]): Promise<void> {
39
+ return new Promise((resolve, reject) => {
40
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ child.stdout.on("data", (chunk) => {
44
+ stdout += String(chunk);
45
+ });
46
+ child.stderr.on("data", (chunk) => {
47
+ stderr += String(chunk);
48
+ });
49
+ child.on("error", reject);
50
+ child.on("close", (code) => {
51
+ if (code === 0) {
52
+ resolve();
53
+ return;
54
+ }
55
+ reject(
56
+ new Error(
57
+ `${command} exited ${code ?? "unknown"}: ${stderr || stdout}`.trim(),
58
+ ),
59
+ );
60
+ });
61
+ });
62
+ }
63
+
64
+ async function writeStatus(
65
+ path: string,
66
+ status: HomeSyncStatus,
67
+ ): Promise<void> {
68
+ await writeFile(path, `${JSON.stringify(status, null, 2)}\n`);
69
+ }
70
+
71
+ class HomeSyncDaemon {
72
+ private pendingTimer: NodeJS.Timeout | null = null;
73
+ private pending = false;
74
+ private inFlight = false;
75
+ private status: HomeSyncStatus;
76
+
77
+ constructor(private readonly args: HomeSyncArgs) {
78
+ this.status = {
79
+ pid: process.pid,
80
+ liveHome: args.liveHome,
81
+ snapshotDir: args.snapshotDir,
82
+ lastSnapshotAt: null,
83
+ lastSnapshotPath: null,
84
+ lastError: null,
85
+ pending: false,
86
+ inFlight: false,
87
+ };
88
+ }
89
+
90
+ async start(): Promise<void> {
91
+ await ensureHomeSyncDirs(this.args);
92
+ await writeStatus(this.args.statusPath, this.status);
93
+
94
+ if (this.args.once) {
95
+ await this.snapshotNow("once");
96
+ return;
97
+ }
98
+
99
+ let chokidar: typeof import("chokidar");
100
+ try {
101
+ chokidar = (await import("chokidar")) as never;
102
+ } catch (error) {
103
+ console.error(
104
+ `[dench-home-sync] chokidar not available; refusing to start (${
105
+ error instanceof Error ? error.message : String(error)
106
+ })`,
107
+ );
108
+ process.exit(EXIT_CONFIG_ERROR);
109
+ }
110
+
111
+ const watcher = chokidar.watch(this.args.liveHome, {
112
+ ignoreInitial: true,
113
+ persistent: true,
114
+ usePolling: false,
115
+ awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 },
116
+ ignored: (path: string) => shouldIgnoreHomePath(path, this.args.liveHome),
117
+ });
118
+
119
+ const schedule = (event: string, path: string) => {
120
+ if (shouldIgnoreHomePath(path, this.args.liveHome)) return;
121
+ this.scheduleSnapshot(`${event}:${path}`);
122
+ };
123
+
124
+ watcher.on("add", (path: string) => schedule("add", path));
125
+ watcher.on("change", (path: string) => schedule("change", path));
126
+ watcher.on("unlink", (path: string) => schedule("unlink", path));
127
+ watcher.on("addDir", (path: string) => schedule("addDir", path));
128
+ watcher.on("unlinkDir", (path: string) => schedule("unlinkDir", path));
129
+ watcher.on("error", (error) => {
130
+ console.error(`[dench-home-sync] watcher error: ${String(error)}`);
131
+ this.status.lastError = String(error);
132
+ void writeStatus(this.args.statusPath, this.status);
133
+ });
134
+
135
+ process.on("SIGTERM", () => {
136
+ void this.shutdown(0);
137
+ });
138
+ process.on("SIGINT", () => {
139
+ void this.shutdown(0);
140
+ });
141
+
142
+ console.log(
143
+ `[dench-home-sync] watching ${this.args.liveHome} -> ${this.args.snapshotDir}`,
144
+ );
145
+ }
146
+
147
+ private scheduleSnapshot(reason: string): void {
148
+ this.pending = true;
149
+ this.status.pending = true;
150
+ void writeStatus(this.args.statusPath, this.status);
151
+
152
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
153
+ this.pendingTimer = setTimeout(() => {
154
+ this.pendingTimer = null;
155
+ void this.snapshotNow(reason);
156
+ }, this.args.debounceMs);
157
+ }
158
+
159
+ private async snapshotNow(reason: string): Promise<void> {
160
+ if (this.inFlight) {
161
+ this.scheduleSnapshot(`queued:${reason}`);
162
+ return;
163
+ }
164
+
165
+ this.pending = false;
166
+ this.inFlight = true;
167
+ this.status.pending = false;
168
+ this.status.inFlight = true;
169
+ await writeStatus(this.args.statusPath, this.status);
170
+
171
+ const tempDir = await mkdtemp(join(tmpdir(), "dench-home-sync-"));
172
+ const archivePath = join(tempDir, "home.tar");
173
+ const snapshotName = buildSnapshotName(new Date());
174
+ const durablePath = join(this.args.snapshotDir, snapshotName);
175
+
176
+ try {
177
+ await runCommand(
178
+ "tar",
179
+ buildTarCreateArgs({ liveHome: this.args.liveHome, archivePath }),
180
+ );
181
+ await cp(archivePath, durablePath, { force: false });
182
+ await pruneHomeSnapshots({
183
+ snapshotDir: this.args.snapshotDir,
184
+ keepSnapshots: this.args.keepSnapshots,
185
+ });
186
+ this.status.lastSnapshotAt = new Date().toISOString();
187
+ this.status.lastSnapshotPath = durablePath;
188
+ this.status.lastError = null;
189
+ console.log(
190
+ `[dench-home-sync] snapshotted home (${reason}) -> ${durablePath}`,
191
+ );
192
+ } catch (error) {
193
+ this.status.lastError =
194
+ error instanceof Error ? error.message : String(error);
195
+ console.error(
196
+ `[dench-home-sync] snapshot failed: ${this.status.lastError}`,
197
+ );
198
+ } finally {
199
+ await rm(tempDir, { recursive: true, force: true });
200
+ this.inFlight = false;
201
+ this.status.inFlight = false;
202
+ this.status.pending = this.pending;
203
+ await writeStatus(this.args.statusPath, this.status);
204
+ }
205
+ }
206
+
207
+ private async shutdown(exitCode: number): Promise<void> {
208
+ if (this.pendingTimer) {
209
+ clearTimeout(this.pendingTimer);
210
+ this.pendingTimer = null;
211
+ }
212
+ if (this.pending || this.inFlight) {
213
+ await this.snapshotNow("shutdown");
214
+ }
215
+ process.exit(exitCode);
216
+ }
217
+ }
218
+
219
+ async function main(): Promise<void> {
220
+ const args = parseHomeSyncArgs(process.argv.slice(2));
221
+ await new HomeSyncDaemon(args).start();
222
+ }
223
+
224
+ main().catch((error) => {
225
+ console.error(
226
+ `[dench-home-sync] fatal: ${
227
+ error instanceof Error ? (error.stack ?? error.message) : String(error)
228
+ }`,
229
+ );
230
+ process.exit(1);
231
+ });