@dench.com/cli 0.3.5 → 0.3.7

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/dench.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { Buffer } from "node:buffer";
3
3
  import { spawn } from "node:child_process";
4
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import {
5
+ chmod,
6
+ mkdir,
7
+ readdir,
8
+ readFile,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
5
12
  import { homedir, hostname, userInfo } from "node:os";
6
- import { join } from "node:path";
13
+ import { basename, join, relative, resolve } from "node:path";
7
14
  import { fileURLToPath } from "node:url";
8
15
  import { ConvexHttpClient } from "convex/browser";
9
16
  import { makeFunctionReference } from "convex/server";
@@ -190,6 +197,11 @@ const api = {
190
197
  commitFileUpload: makeFunctionReference<"mutation">(
191
198
  "functions/files:commitFileUpload",
192
199
  ),
200
+ // Used by `dench stage` to record dir rows for intermediate
201
+ // parents (commitFileUpload only inserts leaf rows).
202
+ recordDirCreate: makeFunctionReference<"mutation">(
203
+ "functions/files:recordDirCreate",
204
+ ),
193
205
  getFileSignedDownloadUrl: makeFunctionReference<"action">(
194
206
  "functions/files:getFileSignedDownloadUrl",
195
207
  ),
@@ -430,6 +442,17 @@ CRM (Daytona-native rewrite):
430
442
  dench crm batch --file ops.jsonl
431
443
  Help: dench crm help
432
444
 
445
+ Live web search (Exa via the Dench Cloud Gateway, auths with DENCH_API_KEY):
446
+ dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural] [--category news|company|people|...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--max-chars 800] [--json]
447
+ dench search contents <url> [<url>...] [--max-chars 4000] [--summary "..."] [--json]
448
+ dench search answer "<query>" [--json]
449
+ Help: dench search help
450
+
451
+ Image generation / editing (gpt-image-2 via the Dench Cloud Gateway):
452
+ dench image generate "<prompt>" [--size 1024x1024] [--quality auto|low|medium|high] [--format png|jpeg|webp] [--save-path /workspace/path/file.png] [--output <local file>] [--no-write] [--json]
453
+ dench image edit "<prompt>" --input <path> [--input <path> ...] [--mask <path>] [--size ...] [--quality ...] [--format ...] [--save-path ...] [--output ...] [--json]
454
+ Help: dench image help
455
+
433
456
  Past chat threads (own + shared in this workspace):
434
457
  dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
435
458
  dench chat search "<query>" [--limit N] [--include-current] [--json]
@@ -456,6 +479,9 @@ returns ENOSYS on the FUSE-backed volume mount):
456
479
  dench files mv <src> <dst> [--json]
457
480
  dench files rm <path> [--recursive] [--json]
458
481
 
482
+ Persist scratch -> workspace (cp from /tmp into /workspace + Convex sync):
483
+ dench stage <local-path> [<workspace-dest>] [--clean] [--json]
484
+
459
485
  Subagents (Daytona-native rewrite):
460
486
  dench agent spawn '<goal>' [--parent-run-id <id>] [--sandbox own|share_parent] [--time-budget-ms N]
461
487
  dench agent await --hook <token> | --children id1,id2,id3
@@ -3038,6 +3064,267 @@ async function runFilesCommand() {
3038
3064
  });
3039
3065
  }
3040
3066
 
3067
+ // ── dench stage ──────────────────────────────────────────────────────────
3068
+ //
3069
+ // Copy a tree from a /tmp scratch path into /workspace AND update the
3070
+ // Convex `fileTree` mirror so the UI tree reflects the new files
3071
+ // immediately. The agent's `stage_to_workspace` typed tool calls into
3072
+ // the same primitive server-side; this CLI variant exists so a human at
3073
+ // a sandbox shell (or a script) can run the same operation manually
3074
+ // without bouncing through the chat API.
3075
+ //
3076
+ // Why upload to Convex (instead of `cp -r local /workspace`):
3077
+ // /workspace is FUSE-backed (Mountpoint-S3) and the file daemon
3078
+ // reconciles between the volume and Convex. Uploading via Convex
3079
+ // guarantees the UI tree updates immediately (no wait for the
3080
+ // daemon's polling tick) and avoids stomping a daemon-in-flight
3081
+ // change on the same path.
3082
+
3083
+ const STAGE_IGNORED_BASENAMES = new Set([
3084
+ ".DS_Store",
3085
+ ".ds_store",
3086
+ "Thumbs.db",
3087
+ "thumbs.db",
3088
+ "desktop.ini",
3089
+ ]);
3090
+
3091
+ const STAGE_IGNORED_DIRS = new Set([
3092
+ ".git",
3093
+ ".next",
3094
+ ".vercel",
3095
+ "__MACOSX",
3096
+ "build",
3097
+ "coverage",
3098
+ "dist",
3099
+ "node_modules",
3100
+ ]);
3101
+
3102
+ function shouldIgnoreStagePath(relPath: string): boolean {
3103
+ const parts = relPath.split("/").filter(Boolean);
3104
+ if (parts.some((p) => STAGE_IGNORED_DIRS.has(p))) return true;
3105
+ const base = parts.at(-1) ?? "";
3106
+ if (STAGE_IGNORED_BASENAMES.has(base)) return true;
3107
+ if (base.startsWith("._")) return true;
3108
+ if (
3109
+ base === ".env" ||
3110
+ (base.startsWith(".env.") &&
3111
+ ![".env.example", ".env.sample", ".env.template"].includes(base))
3112
+ ) {
3113
+ return true;
3114
+ }
3115
+ if (base.endsWith(".pem") || base.endsWith(".key")) return true;
3116
+ return false;
3117
+ }
3118
+
3119
+ type DirEntryLike = {
3120
+ name: string;
3121
+ isDirectory(): boolean;
3122
+ isFile(): boolean;
3123
+ };
3124
+
3125
+ async function* walkLocalFiles(
3126
+ rootAbs: string,
3127
+ ): AsyncGenerator<{ absPath: string; rel: string; size: number }> {
3128
+ const queue: string[] = [rootAbs];
3129
+ while (queue.length > 0) {
3130
+ const dir = queue.shift();
3131
+ if (!dir) continue;
3132
+ let entries: DirEntryLike[];
3133
+ try {
3134
+ // The Node typings for readdir({ withFileTypes: true }) lean on
3135
+ // generics that bun's stub doesn't surface cleanly; cast through
3136
+ // unknown so we get the {name, isDirectory, isFile} contract we
3137
+ // actually depend on without dragging Dirent generics through.
3138
+ entries = (await readdir(dir, {
3139
+ withFileTypes: true,
3140
+ })) as unknown as DirEntryLike[];
3141
+ } catch {
3142
+ continue;
3143
+ }
3144
+ for (const entry of entries) {
3145
+ const abs = join(dir, entry.name);
3146
+ const rel = relative(rootAbs, abs).replace(/\\/g, "/");
3147
+ if (shouldIgnoreStagePath(rel)) continue;
3148
+ if (entry.isDirectory()) {
3149
+ queue.push(abs);
3150
+ continue;
3151
+ }
3152
+ if (!entry.isFile()) continue;
3153
+ const stats = await stat(abs).catch(() => null);
3154
+ if (!stats) continue;
3155
+ yield { absPath: abs, rel, size: stats.size };
3156
+ }
3157
+ }
3158
+ }
3159
+
3160
+ async function runStage() {
3161
+ const runtime = await getRuntime();
3162
+ const apiKey = requireFilesApiKey(runtime, "dench stage");
3163
+ const localRaw = positional(1);
3164
+ const destRaw = positional(2);
3165
+ if (!localRaw) {
3166
+ throw new CliError(
3167
+ "Usage: dench stage <local-path> [<workspace-dest>] [--clean] [--json]",
3168
+ {
3169
+ code: "stage_usage",
3170
+ nextActions: [
3171
+ "Pass an absolute local path (typically under /tmp) to stage from.",
3172
+ "Optional second arg is the /workspace destination (defaults to /<basename>).",
3173
+ ],
3174
+ },
3175
+ );
3176
+ }
3177
+ const localAbs = resolve(localRaw);
3178
+ if (localAbs.startsWith("/workspace/") || localAbs === "/workspace") {
3179
+ throw new CliError(
3180
+ `localPath must not be inside /workspace (got ${localAbs}). Stage from /tmp scratch instead.`,
3181
+ { code: "stage_local_in_workspace" },
3182
+ );
3183
+ }
3184
+
3185
+ let localStat: Awaited<ReturnType<typeof stat>>;
3186
+ try {
3187
+ localStat = await stat(localAbs);
3188
+ } catch (error) {
3189
+ throw new CliError(
3190
+ `Local path not found: ${localAbs} (${error instanceof Error ? error.message : String(error)})`,
3191
+ { code: "stage_local_missing" },
3192
+ );
3193
+ }
3194
+
3195
+ // Default workspace destination = /<basename of source>. The agent
3196
+ // typed tool requires explicit dest; the CLI gives a friendly default
3197
+ // because humans usually want "stage this dir to /workspace/<dir>".
3198
+ const destWorkspace = destRaw
3199
+ ? normalizeFilesPath(destRaw)
3200
+ : normalizeFilesPath(`/${basename(localAbs)}`);
3201
+ const clean = hasFlag("--clean");
3202
+ // `--json` is consumed at the top of the file into the module-level
3203
+ // `json` flag (and filtered out of `args`); read that, not `hasFlag`.
3204
+ const jsonOut = json;
3205
+
3206
+ if (clean) {
3207
+ // Wipe Convex rows under destWorkspace so a clean install really
3208
+ // is clean. The daemon will mirror the deletes onto the volume
3209
+ // on its next reconcile tick.
3210
+ const existing = await listConvexTreeRecursive(
3211
+ runtime,
3212
+ apiKey,
3213
+ destWorkspace,
3214
+ );
3215
+ const fileRows = existing.filter((r) => !r.isDir);
3216
+ const dirRows = existing.filter((r) => r.isDir);
3217
+ dirRows.sort((a, b) => b.path.length - a.path.length);
3218
+ for (const row of fileRows) {
3219
+ await runtime.client.mutation(api.functions.files.deleteFile, {
3220
+ path: row.path,
3221
+ apiKey,
3222
+ } as never);
3223
+ }
3224
+ for (const row of dirRows) {
3225
+ await runtime.client.mutation(api.functions.files.deleteFile, {
3226
+ path: row.path,
3227
+ apiKey,
3228
+ } as never);
3229
+ }
3230
+ }
3231
+
3232
+ const staged: Array<{ from: string; to: string; bytes: number }> = [];
3233
+ const recordedDirs = new Set<string>();
3234
+ let bytesStaged = 0;
3235
+
3236
+ // `commitFileUpload` only inserts the leaf file row — it does NOT
3237
+ // create rows for the file's intermediate parent dirs. Without
3238
+ // those, `listTree(prefix)` won't surface a `sub` directory in the
3239
+ // UI tree even when `sub/notes.txt` exists. We record each unique
3240
+ // parent dir explicitly via `recordDirCreate` so the right-pane
3241
+ // tree mirrors the staged structure 1:1.
3242
+ async function ensureDirRowsFor(filePath: string): Promise<void> {
3243
+ let parent = parentOfPath(filePath);
3244
+ while (parent && parent !== "/" && !recordedDirs.has(parent)) {
3245
+ recordedDirs.add(parent);
3246
+ try {
3247
+ await runtime.client.mutation(api.functions.files.recordDirCreate, {
3248
+ path: parent,
3249
+ apiKey,
3250
+ } as never);
3251
+ } catch (error) {
3252
+ // Best-effort: a transient failure on a single dir shouldn't
3253
+ // fail the entire stage. The leaf files are already persisted;
3254
+ // worst case the daemon's reconciler picks up the dir on its
3255
+ // next tick.
3256
+ if (!jsonOut) {
3257
+ process.stderr.write(
3258
+ `dench stage: warn — recordDirCreate ${parent} failed: ${
3259
+ error instanceof Error ? error.message : String(error)
3260
+ }\n`,
3261
+ );
3262
+ }
3263
+ }
3264
+ parent = parentOfPath(parent);
3265
+ }
3266
+ }
3267
+
3268
+ if (localStat.isFile()) {
3269
+ const bytes = await readFile(localAbs);
3270
+ const dest = destWorkspace;
3271
+ await ensureDirRowsFor(dest);
3272
+ await uploadConvexFileBytes(runtime, apiKey, {
3273
+ path: dest,
3274
+ bytes: new Uint8Array(bytes),
3275
+ });
3276
+ staged.push({ from: localAbs, to: dest, bytes: bytes.byteLength });
3277
+ bytesStaged += bytes.byteLength;
3278
+ } else if (localStat.isDirectory()) {
3279
+ for await (const entry of walkLocalFiles(localAbs)) {
3280
+ const dest = entry.rel
3281
+ ? normalizeFilesPath(`${destWorkspace}/${entry.rel}`)
3282
+ : destWorkspace;
3283
+ await ensureDirRowsFor(dest);
3284
+ const bytes = await readFile(entry.absPath);
3285
+ await uploadConvexFileBytes(runtime, apiKey, {
3286
+ path: dest,
3287
+ bytes: new Uint8Array(bytes),
3288
+ });
3289
+ staged.push({
3290
+ from: entry.absPath,
3291
+ to: dest,
3292
+ bytes: bytes.byteLength,
3293
+ });
3294
+ bytesStaged += bytes.byteLength;
3295
+ if (!jsonOut && staged.length % 25 === 0) {
3296
+ process.stdout.write(
3297
+ `dench stage: ${staged.length} files, ${bytesStaged}B…\n`,
3298
+ );
3299
+ }
3300
+ }
3301
+ } else {
3302
+ throw new CliError(
3303
+ `Local path is neither a file nor a directory: ${localAbs}`,
3304
+ { code: "stage_local_unsupported" },
3305
+ );
3306
+ }
3307
+
3308
+ if (jsonOut) {
3309
+ print({
3310
+ ok: true,
3311
+ localPath: localAbs,
3312
+ workspacePath: destWorkspace,
3313
+ filesStaged: staged.length,
3314
+ bytesStaged,
3315
+ files: staged,
3316
+ clean,
3317
+ });
3318
+ return;
3319
+ }
3320
+
3321
+ process.stdout.write(
3322
+ `dench stage: ${staged.length} files (${bytesStaged}B) staged from ${localAbs} -> ${destWorkspace}${
3323
+ clean ? " [clean install]" : ""
3324
+ }\n`,
3325
+ );
3326
+ }
3327
+
3041
3328
  function parentOfPath(input: string): string {
3042
3329
  const normalized = normalizeFilesPath(input);
3043
3330
  if (normalized === "/") return "/";
@@ -3210,6 +3497,34 @@ async function main() {
3210
3497
  return;
3211
3498
  }
3212
3499
 
3500
+ if (command === "stage") {
3501
+ await runStage();
3502
+ return;
3503
+ }
3504
+
3505
+ if (command === "search") {
3506
+ // `dench search` talks straight to the gateway over HTTP using
3507
+ // DENCH_API_KEY; it deliberately bypasses requireSessionRuntime so
3508
+ // it works in any sandbox / CI environment that has the key.
3509
+ const subArgs = args.slice(args.indexOf("search") + 1);
3510
+ const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3511
+ const { runSearchCommand } = await import("./search");
3512
+ await runSearchCommand({ args: filteredSubArgs, jsonOutput: json });
3513
+ return;
3514
+ }
3515
+
3516
+ if (command === "image") {
3517
+ // `dench image generate|edit` mirrors `dench search`: straight HTTP
3518
+ // to the gateway with DENCH_API_KEY, no Convex session required.
3519
+ // The gateway writes the bytes to Convex Storage on success; we
3520
+ // also drop them on local disk so the next bash command sees them.
3521
+ const subArgs = args.slice(args.indexOf("image") + 1);
3522
+ const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3523
+ const { runImageCommand } = await import("./image");
3524
+ await runImageCommand({ args: filteredSubArgs, jsonOutput: json });
3525
+ return;
3526
+ }
3527
+
3213
3528
  if (command === "chat") {
3214
3529
  const subArgs = args.slice(args.indexOf("chat") + 1);
3215
3530
  const sub = subArgs[0];
@@ -3298,6 +3613,35 @@ async function main() {
3298
3613
  return;
3299
3614
  }
3300
3615
 
3616
+ if (command === "cron") {
3617
+ const subArgs = args.slice(args.indexOf("cron") + 1);
3618
+ const sub = subArgs[0];
3619
+ // Help is a no-auth path so users can read the surface without a
3620
+ // session — mirrors `dench crm help`.
3621
+ if (!sub || sub === "help" || sub === "--help") {
3622
+ const { runCronCommand } = await import("./cron");
3623
+ // biome-ignore lint/suspicious/noExplicitAny: help-only stub, never queries
3624
+ await runCronCommand({ convex: {} as any, args: subArgs });
3625
+ return;
3626
+ }
3627
+ const { runCronCommand } = await import("./cron");
3628
+ const runtime = await getRuntime();
3629
+ // Cron CRUD is org-scoped — accept either an agent session token
3630
+ // (`dch_agent_*` from `dench login`) or a unified Dench API key
3631
+ // (`DENCH_API_KEY` baked into sandbox envVars). Server-side
3632
+ // `requireCronAccess` (convex/lib/cronAccess.ts) dispatches on the
3633
+ // bearer prefix and resolves the org for both. The Bearer token
3634
+ // must carry `crons:read` (queries) or `crons:write` (mutations)
3635
+ // scope, or the wildcard `*`.
3636
+ const authedRuntime = requireAuthenticatedRuntime(runtime, "dench cron");
3637
+ await runCronCommand({
3638
+ convex: authedRuntime.client,
3639
+ args: subArgs,
3640
+ sessionToken: authedRuntime.sessionToken,
3641
+ });
3642
+ return;
3643
+ }
3644
+
3301
3645
  if (command === "login") {
3302
3646
  await login();
3303
3647
  return;