@odeva/cli 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { Command, Flags } from "@oclif/core";
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import { OdevaApi } from "../../../lib/api.js";
|
|
4
|
-
import { loadAppConfig, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
|
|
4
|
+
import { loadAppConfig, resolveAppConfigUrl, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
|
|
5
5
|
import { saveAppEnv } from "../../../lib/app-env.js";
|
|
6
6
|
import { loadCredentials } from "../../../lib/credentials.js";
|
|
7
7
|
import { withErrorHandling } from "../../../lib/run.js";
|
|
8
8
|
import { ui } from "../../../lib/ui.js";
|
|
9
9
|
import { ApiError, CliError } from "../../../lib/errors.js";
|
|
10
10
|
import { isValidSlug } from "../../../lib/slug.js";
|
|
11
|
-
function buildAdminEmbedInput(
|
|
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
|
|
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
|
|
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} (${
|
|
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
|
|
141
|
+
installUrl,
|
|
115
142
|
privacyUrl: loaded.config.privacy_url,
|
|
116
143
|
requestedScopes: loaded.config.access_scopes?.scopes,
|
|
117
144
|
...adminInput ? { adminEmbed: adminInput } : {}
|
package/dist/commands/app/dev.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
preflightChecks,
|
|
14
14
|
registeredWebhookEnv,
|
|
15
15
|
registerWebhookSubscriptions,
|
|
16
|
+
saveDevTunnelUrl,
|
|
16
17
|
spawnDevServer,
|
|
17
18
|
syncDevAppUrls,
|
|
18
19
|
watchedDevInputPaths,
|
|
@@ -66,22 +67,44 @@ class AppDev extends Command {
|
|
|
66
67
|
PORT: String(port)
|
|
67
68
|
}
|
|
68
69
|
});
|
|
70
|
+
let tunnel;
|
|
71
|
+
let stopWatcher = () => {
|
|
72
|
+
};
|
|
73
|
+
let reloading = Promise.resolve();
|
|
74
|
+
let shuttingDown = false;
|
|
75
|
+
installShutdownHandler(async () => {
|
|
76
|
+
shuttingDown = true;
|
|
77
|
+
this.log("\n" + ui.dim("Cleaning up..."));
|
|
78
|
+
stopWatcher();
|
|
79
|
+
await reloading;
|
|
80
|
+
await Promise.allSettled([
|
|
81
|
+
server.stop(),
|
|
82
|
+
tunnel?.stop(),
|
|
83
|
+
cleanupSubscriptions(api, registered)
|
|
84
|
+
]);
|
|
85
|
+
this.log(ui.ok("Done."));
|
|
86
|
+
});
|
|
69
87
|
await this.verifyLocalDevServer(port, server, api, registered);
|
|
70
|
-
|
|
71
|
-
|
|
88
|
+
tunnel = await this.startReachableTunnel(port, server, (startedTunnel) => {
|
|
89
|
+
tunnel = startedTunnel;
|
|
90
|
+
});
|
|
91
|
+
const activeTunnel = tunnel;
|
|
92
|
+
if (saveDevTunnelUrl(loaded, activeTunnel.url)) {
|
|
93
|
+
this.log(ui.ok(`Wrote tunnel_url to ${ui.code(loaded.path)}`));
|
|
94
|
+
}
|
|
95
|
+
registered = await this.registerDevWebhooks(api, loaded, activeTunnel.url);
|
|
72
96
|
this.log("");
|
|
73
|
-
this.log(`${ui.bold("Tunnel:")} ${
|
|
97
|
+
this.log(`${ui.bold("Tunnel:")} ${activeTunnel.url}`);
|
|
74
98
|
this.log(`${ui.bold("Local:")} http://localhost:${port}`);
|
|
75
99
|
for (const reg of registered) {
|
|
76
100
|
this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
|
|
77
101
|
}
|
|
78
102
|
this.log("");
|
|
79
|
-
const syncedUrls = await this.syncDevAppUrls(api, loaded,
|
|
103
|
+
const syncedUrls = await this.syncDevAppUrls(api, loaded, activeTunnel.url);
|
|
80
104
|
this.printDevAppInfo(syncedUrls);
|
|
81
|
-
await this.ensureDevAppInstalled(api, loaded,
|
|
105
|
+
await this.ensureDevAppInstalled(api, loaded, activeTunnel, port, server, registered);
|
|
82
106
|
this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
|
|
83
107
|
this.log("");
|
|
84
|
-
let shuttingDown = false;
|
|
85
108
|
let restartingServer = false;
|
|
86
109
|
let serverExited = () => {
|
|
87
110
|
};
|
|
@@ -94,8 +117,7 @@ class AppDev extends Command {
|
|
|
94
117
|
});
|
|
95
118
|
};
|
|
96
119
|
watchServerExit(server);
|
|
97
|
-
|
|
98
|
-
const stopWatcher = this.watchDevInputs(loaded.path, async () => {
|
|
120
|
+
stopWatcher = this.watchDevInputs(loaded.path, async () => {
|
|
99
121
|
reloading = reloading.then(async () => {
|
|
100
122
|
this.log("\n" + ui.dim("Reloading app dev config..."));
|
|
101
123
|
const nextLoaded = loadAppConfig(loaded.root);
|
|
@@ -103,7 +125,10 @@ class AppDev extends Command {
|
|
|
103
125
|
if (nextPort !== port) {
|
|
104
126
|
this.log(ui.warn(`Port changed to ${nextPort}; restart \`odeva app dev\` to recreate the tunnel.`));
|
|
105
127
|
}
|
|
106
|
-
|
|
128
|
+
if (saveDevTunnelUrl(nextLoaded, activeTunnel.url)) {
|
|
129
|
+
this.log(ui.ok(`Wrote tunnel_url to ${ui.code(nextLoaded.path)}`));
|
|
130
|
+
}
|
|
131
|
+
const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, activeTunnel.url);
|
|
107
132
|
await cleanupSubscriptions(api, registered);
|
|
108
133
|
registered = nextRegistered;
|
|
109
134
|
const nextAppEnv = loadAppEnv(nextLoaded.path);
|
|
@@ -116,32 +141,20 @@ class AppDev extends Command {
|
|
|
116
141
|
env: {
|
|
117
142
|
...nextAppEnv,
|
|
118
143
|
PORT: String(port),
|
|
119
|
-
ODEVA_TUNNEL_URL:
|
|
144
|
+
ODEVA_TUNNEL_URL: activeTunnel.url,
|
|
120
145
|
...registeredWebhookEnv(registered)
|
|
121
146
|
}
|
|
122
147
|
});
|
|
123
148
|
server = nextServer;
|
|
124
149
|
watchServerExit(server);
|
|
125
|
-
await this.verifyDevServerReachable(
|
|
126
|
-
await this.syncDevAppUrls(api, nextLoaded,
|
|
150
|
+
await this.verifyDevServerReachable(activeTunnel, port, server, api, registered);
|
|
151
|
+
await this.syncDevAppUrls(api, nextLoaded, activeTunnel.url);
|
|
127
152
|
this.log(ui.ok("Reloaded app dev config."));
|
|
128
153
|
}).catch((err) => {
|
|
129
154
|
restartingServer = false;
|
|
130
155
|
this.log(ui.err(`Reload failed: ${err.message}`));
|
|
131
156
|
});
|
|
132
157
|
});
|
|
133
|
-
installShutdownHandler(async () => {
|
|
134
|
-
shuttingDown = true;
|
|
135
|
-
this.log("\n" + ui.dim("Cleaning up..."));
|
|
136
|
-
stopWatcher();
|
|
137
|
-
await reloading;
|
|
138
|
-
await Promise.allSettled([
|
|
139
|
-
server.stop(),
|
|
140
|
-
tunnel.stop(),
|
|
141
|
-
cleanupSubscriptions(api, registered)
|
|
142
|
-
]);
|
|
143
|
-
this.log(ui.ok("Done."));
|
|
144
|
-
});
|
|
145
158
|
await serverExit;
|
|
146
159
|
shuttingDown = true;
|
|
147
160
|
stopWatcher();
|
|
@@ -241,19 +254,19 @@ class AppDev extends Command {
|
|
|
241
254
|
throw err;
|
|
242
255
|
}
|
|
243
256
|
}
|
|
244
|
-
async startReachableTunnel(port, server) {
|
|
257
|
+
async startReachableTunnel(port, server, onTunnelStarted) {
|
|
245
258
|
let lastError;
|
|
246
259
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
247
260
|
this.log(ui.dim(`Starting Cloudflare tunnel${attempt > 1 ? ` (attempt ${attempt}/3)` : ""}...`));
|
|
248
261
|
const tunnel = await startQuickTunnel(port);
|
|
262
|
+
onTunnelStarted?.(tunnel);
|
|
249
263
|
this.log(ui.ok(`Tunnel ready at ${ui.code(tunnel.url)}`));
|
|
250
|
-
this.log(ui.dim("
|
|
251
|
-
await new Promise((resolve) => setTimeout(resolve, 2e4));
|
|
252
|
-
this.log(ui.dim("Verifying dev server and tunnel... (trycloudflare can take up to 60s to propagate)"));
|
|
264
|
+
this.log(ui.dim("Verifying dev server and tunnel... (retrying for up to 60s while trycloudflare becomes reachable)"));
|
|
253
265
|
try {
|
|
254
266
|
const result = await waitForDevServerReachable({
|
|
255
267
|
localPort: port,
|
|
256
|
-
tunnelUrl: tunnel.url
|
|
268
|
+
tunnelUrl: tunnel.url,
|
|
269
|
+
onProbeFailure: this.logTunnelProbeProgress()
|
|
257
270
|
});
|
|
258
271
|
this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
|
|
259
272
|
return tunnel;
|
|
@@ -274,7 +287,8 @@ class AppDev extends Command {
|
|
|
274
287
|
try {
|
|
275
288
|
const result = await waitForDevServerReachable({
|
|
276
289
|
localPort: port,
|
|
277
|
-
tunnelUrl: tunnel.url
|
|
290
|
+
tunnelUrl: tunnel.url,
|
|
291
|
+
onProbeFailure: this.logTunnelProbeProgress()
|
|
278
292
|
});
|
|
279
293
|
this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
|
|
280
294
|
} catch (err) {
|
|
@@ -287,6 +301,17 @@ class AppDev extends Command {
|
|
|
287
301
|
throw err;
|
|
288
302
|
}
|
|
289
303
|
}
|
|
304
|
+
logTunnelProbeProgress() {
|
|
305
|
+
let lastLogAt = 0;
|
|
306
|
+
return (probe) => {
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
if (probe.elapsedMs < 5e3 || now - lastLogAt < 5e3) return;
|
|
309
|
+
lastLogAt = now;
|
|
310
|
+
this.log(ui.dim(
|
|
311
|
+
`Still waiting for tunnel ${ui.code(probe.path)} after ${Math.round(probe.elapsedMs / 1e3)}s: ${probe.lastError}`
|
|
312
|
+
));
|
|
313
|
+
};
|
|
314
|
+
}
|
|
290
315
|
watchDevInputs(appConfigPath, onChange) {
|
|
291
316
|
const paths = watchedDevInputPaths(appConfigPath);
|
|
292
317
|
let timer;
|
package/dist/lib/config.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
};
|
package/dist/lib/dev-runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { saveAppConfig } from "./config.js";
|
|
4
5
|
import { APP_DEV_ENV_FILE, APP_ENV_FILE, APP_ENV_LOCAL_FILE, readEnvFile } from "./app-env.js";
|
|
5
6
|
import { CliError } from "./errors.js";
|
|
6
7
|
async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
|
|
@@ -98,15 +99,20 @@ function spawnDevServer(opts) {
|
|
|
98
99
|
}
|
|
99
100
|
async function waitForDevServerReachable(opts) {
|
|
100
101
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
102
|
+
const startedAt = Date.now();
|
|
101
103
|
const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
|
|
102
104
|
const requestTimeoutMs = opts.requestTimeoutMs ?? 2500;
|
|
103
105
|
const mismatchGraceMs = opts.mismatchGraceMs ?? 45e3;
|
|
106
|
+
const cloudflareErrorGraceMs = opts.cloudflareErrorGraceMs ?? 1e4;
|
|
107
|
+
const networkErrorGraceMs = opts.networkErrorGraceMs ?? 1e4;
|
|
104
108
|
const probe = await waitForLocalProbe(opts.localPort, deadline, requestTimeoutMs, fetchImpl);
|
|
105
109
|
const tunnelUrl = new URL(opts.tunnelUrl);
|
|
106
110
|
tunnelUrl.pathname = probe.path;
|
|
107
111
|
tunnelUrl.search = "";
|
|
108
112
|
let lastError = "not ready";
|
|
109
113
|
let mismatchSince = null;
|
|
114
|
+
let cloudflareErrorSince = null;
|
|
115
|
+
let networkErrorSince = null;
|
|
110
116
|
while (Date.now() < deadline) {
|
|
111
117
|
try {
|
|
112
118
|
const response = await fetchWithTimeout(fetchImpl, tunnelUrl, requestTimeoutMs);
|
|
@@ -118,18 +124,40 @@ async function waitForDevServerReachable(opts) {
|
|
|
118
124
|
};
|
|
119
125
|
}
|
|
120
126
|
lastError = `HTTP ${response.status}`;
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
if (isCloudflareTunnelErrorStatus(response.status)) {
|
|
128
|
+
cloudflareErrorSince ??= Date.now();
|
|
129
|
+
mismatchSince = null;
|
|
130
|
+
networkErrorSince = null;
|
|
131
|
+
if (Date.now() - cloudflareErrorSince >= cloudflareErrorGraceMs) break;
|
|
132
|
+
} else {
|
|
133
|
+
mismatchSince ??= Date.now();
|
|
134
|
+
cloudflareErrorSince = null;
|
|
135
|
+
networkErrorSince = null;
|
|
136
|
+
if (Date.now() - mismatchSince >= mismatchGraceMs) break;
|
|
137
|
+
}
|
|
123
138
|
} catch (err) {
|
|
124
139
|
lastError = err instanceof Error ? err.message : String(err);
|
|
125
140
|
mismatchSince = null;
|
|
141
|
+
cloudflareErrorSince = null;
|
|
142
|
+
networkErrorSince ??= Date.now();
|
|
143
|
+
if (Date.now() - networkErrorSince >= networkErrorGraceMs) break;
|
|
126
144
|
}
|
|
145
|
+
opts.onProbeFailure?.({
|
|
146
|
+
elapsedMs: Date.now() - startedAt,
|
|
147
|
+
path: probe.path,
|
|
148
|
+
localStatus: probe.status,
|
|
149
|
+
tunnelUrl: tunnelUrl.toString(),
|
|
150
|
+
lastError
|
|
151
|
+
});
|
|
127
152
|
await delay(500);
|
|
128
153
|
}
|
|
129
154
|
throw new CliError(`Cloudflare tunnel did not reach the local dev server at ${tunnelUrl.toString()}.`, {
|
|
130
155
|
hint: `Local ${probe.path} returned HTTP ${probe.status}, but the tunnel returned ${lastError}. Restart \`odeva app dev\` if the quick tunnel URL expired.`
|
|
131
156
|
});
|
|
132
157
|
}
|
|
158
|
+
function isCloudflareTunnelErrorStatus(status) {
|
|
159
|
+
return status === 530 || status === 502 || status === 503 || status === 504;
|
|
160
|
+
}
|
|
133
161
|
async function waitForLocalDevServer(opts) {
|
|
134
162
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
135
163
|
const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
|
|
@@ -148,13 +176,19 @@ function devInstallCallbackUrl(opts) {
|
|
|
148
176
|
}
|
|
149
177
|
function devAppTunnelUrl(opts) {
|
|
150
178
|
const url = new URL(opts.tunnelUrl);
|
|
151
|
-
|
|
179
|
+
const configuredUrl = opts.configuredUrl?.trim();
|
|
180
|
+
if (configuredUrl) {
|
|
181
|
+
if (configuredUrl.startsWith("/")) {
|
|
182
|
+
url.pathname = configuredUrl;
|
|
183
|
+
url.search = "";
|
|
184
|
+
return url.toString();
|
|
185
|
+
}
|
|
152
186
|
let configured;
|
|
153
187
|
try {
|
|
154
|
-
configured = new URL(
|
|
188
|
+
configured = new URL(configuredUrl);
|
|
155
189
|
} catch {
|
|
156
|
-
throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${
|
|
157
|
-
hint: "
|
|
190
|
+
throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${configuredUrl}`, {
|
|
191
|
+
hint: "Use an absolute URL, a path starting with '/', or omit it so `tunnel_url` can infer it."
|
|
158
192
|
});
|
|
159
193
|
}
|
|
160
194
|
url.pathname = configured.pathname;
|
|
@@ -165,6 +199,12 @@ function devAppTunnelUrl(opts) {
|
|
|
165
199
|
}
|
|
166
200
|
return url.toString();
|
|
167
201
|
}
|
|
202
|
+
function saveDevTunnelUrl(loaded, tunnelUrl) {
|
|
203
|
+
if (loaded.config.tunnel_url === tunnelUrl) return false;
|
|
204
|
+
loaded.config.tunnel_url = tunnelUrl;
|
|
205
|
+
saveAppConfig(loaded);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
168
208
|
function writeDevEnvFile(cwd, registered) {
|
|
169
209
|
if (registered.length === 0) return null;
|
|
170
210
|
const path = join(cwd, APP_DEV_ENV_FILE);
|
|
@@ -361,6 +401,7 @@ export {
|
|
|
361
401
|
preflightChecks,
|
|
362
402
|
registerWebhookSubscriptions,
|
|
363
403
|
registeredWebhookEnv,
|
|
404
|
+
saveDevTunnelUrl,
|
|
364
405
|
spawnDevServer,
|
|
365
406
|
syncDevAppUrls,
|
|
366
407
|
waitForDevServerReachable,
|
package/package.json
CHANGED
|
@@ -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`
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
|
|
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 = "
|
|
39
|
+
# entry_url = "/admin"
|
|
39
40
|
#
|
|
40
41
|
# [admin.sidebar]
|
|
41
42
|
# label = "{{name}}"
|