@hwip/cli 0.0.1 → 0.1.1

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 (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1983 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +305 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +278 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5681 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Desktop banner for a file that just landed (ADR-0013): macOS through
3
+ * `osascript`, Linux through `notify-send` when it is on PATH, otherwise
4
+ * nothing — the listener log line is the fallback everywhere.
5
+ *
6
+ * The banner names the file and the folder it is in, because "a file
7
+ * arrived" is only useful to someone who can then go and find it. On macOS
8
+ * a click reveals the file in Finder when `terminal-notifier` is on PATH
9
+ * (Homebrew; the service's PATH includes /opt/homebrew/bin). `osascript`
10
+ * cannot attach a click action, so without `terminal-notifier` the text is
11
+ * the whole UI.
12
+ *
13
+ * Best-effort by design. Never throws, never rejects; a failed banner
14
+ * must not turn a delivered file into an error.
15
+ */
16
+ export interface DesktopNotification {
17
+ title: string;
18
+ body: string;
19
+ /** Absolute path a click should reveal in the file manager, where the notifier supports it. */
20
+ reveal?: string;
21
+ }
22
+ export interface DesktopNotifyDeps {
23
+ platform?: NodeJS.Platform;
24
+ /** `execFile`-shaped: argv, not a shell string, so nothing is interpolated. */
25
+ run?: (cmd: string, args: string[]) => Promise<void>;
26
+ onPath?: (cmd: string) => boolean;
27
+ }
28
+ /**
29
+ * Show the banner. Resolves true when a command ran and exited cleanly,
30
+ * false when there is no notifier here or it failed.
31
+ */
32
+ export declare const notifyDesktop: (note: DesktopNotification, deps?: DesktopNotifyDeps) => Promise<boolean>;
33
+ //# sourceMappingURL=desktop-notify.d.ts.map
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.notifyDesktop = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const agents_js_1 = require("./agents.js");
6
+ const runCommand = (cmd, args) => new Promise((resolve, reject) => {
7
+ (0, node_child_process_1.execFile)(cmd, args, { timeout: 5_000 }, (err) => (err ? reject(err) : resolve()));
8
+ });
9
+ const commandOnPath = (cmd) => (0, agents_js_1.findOnPath)(cmd) !== null;
10
+ /** POSIX shell single-quoted literal: nothing is special inside it but `'` itself. */
11
+ const shellQuote = (s) => `'${s.split("'").join(`'\\''`)}'`;
12
+ /** AppleScript string literal: only `\` and `"` need escaping inside double quotes. */
13
+ const appleScriptString = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
14
+ /**
15
+ * Show the banner. Resolves true when a command ran and exited cleanly,
16
+ * false when there is no notifier here or it failed.
17
+ */
18
+ const notifyDesktop = async (note, deps = {}) => {
19
+ const platform = deps.platform ?? process.platform;
20
+ const run = deps.run ?? runCommand;
21
+ const onPath = deps.onPath ?? commandOnPath;
22
+ try {
23
+ if (platform === 'darwin') {
24
+ if (note.reveal && onPath('terminal-notifier')) {
25
+ // `-execute` goes through a shell, so the path is quoted, and
26
+ // `--` keeps a name starting with '-' from reading as a flag.
27
+ await run('terminal-notifier', [
28
+ '-title', note.title,
29
+ '-message', note.body,
30
+ '-execute', `open -R -- ${shellQuote(note.reveal)}`,
31
+ ]);
32
+ return true;
33
+ }
34
+ const script = `display notification ${appleScriptString(note.body)} with title ${appleScriptString(note.title)}`;
35
+ await run('osascript', ['-e', script]);
36
+ return true;
37
+ }
38
+ if (platform === 'linux' && onPath('notify-send')) {
39
+ // `--`: a title or body starting with '-' is text, not a flag.
40
+ await run('notify-send', ['--', note.title, note.body]);
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ };
49
+ exports.notifyDesktop = notifyDesktop;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Local transfer (ADR-0013) — who is allowed to send to this machine.
3
+ *
4
+ * A LAN upload is authenticated against the sender's *registered* public
5
+ * key, and the registry is the account's device list (`GET /devices`).
6
+ * This caches it so a burst of uploads does not become a burst of API
7
+ * calls, and refreshes on a miss — once per `refreshMinMs` — so a device
8
+ * registered a moment ago is accepted without waiting a whole TTL. An
9
+ * unknown id after a fresh fetch is simply not a device of this account.
10
+ */
11
+ export interface DeviceDirectoryDeps {
12
+ apiKey: string;
13
+ baseUrl: string;
14
+ log: (msg: string) => void;
15
+ fetchFn?: typeof fetch;
16
+ now?: () => number;
17
+ /** Age after which the next lookup refetches even on a hit. */
18
+ ttlMs?: number;
19
+ /** Shortest interval between two miss-triggered refetches. */
20
+ refreshMinMs?: number;
21
+ timeoutMs?: number;
22
+ }
23
+ /** What the account's device list says about a device besides its key. */
24
+ export interface DeviceInfo {
25
+ deviceId: string;
26
+ type?: string;
27
+ nickname?: string;
28
+ publicKey?: string;
29
+ }
30
+ export interface DeviceDirectory {
31
+ publicKeyOf: (deviceId: string) => Promise<string | null>;
32
+ /** The devices the account lists, refetched when the cache is stale; the last list fetched (empty before any) when the refetch fails. */
33
+ devices: () => Promise<DeviceInfo[]>;
34
+ }
35
+ export declare const createDeviceDirectory: (deps: DeviceDirectoryDeps) => DeviceDirectory;
36
+ //# sourceMappingURL=device-directory.d.ts.map
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Local transfer (ADR-0013) — who is allowed to send to this machine.
4
+ *
5
+ * A LAN upload is authenticated against the sender's *registered* public
6
+ * key, and the registry is the account's device list (`GET /devices`).
7
+ * This caches it so a burst of uploads does not become a burst of API
8
+ * calls, and refreshes on a miss — once per `refreshMinMs` — so a device
9
+ * registered a moment ago is accepted without waiting a whole TTL. An
10
+ * unknown id after a fresh fetch is simply not a device of this account.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.createDeviceDirectory = void 0;
14
+ const api_js_1 = require("./api.js");
15
+ const DEFAULT_TTL_MS = 60_000;
16
+ const DEFAULT_REFRESH_MIN_MS = 10_000;
17
+ const createDeviceDirectory = (deps) => {
18
+ const fetchFn = deps.fetchFn ?? fetch;
19
+ const now = deps.now ?? (() => Date.now());
20
+ const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS;
21
+ const refreshMinMs = deps.refreshMinMs ?? DEFAULT_REFRESH_MIN_MS;
22
+ const url = (0, api_js_1.apiUrl)(deps.baseUrl, '/devices');
23
+ let keys = new Map();
24
+ let infos = [];
25
+ let fetchedAt = -Infinity;
26
+ /** Last attempt, successful or not — the miss brake counts failures too,
27
+ * or an unreachable API is hit on every unknown id. */
28
+ let triedAt = -Infinity;
29
+ let inFlight = null;
30
+ const refresh = () => {
31
+ if (inFlight)
32
+ return inFlight;
33
+ triedAt = now();
34
+ inFlight = (async () => {
35
+ const res = await (0, api_js_1.apiFetch)(fetchFn, url, deps.apiKey, deps.timeoutMs ?? 5_000);
36
+ if (!res.ok)
37
+ throw new Error(`devices ${res.status}`);
38
+ const json = (await res.json());
39
+ const next = new Map();
40
+ const listed = [];
41
+ for (const d of json.data ?? []) {
42
+ if (typeof d.deviceId !== 'string')
43
+ continue;
44
+ const publicKey = typeof d.publicKey === 'string' && d.publicKey.length > 0 ? d.publicKey : undefined;
45
+ if (publicKey)
46
+ next.set(d.deviceId, publicKey);
47
+ listed.push({
48
+ deviceId: d.deviceId,
49
+ ...(typeof d.type === 'string' ? { type: d.type } : {}),
50
+ ...(typeof d.nickname === 'string' ? { nickname: d.nickname } : {}),
51
+ ...(publicKey ? { publicKey } : {}),
52
+ });
53
+ }
54
+ keys = next;
55
+ infos = listed;
56
+ fetchedAt = now();
57
+ })().finally(() => { inFlight = null; });
58
+ return inFlight;
59
+ };
60
+ return {
61
+ publicKeyOf: async (deviceId) => {
62
+ const stale = now() - fetchedAt > ttlMs;
63
+ const hit = keys.get(deviceId);
64
+ if (hit && !stale)
65
+ return hit;
66
+ // Miss or stale: refetch, but a miss alone only once per refreshMinMs —
67
+ // an attacker probing unknown ids must not drive the API.
68
+ if (inFlight) {
69
+ await inFlight.catch(() => undefined); // logged by the caller that started it
70
+ }
71
+ else if ((stale || now() - fetchedAt > refreshMinMs) && now() - triedAt > refreshMinMs) {
72
+ try {
73
+ await refresh();
74
+ }
75
+ catch (err) {
76
+ deps.log(`local transfer: device list unavailable — ${err instanceof Error ? err.message : String(err)}`);
77
+ }
78
+ }
79
+ return keys.get(deviceId) ?? null;
80
+ },
81
+ devices: async () => {
82
+ if (inFlight) {
83
+ await inFlight.catch(() => undefined);
84
+ }
85
+ else if (now() - fetchedAt > ttlMs && now() - triedAt > refreshMinMs) {
86
+ try {
87
+ await refresh();
88
+ }
89
+ catch (err) {
90
+ deps.log(`local transfer: device list unavailable — ${err instanceof Error ? err.message : String(err)}`);
91
+ }
92
+ }
93
+ return infos;
94
+ },
95
+ };
96
+ };
97
+ exports.createDeviceDirectory = createDeviceDirectory;
@@ -0,0 +1,17 @@
1
+ /** The one message both halves of presence ride; the server answers
2
+ * `listener.presence.ack` or `listener.presence.error`. */
3
+ export declare const LISTENER_PRESENCE = "listener.presence";
4
+ export interface DeviceKeyRegistrationDeps {
5
+ log: (msg: string) => void;
6
+ /** Send one frame on the live socket; false when there is none. */
7
+ send: (msg: object) => boolean;
8
+ /** Load-or-create the keypair; resolves the Base64 SPKI public key. */
9
+ publicKey: () => Promise<string>;
10
+ }
11
+ export interface DeviceKeyRegistration {
12
+ /** Call on every WebSocket open. Never throws; resolves true once the
13
+ * frame went out. A failure waits for the next open. */
14
+ onOpen: () => Promise<boolean>;
15
+ }
16
+ export declare const createDeviceKeyRegistration: (deps: DeviceKeyRegistrationDeps) => DeviceKeyRegistration;
17
+ //# sourceMappingURL=device-presence.d.ts.map
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
4
+ /**
5
+ * Per-device E2E (ADR-0007) — put this machine's public key on its device
6
+ * record, so other devices can wrap pushes and files for it.
7
+ *
8
+ * Senders only encrypt for devices whose record carries a `publicKey`, and
9
+ * a local transfer (ADR-0013 decision 3) is only attempted to such a device.
10
+ * The listener held a keypair from the start but never registered it, so
11
+ * the Mac was neither an E2E recipient nor a local-transfer target.
12
+ *
13
+ * It goes over the WebSocket, not an HTTP write: the device-record route
14
+ * (`PATCH /v1/devices/{deviceId}`) is behind the JWT authorizer and this
15
+ * daemon holds an API key, so the socket — which already proves which
16
+ * device is speaking, and which created the record at `$connect` — is the
17
+ * write path. `lan-endpoint.ts` publishes the transfer endpoint on the same
18
+ * message.
19
+ *
20
+ * Sent on every WebSocket open: `$connect` is what makes the record exist,
21
+ * and a record that was recreated has lost its key. The server broadcasts
22
+ * `device.publicKey.changed` only when the key actually differs, so an
23
+ * unchanged re-registration is one quiet write. Senders seal for every listed
24
+ * device that has a key, so a missing key means nothing can be read there. The
25
+ * private half never leaves `~/.hwip/device-keys.json`.
26
+ *
27
+ * The same frame says what this build opens (`capabilities`): a sender seals
28
+ * a file in chunks only for a device that lists `file-chunk-v1`, and one over
29
+ * the relay's ceiling over LAN only to one that lists `lan-large-v1`. In one frame
30
+ * with the key, because a server from before `capabilities` answers a frame
31
+ * that carries nothing else it knows with an error.
32
+ */
33
+ const file_crypto_js_1 = require("./file-crypto.js");
34
+ const lan_receiver_js_1 = require("./lan-receiver.js");
35
+ /** The one message both halves of presence ride; the server answers
36
+ * `listener.presence.ack` or `listener.presence.error`. */
37
+ exports.LISTENER_PRESENCE = 'listener.presence';
38
+ const createDeviceKeyRegistration = (deps) => {
39
+ // Two opens in quick succession (a flapping socket) share one load. A
40
+ // record recreated inside that window waits for the open after — accepted.
41
+ let inFlight = null;
42
+ const register = async () => {
43
+ try {
44
+ const publicKey = await deps.publicKey();
45
+ const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY] } });
46
+ if (!sent)
47
+ deps.log('device key: not registered — no connection right now, retried at the next open');
48
+ return sent;
49
+ }
50
+ catch (err) {
51
+ deps.log(`device key: registration failed — ${err instanceof Error ? err.message : String(err)}`);
52
+ return false;
53
+ }
54
+ };
55
+ return {
56
+ onOpen: () => {
57
+ inFlight ??= register().finally(() => { inFlight = null; });
58
+ return inFlight;
59
+ },
60
+ };
61
+ };
62
+ exports.createDeviceKeyRegistration = createDeviceKeyRegistration;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where a file push addressed to this machine lands (ADR-0013): a flat
3
+ * `~/Downloads/Hwip/`, never garbage-collected — it is the user's folder,
4
+ * not the daemon's cache. `~/.hwip/attachments/` stays what it is: the
5
+ * `agent.command` scratch space with the hourly sweep.
6
+ */
7
+ export declare const downloadsDir: () => string;
8
+ /**
9
+ * Remove the `.part` files a daemon that died mid-download left in the
10
+ * downloads folder — up to 1 GiB each, hidden, and nothing else ever
11
+ * removes them. Only our own temp names, only plain files, only stale ones;
12
+ * the user's files in the folder are never looked at. Returns how many went.
13
+ */
14
+ export declare const sweepStaleParts: (dir?: string, now?: number) => number;
15
+ /**
16
+ * Reduce whatever the sender called the file to one safe path segment:
17
+ * basename (so `../../x` and absolute paths lose their directories),
18
+ * no control characters or separators, no leading dots (a hidden file
19
+ * is not what the user asked to receive), `file` when nothing is left.
20
+ */
21
+ export declare const safeFileName: (raw: string) => string;
22
+ /**
23
+ * Absolute path to write `fileName` under `dir`, never overwriting: an
24
+ * existing `a.png` yields `a (2).png`, then `a (3).png`. The counter starts
25
+ * at 2 because the first copy has no suffix — that is the convention macOS
26
+ * and the browsers use, and the one users already read.
27
+ */
28
+ export declare const resolveDownloadPath: (fileName: string, dir?: string, exists?: (path: string) => boolean) => string;
29
+ //# sourceMappingURL=downloads.d.ts.map
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveDownloadPath = exports.safeFileName = exports.sweepStaleParts = exports.downloadsDir = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_os_1 = require("node:os");
6
+ const node_path_1 = require("node:path");
7
+ /**
8
+ * Where a file push addressed to this machine lands (ADR-0013): a flat
9
+ * `~/Downloads/Hwip/`, never garbage-collected — it is the user's folder,
10
+ * not the daemon's cache. `~/.hwip/attachments/` stays what it is: the
11
+ * `agent.command` scratch space with the hourly sweep.
12
+ */
13
+ const downloadsDir = () => (0, node_path_1.join)((0, node_os_1.homedir)(), 'Downloads', 'Hwip');
14
+ exports.downloadsDir = downloadsDir;
15
+ /** The temp name `saveStream` (file-crypto.ts) streams a download into. */
16
+ const PART_FILE = /^\.hwip-[0-9a-f]{12}\.part$/;
17
+ /**
18
+ * A `.part` untouched this long has no download behind it: a live one is
19
+ * written to at least every minute (DOWNLOAD_IDLE_MS) or aborted.
20
+ */
21
+ const STALE_PART_MS = 60 * 60 * 1000;
22
+ /**
23
+ * Remove the `.part` files a daemon that died mid-download left in the
24
+ * downloads folder — up to 1 GiB each, hidden, and nothing else ever
25
+ * removes them. Only our own temp names, only plain files, only stale ones;
26
+ * the user's files in the folder are never looked at. Returns how many went.
27
+ */
28
+ const sweepStaleParts = (dir = (0, exports.downloadsDir)(), now = Date.now()) => {
29
+ let entries;
30
+ try {
31
+ entries = (0, node_fs_1.readdirSync)(dir);
32
+ }
33
+ catch {
34
+ return 0; // no downloads yet
35
+ }
36
+ let removed = 0;
37
+ for (const name of entries) {
38
+ if (!PART_FILE.test(name))
39
+ continue;
40
+ const path = (0, node_path_1.join)(dir, name);
41
+ try {
42
+ const st = (0, node_fs_1.lstatSync)(path);
43
+ if (!st.isFile() || now - st.mtimeMs < STALE_PART_MS)
44
+ continue;
45
+ (0, node_fs_1.unlinkSync)(path);
46
+ removed++;
47
+ }
48
+ catch { /* gone already, or not ours to remove */ }
49
+ }
50
+ return removed;
51
+ };
52
+ exports.sweepStaleParts = sweepStaleParts;
53
+ /** Longest name most filesystems take; the suffix has to fit inside it too. */
54
+ const MAX_NAME_BYTES = 255;
55
+ /** `.tar.gz`-class extensions are ~8 bytes; nothing real is near this. */
56
+ const MAX_EXT_BYTES = 32;
57
+ const byteLength = (s) => Buffer.byteLength(s, 'utf8');
58
+ /** Trim `stem` until `stem + tail` fits MAX_NAME_BYTES, never splitting a code point. */
59
+ const fitStem = (stem, tail) => {
60
+ let out = stem;
61
+ while (out.length > 0 && byteLength(out) + byteLength(tail) > MAX_NAME_BYTES) {
62
+ out = Array.from(out).slice(0, -1).join('');
63
+ }
64
+ return out;
65
+ };
66
+ /**
67
+ * Reduce whatever the sender called the file to one safe path segment:
68
+ * basename (so `../../x` and absolute paths lose their directories),
69
+ * no control characters or separators, no leading dots (a hidden file
70
+ * is not what the user asked to receive), `file` when nothing is left.
71
+ */
72
+ const safeFileName = (raw) => {
73
+ const cleaned = (0, node_path_1.basename)(raw)
74
+ .replace(/[\x00-\x1f\x7f]/g, '')
75
+ .replace(/[/\\]/g, '_')
76
+ .replace(/^\.+/, '')
77
+ .trim();
78
+ return cleaned || 'file';
79
+ };
80
+ exports.safeFileName = safeFileName;
81
+ /**
82
+ * Absolute path to write `fileName` under `dir`, never overwriting: an
83
+ * existing `a.png` yields `a (2).png`, then `a (3).png`. The counter starts
84
+ * at 2 because the first copy has no suffix — that is the convention macOS
85
+ * and the browsers use, and the one users already read.
86
+ */
87
+ const resolveDownloadPath = (fileName, dir = (0, exports.downloadsDir)(), exists = node_fs_1.existsSync) => {
88
+ const safe = (0, exports.safeFileName)(fileName);
89
+ // An "extension" longer than any real one is a sender playing with the
90
+ // trimming rule: treat it as part of the stem so it gets cut like the rest.
91
+ const rawExt = (0, node_path_1.extname)(safe);
92
+ const ext = byteLength(rawExt) <= MAX_EXT_BYTES ? rawExt : '';
93
+ const stem = ext ? safe.slice(0, -ext.length) : safe;
94
+ // The trim can empty the stem and leave `.ext` — a hidden file — so the
95
+ // name rules run once more on what is actually written.
96
+ const nameFor = (tail) => (0, exports.safeFileName)(fitStem(stem, tail) + tail);
97
+ const first = (0, node_path_1.join)(dir, nameFor(ext));
98
+ if (!exists(first))
99
+ return first;
100
+ for (let n = 2;; n++) {
101
+ const candidate = (0, node_path_1.join)(dir, nameFor(` (${n})${ext}`));
102
+ if (!exists(candidate))
103
+ return candidate;
104
+ }
105
+ };
106
+ exports.resolveDownloadPath = resolveDownloadPath;
@@ -0,0 +1,12 @@
1
+ export declare class HwipError extends Error {
2
+ code: string;
3
+ status: number;
4
+ constructor(message: string, code: string, status: number);
5
+ }
6
+ export declare class AuthenticationError extends HwipError {
7
+ constructor(message?: string);
8
+ }
9
+ export declare class QuotaExceededError extends HwipError {
10
+ constructor(message?: string);
11
+ }
12
+ //# sourceMappingURL=errors.d.ts.map
package/dist/errors.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QuotaExceededError = exports.AuthenticationError = exports.HwipError = void 0;
4
+ class HwipError extends Error {
5
+ code;
6
+ status;
7
+ constructor(message, code, status) {
8
+ super(message);
9
+ this.name = 'HwipError';
10
+ this.code = code;
11
+ this.status = status;
12
+ }
13
+ }
14
+ exports.HwipError = HwipError;
15
+ class AuthenticationError extends HwipError {
16
+ constructor(message = 'Authentication failed') {
17
+ super(message, 'AUTHENTICATION_ERROR', 401);
18
+ this.name = 'AuthenticationError';
19
+ }
20
+ }
21
+ exports.AuthenticationError = AuthenticationError;
22
+ class QuotaExceededError extends HwipError {
23
+ constructor(message = 'Quota exceeded') {
24
+ super(message, 'QUOTA_EXCEEDED', 403);
25
+ this.name = 'QuotaExceededError';
26
+ }
27
+ }
28
+ exports.QuotaExceededError = QuotaExceededError;
@@ -0,0 +1,94 @@
1
+ import { Transform, type Readable } from 'node:stream';
2
+ /**
3
+ * Streaming decrypt of a file the web / MCP side produced with
4
+ * `libs/crypto` `encryptFile` (WebCrypto AES-256-GCM, 12-byte IV). That
5
+ * API is one-shot; a 1 GiB attachment would sit in memory here, so this
6
+ * is the same wire format on `node:crypto`'s incremental cipher:
7
+ * WebCrypto appends the 16-byte GCM tag to the ciphertext, and the stream
8
+ * holds back the last 16 bytes of whatever it is fed as that tag.
9
+ *
10
+ * The key is the raw per-file AES key, already unwrapped from the
11
+ * attachment's `deviceKeyMap` slot (see `unwrapDeviceKey` in crypto.ts).
12
+ */
13
+ export declare const GCM_TAG_BYTES = 16;
14
+ export declare const GCM_IV_BYTES = 12;
15
+ export declare const createFileDecryptStream: (rawKey: Buffer, iv: Buffer) => Transform;
16
+ /**
17
+ * A file sealed in chunks (`file-chunk-v1`), the format a phone seals a file
18
+ * in that is too large to hold in memory. The padded plaintext is cut into
19
+ * `FILE_CHUNK_BYTES` pieces — the last one shorter or equal, never empty —
20
+ * and each is sealed alone with AES-256-GCM under the file key, no AAD:
21
+ *
22
+ * nonce = prefix[7] ‖ piece index (u32 big-endian) ‖ 0x01 on the last piece, else 0x00
23
+ * file = every `ciphertext ‖ tag[16]`, in order
24
+ *
25
+ * The index in the nonce refuses a piece moved or repeated; the last-piece
26
+ * flag refuses a file cut at a piece's end, and bytes added after it. The
27
+ * prefix travels where a whole-sealed file's IV does (`iv`), and the sealed
28
+ * message says `chunk` next to it — see `chunkOf`. Vectors:
29
+ * `file-chunk.vectors.json` (scripts/gen-file-chunk-vectors.py).
30
+ */
31
+ export declare const FILE_CHUNK_BYTES = 1048576;
32
+ export declare const FILE_CHUNK_PREFIX_BYTES = 7;
33
+ /** What a listener tells senders it opens (`capabilities` on its device record). */
34
+ export declare const FILE_CHUNK_CAPABILITY = "file-chunk-v1";
35
+ /**
36
+ * The `chunk` a sender wrote beside a file's `iv`: absent for a file sealed
37
+ * whole, `FILE_CHUNK_BYTES` for one sealed in chunks. Any other value is a
38
+ * format this build does not open — `ok: false`, and the file is left alone.
39
+ */
40
+ export declare const chunkOf: (said: unknown) => {
41
+ ok: true;
42
+ chunk?: number;
43
+ } | {
44
+ ok: false;
45
+ };
46
+ /**
47
+ * Streaming decrypt of a file sealed in chunks. A piece is opened only once
48
+ * a byte past it has arrived, because whether it is the last one is in its
49
+ * nonce: what is left when the stream ends is the last piece, and it has to
50
+ * open as one. Pieces already opened have reached the `.part` file by then;
51
+ * `saveStream` removes it on any failure, so nothing a later piece disowns
52
+ * is ever linked into place.
53
+ */
54
+ export declare const createChunkedFileDecryptStream: (rawKey: Buffer, prefix: Buffer, chunk?: number) => Transform;
55
+ /**
56
+ * Abort `controller` when `source` goes quiet for `idleMs`. A stalled S3
57
+ * connection would otherwise park a download — and its part file — for
58
+ * the life of the daemon with nothing in the log. Bytes still flowing,
59
+ * however slowly, never trip it: a 1 GiB file on a bad link is not a stall.
60
+ * More than `maxBytes` fails the stream: a body that never ends is not a
61
+ * stall either, and would fill the disk.
62
+ */
63
+ export declare const withIdleTimeout: (source: Readable, idleMs: number, controller: AbortController, maxBytes?: number) => Readable;
64
+ /**
65
+ * The most bytes one download may carry: the largest file any tier may send
66
+ * (1 GiB, `fileMaxBytes`), plus the Padmé padding (at most about 12%) and
67
+ * the GCM tag a sealed file adds. The record's `fileSize` is the relay's
68
+ * word, so it cannot be the limit; without one, a URL that streams forever
69
+ * fills the disk.
70
+ */
71
+ export declare const DOWNLOAD_MAX_BYTES: number;
72
+ export interface SavedFile {
73
+ path: string;
74
+ bytes: number;
75
+ }
76
+ /**
77
+ * Stream `source` to the path `nextDest()` names, optionally decrypting
78
+ * on the way — a file sealed whole, or with `chunk` one sealed in chunks
79
+ * (`iv` is then its 7-byte prefix). The bytes go to a private `.part` file first (a per-call
80
+ * random name, so two saves of the same file cannot touch each other's
81
+ * temp) and are fsynced before being linked into place — a reader never
82
+ * sees a half file, and an authentication failure (which GCM only reports
83
+ * at the very end) leaves nothing behind. `link` refuses an existing dest
84
+ * where `rename` would replace it; on that collision `nextDest()` is asked
85
+ * again, so a name taken between the caller's check and here costs a
86
+ * ` (n)` suffix, never the download. Resolves with the path written and
87
+ * the plaintext byte count.
88
+ */
89
+ export declare const saveStream: (source: Readable, nextDest: () => string, decrypt?: {
90
+ rawKey: Buffer;
91
+ iv: Buffer;
92
+ chunk?: number;
93
+ }) => Promise<SavedFile>;
94
+ //# sourceMappingURL=file-crypto.d.ts.map