@hwip/cli 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.JsoncDoc = exports.parseJsonc = exports.JsoncEditError = exports.JsoncNotObjectError = exports.JsoncSyntaxError = void 0;
|
|
4
|
+
const node_util_1 = require("node:util");
|
|
5
|
+
const jsonc_parser_1 = require("jsonc-parser");
|
|
6
|
+
/**
|
|
7
|
+
* An agent's settings file read as JSONC — comments and trailing commas allowed,
|
|
8
|
+
* the way the agents themselves read them — and changed by small edits to its
|
|
9
|
+
* text, so what the user wrote around hwip's entries (comments, their ordering,
|
|
10
|
+
* their indentation) stays exactly as it was. `jsonc-parser` (Microsoft, the
|
|
11
|
+
* parser VS Code uses for its own settings) does the parsing and the edits;
|
|
12
|
+
* this file only fixes the conventions hwip needs on top of it.
|
|
13
|
+
*
|
|
14
|
+
* Plain JSON is a subset, so a file without comments is read and edited the
|
|
15
|
+
* same way. A file with a syntax error is not read at all (`JsoncSyntaxError`):
|
|
16
|
+
* editing around a broken part could write something worse.
|
|
17
|
+
*
|
|
18
|
+
* Every edit is checked: the text after it must read back as exactly the value
|
|
19
|
+
* the edit was meant to produce. The library gets that wrong in places — removing
|
|
20
|
+
* an item of an array whose last two items share a line joins them into one or
|
|
21
|
+
* breaks the brackets — and a settings file that agents read must never be left
|
|
22
|
+
* like that. A file with
|
|
23
|
+
* no comments is then written whole from the intended value (nothing of the
|
|
24
|
+
* user's is lost); one with comments is left unchanged and the edit throws
|
|
25
|
+
* (`JsoncEditError`).
|
|
26
|
+
*/
|
|
27
|
+
class JsoncSyntaxError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super('not valid JSON (comments and trailing commas are fine, anything else is not)');
|
|
30
|
+
this.name = 'JsoncSyntaxError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.JsoncSyntaxError = JsoncSyntaxError;
|
|
34
|
+
/** Valid JSONC, but the value is a list, a string, `null` — not a settings object. */
|
|
35
|
+
class JsoncNotObjectError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super('does not hold a JSON object');
|
|
38
|
+
this.name = 'JsoncNotObjectError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.JsoncNotObjectError = JsoncNotObjectError;
|
|
42
|
+
// jsonc-parser's `SyntaxKind` is a const enum, which this build cannot import (isolatedModules).
|
|
43
|
+
const SYNTAX_LINE_COMMENT = 12;
|
|
44
|
+
const SYNTAX_BLOCK_COMMENT = 13;
|
|
45
|
+
const SYNTAX_EOF = 17;
|
|
46
|
+
/** An edit the library could not apply correctly to a file whose comments a rewrite would lose. */
|
|
47
|
+
class JsoncEditError extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super('could not be edited without risking its comments');
|
|
50
|
+
this.name = 'JsoncEditError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.JsoncEditError = JsoncEditError;
|
|
54
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
55
|
+
/** The value of a JSONC text, or `undefined` when it has a syntax error. An empty text is `{}`. */
|
|
56
|
+
const parseJsonc = (text) => {
|
|
57
|
+
const body = text.replace(/^\uFEFF/, '');
|
|
58
|
+
if (body.trim() === '')
|
|
59
|
+
return {};
|
|
60
|
+
const errors = [];
|
|
61
|
+
const value = (0, jsonc_parser_1.parse)(body, errors, { allowTrailingComma: true });
|
|
62
|
+
return errors.length > 0 ? undefined : value;
|
|
63
|
+
};
|
|
64
|
+
exports.parseJsonc = parseJsonc;
|
|
65
|
+
/** The indentation and line ending the file already uses, so an inserted entry looks like its neighbours. */
|
|
66
|
+
const formattingOf = (body) => {
|
|
67
|
+
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
68
|
+
const indent = /^([ \t]+)\S/m.exec(body)?.[1];
|
|
69
|
+
if (indent?.startsWith('\t'))
|
|
70
|
+
return { insertSpaces: false, tabSize: 1, eol };
|
|
71
|
+
return { insertSpaces: true, tabSize: indent ? Math.min(indent.length, 8) : 2, eol };
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* A copy of `data` with `change` applied to the container that holds `path`'s last
|
|
75
|
+
* segment, missing containers on the way created as objects — the value an edit is
|
|
76
|
+
* meant to leave behind, to check the text against.
|
|
77
|
+
*/
|
|
78
|
+
const valueAfter = (data, path, change) => {
|
|
79
|
+
const copy = structuredClone(data);
|
|
80
|
+
let container = copy;
|
|
81
|
+
for (const segment of path.slice(0, -1)) {
|
|
82
|
+
if (!isPlainObject(container[segment]) && !Array.isArray(container[segment]))
|
|
83
|
+
container[segment] = {};
|
|
84
|
+
container = container[segment];
|
|
85
|
+
}
|
|
86
|
+
change(container, path[path.length - 1]);
|
|
87
|
+
return copy;
|
|
88
|
+
};
|
|
89
|
+
class JsoncDoc {
|
|
90
|
+
bom;
|
|
91
|
+
body;
|
|
92
|
+
formatting;
|
|
93
|
+
/** What the file held when it was read (null: there was no file), for `replaceFile` to check before it writes. */
|
|
94
|
+
expected;
|
|
95
|
+
/**
|
|
96
|
+
* `text` is a settings file's content; throws `JsoncSyntaxError`, or `JsoncNotObjectError` when it is valid but not an object.
|
|
97
|
+
* `existed` is false for a file that was not there (`text` is then empty).
|
|
98
|
+
*/
|
|
99
|
+
constructor(text, existed = true) {
|
|
100
|
+
this.expected = existed ? text : null;
|
|
101
|
+
this.bom = text.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
102
|
+
const stripped = text.slice(this.bom.length);
|
|
103
|
+
const value = (0, exports.parseJsonc)(stripped);
|
|
104
|
+
if (value === undefined)
|
|
105
|
+
throw new JsoncSyntaxError();
|
|
106
|
+
if (!isPlainObject(value))
|
|
107
|
+
throw new JsoncNotObjectError();
|
|
108
|
+
// An empty file is a new one: edits need a document to land in.
|
|
109
|
+
this.body = stripped.trim() === '' ? '{}' : stripped;
|
|
110
|
+
this.formatting = formattingOf(this.body);
|
|
111
|
+
}
|
|
112
|
+
/** The text without an entry that shares its line (a compact array or object), or null when a comment sits in the way. */
|
|
113
|
+
withoutInline(unit, siblings) {
|
|
114
|
+
const i = siblings.indexOf(unit);
|
|
115
|
+
const end = unit.offset + unit.length;
|
|
116
|
+
const between = (from, to) => this.body.slice(from, to);
|
|
117
|
+
const bare = (text) => /^[\s,]*$/.test(text);
|
|
118
|
+
const next = siblings[i + 1];
|
|
119
|
+
if (next) {
|
|
120
|
+
// Up to the next entry: the entry, its comma and the space after it.
|
|
121
|
+
return bare(between(end, next.offset)) ? between(0, unit.offset) + between(next.offset, this.body.length) : null;
|
|
122
|
+
}
|
|
123
|
+
const before = siblings[i - 1];
|
|
124
|
+
const close = (unit.parent?.offset ?? 0) + (unit.parent?.length ?? 0) - 1;
|
|
125
|
+
// The only entry: everything from it to the closing bracket, its trailing comma included.
|
|
126
|
+
if (!before)
|
|
127
|
+
return bare(between(end, close)) ? between(0, unit.offset) + between(close, this.body.length) : null;
|
|
128
|
+
// The last of several: from the end of the one before (its comma goes) to this one's end; a trailing comma stays.
|
|
129
|
+
const beforeEnd = before.offset + before.length;
|
|
130
|
+
return bare(between(beforeEnd, unit.offset)) && bare(between(end, close))
|
|
131
|
+
? between(0, beforeEnd) + between(end, this.body.length)
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
/** Would removing the entry at `path` take a comment with it (one inside the entry, or at the end of its last line)? */
|
|
135
|
+
removalTakesComment(path) {
|
|
136
|
+
const zone = this.removalZone(path);
|
|
137
|
+
return !!zone && JsoncDoc.commentsIn(this.body).some((c) => c.offset >= zone[0] && c.offset < zone[1]);
|
|
138
|
+
}
|
|
139
|
+
/** Does the text hold a comment? (Not one inside a string: the scanner knows tokens.) */
|
|
140
|
+
hasComments() {
|
|
141
|
+
const scanner = (0, jsonc_parser_1.createScanner)(this.body, false);
|
|
142
|
+
for (let kind = scanner.scan(); kind !== SYNTAX_EOF; kind = scanner.scan()) {
|
|
143
|
+
if (kind === SYNTAX_LINE_COMMENT || kind === SYNTAX_BLOCK_COMMENT)
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
/** Every comment in `text` with where it starts, in order. */
|
|
149
|
+
static commentsIn(text) {
|
|
150
|
+
const scanner = (0, jsonc_parser_1.createScanner)(text, false);
|
|
151
|
+
const found = [];
|
|
152
|
+
for (let kind = scanner.scan(); kind !== SYNTAX_EOF; kind = scanner.scan()) {
|
|
153
|
+
if (kind === SYNTAX_LINE_COMMENT || kind === SYNTAX_BLOCK_COMMENT)
|
|
154
|
+
found.push({ text: text.slice(scanner.getTokenOffset(), scanner.getTokenOffset() + scanner.getTokenLength()), offset: scanner.getTokenOffset() });
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Did `next` lose a comment of the current text that was not inside `allowed`
|
|
160
|
+
* (a range of the current text: what the edit may take with it)? The library
|
|
161
|
+
* drops comments around a removed entry it has no right to; a copy of the same
|
|
162
|
+
* text elsewhere does not stand in for a lost one.
|
|
163
|
+
*/
|
|
164
|
+
losesComment(next, allowed) {
|
|
165
|
+
const left = new Map();
|
|
166
|
+
for (const c of JsoncDoc.commentsIn(next))
|
|
167
|
+
left.set(c.text, (left.get(c.text) ?? 0) + 1);
|
|
168
|
+
const inAllowed = (offset) => !!allowed && offset >= allowed[0] && offset < allowed[1];
|
|
169
|
+
// Comments outside the allowed range claim their survivors first.
|
|
170
|
+
const before = JsoncDoc.commentsIn(this.body);
|
|
171
|
+
for (const c of [...before.filter((x) => !inAllowed(x.offset)), ...before.filter((x) => inAllowed(x.offset))]) {
|
|
172
|
+
const count = left.get(c.text) ?? 0;
|
|
173
|
+
if (count > 0)
|
|
174
|
+
left.set(c.text, count - 1);
|
|
175
|
+
else if (!inAllowed(c.offset))
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
/** Take `next` as the new text if it reads back as `expected` and loses no comment but those in `allowed`; else see the class comment. */
|
|
181
|
+
commit(next, expected, allowed = null) {
|
|
182
|
+
if ((0, node_util_1.isDeepStrictEqual)((0, exports.parseJsonc)(next), expected) && !this.losesComment(next, allowed)) {
|
|
183
|
+
this.body = next;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (this.hasComments())
|
|
187
|
+
throw new JsoncEditError();
|
|
188
|
+
this.body = JSON.stringify(expected, null, this.formatting.insertSpaces === false ? '\t' : ' '.repeat(this.formatting.tabSize ?? 2));
|
|
189
|
+
}
|
|
190
|
+
/** The current value, read again after every edit. */
|
|
191
|
+
get data() {
|
|
192
|
+
return (0, exports.parseJsonc)(this.body);
|
|
193
|
+
}
|
|
194
|
+
/** Set `path` to `value`, creating what is missing on the way. */
|
|
195
|
+
set(path, value) {
|
|
196
|
+
const expected = valueAfter(this.data, path, (container, key) => {
|
|
197
|
+
container[key] = value;
|
|
198
|
+
});
|
|
199
|
+
const tree = (0, jsonc_parser_1.parseTree)(this.body, [], { allowTrailingComma: true });
|
|
200
|
+
const existing = tree ? (0, jsonc_parser_1.findNodeAtLocation)(tree, path) : undefined;
|
|
201
|
+
if (tree && !existing) {
|
|
202
|
+
// A new property goes after the last one — and after the comment on its line.
|
|
203
|
+
const key = path[path.length - 1];
|
|
204
|
+
const parent = (0, jsonc_parser_1.findNodeAtLocation)(tree, path.slice(0, -1));
|
|
205
|
+
const text = parent?.type === 'object' && typeof key === 'string'
|
|
206
|
+
? this.insertLast(parent, `${JSON.stringify(key)}: ${this.render(value)}`)
|
|
207
|
+
: null;
|
|
208
|
+
if (text !== null) {
|
|
209
|
+
this.refuseAdoptedComment(text, path);
|
|
210
|
+
return this.commit(text, expected);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// A value that is replaced may take the comments inside it along.
|
|
214
|
+
const replaced = existing ? [existing.offset, existing.offset + existing.length] : null;
|
|
215
|
+
const next = (0, jsonc_parser_1.applyEdits)(this.body, (0, jsonc_parser_1.modify)(this.body, path, value, { formattingOptions: this.formatting }));
|
|
216
|
+
if (!existing)
|
|
217
|
+
this.refuseAdoptedComment(next, path);
|
|
218
|
+
this.commit(next, expected, replaced);
|
|
219
|
+
}
|
|
220
|
+
/** Remove the property or array item at `path`. A comment right next to it goes with it. */
|
|
221
|
+
remove(path) {
|
|
222
|
+
const expected = valueAfter(this.data, path, (container, key) => {
|
|
223
|
+
if (Array.isArray(container))
|
|
224
|
+
container.splice(key, 1);
|
|
225
|
+
else
|
|
226
|
+
delete container[key];
|
|
227
|
+
});
|
|
228
|
+
const lines = this.withoutLines(path);
|
|
229
|
+
this.commit(lines ?? (0, jsonc_parser_1.applyEdits)(this.body, (0, jsonc_parser_1.modify)(this.body, path, undefined, { formattingOptions: this.formatting })), expected, this.removalZone(path));
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* What removing the entry at `path` may take with it: the entry itself and the
|
|
233
|
+
* comment at the end of its last line — not one above it, nor one after the
|
|
234
|
+
* closing bracket of its container, which are not this entry's.
|
|
235
|
+
*/
|
|
236
|
+
removalZone(path) {
|
|
237
|
+
const tree = (0, jsonc_parser_1.parseTree)(this.body, [], { allowTrailingComma: true });
|
|
238
|
+
const node = tree ? (0, jsonc_parser_1.findNodeAtLocation)(tree, path) : undefined;
|
|
239
|
+
const unit = node?.parent?.type === 'property' ? node.parent : node;
|
|
240
|
+
const siblings = unit?.parent?.children;
|
|
241
|
+
if (!unit || !siblings)
|
|
242
|
+
return null;
|
|
243
|
+
const lineEndAfter = (at) => {
|
|
244
|
+
const nl = this.body.indexOf('\n', at);
|
|
245
|
+
return nl < 0 ? this.body.length : nl + 1;
|
|
246
|
+
};
|
|
247
|
+
const after = siblings[siblings.indexOf(unit) + 1];
|
|
248
|
+
const end = unit.offset + unit.length;
|
|
249
|
+
return [unit.offset, Math.min(lineEndAfter(end), after?.offset ?? unit.parent.offset + unit.parent.length - 1)];
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The text without the lines of the entry at `path`, when it stands on lines of
|
|
253
|
+
* its own: its own comment and comma go with it, and so does the comma before it
|
|
254
|
+
* when it was the last one. A comment on another line, or on the line of the
|
|
255
|
+
* entry before, stays — the library would take the comment next to the entry
|
|
256
|
+
* along. An entry that shares its line with others goes through `withoutInline`.
|
|
257
|
+
*/
|
|
258
|
+
withoutLines(path) {
|
|
259
|
+
const tree = (0, jsonc_parser_1.parseTree)(this.body, [], { allowTrailingComma: true });
|
|
260
|
+
const node = tree ? (0, jsonc_parser_1.findNodeAtLocation)(tree, path) : undefined;
|
|
261
|
+
const unit = node?.parent?.type === 'property' ? node.parent : node;
|
|
262
|
+
const siblings = unit?.parent?.children;
|
|
263
|
+
if (!unit || !siblings)
|
|
264
|
+
return null;
|
|
265
|
+
const lineStart = this.body.lastIndexOf('\n', unit.offset - 1) + 1;
|
|
266
|
+
const end = unit.offset + unit.length;
|
|
267
|
+
if (/\S/.test(this.body.slice(lineStart, unit.offset)))
|
|
268
|
+
return this.withoutInline(unit, siblings);
|
|
269
|
+
const rest = /^[ \t]*(,)?[ \t]*(?:\/\/[^\r\n]*|\/\*[^\r\n]*?\*\/)?[ \t]*(?:\r?\n|$)/.exec(this.body.slice(end));
|
|
270
|
+
if (!rest)
|
|
271
|
+
return this.withoutInline(unit, siblings);
|
|
272
|
+
const without = this.body.slice(0, lineStart) + this.body.slice(end + rest[0].length);
|
|
273
|
+
const before = siblings[siblings.indexOf(unit) - 1];
|
|
274
|
+
if (rest[1] !== undefined || !before)
|
|
275
|
+
return without;
|
|
276
|
+
// The last entry, in a file that writes no trailing comma: the one before it loses its comma.
|
|
277
|
+
const beforeEnd = before.offset + before.length;
|
|
278
|
+
const comma = /^[ \t]*,/.exec(this.body.slice(beforeEnd));
|
|
279
|
+
return comma ? without.slice(0, beforeEnd + comma[0].length - 1) + without.slice(beforeEnd + comma[0].length) : null;
|
|
280
|
+
}
|
|
281
|
+
/** Add `value` at the end of the array at `path`, after the comment on the last item's line. */
|
|
282
|
+
append(path, value) {
|
|
283
|
+
const expected = valueAfter(this.data, path, (container, key) => {
|
|
284
|
+
const list = container[key];
|
|
285
|
+
if (!Array.isArray(list))
|
|
286
|
+
throw new Error('not a list');
|
|
287
|
+
list.push(value);
|
|
288
|
+
});
|
|
289
|
+
const tree = (0, jsonc_parser_1.parseTree)(this.body, [], { allowTrailingComma: true });
|
|
290
|
+
const array = tree ? (0, jsonc_parser_1.findNodeAtLocation)(tree, path) : undefined;
|
|
291
|
+
const text = array?.type === 'array' ? this.insertLast(array, this.render(value)) : null;
|
|
292
|
+
const added = [...path, array?.children?.length ?? 0];
|
|
293
|
+
if (text !== null) {
|
|
294
|
+
this.refuseAdoptedComment(text, added);
|
|
295
|
+
return this.commit(text, expected);
|
|
296
|
+
}
|
|
297
|
+
const next = (0, jsonc_parser_1.applyEdits)(this.body, (0, jsonc_parser_1.modify)(this.body, [...path, -1], value, { formattingOptions: this.formatting, isArrayInsertion: true }));
|
|
298
|
+
this.refuseAdoptedComment(next, added);
|
|
299
|
+
this.commit(next, expected);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The library's insert can leave a comment of the user's on the line of the new
|
|
303
|
+
* entry, and an uninstall would take it away with the entry (it is the entry's
|
|
304
|
+
* "own" comment by position). A new entry must have no comment in its removal
|
|
305
|
+
* zone; otherwise fail now, naming the file, and leave the text alone.
|
|
306
|
+
*/
|
|
307
|
+
refuseAdoptedComment(next, path) {
|
|
308
|
+
if (!this.hasComments())
|
|
309
|
+
return;
|
|
310
|
+
const after = new JsoncDoc(next);
|
|
311
|
+
if (after.removalTakesComment(path))
|
|
312
|
+
throw new JsoncEditError();
|
|
313
|
+
}
|
|
314
|
+
/** `value` as JSON text in the file's own indentation unit. */
|
|
315
|
+
render(value) {
|
|
316
|
+
const unit = this.formatting.insertSpaces === false ? '\t' : ' '.repeat(this.formatting.tabSize ?? 2);
|
|
317
|
+
return JSON.stringify(value, null, unit);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The text with `childText` put after the last child of an object or array, on a
|
|
321
|
+
* line of its own at the last child's indentation; null for an empty container
|
|
322
|
+
* or one whose last child shares its line with something else (the caller falls
|
|
323
|
+
* back). The library's insert would re-indent the siblings and move a comment at
|
|
324
|
+
* the end of the last child's line onto the new child.
|
|
325
|
+
*/
|
|
326
|
+
insertLast(container, childText) {
|
|
327
|
+
const children = container.children ?? [];
|
|
328
|
+
const last = children[children.length - 1];
|
|
329
|
+
if (!last)
|
|
330
|
+
return null;
|
|
331
|
+
const lineStart = this.body.lastIndexOf('\n', last.offset - 1) + 1;
|
|
332
|
+
const before = this.body.slice(lineStart, last.offset);
|
|
333
|
+
if (/\S/.test(before))
|
|
334
|
+
return null;
|
|
335
|
+
const lastEnd = last.offset + last.length;
|
|
336
|
+
const close = container.offset + container.length - 1;
|
|
337
|
+
const tail = /^[ \t]*(,)?[ \t]*(\/\/[^\r\n]*|\/\*[^]*?\*\/)?/.exec(this.body.slice(lastEnd, close));
|
|
338
|
+
const hasComma = tail[1] !== undefined;
|
|
339
|
+
const at = lastEnd + tail[0].length;
|
|
340
|
+
// A comment that the closing bracket follows on its line would be left between the two entries.
|
|
341
|
+
if (tail[2] && !this.body.slice(at, close).includes('\n'))
|
|
342
|
+
return null;
|
|
343
|
+
const eol = this.formatting.eol ?? '\n';
|
|
344
|
+
// `childText` came in indented from its own first line: move it under the last child.
|
|
345
|
+
const text = childText.replace(/\r?\n/g, `${eol}${before}`);
|
|
346
|
+
const withNew = this.body.slice(0, at) + `${eol}${before}${text}${hasComma ? ',' : ''}` + this.body.slice(at);
|
|
347
|
+
return hasComma ? withNew : withNew.slice(0, lastEnd) + ',' + withNew.slice(lastEnd);
|
|
348
|
+
}
|
|
349
|
+
/** The file's new text: its byte order mark kept, one line ending at the end. */
|
|
350
|
+
text() {
|
|
351
|
+
return this.bom + this.body.trimEnd() + (this.formatting.eol ?? '\n');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
exports.JsoncDoc = JsoncDoc;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Keep the host awake while the listener runs — macOS only.
|
|
4
|
+
*
|
|
5
|
+
* The listener holds an OUTBOUND WebSocket. macOS "Wake for network access"
|
|
6
|
+
* only wakes the machine for inbound requests to Bonjour-advertised services,
|
|
7
|
+
* so it does nothing for us: when the Mac idle-sleeps, the socket dies and the
|
|
8
|
+
* phone sits on "Waiting for the live stream…" until the lid is opened. On a
|
|
9
|
+
* machine that runs Claude Code this is masked — the agent spawns
|
|
10
|
+
* `caffeinate -i -t 300` while a turn is running — which is why one Mac looks
|
|
11
|
+
* fine and another, idling at a prompt, keeps dropping.
|
|
12
|
+
*
|
|
13
|
+
* `caffeinate -s -w <pid>`: `-s` blocks system sleep and is honoured ONLY on
|
|
14
|
+
* AC power (macOS drops the assertion on battery, so this never drains a
|
|
15
|
+
* laptop); `-w` releases it the moment our pid exits, so a crashed daemon
|
|
16
|
+
* leaves no orphan holding the machine up. The display still sleeps; a closed
|
|
17
|
+
* lid still sleeps — those are the user's call, not ours.
|
|
18
|
+
*/
|
|
19
|
+
export interface KeepAwakeOptions {
|
|
20
|
+
/** `false` = `--no-keep-awake` / `"keepAwake": false` in config. */
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
log: (msg: string) => void;
|
|
23
|
+
platform?: NodeJS.Platform;
|
|
24
|
+
pid?: number;
|
|
25
|
+
spawnFn?: typeof spawn;
|
|
26
|
+
}
|
|
27
|
+
export interface KeepAwake {
|
|
28
|
+
/** Release the assertion now rather than at process exit. Idempotent. */
|
|
29
|
+
stop: () => void;
|
|
30
|
+
}
|
|
31
|
+
export declare const startKeepAwake: (opts: KeepAwakeOptions) => KeepAwake;
|
|
32
|
+
//# sourceMappingURL=keep-awake.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startKeepAwake = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const NOOP = { stop: () => undefined };
|
|
6
|
+
const startKeepAwake = (opts) => {
|
|
7
|
+
if (!opts.enabled) {
|
|
8
|
+
opts.log('keep-awake: off — the Mac may idle-sleep and drop the socket');
|
|
9
|
+
return NOOP;
|
|
10
|
+
}
|
|
11
|
+
if ((opts.platform ?? process.platform) !== 'darwin')
|
|
12
|
+
return NOOP;
|
|
13
|
+
const pid = opts.pid ?? process.pid;
|
|
14
|
+
const spawnFn = opts.spawnFn ?? node_child_process_1.spawn;
|
|
15
|
+
const child = spawnFn('caffeinate', ['-s', '-w', String(pid)], { stdio: 'ignore' });
|
|
16
|
+
let stopped = false;
|
|
17
|
+
// ENOENT and friends arrive here, asynchronously — spawn itself does not
|
|
18
|
+
// throw for a missing binary. Not fatal: the listener works without it,
|
|
19
|
+
// the machine just sleeps like it did before.
|
|
20
|
+
child.on('error', (err) => {
|
|
21
|
+
stopped = true;
|
|
22
|
+
opts.log(`! keep-awake: caffeinate unavailable (${err.message}) — the Mac may idle-sleep and drop the socket`);
|
|
23
|
+
});
|
|
24
|
+
child.on('exit', (code, signal) => {
|
|
25
|
+
if (stopped)
|
|
26
|
+
return;
|
|
27
|
+
stopped = true;
|
|
28
|
+
opts.log(`! keep-awake: caffeinate exited early (${code ?? signal}) — the Mac may idle-sleep and drop the socket`);
|
|
29
|
+
});
|
|
30
|
+
// Never keep the event loop alive on the child's account: the daemon's
|
|
31
|
+
// lifetime is the socket loop's, and `-w` ties caffeinate to it anyway.
|
|
32
|
+
child.unref();
|
|
33
|
+
// Held with no agent session open too: the phone resumes a past session
|
|
34
|
+
// or starts one only while this Mac is awake to answer (C25 — say so
|
|
35
|
+
// rather than let the Mac's sleeplessness go unexplained).
|
|
36
|
+
opts.log(`keep-awake: caffeinate -s -w ${pid} — blocks idle sleep on AC power only, even with no agent session open, so the phone can resume one (--no-keep-awake to disable)`);
|
|
37
|
+
return {
|
|
38
|
+
stop: () => {
|
|
39
|
+
if (stopped)
|
|
40
|
+
return;
|
|
41
|
+
stopped = true;
|
|
42
|
+
child.kill();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
exports.startKeepAwake = startKeepAwake;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devices whose key changed since this computer pinned them, kept until a person has been shown it.
|
|
3
|
+
*
|
|
4
|
+
* Whoever replaces a pin — `hwip notify`, `hwip ask`, `hwip agent send`, the listener, the MCP server — writes the
|
|
5
|
+
* change here, under the trust file's lock (`trust-lock.ts`). Their own output often reaches nobody (a hook's
|
|
6
|
+
* `2>/dev/null`, the daemon log, an MCP server's stderr), and once the pin holds the new key no one sees the change
|
|
7
|
+
* again. So it waits beside the pins until a surface a person reads takes it: a cli command at a terminal, the
|
|
8
|
+
* plugin's SessionStart and /hwip-mode, an MCP tool result.
|
|
9
|
+
*
|
|
10
|
+
* THE CONTRACT (cli and mcp-server write and read it, the plugin reads it — changing it is a cross-repo change):
|
|
11
|
+
*
|
|
12
|
+
* path key-changes.json beside trusted-devices.json (~/.hwip/key-changes.json), 0600
|
|
13
|
+
* body {"v":1,"changes":[{"deviceId":"<device id>","at":"<ISO 8601 UTC>","name":"<nickname, optional>"}]}
|
|
14
|
+
* one entry per device, updated in place; at most 64, the oldest dropped
|
|
15
|
+
* take under the same lock as the writers (trusted-devices.lock), rename to key-changes.json.<pid>.taken, read,
|
|
16
|
+
* delete: a writer cannot put back what was taken, and of two readers only the one whose rename wins shows
|
|
17
|
+
* the changes. A record that does not parse, or another `v`, reads as none.
|
|
18
|
+
*/
|
|
19
|
+
export interface KeyChange {
|
|
20
|
+
deviceId: string;
|
|
21
|
+
/** When the pin was replaced, ISO 8601 UTC. */
|
|
22
|
+
at: string;
|
|
23
|
+
/** The device's nickname as the account lists it. */
|
|
24
|
+
name?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const MAX_KEY_CHANGES = 64;
|
|
27
|
+
export declare const keyChangesPath: (trustPath: string) => string;
|
|
28
|
+
/**
|
|
29
|
+
* Add `changes` to the record. Call it inside the `withTrustLock` section that replaced the pins: that lock is what
|
|
30
|
+
* keeps two writers from dropping each other's entry, and a taker from seeing an entry twice. Throws nothing: false
|
|
31
|
+
* means the record could not be written, and the caller says so — nothing else will.
|
|
32
|
+
*/
|
|
33
|
+
export declare const recordKeyChanges: (trustPath: string, changes: KeyChange[]) => boolean;
|
|
34
|
+
/** The changes nobody has been shown yet, and the record gone. A lock that cannot be had leaves the record for the next surface. */
|
|
35
|
+
export declare const takeKeyChanges: (trustPath: string, opts?: {
|
|
36
|
+
waitMs?: number;
|
|
37
|
+
}) => KeyChange[];
|
|
38
|
+
/** One line for a person; the caller adds its prefix. */
|
|
39
|
+
export declare const describeKeyChange: (change: KeyChange) => string;
|
|
40
|
+
/**
|
|
41
|
+
* Show the recorded changes on stderr and clear them — only when stderr is a terminal. A hook runs `hwip notify …
|
|
42
|
+
* 2>/dev/null`; taking the record there would lose it unseen, so it is left for the plugin to show.
|
|
43
|
+
*/
|
|
44
|
+
export declare const showKeyChanges: (trustPath: string, stderr: {
|
|
45
|
+
isTTY?: boolean;
|
|
46
|
+
write: (text: string) => unknown;
|
|
47
|
+
}) => void;
|
|
48
|
+
//# sourceMappingURL=key-changes.d.ts.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Devices whose key changed since this computer pinned them, kept until a person has been shown it.
|
|
4
|
+
*
|
|
5
|
+
* Whoever replaces a pin — `hwip notify`, `hwip ask`, `hwip agent send`, the listener, the MCP server — writes the
|
|
6
|
+
* change here, under the trust file's lock (`trust-lock.ts`). Their own output often reaches nobody (a hook's
|
|
7
|
+
* `2>/dev/null`, the daemon log, an MCP server's stderr), and once the pin holds the new key no one sees the change
|
|
8
|
+
* again. So it waits beside the pins until a surface a person reads takes it: a cli command at a terminal, the
|
|
9
|
+
* plugin's SessionStart and /hwip-mode, an MCP tool result.
|
|
10
|
+
*
|
|
11
|
+
* THE CONTRACT (cli and mcp-server write and read it, the plugin reads it — changing it is a cross-repo change):
|
|
12
|
+
*
|
|
13
|
+
* path key-changes.json beside trusted-devices.json (~/.hwip/key-changes.json), 0600
|
|
14
|
+
* body {"v":1,"changes":[{"deviceId":"<device id>","at":"<ISO 8601 UTC>","name":"<nickname, optional>"}]}
|
|
15
|
+
* one entry per device, updated in place; at most 64, the oldest dropped
|
|
16
|
+
* take under the same lock as the writers (trusted-devices.lock), rename to key-changes.json.<pid>.taken, read,
|
|
17
|
+
* delete: a writer cannot put back what was taken, and of two readers only the one whose rename wins shows
|
|
18
|
+
* the changes. A record that does not parse, or another `v`, reads as none.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.showKeyChanges = exports.describeKeyChange = exports.takeKeyChanges = exports.recordKeyChanges = exports.keyChangesPath = exports.MAX_KEY_CHANGES = void 0;
|
|
22
|
+
const node_fs_1 = require("node:fs");
|
|
23
|
+
const node_crypto_1 = require("node:crypto");
|
|
24
|
+
const node_path_1 = require("node:path");
|
|
25
|
+
const private_files_js_1 = require("./private-files.js");
|
|
26
|
+
const subscriber_trust_js_1 = require("./subscriber-trust.js");
|
|
27
|
+
const trust_lock_js_1 = require("./trust-lock.js");
|
|
28
|
+
exports.MAX_KEY_CHANGES = 64;
|
|
29
|
+
const NAME_MAX = 80;
|
|
30
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
31
|
+
const keyChangesPath = (trustPath) => (0, node_path_1.join)((0, node_path_1.dirname)(trustPath), 'key-changes.json');
|
|
32
|
+
exports.keyChangesPath = keyChangesPath;
|
|
33
|
+
/** The relay writes nicknames: no control, direction or zero-width characters, one space per run, 80 characters at most. */
|
|
34
|
+
const cleanName = (name) => {
|
|
35
|
+
if (typeof name !== 'string')
|
|
36
|
+
return undefined;
|
|
37
|
+
const plain = name.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]|\p{Cf}/gu, '').replace(/\s+/g, ' ').trim();
|
|
38
|
+
const chars = Array.from(plain);
|
|
39
|
+
if (chars.length === 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
return chars.length > NAME_MAX ? chars.slice(0, NAME_MAX).join('') : plain;
|
|
42
|
+
};
|
|
43
|
+
const entryOf = (raw) => {
|
|
44
|
+
if (typeof raw !== 'object' || raw === null)
|
|
45
|
+
return null;
|
|
46
|
+
const { deviceId, at, name } = raw;
|
|
47
|
+
if (typeof deviceId !== 'string' || !subscriber_trust_js_1.DEVICE_ID.test(deviceId) || typeof at !== 'string' || !ISO_UTC.test(at))
|
|
48
|
+
return null;
|
|
49
|
+
const clean = cleanName(name);
|
|
50
|
+
return clean === undefined ? { deviceId, at } : { deviceId, at, name: clean };
|
|
51
|
+
};
|
|
52
|
+
const readChanges = (path) => {
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const { v, changes } = (raw ?? {});
|
|
61
|
+
if (v !== 1 || !Array.isArray(changes))
|
|
62
|
+
return [];
|
|
63
|
+
return changes.map(entryOf).filter((c) => c !== null).slice(-exports.MAX_KEY_CHANGES);
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Add `changes` to the record. Call it inside the `withTrustLock` section that replaced the pins: that lock is what
|
|
67
|
+
* keeps two writers from dropping each other's entry, and a taker from seeing an entry twice. Throws nothing: false
|
|
68
|
+
* means the record could not be written, and the caller says so — nothing else will.
|
|
69
|
+
*/
|
|
70
|
+
const recordKeyChanges = (trustPath, changes) => {
|
|
71
|
+
if (changes.length === 0)
|
|
72
|
+
return true;
|
|
73
|
+
const path = (0, exports.keyChangesPath)(trustPath);
|
|
74
|
+
const list = readChanges(path);
|
|
75
|
+
for (const change of changes) {
|
|
76
|
+
const entry = entryOf(change);
|
|
77
|
+
if (!entry)
|
|
78
|
+
continue;
|
|
79
|
+
const at = list.findIndex((c) => c.deviceId === entry.deviceId);
|
|
80
|
+
if (at >= 0)
|
|
81
|
+
list[at] = entry;
|
|
82
|
+
else
|
|
83
|
+
list.push(entry);
|
|
84
|
+
}
|
|
85
|
+
const temp = `${path}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
|
|
86
|
+
try {
|
|
87
|
+
(0, private_files_js_1.mkdirHandedBack)((0, node_path_1.dirname)(path), 0o700);
|
|
88
|
+
(0, node_fs_1.writeFileSync)(temp, `${JSON.stringify({ v: 1, changes: list.slice(-exports.MAX_KEY_CHANGES) })}\n`, { mode: 0o600, flag: 'wx' });
|
|
89
|
+
(0, private_files_js_1.handBackToSudoUser)(temp);
|
|
90
|
+
(0, node_fs_1.renameSync)(temp, path);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
try {
|
|
95
|
+
(0, node_fs_1.rmSync)(temp, { force: true });
|
|
96
|
+
}
|
|
97
|
+
catch { /* the folder is not there either */ }
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
exports.recordKeyChanges = recordKeyChanges;
|
|
102
|
+
const takeRecord = (path) => {
|
|
103
|
+
const taken = `${path}.${process.pid}.taken`;
|
|
104
|
+
try {
|
|
105
|
+
(0, node_fs_1.renameSync)(path, taken);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return readChanges(taken);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
(0, node_fs_1.rmSync)(taken, { force: true });
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/** The changes nobody has been shown yet, and the record gone. A lock that cannot be had leaves the record for the next surface. */
|
|
118
|
+
const takeKeyChanges = (trustPath, opts = {}) => {
|
|
119
|
+
const path = (0, exports.keyChangesPath)(trustPath);
|
|
120
|
+
// Most commands find nothing: no lock for them.
|
|
121
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
122
|
+
return [];
|
|
123
|
+
const done = (0, trust_lock_js_1.withTrustLock)(trustPath, () => takeRecord(path), opts);
|
|
124
|
+
return done.locked ? done.value : [];
|
|
125
|
+
};
|
|
126
|
+
exports.takeKeyChanges = takeKeyChanges;
|
|
127
|
+
/** One line for a person; the caller adds its prefix. */
|
|
128
|
+
const describeKeyChange = (change) => `${change.name ?? change.deviceId} changed its encryption key — the new key is trusted. Not expecting that (app reinstalled?) Check it: hwip trust`;
|
|
129
|
+
exports.describeKeyChange = describeKeyChange;
|
|
130
|
+
/**
|
|
131
|
+
* Show the recorded changes on stderr and clear them — only when stderr is a terminal. A hook runs `hwip notify …
|
|
132
|
+
* 2>/dev/null`; taking the record there would lose it unseen, so it is left for the plugin to show.
|
|
133
|
+
*/
|
|
134
|
+
const showKeyChanges = (trustPath, stderr) => {
|
|
135
|
+
if (stderr.isTTY !== true)
|
|
136
|
+
return;
|
|
137
|
+
for (const change of (0, exports.takeKeyChanges)(trustPath))
|
|
138
|
+
stderr.write(`hwip: ${(0, exports.describeKeyChange)(change)}\n`);
|
|
139
|
+
};
|
|
140
|
+
exports.showKeyChanges = showKeyChanges;
|