@vincentt-xr/cli 0.1.5 → 0.2.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 (66) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +13 -0
  3. package/dist/cli/args.js +41 -0
  4. package/dist/cli/index.js +362 -92
  5. package/dist/lib/backend.d.ts +45 -5
  6. package/dist/lib/backend.js +99 -18
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +73 -15
  12. package/dist/lib/lifecycle.js +111 -114
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +47 -20
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/relay/server.d.ts +32 -0
  51. package/dist/relay/server.js +80 -12
  52. package/dist/relay/store.d.ts +32 -3
  53. package/dist/relay/store.js +76 -22
  54. package/dist/relay/testers.d.ts +90 -0
  55. package/dist/relay/testers.js +144 -0
  56. package/dist/shared/authError.d.ts +1 -0
  57. package/dist/shared/authError.js +11 -0
  58. package/dist/shared/config.d.ts +73 -5
  59. package/dist/shared/config.js +133 -20
  60. package/package.json +2 -3
  61. package/dist/login/login.d.ts +0 -34
  62. package/dist/login/login.js +0 -148
  63. package/dist/preview/cloudflared.d.ts +0 -13
  64. package/dist/preview/cloudflared.js +0 -46
  65. package/dist/preview/tunnel.d.ts +0 -14
  66. package/dist/preview/tunnel.js +0 -28
@@ -0,0 +1,217 @@
1
+ // `vincentt create` / `link` / `unlink` — the f2 project verbs, in the CLI voice
2
+ // (→ in progress, ✓ success, ✗ failure, ! advisory; every error ends with the exact
3
+ // next command; a state-changing verb always announces what it wrote to disk).
4
+ //
5
+ // The IO boundary (print, backend, disk, cwd) is injected so every branch is
6
+ // unit-testable without a real API, a real HOME, or a real repository.
7
+ import { promises as fs } from "node:fs";
8
+ import path from "node:path";
9
+ import { BackendError, SlugTakenError, createProject, getProject, } from "./backend.js";
10
+ import { slugify } from "./slug.js";
11
+ import { clearProjectBinding, loadProjectBinding, locateProjectBinding, writeProjectBinding, } from "../shared/config.js";
12
+ /**
13
+ * `vincentt create` — register a project and link this folder to it.
14
+ *
15
+ * It does NOT scaffold. An unscaffolded folder gets a `!` advisory and the command
16
+ * PROCEEDS: the server accepts a create from any folder or none, and blocking here
17
+ * would put the open template behind the auth gate, which is the one thing the
18
+ * init/create split exists to prevent.
19
+ */
20
+ export async function createCommand(deps, opts = {}) {
21
+ const existing = await loadProjectBinding(deps.cwd);
22
+ if (existing && !opts.force) {
23
+ deps.print(`✗ This folder is already linked to a project (${existing.slug}).`);
24
+ deps.print("");
25
+ deps.print(" Re-link it: vincentt link <project-id>");
26
+ deps.print(" Detach it: vincentt unlink");
27
+ deps.print(" Replace it: vincentt create --force");
28
+ return 1;
29
+ }
30
+ if (!(await looksLikeVincenttApp(deps.cwd))) {
31
+ deps.print("! This folder doesn't look like a Vincentt app (no package.json with @vincentt-xr/sdk).");
32
+ deps.print(" `vincentt create` registers a project — it doesn't scaffold one.");
33
+ deps.print(" To start from the template: npx @vincentt-xr/cli init");
34
+ deps.print("");
35
+ }
36
+ const name = opts.name ?? path.basename(path.resolve(deps.cwd));
37
+ const slug = opts.slug ?? slugify(name);
38
+ if (!slug) {
39
+ deps.print("✗ Could not derive an address from that name.");
40
+ deps.print("");
41
+ deps.print(` Run \`vincentt create --name "${name}" --slug <address>\` to pick one.`);
42
+ return 1;
43
+ }
44
+ // Printed BEFORE the round trip: the creator sees which address is about to be
45
+ // claimed on their behalf, not after the fact.
46
+ deps.print(`→ Checking ${slug}…`);
47
+ deps.print("");
48
+ const org = await deps.org();
49
+ if (!org.ok) {
50
+ deps.print(org.message);
51
+ return 1;
52
+ }
53
+ const cfg = await deps.config();
54
+ let created;
55
+ try {
56
+ created = await createProject(cfg, org.org.slug, name, slug);
57
+ }
58
+ catch (err) {
59
+ if (err instanceof SlugTakenError) {
60
+ printSlugTaken(deps.print, err.suggestions, name);
61
+ return 1;
62
+ }
63
+ throw err;
64
+ }
65
+ await writeProjectBinding(deps.cwd, {
66
+ projectId: created.projectId,
67
+ slug: created.slug,
68
+ });
69
+ deps.print(`✓ Created ${created.name} in ${org.org.name}`);
70
+ deps.print(` Address reserved: ${created.liveUrl}`);
71
+ deps.print(` Linked this folder: .vincentt/project.json (added to .gitignore)`);
72
+ deps.print("");
73
+ deps.print(" Nothing is served at that address until you publish.");
74
+ deps.print(" Run `vincentt preview` to try it on your phone.");
75
+ return 0;
76
+ }
77
+ /**
78
+ * Four words, byte-identical to the console's — it must read the same whether the
79
+ * holder is your own org, a stranger, a tombstone, a burn, or a reserved hostname.
80
+ * Suggestions are LISTED, never applied: the remedy restates the whole command so
81
+ * it is copy-pasteable, and the closing clause offers the creator's own choice as
82
+ * an equal option, so an agent reading this cannot conclude auto-picking is sanctioned.
83
+ */
84
+ function printSlugTaken(print, suggestions, name) {
85
+ print("✗ That address is taken.");
86
+ print("");
87
+ if (suggestions.length > 0) {
88
+ print(" Available alternatives:");
89
+ for (const s of suggestions)
90
+ print(` ${s}`);
91
+ print("");
92
+ print(` Run \`vincentt create --name "${name}" --slug ${suggestions[0]}\` to use one,`);
93
+ print(" or pick your own with any --slug.");
94
+ }
95
+ else {
96
+ print(` Run \`vincentt create --name "${name}" --slug <address>\` with another address.`);
97
+ }
98
+ }
99
+ // ---- link -------------------------------------------------------------------
100
+ /**
101
+ * `vincentt link <project-id>` — one verb for four situations (console-first
102
+ * birth, a fresh clone, a folder that lost its binding, a re-link after
103
+ * `login --force`). It is a READ plus a local write: the server has no notion of
104
+ * "which folder", so linking is entirely client-side, gated only by the server's
105
+ * willingness to return the project at all.
106
+ */
107
+ export async function linkCommand(deps, projectId) {
108
+ if (!projectId) {
109
+ deps.print("✗ Which project? `vincentt link` needs a project id.");
110
+ deps.print("");
111
+ deps.print(" vincentt link <project-id> (copy it from the console)");
112
+ return 1;
113
+ }
114
+ const existing = await loadProjectBinding(deps.cwd);
115
+ const cfg = await deps.config();
116
+ let project;
117
+ try {
118
+ project = await getProject(cfg, projectId);
119
+ }
120
+ catch (err) {
121
+ if (err instanceof BackendError && err.status === 404) {
122
+ printUnreachable(deps.print, err.message);
123
+ return 1;
124
+ }
125
+ throw err;
126
+ }
127
+ if (existing && existing.projectId !== project.id) {
128
+ deps.print(`! This folder is already linked to ${existing.slug}.`);
129
+ deps.print(` Re-linking it to ${project.name}.`);
130
+ deps.print("");
131
+ }
132
+ await writeProjectBinding(deps.cwd, { projectId: project.id, slug: project.slug });
133
+ deps.print(`✓ Linked this folder to ${project.name}`);
134
+ if (project.liveUrl)
135
+ deps.print(` Address: ${project.liveUrl}`);
136
+ deps.print(" Wrote .vincentt/project.json (added to .gitignore)");
137
+ return 0;
138
+ }
139
+ /**
140
+ * The API's error messages are lowercase and unpunctuated by convention
141
+ * (`that handle is taken`, `this project is not available to you`), while both
142
+ * client surfaces present them as sentences. Casing is the ONLY thing either
143
+ * surface may change — the words are the server's, so a creator moving between
144
+ * the CLI and the console reads one sentence and support can match a screenshot
145
+ * to a log line without a translation table.
146
+ */
147
+ export function asSentence(message) {
148
+ const trimmed = message.trim();
149
+ if (!trimmed)
150
+ return trimmed;
151
+ const cased = trimmed[0].toUpperCase() + trimmed.slice(1);
152
+ return /[.!?]$/.test(cased) ? cased : `${cased}.`;
153
+ }
154
+ /**
155
+ * Flow L10. Line 1 is the API's message; the body names both candidate causes and
156
+ * resolves neither (that is the maximum the anti-oracle contract permits, and it
157
+ * is accurate for all four real causes). `whoami` is offered first because it is
158
+ * the one diagnosis that discloses nothing about the project.
159
+ */
160
+ export function printUnreachable(print, message) {
161
+ print(`✗ ${asSentence(message)}`);
162
+ print("");
163
+ print(" This folder is linked to a project that can't be reached with your current");
164
+ print(" account. It may have been deleted, or it may belong to an organization you're");
165
+ print(" not a member of.");
166
+ print("");
167
+ print(" Check who you are: vincentt whoami");
168
+ print(" Re-link this folder: vincentt link <project-id> (copy it from the console)");
169
+ }
170
+ // ---- unlink -----------------------------------------------------------------
171
+ /**
172
+ * `vincentt unlink` — deletes the local binding. NO server call: the binding is a
173
+ * local pointer and the project is untouched, so there is nothing to tell the
174
+ * server about.
175
+ */
176
+ export async function unlinkCommand(deps) {
177
+ const { hadBinding } = await clearProjectBinding(deps.cwd);
178
+ if (!hadBinding) {
179
+ // No glyph: neither success nor failure, just a fact.
180
+ deps.print("This folder isn't linked to a project.");
181
+ return 0;
182
+ }
183
+ deps.print("✓ Unlinked. Removed .vincentt/project.json");
184
+ deps.print("");
185
+ deps.print(" The project itself is untouched — this only disconnects this folder.");
186
+ deps.print(" Run `vincentt link <project-id>` to link it again.");
187
+ return 0;
188
+ }
189
+ // ---- shared -----------------------------------------------------------------
190
+ /**
191
+ * The advisory's trigger: a package.json that depends on @vincentt-xr/sdk. It is a
192
+ * HINT only — create proceeds either way — so a false negative costs one extra
193
+ * line of output, never a refused command.
194
+ */
195
+ export async function looksLikeVincenttApp(cwd) {
196
+ try {
197
+ const raw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
198
+ const pkg = JSON.parse(raw);
199
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
200
+ return Object.keys(deps).some((d) => d.startsWith("@vincentt-xr/"));
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ /** Resolve the bound project for a project-scoped verb, or explain why not. */
207
+ export async function requireBinding(deps) {
208
+ const located = await locateProjectBinding(deps.cwd);
209
+ if (!located) {
210
+ deps.print("✗ This folder isn't linked to a project.");
211
+ deps.print("");
212
+ deps.print(" vincentt link <project-id> (copy it from the console)");
213
+ deps.print(" vincentt create Register a new project for this folder");
214
+ return undefined;
215
+ }
216
+ return { projectId: located.binding.projectId, slug: located.binding.slug };
217
+ }
@@ -5,9 +5,6 @@ export interface RelayInfo {
5
5
  url: string;
6
6
  /** The preview process pid, so a stale file can be detected. */
7
7
  pid: number;
8
- /** The cloudflared child pid, so `preview --stop` can reap an orphaned tunnel
9
- * even after the preview process itself is gone (reparented to init). */
10
- tunnelPid?: number;
11
8
  /** WS/HTTP path prefix routed to the relay (default /__harness). */
12
9
  harnessPath: string;
13
10
  }
@@ -15,3 +12,44 @@ export declare function relayInfoPath(cwd: string): string;
15
12
  export declare function writeRelayInfo(cwd: string, info: RelayInfo): Promise<string>;
16
13
  export declare function readRelayInfo(cwd: string): Promise<RelayInfo | undefined>;
17
14
  export declare function clearRelayInfo(cwd: string): Promise<void>;
15
+ /**
16
+ * isProcessAlive is a NECESSARY but NOT SUFFICIENT check.
17
+ *
18
+ * `kill(pid, 0)` proves SOME process holds that pid, never that it is ours: pids
19
+ * are recycled, and after a reboot the number in a stale marker routinely
20
+ * belongs to something else entirely. It is kept as the cheap first gate — it
21
+ * rules out the common case (a killed CLI) with no I/O — and the relay probe
22
+ * below is what actually proves identity.
23
+ */
24
+ export declare function isProcessAlive(pid: number): boolean;
25
+ /**
26
+ * isRelayResponding asks the recorded port whether OUR relay is behind it.
27
+ *
28
+ * This is the check that makes a recycled pid harmless: a `/query` that answers
29
+ * with a RelayResult-shaped body could only have come from a relay this CLI
30
+ * started. A stranger on that port answers something else, or nothing, and the
31
+ * marker is correctly judged stale.
32
+ *
33
+ * A short timeout, because this runs on the startup path of a command a creator
34
+ * runs fifty times a day, and the fallback (mint a preview) is what they asked
35
+ * for anyway.
36
+ */
37
+ export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch): Promise<boolean>;
38
+ /**
39
+ * readLivePreview returns the marker ONLY when it still describes a preview that
40
+ * is genuinely running, and CLEARS it otherwise.
41
+ *
42
+ * The marker is proof that a preview was STARTED, never that one is RUNNING: a
43
+ * crashed CLI, a `kill -9`, or a reboot all leave it behind. Trusting it
44
+ * unvalidated let `vincentt preview` short-circuit into "attaching…" followed by
45
+ * "the preview is no longer running", and exit — no mint, no session, and the
46
+ * creator's only recourse was deleting a dotfile they do not know exists. A
47
+ * marker that cannot be validated is not a marker.
48
+ *
49
+ * Clearing on the stale path is what stops the dead end from recurring on the
50
+ * next run.
51
+ */
52
+ export declare function readLivePreview(cwd: string, deps?: {
53
+ processAlive?: (pid: number) => boolean;
54
+ relayResponding?: (port: number) => Promise<boolean>;
55
+ }): Promise<RelayInfo | undefined>;
@@ -33,3 +33,97 @@ export async function clearRelayInfo(cwd) {
33
33
  // Already gone — clearing a non-existent info file is a no-op.
34
34
  }
35
35
  }
36
+ /**
37
+ * isProcessAlive is a NECESSARY but NOT SUFFICIENT check.
38
+ *
39
+ * `kill(pid, 0)` proves SOME process holds that pid, never that it is ours: pids
40
+ * are recycled, and after a reboot the number in a stale marker routinely
41
+ * belongs to something else entirely. It is kept as the cheap first gate — it
42
+ * rules out the common case (a killed CLI) with no I/O — and the relay probe
43
+ * below is what actually proves identity.
44
+ */
45
+ export function isProcessAlive(pid) {
46
+ if (!Number.isInteger(pid) || pid <= 0)
47
+ return false;
48
+ try {
49
+ process.kill(pid, 0); // signal 0 = existence check, kills nothing
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * isRelayResponding asks the recorded port whether OUR relay is behind it.
58
+ *
59
+ * This is the check that makes a recycled pid harmless: a `/query` that answers
60
+ * with a RelayResult-shaped body could only have come from a relay this CLI
61
+ * started. A stranger on that port answers something else, or nothing, and the
62
+ * marker is correctly judged stale.
63
+ *
64
+ * A short timeout, because this runs on the startup path of a command a creator
65
+ * runs fifty times a day, and the fallback (mint a preview) is what they asked
66
+ * for anyway.
67
+ */
68
+ export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch) {
69
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
70
+ return false;
71
+ const ac = new AbortController();
72
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
73
+ try {
74
+ const res = await fetchImpl(`http://127.0.0.1:${port}/query`, {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json" },
77
+ body: JSON.stringify({ kind: "log", limit: 0 }),
78
+ signal: ac.signal,
79
+ });
80
+ if (!res.ok)
81
+ return false;
82
+ const body = await res.json();
83
+ // The SHAPE is the identity proof. Any 200 would do for liveness, but only
84
+ // a RelayResult says the thing answering is a Vincentt relay.
85
+ return (typeof body === "object" &&
86
+ body !== null &&
87
+ Array.isArray(body.events) &&
88
+ typeof body.latestSeq === "number");
89
+ }
90
+ catch {
91
+ // Refused, timed out, non-JSON, or something else on the port entirely.
92
+ return false;
93
+ }
94
+ finally {
95
+ clearTimeout(timer);
96
+ }
97
+ }
98
+ /**
99
+ * readLivePreview returns the marker ONLY when it still describes a preview that
100
+ * is genuinely running, and CLEARS it otherwise.
101
+ *
102
+ * The marker is proof that a preview was STARTED, never that one is RUNNING: a
103
+ * crashed CLI, a `kill -9`, or a reboot all leave it behind. Trusting it
104
+ * unvalidated let `vincentt preview` short-circuit into "attaching…" followed by
105
+ * "the preview is no longer running", and exit — no mint, no session, and the
106
+ * creator's only recourse was deleting a dotfile they do not know exists. A
107
+ * marker that cannot be validated is not a marker.
108
+ *
109
+ * Clearing on the stale path is what stops the dead end from recurring on the
110
+ * next run.
111
+ */
112
+ export async function readLivePreview(cwd, deps = {}) {
113
+ const info = await readRelayInfo(cwd);
114
+ if (!info)
115
+ return undefined;
116
+ const processAlive = deps.processAlive ?? isProcessAlive;
117
+ const relayResponding = deps.relayResponding ?? ((p) => isRelayResponding(p));
118
+ // The cheap gate first: a dead pid needs no network round trip.
119
+ if (info.pid !== undefined && !processAlive(info.pid)) {
120
+ await clearRelayInfo(cwd);
121
+ return undefined;
122
+ }
123
+ // The identity gate: alive-but-not-ours (a recycled pid) fails here.
124
+ if (!(await relayResponding(info.relayPort))) {
125
+ await clearRelayInfo(cwd);
126
+ return undefined;
127
+ }
128
+ return info;
129
+ }
@@ -0,0 +1,11 @@
1
+ import type { Me } from "./backend.js";
2
+ import { type ActiveOrgInput } from "./org.js";
3
+ import type { ResolvedConfig } from "../shared/config.js";
4
+ export interface WhoamiDeps {
5
+ print: (line: string) => void;
6
+ /** The machine's API host + PAT. The BINDING's apiUrl is never used here. */
7
+ config: () => Promise<ResolvedConfig | undefined>;
8
+ getMe: (cfg: ResolvedConfig) => Promise<Me>;
9
+ org: ActiveOrgInput;
10
+ }
11
+ export declare function whoamiCommand(deps: WhoamiDeps): Promise<number>;
@@ -0,0 +1,38 @@
1
+ // `vincentt whoami` — Flow L10's first offered remedy.
2
+ //
3
+ // Two of the four unreachable-binding causes (`login --force`, a different machine
4
+ // identity) are diagnosable entirely client-side with zero server disclosure, which
5
+ // makes this the one diagnosis the anti-oracle contract cannot object to. That only
6
+ // holds if the command actually answers, so its correctness is load-bearing for
7
+ // f2's hardest copy rather than cosmetic.
8
+ import { resolveActiveOrg } from "./org.js";
9
+ export async function whoamiCommand(deps) {
10
+ const cfg = await deps.config();
11
+ if (!cfg) {
12
+ deps.print("Not signed in. Run `vincentt login`.");
13
+ return 1;
14
+ }
15
+ let me;
16
+ try {
17
+ me = await deps.getMe(cfg);
18
+ }
19
+ catch (err) {
20
+ // Reserved for a GENUINE transport failure. The two lines a creator can act on
21
+ // are named, because "unverified" alone tells someone who does not know who
22
+ // they are that we do not either.
23
+ deps.print(`! Could not reach ${cfg.apiUrl} — your token is present but unverified.`);
24
+ deps.print(" Check your connection, or `vincentt login` if you believe it expired.");
25
+ void err;
26
+ return 1;
27
+ }
28
+ const who = me.email ?? me.name ?? me.id ?? "(unknown account)";
29
+ const org = resolveActiveOrg(me, deps.org);
30
+ deps.print(`Signed in as ${who}`);
31
+ // Printed even when there is no active org: an absent line is indistinguishable
32
+ // from a bug on exactly the path where the creator is already confused.
33
+ deps.print(org
34
+ ? ` Active org: ${org.name} (${org.slug})`
35
+ : " Active org: none — pass --org <slug>");
36
+ deps.print(` API: ${cfg.apiUrl}`);
37
+ return 0;
38
+ }
@@ -0,0 +1,30 @@
1
+ export interface Membership {
2
+ /** Org display name (e.g. "Acme", or "Personal (alice)" for the personal org). */
3
+ orgName: string;
4
+ /** "personal" | "team" — the personal org is the default active org. */
5
+ orgKind: "personal" | "team";
6
+ role?: string;
7
+ }
8
+ export interface Identity {
9
+ email: string;
10
+ /** All orgs the signed-in user belongs to (switcher source). */
11
+ memberships: Membership[];
12
+ }
13
+ /**
14
+ * The auth-flow backend seam. `exchange` redeems the PKCE code for a raw PAT; `me` confirms
15
+ * identity + orgs with that PAT. Both are injected so a stub backend drives the CLI tests.
16
+ */
17
+ export interface AuthBackend {
18
+ /** POST /access-tokens/exchange {code, code_verifier} → the raw PAT ("vct_…"). */
19
+ exchange(code: string, codeVerifier: string): Promise<string>;
20
+ /** GET /me with Bearer <pat> → identity + orgs. */
21
+ me(pat: string): Promise<Identity>;
22
+ }
23
+ /** The real HTTP-backed AuthBackend, pointed at a Vincentt API base URL. */
24
+ export declare function httpAuthBackend(apiBase: string): AuthBackend;
25
+ /**
26
+ * Map the /me response shape onto our Identity. The backend returns memberships with an org
27
+ * name + kind; we keep the projection resilient to the exact field names the Go handler emits
28
+ * (name/orgName, kind/orgKind) so a minor contract wording change doesn't break login.
29
+ */
30
+ export declare function normalizeMe(raw: unknown): Identity;
@@ -0,0 +1,80 @@
1
+ // The two backend calls the CLI makes during `vincentt login`, isolated behind a small
2
+ // interface so the loopback orchestration (pkceLogin.ts) is testable against a stub
3
+ // backend (QA-F1-C2/C4/C5) without real HTTP.
4
+ //
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)
9
+ import { authFailureMessage } from "../shared/authError.js";
10
+ class AuthError extends Error {
11
+ status;
12
+ constructor(message, status) {
13
+ super(message);
14
+ this.status = status;
15
+ this.name = "AuthError";
16
+ }
17
+ }
18
+ async function safeText(res) {
19
+ try {
20
+ return (await res.text()).slice(0, 300);
21
+ }
22
+ catch {
23
+ return "";
24
+ }
25
+ }
26
+ /** The real HTTP-backed AuthBackend, pointed at a Vincentt API base URL. */
27
+ export function httpAuthBackend(apiBase) {
28
+ const base = apiBase.replace(/\/$/, "");
29
+ return {
30
+ async exchange(code, codeVerifier) {
31
+ const res = await fetch(`${base}/access-tokens/exchange`, {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json" },
34
+ body: JSON.stringify({ code, code_verifier: codeVerifier }),
35
+ });
36
+ if (!res.ok) {
37
+ throw new AuthError(`Token exchange failed (${res.status}): ${await safeText(res)}`, res.status);
38
+ }
39
+ const body = (await res.json());
40
+ if (!body.token)
41
+ throw new AuthError("Token exchange returned no token.");
42
+ return body.token;
43
+ },
44
+ async me(pat) {
45
+ const res = await fetch(`${base}/me`, {
46
+ headers: { authorization: `Bearer ${pat}` },
47
+ });
48
+ if (!res.ok) {
49
+ throw new AuthError(authFailureMessage("Identity check failed", res.status, await safeText(res)), res.status);
50
+ }
51
+ return normalizeMe(await res.json());
52
+ },
53
+ };
54
+ }
55
+ /**
56
+ * Map the /me response shape onto our Identity. The backend returns memberships with an org
57
+ * name + kind; we keep the projection resilient to the exact field names the Go handler emits
58
+ * (name/orgName, kind/orgKind) so a minor contract wording change doesn't break login.
59
+ */
60
+ export function normalizeMe(raw) {
61
+ const envelope = (raw ?? {});
62
+ // GET /me returns { me: {...}, csrfToken }. Unwrap it, but stay resilient to a
63
+ // bare user object so a contract tweak doesn't break login.
64
+ const o = (envelope.me && typeof envelope.me === "object" ? envelope.me : envelope);
65
+ const email = typeof o.email === "string" ? o.email : "";
66
+ const rawMemberships = Array.isArray(o.memberships) ? o.memberships : [];
67
+ const memberships = rawMemberships.map((m) => {
68
+ const r = (m ?? {});
69
+ const orgName = (typeof r.orgName === "string" && r.orgName) ||
70
+ (typeof r.name === "string" && r.name) ||
71
+ "";
72
+ const kind = r.orgKind ?? r.kind;
73
+ return {
74
+ orgName,
75
+ orgKind: kind === "team" ? "team" : "personal",
76
+ role: typeof r.role === "string" ? r.role : undefined,
77
+ };
78
+ });
79
+ return { email, memberships };
80
+ }
@@ -0,0 +1,13 @@
1
+ export interface RequireSignedInDeps {
2
+ /** The token that would authenticate a machine call, if any (env override or config). */
3
+ currentToken: () => Promise<string | undefined>;
4
+ /** Emit a line (stderr, so it never pollutes machine-readable stdout). */
5
+ print: (line: string) => void;
6
+ }
7
+ /**
8
+ * True when a token is present. When absent, prints the L3 not-signed-in message and returns
9
+ * false. The exact copy is locked by the f1 design:
10
+ * ✗ You're not signed in.
11
+ * Run `vincentt login` to connect this machine to your Vincentt account.
12
+ */
13
+ export declare function requireSignedIn(deps: RequireSignedInDeps): Promise<boolean>;
@@ -0,0 +1,17 @@
1
+ // The not-signed-in voice (Flow L3). f1 defines this voice; any gated command (f2+) uses
2
+ // it via `requireSignedIn`. When no token is present it prints the exact two-line message
3
+ // and reports "not signed in" so the caller can abort with a non-zero exit — no stack trace,
4
+ // no auto-browser-launch (that's `ensureLoggedIn`'s convenience path; the gate is explicit).
5
+ /**
6
+ * True when a token is present. When absent, prints the L3 not-signed-in message and returns
7
+ * false. The exact copy is locked by the f1 design:
8
+ * ✗ You're not signed in.
9
+ * Run `vincentt login` to connect this machine to your Vincentt account.
10
+ */
11
+ export async function requireSignedIn(deps) {
12
+ if (await deps.currentToken())
13
+ return true;
14
+ deps.print("✗ You're not signed in.");
15
+ deps.print(" Run `vincentt login` to connect this machine to your Vincentt account.");
16
+ return false;
17
+ }
@@ -0,0 +1,39 @@
1
+ import type { Identity, Membership } from "./authBackend.js";
2
+ /** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
3
+ export declare function displayConfigPath(absPath: string, home: string): string;
4
+ /** The active org is the personal one (login lands on personal; the CLI never prompts). */
5
+ export declare function activeOrg(memberships: Membership[]): Membership | undefined;
6
+ /**
7
+ * The success line's org clause: "Active org: <personal>" plus, when the user belongs to
8
+ * team orgs too, "· also a member of: A, B". A platform-invited creator with only a personal
9
+ * org gets just the active-org line (no trailing clause).
10
+ */
11
+ export declare function orgSummaryLine(identity: Identity): string;
12
+ export interface LoginCommandDeps {
13
+ /** Print a line to the user (stdout for the transcript). */
14
+ print: (line: string) => void;
15
+ /** The token currently on this machine, if any (env override or config file). */
16
+ currentToken: () => Promise<string | undefined>;
17
+ /** Confirm who a present token belongs to (GET /me). Undefined if it can't be resolved. */
18
+ whoAmI: () => Promise<Identity | undefined>;
19
+ /** Run the loopback + PKCE flow; resolves identity + the saved config path. */
20
+ runLogin: () => Promise<{
21
+ identity: Identity;
22
+ configPath: string;
23
+ }>;
24
+ /** Forget the existing token before a --force re-login. */
25
+ clearToken: () => Promise<void>;
26
+ /** For displaying the config path as ~/…. */
27
+ home: string;
28
+ /** Absolute config path (for the L2 already-signed-in line). */
29
+ configPath: string;
30
+ }
31
+ export interface LoginCommandOptions {
32
+ force?: boolean;
33
+ }
34
+ /**
35
+ * Drive `vincentt login`. Returns an exit code (0 ok, 1 failed). Never throws for expected
36
+ * failure states (timeout, cancel, state-mismatch) — they print a ✗ line ending with the
37
+ * exact retry command and return 1. No stack trace, no token printed.
38
+ */
39
+ export declare function loginCommand(deps: LoginCommandDeps, opts?: LoginCommandOptions): Promise<number>;