howtofixcli 0.7.7 → 0.7.9

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 (152) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-63ZCQDXH.js +6 -0
  5. package/dist/chunk-6FUWMLWO.js +1 -0
  6. package/dist/chunk-7CHDNTH6.js +1 -0
  7. package/dist/chunk-ACOMNDUL.js +185 -0
  8. package/dist/chunk-AEVPET7X.js +2 -0
  9. package/dist/chunk-AGGIYXN7.js +2 -0
  10. package/dist/chunk-BHVBO4SC.js +1 -0
  11. package/dist/chunk-FPXVDP32.js +8 -0
  12. package/dist/chunk-GIUYIKH3.js +109 -0
  13. package/dist/chunk-GQCAJ56I.js +5 -0
  14. package/dist/chunk-GXB7RZHH.js +1 -0
  15. package/dist/chunk-IGEY3AWF.js +1 -0
  16. package/dist/chunk-ILDDDLRC.js +1 -0
  17. package/dist/chunk-JFYVRWJI.js +2 -0
  18. package/dist/chunk-MVLCGU3G.js +1 -0
  19. package/dist/chunk-N345KWWK.js +1 -0
  20. package/dist/chunk-NBQFPCRT.js +1 -0
  21. package/dist/chunk-OIPSLIZE.js +5 -0
  22. package/dist/chunk-ORJ2QZBG.js +3 -0
  23. package/dist/chunk-PCALBZOO.js +2 -0
  24. package/dist/chunk-PDNBGUYP.js +2 -0
  25. package/dist/chunk-S656RE4A.js +1 -0
  26. package/dist/chunk-TSEKPGK4.js +1 -0
  27. package/dist/chunk-U6CI6KXN.js +34 -0
  28. package/dist/chunk-UTYFLLSM.js +1 -0
  29. package/dist/chunk-V3R4FYTJ.js +1 -0
  30. package/dist/chunk-V656XVXV.js +8 -0
  31. package/dist/chunk-WKUJ7CFO.js +1 -0
  32. package/dist/chunk-WOT6VMZA.js +1 -0
  33. package/dist/chunk-WUPU6LTD.js +4 -0
  34. package/dist/chunk-WX3V5P7U.js +5 -0
  35. package/dist/chunk-ZKZ6WCI3.js +2 -0
  36. package/dist/main.js +94 -536
  37. package/package.json +15 -62
  38. package/dist/account-credential.js +0 -136
  39. package/dist/account-credential.test.js +0 -38
  40. package/dist/activity.js +0 -124
  41. package/dist/agent-defs.js +0 -237
  42. package/dist/agent-limiter.js +0 -68
  43. package/dist/agents.js +0 -554
  44. package/dist/anim.js +0 -361
  45. package/dist/args.js +0 -228
  46. package/dist/at-file.js +0 -28
  47. package/dist/brand-extension.js +0 -87
  48. package/dist/brand.js +0 -76
  49. package/dist/browser.js +0 -58
  50. package/dist/cache-report.js +0 -139
  51. package/dist/checkpoint.js +0 -437
  52. package/dist/claims.js +0 -167
  53. package/dist/cli-auth.js +0 -142
  54. package/dist/cli-checkin.js +0 -116
  55. package/dist/cli-credentials.js +0 -254
  56. package/dist/clipboard.js +0 -149
  57. package/dist/collapse.js +0 -129
  58. package/dist/commands.js +0 -301
  59. package/dist/compaction-guard.js +0 -81
  60. package/dist/context-extension.js +0 -17
  61. package/dist/dcp/commands.js +0 -172
  62. package/dist/dcp/compress-state.js +0 -139
  63. package/dist/dcp/compress.js +0 -329
  64. package/dist/dcp/dcp-extension.js +0 -288
  65. package/dist/dcp/dedup.js +0 -93
  66. package/dist/dcp/ids.js +0 -189
  67. package/dist/dcp/index.js +0 -25
  68. package/dist/dcp/prompts.js +0 -68
  69. package/dist/dcp/protected-content.js +0 -221
  70. package/dist/dcp/protected.js +0 -161
  71. package/dist/dcp/prune.js +0 -418
  72. package/dist/dcp/state.js +0 -176
  73. package/dist/device-id.js +0 -90
  74. package/dist/device-id.test.js +0 -23
  75. package/dist/errors.js +0 -63
  76. package/dist/extensions.js +0 -48
  77. package/dist/fatal.js +0 -35
  78. package/dist/feed.js +0 -498
  79. package/dist/fs-atomic.js +0 -54
  80. package/dist/gateway-errors.js +0 -90
  81. package/dist/git.js +0 -230
  82. package/dist/goal-extension.js +0 -88
  83. package/dist/goal.js +0 -222
  84. package/dist/hooks-extension.js +0 -176
  85. package/dist/hooks.js +0 -443
  86. package/dist/howtofix-account.js +0 -715
  87. package/dist/hud-line.js +0 -153
  88. package/dist/hwid.js +0 -130
  89. package/dist/interrupt.js +0 -60
  90. package/dist/json-events.js +0 -50
  91. package/dist/level-style.js +0 -112
  92. package/dist/loop-guard.js +0 -95
  93. package/dist/lsp-tools.js +0 -32
  94. package/dist/lsp.js +0 -347
  95. package/dist/mcp-config.js +0 -85
  96. package/dist/mcp-lifecycle.js +0 -147
  97. package/dist/model-catalog.js +0 -217
  98. package/dist/model-resolve.js +0 -65
  99. package/dist/notes.js +0 -78
  100. package/dist/oauth-login.js +0 -568
  101. package/dist/paths.js +0 -61
  102. package/dist/permissions.js +0 -298
  103. package/dist/plan-mode.js +0 -363
  104. package/dist/preset-command.js +0 -227
  105. package/dist/preset-extension.js +0 -35
  106. package/dist/preset-mcp.js +0 -292
  107. package/dist/presets.js +0 -727
  108. package/dist/prompt-files.js +0 -103
  109. package/dist/prompt-text.js +0 -41
  110. package/dist/proxy.js +0 -137
  111. package/dist/quality/ast.js +0 -196
  112. package/dist/quality/cognitive.js +0 -83
  113. package/dist/quality/cyclomatic.js +0 -30
  114. package/dist/quality/halstead.js +0 -83
  115. package/dist/quality/index.js +0 -18
  116. package/dist/quality/length.js +0 -3
  117. package/dist/quality/maintainability.js +0 -15
  118. package/dist/quality/nesting.js +0 -57
  119. package/dist/quality/report.js +0 -803
  120. package/dist/quality/smells.js +0 -67
  121. package/dist/quality-tool.js +0 -63
  122. package/dist/read-only-commands.js +0 -597
  123. package/dist/repo-map.js +0 -292
  124. package/dist/rtk.js +0 -193
  125. package/dist/sandbox.js +0 -105
  126. package/dist/schema.js +0 -214
  127. package/dist/session-lifecycle.js +0 -19
  128. package/dist/session-retention.js +0 -111
  129. package/dist/session.js +0 -446
  130. package/dist/sessions.js +0 -100
  131. package/dist/settings.js +0 -148
  132. package/dist/skills.js +0 -35
  133. package/dist/slop-tool.js +0 -96
  134. package/dist/spend-cap.js +0 -43
  135. package/dist/subagent.js +0 -171
  136. package/dist/tar.js +0 -146
  137. package/dist/theme.js +0 -79
  138. package/dist/thinking.js +0 -45
  139. package/dist/todo.js +0 -114
  140. package/dist/tool-loadout.js +0 -97
  141. package/dist/tool-render.js +0 -769
  142. package/dist/tools.js +0 -176
  143. package/dist/ui.js +0 -4751
  144. package/dist/update.js +0 -533
  145. package/dist/usage-segment.js +0 -69
  146. package/dist/web-tools.js +0 -46
  147. package/dist/web.js +0 -265
  148. package/dist/welcome.js +0 -179
  149. package/dist/workflow-engine.js +0 -246
  150. package/dist/workflow-progress.js +0 -446
  151. package/dist/workflow-view.js +0 -195
  152. package/dist/workflow.js +0 -349
@@ -1,568 +0,0 @@
1
- /**
2
- * Signing in to howtofix.id the way Claude Code signs in: a browser, not a
3
- * pasted token.
4
- *
5
- * ## Why this is a flow and not a request
6
- *
7
- * `/connect <token>` works and stays. But the token is the step people get
8
- * stuck on: find the panel, sign in, find the keys page, create a key, copy it,
9
- * come back. A browser login removes all of it — the CLI opens the page, the
10
- * user clicks once, and a key lands in the CLI.
11
- *
12
- * ## The two flows, and why both
13
- *
14
- * **Loopback (preferred).** The CLI listens on `127.0.0.1:<random port>`, sends
15
- * the browser to the panel with that address as the callback, and catches the
16
- * redirect. This is Claude Code's flow and it needs no code to copy. It requires
17
- * the panel to accept a client-supplied `redirect_uri` and to hand the issued key
18
- * to a non-browser client.
19
- *
20
- * **Device code (fallback).** For a remote box, a container, or a browser on
21
- * another machine, nothing can reach `127.0.0.1` on this host. The CLI asks the
22
- * panel for a short code, shows it, and polls until the user approves it in
23
- * their own browser. This is what `gh auth login` and the OAuth device grant do,
24
- * and it needs no callback at all.
25
- *
26
- * Both are spoken only when the panel offers them. Against a panel that has
27
- * neither — where howtofix.id is today — the CLI says exactly what is missing
28
- * instead of opening a browser onto a dead end.
29
- *
30
- * ## The order that matters
31
- *
32
- * The listener starts *before* the panel is asked for an authorize URL, because
33
- * the callback address has to contain the port the listener actually bound. A
34
- * redirect URI guessed before the socket exists is the classic way this flow
35
- * breaks on a machine where the port was taken.
36
- *
37
- * ## What is never done here
38
- *
39
- * No credential is printed, and the token that comes back is handed straight to
40
- * `/connect`'s existing path (verify, store, sync models, pick a model). This
41
- * module's whole job is to turn a browser click into that token.
42
- */
43
- import { createHash, randomBytes } from "node:crypto";
44
- import { createServer } from "node:http";
45
- import { describeClient, signLoginRequest } from "./cli-auth.js";
46
- import { fingerprintParts, resolveHwid } from "./hwid.js";
47
- /** Where the panel lives. `api.` serves the API but not the OAuth routes. */
48
- export const PANEL_URL = "https://howtofix.id";
49
- /** How long to wait for the browser before giving up, in milliseconds. */
50
- export const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
51
- /** How often the device flow asks whether the user has approved yet. */
52
- export const POLL_INTERVAL_MS = 3_000;
53
- /** The path the loopback listener answers on. */
54
- export const CALLBACK_PATH = "/callback";
55
- /** A base64url string with no padding, as PKCE requires. */
56
- export function base64url(bytes) {
57
- return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
58
- }
59
- /** A fresh PKCE verifier. */
60
- export function createVerifier() {
61
- return base64url(randomBytes(32));
62
- }
63
- /** The S256 challenge for a verifier. */
64
- export function createChallenge(verifier) {
65
- return base64url(createHash("sha256").update(verifier).digest());
66
- }
67
- /** A CSRF state value. */
68
- export function createState() {
69
- return base64url(randomBytes(16));
70
- }
71
- /**
72
- * The marker that tells the panel this is a CLI, not an API key.
73
- *
74
- * The panel has to tell two very different clients apart: a program holding an
75
- * `sk-` key, and this CLI asking to be signed in. A prefix on the fingerprint is
76
- * what carries that — it is present on every CLI request, it needs no secret, and
77
- * a key can never be mistaken for it. (Freebuff marks its own the same way.)
78
- */
79
- export const CLI_FINGERPRINT_PREFIX = "howtofix-cli-";
80
- /** The header that says which client is asking, for a server that logs by it. */
81
- export const CLIENT_HEADER = "x-howtofix-client";
82
- export const CLIENT_NAME = "cli";
83
- /**
84
- * A stable id for this machine, derived from the machine's own OS identity.
85
- *
86
- * Stable matters: the panel recognises the same CLI across runs, so a login
87
- * started in one run can be finished by the next — and, more importantly, so a
88
- * second sign-in from the same computer is recognised as the same computer
89
- * instead of piling up another device session. Before `hwid.ts` the inputs were
90
- * the hostname, the platform, the username and the CPU model, none of which is
91
- * hardware: one box had already produced two device sessions and two access
92
- * tokens under one fingerprint.
93
- *
94
- * Nothing here is a secret and nothing is persisted — the machine id is an HMAC
95
- * over the OS value, so the panel can match a machine without being handed its
96
- * serial number.
97
- *
98
- * The prefix is load-bearing: it is how the server distinguishes a CLI login
99
- * request from an API-key request without a key ever being sent.
100
- */
101
- export function createFingerprintId(parts) {
102
- const stable = parts ?? fingerprintParts(resolveHwid());
103
- return CLI_FINGERPRINT_PREFIX + createHash("sha256").update(stable.join("\u0000")).digest("hex").slice(0, 32);
104
- }
105
- /** The headers every CLI request carries. */
106
- export function clientHeaders() {
107
- return { "content-type": "application/json", [CLIENT_HEADER]: CLIENT_NAME };
108
- }
109
- /** A real listener on `127.0.0.1`, on a port the OS picks. */
110
- export const openLoopbackListener = () => new Promise((resolve, reject) => {
111
- const server = createServer();
112
- server.once("error", reject);
113
- server.listen(0, "127.0.0.1", () => {
114
- const { port } = server.address();
115
- resolve({
116
- port,
117
- close: () => server.close(),
118
- waitForCode: (state, signal) => new Promise((settle, fail) => {
119
- const onRequest = (request, response) => {
120
- const url = new URL(request.url ?? "/", "http://127.0.0.1");
121
- if (url.pathname !== CALLBACK_PATH) {
122
- response.writeHead(404).end("not found");
123
- return;
124
- }
125
- const page = (title, detail) => response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><meta charset="utf-8"><h2>${title}</h2><p>${detail}</p>`);
126
- const error = url.searchParams.get("error") ?? "";
127
- const code = url.searchParams.get("code") ?? "";
128
- const returnedState = url.searchParams.get("state") ?? "";
129
- if (error) {
130
- page("Sign-in was refused", "You can close this tab.");
131
- finish(new Error(`the browser reported: ${error}`));
132
- return;
133
- }
134
- if (!code || returnedState !== state) {
135
- // A wrong state is answered, not ignored: the browser is waiting.
136
- page("This callback did not belong to the login", "You can close this tab.");
137
- finish(new Error("the callback carried the wrong state"));
138
- return;
139
- }
140
- page("Signed in", "You can close this tab and go back to HowToFix.");
141
- finish(undefined, code);
142
- };
143
- const finish = (cause, code) => {
144
- server.off("request", onRequest);
145
- signal?.removeEventListener("abort", onAbort);
146
- if (cause)
147
- fail(cause);
148
- else
149
- settle(code);
150
- };
151
- const onAbort = () => finish(new Error("cancelled"));
152
- signal?.addEventListener("abort", onAbort, { once: true });
153
- server.on("request", onRequest);
154
- }),
155
- });
156
- });
157
- });
158
- /** Report progress if anyone is listening; an omitted callback is not an error. */
159
- function notify(options, progress) {
160
- options.onProgress?.(progress);
161
- }
162
- /** POST JSON and read JSON back, keeping the status for the caller to read. */
163
- async function postJson(fetchImpl, url, body, signal) {
164
- const response = await fetchImpl(url, {
165
- method: "POST",
166
- headers: { "content-type": "application/json" },
167
- body: JSON.stringify(body),
168
- signal,
169
- });
170
- let parsed = {};
171
- try {
172
- parsed = (await response.json());
173
- }
174
- catch {
175
- // A non-JSON body (a proxy's HTML error page) leaves the status as the story.
176
- }
177
- const retryAfter = Number(response.headers?.get?.("retry-after"));
178
- return {
179
- status: response.status,
180
- body: parsed,
181
- retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
182
- };
183
- }
184
- /** The first non-empty string among the candidates. */
185
- function pickString(...candidates) {
186
- for (const candidate of candidates) {
187
- if (typeof candidate === "string" && candidate.trim() !== "")
188
- return candidate.trim();
189
- }
190
- return undefined;
191
- }
192
- /** The first finite number among the candidates, or 0. Used for expiry stamps. */
193
- function pickNumber(...candidates) {
194
- for (const candidate of candidates) {
195
- if (typeof candidate === "number" && Number.isFinite(candidate))
196
- return candidate;
197
- }
198
- return 0;
199
- }
200
- /** The code inside a login URL, for a panel that returns only the URL. */
201
- export function codeFromUrl(url) {
202
- try {
203
- const value = new URL(url).searchParams.get("code");
204
- return value && value.trim() !== "" ? value.trim() : undefined;
205
- }
206
- catch {
207
- return undefined;
208
- }
209
- }
210
- /** GET JSON, keeping the status for the caller to read. */
211
- async function getJson(fetchImpl, url, signal) {
212
- const response = await fetchImpl(url, { headers: clientHeaders(), signal });
213
- let parsed = {};
214
- try {
215
- parsed = (await response.json());
216
- }
217
- catch {
218
- // A non-JSON body leaves the status as the story.
219
- }
220
- // The panel asks throttled clients to wait; honouring it is the difference
221
- // between backing off and failing. Guarded because a panel (or a test
222
- // double) is not required to send the header at all.
223
- const retryAfter = Number(response.headers?.get?.("retry-after"));
224
- return {
225
- status: response.status,
226
- body: parsed,
227
- retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
228
- };
229
- }
230
- /** The API key inside a panel response, wherever the panel put it. */
231
- export function tokenFromResponse(body) {
232
- const data = body.data;
233
- const candidates = [body.token, body.api_key, body.apiKey, body.key, data?.token, data?.api_key, data?.apiKey, data?.key];
234
- for (const candidate of candidates) {
235
- if (typeof candidate === "string" && candidate.trim().startsWith("sk-"))
236
- return candidate.trim();
237
- }
238
- return undefined;
239
- }
240
- /**
241
- * The credential pair a poll body carries, or undefined.
242
- *
243
- * The access token is the relay key; the other three fields are what renew it.
244
- * All four must be present for the pair to be usable — an access token with no
245
- * refresh token is the old, permanent-key behaviour, and storing it as a device
246
- * session would mean the panel lists a device that can never renew.
247
- */
248
- export function credentialFromResponse(body) {
249
- const data = body.data;
250
- const accessToken = pickString(body.token, data?.token);
251
- const refreshToken = pickString(body.refresh_token, data?.refresh_token);
252
- const deviceId = pickString(body.device_id, data?.device_id);
253
- if (!accessToken || !refreshToken || !deviceId)
254
- return undefined;
255
- const accessExpiresAt = pickNumber(body.access_expires_at, data?.access_expires_at);
256
- const refreshExpiresAt = pickNumber(body.refresh_expires_at, data?.refresh_expires_at);
257
- return { accessToken, accessExpiresAt, refreshToken, deviceId, refreshExpiresAt };
258
- }
259
- /** A sentence from a panel error body, or a fallback. */
260
- function messageFrom(body, fallback) {
261
- const message = body.message ?? body.error;
262
- return typeof message === "string" && message.trim() ? message.trim() : fallback;
263
- }
264
- /**
265
- * Sign in through the browser and return an API key.
266
- *
267
- * Tries the loopback flow first — it is the one with no code to copy — and falls
268
- * back to the device flow when this machine cannot receive a callback. Every
269
- * failure is a sentence, because this runs from a slash command and an unhandled
270
- * rejection there kills the workbench.
271
- */
272
- export async function loginWithBrowser(options) {
273
- const fetchImpl = options.fetchImpl ?? fetch;
274
- const panel = (options.panelUrl ?? PANEL_URL).replace(/\/+$/, "");
275
- const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
276
- const shared = { ...options, panel, fetchImpl, timeoutMs };
277
- // The fingerprint flow first: it needs no callback at all, so it works from a
278
- // remote box, a container, and a browser on another machine alike.
279
- const fingerprint = await fingerprintLogin(shared);
280
- if (fingerprint.ok)
281
- return fingerprint.result;
282
- if (fingerprint.terminal)
283
- return fingerprint.result;
284
- const loopback = await loopbackLogin(shared);
285
- if (loopback.ok)
286
- return loopback.result;
287
- // A refused exchange or a cancel is the user's answer, not a missing feature:
288
- // asking again through another flow would only repeat the question.
289
- if (loopback.terminal)
290
- return loopback.result;
291
- const device = await deviceLogin(shared);
292
- if (device.ok)
293
- return device;
294
- return {
295
- ok: false,
296
- message: `${fingerprint.result.ok ? "" : fingerprint.result.message}; ` +
297
- `${loopback.result.ok ? "" : loopback.result.message}; ${device.message}. ` +
298
- `howtofix.id does not offer a browser login a local client can complete yet — ` +
299
- `run /connect and paste a token from ${panel}/keys instead.`,
300
- };
301
- }
302
- async function loopbackLogin(options) {
303
- const verifier = createVerifier();
304
- const state = createState();
305
- let listener;
306
- try {
307
- // The socket first: the callback address must carry the port it really bound.
308
- listener = await (options.loopback ?? openLoopbackListener)();
309
- const redirectUri = `http://127.0.0.1:${listener.port}${CALLBACK_PATH}`;
310
- const started = await postJson(options.fetchImpl, `${options.panel}/api/oauth/device/start`, { redirect_uri: redirectUri, code_challenge: createChallenge(verifier), code_challenge_method: "S256", state }, options.signal);
311
- if (started.status === 404) {
312
- return { ok: false, terminal: false, result: { ok: false, message: "the panel has no loopback login endpoint" } };
313
- }
314
- if (started.status >= 400) {
315
- return {
316
- ok: false,
317
- terminal: false,
318
- result: { ok: false, message: messageFrom(started.body, `the panel refused the login (${started.status})`) },
319
- };
320
- }
321
- const authorizeUrl = typeof started.body.authorize_url === "string" ? started.body.authorize_url : "";
322
- if (!authorizeUrl) {
323
- return { ok: false, terminal: false, result: { ok: false, message: "the panel did not say where to send the browser" } };
324
- }
325
- notify(options, { kind: "browser", url: authorizeUrl });
326
- const code = await withTimeout(listener.waitForCode(state, options.signal), options.timeoutMs);
327
- const exchange = await postJson(options.fetchImpl, `${options.panel}/api/oauth/device/token`, { flow_token: started.body.flow_token, code, code_verifier: verifier }, options.signal);
328
- if (exchange.status >= 400) {
329
- return {
330
- ok: false,
331
- terminal: true,
332
- result: { ok: false, message: messageFrom(exchange.body, `the panel refused the exchange (${exchange.status})`) },
333
- };
334
- }
335
- const token = tokenFromResponse(exchange.body);
336
- if (!token) {
337
- return { ok: false, terminal: true, result: { ok: false, message: "the panel did not return an API key" } };
338
- }
339
- return { ok: true, result: { ok: true, token } };
340
- }
341
- catch (cause) {
342
- const message = cause instanceof Error ? cause.message : String(cause);
343
- // A cancel or a timeout is the user's answer; anything else might be a panel
344
- // that only speaks the other flow, so the caller may still fall back.
345
- const terminal = message === "cancelled";
346
- return { ok: false, terminal, result: { ok: false, message } };
347
- }
348
- finally {
349
- listener?.close();
350
- }
351
- }
352
- /**
353
- * The fingerprint flow: ask for a login URL, show it, and poll until the user
354
- * has approved in their own browser.
355
- *
356
- * This is the one that needs nothing from the network but outbound HTTPS — no
357
- * loopback callback, no code to copy — which is why it is tried first. The CLI
358
- * signs the request so the panel can tell a real CLI from a script that merely
359
- * sends the same prefix, and it keeps the polling secret the panel hands back:
360
- * the code travels in the URL the user opens, so it cannot be the thing that
361
- * collects the key.
362
- */
363
- async function fingerprintLogin(options) {
364
- const interval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
365
- const fingerprintId = options.fingerprintId ?? createFingerprintId();
366
- // Describe the machine for the human on the approval page, and sign the whole
367
- // request so the panel knows it is the CLI and not something wearing its name.
368
- const clientInfo = options.clientInfo ?? describeClient();
369
- const proof = signLoginRequest(fingerprintId, clientInfo);
370
- try {
371
- const started = await postJson(options.fetchImpl, `${options.panel}/api/auth/cli/code`, { fingerprintId, clientInfo, ...proof }, options.signal);
372
- if (started.status === 404) {
373
- return { ok: false, terminal: false, result: { ok: false, message: "the panel has no CLI login endpoint" } };
374
- }
375
- if (started.status === 401) {
376
- return {
377
- ok: false,
378
- terminal: false,
379
- result: {
380
- ok: false,
381
- message: "the panel did not recognise this build as the HowToFix CLI — update it with `npm install -g howtofixcli@latest`",
382
- },
383
- };
384
- }
385
- if (started.status === 429) {
386
- // Throttled at the start, not refused. Falling through to the loopback and
387
- // device flows here is actively misleading: they fail too, and the user is
388
- // shown three unrelated errors instead of the one real cause. Say what
389
- // happened and stop.
390
- const wait = started.retryAfterSeconds ? ` Try again in ${Math.ceil(started.retryAfterSeconds / 60)} minute(s).` : "";
391
- return {
392
- ok: false,
393
- terminal: true,
394
- result: {
395
- ok: false,
396
- message: `too many sign-in attempts from this machine right now.${wait}`,
397
- },
398
- };
399
- }
400
- if (started.status >= 400) {
401
- return {
402
- ok: false,
403
- terminal: false,
404
- result: { ok: false, message: messageFrom(started.body, `the panel refused the login (${started.status})`) },
405
- };
406
- }
407
- // The panel wraps its payload in `data` (its own success envelope), so read
408
- // both shapes — a panel that answers flat must keep working.
409
- const data = (started.body.data ?? {});
410
- const url = pickString(started.body.login_url, data.login_url);
411
- // The code is what the browser approves with, and it is in the URL; the
412
- // secret is what collects the key and never leaves this process. Both are
413
- // needed, and keeping them apart is what stops a leaked link yielding a key.
414
- const code = pickString(started.body.code, data.code) ?? (url ? codeFromUrl(url) : undefined);
415
- const secret = pickString(started.body.secret, data.secret);
416
- if (!url || !code) {
417
- return { ok: false, terminal: false, result: { ok: false, message: "the panel did not return a login URL" } };
418
- }
419
- if (!secret) {
420
- // A panel that predates the split does not return one. The CLI still has
421
- // to work against it, because the release order is CLI first and server
422
- // second: refusing here would break every sign-in in the window between
423
- // the two. So fall back to polling with the code alone, which is exactly
424
- // the old behaviour and no worse than it. Once the panel is updated it
425
- // always returns a secret and this branch stops being taken.
426
- notify(options, { kind: "browser", url });
427
- return await pollForToken(options, code, undefined);
428
- }
429
- notify(options, { kind: "browser", url });
430
- return await pollForToken(options, code, secret);
431
- }
432
- catch (cause) {
433
- const message = cause instanceof Error ? cause.message : String(cause);
434
- return { ok: false, terminal: message === "cancelled", result: { ok: false, message } };
435
- }
436
- }
437
- /**
438
- * Poll until the login is approved, the link dies, or the wait runs out.
439
- *
440
- * `secret` is sent when the panel gave one: the code travels in the URL the user
441
- * opens, so it cannot be the only thing that collects the key.
442
- */
443
- async function pollForToken(options, code, secret) {
444
- const interval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
445
- const deadline = Date.now() + options.timeoutMs;
446
- while (Date.now() < deadline) {
447
- await sleep(interval, options.signal);
448
- // `capability` tells the panel this build can renew an access token with a
449
- // refresh token. The server uses it only to choose the credential's shape:
450
- // a build that cannot renew is handed the permanent key it has always had,
451
- // because a token that expires in hours would break it with no way back. A
452
- // panel that predates the field ignores it and answers as before.
453
- const query = new URLSearchParams(secret ? { code, secret } : { code });
454
- query.set("capability", "device-refresh");
455
- const poll = await getJson(options.fetchImpl, `${options.panel}/api/auth/cli/status?${query}`, options.signal);
456
- if (poll.status === 404) {
457
- return { ok: false, terminal: false, result: { ok: false, message: "the panel has no CLI login status endpoint" } };
458
- }
459
- if (poll.status === 403) {
460
- return {
461
- ok: false,
462
- terminal: true,
463
- result: { ok: false, message: "the panel rejected this poll — run /connect again" },
464
- };
465
- }
466
- if (poll.status === 429) {
467
- // Throttled, not refused. Wait as long as the panel asks — bounded, so a
468
- // hostile or broken Retry-After cannot hang the login — then keep
469
- // polling. A rate limit must never turn into a failed sign-in: the user
470
- // is sitting there waiting for a login that would otherwise just die.
471
- const waitMs = Math.min(Math.max(poll.retryAfterSeconds ?? 5, 1) * 1_000, 30_000);
472
- await sleep(waitMs, options.signal);
473
- continue;
474
- }
475
- const token = tokenFromResponse(poll.body);
476
- if (token) {
477
- // A panel that issues a device session returns the refresh half too, and
478
- // the caller has to keep it: without it the access token simply expires in
479
- // hours and the user is back at the browser. A panel that predates the pair
480
- // returns only the token, which is the old behaviour and still works.
481
- return { ok: true, result: { ok: true, token, credential: credentialFromResponse(poll.body) } };
482
- }
483
- const status = pickString(poll.body.data?.status, poll.body.status);
484
- if (status === "expired") {
485
- return {
486
- ok: false,
487
- terminal: true,
488
- result: { ok: false, message: "the sign-in link expired before it was approved" },
489
- };
490
- }
491
- if (poll.status >= 400) {
492
- return {
493
- ok: false,
494
- terminal: true,
495
- result: { ok: false, message: messageFrom(poll.body, `the panel refused the poll (${poll.status})`) },
496
- };
497
- }
498
- }
499
- return { ok: false, terminal: true, result: { ok: false, message: "the sign-in was not approved in time" } };
500
- }
501
- async function deviceLogin(options) {
502
- const interval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
503
- try {
504
- const started = await postJson(options.fetchImpl, `${options.panel}/api/oauth/device`, {}, options.signal);
505
- if (started.status === 404)
506
- return { ok: false, message: "the panel has no device-code endpoint" };
507
- if (started.status >= 400) {
508
- return { ok: false, message: messageFrom(started.body, `the panel refused the request (${started.status})`) };
509
- }
510
- const deviceCode = typeof started.body.device_code === "string" ? started.body.device_code : "";
511
- const userCode = typeof started.body.user_code === "string" ? started.body.user_code : "";
512
- const url = typeof started.body.verification_uri === "string" ? started.body.verification_uri : `${options.panel}/oauth/device`;
513
- if (!deviceCode || !userCode)
514
- return { ok: false, message: "the panel did not return a code to show" };
515
- notify(options, { kind: "code", code: userCode, url });
516
- const deadline = Date.now() + options.timeoutMs;
517
- while (Date.now() < deadline) {
518
- await sleep(interval, options.signal);
519
- const poll = await postJson(options.fetchImpl, `${options.panel}/api/oauth/device/poll`, { device_code: deviceCode }, options.signal);
520
- if (poll.status === 404)
521
- return { ok: false, message: "the panel has no device-code poll endpoint" };
522
- const status = typeof poll.body.status === "string" ? poll.body.status : "";
523
- if (status === "pending")
524
- continue;
525
- if (status === "slow_down") {
526
- await sleep(interval, options.signal);
527
- continue;
528
- }
529
- if (status === "denied")
530
- return { ok: false, message: "the sign-in was refused in the browser" };
531
- if (status === "expired")
532
- return { ok: false, message: "the code expired; run /connect again" };
533
- const token = tokenFromResponse(poll.body);
534
- if (token)
535
- return { ok: true, token };
536
- if (poll.status >= 400) {
537
- return { ok: false, message: messageFrom(poll.body, `the panel refused the poll (${poll.status})`) };
538
- }
539
- }
540
- return { ok: false, message: "the code was not approved in time" };
541
- }
542
- catch (cause) {
543
- return { ok: false, message: cause instanceof Error ? cause.message : String(cause) };
544
- }
545
- }
546
- /** Reject after `ms`, so a login that is never finished does not hang forever. */
547
- function withTimeout(promise, ms) {
548
- return new Promise((resolve, reject) => {
549
- const timer = setTimeout(() => reject(new Error("the sign-in was not finished in time")), ms);
550
- promise.then((value) => {
551
- clearTimeout(timer);
552
- resolve(value);
553
- }, (cause) => {
554
- clearTimeout(timer);
555
- reject(cause);
556
- });
557
- });
558
- }
559
- /** A sleep a cancel can cut short. */
560
- function sleep(ms, signal) {
561
- return new Promise((resolve, reject) => {
562
- const timer = setTimeout(resolve, ms);
563
- signal?.addEventListener("abort", () => {
564
- clearTimeout(timer);
565
- reject(new Error("cancelled"));
566
- }, { once: true });
567
- });
568
- }
package/dist/paths.js DELETED
@@ -1,61 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { join, resolve } from "node:path";
3
- import { installedPresetSkillPaths } from "./presets.js";
4
- /**
5
- * The directory the config root is resolved against.
6
- *
7
- * `os.homedir()` returns `$HOME` verbatim on POSIX, so a relative or
8
- * trailing-slash `HOME` (a wrapper script that did `HOME=./sandbox`, a
9
- * container that exported it oddly) makes every derived path relative — the
10
- * config root, the session store, the rewind shadow repos. Everything then
11
- * lands wherever the process happens to be, and the "project scope walk" would
12
- * start treating `$HOME` itself as a project directory. Resolve it once, to an
13
- * absolute path, so a non-canonical HOME cannot leak into a path.
14
- *
15
- * The environment variable is preferred when set, matching how the SDK resolves
16
- * its own root, and `HOWTOFIX_HOME` is accepted as an override for tests.
17
- */
18
- function homeDirectory() {
19
- const configured = (process.env.HOWTOFIX_HOME ?? process.env.HOME ?? "").trim();
20
- return resolve(configured || homedir());
21
- }
22
- /** Config root. Everything HowToFix writes lives here, never in the SDK's own config dir. */
23
- export const AGENT_DIR = process.env.HOWTOFIX_CODING_AGENT_DIR?.trim() || join(homeDirectory(), ".howtofix", "agent");
24
- /** User-global skills. The SDK already scans AGENT_DIR/skills; this is the same path, named. */
25
- export const USER_SKILLS_DIR = join(AGENT_DIR, "skills");
26
- /** Project skills that do not live under the SDK's unrenameable `.hxf/` directory. */
27
- export function projectSkillsDir(cwd) {
28
- return join(cwd, ".howtofix", "skills");
29
- }
30
- /**
31
- * The skill directories to hand the SDK resource loader for a project, in
32
- * order. Order is load-bearing: the loader is first-wins on a name collision
33
- * (it logs a diagnostic and keeps the entry already in its map), so the user's
34
- * project skills come first and preset skills are the fallback — a user skill
35
- * of the same name wins. The global user skill dirs are discovered by the SDK
36
- * ahead of everything here and win over both.
37
- *
38
- * Nothing else is added. The rule suite that used to be injected here is now an
39
- * ordinary preset (`presets/quality/antislop/`), so it reaches the model only
40
- * after `/preset` installs it — the same opt-in every other preset gets. A
41
- * suite that loads whether or not anyone asked is not a catalogue, it is a
42
- * hidden default, and it made `/skills` disagree with what the user had chosen.
43
- */
44
- export function additionalSkillPaths(cwd) {
45
- const paths = [projectSkillsDir(cwd)];
46
- // Skills from presets the user installed. Only installed ones: a preset that
47
- // was merely offered must not put anything into the prompt.
48
- paths.push(...installedPresetSkillPaths(cwd));
49
- return paths;
50
- }
51
- export const AGENTS_STORE_DIR = join(AGENT_DIR, "agents");
52
- export const USAGE_CACHE_PATH = join(AGENT_DIR, "usage-cache.json");
53
- /** Where `s` in the workflow panel writes the script behind a run. */
54
- export const WORKFLOWS_DIR = join(AGENT_DIR, "workflows");
55
- /**
56
- * The SDK resolves its config paths (agent dir, sessions, auth, models) from
57
- * HXF_CODING_AGENT_DIR. Pin that to ~/.howtofix/agent before any session exists.
58
- */
59
- export function pinAgentDir() {
60
- process.env.HXF_CODING_AGENT_DIR = AGENT_DIR;
61
- }