@dench.com/cli 0.4.6 → 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/README.md CHANGED
@@ -23,7 +23,27 @@ dench login --name "AI Agent - Billing Repo"
23
23
  to lowercase snake_case before send (e.g. `--kind "Claude Code"` becomes
24
24
  `claude_code`).
25
25
 
26
- The default host is `https://dench.dev`.
26
+ The default backend is `https://dench.dev` (production). Use `dench backend`
27
+ to switch:
28
+
29
+ ```bash
30
+ dench backend show
31
+ dench backend set production
32
+ dench backend set staging
33
+ dench backend set local # http://localhost:3000
34
+ dench backend set https://example.com # custom URL
35
+ dench backend clear # revert to production
36
+ ```
37
+
38
+ For one-shot overrides (no config change):
39
+
40
+ ```bash
41
+ dench --prod <cmd>
42
+ dench --staging <cmd>
43
+ dench --backend local <cmd>
44
+ dench --host https://example.com <cmd>
45
+ DENCH_HOST=https://example.com dench <cmd>
46
+ ```
27
47
 
28
48
  The smooth setup flow is:
29
49
 
@@ -197,13 +217,31 @@ Auth precedence:
197
217
 
198
218
  1. Explicit `--dev` flag → use the dev workspace env (`NEXT_PUBLIC_CONVEX_URL`,
199
219
  `DENCH_WORKSPACE`, `DENCH_DEV_AGENT_KEY`).
200
- 2. A locally-stored `dench login` session for the current host.
220
+ 2. A locally-stored `dench login` session for the current backend (set via
221
+ `dench backend`, `--host`, `--prod`, or `--staging`).
201
222
  3. `DENCH_API_KEY` + `CONVEX_URL` (sandbox / automation path).
202
223
 
203
224
  Plain terminal usage does not silently fall back to dev env vars. Without
204
225
  `--dev`, a saved session, or `DENCH_API_KEY` + `CONVEX_URL`, live commands
205
226
  return `login_required`.
206
227
 
228
+ ### `.env` auto-load is opt-in
229
+
230
+ When `dench` runs from inside the `dench.com` repo it does **not**
231
+ auto-load workspace `.env` / `.env.local` files by default — that used
232
+ to silently flip `NEXT_PUBLIC_CONVEX_URL` to the dev Convex deployment
233
+ and made the CLI talk to local Convex even when the user expected
234
+ production.
235
+
236
+ Auto-load now turns on only when one of:
237
+
238
+ - `--dev` (also switches the runtime to dev-workspace mode)
239
+ - `--use-dev-env` (load `.env` files but keep the rest of the runtime)
240
+ - `DENCH_USE_DEV_ENV=1` in the shell env (e.g. via `direnv`)
241
+ - `dench backend set local` (a stored local backend implies dev env)
242
+
243
+ Explicit shell exports (`GATEWAY_URL=… ./dench …`) always win regardless.
244
+
207
245
  Commands that need to impersonate a specific agent (`dench memory`,
208
246
  `dench task`, `dench claim`, `dench log`, `dench approval request`,
209
247
  etc.) still require an agent session (`dench login`) — the unified API
package/crm.ts CHANGED
@@ -1653,7 +1653,7 @@ async function callEnrichmentGatewayForEntry(args: {
1653
1653
 
1654
1654
  async function enrichOneEntry(
1655
1655
  ctx: CrmCliContext,
1656
- args: CellEnrichmentArgs & {
1656
+ args: Omit<CellEnrichmentArgs, "entryId"> & {
1657
1657
  target: EnrichmentTarget;
1658
1658
  entry: CrmEntryForEnrichment;
1659
1659
  },
package/dench.ts CHANGED
@@ -18,7 +18,12 @@ import { agentKindLabel, normalizeAgentKind } from "./agentKind";
18
18
  import {
19
19
  DEFAULT_HOST,
20
20
  isLocalHost,
21
+ LOCAL_HOST,
21
22
  normalizeHost,
23
+ PRODUCTION_API_URL,
24
+ PRODUCTION_CONVEX_URL,
25
+ PRODUCTION_HOST,
26
+ resolveBackendAlias,
22
27
  resolveHostFromArgs,
23
28
  STAGING_HOST,
24
29
  } from "./host";
@@ -29,7 +34,6 @@ import {
29
34
  listStoredSessionEntries,
30
35
  removeAllStoredSessions,
31
36
  removeStoredSession,
32
- resolveConfigHost,
33
37
  resolveSessionScope,
34
38
  type SessionScope,
35
39
  type StoredSessionEntry,
@@ -65,6 +69,24 @@ type StoredSession = {
65
69
  };
66
70
 
67
71
  type ConfigFile = {
72
+ /**
73
+ * Explicit user choice of which Dench backend to talk to by default,
74
+ * set via `dench backend set <local|staging|prod|<url>>`. Takes
75
+ * precedence over the legacy `currentHost` field (which used to be
76
+ * silently updated by every `dench login` and caused the CLI to
77
+ * silently follow the last-logged-in environment).
78
+ *
79
+ * Resolution order in `resolveHost`:
80
+ * 1. CLI flags (`--prod`, `--staging`, `--backend`, `--host`)
81
+ * 2. `DENCH_HOST` env var
82
+ * 3. `defaultBackend` from this config
83
+ * 4. `PRODUCTION_HOST` (the safe fallback)
84
+ *
85
+ * `currentHost` / `currentHosts` / `currentSessionKey` /
86
+ * `currentSessionKeys` keep their old role of remembering which
87
+ * stored session to use, but they no longer drive backend choice.
88
+ */
89
+ defaultBackend?: string;
68
90
  currentHost?: string;
69
91
  currentSessionKey?: string;
70
92
  currentHosts?: Record<string, string>;
@@ -244,6 +266,7 @@ const BOOLEAN_FLAGS = new Set([
244
266
  "--staging",
245
267
  "--prod",
246
268
  "--no-open",
269
+ "--use-dev-env",
247
270
  "--version",
248
271
  "--yolo",
249
272
  "--ask-before-publishing",
@@ -407,6 +430,7 @@ Usage:
407
430
  dench sessions [--host <host>] [--json]
408
431
  dench use <session-key-or-workspace-slug> [--host <host>] [--json]
409
432
  dench logout [session-key-or-workspace-slug] [--session <key-or-scope>] [--host <host>] [--all] [--json]
433
+ dench backend [show|set|clear] [<production|staging|local|<url>>] [--json]
410
434
  dench context [--json]
411
435
  dench status [--mine|--self] [--json]
412
436
  dench tasks [--json]
@@ -549,13 +573,25 @@ Agent setup:
549
573
  Use dench memory and dench artifacts to continue from durable context.
550
574
  Do not create, claim, or log a setup task by default.
551
575
 
552
- Hosts:
553
- Default production host: ${DEFAULT_HOST}
554
- Staging: dench login --staging
555
- Staging with explicit host: dench login --host ${STAGING_HOST}
556
-
557
- Dev fallback:
576
+ Backends and hosts:
577
+ Default backend: ${DEFAULT_HOST} (overridden by \`dench backend set\`)
578
+ Show / change the persistent default:
579
+ dench backend show
580
+ dench backend set production | staging | local | <url>
581
+ dench backend clear
582
+ One-shot overrides for a single command:
583
+ dench --prod <cmd>
584
+ dench --staging <cmd>
585
+ dench --backend local <cmd>
586
+ dench --host https://example.com <cmd>
587
+ DENCH_HOST=https://example.com dench <cmd>
588
+
589
+ Dev fallback (run against a local Convex deployment with a dev key):
558
590
  DENCH_DEV_AGENT_KEY=... DENCH_WORKSPACE=... NEXT_PUBLIC_CONVEX_URL=... dench status --dev
591
+ Inside the dench.com repo, --dev also auto-loads .env / .env.local
592
+ so GATEWAY_URL etc. flow through. Use --use-dev-env (or
593
+ DENCH_USE_DEV_ENV=1) when you want that auto-load without switching
594
+ the runtime to the dev workspace mode.
559
595
 
560
596
  Advanced:
561
597
  For long-lived agents or when the human asks:
@@ -728,8 +764,49 @@ Notes:
728
764
  `);
729
765
  }
730
766
 
731
- function resolveHost(config: ConfigFile | undefined, scope: SessionScope) {
732
- return resolveHostFromArgs(filteredArgs, resolveConfigHost(config, scope));
767
+ function backendHelp() {
768
+ console.log(`Dench backend
769
+
770
+ Usage:
771
+ dench backend # alias for "show"
772
+ dench backend show [--json]
773
+ dench backend set <production|staging|local|<url>> [--json]
774
+ dench backend clear [--json]
775
+
776
+ Aliases: prod = production, dev / localhost = local, stage = staging.
777
+
778
+ What this does:
779
+ The default Dench backend is production (${PRODUCTION_HOST}). "dench backend set"
780
+ stores an explicit override in ~/.dench/config.json so every future
781
+ "dench" invocation talks to that backend by default. Use it instead of
782
+ relying on a stale currentHost from a past "dench login".
783
+
784
+ Setting the backend to "local" also opts the CLI into auto-loading
785
+ .env and .env.local from the dench.com workspace at startup, which is
786
+ needed for GATEWAY_URL and similar local-only env vars.
787
+
788
+ One-shot overrides (no config change):
789
+ dench --prod <cmd> # one command against production
790
+ dench --staging <cmd> # one command against staging
791
+ dench --backend local <cmd> # one command against http://localhost:3000
792
+ dench --host https://… <cmd> # one command against a custom URL
793
+ DENCH_HOST=https://… dench … # same, via env
794
+
795
+ Opt into dev .env auto-loading without changing the default backend:
796
+ --use-dev-env # one-shot flag
797
+ DENCH_USE_DEV_ENV=1 # env var (e.g. via direnv)
798
+ `);
799
+ }
800
+
801
+ function resolveHost(config: ConfigFile | undefined, _scope: SessionScope) {
802
+ // `defaultBackend` is only ever set via `dench backend set`, so it
803
+ // represents an explicit user choice. Production wins on fresh
804
+ // installs (no flags, no env, no config) — which is the safe default
805
+ // for an agent CLI. The per-scope `currentHosts[scope.key]` map is
806
+ // intentionally NOT consulted here so that a past `dench login
807
+ // --host http://localhost:3000` doesn't silently keep routing every
808
+ // future command at local dev.
809
+ return resolveHostFromArgs(filteredArgs, config?.defaultBackend);
733
810
  }
734
811
 
735
812
  async function loadConfig(): Promise<ConfigFile> {
@@ -868,6 +945,194 @@ function formatSessionChoices(
868
945
  return lines.length > 0 ? lines.join("\n") : " No Dench sessions found.";
869
946
  }
870
947
 
948
+ function backendLabelForHost(host: string) {
949
+ const normalized = normalizeHost(host);
950
+ if (normalized === PRODUCTION_HOST) return "production";
951
+ if (normalized === STAGING_HOST) return "staging";
952
+ if (isLocalHost(normalized)) return "local";
953
+ return "custom";
954
+ }
955
+
956
+ function backendSnapshot(config: ConfigFile) {
957
+ const resolved = resolveHost(config, {
958
+ key: "auto:backend-show",
959
+ label: "cwd",
960
+ });
961
+ const stored = config.defaultBackend?.trim()
962
+ ? normalizeHost(config.defaultBackend.trim())
963
+ : null;
964
+ const envHost = process.env.DENCH_HOST?.trim() || null;
965
+ const argBackend =
966
+ option("--backend") ?? (hasFlag("--prod")
967
+ ? "production"
968
+ : hasFlag("--staging")
969
+ ? "staging"
970
+ : option("--host") ?? null);
971
+
972
+ let source: "flag" | "env" | "config" | "default";
973
+ if (argBackend) source = "flag";
974
+ else if (envHost) source = "env";
975
+ else if (stored) source = "config";
976
+ else source = "default";
977
+
978
+ return {
979
+ host: resolved,
980
+ label: backendLabelForHost(resolved),
981
+ source,
982
+ defaultBackend: stored,
983
+ envHost,
984
+ knownBackends: {
985
+ production: PRODUCTION_HOST,
986
+ staging: STAGING_HOST,
987
+ local: LOCAL_HOST,
988
+ },
989
+ };
990
+ }
991
+
992
+ function formatBackendShow(snapshot: ReturnType<typeof backendSnapshot>) {
993
+ const sourceHint =
994
+ snapshot.source === "flag"
995
+ ? "(set via CLI flag for this invocation)"
996
+ : snapshot.source === "env"
997
+ ? "(set via DENCH_HOST env var)"
998
+ : snapshot.source === "config"
999
+ ? "(set via `dench backend set`)"
1000
+ : "(default — no explicit choice in env or config)";
1001
+ const lines = [
1002
+ `Active backend: ${snapshot.host} [${snapshot.label}] ${sourceHint}`,
1003
+ ];
1004
+ if (snapshot.defaultBackend && snapshot.source !== "config") {
1005
+ lines.push(`Stored default backend: ${snapshot.defaultBackend}`);
1006
+ }
1007
+ if (snapshot.envHost) {
1008
+ lines.push(`DENCH_HOST env: ${snapshot.envHost}`);
1009
+ }
1010
+ lines.push(
1011
+ "",
1012
+ "Manage default backend:",
1013
+ " dench backend set production # default fallback",
1014
+ " dench backend set staging",
1015
+ " dench backend set local # http://localhost:3000",
1016
+ " dench backend set https://example.com # custom URL",
1017
+ " dench backend clear # revert to production default",
1018
+ "",
1019
+ "One-shot override flags also work on every command:",
1020
+ " dench --prod <cmd> dench --staging <cmd>",
1021
+ " dench --backend local <cmd> dench --host https://example.com <cmd>",
1022
+ );
1023
+ return lines.join("\n");
1024
+ }
1025
+
1026
+ async function backendShow() {
1027
+ const config = await loadConfig();
1028
+ const snapshot = backendSnapshot(config);
1029
+ print(json ? snapshot : formatBackendShow(snapshot));
1030
+ }
1031
+
1032
+ async function backendSet() {
1033
+ const target = positional(2);
1034
+ if (!target) {
1035
+ throw new CliError(
1036
+ "Missing backend value. Pass local, staging, prod, or a URL.",
1037
+ {
1038
+ code: "backend_set_missing_value",
1039
+ nextActions: [
1040
+ "Run dench backend set production.",
1041
+ "Run dench backend set local.",
1042
+ "Run dench backend set https://your-host.example.com.",
1043
+ ],
1044
+ },
1045
+ );
1046
+ }
1047
+ let resolved: string;
1048
+ try {
1049
+ resolved = resolveBackendAlias(target);
1050
+ } catch (error) {
1051
+ const message = error instanceof Error ? error.message : String(error);
1052
+ throw new CliError(message, {
1053
+ code: "backend_set_invalid_value",
1054
+ nextActions: [
1055
+ "Run dench backend set production / staging / local.",
1056
+ "Or pass an explicit URL: dench backend set https://your-host.example.com.",
1057
+ ],
1058
+ });
1059
+ }
1060
+ const config = await loadConfig();
1061
+ await saveConfig({ ...config, defaultBackend: resolved });
1062
+ const label = backendLabelForHost(resolved);
1063
+ if (json) {
1064
+ print({
1065
+ ok: true,
1066
+ defaultBackend: resolved,
1067
+ label,
1068
+ message: `Default Dench backend set to ${resolved} (${label}).`,
1069
+ });
1070
+ return;
1071
+ }
1072
+ print(`Default Dench backend set to ${resolved} (${label}).`);
1073
+ if (label === "local") {
1074
+ print(
1075
+ "Local backend selected. dench will now also load .env / .env.local from the workspace so GATEWAY_URL etc. are picked up.",
1076
+ );
1077
+ }
1078
+ print("Future `dench` commands without --prod/--staging/--host will target this backend.");
1079
+ }
1080
+
1081
+ async function backendClear() {
1082
+ const config = await loadConfig();
1083
+ if (!config.defaultBackend) {
1084
+ print(
1085
+ json
1086
+ ? { ok: true, cleared: false, defaultBackend: null }
1087
+ : "No default backend is set. dench already falls back to production.",
1088
+ );
1089
+ return;
1090
+ }
1091
+ const previous = config.defaultBackend;
1092
+ const next: ConfigFile = { ...config };
1093
+ delete next.defaultBackend;
1094
+ await saveConfig(next);
1095
+ print(
1096
+ json
1097
+ ? {
1098
+ ok: true,
1099
+ cleared: true,
1100
+ previous,
1101
+ defaultBackend: null,
1102
+ fallback: PRODUCTION_HOST,
1103
+ }
1104
+ : `Cleared default backend (was ${previous}). dench will fall back to ${PRODUCTION_HOST}.`,
1105
+ );
1106
+ }
1107
+
1108
+ async function runBackendCommand() {
1109
+ const sub = positional(1);
1110
+ if (!sub || sub === "show" || sub === "status") {
1111
+ await backendShow();
1112
+ return;
1113
+ }
1114
+ if (sub === "set") {
1115
+ await backendSet();
1116
+ return;
1117
+ }
1118
+ if (sub === "clear" || sub === "reset" || sub === "unset") {
1119
+ await backendClear();
1120
+ return;
1121
+ }
1122
+ if (sub === "help" || sub === "--help" || sub === "-h") {
1123
+ backendHelp();
1124
+ return;
1125
+ }
1126
+ throw new CliError(`Unknown dench backend subcommand: ${sub}`, {
1127
+ code: "unknown_backend_subcommand",
1128
+ nextActions: [
1129
+ "Run dench backend show to print the active backend.",
1130
+ "Run dench backend set <local|staging|prod|<url>>.",
1131
+ "Run dench backend clear to revert to production.",
1132
+ ],
1133
+ });
1134
+ }
1135
+
871
1136
  async function listSessions() {
872
1137
  const config = await loadConfig();
873
1138
  const scope = resolveSessionScope({ args: filteredArgs });
@@ -1487,12 +1752,37 @@ function hasDevEnv() {
1487
1752
  * dev typically only has `NEXT_PUBLIC_CONVEX_URL` exported. We accept
1488
1753
  * either so the same code path works in both shells without callers
1489
1754
  * needing to know which env var is set.
1755
+ *
1756
+ * When neither env var is set we fall back to the baked-in production
1757
+ * Convex URL (`PRODUCTION_CONVEX_URL`). This is a deliberate
1758
+ * defense-in-depth move: if a sandbox is provisioned while a Vercel
1759
+ * deployment is half-configured and `CONVEX_URL` lands as empty
1760
+ * string, the CLI inside it still ends up pointed at the production
1761
+ * deployment instead of crashing on a "missing Convex URL" error.
1762
+ * Dev / staging shells override this by exporting `CONVEX_URL` or
1763
+ * `NEXT_PUBLIC_CONVEX_URL` explicitly.
1490
1764
  */
1491
1765
  function resolveApiKeyConvexUrl(): string | undefined {
1492
1766
  return (
1493
1767
  process.env.CONVEX_URL?.trim() ||
1494
1768
  process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ||
1495
- undefined
1769
+ PRODUCTION_CONVEX_URL
1770
+ );
1771
+ }
1772
+
1773
+ /**
1774
+ * Resolve the Next.js API host the CLI should hit for HTTP calls (e.g.
1775
+ * Stripe billing topup, the gateway proxy). Same defense-in-depth
1776
+ * pattern as `resolveApiKeyConvexUrl`: env wins, then the resolved
1777
+ * Dench host the runtime is targeting, and finally
1778
+ * `PRODUCTION_API_URL` as the last resort.
1779
+ */
1780
+ function resolveApiHost(fallbackHost: string): string {
1781
+ return (
1782
+ process.env.DENCH_API_URL?.trim() ||
1783
+ process.env.DENCH_INTERNAL_BASE?.trim() ||
1784
+ fallbackHost ||
1785
+ PRODUCTION_API_URL
1496
1786
  );
1497
1787
  }
1498
1788
 
@@ -1556,10 +1846,7 @@ async function getRuntime() {
1556
1846
  if (sandboxAgentToken && sandboxConvexUrl) {
1557
1847
  const orgId = process.env.DENCH_ORG_ID?.trim();
1558
1848
  const orgSlug = process.env.DENCH_ORG_SLUG?.trim();
1559
- const apiHost =
1560
- process.env.DENCH_API_URL?.trim() ||
1561
- process.env.DENCH_INTERNAL_BASE?.trim() ||
1562
- host;
1849
+ const apiHost = resolveApiHost(host);
1563
1850
  return {
1564
1851
  mode: "session" as const,
1565
1852
  host: apiHost,
@@ -1583,10 +1870,7 @@ async function getRuntime() {
1583
1870
  if (apiKey && convexUrl) {
1584
1871
  const orgId = process.env.DENCH_ORG_ID?.trim();
1585
1872
  const orgSlug = process.env.DENCH_ORG_SLUG?.trim();
1586
- const apiHost =
1587
- process.env.DENCH_API_URL?.trim() ||
1588
- process.env.DENCH_INTERNAL_BASE?.trim() ||
1589
- host;
1873
+ const apiHost = resolveApiHost(host);
1590
1874
  return {
1591
1875
  mode: "apiKey" as const,
1592
1876
  host: apiHost,
@@ -1619,8 +1903,13 @@ async function getRuntime() {
1619
1903
  type Runtime = Awaited<ReturnType<typeof getRuntime>>;
1620
1904
 
1621
1905
  function stripRuntimeFlags(args: string[]) {
1622
- const booleanFlags = new Set(["--dev", "--prod", "--staging"]);
1623
- const valueFlags = new Set(["--convex-url", "--host"]);
1906
+ const booleanFlags = new Set([
1907
+ "--dev",
1908
+ "--prod",
1909
+ "--staging",
1910
+ "--use-dev-env",
1911
+ ]);
1912
+ const valueFlags = new Set(["--convex-url", "--host", "--backend"]);
1624
1913
  const stripped: string[] = [];
1625
1914
  for (let i = 0; i < args.length; i++) {
1626
1915
  const arg = args[i];
@@ -2937,6 +3226,14 @@ async function main() {
2937
3226
  return;
2938
3227
  }
2939
3228
 
3229
+ if (
3230
+ command === "backend" &&
3231
+ (subcommand === "help" || hasFlag("--help"))
3232
+ ) {
3233
+ backendHelp();
3234
+ return;
3235
+ }
3236
+
2940
3237
  if (command === "crm") {
2941
3238
  const subArgs = stripRuntimeFlags(args.slice(args.indexOf("crm") + 1));
2942
3239
  const sub = subArgs[0];
@@ -3267,6 +3564,11 @@ async function main() {
3267
3564
  return;
3268
3565
  }
3269
3566
 
3567
+ if (command === "backend") {
3568
+ await runBackendCommand();
3569
+ return;
3570
+ }
3571
+
3270
3572
  const runtime = await getRuntime();
3271
3573
 
3272
3574
  if (command === "billing") {
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.6",
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 {