@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,85 @@
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 { CliError } from "../../lib/errors.js";
6
+ import { withErrorHandling } from "../../lib/run.js";
7
+ import { ui } from "../../lib/ui.js";
8
+ class AppSubmit extends Command {
9
+ static description = "Submit the local app to Odeva for marketplace review";
10
+ static examples = ["$ odeva app submit"];
11
+ async run() {
12
+ await this.parse(AppSubmit);
13
+ await withErrorHandling(this, async () => {
14
+ const loaded = loadAppConfig();
15
+ if (!loaded.config.client_id) {
16
+ throw new CliError("App has not been registered yet.", {
17
+ hint: "Run `odeva app config link` first."
18
+ });
19
+ }
20
+ const api = new OdevaApi();
21
+ p.intro(ui.brand("odeva app submit"));
22
+ const lookup = p.spinner();
23
+ lookup.start("Looking up app on Odeva");
24
+ const app = await api.findAppWithReviewByClientId(loaded.config.client_id);
25
+ if (!app) {
26
+ lookup.stop("Not found.");
27
+ throw new CliError(
28
+ `No app with client_id ${loaded.config.client_id} is owned by your active organization.`,
29
+ { hint: "If you registered against a different org, run `odeva auth select-org`." }
30
+ );
31
+ }
32
+ lookup.stop(`Found ${ui.code(app.slug)} (${app.publicationStatus})`);
33
+ if (app.publicationStatus === "submitted") {
34
+ this.log(ui.warn("This app is already under review. Run `odeva app status` to check progress."));
35
+ return;
36
+ }
37
+ if (app.publicationStatus === "published") {
38
+ this.log(ui.warn("This app is already published. Re-running submit would have no effect."));
39
+ return;
40
+ }
41
+ if (!app.installUrl) {
42
+ throw new CliError("install_url is missing on the server-side app record.", {
43
+ hint: "Set a non-empty install_url in odeva.app.toml (it must be a publicly reachable URL that handles the install handshake), then run `odeva app config link` and try again."
44
+ });
45
+ }
46
+ p.note(
47
+ [
48
+ `${ui.dim("name:")} ${app.name}`,
49
+ `${ui.dim("slug:")} ${app.slug}`,
50
+ `${ui.dim("install_url:")} ${app.installUrl}`,
51
+ `${ui.dim("scopes:")} ${app.requestedScopes.join(", ") || ui.dim("(none)")}`,
52
+ ``,
53
+ `Reviewers check that install + UI work and that scopes match the app's purpose.`,
54
+ `They don't review your code.`
55
+ ].join("\n"),
56
+ "Ready to submit"
57
+ );
58
+ const confirm = await p.confirm({
59
+ message: "Submit for review?",
60
+ initialValue: true
61
+ });
62
+ if (p.isCancel(confirm) || !confirm) {
63
+ this.log(ui.warn("Cancelled."));
64
+ return;
65
+ }
66
+ const submit = p.spinner();
67
+ submit.start("Submitting");
68
+ const result = await api.submitDeveloperApp(app.id);
69
+ if (result.errors.length > 0) {
70
+ submit.stop("Failed.");
71
+ throw new CliError(result.errors.join(", "));
72
+ }
73
+ submit.stop(`Submitted. Status: ${ui.code(result.app?.publicationStatus ?? "submitted")}`);
74
+ p.outro(
75
+ [
76
+ ui.ok("Awaiting review."),
77
+ ` Check progress: ${ui.code("odeva app status")}`
78
+ ].join("\n")
79
+ );
80
+ });
81
+ }
82
+ }
83
+ export {
84
+ AppSubmit as default
85
+ };
@@ -0,0 +1,136 @@
1
+ import { hostname as osHostname } from "node:os";
2
+ import { Command, Flags } from "@oclif/core";
3
+ import * as p from "@clack/prompts";
4
+ import { OdevaApi } from "../../lib/api.js";
5
+ import { saveCredentials } from "../../lib/credentials.js";
6
+ import { DEFAULT_API_URL } from "../../lib/paths.js";
7
+ import { ApiError, CliError } from "../../lib/errors.js";
8
+ import { openUrl } from "../../lib/open-url.js";
9
+ import { pickOrganization } from "../../lib/org-picker.js";
10
+ import { withErrorHandling } from "../../lib/run.js";
11
+ import { ui } from "../../lib/ui.js";
12
+ const POLL_TIMEOUT_MS = 15 * 60 * 1e3;
13
+ class AuthLogin extends Command {
14
+ static description = "Authenticate the CLI with your Odeva account";
15
+ static examples = [
16
+ "$ odeva auth login",
17
+ "$ odeva auth login --token odeva_cli_...",
18
+ "$ odeva auth login --api-url https://api.staging.odeva.app"
19
+ ];
20
+ static flags = {
21
+ token: Flags.string({
22
+ description: "CLI token (skips the browser flow). Mostly useful for CI.",
23
+ env: "ODEVA_TOKEN"
24
+ }),
25
+ "api-url": Flags.string({
26
+ description: "Override the Odeva API URL",
27
+ default: DEFAULT_API_URL
28
+ }),
29
+ "no-open": Flags.boolean({
30
+ description: "Print the approval URL instead of opening a browser",
31
+ default: false
32
+ })
33
+ };
34
+ async run() {
35
+ const { flags } = await this.parse(AuthLogin);
36
+ await withErrorHandling(this, async () => {
37
+ const apiUrl = flags["api-url"];
38
+ if (flags.token) {
39
+ await this.completeWithToken(flags.token.trim(), apiUrl);
40
+ return;
41
+ }
42
+ const api = new OdevaApi({ apiUrl });
43
+ p.intro(ui.brand("odeva auth login"));
44
+ const begin = await api.cliAuthBegin(osHostname());
45
+ p.note(
46
+ [
47
+ `Verify this code matches the one shown on the approval page:`,
48
+ ``,
49
+ ` ${ui.code(begin.userCode)}`,
50
+ ``,
51
+ `Opening: ${begin.verificationUri}`
52
+ ].join("\n"),
53
+ "Approval required"
54
+ );
55
+ if (!flags["no-open"]) {
56
+ const opened = openUrl(begin.verificationUri);
57
+ if (!opened) {
58
+ this.log(ui.warn(`Could not open a browser automatically. Visit the URL above to approve.`));
59
+ }
60
+ }
61
+ const token = await this.pollUntilResolved(api, begin.deviceCode, begin.interval);
62
+ await this.completeWithToken(token, apiUrl);
63
+ });
64
+ }
65
+ async pollUntilResolved(api, deviceCode, intervalSec) {
66
+ const spinner = p.spinner();
67
+ spinner.start("Waiting for approval in your browser\u2026");
68
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
69
+ try {
70
+ while (Date.now() < deadline) {
71
+ await sleep(intervalSec * 1e3);
72
+ const result = await api.cliAuthPoll(deviceCode);
73
+ switch (result.status) {
74
+ case "approved":
75
+ if (!result.token) {
76
+ throw new CliError("Approval succeeded but no token was issued.");
77
+ }
78
+ spinner.stop("Approved.");
79
+ return result.token;
80
+ case "denied":
81
+ spinner.stop("Denied.");
82
+ throw new CliError("Approval was denied in the browser.", {
83
+ hint: "Run `odeva auth login` again if this was a mistake."
84
+ });
85
+ case "expired":
86
+ spinner.stop("Expired.");
87
+ throw new CliError("The approval window expired before you confirmed.", {
88
+ hint: "Run `odeva auth login` again to start a new session."
89
+ });
90
+ case "not_found":
91
+ spinner.stop("Lost session.");
92
+ throw new CliError("The CLI session was not found server-side.");
93
+ case "pending":
94
+ continue;
95
+ }
96
+ }
97
+ spinner.stop("Timed out.");
98
+ throw new CliError("Gave up waiting for approval.", {
99
+ hint: "Run `odeva auth login` again."
100
+ });
101
+ } catch (err) {
102
+ if (!(err instanceof CliError)) spinner.stop("Failed.");
103
+ throw err;
104
+ }
105
+ }
106
+ async completeWithToken(token, apiUrl) {
107
+ if (!token) {
108
+ throw new CliError("Empty token.", { hint: "Run `odeva auth login` again." });
109
+ }
110
+ const api = new OdevaApi({ credentials: { token, apiUrl, savedAt: (/* @__PURE__ */ new Date()).toISOString() } });
111
+ try {
112
+ const { organization, email } = await pickOrganization(api);
113
+ saveCredentials({
114
+ token,
115
+ apiUrl,
116
+ organizationId: organization.id,
117
+ organizationSlug: organization.slug
118
+ });
119
+ const who = email ?? "unknown";
120
+ this.log(ui.ok(`Authenticated as ${ui.bold(who)} (${ui.dim(organization.name)}).`));
121
+ } catch (err) {
122
+ if (err instanceof ApiError) {
123
+ throw new CliError(`Token rejected by ${apiUrl}: ${err.message}`, {
124
+ hint: "Double-check the API URL and try again."
125
+ });
126
+ }
127
+ throw err;
128
+ }
129
+ }
130
+ }
131
+ function sleep(ms) {
132
+ return new Promise((resolve) => setTimeout(resolve, ms));
133
+ }
134
+ export {
135
+ AuthLogin as default
136
+ };
@@ -0,0 +1,20 @@
1
+ import { Command } from "@oclif/core";
2
+ import { clearCredentials } from "../../lib/credentials.js";
3
+ import { withErrorHandling } from "../../lib/run.js";
4
+ import { ui } from "../../lib/ui.js";
5
+ class AuthLogout extends Command {
6
+ static description = "Remove stored credentials from this machine";
7
+ async run() {
8
+ await withErrorHandling(this, async () => {
9
+ const removed = clearCredentials();
10
+ if (removed) {
11
+ this.log(ui.ok("Logged out."));
12
+ } else {
13
+ this.log(ui.dim("Already logged out."));
14
+ }
15
+ });
16
+ }
17
+ }
18
+ export {
19
+ AuthLogout as default
20
+ };
@@ -0,0 +1,30 @@
1
+ import { Command } from "@oclif/core";
2
+ import { OdevaApi } from "../../lib/api.js";
3
+ import { requireCredentials, saveCredentials } from "../../lib/credentials.js";
4
+ import { pickOrganization } from "../../lib/org-picker.js";
5
+ import { withErrorHandling } from "../../lib/run.js";
6
+ import { ui } from "../../lib/ui.js";
7
+ class AuthSelectOrg extends Command {
8
+ static description = "Switch which organization the CLI acts on";
9
+ static examples = ["$ odeva auth select-org"];
10
+ async run() {
11
+ await this.parse(AuthSelectOrg);
12
+ await withErrorHandling(this, async () => {
13
+ const creds = requireCredentials();
14
+ const api = new OdevaApi({ credentials: creds });
15
+ const { organization } = await pickOrganization(api, {
16
+ preferOrganizationId: creds.organizationId
17
+ });
18
+ saveCredentials({
19
+ token: creds.token,
20
+ apiUrl: creds.apiUrl,
21
+ organizationId: organization.id,
22
+ organizationSlug: organization.slug
23
+ });
24
+ this.log(ui.ok(`Active organization: ${ui.bold(organization.name)} (${ui.dim(organization.slug)})`));
25
+ });
26
+ }
27
+ }
28
+ export {
29
+ AuthSelectOrg as default
30
+ };
@@ -0,0 +1,24 @@
1
+ import { Command } from "@oclif/core";
2
+ import { OdevaApi } from "../../lib/api.js";
3
+ import { requireCredentials } from "../../lib/credentials.js";
4
+ import { withErrorHandling } from "../../lib/run.js";
5
+ import { ui } from "../../lib/ui.js";
6
+ class AuthWhoami extends Command {
7
+ static description = "Show the currently authenticated session";
8
+ async run() {
9
+ await this.parse(AuthWhoami);
10
+ await withErrorHandling(this, async () => {
11
+ const creds = requireCredentials();
12
+ const api = new OdevaApi({ credentials: creds });
13
+ const probe = await api.ping();
14
+ this.log(ui.bold("Authenticated"));
15
+ this.log(` ${ui.dim("User:")} ${probe.email ?? "unknown"}`);
16
+ this.log(` ${ui.dim("Org:")} ${creds.organizationSlug ?? ui.dim("(none \u2014 run `odeva auth select-org`)")}`);
17
+ this.log(` ${ui.dim("API URL:")} ${creds.apiUrl}`);
18
+ this.log(` ${ui.dim("Saved at:")} ${creds.savedAt}`);
19
+ });
20
+ }
21
+ }
22
+ export {
23
+ AuthWhoami as default
24
+ };
@@ -0,0 +1,17 @@
1
+ import { Command } from "@oclif/core";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ class Version extends Command {
6
+ static description = "Show the installed odeva CLI version";
7
+ static hidden = true;
8
+ async run() {
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const pkgPath = join(here, "..", "..", "package.json");
11
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
12
+ this.log(`odeva/${pkg.version}`);
13
+ }
14
+ }
15
+ export {
16
+ Version as default
17
+ };
@@ -0,0 +1,50 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { OdevaApi } from "../../lib/api.js";
3
+ import { withErrorHandling } from "../../lib/run.js";
4
+ import { ui } from "../../lib/ui.js";
5
+ class WebhookList extends Command {
6
+ static description = "List webhook subscriptions (and optionally available event types)";
7
+ static examples = ["$ odeva webhook list", "$ odeva webhook list --available"];
8
+ static flags = {
9
+ available: Flags.boolean({
10
+ description: "List event types the API supports instead of active subscriptions",
11
+ default: false
12
+ })
13
+ };
14
+ async run() {
15
+ const { flags } = await this.parse(WebhookList);
16
+ await withErrorHandling(this, async () => {
17
+ const api = new OdevaApi();
18
+ if (flags.available) {
19
+ const events = await api.webhookEventTypes();
20
+ if (events.length === 0) {
21
+ this.log(ui.dim("No event types reported by the API."));
22
+ return;
23
+ }
24
+ this.log(ui.bold("Available webhook event types"));
25
+ for (const event of events) {
26
+ this.log(` ${event}`);
27
+ }
28
+ return;
29
+ }
30
+ const subscriptions = await api.webhookSubscriptions();
31
+ if (subscriptions.length === 0) {
32
+ this.log(ui.dim("No webhook subscriptions on this organization."));
33
+ this.log(ui.dim("Hint: `odeva app dev` registers subscriptions automatically from odeva.app.toml."));
34
+ return;
35
+ }
36
+ this.log(ui.bold(`Webhook subscriptions (${subscriptions.length})`));
37
+ for (const sub of subscriptions) {
38
+ const statusColor = sub.status === "active" ? ui.ok : sub.status === "paused" ? ui.warn : ui.err;
39
+ this.log("");
40
+ this.log(` ${ui.code(sub.id)} ${sub.name}`);
41
+ this.log(` ${ui.dim("status:")} ${statusColor(sub.status)}`);
42
+ this.log(` ${ui.dim("events:")} ${sub.eventTypes.join(", ")}`);
43
+ this.log(` ${ui.dim("endpoint:")} ${sub.endpointUrl}`);
44
+ }
45
+ });
46
+ }
47
+ }
48
+ export {
49
+ WebhookList as default
50
+ };
@@ -0,0 +1,128 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { loadAppConfig, findAppConfigPath } from "../../lib/config.js";
5
+ import { buildFixture, signPayload } from "../../lib/webhook-fixtures.js";
6
+ import { CliError } from "../../lib/errors.js";
7
+ import { withErrorHandling } from "../../lib/run.js";
8
+ import { readEnvFile } from "../../lib/dev-runner.js";
9
+ import { ui } from "../../lib/ui.js";
10
+ import { join } from "node:path";
11
+ class WebhookTrigger extends Command {
12
+ static description = "Fire a sample webhook payload at a local handler (uses the secret from .env.odeva.local if present)";
13
+ static examples = [
14
+ "$ odeva webhook trigger reservation.created",
15
+ "$ odeva webhook trigger payment.succeeded --port 4000",
16
+ "$ odeva webhook trigger reservation.created --payload ./fixtures/big-booking.json",
17
+ "$ odeva webhook trigger reservation.created --url http://localhost:3000/webhooks/reservation.created"
18
+ ];
19
+ static args = {
20
+ event: Args.string({
21
+ description: "Event type to trigger (e.g. reservation.created)",
22
+ required: true
23
+ })
24
+ };
25
+ static flags = {
26
+ url: Flags.string({
27
+ description: "Full URL to POST the payload to (overrides toml lookup)"
28
+ }),
29
+ port: Flags.integer({
30
+ description: "Local port for the handler (defaults to odeva.app.toml [build].port or 3000)"
31
+ }),
32
+ payload: Flags.string({
33
+ description: "Path to a JSON file with the payload body (overrides built-in fixture)"
34
+ }),
35
+ secret: Flags.string({
36
+ description: "HMAC secret to sign the payload with",
37
+ env: "ODEVA_WEBHOOK_SECRET"
38
+ }),
39
+ "no-sign": Flags.boolean({
40
+ description: "Skip HMAC signing (omits the x-odeva-signature header)",
41
+ default: false
42
+ })
43
+ };
44
+ async run() {
45
+ const { args, flags } = await this.parse(WebhookTrigger);
46
+ await withErrorHandling(this, async () => {
47
+ const event = args.event;
48
+ const url = flags.url ?? this.urlFromConfig(event, flags.port);
49
+ const payload = flags.payload ? this.readPayload(flags.payload, event) : buildFixture(event);
50
+ const body = JSON.stringify(payload);
51
+ const secret = flags.secret ?? this.secretFromEnvFile();
52
+ const headers = {
53
+ "content-type": "application/json",
54
+ "x-odeva-event": event,
55
+ "x-odeva-delivery": payload.id
56
+ };
57
+ if (!flags["no-sign"]) {
58
+ if (!secret) {
59
+ throw new CliError("No webhook secret available.", {
60
+ hint: "Run `odeva app dev` first to generate one, or pass --secret <value>, or use --no-sign for unsigned testing."
61
+ });
62
+ }
63
+ headers["x-odeva-signature"] = signPayload(body, secret);
64
+ }
65
+ this.log(`${ui.bold("POST")} ${url}`);
66
+ this.log(` ${ui.dim("event:")} ${event}`);
67
+ this.log(` ${ui.dim("delivery:")} ${payload.id}`);
68
+ this.log(` ${ui.dim("signed:")} ${headers["x-odeva-signature"] ? "yes" : "no"}`);
69
+ const started = Date.now();
70
+ let response;
71
+ try {
72
+ response = await fetch(url, { method: "POST", headers, body });
73
+ } catch (err) {
74
+ throw new CliError(`Could not reach ${url}: ${err.message}`, {
75
+ hint: "Is your dev server running? Try `odeva app dev`."
76
+ });
77
+ }
78
+ const durationMs = Date.now() - started;
79
+ const text = await response.text().catch(() => "");
80
+ const indicator = response.ok ? ui.ok : ui.err;
81
+ this.log("");
82
+ this.log(`${indicator(`${response.status} ${response.statusText}`)} ${ui.dim(`(${durationMs}ms)`)}`);
83
+ if (text) this.log(ui.dim(text.length > 500 ? `${text.slice(0, 500)}\u2026` : text));
84
+ });
85
+ }
86
+ urlFromConfig(event, portFlag) {
87
+ const cfgPath = findAppConfigPath();
88
+ if (!cfgPath) {
89
+ throw new CliError("No odeva.app.toml found, and no --url provided.", {
90
+ hint: "Pass --url <full-local-url> or run from an Odeva app directory."
91
+ });
92
+ }
93
+ const loaded = loadAppConfig();
94
+ const sub = loaded.config.webhooks?.subscriptions?.find((s) => s.topic === event);
95
+ const port = portFlag ?? loaded.config.build?.port ?? 3e3;
96
+ const path = sub?.uri ?? `/webhooks/${event}`;
97
+ return `http://localhost:${port}${path.startsWith("/") ? path : `/${path}`}`;
98
+ }
99
+ secretFromEnvFile() {
100
+ const cfgPath = findAppConfigPath();
101
+ if (!cfgPath) return void 0;
102
+ const envPath = join(resolve(cfgPath, ".."), ".env.odeva.local");
103
+ const env = readEnvFile(envPath);
104
+ return env["ODEVA_WEBHOOK_SECRET"];
105
+ }
106
+ readPayload(path, event) {
107
+ const abs = resolve(process.cwd(), path);
108
+ let raw;
109
+ try {
110
+ raw = readFileSync(abs, "utf8");
111
+ } catch (err) {
112
+ throw new CliError(`Failed to read payload file ${abs}: ${err.message}`);
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(raw);
117
+ } catch (err) {
118
+ throw new CliError(`Invalid JSON in ${abs}: ${err.message}`);
119
+ }
120
+ if (typeof parsed.event === "string" && typeof parsed.id === "string" && "data" in parsed) {
121
+ return parsed;
122
+ }
123
+ return buildFixture(event, parsed);
124
+ }
125
+ }
126
+ export {
127
+ WebhookTrigger as default
128
+ };
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import { run } from "@oclif/core";
2
+ export {
3
+ run
4
+ };