@odeva/cli 0.0.1

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/bin/dev.js +7 -0
  4. package/bin/run.js +4 -0
  5. package/dist/commands/app/config/link.js +93 -0
  6. package/dist/commands/app/config/rotate-secret.js +74 -0
  7. package/dist/commands/app/dev.js +127 -0
  8. package/dist/commands/app/init.js +124 -0
  9. package/dist/commands/app/status.js +59 -0
  10. package/dist/commands/app/submit.js +85 -0
  11. package/dist/commands/auth/login.js +136 -0
  12. package/dist/commands/auth/logout.js +20 -0
  13. package/dist/commands/auth/select-org.js +30 -0
  14. package/dist/commands/auth/whoami.js +24 -0
  15. package/dist/commands/version.js +17 -0
  16. package/dist/commands/webhook/list.js +50 -0
  17. package/dist/commands/webhook/trigger.js +128 -0
  18. package/dist/index.js +4 -0
  19. package/dist/lib/api.js +276 -0
  20. package/dist/lib/app-env.js +39 -0
  21. package/dist/lib/cloudflared.js +93 -0
  22. package/dist/lib/config.js +51 -0
  23. package/dist/lib/credentials.js +50 -0
  24. package/dist/lib/dev-runner.js +115 -0
  25. package/dist/lib/errors.js +41 -0
  26. package/dist/lib/open-url.js +29 -0
  27. package/dist/lib/org-picker.js +34 -0
  28. package/dist/lib/paths.js +13 -0
  29. package/dist/lib/run.js +17 -0
  30. package/dist/lib/slug.js +10 -0
  31. package/dist/lib/templates.js +115 -0
  32. package/dist/lib/ui.js +13 -0
  33. package/dist/lib/webhook-fixtures.js +60 -0
  34. package/package.json +74 -0
  35. package/templates/hono-bun/.gitignore.tmpl +9 -0
  36. package/templates/hono-bun/README.md.tmpl +23 -0
  37. package/templates/hono-bun/odeva.app.toml.tmpl +25 -0
  38. package/templates/hono-bun/package.json.tmpl +19 -0
  39. package/templates/hono-bun/src/index.ts +49 -0
  40. package/templates/hono-bun/src/install.ts +97 -0
  41. package/templates/hono-bun/src/installations.ts +84 -0
  42. package/templates/hono-bun/src/webhook.ts +16 -0
  43. package/templates/hono-bun/tsconfig.json +16 -0
@@ -0,0 +1,276 @@
1
+ import { GraphQLClient, ClientError } from "graphql-request";
2
+ import { loadCredentials } from "./credentials.js";
3
+ import { ApiError, NotAuthenticatedError } from "./errors.js";
4
+ import { DEFAULT_API_URL } from "./paths.js";
5
+ class OdevaApi {
6
+ client;
7
+ endpoint;
8
+ authenticated;
9
+ constructor(opts = {}) {
10
+ const creds = opts.credentials ?? loadCredentials();
11
+ const apiUrl = opts.apiUrl ?? creds?.apiUrl ?? DEFAULT_API_URL;
12
+ this.endpoint = opts.endpoint ?? `${apiUrl.replace(/\/$/, "")}/graphql`;
13
+ this.authenticated = Boolean(creds?.token);
14
+ this.client = new GraphQLClient(this.endpoint, {
15
+ headers: {
16
+ ...creds?.token ? { authorization: `Bearer ${creds.token}` } : {},
17
+ ...creds?.organizationId ? { "x-organization-id": creds.organizationId } : {},
18
+ "user-agent": userAgent()
19
+ }
20
+ });
21
+ }
22
+ requireAuth() {
23
+ if (!this.authenticated) throw new NotAuthenticatedError();
24
+ }
25
+ async request(query, variables) {
26
+ try {
27
+ return await this.client.request(query, variables);
28
+ } catch (err) {
29
+ if (err instanceof ClientError) {
30
+ const gqlErrors = err.response.errors;
31
+ const first = gqlErrors?.[0]?.message ?? err.message;
32
+ throw new ApiError(`Odeva API error: ${first}`, {
33
+ graphqlErrors: gqlErrors
34
+ });
35
+ }
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ throw new ApiError(`Could not reach ${this.endpoint}: ${message}`, {
38
+ hint: "Is the Odeva API running? Pass --api-url, or set ODEVA_API_URL."
39
+ });
40
+ }
41
+ }
42
+ /** Lightweight auth probe — succeeds if the token resolves to a user. */
43
+ async ping() {
44
+ this.requireAuth();
45
+ const data = await this.request(`
46
+ query OdevaCliWhoami { session { user { email } } }
47
+ `);
48
+ return { ok: true, email: data.session?.user?.email ?? null };
49
+ }
50
+ async session() {
51
+ this.requireAuth();
52
+ const data = await this.request(`
53
+ query OdevaCliSession {
54
+ session {
55
+ user { email }
56
+ organizations { id slug name }
57
+ }
58
+ }
59
+ `);
60
+ return {
61
+ email: data.session?.user?.email ?? null,
62
+ organizations: data.session?.organizations ?? []
63
+ };
64
+ }
65
+ async cliAuthBegin(hostname) {
66
+ const data = await this.request(
67
+ `mutation OdevaCliAuthBegin($hostname: String!) {
68
+ cliAuthBegin(hostname: $hostname) {
69
+ deviceCode
70
+ userCode
71
+ verificationUri
72
+ expiresIn
73
+ interval
74
+ }
75
+ }`,
76
+ { hostname }
77
+ );
78
+ return data.cliAuthBegin;
79
+ }
80
+ async cliAuthPoll(deviceCode) {
81
+ const data = await this.request(
82
+ `mutation OdevaCliAuthPoll($deviceCode: String!) {
83
+ cliAuthPoll(deviceCode: $deviceCode) {
84
+ status
85
+ token
86
+ }
87
+ }`,
88
+ { deviceCode }
89
+ );
90
+ return data.cliAuthPoll;
91
+ }
92
+ async listDeveloperApps() {
93
+ const data = await this.request(`
94
+ query OdevaCliListDeveloperApps {
95
+ developerApps {
96
+ id
97
+ name
98
+ slug
99
+ clientId
100
+ homepageUrl
101
+ installUrl
102
+ webhookUrl
103
+ requestedScopes
104
+ publicationStatus
105
+ }
106
+ }
107
+ `);
108
+ return data.developerApps;
109
+ }
110
+ async findAppByClientId(clientId) {
111
+ const apps = await this.listDeveloperApps();
112
+ return apps.find((a) => a.clientId === clientId) ?? null;
113
+ }
114
+ /** Same as findAppByClientId but pulls review-status fields. Requires the
115
+ * `reviewNotes` column on App (added 2026-06-04). */
116
+ async findAppWithReviewByClientId(clientId) {
117
+ const data = await this.request(`
118
+ query OdevaCliListDeveloperAppsWithReview {
119
+ developerApps {
120
+ id name slug clientId homepageUrl installUrl webhookUrl
121
+ requestedScopes publicationStatus reviewNotes verifiedAt updatedAt
122
+ }
123
+ }
124
+ `);
125
+ return data.developerApps.find((a) => a.clientId === clientId) ?? null;
126
+ }
127
+ async submitDeveloperApp(id) {
128
+ const data = await this.request(
129
+ `mutation OdevaCliSubmitDeveloperApp($id: ID!) {
130
+ submitDeveloperApp(id: $id) {
131
+ app {
132
+ id name slug clientId homepageUrl installUrl webhookUrl
133
+ requestedScopes publicationStatus reviewNotes verifiedAt updatedAt
134
+ }
135
+ errors
136
+ }
137
+ }`,
138
+ { id }
139
+ );
140
+ return data.submitDeveloperApp;
141
+ }
142
+ async upsertDeveloperApp(input) {
143
+ const data = await this.request(
144
+ `
145
+ mutation OdevaCliUpsertDeveloperApp(
146
+ $id: ID
147
+ $name: String!
148
+ $slug: String!
149
+ $authorName: String
150
+ $description: String
151
+ $homepageUrl: String
152
+ $installUrl: String
153
+ $privacyUrl: String
154
+ $requestedScopes: [String!]
155
+ $webhookUrl: String
156
+ ) {
157
+ upsertDeveloperApp(
158
+ id: $id
159
+ name: $name
160
+ slug: $slug
161
+ authorName: $authorName
162
+ description: $description
163
+ homepageUrl: $homepageUrl
164
+ installUrl: $installUrl
165
+ privacyUrl: $privacyUrl
166
+ requestedScopes: $requestedScopes
167
+ webhookUrl: $webhookUrl
168
+ ) {
169
+ app {
170
+ id
171
+ name
172
+ slug
173
+ clientId
174
+ homepageUrl
175
+ installUrl
176
+ webhookUrl
177
+ requestedScopes
178
+ publicationStatus
179
+ }
180
+ rawClientSecret
181
+ errors
182
+ }
183
+ }
184
+ `,
185
+ input
186
+ );
187
+ const payload = data.upsertDeveloperApp;
188
+ if (payload.errors?.length) {
189
+ throw new ApiError(`Could not save app: ${payload.errors.join(", ")}`);
190
+ }
191
+ return { app: payload.app, rawClientSecret: payload.rawClientSecret };
192
+ }
193
+ async rotateDeveloperAppSecret(id) {
194
+ const data = await this.request(
195
+ `mutation OdevaCliRotateDeveloperAppSecret($id: ID!) {
196
+ rotateDeveloperAppSecret(id: $id) {
197
+ app {
198
+ id name slug clientId homepageUrl installUrl webhookUrl
199
+ requestedScopes publicationStatus
200
+ }
201
+ rawClientSecret
202
+ errors
203
+ }
204
+ }`,
205
+ { id }
206
+ );
207
+ const payload = data.rotateDeveloperAppSecret;
208
+ if (payload.errors?.length || !payload.app || !payload.rawClientSecret) {
209
+ throw new ApiError(`Could not rotate secret: ${payload.errors.join(", ") || "no secret returned"}`);
210
+ }
211
+ return { app: payload.app, rawClientSecret: payload.rawClientSecret };
212
+ }
213
+ async webhookEventTypes() {
214
+ const data = await this.request(`
215
+ query OdevaCliWebhookEventTypes { webhookEventTypes }
216
+ `);
217
+ return data.webhookEventTypes;
218
+ }
219
+ async webhookSubscriptions() {
220
+ const data = await this.request(`
221
+ query OdevaCliWebhookSubscriptions {
222
+ webhookSubscriptions {
223
+ id
224
+ name
225
+ endpointUrl
226
+ eventTypes
227
+ status
228
+ }
229
+ }
230
+ `);
231
+ return data.webhookSubscriptions;
232
+ }
233
+ async createWebhookSubscription(input) {
234
+ const data = await this.request(
235
+ `
236
+ mutation OdevaCliCreateWebhookSubscription(
237
+ $name: String!
238
+ $endpointUrl: String!
239
+ $eventTypes: [String!]!
240
+ $appInstallationId: ID
241
+ ) {
242
+ createWebhookSubscription(
243
+ name: $name
244
+ endpointUrl: $endpointUrl
245
+ eventTypes: $eventTypes
246
+ appInstallationId: $appInstallationId
247
+ ) {
248
+ webhookSubscription { id name endpointUrl eventTypes status }
249
+ webhookSecret
250
+ errors
251
+ }
252
+ }
253
+ `,
254
+ input
255
+ );
256
+ const payload = data.createWebhookSubscription;
257
+ if (payload.errors?.length) {
258
+ throw new ApiError(`Could not create webhook subscription: ${payload.errors.join(", ")}`);
259
+ }
260
+ return { subscription: payload.webhookSubscription, webhookSecret: payload.webhookSecret };
261
+ }
262
+ async deleteWebhookSubscription(id) {
263
+ await this.request(
264
+ `mutation OdevaCliDeleteWebhookSubscription($id: ID!) {
265
+ deleteWebhookSubscription(id: $id) { webhookSubscription { id } }
266
+ }`,
267
+ { id }
268
+ );
269
+ }
270
+ }
271
+ function userAgent() {
272
+ return `odeva-cli/0.0.1 (${process.platform}; ${process.arch}; node-${process.versions.node})`;
273
+ }
274
+ export {
275
+ OdevaApi
276
+ };
@@ -0,0 +1,39 @@
1
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ const APP_ENV_FILE = ".odeva.env";
4
+ function loadAppEnv(appConfigPath) {
5
+ const envPath = join(dirname(appConfigPath), APP_ENV_FILE);
6
+ if (!existsSync(envPath)) return {};
7
+ const raw = readFileSync(envPath, "utf8");
8
+ const result = {};
9
+ for (const line of raw.split("\n")) {
10
+ const trimmed = line.trim();
11
+ if (!trimmed || trimmed.startsWith("#")) continue;
12
+ const eq = trimmed.indexOf("=");
13
+ if (eq < 0) continue;
14
+ const key = trimmed.slice(0, eq).trim();
15
+ const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
16
+ if (key) result[key] = value;
17
+ }
18
+ return result;
19
+ }
20
+ function saveAppEnv(appConfigPath, values) {
21
+ const envPath = join(dirname(appConfigPath), APP_ENV_FILE);
22
+ const merged = { ...loadAppEnv(appConfigPath), ...values };
23
+ const body = [
24
+ "# Auto-managed by `odeva` \u2014 do not commit. Add `.odeva.env` to .gitignore.",
25
+ ...Object.entries(merged).map(([k, v]) => `${k}=${v}`),
26
+ ""
27
+ ].join("\n");
28
+ writeFileSync(envPath, body, { mode: 384 });
29
+ try {
30
+ chmodSync(envPath, 384);
31
+ } catch {
32
+ }
33
+ return envPath;
34
+ }
35
+ export {
36
+ APP_ENV_FILE,
37
+ loadAppEnv,
38
+ saveAppEnv
39
+ };
@@ -0,0 +1,93 @@
1
+ import { spawn } from "node:child_process";
2
+ import { spawnSync } from "node:child_process";
3
+ import { CliError } from "./errors.js";
4
+ const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
5
+ function isCloudflaredInstalled() {
6
+ const result = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
7
+ return result.status === 0;
8
+ }
9
+ function startQuickTunnel(localPort, options = {}) {
10
+ if (!isCloudflaredInstalled()) {
11
+ throw new CliError("`cloudflared` is not installed or not on PATH.", {
12
+ hint: "Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ (e.g. `brew install cloudflared` on macOS, `apt install cloudflared` on Debian/Ubuntu)."
13
+ });
14
+ }
15
+ return new Promise((resolve, reject) => {
16
+ const proc = spawn(
17
+ "cloudflared",
18
+ ["tunnel", "--no-autoupdate", "--url", `http://localhost:${localPort}`],
19
+ { stdio: ["ignore", "pipe", "pipe"] }
20
+ );
21
+ let resolved = false;
22
+ const timeoutMs = options.timeoutMs ?? 3e4;
23
+ const timer = setTimeout(() => {
24
+ if (resolved) return;
25
+ cleanup();
26
+ reject(
27
+ 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://localhost:" + localPort + "` manually."
29
+ })
30
+ );
31
+ }, timeoutMs);
32
+ const onData = (chunk) => {
33
+ const text = chunk.toString("utf8");
34
+ const match = TRYCLOUDFLARE_RE.exec(text);
35
+ if (match && !resolved) {
36
+ resolved = true;
37
+ clearTimeout(timer);
38
+ resolve({
39
+ url: match[0],
40
+ process: proc,
41
+ stop: () => stopProcess(proc)
42
+ });
43
+ }
44
+ };
45
+ proc.stdout?.on("data", onData);
46
+ proc.stderr?.on("data", onData);
47
+ proc.on("error", (err) => {
48
+ if (resolved) return;
49
+ clearTimeout(timer);
50
+ reject(new CliError(`Failed to start cloudflared: ${err.message}`));
51
+ });
52
+ proc.on("exit", (code) => {
53
+ if (resolved) return;
54
+ clearTimeout(timer);
55
+ reject(new CliError(`cloudflared exited with code ${code} before producing a URL.`));
56
+ });
57
+ function cleanup() {
58
+ try {
59
+ proc.kill("SIGTERM");
60
+ } catch {
61
+ }
62
+ }
63
+ });
64
+ }
65
+ function stopProcess(proc) {
66
+ return new Promise((resolve) => {
67
+ if (proc.exitCode !== null) return resolve();
68
+ proc.once("exit", () => resolve());
69
+ try {
70
+ proc.kill("SIGTERM");
71
+ } catch {
72
+ resolve();
73
+ return;
74
+ }
75
+ setTimeout(() => {
76
+ if (proc.exitCode === null) {
77
+ try {
78
+ proc.kill("SIGKILL");
79
+ } catch {
80
+ }
81
+ }
82
+ }, 2e3);
83
+ });
84
+ }
85
+ function parseTunnelUrl(text) {
86
+ const match = TRYCLOUDFLARE_RE.exec(text);
87
+ return match ? match[0] : null;
88
+ }
89
+ export {
90
+ isCloudflaredInstalled,
91
+ parseTunnelUrl,
92
+ startQuickTunnel
93
+ };
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { parse, stringify } from "smol-toml";
4
+ import { APP_CONFIG_FILE } from "./paths.js";
5
+ import { AppConfigNotFoundError, CliError } from "./errors.js";
6
+ function findAppConfigPath(startDir = process.cwd()) {
7
+ let dir = resolve(startDir);
8
+ while (true) {
9
+ const candidate = join(dir, APP_CONFIG_FILE);
10
+ if (existsSync(candidate)) return candidate;
11
+ const parent = dirname(dir);
12
+ if (parent === dir) return null;
13
+ dir = parent;
14
+ }
15
+ }
16
+ function loadAppConfig(startDir = process.cwd()) {
17
+ const path = findAppConfigPath(startDir);
18
+ if (!path) throw new AppConfigNotFoundError();
19
+ const raw = readFileSync(path, "utf8");
20
+ const parsed = parse(raw);
21
+ validateConfig(parsed, path);
22
+ return { config: parsed, path, root: dirname(path) };
23
+ }
24
+ function saveAppConfig(loaded) {
25
+ validateConfig(loaded.config, loaded.path);
26
+ writeFileSync(loaded.path, stringify(loaded.config) + "\n", "utf8");
27
+ }
28
+ function writeAppConfigAt(path, config) {
29
+ validateConfig(config, path);
30
+ writeFileSync(path, stringify(config) + "\n", "utf8");
31
+ }
32
+ function validateConfig(config, path) {
33
+ if (!config.name || typeof config.name !== "string") {
34
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'name'.`, { hint: `Edit ${path}` });
35
+ }
36
+ if (!config.slug || typeof config.slug !== "string") {
37
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'slug'.`, { hint: `Edit ${path}` });
38
+ }
39
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(config.slug)) {
40
+ throw new CliError(
41
+ `Invalid 'slug' in ${APP_CONFIG_FILE}: '${config.slug}'. Use lowercase letters, digits, and hyphens.`,
42
+ { hint: `Edit ${path}` }
43
+ );
44
+ }
45
+ }
46
+ export {
47
+ findAppConfigPath,
48
+ loadAppConfig,
49
+ saveAppConfig,
50
+ writeAppConfigAt
51
+ };
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { CONFIG_DIR, CREDENTIALS_PATH, DEFAULT_API_URL } from "./paths.js";
4
+ import { NotAuthenticatedError } from "./errors.js";
5
+ function loadCredentials() {
6
+ if (!existsSync(CREDENTIALS_PATH)) return null;
7
+ try {
8
+ const raw = readFileSync(CREDENTIALS_PATH, "utf8");
9
+ const parsed = JSON.parse(raw);
10
+ if (!parsed.token) return null;
11
+ return parsed;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ function requireCredentials() {
17
+ const creds = loadCredentials();
18
+ if (!creds) throw new NotAuthenticatedError();
19
+ return creds;
20
+ }
21
+ function saveCredentials(partial) {
22
+ if (!existsSync(CONFIG_DIR)) {
23
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
24
+ }
25
+ const existing = loadCredentials();
26
+ const creds = {
27
+ apiUrl: DEFAULT_API_URL,
28
+ ...existing,
29
+ ...partial,
30
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
31
+ };
32
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 384 });
33
+ try {
34
+ chmodSync(CREDENTIALS_PATH, 384);
35
+ chmodSync(dirname(CREDENTIALS_PATH), 448);
36
+ } catch {
37
+ }
38
+ return creds;
39
+ }
40
+ function clearCredentials() {
41
+ if (!existsSync(CREDENTIALS_PATH)) return false;
42
+ unlinkSync(CREDENTIALS_PATH);
43
+ return true;
44
+ }
45
+ export {
46
+ clearCredentials,
47
+ loadCredentials,
48
+ requireCredentials,
49
+ saveCredentials
50
+ };
@@ -0,0 +1,115 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
5
+ const created = [];
6
+ for (const sub of subscriptions) {
7
+ const fullUrl = joinUrl(tunnelUrl, sub.uri);
8
+ const { subscription, webhookSecret } = await api.createWebhookSubscription({
9
+ name: `${appName} (dev) \u2014 ${sub.topic}`,
10
+ endpointUrl: fullUrl,
11
+ eventTypes: [sub.topic]
12
+ });
13
+ created.push({ config: sub, subscription, secret: webhookSecret, fullUrl });
14
+ }
15
+ return created;
16
+ }
17
+ async function cleanupSubscriptions(api, registered) {
18
+ for (const reg of registered) {
19
+ try {
20
+ await api.deleteWebhookSubscription(reg.subscription.id);
21
+ } catch {
22
+ }
23
+ }
24
+ }
25
+ function spawnDevServer(opts) {
26
+ const command = opts.config.build?.dev ?? "bun run dev";
27
+ const proc = spawn(command, {
28
+ cwd: opts.cwd,
29
+ shell: true,
30
+ stdio: "inherit",
31
+ env: {
32
+ ...process.env,
33
+ ...opts.env
34
+ }
35
+ });
36
+ return {
37
+ process: proc,
38
+ stop: () => stopProcess(proc),
39
+ waitForExit: () => new Promise((resolve) => {
40
+ if (proc.exitCode !== null) return resolve(proc.exitCode);
41
+ proc.once("exit", (code) => resolve(code));
42
+ })
43
+ };
44
+ }
45
+ function writeDevEnvFile(cwd, registered) {
46
+ if (registered.length === 0) return null;
47
+ const path = join(cwd, ".env.odeva.local");
48
+ const lines = [
49
+ "# Auto-generated by `odeva app dev`. Do not commit.",
50
+ "# Reload your dev server to pick up these values."
51
+ ];
52
+ const primary = registered[0];
53
+ lines.push(`ODEVA_WEBHOOK_SECRET=${primary.secret}`);
54
+ for (const reg of registered) {
55
+ lines.push(`# ${reg.config.topic} \u2192 ${reg.fullUrl}`);
56
+ lines.push(`ODEVA_WEBHOOK_SECRET__${secretEnvKey(reg.config.topic)}=${reg.secret}`);
57
+ }
58
+ writeFileSync(path, lines.join("\n") + "\n", { mode: 384 });
59
+ return path;
60
+ }
61
+ function secretEnvKey(topic) {
62
+ return topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
63
+ }
64
+ function joinUrl(base, path) {
65
+ const trimmedBase = base.replace(/\/$/, "");
66
+ const trimmedPath = path.startsWith("/") ? path : `/${path}`;
67
+ return `${trimmedBase}${trimmedPath}`;
68
+ }
69
+ function stopProcess(proc) {
70
+ return new Promise((resolve) => {
71
+ if (proc.exitCode !== null) return resolve();
72
+ proc.once("exit", () => resolve());
73
+ try {
74
+ proc.kill("SIGTERM");
75
+ } catch {
76
+ resolve();
77
+ return;
78
+ }
79
+ setTimeout(() => {
80
+ if (proc.exitCode === null) {
81
+ try {
82
+ proc.kill("SIGKILL");
83
+ } catch {
84
+ }
85
+ }
86
+ }, 2e3);
87
+ });
88
+ }
89
+ function preflightChecks(cwd) {
90
+ const warnings = [];
91
+ if (!existsSync(join(cwd, "node_modules"))) {
92
+ warnings.push("node_modules/ not found \u2014 run `bun install` first.");
93
+ }
94
+ return { warnings };
95
+ }
96
+ function readEnvFile(path) {
97
+ if (!existsSync(path)) return {};
98
+ const out = {};
99
+ for (const line of readFileSync(path, "utf8").split("\n")) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed || trimmed.startsWith("#")) continue;
102
+ const eq = trimmed.indexOf("=");
103
+ if (eq === -1) continue;
104
+ out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
105
+ }
106
+ return out;
107
+ }
108
+ export {
109
+ cleanupSubscriptions,
110
+ preflightChecks,
111
+ readEnvFile,
112
+ registerWebhookSubscriptions,
113
+ spawnDevServer,
114
+ writeDevEnvFile
115
+ };
@@ -0,0 +1,41 @@
1
+ class CliError extends Error {
2
+ exitCode;
3
+ hint;
4
+ constructor(message, options = {}) {
5
+ super(message);
6
+ this.name = "CliError";
7
+ this.exitCode = options.exitCode ?? 1;
8
+ this.hint = options.hint;
9
+ }
10
+ }
11
+ class NotAuthenticatedError extends CliError {
12
+ constructor() {
13
+ super("Not authenticated.", {
14
+ exitCode: 1,
15
+ hint: "Run `odeva auth login` first."
16
+ });
17
+ this.name = "NotAuthenticatedError";
18
+ }
19
+ }
20
+ class ApiError extends CliError {
21
+ graphqlErrors;
22
+ constructor(message, options = {}) {
23
+ super(message, { hint: options.hint });
24
+ this.name = "ApiError";
25
+ this.graphqlErrors = options.graphqlErrors;
26
+ }
27
+ }
28
+ class AppConfigNotFoundError extends CliError {
29
+ constructor() {
30
+ super("No odeva.app.toml found in this directory or any parent.", {
31
+ hint: "Run `odeva app init` to scaffold a new app, or cd into an existing one."
32
+ });
33
+ this.name = "AppConfigNotFoundError";
34
+ }
35
+ }
36
+ export {
37
+ ApiError,
38
+ AppConfigNotFoundError,
39
+ CliError,
40
+ NotAuthenticatedError
41
+ };
@@ -0,0 +1,29 @@
1
+ import { spawn } from "node:child_process";
2
+ function openUrl(url) {
3
+ const platform = process.platform;
4
+ let command;
5
+ let args;
6
+ if (platform === "darwin") {
7
+ command = "open";
8
+ args = [url];
9
+ } else if (platform === "win32") {
10
+ command = "cmd";
11
+ args = ["/c", "start", "", url];
12
+ } else {
13
+ command = "xdg-open";
14
+ args = [url];
15
+ }
16
+ try {
17
+ const child = spawn(command, args, {
18
+ detached: true,
19
+ stdio: "ignore"
20
+ });
21
+ child.unref();
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+ export {
28
+ openUrl
29
+ };