@vincentt-xr/cli 0.19.0 → 0.20.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/MIGRATION.md CHANGED
@@ -3,6 +3,16 @@
3
3
  Per version, newest first. Read by the platform's own version surface and served to
4
4
  creators' agents. Accurate or absent, never speculative (B-F5-2).
5
5
 
6
+ ## 0.20.0
7
+
8
+ required: false
9
+ No action required. Behaviour that changed:
10
+ - `vincentt login` signs in this machine on its own and leaves your other machines signed in. It sends a machine label, `"<OS> <arch>, <d Mon>"` by default (never the hostname), or the name given with `--label "<name>"`; a `--label` the platform would refuse (empty, over 64 characters, control or invisible characters, or a platform-reserved name) exits 1 before any browser opens.
11
+ - The `vincentt login` success line names the machine: `✓ Signed in as <email> on this machine (<label>)`. Against an api older than machine sign-ins the line is unchanged.
12
+ - When a login leaves more than 10 signed-in machines, the least recently used are signed out and each is named on its own line after the workspace line: `Signed out "<label>", the least recently used of your 10 signed-in machines (last used <d Mon>).`
13
+ - `vincentt logout` also ends this machine's sign-in on the platform (best-effort, 5 seconds), then always removes the local token; when the api cannot be reached it says the machine stays listed in the console. `vincentt login --force` keeps the old token until the new one is saved, then ends the old sign-in.
14
+ - A refused token now reads ``✗ This machine is no longer signed in to Vincentt. Run `vincentt login` to sign in again.`` (it read "Your Vincentt access token is no longer valid."). A script matching the old wording exactly needs updating. The exit code is unchanged (4).
15
+
6
16
  ## 0.19.0
7
17
 
8
18
  required: false
package/README.md CHANGED
@@ -53,8 +53,9 @@ registers the project, which is what needs an account.
53
53
 
54
54
  ```
55
55
  Auth
56
- login Sign in; save an access token to ~/.vincentt/config.json
57
- logout Remove the saved access token
56
+ login Sign in this machine; save an access token to ~/.vincentt/config.json
57
+ [--label <name>] names this machine (default: OS, arch, date)
58
+ logout Sign this machine out and remove the saved access token
58
59
  whoami Show the signed-in account
59
60
 
60
61
  Project
package/dist/cli/index.js CHANGED
@@ -15,8 +15,9 @@ import { createRequire } from "node:module";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
17
17
  import { runPkceLogin } from "../login/pkceLogin.js";
18
- import { httpAuthBackend } from "../login/authBackend.js";
19
- import { loginCommand } from "../login/loginCommand.js";
18
+ import { httpAuthBackend, revokeCurrent } from "../login/authBackend.js";
19
+ import { resolveLoginLabel } from "../login/machineLabel.js";
20
+ import { loginCommand, logoutLines as logoutCommand } from "../login/loginCommand.js";
20
21
  import { ensureLoggedIn } from "../login/ensure.js";
21
22
  import { clearMachineToken, loadMachineConfig, locateProjectBinding, machineApiUrl, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
22
23
  import { fileLocationStore } from "../shared/locationStore.js";
@@ -120,16 +121,33 @@ function machineCredentials(machine) {
120
121
  return pat ? { apiUrl: machineApiUrl(machine), pat } : undefined;
121
122
  }
122
123
  /** The PKCE loopback + code-exchange login (f1). The raw PAT never rides a redirect query. */
123
- async function runLoginFlow() {
124
+ async function runLoginFlow(label) {
124
125
  const { consoleUrl, apiUrl } = await resolveAuthUrls();
125
126
  return runPkceLogin({
126
127
  consoleUrl,
127
128
  apiUrl,
129
+ label,
128
130
  onProgress: (line) => console.log(line),
129
131
  });
130
132
  }
131
- /** `vincentt login` [--force] — the L1/L2/L4 transcripts. */
133
+ /** The label this login sends, or undefined when --label was refused (the refusal is printed). */
134
+ function loginLabel() {
135
+ const eq = valueOfEqualsFlag(argv, "--label");
136
+ const resolved = resolveLoginLabel(has("--label") || eq !== undefined, flag("--label") ?? eq);
137
+ if (!resolved.ok) {
138
+ for (const line of resolved.lines)
139
+ console.log(line);
140
+ return undefined;
141
+ }
142
+ return resolved.label;
143
+ }
144
+ /** `vincentt login` [--force] [--label <name>] — the L1/L2/L4/L5 transcripts. */
132
145
  async function login() {
146
+ const label = loginLabel();
147
+ if (label === undefined) {
148
+ process.exitCode = EXIT.FAILED;
149
+ return;
150
+ }
133
151
  const { apiUrl } = await resolveAuthUrls();
134
152
  const code = await loginCommand({
135
153
  print: (line) => console.log(line),
@@ -140,10 +158,12 @@ async function login() {
140
158
  throw new NotSignedInError();
141
159
  return httpAuthBackend(apiUrl).me(pat);
142
160
  },
143
- runLogin: runLoginFlow,
161
+ runLogin: () => runLoginFlow(label),
144
162
  clearToken: async () => {
145
163
  await clearMachineToken();
146
164
  },
165
+ storedToken: async () => (await loadMachineConfig()).pat,
166
+ revokeToken: (pat) => revokeCurrent(apiUrl, pat),
147
167
  home: os.homedir(),
148
168
  configPath: MACHINE_CONFIG_PATH,
149
169
  }, { force: has("--force") });
@@ -169,8 +189,13 @@ async function requireLogin() {
169
189
  return ok;
170
190
  }
171
191
  async function logout() {
172
- const { path: p, hadToken } = await clearMachineToken();
173
- console.log(hadToken ? `✓ Signed out. Token removed from ${p}` : "Already signed out.");
192
+ const machine = await loadMachineConfig();
193
+ const outcome = machine.pat
194
+ ? await revokeCurrent(machineApiUrl(machine), machine.pat)
195
+ : "done";
196
+ const lines = await logoutCommand({ clear: clearMachineToken, outcome });
197
+ for (const line of lines)
198
+ console.log(line);
174
199
  }
175
200
  /**
176
201
  * `whoami` reads the MACHINE config's API host, never the folder's binding — it
@@ -11,9 +11,10 @@ export const USAGE_TEXT = `Vincentt CLI: the local-first AR dev loop\n\n` +
11
11
  ` Wait for the developer to describe what to build.\n` +
12
12
  ` Full guide: npm view @vincentt-xr/cli readme\n\n` +
13
13
  `Auth\n` +
14
- ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
14
+ ` login Sign in this machine; save an access token to ~/.vincentt/config.json\n` +
15
+ ` [--label <name>] names this machine (default: OS, arch, date)\n` +
15
16
  ` [--api <url>] [--console <url>] to target a non-prod env\n` +
16
- ` logout Remove the saved access token\n` +
17
+ ` logout Sign this machine out and remove the saved access token\n` +
17
18
  ` whoami Show the signed-in account\n\n` +
18
19
  `Project\n` +
19
20
  ` init [dir] Scaffold a Vincentt XR app; no account needed, ever\n` +
@@ -10,18 +10,43 @@ export interface Identity {
10
10
  /** All orgs the signed-in user belongs to (switcher source). */
11
11
  memberships: Membership[];
12
12
  }
13
+ /** A machine sign-in the api signed out to stay within the 10-machine cap. */
14
+ export interface EvictedMachine {
15
+ label: string;
16
+ /** ISO 8601, or null for a machine that was never used. */
17
+ lastUsedAt: string | null;
18
+ }
19
+ export interface ExchangeResult {
20
+ /** The raw PAT ("vct_…"), delivered once. */
21
+ token: string;
22
+ /** The label the api stored, echoed. Absent from an api older than machine sign-ins. */
23
+ label?: string;
24
+ /** Machines signed out by this login (the cap). Absent when none were. */
25
+ evicted?: EvictedMachine[];
26
+ }
27
+ /** How a self sign-out ended: the api ended it (or it had nothing to end), or it could not be reached. */
28
+ export type RevokeOutcome = "done" | "unreachable";
13
29
  /**
14
30
  * The auth-flow backend seam. `exchange` redeems the PKCE code for a raw PAT; `me` confirms
15
31
  * identity + orgs with that PAT. Both are injected so a stub backend drives the CLI tests.
16
32
  */
17
33
  export interface AuthBackend {
18
- /** POST /access-tokens/exchange {code, code_verifier} → the raw PAT ("vct_…"). */
19
- exchange(code: string, codeVerifier: string): Promise<string>;
34
+ /** POST /access-tokens/exchange {code, code_verifier, label?} → the raw PAT and what the api reports. */
35
+ exchange(code: string, codeVerifier: string, label?: string): Promise<ExchangeResult>;
20
36
  /** GET /me with Bearer <pat> → identity + orgs. */
21
37
  me(pat: string): Promise<Identity>;
22
38
  }
39
+ /** DELETE /access-tokens/current gives up after this long: sign-out never hangs on the api. */
40
+ export declare const REVOKE_CURRENT_TIMEOUT_MS = 5000;
23
41
  /** The real HTTP-backed AuthBackend, pointed at a Vincentt API base URL. */
24
42
  export declare function httpAuthBackend(apiBase: string): AuthBackend;
43
+ /**
44
+ * End THIS machine's sign-in on the platform (DELETE /access-tokens/current),
45
+ * best-effort. 204 is done; 400, 401, 403, 404 and 405 count as done too: an
46
+ * older api has no such route, or the sign-in had already ended. A network
47
+ * failure, a timeout or a 5xx is "unreachable".
48
+ */
49
+ export declare function revokeCurrent(apiBase: string, pat: string, fetchImpl?: typeof fetch, timeoutMs?: number): Promise<RevokeOutcome>;
25
50
  /**
26
51
  * Map the /me response shape onto our Identity. The backend returns memberships with an org
27
52
  * name + kind; we keep the projection resilient to the exact field names the Go handler emits
@@ -3,12 +3,16 @@
3
3
  // backend (QA-F1-C2/C4/C5) without real HTTP.
4
4
  //
5
5
  // Note the CLI does NOT call POST /access-tokens — the browser/console does that (it holds
6
- // the JWT session; a PAT may not mint a PAT). The CLI only redeems the returned auth_code:
7
- // POST /access-tokens/exchange {code, code_verifier} → {token} (raw PAT, once, in body)
8
- // GET /me Bearer <PAT> → identity + orgs (confirm)
6
+ // the session; a PAT may not mint a PAT). The CLI only redeems the returned auth_code:
7
+ // POST /access-tokens/exchange {code, code_verifier, label}
8
+ // → {token, label, evicted?} (raw PAT, once, in body)
9
+ // GET /me Bearer <PAT> → identity + orgs (confirm)
10
+ // and, to end this machine's own sign-in, DELETE /access-tokens/current Bearer <PAT>.
9
11
  import { authFailureMessage } from "../shared/authError.js";
10
12
  import { clientHeaders } from "../shared/clientHeader.js";
11
- import { apiFetch } from "../shared/http.js";
13
+ import { apiFetch, withDeadline } from "../shared/http.js";
14
+ /** DELETE /access-tokens/current gives up after this long: sign-out never hangs on the api. */
15
+ export const REVOKE_CURRENT_TIMEOUT_MS = 5_000;
12
16
  class AuthError extends Error {
13
17
  status;
14
18
  constructor(message, status) {
@@ -25,23 +29,64 @@ async function safeText(res) {
25
29
  return "";
26
30
  }
27
31
  }
32
+ /** The error code a JSON error body carries, if any. */
33
+ function errorCode(text) {
34
+ try {
35
+ const body = JSON.parse(text);
36
+ return typeof body.code === "string" ? body.code : undefined;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ function readEvicted(raw) {
43
+ if (!Array.isArray(raw) || raw.length === 0)
44
+ return undefined;
45
+ return raw.map((e) => {
46
+ const r = (e ?? {});
47
+ return {
48
+ label: typeof r.label === "string" ? r.label : "",
49
+ lastUsedAt: typeof r.lastUsedAt === "string" ? r.lastUsedAt : null,
50
+ };
51
+ });
52
+ }
28
53
  /** The real HTTP-backed AuthBackend, pointed at a Vincentt API base URL. */
29
54
  export function httpAuthBackend(apiBase) {
30
55
  const base = apiBase.replace(/\/$/, "");
56
+ const post = (body) => apiFetch(`${base}/access-tokens/exchange`, {
57
+ method: "POST",
58
+ headers: { ...clientHeaders(), "content-type": "application/json" },
59
+ body: JSON.stringify(body),
60
+ });
31
61
  return {
32
- async exchange(code, codeVerifier) {
33
- const res = await apiFetch(`${base}/access-tokens/exchange`, {
34
- method: "POST",
35
- headers: { ...clientHeaders(), "content-type": "application/json" },
36
- body: JSON.stringify({ code, code_verifier: codeVerifier }),
37
- });
62
+ async exchange(code, codeVerifier, label) {
63
+ const body = { code, code_verifier: codeVerifier };
64
+ if (label !== undefined)
65
+ body.label = label;
66
+ let res = await post(body);
67
+ if (!res.ok && res.status === 400 && label !== undefined) {
68
+ const text = await safeText(res);
69
+ // An api older than machine sign-ins decodes strictly and refuses the
70
+ // unknown `label` field with bad_request BEFORE it consumes the code, so
71
+ // the same code and verifier are still good: retry once without it. Any
72
+ // other refusal (label_invalid included) is final.
73
+ if (errorCode(text) !== "bad_request") {
74
+ throw new AuthError(`Token exchange failed (${res.status}): ${text}`, res.status);
75
+ }
76
+ res = await post({ code, code_verifier: codeVerifier });
77
+ }
38
78
  if (!res.ok) {
39
79
  throw new AuthError(`Token exchange failed (${res.status}): ${await safeText(res)}`, res.status);
40
80
  }
41
- const body = (await res.json());
42
- if (!body.token)
81
+ const out = (await res.json());
82
+ if (typeof out.token !== "string" || !out.token) {
43
83
  throw new AuthError("Token exchange returned no token.");
44
- return body.token;
84
+ }
85
+ return {
86
+ token: out.token,
87
+ label: typeof out.label === "string" ? out.label : undefined,
88
+ evicted: readEvicted(out.evicted),
89
+ };
45
90
  },
46
91
  async me(pat) {
47
92
  const res = await apiFetch(`${base}/me`, {
@@ -54,6 +99,27 @@ export function httpAuthBackend(apiBase) {
54
99
  },
55
100
  };
56
101
  }
102
+ /**
103
+ * End THIS machine's sign-in on the platform (DELETE /access-tokens/current),
104
+ * best-effort. 204 is done; 400, 401, 403, 404 and 405 count as done too: an
105
+ * older api has no such route, or the sign-in had already ended. A network
106
+ * failure, a timeout or a 5xx is "unreachable".
107
+ */
108
+ export async function revokeCurrent(apiBase, pat, fetchImpl = (input, init) => globalThis.fetch(input, init), timeoutMs = REVOKE_CURRENT_TIMEOUT_MS) {
109
+ const base = apiBase.replace(/\/$/, "");
110
+ try {
111
+ const res = await withDeadline(fetchImpl, timeoutMs)(`${base}/access-tokens/current`, {
112
+ method: "DELETE",
113
+ headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
114
+ });
115
+ if (res.ok || [400, 401, 403, 404, 405].includes(res.status))
116
+ return "done";
117
+ return "unreachable";
118
+ }
119
+ catch {
120
+ return "unreachable";
121
+ }
122
+ }
57
123
  /**
58
124
  * Map the /me response shape onto our Identity. The backend returns memberships with an org
59
125
  * name + kind; we keep the projection resilient to the exact field names the Go handler emits
@@ -1,4 +1,4 @@
1
- import type { Identity, Membership } from "./authBackend.js";
1
+ import type { EvictedMachine, Identity, Membership, RevokeOutcome } from "./authBackend.js";
2
2
  /** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
3
3
  export declare function displayConfigPath(absPath: string, home: string): string;
4
4
  /** The active org is the personal one (login lands on personal; the CLI never prompts). */
@@ -9,6 +9,12 @@ export declare function activeOrg(memberships: Membership[]): Membership | undef
9
9
  * org gets just the active-org line (no trailing clause).
10
10
  */
11
11
  export declare function orgSummaryLine(identity: Identity): string;
12
+ /**
13
+ * The line naming a machine the 10-machine cap signed out (Flow L5). No glyph:
14
+ * it is not an advisory about this machine; no command: the other machine signs
15
+ * in again if someone wants it.
16
+ */
17
+ export declare function evictionLine(e: EvictedMachine): string;
12
18
  export interface LoginCommandDeps {
13
19
  /** Print a line to the user (stdout for the transcript). */
14
20
  print: (line: string) => void;
@@ -16,13 +22,22 @@ export interface LoginCommandDeps {
16
22
  currentToken: () => Promise<string | undefined>;
17
23
  /** Confirm who the present token belongs to (GET /me). Throws the client's error unchanged. */
18
24
  whoAmI: () => Promise<Identity>;
19
- /** Run the loopback + PKCE flow; resolves identity + the saved config path. */
25
+ /** Run the loopback + PKCE flow; resolves identity, the saved config path and what the api reported. */
20
26
  runLogin: () => Promise<{
21
27
  identity: Identity;
22
28
  configPath: string;
29
+ label?: string;
30
+ evicted?: EvictedMachine[];
23
31
  }>;
24
- /** Forget the existing token before a --force re-login. */
32
+ /** Forget a stored token the api no longer accepts. */
25
33
  clearToken: () => Promise<void>;
34
+ /** The token saved in this machine's config (never the env override), read before a --force login. */
35
+ storedToken?: () => Promise<string | undefined>;
36
+ /**
37
+ * End a replaced token's sign-in on the platform, best-effort and silent. Called with
38
+ * the OLD token only after the new one is saved.
39
+ */
40
+ revokeToken?: (pat: string) => Promise<unknown>;
26
41
  /** For displaying the config path as ~/…. */
27
42
  home: string;
28
43
  /** Absolute config path (for the L2 already-signed-in line). */
@@ -37,3 +52,20 @@ export interface LoginCommandOptions {
37
52
  * exact retry command and return 1. No stack trace, no token printed.
38
53
  */
39
54
  export declare function loginCommand(deps: LoginCommandDeps, opts?: LoginCommandOptions): Promise<number>;
55
+ export interface LogoutDeps {
56
+ /** Delete the local token; reports whether there was one. */
57
+ clear: () => Promise<{
58
+ path: string;
59
+ hadToken: boolean;
60
+ }>;
61
+ /** How the platform sign-out (DELETE /access-tokens/current) ended, asked BEFORE clearing. */
62
+ outcome: RevokeOutcome;
63
+ /** For displaying the config path as ~/…. */
64
+ home?: string;
65
+ }
66
+ /**
67
+ * `vincentt logout` (Flow L7): the platform sign-out was already attempted; the
68
+ * local token is ALWAYS deleted, and only an api that could not be reached adds
69
+ * a fact line, because that machine then stays listed in the console.
70
+ */
71
+ export declare function logoutLines(deps: LogoutDeps): Promise<string[]>;
@@ -5,8 +5,10 @@
5
5
  //
6
6
  // The IO boundary (print, current identity, run the PKCE flow, clock) is injected so every
7
7
  // L1–L4 branch is unit-testable with no real browser, socket, or config file.
8
+ import os from "node:os";
8
9
  import { isAuthRefused } from "../shared/authError.js";
9
10
  import { EXIT } from "../lib/exitCodes.js";
11
+ import { dayMonth, sanitizeServerLabel } from "./machineLabel.js";
10
12
  /** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
11
13
  export function displayConfigPath(absPath, home) {
12
14
  if (home && absPath.startsWith(home))
@@ -32,6 +34,18 @@ export function orgSummaryLine(identity) {
32
34
  const base = ` Active workspace: ${activeName}`;
33
35
  return others.length ? `${base} · also a member of: ${others.join(", ")}` : base;
34
36
  }
37
+ /**
38
+ * The line naming a machine the 10-machine cap signed out (Flow L5). No glyph:
39
+ * it is not an advisory about this machine; no command: the other machine signs
40
+ * in again if someone wants it.
41
+ */
42
+ export function evictionLine(e) {
43
+ const label = sanitizeServerLabel(e.label);
44
+ const when = e.lastUsedAt
45
+ ? `last used ${dayMonth(new Date(e.lastUsedAt))}`
46
+ : "never used";
47
+ return ` Signed out "${label}", the least recently used of your 10 signed-in machines (${when}).`;
48
+ }
35
49
  /**
36
50
  * Drive `vincentt login`. Returns an exit code (0 ok, 1 failed). Never throws for expected
37
51
  * failure states (timeout, cancel, state-mismatch) — they print a ✗ line ending with the
@@ -41,7 +55,19 @@ export async function loginCommand(deps, opts = {}) {
41
55
  const displayPath = displayConfigPath(deps.configPath, deps.home);
42
56
  if (opts.force) {
43
57
  deps.print("→ Replacing the existing token on this machine…");
44
- await deps.clearToken();
58
+ // The old token stays on disk until the new one is saved: a failed login must
59
+ // never leave the machine with neither.
60
+ const old = deps.storedToken ? await deps.storedToken() : undefined;
61
+ const code = await runLoginFlow(deps);
62
+ if (code === EXIT.OK && old && deps.revokeToken) {
63
+ try {
64
+ await deps.revokeToken(old);
65
+ }
66
+ catch {
67
+ /* best-effort: the old sign-in ages out or is signed out in the console */
68
+ }
69
+ }
70
+ return code;
45
71
  }
46
72
  else {
47
73
  // L2: already signed in, but only once the api confirms the token.
@@ -78,9 +104,15 @@ function isTokenRejected(err) {
78
104
  async function runLoginFlow(deps) {
79
105
  // L1 / L4 — run the loopback + PKCE flow.
80
106
  try {
81
- const { identity, configPath } = await deps.runLogin();
82
- deps.print(`✓ Signed in as ${identity.email}`);
107
+ const { identity, configPath, label, evicted } = await deps.runLogin();
108
+ // The label is the api's ECHO, never what was sent: an older api never saw it.
109
+ const named = label ? sanitizeServerLabel(label) : "";
110
+ deps.print(named
111
+ ? `✓ Signed in as ${identity.email} on this machine (${named})`
112
+ : `✓ Signed in as ${identity.email}`);
83
113
  deps.print(orgSummaryLine(identity));
114
+ for (const e of evicted ?? [])
115
+ deps.print(evictionLine(e));
84
116
  deps.print(` Token saved to ${displayConfigPath(configPath, deps.home)}`);
85
117
  deps.print("");
86
118
  deps.print("Run `vincentt --help` to see what you can do.");
@@ -94,3 +126,20 @@ async function runLoginFlow(deps) {
94
126
  return 1;
95
127
  }
96
128
  }
129
+ /**
130
+ * `vincentt logout` (Flow L7): the platform sign-out was already attempted; the
131
+ * local token is ALWAYS deleted, and only an api that could not be reached adds
132
+ * a fact line, because that machine then stays listed in the console.
133
+ */
134
+ export async function logoutLines(deps) {
135
+ const { path: p, hadToken } = await deps.clear();
136
+ if (!hadToken)
137
+ return ["Already signed out."];
138
+ const lines = [
139
+ `✓ Signed out. Token removed from ${displayConfigPath(p, deps.home ?? os.homedir())}`,
140
+ ];
141
+ if (deps.outcome === "unreachable") {
142
+ lines.push(" The api could not be reached. This machine stays listed under Signed-in machines until it is", " signed out there or goes 90 days unused.");
143
+ }
144
+ return lines;
145
+ }
@@ -0,0 +1,46 @@
1
+ export declare const MACHINE_LABEL_MAX_CODE_POINTS = 64;
2
+ export declare const MACHINE_LABEL_MAX_MARK_RUN = 4;
3
+ export declare const LEGACY_MACHINE_LABEL = "Signed in before machine names";
4
+ export declare const DEFAULT_MACHINE_LABEL_PREFIX = "Unnamed machine,";
5
+ export declare const MACHINE_LABEL_REFUSED_LINES: readonly string[];
6
+ /** "d Mon" in the given date's own calendar fields (local time unless the caller says otherwise). */
7
+ export declare function dayMonth(d: Date, utc?: boolean): string;
8
+ /** The default label, "<OS> <arch>, <d Mon>", from injected platform, arch and clock. */
9
+ export declare function defaultMachineLabel(platform?: string, arch?: string, now?: Date): string;
10
+ export type LabelCheck = {
11
+ ok: true;
12
+ label: string;
13
+ } | {
14
+ ok: false;
15
+ };
16
+ /**
17
+ * The api's label rules, for a label the person typed: NFC, trim U+0020 only,
18
+ * then 1 to 64 code points, each a letter, mark, number, punctuation, symbol or
19
+ * plain space, no run of more than 4 marks, and not a platform-only string. An
20
+ * empty label is refused here (the api would store its default, but `--label ""`
21
+ * is a mistake, not a request for the default).
22
+ */
23
+ export declare function validateMachineLabel(raw: string): LabelCheck;
24
+ /**
25
+ * Drop every code point outside the label allowlist before a server-sent label
26
+ * is printed. The api already refuses such labels; this keeps a later
27
+ * validation bug from writing escape sequences or bidi controls to a terminal.
28
+ */
29
+ export declare function sanitizeServerLabel(label: unknown): string;
30
+ export type LoginLabel = {
31
+ ok: true;
32
+ label: string;
33
+ } | {
34
+ ok: false;
35
+ lines: readonly string[];
36
+ };
37
+ /**
38
+ * The label a `vincentt login` sends. Without --label it is the default; with it,
39
+ * the typed value must pass the api's rules, or the refusal lines are returned
40
+ * and the login must stop before any browser or network.
41
+ */
42
+ export declare function resolveLoginLabel(given: boolean, raw: string | undefined, defaults?: {
43
+ platform?: string;
44
+ arch?: string;
45
+ now?: Date;
46
+ }): LoginLabel;
@@ -0,0 +1,119 @@
1
+ // The machine label `vincentt login` sends with the exchange: a name for this
2
+ // machine's sign-in that the console lists and the eviction line prints.
3
+ //
4
+ // It is built from the OS, the architecture and today's date, NEVER the hostname:
5
+ // a hostname often carries a person's name or an employer's asset tag, and the
6
+ // label is shown back to the person. `--label` overrides it.
7
+ //
8
+ // validateMachineLabel applies the api's exact rules, counting CODE POINTS after
9
+ // NFC (not UTF-16 units), so `--label` is refused here, before any browser or
10
+ // network, for exactly what the api would refuse. The shared corpus
11
+ // (__fixtures__/machine_label_corpus.json, copied from the api) pins agreement.
12
+ export const MACHINE_LABEL_MAX_CODE_POINTS = 64;
13
+ export const MACHINE_LABEL_MAX_MARK_RUN = 4;
14
+ export const LEGACY_MACHINE_LABEL = "Signed in before machine names";
15
+ export const DEFAULT_MACHINE_LABEL_PREFIX = "Unnamed machine,";
16
+ const MONTHS = [
17
+ "Jan",
18
+ "Feb",
19
+ "Mar",
20
+ "Apr",
21
+ "May",
22
+ "Jun",
23
+ "Jul",
24
+ "Aug",
25
+ "Sep",
26
+ "Oct",
27
+ "Nov",
28
+ "Dec",
29
+ ];
30
+ export const MACHINE_LABEL_REFUSED_LINES = [
31
+ "✗ A machine label must be 1 to 64 letters, numbers, spaces, punctuation or symbols.",
32
+ ' Run `vincentt login --label "<name>"` with a shorter name, or leave out --label.',
33
+ ];
34
+ /** "d Mon" in the given date's own calendar fields (local time unless the caller says otherwise). */
35
+ export function dayMonth(d, utc = false) {
36
+ const day = utc ? d.getUTCDate() : d.getDate();
37
+ const month = utc ? d.getUTCMonth() : d.getMonth();
38
+ return `${day} ${MONTHS[month]}`;
39
+ }
40
+ function osName(platform) {
41
+ if (platform === "darwin")
42
+ return "macOS";
43
+ if (platform === "win32")
44
+ return "Windows";
45
+ if (platform === "linux")
46
+ return "Linux";
47
+ return platform;
48
+ }
49
+ /** The default label, "<OS> <arch>, <d Mon>", from injected platform, arch and clock. */
50
+ export function defaultMachineLabel(platform = process.platform, arch = process.arch, now = new Date()) {
51
+ return `${osName(platform)} ${arch}, ${dayMonth(now)}`;
52
+ }
53
+ const ALLOWED = /^[\p{L}\p{M}\p{N}\p{P}\p{S} ]$/u;
54
+ const MARK = /^\p{M}$/u;
55
+ function trimSpaces(s) {
56
+ return s.replace(/^ +| +$/g, "");
57
+ }
58
+ function fold(s) {
59
+ return s.normalize("NFKC").toLowerCase();
60
+ }
61
+ const FOLDED_LEGACY = fold(LEGACY_MACHINE_LABEL);
62
+ const FOLDED_DEFAULT_PREFIX = fold(DEFAULT_MACHINE_LABEL_PREFIX);
63
+ /**
64
+ * The api's label rules, for a label the person typed: NFC, trim U+0020 only,
65
+ * then 1 to 64 code points, each a letter, mark, number, punctuation, symbol or
66
+ * plain space, no run of more than 4 marks, and not a platform-only string. An
67
+ * empty label is refused here (the api would store its default, but `--label ""`
68
+ * is a mistake, not a request for the default).
69
+ */
70
+ export function validateMachineLabel(raw) {
71
+ const label = trimSpaces(raw.normalize("NFC"));
72
+ const points = [...label];
73
+ if (points.length < 1 || points.length > MACHINE_LABEL_MAX_CODE_POINTS)
74
+ return { ok: false };
75
+ let markRun = 0;
76
+ for (const cp of points) {
77
+ if (!ALLOWED.test(cp))
78
+ return { ok: false };
79
+ if (MARK.test(cp)) {
80
+ markRun++;
81
+ if (markRun > MACHINE_LABEL_MAX_MARK_RUN)
82
+ return { ok: false };
83
+ }
84
+ else {
85
+ markRun = 0;
86
+ }
87
+ }
88
+ // Case folding approximated by NFKC + toLowerCase; the shared corpus pins
89
+ // agreement with the api's NFKC + Unicode case fold.
90
+ const folded = trimSpaces(fold(label));
91
+ if (folded === FOLDED_LEGACY || folded.startsWith(FOLDED_DEFAULT_PREFIX))
92
+ return { ok: false };
93
+ return { ok: true, label };
94
+ }
95
+ /**
96
+ * Drop every code point outside the label allowlist before a server-sent label
97
+ * is printed. The api already refuses such labels; this keeps a later
98
+ * validation bug from writing escape sequences or bidi controls to a terminal.
99
+ */
100
+ export function sanitizeServerLabel(label) {
101
+ if (typeof label !== "string")
102
+ return "";
103
+ return [...label].filter((cp) => ALLOWED.test(cp)).join("");
104
+ }
105
+ /**
106
+ * The label a `vincentt login` sends. Without --label it is the default; with it,
107
+ * the typed value must pass the api's rules, or the refusal lines are returned
108
+ * and the login must stop before any browser or network.
109
+ */
110
+ export function resolveLoginLabel(given, raw, defaults = {}) {
111
+ if (!given) {
112
+ return {
113
+ ok: true,
114
+ label: defaultMachineLabel(defaults.platform, defaults.arch, defaults.now),
115
+ };
116
+ }
117
+ const check = validateMachineLabel(raw ?? "");
118
+ return check.ok ? check : { ok: false, lines: MACHINE_LABEL_REFUSED_LINES };
119
+ }
@@ -1,4 +1,4 @@
1
- import { type AuthBackend, type Identity } from "./authBackend.js";
1
+ import { type AuthBackend, type EvictedMachine, type Identity } from "./authBackend.js";
2
2
  export interface PkceLoginOptions {
3
3
  /** Console origin that serves /cli-auth (browser target). */
4
4
  consoleUrl: string;
@@ -6,6 +6,8 @@ export interface PkceLoginOptions {
6
6
  apiUrl: string;
7
7
  /** Abort if the browser round-trip doesn't complete in time. Default 5 min. */
8
8
  timeoutMs?: number;
9
+ /** The machine label sent with the exchange (already validated). */
10
+ label?: string;
9
11
  /** Backend calls (exchange + me). Default: real HTTP against apiUrl. */
10
12
  backend?: AuthBackend;
11
13
  /** Pick a free loopback port. Default: the OS ephemeral picker. */
@@ -20,6 +22,10 @@ export interface PkceLoginOptions {
20
22
  export interface PkceLoginResult {
21
23
  identity: Identity;
22
24
  configPath: string;
25
+ /** The label the api stored, echoed back; absent from an older api. */
26
+ label?: string;
27
+ /** Machines this login signed out at the 10-machine cap. */
28
+ evicted?: EvictedMachine[];
23
29
  }
24
30
  /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
25
31
  export declare class LoginTimeoutError extends Error {
@@ -5,7 +5,8 @@
5
5
  // 2. Mint state + PKCE code_verifier/code_challenge (S256).
6
6
  // 3. Open the browser to /cli-auth?redirect_uri=<loopback>&state=…&code_challenge=….
7
7
  // 4. On the loopback callback: verify state constant-time (reject mismatch → no exchange).
8
- // 5. POST /access-tokens/exchange {code, code_verifier} → raw PAT (in body, once).
8
+ // 5. POST /access-tokens/exchange {code, code_verifier, label} → raw PAT (in body, once),
9
+ // the stored label, and any machines the 10-machine cap signed out.
9
10
  // 6. Write ~/.vincentt/config.json, then GET /me Bearer <PAT> to confirm identity + orgs.
10
11
  //
11
12
  // Every external boundary is injected (getFreePort, listen, openBrowser, backend, clock/
@@ -14,7 +15,7 @@
14
15
  import { getFreePort } from "../preview/net.js";
15
16
  import { writeMachineConfig } from "../shared/config.js";
16
17
  import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
17
- import { httpAuthBackend } from "./authBackend.js";
18
+ import { httpAuthBackend, } from "./authBackend.js";
18
19
  import { waitForLoopbackCallback } from "./loopback.js";
19
20
  /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
20
21
  export class LoginTimeoutError extends Error {
@@ -166,8 +167,9 @@ export async function runPkceLogin(opts) {
166
167
  },
167
168
  });
168
169
  // (5) redeem the code for the raw PAT; (6) persist then confirm identity.
169
- const pat = await backend.exchange(code, material.codeVerifier);
170
+ const exchanged = await backend.exchange(code, material.codeVerifier, opts.label);
171
+ const pat = exchanged.token;
170
172
  const configPath = await saveToken(opts.apiUrl.replace(/\/$/, ""), pat);
171
173
  const identity = await backend.me(pat);
172
- return { identity, configPath };
174
+ return { identity, configPath, label: exchanged.label, evicted: exchanged.evicted };
173
175
  }
@@ -448,7 +448,7 @@ export function endedUnreachable(orgName) {
448
448
  export function endedAuthExpired() {
449
449
  return [
450
450
  "",
451
- "✗ Preview ended. Your Vincentt access token is no longer valid.",
451
+ "✗ Preview ended. This machine is no longer signed in to Vincentt.",
452
452
  "",
453
453
  " Sign in again: vincentt login",
454
454
  " Then start a new preview: vincentt preview",
@@ -1,4 +1,4 @@
1
- export declare const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
1
+ export declare const AUTH_EXPIRED_MESSAGE = "This machine is no longer signed in to Vincentt. Run `vincentt login` to sign in again.";
2
2
  export declare function authFailureMessage(label: string, status: number, detail: string): string;
3
3
  /**
4
4
  * The api refused the token. Distinct from every other refusal because the
@@ -3,7 +3,9 @@
3
3
  // "(401): <body>" is unactionable noise for every user at once. Name the cause and give
4
4
  // the one instruction that fixes it. Any other status keeps its existing "(<n>): <body>"
5
5
  // shape: a 500/timeout is a different problem and must not be mislabeled a stale token.
6
- export const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
6
+ // A machine's sign-in ends when it is signed out in the console, signed out at the
7
+ // 10-machine cap, or left unused for 90 days; all three reach the CLI as this 401.
8
+ export const AUTH_EXPIRED_MESSAGE = "This machine is no longer signed in to Vincentt. Run `vincentt login` to sign in again.";
7
9
  export function authFailureMessage(label, status, detail) {
8
10
  if (status === 401)
9
11
  return AUTH_EXPIRED_MESSAGE;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",