@odeva/cli 0.0.9 → 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.
- package/dist/commands/app/config/link.js +35 -8
- package/dist/commands/app/dev.js +7 -0
- package/dist/commands/webhook/trigger.js +4 -4
- package/dist/lib/app-env.js +14 -2
- package/dist/lib/config.js +84 -9
- package/dist/lib/dev-runner.js +34 -21
- package/package.json +1 -1
- package/templates/hono-bun/README.md.tmpl +4 -1
- package/templates/hono-bun/odeva.app.toml.tmpl +6 -5
|
@@ -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;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oclif/core";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { APP_DEV_ENV_FILE, readEnvFile } from "../../lib/app-env.js";
|
|
4
5
|
import { loadAppConfig, findAppConfigPath } from "../../lib/config.js";
|
|
5
6
|
import { buildFixture, signPayload } from "../../lib/webhook-fixtures.js";
|
|
6
7
|
import { CliError } from "../../lib/errors.js";
|
|
7
8
|
import { withErrorHandling } from "../../lib/run.js";
|
|
8
|
-
import {
|
|
9
|
+
import { webhookSecretForEvent } from "../../lib/dev-runner.js";
|
|
9
10
|
import { ui } from "../../lib/ui.js";
|
|
10
|
-
import { join } from "node:path";
|
|
11
11
|
class WebhookTrigger extends Command {
|
|
12
12
|
static description = "Fire a sample webhook payload at a local handler (uses the secret from .env.odeva.local if present)";
|
|
13
13
|
static examples = [
|
|
@@ -107,7 +107,7 @@ class WebhookTrigger extends Command {
|
|
|
107
107
|
secretFromEnvFile(event) {
|
|
108
108
|
const cfgPath = findAppConfigPath();
|
|
109
109
|
if (!cfgPath) return void 0;
|
|
110
|
-
const envPath = join(resolve(cfgPath, ".."),
|
|
110
|
+
const envPath = join(resolve(cfgPath, ".."), APP_DEV_ENV_FILE);
|
|
111
111
|
const env = readEnvFile(envPath);
|
|
112
112
|
return webhookSecretForEvent(event, env);
|
|
113
113
|
}
|
package/dist/lib/app-env.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
const APP_ENV_FILE = ".odeva.env";
|
|
4
|
+
const APP_ENV_LOCAL_FILE = ".odeva.env.local";
|
|
5
|
+
const APP_DEV_ENV_FILE = ".env.odeva.local";
|
|
4
6
|
function loadAppEnv(appConfigPath) {
|
|
5
|
-
const
|
|
7
|
+
const dir = dirname(appConfigPath);
|
|
8
|
+
return {
|
|
9
|
+
...readEnvFile(join(dir, APP_DEV_ENV_FILE)),
|
|
10
|
+
...readEnvFile(join(dir, APP_ENV_FILE)),
|
|
11
|
+
...readEnvFile(join(dir, APP_ENV_LOCAL_FILE))
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function readEnvFile(envPath) {
|
|
6
15
|
if (!existsSync(envPath)) return {};
|
|
7
16
|
const raw = readFileSync(envPath, "utf8");
|
|
8
17
|
const result = {};
|
|
@@ -19,7 +28,7 @@ function loadAppEnv(appConfigPath) {
|
|
|
19
28
|
}
|
|
20
29
|
function saveAppEnv(appConfigPath, values) {
|
|
21
30
|
const envPath = join(dirname(appConfigPath), APP_ENV_FILE);
|
|
22
|
-
const merged = { ...
|
|
31
|
+
const merged = { ...readEnvFile(envPath), ...values };
|
|
23
32
|
const body = [
|
|
24
33
|
"# Auto-managed by `odeva` \u2014 do not commit. Add `.odeva.env` to .gitignore.",
|
|
25
34
|
...Object.entries(merged).map(([k, v]) => `${k}=${v}`),
|
|
@@ -33,7 +42,10 @@ function saveAppEnv(appConfigPath, values) {
|
|
|
33
42
|
return envPath;
|
|
34
43
|
}
|
|
35
44
|
export {
|
|
45
|
+
APP_DEV_ENV_FILE,
|
|
36
46
|
APP_ENV_FILE,
|
|
47
|
+
APP_ENV_LOCAL_FILE,
|
|
37
48
|
loadAppEnv,
|
|
49
|
+
readEnvFile,
|
|
38
50
|
saveAppEnv
|
|
39
51
|
};
|
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,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { saveAppConfig } from "./config.js";
|
|
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) {
|
|
7
8
|
const created = [];
|
|
@@ -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,9 +172,16 @@ 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
|
-
const path = join(cwd,
|
|
183
|
+
const path = join(cwd, APP_DEV_ENV_FILE);
|
|
184
|
+
const preserved = Object.entries(readEnvFile(path)).filter(([key]) => !key.startsWith("ODEVA_WEBHOOK_SECRET"));
|
|
171
185
|
const lines = [
|
|
172
186
|
"# Auto-generated by `odeva app dev`. Do not commit.",
|
|
173
187
|
"# Reload your dev server to pick up these values."
|
|
@@ -178,6 +192,13 @@ function writeDevEnvFile(cwd, registered) {
|
|
|
178
192
|
lines.push(`# ${reg.config.topic} \u2192 ${reg.fullUrl}`);
|
|
179
193
|
lines.push(`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(reg.config.topic)}=${reg.secret}`);
|
|
180
194
|
}
|
|
195
|
+
if (preserved.length > 0) {
|
|
196
|
+
lines.push("");
|
|
197
|
+
lines.push("# Preserved local values.");
|
|
198
|
+
for (const [key, value] of preserved) {
|
|
199
|
+
lines.push(`${key}=${value}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
181
202
|
writeFileSync(path, lines.join("\n") + "\n", { mode: 384 });
|
|
182
203
|
return path;
|
|
183
204
|
}
|
|
@@ -196,7 +217,11 @@ function webhookSecretForEvent(event, env) {
|
|
|
196
217
|
return env[`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(event)}`] ?? env["ODEVA_WEBHOOK_SECRET"];
|
|
197
218
|
}
|
|
198
219
|
function watchedDevInputPaths(appConfigPath) {
|
|
199
|
-
return [
|
|
220
|
+
return [
|
|
221
|
+
appConfigPath,
|
|
222
|
+
join(dirname(appConfigPath), APP_ENV_FILE),
|
|
223
|
+
join(dirname(appConfigPath), APP_ENV_LOCAL_FILE)
|
|
224
|
+
];
|
|
200
225
|
}
|
|
201
226
|
function joinUrl(base, path) {
|
|
202
227
|
const trimmedBase = base.replace(/\/$/, "");
|
|
@@ -341,27 +366,15 @@ function preflightChecks(cwd) {
|
|
|
341
366
|
}
|
|
342
367
|
return { warnings };
|
|
343
368
|
}
|
|
344
|
-
function readEnvFile(path) {
|
|
345
|
-
if (!existsSync(path)) return {};
|
|
346
|
-
const out = {};
|
|
347
|
-
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
348
|
-
const trimmed = line.trim();
|
|
349
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
350
|
-
const eq = trimmed.indexOf("=");
|
|
351
|
-
if (eq === -1) continue;
|
|
352
|
-
out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
353
|
-
}
|
|
354
|
-
return out;
|
|
355
|
-
}
|
|
356
369
|
export {
|
|
357
370
|
cleanupSubscriptions,
|
|
358
371
|
devAppTunnelUrl,
|
|
359
372
|
devInstallCallbackUrl,
|
|
360
373
|
ensureDevAppInstalled,
|
|
361
374
|
preflightChecks,
|
|
362
|
-
readEnvFile,
|
|
363
375
|
registerWebhookSubscriptions,
|
|
364
376
|
registeredWebhookEnv,
|
|
377
|
+
saveDevTunnelUrl,
|
|
365
378
|
spawnDevServer,
|
|
366
379
|
syncDevAppUrls,
|
|
367
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}}"
|