@dench.com/cli 0.3.2 → 0.3.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/README.md +24 -0
- package/agent.ts +391 -0
- package/chat-spawn.ts +370 -0
- package/chat.ts +216 -0
- package/dench.ts +467 -2
- package/fs-daemon-mount.ts +133 -0
- package/fs-daemon.ts +790 -23
- package/package.json +5 -1
package/dench.ts
CHANGED
|
@@ -179,6 +179,21 @@ const api = {
|
|
|
179
179
|
"functions/agentToolsNode:agentToolStatus",
|
|
180
180
|
),
|
|
181
181
|
},
|
|
182
|
+
files: {
|
|
183
|
+
listTree: makeFunctionReference<"query">("functions/files:listTree"),
|
|
184
|
+
deleteFile: makeFunctionReference<"mutation">(
|
|
185
|
+
"functions/files:deleteFile",
|
|
186
|
+
),
|
|
187
|
+
generateUploadUrl: makeFunctionReference<"mutation">(
|
|
188
|
+
"functions/files:generateUploadUrl",
|
|
189
|
+
),
|
|
190
|
+
commitFileUpload: makeFunctionReference<"mutation">(
|
|
191
|
+
"functions/files:commitFileUpload",
|
|
192
|
+
),
|
|
193
|
+
getFileSignedDownloadUrl: makeFunctionReference<"action">(
|
|
194
|
+
"functions/files:getFileSignedDownloadUrl",
|
|
195
|
+
),
|
|
196
|
+
},
|
|
182
197
|
},
|
|
183
198
|
};
|
|
184
199
|
const CONFIG_DIR = join(homedir(), ".dench");
|
|
@@ -233,6 +248,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
233
248
|
"--all",
|
|
234
249
|
"--compact",
|
|
235
250
|
"--dev",
|
|
251
|
+
"--follow",
|
|
236
252
|
"--help",
|
|
237
253
|
"--mine",
|
|
238
254
|
"--self",
|
|
@@ -240,6 +256,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
240
256
|
"--prod",
|
|
241
257
|
"--no-open",
|
|
242
258
|
"--version",
|
|
259
|
+
"--yolo",
|
|
243
260
|
"--ask-before-publishing",
|
|
244
261
|
"--ask-before-spending",
|
|
245
262
|
"--ask-before-external-messages",
|
|
@@ -391,7 +408,7 @@ Usage:
|
|
|
391
408
|
dench tool connect <toolkit> [--json]
|
|
392
409
|
dench tool search "create github issue" [--toolkit github] [--limit 20] [--compact] [--json]
|
|
393
410
|
dench tool run <composio_tool_slug> --args '{"key":"value"}' [--account <connected_account_id>] [--approval <approvalId>] [--json]
|
|
394
|
-
dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--json]
|
|
411
|
+
dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--yolo] [--json]
|
|
395
412
|
|
|
396
413
|
CRM (Daytona-native rewrite):
|
|
397
414
|
dench crm objects <list|get|create|update|rename|delete> ...
|
|
@@ -413,12 +430,32 @@ CRM (Daytona-native rewrite):
|
|
|
413
430
|
dench crm batch --file ops.jsonl
|
|
414
431
|
Help: dench crm help
|
|
415
432
|
|
|
433
|
+
Past chat threads (own + shared in this workspace):
|
|
434
|
+
dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
|
|
435
|
+
dench chat search "<query>" [--limit N] [--include-current] [--json]
|
|
436
|
+
dench chat read <thread-id> [--limit N] [--json]
|
|
437
|
+
|
|
438
|
+
Spawn a new chat-turn workflow (same as the web UI):
|
|
439
|
+
dench chat new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
|
|
440
|
+
dench chat send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
|
|
441
|
+
dench agent new ... (alias for dench chat new)
|
|
442
|
+
dench agent send ... (alias for dench chat send)
|
|
443
|
+
|
|
444
|
+
Help: dench chat help
|
|
445
|
+
|
|
416
446
|
File sync (Daytona-native rewrite):
|
|
447
|
+
dench fs status [--json] [--workspace /workspace] [--no-hash] [--drift-limit N]
|
|
417
448
|
dench fs sync --initial --org-id <orgId> [--workspace /workspace]
|
|
418
449
|
dench fs stream-open <path>
|
|
419
450
|
dench fs stream-append <token> <chunk>
|
|
420
451
|
dench fs stream-close <token>
|
|
421
452
|
|
|
453
|
+
Workspace files (use these instead of mv/rm in /workspace; rename(2)
|
|
454
|
+
returns ENOSYS on the FUSE-backed volume mount):
|
|
455
|
+
dench files ls <path> [--json]
|
|
456
|
+
dench files mv <src> <dst> [--json]
|
|
457
|
+
dench files rm <path> [--recursive] [--json]
|
|
458
|
+
|
|
422
459
|
Subagents (Daytona-native rewrite):
|
|
423
460
|
dench agent spawn '<goal>' [--parent-run-id <id>] [--sandbox own|share_parent] [--time-budget-ms N]
|
|
424
461
|
dench agent await --hook <token> | --children id1,id2,id3
|
|
@@ -491,7 +528,7 @@ function autonomousHelp() {
|
|
|
491
528
|
console.log(`Dench Long Session commands
|
|
492
529
|
|
|
493
530
|
Usage:
|
|
494
|
-
dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--json]
|
|
531
|
+
dench autonomous run "Inspect this repo and summarize the relevant feature. Do not edit files, publish, deploy, spend money, send external messages, access secrets, or change production data." [--duration 1h] [--model <model>] [--api-base http://localhost:3000] [--yolo] [--json]
|
|
495
532
|
|
|
496
533
|
Notes:
|
|
497
534
|
Starts a Long Session through /api/runs.
|
|
@@ -501,6 +538,8 @@ Notes:
|
|
|
501
538
|
If needed, run dench login --host <api-base> once so the CLI can authenticate.
|
|
502
539
|
Omit --model to use the server default.
|
|
503
540
|
Approval flags: --ask-before-publishing --ask-before-spending --ask-before-external-messages --ask-before-production-changes --ask-before-secrets.
|
|
541
|
+
--yolo: skip every approval gate (overrides --ask-before-* / --approval-rules). Equivalent to the workspace's YOLO mode in /<slug>/settings -> Approvals.
|
|
542
|
+
Without any approval flags, the run inherits the workspace's Approvals policy from /<slug>/settings.
|
|
504
543
|
Safe goals should explicitly say: ${SAFE_LONG_SESSION_CONSTRAINTS.join(", ")}.
|
|
505
544
|
`);
|
|
506
545
|
}
|
|
@@ -1417,6 +1456,9 @@ function durationPreset(rawDuration?: string) {
|
|
|
1417
1456
|
}
|
|
1418
1457
|
|
|
1419
1458
|
function parseApprovalRules() {
|
|
1459
|
+
// --yolo wins over everything else: every approval gate is off,
|
|
1460
|
+
// mirroring the org-level YOLO mode (settings -> Approvals).
|
|
1461
|
+
if (hasFlag("--yolo")) return [];
|
|
1420
1462
|
const explicit = option("--approval-rules")
|
|
1421
1463
|
?.split(",")
|
|
1422
1464
|
.map((rule) => rule.trim())
|
|
@@ -2663,6 +2705,354 @@ async function runFsCommand() {
|
|
|
2663
2705
|
}
|
|
2664
2706
|
}
|
|
2665
2707
|
|
|
2708
|
+
// ── dench files <ls|mv|rm> ────────────────────────────────────────────────
|
|
2709
|
+
//
|
|
2710
|
+
// Escape hatches the model needs every time something on /workspace
|
|
2711
|
+
// can't be done with `mv` / `rm` — Mountpoint-S3 doesn't implement
|
|
2712
|
+
// rename(2) and the file daemon needs a hint to prune Convex rows.
|
|
2713
|
+
// All three commands take + emit POSIX paths under /workspace.
|
|
2714
|
+
|
|
2715
|
+
function normalizeFilesPath(input: string): string {
|
|
2716
|
+
if (!input || input === "/") return "/";
|
|
2717
|
+
let cleaned = input.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
2718
|
+
if (cleaned.startsWith("/workspace/")) cleaned = cleaned.slice("/workspace".length);
|
|
2719
|
+
else if (cleaned === "/workspace") cleaned = "/";
|
|
2720
|
+
if (!cleaned.startsWith("/")) cleaned = `/${cleaned}`;
|
|
2721
|
+
if (cleaned.length > 1 && cleaned.endsWith("/")) cleaned = cleaned.slice(0, -1);
|
|
2722
|
+
return cleaned;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
function filesApiKey(runtime: Runtime): string | undefined {
|
|
2726
|
+
if (runtime.mode === "apiKey") return runtime.sessionToken;
|
|
2727
|
+
return process.env.DENCH_API_KEY?.trim() || undefined;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function requireFilesApiKey(runtime: Runtime, command: string): string {
|
|
2731
|
+
const apiKey = filesApiKey(runtime);
|
|
2732
|
+
if (!apiKey) {
|
|
2733
|
+
throw new CliError(
|
|
2734
|
+
`${command} needs DENCH_API_KEY (or a sandbox-baked api-key session). It bypasses the user-session auth path.`,
|
|
2735
|
+
{
|
|
2736
|
+
code: "files_api_key_required",
|
|
2737
|
+
nextActions: [
|
|
2738
|
+
"Run inside a Dench sandbox where DENCH_API_KEY is baked in.",
|
|
2739
|
+
"Or export DENCH_API_KEY=<key> first (see dench login output).",
|
|
2740
|
+
],
|
|
2741
|
+
},
|
|
2742
|
+
);
|
|
2743
|
+
}
|
|
2744
|
+
return apiKey;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
type FileTreeRow = {
|
|
2748
|
+
path: string;
|
|
2749
|
+
contentHash?: string;
|
|
2750
|
+
isDir: boolean;
|
|
2751
|
+
size?: number;
|
|
2752
|
+
mtime?: number;
|
|
2753
|
+
};
|
|
2754
|
+
|
|
2755
|
+
async function listConvexTreeAt(
|
|
2756
|
+
runtime: Runtime,
|
|
2757
|
+
apiKey: string,
|
|
2758
|
+
prefix: string,
|
|
2759
|
+
): Promise<FileTreeRow[]> {
|
|
2760
|
+
return (await runtime.client.query(api.functions.files.listTree, {
|
|
2761
|
+
prefix,
|
|
2762
|
+
apiKey,
|
|
2763
|
+
} as never)) as FileTreeRow[];
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
async function listConvexTreeRecursive(
|
|
2767
|
+
runtime: Runtime,
|
|
2768
|
+
apiKey: string,
|
|
2769
|
+
prefix: string,
|
|
2770
|
+
): Promise<FileTreeRow[]> {
|
|
2771
|
+
const rows = await listConvexTreeAt(runtime, apiKey, prefix);
|
|
2772
|
+
const out: FileTreeRow[] = [...rows];
|
|
2773
|
+
for (const row of rows) {
|
|
2774
|
+
if (row.isDir) {
|
|
2775
|
+
const children = await listConvexTreeRecursive(runtime, apiKey, row.path);
|
|
2776
|
+
out.push(...children);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
return out;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
async function downloadConvexFileBytes(
|
|
2783
|
+
runtime: Runtime,
|
|
2784
|
+
apiKey: string,
|
|
2785
|
+
path: string,
|
|
2786
|
+
): Promise<Uint8Array | null> {
|
|
2787
|
+
const url = (await runtime.client.action(
|
|
2788
|
+
api.functions.files.getFileSignedDownloadUrl,
|
|
2789
|
+
{ path, apiKey } as never,
|
|
2790
|
+
)) as string | null;
|
|
2791
|
+
if (!url) return null;
|
|
2792
|
+
const response = await fetch(url);
|
|
2793
|
+
if (!response.ok) {
|
|
2794
|
+
throw new Error(
|
|
2795
|
+
`signed download failed (${response.status}): ${await response
|
|
2796
|
+
.text()
|
|
2797
|
+
.catch(() => "")}`,
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
async function uploadConvexFileBytes(
|
|
2804
|
+
runtime: Runtime,
|
|
2805
|
+
apiKey: string,
|
|
2806
|
+
args: { path: string; bytes: Uint8Array },
|
|
2807
|
+
): Promise<{ path: string; storageId: string; contentHash: string }> {
|
|
2808
|
+
const uploadUrl = (await runtime.client.mutation(
|
|
2809
|
+
api.functions.files.generateUploadUrl,
|
|
2810
|
+
{ path: args.path, apiKey } as never,
|
|
2811
|
+
)) as string;
|
|
2812
|
+
const response = await fetch(uploadUrl, {
|
|
2813
|
+
method: "POST",
|
|
2814
|
+
headers: { "content-type": "application/octet-stream" },
|
|
2815
|
+
body: new Blob([args.bytes as BlobPart]),
|
|
2816
|
+
});
|
|
2817
|
+
if (!response.ok) {
|
|
2818
|
+
throw new Error(
|
|
2819
|
+
`convex storage upload failed (${response.status}): ${await response
|
|
2820
|
+
.text()
|
|
2821
|
+
.catch(() => "")}`,
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
const { storageId } = (await response.json()) as { storageId: string };
|
|
2825
|
+
const hash = sha256HexSync(args.bytes);
|
|
2826
|
+
const inlineEligible = args.bytes.byteLength <= 100_000;
|
|
2827
|
+
const inlineText = inlineEligible
|
|
2828
|
+
? new TextDecoder("utf-8", { fatal: false }).decode(args.bytes)
|
|
2829
|
+
: undefined;
|
|
2830
|
+
await runtime.client.mutation(api.functions.files.commitFileUpload, {
|
|
2831
|
+
path: args.path,
|
|
2832
|
+
storageId,
|
|
2833
|
+
contentHash: hash,
|
|
2834
|
+
size: args.bytes.byteLength,
|
|
2835
|
+
text: inlineText,
|
|
2836
|
+
lastModifiedBy: "cli",
|
|
2837
|
+
apiKey,
|
|
2838
|
+
} as never);
|
|
2839
|
+
return { path: args.path, storageId, contentHash: hash };
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
async function runFilesLs() {
|
|
2843
|
+
const runtime = await getRuntime();
|
|
2844
|
+
const apiKey = requireFilesApiKey(runtime, "dench files ls");
|
|
2845
|
+
const target = normalizeFilesPath(positional(2) ?? "/");
|
|
2846
|
+
const recursive = hasFlag("--recursive") || hasFlag("-r");
|
|
2847
|
+
const rows = recursive
|
|
2848
|
+
? await listConvexTreeRecursive(runtime, apiKey, target)
|
|
2849
|
+
: await listConvexTreeAt(runtime, apiKey, target);
|
|
2850
|
+
if (hasFlag("--json")) {
|
|
2851
|
+
print({
|
|
2852
|
+
ok: true,
|
|
2853
|
+
prefix: target,
|
|
2854
|
+
recursive,
|
|
2855
|
+
count: rows.length,
|
|
2856
|
+
entries: rows.map((row) => ({
|
|
2857
|
+
path: row.path,
|
|
2858
|
+
isDir: row.isDir,
|
|
2859
|
+
size: row.size,
|
|
2860
|
+
mtime: row.mtime,
|
|
2861
|
+
contentHash: row.contentHash,
|
|
2862
|
+
})),
|
|
2863
|
+
});
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
if (rows.length === 0) {
|
|
2867
|
+
process.stdout.write(`(no entries under ${target})\n`);
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
for (const row of rows) {
|
|
2871
|
+
const kind = row.isDir ? "DIR " : "FILE";
|
|
2872
|
+
const size = row.isDir
|
|
2873
|
+
? " -"
|
|
2874
|
+
: (row.size ?? 0).toString().padStart(8, " ");
|
|
2875
|
+
process.stdout.write(`${kind} ${size} ${row.path}\n`);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
async function runFilesMv() {
|
|
2880
|
+
const runtime = await getRuntime();
|
|
2881
|
+
const apiKey = requireFilesApiKey(runtime, "dench files mv");
|
|
2882
|
+
const fromRaw = positional(2);
|
|
2883
|
+
const toRaw = positional(3);
|
|
2884
|
+
if (!fromRaw || !toRaw) {
|
|
2885
|
+
throw new CliError("Usage: dench files mv <src> <dst>", {
|
|
2886
|
+
code: "files_mv_usage",
|
|
2887
|
+
nextActions: ["Pass both source and destination paths."],
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
const from = normalizeFilesPath(fromRaw);
|
|
2891
|
+
const to = normalizeFilesPath(toRaw);
|
|
2892
|
+
if (from === to) {
|
|
2893
|
+
throw new CliError("Source and destination are the same path", {
|
|
2894
|
+
code: "files_mv_noop",
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
const sourceRows = await listConvexTreeRecursive(runtime, apiKey, from);
|
|
2899
|
+
const sourceSelf = (await listConvexTreeAt(runtime, apiKey, parentOfPath(from))).find(
|
|
2900
|
+
(row) => row.path === from,
|
|
2901
|
+
);
|
|
2902
|
+
if (!sourceSelf && sourceRows.length === 0) {
|
|
2903
|
+
throw new CliError(`Source not found in Convex: ${from}`, {
|
|
2904
|
+
code: "files_mv_not_found",
|
|
2905
|
+
nextActions: [
|
|
2906
|
+
"Run `dench files ls` on the parent directory to confirm.",
|
|
2907
|
+
"If the file only exists on the sandbox FS, run the daemon (or `dench fs sync --initial`) first.",
|
|
2908
|
+
],
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
const moved: Array<{ from: string; to: string }> = [];
|
|
2913
|
+
if (sourceSelf && !sourceSelf.isDir) {
|
|
2914
|
+
const bytes = await downloadConvexFileBytes(runtime, apiKey, from);
|
|
2915
|
+
if (!bytes) {
|
|
2916
|
+
throw new CliError(`Could not download bytes for ${from}`, {
|
|
2917
|
+
code: "files_mv_download",
|
|
2918
|
+
});
|
|
2919
|
+
}
|
|
2920
|
+
await uploadConvexFileBytes(runtime, apiKey, { path: to, bytes });
|
|
2921
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
2922
|
+
path: from,
|
|
2923
|
+
apiKey,
|
|
2924
|
+
} as never);
|
|
2925
|
+
moved.push({ from, to });
|
|
2926
|
+
} else {
|
|
2927
|
+
for (const row of sourceRows) {
|
|
2928
|
+
if (row.isDir) continue;
|
|
2929
|
+
const rel = row.path.slice(from.length).replace(/^\/+/, "");
|
|
2930
|
+
const dest = rel ? `${to}/${rel}` : to;
|
|
2931
|
+
const bytes = await downloadConvexFileBytes(runtime, apiKey, row.path);
|
|
2932
|
+
if (!bytes) continue;
|
|
2933
|
+
await uploadConvexFileBytes(runtime, apiKey, { path: dest, bytes });
|
|
2934
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
2935
|
+
path: row.path,
|
|
2936
|
+
apiKey,
|
|
2937
|
+
} as never);
|
|
2938
|
+
moved.push({ from: row.path, to: dest });
|
|
2939
|
+
}
|
|
2940
|
+
if (sourceSelf?.isDir) {
|
|
2941
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
2942
|
+
path: from,
|
|
2943
|
+
apiKey,
|
|
2944
|
+
} as never);
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
print({ ok: true, from, to, moved });
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
async function runFilesRm() {
|
|
2952
|
+
const runtime = await getRuntime();
|
|
2953
|
+
const apiKey = requireFilesApiKey(runtime, "dench files rm");
|
|
2954
|
+
const targetRaw = positional(2);
|
|
2955
|
+
if (!targetRaw) {
|
|
2956
|
+
throw new CliError("Usage: dench files rm <path> [--recursive]", {
|
|
2957
|
+
code: "files_rm_usage",
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
const target = normalizeFilesPath(targetRaw);
|
|
2961
|
+
const recursive = hasFlag("--recursive") || hasFlag("-r");
|
|
2962
|
+
|
|
2963
|
+
const parentRows = await listConvexTreeAt(runtime, apiKey, parentOfPath(target));
|
|
2964
|
+
const self = parentRows.find((row) => row.path === target);
|
|
2965
|
+
if (!self) {
|
|
2966
|
+
if (hasFlag("--json")) {
|
|
2967
|
+
print({ ok: true, path: target, removedFiles: 0, removedDirs: 0 });
|
|
2968
|
+
} else {
|
|
2969
|
+
process.stdout.write(
|
|
2970
|
+
`dench files rm: ${target} not present in Convex (already absent)\n`,
|
|
2971
|
+
);
|
|
2972
|
+
}
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
if (self.isDir && !recursive) {
|
|
2976
|
+
throw new CliError(
|
|
2977
|
+
`${target} is a directory; pass --recursive to delete it.`,
|
|
2978
|
+
{ code: "files_rm_recursive" },
|
|
2979
|
+
);
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
const toDelete = self.isDir
|
|
2983
|
+
? await listConvexTreeRecursive(runtime, apiKey, target)
|
|
2984
|
+
: [];
|
|
2985
|
+
const fileRows = toDelete.filter((row) => !row.isDir);
|
|
2986
|
+
const dirRows = toDelete.filter((row) => row.isDir);
|
|
2987
|
+
// Children first, parents last.
|
|
2988
|
+
dirRows.sort((a, b) => b.path.length - a.path.length);
|
|
2989
|
+
|
|
2990
|
+
for (const row of fileRows) {
|
|
2991
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
2992
|
+
path: row.path,
|
|
2993
|
+
apiKey,
|
|
2994
|
+
} as never);
|
|
2995
|
+
}
|
|
2996
|
+
for (const row of dirRows) {
|
|
2997
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
2998
|
+
path: row.path,
|
|
2999
|
+
apiKey,
|
|
3000
|
+
} as never);
|
|
3001
|
+
}
|
|
3002
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
3003
|
+
path: target,
|
|
3004
|
+
apiKey,
|
|
3005
|
+
} as never);
|
|
3006
|
+
|
|
3007
|
+
print({
|
|
3008
|
+
ok: true,
|
|
3009
|
+
path: target,
|
|
3010
|
+
removedFiles: fileRows.length + (self.isDir ? 0 : 1),
|
|
3011
|
+
removedDirs: dirRows.length + (self.isDir ? 1 : 0),
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
async function runFilesCommand() {
|
|
3016
|
+
const sub = positional(1);
|
|
3017
|
+
if (!sub || sub === "help" || hasFlag("--help")) {
|
|
3018
|
+
process.stdout.write(
|
|
3019
|
+
[
|
|
3020
|
+
"dench files <ls|mv|rm> — workspace file ops that update the UI tree",
|
|
3021
|
+
"",
|
|
3022
|
+
" dench files ls [<path>] [--recursive] [--json]",
|
|
3023
|
+
" dench files mv <src> <dst> [--json]",
|
|
3024
|
+
" dench files rm <path> [--recursive] [--json]",
|
|
3025
|
+
"",
|
|
3026
|
+
"Paths are POSIX paths under /workspace; you can pass either form.",
|
|
3027
|
+
"These mutate Convex directly (and the daemon syncs to the volume).",
|
|
3028
|
+
].join("\n"),
|
|
3029
|
+
);
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3032
|
+
if (sub === "ls") return await runFilesLs();
|
|
3033
|
+
if (sub === "mv") return await runFilesMv();
|
|
3034
|
+
if (sub === "rm") return await runFilesRm();
|
|
3035
|
+
throw new CliError(`Unknown subcommand: dench files ${sub}`, {
|
|
3036
|
+
code: "files_unknown_subcommand",
|
|
3037
|
+
nextActions: ["Run dench files help for usage."],
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
function parentOfPath(input: string): string {
|
|
3042
|
+
const normalized = normalizeFilesPath(input);
|
|
3043
|
+
if (normalized === "/") return "/";
|
|
3044
|
+
const idx = normalized.lastIndexOf("/");
|
|
3045
|
+
return idx <= 0 ? "/" : normalized.slice(0, idx);
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function sha256HexSync(bytes: Uint8Array): string {
|
|
3049
|
+
// Used by the dench files commands so we can compute the hash
|
|
3050
|
+
// alongside the upload synchronously. Bun + Node both ship
|
|
3051
|
+
// node:crypto, so this is free.
|
|
3052
|
+
const { createHash } = require("node:crypto") as typeof import("node:crypto");
|
|
3053
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
3054
|
+
}
|
|
3055
|
+
|
|
2666
3056
|
async function spawnInherited(binary: string, commandArgs: string[]) {
|
|
2667
3057
|
return await new Promise<number>((resolve, reject) => {
|
|
2668
3058
|
const child = spawn(binary, commandArgs, {
|
|
@@ -2815,6 +3205,60 @@ async function main() {
|
|
|
2815
3205
|
return;
|
|
2816
3206
|
}
|
|
2817
3207
|
|
|
3208
|
+
if (command === "files") {
|
|
3209
|
+
await runFilesCommand();
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
if (command === "chat") {
|
|
3214
|
+
const subArgs = args.slice(args.indexOf("chat") + 1);
|
|
3215
|
+
const sub = subArgs[0];
|
|
3216
|
+
if (!sub || sub === "help" || sub === "--help") {
|
|
3217
|
+
const { runChatCommand } = await import("./chat");
|
|
3218
|
+
// biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub
|
|
3219
|
+
await runChatCommand({ convex: {} as any, args: subArgs });
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
3222
|
+
// `chat new` / `chat send` spawn a chat-turn workflow via
|
|
3223
|
+
// /api/chat/cli (the same workflow the web UI uses). They need
|
|
3224
|
+
// an HTTP host + bearer token, not a Convex client. The
|
|
3225
|
+
// remaining subcommands (list / search / read) are Convex
|
|
3226
|
+
// queries and keep going through `runChatCommand`.
|
|
3227
|
+
if (sub === "new" || sub === "send") {
|
|
3228
|
+
const { runChatSpawnCommand } = await import("./chat-spawn");
|
|
3229
|
+
const runtime = await getRuntime();
|
|
3230
|
+
const authedRuntime = requireAuthenticatedRuntime(
|
|
3231
|
+
runtime,
|
|
3232
|
+
`dench chat ${sub}`,
|
|
3233
|
+
);
|
|
3234
|
+
// Drop the leading subcommand from subArgs — runChatSpawnCommand
|
|
3235
|
+
// expects the remaining tokens (prompt + flags).
|
|
3236
|
+
await runChatSpawnCommand({
|
|
3237
|
+
runtime: {
|
|
3238
|
+
host: authedRuntime.host,
|
|
3239
|
+
bearerToken: authedRuntime.sessionToken,
|
|
3240
|
+
},
|
|
3241
|
+
args: subArgs.slice(1),
|
|
3242
|
+
subcommand: sub,
|
|
3243
|
+
});
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
const { runChatCommand } = await import("./chat");
|
|
3247
|
+
const runtime = await getRuntime();
|
|
3248
|
+
// Past-chat queries are org+user-scoped: the server resolves the
|
|
3249
|
+
// user from the session token, OR from `runId` when authenticating
|
|
3250
|
+
// with a Dench API key. Pass DENCH_RUN_ID through automatically so
|
|
3251
|
+
// `dench chat …` works inside a sandbox without extra flags.
|
|
3252
|
+
const authedRuntime = requireAuthenticatedRuntime(runtime, "dench chat");
|
|
3253
|
+
await runChatCommand({
|
|
3254
|
+
convex: authedRuntime.client,
|
|
3255
|
+
args: subArgs,
|
|
3256
|
+
sessionToken: authedRuntime.sessionToken,
|
|
3257
|
+
runId: process.env.DENCH_RUN_ID?.trim() || undefined,
|
|
3258
|
+
});
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
|
|
2818
3262
|
if (command === "agent") {
|
|
2819
3263
|
const subArgs = args.slice(args.indexOf("agent") + 1);
|
|
2820
3264
|
const sub = subArgs[0];
|
|
@@ -2824,6 +3268,27 @@ async function main() {
|
|
|
2824
3268
|
await runAgentCommand({ convex: {} as any, args: subArgs });
|
|
2825
3269
|
return;
|
|
2826
3270
|
}
|
|
3271
|
+
// Aliases: `dench agent new` / `dench agent send` -> the chat-spawn
|
|
3272
|
+
// implementation. Surfaced under `agent` so users who associate
|
|
3273
|
+
// chat threads with the agent surface still find them; the actual
|
|
3274
|
+
// workflow is identical to `dench chat new` / `dench chat send`.
|
|
3275
|
+
if (sub === "new" || sub === "send") {
|
|
3276
|
+
const { runChatSpawnCommand } = await import("./chat-spawn");
|
|
3277
|
+
const runtime = await getRuntime();
|
|
3278
|
+
const authedRuntime = requireAuthenticatedRuntime(
|
|
3279
|
+
runtime,
|
|
3280
|
+
`dench agent ${sub}`,
|
|
3281
|
+
);
|
|
3282
|
+
await runChatSpawnCommand({
|
|
3283
|
+
runtime: {
|
|
3284
|
+
host: authedRuntime.host,
|
|
3285
|
+
bearerToken: authedRuntime.sessionToken,
|
|
3286
|
+
},
|
|
3287
|
+
args: subArgs.slice(1),
|
|
3288
|
+
subcommand: sub,
|
|
3289
|
+
});
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
2827
3292
|
const { runAgentCommand } = await import("./agent");
|
|
2828
3293
|
const runtime = await getRuntime();
|
|
2829
3294
|
await runAgentCommand({
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount-point detection helpers for `dench-fs-daemon`.
|
|
3
|
+
*
|
|
4
|
+
* `/workspace` in every Dench sandbox is a Daytona persistent volume
|
|
5
|
+
* mounted via AWS Mountpoint for S3 (a FUSE driver). Two upstream
|
|
6
|
+
* limitations matter for the file daemon:
|
|
7
|
+
*
|
|
8
|
+
* • inotify events never fire on the mount
|
|
9
|
+
* (https://github.com/awslabs/mountpoint-s3/issues/1290), so
|
|
10
|
+
* chokidar's default backend is useless. The daemon switches
|
|
11
|
+
* to polling mode when these helpers report a FUSE backing.
|
|
12
|
+
*
|
|
13
|
+
* • rename(2) returns ENOSYS on general-purpose buckets, so
|
|
14
|
+
* callers that need to detect "is this path on the volume?"
|
|
15
|
+
* can use `detectWorkspaceFsType` to choose the safe
|
|
16
|
+
* copy+unlink path instead of trusting `mv`.
|
|
17
|
+
*
|
|
18
|
+
* This file lives alongside fs-daemon.ts but contains no Convex /
|
|
19
|
+
* chokidar imports so unit tests can pull it directly.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
|
|
24
|
+
export type MountInfo = {
|
|
25
|
+
mountPoint: string;
|
|
26
|
+
fsType: string;
|
|
27
|
+
source: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read /proc/self/mountinfo and return the parsed mount table.
|
|
32
|
+
* Returns an empty array on non-Linux hosts (e.g. local macOS dev,
|
|
33
|
+
* where /proc doesn't exist) so callers can fall through cleanly.
|
|
34
|
+
*
|
|
35
|
+
* The proc(5) line format is:
|
|
36
|
+
*
|
|
37
|
+
* 36 35 98:0 /mnt1 /mnt/parent rw,noatime master:1 - ext3 /dev/root rw
|
|
38
|
+
* ─┬ ─┬ ──┬─ ──┬── ──┬─────── ──┬───── ──┬────── ─ ──┬─ ────┬──── ──┬
|
|
39
|
+
* 1 2 3 4 5 6 7 8 9 10
|
|
40
|
+
*
|
|
41
|
+
* We only need fields 5 (mount point) and the fs type after the
|
|
42
|
+
* " - " separator (field 9, with optional source as field 10).
|
|
43
|
+
*/
|
|
44
|
+
export async function readMountInfo(
|
|
45
|
+
procPath = "/proc/self/mountinfo",
|
|
46
|
+
): Promise<MountInfo[]> {
|
|
47
|
+
let raw: string;
|
|
48
|
+
try {
|
|
49
|
+
raw = await readFile(procPath, "utf-8");
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const out: MountInfo[] = [];
|
|
54
|
+
for (const rawLine of raw.split("\n")) {
|
|
55
|
+
const line = rawLine.trim();
|
|
56
|
+
if (!line) continue;
|
|
57
|
+
const dashIdx = line.indexOf(" - ");
|
|
58
|
+
if (dashIdx === -1) continue;
|
|
59
|
+
const before = line.slice(0, dashIdx).split(/\s+/);
|
|
60
|
+
const after = line.slice(dashIdx + 3).split(/\s+/);
|
|
61
|
+
const mountPoint = before[4];
|
|
62
|
+
const fsType = after[0];
|
|
63
|
+
const source = after[1] ?? "";
|
|
64
|
+
if (!mountPoint || !fsType) continue;
|
|
65
|
+
out.push({ mountPoint, fsType, source });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find the longest mount point that is a prefix of `absPath`. Used to
|
|
72
|
+
* answer "what filesystem is this path on?" from the parsed mount
|
|
73
|
+
* table. Important to use longest-prefix so a `/workspace` mount
|
|
74
|
+
* over `/` doesn't get shadowed by the root mount.
|
|
75
|
+
*
|
|
76
|
+
* Naïve `startsWith` would also match `/workspace` against
|
|
77
|
+
* `/worker`; we guard against that by requiring the next char to be
|
|
78
|
+
* `/` (or the strings to be equal).
|
|
79
|
+
*/
|
|
80
|
+
export function findMountForPath(
|
|
81
|
+
mounts: MountInfo[],
|
|
82
|
+
absPath: string,
|
|
83
|
+
): MountInfo | null {
|
|
84
|
+
let best: MountInfo | null = null;
|
|
85
|
+
for (const mount of mounts) {
|
|
86
|
+
if (mount.mountPoint === absPath) return mount;
|
|
87
|
+
const trimmed = mount.mountPoint.replace(/\/+$/, "");
|
|
88
|
+
if (
|
|
89
|
+
absPath === trimmed ||
|
|
90
|
+
absPath.startsWith(`${trimmed === "" ? "" : trimmed}/`)
|
|
91
|
+
) {
|
|
92
|
+
if (!best || mount.mountPoint.length > best.mountPoint.length) {
|
|
93
|
+
best = mount;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return best;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns true for any FUSE-flavored fs type. mount-s3 reports
|
|
102
|
+
* `fuse` (no subtype on Linux); other FUSE drivers commonly use
|
|
103
|
+
* `fuse.<name>` (e.g. fuse.sshfs). `fuseblk` is the block-device
|
|
104
|
+
* flavor used by ntfs-3g and friends.
|
|
105
|
+
*/
|
|
106
|
+
export function isFuseFsType(fsType: string): boolean {
|
|
107
|
+
if (!fsType) return false;
|
|
108
|
+
return (
|
|
109
|
+
fsType === "fuse" || fsType.startsWith("fuse.") || fsType === "fuseblk"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Convenience: detect the filesystem backing a given workspace path
|
|
115
|
+
* in one call. The daemon uses this on startup to decide whether to
|
|
116
|
+
* enable chokidar's polling mode.
|
|
117
|
+
*/
|
|
118
|
+
export async function detectWorkspaceFsType(workspace: string): Promise<{
|
|
119
|
+
fsType: string;
|
|
120
|
+
isFuse: boolean;
|
|
121
|
+
source: string;
|
|
122
|
+
}> {
|
|
123
|
+
const mounts = await readMountInfo();
|
|
124
|
+
const match = findMountForPath(mounts, workspace);
|
|
125
|
+
if (!match) {
|
|
126
|
+
return { fsType: "unknown", isFuse: false, source: "" };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
fsType: match.fsType,
|
|
130
|
+
isFuse: isFuseFsType(match.fsType),
|
|
131
|
+
source: match.source,
|
|
132
|
+
};
|
|
133
|
+
}
|