@odeva/cli 0.0.4 → 0.0.5

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.
@@ -3,9 +3,11 @@ import * as p from "@clack/prompts";
3
3
  import { OdevaApi } from "../../../lib/api.js";
4
4
  import { loadAppConfig, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
5
5
  import { saveAppEnv } from "../../../lib/app-env.js";
6
+ import { loadCredentials } from "../../../lib/credentials.js";
6
7
  import { withErrorHandling } from "../../../lib/run.js";
7
8
  import { ui } from "../../../lib/ui.js";
8
- import { CliError } from "../../../lib/errors.js";
9
+ import { ApiError, CliError } from "../../../lib/errors.js";
10
+ import { isValidSlug } from "../../../lib/slug.js";
9
11
  function buildAdminEmbedInput(admin, configPath) {
10
12
  const validIcons = ADMIN_ICONS;
11
13
  if (!validIcons.includes(admin.sidebar.icon)) {
@@ -63,29 +65,18 @@ class AppConfigLink extends Command {
63
65
  if (match) existingId = match.id;
64
66
  }
65
67
  spinner.message(existingId ? "Updating app on Odeva" : "Creating app on Odeva");
66
- const { app, rawClientSecret } = await api.upsertDeveloperApp({
67
- id: existingId,
68
- name: loaded.config.name,
69
- slug: loaded.config.slug,
70
- description: loaded.config.description,
71
- homepageUrl: loaded.config.homepage_url,
72
- installUrl: loaded.config.install_url,
73
- privacyUrl: loaded.config.privacy_url,
74
- requestedScopes: loaded.config.access_scopes?.scopes,
75
- // `webhookUrl` is the install-time URL recorded on the App; per-event
76
- // subscriptions are managed separately by `odeva app dev` against the
77
- // tunnel URL.
78
- ...adminInput ? { adminEmbed: adminInput } : {}
79
- });
68
+ const { app, rawClientSecret } = await upsertWithSlugRetry(api, spinner, loaded, existingId, adminInput);
80
69
  spinner.stop(`${existingId ? "Updated" : "Registered"} app ${ui.code(app.slug)} (client_id ${ui.code(app.clientId)})`);
81
70
  loaded.config.client_id = app.clientId;
82
71
  saveAppConfig(loaded);
83
- let envPath = null;
72
+ const creds = loadCredentials();
73
+ const envValues = {
74
+ ODEVA_APP_CLIENT_ID: app.clientId,
75
+ ...rawClientSecret ? { ODEVA_APP_CLIENT_SECRET: rawClientSecret } : {},
76
+ ...creds?.organizationSlug ? { ODEVA_ORGANIZATION_SLUG: creds.organizationSlug } : {}
77
+ };
78
+ const envPath = saveAppEnv(loaded.path, envValues);
84
79
  if (rawClientSecret) {
85
- envPath = saveAppEnv(loaded.path, {
86
- ODEVA_APP_CLIENT_ID: app.clientId,
87
- ODEVA_APP_CLIENT_SECRET: rawClientSecret
88
- });
89
80
  p.note(
90
81
  [
91
82
  "This is the only time the secret is shown.",
@@ -101,7 +92,8 @@ class AppConfigLink extends Command {
101
92
  p.outro(
102
93
  [
103
94
  ui.ok(`Wrote client_id to ${ui.code(loaded.path)}`),
104
- envPath ? ui.ok(`Wrote client_secret to ${ui.code(envPath)}`) : null,
95
+ rawClientSecret ? ui.ok(`Wrote client_secret to ${ui.code(envPath)}`) : null,
96
+ creds?.organizationSlug ? ui.ok(`Wrote organization slug to ${ui.code(envPath)}`) : null,
105
97
  "",
106
98
  ` Next: ${ui.code("odeva app dev")} to run the app locally with a public tunnel.`
107
99
  ].filter((line) => line !== null).join("\n")
@@ -109,6 +101,50 @@ class AppConfigLink extends Command {
109
101
  });
110
102
  }
111
103
  }
104
+ const SLUG_TAKEN_RE = /slug.*(?:already been taken|taken|in use|exists)/i;
105
+ async function upsertWithSlugRetry(api, spinner, loaded, existingId, adminInput) {
106
+ for (; ; ) {
107
+ try {
108
+ return await api.upsertDeveloperApp({
109
+ id: existingId,
110
+ name: loaded.config.name,
111
+ slug: loaded.config.slug,
112
+ description: loaded.config.description,
113
+ homepageUrl: loaded.config.homepage_url,
114
+ installUrl: loaded.config.install_url,
115
+ privacyUrl: loaded.config.privacy_url,
116
+ requestedScopes: loaded.config.access_scopes?.scopes,
117
+ ...adminInput ? { adminEmbed: adminInput } : {}
118
+ });
119
+ } catch (err) {
120
+ if (!(err instanceof ApiError) || !SLUG_TAKEN_RE.test(err.message)) throw err;
121
+ spinner.stop(ui.warn(`Slug '${loaded.config.slug}' is already taken.`));
122
+ const creds = loadCredentials();
123
+ const suggestion = creds?.organizationSlug && !loaded.config.slug.startsWith(`${creds.organizationSlug}-`) ? `${creds.organizationSlug}-${loaded.config.slug}` : `${loaded.config.slug}-2`;
124
+ const next = await p.text({
125
+ message: "Pick a new slug",
126
+ placeholder: suggestion,
127
+ initialValue: suggestion,
128
+ validate: (v) => {
129
+ const trimmed = v.trim();
130
+ if (!isValidSlug(trimmed)) {
131
+ return "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager').";
132
+ }
133
+ if (trimmed === loaded.config.slug) {
134
+ return "Pick a different slug than the one that's taken.";
135
+ }
136
+ return void 0;
137
+ }
138
+ });
139
+ if (p.isCancel(next)) {
140
+ throw new CliError("link cancelled.");
141
+ }
142
+ loaded.config.slug = next.trim();
143
+ saveAppConfig(loaded);
144
+ spinner.start(existingId ? "Updating app on Odeva" : "Creating app on Odeva");
145
+ }
146
+ }
147
+ }
112
148
  export {
113
149
  buildAdminEmbedInput,
114
150
  AppConfigLink as default
@@ -5,6 +5,8 @@ import { OdevaApi } from "../../lib/api.js";
5
5
  import { loadAppConfig } from "../../lib/config.js";
6
6
  import { loadAppEnv } from "../../lib/app-env.js";
7
7
  import { startQuickTunnel } from "../../lib/cloudflared.js";
8
+ import { loadCredentials } from "../../lib/credentials.js";
9
+ import { adminUrl } from "../../lib/paths.js";
8
10
  import {
9
11
  cleanupSubscriptions,
10
12
  preflightChecks,
@@ -64,6 +66,7 @@ class AppDev extends Command {
64
66
  this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
65
67
  }
66
68
  this.log("");
69
+ this.printInstallHint(loaded, tunnel.url);
67
70
  this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
68
71
  this.log("");
69
72
  let server = spawnDevServer({
@@ -142,6 +145,26 @@ class AppDev extends Command {
142
145
  await Promise.allSettled([tunnel.stop(), cleanupSubscriptions(api, registered)]);
143
146
  });
144
147
  }
148
+ printInstallHint(loaded, tunnelUrl) {
149
+ const creds = loadCredentials();
150
+ const apps = adminUrl(creds?.apiUrl);
151
+ this.log(`${ui.bold("Install:")} ${apps}/settings/apps ${ui.dim('(find your draft app under "Your apps")')}`);
152
+ if (creds?.organizationSlug) {
153
+ this.log(` ${ui.dim("org:")} ${creds.organizationSlug}`);
154
+ }
155
+ const installUrl = loaded.config.install_url?.trim();
156
+ const expected = `${tunnelUrl}/install`;
157
+ if (!installUrl) {
158
+ this.log(ui.warn(
159
+ `install_url in odeva.app.toml is empty \u2014 installs won't redirect back. Set it to ${expected} and re-run \`odeva app config link\` (or use a stable URL once you deploy).`
160
+ ));
161
+ } else if (!installUrl.startsWith(tunnelUrl) && !/localhost|127\.0\.0\.1/.test(installUrl)) {
162
+ this.log(ui.warn(
163
+ `install_url is ${installUrl} \u2014 installs from your org will redirect there, not to this tunnel. Update odeva.app.toml + \`odeva app config link\` if you want installs to hit the dev tunnel.`
164
+ ));
165
+ }
166
+ this.log("");
167
+ }
145
168
  async registerDevWebhooks(api, loaded, tunnelUrl) {
146
169
  const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
147
170
  if (subscriptions.length === 0) {
@@ -2,6 +2,8 @@ import { Args, Command, Flags } from "@oclif/core";
2
2
  import * as p from "@clack/prompts";
3
3
  import { existsSync } from "node:fs";
4
4
  import { basename, resolve } from "node:path";
5
+ import { OdevaApi } from "../../lib/api.js";
6
+ import { loadCredentials } from "../../lib/credentials.js";
5
7
  import { CliError } from "../../lib/errors.js";
6
8
  import { withErrorHandling } from "../../lib/run.js";
7
9
  import { isValidSlug, slugify } from "../../lib/slug.js";
@@ -43,8 +45,12 @@ class AppInit extends Command {
43
45
  await withErrorHandling(this, async () => {
44
46
  p.intro(ui.brand("odeva app init"));
45
47
  const directory = await this.resolveDirectory(args.name, flags.yes);
46
- const slug = this.resolveSlug(flags.slug ?? slugify(basename(directory)));
47
48
  const displayName = flags["display-name"] ?? toTitleCase(basename(directory));
49
+ const slug = await this.resolveSlug({
50
+ explicit: flags.slug,
51
+ dirBase: basename(directory),
52
+ skipPrompts: flags.yes
53
+ });
48
54
  const template = flags.template;
49
55
  if (!listTemplates().includes(template)) {
50
56
  throw new CliError(`Unknown template: '${template}'.`, {
@@ -106,14 +112,63 @@ class AppInit extends Command {
106
112
  }
107
113
  return directory;
108
114
  }
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')."
115
+ async resolveSlug(opts) {
116
+ const creds = loadCredentials();
117
+ const orgSlug = creds?.organizationSlug;
118
+ const derived = slugify(opts.dirBase);
119
+ const initial = opts.explicit?.trim() || (orgSlug ? `${orgSlug}-${derived}` : derived);
120
+ validateSlug(initial);
121
+ if (opts.explicit || opts.skipPrompts || !creds) {
122
+ if (creds && opts.explicit) await assertAvailable(initial);
123
+ return initial;
124
+ }
125
+ let candidate = initial;
126
+ const api = new OdevaApi({ credentials: creds });
127
+ for (; ; ) {
128
+ const available = await isAvailable(api, candidate);
129
+ if (available !== false) return candidate;
130
+ const next = await p.text({
131
+ message: `Slug '${candidate}' is already taken \u2014 pick another`,
132
+ placeholder: `${candidate}-2`,
133
+ initialValue: `${candidate}-2`,
134
+ validate: (v) => {
135
+ const trimmed = v.trim();
136
+ if (!isValidSlug(trimmed)) {
137
+ return "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager').";
138
+ }
139
+ return void 0;
140
+ }
114
141
  });
142
+ if (p.isCancel(next)) {
143
+ throw new CliError("init cancelled.");
144
+ }
145
+ candidate = next.trim();
115
146
  }
116
- return slug;
147
+ }
148
+ }
149
+ function validateSlug(slug) {
150
+ if (!isValidSlug(slug)) {
151
+ throw new CliError(`Invalid slug '${slug}'.`, {
152
+ hint: "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager')."
153
+ });
154
+ }
155
+ }
156
+ async function isAvailable(api, slug) {
157
+ try {
158
+ return await api.developerAppSlugAvailable(slug);
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ async function assertAvailable(slug) {
164
+ const creds = loadCredentials();
165
+ if (!creds) return;
166
+ const api = new OdevaApi({ credentials: creds });
167
+ const ok = await isAvailable(api, slug);
168
+ if (ok === false) {
169
+ throw new CliError(`Slug '${slug}' is already taken.`, {
170
+ hint: "Choose another with --slug, or omit --slug to be prompted."
171
+ });
117
172
  }
118
173
  }
119
174
  function toTitleCase(input) {
@@ -0,0 +1,158 @@
1
+ import { Args, Command } from "@oclif/core";
2
+ import { CliError } from "../lib/errors.js";
3
+ import { withErrorHandling } from "../lib/run.js";
4
+ const SUPPORTED_SHELLS = ["fish", "zsh"];
5
+ class Autocomplete extends Command {
6
+ static description = "Print a shell completion script for the odeva CLI";
7
+ static examples = [
8
+ "$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish",
9
+ "$ odeva autocomplete zsh > ~/.odeva/_odeva # then source it from .zshrc"
10
+ ];
11
+ static args = {
12
+ shell: Args.string({
13
+ description: "Target shell",
14
+ options: [...SUPPORTED_SHELLS],
15
+ required: true
16
+ })
17
+ };
18
+ async run() {
19
+ const { args } = await this.parse(Autocomplete);
20
+ await withErrorHandling(this, async () => {
21
+ const shell = args.shell;
22
+ if (!SUPPORTED_SHELLS.includes(shell)) {
23
+ throw new CliError(`Unsupported shell '${shell}'.`, {
24
+ hint: `Supported: ${SUPPORTED_SHELLS.join(", ")}.`
25
+ });
26
+ }
27
+ const bin = this.config.bin;
28
+ const tree = buildCommandTree(this.config.commands, bin);
29
+ const script = shell === "fish" ? renderFish(bin, tree) : renderZsh(bin, tree);
30
+ process.stdout.write(script);
31
+ });
32
+ }
33
+ }
34
+ function buildCommandTree(commands, binName) {
35
+ const root = { name: "", description: "", children: /* @__PURE__ */ new Map() };
36
+ for (const cmd of commands) {
37
+ if (cmd.hidden || cmd.id === "autocomplete") continue;
38
+ const segments = cmd.id.split(":");
39
+ let node = root;
40
+ for (let i = 0; i < segments.length; i++) {
41
+ const seg = segments[i];
42
+ const isLeaf = i === segments.length - 1;
43
+ let child = node.children.get(seg);
44
+ if (!child) {
45
+ child = { name: seg, description: "", children: /* @__PURE__ */ new Map() };
46
+ node.children.set(seg, child);
47
+ }
48
+ if (isLeaf) {
49
+ child.command = cmd;
50
+ const rawDesc = cmd.summary || cmd.description?.split("\n")[0] || "";
51
+ child.description = rawDesc.replace(/<%=\s*config\.bin\s*%>/g, binName);
52
+ }
53
+ node = child;
54
+ }
55
+ }
56
+ return root;
57
+ }
58
+ function shellSingleQuote(s) {
59
+ return `'${s.replace(/'/g, "'\\''")}'`;
60
+ }
61
+ function renderFish(bin, root) {
62
+ const lines = [
63
+ `# fish completion for ${bin}`,
64
+ `# Generated by \`${bin} autocomplete fish\``,
65
+ "",
66
+ `function __${bin}_at_path`,
67
+ " # Returns 0 if the current command line is exactly `bin arg1 arg2 ...`",
68
+ " set -l tokens (commandline -opc)",
69
+ " set -e tokens[1]",
70
+ " test (count $tokens) -eq (count $argv); or return 1",
71
+ " for i in (seq (count $argv))",
72
+ ' test "$tokens[$i]" = "$argv[$i]"; or return 1',
73
+ " end",
74
+ " return 0",
75
+ "end",
76
+ ""
77
+ ];
78
+ const walk = (node, path) => {
79
+ if (node.children.size > 0) {
80
+ const condition = path.length === 0 ? `__${bin}_at_path` : `__${bin}_at_path ${path.join(" ")}`;
81
+ for (const child of node.children.values()) {
82
+ const desc = child.description.replace(/\s+/g, " ").trim();
83
+ const descPart = desc ? ` -d ${shellSingleQuote(desc)}` : "";
84
+ lines.push(`complete -c ${bin} -n ${shellSingleQuote(condition)} -f -a ${shellSingleQuote(child.name)}${descPart}`);
85
+ }
86
+ }
87
+ if (node.command) {
88
+ const condition = `__${bin}_at_path ${path.join(" ")}`;
89
+ for (const [flagName, flag] of Object.entries(node.command.flags)) {
90
+ if (flag.hidden) continue;
91
+ const desc = (flag.summary || flag.description || "").replace(/\s+/g, " ").trim();
92
+ const descPart = desc ? ` -d ${shellSingleQuote(desc)}` : "";
93
+ const takesValue = flag.type === "option" ? " -r" : "";
94
+ const short = flag.char ? ` -s ${flag.char}` : "";
95
+ lines.push(`complete -c ${bin} -n ${shellSingleQuote(condition)} -l ${flagName}${short}${takesValue}${descPart}`);
96
+ }
97
+ }
98
+ for (const child of node.children.values()) {
99
+ walk(child, [...path, child.name]);
100
+ }
101
+ };
102
+ walk(root, []);
103
+ return lines.join("\n") + "\n";
104
+ }
105
+ function renderZsh(bin, root) {
106
+ const dispatch = [];
107
+ const collect = (node, path) => {
108
+ const key = path.join(" ");
109
+ const subcommands = [];
110
+ for (const child of node.children.values()) {
111
+ const desc = child.description.replace(/[:\s]+/g, " ").trim();
112
+ subcommands.push(`'${child.name}:${desc}'`);
113
+ }
114
+ const flagLines = [];
115
+ if (node.command) {
116
+ for (const [flagName, flag] of Object.entries(node.command.flags)) {
117
+ if (flag.hidden) continue;
118
+ const desc = (flag.summary || flag.description || "").replace(/[:[\]]/g, " ").trim();
119
+ const arg = flag.type === "option" ? ":value:" : "";
120
+ flagLines.push(`'--${flagName}[${desc}]${arg}'`);
121
+ if (flag.char) flagLines.push(`'-${flag.char}[${desc}]${arg}'`);
122
+ }
123
+ }
124
+ dispatch.push(
125
+ ` ${shellSingleQuote(key)})`,
126
+ ` _values 'subcommand or flag' ${[...subcommands, ...flagLines].join(" ")}`,
127
+ ` ;;`
128
+ );
129
+ for (const child of node.children.values()) {
130
+ collect(child, [...path, child.name]);
131
+ }
132
+ };
133
+ collect(root, []);
134
+ return `#compdef ${bin}
135
+ # zsh completion for ${bin}
136
+ # Generated by \`${bin} autocomplete zsh\`
137
+
138
+ _${bin}() {
139
+ local -a words
140
+ words=("\${(@)words[2,$CURRENT - 1]}")
141
+ local key="\${(j: :)words}"
142
+ case "$key" in
143
+ ${dispatch.join("\n")}
144
+ *)
145
+ _values 'subcommand'
146
+ ;;
147
+ esac
148
+ }
149
+
150
+ _${bin} "$@"
151
+ `;
152
+ }
153
+ export {
154
+ buildCommandTree,
155
+ Autocomplete as default,
156
+ renderFish,
157
+ renderZsh
158
+ };
package/dist/lib/api.js CHANGED
@@ -89,6 +89,21 @@ class OdevaApi {
89
89
  );
90
90
  return data.cliAuthPoll;
91
91
  }
92
+ /**
93
+ * Check whether a developer-app slug is free. Returns `null` when the CLI
94
+ * isn't authenticated yet (init can be invoked before `auth login`) so
95
+ * callers can fall back to local validation instead of bailing.
96
+ */
97
+ async developerAppSlugAvailable(slug, excludeId) {
98
+ if (!this.authenticated) return null;
99
+ const data = await this.request(
100
+ `query OdevaCliDeveloperAppSlugAvailable($slug: String!, $excludeId: ID) {
101
+ developerAppSlugAvailable(slug: $slug, excludeId: $excludeId)
102
+ }`,
103
+ { slug, excludeId }
104
+ );
105
+ return data.developerAppSlugAvailable;
106
+ }
92
107
  async listDeveloperApps() {
93
108
  const data = await this.request(`
94
109
  query OdevaCliListDeveloperApps {
package/dist/lib/paths.js CHANGED
@@ -5,9 +5,16 @@ const CONFIG_DIR = join(xdgConfigHome, "odeva");
5
5
  const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
6
6
  const APP_CONFIG_FILE = "odeva.app.toml";
7
7
  const DEFAULT_API_URL = process.env["ODEVA_API_URL"] || "https://booking.odeva.app";
8
+ function adminUrl(apiUrl = DEFAULT_API_URL) {
9
+ const override = process.env["ODEVA_ADMIN_URL"];
10
+ if (override) return override.replace(/\/$/, "");
11
+ const base = apiUrl.replace(/\/$/, "");
12
+ return /localhost|127\.0\.0\.1/.test(base) ? base : `${base}/admin`;
13
+ }
8
14
  export {
9
15
  APP_CONFIG_FILE,
10
16
  CONFIG_DIR,
11
17
  CREDENTIALS_PATH,
12
- DEFAULT_API_URL
18
+ DEFAULT_API_URL,
19
+ adminUrl
13
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",