@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.
- package/chat.ts +33 -8
- package/crm.ts +388 -17
- package/dench +4 -0
- package/dench.mjs +4 -0
- package/dench.ts +24 -0
- package/fs-daemon-binary.ts +64 -0
- package/fs-daemon-flush.ts +140 -0
- package/fs-daemon.ts +1 -0
- package/home-sync +10 -0
- package/home-sync-lib.ts +185 -0
- package/home-sync.ts +231 -0
- package/lib/crm-enrichment.ts +431 -0
- package/lib/enrichment-gateway.ts +279 -0
- package/lib/load-dev-env.mjs +102 -0
- package/package.json +8 -2
|
@@ -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
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
|
+
}
|
package/home-sync-lib.ts
ADDED
|
@@ -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
|
+
});
|