@odeva/cli 0.0.10 → 0.0.11

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,
@@ -68,6 +69,9 @@ class AppDev extends Command {
68
69
  });
69
70
  await this.verifyLocalDevServer(port, server, api, registered);
70
71
  const tunnel = await this.startReachableTunnel(port, server);
72
+ if (saveDevTunnelUrl(loaded, tunnel.url)) {
73
+ this.log(ui.ok(`Wrote tunnel_url to ${ui.code(loaded.path)}`));
74
+ }
71
75
  registered = await this.registerDevWebhooks(api, loaded, tunnel.url);
72
76
  this.log("");
73
77
  this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
@@ -103,6 +107,9 @@ class AppDev extends Command {
103
107
  if (nextPort !== port) {
104
108
  this.log(ui.warn(`Port changed to ${nextPort}; restart \`odeva app dev\` to recreate the tunnel.`));
105
109
  }
110
+ if (saveDevTunnelUrl(nextLoaded, tunnel.url)) {
111
+ this.log(ui.ok(`Wrote tunnel_url to ${ui.code(nextLoaded.path)}`));
112
+ }
106
113
  const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, tunnel.url);
107
114
  await cleanupSubscriptions(api, registered);
108
115
  registered = nextRegistered;
@@ -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) {
@@ -148,13 +149,19 @@ function devInstallCallbackUrl(opts) {
148
149
  }
149
150
  function devAppTunnelUrl(opts) {
150
151
  const url = new URL(opts.tunnelUrl);
151
- if (opts.configuredUrl) {
152
+ const configuredUrl = opts.configuredUrl?.trim();
153
+ if (configuredUrl) {
154
+ if (configuredUrl.startsWith("/")) {
155
+ url.pathname = configuredUrl;
156
+ url.search = "";
157
+ return url.toString();
158
+ }
152
159
  let configured;
153
160
  try {
154
- configured = new URL(opts.configuredUrl);
161
+ configured = new URL(configuredUrl);
155
162
  } 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."
163
+ throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${configuredUrl}`, {
164
+ hint: "Use an absolute URL, a path starting with '/', or omit it so `tunnel_url` can infer it."
158
165
  });
159
166
  }
160
167
  url.pathname = configured.pathname;
@@ -165,6 +172,12 @@ function devAppTunnelUrl(opts) {
165
172
  }
166
173
  return url.toString();
167
174
  }
175
+ function saveDevTunnelUrl(loaded, tunnelUrl) {
176
+ if (loaded.config.tunnel_url === tunnelUrl) return false;
177
+ loaded.config.tunnel_url = tunnelUrl;
178
+ saveAppConfig(loaded);
179
+ return true;
180
+ }
168
181
  function writeDevEnvFile(cwd, registered) {
169
182
  if (registered.length === 0) return null;
170
183
  const path = join(cwd, APP_DEV_ENV_FILE);
@@ -361,6 +374,7 @@ export {
361
374
  preflightChecks,
362
375
  registerWebhookSubscriptions,
363
376
  registeredWebhookEnv,
377
+ saveDevTunnelUrl,
364
378
  spawnDevServer,
365
379
  syncDevAppUrls,
366
380
  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.11",
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}}"