@vincentt-xr/cli 0.18.3 → 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.
Files changed (41) hide show
  1. package/MIGRATION.md +26 -2
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +43 -12
  4. package/dist/cli/usage-text.js +4 -3
  5. package/dist/lib/terminalText.d.ts +2 -0
  6. package/dist/lib/terminalText.js +7 -0
  7. package/dist/login/authBackend.d.ts +27 -2
  8. package/dist/login/authBackend.js +79 -13
  9. package/dist/login/loginCommand.d.ts +35 -3
  10. package/dist/login/loginCommand.js +52 -3
  11. package/dist/login/machineLabel.d.ts +46 -0
  12. package/dist/login/machineLabel.js +119 -0
  13. package/dist/login/pkceLogin.d.ts +14 -2
  14. package/dist/login/pkceLogin.js +31 -8
  15. package/dist/preview/api.d.ts +15 -0
  16. package/dist/preview/api.js +27 -1
  17. package/dist/preview/command.d.ts +1 -1
  18. package/dist/preview/copy.d.ts +14 -1
  19. package/dist/preview/copy.js +38 -4
  20. package/dist/preview/devPort.d.ts +17 -0
  21. package/dist/preview/devPort.js +14 -0
  22. package/dist/preview/proxy.d.ts +16 -9
  23. package/dist/preview/proxy.js +102 -20
  24. package/dist/preview/runner.d.ts +10 -2
  25. package/dist/preview/runner.js +21 -6
  26. package/dist/preview/stream.d.ts +1 -1
  27. package/dist/preview/viewers.d.ts +1 -1
  28. package/dist/relay/server.js +1 -1
  29. package/dist/shared/authError.d.ts +1 -1
  30. package/dist/shared/authError.js +3 -1
  31. package/dist/tunnel/cbor.d.ts +71 -0
  32. package/dist/tunnel/cbor.js +403 -0
  33. package/dist/tunnel/client.d.ts +225 -0
  34. package/dist/tunnel/client.js +764 -0
  35. package/dist/tunnel/forwardTarget.d.ts +69 -0
  36. package/dist/tunnel/forwardTarget.js +179 -0
  37. package/dist/tunnel/frame.d.ts +126 -0
  38. package/dist/tunnel/frame.js +244 -0
  39. package/dist/tunnel/index.d.ts +9 -0
  40. package/dist/tunnel/index.js +9 -0
  41. package/package.json +2 -2
package/MIGRATION.md CHANGED
@@ -3,12 +3,36 @@
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
+
16
+ ## 0.19.0
17
+
18
+ required: false
19
+ No action required for a project made from the Vincentt template. Behaviour that changed:
20
+ - The platform caps how many previews one person runs at once (5 by default, across every workspace; a preview that is still connecting counts, viewers never do). Re-running `vincentt preview` in a folder that already has a preview is never refused by the cap.
21
+ - `vincentt preview` past the preview cap exits 1, starts nothing and stops nothing, and says how many previews are running and how to stop one: Ctrl-C where it runs, `vincentt preview --stop` in its folder, or Stop on its project's Preview tab in the console.
22
+ - A preview refusal the CLI does not recognise prints the platform's own message instead of "A preview is already running for this folder."
23
+ - `vincentt preview` with no `vincentt.json`, `--port` or `--dev-command` starts the project's `dev` script on a free port, passed to it as `PORT`, instead of 5173, and never reuses a server it finds already listening. Two previews at once, or another server already on 5173, no longer share one app. The settings banner shows the chosen port. A port from `--port` or `vincentt.json` is kept, and a server already listening there is still reused.
24
+ - A preview answers 404 for any path segment shaped like a Windows short name (one to six characters, then `~` and a digit, such as `GIT~1` or `ENVLOC~1`), on every platform. On Windows these names open the dotted files the preview already hides. Bundler chunk names such as `vendors~main` are unaffected.
25
+ - The preview tunnel client is part of the CLI itself instead of being loaded from `@vincentt-xr/harness`. The preview behaves the same, and it works with any harness version a project has installed.
26
+ - A preview answers 404 for files and folders named like credentials, with no opt-out: `credentials.json`, `service-account*.json`, `*firebase-adminsdk*.json`, `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.p8`, `*.ppk`, `id_rsa*`, `id_dsa*`, `id_ed25519*`, `id_ecdsa*`, `*.jks`, `*.keystore`, `*.env`, `*.tfstate`, `*.tfvars`, and their `~` or `.bak` backups, in any letter case. An app asset with one of these names (for example a `.key` file) needs a new name to load in a preview.
27
+ - A preview also refuses a Windows stream name, a name with trailing dots or spaces, and a query or fragment carrying a parent-folder step, a backslash or an encoded slash. The 404 advisory names the path decoded, without control or bidirectional-override characters, and now ends `A preview never serves hidden or credential files from your project.`
28
+ - `vincentt login` and the report consent prompt open a browser only for an `https:` address, or an `http:` address on `localhost`, `127.0.0.1` or `[::1]`; anything else is printed but not opened. On Windows the browser opens through `rundll32` instead of `cmd`.
29
+
6
30
  ## 0.18.3
7
31
 
8
32
  required: false
9
33
  No action required when the preview and the commands reading it (`logs`, `feedback`, `preview --stop`) come from the same CLI version. Behaviour that changed:
10
- - The local preview relay refuses web pages other than the preview itself. It answers only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>`, accepts the diagnostics socket and feedback posts only from the preview's own address (or from a client that sends no Origin), and refuses a POST that is not `application/json` with 415.
11
- - The relay's diagnostics read and feedback long-poll now require a per-session token, which the preview writes to `.vincentt/relay.json` (now readable by the owner only). The CLI's own commands send it; a direct request to `/query` or `feedback/next` without the `x-vincentt-relay-token` header is refused with 401. A command from CLI 0.18.2 or older cannot read a preview started by this version, and the reverse still works.
34
+ - The local preview relay refuses web pages other than the preview itself. It answers only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>`, accepts the diagnostics socket and feedback posts only from the preview's own address (or from a client that sends no Origin), and refuses a POST whose content type is not JSON with 415.
35
+ - The relay's diagnostics read and feedback long-poll now require a per-session token, which the preview writes to the project's relay record (now readable by the owner only). The CLI's own commands send it; a direct diagnostics or feedback request without the `x-vincentt-relay-token` header is refused with 401. A command from CLI 0.18.2 or older cannot read a preview started by this version, and the reverse still works.
12
36
  - `vincentt relay` prints the token it requires and accepts `--origin <url>` for a page it should accept; without it, only pages served from localhost or 127.0.0.1 can connect.
13
37
 
14
38
  ## 0.18.2
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";
@@ -37,13 +38,14 @@ import { USAGE_TEXT } from "./usage-text.js";
37
38
  import { PreviewUi } from "../preview/command.js";
38
39
  import { httpRelayClient } from "../lib/diagnostics.js";
39
40
  import { readLivePreview, relayInfoDir } from "../lib/relayInfo.js";
40
- import { attachedToRunning, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, settingsBanner, startBlock, } from "../preview/copy.js";
41
+ import { attachedToRunning, mintRateLimited, noDiagnosticsStatic, notSignedIn, previewConflict, previewUserLimitRefused, settingsBanner, startBlock, } from "../preview/copy.js";
42
+ import { chooseDevPort } from "../preview/devPort.js";
41
43
  import { isPortListening } from "../preview/net.js";
42
44
  import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, validateField, } from "../lib/settings.js";
43
45
  import { settingsRefusal, sourceWords } from "../lib/settingsCopy.js";
44
46
  import { listsHarness } from "../lib/template.js";
45
47
  import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
46
- import { PreviewConflictError, PreviewLimitError, PreviewRateLimitedError, } from "../preview/api.js";
48
+ import { PreviewConflictError, PreviewLimitError, PreviewRateLimitedError, PreviewUserLimitError, } from "../preview/api.js";
47
49
  import { previewLimitRefused, resolveCountLimit, } from "../shared/countLimit.js";
48
50
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
49
51
  import * as feedbackCopy from "../lib/feedbackCopy.js";
@@ -119,16 +121,33 @@ function machineCredentials(machine) {
119
121
  return pat ? { apiUrl: machineApiUrl(machine), pat } : undefined;
120
122
  }
121
123
  /** The PKCE loopback + code-exchange login (f1). The raw PAT never rides a redirect query. */
122
- async function runLoginFlow() {
124
+ async function runLoginFlow(label) {
123
125
  const { consoleUrl, apiUrl } = await resolveAuthUrls();
124
126
  return runPkceLogin({
125
127
  consoleUrl,
126
128
  apiUrl,
129
+ label,
127
130
  onProgress: (line) => console.log(line),
128
131
  });
129
132
  }
130
- /** `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. */
131
145
  async function login() {
146
+ const label = loginLabel();
147
+ if (label === undefined) {
148
+ process.exitCode = EXIT.FAILED;
149
+ return;
150
+ }
132
151
  const { apiUrl } = await resolveAuthUrls();
133
152
  const code = await loginCommand({
134
153
  print: (line) => console.log(line),
@@ -139,10 +158,12 @@ async function login() {
139
158
  throw new NotSignedInError();
140
159
  return httpAuthBackend(apiUrl).me(pat);
141
160
  },
142
- runLogin: runLoginFlow,
161
+ runLogin: () => runLoginFlow(label),
143
162
  clearToken: async () => {
144
163
  await clearMachineToken();
145
164
  },
165
+ storedToken: async () => (await loadMachineConfig()).pat,
166
+ revokeToken: (pat) => revokeCurrent(apiUrl, pat),
146
167
  home: os.homedir(),
147
168
  configPath: MACHINE_CONFIG_PATH,
148
169
  }, { force: has("--force") });
@@ -168,8 +189,13 @@ async function requireLogin() {
168
189
  return ok;
169
190
  }
170
191
  async function logout() {
171
- const { path: p, hadToken } = await clearMachineToken();
172
- 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);
173
199
  }
174
200
  /**
175
201
  * `whoami` reads the MACHINE config's API host, never the folder's binding — it
@@ -531,14 +557,16 @@ async function preview() {
531
557
  return;
532
558
  }
533
559
  const devCommand = settings.devCommand.value;
534
- const devPort = settings.devPort.value;
560
+ const { port: devPort, fresh: devPortFresh } = await chooseDevPort(settings);
561
+ // What the creator gets running the dev command by hand, without PORT.
562
+ const handRunUrl = `http://localhost:${settings.devPort.value}`;
535
563
  const localUrl = `http://localhost:${devPort}`;
536
564
  for (const l of settingsBanner({
537
565
  devCommand,
538
566
  devCommandSource: sourceWords(settings.devCommand),
539
567
  devPort,
540
568
  devPortSource: sourceWords(settings.devPort),
541
- reused: await isPortListening(devPort),
569
+ reused: devPortFresh ? false : await isPortListening(devPort),
542
570
  })) {
543
571
  console.log(l);
544
572
  }
@@ -549,7 +577,7 @@ async function preview() {
549
577
  // The gate is LOCAL and NOTHING IS ATTEMPTED: no spinner, no partial mint, no
550
578
  // session left in `pending`. The command stops before it touches the network.
551
579
  if (!(await currentToken())) {
552
- for (const l of notSignedIn(devCommand, localUrl))
580
+ for (const l of notSignedIn(devCommand, handRunUrl))
553
581
  console.log(l);
554
582
  process.exitCode = EXIT.NOT_AUTHENTICATED;
555
583
  return;
@@ -607,6 +635,7 @@ async function preview() {
607
635
  try {
608
636
  const started = await previewStart(cwd, (m) => console.error(`[preview] ${m}`), {
609
637
  appPort: devPort,
638
+ appPortFresh: devPortFresh,
610
639
  devCommand,
611
640
  onHiddenPath: (p) => ui.hiddenPath(p),
612
641
  appStdio: has("--verbose") ? "inherit" : "ignore",
@@ -842,6 +871,8 @@ function previewFailureLines(err, local) {
842
871
  if (err instanceof PreviewLimitError) {
843
872
  return previewLimitRefused(resolveCountLimit(err.detail, local));
844
873
  }
874
+ if (err instanceof PreviewUserLimitError)
875
+ return previewUserLimitRefused(err.detail);
845
876
  if (err instanceof PreviewConflictError)
846
877
  return previewConflict();
847
878
  if (err instanceof ProjectUnreachableError) {
@@ -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` +
@@ -58,7 +59,7 @@ export const USAGE_TEXT = `Vincentt CLI: the local-first AR dev loop\n\n` +
58
59
  ` [--port <n>] [--dev-command <cmd>] [--tester <id>]\n` +
59
60
  ` [--no-qr] [--stop]\n` +
60
61
  ` Runs dev.command and forwards dev.port from vincentt.json,\n` +
61
- ` else npm run dev on 5173.\n` +
62
+ ` else npm run dev on a free port (passed as PORT).\n` +
62
63
  ` Re-running prints the live link; --restart mints a new one.\n\n` +
63
64
  // f16. HELP TEACHES THE CANONICAL VERBS ONLY: `add` and `remove` are accepted
64
65
  // as silent aliases and are deliberately ABSENT here, because help, docs and
@@ -1,3 +1,5 @@
1
1
  export declare const CONTROL_CHARS: RegExp;
2
2
  /** Each C0/C1 control character as a visible `\uXXXX`, so nothing is hidden or redrawn. */
3
3
  export declare function escapeControl(text: string): string;
4
+ /** Text with every C0/C1 control and bidi override or isolate removed. */
5
+ export declare function terminalSafe(text: string): string;
@@ -8,3 +8,10 @@ const CONTROL_CHARS_G = /[\u0000-\u001f\u007f-\u009f]/g;
8
8
  export function escapeControl(text) {
9
9
  return text.replace(CONTROL_CHARS_G, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
10
10
  }
11
+ // U+202A..U+202E (embeddings, overrides) and U+2066..U+2069 (isolates) can
12
+ // reorder a terminal line, so a crafted name could disguise what it says.
13
+ const BIDI_CONTROLS_G = /[\u202a-\u202e\u2066-\u2069]/g;
14
+ /** Text with every C0/C1 control and bidi override or isolate removed. */
15
+ export function terminalSafe(text) {
16
+ return text.replace(CONTROL_CHARS_G, "").replace(BIDI_CONTROLS_G, "");
17
+ }
@@ -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;