@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.
- package/dist/commands/app/config/link.js +57 -21
- package/dist/commands/app/dev.js +23 -0
- package/dist/commands/app/init.js +62 -7
- package/dist/commands/autocomplete.js +158 -0
- package/dist/lib/api.js +15 -0
- package/dist/lib/paths.js +8 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/commands/app/dev.js
CHANGED
|
@@ -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(
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
};
|