@vecteur/cli 0.2.4 → 0.3.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.
@@ -0,0 +1,75 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { CliError } from "./errors.js";
6
+
7
+ /**
8
+ * ONE PERSISTENT LOGIN — SRD 06's release bar for this door, beside "the same product from any
9
+ * working directory".
10
+ *
11
+ * Before this, `configuration()` read `VECTEUR_TOKEN` from the environment and nothing else, so
12
+ * "logged in" lasted exactly as long as one shell. Every new terminal, every cron line and every
13
+ * `cd` into a project began by pasting a credential again — which is not a login, and it is the
14
+ * habit that puts secrets in shell history.
15
+ *
16
+ * THE TOKEN IS NEVER AN ARGUMENT. `vecteur login` reads it from stdin. A credential passed as
17
+ * `--token vct_…` is visible in `ps`, in `/proc/<pid>/cmdline` to every local user, and in the
18
+ * shell history file afterwards — the exact exposure `SHIP-007` exists to rotate away in this
19
+ * same product. A door that ships a convenient way to make that mistake has shipped the mistake.
20
+ *
21
+ * The store lives OUTSIDE every git tree, one file, 0600 — the same shape `scripts/with-secrets.py`
22
+ * holds the fleet's own store in, for the same reason.
23
+ */
24
+ const FILE_MODE = 0o600;
25
+ const DIRECTORY_MODE = 0o700;
26
+
27
+ /** `$XDG_CONFIG_HOME/vecteur/credentials.json`, or `~/.config/vecteur/credentials.json`. */
28
+ export function credentialsPath(env = process.env) {
29
+ const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.startsWith("/")
30
+ ? env.XDG_CONFIG_HOME
31
+ : join(env.HOME || homedir(), ".config");
32
+ return join(base, "vecteur", "credentials.json");
33
+ }
34
+
35
+ /** `{ origin, token }` as stored, or `null` when nobody has logged in on this machine. */
36
+ export function readCredentials(env = process.env) {
37
+ let raw;
38
+ try {
39
+ raw = readFileSync(credentialsPath(env), "utf8");
40
+ } catch {
41
+ return null;
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch {
47
+ // A corrupt store is a typed refusal, not a silent fall back to "not logged in": the second
48
+ // reads as "run login again" and loses the fact that a credential IS there and unreadable.
49
+ throw new CliError("credentials_unreadable",
50
+ `${credentialsPath(env)} is not readable JSON — run \`vecteur logout\` and log in again`);
51
+ }
52
+ if (!parsed || typeof parsed !== "object" ||
53
+ typeof parsed.origin !== "string" || typeof parsed.token !== "string") {
54
+ throw new CliError("credentials_unreadable",
55
+ `${credentialsPath(env)} does not hold an origin and a token`);
56
+ }
57
+ return { origin: parsed.origin, token: parsed.token };
58
+ }
59
+
60
+ export function writeCredentials({ origin, token }, env = process.env) {
61
+ const path = credentialsPath(env);
62
+ mkdirSync(dirname(path), { recursive: true, mode: DIRECTORY_MODE });
63
+ // Written 0600 AT CREATION, not chmod'ed after: between an 0644 create and a chmod there is a
64
+ // window in which any local user can read the token, and on a shared machine that window is
65
+ // the whole exposure.
66
+ writeFileSync(path, `${JSON.stringify({ origin, token }, null, 2)}\n`, { mode: FILE_MODE });
67
+ chmodSync(path, FILE_MODE); // an existing file keeps its old mode through writeFileSync
68
+ return path;
69
+ }
70
+
71
+ export function clearCredentials(env = process.env) {
72
+ const path = credentialsPath(env);
73
+ rmSync(path, { force: true });
74
+ return path;
75
+ }
package/src/errors.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The CLI's one typed error, in its own module.
3
+ *
4
+ * It used to live in `client.js`, which `credentials.js` had to import for it while `client.js`
5
+ * needed `credentials.js` to resolve a stored login — a cycle whose first workaround was a
6
+ * function reached through `globalThis`. An error type is not part of either concern; it is
7
+ * shared vocabulary, so it owns a file and neither side reaches through the other.
8
+ */
9
+ export class CliError extends Error {
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.code = code;
13
+ }
14
+ }
package/src/format.js ADDED
@@ -0,0 +1,91 @@
1
+ import { ORIGIN_KINDS, ORIGIN_WORD } from "./contract.js";
2
+
3
+ export function canonicalJson(value) {
4
+ validateNumbers(value);
5
+ return `${JSON.stringify(sortValue(value))}\n`;
6
+ }
7
+
8
+ function keyOrder(left, right) {
9
+ const a = Array.from(left, (character) => character.codePointAt(0));
10
+ const b = Array.from(right, (character) => character.codePointAt(0));
11
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
12
+ if (a[index] !== b[index]) return a[index] - b[index];
13
+ }
14
+ return a.length - b.length;
15
+ }
16
+
17
+ function validateNumbers(value) {
18
+ if (typeof value === "number" && (!Number.isFinite(value) ||
19
+ (Number.isInteger(value) && !Number.isSafeInteger(value)))) {
20
+ throw new TypeError("canonical JSON contains an unsafe number");
21
+ }
22
+ if (Array.isArray(value)) value.forEach(validateNumbers);
23
+ else if (value && typeof value === "object") Object.values(value).forEach(validateNumbers);
24
+ }
25
+
26
+ function sortValue(value) {
27
+ if (Array.isArray(value)) return value.map(sortValue);
28
+ if (value && typeof value === "object") {
29
+ return Object.fromEntries(
30
+ Object.keys(value).sort(keyOrder).map((key) => [key, sortValue(value[key])]),
31
+ );
32
+ }
33
+ return value;
34
+ }
35
+
36
+ /**
37
+ * ONE CLASSIFICATION OF THE EVIDENCE A RUN USED, SPOKEN BY THIS DOOR TOO.
38
+ *
39
+ * PRD-UX-16: every load-bearing value in a step's inputs shows its origin — your brief, assumed,
40
+ * derived, your file, catalogue, default — and a value the program attributed to nothing renders
41
+ * an explicit marker, NEVER a blank. PRD-CLI-9: every door sees the same state. The web has said
42
+ * this for a while; this door printed `[3] step_finished` and nothing else, so the same run
43
+ * answered "where did this number come from?" on one surface and stayed silent on the other.
44
+ *
45
+ * The words come from `contract.js`, generated from `frontend/src/run/provenance.ts` — the
46
+ * web's own owner — so the two doors cannot drift into different vocabularies without the
47
+ * contract gate going red.
48
+ */
49
+ export function originWord(origin) {
50
+ if (!origin || typeof origin !== "object" || typeof origin.kind !== "string") {
51
+ return ORIGIN_WORD.unattributed;
52
+ }
53
+ // A kind the engine invented and this build does not know is NOT silently "unstated": that
54
+ // would report an attribution failure where there is a version skew, and the two read
55
+ // completely differently to whoever is trying to trust the number.
56
+ if (!ORIGIN_KINDS.includes(origin.kind)) return `unknown origin \`${origin.kind}\``;
57
+ return ORIGIN_WORD[origin.kind];
58
+ }
59
+
60
+ /** `name = value unit (origin word, ref)` — one line per input, in wire order. */
61
+ export function inputLines(inputs) {
62
+ if (!Array.isArray(inputs)) return [];
63
+ return inputs.map((row) => {
64
+ const unit = typeof row?.unit === "string" && row.unit ? ` ${row.unit}` : "";
65
+ const word = originWord(row?.origin);
66
+ // The REF is what makes the word actionable — "your file" is a category, "your file
67
+ // (orbit.csv)" is an answer. It is omitted only when the origin does not carry one.
68
+ const ref = typeof row?.origin?.ref === "string" && row.origin.ref
69
+ ? ` ${row.origin.ref}` : "";
70
+ return ` ${row?.name ?? "?"} = ${JSON.stringify(row?.value)}${unit} (${word}${ref})`;
71
+ });
72
+ }
73
+
74
+ export function human(value) {
75
+ if (value && typeof value === "object" && typeof value.run_id === "string") {
76
+ return `Run ${value.run_id}: ${value.terminal ?? value.state_hash ?? "accepted"}\n`;
77
+ }
78
+ if (value && typeof value === "object" && typeof value.name === "string") {
79
+ return `${value.name} (${value.id})\n`;
80
+ }
81
+ return canonicalJson(value);
82
+ }
83
+
84
+ export function progress(event) {
85
+ const head = `[${event.seq}] ${event.kind}\n`;
86
+ // A finished step is where a run's evidence becomes visible: its inputs each carry an
87
+ // origin. Every other kind keeps the one-line shape.
88
+ if (event?.kind !== "step_finished") return head;
89
+ const lines = inputLines(event?.payload?.step?.inputs ?? event?.payload?.inputs);
90
+ return lines.length ? `${head}${lines.join("\n")}\n` : head;
91
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,98 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import * as z from "zod/v4";
3
+
4
+ import { CliError, VecteurClient } from "./client.js";
5
+ import { EVENT_KINDS } from "./contract.js";
6
+ import { canonicalJson } from "./format.js";
7
+
8
+ const projectInput = z.object({ project_id: z.string().min(1) }).strict();
9
+ const submitInput = z.object({
10
+ project_id: z.string().min(1),
11
+ ask: z.string().min(1),
12
+ }).strict();
13
+ const runInput = z.object({ run_id: z.string().min(1) }).strict();
14
+ const eventKind = z.enum(EVENT_KINDS);
15
+ const eventOutput = z.object({
16
+ run_id: z.string().min(1), seq: z.number().int().safe().nonnegative(),
17
+ kind: eventKind,
18
+ payload: z.record(z.string(), z.json()),
19
+ }).strict();
20
+ const memberOutput = z.object({
21
+ user_id: z.string().min(1), email: z.string().min(1), display_name: z.string().min(1),
22
+ role: z.enum(["OWNER", "ADMIN", "EDITOR", "VIEWER"]),
23
+ }).strict();
24
+ const projectOutput = z.object({
25
+ id: z.string().min(1), name: z.string().min(1), org_id: z.string().min(1),
26
+ workspace_id: z.string().min(1), role: z.enum(["OWNER", "ADMIN", "EDITOR", "VIEWER"]),
27
+ role_source: z.enum(["workspace", "project"]), members: z.array(memberOutput),
28
+ created_at: z.number().int().safe(), updated_at: z.number().int().safe(),
29
+ }).strict();
30
+ const admittedOutput = z.object({
31
+ run_id: z.string().min(1), state_hash: z.string().min(1), result_hash: z.string().min(1),
32
+ profile: z.string().min(1),
33
+ terminal: z.enum(["ok", "partial", "blocked", "refused", "infeasible_physics", "capability_gap"]),
34
+ usage: z.object({
35
+ state: z.enum(["recorded", "usage_unavailable"]), provider: z.string().min(1),
36
+ model_id: z.string().min(1), input_tokens: z.number().int().safe().nonnegative().nullable(),
37
+ output_tokens: z.number().int().safe().nonnegative().nullable(),
38
+ cost_usd: z.number().finite().nonnegative().nullable(),
39
+ }).strict(),
40
+ events: z.array(eventOutput),
41
+ }).strict();
42
+
43
+ function success(value) {
44
+ return {
45
+ content: [{ type: "text", text: canonicalJson(value) }],
46
+ structuredContent: value,
47
+ };
48
+ }
49
+
50
+ function failure(error) {
51
+ const code = error instanceof CliError && /^[a-z][a-z0-9_]{0,63}$/.test(error.code)
52
+ ? error.code : "internal_error";
53
+ return {
54
+ isError: true,
55
+ content: [{ type: "text", text: canonicalJson({ error: { code } }) }],
56
+ };
57
+ }
58
+
59
+ function tool(server, name, description, inputSchema, outputSchema, call) {
60
+ server.registerTool(name, { description, inputSchema, outputSchema }, async (input, context) => {
61
+ try {
62
+ return success(await call(input, context));
63
+ } catch (error) {
64
+ return failure(error);
65
+ }
66
+ });
67
+ }
68
+
69
+ export function createVecteurMcpServer(client = new VecteurClient(), shutdownSignal = undefined) {
70
+ const server = new McpServer({ name: "vecteur", version: "0.1.0" });
71
+ const signal = (context) => shutdownSignal
72
+ ? AbortSignal.any([context.mcpReq.signal, shutdownSignal]) : context.mcpReq.signal;
73
+
74
+ tool(server, "vecteur_project_get", "Get one PAT-bound Vecteur project.", projectInput, projectOutput,
75
+ ({ project_id: projectId }, context) => client.projectGet(projectId, signal(context)));
76
+
77
+ tool(server, "vecteur_run_submit", "Submit one project turn and return its terminal record.",
78
+ submitInput, admittedOutput, async ({ project_id: projectId, ask }, context) => {
79
+ const progressToken = context.mcpReq._meta?.progressToken;
80
+ const onEvent = progressToken === undefined ? null : (event) => context.mcpReq.notify({
81
+ method: "notifications/progress",
82
+ params: {
83
+ progressToken,
84
+ progress: event.seq + 1,
85
+ message: event.kind,
86
+ },
87
+ });
88
+ return client.runSubmit(projectId, ask, onEvent, signal(context));
89
+ });
90
+
91
+ tool(server, "vecteur_run_get", "Get one terminal Vecteur run record.", runInput, admittedOutput,
92
+ ({ run_id: runId }, context) => client.runGet(runId, signal(context)));
93
+
94
+ tool(server, "vecteur_run_events", "Validate run events against its direct record.", runInput,
95
+ admittedOutput, ({ run_id: runId }, context) => client.runEvents(runId, null, signal(context)));
96
+
97
+ return server;
98
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Vecteur
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
package/README.md DELETED
@@ -1,70 +0,0 @@
1
- # Vecteur CLI
2
-
3
- Run space-mission-engineering queries from your terminal — an interactive agent that works on
4
- your local files. The `vecteur` CLI is a **thin, open-source client**: the agent, physics
5
- libraries, and models run on Vecteur's servers, so nothing proprietary ships in this package.
6
-
7
- ![Vecteur CLI](https://raw.githubusercontent.com/vecteurspace/vecteur-cli/main/assets/screenshot.png)
8
-
9
- ```
10
- npm install -g @vecteur/cli
11
- vecteur login # opens your browser to approve this device
12
- cd my-mission/ # this directory becomes your workspace
13
- vecteur # start an interactive session
14
- ```
15
-
16
- ## Install
17
-
18
- ```bash
19
- npm install -g @vecteur/cli
20
- ```
21
-
22
- Standalone binaries (no Node needed) and `brew` / `winget` / `curl` installers are published with
23
- each release — see the [releases page](https://github.com/vecteurspace/vecteur-cli/releases).
24
-
25
- ## Use
26
-
27
- ```bash
28
- vecteur # interactive session; the current directory is your workspace
29
- vecteur ask "period at 550 km circular" --project <id>
30
- vecteur ask "explain this" --file ./mission.md # attach local files as context
31
- vecteur projects # your projects (same as the web app at vecteur.space)
32
- vecteur whoami # who you're signed in as
33
- ```
34
-
35
- Inside the interactive session: type naturally, use `@path` to attach a local file, and
36
- `/help` for commands (`/files`, `/new`, `/open`, `/exit`).
37
-
38
- ## Use it from an AI assistant (MCP)
39
-
40
- No install needed — connect Vecteur to Claude, Cursor, or any MCP-capable assistant:
41
-
42
- ```bash
43
- claude mcp add --transport http vecteur https://api.vecteur.space/mcp
44
- ```
45
-
46
- ## Configuration
47
-
48
- - `VECTEUR_API_URL` — point at a different Vecteur instance (default `https://api.vecteur.space`).
49
- - `VECTEUR_TOKEN` — supply an access key non-interactively (CI).
50
-
51
- Credentials are stored at `~/.config/vecteur/config.json` (owner-only).
52
-
53
- ## What this package contains (and doesn't)
54
-
55
- This is a client. It sends your queries (and any files you explicitly attach) to the Vecteur API
56
- and streams results back. It contains **no** agent logic, physics code, prompts, or API keys — a
57
- CI gate (`.github/workflows/ci.yml`) scans every change and the published tarball ships only
58
- compiled client code + LICENSE + README.
59
-
60
- ## Development
61
-
62
- ```bash
63
- npm install
64
- npm run build # tsc typecheck + emit to dist/
65
- npm run typecheck
66
- ```
67
-
68
- ## License
69
-
70
- MIT — see [LICENSE](./LICENSE).
package/dist/api.js DELETED
@@ -1,89 +0,0 @@
1
- /**
2
- * Thin HTTP client for the Vecteur backend. Bearer auth (PAT or JWT — the CLI is
3
- * auth-method-agnostic so it works with whatever credential the platform issues).
4
- * Structured errors so commands can print actionable messages and set exit codes.
5
- */
6
- import { loadConfig } from "./config.js";
7
- import { VERSION } from "./version.js";
8
- export class ApiError extends Error {
9
- status;
10
- body;
11
- retryAfter;
12
- constructor(status, message, body, retryAfter) {
13
- super(message);
14
- this.status = status;
15
- this.body = body;
16
- this.retryAfter = retryAfter;
17
- this.name = "ApiError";
18
- }
19
- /** A human, actionable line for the terminal. */
20
- hint() {
21
- switch (this.status) {
22
- case 401:
23
- return "Not authenticated. Run `vecteur login` (or set VECTEUR_TOKEN).";
24
- case 403:
25
- // Prefer the server's actual reason (e.g. "Project limit reached…", a plan gate);
26
- // fall back to the scope hint only when the server gave no detail (bare status text).
27
- return this.message && this.message.toLowerCase() !== "forbidden"
28
- ? this.message
29
- : "Forbidden — your token may lack the required scope for this action.";
30
- case 429:
31
- return `Rate limit / quota exceeded${this.retryAfter ? ` — retry in ${this.retryAfter}s` : ""}.`;
32
- case 426:
33
- return "This CLI is out of date. Run `vecteur update`.";
34
- default:
35
- return this.message;
36
- }
37
- }
38
- }
39
- export function apiBase() {
40
- return loadConfig().apiUrl.replace(/\/+$/, "");
41
- }
42
- export async function api(path, opts = {}) {
43
- const cfg = loadConfig();
44
- const token = opts.token ?? cfg.token;
45
- const url = new URL(apiBase() + path);
46
- if (opts.query) {
47
- for (const [k, v] of Object.entries(opts.query)) {
48
- if (v !== undefined)
49
- url.searchParams.set(k, String(v));
50
- }
51
- }
52
- const headers = {
53
- Accept: "application/json",
54
- "User-Agent": `vecteur-cli/${VERSION}`, // lets the server 426-gate clients below a min version
55
- };
56
- if (token)
57
- headers["Authorization"] = `Bearer ${token}`;
58
- if (opts.body !== undefined)
59
- headers["Content-Type"] = "application/json";
60
- let res;
61
- try {
62
- res = await fetch(url, {
63
- method: opts.method ?? "GET",
64
- headers,
65
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
66
- });
67
- }
68
- catch (e) {
69
- throw new ApiError(0, `Network error reaching ${apiBase()} — is the API URL correct and are you online? (${e.message})`);
70
- }
71
- const text = await res.text();
72
- let parsed = undefined;
73
- if (text) {
74
- try {
75
- parsed = JSON.parse(text);
76
- }
77
- catch {
78
- parsed = text;
79
- }
80
- }
81
- if (!res.ok) {
82
- const retryAfter = Number(res.headers.get("retry-after")) || undefined;
83
- const detail = (parsed && typeof parsed === "object" && "detail" in parsed
84
- ? String(parsed.detail)
85
- : undefined) ?? res.statusText;
86
- throw new ApiError(res.status, detail, parsed, retryAfter);
87
- }
88
- return parsed;
89
- }
@@ -1,44 +0,0 @@
1
- /**
2
- * ask: one-shot — run an engineering query and stream the answer. A thin wrapper over the
3
- * shared runner (same streaming path the interactive `chat` REPL uses).
4
- */
5
- import { loadConfig } from "../config.js";
6
- import { buildLocalContextQuery, openBrowser, streamTurn, webBase } from "../runner.js";
7
- export async function ask(query, opts) {
8
- const cfg = loadConfig();
9
- if (!cfg.token) {
10
- console.error("Not logged in. Run `vecteur login` first.");
11
- process.exitCode = 1;
12
- return;
13
- }
14
- if (!opts.project) {
15
- console.error('A project is required: `vecteur ask "…" --project <id>` (or use `vecteur` for an interactive session).');
16
- process.exitCode = 1;
17
- return;
18
- }
19
- const effectiveQuery = buildLocalContextQuery(query, opts.file);
20
- if (!opts.json)
21
- console.error("▸ running…");
22
- const res = await streamTurn({
23
- project: opts.project,
24
- query: effectiveQuery,
25
- followUp: opts.followUp,
26
- agent: opts.agent,
27
- json: opts.json,
28
- onStep: opts.json ? undefined : (s) => process.stderr.write(` · ${s}\n`),
29
- });
30
- if (opts.json)
31
- return;
32
- if (res.failed) {
33
- console.error(`✗ ${res.failed}`);
34
- process.exitCode = 1;
35
- return;
36
- }
37
- console.log("\n" + (res.answer ?? "(no answer)"));
38
- const link = `${webBase()}/projects/${opts.project}`;
39
- console.log(`\nView the full run (globe, tables, provenance): ${link}`);
40
- if (res.sawVisual)
41
- console.log("(this run produced visual artifacts best seen in the web view)");
42
- if (opts.open)
43
- void openBrowser(link);
44
- }
@@ -1,76 +0,0 @@
1
- /** login / logout / whoami. */
2
- import { api, ApiError } from "../api.js";
3
- import { clearToken, loadConfig, saveToken } from "../config.js";
4
- import { openBrowser } from "../runner.js";
5
- export async function login(opts) {
6
- if (opts.token) {
7
- // Verify the token works before persisting.
8
- await api("/api/v1/auth/me", { token: opts.token });
9
- saveToken(opts.token, opts.apiUrl);
10
- console.log("Logged in (token stored).");
11
- return;
12
- }
13
- if (opts.email && opts.password) {
14
- const res = await api("/api/v1/auth/login", {
15
- method: "POST",
16
- body: { email: opts.email, password: opts.password },
17
- });
18
- saveToken(res.access_token, opts.apiUrl);
19
- console.log(`Logged in as ${res.user?.email ?? opts.email}.`);
20
- return;
21
- }
22
- // Default: browser device flow (RFC 8628). No password touches the terminal.
23
- await deviceLogin(opts.apiUrl, opts.noBrowser);
24
- }
25
- export function formatDeviceInstructions(verification_uri, user_code) {
26
- return `To authorize this device, open:
27
- ${verification_uri}?code=${user_code}
28
- or go to ${verification_uri} and enter the code: ${user_code}`;
29
- }
30
- async function deviceLogin(apiUrl, noBrowser = false) {
31
- const dc = await api("/api/v1/auth/device/code", { method: "POST", body: {} });
32
- console.log(`\n${formatDeviceInstructions(dc.verification_uri, dc.user_code)}\n`);
33
- if (!noBrowser) {
34
- await openBrowser(`${dc.verification_uri}?code=${dc.user_code}`);
35
- }
36
- console.log(`Waiting for approval (expires in ${Math.round(dc.expires_in / 60)} min)…`);
37
- const deadline = Date.now() + dc.expires_in * 1000;
38
- const intervalMs = Math.max(2, dc.interval ?? 3) * 1000;
39
- while (Date.now() < deadline) {
40
- await new Promise((r) => setTimeout(r, intervalMs));
41
- try {
42
- const res = await api("/api/v1/auth/device/token", {
43
- method: "POST",
44
- body: { device_code: dc.device_code },
45
- });
46
- saveToken(res.token, apiUrl);
47
- console.log("Approved — logged in.");
48
- return;
49
- }
50
- catch (e) {
51
- if (e instanceof ApiError && e.status === 428)
52
- continue; // authorization_pending
53
- throw e;
54
- }
55
- }
56
- throw new ApiError(408, "Device authorization timed out. Run `vecteur login` again.");
57
- }
58
- export function logout() {
59
- clearToken();
60
- console.log("Logged out (local token cleared).");
61
- }
62
- export async function whoami() {
63
- const cfg = loadConfig();
64
- if (!cfg.token) {
65
- console.log("Not logged in. Run `vecteur login`.");
66
- process.exitCode = 1;
67
- return;
68
- }
69
- const me = await api("/api/v1/auth/me");
70
- console.log(`user: ${me.email ?? me.username ?? me.id}`);
71
- if (me.role)
72
- console.log(`role: ${me.role}`);
73
- console.log(`token: ${cfg.tokenPrefix ?? "(hidden)"}…`);
74
- console.log(`api: ${cfg.apiUrl}`);
75
- // Quota is shown here once the platform exposes a per-credential quota endpoint (M-U3).
76
- }