@odeva/cli 0.0.1 → 0.0.3
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 +98 -22
- package/dist/commands/auth/login.js +26 -4
- package/dist/commands/webhook/trigger.js +15 -9
- package/dist/lib/api.js +5 -2
- package/dist/lib/config.js +17 -0
- package/dist/lib/dev-runner.js +38 -7
- package/package.json +1 -1
- package/templates/hono-bun/src/index.ts +30 -4
- package/templates/hono-bun/src/logger.ts +20 -0
package/dist/commands/app/dev.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command, Flags } from "@oclif/core";
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
|
+
import { unwatchFile, watchFile } from "node:fs";
|
|
3
4
|
import { OdevaApi } from "../../lib/api.js";
|
|
4
5
|
import { loadAppConfig } from "../../lib/config.js";
|
|
5
6
|
import { loadAppEnv } from "../../lib/app-env.js";
|
|
@@ -7,8 +8,10 @@ import { startQuickTunnel } from "../../lib/cloudflared.js";
|
|
|
7
8
|
import {
|
|
8
9
|
cleanupSubscriptions,
|
|
9
10
|
preflightChecks,
|
|
11
|
+
registeredWebhookEnv,
|
|
10
12
|
registerWebhookSubscriptions,
|
|
11
13
|
spawnDevServer,
|
|
14
|
+
watchedDevInputPaths,
|
|
12
15
|
writeDevEnvFile
|
|
13
16
|
} from "../../lib/dev-runner.js";
|
|
14
17
|
import { CliError } from "../../lib/errors.js";
|
|
@@ -31,7 +34,6 @@ class AppDev extends Command {
|
|
|
31
34
|
const loaded = loadAppConfig();
|
|
32
35
|
const appEnv = loadAppEnv(loaded.path);
|
|
33
36
|
const port = flags.port ?? loaded.config.build?.port ?? 3e3;
|
|
34
|
-
const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
|
|
35
37
|
for (const warning of preflightChecks(loaded.root).warnings) {
|
|
36
38
|
this.log(ui.warn(warning));
|
|
37
39
|
}
|
|
@@ -54,24 +56,7 @@ class AppDev extends Command {
|
|
|
54
56
|
const tunnel = await startQuickTunnel(port);
|
|
55
57
|
tunnelSpinner.stop(`Tunnel ready at ${ui.code(tunnel.url)}`);
|
|
56
58
|
let registered = [];
|
|
57
|
-
|
|
58
|
-
const whSpinner = p.spinner();
|
|
59
|
-
whSpinner.start(`Registering ${subscriptions.length} webhook subscription${subscriptions.length === 1 ? "" : "s"}`);
|
|
60
|
-
try {
|
|
61
|
-
registered = await registerWebhookSubscriptions(api, loaded.config.name, tunnel.url, subscriptions);
|
|
62
|
-
whSpinner.stop(`Registered ${registered.length} subscription${registered.length === 1 ? "" : "s"}`);
|
|
63
|
-
} catch (err) {
|
|
64
|
-
whSpinner.stop(ui.err("Webhook registration failed"));
|
|
65
|
-
await tunnel.stop();
|
|
66
|
-
throw err instanceof CliError ? err : new CliError(`Webhook registration failed: ${err.message}`);
|
|
67
|
-
}
|
|
68
|
-
const envPath = writeDevEnvFile(loaded.root, registered);
|
|
69
|
-
if (envPath) {
|
|
70
|
-
this.log(ui.ok(`Wrote ${ui.code(envPath)} (add to .gitignore \u2014 it contains secrets)`));
|
|
71
|
-
}
|
|
72
|
-
} else {
|
|
73
|
-
this.log(ui.dim("No webhook subscriptions in odeva.app.toml \u2014 skipping registration."));
|
|
74
|
-
}
|
|
59
|
+
registered = await this.registerDevWebhooks(api, loaded, tunnel.url);
|
|
75
60
|
this.log("");
|
|
76
61
|
this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
|
|
77
62
|
this.log(`${ui.bold("Local:")} http://localhost:${port}`);
|
|
@@ -81,18 +66,68 @@ class AppDev extends Command {
|
|
|
81
66
|
this.log("");
|
|
82
67
|
this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
|
|
83
68
|
this.log("");
|
|
84
|
-
|
|
69
|
+
let server = spawnDevServer({
|
|
85
70
|
cwd: loaded.root,
|
|
86
71
|
config: loaded.config,
|
|
87
72
|
env: {
|
|
88
73
|
...appEnv,
|
|
89
74
|
PORT: String(port),
|
|
90
75
|
ODEVA_TUNNEL_URL: tunnel.url,
|
|
91
|
-
...registered
|
|
76
|
+
...registeredWebhookEnv(registered)
|
|
92
77
|
}
|
|
93
78
|
});
|
|
79
|
+
let shuttingDown = false;
|
|
80
|
+
let restartingServer = false;
|
|
81
|
+
let serverExited = () => {
|
|
82
|
+
};
|
|
83
|
+
const serverExit = new Promise((resolve) => {
|
|
84
|
+
serverExited = resolve;
|
|
85
|
+
});
|
|
86
|
+
const watchServerExit = (devServer) => {
|
|
87
|
+
devServer.process.once("exit", (code) => {
|
|
88
|
+
if (!restartingServer && !shuttingDown) serverExited(code);
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
watchServerExit(server);
|
|
92
|
+
let reloading = Promise.resolve();
|
|
93
|
+
const stopWatcher = this.watchDevInputs(loaded.path, async () => {
|
|
94
|
+
reloading = reloading.then(async () => {
|
|
95
|
+
this.log("\n" + ui.dim("Reloading app dev config..."));
|
|
96
|
+
const nextLoaded = loadAppConfig(loaded.root);
|
|
97
|
+
const nextPort = flags.port ?? nextLoaded.config.build?.port ?? 3e3;
|
|
98
|
+
if (nextPort !== port) {
|
|
99
|
+
this.log(ui.warn(`Port changed to ${nextPort}; restart \`odeva app dev\` to recreate the tunnel.`));
|
|
100
|
+
}
|
|
101
|
+
const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, tunnel.url);
|
|
102
|
+
await cleanupSubscriptions(api, registered);
|
|
103
|
+
registered = nextRegistered;
|
|
104
|
+
const nextAppEnv = loadAppEnv(nextLoaded.path);
|
|
105
|
+
restartingServer = true;
|
|
106
|
+
await server.stop();
|
|
107
|
+
restartingServer = false;
|
|
108
|
+
const nextServer = spawnDevServer({
|
|
109
|
+
cwd: nextLoaded.root,
|
|
110
|
+
config: nextLoaded.config,
|
|
111
|
+
env: {
|
|
112
|
+
...nextAppEnv,
|
|
113
|
+
PORT: String(port),
|
|
114
|
+
ODEVA_TUNNEL_URL: tunnel.url,
|
|
115
|
+
...registeredWebhookEnv(registered)
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
server = nextServer;
|
|
119
|
+
watchServerExit(server);
|
|
120
|
+
this.log(ui.ok("Reloaded app dev config."));
|
|
121
|
+
}).catch((err) => {
|
|
122
|
+
restartingServer = false;
|
|
123
|
+
this.log(ui.err(`Reload failed: ${err.message}`));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
94
126
|
installShutdownHandler(async () => {
|
|
127
|
+
shuttingDown = true;
|
|
95
128
|
this.log("\n" + ui.dim("Cleaning up..."));
|
|
129
|
+
stopWatcher();
|
|
130
|
+
await reloading;
|
|
96
131
|
await Promise.allSettled([
|
|
97
132
|
server.stop(),
|
|
98
133
|
tunnel.stop(),
|
|
@@ -100,10 +135,51 @@ class AppDev extends Command {
|
|
|
100
135
|
]);
|
|
101
136
|
this.log(ui.ok("Done."));
|
|
102
137
|
});
|
|
103
|
-
await
|
|
138
|
+
await serverExit;
|
|
139
|
+
shuttingDown = true;
|
|
140
|
+
stopWatcher();
|
|
141
|
+
await reloading;
|
|
104
142
|
await Promise.allSettled([tunnel.stop(), cleanupSubscriptions(api, registered)]);
|
|
105
143
|
});
|
|
106
144
|
}
|
|
145
|
+
async registerDevWebhooks(api, loaded, tunnelUrl) {
|
|
146
|
+
const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
|
|
147
|
+
if (subscriptions.length === 0) {
|
|
148
|
+
this.log(ui.dim("No webhook subscriptions in odeva.app.toml \u2014 skipping registration."));
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
const whSpinner = p.spinner();
|
|
152
|
+
whSpinner.start(`Registering ${subscriptions.length} webhook subscription${subscriptions.length === 1 ? "" : "s"}`);
|
|
153
|
+
try {
|
|
154
|
+
const registered = await registerWebhookSubscriptions(api, loaded.config.name, tunnelUrl, subscriptions);
|
|
155
|
+
whSpinner.stop(`Registered ${registered.length} subscription${registered.length === 1 ? "" : "s"}`);
|
|
156
|
+
const envPath = writeDevEnvFile(loaded.root, registered);
|
|
157
|
+
if (envPath) {
|
|
158
|
+
this.log(ui.ok(`Wrote ${ui.code(envPath)} (add to .gitignore \u2014 it contains secrets)`));
|
|
159
|
+
}
|
|
160
|
+
return registered;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
whSpinner.stop(ui.err("Webhook registration failed"));
|
|
163
|
+
throw err instanceof CliError ? err : new CliError(`Webhook registration failed: ${err.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
watchDevInputs(appConfigPath, onChange) {
|
|
167
|
+
const paths = watchedDevInputPaths(appConfigPath);
|
|
168
|
+
let timer;
|
|
169
|
+
const queue = () => {
|
|
170
|
+
if (timer) clearTimeout(timer);
|
|
171
|
+
timer = setTimeout(onChange, 250);
|
|
172
|
+
};
|
|
173
|
+
for (const path of paths) {
|
|
174
|
+
watchFile(path, { interval: 500 }, (curr, prev) => {
|
|
175
|
+
if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) queue();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return () => {
|
|
179
|
+
if (timer) clearTimeout(timer);
|
|
180
|
+
for (const path of paths) unwatchFile(path);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
107
183
|
}
|
|
108
184
|
let shutdownInstalled = false;
|
|
109
185
|
function installShutdownHandler(handler) {
|
|
@@ -10,6 +10,8 @@ import { pickOrganization } from "../../lib/org-picker.js";
|
|
|
10
10
|
import { withErrorHandling } from "../../lib/run.js";
|
|
11
11
|
import { ui } from "../../lib/ui.js";
|
|
12
12
|
const POLL_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
13
|
+
const MIN_POLL_INTERVAL_MS = 10 * 1e3;
|
|
14
|
+
const RATE_LIMIT_BACKOFF_MS = 30 * 1e3;
|
|
13
15
|
class AuthLogin extends Command {
|
|
14
16
|
static description = "Authenticate the CLI with your Odeva account";
|
|
15
17
|
static examples = [
|
|
@@ -42,6 +44,7 @@ class AuthLogin extends Command {
|
|
|
42
44
|
const api = new OdevaApi({ apiUrl });
|
|
43
45
|
p.intro(ui.brand("odeva auth login"));
|
|
44
46
|
const begin = await api.cliAuthBegin(osHostname());
|
|
47
|
+
const pollIntervalMs = Math.max(begin.interval * 1e3, MIN_POLL_INTERVAL_MS);
|
|
45
48
|
p.note(
|
|
46
49
|
[
|
|
47
50
|
`Verify this code matches the one shown on the approval page:`,
|
|
@@ -58,18 +61,28 @@ class AuthLogin extends Command {
|
|
|
58
61
|
this.log(ui.warn(`Could not open a browser automatically. Visit the URL above to approve.`));
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
|
-
const token = await this.pollUntilResolved(api, begin.deviceCode,
|
|
64
|
+
const token = await this.pollUntilResolved(api, begin.deviceCode, pollIntervalMs);
|
|
62
65
|
await this.completeWithToken(token, apiUrl);
|
|
63
66
|
});
|
|
64
67
|
}
|
|
65
|
-
async pollUntilResolved(api, deviceCode,
|
|
68
|
+
async pollUntilResolved(api, deviceCode, intervalMs) {
|
|
66
69
|
const spinner = p.spinner();
|
|
67
70
|
spinner.start("Waiting for approval in your browser\u2026");
|
|
68
71
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
69
72
|
try {
|
|
70
73
|
while (Date.now() < deadline) {
|
|
71
|
-
await sleep(
|
|
72
|
-
|
|
74
|
+
await sleep(intervalMs);
|
|
75
|
+
let result;
|
|
76
|
+
try {
|
|
77
|
+
result = await api.cliAuthPoll(deviceCode);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (isRateLimitError(err)) {
|
|
80
|
+
spinner.message("Waiting for approval in your browser\u2026");
|
|
81
|
+
await sleep(RATE_LIMIT_BACKOFF_MS);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
73
86
|
switch (result.status) {
|
|
74
87
|
case "approved":
|
|
75
88
|
if (!result.token) {
|
|
@@ -131,6 +144,15 @@ class AuthLogin extends Command {
|
|
|
131
144
|
function sleep(ms) {
|
|
132
145
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
133
146
|
}
|
|
147
|
+
function isRateLimitError(err) {
|
|
148
|
+
if (!(err instanceof ApiError)) return false;
|
|
149
|
+
return err.graphqlErrors?.some((gqlError) => {
|
|
150
|
+
const extensions = gqlError.extensions;
|
|
151
|
+
if (!extensions || typeof extensions !== "object") return false;
|
|
152
|
+
const code = extensions.code;
|
|
153
|
+
return typeof code === "string" && code.toUpperCase() === "RATE_LIMITED";
|
|
154
|
+
}) ?? /rate limit/i.test(err.message);
|
|
155
|
+
}
|
|
134
156
|
export {
|
|
135
157
|
AuthLogin as default
|
|
136
158
|
};
|
|
@@ -5,7 +5,7 @@ import { loadAppConfig, findAppConfigPath } from "../../lib/config.js";
|
|
|
5
5
|
import { buildFixture, signPayload } from "../../lib/webhook-fixtures.js";
|
|
6
6
|
import { CliError } from "../../lib/errors.js";
|
|
7
7
|
import { withErrorHandling } from "../../lib/run.js";
|
|
8
|
-
import { readEnvFile } from "../../lib/dev-runner.js";
|
|
8
|
+
import { readEnvFile, webhookSecretForEvent } from "../../lib/dev-runner.js";
|
|
9
9
|
import { ui } from "../../lib/ui.js";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
class WebhookTrigger extends Command {
|
|
@@ -45,10 +45,11 @@ class WebhookTrigger extends Command {
|
|
|
45
45
|
const { args, flags } = await this.parse(WebhookTrigger);
|
|
46
46
|
await withErrorHandling(this, async () => {
|
|
47
47
|
const event = args.event;
|
|
48
|
-
const
|
|
48
|
+
const configSubscription = this.subscriptionFromConfig(event);
|
|
49
|
+
const url = flags.url ?? this.urlFromConfig(event, flags.port, configSubscription?.uri);
|
|
49
50
|
const payload = flags.payload ? this.readPayload(flags.payload, event) : buildFixture(event);
|
|
50
51
|
const body = JSON.stringify(payload);
|
|
51
|
-
const secret = flags.secret ?? this.secretFromEnvFile();
|
|
52
|
+
const secret = flags.secret ?? this.secretFromEnvFile(event);
|
|
52
53
|
const headers = {
|
|
53
54
|
"content-type": "application/json",
|
|
54
55
|
"x-odeva-event": event,
|
|
@@ -57,7 +58,7 @@ class WebhookTrigger extends Command {
|
|
|
57
58
|
if (!flags["no-sign"]) {
|
|
58
59
|
if (!secret) {
|
|
59
60
|
throw new CliError("No webhook secret available.", {
|
|
60
|
-
hint: "Run `odeva app dev`
|
|
61
|
+
hint: flags.url || configSubscription ? "Run `odeva app dev` to generate one, or pass --secret <value>, or use --no-sign for unsigned testing." : `Add a '${event}' subscription to odeva.app.toml, let \`odeva app dev\` reload, then try again.`
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
headers["x-odeva-signature"] = signPayload(body, secret);
|
|
@@ -83,7 +84,13 @@ class WebhookTrigger extends Command {
|
|
|
83
84
|
if (text) this.log(ui.dim(text.length > 500 ? `${text.slice(0, 500)}\u2026` : text));
|
|
84
85
|
});
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
+
subscriptionFromConfig(event) {
|
|
88
|
+
const cfgPath = findAppConfigPath();
|
|
89
|
+
if (!cfgPath) return void 0;
|
|
90
|
+
const loaded = loadAppConfig();
|
|
91
|
+
return loaded.config.webhooks?.subscriptions?.find((s) => s.topic === event);
|
|
92
|
+
}
|
|
93
|
+
urlFromConfig(event, portFlag, subscriptionUri) {
|
|
87
94
|
const cfgPath = findAppConfigPath();
|
|
88
95
|
if (!cfgPath) {
|
|
89
96
|
throw new CliError("No odeva.app.toml found, and no --url provided.", {
|
|
@@ -91,17 +98,16 @@ class WebhookTrigger extends Command {
|
|
|
91
98
|
});
|
|
92
99
|
}
|
|
93
100
|
const loaded = loadAppConfig();
|
|
94
|
-
const sub = loaded.config.webhooks?.subscriptions?.find((s) => s.topic === event);
|
|
95
101
|
const port = portFlag ?? loaded.config.build?.port ?? 3e3;
|
|
96
|
-
const path =
|
|
102
|
+
const path = subscriptionUri ?? `/webhooks/${event}`;
|
|
97
103
|
return `http://localhost:${port}${path.startsWith("/") ? path : `/${path}`}`;
|
|
98
104
|
}
|
|
99
|
-
secretFromEnvFile() {
|
|
105
|
+
secretFromEnvFile(event) {
|
|
100
106
|
const cfgPath = findAppConfigPath();
|
|
101
107
|
if (!cfgPath) return void 0;
|
|
102
108
|
const envPath = join(resolve(cfgPath, ".."), ".env.odeva.local");
|
|
103
109
|
const env = readEnvFile(envPath);
|
|
104
|
-
return env
|
|
110
|
+
return webhookSecretForEvent(event, env);
|
|
105
111
|
}
|
|
106
112
|
readPayload(path, event) {
|
|
107
113
|
const abs = resolve(process.cwd(), path);
|
package/dist/lib/api.js
CHANGED
|
@@ -246,7 +246,7 @@ class OdevaApi {
|
|
|
246
246
|
appInstallationId: $appInstallationId
|
|
247
247
|
) {
|
|
248
248
|
webhookSubscription { id name endpointUrl eventTypes status }
|
|
249
|
-
|
|
249
|
+
signingSecret
|
|
250
250
|
errors
|
|
251
251
|
}
|
|
252
252
|
}
|
|
@@ -257,7 +257,10 @@ class OdevaApi {
|
|
|
257
257
|
if (payload.errors?.length) {
|
|
258
258
|
throw new ApiError(`Could not create webhook subscription: ${payload.errors.join(", ")}`);
|
|
259
259
|
}
|
|
260
|
-
|
|
260
|
+
if (!payload.signingSecret) {
|
|
261
|
+
throw new ApiError("Could not create webhook subscription: no signing secret returned");
|
|
262
|
+
}
|
|
263
|
+
return { subscription: payload.webhookSubscription, signingSecret: payload.signingSecret };
|
|
261
264
|
}
|
|
262
265
|
async deleteWebhookSubscription(id) {
|
|
263
266
|
await this.request(
|
package/dist/lib/config.js
CHANGED
|
@@ -42,6 +42,23 @@ function validateConfig(config, path) {
|
|
|
42
42
|
{ hint: `Edit ${path}` }
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
|
+
if (config.webhooks?.subscriptions) {
|
|
46
|
+
const rawSubscriptions = config.webhooks.subscriptions;
|
|
47
|
+
config.webhooks.subscriptions = rawSubscriptions.map((subscription) => {
|
|
48
|
+
if (typeof subscription === "string") {
|
|
49
|
+
return { topic: subscription, uri: `/webhooks/${subscription}` };
|
|
50
|
+
}
|
|
51
|
+
if (!subscription.topic || typeof subscription.topic !== "string") {
|
|
52
|
+
throw new CliError(`Invalid ${APP_CONFIG_FILE}: webhook subscription missing 'topic'.`, {
|
|
53
|
+
hint: `Edit ${path}`
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
...subscription,
|
|
58
|
+
uri: subscription.uri || `/webhooks/${subscription.topic}`
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
45
62
|
}
|
|
46
63
|
export {
|
|
47
64
|
findAppConfigPath,
|
package/dist/lib/dev-runner.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { APP_ENV_FILE } from "./app-env.js";
|
|
4
5
|
async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
|
|
5
6
|
const created = [];
|
|
6
7
|
for (const sub of subscriptions) {
|
|
7
8
|
const fullUrl = joinUrl(tunnelUrl, sub.uri);
|
|
8
|
-
const { subscription,
|
|
9
|
+
const { subscription, signingSecret } = await api.createWebhookSubscription({
|
|
9
10
|
name: `${appName} (dev) \u2014 ${sub.topic}`,
|
|
10
11
|
endpointUrl: fullUrl,
|
|
11
12
|
eventTypes: [sub.topic]
|
|
12
13
|
});
|
|
13
|
-
created.push({ config: sub, subscription, secret:
|
|
14
|
+
created.push({ config: sub, subscription, secret: signingSecret, fullUrl });
|
|
14
15
|
}
|
|
15
16
|
return created;
|
|
16
17
|
}
|
|
@@ -26,6 +27,7 @@ function spawnDevServer(opts) {
|
|
|
26
27
|
const command = opts.config.build?.dev ?? "bun run dev";
|
|
27
28
|
const proc = spawn(command, {
|
|
28
29
|
cwd: opts.cwd,
|
|
30
|
+
detached: process.platform !== "win32",
|
|
29
31
|
shell: true,
|
|
30
32
|
stdio: "inherit",
|
|
31
33
|
env: {
|
|
@@ -53,14 +55,28 @@ function writeDevEnvFile(cwd, registered) {
|
|
|
53
55
|
lines.push(`ODEVA_WEBHOOK_SECRET=${primary.secret}`);
|
|
54
56
|
for (const reg of registered) {
|
|
55
57
|
lines.push(`# ${reg.config.topic} \u2192 ${reg.fullUrl}`);
|
|
56
|
-
lines.push(`ODEVA_WEBHOOK_SECRET__${
|
|
58
|
+
lines.push(`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(reg.config.topic)}=${reg.secret}`);
|
|
57
59
|
}
|
|
58
60
|
writeFileSync(path, lines.join("\n") + "\n", { mode: 384 });
|
|
59
61
|
return path;
|
|
60
62
|
}
|
|
61
|
-
function
|
|
63
|
+
function registeredWebhookEnv(registered) {
|
|
64
|
+
const env = {};
|
|
65
|
+
if (registered[0]) env.ODEVA_WEBHOOK_SECRET = registered[0].secret;
|
|
66
|
+
for (const reg of registered) {
|
|
67
|
+
env[`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(reg.config.topic)}`] = reg.secret;
|
|
68
|
+
}
|
|
69
|
+
return env;
|
|
70
|
+
}
|
|
71
|
+
function webhookSecretEnvKey(topic) {
|
|
62
72
|
return topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
63
73
|
}
|
|
74
|
+
function webhookSecretForEvent(event, env) {
|
|
75
|
+
return env[`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(event)}`] ?? env["ODEVA_WEBHOOK_SECRET"];
|
|
76
|
+
}
|
|
77
|
+
function watchedDevInputPaths(appConfigPath) {
|
|
78
|
+
return [appConfigPath, join(dirname(appConfigPath), APP_ENV_FILE)];
|
|
79
|
+
}
|
|
64
80
|
function joinUrl(base, path) {
|
|
65
81
|
const trimmedBase = base.replace(/\/$/, "");
|
|
66
82
|
const trimmedPath = path.startsWith("/") ? path : `/${path}`;
|
|
@@ -71,7 +87,7 @@ function stopProcess(proc) {
|
|
|
71
87
|
if (proc.exitCode !== null) return resolve();
|
|
72
88
|
proc.once("exit", () => resolve());
|
|
73
89
|
try {
|
|
74
|
-
proc
|
|
90
|
+
killDevProcess(proc, "SIGTERM");
|
|
75
91
|
} catch {
|
|
76
92
|
resolve();
|
|
77
93
|
return;
|
|
@@ -79,13 +95,24 @@ function stopProcess(proc) {
|
|
|
79
95
|
setTimeout(() => {
|
|
80
96
|
if (proc.exitCode === null) {
|
|
81
97
|
try {
|
|
82
|
-
proc
|
|
98
|
+
killDevProcess(proc, "SIGKILL");
|
|
83
99
|
} catch {
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
}, 2e3);
|
|
87
103
|
});
|
|
88
104
|
}
|
|
105
|
+
function killDevProcess(proc, signal) {
|
|
106
|
+
if (process.platform === "win32") {
|
|
107
|
+
proc.kill(signal);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (proc.pid) {
|
|
111
|
+
process.kill(-proc.pid, signal);
|
|
112
|
+
} else {
|
|
113
|
+
proc.kill(signal);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
89
116
|
function preflightChecks(cwd) {
|
|
90
117
|
const warnings = [];
|
|
91
118
|
if (!existsSync(join(cwd, "node_modules"))) {
|
|
@@ -110,6 +137,10 @@ export {
|
|
|
110
137
|
preflightChecks,
|
|
111
138
|
readEnvFile,
|
|
112
139
|
registerWebhookSubscriptions,
|
|
140
|
+
registeredWebhookEnv,
|
|
113
141
|
spawnDevServer,
|
|
142
|
+
watchedDevInputPaths,
|
|
143
|
+
webhookSecretEnvKey,
|
|
144
|
+
webhookSecretForEvent,
|
|
114
145
|
writeDevEnvFile
|
|
115
146
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { OdevaClient } from "@odeva/booking-sdk";
|
|
2
3
|
import { verifyWebhook } from "./webhook.js";
|
|
3
4
|
import { makeInstallHandler } from "./install.js";
|
|
4
5
|
import { SqliteInstallationStore } from "./installations.js";
|
|
6
|
+
import { logger } from "./logger.js";
|
|
5
7
|
|
|
6
8
|
// Default store: SQLite file next to the app. Swap to your own
|
|
7
9
|
// `InstallationStore` impl (e.g. Postgres) for multi-instance deploys.
|
|
@@ -13,12 +15,34 @@ app.get("/", (c) =>
|
|
|
13
15
|
c.json({
|
|
14
16
|
app: "{{slug}}",
|
|
15
17
|
ok: true,
|
|
16
|
-
routes: ["GET /", "GET /healthz", "GET /install", "POST /webhooks/:topic"],
|
|
18
|
+
routes: ["GET /", "GET /healthz", "GET /install", "GET /sdk/accommodations", "POST /webhooks/:topic"],
|
|
17
19
|
}),
|
|
18
20
|
);
|
|
19
21
|
|
|
20
22
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
21
23
|
|
|
24
|
+
// SDK example. Set ODEVA_ORGANIZATION_SLUG to query public booking data for an
|
|
25
|
+
// organization, then visit /sdk/accommodations while the app is running.
|
|
26
|
+
app.get("/sdk/accommodations", async (c) => {
|
|
27
|
+
const organizationSlug = process.env.ODEVA_ORGANIZATION_SLUG;
|
|
28
|
+
if (!organizationSlug) {
|
|
29
|
+
return c.json({
|
|
30
|
+
error: "missing ODEVA_ORGANIZATION_SLUG",
|
|
31
|
+
hint: "Set ODEVA_ORGANIZATION_SLUG in .odeva.env, then let `odeva app dev` reload.",
|
|
32
|
+
}, 400);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const odeva = new OdevaClient({ organizationSlug });
|
|
36
|
+
const accommodations = await odeva.searchAccommodations({
|
|
37
|
+
startDate: "2026-07-01",
|
|
38
|
+
endDate: "2026-07-08",
|
|
39
|
+
guests: 2,
|
|
40
|
+
limit: 5,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return c.json({ accommodations });
|
|
44
|
+
});
|
|
45
|
+
|
|
22
46
|
// Install handshake. Odeva redirects here with `?install_code=...`.
|
|
23
47
|
app.get("/install", makeInstallHandler(installations));
|
|
24
48
|
|
|
@@ -28,14 +52,16 @@ app.post("/webhooks/:topic", async (c) => {
|
|
|
28
52
|
const topic = c.req.param("topic");
|
|
29
53
|
const rawBody = await c.req.text();
|
|
30
54
|
const signature = c.req.header("x-odeva-signature");
|
|
31
|
-
const secret =
|
|
55
|
+
const secret =
|
|
56
|
+
process.env[`ODEVA_WEBHOOK_SECRET__${topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`] ??
|
|
57
|
+
process.env.ODEVA_WEBHOOK_SECRET;
|
|
32
58
|
|
|
33
59
|
if (secret && !verifyWebhook(rawBody, signature, secret)) {
|
|
34
60
|
return c.json({ error: "invalid signature" }, 401);
|
|
35
61
|
}
|
|
36
62
|
|
|
37
63
|
const payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
38
|
-
|
|
64
|
+
logger.info(`[webhook] ${topic}`, payload);
|
|
39
65
|
|
|
40
66
|
// TODO: handle the event.
|
|
41
67
|
// Tip: run `odeva webhook trigger <topic>` to fire a sample payload at this handler.
|
|
@@ -44,6 +70,6 @@ app.post("/webhooks/:topic", async (c) => {
|
|
|
44
70
|
});
|
|
45
71
|
|
|
46
72
|
const port = Number(process.env.PORT ?? 3000);
|
|
47
|
-
|
|
73
|
+
logger.info(`{{name}} listening on http://localhost:${port}`);
|
|
48
74
|
|
|
49
75
|
export default { port, fetch: app.fetch };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
|
|
3
|
+
type LogLevel = "info" | "warn" | "error";
|
|
4
|
+
|
|
5
|
+
function log(level: LogLevel, message: string, details?: unknown): void {
|
|
6
|
+
const prefix = `[${new Date().toISOString()}] ${level.toUpperCase()} ${message}`;
|
|
7
|
+
if (details === undefined) {
|
|
8
|
+
console.log(prefix);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
console.log(prefix);
|
|
13
|
+
console.log(inspect(details, { colors: true, depth: 6, compact: false }));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const logger = {
|
|
17
|
+
info: (message: string, details?: unknown) => log("info", message, details),
|
|
18
|
+
warn: (message: string, details?: unknown) => log("warn", message, details),
|
|
19
|
+
error: (message: string, details?: unknown) => log("error", message, details),
|
|
20
|
+
};
|