@odeva/cli 0.0.10 → 0.0.12

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.
@@ -1,14 +1,20 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
2
  import * as p from "@clack/prompts";
3
3
  import { OdevaApi } from "../../../lib/api.js";
4
- import { loadAppConfig, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
4
+ import { loadAppConfig, resolveAppConfigUrl, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
5
5
  import { saveAppEnv } from "../../../lib/app-env.js";
6
6
  import { loadCredentials } from "../../../lib/credentials.js";
7
7
  import { withErrorHandling } from "../../../lib/run.js";
8
8
  import { ui } from "../../../lib/ui.js";
9
9
  import { ApiError, CliError } from "../../../lib/errors.js";
10
10
  import { isValidSlug } from "../../../lib/slug.js";
11
- function buildAdminEmbedInput(admin, configPath) {
11
+ function buildAdminEmbedInput(config, configPath) {
12
+ const admin = config.admin;
13
+ if (!admin) {
14
+ throw new CliError("Cannot build admin embed input without an [admin] section.", {
15
+ hint: `Edit ${configPath}`
16
+ });
17
+ }
12
18
  const validIcons = ADMIN_ICONS;
13
19
  if (!validIcons.includes(admin.sidebar.icon)) {
14
20
  const iconList = ADMIN_ICONS.join(", ");
@@ -17,8 +23,20 @@ function buildAdminEmbedInput(admin, configPath) {
17
23
  { hint: `Edit ${configPath}` }
18
24
  );
19
25
  }
26
+ const entryUrl = resolveAppConfigUrl({
27
+ config,
28
+ configuredUrl: admin.entry_url,
29
+ defaultPath: "/admin",
30
+ fieldName: "admin.entry_url",
31
+ configPath
32
+ });
33
+ if (!entryUrl) {
34
+ throw new CliError(`Invalid odeva.app.toml: missing 'admin.entry_url'.`, {
35
+ hint: `Set 'tunnel_url' or add 'admin.entry_url' in ${configPath}.`
36
+ });
37
+ }
20
38
  return {
21
- entryUrl: admin.entry_url,
39
+ entryUrl,
22
40
  sidebar: {
23
41
  label: admin.sidebar.label,
24
42
  icon: admin.sidebar.icon
@@ -38,16 +56,25 @@ class AppConfigLink extends Command {
38
56
  const { flags } = await this.parse(AppConfigLink);
39
57
  await withErrorHandling(this, async () => {
40
58
  const loaded = loadAppConfig();
41
- const adminInput = loaded.config.admin ? buildAdminEmbedInput(loaded.config.admin, loaded.path) : void 0;
59
+ const adminInput = loaded.config.admin ? buildAdminEmbedInput(loaded.config, loaded.path) : void 0;
60
+ const installUrl = resolveAppConfigUrl({
61
+ config: loaded.config,
62
+ configuredUrl: loaded.config.install_url,
63
+ defaultPath: "/install",
64
+ fieldName: "install_url",
65
+ configPath: loaded.path
66
+ });
42
67
  p.intro(ui.brand("odeva app config link"));
43
68
  p.note(
44
69
  [
45
70
  `${ui.dim("name:")} ${loaded.config.name}`,
46
71
  `${ui.dim("slug:")} ${loaded.config.slug}`,
47
72
  `${ui.dim("client_id:")} ${loaded.config.client_id ?? ui.dim("(new)")}`,
73
+ `${ui.dim("tunnel_url:")} ${loaded.config.tunnel_url ?? ui.dim("(none)")}`,
48
74
  `${ui.dim("homepage:")} ${loaded.config.homepage_url ?? ui.dim("(none)")}`,
75
+ `${ui.dim("install:")} ${installUrl ?? ui.dim("(none)")}`,
49
76
  `${ui.dim("scopes:")} ${(loaded.config.access_scopes?.scopes ?? []).join(", ") || ui.dim("(none)")}`,
50
- ...loaded.config.admin ? [`${ui.dim("admin:")} ${loaded.config.admin.sidebar.label} (${loaded.config.admin.entry_url})`] : []
77
+ ...loaded.config.admin ? [`${ui.dim("admin:")} ${loaded.config.admin.sidebar.label} (${adminInput?.entryUrl})`] : []
51
78
  ].join("\n"),
52
79
  "Will register this app"
53
80
  );
@@ -65,7 +92,7 @@ class AppConfigLink extends Command {
65
92
  if (match) existingId = match.id;
66
93
  }
67
94
  spinner.message(existingId ? "Updating app on Odeva" : "Creating app on Odeva");
68
- const { app, rawClientSecret } = await upsertWithSlugRetry(api, spinner, loaded, existingId, adminInput);
95
+ const { app, rawClientSecret } = await upsertWithSlugRetry(api, spinner, loaded, existingId, installUrl, adminInput);
69
96
  spinner.stop(`${existingId ? "Updated" : "Registered"} app ${ui.code(app.slug)} (client_id ${ui.code(app.clientId)})`);
70
97
  loaded.config.client_id = app.clientId;
71
98
  saveAppConfig(loaded);
@@ -102,7 +129,7 @@ class AppConfigLink extends Command {
102
129
  }
103
130
  }
104
131
  const SLUG_TAKEN_RE = /slug.*(?:already been taken|taken|in use|exists)/i;
105
- async function upsertWithSlugRetry(api, spinner, loaded, existingId, adminInput) {
132
+ async function upsertWithSlugRetry(api, spinner, loaded, existingId, installUrl, adminInput) {
106
133
  for (; ; ) {
107
134
  try {
108
135
  return await api.upsertDeveloperApp({
@@ -111,7 +138,7 @@ async function upsertWithSlugRetry(api, spinner, loaded, existingId, adminInput)
111
138
  slug: loaded.config.slug,
112
139
  description: loaded.config.description,
113
140
  homepageUrl: loaded.config.homepage_url,
114
- installUrl: loaded.config.install_url,
141
+ installUrl,
115
142
  privacyUrl: loaded.config.privacy_url,
116
143
  requestedScopes: loaded.config.access_scopes?.scopes,
117
144
  ...adminInput ? { adminEmbed: adminInput } : {}
@@ -13,6 +13,7 @@ import {
13
13
  preflightChecks,
14
14
  registeredWebhookEnv,
15
15
  registerWebhookSubscriptions,
16
+ saveDevTunnelUrl,
16
17
  spawnDevServer,
17
18
  syncDevAppUrls,
18
19
  watchedDevInputPaths,
@@ -66,22 +67,44 @@ class AppDev extends Command {
66
67
  PORT: String(port)
67
68
  }
68
69
  });
70
+ let tunnel;
71
+ let stopWatcher = () => {
72
+ };
73
+ let reloading = Promise.resolve();
74
+ let shuttingDown = false;
75
+ installShutdownHandler(async () => {
76
+ shuttingDown = true;
77
+ this.log("\n" + ui.dim("Cleaning up..."));
78
+ stopWatcher();
79
+ await reloading;
80
+ await Promise.allSettled([
81
+ server.stop(),
82
+ tunnel?.stop(),
83
+ cleanupSubscriptions(api, registered)
84
+ ]);
85
+ this.log(ui.ok("Done."));
86
+ });
69
87
  await this.verifyLocalDevServer(port, server, api, registered);
70
- const tunnel = await this.startReachableTunnel(port, server);
71
- registered = await this.registerDevWebhooks(api, loaded, tunnel.url);
88
+ tunnel = await this.startReachableTunnel(port, server, (startedTunnel) => {
89
+ tunnel = startedTunnel;
90
+ });
91
+ const activeTunnel = tunnel;
92
+ if (saveDevTunnelUrl(loaded, activeTunnel.url)) {
93
+ this.log(ui.ok(`Wrote tunnel_url to ${ui.code(loaded.path)}`));
94
+ }
95
+ registered = await this.registerDevWebhooks(api, loaded, activeTunnel.url);
72
96
  this.log("");
73
- this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
97
+ this.log(`${ui.bold("Tunnel:")} ${activeTunnel.url}`);
74
98
  this.log(`${ui.bold("Local:")} http://localhost:${port}`);
75
99
  for (const reg of registered) {
76
100
  this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
77
101
  }
78
102
  this.log("");
79
- const syncedUrls = await this.syncDevAppUrls(api, loaded, tunnel.url);
103
+ const syncedUrls = await this.syncDevAppUrls(api, loaded, activeTunnel.url);
80
104
  this.printDevAppInfo(syncedUrls);
81
- await this.ensureDevAppInstalled(api, loaded, tunnel, port, server, registered);
105
+ await this.ensureDevAppInstalled(api, loaded, activeTunnel, port, server, registered);
82
106
  this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
83
107
  this.log("");
84
- let shuttingDown = false;
85
108
  let restartingServer = false;
86
109
  let serverExited = () => {
87
110
  };
@@ -94,8 +117,7 @@ class AppDev extends Command {
94
117
  });
95
118
  };
96
119
  watchServerExit(server);
97
- let reloading = Promise.resolve();
98
- const stopWatcher = this.watchDevInputs(loaded.path, async () => {
120
+ stopWatcher = this.watchDevInputs(loaded.path, async () => {
99
121
  reloading = reloading.then(async () => {
100
122
  this.log("\n" + ui.dim("Reloading app dev config..."));
101
123
  const nextLoaded = loadAppConfig(loaded.root);
@@ -103,7 +125,10 @@ class AppDev extends Command {
103
125
  if (nextPort !== port) {
104
126
  this.log(ui.warn(`Port changed to ${nextPort}; restart \`odeva app dev\` to recreate the tunnel.`));
105
127
  }
106
- const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, tunnel.url);
128
+ if (saveDevTunnelUrl(nextLoaded, activeTunnel.url)) {
129
+ this.log(ui.ok(`Wrote tunnel_url to ${ui.code(nextLoaded.path)}`));
130
+ }
131
+ const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, activeTunnel.url);
107
132
  await cleanupSubscriptions(api, registered);
108
133
  registered = nextRegistered;
109
134
  const nextAppEnv = loadAppEnv(nextLoaded.path);
@@ -116,32 +141,20 @@ class AppDev extends Command {
116
141
  env: {
117
142
  ...nextAppEnv,
118
143
  PORT: String(port),
119
- ODEVA_TUNNEL_URL: tunnel.url,
144
+ ODEVA_TUNNEL_URL: activeTunnel.url,
120
145
  ...registeredWebhookEnv(registered)
121
146
  }
122
147
  });
123
148
  server = nextServer;
124
149
  watchServerExit(server);
125
- await this.verifyDevServerReachable(tunnel, port, server, api, registered);
126
- await this.syncDevAppUrls(api, nextLoaded, tunnel.url);
150
+ await this.verifyDevServerReachable(activeTunnel, port, server, api, registered);
151
+ await this.syncDevAppUrls(api, nextLoaded, activeTunnel.url);
127
152
  this.log(ui.ok("Reloaded app dev config."));
128
153
  }).catch((err) => {
129
154
  restartingServer = false;
130
155
  this.log(ui.err(`Reload failed: ${err.message}`));
131
156
  });
132
157
  });
133
- installShutdownHandler(async () => {
134
- shuttingDown = true;
135
- this.log("\n" + ui.dim("Cleaning up..."));
136
- stopWatcher();
137
- await reloading;
138
- await Promise.allSettled([
139
- server.stop(),
140
- tunnel.stop(),
141
- cleanupSubscriptions(api, registered)
142
- ]);
143
- this.log(ui.ok("Done."));
144
- });
145
158
  await serverExit;
146
159
  shuttingDown = true;
147
160
  stopWatcher();
@@ -241,19 +254,19 @@ class AppDev extends Command {
241
254
  throw err;
242
255
  }
243
256
  }
244
- async startReachableTunnel(port, server) {
257
+ async startReachableTunnel(port, server, onTunnelStarted) {
245
258
  let lastError;
246
259
  for (let attempt = 1; attempt <= 3; attempt += 1) {
247
260
  this.log(ui.dim(`Starting Cloudflare tunnel${attempt > 1 ? ` (attempt ${attempt}/3)` : ""}...`));
248
261
  const tunnel = await startQuickTunnel(port);
262
+ onTunnelStarted?.(tunnel);
249
263
  this.log(ui.ok(`Tunnel ready at ${ui.code(tunnel.url)}`));
250
- this.log(ui.dim("Waiting 20s for trycloudflare DNS to propagate before first probe..."));
251
- await new Promise((resolve) => setTimeout(resolve, 2e4));
252
- this.log(ui.dim("Verifying dev server and tunnel... (trycloudflare can take up to 60s to propagate)"));
264
+ this.log(ui.dim("Verifying dev server and tunnel... (retrying for up to 60s while trycloudflare becomes reachable)"));
253
265
  try {
254
266
  const result = await waitForDevServerReachable({
255
267
  localPort: port,
256
- tunnelUrl: tunnel.url
268
+ tunnelUrl: tunnel.url,
269
+ onProbeFailure: this.logTunnelProbeProgress()
257
270
  });
258
271
  this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
259
272
  return tunnel;
@@ -274,7 +287,8 @@ class AppDev extends Command {
274
287
  try {
275
288
  const result = await waitForDevServerReachable({
276
289
  localPort: port,
277
- tunnelUrl: tunnel.url
290
+ tunnelUrl: tunnel.url,
291
+ onProbeFailure: this.logTunnelProbeProgress()
278
292
  });
279
293
  this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
280
294
  } catch (err) {
@@ -287,6 +301,17 @@ class AppDev extends Command {
287
301
  throw err;
288
302
  }
289
303
  }
304
+ logTunnelProbeProgress() {
305
+ let lastLogAt = 0;
306
+ return (probe) => {
307
+ const now = Date.now();
308
+ if (probe.elapsedMs < 5e3 || now - lastLogAt < 5e3) return;
309
+ lastLogAt = now;
310
+ this.log(ui.dim(
311
+ `Still waiting for tunnel ${ui.code(probe.path)} after ${Math.round(probe.elapsedMs / 1e3)}s: ${probe.lastError}`
312
+ ));
313
+ };
314
+ }
290
315
  watchDevInputs(appConfigPath, onChange) {
291
316
  const paths = watchedDevInputPaths(appConfigPath);
292
317
  let timer;
@@ -14,6 +14,20 @@ const ADMIN_ICONS = [
14
14
  "bar-chart",
15
15
  "wallet"
16
16
  ];
17
+ function resolveAppConfigUrl(opts) {
18
+ const configured = opts.configuredUrl?.trim();
19
+ if (configured) {
20
+ if (isAbsoluteHttpUrl(configured)) return new URL(configured).toString();
21
+ if (configured.startsWith("/")) {
22
+ return appUrlFromTunnel(opts.config.tunnel_url, configured, opts.fieldName, opts.configPath);
23
+ }
24
+ throw new CliError(`Invalid '${opts.fieldName}' in ${APP_CONFIG_FILE}: must be an absolute URL or path starting with '/'.`, {
25
+ hint: `Edit ${opts.configPath}`
26
+ });
27
+ }
28
+ if (!opts.config.tunnel_url) return void 0;
29
+ return appUrlFromTunnel(opts.config.tunnel_url, opts.defaultPath, "tunnel_url", opts.configPath);
30
+ }
17
31
  function findAppConfigPath(startDir = process.cwd()) {
18
32
  let dir = resolve(startDir);
19
33
  while (true) {
@@ -70,27 +84,68 @@ function validateConfig(config, path) {
70
84
  };
71
85
  });
72
86
  }
87
+ if (config.tunnel_url !== void 0) {
88
+ validateTunnelUrl(config.tunnel_url, path);
89
+ }
90
+ if (config.install_url !== void 0 && config.install_url !== "") {
91
+ validateResolvableUrl(config.install_url, "install_url", config.tunnel_url, path);
92
+ }
73
93
  if (config.admin !== void 0) {
74
- validateAdminSection(config.admin, path);
94
+ validateAdminSection(config.admin, config.tunnel_url, path);
95
+ }
96
+ }
97
+ function validateTunnelUrl(value, path) {
98
+ if (typeof value !== "string" || !value) {
99
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: 'tunnel_url' must be a non-empty string.`, {
100
+ hint: `Edit ${path}`
101
+ });
102
+ }
103
+ if (!isAbsoluteHttpUrl(value)) {
104
+ throw new CliError(`Invalid 'tunnel_url' in ${APP_CONFIG_FILE}: must start with https:// or http://.`, {
105
+ hint: `Edit ${path}`
106
+ });
107
+ }
108
+ }
109
+ function validateResolvableUrl(value, fieldName, tunnelUrl, path) {
110
+ if (typeof value !== "string" || !value) {
111
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: '${fieldName}' must be a non-empty string.`, {
112
+ hint: `Edit ${path}`
113
+ });
114
+ }
115
+ if (isAbsoluteHttpUrl(value)) {
116
+ if (fieldName === "admin.entry_url" && !value.startsWith("https://")) {
117
+ throw new CliError(
118
+ `Invalid 'admin.entry_url' in ${APP_CONFIG_FILE}: must start with https://.`,
119
+ { hint: `Edit ${path}` }
120
+ );
121
+ }
122
+ return;
75
123
  }
124
+ if (value.startsWith("/") && tunnelUrl) return;
125
+ throw new CliError(
126
+ `Invalid '${fieldName}' in ${APP_CONFIG_FILE}: must be an absolute URL or a path with 'tunnel_url' set.`,
127
+ { hint: `Edit ${path}` }
128
+ );
76
129
  }
77
- function validateAdminSection(admin, path) {
130
+ function validateAdminSection(admin, tunnelUrl, path) {
78
131
  if (typeof admin !== "object" || admin === null || Array.isArray(admin)) {
79
132
  throw new CliError(`Invalid ${APP_CONFIG_FILE}: 'admin' must be a table.`, {
80
133
  hint: `Edit ${path}`
81
134
  });
82
135
  }
83
136
  const a = admin;
84
- if (typeof a["entry_url"] !== "string" || !a["entry_url"]) {
137
+ if (a["entry_url"] === void 0) {
138
+ if (!tunnelUrl) {
139
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'admin.entry_url'.`, {
140
+ hint: `Set 'tunnel_url' or add 'admin.entry_url' in ${path}.`
141
+ });
142
+ }
143
+ } else if (a["entry_url"] === "") {
85
144
  throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'admin.entry_url'.`, {
86
145
  hint: `Edit ${path}`
87
146
  });
88
- }
89
- if (!a["entry_url"].startsWith("https://")) {
90
- throw new CliError(
91
- `Invalid 'admin.entry_url' in ${APP_CONFIG_FILE}: must start with https://.`,
92
- { hint: `Edit ${path}` }
93
- );
147
+ } else {
148
+ validateResolvableUrl(a["entry_url"], "admin.entry_url", tunnelUrl, path);
94
149
  }
95
150
  const sidebar = a["sidebar"];
96
151
  if (typeof sidebar !== "object" || sidebar === null || Array.isArray(sidebar)) {
@@ -122,10 +177,30 @@ function validateAdminSection(admin, path) {
122
177
  );
123
178
  }
124
179
  }
180
+ function appUrlFromTunnel(tunnelUrl, pathValue, fieldName, configPath) {
181
+ if (!tunnelUrl) {
182
+ throw new CliError(`Cannot resolve '${fieldName}' without 'tunnel_url'.`, {
183
+ hint: `Set tunnel_url in ${configPath}.`
184
+ });
185
+ }
186
+ const url = new URL(tunnelUrl);
187
+ url.pathname = pathValue;
188
+ url.search = "";
189
+ return url.toString();
190
+ }
191
+ function isAbsoluteHttpUrl(value) {
192
+ try {
193
+ const url = new URL(value);
194
+ return url.protocol === "http:" || url.protocol === "https:";
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
125
199
  export {
126
200
  ADMIN_ICONS,
127
201
  findAppConfigPath,
128
202
  loadAppConfig,
203
+ resolveAppConfigUrl,
129
204
  saveAppConfig,
130
205
  writeAppConfigAt
131
206
  };
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import { saveAppConfig } from "./config.js";
4
5
  import { APP_DEV_ENV_FILE, APP_ENV_FILE, APP_ENV_LOCAL_FILE, readEnvFile } from "./app-env.js";
5
6
  import { CliError } from "./errors.js";
6
7
  async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
@@ -98,15 +99,20 @@ function spawnDevServer(opts) {
98
99
  }
99
100
  async function waitForDevServerReachable(opts) {
100
101
  const fetchImpl = opts.fetchImpl ?? fetch;
102
+ const startedAt = Date.now();
101
103
  const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
102
104
  const requestTimeoutMs = opts.requestTimeoutMs ?? 2500;
103
105
  const mismatchGraceMs = opts.mismatchGraceMs ?? 45e3;
106
+ const cloudflareErrorGraceMs = opts.cloudflareErrorGraceMs ?? 1e4;
107
+ const networkErrorGraceMs = opts.networkErrorGraceMs ?? 1e4;
104
108
  const probe = await waitForLocalProbe(opts.localPort, deadline, requestTimeoutMs, fetchImpl);
105
109
  const tunnelUrl = new URL(opts.tunnelUrl);
106
110
  tunnelUrl.pathname = probe.path;
107
111
  tunnelUrl.search = "";
108
112
  let lastError = "not ready";
109
113
  let mismatchSince = null;
114
+ let cloudflareErrorSince = null;
115
+ let networkErrorSince = null;
110
116
  while (Date.now() < deadline) {
111
117
  try {
112
118
  const response = await fetchWithTimeout(fetchImpl, tunnelUrl, requestTimeoutMs);
@@ -118,18 +124,40 @@ async function waitForDevServerReachable(opts) {
118
124
  };
119
125
  }
120
126
  lastError = `HTTP ${response.status}`;
121
- mismatchSince ??= Date.now();
122
- if (Date.now() - mismatchSince >= mismatchGraceMs) break;
127
+ if (isCloudflareTunnelErrorStatus(response.status)) {
128
+ cloudflareErrorSince ??= Date.now();
129
+ mismatchSince = null;
130
+ networkErrorSince = null;
131
+ if (Date.now() - cloudflareErrorSince >= cloudflareErrorGraceMs) break;
132
+ } else {
133
+ mismatchSince ??= Date.now();
134
+ cloudflareErrorSince = null;
135
+ networkErrorSince = null;
136
+ if (Date.now() - mismatchSince >= mismatchGraceMs) break;
137
+ }
123
138
  } catch (err) {
124
139
  lastError = err instanceof Error ? err.message : String(err);
125
140
  mismatchSince = null;
141
+ cloudflareErrorSince = null;
142
+ networkErrorSince ??= Date.now();
143
+ if (Date.now() - networkErrorSince >= networkErrorGraceMs) break;
126
144
  }
145
+ opts.onProbeFailure?.({
146
+ elapsedMs: Date.now() - startedAt,
147
+ path: probe.path,
148
+ localStatus: probe.status,
149
+ tunnelUrl: tunnelUrl.toString(),
150
+ lastError
151
+ });
127
152
  await delay(500);
128
153
  }
129
154
  throw new CliError(`Cloudflare tunnel did not reach the local dev server at ${tunnelUrl.toString()}.`, {
130
155
  hint: `Local ${probe.path} returned HTTP ${probe.status}, but the tunnel returned ${lastError}. Restart \`odeva app dev\` if the quick tunnel URL expired.`
131
156
  });
132
157
  }
158
+ function isCloudflareTunnelErrorStatus(status) {
159
+ return status === 530 || status === 502 || status === 503 || status === 504;
160
+ }
133
161
  async function waitForLocalDevServer(opts) {
134
162
  const fetchImpl = opts.fetchImpl ?? fetch;
135
163
  const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
@@ -148,13 +176,19 @@ function devInstallCallbackUrl(opts) {
148
176
  }
149
177
  function devAppTunnelUrl(opts) {
150
178
  const url = new URL(opts.tunnelUrl);
151
- if (opts.configuredUrl) {
179
+ const configuredUrl = opts.configuredUrl?.trim();
180
+ if (configuredUrl) {
181
+ if (configuredUrl.startsWith("/")) {
182
+ url.pathname = configuredUrl;
183
+ url.search = "";
184
+ return url.toString();
185
+ }
152
186
  let configured;
153
187
  try {
154
- configured = new URL(opts.configuredUrl);
188
+ configured = new URL(configuredUrl);
155
189
  } catch {
156
- throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${opts.configuredUrl}`, {
157
- hint: "Fix odeva.app.toml or run `odeva app config link` with a valid URL."
190
+ throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${configuredUrl}`, {
191
+ hint: "Use an absolute URL, a path starting with '/', or omit it so `tunnel_url` can infer it."
158
192
  });
159
193
  }
160
194
  url.pathname = configured.pathname;
@@ -165,6 +199,12 @@ function devAppTunnelUrl(opts) {
165
199
  }
166
200
  return url.toString();
167
201
  }
202
+ function saveDevTunnelUrl(loaded, tunnelUrl) {
203
+ if (loaded.config.tunnel_url === tunnelUrl) return false;
204
+ loaded.config.tunnel_url = tunnelUrl;
205
+ saveAppConfig(loaded);
206
+ return true;
207
+ }
168
208
  function writeDevEnvFile(cwd, registered) {
169
209
  if (registered.length === 0) return null;
170
210
  const path = join(cwd, APP_DEV_ENV_FILE);
@@ -361,6 +401,7 @@ export {
361
401
  preflightChecks,
362
402
  registerWebhookSubscriptions,
363
403
  registeredWebhookEnv,
404
+ saveDevTunnelUrl,
364
405
  spawnDevServer,
365
406
  syncDevAppUrls,
366
407
  waitForDevServerReachable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,10 +30,13 @@ This template ships a `/admin` route that verifies Odeva session tokens against
30
30
  the platform's JWKS. To surface your app inside the merchant admin sidebar:
31
31
 
32
32
  1. Uncomment the `[admin]` block at the bottom of `odeva.app.toml` and set
33
- `entry_url` to a publicly reachable URL (your `odeva app dev` tunnel works).
33
+ `entry_url = "/admin"` or omit it to use the default `/admin` path.
34
34
  2. Run `odeva app config link` to push the embed config to Odeva.
35
35
  3. Reinstall the app on a test org; the sidebar entry appears.
36
36
 
37
+ `odeva app dev` writes the active public tunnel to `tunnel_url`; the CLI
38
+ resolves relative app URLs such as `/install` and `/admin` against it.
39
+
37
40
  `/admin` reads `?session_token=<jwt>` from the iframe URL, verifies it via
38
41
  `jose` + the JWKS at `${ODEVA_API_URL}/.well-known/odeva/apps/jwks.json`,
39
42
  and renders `Hello, org <org_id>`. See `src/admin.ts` for the verification
@@ -2,10 +2,11 @@ name = "{{name}}"
2
2
  slug = "{{slug}}"
3
3
  description = "An Odeva app."
4
4
 
5
- # Where Odeva redirects users when they install this app on their org.
6
- # Must be a publicly reachable URL once you submit for marketplace review.
7
- # During development you can leave it blank or point it at your `odeva app dev` tunnel.
8
- install_url = ""
5
+ # `odeva app dev` keeps this set to the active public tunnel.
6
+ # `install_url` and `[admin].entry_url` can be omitted or written as paths;
7
+ # the CLI resolves them against tunnel_url when syncing the app record.
8
+ tunnel_url = ""
9
+ install_url = "/install"
9
10
 
10
11
  [build]
11
12
  dev = "bun run dev"
@@ -35,7 +36,7 @@ api_version = "2026-01"
35
36
  # Icons: puzzle, box, plug, bot, sparkles, wrench, database, bar-chart, wallet.
36
37
  #
37
38
  # [admin]
38
- # entry_url = "https://<your-app-dev-url>/admin"
39
+ # entry_url = "/admin"
39
40
  #
40
41
  # [admin.sidebar]
41
42
  # label = "{{name}}"