@odeva/cli 0.0.7 → 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,33 +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
- ));
165
- }
166
- const adminEntryUrl = loaded.config.admin?.entry_url.trim();
167
- const expectedAdmin = `${tunnelUrl}/admin`;
168
- if (adminEntryUrl && !adminEntryUrl.startsWith(tunnelUrl) && !/localhost|127\.0\.0\.1/.test(adminEntryUrl)) {
169
- this.log(ui.warn(
170
- `admin.entry_url is ${adminEntryUrl} \u2014 the admin sidebar will iframe that URL, not this tunnel. Set it to ${expectedAdmin} and re-run \`odeva app config link\` for local embed testing.`
171
- ));
162
+ this.log(` ${ui.dim("install:")} ${syncedUrls.installUrl}`);
163
+ if (syncedUrls.adminEntryUrl) {
164
+ this.log(` ${ui.dim("admin:")} ${syncedUrls.adminEntryUrl}`);
172
165
  }
166
+ this.log(ui.dim(" dev URLs are synced to this tunnel for the current app record."));
173
167
  this.log("");
174
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
+ }
175
185
  async registerDevWebhooks(api, loaded, tunnelUrl) {
176
186
  const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
177
187
  if (subscriptions.length === 0) {
@@ -193,6 +203,65 @@ class AppDev extends Command {
193
203
  throw err instanceof CliError ? err : new CliError(`Webhook registration failed: ${err.message}`);
194
204
  }
195
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
+ }
196
265
  watchDevInputs(appConfigPath, onChange) {
197
266
  const paths = watchedDevInputPaths(appConfigPath);
198
267
  let timer;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.7",
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",