@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.
Files changed (3) hide show
  1. package/README.md +64 -10
  2. package/dist/cli.js +444 -33
  3. 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` | `{ "<host>": { "token": "..." } }` - saved by `cervio login`, one token per host. |
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` → `~/.cervio/credentials.json` entry for the resolved host.
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
- ## PAT scopes
93
+ ## Scopes
58
94
 
59
- The plugin admin API is scope-gated. A personal access token used with this CLI needs:
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> [--token <pat>]`
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
- Prompts for a personal access token (or use `--token`), saves it to `~/.cervio/credentials.json`
100
- keyed by host, then calls `GET /api/v1/me` to confirm it works and prints the identity.
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 prompts2 from 'prompts';
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 path7 = await import('path');
194
- if (existsSync(path7.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
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
- return getToken(host);
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://app.example.com");
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
- let token = opts.token;
514
- if (!token) {
515
- const response = await prompts2({
516
- type: "password",
517
- name: "token",
518
- message: `Personal access token for ${host}`
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
- token = typeof response.token === "string" ? response.token.trim() : "";
885
+ if (!opts.json) log.dim(" then run: cervio login --resume");
886
+ return;
521
887
  }
522
- if (!token) {
523
- throw new CliError("No token provided.");
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 prompts2({
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 prompts2({
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("Authenticate with a Cervio host and save the token").option("--host <url>", "Cervio host, e.g. https://app.example.com").option("--token <token>", "Personal access token (prompted if omitted)").action(action(loginCommand));
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.1",
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.0",
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",