@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.
- package/dist/commands/app/dev.js +102 -33
- package/dist/lib/api.js +14 -0
- package/dist/lib/cloudflared.js +32 -14
- package/dist/lib/dev-runner.js +229 -2
- package/package.json +1 -1
package/dist/commands/app/dev.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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!) {
|
package/dist/lib/cloudflared.js
CHANGED
|
@@ -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://
|
|
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://
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/lib/dev-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|