@odeva/cli 0.0.6 → 0.0.8

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.
@@ -9,11 +9,15 @@ import { loadCredentials } from "../../lib/credentials.js";
9
9
  import { adminUrl } from "../../lib/paths.js";
10
10
  import {
11
11
  cleanupSubscriptions,
12
+ ensureDevAppInstalled,
12
13
  preflightChecks,
13
14
  registeredWebhookEnv,
14
15
  registerWebhookSubscriptions,
15
16
  spawnDevServer,
17
+ syncDevAppUrls,
16
18
  watchedDevInputPaths,
19
+ waitForLocalDevServer,
20
+ waitForDevServerReachable,
17
21
  writeDevEnvFile
18
22
  } from "../../lib/dev-runner.js";
19
23
  import { CliError } from "../../lib/errors.js";
@@ -53,11 +57,19 @@ class AppDev extends Command {
53
57
  return;
54
58
  }
55
59
  const api = new OdevaApi();
56
- const tunnelSpinner = p.spinner();
57
- tunnelSpinner.start("Starting Cloudflare tunnel");
58
- const tunnel = await startQuickTunnel(port);
59
- tunnelSpinner.stop(`Tunnel ready at ${ui.code(tunnel.url)}`);
60
60
  let registered = [];
61
+ let server = spawnDevServer({
62
+ cwd: loaded.root,
63
+ config: loaded.config,
64
+ env: {
65
+ ...appEnv,
66
+ PORT: String(port)
67
+ }
68
+ });
69
+ await this.verifyLocalDevServer(port, server, api, registered);
70
+ this.log(ui.dim("Starting Cloudflare tunnel..."));
71
+ const tunnel = await startQuickTunnel(port);
72
+ this.log(ui.ok(`Tunnel ready at ${ui.code(tunnel.url)}`));
61
73
  registered = await this.registerDevWebhooks(api, loaded, tunnel.url);
62
74
  this.log("");
63
75
  this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
@@ -66,19 +78,12 @@ class AppDev extends Command {
66
78
  this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
67
79
  }
68
80
  this.log("");
69
- this.printInstallHint(loaded, tunnel.url);
81
+ await this.verifyDevServerReachable(tunnel, port, server, api, registered);
82
+ const syncedUrls = await this.syncDevAppUrls(api, loaded, tunnel.url);
83
+ this.printDevAppInfo(syncedUrls);
84
+ await this.ensureDevAppInstalled(api, loaded, tunnel, port, server, registered);
70
85
  this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
71
86
  this.log("");
72
- let server = spawnDevServer({
73
- cwd: loaded.root,
74
- config: loaded.config,
75
- env: {
76
- ...appEnv,
77
- PORT: String(port),
78
- ODEVA_TUNNEL_URL: tunnel.url,
79
- ...registeredWebhookEnv(registered)
80
- }
81
- });
82
87
  let shuttingDown = false;
83
88
  let restartingServer = false;
84
89
  let serverExited = () => {
@@ -120,6 +125,8 @@ class AppDev extends Command {
120
125
  });
121
126
  server = nextServer;
122
127
  watchServerExit(server);
128
+ await this.verifyDevServerReachable(tunnel, port, server, api, registered);
129
+ await this.syncDevAppUrls(api, nextLoaded, tunnel.url);
123
130
  this.log(ui.ok("Reloaded app dev config."));
124
131
  }).catch((err) => {
125
132
  restartingServer = false;
@@ -145,26 +152,36 @@ class AppDev extends Command {
145
152
  await Promise.allSettled([tunnel.stop(), cleanupSubscriptions(api, registered)]);
146
153
  });
147
154
  }
148
- printInstallHint(loaded, tunnelUrl) {
155
+ printDevAppInfo(syncedUrls) {
149
156
  const creds = loadCredentials();
150
157
  const apps = adminUrl(creds?.apiUrl);
151
158
  this.log(`${ui.bold("Install:")} ${apps}/settings/apps ${ui.dim('(find your draft app under "Your apps")')}`);
152
159
  if (creds?.organizationSlug) {
153
160
  this.log(` ${ui.dim("org:")} ${creds.organizationSlug}`);
154
161
  }
155
- const installUrl = loaded.config.install_url?.trim();
156
- const expected = `${tunnelUrl}/install`;
157
- if (!installUrl) {
158
- this.log(ui.warn(
159
- `install_url in odeva.app.toml is empty \u2014 installs won't redirect back. Set it to ${expected} and re-run \`odeva app config link\` (or use a stable URL once you deploy).`
160
- ));
161
- } else if (!installUrl.startsWith(tunnelUrl) && !/localhost|127\.0\.0\.1/.test(installUrl)) {
162
- this.log(ui.warn(
163
- `install_url is ${installUrl} \u2014 installs from your org will redirect there, not to this tunnel. Update odeva.app.toml + \`odeva app config link\` if you want installs to hit the dev tunnel.`
164
- ));
162
+ this.log(` ${ui.dim("install:")} ${syncedUrls.installUrl}`);
163
+ if (syncedUrls.adminEntryUrl) {
164
+ this.log(` ${ui.dim("admin:")} ${syncedUrls.adminEntryUrl}`);
165
165
  }
166
+ this.log(ui.dim(" dev URLs are synced to this tunnel for the current app record."));
166
167
  this.log("");
167
168
  }
169
+ async syncDevAppUrls(api, loaded, tunnelUrl) {
170
+ const spinner = p.spinner();
171
+ spinner.start("Syncing dev app URLs");
172
+ try {
173
+ const synced = await syncDevAppUrls({
174
+ api,
175
+ config: loaded.config,
176
+ tunnelUrl
177
+ });
178
+ spinner.stop(`Synced app URLs for ${ui.code(synced.app.slug)}`);
179
+ return synced;
180
+ } catch (err) {
181
+ spinner.stop(ui.err("Dev app URL sync failed"));
182
+ throw err instanceof CliError ? err : new CliError(`Dev app URL sync failed: ${err.message}`);
183
+ }
184
+ }
168
185
  async registerDevWebhooks(api, loaded, tunnelUrl) {
169
186
  const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
170
187
  if (subscriptions.length === 0) {
@@ -186,6 +203,65 @@ class AppDev extends Command {
186
203
  throw err instanceof CliError ? err : new CliError(`Webhook registration failed: ${err.message}`);
187
204
  }
188
205
  }
206
+ async ensureDevAppInstalled(api, loaded, tunnel, port, server, registered) {
207
+ const spinner = p.spinner();
208
+ spinner.start("Ensuring app is installed in this organization");
209
+ try {
210
+ const installed = await ensureDevAppInstalled({
211
+ api,
212
+ config: loaded.config,
213
+ tunnelUrl: tunnel.url,
214
+ localPort: port
215
+ });
216
+ if (installed.alreadyInstalled) {
217
+ spinner.stop(`App ${ui.code(installed.app.slug)} is already installed`);
218
+ } else if (installed.callbackUrl) {
219
+ spinner.stop(`Installed app ${ui.code(installed.app.slug)} via ${ui.code(installed.callbackUrl)}`);
220
+ } else {
221
+ spinner.stop(`Installed app ${ui.code(installed.app.slug)}`);
222
+ }
223
+ } catch (err) {
224
+ spinner.stop(ui.err("App installation failed"));
225
+ await Promise.allSettled([
226
+ server.stop(),
227
+ tunnel.stop(),
228
+ cleanupSubscriptions(api, registered)
229
+ ]);
230
+ throw err;
231
+ }
232
+ }
233
+ async verifyLocalDevServer(port, server, api, registered) {
234
+ this.log(ui.dim("Waiting for local dev server..."));
235
+ try {
236
+ const result = await waitForLocalDevServer({ localPort: port });
237
+ this.log(ui.ok(`Local dev server ready via ${ui.code(result.path)} (HTTP ${result.status})`));
238
+ } catch (err) {
239
+ this.log(ui.err("Local dev server failed to start"));
240
+ await Promise.allSettled([
241
+ server.stop(),
242
+ cleanupSubscriptions(api, registered)
243
+ ]);
244
+ throw err;
245
+ }
246
+ }
247
+ async verifyDevServerReachable(tunnel, port, server, api, registered) {
248
+ this.log(ui.dim("Verifying dev server and tunnel... (trycloudflare can take up to 60s to propagate)"));
249
+ try {
250
+ const result = await waitForDevServerReachable({
251
+ localPort: port,
252
+ tunnelUrl: tunnel.url
253
+ });
254
+ this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
255
+ } catch (err) {
256
+ this.log(ui.err("Dev server tunnel check failed"));
257
+ await Promise.allSettled([
258
+ server.stop(),
259
+ tunnel.stop(),
260
+ cleanupSubscriptions(api, registered)
261
+ ]);
262
+ throw err;
263
+ }
264
+ }
189
265
  watchDevInputs(appConfigPath, onChange) {
190
266
  const paths = watchedDevInputPaths(appConfigPath);
191
267
  let timer;
@@ -9,7 +9,6 @@ import { withErrorHandling } from "../../lib/run.js";
9
9
  import { isValidSlug, slugify } from "../../lib/slug.js";
10
10
  import { listTemplates, renderTemplate } from "../../lib/templates.js";
11
11
  import { ui } from "../../lib/ui.js";
12
- import { detectShell, isCompletionInstalled } from "../autocomplete.js";
13
12
  const DEFAULT_TEMPLATE = "hono-bun";
14
13
  class AppInit extends Command {
15
14
  static description = "Scaffold a new Odeva app";
@@ -69,23 +68,17 @@ class AppInit extends Command {
69
68
  }
70
69
  });
71
70
  spinner.stop(`Wrote ${filesWritten} file${filesWritten === 1 ? "" : "s"} from template '${template}'.`);
72
- const outroLines = [
73
- ui.ok(`Created ${ui.bold(displayName)}`),
74
- "",
75
- " Next steps:",
76
- ` ${ui.code(`cd ${basename(directory)}`)}`,
77
- ` ${ui.code("bun install")}`,
78
- ` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
79
- ` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
80
- ];
81
- const shell = detectShell();
82
- if (shell && !isCompletionInstalled(shell, "odeva")) {
83
- outroLines.push(
71
+ p.outro(
72
+ [
73
+ ui.ok(`Created ${ui.bold(displayName)}`),
84
74
  "",
85
- ` ${ui.dim("Tip:")} ${ui.code("odeva autocomplete --install")} ${ui.dim("to enable tab-completion.")}`
86
- );
87
- }
88
- p.outro(outroLines.join("\n"));
75
+ " Next steps:",
76
+ ` ${ui.code(`cd ${basename(directory)}`)}`,
77
+ ` ${ui.code("bun install")}`,
78
+ ` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
79
+ ` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
80
+ ].join("\n")
81
+ );
89
82
  });
90
83
  }
91
84
  async resolveDirectory(nameArg, skipPrompts) {
@@ -1,90 +1,36 @@
1
- import { Args, Command, Flags } from "@oclif/core";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { basename, dirname, join } from "node:path";
1
+ import { Args, Command } from "@oclif/core";
5
2
  import { CliError } from "../lib/errors.js";
6
3
  import { withErrorHandling } from "../lib/run.js";
7
- import { ui } from "../lib/ui.js";
8
4
  const SUPPORTED_SHELLS = ["fish", "zsh"];
9
5
  class Autocomplete extends Command {
10
- static description = "Print or install a shell completion script for the odeva CLI";
6
+ static description = "Print a shell completion script for the odeva CLI";
11
7
  static examples = [
12
- "$ odeva autocomplete --install # detect shell and install",
13
- "$ odeva autocomplete fish --install # explicit shell",
14
- "$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish"
8
+ "$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish",
9
+ "$ odeva autocomplete zsh > ~/.odeva/_odeva # then source it from .zshrc"
15
10
  ];
16
11
  static args = {
17
12
  shell: Args.string({
18
- description: "Target shell (auto-detected from $SHELL when omitted with --install)",
13
+ description: "Target shell",
19
14
  options: [...SUPPORTED_SHELLS],
20
- required: false
21
- })
22
- };
23
- static flags = {
24
- install: Flags.boolean({
25
- description: "Write the completion script to the conventional path for the shell",
26
- default: false
15
+ required: true
27
16
  })
28
17
  };
29
18
  async run() {
30
- const { args, flags } = await this.parse(Autocomplete);
19
+ const { args } = await this.parse(Autocomplete);
31
20
  await withErrorHandling(this, async () => {
32
- const bin = this.config.bin;
33
- const shell = args.shell ?? (flags.install ? detectShell() : void 0);
34
- if (!shell) {
35
- if (flags.install) {
36
- throw new CliError("Couldn't detect shell from $SHELL.", {
37
- hint: `Pass it explicitly: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}> --install\`.`
38
- });
39
- }
40
- throw new CliError("A shell is required.", {
41
- hint: `Usage: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}>\`. Add --install to write the script to the conventional path.`
21
+ const shell = args.shell;
22
+ if (!SUPPORTED_SHELLS.includes(shell)) {
23
+ throw new CliError(`Unsupported shell '${shell}'.`, {
24
+ hint: `Supported: ${SUPPORTED_SHELLS.join(", ")}.`
42
25
  });
43
26
  }
27
+ const bin = this.config.bin;
44
28
  const tree = buildCommandTree(this.config.commands, bin);
45
29
  const script = shell === "fish" ? renderFish(bin, tree) : renderZsh(bin, tree);
46
- if (!flags.install) {
47
- process.stdout.write(script);
48
- return;
49
- }
50
- const target = completionInstallPath(shell, bin);
51
- mkdirSync(dirname(target), { recursive: true });
52
- writeFileSync(target, script, { mode: 420 });
53
- this.log(ui.ok(`Wrote ${shell} completion to ${ui.code(target)}`));
54
- if (shell === "fish") {
55
- this.log(ui.dim(" Open a new fish shell \u2014 completions auto-load from this path."));
56
- } else {
57
- const fpathDir = dirname(target);
58
- this.log("");
59
- this.log(" Add this to your ~/.zshrc (if you haven't already):");
60
- this.log("");
61
- this.log(ui.code(` fpath=(${fpathDir} $fpath)`));
62
- this.log(ui.code(` autoload -U compinit && compinit`));
63
- this.log("");
64
- this.log(ui.dim(" Then open a new zsh shell."));
65
- }
30
+ process.stdout.write(script);
66
31
  });
67
32
  }
68
33
  }
69
- function detectShell(env = process.env) {
70
- const shell = env["SHELL"];
71
- if (!shell) return null;
72
- const name = basename(shell);
73
- if (name === "fish" || name === "zsh") return name;
74
- return null;
75
- }
76
- function completionInstallPath(shell, bin, env = process.env) {
77
- const home = env["HOME"] || homedir();
78
- if (shell === "fish") {
79
- const config = env["XDG_CONFIG_HOME"] || join(home, ".config");
80
- return join(config, "fish", "completions", `${bin}.fish`);
81
- }
82
- const data = env["XDG_DATA_HOME"] || join(home, ".local", "share");
83
- return join(data, bin, "completions", `_${bin}`);
84
- }
85
- function isCompletionInstalled(shell, bin, env = process.env) {
86
- return existsSync(completionInstallPath(shell, bin, env));
87
- }
88
34
  function buildCommandTree(commands, binName) {
89
35
  const root = { name: "", description: "", children: /* @__PURE__ */ new Map() };
90
36
  for (const cmd of commands) {
@@ -206,10 +152,7 @@ _${bin} "$@"
206
152
  }
207
153
  export {
208
154
  buildCommandTree,
209
- completionInstallPath,
210
155
  Autocomplete as default,
211
- detectShell,
212
- isCompletionInstalled,
213
156
  renderFish,
214
157
  renderZsh
215
158
  };
@@ -61,7 +61,9 @@ class WebhookTrigger extends Command {
61
61
  hint: flags.url || configSubscription ? "Run `odeva app dev` to generate one, or pass --secret <value>, or use --no-sign for unsigned testing." : `Add a '${event}' subscription to odeva.app.toml, let \`odeva app dev\` reload, then try again.`
62
62
  });
63
63
  }
64
- headers["x-odeva-signature"] = signPayload(body, secret);
64
+ const timestamp = Math.floor(Date.now() / 1e3).toString();
65
+ headers["x-odeva-timestamp"] = timestamp;
66
+ headers["x-odeva-signature"] = signPayload(body, secret, timestamp);
65
67
  }
66
68
  this.log(`${ui.bold("POST")} ${url}`);
67
69
  this.log(` ${ui.dim("event:")} ${event}`);
package/dist/lib/api.js CHANGED
@@ -139,6 +139,20 @@ class OdevaApi {
139
139
  `);
140
140
  return data.developerApps.find((a) => a.clientId === clientId) ?? null;
141
141
  }
142
+ async installApp(appId, grantedScopes) {
143
+ const data = await this.request(
144
+ `mutation OdevaCliInstallApp($appId: ID!, $grantedScopes: [String!]) {
145
+ installApp(appId: $appId, grantedScopes: $grantedScopes) {
146
+ appInstallation { id status grantedScopes }
147
+ installCode
148
+ installUrl
149
+ errors
150
+ }
151
+ }`,
152
+ { appId, grantedScopes }
153
+ );
154
+ return data.installApp;
155
+ }
142
156
  async submitDeveloperApp(id) {
143
157
  const data = await this.request(
144
158
  `mutation OdevaCliSubmitDeveloperApp($id: ID!) {
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { CliError } from "./errors.js";
4
4
  const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
5
+ const REGISTERED_CONNECTION_RE = /Registered tunnel connection/i;
5
6
  function isCloudflaredInstalled() {
6
7
  const result = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
7
8
  return result.status === 0;
@@ -15,32 +16,28 @@ function startQuickTunnel(localPort, options = {}) {
15
16
  return new Promise((resolve, reject) => {
16
17
  const proc = spawn(
17
18
  "cloudflared",
18
- ["tunnel", "--no-autoupdate", "--url", `http://localhost:${localPort}`],
19
+ ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${localPort}`],
19
20
  { stdio: ["ignore", "pipe", "pipe"] }
20
21
  );
21
22
  let resolved = false;
23
+ let url = null;
24
+ let registered = false;
22
25
  const timeoutMs = options.timeoutMs ?? 3e4;
23
26
  const timer = setTimeout(() => {
24
27
  if (resolved) return;
25
28
  cleanup();
26
29
  reject(
27
30
  new CliError(`cloudflared did not produce a tunnel URL within ${timeoutMs / 1e3}s.`, {
28
- hint: "Check your network connection or run `cloudflared tunnel --url http://localhost:" + localPort + "` manually."
31
+ hint: "Check your network connection or run `cloudflared tunnel --url http://127.0.0.1:" + localPort + "` manually."
29
32
  })
30
33
  );
31
34
  }, timeoutMs);
32
35
  const onData = (chunk) => {
33
36
  const text = chunk.toString("utf8");
34
37
  const match = TRYCLOUDFLARE_RE.exec(text);
35
- if (match && !resolved) {
36
- resolved = true;
37
- clearTimeout(timer);
38
- resolve({
39
- url: match[0],
40
- process: proc,
41
- stop: () => stopProcess(proc)
42
- });
43
- }
38
+ if (match) url = match[0];
39
+ if (REGISTERED_CONNECTION_RE.test(text)) registered = true;
40
+ maybeResolve();
44
41
  };
45
42
  proc.stdout?.on("data", onData);
46
43
  proc.stderr?.on("data", onData);
@@ -60,16 +57,32 @@ function startQuickTunnel(localPort, options = {}) {
60
57
  } catch {
61
58
  }
62
59
  }
60
+ function maybeResolve() {
61
+ if (!url || !registered || resolved) return;
62
+ resolved = true;
63
+ clearTimeout(timer);
64
+ resolve({
65
+ url,
66
+ process: proc,
67
+ stop: () => stopProcess(proc)
68
+ });
69
+ }
63
70
  });
64
71
  }
65
72
  function stopProcess(proc) {
66
73
  return new Promise((resolve) => {
67
74
  if (proc.exitCode !== null) return resolve();
68
- proc.once("exit", () => resolve());
75
+ let resolved = false;
76
+ const finish = () => {
77
+ if (resolved) return;
78
+ resolved = true;
79
+ resolve();
80
+ };
81
+ proc.once("exit", finish);
69
82
  try {
70
83
  proc.kill("SIGTERM");
71
84
  } catch {
72
- resolve();
85
+ finish();
73
86
  return;
74
87
  }
75
88
  setTimeout(() => {
@@ -80,14 +93,19 @@ function stopProcess(proc) {
80
93
  }
81
94
  }
82
95
  }, 2e3);
96
+ setTimeout(finish, 5e3);
83
97
  });
84
98
  }
85
99
  function parseTunnelUrl(text) {
86
100
  const match = TRYCLOUDFLARE_RE.exec(text);
87
101
  return match ? match[0] : null;
88
102
  }
103
+ function tunnelConnectionRegistered(text) {
104
+ return REGISTERED_CONNECTION_RE.test(text);
105
+ }
89
106
  export {
90
107
  isCloudflaredInstalled,
91
108
  parseTunnelUrl,
92
- startQuickTunnel
109
+ startQuickTunnel,
110
+ tunnelConnectionRegistered
93
111
  };
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { APP_ENV_FILE } from "./app-env.js";
5
+ import { CliError } from "./errors.js";
5
6
  async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
6
7
  const created = [];
7
8
  for (const sub of subscriptions) {
@@ -23,6 +24,57 @@ async function cleanupSubscriptions(api, registered) {
23
24
  }
24
25
  }
25
26
  }
27
+ async function syncDevAppUrls(opts) {
28
+ const app = await findLinkedApp(opts.api, opts.config.client_id);
29
+ const installUrl = devAppTunnelUrl({
30
+ tunnelUrl: opts.tunnelUrl,
31
+ configuredUrl: opts.config.install_url,
32
+ defaultPath: "/install"
33
+ });
34
+ const adminEntryUrl = opts.config.admin ? devAppTunnelUrl({
35
+ tunnelUrl: opts.tunnelUrl,
36
+ configuredUrl: opts.config.admin.entry_url,
37
+ defaultPath: "/admin"
38
+ }) : null;
39
+ const { app: updated } = await opts.api.upsertDeveloperApp({
40
+ id: app.id,
41
+ name: opts.config.name,
42
+ slug: opts.config.slug,
43
+ description: opts.config.description,
44
+ homepageUrl: opts.config.homepage_url,
45
+ installUrl,
46
+ privacyUrl: opts.config.privacy_url,
47
+ requestedScopes: opts.config.access_scopes?.scopes,
48
+ ...opts.config.admin && adminEntryUrl ? {
49
+ adminEmbed: {
50
+ entryUrl: adminEntryUrl,
51
+ sidebar: opts.config.admin.sidebar
52
+ }
53
+ } : {}
54
+ });
55
+ return { app: updated, installUrl, adminEntryUrl };
56
+ }
57
+ async function ensureDevAppInstalled(opts) {
58
+ const app = await findLinkedApp(opts.api, opts.config.client_id);
59
+ const result = await opts.api.installApp(app.id, opts.config.access_scopes?.scopes ?? app.requestedScopes);
60
+ if (isAlreadyInstalled(result)) {
61
+ return { app, result, callbackUrl: null, alreadyInstalled: true };
62
+ }
63
+ if (result.errors.length > 0) {
64
+ throw new CliError(`Could not install app: ${result.errors.join(", ")}`);
65
+ }
66
+ if (!result.installCode) {
67
+ return { app, result, callbackUrl: null, alreadyInstalled: false };
68
+ }
69
+ const callbackUrl = devInstallCallbackUrl({
70
+ tunnelUrl: opts.tunnelUrl,
71
+ installUrl: result.installUrl ?? opts.config.install_url,
72
+ installCode: result.installCode
73
+ });
74
+ await waitForDevInstallRoute({ localPort: opts.localPort, callbackUrl });
75
+ await completeInstallCallback(callbackUrl);
76
+ return { app, result, callbackUrl, alreadyInstalled: false };
77
+ }
26
78
  function spawnDevServer(opts) {
27
79
  const command = opts.config.build?.dev ?? "bun run dev";
28
80
  const proc = spawn(command, {
@@ -44,6 +96,75 @@ function spawnDevServer(opts) {
44
96
  })
45
97
  };
46
98
  }
99
+ async function waitForDevServerReachable(opts) {
100
+ const fetchImpl = opts.fetchImpl ?? fetch;
101
+ const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
102
+ const requestTimeoutMs = opts.requestTimeoutMs ?? 2500;
103
+ const mismatchGraceMs = opts.mismatchGraceMs ?? 45e3;
104
+ const probe = await waitForLocalProbe(opts.localPort, deadline, requestTimeoutMs, fetchImpl);
105
+ const tunnelUrl = new URL(opts.tunnelUrl);
106
+ tunnelUrl.pathname = probe.path;
107
+ tunnelUrl.search = "";
108
+ let lastError = "not ready";
109
+ let mismatchSince = null;
110
+ while (Date.now() < deadline) {
111
+ try {
112
+ const response = await fetchWithTimeout(fetchImpl, tunnelUrl, requestTimeoutMs);
113
+ if (response.status === probe.status) {
114
+ return {
115
+ path: probe.path,
116
+ localStatus: probe.status,
117
+ tunnelStatus: response.status
118
+ };
119
+ }
120
+ lastError = `HTTP ${response.status}`;
121
+ mismatchSince ??= Date.now();
122
+ if (Date.now() - mismatchSince >= mismatchGraceMs) break;
123
+ } catch (err) {
124
+ lastError = err instanceof Error ? err.message : String(err);
125
+ mismatchSince = null;
126
+ }
127
+ await delay(500);
128
+ }
129
+ throw new CliError(`Cloudflare tunnel did not reach the local dev server at ${tunnelUrl.toString()}.`, {
130
+ hint: `Local ${probe.path} returned HTTP ${probe.status}, but the tunnel returned ${lastError}. Restart \`odeva app dev\` if the quick tunnel URL expired.`
131
+ });
132
+ }
133
+ async function waitForLocalDevServer(opts) {
134
+ const fetchImpl = opts.fetchImpl ?? fetch;
135
+ const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
136
+ const requestTimeoutMs = opts.requestTimeoutMs ?? 1500;
137
+ return waitForLocalProbe(opts.localPort, deadline, requestTimeoutMs, fetchImpl);
138
+ }
139
+ function devInstallCallbackUrl(opts) {
140
+ const url = new URL(devAppTunnelUrl({
141
+ tunnelUrl: opts.tunnelUrl,
142
+ configuredUrl: opts.installUrl,
143
+ defaultPath: "/install",
144
+ configField: "install_url"
145
+ }));
146
+ url.searchParams.set("install_code", opts.installCode);
147
+ return url.toString();
148
+ }
149
+ function devAppTunnelUrl(opts) {
150
+ const url = new URL(opts.tunnelUrl);
151
+ if (opts.configuredUrl) {
152
+ let configured;
153
+ try {
154
+ configured = new URL(opts.configuredUrl);
155
+ } 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."
158
+ });
159
+ }
160
+ url.pathname = configured.pathname;
161
+ url.search = configured.search;
162
+ } else {
163
+ url.pathname = opts.defaultPath;
164
+ url.search = "";
165
+ }
166
+ return url.toString();
167
+ }
47
168
  function writeDevEnvFile(cwd, registered) {
48
169
  if (registered.length === 0) return null;
49
170
  const path = join(cwd, ".env.odeva.local");
@@ -82,14 +203,113 @@ function joinUrl(base, path) {
82
203
  const trimmedPath = path.startsWith("/") ? path : `/${path}`;
83
204
  return `${trimmedBase}${trimmedPath}`;
84
205
  }
206
+ function isAlreadyInstalled(result) {
207
+ return result.errors.some((error) => /already installed/i.test(error));
208
+ }
209
+ async function findLinkedApp(api, clientId) {
210
+ if (!clientId) {
211
+ throw new CliError("App has not been registered yet.", {
212
+ hint: "Run `odeva app config link` first."
213
+ });
214
+ }
215
+ const app = await api.findAppByClientId(clientId);
216
+ if (!app) {
217
+ throw new CliError(`No app with client_id ${clientId} is owned by your active organization.`, {
218
+ hint: "If you registered against a different org, run `odeva auth select-org`."
219
+ });
220
+ }
221
+ return app;
222
+ }
223
+ async function waitForDevInstallRoute(opts) {
224
+ const deadline = Date.now() + (opts.timeoutMs ?? 15e3);
225
+ const callback = new URL(opts.callbackUrl);
226
+ const localUrl = new URL(`http://127.0.0.1:${opts.localPort}`);
227
+ localUrl.pathname = callback.pathname;
228
+ let lastError = "not ready";
229
+ while (Date.now() < deadline) {
230
+ try {
231
+ const response = await fetch(localUrl);
232
+ if (response.status < 500) return;
233
+ lastError = `HTTP ${response.status}`;
234
+ } catch (err) {
235
+ lastError = err instanceof Error ? err.message : String(err);
236
+ }
237
+ await delay(250);
238
+ }
239
+ throw new CliError(`Dev server did not become ready at ${localUrl.toString()}.`, {
240
+ hint: `Last probe failed with: ${lastError}`
241
+ });
242
+ }
243
+ async function waitForLocalProbe(localPort, deadline, requestTimeoutMs, fetchImpl) {
244
+ const candidates = [
245
+ { path: "/healthz", accepts: (status) => status < 400 },
246
+ { path: "/", accepts: (status) => status < 500 }
247
+ ];
248
+ let lastError = "not ready";
249
+ while (Date.now() < deadline) {
250
+ for (const candidate of candidates) {
251
+ const url = new URL(`http://127.0.0.1:${localPort}`);
252
+ url.pathname = candidate.path;
253
+ try {
254
+ const response = await fetchWithTimeout(fetchImpl, url, requestTimeoutMs);
255
+ if (candidate.accepts(response.status)) return { path: candidate.path, status: response.status };
256
+ lastError = `${candidate.path} returned HTTP ${response.status}`;
257
+ } catch (err) {
258
+ lastError = err instanceof Error ? err.message : String(err);
259
+ }
260
+ }
261
+ await delay(250);
262
+ }
263
+ throw new CliError(`Dev server did not become reachable at http://127.0.0.1:${localPort}.`, {
264
+ hint: `Last probe failed with: ${lastError}`
265
+ });
266
+ }
267
+ async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
268
+ const controller = new AbortController();
269
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
270
+ try {
271
+ return await fetchImpl(url, { signal: controller.signal });
272
+ } finally {
273
+ clearTimeout(timer);
274
+ }
275
+ }
276
+ async function completeInstallCallback(callbackUrl) {
277
+ let response;
278
+ try {
279
+ response = await fetch(callbackUrl);
280
+ } catch (err) {
281
+ throw new CliError(`Could not reach app install callback at ${callbackUrl}.`, {
282
+ hint: err instanceof Error ? err.message : String(err)
283
+ });
284
+ }
285
+ if (response.ok) return;
286
+ let body = "";
287
+ try {
288
+ body = (await response.text()).trim().slice(0, 300);
289
+ } catch {
290
+ body = "";
291
+ }
292
+ throw new CliError(`App install callback failed with HTTP ${response.status}.`, {
293
+ hint: body || callbackUrl
294
+ });
295
+ }
296
+ function delay(ms) {
297
+ return new Promise((resolve) => setTimeout(resolve, ms));
298
+ }
85
299
  function stopProcess(proc) {
86
300
  return new Promise((resolve) => {
87
301
  if (proc.exitCode !== null) return resolve();
88
- proc.once("exit", () => resolve());
302
+ let resolved = false;
303
+ const finish = () => {
304
+ if (resolved) return;
305
+ resolved = true;
306
+ resolve();
307
+ };
308
+ proc.once("exit", finish);
89
309
  try {
90
310
  killDevProcess(proc, "SIGTERM");
91
311
  } catch {
92
- resolve();
312
+ finish();
93
313
  return;
94
314
  }
95
315
  setTimeout(() => {
@@ -100,6 +320,7 @@ function stopProcess(proc) {
100
320
  }
101
321
  }
102
322
  }, 2e3);
323
+ setTimeout(finish, 5e3);
103
324
  });
104
325
  }
105
326
  function killDevProcess(proc, signal) {
@@ -134,11 +355,17 @@ function readEnvFile(path) {
134
355
  }
135
356
  export {
136
357
  cleanupSubscriptions,
358
+ devAppTunnelUrl,
359
+ devInstallCallbackUrl,
360
+ ensureDevAppInstalled,
137
361
  preflightChecks,
138
362
  readEnvFile,
139
363
  registerWebhookSubscriptions,
140
364
  registeredWebhookEnv,
141
365
  spawnDevServer,
366
+ syncDevAppUrls,
367
+ waitForDevServerReachable,
368
+ waitForLocalDevServer,
142
369
  watchedDevInputPaths,
143
370
  webhookSecretEnvKey,
144
371
  webhookSecretForEvent,
@@ -20,6 +20,16 @@ const FIXTURES = {
20
20
  },
21
21
  changes: ["checkIn", "checkOut"]
22
22
  },
23
+ "reservation.confirmed": {
24
+ reservation: {
25
+ id: "res_test_abc123",
26
+ reservationNumber: "R-2026-0001",
27
+ status: "confirmed",
28
+ checkIn: "2026-07-01",
29
+ checkOut: "2026-07-05",
30
+ total: { amount: "480.00", currency: "EUR" }
31
+ }
32
+ },
23
33
  "reservation.cancelled": {
24
34
  reservation: {
25
35
  id: "res_test_abc123",
@@ -47,8 +57,9 @@ function buildFixture(event, overrides) {
47
57
  data
48
58
  };
49
59
  }
50
- function signPayload(rawBody, secret) {
51
- return createHmac("sha256", secret).update(rawBody).digest("hex");
60
+ function signPayload(rawBody, secret, timestamp) {
61
+ const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
62
+ return `sha256=${digest}`;
52
63
  }
53
64
  function listFixtureEvents() {
54
65
  return Object.keys(FIXTURES);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,14 +13,15 @@ odeva app dev # local server + tunnel + webhook delivery
13
13
  In another terminal:
14
14
 
15
15
  ```sh
16
- odeva webhook trigger reservation.created
16
+ odeva webhook trigger reservation.confirmed
17
+ odeva webhook trigger payment.succeeded
17
18
  ```
18
19
 
19
20
  ## Project layout
20
21
 
21
22
  - `odeva.app.toml` — app config (name, slug, webhooks, scopes)
22
23
  - `src/index.ts` — Hono server with a webhook handler stub
23
- - `src/webhook.ts` — HMAC signature verification
24
+ - `src/webhook.ts` — timestamped `sha256=` HMAC signature verification
24
25
  - `src/admin.ts` — iframe session-token verification
25
26
 
26
27
  ## Embed in the merchant admin
@@ -21,8 +21,12 @@ api_version = "2026-01"
21
21
  # tunnel URL. List supported events with: `odeva webhook list --available`.
22
22
  #
23
23
  # [[webhooks.subscriptions]]
24
- # topic = "reservation.created"
25
- # uri = "/webhooks/reservation.created"
24
+ # topic = "reservation.confirmed"
25
+ # uri = "/webhooks/reservation.confirmed"
26
+ #
27
+ # [[webhooks.subscriptions]]
28
+ # topic = "payment.succeeded"
29
+ # uri = "/webhooks/payment.succeeded"
26
30
 
27
31
  # Uncomment to embed this app inside the merchant admin sidebar.
28
32
  # The merchant admin will iframe `entry_url?session_token=<short-lived JWT>`
@@ -57,19 +57,20 @@ app.post("/webhooks/:topic", async (c) => {
57
57
  const topic = c.req.param("topic");
58
58
  const rawBody = await c.req.text();
59
59
  const signature = c.req.header("x-odeva-signature");
60
+ const timestamp = c.req.header("x-odeva-timestamp");
60
61
  const secret =
61
62
  process.env[`ODEVA_WEBHOOK_SECRET__${topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`] ??
62
63
  process.env.ODEVA_WEBHOOK_SECRET;
63
64
 
64
- if (secret && !verifyWebhook(rawBody, signature, secret)) {
65
+ if (secret && !verifyWebhook(rawBody, signature, secret, timestamp)) {
65
66
  return c.json({ error: "invalid signature" }, 401);
66
67
  }
67
68
 
68
69
  const payload = JSON.parse(rawBody) as Record<string, unknown>;
69
70
  logger.info(`[webhook] ${topic}`, payload);
70
71
 
71
- // TODO: handle the event.
72
- // Tip: run `odeva webhook trigger <topic>` to fire a sample payload at this handler.
72
+ // Route work by topic here. Tip: run `odeva webhook trigger <topic>` to fire
73
+ // a signed sample payload at this handler.
73
74
 
74
75
  return c.json({ received: true });
75
76
  });
@@ -27,7 +27,7 @@ const EXCHANGE_MUTATION = `
27
27
  `;
28
28
 
29
29
  interface ExchangeResponse {
30
- data: {
30
+ data?: {
31
31
  exchangeAppInstallCode: {
32
32
  appInstallation: {
33
33
  id: string;
@@ -37,10 +37,14 @@ interface ExchangeResponse {
37
37
  rawKey: string | null;
38
38
  errors: string[];
39
39
  } | null;
40
- };
40
+ } | null;
41
41
  errors?: Array<{ message: string }>;
42
42
  }
43
43
 
44
+ function isExchangeResponse(value: unknown): value is ExchangeResponse {
45
+ return typeof value === "object" && value !== null;
46
+ }
47
+
44
48
  export function makeInstallHandler(store: InstallationStore) {
45
49
  return async (c: Context): Promise<Response> => {
46
50
  const installCode = c.req.query("install_code");
@@ -68,13 +72,23 @@ export function makeInstallHandler(store: InstallationStore) {
68
72
  }),
69
73
  });
70
74
 
71
- const body = (await response.json()) as ExchangeResponse;
75
+ let body: ExchangeResponse;
76
+ try {
77
+ const parsed: unknown = await response.json();
78
+ if (!isExchangeResponse(parsed)) {
79
+ return c.text(`Install failed: Odeva returned an unexpected response (${response.status}).`, 502);
80
+ }
81
+ body = parsed;
82
+ } catch {
83
+ return c.text(`Install failed: Could not parse Odeva response (${response.status}).`, 502);
84
+ }
85
+
72
86
  const topErrors = body.errors?.map((e) => e.message) ?? [];
73
87
  const payload = body.data?.exchangeAppInstallCode ?? null;
74
88
  const userErrors = payload?.errors ?? [];
75
89
 
76
90
  if (topErrors.length > 0 || userErrors.length > 0 || !payload?.rawKey || !payload.appInstallation) {
77
- const msg = [...topErrors, ...userErrors].join("; ") || "Exchange failed";
91
+ const msg = [...topErrors, ...userErrors].join("; ") || `Exchange failed (${response.status})`;
78
92
  return c.text(`Install failed: ${msg}`, 400);
79
93
  }
80
94
 
@@ -3,12 +3,19 @@ import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  /**
4
4
  * Verify a webhook signature from the Odeva platform.
5
5
  *
6
- * The CLI's `odeva webhook trigger` and the platform itself both sign payloads
7
- * with HMAC-SHA256(secret, rawBody), hex-encoded, sent as `x-odeva-signature`.
6
+ * Webhooks are signed as HMAC-SHA256(secret, `${timestamp}.${rawBody}`),
7
+ * hex-encoded and sent as `x-odeva-signature: sha256=<digest>`.
8
8
  */
9
- export function verifyWebhook(rawBody: string, signature: string | undefined, secret: string): boolean {
9
+ export function verifyWebhook(
10
+ rawBody: string,
11
+ signature: string | undefined,
12
+ secret: string,
13
+ timestamp: string | undefined,
14
+ ): boolean {
10
15
  if (!signature) return false;
11
- const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
16
+ if (!timestamp) return false;
17
+ const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
18
+ const expected = `sha256=${digest}`;
12
19
  const a = Buffer.from(expected, "utf8");
13
20
  const b = Buffer.from(signature, "utf8");
14
21
  if (a.length !== b.length) return false;