@dench.com/cli 0.4.5 → 0.4.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/fs-daemon.ts CHANGED
@@ -68,6 +68,12 @@ import {
68
68
  readMountInfo,
69
69
  } from "./fs-daemon-mount";
70
70
 
71
+ type FileTreeSyncRow = {
72
+ path: string;
73
+ contentHash?: string;
74
+ isDir: boolean;
75
+ };
76
+
71
77
  type Args = {
72
78
  orgId?: string;
73
79
  workspace: string;
@@ -302,6 +308,9 @@ const api = {
302
308
  ),
303
309
  deleteFile: makeFunctionReference<"mutation">("functions/files:deleteFile"),
304
310
  listTree: makeFunctionReference<"query">("functions/files:listTree"),
311
+ listTreeSnapshot: makeFunctionReference<"query">(
312
+ "functions/files:listTreeSnapshot",
313
+ ),
305
314
  resolveApiKeyOrganization: makeFunctionReference<"query">(
306
315
  "functions/files:resolveApiKeyOrganization",
307
316
  ),
@@ -404,13 +413,11 @@ class DenchFileClient {
404
413
  * fs status` to recursively crawl the canonical tree and diff
405
414
  * against the local FS snapshot.
406
415
  */
407
- async listTree(
408
- prefix: string,
409
- ): Promise<Array<{ path: string; contentHash?: string; isDir: boolean }>> {
416
+ async listTree(prefix: string): Promise<FileTreeSyncRow[]> {
410
417
  const rows = (await this.http.query(api.files.listTree, {
411
418
  prefix,
412
419
  apiKey: this.apiKey,
413
- })) as Array<{ path: string; contentHash?: string; isDir: boolean }>;
420
+ })) as FileTreeSyncRow[];
414
421
  return Array.isArray(rows) ? rows : [];
415
422
  }
416
423
 
@@ -468,25 +475,26 @@ class DenchFileClient {
468
475
  */
469
476
  subscribeFileTree(
470
477
  convexUrl: string,
471
- onChange: (
472
- rows: Array<{ path: string; contentHash?: string; isDir: boolean }>,
473
- ) => void,
478
+ onChange: (rows: FileTreeSyncRow[]) => void,
474
479
  ): () => void {
475
480
  if (!this.realtime) {
476
481
  this.realtime = new ConvexClient(convexUrl);
477
482
  }
478
483
  const unsubscribe = this.realtime.onUpdate(
479
- api.files.listTree,
480
- { prefix: "/", apiKey: this.apiKey },
481
- (rows: unknown) => {
482
- const list = Array.isArray(rows)
483
- ? (rows as Array<{
484
- path: string;
485
- contentHash?: string;
486
- isDir: boolean;
487
- }>)
488
- : [];
489
- onChange(list);
484
+ api.files.listTreeSnapshot,
485
+ { apiKey: this.apiKey },
486
+ (snapshot: unknown) => {
487
+ // Remote writes may land deep in the tree (for example seeded
488
+ // `/skills/<slug>/SKILL.md` files). A root-only `listTree`
489
+ // subscription only sees the `/skills` directory row, so it
490
+ // never pulls the actual file bytes down to the volume.
491
+ const rows =
492
+ snapshot &&
493
+ typeof snapshot === "object" &&
494
+ Array.isArray((snapshot as { rows?: unknown }).rows)
495
+ ? (snapshot as { rows: FileTreeSyncRow[] }).rows
496
+ : [];
497
+ onChange(rows);
490
498
  },
491
499
  );
492
500
  return unsubscribe;
package/host.ts CHANGED
@@ -1,7 +1,24 @@
1
1
  export const PRODUCTION_HOST = "https://dench.dev";
2
2
  export const STAGING_HOST = "https://workspace-staging.dench.com";
3
+ export const LOCAL_HOST = "http://localhost:3000";
3
4
  export const DEFAULT_HOST = PRODUCTION_HOST;
4
5
 
6
+ /**
7
+ * Production-only fallbacks baked into the CLI binary so it does the
8
+ * right thing even when run with no config and no env vars (e.g. in a
9
+ * sandbox whose `buildSandboxEnvVars` map was assembled while the
10
+ * Vercel deployment was missing one of these keys).
11
+ *
12
+ * If any of these is wrong, EVERY install of @dench.com/cli is broken
13
+ * until republished — keep them in sync with whichever Convex
14
+ * deployment + Next.js host backs `https://www.dench.com`.
15
+ */
16
+ export const PRODUCTION_API_URL = "https://www.dench.com";
17
+ export const PRODUCTION_CONVEX_URL = "https://beaming-alligator-67.convex.cloud";
18
+ export const PRODUCTION_GATEWAY_URL = "https://gateway.merseoriginals.com";
19
+
20
+ export type BackendAlias = "production" | "prod" | "staging" | "local";
21
+
5
22
  function optionFromArgs(args: string[], name: string) {
6
23
  const index = args.indexOf(name);
7
24
  if (index === -1) return undefined;
@@ -13,7 +30,13 @@ function hasFlag(args: string[], name: string) {
13
30
  }
14
31
 
15
32
  export function normalizeHost(input: string) {
16
- const withProtocol = /^https?:\/\//.test(input) ? input : `https://${input}`;
33
+ const trimmed = input.trim();
34
+ if (/^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|$)/.test(trimmed)) {
35
+ return new URL(`http://${trimmed}`).origin;
36
+ }
37
+ const withProtocol = /^https?:\/\//.test(trimmed)
38
+ ? trimmed
39
+ : `https://${trimmed}`;
17
40
  const url = new URL(withProtocol);
18
41
  return url.origin;
19
42
  }
@@ -29,6 +52,39 @@ export function isLocalHost(input: string) {
29
52
  );
30
53
  }
31
54
 
55
+ /**
56
+ * Resolve `local|staging|prod|production|<url>` -> a normalized host
57
+ * origin. Aliases let `dench backend set <alias>` (and `--backend
58
+ * <alias>`) accept the short forms most agents reach for first.
59
+ * Returns `undefined` for empty input and throws on values that
60
+ * neither match an alias nor parse as a URL.
61
+ */
62
+ export function resolveBackendAlias(input: string): string {
63
+ const value = input.trim();
64
+ if (!value) {
65
+ throw new Error(
66
+ "Backend value is empty. Pass local, staging, prod, or a URL like https://dench.dev.",
67
+ );
68
+ }
69
+ const normalized = value.toLowerCase();
70
+ if (normalized === "prod" || normalized === "production") {
71
+ return PRODUCTION_HOST;
72
+ }
73
+ if (normalized === "staging" || normalized === "stage") {
74
+ return STAGING_HOST;
75
+ }
76
+ if (normalized === "local" || normalized === "localhost" || normalized === "dev") {
77
+ return LOCAL_HOST;
78
+ }
79
+ try {
80
+ return normalizeHost(value);
81
+ } catch (_error) {
82
+ throw new Error(
83
+ `Unrecognized backend "${value}". Use local, staging, prod, or a URL like https://dench.dev.`,
84
+ );
85
+ }
86
+ }
87
+
32
88
  export function resolveHostFromArgs(
33
89
  args: string[],
34
90
  configHost?: string,
@@ -37,6 +93,9 @@ export function resolveHostFromArgs(
37
93
  if (hasFlag(args, "--prod")) return PRODUCTION_HOST;
38
94
  if (hasFlag(args, "--staging")) return STAGING_HOST;
39
95
 
96
+ const explicitBackend = optionFromArgs(args, "--backend");
97
+ if (explicitBackend) return resolveBackendAlias(explicitBackend);
98
+
40
99
  const explicit = optionFromArgs(args, "--host") ?? envHost;
41
100
  if (explicit) return normalizeHost(explicit);
42
101
  if (configHost) return normalizeHost(configHost);
@@ -5,10 +5,13 @@ export type FetchLike = (
5
5
  init?: RequestInit,
6
6
  ) => Promise<Response>;
7
7
 
8
- export type EnrichmentGatewayEnv = Pick<
9
- NodeJS.ProcessEnv,
10
- "DENCH_API_KEY" | "DENCH_GATEWAY_URL" | "GATEWAY_URL" | "DENCH_HOST"
11
- >;
8
+ export type EnrichmentGatewayEnv = {
9
+ DENCH_API_KEY?: string;
10
+ DENCH_GATEWAY_URL?: string;
11
+ GATEWAY_URL?: string;
12
+ DENCH_HOST?: string;
13
+ [key: string]: string | undefined;
14
+ };
12
15
 
13
16
  export class EnrichmentGatewayError extends Error {
14
17
  readonly status?: number;
@@ -1,27 +1,100 @@
1
- // When `./dench` (or `./dench.mjs`) runs through Node from inside the
2
- // dench.com source tree, Node does not auto-load workspace `.env` files
3
- // the way Bun does for `bun run`. That made every gateway-backed
4
- // subcommand (`dench apps`, `dench tool …`, the enrichment helpers,
5
- // etc.) silently default to production
6
- // (`https://gateway.merseoriginals.com`), because `cli/tools.ts`
7
- // resolves the gateway URL purely from `process.env`. A dev-issued
8
- // `DENCH_API_KEY` then 401'd against prod with `invalid_api_key`.
1
+ // `./dench` and `./dench.mjs` ship as Node entry points. When Node runs
2
+ // them from inside the dench.com source tree it does NOT auto-load
3
+ // workspace `.env` files the way `bun run` does, so we have a one-shot
4
+ // chance here to inject env vars (e.g. `GATEWAY_URL`,
5
+ // `NEXT_PUBLIC_CONVEX_URL`) before `dench.ts` is imported.
9
6
  //
10
- // This helper detects the dench.com source tree (via the parent
11
- // `package.json` name) and loads `.env` then `.env.local` from the repo
12
- // root. Globally-installed and sandbox-baked CLI installs never see
13
- // that marker, so this is a no-op there. Existing `process.env` values
14
- // always win, so explicit shell exports (e.g.
15
- // `GATEWAY_URL=… ./dench …`) still take precedence over file values.
7
+ // HISTORY: this helper used to ALWAYS load `.env` and `.env.local` when
8
+ // the parent package was `ironclaw-cloud`. That fixed gateway-backed
9
+ // subcommands (`dench apps`, `dench tool …`, the enrichment helpers)
10
+ // that would otherwise silently 401 against production gateway with a
11
+ // dev-issued `DENCH_API_KEY`. The side effect was severe: the auto-load
12
+ // also injected `NEXT_PUBLIC_CONVEX_URL=…dev convex…` and friends, so
13
+ // every Convex call from the CLI silently pointed at the dev
14
+ // deployment, even when the user expected production.
15
+ //
16
+ // FIX: dev-env loading is now OPT-IN. The default is "do nothing" so
17
+ // the CLI always defaults to production, no matter where it is invoked
18
+ // from. Users opt into local/dev env vars one of three ways:
19
+ //
20
+ // 1. `--dev` flag in argv (existing flag — also flips the runtime to
21
+ // dev workspace mode).
22
+ // 2. `--use-dev-env` flag in argv (load `.env` files without
23
+ // switching to dev workspace mode — useful when you want
24
+ // `GATEWAY_URL=http://0.0.0.0:8787` baked in but still talk to a
25
+ // stored session).
26
+ // 3. `DENCH_USE_DEV_ENV=1` env var (same effect as `--use-dev-env`,
27
+ // handy for `direnv` setups).
28
+ // 4. `defaultBackend: "http://localhost:…"` (or any local URL) in
29
+ // `~/.dench/config.json` — set via `dench backend set local`. If
30
+ // the user has explicitly pointed the CLI at localhost they almost
31
+ // always also want the dev gateway URL.
32
+ //
33
+ // Existing `process.env` values always win, so explicit shell exports
34
+ // (e.g. `GATEWAY_URL=… ./dench …`) keep working even with auto-load
35
+ // disabled.
16
36
 
17
37
  import { existsSync, readFileSync } from "node:fs";
38
+ import { homedir } from "node:os";
18
39
  import { dirname, join } from "node:path";
19
40
  import { fileURLToPath } from "node:url";
20
41
 
21
42
  const WORKSPACE_PACKAGE_NAME = "ironclaw-cloud";
22
43
  const ENV_FILES = [".env", ".env.local"];
44
+ const CONFIG_PATH = join(homedir(), ".dench", "config.json");
45
+
46
+ const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
47
+
48
+ function envFlagEnabled(value) {
49
+ if (!value) return false;
50
+ return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
51
+ }
23
52
 
24
- export function loadDevEnv(callerUrl) {
53
+ function argvOptIn(argv) {
54
+ if (!Array.isArray(argv)) return false;
55
+ return argv.includes("--dev") || argv.includes("--use-dev-env");
56
+ }
57
+
58
+ function configOptIn() {
59
+ try {
60
+ if (!existsSync(CONFIG_PATH)) return false;
61
+ const raw = readFileSync(CONFIG_PATH, "utf8");
62
+ const config = JSON.parse(raw);
63
+ const backend = config?.defaultBackend;
64
+ if (typeof backend !== "string" || !backend.trim()) return false;
65
+ return isLocalUrl(backend.trim());
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function isLocalUrl(value) {
72
+ try {
73
+ const url = new URL(/^https?:\/\//.test(value) ? value : `https://${value}`);
74
+ const host = url.hostname;
75
+ return (
76
+ host === "localhost" ||
77
+ host === "127.0.0.1" ||
78
+ host === "0.0.0.0" ||
79
+ host === "::1" ||
80
+ host.endsWith(".localhost")
81
+ );
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ export function shouldLoadDevEnv({
88
+ argv = process.argv.slice(2),
89
+ env = process.env,
90
+ } = {}) {
91
+ if (envFlagEnabled(env.DENCH_USE_DEV_ENV)) return "env";
92
+ if (argvOptIn(argv)) return "argv";
93
+ if (configOptIn()) return "config";
94
+ return false;
95
+ }
96
+
97
+ export function loadDevEnv(callerUrl, options = {}) {
25
98
  try {
26
99
  const here = dirname(fileURLToPath(callerUrl));
27
100
  const repoRoot = dirname(here);
@@ -30,6 +103,8 @@ export function loadDevEnv(callerUrl) {
30
103
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
31
104
  if (pkg?.name !== WORKSPACE_PACKAGE_NAME) return;
32
105
 
106
+ if (!shouldLoadDevEnv(options)) return;
107
+
33
108
  for (const filename of ENV_FILES) {
34
109
  const path = join(repoRoot, filename);
35
110
  if (!existsSync(path)) continue;
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { shouldLoadDevEnv } from "./load-dev-env.mjs";
3
+
4
+ describe("shouldLoadDevEnv", () => {
5
+ it("returns false on a fresh install with no opt-in", () => {
6
+ expect(shouldLoadDevEnv({ argv: [], env: {} })).toBe(false);
7
+ });
8
+
9
+ it("opts in when --dev is in argv", () => {
10
+ expect(shouldLoadDevEnv({ argv: ["status", "--dev"], env: {} })).toBe(
11
+ "argv",
12
+ );
13
+ });
14
+
15
+ it("opts in when --use-dev-env is in argv", () => {
16
+ expect(
17
+ shouldLoadDevEnv({ argv: ["apps", "--use-dev-env"], env: {} }),
18
+ ).toBe("argv");
19
+ });
20
+
21
+ it("opts in when DENCH_USE_DEV_ENV is set to a truthy value", () => {
22
+ expect(shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "1" } })).toBe(
23
+ "env",
24
+ );
25
+ expect(
26
+ shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "true" } }),
27
+ ).toBe("env");
28
+ expect(
29
+ shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "yes" } }),
30
+ ).toBe("env");
31
+ });
32
+
33
+ it("ignores DENCH_USE_DEV_ENV when set to a falsy/empty value", () => {
34
+ expect(shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "" } })).toBe(
35
+ false,
36
+ );
37
+ expect(shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "0" } })).toBe(
38
+ false,
39
+ );
40
+ expect(
41
+ shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "false" } }),
42
+ ).toBe(false);
43
+ });
44
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/tools.ts CHANGED
@@ -36,6 +36,10 @@
36
36
  * working.
37
37
  */
38
38
 
39
+ import {
40
+ PRODUCTION_API_URL,
41
+ PRODUCTION_GATEWAY_URL,
42
+ } from "./host";
39
43
  import {
40
44
  CliArgError,
41
45
  getFlag,
@@ -380,7 +384,7 @@ function resolveGatewayBaseUrl(): string {
380
384
  if (host && /localhost|127\.0\.0\.1/.test(host)) {
381
385
  return "http://localhost:8787";
382
386
  }
383
- return "https://gateway.merseoriginals.com";
387
+ return PRODUCTION_GATEWAY_URL;
384
388
  }
385
389
 
386
390
  /**
@@ -403,7 +407,7 @@ function resolveAppPublicOrigin(): string {
403
407
  const trimmed = candidate?.trim();
404
408
  if (trimmed) return trimmed.replace(/\/+$/, "");
405
409
  }
406
- return "https://dench.com";
410
+ return PRODUCTION_API_URL;
407
411
  }
408
412
 
409
413
  function requireApiKey(): string {