@vincentt-xr/cli 0.19.0 → 0.21.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 (67) hide show
  1. package/MIGRATION.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +95 -13
  4. package/dist/cli/usage-text.js +3 -2
  5. package/dist/domain/api.js +16 -12
  6. package/dist/domain/command.js +9 -0
  7. package/dist/dx/command.js +3 -0
  8. package/dist/dx/consent.js +10 -14
  9. package/dist/dx/reportIo.js +6 -4
  10. package/dist/dx/send.js +6 -4
  11. package/dist/lib/backend.js +5 -10
  12. package/dist/lib/exitCodes.d.ts +6 -0
  13. package/dist/lib/exitCodes.js +6 -0
  14. package/dist/lib/terminalText.d.ts +6 -0
  15. package/dist/lib/terminalText.js +10 -0
  16. package/dist/lib/whoami.js +3 -0
  17. package/dist/library/assetsCommand.js +4 -0
  18. package/dist/library/client.js +6 -8
  19. package/dist/login/authBackend.d.ts +27 -2
  20. package/dist/login/authBackend.js +86 -17
  21. package/dist/login/loginCommand.d.ts +35 -3
  22. package/dist/login/loginCommand.js +81 -5
  23. package/dist/login/machineLabel.d.ts +46 -0
  24. package/dist/login/machineLabel.js +119 -0
  25. package/dist/login/pkceLogin.d.ts +17 -1
  26. package/dist/login/pkceLogin.js +30 -5
  27. package/dist/platform/releaseFetch.js +36 -69
  28. package/dist/preview/api.js +9 -12
  29. package/dist/preview/command.d.ts +3 -2
  30. package/dist/preview/command.js +8 -5
  31. package/dist/preview/copy.d.ts +8 -1
  32. package/dist/preview/copy.js +17 -4
  33. package/dist/preview/proxy.js +5 -39
  34. package/dist/preview/runner.d.ts +25 -9
  35. package/dist/preview/runner.js +42 -15
  36. package/dist/publish/api.js +14 -14
  37. package/dist/publish/command.d.ts +3 -0
  38. package/dist/publish/command.js +71 -7
  39. package/dist/publish/copy.d.ts +9 -0
  40. package/dist/publish/copy.js +83 -14
  41. package/dist/publish/keyScan.d.ts +25 -0
  42. package/dist/publish/keyScan.js +88 -0
  43. package/dist/publish/manifest.d.ts +51 -19
  44. package/dist/publish/manifest.js +246 -63
  45. package/dist/publish/upload.d.ts +8 -0
  46. package/dist/publish/upload.js +15 -0
  47. package/dist/publish/weight.d.ts +23 -0
  48. package/dist/publish/weight.js +113 -0
  49. package/dist/shared/authError.d.ts +1 -1
  50. package/dist/shared/authError.js +3 -1
  51. package/dist/shared/credentialNames.d.ts +17 -0
  52. package/dist/shared/credentialNames.js +72 -0
  53. package/dist/shared/http.d.ts +34 -0
  54. package/dist/shared/http.js +98 -2
  55. package/dist/shared/upgradeError.d.ts +62 -0
  56. package/dist/shared/upgradeError.js +215 -0
  57. package/dist/tunnel/caps.d.ts +19 -0
  58. package/dist/tunnel/caps.js +52 -0
  59. package/dist/tunnel/cbor.d.ts +39 -12
  60. package/dist/tunnel/cbor.js +84 -10
  61. package/dist/tunnel/client.d.ts +48 -2
  62. package/dist/tunnel/client.js +157 -44
  63. package/dist/tunnel/frame.d.ts +1 -1
  64. package/dist/tunnel/frame.js +8 -4
  65. package/dist/tunnel/index.d.ts +3 -2
  66. package/dist/tunnel/index.js +3 -2
  67. package/package.json +4 -3
@@ -18,6 +18,7 @@ import { DOWNLOAD_DIR, downloadPath, exists, overwriteRefusalLines, writeAsset,
18
18
  import { dedupeForDisplay, parseIdArgs, parseSearchFlags } from "./parse.js";
19
19
  import { addLines, humanBytes, searchLines, showLines, unknownBlockLines, unreachableLines, } from "./render.js";
20
20
  import { isLibraryId } from "./wire.js";
21
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
21
22
  /**
22
23
  * Map a thrown client error onto the exit-4 screen it deserves.
23
24
  *
@@ -28,6 +29,9 @@ import { isLibraryId } from "./wire.js";
28
29
  * corpus failed to load.
29
30
  */
30
31
  function failureScreen(err, retry) {
32
+ // Rethrown so main() renders the one refusal screen and exits 5.
33
+ if (err instanceof UpgradeRequiredError)
34
+ throw err;
31
35
  if (err instanceof LibraryUnreachableError) {
32
36
  return { lines: unreachableLines("transport", retry), code: 4 };
33
37
  }
@@ -12,9 +12,9 @@
12
12
  // CLI-supplied URL reaches a fetch. That is a security control, not a style choice
13
13
  // (D-add-prints-urls degraded the no-invented-URLs rule from prevention to
14
14
  // detection, and this is the one arm that stayed prevention).
15
- import { clientHeaders } from "../shared/clientHeader.js";
16
15
  import { authFailureMessage } from "../shared/authError.js";
17
- import { apiFetch, withDeadline } from "../shared/http.js";
16
+ import { apiRequest, withDeadline } from "../shared/http.js";
17
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
18
18
  import { parseBatchResponse, parseFetchResponse, parseSearchResponse, } from "./wire.js";
19
19
  // An asset file can be tens of megabytes, so its download gets longer than an api call.
20
20
  const downloadFetch = withDeadline((input, init) => globalThis.fetch(input, init), 5 * 60_000);
@@ -65,18 +65,16 @@ async function readJson(res) {
65
65
  * correct recovery differs between them (retry later vs re-search).
66
66
  */
67
67
  export function httpLibraryClient(cfg) {
68
- const base = cfg.apiUrl.replace(/\/$/, "");
69
- const auth = { ...clientHeaders(), authorization: `Bearer ${cfg.pat}` };
70
68
  const call = async (pathname, init) => {
69
+ let res;
71
70
  try {
72
- return await apiFetch(`${base}${pathname}`, {
73
- ...init,
74
- headers: { ...(init?.headers ?? {}), ...auth },
75
- });
71
+ res = await apiRequest(cfg, pathname, init, { authenticated: true });
76
72
  }
77
73
  catch (err) {
78
74
  throw new LibraryUnreachableError(err);
79
75
  }
76
+ await rejectUpgradeRequired(res);
77
+ return res;
80
78
  };
81
79
  const fail = async (res, what) => {
82
80
  // A 401 keeps its status: assetsCommand maps it to the sign-in screen and exit 4.
@@ -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
- import { clientHeaders } from "../shared/clientHeader.js";
11
- import { apiFetch } from "../shared/http.js";
12
+ import { apiRequest, withDeadline } from "../shared/http.js";
13
+ import { rejectUpgradeRequired } from "../shared/upgradeError.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,28 +29,76 @@ 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
- const base = apiBase.replace(/\/$/, "");
55
+ const target = { apiUrl: apiBase };
56
+ // The exchange is unauthenticated but judged by the CLI floor, so it carries
57
+ // the tag like every other api request and its 426 is read before any arm.
58
+ const post = async (body) => {
59
+ const res = await apiRequest(target, "/access-tokens/exchange", {
60
+ method: "POST",
61
+ headers: { "content-type": "application/json" },
62
+ body: JSON.stringify(body),
63
+ }, { authenticated: false });
64
+ await rejectUpgradeRequired(res);
65
+ return res;
66
+ };
31
67
  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
- });
68
+ async exchange(code, codeVerifier, label) {
69
+ const body = { code, code_verifier: codeVerifier };
70
+ if (label !== undefined)
71
+ body.label = label;
72
+ let res = await post(body);
73
+ if (!res.ok && res.status === 400 && label !== undefined) {
74
+ const text = await safeText(res);
75
+ // An api older than machine sign-ins decodes strictly and refuses the
76
+ // unknown `label` field with bad_request BEFORE it consumes the code, so
77
+ // the same code and verifier are still good: retry once without it. Any
78
+ // other refusal (label_invalid included) is final.
79
+ if (errorCode(text) !== "bad_request") {
80
+ throw new AuthError(`Token exchange failed (${res.status}): ${text}`, res.status);
81
+ }
82
+ res = await post({ code, code_verifier: codeVerifier });
83
+ }
38
84
  if (!res.ok) {
39
85
  throw new AuthError(`Token exchange failed (${res.status}): ${await safeText(res)}`, res.status);
40
86
  }
41
- const body = (await res.json());
42
- if (!body.token)
87
+ const out = (await res.json());
88
+ if (typeof out.token !== "string" || !out.token) {
43
89
  throw new AuthError("Token exchange returned no token.");
44
- return body.token;
90
+ }
91
+ return {
92
+ token: out.token,
93
+ label: typeof out.label === "string" ? out.label : undefined,
94
+ evicted: readEvicted(out.evicted),
95
+ };
45
96
  },
46
97
  async me(pat) {
47
- const res = await apiFetch(`${base}/me`, {
48
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
98
+ const res = await apiRequest({ apiUrl: apiBase, pat }, "/me", {}, {
99
+ authenticated: true,
49
100
  });
101
+ await rejectUpgradeRequired(res);
50
102
  if (!res.ok) {
51
103
  throw new AuthError(authFailureMessage("Identity check failed", res.status, await safeText(res)), res.status);
52
104
  }
@@ -54,6 +106,23 @@ export function httpAuthBackend(apiBase) {
54
106
  },
55
107
  };
56
108
  }
109
+ /**
110
+ * End THIS machine's sign-in on the platform (DELETE /access-tokens/current),
111
+ * best-effort. 204 is done; 400, 401, 403, 404 and 405 count as done too: an
112
+ * older api has no such route, or the sign-in had already ended. A network
113
+ * failure, a timeout or a 5xx is "unreachable".
114
+ */
115
+ export async function revokeCurrent(apiBase, pat, fetchImpl = (input, init) => globalThis.fetch(input, init), timeoutMs = REVOKE_CURRENT_TIMEOUT_MS) {
116
+ try {
117
+ const res = await apiRequest({ apiUrl: apiBase, pat }, "/access-tokens/current", { method: "DELETE" }, { authenticated: true }, withDeadline(fetchImpl, timeoutMs));
118
+ if (res.ok || [400, 401, 403, 404, 405].includes(res.status))
119
+ return "done";
120
+ return "unreachable";
121
+ }
122
+ catch {
123
+ return "unreachable";
124
+ }
125
+ }
57
126
  /**
58
127
  * Map the /me response shape onto our Identity. The backend returns memberships with an org
59
128
  * 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,12 @@
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";
10
+ import { UpgradeRequiredError, upgradeRefusalLines } from "../shared/upgradeError.js";
9
11
  import { EXIT } from "../lib/exitCodes.js";
12
+ import { dayMonth, sanitizeServerLabel } from "./machineLabel.js";
13
+ import { SignedInThenRefusedError } from "./pkceLogin.js";
10
14
  /** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
11
15
  export function displayConfigPath(absPath, home) {
12
16
  if (home && absPath.startsWith(home))
@@ -32,6 +36,18 @@ export function orgSummaryLine(identity) {
32
36
  const base = ` Active workspace: ${activeName}`;
33
37
  return others.length ? `${base} · also a member of: ${others.join(", ")}` : base;
34
38
  }
39
+ /**
40
+ * The line naming a machine the 10-machine cap signed out (Flow L5). No glyph:
41
+ * it is not an advisory about this machine; no command: the other machine signs
42
+ * in again if someone wants it.
43
+ */
44
+ export function evictionLine(e) {
45
+ const label = sanitizeServerLabel(e.label);
46
+ const when = e.lastUsedAt
47
+ ? `last used ${dayMonth(new Date(e.lastUsedAt))}`
48
+ : "never used";
49
+ return ` Signed out "${label}", the least recently used of your 10 signed-in machines (${when}).`;
50
+ }
35
51
  /**
36
52
  * Drive `vincentt login`. Returns an exit code (0 ok, 1 failed). Never throws for expected
37
53
  * failure states (timeout, cancel, state-mismatch) — they print a ✗ line ending with the
@@ -41,7 +57,21 @@ export async function loginCommand(deps, opts = {}) {
41
57
  const displayPath = displayConfigPath(deps.configPath, deps.home);
42
58
  if (opts.force) {
43
59
  deps.print("→ Replacing the existing token on this machine…");
44
- await deps.clearToken();
60
+ // The old token stays on disk until the new one is saved: a failed login must
61
+ // never leave the machine with neither.
62
+ const old = deps.storedToken ? await deps.storedToken() : undefined;
63
+ const { code, saved } = await signIn(deps);
64
+ // Keyed on the new token being SAVED, not on success: a version refusal
65
+ // after the save still replaced the old token, which must not stay live.
66
+ if (saved && old && deps.revokeToken) {
67
+ try {
68
+ await deps.revokeToken(old);
69
+ }
70
+ catch {
71
+ /* best-effort: the old sign-in ages out or is signed out in the console */
72
+ }
73
+ }
74
+ return code;
45
75
  }
46
76
  else {
47
77
  // L2: already signed in, but only once the api confirms the token.
@@ -52,6 +82,8 @@ export async function loginCommand(deps, opts = {}) {
52
82
  who = await deps.whoAmI();
53
83
  }
54
84
  catch (err) {
85
+ if (err instanceof UpgradeRequiredError)
86
+ return refusedVersion(deps, err);
55
87
  if (!isTokenRejected(err)) {
56
88
  // A down api or a dead network says nothing about the token, so it stays.
57
89
  deps.print("✗ Could not check the account for the token on this machine.");
@@ -76,21 +108,65 @@ function isTokenRejected(err) {
76
108
  (err instanceof Error && err.status === 403));
77
109
  }
78
110
  async function runLoginFlow(deps) {
111
+ return (await signIn(deps)).code;
112
+ }
113
+ /** The flow's exit code, and whether a new token was written to this machine. */
114
+ async function signIn(deps) {
79
115
  // L1 / L4 — run the loopback + PKCE flow.
80
116
  try {
81
- const { identity, configPath } = await deps.runLogin();
82
- deps.print(`✓ Signed in as ${identity.email}`);
117
+ const { identity, configPath, label, evicted } = await deps.runLogin();
118
+ // The label is the api's ECHO, never what was sent: an older api never saw it.
119
+ const named = label ? sanitizeServerLabel(label) : "";
120
+ deps.print(named
121
+ ? `✓ Signed in as ${identity.email} on this machine (${named})`
122
+ : `✓ Signed in as ${identity.email}`);
83
123
  deps.print(orgSummaryLine(identity));
124
+ for (const e of evicted ?? [])
125
+ deps.print(evictionLine(e));
84
126
  deps.print(` Token saved to ${displayConfigPath(configPath, deps.home)}`);
85
127
  deps.print("");
86
128
  deps.print("Run `vincentt --help` to see what you can do.");
87
- return 0;
129
+ return { code: EXIT.OK, saved: true };
88
130
  }
89
131
  catch (err) {
132
+ // Refused after the exchange: the token is on disk, so the screen says so
133
+ // and names any machine this sign-in signed out.
134
+ if (err instanceof SignedInThenRefusedError) {
135
+ const arm = { kind: "login_saved", evictionLines: err.evicted.map(evictionLine) };
136
+ for (const l of upgradeRefusalLines(err, arm))
137
+ deps.print(l);
138
+ return { code: EXIT.UPGRADE_REQUIRED, saved: true };
139
+ }
140
+ // The exchange is judged before anything is minted, so no token was written.
141
+ if (err instanceof UpgradeRequiredError) {
142
+ return { code: refusedVersion(deps, err), saved: false };
143
+ }
90
144
  // L4 — timeout / callback failure: ✗ line + the exact next command. No token written.
91
145
  const message = err instanceof Error ? err.message : "Sign-in failed.";
92
146
  deps.print(`✗ ${message}`);
93
147
  deps.print(" Run `vincentt login` to try again.");
94
- return 1;
148
+ return { code: EXIT.FAILED, saved: false };
149
+ }
150
+ }
151
+ function refusedVersion(deps, err) {
152
+ for (const l of upgradeRefusalLines(err, { kind: "login" }))
153
+ deps.print(l);
154
+ return EXIT.UPGRADE_REQUIRED;
155
+ }
156
+ /**
157
+ * `vincentt logout` (Flow L7): the platform sign-out was already attempted; the
158
+ * local token is ALWAYS deleted, and only an api that could not be reached adds
159
+ * a fact line, because that machine then stays listed in the console.
160
+ */
161
+ export async function logoutLines(deps) {
162
+ const { path: p, hadToken } = await deps.clear();
163
+ if (!hadToken)
164
+ return ["Already signed out."];
165
+ const lines = [
166
+ `✓ Signed out. Token removed from ${displayConfigPath(p, deps.home ?? os.homedir())}`,
167
+ ];
168
+ if (deps.outcome === "unreachable") {
169
+ 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.");
95
170
  }
171
+ return lines;
96
172
  }
@@ -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,5 @@
1
- import { type AuthBackend, type Identity } from "./authBackend.js";
1
+ import { type AuthBackend, type EvictedMachine, type Identity } from "./authBackend.js";
2
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
2
3
  export interface PkceLoginOptions {
3
4
  /** Console origin that serves /cli-auth (browser target). */
4
5
  consoleUrl: string;
@@ -6,6 +7,8 @@ export interface PkceLoginOptions {
6
7
  apiUrl: string;
7
8
  /** Abort if the browser round-trip doesn't complete in time. Default 5 min. */
8
9
  timeoutMs?: number;
10
+ /** The machine label sent with the exchange (already validated). */
11
+ label?: string;
9
12
  /** Backend calls (exchange + me). Default: real HTTP against apiUrl. */
10
13
  backend?: AuthBackend;
11
14
  /** Pick a free loopback port. Default: the OS ephemeral picker. */
@@ -20,11 +23,24 @@ export interface PkceLoginOptions {
20
23
  export interface PkceLoginResult {
21
24
  identity: Identity;
22
25
  configPath: string;
26
+ /** The label the api stored, echoed back; absent from an older api. */
27
+ label?: string;
28
+ /** Machines this login signed out at the 10-machine cap. */
29
+ evicted?: EvictedMachine[];
23
30
  }
24
31
  /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
25
32
  export declare class LoginTimeoutError extends Error {
26
33
  constructor();
27
34
  }
35
+ /**
36
+ * The exchange was admitted and the token SAVED, then the identity check was
37
+ * refused for this CLI's version. Distinct from a refused exchange: a sign-in
38
+ * now exists on this machine, and machines may have been signed out for it.
39
+ */
40
+ export declare class SignedInThenRefusedError extends UpgradeRequiredError {
41
+ readonly evicted: EvictedMachine[];
42
+ constructor(refused: UpgradeRequiredError, evicted?: EvictedMachine[]);
43
+ }
28
44
  /** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
29
45
  export declare class LoginCallbackError extends Error {
30
46
  constructor(message: string);