@odeva/cli 0.0.4 → 0.0.6

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,11 +2,14 @@ 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";
8
10
  import { listTemplates, renderTemplate } from "../../lib/templates.js";
9
11
  import { ui } from "../../lib/ui.js";
12
+ import { detectShell, isCompletionInstalled } from "../autocomplete.js";
10
13
  const DEFAULT_TEMPLATE = "hono-bun";
11
14
  class AppInit extends Command {
12
15
  static description = "Scaffold a new Odeva app";
@@ -43,8 +46,12 @@ class AppInit extends Command {
43
46
  await withErrorHandling(this, async () => {
44
47
  p.intro(ui.brand("odeva app init"));
45
48
  const directory = await this.resolveDirectory(args.name, flags.yes);
46
- const slug = this.resolveSlug(flags.slug ?? slugify(basename(directory)));
47
49
  const displayName = flags["display-name"] ?? toTitleCase(basename(directory));
50
+ const slug = await this.resolveSlug({
51
+ explicit: flags.slug,
52
+ dirBase: basename(directory),
53
+ skipPrompts: flags.yes
54
+ });
48
55
  const template = flags.template;
49
56
  if (!listTemplates().includes(template)) {
50
57
  throw new CliError(`Unknown template: '${template}'.`, {
@@ -62,17 +69,23 @@ class AppInit extends Command {
62
69
  }
63
70
  });
64
71
  spinner.stop(`Wrote ${filesWritten} file${filesWritten === 1 ? "" : "s"} from template '${template}'.`);
65
- p.outro(
66
- [
67
- ui.ok(`Created ${ui.bold(displayName)}`),
72
+ const outroLines = [
73
+ ui.ok(`Created ${ui.bold(displayName)}`),
74
+ "",
75
+ " Next steps:",
76
+ ` ${ui.code(`cd ${basename(directory)}`)}`,
77
+ ` ${ui.code("bun install")}`,
78
+ ` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
79
+ ` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
80
+ ];
81
+ const shell = detectShell();
82
+ if (shell && !isCompletionInstalled(shell, "odeva")) {
83
+ outroLines.push(
68
84
  "",
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
- );
85
+ ` ${ui.dim("Tip:")} ${ui.code("odeva autocomplete --install")} ${ui.dim("to enable tab-completion.")}`
86
+ );
87
+ }
88
+ p.outro(outroLines.join("\n"));
76
89
  });
77
90
  }
78
91
  async resolveDirectory(nameArg, skipPrompts) {
@@ -106,14 +119,63 @@ class AppInit extends Command {
106
119
  }
107
120
  return directory;
108
121
  }
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')."
122
+ async resolveSlug(opts) {
123
+ const creds = loadCredentials();
124
+ const orgSlug = creds?.organizationSlug;
125
+ const derived = slugify(opts.dirBase);
126
+ const initial = opts.explicit?.trim() || (orgSlug ? `${orgSlug}-${derived}` : derived);
127
+ validateSlug(initial);
128
+ if (opts.explicit || opts.skipPrompts || !creds) {
129
+ if (creds && opts.explicit) await assertAvailable(initial);
130
+ return initial;
131
+ }
132
+ let candidate = initial;
133
+ const api = new OdevaApi({ credentials: creds });
134
+ for (; ; ) {
135
+ const available = await isAvailable(api, candidate);
136
+ if (available !== false) return candidate;
137
+ const next = await p.text({
138
+ message: `Slug '${candidate}' is already taken \u2014 pick another`,
139
+ placeholder: `${candidate}-2`,
140
+ initialValue: `${candidate}-2`,
141
+ validate: (v) => {
142
+ const trimmed = v.trim();
143
+ if (!isValidSlug(trimmed)) {
144
+ return "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager').";
145
+ }
146
+ return void 0;
147
+ }
114
148
  });
149
+ if (p.isCancel(next)) {
150
+ throw new CliError("init cancelled.");
151
+ }
152
+ candidate = next.trim();
115
153
  }
116
- return slug;
154
+ }
155
+ }
156
+ function validateSlug(slug) {
157
+ if (!isValidSlug(slug)) {
158
+ throw new CliError(`Invalid slug '${slug}'.`, {
159
+ hint: "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager')."
160
+ });
161
+ }
162
+ }
163
+ async function isAvailable(api, slug) {
164
+ try {
165
+ return await api.developerAppSlugAvailable(slug);
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+ async function assertAvailable(slug) {
171
+ const creds = loadCredentials();
172
+ if (!creds) return;
173
+ const api = new OdevaApi({ credentials: creds });
174
+ const ok = await isAvailable(api, slug);
175
+ if (ok === false) {
176
+ throw new CliError(`Slug '${slug}' is already taken.`, {
177
+ hint: "Choose another with --slug, or omit --slug to be prompted."
178
+ });
117
179
  }
118
180
  }
119
181
  function toTitleCase(input) {
@@ -0,0 +1,215 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { CliError } from "../lib/errors.js";
6
+ import { withErrorHandling } from "../lib/run.js";
7
+ import { ui } from "../lib/ui.js";
8
+ const SUPPORTED_SHELLS = ["fish", "zsh"];
9
+ class Autocomplete extends Command {
10
+ static description = "Print or install a shell completion script for the odeva CLI";
11
+ static examples = [
12
+ "$ odeva autocomplete --install # detect shell and install",
13
+ "$ odeva autocomplete fish --install # explicit shell",
14
+ "$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish"
15
+ ];
16
+ static args = {
17
+ shell: Args.string({
18
+ description: "Target shell (auto-detected from $SHELL when omitted with --install)",
19
+ options: [...SUPPORTED_SHELLS],
20
+ required: false
21
+ })
22
+ };
23
+ static flags = {
24
+ install: Flags.boolean({
25
+ description: "Write the completion script to the conventional path for the shell",
26
+ default: false
27
+ })
28
+ };
29
+ async run() {
30
+ const { args, flags } = await this.parse(Autocomplete);
31
+ await withErrorHandling(this, async () => {
32
+ const bin = this.config.bin;
33
+ const shell = args.shell ?? (flags.install ? detectShell() : void 0);
34
+ if (!shell) {
35
+ if (flags.install) {
36
+ throw new CliError("Couldn't detect shell from $SHELL.", {
37
+ hint: `Pass it explicitly: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}> --install\`.`
38
+ });
39
+ }
40
+ throw new CliError("A shell is required.", {
41
+ hint: `Usage: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}>\`. Add --install to write the script to the conventional path.`
42
+ });
43
+ }
44
+ const tree = buildCommandTree(this.config.commands, bin);
45
+ const script = shell === "fish" ? renderFish(bin, tree) : renderZsh(bin, tree);
46
+ if (!flags.install) {
47
+ process.stdout.write(script);
48
+ return;
49
+ }
50
+ const target = completionInstallPath(shell, bin);
51
+ mkdirSync(dirname(target), { recursive: true });
52
+ writeFileSync(target, script, { mode: 420 });
53
+ this.log(ui.ok(`Wrote ${shell} completion to ${ui.code(target)}`));
54
+ if (shell === "fish") {
55
+ this.log(ui.dim(" Open a new fish shell \u2014 completions auto-load from this path."));
56
+ } else {
57
+ const fpathDir = dirname(target);
58
+ this.log("");
59
+ this.log(" Add this to your ~/.zshrc (if you haven't already):");
60
+ this.log("");
61
+ this.log(ui.code(` fpath=(${fpathDir} $fpath)`));
62
+ this.log(ui.code(` autoload -U compinit && compinit`));
63
+ this.log("");
64
+ this.log(ui.dim(" Then open a new zsh shell."));
65
+ }
66
+ });
67
+ }
68
+ }
69
+ function detectShell(env = process.env) {
70
+ const shell = env["SHELL"];
71
+ if (!shell) return null;
72
+ const name = basename(shell);
73
+ if (name === "fish" || name === "zsh") return name;
74
+ return null;
75
+ }
76
+ function completionInstallPath(shell, bin, env = process.env) {
77
+ const home = env["HOME"] || homedir();
78
+ if (shell === "fish") {
79
+ const config = env["XDG_CONFIG_HOME"] || join(home, ".config");
80
+ return join(config, "fish", "completions", `${bin}.fish`);
81
+ }
82
+ const data = env["XDG_DATA_HOME"] || join(home, ".local", "share");
83
+ return join(data, bin, "completions", `_${bin}`);
84
+ }
85
+ function isCompletionInstalled(shell, bin, env = process.env) {
86
+ return existsSync(completionInstallPath(shell, bin, env));
87
+ }
88
+ function buildCommandTree(commands, binName) {
89
+ const root = { name: "", description: "", children: /* @__PURE__ */ new Map() };
90
+ for (const cmd of commands) {
91
+ if (cmd.hidden || cmd.id === "autocomplete") continue;
92
+ const segments = cmd.id.split(":");
93
+ let node = root;
94
+ for (let i = 0; i < segments.length; i++) {
95
+ const seg = segments[i];
96
+ const isLeaf = i === segments.length - 1;
97
+ let child = node.children.get(seg);
98
+ if (!child) {
99
+ child = { name: seg, description: "", children: /* @__PURE__ */ new Map() };
100
+ node.children.set(seg, child);
101
+ }
102
+ if (isLeaf) {
103
+ child.command = cmd;
104
+ const rawDesc = cmd.summary || cmd.description?.split("\n")[0] || "";
105
+ child.description = rawDesc.replace(/<%=\s*config\.bin\s*%>/g, binName);
106
+ }
107
+ node = child;
108
+ }
109
+ }
110
+ return root;
111
+ }
112
+ function shellSingleQuote(s) {
113
+ return `'${s.replace(/'/g, "'\\''")}'`;
114
+ }
115
+ function renderFish(bin, root) {
116
+ const lines = [
117
+ `# fish completion for ${bin}`,
118
+ `# Generated by \`${bin} autocomplete fish\``,
119
+ "",
120
+ `function __${bin}_at_path`,
121
+ " # Returns 0 if the current command line is exactly `bin arg1 arg2 ...`",
122
+ " set -l tokens (commandline -opc)",
123
+ " set -e tokens[1]",
124
+ " test (count $tokens) -eq (count $argv); or return 1",
125
+ " for i in (seq (count $argv))",
126
+ ' test "$tokens[$i]" = "$argv[$i]"; or return 1',
127
+ " end",
128
+ " return 0",
129
+ "end",
130
+ ""
131
+ ];
132
+ const walk = (node, path) => {
133
+ if (node.children.size > 0) {
134
+ const condition = path.length === 0 ? `__${bin}_at_path` : `__${bin}_at_path ${path.join(" ")}`;
135
+ for (const child of node.children.values()) {
136
+ const desc = child.description.replace(/\s+/g, " ").trim();
137
+ const descPart = desc ? ` -d ${shellSingleQuote(desc)}` : "";
138
+ lines.push(`complete -c ${bin} -n ${shellSingleQuote(condition)} -f -a ${shellSingleQuote(child.name)}${descPart}`);
139
+ }
140
+ }
141
+ if (node.command) {
142
+ const condition = `__${bin}_at_path ${path.join(" ")}`;
143
+ for (const [flagName, flag] of Object.entries(node.command.flags)) {
144
+ if (flag.hidden) continue;
145
+ const desc = (flag.summary || flag.description || "").replace(/\s+/g, " ").trim();
146
+ const descPart = desc ? ` -d ${shellSingleQuote(desc)}` : "";
147
+ const takesValue = flag.type === "option" ? " -r" : "";
148
+ const short = flag.char ? ` -s ${flag.char}` : "";
149
+ lines.push(`complete -c ${bin} -n ${shellSingleQuote(condition)} -l ${flagName}${short}${takesValue}${descPart}`);
150
+ }
151
+ }
152
+ for (const child of node.children.values()) {
153
+ walk(child, [...path, child.name]);
154
+ }
155
+ };
156
+ walk(root, []);
157
+ return lines.join("\n") + "\n";
158
+ }
159
+ function renderZsh(bin, root) {
160
+ const dispatch = [];
161
+ const collect = (node, path) => {
162
+ const key = path.join(" ");
163
+ const subcommands = [];
164
+ for (const child of node.children.values()) {
165
+ const desc = child.description.replace(/[:\s]+/g, " ").trim();
166
+ subcommands.push(`'${child.name}:${desc}'`);
167
+ }
168
+ const flagLines = [];
169
+ if (node.command) {
170
+ for (const [flagName, flag] of Object.entries(node.command.flags)) {
171
+ if (flag.hidden) continue;
172
+ const desc = (flag.summary || flag.description || "").replace(/[:[\]]/g, " ").trim();
173
+ const arg = flag.type === "option" ? ":value:" : "";
174
+ flagLines.push(`'--${flagName}[${desc}]${arg}'`);
175
+ if (flag.char) flagLines.push(`'-${flag.char}[${desc}]${arg}'`);
176
+ }
177
+ }
178
+ dispatch.push(
179
+ ` ${shellSingleQuote(key)})`,
180
+ ` _values 'subcommand or flag' ${[...subcommands, ...flagLines].join(" ")}`,
181
+ ` ;;`
182
+ );
183
+ for (const child of node.children.values()) {
184
+ collect(child, [...path, child.name]);
185
+ }
186
+ };
187
+ collect(root, []);
188
+ return `#compdef ${bin}
189
+ # zsh completion for ${bin}
190
+ # Generated by \`${bin} autocomplete zsh\`
191
+
192
+ _${bin}() {
193
+ local -a words
194
+ words=("\${(@)words[2,$CURRENT - 1]}")
195
+ local key="\${(j: :)words}"
196
+ case "$key" in
197
+ ${dispatch.join("\n")}
198
+ *)
199
+ _values 'subcommand'
200
+ ;;
201
+ esac
202
+ }
203
+
204
+ _${bin} "$@"
205
+ `;
206
+ }
207
+ export {
208
+ buildCommandTree,
209
+ completionInstallPath,
210
+ Autocomplete as default,
211
+ detectShell,
212
+ isCompletionInstalled,
213
+ renderFish,
214
+ renderZsh
215
+ };
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.6",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",