@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Odeva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @odeva/cli
2
+
3
+ > Build apps on the Odeva booking platform — scaffold, develop, deploy.
4
+
5
+ The `odeva` CLI is the developer toolkit for the [Odeva](https://odeva.app) booking platform. It is to Odeva what `shopify` is to Shopify and `wrangler` is to Cloudflare: scaffold a project, run it locally with a public tunnel and live webhook delivery, then ship it.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install -g @odeva/cli
11
+ # or
12
+ bun add -g @odeva/cli
13
+ ```
14
+
15
+ You'll also need `cloudflared` on your PATH for the dev tunnel:
16
+
17
+ ```sh
18
+ # macOS
19
+ brew install cloudflared
20
+
21
+ # Debian/Ubuntu
22
+ sudo apt install cloudflared
23
+
24
+ # Other platforms: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ ```sh
30
+ odeva auth login # paste a personal access token
31
+ odeva app init my-app # scaffold a new app (default template: hono-bun)
32
+ cd my-app
33
+ bun install
34
+ odeva app config link # register the app on Odeva, write client_id to toml
35
+ odeva app dev # local server + tunnel + webhook auto-registration
36
+ ```
37
+
38
+ In another terminal:
39
+
40
+ ```sh
41
+ odeva webhook trigger reservation.created
42
+ ```
43
+
44
+ You should see the fixture payload arrive in your dev server's logs, signed and ready to handle.
45
+
46
+ ## How `odeva app dev` works
47
+
48
+ ```
49
+ ┌─────────────────────────────────────────────┐
50
+ │ Odeva platform │
51
+ │ (creates webhook subscriptions, signs │
52
+ │ payloads, delivers events) │
53
+ └────────────────────┬────────────────────────┘
54
+ │ POST https://<id>.trycloudflare.com/webhooks/...
55
+
56
+ ┌──────────────────────────────────┐
57
+ │ cloudflared quick tunnel │
58
+ │ (started by `odeva app dev`) │
59
+ └────────────────┬─────────────────┘
60
+ │ http://localhost:3000
61
+
62
+ ┌──────────────────────────────────┐
63
+ │ Your Hono/Bun/Express app │
64
+ │ Verify signature with the │
65
+ │ ODEVA_WEBHOOK_SECRET injected │
66
+ │ into the dev process. │
67
+ └──────────────────────────────────┘
68
+ ```
69
+
70
+ On Ctrl-C, the CLI tears down the tunnel **and deletes the webhook subscriptions it created**, so your production endpoints aren't polluted with dev URLs.
71
+
72
+ ## Commands
73
+
74
+ | Command | What it does |
75
+ | --- | --- |
76
+ | `odeva auth login` | Authenticate the CLI with a personal access token |
77
+ | `odeva auth logout` | Remove stored credentials from this machine |
78
+ | `odeva auth whoami` | Show the currently authenticated session |
79
+ | `odeva app init [name]` | Scaffold a new app from a template (default: `hono-bun`) |
80
+ | `odeva app config link` | Register the local app on Odeva, write `client_id` back to toml |
81
+ | `odeva app dev` | Run the app locally with a public tunnel + webhook auto-registration |
82
+ | `odeva webhook list` | List active subscriptions on the current org (`--available` shows event types) |
83
+ | `odeva webhook trigger <event>` | Fire a signed sample payload at your local handler |
84
+
85
+ Run `odeva help <command>` for full flags on any command.
86
+
87
+ ## Config: `odeva.app.toml`
88
+
89
+ Each Odeva app has an `odeva.app.toml` at its root. Example:
90
+
91
+ ```toml
92
+ name = "Cabin Manager"
93
+ slug = "cabin-manager"
94
+ client_id = "app_..." # written by `odeva app config link`
95
+ description = "A booking add-on for Foo Holiday Park."
96
+
97
+ [build]
98
+ dev = "bun run dev"
99
+ port = 3000
100
+
101
+ [access_scopes]
102
+ scopes = ["reservations:read", "reservations:write"]
103
+
104
+ [webhooks]
105
+ api_version = "2026-01"
106
+
107
+ [[webhooks.subscriptions]]
108
+ topic = "reservation.created"
109
+ uri = "/webhooks/reservation.created"
110
+
111
+ [[webhooks.subscriptions]]
112
+ topic = "payment.succeeded"
113
+ uri = "/webhooks/payment.succeeded"
114
+ ```
115
+
116
+ This file is the source of truth for app config. `odeva app config link` pushes it to the platform; `odeva app dev` reads webhook subscriptions from it to wire up the tunnel.
117
+
118
+ ## Templates
119
+
120
+ Bundled templates live under `templates/` in this repo:
121
+
122
+ - **`hono-bun`** (default) — Hono on Bun, runtime-agnostic. Deploys later to Node, Bun, Cloudflare Workers, or Deno without rewrites.
123
+
124
+ More templates (Next.js, Express, Fresh, WordPress) are on the roadmap — drop a directory into `templates/` and it's available via `odeva app init --template <name>`.
125
+
126
+ ## Authentication
127
+
128
+ For now, the CLI authenticates with a **personal access token** (PAT). Generate one from the Odeva admin panel and paste it into `odeva auth login`. The token is stored at `~/.config/odeva/credentials.json` with mode `0600`.
129
+
130
+ Browser-based OAuth (device-code flow) lands once the developer dashboard ships.
131
+
132
+ ## Status
133
+
134
+ Pre-1.0. The command surface and `odeva.app.toml` schema are stabilizing. Expect breaking changes between minor versions until 1.0.
135
+
136
+ ## Development
137
+
138
+ ```sh
139
+ git clone https://codeberg.org/odeva/odeva-cli
140
+ cd odeva-cli
141
+ bun install
142
+ bun run test
143
+ bun run typecheck
144
+ bun run build
145
+ node ./bin/run.js --help
146
+ ```
147
+
148
+ ## License
149
+
150
+ MIT — see [LICENSE](./LICENSE).
package/bin/dev.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // Dev entrypoint — runs TS sources directly via tsx/bun without a build step.
3
+ import { execute } from "@oclif/core";
4
+
5
+ process.env.NODE_ENV = "development";
6
+
7
+ await execute({ development: true, dir: import.meta.url });
package/bin/run.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { execute } from "@oclif/core";
3
+
4
+ await execute({ dir: import.meta.url });
@@ -0,0 +1,93 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import * as p from "@clack/prompts";
3
+ import { OdevaApi } from "../../../lib/api.js";
4
+ import { loadAppConfig, saveAppConfig } from "../../../lib/config.js";
5
+ import { saveAppEnv } from "../../../lib/app-env.js";
6
+ import { withErrorHandling } from "../../../lib/run.js";
7
+ import { ui } from "../../../lib/ui.js";
8
+ class AppConfigLink extends Command {
9
+ static description = "Register the local app on Odeva and sync its client_id back to odeva.app.toml";
10
+ static examples = ["$ odeva app config link"];
11
+ static flags = {
12
+ "dry-run": Flags.boolean({
13
+ description: "Show what would be sent to the API without making changes",
14
+ default: false
15
+ })
16
+ };
17
+ async run() {
18
+ const { flags } = await this.parse(AppConfigLink);
19
+ await withErrorHandling(this, async () => {
20
+ const loaded = loadAppConfig();
21
+ p.intro(ui.brand("odeva app config link"));
22
+ p.note(
23
+ [
24
+ `${ui.dim("name:")} ${loaded.config.name}`,
25
+ `${ui.dim("slug:")} ${loaded.config.slug}`,
26
+ `${ui.dim("client_id:")} ${loaded.config.client_id ?? ui.dim("(new)")}`,
27
+ `${ui.dim("homepage:")} ${loaded.config.homepage_url ?? ui.dim("(none)")}`,
28
+ `${ui.dim("scopes:")} ${(loaded.config.access_scopes?.scopes ?? []).join(", ") || ui.dim("(none)")}`
29
+ ].join("\n"),
30
+ "Will register this app"
31
+ );
32
+ if (flags["dry-run"]) {
33
+ p.outro(ui.warn("Dry run \u2014 no API call made."));
34
+ return;
35
+ }
36
+ const api = new OdevaApi();
37
+ const spinner = p.spinner();
38
+ spinner.start("Looking up existing app");
39
+ let existingId;
40
+ if (loaded.config.client_id) {
41
+ const apps = await api.listDeveloperApps();
42
+ const match = apps.find((a) => a.clientId === loaded.config.client_id);
43
+ if (match) existingId = match.id;
44
+ }
45
+ spinner.message(existingId ? "Updating app on Odeva" : "Creating app on Odeva");
46
+ const { app, rawClientSecret } = await api.upsertDeveloperApp({
47
+ id: existingId,
48
+ name: loaded.config.name,
49
+ slug: loaded.config.slug,
50
+ description: loaded.config.description,
51
+ homepageUrl: loaded.config.homepage_url,
52
+ installUrl: loaded.config.install_url,
53
+ privacyUrl: loaded.config.privacy_url,
54
+ requestedScopes: loaded.config.access_scopes?.scopes
55
+ // `webhookUrl` is the install-time URL recorded on the App; per-event
56
+ // subscriptions are managed separately by `odeva app dev` against the
57
+ // tunnel URL.
58
+ });
59
+ spinner.stop(`${existingId ? "Updated" : "Registered"} app ${ui.code(app.slug)} (client_id ${ui.code(app.clientId)})`);
60
+ loaded.config.client_id = app.clientId;
61
+ saveAppConfig(loaded);
62
+ let envPath = null;
63
+ if (rawClientSecret) {
64
+ envPath = saveAppEnv(loaded.path, {
65
+ ODEVA_APP_CLIENT_ID: app.clientId,
66
+ ODEVA_APP_CLIENT_SECRET: rawClientSecret
67
+ });
68
+ p.note(
69
+ [
70
+ "This is the only time the secret is shown.",
71
+ "",
72
+ ` ${ui.code(rawClientSecret)}`,
73
+ "",
74
+ `Saved to ${ui.code(envPath)} (mode 0600). Keep it out of git.`,
75
+ "If you ever lose it, run `odeva app config rotate-secret` to mint a new one."
76
+ ].join("\n"),
77
+ "client_secret"
78
+ );
79
+ }
80
+ p.outro(
81
+ [
82
+ ui.ok(`Wrote client_id to ${ui.code(loaded.path)}`),
83
+ envPath ? ui.ok(`Wrote client_secret to ${ui.code(envPath)}`) : null,
84
+ "",
85
+ ` Next: ${ui.code("odeva app dev")} to run the app locally with a public tunnel.`
86
+ ].filter((line) => line !== null).join("\n")
87
+ );
88
+ });
89
+ }
90
+ }
91
+ export {
92
+ AppConfigLink as default
93
+ };
@@ -0,0 +1,74 @@
1
+ import { Command } from "@oclif/core";
2
+ import * as p from "@clack/prompts";
3
+ import { OdevaApi } from "../../../lib/api.js";
4
+ import { loadAppConfig } from "../../../lib/config.js";
5
+ import { saveAppEnv } from "../../../lib/app-env.js";
6
+ import { CliError } from "../../../lib/errors.js";
7
+ import { withErrorHandling } from "../../../lib/run.js";
8
+ import { ui } from "../../../lib/ui.js";
9
+ class AppConfigRotateSecret extends Command {
10
+ static description = "Mint a new client_secret for the app and overwrite .odeva.env. The previous secret stops working immediately.";
11
+ static examples = ["$ odeva app config rotate-secret"];
12
+ async run() {
13
+ await this.parse(AppConfigRotateSecret);
14
+ await withErrorHandling(this, async () => {
15
+ const loaded = loadAppConfig();
16
+ if (!loaded.config.client_id) {
17
+ throw new CliError("App has not been registered yet.", {
18
+ hint: "Run `odeva app config link` first."
19
+ });
20
+ }
21
+ p.intro(ui.brand("odeva app config rotate-secret"));
22
+ p.note(
23
+ [
24
+ `client_id: ${ui.code(loaded.config.client_id)}`,
25
+ "",
26
+ "Rotating invalidates the previous secret immediately.",
27
+ "Any deployed app instance still holding the old secret will start failing the install handshake."
28
+ ].join("\n"),
29
+ "About to rotate"
30
+ );
31
+ const confirm = await p.confirm({
32
+ message: "Rotate now?",
33
+ initialValue: false
34
+ });
35
+ if (p.isCancel(confirm) || !confirm) {
36
+ this.log(ui.warn("Cancelled."));
37
+ return;
38
+ }
39
+ const api = new OdevaApi();
40
+ const lookup = p.spinner();
41
+ lookup.start("Looking up app");
42
+ const existing = await api.findAppByClientId(loaded.config.client_id);
43
+ if (!existing) {
44
+ lookup.stop("Not found.");
45
+ throw new CliError(
46
+ `No app with client_id ${loaded.config.client_id} is owned by your active organization.`,
47
+ { hint: "If you registered against a different org, run `odeva auth select-org`." }
48
+ );
49
+ }
50
+ lookup.message("Rotating secret");
51
+ const { rawClientSecret } = await api.rotateDeveloperAppSecret(existing.id);
52
+ lookup.stop("Rotated.");
53
+ const envPath = saveAppEnv(loaded.path, {
54
+ ODEVA_APP_CLIENT_ID: existing.clientId,
55
+ ODEVA_APP_CLIENT_SECRET: rawClientSecret
56
+ });
57
+ p.note(
58
+ [
59
+ "This is the only time the secret is shown.",
60
+ "",
61
+ ` ${ui.code(rawClientSecret)}`,
62
+ "",
63
+ `Saved to ${ui.code(envPath)} (mode 0600).`,
64
+ "Redeploy any environments that hold the old secret."
65
+ ].join("\n"),
66
+ "new client_secret"
67
+ );
68
+ p.outro(ui.ok("Done."));
69
+ });
70
+ }
71
+ }
72
+ export {
73
+ AppConfigRotateSecret as default
74
+ };
@@ -0,0 +1,127 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import * as p from "@clack/prompts";
3
+ import { OdevaApi } from "../../lib/api.js";
4
+ import { loadAppConfig } from "../../lib/config.js";
5
+ import { loadAppEnv } from "../../lib/app-env.js";
6
+ import { startQuickTunnel } from "../../lib/cloudflared.js";
7
+ import {
8
+ cleanupSubscriptions,
9
+ preflightChecks,
10
+ registerWebhookSubscriptions,
11
+ spawnDevServer,
12
+ writeDevEnvFile
13
+ } from "../../lib/dev-runner.js";
14
+ import { CliError } from "../../lib/errors.js";
15
+ import { withErrorHandling } from "../../lib/run.js";
16
+ import { ui } from "../../lib/ui.js";
17
+ class AppDev extends Command {
18
+ static description = "Run the app locally with a public Cloudflare tunnel and auto-registered webhook subscriptions";
19
+ static flags = {
20
+ port: Flags.integer({
21
+ description: "Local port the app listens on (overrides odeva.app.toml [build].port)"
22
+ }),
23
+ "no-tunnel": Flags.boolean({
24
+ description: "Skip the tunnel and webhook registration; just run the dev server.",
25
+ default: false
26
+ })
27
+ };
28
+ async run() {
29
+ const { flags } = await this.parse(AppDev);
30
+ await withErrorHandling(this, async () => {
31
+ const loaded = loadAppConfig();
32
+ const appEnv = loadAppEnv(loaded.path);
33
+ const port = flags.port ?? loaded.config.build?.port ?? 3e3;
34
+ const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
35
+ for (const warning of preflightChecks(loaded.root).warnings) {
36
+ this.log(ui.warn(warning));
37
+ }
38
+ if (flags["no-tunnel"]) {
39
+ this.log(ui.warn("Skipping tunnel and webhook registration (--no-tunnel)"));
40
+ const server2 = spawnDevServer({
41
+ cwd: loaded.root,
42
+ config: loaded.config,
43
+ env: { ...appEnv, PORT: String(port) }
44
+ });
45
+ installShutdownHandler(async () => {
46
+ await server2.stop();
47
+ });
48
+ await server2.waitForExit();
49
+ return;
50
+ }
51
+ const api = new OdevaApi();
52
+ const tunnelSpinner = p.spinner();
53
+ tunnelSpinner.start("Starting Cloudflare tunnel");
54
+ const tunnel = await startQuickTunnel(port);
55
+ tunnelSpinner.stop(`Tunnel ready at ${ui.code(tunnel.url)}`);
56
+ let registered = [];
57
+ if (subscriptions.length > 0) {
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
+ }
75
+ this.log("");
76
+ this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
77
+ this.log(`${ui.bold("Local:")} http://localhost:${port}`);
78
+ for (const reg of registered) {
79
+ this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
80
+ }
81
+ this.log("");
82
+ this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
83
+ this.log("");
84
+ const server = spawnDevServer({
85
+ cwd: loaded.root,
86
+ config: loaded.config,
87
+ env: {
88
+ ...appEnv,
89
+ PORT: String(port),
90
+ ODEVA_TUNNEL_URL: tunnel.url,
91
+ ...registered[0] ? { ODEVA_WEBHOOK_SECRET: registered[0].secret } : {}
92
+ }
93
+ });
94
+ installShutdownHandler(async () => {
95
+ this.log("\n" + ui.dim("Cleaning up..."));
96
+ await Promise.allSettled([
97
+ server.stop(),
98
+ tunnel.stop(),
99
+ cleanupSubscriptions(api, registered)
100
+ ]);
101
+ this.log(ui.ok("Done."));
102
+ });
103
+ await server.waitForExit();
104
+ await Promise.allSettled([tunnel.stop(), cleanupSubscriptions(api, registered)]);
105
+ });
106
+ }
107
+ }
108
+ let shutdownInstalled = false;
109
+ function installShutdownHandler(handler) {
110
+ if (shutdownInstalled) return;
111
+ shutdownInstalled = true;
112
+ let cleaning = false;
113
+ const cleanup = async (signal) => {
114
+ if (cleaning) return;
115
+ cleaning = true;
116
+ try {
117
+ await handler();
118
+ } finally {
119
+ process.exit(signal === "SIGINT" ? 130 : 0);
120
+ }
121
+ };
122
+ process.on("SIGINT", () => void cleanup("SIGINT"));
123
+ process.on("SIGTERM", () => void cleanup("SIGTERM"));
124
+ }
125
+ export {
126
+ AppDev as default
127
+ };
@@ -0,0 +1,124 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import * as p from "@clack/prompts";
3
+ import { existsSync } from "node:fs";
4
+ import { basename, resolve } from "node:path";
5
+ import { CliError } from "../../lib/errors.js";
6
+ import { withErrorHandling } from "../../lib/run.js";
7
+ import { isValidSlug, slugify } from "../../lib/slug.js";
8
+ import { listTemplates, renderTemplate } from "../../lib/templates.js";
9
+ import { ui } from "../../lib/ui.js";
10
+ const DEFAULT_TEMPLATE = "hono-bun";
11
+ class AppInit extends Command {
12
+ static description = "Scaffold a new Odeva app";
13
+ static examples = [
14
+ "$ odeva app init",
15
+ "$ odeva app init my-cabin-app",
16
+ "$ odeva app init my-cabin-app --template hono-bun"
17
+ ];
18
+ static args = {
19
+ name: Args.string({
20
+ description: "Directory name and default slug for the new app",
21
+ required: false
22
+ })
23
+ };
24
+ static flags = {
25
+ template: Flags.string({
26
+ description: "Template to scaffold from",
27
+ default: DEFAULT_TEMPLATE
28
+ }),
29
+ "display-name": Flags.string({
30
+ description: "Human-readable app name (defaults to a Title Case of the directory name)"
31
+ }),
32
+ slug: Flags.string({
33
+ description: "App slug (defaults to the directory name)"
34
+ }),
35
+ yes: Flags.boolean({
36
+ char: "y",
37
+ description: "Skip interactive prompts and use defaults",
38
+ default: false
39
+ })
40
+ };
41
+ async run() {
42
+ const { args, flags } = await this.parse(AppInit);
43
+ await withErrorHandling(this, async () => {
44
+ p.intro(ui.brand("odeva app init"));
45
+ const directory = await this.resolveDirectory(args.name, flags.yes);
46
+ const slug = this.resolveSlug(flags.slug ?? slugify(basename(directory)));
47
+ const displayName = flags["display-name"] ?? toTitleCase(basename(directory));
48
+ const template = flags.template;
49
+ if (!listTemplates().includes(template)) {
50
+ throw new CliError(`Unknown template: '${template}'.`, {
51
+ hint: `Available templates: ${listTemplates().join(", ")}`
52
+ });
53
+ }
54
+ const spinner = p.spinner();
55
+ spinner.start(`Scaffolding ${ui.bold(displayName)} into ${ui.code(directory)}`);
56
+ const { filesWritten } = renderTemplate({
57
+ template,
58
+ destination: directory,
59
+ variables: {
60
+ name: displayName,
61
+ slug
62
+ }
63
+ });
64
+ spinner.stop(`Wrote ${filesWritten} file${filesWritten === 1 ? "" : "s"} from template '${template}'.`);
65
+ p.outro(
66
+ [
67
+ ui.ok(`Created ${ui.bold(displayName)}`),
68
+ "",
69
+ " Next steps:",
70
+ ` ${ui.code(`cd ${basename(directory)}`)}`,
71
+ ` ${ui.code("bun install")}`,
72
+ ` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
73
+ ` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
74
+ ].join("\n")
75
+ );
76
+ });
77
+ }
78
+ async resolveDirectory(nameArg, skipPrompts) {
79
+ let name = nameArg;
80
+ if (!name) {
81
+ if (skipPrompts) {
82
+ throw new CliError("A directory name is required when using --yes.", {
83
+ hint: "Run `odeva app init <name>` or omit --yes to prompt."
84
+ });
85
+ }
86
+ const result = await p.text({
87
+ message: "Directory name for your app",
88
+ placeholder: "my-odeva-app",
89
+ validate: (v) => {
90
+ const trimmed = v.trim();
91
+ if (!trimmed) return "Directory name cannot be empty";
92
+ if (trimmed.includes("/") || trimmed.includes("\\")) return "Use a single directory name, not a path";
93
+ return void 0;
94
+ }
95
+ });
96
+ if (p.isCancel(result)) {
97
+ throw new CliError("init cancelled.");
98
+ }
99
+ name = result.trim();
100
+ }
101
+ const directory = resolve(process.cwd(), name);
102
+ if (existsSync(directory)) {
103
+ throw new CliError(`Directory '${name}' already exists.`, {
104
+ hint: "Choose a different name or remove the existing directory."
105
+ });
106
+ }
107
+ return directory;
108
+ }
109
+ resolveSlug(candidate) {
110
+ const slug = candidate.trim();
111
+ if (!isValidSlug(slug)) {
112
+ throw new CliError(`Invalid slug '${slug}'.`, {
113
+ hint: "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager')."
114
+ });
115
+ }
116
+ return slug;
117
+ }
118
+ }
119
+ function toTitleCase(input) {
120
+ return input.split(/[-_\s]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1).toLowerCase()).join(" ");
121
+ }
122
+ export {
123
+ AppInit as default
124
+ };
@@ -0,0 +1,59 @@
1
+ import { Command } from "@oclif/core";
2
+ import { OdevaApi } from "../../lib/api.js";
3
+ import { loadAppConfig } from "../../lib/config.js";
4
+ import { CliError } from "../../lib/errors.js";
5
+ import { withErrorHandling } from "../../lib/run.js";
6
+ import { ui } from "../../lib/ui.js";
7
+ const STATUS_LABEL = {
8
+ draft: ui.dim("draft"),
9
+ submitted: "submitted",
10
+ published: ui.ok("published"),
11
+ rejected: ui.err("rejected")
12
+ };
13
+ class AppStatus extends Command {
14
+ static description = "Show the review status of the local app";
15
+ static examples = ["$ odeva app status"];
16
+ async run() {
17
+ await this.parse(AppStatus);
18
+ await withErrorHandling(this, async () => {
19
+ const loaded = loadAppConfig();
20
+ if (!loaded.config.client_id) {
21
+ throw new CliError("App has not been registered yet.", {
22
+ hint: "Run `odeva app config link` first."
23
+ });
24
+ }
25
+ const api = new OdevaApi();
26
+ const app = await api.findAppWithReviewByClientId(loaded.config.client_id);
27
+ if (!app) {
28
+ throw new CliError(
29
+ `No app with client_id ${loaded.config.client_id} is owned by your active organization.`,
30
+ { hint: "Run `odeva auth select-org` if you registered against a different org." }
31
+ );
32
+ }
33
+ const status = STATUS_LABEL[app.publicationStatus] ?? app.publicationStatus;
34
+ this.log(ui.bold(app.name));
35
+ this.log(` ${ui.dim("Slug:")} ${app.slug}`);
36
+ this.log(` ${ui.dim("Status:")} ${status}`);
37
+ if (app.verifiedAt) {
38
+ this.log(` ${ui.dim("Verified:")} ${app.verifiedAt}`);
39
+ }
40
+ if (app.updatedAt) {
41
+ this.log(` ${ui.dim("Updated:")} ${app.updatedAt}`);
42
+ }
43
+ if (app.reviewNotes) {
44
+ this.log("");
45
+ this.log(ui.bold("Reviewer notes:"));
46
+ for (const line of app.reviewNotes.split("\n")) {
47
+ this.log(` ${line}`);
48
+ }
49
+ }
50
+ if (app.publicationStatus === "rejected") {
51
+ this.log("");
52
+ this.log(ui.dim("Address the feedback above, then run `odeva app submit` to resubmit."));
53
+ }
54
+ });
55
+ }
56
+ }
57
+ export {
58
+ AppStatus as default
59
+ };