@cervio/cli 1.0.1 → 1.1.0
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/README.md +64 -10
- package/dist/cli.js +444 -33
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -34,11 +34,46 @@ cervio push --changelog "…" --expect-host https://your-instance.cervio.app
|
|
|
34
34
|
(`cervio push` without `--dev`) so the host creates the plugin. Only then can
|
|
35
35
|
`cervio push --dev` upload a scratch build of it.
|
|
36
36
|
|
|
37
|
+
## Signing in
|
|
38
|
+
|
|
39
|
+
`cervio login --host <url>` picks the right method for where it runs. No token is ever
|
|
40
|
+
typed, pasted or printed:
|
|
41
|
+
|
|
42
|
+
| Where | What happens |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Your computer | Cervio opens in the browser; you sign in (MFA included) and allow **Cervio CLI**. The token comes back to the CLI on a `127.0.0.1` callback (OAuth authorization code + PKCE). |
|
|
45
|
+
| No browser here (containers, Claude Code on the web, SSH; or `--device`) | The CLI prints a link and a code such as `BCDF-GHJK`. Open the link in any browser, phone included, sign in and approve. The CLI waits and receives the token (OAuth device flow, RFC 8628). |
|
|
46
|
+
| CI | A personal access token from standard input: `cervio login --host <url> --with-token < token.txt`, or `CERVIO_TOKEN`. |
|
|
47
|
+
|
|
48
|
+
The login lasts: the CLI refreshes its one-hour access token by itself, for up to 90 days.
|
|
49
|
+
It is saved in the system keychain (macOS Keychain, Windows Credential Manager, libsecret)
|
|
50
|
+
when there is one, otherwise in `~/.cervio/credentials.json` (mode 0600). Revoke it any time
|
|
51
|
+
in Cervio → Profile → Security; `cervio logout` forgets it locally.
|
|
52
|
+
|
|
53
|
+
### With an AI agent (Claude Code and others)
|
|
54
|
+
|
|
55
|
+
Let the agent run the CLI, not see your credentials:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cervio login --host https://your-org.cervio.app --dev-only --device --no-wait
|
|
59
|
+
# the agent shows you the link and the code; you approve in your browser, then:
|
|
60
|
+
cervio login --resume
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `--dev-only` asks for `plugin:dev` instead of `plugin:push`: the agent can push and try
|
|
64
|
+
builds on the dev channel, a release (`cervio push`) is refused by the host. Publish
|
|
65
|
+
yourself, or from CI.
|
|
66
|
+
- `--no-wait` / `--resume` split the login in two short commands, so an agent with a
|
|
67
|
+
command timeout does not have to wait for you.
|
|
68
|
+
- The code on screen gives no access by itself: the approval needs your Cervio session.
|
|
69
|
+
Check that the code in the browser matches the one in the terminal.
|
|
70
|
+
- In Claude Code on the web, allow your Cervio host in the environment's network settings.
|
|
71
|
+
|
|
37
72
|
## Configuration
|
|
38
73
|
|
|
39
74
|
| File | Purpose |
|
|
40
75
|
|---|---|
|
|
41
|
-
| `~/.cervio/credentials.json` |
|
|
76
|
+
| `~/.cervio/credentials.json` | One entry per host, saved by `cervio login`: a pointer to the system keychain, or the credential itself (mode 0600) where there is no keychain. |
|
|
42
77
|
| `~/.cervio/state.json` | `{ "lastHost": "<host>" }` - last host successfully pushed to, used for the multi-instance guard. |
|
|
43
78
|
| `./cervio.json` | Per-project config: `{ "host": "...", "plugin_key": "...", "route_slug": "...", "dev_conflict_minutes": 30 }`. |
|
|
44
79
|
|
|
@@ -46,21 +81,24 @@ Env overrides:
|
|
|
46
81
|
|
|
47
82
|
- `CERVIO_URL` - host URL (wins over `cervio.json`, loses to an explicit `--host` flag)
|
|
48
83
|
- `CERVIO_TOKEN` - bearer token (wins over `~/.cervio/credentials.json` entirely)
|
|
49
|
-
- `CERVIO_CONFIG_DIR` - folder for `credentials.json` and `state.json` instead of `~/.cervio` (useful on CI runners)
|
|
84
|
+
- `CERVIO_CONFIG_DIR` - folder for `credentials.json` and `state.json` instead of `~/.cervio` (useful on CI runners; the keychain is not used with it)
|
|
85
|
+
- `CERVIO_KEYCHAIN=off` - never use the system keychain
|
|
50
86
|
|
|
51
87
|
Host resolution order for most commands: `--host` flag → `CERVIO_URL` → `cervio.json` `"host"`.
|
|
52
|
-
Token resolution order: `CERVIO_TOKEN` →
|
|
88
|
+
Token resolution order: `CERVIO_TOKEN` → the saved login for the resolved host (refreshed when it is about to expire).
|
|
53
89
|
|
|
54
90
|
Do not duplicate multi-instance guidance here - use the auth-instances docs (or `cervio whoami` /
|
|
55
91
|
`--expect-host` / `--yes` as documented below).
|
|
56
92
|
|
|
57
|
-
##
|
|
93
|
+
## Scopes
|
|
58
94
|
|
|
59
|
-
The plugin admin API is scope-gated.
|
|
95
|
+
The plugin admin API is scope-gated. `cervio login` asks for `plugin:read plugin:push`
|
|
96
|
+
(`plugin:read plugin:dev` with `--dev-only`); a personal access token needs:
|
|
60
97
|
|
|
61
98
|
| Scope | Commands |
|
|
62
99
|
|---|---|
|
|
63
|
-
| **`plugin:push`** | `push`, `rollback`, `route set` (implies `plugin:read`) |
|
|
100
|
+
| **`plugin:push`** | `push`, `rollback`, `route set` (implies `plugin:dev` and `plugin:read`) |
|
|
101
|
+
| **`plugin:dev`** | `push --dev` only, for agents and automation (implies `plugin:read`) |
|
|
64
102
|
| **`plugin:read`** | `status`, `versions`, `download`, `doctor` entity check (`GET /api/v1/entities`), `route set` slug lookup |
|
|
65
103
|
| **`admin:read` / `admin:write`** | Admin REST (`/api/v1/admin/**`) - not needed for CLI plugin commands |
|
|
66
104
|
| **`records:read` / `records:write`** | Entity records/documents API - not needed for CLI plugin commands |
|
|
@@ -73,7 +111,8 @@ declares scopes. Use **`plugin:read` + `plugin:push`** for this CLI.
|
|
|
73
111
|
|
|
74
112
|
| Command | Flags |
|
|
75
113
|
|---|---|
|
|
76
|
-
| `login` | `--host <url>`, `--token <pat
|
|
114
|
+
| `login` | `--host <url>`, `--device`, `--dev-only`, `--no-wait`, `--resume`, `--with-token`, `--token <pat>`, `--json` |
|
|
115
|
+
| `logout` | `--host <url>` |
|
|
77
116
|
| `whoami` | `--host <url>`, `--json` |
|
|
78
117
|
| `build` | _(none)_ |
|
|
79
118
|
| `validate` | `--json` |
|
|
@@ -94,10 +133,25 @@ structured errors such as `host_changed` / `dev_channel_conflict`).
|
|
|
94
133
|
|
|
95
134
|
## Commands
|
|
96
135
|
|
|
97
|
-
### `cervio login --host <url> [
|
|
136
|
+
### `cervio login --host <url> [options]`
|
|
137
|
+
|
|
138
|
+
Signs in (see [Signing in](#signing-in)), confirms the identity with `GET /api/v1/me`,
|
|
139
|
+
saves the login and fills an empty `host` in `cervio.json`.
|
|
98
140
|
|
|
99
|
-
|
|
100
|
-
|
|
141
|
+
| Option | Effect |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `--device` | Code to approve from any browser, even where one could open here |
|
|
144
|
+
| `--dev-only` | Ask for `plugin:dev`: dev-channel pushes only |
|
|
145
|
+
| `--no-wait` | With the device code: print it and exit; `cervio login --resume` finishes |
|
|
146
|
+
| `--resume` | Finish a device login started with `--no-wait` |
|
|
147
|
+
| `--with-token` | Read a personal access token from standard input |
|
|
148
|
+
| `--token <pat>` | A token as an argument: it ends up in the shell history, prefer `--with-token` |
|
|
149
|
+
| `--json` | Machine-readable output (the device code too) |
|
|
150
|
+
|
|
151
|
+
### `cervio logout [--host <url>]`
|
|
152
|
+
|
|
153
|
+
Removes the saved login for the host (default: `host` in `cervio.json`). To end it on the
|
|
154
|
+
server as well, revoke it in Cervio → Profile → Security.
|
|
101
155
|
|
|
102
156
|
### `cervio whoami [--host <url>] [--json]`
|
|
103
157
|
|
package/dist/cli.js
CHANGED
|
@@ -7,8 +7,10 @@ import { promises, openAsBlob, watch } from 'fs';
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { validatePluginDir, validatePluginZipBuffer, parseManifestObject, packPlugin, PLUGIN_ARTIFACT_SOFT_STRONG_WARN_BYTES, PLUGIN_ARTIFACT_SOFT_WARN_BYTES } from '@cervio/plugin-core';
|
|
9
9
|
import os from 'os';
|
|
10
|
+
import { randomBytes, createHash } from 'crypto';
|
|
11
|
+
import { createServer } from 'http';
|
|
10
12
|
import { createPlugin } from '@cervio/create-plugin';
|
|
11
|
-
import
|
|
13
|
+
import prompts from 'prompts';
|
|
12
14
|
|
|
13
15
|
var CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
14
16
|
var USER_AGENT = `cervio-cli/${CLI_VERSION} node/${process.versions.node}`;
|
|
@@ -190,8 +192,8 @@ function runCommand(command, args, opts = {}) {
|
|
|
190
192
|
}
|
|
191
193
|
async function detectPackageManager(cwd) {
|
|
192
194
|
const { existsSync } = await import('fs');
|
|
193
|
-
const
|
|
194
|
-
if (existsSync(
|
|
195
|
+
const path9 = await import('path');
|
|
196
|
+
if (existsSync(path9.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
195
197
|
return "npm";
|
|
196
198
|
}
|
|
197
199
|
function formatBytes(bytes) {
|
|
@@ -235,7 +237,6 @@ async function buildCommand() {
|
|
|
235
237
|
function cervioConfigDir() {
|
|
236
238
|
return process.env.CERVIO_CONFIG_DIR || path.join(os.homedir(), ".cervio");
|
|
237
239
|
}
|
|
238
|
-
var credentialsPath = () => path.join(cervioConfigDir(), "credentials.json");
|
|
239
240
|
var statePath = () => path.join(cervioConfigDir(), "state.json");
|
|
240
241
|
var PROJECT_CONFIG_FILENAME = "cervio.json";
|
|
241
242
|
async function ensureCervioDir() {
|
|
@@ -258,18 +259,6 @@ async function writeJsonFile(filePath, data) {
|
|
|
258
259
|
function normalizeHost(host) {
|
|
259
260
|
return host.trim().replace(/\/+$/, "");
|
|
260
261
|
}
|
|
261
|
-
async function readCredentials() {
|
|
262
|
-
return readJsonFile(credentialsPath(), {});
|
|
263
|
-
}
|
|
264
|
-
async function getToken(host) {
|
|
265
|
-
const creds = await readCredentials();
|
|
266
|
-
return creds[normalizeHost(host)]?.token;
|
|
267
|
-
}
|
|
268
|
-
async function saveToken(host, token) {
|
|
269
|
-
const creds = await readCredentials();
|
|
270
|
-
creds[normalizeHost(host)] = { token };
|
|
271
|
-
await writeJsonFile(credentialsPath(), creds);
|
|
272
|
-
}
|
|
273
262
|
async function readState() {
|
|
274
263
|
return readJsonFile(statePath(), {});
|
|
275
264
|
}
|
|
@@ -297,13 +286,334 @@ async function readProjectConfig(cwd = process.cwd()) {
|
|
|
297
286
|
throw err;
|
|
298
287
|
}
|
|
299
288
|
}
|
|
289
|
+
async function writeProjectConfig(config, cwd = process.cwd()) {
|
|
290
|
+
await promises.writeFile(projectConfigPath(cwd), `${JSON.stringify(config, null, 2)}
|
|
291
|
+
`);
|
|
292
|
+
}
|
|
300
293
|
function resolveHost(opts) {
|
|
301
294
|
const host = opts.cliHost || process.env.CERVIO_URL || opts.projectConfig?.host;
|
|
302
295
|
return host ? normalizeHost(host) : void 0;
|
|
303
296
|
}
|
|
297
|
+
|
|
298
|
+
// src/credentials.ts
|
|
299
|
+
var KEYCHAIN_SERVICE = "cervio-cli";
|
|
300
|
+
var credentialsPath = () => path.join(cervioConfigDir(), "credentials.json");
|
|
301
|
+
async function readFileEntries() {
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(await promises.readFile(credentialsPath(), "utf8"));
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err?.code === "ENOENT") return {};
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function writeFileEntries(entries) {
|
|
310
|
+
await promises.mkdir(cervioConfigDir(), { recursive: true, mode: 448 });
|
|
311
|
+
await promises.writeFile(credentialsPath(), `${JSON.stringify(entries, null, 2)}
|
|
312
|
+
`, { mode: 384 });
|
|
313
|
+
}
|
|
314
|
+
async function keychain() {
|
|
315
|
+
if (process.env.CERVIO_CONFIG_DIR || process.env.CERVIO_KEYCHAIN === "off") return null;
|
|
316
|
+
try {
|
|
317
|
+
const { Entry } = await import('@napi-rs/keyring');
|
|
318
|
+
return {
|
|
319
|
+
get: (account) => new Entry(KEYCHAIN_SERVICE, account).getPassword() ?? null,
|
|
320
|
+
set: (account, secret) => new Entry(KEYCHAIN_SERVICE, account).setPassword(secret),
|
|
321
|
+
remove: (account) => {
|
|
322
|
+
new Entry(KEYCHAIN_SERVICE, account).deletePassword();
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
} catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function fromFileEntry(entry) {
|
|
330
|
+
if (!entry) return void 0;
|
|
331
|
+
if ("kind" in entry) return entry.kind === "keychain" ? "keychain" : entry;
|
|
332
|
+
return typeof entry.token === "string" ? { kind: "pat", token: entry.token } : void 0;
|
|
333
|
+
}
|
|
334
|
+
async function readCredential(host) {
|
|
335
|
+
const key = normalizeHost(host);
|
|
336
|
+
const entry = fromFileEntry((await readFileEntries())[key]);
|
|
337
|
+
if (entry !== "keychain") return entry;
|
|
338
|
+
const kc = await keychain();
|
|
339
|
+
try {
|
|
340
|
+
const raw = kc?.get(key);
|
|
341
|
+
return raw ? JSON.parse(raw) : void 0;
|
|
342
|
+
} catch {
|
|
343
|
+
return void 0;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function saveCredential(host, credential) {
|
|
347
|
+
const key = normalizeHost(host);
|
|
348
|
+
const entries = await readFileEntries();
|
|
349
|
+
const kc = await keychain();
|
|
350
|
+
if (kc) {
|
|
351
|
+
try {
|
|
352
|
+
kc.set(key, JSON.stringify(credential));
|
|
353
|
+
entries[key] = { kind: "keychain" };
|
|
354
|
+
await writeFileEntries(entries);
|
|
355
|
+
return "keychain";
|
|
356
|
+
} catch {
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
entries[key] = credential;
|
|
360
|
+
await writeFileEntries(entries);
|
|
361
|
+
return "file";
|
|
362
|
+
}
|
|
363
|
+
async function removeCredential(host) {
|
|
364
|
+
const key = normalizeHost(host);
|
|
365
|
+
const entries = await readFileEntries();
|
|
366
|
+
const had = key in entries;
|
|
367
|
+
if (fromFileEntry(entries[key]) === "keychain") {
|
|
368
|
+
try {
|
|
369
|
+
;
|
|
370
|
+
(await keychain())?.remove(key);
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
delete entries[key];
|
|
375
|
+
await writeFileEntries(entries);
|
|
376
|
+
return had;
|
|
377
|
+
}
|
|
378
|
+
var CLI_SCOPES = "plugin:read plugin:push";
|
|
379
|
+
var CLI_DEV_SCOPES = "plugin:read plugin:dev";
|
|
380
|
+
var CLIENT_NAME = "Cervio CLI";
|
|
381
|
+
var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
382
|
+
var BROWSER_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
383
|
+
var EXPIRY_MARGIN_MS = 60 * 1e3;
|
|
384
|
+
var OAuthError = class extends Error {
|
|
385
|
+
constructor(code, message) {
|
|
386
|
+
super(message);
|
|
387
|
+
this.code = code;
|
|
388
|
+
}
|
|
389
|
+
code;
|
|
390
|
+
};
|
|
391
|
+
async function postForm(url, body) {
|
|
392
|
+
let res;
|
|
393
|
+
try {
|
|
394
|
+
res = await fetch(url, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: {
|
|
397
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
398
|
+
Accept: "application/json",
|
|
399
|
+
"User-Agent": USER_AGENT
|
|
400
|
+
},
|
|
401
|
+
body: new URLSearchParams(body)
|
|
402
|
+
});
|
|
403
|
+
} catch (err) {
|
|
404
|
+
throw new CliError(`Could not reach ${url}: ${err.message}`);
|
|
405
|
+
}
|
|
406
|
+
const data = await res.json().catch(() => ({}));
|
|
407
|
+
if (!res.ok) {
|
|
408
|
+
const code = typeof data.error === "string" ? data.error : `http_${res.status}`;
|
|
409
|
+
const description = typeof data.error_description === "string" ? data.error_description : code;
|
|
410
|
+
throw new OAuthError(code, description);
|
|
411
|
+
}
|
|
412
|
+
return data;
|
|
413
|
+
}
|
|
414
|
+
function toCredential(data, clientId) {
|
|
415
|
+
const token = data;
|
|
416
|
+
if (typeof token.access_token !== "string" || typeof token.refresh_token !== "string") {
|
|
417
|
+
throw new CliError("The host returned an incomplete token response.");
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
kind: "oauth",
|
|
421
|
+
access_token: token.access_token,
|
|
422
|
+
refresh_token: token.refresh_token,
|
|
423
|
+
expires_at: Date.now() + (typeof token.expires_in === "number" ? token.expires_in : 3600) * 1e3,
|
|
424
|
+
client_id: clientId,
|
|
425
|
+
...typeof token.scope === "string" ? { scope: token.scope } : {}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function assertHostSupportsLogin(host) {
|
|
429
|
+
let metadata = {};
|
|
430
|
+
try {
|
|
431
|
+
const res = await fetch(`${host}/.well-known/oauth-authorization-server`, {
|
|
432
|
+
headers: { Accept: "application/json", "User-Agent": USER_AGENT }
|
|
433
|
+
});
|
|
434
|
+
if (res.ok) metadata = await res.json();
|
|
435
|
+
} catch (err) {
|
|
436
|
+
throw new CliError(`Could not reach ${host}: ${err.message}`);
|
|
437
|
+
}
|
|
438
|
+
if (typeof metadata.device_authorization_endpoint !== "string") {
|
|
439
|
+
throw new CliError(
|
|
440
|
+
`${host} does not support signing in from the CLI yet. Create a personal access token (Integrations & API \u2192 Personal tokens, scope plugin push) and run: cervio login --host ${host} --with-token < token.txt`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async function registerClient(host, redirectUri, scopes) {
|
|
445
|
+
let res;
|
|
446
|
+
try {
|
|
447
|
+
res = await fetch(`${host}/api/oauth/register`, {
|
|
448
|
+
method: "POST",
|
|
449
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": USER_AGENT },
|
|
450
|
+
body: JSON.stringify({ client_name: CLIENT_NAME, redirect_uris: [redirectUri], scope: scopes })
|
|
451
|
+
});
|
|
452
|
+
} catch (err) {
|
|
453
|
+
throw new CliError(`Could not reach ${host}: ${err.message}`);
|
|
454
|
+
}
|
|
455
|
+
const data = await res.json().catch(() => ({}));
|
|
456
|
+
if (!res.ok || typeof data.client_id !== "string") {
|
|
457
|
+
throw new CliError(
|
|
458
|
+
`This host does not accept the CLI login (${res.status}${typeof data.error_description === "string" ? `: ${data.error_description}` : ""}). Use a personal access token: cervio login --with-token.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return data.client_id;
|
|
462
|
+
}
|
|
463
|
+
function canOpenBrowser(env = process.env, platform = process.platform) {
|
|
464
|
+
if (env.CI || env.SSH_CONNECTION || env.SSH_TTY) return false;
|
|
465
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
466
|
+
return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
|
|
467
|
+
}
|
|
468
|
+
function openBrowser(url) {
|
|
469
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
470
|
+
try {
|
|
471
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {
|
|
472
|
+
}).unref();
|
|
473
|
+
} catch {
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
var b64url = (buf) => buf.toString("base64url");
|
|
477
|
+
async function browserLogin(host, print, scopes = CLI_SCOPES) {
|
|
478
|
+
const verifier = b64url(randomBytes(32));
|
|
479
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
480
|
+
const state = b64url(randomBytes(16));
|
|
481
|
+
await assertHostSupportsLogin(host);
|
|
482
|
+
const server = createServer();
|
|
483
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
484
|
+
const port = server.address().port;
|
|
485
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
486
|
+
try {
|
|
487
|
+
const clientId = await registerClient(host, redirectUri, scopes);
|
|
488
|
+
const authorizeUrl = new URL("/authorize", host);
|
|
489
|
+
authorizeUrl.search = new URLSearchParams({
|
|
490
|
+
response_type: "code",
|
|
491
|
+
client_id: clientId,
|
|
492
|
+
redirect_uri: redirectUri,
|
|
493
|
+
code_challenge: challenge,
|
|
494
|
+
code_challenge_method: "S256",
|
|
495
|
+
scope: scopes,
|
|
496
|
+
state,
|
|
497
|
+
resource: host
|
|
498
|
+
}).toString();
|
|
499
|
+
const code = await new Promise((resolve, reject) => {
|
|
500
|
+
const timer = setTimeout(() => reject(new CliError("Login timed out: no answer from the browser in 5 minutes.")), BROWSER_TIMEOUT_MS);
|
|
501
|
+
server.on("request", (req, res) => {
|
|
502
|
+
const url = new URL(req.url ?? "/", redirectUri);
|
|
503
|
+
if (url.pathname !== "/callback") {
|
|
504
|
+
res.writeHead(404).end();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const finish2 = (ok, message) => {
|
|
508
|
+
res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
|
|
509
|
+
res.end(
|
|
510
|
+
`<!doctype html><meta charset="utf-8"><title>Cervio CLI</title><body style="font-family:system-ui;padding:3rem;text-align:center"><p>${message}</p></body>`
|
|
511
|
+
);
|
|
512
|
+
};
|
|
513
|
+
if (url.searchParams.get("state") !== state) {
|
|
514
|
+
finish2(false, "This login request does not match. Run cervio login again.");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
clearTimeout(timer);
|
|
518
|
+
const error = url.searchParams.get("error");
|
|
519
|
+
const value = url.searchParams.get("code");
|
|
520
|
+
if (error || !value) {
|
|
521
|
+
finish2(false, "Login cancelled. You can close this tab.");
|
|
522
|
+
reject(new CliError(error === "access_denied" ? "Login cancelled in the browser." : `Login failed: ${error ?? "no code"}.`));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
finish2(true, "You are signed in to the Cervio CLI. You can close this tab.");
|
|
526
|
+
resolve(value);
|
|
527
|
+
});
|
|
528
|
+
print(`Opening the browser to sign in. If it does not open, visit:
|
|
529
|
+
${authorizeUrl.toString()}`);
|
|
530
|
+
openBrowser(authorizeUrl.toString());
|
|
531
|
+
});
|
|
532
|
+
const data = await postForm(`${host}/api/oauth/token`, {
|
|
533
|
+
grant_type: "authorization_code",
|
|
534
|
+
code,
|
|
535
|
+
client_id: clientId,
|
|
536
|
+
redirect_uri: redirectUri,
|
|
537
|
+
code_verifier: verifier,
|
|
538
|
+
resource: host
|
|
539
|
+
});
|
|
540
|
+
return toCredential(data, clientId);
|
|
541
|
+
} finally {
|
|
542
|
+
server.close();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async function startDeviceLogin(host, scopes = CLI_SCOPES) {
|
|
546
|
+
await assertHostSupportsLogin(host);
|
|
547
|
+
const clientId = await registerClient(host, "http://127.0.0.1/cervio-cli-device", scopes);
|
|
548
|
+
let data;
|
|
549
|
+
try {
|
|
550
|
+
data = await postForm(`${host}/api/oauth/device_authorization`, {
|
|
551
|
+
client_id: clientId,
|
|
552
|
+
scope: scopes,
|
|
553
|
+
resource: host
|
|
554
|
+
});
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (err instanceof OAuthError && err.code === "http_404") {
|
|
557
|
+
throw new CliError("This host does not support device login yet. Use cervio login in a browser, or --with-token.");
|
|
558
|
+
}
|
|
559
|
+
throw err instanceof OAuthError ? new CliError(`Device login failed: ${err.message}`) : err;
|
|
560
|
+
}
|
|
561
|
+
return { ...data, client_id: clientId };
|
|
562
|
+
}
|
|
563
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
564
|
+
async function pollDeviceLogin(host, request, deadline) {
|
|
565
|
+
let interval = Math.max(1, request.interval || 5) * 1e3;
|
|
566
|
+
const end = deadline ?? Date.now() + request.expires_in * 1e3;
|
|
567
|
+
while (Date.now() < end) {
|
|
568
|
+
await sleep(interval);
|
|
569
|
+
try {
|
|
570
|
+
const data = await postForm(`${host}/api/oauth/token`, {
|
|
571
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
572
|
+
device_code: request.device_code,
|
|
573
|
+
client_id: request.client_id
|
|
574
|
+
});
|
|
575
|
+
return toCredential(data, request.client_id);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
if (!(err instanceof OAuthError)) throw err;
|
|
578
|
+
if (err.code === "authorization_pending") continue;
|
|
579
|
+
if (err.code === "slow_down") {
|
|
580
|
+
interval += 5e3;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (err.code === "access_denied") throw new CliError("The request was denied in the browser.");
|
|
584
|
+
if (err.code === "expired_token") break;
|
|
585
|
+
throw new CliError(`Device login failed: ${err.message}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
throw new CliError("The code expired before it was approved. Run cervio login --device again.");
|
|
589
|
+
}
|
|
590
|
+
async function freshAccessToken(host, credential, save) {
|
|
591
|
+
if (credential.expires_at - EXPIRY_MARGIN_MS > Date.now()) return credential.access_token;
|
|
592
|
+
let data;
|
|
593
|
+
try {
|
|
594
|
+
data = await postForm(`${host}/api/oauth/token`, {
|
|
595
|
+
grant_type: "refresh_token",
|
|
596
|
+
refresh_token: credential.refresh_token,
|
|
597
|
+
client_id: credential.client_id
|
|
598
|
+
});
|
|
599
|
+
} catch (err) {
|
|
600
|
+
if (err instanceof OAuthError) {
|
|
601
|
+
throw new CliError(`Your Cervio login for ${host} has expired or was revoked. Run "cervio login --host ${host}".`);
|
|
602
|
+
}
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
const next = toCredential(data, credential.client_id);
|
|
606
|
+
await save(next);
|
|
607
|
+
return next.access_token;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/auth.ts
|
|
304
611
|
async function resolveToken(host) {
|
|
305
612
|
if (process.env.CERVIO_TOKEN) return process.env.CERVIO_TOKEN;
|
|
306
|
-
|
|
613
|
+
const credential = await readCredential(host);
|
|
614
|
+
if (!credential) return void 0;
|
|
615
|
+
if (credential.kind === "pat") return credential.token;
|
|
616
|
+
return freshAccessToken(host, credential, (next) => saveCredential(host, next));
|
|
307
617
|
}
|
|
308
618
|
|
|
309
619
|
// src/context.ts
|
|
@@ -505,30 +815,120 @@ async function initCommand(name) {
|
|
|
505
815
|
log.step("Scaffolding a new Cervio plugin...");
|
|
506
816
|
await createPlugin(name ? [name] : []);
|
|
507
817
|
}
|
|
818
|
+
async function readStdin() {
|
|
819
|
+
if (process.stdin.isTTY) {
|
|
820
|
+
throw new CliError("--with-token reads the token from standard input, e.g. cervio login --with-token < token.txt");
|
|
821
|
+
}
|
|
822
|
+
const chunks = [];
|
|
823
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
824
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
825
|
+
}
|
|
826
|
+
function printDeviceInstructions(request, json) {
|
|
827
|
+
const url = request.verification_uri_complete ?? request.verification_uri;
|
|
828
|
+
if (json) {
|
|
829
|
+
console.log(
|
|
830
|
+
JSON.stringify({
|
|
831
|
+
status: "pending",
|
|
832
|
+
verification_uri: request.verification_uri,
|
|
833
|
+
verification_uri_complete: request.verification_uri_complete,
|
|
834
|
+
user_code: request.user_code,
|
|
835
|
+
expires_in: request.expires_in
|
|
836
|
+
})
|
|
837
|
+
);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
log.info(`To sign in, open this page in any browser and approve the code:`);
|
|
841
|
+
log.info(` ${url}`);
|
|
842
|
+
log.info(` code: ${request.user_code}`);
|
|
843
|
+
log.dim(` (expires in ${Math.round(request.expires_in / 60)} minutes; the code alone gives no access)`);
|
|
844
|
+
}
|
|
508
845
|
async function loginCommand(opts) {
|
|
846
|
+
const state = await readState();
|
|
847
|
+
if (opts.resume) {
|
|
848
|
+
const pending = state.pendingDevice;
|
|
849
|
+
if (!pending || opts.host && normalizeHost(opts.host) !== pending.host) {
|
|
850
|
+
throw new CliError("No device login is waiting. Run cervio login --device first.");
|
|
851
|
+
}
|
|
852
|
+
if (pending.expires_at < Date.now()) {
|
|
853
|
+
await writeState({ ...state, pendingDevice: void 0 });
|
|
854
|
+
throw new CliError("The code expired before it was approved. Run cervio login --device again.");
|
|
855
|
+
}
|
|
856
|
+
const credential2 = await pollDeviceLogin(pending.host, pending.request, pending.expires_at);
|
|
857
|
+
await writeState({ ...await readState(), pendingDevice: void 0 });
|
|
858
|
+
return finish(pending.host, credential2, !!opts.json);
|
|
859
|
+
}
|
|
509
860
|
if (!opts.host) {
|
|
510
|
-
throw new CliError("--host <url> is required, e.g. cervio login --host https://
|
|
861
|
+
throw new CliError("--host <url> is required, e.g. cervio login --host https://your-org.cervio.app");
|
|
511
862
|
}
|
|
512
863
|
const host = normalizeHost(opts.host);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
864
|
+
if (opts.token || opts.withToken) {
|
|
865
|
+
if (opts.token) {
|
|
866
|
+
log.warn("--token puts the token in your shell history and process list: prefer --with-token (stdin) or a browser login.");
|
|
867
|
+
}
|
|
868
|
+
const token = opts.token ?? await readStdin();
|
|
869
|
+
if (!token) throw new CliError("No token provided.");
|
|
870
|
+
return finish(host, { kind: "pat", token }, !!opts.json);
|
|
871
|
+
}
|
|
872
|
+
const scopes = opts.devOnly ? CLI_DEV_SCOPES : CLI_SCOPES;
|
|
873
|
+
const useDevice = opts.device || !canOpenBrowser();
|
|
874
|
+
if (!useDevice) {
|
|
875
|
+
const credential2 = await browserLogin(host, (msg) => log.info(msg), scopes);
|
|
876
|
+
return finish(host, credential2, !!opts.json);
|
|
877
|
+
}
|
|
878
|
+
const request = await startDeviceLogin(host, scopes);
|
|
879
|
+
printDeviceInstructions(request, !!opts.json);
|
|
880
|
+
if (opts.wait === false) {
|
|
881
|
+
await writeState({
|
|
882
|
+
...state,
|
|
883
|
+
pendingDevice: { host, request, expires_at: Date.now() + request.expires_in * 1e3 }
|
|
519
884
|
});
|
|
520
|
-
|
|
885
|
+
if (!opts.json) log.dim(" then run: cervio login --resume");
|
|
886
|
+
return;
|
|
521
887
|
}
|
|
522
|
-
if (!
|
|
523
|
-
|
|
888
|
+
if (!opts.json) log.dim(" waiting for approval\u2026");
|
|
889
|
+
const credential = await pollDeviceLogin(host, request);
|
|
890
|
+
return finish(host, credential, !!opts.json);
|
|
891
|
+
}
|
|
892
|
+
async function finish(host, credential, json) {
|
|
893
|
+
const token = credential.kind === "pat" ? credential.token : credential.access_token;
|
|
894
|
+
const me = await createApiClient({ host, token }).getMe();
|
|
895
|
+
const store = await saveCredential(host, credential);
|
|
896
|
+
const project = await readProjectConfig().catch(() => null);
|
|
897
|
+
const fillProjectHost = !!project && !project.host?.trim();
|
|
898
|
+
if (project && fillProjectHost) await writeProjectConfig({ ...project, host });
|
|
899
|
+
if (json) {
|
|
900
|
+
console.log(
|
|
901
|
+
JSON.stringify({
|
|
902
|
+
status: "logged_in",
|
|
903
|
+
host,
|
|
904
|
+
user: me.email ?? me.user_id,
|
|
905
|
+
organization: me.organization_name ?? me.organization_id,
|
|
906
|
+
auth: me.auth_method,
|
|
907
|
+
stored_in: store
|
|
908
|
+
})
|
|
909
|
+
);
|
|
910
|
+
return;
|
|
524
911
|
}
|
|
525
|
-
const client = createApiClient({ host, token });
|
|
526
|
-
const me = await client.getMe();
|
|
527
|
-
await saveToken(host, token);
|
|
528
912
|
log.success(`Logged in to ${host}`);
|
|
529
913
|
log.info(` user: ${me.email ?? me.user_id}`);
|
|
530
914
|
log.info(` org: ${me.organization_name ?? me.organization_id}`);
|
|
531
915
|
log.info(` auth: ${me.auth_method}`);
|
|
916
|
+
if (credential.kind === "oauth" && credential.scope) log.info(` scope: ${credential.scope}`);
|
|
917
|
+
log.dim(
|
|
918
|
+
store === "keychain" ? " saved in the system keychain" : ` saved in ${path.join(cervioConfigDir(), "credentials.json")} (readable only by you)`
|
|
919
|
+
);
|
|
920
|
+
if (fillProjectHost) log.dim(` cervio.json host set to ${host}`);
|
|
921
|
+
}
|
|
922
|
+
async function logoutCommand(opts) {
|
|
923
|
+
const host = opts.host ? normalizeHost(opts.host) : (await readProjectConfig().catch(() => null))?.host;
|
|
924
|
+
if (!host) throw new CliError('--host <url> is required (or run it in a project with "host" in cervio.json).');
|
|
925
|
+
const removed = await removeCredential(host);
|
|
926
|
+
if (removed) {
|
|
927
|
+
log.success(`Logged out of ${normalizeHost(host)}`);
|
|
928
|
+
log.dim(" To revoke the login on the server too: Cervio \u2192 Profile \u2192 Security.");
|
|
929
|
+
} else {
|
|
930
|
+
log.info(`No saved login for ${normalizeHost(host)}.`);
|
|
931
|
+
}
|
|
532
932
|
}
|
|
533
933
|
|
|
534
934
|
// src/commands/open.ts
|
|
@@ -621,7 +1021,7 @@ async function pushCommand(opts) {
|
|
|
621
1021
|
return;
|
|
622
1022
|
}
|
|
623
1023
|
} else if (!opts.yes) {
|
|
624
|
-
const response = await
|
|
1024
|
+
const response = await prompts({
|
|
625
1025
|
type: "confirm",
|
|
626
1026
|
name: "proceed",
|
|
627
1027
|
message: `Last push was to ${lastHost}. Continue publishing to ${host}?`,
|
|
@@ -649,7 +1049,7 @@ async function pushCommand(opts) {
|
|
|
649
1049
|
return;
|
|
650
1050
|
}
|
|
651
1051
|
if (!opts.force) {
|
|
652
|
-
const response = await
|
|
1052
|
+
const response = await prompts({
|
|
653
1053
|
type: "confirm",
|
|
654
1054
|
name: "proceed",
|
|
655
1055
|
message: `Dev channel was last updated by ${conflict.updated_by_email ?? "someone else"} ${conflict.minutes_ago.toFixed(0)}m ago. Overwrite anyway?`,
|
|
@@ -731,6 +1131,16 @@ async function pushCommand(opts) {
|
|
|
731
1131
|
log.info(` content_sha256: ${upload.version.content_sha256 ?? "(n/a)"}`);
|
|
732
1132
|
if (upload.reason) log.dim(` ${upload.reason}`);
|
|
733
1133
|
}
|
|
1134
|
+
if (upload.plugin.plugin_type === "route") await hintMissingRouteConfig(client, upload.plugin.id);
|
|
1135
|
+
}
|
|
1136
|
+
async function hintMissingRouteConfig(client, pluginId) {
|
|
1137
|
+
try {
|
|
1138
|
+
const { route_config } = await client.getRouteConfig(pluginId);
|
|
1139
|
+
if (route_config) return;
|
|
1140
|
+
} catch {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
log.step('Not reachable yet: run "cervio route set" to publish it at /r/<route_slug> (or set it in Admin \u2192 Plugins).');
|
|
734
1144
|
}
|
|
735
1145
|
async function safeMe(client) {
|
|
736
1146
|
try {
|
|
@@ -1030,7 +1440,8 @@ function action(fn) {
|
|
|
1030
1440
|
}
|
|
1031
1441
|
var program = new Command();
|
|
1032
1442
|
program.name("cervio").description("Cervio plugin developer CLI").version(CLI_VERSION);
|
|
1033
|
-
program.command("login").description("
|
|
1443
|
+
program.command("login").description("Sign in to a Cervio host: in the browser, or with a code to approve from any device").option("--host <url>", "Cervio host, e.g. https://your-org.cervio.app").option("--device", "Print a code to approve from any browser (default when no browser can open here)").option("--dev-only", "Ask only for dev-channel pushes (plugin:dev): for AI agents and automation").option("--no-wait", 'With --device: print the code and exit; finish with "cervio login --resume"').option("--resume", "Finish a device login started with --no-wait").option("--with-token", "Read a personal access token from standard input (CI)").option("--token <token>", "Personal access token as an argument (ends up in shell history: prefer --with-token)").option("--json", "Output JSON").action(action(loginCommand));
|
|
1444
|
+
program.command("logout").description("Remove the saved login for a host").option("--host <url>", 'Cervio host (default: "host" in cervio.json)').action(action(logoutCommand));
|
|
1034
1445
|
program.command("whoami").description("Print the current host and identity").option("--host <url>", "Override the resolved host").option("--json", "Output JSON").action(action(whoamiCommand));
|
|
1035
1446
|
program.command("build").description("Run the project build script (pnpm/npm run build)").action(action(buildCommand));
|
|
1036
1447
|
program.command("validate").description("Validate the plugin in the current directory").option("--json", "Output JSON").action(action(validateCommand));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cervio/cli",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Developer CLI for Cervio plugins: login, validate, pack, push and manage releases.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cervio",
|
|
@@ -30,9 +30,12 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"commander": "^15.0.0",
|
|
32
32
|
"prompts": "^2.4.2",
|
|
33
|
-
"@cervio/create-plugin": "^1.1.
|
|
33
|
+
"@cervio/create-plugin": "^1.1.1",
|
|
34
34
|
"@cervio/plugin-core": "^1.0.1"
|
|
35
35
|
},
|
|
36
|
+
"optionalDependencies": {
|
|
37
|
+
"@napi-rs/keyring": "2.1.2"
|
|
38
|
+
},
|
|
36
39
|
"devDependencies": {
|
|
37
40
|
"@types/node": "^26.0.0",
|
|
38
41
|
"@types/prompts": "^2.4.9",
|