@tokenfactory/acc-runner 0.41.7 → 0.43.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 (114) hide show
  1. package/README.md +87 -4
  2. package/dist/acc.d.ts +3 -0
  3. package/dist/acc.d.ts.map +1 -0
  4. package/dist/acc.js +21 -0
  5. package/dist/acc.js.map +1 -0
  6. package/dist/cli-name.d.ts +29 -0
  7. package/dist/cli-name.d.ts.map +1 -0
  8. package/dist/cli-name.js +17 -0
  9. package/dist/cli-name.js.map +1 -0
  10. package/dist/cli.d.ts +20 -2
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +45 -289
  13. package/dist/cli.js.map +1 -1
  14. package/dist/engines/account-probe.d.ts +24 -0
  15. package/dist/engines/account-probe.d.ts.map +1 -1
  16. package/dist/engines/account-probe.js +18 -1
  17. package/dist/engines/account-probe.js.map +1 -1
  18. package/dist/keychain.d.ts +17 -0
  19. package/dist/keychain.d.ts.map +1 -1
  20. package/dist/keychain.js +36 -0
  21. package/dist/keychain.js.map +1 -1
  22. package/dist/login.d.ts +45 -1
  23. package/dist/login.d.ts.map +1 -1
  24. package/dist/login.js +91 -4
  25. package/dist/login.js.map +1 -1
  26. package/dist/machine-capabilities/index.d.ts +21 -0
  27. package/dist/machine-capabilities/index.d.ts.map +1 -0
  28. package/dist/machine-capabilities/index.js +21 -0
  29. package/dist/machine-capabilities/index.js.map +1 -0
  30. package/dist/machine-capabilities/probe.d.ts +61 -0
  31. package/dist/machine-capabilities/probe.d.ts.map +1 -0
  32. package/dist/machine-capabilities/probe.js +243 -0
  33. package/dist/machine-capabilities/probe.js.map +1 -0
  34. package/dist/machine-capabilities/redact.d.ts +42 -0
  35. package/dist/machine-capabilities/redact.d.ts.map +1 -0
  36. package/dist/machine-capabilities/redact.js +160 -0
  37. package/dist/machine-capabilities/redact.js.map +1 -0
  38. package/dist/machine-capabilities/report.d.ts +107 -0
  39. package/dist/machine-capabilities/report.d.ts.map +1 -0
  40. package/dist/machine-capabilities/report.js +211 -0
  41. package/dist/machine-capabilities/report.js.map +1 -0
  42. package/dist/machine-capabilities/types.d.ts +115 -0
  43. package/dist/machine-capabilities/types.d.ts.map +1 -0
  44. package/dist/machine-capabilities/types.js +11 -0
  45. package/dist/machine-capabilities/types.js.map +1 -0
  46. package/dist/migration-seq-guard.d.ts +78 -0
  47. package/dist/migration-seq-guard.d.ts.map +1 -0
  48. package/dist/migration-seq-guard.js +304 -0
  49. package/dist/migration-seq-guard.js.map +1 -0
  50. package/dist/node-version.d.ts +132 -0
  51. package/dist/node-version.d.ts.map +1 -0
  52. package/dist/node-version.js +318 -0
  53. package/dist/node-version.js.map +1 -0
  54. package/dist/prepr-gate.d.ts +120 -0
  55. package/dist/prepr-gate.d.ts.map +1 -1
  56. package/dist/prepr-gate.js +202 -3
  57. package/dist/prepr-gate.js.map +1 -1
  58. package/dist/program.d.ts +16 -0
  59. package/dist/program.d.ts.map +1 -0
  60. package/dist/program.js +355 -0
  61. package/dist/program.js.map +1 -0
  62. package/dist/runtime/review-chunking.d.ts +101 -0
  63. package/dist/runtime/review-chunking.d.ts.map +1 -0
  64. package/dist/runtime/review-chunking.js +192 -0
  65. package/dist/runtime/review-chunking.js.map +1 -0
  66. package/dist/runtime/review-diff.d.ts +30 -0
  67. package/dist/runtime/review-diff.d.ts.map +1 -0
  68. package/dist/runtime/review-diff.js +59 -0
  69. package/dist/runtime/review-diff.js.map +1 -0
  70. package/dist/runtime/reviewer.d.ts +40 -19
  71. package/dist/runtime/reviewer.d.ts.map +1 -1
  72. package/dist/runtime/reviewer.js +252 -51
  73. package/dist/runtime/reviewer.js.map +1 -1
  74. package/dist/serve-scope/command.d.ts +12 -0
  75. package/dist/serve-scope/command.d.ts.map +1 -0
  76. package/dist/serve-scope/command.js +89 -0
  77. package/dist/serve-scope/command.js.map +1 -0
  78. package/dist/serve-scope/index.d.ts +130 -0
  79. package/dist/serve-scope/index.d.ts.map +1 -0
  80. package/dist/serve-scope/index.js +148 -0
  81. package/dist/serve-scope/index.js.map +1 -0
  82. package/dist/task-runner.d.ts +11 -0
  83. package/dist/task-runner.d.ts.map +1 -1
  84. package/dist/task-runner.js +69 -0
  85. package/dist/task-runner.js.map +1 -1
  86. package/dist/user-auth/device-login.d.ts +47 -0
  87. package/dist/user-auth/device-login.d.ts.map +1 -0
  88. package/dist/user-auth/device-login.js +189 -0
  89. package/dist/user-auth/device-login.js.map +1 -0
  90. package/dist/user-auth/liveness.d.ts +27 -0
  91. package/dist/user-auth/liveness.d.ts.map +1 -0
  92. package/dist/user-auth/liveness.js +128 -0
  93. package/dist/user-auth/liveness.js.map +1 -0
  94. package/dist/user-auth/session.d.ts +82 -0
  95. package/dist/user-auth/session.d.ts.map +1 -0
  96. package/dist/user-auth/session.js +172 -0
  97. package/dist/user-auth/session.js.map +1 -0
  98. package/dist/user-cli/chat.d.ts +44 -0
  99. package/dist/user-cli/chat.d.ts.map +1 -0
  100. package/dist/user-cli/chat.js +199 -0
  101. package/dist/user-cli/chat.js.map +1 -0
  102. package/dist/user-cli/client.d.ts +20 -0
  103. package/dist/user-cli/client.d.ts.map +1 -0
  104. package/dist/user-cli/client.js +60 -0
  105. package/dist/user-cli/client.js.map +1 -0
  106. package/dist/user-cli/tasks.d.ts +37 -0
  107. package/dist/user-cli/tasks.d.ts.map +1 -0
  108. package/dist/user-cli/tasks.js +131 -0
  109. package/dist/user-cli/tasks.js.map +1 -0
  110. package/dist/watch.d.ts +8 -0
  111. package/dist/watch.d.ts.map +1 -1
  112. package/dist/watch.js +28 -0
  113. package/dist/watch.js.map +1 -1
  114. package/package.json +3 -2
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Does this terminal's session still exist server-side? (AX-G3, gap G-16.)
3
+ *
4
+ * THE PROBLEM. Revoking a CLI login on Settings → Sessions deletes the
5
+ * `auth.sessions` row and cascades its refresh-token family, so this machine can
6
+ * never mint another access token. But the access token already in the keychain
7
+ * stays cryptographically valid until it expires — and PostgREST, which every
8
+ * `acc task` read goes through, verifies a JWT's signature without ever looking at
9
+ * the session table. So a revoked terminal would keep reading the board for the
10
+ * rest of the token's life, which is up to an hour of a revoke not being a revoke.
11
+ *
12
+ * The refresh path already handles the other half: once the token IS stale,
13
+ * refreshUserSession gets a 4xx and raises SignedOutError. This module covers the
14
+ * window before that — one cheap read of /api/auth/session-status per command.
15
+ *
16
+ * FAIL OPEN, ON PURPOSE. Only a positive `live: false` signs the operator out.
17
+ * An unreachable control plane, a 5xx, a deployment that predates the endpoint
18
+ * (404), an unwired session directory, a token with no session claim — all of
19
+ * those leave the session alone. The alternative is a pooler hiccup logging out
20
+ * every terminal in the fleet at once, which is a worse failure than the one this
21
+ * module exists to fix. The only thing we treat as definitive besides `live:false`
22
+ * is a 401 whose reason is `invalid_token`: that is the auth authority itself
23
+ * saying this credential is not a credential.
24
+ */
25
+ import { loadConfig } from "../config.js";
26
+ import { signInCommand } from "../cli-name.js";
27
+ import { accessTokenIsFresh, clearUserSession, cliUserAgent, loadUserSession, SignedOutError, } from "./session.js";
28
+ export const SESSION_STATUS_PATH = "/api/auth/session-status";
29
+ /**
30
+ * Map one HTTP answer onto a verdict. Pure, so every branch is asserted directly
31
+ * rather than through a fetch double.
32
+ */
33
+ export function decideLiveness(status, body) {
34
+ const b = (body ?? {});
35
+ if (status === 401) {
36
+ // requireUser answers 401 with a discriminated reason. `invalid_token` is the
37
+ // authority rejecting the credential — definitive. `auth_unreachable` and the
38
+ // header-shape classes are not about this session at all.
39
+ const reason = typeof b.reason === "string" ? b.reason : "unauthenticated";
40
+ if (reason === "invalid_token")
41
+ return { live: false, verified: true, reason };
42
+ return { live: true, verified: false, reason };
43
+ }
44
+ if (status !== 200) {
45
+ // 404 = an older deployment without this endpoint. 5xx = the control plane.
46
+ // Neither is evidence about the session.
47
+ return { live: true, verified: false, reason: `http_${status}` };
48
+ }
49
+ if (b.live === false && b.verified === true) {
50
+ return { live: false, verified: true, reason: typeof b.reason === "string" ? b.reason : "revoked" };
51
+ }
52
+ return {
53
+ live: true,
54
+ verified: b.verified === true,
55
+ reason: typeof b.reason === "string" ? b.reason : "live",
56
+ };
57
+ }
58
+ /** Ask the control plane about this session. Never throws — a transport failure
59
+ * is an unverified verdict, not a sign-out. */
60
+ export async function probeUserSession(session, deps = {}) {
61
+ const doFetch = deps.fetch ?? fetch;
62
+ const base = loadConfig().publicUrl.replace(/\/+$/, "");
63
+ try {
64
+ const res = await doFetch(`${base}${SESSION_STATUS_PATH}`, {
65
+ method: "GET",
66
+ headers: {
67
+ Accept: "application/json",
68
+ Authorization: `Bearer ${session.access_token}`,
69
+ "User-Agent": deps.userAgent ?? cliUserAgent(),
70
+ },
71
+ });
72
+ const body = await res.json().catch(() => ({}));
73
+ return decideLiveness(res.status, body);
74
+ }
75
+ catch (err) {
76
+ return {
77
+ live: true,
78
+ verified: false,
79
+ reason: `unreachable: ${err.message?.slice(0, 120) ?? "error"}`,
80
+ };
81
+ }
82
+ }
83
+ /** The message an operator sees when their terminal was revoked from the web. */
84
+ export function revokedMessage() {
85
+ return ("This terminal's ACC session is no longer valid — it was revoked from " +
86
+ `Settings → Sessions, or it expired. Run \`${signInCommand()}\` to sign in again.`);
87
+ }
88
+ /**
89
+ * One probe per process, so two calls inside a single command do not both go to
90
+ * the network. A new command is a new process, which is what "takes effect on its
91
+ * next call" means for a CLI.
92
+ */
93
+ let inFlight = null;
94
+ /** Test seam: forget the memoized verdict. */
95
+ export function resetSessionLivenessProbe() {
96
+ inFlight = null;
97
+ }
98
+ async function runCheck(deps) {
99
+ const session = await loadUserSession();
100
+ // No session at all: the command's own "not signed in" error is the right
101
+ // message, and it does not need a network call to say it.
102
+ if (!session)
103
+ return;
104
+ // A stale token is about to be refreshed, and a refresh against a revoked
105
+ // session already fails with the same error. Probing would be a second network
106
+ // call to learn what the first one is about to tell us.
107
+ if (!accessTokenIsFresh(session, deps.now?.() ?? Date.now()))
108
+ return;
109
+ const verdict = await probeUserSession(session, deps);
110
+ if (verdict.live)
111
+ return;
112
+ // Drop the dead credential: leaving it would make every later command fail the
113
+ // same way, and would make `whoami` claim a session that no longer exists.
114
+ await clearUserSession();
115
+ throw new SignedOutError(revokedMessage());
116
+ }
117
+ /**
118
+ * Called at the top of every person-lane command. Throws SignedOutError when the
119
+ * session has been revoked; returns silently in every other case, including every
120
+ * case where we could not tell.
121
+ */
122
+ export function ensureUserSessionLive(deps = {}) {
123
+ // A rejected memo is deliberately kept: a second caller in the same process
124
+ // should see the same sign-out, not probe a session we already know is gone.
125
+ inFlight ??= runCheck(deps);
126
+ return inFlight;
127
+ }
128
+ //# sourceMappingURL=liveness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"liveness.js","sourceRoot":"","sources":["../../src/user-auth/liveness.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,82 @@
1
+ /** Keychain account for the person session. The runner's is "default". */
2
+ export declare const USER_SESSION_SLOT: "user";
3
+ /** Refresh this far ahead of expiry, so a long RPC never straddles it. */
4
+ export declare const REFRESH_SKEW_SECONDS = 120;
5
+ export interface StoredUserSession {
6
+ access_token: string;
7
+ refresh_token: string;
8
+ /** ISO-8601 instant at which access_token expires. */
9
+ access_expires_at: string;
10
+ user_id: string;
11
+ email: string | null;
12
+ /** Resolved by the exchange, so a fresh machine needs no connection env. */
13
+ supabase_url: string;
14
+ anon_key: string;
15
+ }
16
+ /**
17
+ * `acc-cli/1.2.3 (darwin; my-laptop)`. The hostname is filtered to header-safe
18
+ * characters — a hostname with a stray byte must not make an auth call throw
19
+ * where it would otherwise have succeeded.
20
+ */
21
+ export declare function cliUserAgent(hostname?: string): string;
22
+ /** Raised when the session is gone for good — revoked, expired, or never there. */
23
+ export declare class SignedOutError extends Error {
24
+ constructor(message: string);
25
+ }
26
+ export declare function saveUserSession(session: StoredUserSession): Promise<void>;
27
+ export declare function loadUserSession(): Promise<StoredUserSession | null>;
28
+ export declare function clearUserSession(): Promise<boolean>;
29
+ /** The token payload GoTrue answers `/verify` and `/token` with. */
30
+ export interface GoTrueTokenResponse {
31
+ access_token?: string;
32
+ refresh_token?: string;
33
+ expires_in?: number;
34
+ expires_at?: number;
35
+ user?: {
36
+ id?: string;
37
+ email?: string | null;
38
+ } | null;
39
+ }
40
+ export interface GoTrueTarget {
41
+ supabaseUrl: string;
42
+ anonKey: string;
43
+ }
44
+ /** Injectable so the tests drive the rules without a network. */
45
+ export interface GoTrueDeps {
46
+ fetch?: typeof fetch;
47
+ now?: () => number;
48
+ userAgent?: string;
49
+ }
50
+ /**
51
+ * Fold a token response into a session, or fail loudly. A response missing
52
+ * either token, or the user id, is not a session however cheerful its status
53
+ * code was — better to refuse than to persist a credential we cannot refresh.
54
+ */
55
+ export declare function toStoredSession(json: GoTrueTokenResponse, target: GoTrueTarget, now: number): StoredUserSession;
56
+ /** Spend the one-time grant from /api/auth/device-code for a real session. */
57
+ export declare function verifyEmailOtp(target: GoTrueTarget, params: {
58
+ email: string;
59
+ token: string;
60
+ }, deps?: GoTrueDeps): Promise<StoredUserSession>;
61
+ /**
62
+ * Trade the refresh token for a fresh pair. A 4xx here is the revocation path,
63
+ * not an outage: GoTrue answers 400/401 for a token that was revoked (the
64
+ * sessions surface deleted the row) or already rotated, and both mean the same
65
+ * thing to the operator — sign in again.
66
+ */
67
+ export declare function refreshUserSession(session: StoredUserSession, deps?: GoTrueDeps): Promise<StoredUserSession>;
68
+ export declare function accessTokenIsFresh(session: StoredUserSession, now?: number): boolean;
69
+ /**
70
+ * The accessor everything downstream should use: a live access token for the
71
+ * signed-in human, refreshed and re-persisted when it is close to expiry.
72
+ *
73
+ * The rotated successor is written back BEFORE the token is returned, because a
74
+ * caller that used a token whose refresh partner never landed would be one crash
75
+ * away from replaying a spent token — which GoTrue treats as reuse.
76
+ *
77
+ * A revoked session clears the keychain slot on the way out. Leaving a dead
78
+ * credential in the keychain would make every later command fail the same way
79
+ * with nothing to show for it; removing it makes `auth status` honest.
80
+ */
81
+ export declare function getUserAccessToken(deps?: GoTrueDeps): Promise<string>;
82
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/user-auth/session.ts"],"names":[],"mappings":"AAmCA,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,EAAG,MAAe,CAAC;AAEjD,0EAA0E;AAC1E,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,4EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,CAGrE;AAED,mFAAmF;AACnF,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAID,wBAAgB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAEzE;AAED,wBAAgB,eAAe,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAEnE;AAED,wBAAgB,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC,CAEnD;AAID,oEAAoE;AACpE,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CACtD;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,iEAAiE;AACjE,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA4BD;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,GACV,iBAAiB,CAoBnB;AAED,8EAA8E;AAC9E,wBAAsB,cAAc,CAClC,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EACxC,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,iBAAiB,CAAC,CAW5B;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,GAAE,UAAe,GACpB,OAAO,CAAC,iBAAiB,CAAC,CAiB5B;AAED,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,iBAAiB,EAC1B,GAAG,GAAE,MAAmB,GACvB,OAAO,CAIT;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAc/E"}
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The PERSON's CLI session (AX-G1 DEVICE-FLOW-GENERALIZE, gap G-16).
3
+ *
4
+ * `acc-runner login` enrols a MACHINE: it ends holding an ACC-signed JWT that
5
+ * impersonates its owner, plus a runner row in the fleet. This module is the
6
+ * other thing a terminal might want to be — a human, signed in as themselves,
7
+ * with no runner attached. It is the credential AX-G2 acts under.
8
+ *
9
+ * WHAT THE CREDENTIAL IS
10
+ * ----------------------------------------------------------------------------
11
+ * An ordinary Supabase session: the same access/refresh pair the user's browser
12
+ * holds, obtained by spending the one-time grant /api/auth/device-code hands
13
+ * back (see that file for why the CLI, not the server, spends it). Everything
14
+ * follows from that choice:
15
+ *
16
+ * * REFRESH is GoTrue's, not ACC's. `/auth/v1/token?grant_type=refresh_token`,
17
+ * with rotation — so the successor MUST be written back to the keychain on
18
+ * every refresh, or the next start replays a spent token.
19
+ * * REVOCATION is real and it is not ours. The session is a row in
20
+ * `auth.sessions`; Settings → Sessions deletes it and the delete cascades
21
+ * the refresh-token family. The next refresh here fails, and `signedOut`
22
+ * below is how that becomes "you were signed out" rather than a stack trace.
23
+ * * STORAGE is the OS keychain and nothing else. Its own account slot, so it
24
+ * cannot disturb the runner session's frozen entry.
25
+ *
26
+ * The USER AGENT is stamped deliberately. GoTrue records it on the session row,
27
+ * and api/auth/sessions-core.ts turns it into the device label on the sessions
28
+ * surface — so this string is the difference between "Unknown device" and
29
+ * "acc CLI · my-laptop" when the user decides what to revoke.
30
+ */
31
+ import os from "node:os";
32
+ import { clearSlot, loadSlot, saveSlot } from "../keychain.js";
33
+ import { signInCommand } from "../cli-name.js";
34
+ import { PACKAGE_VERSION } from "../pkg-version.js";
35
+ /** Keychain account for the person session. The runner's is "default". */
36
+ export const USER_SESSION_SLOT = "user";
37
+ /** Refresh this far ahead of expiry, so a long RPC never straddles it. */
38
+ export const REFRESH_SKEW_SECONDS = 120;
39
+ /**
40
+ * `acc-cli/1.2.3 (darwin; my-laptop)`. The hostname is filtered to header-safe
41
+ * characters — a hostname with a stray byte must not make an auth call throw
42
+ * where it would otherwise have succeeded.
43
+ */
44
+ export function cliUserAgent(hostname = os.hostname()) {
45
+ const host = hostname.replace(/[^\w.-]+/g, "-").slice(0, 63) || "unknown-host";
46
+ return `acc-cli/${PACKAGE_VERSION} (${process.platform}; ${host})`;
47
+ }
48
+ /** Raised when the session is gone for good — revoked, expired, or never there. */
49
+ export class SignedOutError extends Error {
50
+ constructor(message) {
51
+ super(message);
52
+ this.name = "SignedOutError";
53
+ }
54
+ }
55
+ // ── Keychain ───────────────────────────────────────────────────────────────
56
+ export function saveUserSession(session) {
57
+ return saveSlot(USER_SESSION_SLOT, session);
58
+ }
59
+ export function loadUserSession() {
60
+ return loadSlot(USER_SESSION_SLOT);
61
+ }
62
+ export function clearUserSession() {
63
+ return clearSlot(USER_SESSION_SLOT);
64
+ }
65
+ async function postGoTrue(target, path, body, deps) {
66
+ const doFetch = deps.fetch ?? fetch;
67
+ const res = await doFetch(`${target.supabaseUrl.replace(/\/+$/, "")}${path}`, {
68
+ method: "POST",
69
+ headers: {
70
+ "Content-Type": "application/json",
71
+ apikey: target.anonKey,
72
+ // Recorded on auth.sessions.user_agent — this is the device label.
73
+ "User-Agent": deps.userAgent ?? cliUserAgent(),
74
+ },
75
+ body: JSON.stringify(body),
76
+ });
77
+ let json = {};
78
+ try {
79
+ json = (await res.json());
80
+ }
81
+ catch {
82
+ /* a non-JSON body is handled by the shape check below */
83
+ }
84
+ return { status: res.status, json };
85
+ }
86
+ /**
87
+ * Fold a token response into a session, or fail loudly. A response missing
88
+ * either token, or the user id, is not a session however cheerful its status
89
+ * code was — better to refuse than to persist a credential we cannot refresh.
90
+ */
91
+ export function toStoredSession(json, target, now) {
92
+ const access = json.access_token;
93
+ const refresh = json.refresh_token;
94
+ const userId = json.user?.id;
95
+ if (!access || !refresh || !userId) {
96
+ throw new Error("auth response did not contain a usable session");
97
+ }
98
+ const expiresAt = typeof json.expires_at === "number"
99
+ ? json.expires_at * 1000
100
+ : now + (typeof json.expires_in === "number" ? json.expires_in : 3600) * 1000;
101
+ return {
102
+ access_token: access,
103
+ refresh_token: refresh,
104
+ access_expires_at: new Date(expiresAt).toISOString(),
105
+ user_id: userId,
106
+ email: json.user?.email ?? null,
107
+ supabase_url: target.supabaseUrl,
108
+ anon_key: target.anonKey,
109
+ };
110
+ }
111
+ /** Spend the one-time grant from /api/auth/device-code for a real session. */
112
+ export async function verifyEmailOtp(target, params, deps = {}) {
113
+ const { status, json } = await postGoTrue(target, "/auth/v1/verify", { type: "email", email: params.email, token: params.token }, deps);
114
+ if (status !== 200) {
115
+ throw new Error(`sign-in grant was rejected (HTTP ${status})`);
116
+ }
117
+ return toStoredSession(json, target, deps.now?.() ?? Date.now());
118
+ }
119
+ /**
120
+ * Trade the refresh token for a fresh pair. A 4xx here is the revocation path,
121
+ * not an outage: GoTrue answers 400/401 for a token that was revoked (the
122
+ * sessions surface deleted the row) or already rotated, and both mean the same
123
+ * thing to the operator — sign in again.
124
+ */
125
+ export async function refreshUserSession(session, deps = {}) {
126
+ const target = { supabaseUrl: session.supabase_url, anonKey: session.anon_key };
127
+ const { status, json } = await postGoTrue(target, "/auth/v1/token?grant_type=refresh_token", { refresh_token: session.refresh_token }, deps);
128
+ if (status >= 400 && status < 500) {
129
+ throw new SignedOutError(`This CLI session was revoked or expired. Run \`${signInCommand()}\`.`);
130
+ }
131
+ if (status !== 200) {
132
+ throw new Error(`token refresh failed (HTTP ${status})`);
133
+ }
134
+ return toStoredSession(json, target, deps.now?.() ?? Date.now());
135
+ }
136
+ export function accessTokenIsFresh(session, now = Date.now()) {
137
+ const expiry = new Date(session.access_expires_at).getTime();
138
+ if (!Number.isFinite(expiry))
139
+ return false;
140
+ return expiry - now > REFRESH_SKEW_SECONDS * 1000;
141
+ }
142
+ /**
143
+ * The accessor everything downstream should use: a live access token for the
144
+ * signed-in human, refreshed and re-persisted when it is close to expiry.
145
+ *
146
+ * The rotated successor is written back BEFORE the token is returned, because a
147
+ * caller that used a token whose refresh partner never landed would be one crash
148
+ * away from replaying a spent token — which GoTrue treats as reuse.
149
+ *
150
+ * A revoked session clears the keychain slot on the way out. Leaving a dead
151
+ * credential in the keychain would make every later command fail the same way
152
+ * with nothing to show for it; removing it makes `auth status` honest.
153
+ */
154
+ export async function getUserAccessToken(deps = {}) {
155
+ const session = await loadUserSession();
156
+ if (!session) {
157
+ throw new SignedOutError(`Not signed in. Run \`${signInCommand()}\`.`);
158
+ }
159
+ if (accessTokenIsFresh(session, deps.now?.() ?? Date.now()))
160
+ return session.access_token;
161
+ try {
162
+ const rotated = await refreshUserSession(session, deps);
163
+ await saveUserSession(rotated);
164
+ return rotated.access_token;
165
+ }
166
+ catch (err) {
167
+ if (err instanceof SignedOutError)
168
+ await clearUserSession();
169
+ throw err;
170
+ }
171
+ }
172
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/user-auth/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ import { type UserApiDeps } from "./client.js";
2
+ export interface ChatOptions {
3
+ conversation?: string;
4
+ json?: boolean;
5
+ }
6
+ export interface SseEvent {
7
+ event: string;
8
+ data: unknown;
9
+ }
10
+ /**
11
+ * Split a raw SSE buffer into whole events, returning the unconsumed tail.
12
+ *
13
+ * The tail matters: a chunk boundary lands mid-frame constantly on a token
14
+ * stream, and an implementation that parsed each chunk independently would drop
15
+ * every event unlucky enough to straddle one.
16
+ */
17
+ export declare function drainSseBuffer(buffer: string): {
18
+ events: SseEvent[];
19
+ rest: string;
20
+ };
21
+ /** What one completed turn tells the caller, so a REPL can continue the thread. */
22
+ export interface TurnResult {
23
+ conversationId: string | null;
24
+ text: string;
25
+ queued: boolean;
26
+ }
27
+ /**
28
+ * Send one message and render the reply. Exported separately from the command so
29
+ * the REPL below can loop over it while holding the conversation id, and so the
30
+ * tests can drive a whole turn through a fetch double.
31
+ */
32
+ export declare function streamTurn(message: string, opts: ChatOptions, deps?: UserApiDeps & {
33
+ write?: (chunk: string) => void;
34
+ }): Promise<TurnResult>;
35
+ /**
36
+ * `acc chat "question"` for one shot, `echo q | acc chat` for a pipe, and a bare
37
+ * `acc chat` on a terminal for a REPL that holds the conversation open. The REPL
38
+ * is not a nicety: without it every follow-up question would need the operator
39
+ * to copy a conversation id back out of the previous line.
40
+ */
41
+ export declare function chatCommand(words: string[], opts?: ChatOptions, deps?: UserApiDeps & {
42
+ write?: (chunk: string) => void;
43
+ }): Promise<void>;
44
+ //# sourceMappingURL=chat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/user-cli/chat.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAa,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAG1D,MAAM,WAAW,WAAW;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAwBnF;AAED,mFAAmF;AACnF,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;CACjB;AAMD;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,EACjB,IAAI,GAAE,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC3D,OAAO,CAAC,UAAU,CAAC,CAkFrB;AASD;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,MAAM,EAAE,EACf,IAAI,GAAE,WAAgB,EACtB,IAAI,GAAE,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC3D,OAAO,CAAC,IAAI,CAAC,CAoCf"}
@@ -0,0 +1,199 @@
1
+ /**
2
+ * `acc chat` — Ask-ACC from the terminal.
3
+ *
4
+ * Posts to the same `/api/chat/stream` endpoint the browser uses, authenticated
5
+ * with the operator's own session, and renders the SSE stream as it arrives.
6
+ * Using the shared endpoint rather than calling a model directly is the whole
7
+ * point: the tool catalog, the delegation ceiling, the capability manifest, the
8
+ * per-turn tool cap and the chat audit trail are all server-side, so a terminal
9
+ * turn is governed exactly like a browser turn and cannot become a side door.
10
+ *
11
+ * TWO SHAPES OF ANSWER. With a BYOK key the endpoint streams `token` events and
12
+ * the reply appears here word by word. Without one, an eligible turn is ENQUEUED
13
+ * for a companion runner to drain and the stream ends after `queued` — there is
14
+ * no inline answer to print, so this says so and hands back the conversation id
15
+ * rather than pretending the turn failed.
16
+ */
17
+ import chalk from "chalk";
18
+ import { createInterface } from "node:readline/promises";
19
+ import { userFetch } from "./client.js";
20
+ import { cliName } from "../cli-name.js";
21
+ /**
22
+ * Split a raw SSE buffer into whole events, returning the unconsumed tail.
23
+ *
24
+ * The tail matters: a chunk boundary lands mid-frame constantly on a token
25
+ * stream, and an implementation that parsed each chunk independently would drop
26
+ * every event unlucky enough to straddle one.
27
+ */
28
+ export function drainSseBuffer(buffer) {
29
+ const events = [];
30
+ let rest = buffer;
31
+ for (;;) {
32
+ const boundary = rest.indexOf("\n\n");
33
+ if (boundary === -1)
34
+ break;
35
+ const frame = rest.slice(0, boundary);
36
+ rest = rest.slice(boundary + 2);
37
+ let event = "message";
38
+ const dataLines = [];
39
+ for (const line of frame.split("\n")) {
40
+ if (line.startsWith("event:"))
41
+ event = line.slice(6).trim();
42
+ else if (line.startsWith("data:"))
43
+ dataLines.push(line.slice(5).trim());
44
+ }
45
+ if (dataLines.length === 0)
46
+ continue;
47
+ let data = dataLines.join("\n");
48
+ try {
49
+ data = JSON.parse(dataLines.join("\n"));
50
+ }
51
+ catch {
52
+ /* a non-JSON payload is passed through as the raw string */
53
+ }
54
+ events.push({ event, data });
55
+ }
56
+ return { events, rest };
57
+ }
58
+ function asRecord(data) {
59
+ return data && typeof data === "object" ? data : {};
60
+ }
61
+ /**
62
+ * Send one message and render the reply. Exported separately from the command so
63
+ * the REPL below can loop over it while holding the conversation id, and so the
64
+ * tests can drive a whole turn through a fetch double.
65
+ */
66
+ export async function streamTurn(message, opts, deps = {}) {
67
+ const say = deps.log ?? ((line) => console.log(line));
68
+ const write = deps.write ?? ((chunk) => process.stdout.write(chunk));
69
+ const res = await userFetch("/api/chat/stream", { body: { message, conversation_id: opts.conversation } }, deps);
70
+ if (!res.ok) {
71
+ // The endpoint's own body carries the actionable reason (no provider, no
72
+ // membership, rate limit); a bare status code would send the operator to
73
+ // the logs for something they can read here.
74
+ let detail = `HTTP ${res.status}`;
75
+ try {
76
+ const body = asRecord(await res.json());
77
+ if (typeof body.error === "string")
78
+ detail = `${body.error} (HTTP ${res.status})`;
79
+ }
80
+ catch {
81
+ /* keep the status-only detail */
82
+ }
83
+ throw new Error(`Chat request failed: ${detail}`);
84
+ }
85
+ if (!res.body)
86
+ throw new Error("Chat response carried no stream.");
87
+ let conversationId = opts.conversation ?? null;
88
+ let text = "";
89
+ let queued = false;
90
+ let buffer = "";
91
+ let wroteAnything = false;
92
+ const decoder = new TextDecoder();
93
+ const reader = res.body.getReader();
94
+ for (;;) {
95
+ const { done, value } = await reader.read();
96
+ if (done)
97
+ break;
98
+ buffer += decoder.decode(value, { stream: true });
99
+ const drained = drainSseBuffer(buffer);
100
+ buffer = drained.rest;
101
+ for (const { event, data } of drained.events) {
102
+ const payload = asRecord(data);
103
+ switch (event) {
104
+ case "conversation":
105
+ if (typeof payload.id === "string")
106
+ conversationId = payload.id;
107
+ break;
108
+ case "token": {
109
+ const chunk = typeof payload.text === "string" ? payload.text : "";
110
+ text += chunk;
111
+ if (!opts.json) {
112
+ write(chunk);
113
+ wroteAnything = wroteAnything || chunk.length > 0;
114
+ }
115
+ break;
116
+ }
117
+ case "tool_call":
118
+ if (!opts.json)
119
+ say(chalk.gray(`\n · ${String(payload.name ?? "tool")}…`));
120
+ break;
121
+ case "queued":
122
+ case "turn":
123
+ queued = true;
124
+ break;
125
+ case "error":
126
+ throw new Error(String(payload.message ?? "chat stream failed"));
127
+ case "done":
128
+ if (typeof payload.conversation_id === "string")
129
+ conversationId = payload.conversation_id;
130
+ if (payload.queued === true)
131
+ queued = true;
132
+ break;
133
+ default:
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ if (wroteAnything)
139
+ write("\n");
140
+ if (opts.json) {
141
+ say(JSON.stringify({ conversation_id: conversationId, text, queued }, null, 2));
142
+ }
143
+ else if (queued && !text) {
144
+ say(chalk.yellow("· Queued to a companion runner — no inline answer. Open the conversation in ACC to read the reply."));
145
+ }
146
+ return { conversationId, text, queued };
147
+ }
148
+ /** Read a piped message when one was not passed as an argument. */
149
+ async function readStdin() {
150
+ const chunks = [];
151
+ for await (const chunk of process.stdin)
152
+ chunks.push(Buffer.from(chunk));
153
+ return Buffer.concat(chunks).toString("utf8").trim();
154
+ }
155
+ /**
156
+ * `acc chat "question"` for one shot, `echo q | acc chat` for a pipe, and a bare
157
+ * `acc chat` on a terminal for a REPL that holds the conversation open. The REPL
158
+ * is not a nicety: without it every follow-up question would need the operator
159
+ * to copy a conversation id back out of the previous line.
160
+ */
161
+ export async function chatCommand(words, opts = {}, deps = {}) {
162
+ const say = deps.log ?? ((line) => console.log(line));
163
+ const inline = words.join(" ").trim();
164
+ if (inline) {
165
+ const result = await streamTurn(inline, opts, deps);
166
+ if (!opts.json && result.conversationId) {
167
+ say(chalk.gray(`\nContinue: ${cliName()} chat --conversation ${result.conversationId}`));
168
+ }
169
+ return;
170
+ }
171
+ if (!process.stdin.isTTY) {
172
+ const piped = await readStdin();
173
+ if (!piped)
174
+ throw new Error("No message given (pass one as an argument or pipe it in).");
175
+ await streamTurn(piped, opts, deps);
176
+ return;
177
+ }
178
+ say(chalk.bold("Ask ACC. Blank line or Ctrl-D to exit."));
179
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
180
+ // Ctrl-D closes the input without settling the pending question(), which would
181
+ // hang the process on an invisible prompt. Racing the close event turns EOF
182
+ // into the same clean exit a blank line gives.
183
+ const closed = new Promise((resolve) => rl.once("close", () => resolve(null)));
184
+ let conversation = opts.conversation;
185
+ try {
186
+ for (;;) {
187
+ const answer = await Promise.race([rl.question(chalk.cyan("\n› ")), closed]);
188
+ const line = (answer ?? "").trim();
189
+ if (!line)
190
+ break;
191
+ const result = await streamTurn(line, { ...opts, conversation }, deps);
192
+ conversation = result.conversationId ?? conversation;
193
+ }
194
+ }
195
+ finally {
196
+ rl.close();
197
+ }
198
+ }
199
+ //# sourceMappingURL=chat.js.map