@odeva/cli 0.0.10 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { Command, Flags } from "@oclif/core";
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import { OdevaApi } from "../../../lib/api.js";
|
|
4
|
-
import { loadAppConfig, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
|
|
4
|
+
import { loadAppConfig, resolveAppConfigUrl, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
|
|
5
5
|
import { saveAppEnv } from "../../../lib/app-env.js";
|
|
6
6
|
import { loadCredentials } from "../../../lib/credentials.js";
|
|
7
7
|
import { withErrorHandling } from "../../../lib/run.js";
|
|
8
8
|
import { ui } from "../../../lib/ui.js";
|
|
9
9
|
import { ApiError, CliError } from "../../../lib/errors.js";
|
|
10
10
|
import { isValidSlug } from "../../../lib/slug.js";
|
|
11
|
-
function buildAdminEmbedInput(
|
|
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,
|
|
@@ -68,6 +69,9 @@ class AppDev extends Command {
|
|
|
68
69
|
});
|
|
69
70
|
await this.verifyLocalDevServer(port, server, api, registered);
|
|
70
71
|
const tunnel = await this.startReachableTunnel(port, server);
|
|
72
|
+
if (saveDevTunnelUrl(loaded, tunnel.url)) {
|
|
73
|
+
this.log(ui.ok(`Wrote tunnel_url to ${ui.code(loaded.path)}`));
|
|
74
|
+
}
|
|
71
75
|
registered = await this.registerDevWebhooks(api, loaded, tunnel.url);
|
|
72
76
|
this.log("");
|
|
73
77
|
this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
|
|
@@ -103,6 +107,9 @@ class AppDev extends Command {
|
|
|
103
107
|
if (nextPort !== port) {
|
|
104
108
|
this.log(ui.warn(`Port changed to ${nextPort}; restart \`odeva app dev\` to recreate the tunnel.`));
|
|
105
109
|
}
|
|
110
|
+
if (saveDevTunnelUrl(nextLoaded, tunnel.url)) {
|
|
111
|
+
this.log(ui.ok(`Wrote tunnel_url to ${ui.code(nextLoaded.path)}`));
|
|
112
|
+
}
|
|
106
113
|
const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, tunnel.url);
|
|
107
114
|
await cleanupSubscriptions(api, registered);
|
|
108
115
|
registered = nextRegistered;
|
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) {
|
|
@@ -148,13 +149,19 @@ function devInstallCallbackUrl(opts) {
|
|
|
148
149
|
}
|
|
149
150
|
function devAppTunnelUrl(opts) {
|
|
150
151
|
const url = new URL(opts.tunnelUrl);
|
|
151
|
-
|
|
152
|
+
const configuredUrl = opts.configuredUrl?.trim();
|
|
153
|
+
if (configuredUrl) {
|
|
154
|
+
if (configuredUrl.startsWith("/")) {
|
|
155
|
+
url.pathname = configuredUrl;
|
|
156
|
+
url.search = "";
|
|
157
|
+
return url.toString();
|
|
158
|
+
}
|
|
152
159
|
let configured;
|
|
153
160
|
try {
|
|
154
|
-
configured = new URL(
|
|
161
|
+
configured = new URL(configuredUrl);
|
|
155
162
|
} catch {
|
|
156
|
-
throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${
|
|
157
|
-
hint: "
|
|
163
|
+
throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${configuredUrl}`, {
|
|
164
|
+
hint: "Use an absolute URL, a path starting with '/', or omit it so `tunnel_url` can infer it."
|
|
158
165
|
});
|
|
159
166
|
}
|
|
160
167
|
url.pathname = configured.pathname;
|
|
@@ -165,6 +172,12 @@ function devAppTunnelUrl(opts) {
|
|
|
165
172
|
}
|
|
166
173
|
return url.toString();
|
|
167
174
|
}
|
|
175
|
+
function saveDevTunnelUrl(loaded, tunnelUrl) {
|
|
176
|
+
if (loaded.config.tunnel_url === tunnelUrl) return false;
|
|
177
|
+
loaded.config.tunnel_url = tunnelUrl;
|
|
178
|
+
saveAppConfig(loaded);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
168
181
|
function writeDevEnvFile(cwd, registered) {
|
|
169
182
|
if (registered.length === 0) return null;
|
|
170
183
|
const path = join(cwd, APP_DEV_ENV_FILE);
|
|
@@ -361,6 +374,7 @@ export {
|
|
|
361
374
|
preflightChecks,
|
|
362
375
|
registerWebhookSubscriptions,
|
|
363
376
|
registeredWebhookEnv,
|
|
377
|
+
saveDevTunnelUrl,
|
|
364
378
|
spawnDevServer,
|
|
365
379
|
syncDevAppUrls,
|
|
366
380
|
waitForDevServerReachable,
|
package/package.json
CHANGED
|
@@ -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}}"
|