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
package/dist/cli-auth.js DELETED
@@ -1,142 +0,0 @@
1
- /**
2
- * Proof of identity for the requests this CLI makes to start a sign-in.
3
- *
4
- * The panel used to take the fingerprint prefix (`howtofix-cli-…`) as proof that
5
- * a request came from this CLI. It is not proof: the prefix is a public string,
6
- * so any script could send it. Two things go wrong when it is trusted —
7
- *
8
- * 1. the panel records a login that did not come from a CLI, and
9
- * 2. worse, the forger can start a login and hand the approval link to someone
10
- * else, so a stranger's browser approves a sign-in that mints a key for the
11
- * forger's pending flow.
12
- *
13
- * A signature fixes both. The CLI signs the request with a key only the published
14
- * bundle carries, so the panel can tell a real CLI from anything that merely says
15
- * it is one.
16
- *
17
- * The honest limit, stated where it belongs: the private key ships inside the
18
- * published bundle, so anyone willing to unpack a minified file can recover it.
19
- * This is a bar, not a wall. It defeats the drive-by forger — the curl, the
20
- * script, the "just send the header" trick — and it makes an abuse of the login
21
- * endpoint traceable to something that had to be extracted, not guessed. A real
22
- * wall needs a per-install credential the panel issued, and that only exists
23
- * after a first sign-in; the bootstrap is what has to be open.
24
- */
25
- import { createPrivateKey, createPublicKey, createHash, randomBytes, sign } from "node:crypto";
26
- import { arch, cpus, hostname, platform } from "node:os";
27
- import { APP_VERSION } from "./brand.js";
28
- /** The key this build signs with. Rotating it means publishing a new CLI. */
29
- export const CLI_SIGNING_KEY_ID = "hf-cli-1";
30
- /**
31
- * The private half of the CLI's signing keypair, as a PKCS#8 DER blob.
32
- *
33
- * A private key cannot live anywhere else in this project: the CLI ships as a
34
- * single minified file with no asset loading, so the key is either in the bundle
35
- * or the CLI cannot prove anything. That is the tradeoff described above, made
36
- * explicit here so nobody mistakes it for an oversight.
37
- */
38
- const CLI_SIGNING_PRIVATE_KEY_DER_B64 = "MC4CAQAwBQYDK2VwBCIEIDmKoNgInS7oDpncee1d1+Mnci1R/q4aE5xxHeCKq4Dc";
39
- /**
40
- * Domain separation: the same key must never be usable for a second purpose, so
41
- * every signature is over a string that names what it is for.
42
- */
43
- const SIGNATURE_DOMAIN = "howtofix-cli-auth-v1";
44
- let cachedKey;
45
- /** The signing key, built once from the embedded DER. */
46
- function signingKey() {
47
- if (!cachedKey) {
48
- cachedKey = createPrivateKey({
49
- key: Buffer.from(CLI_SIGNING_PRIVATE_KEY_DER_B64, "base64"),
50
- format: "der",
51
- type: "pkcs8",
52
- });
53
- }
54
- return cachedKey;
55
- }
56
- /** The matching public key — used by the self-check below, and nothing else. */
57
- export function cliSigningPublicKey() {
58
- return createPublicKey(signingKey());
59
- }
60
- /**
61
- * The exact byte string that gets signed. The panel builds the same string, so
62
- * the two must agree field for field; each field is on its own line and the
63
- * separator cannot occur inside any of them.
64
- */
65
- export function cliSignaturePayload(keyId, fingerprintId, clientInfo, nonce) {
66
- return Buffer.from([SIGNATURE_DOMAIN, keyId, fingerprintId, clientInfo, nonce].join("\n"), "utf8");
67
- }
68
- /** A fresh, single-use value for each request, so a captured one cannot be reused. */
69
- export function createNonce() {
70
- return randomBytes(16).toString("hex");
71
- }
72
- /**
73
- * The public half this build's key must match, as the panel has it configured.
74
- *
75
- * A wrong half of the pair is silent otherwise: every sign-in fails with a 401
76
- * that reads like a server problem, and nothing points at the key. `--version`
77
- * checks this so a mismatch is visible at once, and the bundle self-check fails
78
- * on it.
79
- */
80
- const EXPECTED_PUBLIC_KEY_B64 = "tQ91uspGl0sw5Y8x+soLYnFNX6a9vEzSzCuuvalzdKk=";
81
- /** Report whether the embedded key is the one the panel verifies against. */
82
- export function cliSigningKeyMatchesPanel() {
83
- const spki = cliSigningPublicKey().export({ format: "der", type: "spki" });
84
- // The SPKI wrapper is a fixed 12-byte prefix, so the raw key is the tail.
85
- const raw = spki.subarray(spki.length - 32);
86
- return raw.toString("base64") === EXPECTED_PUBLIC_KEY_B64;
87
- }
88
- /** A short fingerprint of the signing key, for logs and bug reports. */
89
- export function cliSigningKeyFingerprint() {
90
- const spki = cliSigningPublicKey().export({ format: "der", type: "spki" });
91
- return createHash("sha256").update(spki).digest("hex").slice(0, 16);
92
- }
93
- /**
94
- * A short, human-readable description of this machine, shown on the approval page
95
- * so the person can see what they are approving. It is display only — the panel
96
- * sanitises it and never authorises on it — so a best-effort value is correct.
97
- *
98
- * The version leads, because the panel turns this string into the name its usage
99
- * log records for the device ("CLI (0.4.90)"): that is what tells one terminal's
100
- * runs apart from an API key in the log. The machine detail follows it so the
101
- * person approving still sees which computer is asking.
102
- */
103
- export function describeClient() {
104
- const model = cpus()[0]?.model ?? "";
105
- const shortModel = model.replace(/\s+/g, " ").trim();
106
- const name = hostname();
107
- const machine = shortModel ? `${name} (${platform()}/${arch()}, ${shortModel})` : `${name} (${platform()}/${arch()})`;
108
- return `CLI (${APP_VERSION}, ${machine})`;
109
- }
110
- /**
111
- * The header that names this CLI on every call it makes to the gateway.
112
- *
113
- * It is the counterpart of {@link describeClient} for requests that are not the
114
- * login: the gateway labels the usage log of a model call with whatever this
115
- * says, so a run from a terminal is told apart from an API key without opening
116
- * the request. It is a label, never a credential, and never a permission.
117
- */
118
- export const CLI_CLIENT_HEADER = "X-HowToFix-Client";
119
- /**
120
- * What the CLI calls itself in that header: the version, and nothing else.
121
- *
122
- * The machine detail stays on the approval page rather than riding every model
123
- * request, and the version alone is what the panel turns into the log label
124
- * ("CLI (0.4.92)"). The panel parses this exactly as it parses the login
125
- * description, so both spellings produce the same label.
126
- */
127
- export function cliClientHeader() {
128
- return `CLI (${APP_VERSION})`;
129
- }
130
- /**
131
- * Sign one login request.
132
- *
133
- * `fingerprintId` and `clientInfo` are signed too, not just carried: otherwise a
134
- * captured signature could be replayed against a different fingerprint or with a
135
- * different description of the machine, and the panel's record of who asked
136
- * would be worthless.
137
- */
138
- export function signLoginRequest(fingerprintId, clientInfo) {
139
- const nonce = createNonce();
140
- const signature = sign(null, cliSignaturePayload(CLI_SIGNING_KEY_ID, fingerprintId, clientInfo, nonce), signingKey());
141
- return { keyId: CLI_SIGNING_KEY_ID, nonce, signature: signature.toString("base64") };
142
- }
@@ -1,116 +0,0 @@
1
- /**
2
- * The daily check-in, claimed from the CLI.
3
- *
4
- * The server endpoint (`/v1/cli/checkin` on the gateway) exists because a
5
- * terminal holds no dashboard session — the web claim needs a cookie the CLI
6
- * can never send — but the rules are the server's own: one claim per account
7
- * per day, the date from the server clock, the amount from the admin's
8
- * setting. Nothing in this module can grant more than once or choose a
9
- * number; it asks, and the server answers.
10
- *
11
- * Every failure is a sentence and none is fatal: an auto check-in that could
12
- * not reach the gateway costs the user nothing they had before, and a CLI
13
- * that refused to start over it would be far worse than one line missing.
14
- */
15
- import { CLI_CLIENT_HEADER, cliClientHeader } from "./cli-auth.js";
16
- import { deviceId } from "./device-id.js";
17
- /** Base URL for the check-in endpoints: the API host, `/v1` included. */
18
- const CHECKIN_PATH = "/cli/checkin";
19
- /**
20
- The auth scheme name, assembled rather than written out: the tooling that
21
- writes these files rewrites a literal header prefix and breaks the build.
22
- */
23
- const AUTH_SCHEME = ["Bearer", ""].join(" ").trim();
24
- /** Quota units per dollar, as the gateway bills them ($1 = 500,000). */
25
- export const QUOTA_PER_USD = 500_000;
26
- /** Quota formatted the way a person reads it: dollars, two decimals. */
27
- export function formatQuotaUsd(quota) {
28
- return `$${(quota / QUOTA_PER_USD).toFixed(2)}`;
29
- }
30
- async function callCheckin(token, method, baseUrl, fetchImpl, timeoutMs) {
31
- const controller = new AbortController();
32
- const timer = setTimeout(() => controller.abort(), timeoutMs);
33
- try {
34
- const response = await fetchImpl(`${baseUrl}${CHECKIN_PATH}`, {
35
- method,
36
- headers: {
37
- [CLI_CLIENT_HEADER]: cliClientHeader(),
38
- "X-HowToFix-Device": deviceId().id,
39
- Authorization: `${AUTH_SCHEME} ${token}`,
40
- },
41
- signal: controller.signal,
42
- });
43
- let parsed = {};
44
- try {
45
- parsed = (await response.json());
46
- }
47
- catch {
48
- // A non-JSON body leaves the status as the story.
49
- }
50
- return { status: response.status, body: parsed };
51
- }
52
- finally {
53
- clearTimeout(timer);
54
- }
55
- }
56
- /** Ask the gateway whether today's claim is already taken. Never throws. */
57
- export async function checkCliCheckin(token, options) {
58
- const baseUrl = (options?.baseUrl ?? "https://api.howtofix.id/v1").replace(/\/+$/, "");
59
- const fetchImpl = options?.fetchImpl ?? fetch;
60
- const timeoutMs = options?.timeoutMs ?? 10_000;
61
- const trimmed = token.trim();
62
- if (!trimmed)
63
- return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: "no token given" };
64
- try {
65
- const { status, body } = await callCheckin(trimmed, "GET", baseUrl, fetchImpl, timeoutMs);
66
- if (status === 401 || status === 403) {
67
- return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: "the gateway rejected that token for the check-in status" };
68
- }
69
- if (status >= 400 || body.success !== true) {
70
- return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: typeof body.message === "string" ? body.message : `the gateway answered ${status}` };
71
- }
72
- const data = (body.data ?? {});
73
- return {
74
- ok: true,
75
- enabled: data.enabled === true,
76
- checkedInToday: data.checked_in_today === true,
77
- quotaToday: Number(data.quota_today ?? 0) || 0,
78
- message: "",
79
- };
80
- }
81
- catch (cause) {
82
- const aborted = cause instanceof Error && cause.name === "AbortError";
83
- return { ok: false, enabled: false, checkedInToday: false, quotaToday: 0, message: aborted ? "the gateway did not answer the check-in in time" : "could not reach the gateway" };
84
- }
85
- }
86
- /**
87
- * Claim today's check-in.
88
- *
89
- * A refusal that says today is already taken is `alreadyClaimed`, not a
90
- * failure: that is the normal state of a second start in one day and the
91
- * caller shows nothing for it.
92
- */
93
- export async function claimCliCheckin(token, options) {
94
- const baseUrl = (options?.baseUrl ?? "https://api.howtofix.id/v1").replace(/\/+$/, "");
95
- const fetchImpl = options?.fetchImpl ?? fetch;
96
- const timeoutMs = options?.timeoutMs ?? 15_000;
97
- const trimmed = token.trim();
98
- if (!trimmed)
99
- return { ok: false, alreadyClaimed: false, quotaAwarded: 0, message: "no token given" };
100
- try {
101
- const { status, body } = await callCheckin(trimmed, "POST", baseUrl, fetchImpl, timeoutMs);
102
- if (body.success === true) {
103
- const data = (body.data ?? {});
104
- return { ok: true, alreadyClaimed: false, quotaAwarded: Number(data.quota_awarded ?? 0) || 0, message: "" };
105
- }
106
- const message = typeof body.message === "string" ? body.message : `the gateway answered ${status}`;
107
- // The server's own sentence for "today is spent" — recognised so a normal
108
- // second start never reads as an error.
109
- const alreadyClaimed = /签到|check-?in|already|今日/.test(message) && !/not enabled/i.test(message);
110
- return { ok: false, alreadyClaimed, quotaAwarded: 0, message };
111
- }
112
- catch (cause) {
113
- const aborted = cause instanceof Error && cause.name === "AbortError";
114
- return { ok: false, alreadyClaimed: false, quotaAwarded: 0, message: aborted ? "the gateway did not answer the check-in in time" : "could not reach the gateway" };
115
- }
116
- }
@@ -1,254 +0,0 @@
1
- /**
2
- * The CLI's own credential file: a short-lived access token plus the refresh
3
- * token that renews it.
4
- *
5
- * This file exists because the SDK cannot hold this shape. A stored credential
6
- * is a single string — an `api_key` is one secret with no expiry — and the SDK
7
- * only ever refreshes a credential it understands as OAuth. Measured against the
8
- * SDK: `resolveRefreshCredential` returns early unless the stored credential's
9
- * type is `oauth`, so an `api_key` is handed to the relay until the relay
10
- * refuses it, and then the user is stuck.
11
- *
12
- * So the CLI owns the pair. It writes the file, renews it before the access
13
- * token dies, and feeds the fresh access token to the SDK as a runtime key. That
14
- * is the same split Claude Code and Codex use: their own credentials file next to
15
- * the client, and the client does the refreshing.
16
- *
17
- * What the pair buys, stated plainly: a leaked access token is worth hours, not
18
- * forever. Revoking the device from the panel disables the relay token, so the
19
- * old way — a permanent `sk-` key that works until someone remembers to delete
20
- * it — is gone.
21
- *
22
- * The refresh token is stored as the server issued it (an opaque secret). It is
23
- * never sent anywhere but `/api/auth/cli/refresh` on the panel, and never
24
- * printed: `maskToken` is what reaches the feed.
25
- */
26
- import { existsSync, readFileSync } from "node:fs";
27
- import { join } from "node:path";
28
- import { writeFileAtomicSync } from "./fs-atomic.js";
29
- import { createFingerprintId, PANEL_URL } from "./oauth-login.js";
30
- /** Where the pair lives, next to the rest of the agent state. */
31
- export function cliCredentialsPath(agentDir) {
32
- return join(agentDir, "cli-credentials.json");
33
- }
34
- /**
35
- * How long before expiry the CLI renews.
36
- *
37
- * A renewal that only fires once the key is dead leaves a window where the very
38
- * next prompt fails, and the user has to retry to make the refresh happen. Five
39
- * minutes is comfortably longer than a request and short enough that a stale
40
- * file is refreshed on any normal start.
41
- */
42
- export const CLI_REFRESH_SKEW_MS = 5 * 60 * 1000;
43
- /**
44
- * Whether the stored access token is usable right now.
45
- *
46
- * A credential with no readable expiry is treated as usable: the file is ours,
47
- * and refusing to start because a field is missing would strand a user whose
48
- * only problem is a truncated write. The relay is the real authority on whether
49
- * the key works, and a 401 is handled at the call site.
50
- */
51
- export function accessTokenIsFresh(credential, now = Date.now()) {
52
- if (!credential.accessToken)
53
- return false;
54
- if (!Number.isFinite(credential.accessExpiresAt) || credential.accessExpiresAt <= 0)
55
- return true;
56
- return credential.accessExpiresAt * 1000 - CLI_REFRESH_SKEW_MS > now;
57
- }
58
- /** Whether the refresh token can still renew anything. */
59
- export function refreshTokenIsUsable(credential, now = Date.now()) {
60
- if (!credential.refreshToken || !credential.deviceId)
61
- return false;
62
- if (!Number.isFinite(credential.refreshExpiresAt) || credential.refreshExpiresAt <= 0)
63
- return true;
64
- return credential.refreshExpiresAt * 1000 > now;
65
- }
66
- /**
67
- * Read the pair, or undefined.
68
- *
69
- * Never throws. A corrupt or half-written file is a file with no usable
70
- * credential in it, which is a state the caller already handles — the user signs
71
- * in again — and turning it into an exception would make the CLI fail to start
72
- * over a file it can simply ignore.
73
- */
74
- export function readCliCredential(agentDir) {
75
- const path = cliCredentialsPath(agentDir);
76
- if (!existsSync(path))
77
- return undefined;
78
- let parsed;
79
- try {
80
- parsed = JSON.parse(readFileSync(path, "utf8"));
81
- }
82
- catch {
83
- return undefined;
84
- }
85
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
86
- return undefined;
87
- const record = parsed;
88
- const accessToken = typeof record.accessToken === "string" ? record.accessToken.trim() : "";
89
- const refreshToken = typeof record.refreshToken === "string" ? record.refreshToken.trim() : "";
90
- const deviceId = typeof record.deviceId === "string" ? record.deviceId.trim() : "";
91
- if (!accessToken || !refreshToken || !deviceId)
92
- return undefined;
93
- return {
94
- accessToken,
95
- accessExpiresAt: numberOrZero(record.accessExpiresAt),
96
- refreshToken,
97
- deviceId,
98
- refreshExpiresAt: numberOrZero(record.refreshExpiresAt),
99
- };
100
- }
101
- /** Write the pair. Returns false when the file could not be written. */
102
- export function writeCliCredential(agentDir, credential) {
103
- try {
104
- // 0600: this file carries the device access token and its refresh pair —
105
- // readable by anyone on a shared host is a live session stolen.
106
- writeFileAtomicSync(cliCredentialsPath(agentDir), `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 });
107
- return true;
108
- }
109
- catch {
110
- return false;
111
- }
112
- }
113
- /** Forget the pair. Used when the panel says the sign-in is gone for good. */
114
- export function clearCliCredential(agentDir) {
115
- try {
116
- const path = cliCredentialsPath(agentDir);
117
- if (existsSync(path))
118
- writeFileAtomicSync(path, "", { mode: 0o600 });
119
- }
120
- catch {
121
- // Nothing to do: the caller is already on the "sign in again" path.
122
- }
123
- }
124
- function numberOrZero(value) {
125
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
126
- }
127
- /**
128
- * Renew the pair against the panel.
129
- *
130
- * The response carries a new access token *and* a new refresh token: the server
131
- * rotates the refresh secret on every use, so the value in hand is spent the
132
- * moment this succeeds. That is why the new pair is written before the caller
133
- * sees it — losing the rotated refresh token would force the user to sign in
134
- * again for no reason.
135
- */
136
- export async function refreshCliCredential(credential, options) {
137
- const panel = (options?.panelUrl ?? PANEL_URL).replace(/\/+$/, "");
138
- const doFetch = options?.fetchImpl ?? fetch;
139
- const timeoutMs = options?.timeoutMs ?? 15_000;
140
- const controller = new AbortController();
141
- const timer = setTimeout(() => controller.abort(), timeoutMs);
142
- try {
143
- const response = await doFetch(`${panel}/api/auth/cli/refresh`, {
144
- method: "POST",
145
- headers: { "Content-Type": "application/json", Accept: "application/json" },
146
- body: JSON.stringify({
147
- device_id: credential.deviceId,
148
- refresh_token: credential.refreshToken,
149
- // The machine this renewal comes from. The server pins a device to the
150
- // machine that first renewed it and refuses a renewal from any other, so
151
- // a refresh token copied off the disk is not portable. The value is the
152
- // same fingerprint the sign-in sent, so the panel shows one identity for
153
- // one machine instead of two. A machine that cannot read an OS id still
154
- // sends one (a hostname-derived value), which is weaker but consistent —
155
- // the server compares what it is given and does not care how strong the
156
- // source was.
157
- fingerprint_id: createFingerprintId(),
158
- }),
159
- signal: controller.signal,
160
- });
161
- const body = (await response.json().catch(() => ({})));
162
- if (response.status === 401 || response.status === 403) {
163
- return {
164
- ok: false,
165
- terminal: true,
166
- message: "this sign-in is no longer valid — run /connect to sign in again",
167
- };
168
- }
169
- if (!response.ok) {
170
- return { ok: false, terminal: false, message: `the panel refused the renewal (${response.status})` };
171
- }
172
- const data = (body.data ?? {});
173
- const accessToken = pickString(body.token, data.token);
174
- const refreshToken = pickString(body.refresh_token, data.refresh_token);
175
- const deviceId = pickString(body.device_id, data.device_id) ?? credential.deviceId;
176
- if (!accessToken || !refreshToken) {
177
- return { ok: false, terminal: false, message: "the panel did not return a renewed credential" };
178
- }
179
- return {
180
- ok: true,
181
- terminal: false,
182
- credential: {
183
- accessToken,
184
- accessExpiresAt: pickNumber(body.access_expires_at, data.access_expires_at),
185
- refreshToken,
186
- deviceId,
187
- refreshExpiresAt: pickNumber(body.refresh_expires_at, data.refresh_expires_at),
188
- },
189
- };
190
- }
191
- catch (cause) {
192
- const aborted = cause instanceof Error && cause.name === "AbortError";
193
- return {
194
- ok: false,
195
- terminal: false,
196
- message: aborted ? `the panel did not answer within ${Math.round(timeoutMs / 1000)}s` : "could not reach the panel",
197
- };
198
- }
199
- finally {
200
- clearTimeout(timer);
201
- }
202
- }
203
- function pickString(...values) {
204
- for (const value of values) {
205
- if (typeof value === "string" && value.trim())
206
- return value.trim();
207
- }
208
- return undefined;
209
- }
210
- function pickNumber(...values) {
211
- for (const value of values) {
212
- if (typeof value === "number" && Number.isFinite(value))
213
- return value;
214
- }
215
- return 0;
216
- }
217
- /**
218
- * Renew the pair and persist the result, if the access token is close to
219
- * expiring.
220
- *
221
- * One function rather than two call sites each doing it, because the order is
222
- * load-bearing: the rotated refresh token must reach the disk *before* anything
223
- * else happens, since the value in hand is spent the moment the server answers.
224
- * A caller that renewed and then forgot to write would lock the user out at the
225
- * next start with a refresh token that no longer works.
226
- *
227
- * Returns the credential in hand when nothing needed doing, so callers can treat
228
- * "no renewal required" and "renewed" the same way.
229
- */
230
- export async function renewCliCredentialIfNeeded(agentDir, credential, options) {
231
- const now = options?.now ?? Date.now();
232
- if (accessTokenIsFresh(credential, now) || !refreshTokenIsUsable(credential, now)) {
233
- return { credential, renewed: false, terminal: false };
234
- }
235
- const outcome = await refreshCliCredential(credential, options);
236
- if (!outcome.ok || !outcome.credential) {
237
- // A terminal refusal means the panel has already ended this device session —
238
- // the refresh token is gone and, because revoking a device disables the relay
239
- // token behind it, the access token in hand is dead too. Keeping the pair
240
- // would make every prompt retry against a credential that can never work and
241
- // repeat the same message; clearing it means one clear answer and a clean
242
- // `/connect` afterwards.
243
- if (outcome.terminal)
244
- clearCliCredential(agentDir);
245
- return { credential, renewed: false, terminal: outcome.terminal, message: outcome.message };
246
- }
247
- const persisted = writeCliCredential(agentDir, outcome.credential);
248
- return {
249
- credential: outcome.credential,
250
- renewed: true,
251
- terminal: false,
252
- message: persisted ? undefined : `the renewed sign-in could not be saved to ${cliCredentialsPath(agentDir)}`,
253
- };
254
- }
package/dist/clipboard.js DELETED
@@ -1,149 +0,0 @@
1
- /**
2
- * Reading the system clipboard on every desktop we can actually reach.
3
- *
4
- * The SDK's `getNativeClipboard()` is a native module, and on Linux it returns
5
- * nothing unless `DISPLAY` is set: a Wayland-only session (no XWayland) has no
6
- * path at all, and a machine without the prebuilt binary has none either. On
7
- * every platform but Windows mouse tracking has also stopped the terminal from
8
- * pasting on its own, so a right click reaches the app and does nothing unless
9
- * this module is behind it. (On Windows the terminal does the pasting, which is
10
- * why `rightClickPastesHere` in `settings.ts` says so — see its comment.)
11
- *
12
- * This module fills that in with the command-line clipboard tools that are
13
- * actually installed, and it keeps three rules a sibling harness learned the
14
- * hard way (see the pitfalls file):
15
- *
16
- * - **Never block the event loop.** A clipboard daemon that is slow or hung used
17
- * to freeze the whole TUI, because reading it ran a synchronous subprocess
18
- * with a two-second timeout. Every read here is `spawn` + `await`.
19
- * - **Ask Wayland for the right type.** `wl-paste --type text/plain` returns
20
- * ISO Latin-1 when the clipboard is owned by an X11 client through XWayland
21
- * (`STRING`), which turns CJK text into `?`. Ask for
22
- * `text/plain;charset=utf-8` first — that maps to `UTF8_STRING` — and fall
23
- * back only if the owner does not offer it.
24
- * - **Read back before you claim a write.** On X11 an empty `xclip` write can
25
- * return success while the clipboard stays empty, so `copyText` verifies.
26
- */
27
- import { spawn } from "node:child_process";
28
- /**
29
- * Run a command, return its stdout, and never throw.
30
- *
31
- * The timeout is a guard against a hung daemon, not a normal path: a clipboard
32
- * read on a healthy machine is a few milliseconds.
33
- */
34
- export function runClipboardCommand(command, args, timeoutMs = 2000) {
35
- return new Promise((resolve) => {
36
- let settled = false;
37
- // Declared before `finish`, not after `spawn`: a spawn that throws
38
- // synchronously calls `finish` from the catch below, and `clearTimeout` on a
39
- // `const` timer that has not been reached yet is a temporal-dead-zone
40
- // ReferenceError. The executor then throws, the promise rejects, and the
41
- // caller gets an exception from the one helper documented to never throw —
42
- // the failure path was the thing that crashed.
43
- let timer;
44
- const finish = (value) => {
45
- if (settled)
46
- return;
47
- settled = true;
48
- if (timer !== undefined)
49
- clearTimeout(timer);
50
- resolve(value);
51
- };
52
- let child;
53
- try {
54
- child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
55
- }
56
- catch {
57
- finish({ ok: false, stdout: "" });
58
- return;
59
- }
60
- timer = setTimeout(() => {
61
- child.kill("SIGKILL");
62
- finish({ ok: false, stdout: "" });
63
- }, timeoutMs);
64
- const chunks = [];
65
- child.stdout?.on("data", (chunk) => chunks.push(chunk));
66
- child.on("error", () => finish({ ok: false, stdout: "" }));
67
- child.on("close", (code) => {
68
- finish({ ok: code === 0, stdout: Buffer.concat(chunks).toString("utf8") });
69
- });
70
- });
71
- }
72
- /** True when the process is running under Wayland, with or without XWayland. */
73
- function onWayland() {
74
- return process.env.XDG_SESSION_TYPE === "wayland" || (process.env.WAYLAND_DISPLAY?.length ?? 0) > 0;
75
- }
76
- /**
77
- * Read the clipboard with the platform's own tools.
78
- *
79
- * Each entry is tried in order and the first one that yields text wins. The
80
- * order matters: the Wayland read asks for UTF-8 before the plain type, and the
81
- * X11 read comes after Wayland so a Wayland session never picks up a stale X11
82
- * selection through XWayland.
83
- */
84
- async function readWithTools() {
85
- const candidates = [];
86
- if (process.platform === "darwin") {
87
- candidates.push({ command: "pbpaste", args: [] });
88
- }
89
- else if (process.platform === "win32") {
90
- candidates.push({ command: "powershell.exe", args: ["-NoProfile", "-Command", "Get-Clipboard -Raw"] });
91
- }
92
- else {
93
- if (onWayland()) {
94
- candidates.push({ command: "wl-paste", args: ["--no-newline", "--type", "text/plain;charset=utf-8"] });
95
- candidates.push({ command: "wl-paste", args: ["--no-newline", "--type", "text/plain"] });
96
- candidates.push({ command: "wl-paste", args: ["--no-newline"] });
97
- }
98
- if ((process.env.DISPLAY?.length ?? 0) > 0 || !onWayland()) {
99
- candidates.push({ command: "xclip", args: ["-selection", "clipboard", "-o"] });
100
- candidates.push({ command: "xsel", args: ["--clipboard", "--output"] });
101
- }
102
- candidates.push({ command: "termux-clipboard-get", args: [] });
103
- }
104
- for (const candidate of candidates) {
105
- const result = await runClipboardCommand(candidate.command, candidate.args);
106
- if (result.ok && result.stdout.length > 0)
107
- return result.stdout;
108
- }
109
- return undefined;
110
- }
111
- /**
112
- * Read the clipboard, preferring the SDK's native helper and falling back to the
113
- * command-line tools.
114
- *
115
- * The native helper is tried first because it is faster and already handles the
116
- * desktop's own conventions; the tools are what makes Linux without `DISPLAY`,
117
- * or without the prebuilt binary, work at all.
118
- */
119
- export async function readClipboardText() {
120
- let native;
121
- try {
122
- const { getNativeClipboard } = await import("@netrodev/hxf-tui");
123
- native = getNativeClipboard();
124
- }
125
- catch {
126
- native = undefined;
127
- }
128
- if (native) {
129
- try {
130
- const text = await native.getText();
131
- if (typeof text === "string" && text.length > 0)
132
- return { ok: true, text };
133
- }
134
- catch {
135
- // Fall through: a native failure must not be the end of the story.
136
- }
137
- }
138
- const text = await readWithTools();
139
- if (text !== undefined)
140
- return { ok: true, text };
141
- const hint = onWayland()
142
- ? "install wl-clipboard (wl-paste)"
143
- : process.platform === "darwin"
144
- ? "pbpaste should be present on macOS"
145
- : process.platform === "win32"
146
- ? "Get-Clipboard should be present on Windows"
147
- : "install xclip or xsel, or run under a display";
148
- return { ok: false, reason: `no clipboard tool could read it — ${hint}` };
149
- }