@dench.com/cli 0.3.0 → 0.3.2

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 CHANGED
@@ -149,3 +149,36 @@ For staging:
149
149
  dench login --staging
150
150
  dench login --host https://workspace-staging.dench.com
151
151
  ```
152
+
153
+ ## Sandbox / API key auth (no `dench login` required)
154
+
155
+ Inside a Daytona sandbox the unified Dench API key is baked into envVars
156
+ at create time (see `src/lib/secrets/sandbox-tokens.ts`). When
157
+ `DENCH_API_KEY` and `CONVEX_URL` are both present, the CLI skips the
158
+ `dench login` flow entirely and uses the API key as a bearer for every
159
+ org-scoped command — `dench crm`, `dench files`, `dench fs sync`, etc.
160
+
161
+ ```bash
162
+ export DENCH_API_KEY=dench_pk_live_…
163
+ export CONVEX_URL=https://your-convex-deployment.convex.cloud
164
+ # Optional but enables nicer URLs in CLI output:
165
+ export DENCH_API_URL=https://app.dench.com
166
+ export DENCH_ORG_ID=…
167
+ export DENCH_ORG_SLUG=…
168
+
169
+ dench crm entries list people
170
+ ```
171
+
172
+ Auth precedence:
173
+
174
+ 1. `--dev` flag → use the dev workspace env (`NEXT_PUBLIC_CONVEX_URL`,
175
+ `DENCH_WORKSPACE`, `DENCH_DEV_AGENT_KEY`).
176
+ 2. A locally-stored `dench login` session for the current host.
177
+ 3. `DENCH_API_KEY` + `CONVEX_URL` (sandbox / automation path).
178
+ 4. `--dev`-style env vars (without the flag).
179
+
180
+ Commands that need to impersonate a specific agent (`dench memory`,
181
+ `dench task`, `dench claim`, `dench log`, `dench approval request`,
182
+ etc.) still require an agent session (`dench login`) — the unified API
183
+ key is org-scoped and cannot speak as a single agent. Those commands
184
+ return a clear "agent session required" error in API-key mode.
package/crm.ts CHANGED
@@ -2,8 +2,14 @@
2
2
  * `dench crm <subcommand>` — CLI surface for the Convex CRM.
3
3
  *
4
4
  * All operations resolve via the unified Dench API key baked into the
5
- * sandbox (DENCH_API_KEY) or the locally-stored session token. Talks to
6
- * the Convex deployment URL stored in the active session.
5
+ * sandbox (DENCH_API_KEY) or a locally-stored agent session token from
6
+ * `dench login`. The dispatcher does not care which one — the parent
7
+ * (`cli/dench.ts → getRuntime`) picks whichever auth is available and
8
+ * forwards a single bearer through `sessionToken`. The server-side
9
+ * `requireCrmAccess` (convex/lib/crm/access.ts) accepts either format.
10
+ *
11
+ * Talks to the Convex deployment URL provided by the active runtime
12
+ * (the session's `convexUrl` or the sandbox's `CONVEX_URL` envvar).
7
13
  *
8
14
  * This is a thin command dispatcher; the heavy lifting lives in
9
15
  * convex/functions/crm/* and convex/lib/crm/*.
@@ -37,9 +43,14 @@ type CrmCliContext = {
37
43
  args: string[];
38
44
  jsonOutput: boolean;
39
45
  /**
40
- * Agent session token (`dch_agent_*`) minted by `dench login`. Sent
41
- * with every Convex call so the server-side `requireCrmAccess` helper
42
- * can resolve the caller's organization without a Convex Auth cookie.
46
+ * Bearer token sent with every Convex call so the server-side
47
+ * `requireCrmAccess` helper can resolve the caller's organization
48
+ * without a Convex Auth cookie. Two formats are accepted:
49
+ * • `dch_agent_*` agent session minted by `dench login`.
50
+ * • Unified Dench API key (`dench_pk_*`, `dench_live_*`,
51
+ * `gw_sk_*`, etc.) baked into sandbox envVars as `DENCH_API_KEY`.
52
+ * The server dispatches on prefix; both resolve to the same
53
+ * organization context.
43
54
  */
44
55
  sessionToken?: string;
45
56
  };
package/dench.ts CHANGED
@@ -1620,11 +1620,27 @@ function hasDevEnv() {
1620
1620
  );
1621
1621
  }
1622
1622
 
1623
+ /**
1624
+ * Resolve the Convex URL the API-key runtime should talk to. Sandboxes
1625
+ * bake `CONVEX_URL` (see `src/lib/secrets/sandbox-tokens.ts`); local
1626
+ * dev typically only has `NEXT_PUBLIC_CONVEX_URL` exported. We accept
1627
+ * either so the same code path works in both shells without callers
1628
+ * needing to know which env var is set.
1629
+ */
1630
+ function resolveApiKeyConvexUrl(): string | undefined {
1631
+ return (
1632
+ process.env.CONVEX_URL?.trim() ||
1633
+ process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ||
1634
+ undefined
1635
+ );
1636
+ }
1637
+
1623
1638
  function loginRequiredError(command = "this command") {
1624
1639
  return new CliError(`${command} requires dench login`, {
1625
1640
  code: "login_required",
1626
1641
  nextActions: [
1627
- 'Run dench onboard --kind <kind> --name "AI Agent - Project".',
1642
+ "Set DENCH_API_KEY (and CONVEX_URL) in your env if running inside a sandbox or CI.",
1643
+ 'Or run dench onboard --kind <kind> --name "AI Agent - Project" to mint an agent session.',
1628
1644
  "If already approved, run dench sessions --json, then dench use <session-key-or-workspace-slug>.",
1629
1645
  ],
1630
1646
  });
@@ -1670,6 +1686,33 @@ async function getRuntime() {
1670
1686
  ],
1671
1687
  });
1672
1688
  }
1689
+
1690
+ // Sandbox / automation path: the unified Dench API key + Convex URL
1691
+ // baked into sandbox envVars (see
1692
+ // src/lib/secrets/sandbox-tokens.ts) is enough to drive every
1693
+ // command that goes through `requireCrmAccess` server-side. We only
1694
+ // fall into this branch when no stored session is present, so
1695
+ // explicit `dench login` setups keep their existing behavior.
1696
+ const apiKey = process.env.DENCH_API_KEY?.trim();
1697
+ const convexUrl = resolveApiKeyConvexUrl();
1698
+ if (apiKey && convexUrl) {
1699
+ const orgId = process.env.DENCH_ORG_ID?.trim();
1700
+ const orgSlug = process.env.DENCH_ORG_SLUG?.trim();
1701
+ const apiHost =
1702
+ process.env.DENCH_API_URL?.trim() ||
1703
+ process.env.DENCH_INTERNAL_BASE?.trim() ||
1704
+ host;
1705
+ return {
1706
+ mode: "apiKey" as const,
1707
+ host: apiHost,
1708
+ client: new ConvexHttpClient(convexUrl),
1709
+ sessionToken: apiKey,
1710
+ organization:
1711
+ orgId && orgSlug
1712
+ ? { id: orgId, name: orgSlug, slug: orgSlug }
1713
+ : undefined,
1714
+ };
1715
+ }
1673
1716
  }
1674
1717
 
1675
1718
  if (!hasDevEnv()) {
@@ -1688,14 +1731,73 @@ async function getRuntime() {
1688
1731
 
1689
1732
  type Runtime = Awaited<ReturnType<typeof getRuntime>>;
1690
1733
 
1734
+ /**
1735
+ * `session` mode is required for `agentWorkspace.*` calls because they
1736
+ * gate on `validateAgentSession` server-side, which only accepts the
1737
+ * `dch_agent_*` token format. Sandboxes that only have a unified
1738
+ * `DENCH_API_KEY` (apiKey mode) cannot impersonate a specific agent
1739
+ * and will fail at the server with `Invalid agent session` — surface
1740
+ * that here as a clear error instead.
1741
+ */
1691
1742
  function requireSessionRuntime(
1692
1743
  runtime: Runtime,
1693
1744
  command: string,
1694
1745
  ): Extract<Runtime, { mode: "session" }> {
1695
- if (runtime.mode !== "session") {
1696
- throw loginRequiredError(command);
1746
+ if (runtime.mode === "session") return runtime;
1747
+ if (runtime.mode === "apiKey") {
1748
+ throw agentSessionRequiredError(command);
1749
+ }
1750
+ throw loginRequiredError(command);
1751
+ }
1752
+
1753
+ /**
1754
+ * Org-scoped commands (`dench crm`, `dench files`, etc.) accept either
1755
+ * a `dch_agent_*` agent session token OR a unified Dench API key
1756
+ * (`dench_*` / `gw_sk_*` / etc.) baked into sandbox envVars. The
1757
+ * server-side `requireCrmAccess` helper dispatches on the prefix.
1758
+ */
1759
+ type AuthenticatedRuntime = Extract<
1760
+ Runtime,
1761
+ { mode: "session" | "apiKey" }
1762
+ >;
1763
+
1764
+ function requireAuthenticatedRuntime(
1765
+ runtime: Runtime,
1766
+ command: string,
1767
+ ): AuthenticatedRuntime {
1768
+ if (runtime.mode === "session" || runtime.mode === "apiKey") return runtime;
1769
+ throw loginRequiredError(command);
1770
+ }
1771
+
1772
+ function agentSessionRequiredError(command: string) {
1773
+ return new CliError(
1774
+ `${command} requires an agent session — DENCH_API_KEY is org-scoped and cannot impersonate a specific agent`,
1775
+ {
1776
+ code: "agent_session_required",
1777
+ nextActions: [
1778
+ 'Run dench onboard --kind <kind> --name "AI Agent - Project" to mint an agent session for this command.',
1779
+ "Or run the equivalent action via the workspace UI / dench crm if it is org-level (e.g. CRM data, files).",
1780
+ ],
1781
+ },
1782
+ );
1783
+ }
1784
+
1785
+ /**
1786
+ * Some legacy `agentWorkspace.*` commands (task create / claim / log /
1787
+ * approval request) have separate session vs dev branches. With apiKey
1788
+ * mode added, the dev branch would silently receive partial args. Use
1789
+ * this to short-circuit those branches with a clear error.
1790
+ *
1791
+ * Asserts away the `apiKey` mode so the caller's downstream type
1792
+ * narrowing (e.g. `runtime.workspaceArgs`) keeps working.
1793
+ */
1794
+ function rejectApiKeyForAgentCommand(
1795
+ runtime: Runtime,
1796
+ command: string,
1797
+ ): asserts runtime is Exclude<Runtime, { mode: "apiKey" }> {
1798
+ if (runtime.mode === "apiKey") {
1799
+ throw agentSessionRequiredError(command);
1697
1800
  }
1698
- return runtime;
1699
1801
  }
1700
1802
 
1701
1803
  async function printToolStatus(runtime: Runtime, toolkit?: string) {
@@ -1927,23 +2029,36 @@ async function connectedAppsContext(runtime: Runtime) {
1927
2029
  }
1928
2030
 
1929
2031
  async function buildContext(runtime: Runtime) {
1930
- const mine =
1931
- runtime.mode === "session"
1932
- ? ((await runtime.client.query(
1933
- api.functions.agentWorkspace.whatCanIDoHere,
1934
- {
1935
- sessionToken: runtime.sessionToken,
1936
- },
1937
- )) as JsonRecord)
1938
- : summarizeDevMine(
1939
- runtime.workspaceArgs.workspaceSlug,
1940
- await devOverview(runtime),
1941
- );
1942
-
1943
- const urls =
1944
- runtime.mode === "session"
1945
- ? workspaceUrls(runtime.host, workspaceSlugFromContext(mine))
2032
+ let mine: JsonRecord;
2033
+ let urls: JsonRecord;
2034
+ if (runtime.mode === "session") {
2035
+ mine = (await runtime.client.query(
2036
+ api.functions.agentWorkspace.whatCanIDoHere,
2037
+ { sessionToken: runtime.sessionToken },
2038
+ )) as JsonRecord;
2039
+ urls = workspaceUrls(runtime.host, workspaceSlugFromContext(mine));
2040
+ } else if (runtime.mode === "dev") {
2041
+ mine = summarizeDevMine(
2042
+ runtime.workspaceArgs.workspaceSlug,
2043
+ await devOverview(runtime),
2044
+ );
2045
+ urls = {};
2046
+ } else {
2047
+ // apiKey mode: no agent session is available, so we can't call
2048
+ // `whatCanIDoHere` (it gates on `validateAgentSession`). Return a
2049
+ // minimal context derived from sandbox envVars so dench-cli help
2050
+ // surfaces still work without a full login.
2051
+ mine = {
2052
+ authMode: "apiKey",
2053
+ organization: runtime.organization ?? null,
2054
+ runId: process.env.DENCH_RUN_ID?.trim() ?? null,
2055
+ parentRunId: process.env.DENCH_PARENT_RUN_ID?.trim() ?? null,
2056
+ rootRunId: process.env.DENCH_ROOT_RUN_ID?.trim() ?? null,
2057
+ };
2058
+ urls = runtime.host
2059
+ ? workspaceUrls(runtime.host, runtime.organization?.slug)
1946
2060
  : {};
2061
+ }
1947
2062
 
1948
2063
  return {
1949
2064
  ...mine,
@@ -2341,7 +2456,7 @@ function withToolRunGuidance(runtime: Runtime, result: unknown) {
2341
2456
  if (!record || record.requiresApproval !== true) return result;
2342
2457
  const approvalId = stringField(record, "approvalId");
2343
2458
  const statusContext =
2344
- runtime.mode === "session"
2459
+ runtime.mode === "session" || runtime.mode === "apiKey"
2345
2460
  ? workspaceUrls(runtime.host, runtime.organization?.slug)
2346
2461
  : {};
2347
2462
  const approvalCommand = approvalId
@@ -2501,6 +2616,12 @@ async function listArtifacts(runtime: Runtime, limit?: number) {
2501
2616
  );
2502
2617
  }
2503
2618
  }
2619
+ if (runtime.mode === "apiKey") {
2620
+ // No agent identity → no artifact list. Return empty so callers
2621
+ // (e.g. `dench artifacts`, `dench suggested-work`) degrade gracefully
2622
+ // instead of throwing for sandbox callers.
2623
+ return [] as JsonRecord[];
2624
+ }
2504
2625
  const data = await devOverview(runtime);
2505
2626
  return asRecordArray(data.recentArtifacts);
2506
2627
  }
@@ -2675,14 +2796,16 @@ async function main() {
2675
2796
  }
2676
2797
  const { runCrmCommand } = await import("./crm");
2677
2798
  const runtime = await getRuntime();
2678
- const sessionRuntime = requireSessionRuntime(runtime, "dench crm");
2799
+ // CRM is org-scoped accept either an agent session token
2800
+ // (`dch_agent_*` from `dench login`) or a unified Dench API key
2801
+ // (`DENCH_API_KEY` baked into sandbox envVars). Server-side
2802
+ // `requireCrmAccess` (convex/lib/crm/access.ts) dispatches on the
2803
+ // bearer prefix and resolves the org for both.
2804
+ const authedRuntime = requireAuthenticatedRuntime(runtime, "dench crm");
2679
2805
  await runCrmCommand({
2680
- convex: sessionRuntime.client,
2806
+ convex: authedRuntime.client,
2681
2807
  args: subArgs,
2682
- // Server-side `requireCrmAccess` (convex/lib/crm/access.ts) accepts
2683
- // this token and resolves the agent's organization without a Convex
2684
- // Auth cookie. Mirrors the pattern used by every agentWorkspace fn.
2685
- sessionToken: sessionRuntime.sessionToken,
2808
+ sessionToken: authedRuntime.sessionToken,
2686
2809
  });
2687
2810
  return;
2688
2811
  }
@@ -2881,6 +3004,13 @@ async function main() {
2881
3004
  print(status);
2882
3005
  return;
2883
3006
  }
3007
+ if (runtime.mode === "apiKey") {
3008
+ // No agent session → no agent-status query. Fall back to a thin
3009
+ // env-derived snapshot so sandbox callers still get something
3010
+ // useful from `dench status`.
3011
+ print(await buildContext(runtime));
3012
+ return;
3013
+ }
2884
3014
  const data = await devOverview(runtime);
2885
3015
  const output = scopedStatus
2886
3016
  ? summarizeDevMine(runtime.workspaceArgs.workspaceSlug, data)
@@ -2903,6 +3033,11 @@ async function main() {
2903
3033
  );
2904
3034
  return;
2905
3035
  }
3036
+ if (runtime.mode === "apiKey") {
3037
+ throw loginRequiredError(
3038
+ "dench tasks (agent-scoped task list)",
3039
+ );
3040
+ }
2906
3041
  const data = await devOverview(runtime);
2907
3042
  print(data.tasks);
2908
3043
  return;
@@ -2919,6 +3054,9 @@ async function main() {
2919
3054
  print(status.agents);
2920
3055
  return;
2921
3056
  }
3057
+ if (runtime.mode === "apiKey") {
3058
+ throw loginRequiredError("dench agents");
3059
+ }
2922
3060
  const data = await devOverview(runtime);
2923
3061
  print(data.agents);
2924
3062
  return;
@@ -2935,6 +3073,9 @@ async function main() {
2935
3073
  print(status.approvals);
2936
3074
  return;
2937
3075
  }
3076
+ if (runtime.mode === "apiKey") {
3077
+ throw loginRequiredError("dench approvals");
3078
+ }
2938
3079
  const data = await devOverview(runtime);
2939
3080
  print(data.approvals);
2940
3081
  return;
@@ -3000,6 +3141,7 @@ async function main() {
3000
3141
  );
3001
3142
  return;
3002
3143
  }
3144
+ rejectApiKeyForAgentCommand(runtime, "dench task create");
3003
3145
  const taskId = await runtime.client.mutation(
3004
3146
  api.functions.agentWorkspace.devCreateTask,
3005
3147
  {
@@ -3108,6 +3250,7 @@ async function main() {
3108
3250
  );
3109
3251
  return;
3110
3252
  }
3253
+ rejectApiKeyForAgentCommand(runtime, "dench claim");
3111
3254
  const agentId = option("--agent") ?? (await defaultDevAgentId(runtime));
3112
3255
  print(
3113
3256
  await runtime.client.mutation(api.functions.agentWorkspace.devClaimTask, {
@@ -3135,6 +3278,7 @@ async function main() {
3135
3278
  );
3136
3279
  return;
3137
3280
  }
3281
+ rejectApiKeyForAgentCommand(runtime, "dench log");
3138
3282
  print(
3139
3283
  await runtime.client.mutation(api.functions.agentWorkspace.devAppendLog, {
3140
3284
  ...runtime.workspaceArgs,
@@ -3164,6 +3308,7 @@ async function main() {
3164
3308
  );
3165
3309
  return;
3166
3310
  }
3311
+ rejectApiKeyForAgentCommand(runtime, "dench approval request");
3167
3312
  const approvalId = await runtime.client.mutation(
3168
3313
  api.functions.agentWorkspace.devRequestApproval,
3169
3314
  {
package/fs-daemon.ts CHANGED
@@ -27,9 +27,11 @@
27
27
  *
28
28
  * Usage (daemon mode):
29
29
  * dench-fs-daemon \
30
- * --org-id <orgId> \
31
30
  * --workspace /workspace \
32
31
  * --hot-cache /tmp/dench-cache
32
+ *
33
+ * `--org-id` is optional: when omitted, the daemon resolves the organization
34
+ * from DENCH_API_KEY via Convex before it starts watching files.
33
35
  */
34
36
  import { createHash } from "node:crypto";
35
37
  import { mkdirSync } from "node:fs";
@@ -39,7 +41,7 @@ import { ConvexClient, ConvexHttpClient } from "convex/browser";
39
41
  import { makeFunctionReference } from "convex/server";
40
42
 
41
43
  type Args = {
42
- orgId: string;
44
+ orgId?: string;
43
45
  workspace: string;
44
46
  hotCache: string;
45
47
  };
@@ -71,11 +73,6 @@ function parseArgs(argv: string[]): Args {
71
73
  out.workspace ??= process.env.DENCH_VOLUME_PATH?.trim() || "/workspace";
72
74
  out.hotCache ??=
73
75
  process.env.DENCH_HOT_CACHE_PATH?.trim() || "/tmp/dench-cache";
74
- if (!out.orgId) {
75
- throw new Error(
76
- "dench-fs-daemon: org id is required. Set DENCH_ORG_ID env var or pass --org-id.",
77
- );
78
- }
79
76
  return out as Args;
80
77
  }
81
78
 
@@ -185,6 +182,9 @@ const api = {
185
182
  ),
186
183
  deleteFile: makeFunctionReference<"mutation">("functions/files:deleteFile"),
187
184
  listTree: makeFunctionReference<"query">("functions/files:listTree"),
185
+ resolveApiKeyOrganization: makeFunctionReference<"query">(
186
+ "functions/files:resolveApiKeyOrganization",
187
+ ),
188
188
  appendLiveBuffer: makeFunctionReference<"mutation">(
189
189
  "functions/files:appendLiveBuffer",
190
190
  ),
@@ -201,17 +201,24 @@ class DenchFileClient {
201
201
  private http: ConvexHttpClient;
202
202
  private realtime: ConvexClient | null = null;
203
203
 
204
- constructor(convexUrl: string, apiKey: string) {
204
+ constructor(
205
+ convexUrl: string,
206
+ private readonly apiKey: string,
207
+ ) {
205
208
  this.http = new ConvexHttpClient(convexUrl);
206
- // Convex's HTTP client supports a Bearer token for auth via setAuth.
207
- // The unified Dench API key is accepted by the validator we wired in
208
- // convex/lib/denchApiKey.ts.
209
- this.http.setAuth(apiKey);
209
+ }
210
+
211
+ async resolveOrganizationId(): Promise<string> {
212
+ const result = (await this.http.query(api.files.resolveApiKeyOrganization, {
213
+ apiKey: this.apiKey,
214
+ })) as { organizationId: string };
215
+ return result.organizationId;
210
216
  }
211
217
 
212
218
  async generateUploadUrl(path: string): Promise<string> {
213
219
  return (await this.http.mutation(api.files.generateUploadUrl, {
214
220
  path,
221
+ apiKey: this.apiKey,
215
222
  })) as string;
216
223
  }
217
224
 
@@ -242,7 +249,10 @@ class DenchFileClient {
242
249
  lastModifiedBy: string;
243
250
  }): Promise<{ versionConflict: boolean; currentHash?: string }> {
244
251
  try {
245
- await this.http.mutation(api.files.commitFileUpload, args);
252
+ await this.http.mutation(api.files.commitFileUpload, {
253
+ ...args,
254
+ apiKey: this.apiKey,
255
+ });
246
256
  return { versionConflict: false };
247
257
  } catch (error) {
248
258
  const message = error instanceof Error ? error.message : String(error);
@@ -260,12 +270,16 @@ class DenchFileClient {
260
270
  }
261
271
 
262
272
  async deleteFile(path: string): Promise<void> {
263
- await this.http.mutation(api.files.deleteFile, { path });
273
+ await this.http.mutation(api.files.deleteFile, {
274
+ path,
275
+ apiKey: this.apiKey,
276
+ });
264
277
  }
265
278
 
266
279
  async getSignedDownloadUrl(path: string): Promise<string | null> {
267
280
  const url = (await this.http.action(api.files.getFileSignedDownloadUrl, {
268
281
  path,
282
+ apiKey: this.apiKey,
269
283
  })) as string | null;
270
284
  return url ?? null;
271
285
  }
@@ -275,11 +289,17 @@ class DenchFileClient {
275
289
  runId: string;
276
290
  chunk: string;
277
291
  }): Promise<void> {
278
- await this.http.mutation(api.files.appendLiveBuffer, args as never);
292
+ await this.http.mutation(api.files.appendLiveBuffer, {
293
+ ...args,
294
+ apiKey: this.apiKey,
295
+ } as never);
279
296
  }
280
297
 
281
298
  async finalizeLiveBuffer(path: string): Promise<void> {
282
- await this.http.mutation(api.files.finalizeLiveBuffer, { path });
299
+ await this.http.mutation(api.files.finalizeLiveBuffer, {
300
+ path,
301
+ apiKey: this.apiKey,
302
+ });
283
303
  }
284
304
 
285
305
  /**
@@ -298,7 +318,7 @@ class DenchFileClient {
298
318
  }
299
319
  const unsubscribe = this.realtime.onUpdate(
300
320
  api.files.listTree,
301
- { prefix: "/" },
321
+ { prefix: "/", apiKey: this.apiKey },
302
322
  (rows: unknown) => {
303
323
  const list = Array.isArray(rows)
304
324
  ? (rows as Array<{
@@ -339,37 +359,46 @@ async function pullDownRemoteFile(
339
359
  );
340
360
  }
341
361
 
362
+ /**
363
+ * sysexits.h-style exit codes the supervisor (in
364
+ * src/lib/daytona/sandbox-bootstrap.ts) recognizes as **permanent** —
365
+ * keep restarting won't fix a missing env var or a missing dependency,
366
+ * so the supervisor gives up instead of crash-looping. Anything else
367
+ * (network blip, transient Convex error) exits 1 and gets retried.
368
+ */
369
+ const EXIT_CONFIG_ERROR = 78;
370
+
342
371
  async function runDaemon(daemonArgs: Args): Promise<void> {
343
372
  const convexUrl = process.env.CONVEX_URL?.trim();
344
373
  const apiKey = process.env.DENCH_API_KEY?.trim();
345
374
  if (!convexUrl || !apiKey) {
346
375
  console.error(
347
- "[dench-fs-daemon] CONVEX_URL or DENCH_API_KEY missing in env; nothing to sync",
376
+ "[dench-fs-daemon] CONVEX_URL or DENCH_API_KEY missing in env; refusing to start",
348
377
  );
349
- setInterval(() => undefined, 60_000);
350
- return;
378
+ process.exit(EXIT_CONFIG_ERROR);
351
379
  }
352
380
 
381
+ const client = new DenchFileClient(convexUrl, apiKey);
382
+ const resolvedOrgId =
383
+ daemonArgs.orgId ?? (await client.resolveOrganizationId());
353
384
  console.log(
354
- `[dench-fs-daemon] starting org=${daemonArgs.orgId} workspace=${daemonArgs.workspace}`,
385
+ `[dench-fs-daemon] starting org=${resolvedOrgId} workspace=${daemonArgs.workspace}`,
355
386
  );
356
387
  mkdirSync(daemonArgs.workspace, { recursive: true });
357
388
  mkdirSync(daemonArgs.hotCache, { recursive: true });
358
389
 
359
390
  const tracker = new HashTracker();
360
- const client = new DenchFileClient(convexUrl, apiKey);
361
391
 
362
392
  let chokidar: typeof import("chokidar");
363
393
  try {
364
394
  chokidar = (await import("chokidar")) as never;
365
395
  } catch (error) {
366
396
  console.error(
367
- `[dench-fs-daemon] chokidar not available; running in idle mode (${
397
+ `[dench-fs-daemon] chokidar not available; refusing to start (${
368
398
  error instanceof Error ? error.message : String(error)
369
399
  })`,
370
400
  );
371
- setInterval(() => undefined, 60_000);
372
- return;
401
+ process.exit(EXIT_CONFIG_ERROR);
373
402
  }
374
403
 
375
404
  const watcher = chokidar.watch(daemonArgs.workspace, {
@@ -606,11 +635,19 @@ async function main(): Promise<void> {
606
635
  */
607
636
  async function runInitialSync(argv: string[]): Promise<void> {
608
637
  const initial = argv.includes("--initial");
609
- const orgId = argv[argv.indexOf("--org-id") + 1];
610
- const workspace = argv[argv.indexOf("--workspace") + 1] ?? "/workspace";
611
- if (!initial || !orgId) {
638
+ const orgIdIndex = argv.indexOf("--org-id");
639
+ const workspaceIndex = argv.indexOf("--workspace");
640
+ const orgId =
641
+ orgIdIndex === -1
642
+ ? (process.env.DENCH_ORG_ID?.trim() ?? undefined)
643
+ : argv[orgIdIndex + 1];
644
+ const workspace =
645
+ workspaceIndex === -1
646
+ ? "/workspace"
647
+ : (argv[workspaceIndex + 1] ?? "/workspace");
648
+ if (!initial) {
612
649
  throw new Error(
613
- "dench fs sync --initial --org-id <orgId> [--workspace /workspace]",
650
+ "dench fs sync --initial [--org-id <orgId>] [--workspace /workspace]",
614
651
  );
615
652
  }
616
653
  const convexUrl = process.env.CONVEX_URL?.trim();
@@ -619,6 +656,10 @@ async function runInitialSync(argv: string[]): Promise<void> {
619
656
  throw new Error("dench fs sync requires CONVEX_URL + DENCH_API_KEY in env");
620
657
  }
621
658
  const client = new DenchFileClient(convexUrl, apiKey);
659
+ const resolvedOrgId = orgId ?? (await client.resolveOrganizationId());
660
+ process.stdout.write(
661
+ `[dench-fs-daemon] initial sync org=${resolvedOrgId} workspace=${workspace}\n`,
662
+ );
622
663
  const fs = await import("node:fs/promises");
623
664
  const path = await import("node:path");
624
665
 
@@ -671,10 +712,10 @@ main().catch((error) => {
671
712
  error instanceof Error ? (error.stack ?? error.message) : String(error)
672
713
  }`,
673
714
  );
674
- // Long-running daemon: don't exit on transient errors. One-shot
675
- // subcommands use a non-zero exit so callers see failure.
676
- if (process.argv.slice(2)[0]?.startsWith("stream-")) {
677
- process.exit(1);
678
- }
679
- setInterval(() => undefined, 60_000);
715
+ // Always exit with a non-zero code so the supervisor (in
716
+ // src/lib/daytona/sandbox-bootstrap.ts) can decide whether to
717
+ // restart. Previously we parked the process on a noop interval,
718
+ // which made `pgrep dench-fs-daemon` report "alive" for a daemon
719
+ // that had silently given up syncing.
720
+ process.exit(1);
680
721
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {