pendingyou 0.9.0 → 0.10.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/CHANGELOG.md +49 -0
- package/README.md +80 -22
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- package/dist/credentials.js +39 -1
- package/dist/files.js +3 -3
- package/dist/hold.js +35 -17
- package/dist/index.js +1 -0
- package/dist/install.js +4 -2
- package/dist/io.js +3 -1
- package/dist/main.js +195 -35
- package/dist/mod.js +87 -0
- package/dist/oauth.js +27 -6
- package/dist/pickup.js +16 -18
- package/dist/qr.js +365 -0
- package/dist/remote.js +306 -0
- package/dist/state.js +5 -32
- package/dist/stopcheck.js +2 -1
- package/dist/version.js +1 -1
- package/dist/watch.js +3 -5
- package/mod/.claude-plugin/plugin.json +12 -0
- package/mod/README.md +7 -0
- package/mod/hooks/hooks.json +7 -0
- package/mod/hooks/register.js +314 -0
- package/mod/hooks/wake.js +599 -0
- package/mod/tests/wake.test.ts +198 -0
- package/package.json +3 -2
package/dist/qr.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// A QR code for the device sign-in's link (0.10.0), so the person can scan it from the terminal with their phone
|
|
2
|
+
// instead of typing the address: on a computer reached over SSH, that phone is where they approve the code. The
|
|
3
|
+
// command line has no dependencies, so this is a small encoder of its own: byte mode, versions 1–9 (up to 180 bytes at
|
|
4
|
+
// level M), every error correction level, and the standard's mask choice (lowest penalty). It follows Project Nayuki's
|
|
5
|
+
// QR Code generator, as uqr does (the app's phone code, onboarding.tsx); apps/pendingyou/test/cli-qr.test.ts checks it
|
|
6
|
+
// against uqr module for module.
|
|
7
|
+
const LEVELS = {
|
|
8
|
+
L: { index: 0, format: 1 },
|
|
9
|
+
M: { index: 1, format: 0 },
|
|
10
|
+
Q: { index: 2, format: 3 },
|
|
11
|
+
H: { index: 3, format: 2 },
|
|
12
|
+
};
|
|
13
|
+
/** Byte mode's character count is 8 bits up to version 9; this encoder stops there. */
|
|
14
|
+
const MAX_VERSION = 9;
|
|
15
|
+
/** Error correction codewords per block, by level, then version (index 0 unused). */
|
|
16
|
+
const ECC_PER_BLOCK = [
|
|
17
|
+
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30],
|
|
18
|
+
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22],
|
|
19
|
+
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20],
|
|
20
|
+
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24],
|
|
21
|
+
];
|
|
22
|
+
/** Error correction blocks, by level, then version (index 0 unused). */
|
|
23
|
+
const BLOCKS = [
|
|
24
|
+
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2],
|
|
25
|
+
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5],
|
|
26
|
+
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8],
|
|
27
|
+
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8],
|
|
28
|
+
];
|
|
29
|
+
const bit = (value, index) => ((value >>> index) & 1) !== 0;
|
|
30
|
+
/** Modules left for data and error correction once the function patterns are drawn. */
|
|
31
|
+
function rawModules(version) {
|
|
32
|
+
let result = (16 * version + 128) * version + 64;
|
|
33
|
+
if (version >= 2) {
|
|
34
|
+
const align = Math.floor(version / 7) + 2;
|
|
35
|
+
result -= (25 * align - 10) * align - 55;
|
|
36
|
+
if (version >= 7)
|
|
37
|
+
result -= 36;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
const dataCodewords = (version, level) => Math.floor(rawModules(version) / 8) -
|
|
42
|
+
(ECC_PER_BLOCK[level]?.[version] ?? 0) * (BLOCKS[level]?.[version] ?? 0);
|
|
43
|
+
/** GF(256) multiplication, modulo x^8 + x^4 + x^3 + x^2 + 1. */
|
|
44
|
+
function multiply(x, y) {
|
|
45
|
+
let z = 0;
|
|
46
|
+
for (let i = 7; i >= 0; i--) {
|
|
47
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
48
|
+
z ^= ((y >>> i) & 1) * x;
|
|
49
|
+
}
|
|
50
|
+
return z;
|
|
51
|
+
}
|
|
52
|
+
function divisor(degree) {
|
|
53
|
+
const result = new Array(degree).fill(0);
|
|
54
|
+
result[degree - 1] = 1;
|
|
55
|
+
let root = 1;
|
|
56
|
+
for (let i = 0; i < degree; i++) {
|
|
57
|
+
for (let j = 0; j < result.length; j++) {
|
|
58
|
+
result[j] = multiply(result[j], root);
|
|
59
|
+
if (j + 1 < result.length)
|
|
60
|
+
result[j] = result[j] ^ result[j + 1];
|
|
61
|
+
}
|
|
62
|
+
root = multiply(root, 2);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function remainder(data, by) {
|
|
67
|
+
const result = by.map(() => 0);
|
|
68
|
+
for (const byte of data) {
|
|
69
|
+
const factor = byte ^ result.shift();
|
|
70
|
+
result.push(0);
|
|
71
|
+
by.forEach((coefficient, index) => {
|
|
72
|
+
result[index] = result[index] ^ multiply(coefficient, factor);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/** The data codewords with each block's error correction, interleaved as the standard places them. */
|
|
78
|
+
function interleave(data, version, level) {
|
|
79
|
+
const blocks = BLOCKS[level]?.[version];
|
|
80
|
+
const eccLength = ECC_PER_BLOCK[level]?.[version];
|
|
81
|
+
const raw = Math.floor(rawModules(version) / 8);
|
|
82
|
+
const short = blocks - (raw % blocks);
|
|
83
|
+
const shortLength = Math.floor(raw / blocks);
|
|
84
|
+
const by = divisor(eccLength);
|
|
85
|
+
const all = [];
|
|
86
|
+
for (let index = 0, at = 0; index < blocks; index++) {
|
|
87
|
+
const block = data.slice(at, at + shortLength - eccLength + (index < short ? 0 : 1));
|
|
88
|
+
at += block.length;
|
|
89
|
+
const ecc = remainder(block, by);
|
|
90
|
+
if (index < short)
|
|
91
|
+
block.push(0);
|
|
92
|
+
all.push([...block, ...ecc]);
|
|
93
|
+
}
|
|
94
|
+
const result = [];
|
|
95
|
+
for (let i = 0; i < (all[0]?.length ?? 0); i++)
|
|
96
|
+
all.forEach((block, j) => {
|
|
97
|
+
if (i !== shortLength - eccLength || j >= short)
|
|
98
|
+
result.push(block[i]);
|
|
99
|
+
});
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
class Grid {
|
|
103
|
+
version;
|
|
104
|
+
/** The level's format bits (L 1, M 0, Q 3, H 2). */
|
|
105
|
+
format;
|
|
106
|
+
size;
|
|
107
|
+
modules;
|
|
108
|
+
/** Function modules (finders, timing, alignment, format and version), which data and masks never touch. */
|
|
109
|
+
reserved;
|
|
110
|
+
constructor(version, format) {
|
|
111
|
+
this.version = version;
|
|
112
|
+
this.format = format;
|
|
113
|
+
this.size = version * 4 + 17;
|
|
114
|
+
this.modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false));
|
|
115
|
+
this.reserved = Array.from({ length: this.size }, () => new Array(this.size).fill(false));
|
|
116
|
+
}
|
|
117
|
+
set(x, y, dark) {
|
|
118
|
+
;
|
|
119
|
+
this.modules[y][x] = dark;
|
|
120
|
+
this.reserved[y][x] = true;
|
|
121
|
+
}
|
|
122
|
+
functionPatterns() {
|
|
123
|
+
for (let i = 0; i < this.size; i++) {
|
|
124
|
+
this.set(6, i, i % 2 === 0);
|
|
125
|
+
this.set(i, 6, i % 2 === 0);
|
|
126
|
+
}
|
|
127
|
+
for (const [x, y] of [
|
|
128
|
+
[3, 3],
|
|
129
|
+
[this.size - 4, 3],
|
|
130
|
+
[3, this.size - 4],
|
|
131
|
+
])
|
|
132
|
+
for (let dy = -4; dy <= 4; dy++)
|
|
133
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
134
|
+
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
|
135
|
+
const [xx, yy] = [x + dx, y + dy];
|
|
136
|
+
if (xx >= 0 && xx < this.size && yy >= 0 && yy < this.size)
|
|
137
|
+
this.set(xx, yy, distance !== 2 && distance !== 4);
|
|
138
|
+
}
|
|
139
|
+
// Alignment patterns everywhere on the grid of positions but the three finder corners.
|
|
140
|
+
const positions = this.alignments();
|
|
141
|
+
const last = positions.length - 1;
|
|
142
|
+
for (const [i, a] of positions.entries())
|
|
143
|
+
for (const [j, b] of positions.entries()) {
|
|
144
|
+
if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0))
|
|
145
|
+
continue;
|
|
146
|
+
for (let dy = -2; dy <= 2; dy++)
|
|
147
|
+
for (let dx = -2; dx <= 2; dx++)
|
|
148
|
+
this.set(a + dx, b + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
149
|
+
}
|
|
150
|
+
this.formatBits(0);
|
|
151
|
+
if (this.version >= 7) {
|
|
152
|
+
let rest = this.version;
|
|
153
|
+
for (let i = 0; i < 12; i++)
|
|
154
|
+
rest = (rest << 1) ^ ((rest >>> 11) * 0x1f25);
|
|
155
|
+
const bits = (this.version << 12) | rest;
|
|
156
|
+
for (let i = 0; i < 18; i++) {
|
|
157
|
+
const a = this.size - 11 + (i % 3);
|
|
158
|
+
const b = Math.floor(i / 3);
|
|
159
|
+
this.set(a, b, bit(bits, i));
|
|
160
|
+
this.set(b, a, bit(bits, i));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
alignments() {
|
|
165
|
+
if (this.version === 1)
|
|
166
|
+
return [];
|
|
167
|
+
const count = Math.floor(this.version / 7) + 2;
|
|
168
|
+
const step = Math.ceil((this.version * 4 + 4) / (count * 2 - 2)) * 2;
|
|
169
|
+
const result = [6];
|
|
170
|
+
for (let at = this.size - 7; result.length < count; at -= step)
|
|
171
|
+
result.splice(1, 0, at);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
formatBits(mask) {
|
|
175
|
+
const data = (this.format << 3) | mask;
|
|
176
|
+
let rest = data;
|
|
177
|
+
for (let i = 0; i < 10; i++)
|
|
178
|
+
rest = (rest << 1) ^ ((rest >>> 9) * 0x537);
|
|
179
|
+
const bits = ((data << 10) | rest) ^ 0x5412;
|
|
180
|
+
for (let i = 0; i <= 5; i++)
|
|
181
|
+
this.set(8, i, bit(bits, i));
|
|
182
|
+
this.set(8, 7, bit(bits, 6));
|
|
183
|
+
this.set(8, 8, bit(bits, 7));
|
|
184
|
+
this.set(7, 8, bit(bits, 8));
|
|
185
|
+
for (let i = 9; i < 15; i++)
|
|
186
|
+
this.set(14 - i, 8, bit(bits, i));
|
|
187
|
+
for (let i = 0; i < 8; i++)
|
|
188
|
+
this.set(this.size - 1 - i, 8, bit(bits, i));
|
|
189
|
+
for (let i = 8; i < 15; i++)
|
|
190
|
+
this.set(8, this.size - 15 + i, bit(bits, i));
|
|
191
|
+
this.set(8, this.size - 8, true);
|
|
192
|
+
}
|
|
193
|
+
codewords(data) {
|
|
194
|
+
let i = 0;
|
|
195
|
+
for (let right = this.size - 1; right >= 1; right -= 2) {
|
|
196
|
+
if (right === 6)
|
|
197
|
+
right = 5;
|
|
198
|
+
for (let vertical = 0; vertical < this.size; vertical++)
|
|
199
|
+
for (let j = 0; j < 2; j++) {
|
|
200
|
+
const x = right - j;
|
|
201
|
+
const upward = ((right + 1) & 2) === 0;
|
|
202
|
+
const y = upward ? this.size - 1 - vertical : vertical;
|
|
203
|
+
if (!this.reserved[y]?.[x] && i < data.length * 8) {
|
|
204
|
+
;
|
|
205
|
+
this.modules[y][x] = bit(data[i >>> 3], 7 - (i & 7));
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** XORs the data modules with a mask pattern; applying it twice undoes it. */
|
|
212
|
+
mask(mask) {
|
|
213
|
+
for (let y = 0; y < this.size; y++)
|
|
214
|
+
for (let x = 0; x < this.size; x++) {
|
|
215
|
+
const flip = [
|
|
216
|
+
(x + y) % 2 === 0,
|
|
217
|
+
y % 2 === 0,
|
|
218
|
+
x % 3 === 0,
|
|
219
|
+
(x + y) % 3 === 0,
|
|
220
|
+
(Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
221
|
+
((x * y) % 2) + ((x * y) % 3) === 0,
|
|
222
|
+
(((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
223
|
+
(((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
224
|
+
][mask];
|
|
225
|
+
if (flip && !this.reserved[y]?.[x])
|
|
226
|
+
this.modules[y][x] = !this.modules[y]?.[x];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** The standard's penalty for the modules as they are: runs, boxes, finder look-alikes and imbalance. */
|
|
230
|
+
penalty() {
|
|
231
|
+
let result = 0;
|
|
232
|
+
const at = (x, y) => this.modules[y]?.[x];
|
|
233
|
+
for (const across of [true, false]) {
|
|
234
|
+
for (let line = 0; line < this.size; line++) {
|
|
235
|
+
let color = false;
|
|
236
|
+
let run = 0;
|
|
237
|
+
const history = [0, 0, 0, 0, 0, 0, 0];
|
|
238
|
+
for (let along = 0; along < this.size; along++) {
|
|
239
|
+
const dark = across ? at(along, line) : at(line, along);
|
|
240
|
+
if (dark === color) {
|
|
241
|
+
run++;
|
|
242
|
+
if (run === 5)
|
|
243
|
+
result += 3;
|
|
244
|
+
else if (run > 5)
|
|
245
|
+
result++;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
this.addHistory(run, history);
|
|
249
|
+
if (!color)
|
|
250
|
+
result += this.finderLike(history) * 40;
|
|
251
|
+
color = dark;
|
|
252
|
+
run = 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (color) {
|
|
256
|
+
this.addHistory(run, history);
|
|
257
|
+
run = 0;
|
|
258
|
+
}
|
|
259
|
+
this.addHistory(run + this.size, history);
|
|
260
|
+
result += this.finderLike(history) * 40;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (let y = 0; y < this.size - 1; y++)
|
|
264
|
+
for (let x = 0; x < this.size - 1; x++) {
|
|
265
|
+
const color = at(x, y);
|
|
266
|
+
if (color === at(x + 1, y) && color === at(x, y + 1) && color === at(x + 1, y + 1))
|
|
267
|
+
result += 3;
|
|
268
|
+
}
|
|
269
|
+
const dark = this.modules.reduce((sum, row) => sum + row.filter(Boolean).length, 0);
|
|
270
|
+
const total = this.size * this.size;
|
|
271
|
+
result += (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * 10;
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
addHistory(run, history) {
|
|
275
|
+
history.pop();
|
|
276
|
+
history.unshift(history[0] === 0 ? run + this.size : run);
|
|
277
|
+
}
|
|
278
|
+
finderLike(history) {
|
|
279
|
+
const [a = 0, n = 0, c = 0, d = 0, e = 0, f = 0, g = 0] = history;
|
|
280
|
+
const core = n > 0 && c === n && d === n * 3 && e === n && f === n;
|
|
281
|
+
return (core && a >= n * 4 && g >= n ? 1 : 0) + (core && g >= n * 4 && a >= n ? 1 : 0);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* `text` as a QR code in byte mode, the smallest version that holds it at level `ecc` (M unless asked), with the mask
|
|
286
|
+
* the standard's penalty prefers (or `mask`). Null when it's longer than version 9 holds.
|
|
287
|
+
*/
|
|
288
|
+
export function qrCode(text, options = {}) {
|
|
289
|
+
const bytes = [...new TextEncoder().encode(text)];
|
|
290
|
+
const { index: level, format } = LEVELS[options.ecc ?? 'M'];
|
|
291
|
+
let version = 1;
|
|
292
|
+
// Mode (4 bits), the count (8 bits), then the bytes.
|
|
293
|
+
const used = 4 + 8 + bytes.length * 8;
|
|
294
|
+
while (version <= MAX_VERSION && used > dataCodewords(version, level) * 8)
|
|
295
|
+
version++;
|
|
296
|
+
if (version > MAX_VERSION)
|
|
297
|
+
return null;
|
|
298
|
+
const capacity = dataCodewords(version, level) * 8;
|
|
299
|
+
const bits = [];
|
|
300
|
+
const append = (value, length) => {
|
|
301
|
+
for (let i = length - 1; i >= 0; i--)
|
|
302
|
+
bits.push((value >>> i) & 1);
|
|
303
|
+
};
|
|
304
|
+
append(0b0100, 4);
|
|
305
|
+
append(bytes.length, 8);
|
|
306
|
+
for (const byte of bytes)
|
|
307
|
+
append(byte, 8);
|
|
308
|
+
append(0, Math.min(4, capacity - bits.length));
|
|
309
|
+
append(0, (8 - (bits.length % 8)) % 8);
|
|
310
|
+
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11)
|
|
311
|
+
append(pad, 8);
|
|
312
|
+
const data = new Array(bits.length / 8).fill(0);
|
|
313
|
+
bits.forEach((value, i) => {
|
|
314
|
+
data[i >>> 3] = data[i >>> 3] | (value << (7 - (i & 7)));
|
|
315
|
+
});
|
|
316
|
+
const grid = new Grid(version, format);
|
|
317
|
+
grid.functionPatterns();
|
|
318
|
+
grid.codewords(interleave(data, version, level));
|
|
319
|
+
let mask = options.mask ?? -1;
|
|
320
|
+
if (mask < 0) {
|
|
321
|
+
let lowest = Number.POSITIVE_INFINITY;
|
|
322
|
+
for (let candidate = 0; candidate < 8; candidate++) {
|
|
323
|
+
grid.mask(candidate);
|
|
324
|
+
grid.formatBits(candidate);
|
|
325
|
+
const score = grid.penalty();
|
|
326
|
+
if (score < lowest) {
|
|
327
|
+
lowest = score;
|
|
328
|
+
mask = candidate;
|
|
329
|
+
}
|
|
330
|
+
grid.mask(candidate);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
grid.mask(mask);
|
|
334
|
+
grid.formatBits(mask);
|
|
335
|
+
return { version, mask, modules: grid.modules.map((row) => [...row]) };
|
|
336
|
+
}
|
|
337
|
+
/** Dark on light whatever the terminal's colours: black text on a white background, then back to normal. */
|
|
338
|
+
const ON = '\u001b[30;47m';
|
|
339
|
+
const OFF = '\u001b[0m';
|
|
340
|
+
/** Light modules around the code, so a camera finds its edges. */
|
|
341
|
+
const QUIET = 2;
|
|
342
|
+
/**
|
|
343
|
+
* The code as terminal lines: two rows of modules a line, drawn with half blocks, dark on light, with a quiet zone.
|
|
344
|
+
* Null when the text is too long for a code, or the terminal can't be trusted with colours (NO_COLOR, TERM=dumb).
|
|
345
|
+
*/
|
|
346
|
+
export function qrLines(text, env = {}) {
|
|
347
|
+
if (env.NO_COLOR || env.TERM === 'dumb')
|
|
348
|
+
return null;
|
|
349
|
+
const code = qrCode(text);
|
|
350
|
+
if (!code)
|
|
351
|
+
return null;
|
|
352
|
+
const size = code.modules.length + QUIET * 2;
|
|
353
|
+
const dark = (x, y) => code.modules[y - QUIET]?.[x - QUIET] === true;
|
|
354
|
+
const lines = [];
|
|
355
|
+
for (let y = 0; y < size; y += 2) {
|
|
356
|
+
let line = '';
|
|
357
|
+
for (let x = 0; x < size; x++) {
|
|
358
|
+
const top = dark(x, y);
|
|
359
|
+
const bottom = y + 1 < size && dark(x, y + 1);
|
|
360
|
+
line += top ? (bottom ? '█' : '▀') : bottom ? '▄' : ' ';
|
|
361
|
+
}
|
|
362
|
+
lines.push(`${ON}${line}${OFF}`);
|
|
363
|
+
}
|
|
364
|
+
return lines;
|
|
365
|
+
}
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Claude Code on a computer with no browser (0.10.0): a devbox or server reached over SSH, a container, a Raspberry
|
|
2
|
+
// Pi. There `/mcp` → Authenticate sends the person's browser to a localhost on that computer, which it can't reach. So
|
|
3
|
+
// `npx pendingyou init` signs the computer in once with a code the person approves on their phone, as a Claude Code
|
|
4
|
+
// connection of its own ("Claude Code on devbox"; Pending You's worker/oauth/device.ts), and gives Claude Code's
|
|
5
|
+
// pendingyou MCP server a headersHelper instead of OAuth: a small sh script at a stable path,
|
|
6
|
+
// ~/.config/pendingyou/bin/pendingyou-mcp-headers, which Claude Code runs each time it connects (again on a 401 or 403;
|
|
7
|
+
// 10 seconds to answer) and which prints {"Authorization":"Bearer <token>"} from that sign-in, and nothing else. The
|
|
8
|
+
// hooks and the wake mod use the same sign-in, so one approval sets up all three.
|
|
9
|
+
//
|
|
10
|
+
// The script finds Node in this order: node on PATH; nvm's default ($NVM_DIR or ~/.nvm: alias/default, else the newest
|
|
11
|
+
// installed); then the Node init ran with, recorded in ~/.config/pendingyou/node-path, while it's still there. Claude
|
|
12
|
+
// Code may run without nvm on its PATH (a systemd service, a terminal that doesn't read ~/.profile, `ssh host claude`).
|
|
13
|
+
// It runs the private copy init installed (install.ts), never npx, and its stdout is only ever that one JSON object.
|
|
14
|
+
import { readFile, rm, rmdir, stat } from 'node:fs/promises';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { helperToken, SignInNeeded, Unavailable } from "./api.js";
|
|
17
|
+
import { cleanName, DEFAULT_ORIGIN } from "./args.js";
|
|
18
|
+
import { readCredential } from "./credentials.js";
|
|
19
|
+
import { configDir, writeWhole } from "./files.js";
|
|
20
|
+
import { VERSION } from "./version.js";
|
|
21
|
+
export const HELPER = 'pendingyou-mcp-headers';
|
|
22
|
+
/** The script Claude Code runs: the same path whatever the version, so its MCP server never needs changing. */
|
|
23
|
+
export const helperPath = (io) => join(configDir(io), 'bin', HELPER);
|
|
24
|
+
/** Where init records the Node it ran with: the script's last way to find one. */
|
|
25
|
+
export const nodePathFile = (io) => join(configDir(io), 'node-path');
|
|
26
|
+
/** How long the helper waits for a refresh before it hands over the token it has (Claude Code allows 10 s). */
|
|
27
|
+
export const HELPER_WAIT_MS = 7000;
|
|
28
|
+
/** The PATH a check runs the helper with: as bare as a systemd service's, so it proves Node is found without PATH. */
|
|
29
|
+
export const BARE_PATH = '/usr/bin:/bin';
|
|
30
|
+
/**
|
|
31
|
+
* Why this computer counts as having no browser here, or null when it doesn't: a session over SSH, or Linux with no
|
|
32
|
+
* display. `init --device` and `--browser` override it.
|
|
33
|
+
*/
|
|
34
|
+
export function headlessReason(io) {
|
|
35
|
+
if (io.env.SSH_CONNECTION || io.env.SSH_TTY)
|
|
36
|
+
return 'an SSH session';
|
|
37
|
+
if (io.platform === 'linux' && !io.env.DISPLAY && !io.env.WAYLAND_DISPLAY)
|
|
38
|
+
return 'Linux with no display';
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether init sets this computer up as one with no browser: `--device` (true) or `--browser` (false) when given;
|
|
43
|
+
* otherwise when it's set up that way already (`configured`: Claude Code's server signs in through the helper), so
|
|
44
|
+
* running init again keeps it, or when it looks like it has no browser. Never on Windows, where Claude Code runs a
|
|
45
|
+
* headersHelper with cmd, not sh: there `--device` signs the command line in by code, as before.
|
|
46
|
+
*/
|
|
47
|
+
export function isRemoteSetup(io, device, configured = false) {
|
|
48
|
+
if (io.platform === 'win32')
|
|
49
|
+
return false;
|
|
50
|
+
return device ?? (configured || headlessReason(io) !== null);
|
|
51
|
+
}
|
|
52
|
+
/** Whether a pendingyou MCP server signs in through our helper, for `origin`. */
|
|
53
|
+
export function usesHelper(server, origin) {
|
|
54
|
+
if (!server || server === 'unknown' || !server.url || !isOurHelper(server.helper))
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
return new URL(server.url).origin === origin;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The computer's name for its connection, from its hostname ("devbox.tail1234.ts.net" → "devbox"): the first label
|
|
65
|
+
* (unless it's an address), control characters out, spaces collapsed, at most 60 characters.
|
|
66
|
+
*/
|
|
67
|
+
export function machineOf(host) {
|
|
68
|
+
const name = /^\d+(\.\d+){3}$/.test(host.trim()) ? host.trim() : (host.split('.')[0] ?? '');
|
|
69
|
+
return cleanName(name);
|
|
70
|
+
}
|
|
71
|
+
/** How long Tailscale may take to say this computer's name before the hostname is used (it answers in milliseconds). */
|
|
72
|
+
export const TAILSCALE_MS = 1200;
|
|
73
|
+
/**
|
|
74
|
+
* Names nobody gave a computer: a cloud's own (AWS's ip-10-42-1-252 and ec2-54-…), or an address. One is still used when
|
|
75
|
+
* there's nothing better, and `login` says how to change it.
|
|
76
|
+
*/
|
|
77
|
+
export function isUnfriendly(name) {
|
|
78
|
+
return /^ip-\d+(-\d+){3}$/i.test(name) || /^ec2-/i.test(name) || /^\d+(\.\d+){3}$/.test(name);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Tailscale's name for this computer, when Tailscale is installed and answers within TAILSCALE_MS: its MagicDNS name's
|
|
82
|
+
* first label, as `tailscale status` shows it (it follows a rename in Tailscale's admin console), else the hostname it
|
|
83
|
+
* reports (`tailscale up --hostname`, or the computer's own). Null otherwise.
|
|
84
|
+
*/
|
|
85
|
+
export async function tailscaleName(io) {
|
|
86
|
+
const result = await io.run('tailscale', ['status', '--json'], TAILSCALE_MS);
|
|
87
|
+
if (result.code !== 0)
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
const self = JSON.parse(result.stdout)
|
|
91
|
+
.Self;
|
|
92
|
+
const dns = typeof self?.DNSName === 'string' ? (self.DNSName.split('.')[0] ?? '') : '';
|
|
93
|
+
const host = typeof self?.HostName === 'string' ? machineOf(self.HostName) : '';
|
|
94
|
+
return cleanName(dns) || host || null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The name a computer signs in with: `--name` when given; else Tailscale's name for it; else its hostname's first
|
|
102
|
+
* label. The phone's approval page and the Assistants page show it ("Claude Code on devbox").
|
|
103
|
+
*/
|
|
104
|
+
export async function computerName(io, given) {
|
|
105
|
+
if (given)
|
|
106
|
+
return { name: given, from: 'flag', unfriendly: false };
|
|
107
|
+
const tailscale = await tailscaleName(io);
|
|
108
|
+
if (tailscale)
|
|
109
|
+
return { name: tailscale, from: 'tailscale', unfriendly: isUnfriendly(tailscale) };
|
|
110
|
+
const host = machineOf(io.host);
|
|
111
|
+
return { name: host, from: 'hostname', unfriendly: isUnfriendly(host) };
|
|
112
|
+
}
|
|
113
|
+
/** One word for sh: as it is when it's plain, otherwise in single quotes. */
|
|
114
|
+
const shWord = (text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", `'\\''`)}'`;
|
|
115
|
+
/** What Claude Code runs for the MCP server's headers: the script, and `--origin` when it isn't the default. */
|
|
116
|
+
export function helperCommand(io, origin) {
|
|
117
|
+
return `${shWord(helperPath(io))}${origin === DEFAULT_ORIGIN ? '' : ` --origin ${shWord(origin)}`}`;
|
|
118
|
+
}
|
|
119
|
+
/** Whether a headersHelper command runs our script, from any folder. */
|
|
120
|
+
export const isOurHelper = (command) => typeof command === 'string' && new RegExp(`(^|[\\\\/'"\\s])${HELPER}(['"]?)(\\s|$)`).test(command);
|
|
121
|
+
/** The script, for this version's private copy (`cli`) and config folder. Plain POSIX sh: dash runs it on Ubuntu. */
|
|
122
|
+
export function helperScript(cli, config) {
|
|
123
|
+
return `#!/bin/sh
|
|
124
|
+
# Pending You's sign-in for Claude Code's pendingyou MCP server on this computer, which has no browser. Claude Code
|
|
125
|
+
# runs it each time it connects (its headersHelper), and it prints {"Authorization":"Bearer <token>"} from this
|
|
126
|
+
# computer's own sign-in, and nothing else. Written by npx pendingyou init (pendingyou ${VERSION}); run that again to
|
|
127
|
+
# repair it. It runs pendingyou's own copy, never npx, with the first Node it finds: on PATH, nvm's default, or the
|
|
128
|
+
# one init ran with.
|
|
129
|
+
cli=${shWord(cli)}
|
|
130
|
+
PENDINGYOU_CONFIG_DIR=${shWord(config)}
|
|
131
|
+
export PENDINGYOU_CONFIG_DIR
|
|
132
|
+
|
|
133
|
+
# The newest Node nvm installed whose version is $1 or starts with "$1." (any version when $1 is empty).
|
|
134
|
+
newest() {
|
|
135
|
+
for dir in "$nvm/versions/node"/v*; do
|
|
136
|
+
[ -x "$dir/bin/node" ] || continue
|
|
137
|
+
version=\${dir##*/v}
|
|
138
|
+
if [ -n "$1" ]; then
|
|
139
|
+
case $version in "$1" | "$1".*) ;; *) continue ;; esac
|
|
140
|
+
fi
|
|
141
|
+
printf '%s\\n' "$version"
|
|
142
|
+
done | sort -t . -k 1,1n -k 2,2n -k 3,3n | tail -n 1
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
node=
|
|
146
|
+
from=
|
|
147
|
+
if found=$(command -v node 2>/dev/null) && [ -n "$found" ]; then
|
|
148
|
+
node=$found
|
|
149
|
+
from=PATH
|
|
150
|
+
else
|
|
151
|
+
nvm=\${NVM_DIR:-$HOME/.nvm}
|
|
152
|
+
want=
|
|
153
|
+
[ -r "$nvm/alias/default" ] && want=$(sed -n 1p "$nvm/alias/default" | tr -d '[:space:]')
|
|
154
|
+
# An alias can name another (lts/* → lts/jod → v22.21.0).
|
|
155
|
+
hops=0
|
|
156
|
+
while [ -n "$want" ] && [ "$hops" -lt 3 ] && [ -r "$nvm/alias/$want" ]; do
|
|
157
|
+
want=$(sed -n 1p "$nvm/alias/$want" | tr -d '[:space:]')
|
|
158
|
+
hops=$((hops + 1))
|
|
159
|
+
done
|
|
160
|
+
case $want in
|
|
161
|
+
v[0-9]*) version=$(newest "\${want#v}") ;;
|
|
162
|
+
[0-9]*) version=$(newest "$want") ;;
|
|
163
|
+
*) version= ;;
|
|
164
|
+
esac
|
|
165
|
+
[ -n "$version" ] || version=$(newest '')
|
|
166
|
+
if [ -n "$version" ]; then
|
|
167
|
+
node=$nvm/versions/node/v$version/bin/node
|
|
168
|
+
from=nvm
|
|
169
|
+
else
|
|
170
|
+
recorded=
|
|
171
|
+
[ -r "$PENDINGYOU_CONFIG_DIR/node-path" ] && recorded=$(sed -n 1p "$PENDINGYOU_CONFIG_DIR/node-path")
|
|
172
|
+
if [ -n "$recorded" ] && [ -x "$recorded" ]; then
|
|
173
|
+
node=$recorded
|
|
174
|
+
from=recorded
|
|
175
|
+
fi
|
|
176
|
+
fi
|
|
177
|
+
fi
|
|
178
|
+
if [ -z "$node" ]; then
|
|
179
|
+
echo "pendingyou: no Node.js found (not on PATH, not in nvm, and not where init found it). Install Node, then run: npx pendingyou init" >&2
|
|
180
|
+
exit 1
|
|
181
|
+
fi
|
|
182
|
+
if [ ! -r "$cli" ]; then
|
|
183
|
+
echo "pendingyou: its copy of pendingyou is gone ($cli). Run: npx pendingyou init" >&2
|
|
184
|
+
exit 1
|
|
185
|
+
fi
|
|
186
|
+
PENDINGYOU_NODE_FROM=$from
|
|
187
|
+
export PENDINGYOU_NODE_FROM
|
|
188
|
+
exec "$node" "$cli" mcp-headers "$@"
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Writes the helper for this version's private copy, and records the Node init runs with as its last resort. Throws
|
|
193
|
+
* when it can't write them.
|
|
194
|
+
*/
|
|
195
|
+
export async function writeHelper(io, installed) {
|
|
196
|
+
const path = helperPath(io);
|
|
197
|
+
const text = helperScript(installed.script, configDir(io));
|
|
198
|
+
const before = await readFile(path, 'utf8').catch(() => null);
|
|
199
|
+
const mode = await stat(path).then((info) => info.mode & 0o777, () => 0);
|
|
200
|
+
const recorded = await readFile(nodePathFile(io), 'utf8').catch(() => null);
|
|
201
|
+
if (recorded !== `${io.execPath}\n`)
|
|
202
|
+
await writeWhole(nodePathFile(io), `${io.execPath}\n`, { mode: 0o644 });
|
|
203
|
+
if (before === text && mode === 0o755)
|
|
204
|
+
return { path, changed: false };
|
|
205
|
+
await writeWhole(path, text, { mode: 0o755 });
|
|
206
|
+
return { path, changed: true };
|
|
207
|
+
}
|
|
208
|
+
/** Removes the helper and the Node it recorded (uninstall). True when there was a helper. */
|
|
209
|
+
export async function removeHelper(io) {
|
|
210
|
+
const path = helperPath(io);
|
|
211
|
+
const there = await stat(path).then(() => true, () => false);
|
|
212
|
+
await rm(path, { force: true });
|
|
213
|
+
await rm(nodePathFile(io), { force: true });
|
|
214
|
+
await rmdir(dirname(path)).catch(() => { });
|
|
215
|
+
return there;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Runs the helper as Claude Code would at its barest (PATH /usr/bin:/bin, as a systemd service has it), in its check
|
|
219
|
+
* mode: it finds Node and runs pendingyou's copy, which says which Node it is, and nothing is signed in or printed.
|
|
220
|
+
*/
|
|
221
|
+
export async function checkHelper(io, origin) {
|
|
222
|
+
const path = helperPath(io);
|
|
223
|
+
const exists = await stat(path).then((info) => info.isFile(), () => false);
|
|
224
|
+
if (!exists)
|
|
225
|
+
return { ok: false, why: `${path} is gone` };
|
|
226
|
+
// Exactly as Claude Code runs it (sh -c with the command), with only HOME and a bare PATH.
|
|
227
|
+
const result = await io.run('/bin/sh', ['-c', `${helperCommand(io, origin)} --check`], 15_000, {
|
|
228
|
+
env: { HOME: io.home, PATH: BARE_PATH },
|
|
229
|
+
});
|
|
230
|
+
const line = result.stdout.trim().split('\n').at(-1) ?? '';
|
|
231
|
+
const found = /^node (.+) \(([\w-]+)\), pendingyou (\S+)$/.exec(line);
|
|
232
|
+
if (result.code !== 0 || !found)
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
why: result.stderr
|
|
236
|
+
.trim()
|
|
237
|
+
.split('\n')
|
|
238
|
+
.at(-1)
|
|
239
|
+
?.replace(/^pendingyou: /, '')
|
|
240
|
+
.replace(/\s*Run: npx pendingyou init\.?$/, '') || `it didn’t run (exit ${result.code})`,
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
node: found[1],
|
|
245
|
+
from: found[2],
|
|
246
|
+
version: found[3],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** How the helper found Node, in words. */
|
|
250
|
+
export const foundBy = (from) => from === 'nvm'
|
|
251
|
+
? 'nvm’s default'
|
|
252
|
+
: from === 'recorded'
|
|
253
|
+
? 'the Node init ran with'
|
|
254
|
+
: from === 'PATH'
|
|
255
|
+
? 'PATH'
|
|
256
|
+
: from;
|
|
257
|
+
const originOf = (url) => {
|
|
258
|
+
try {
|
|
259
|
+
return new URL(url).origin;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* `pendingyou mcp-headers`: what the helper runs. Prints {"Authorization":"Bearer <token>"} to stdout and nothing else
|
|
267
|
+
* (Claude Code reads stdout as the headers), from this computer's Claude Code connection for `origin`, refreshed when
|
|
268
|
+
* it's due; every problem goes to stderr in one line, with a non-zero exit. It never hands a token to a server other
|
|
269
|
+
* than `origin`'s (CLAUDE_CODE_MCP_SERVER_URL, which Claude Code sets). `--check` says which Node runs it instead.
|
|
270
|
+
*/
|
|
271
|
+
export async function mcpHeaders(io, options) {
|
|
272
|
+
const login = options.origin === DEFAULT_ORIGIN
|
|
273
|
+
? 'npx pendingyou login'
|
|
274
|
+
: `npx pendingyou login --origin ${options.origin}`;
|
|
275
|
+
if (options.check) {
|
|
276
|
+
io.out(`node ${io.execPath} (${io.env.PENDINGYOU_NODE_FROM || 'PATH'}), pendingyou ${VERSION}\n`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
const server = io.env.CLAUDE_CODE_MCP_SERVER_URL;
|
|
280
|
+
if (server && originOf(server) !== options.origin) {
|
|
281
|
+
io.err(`pendingyou: this helper signs in to ${options.origin}, but Claude Code’s server is ${server}, so it gave no token. Run: npx pendingyou init --origin ${originOf(server) ?? server}\n`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
const stored = await readCredential(io, options.origin);
|
|
285
|
+
if (!stored) {
|
|
286
|
+
io.err(`pendingyou: this computer isn’t signed in to Pending You (${options.origin}). Run: ${login}\n`);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
if (stored.kind !== 'connection') {
|
|
290
|
+
io.err(`pendingyou: this computer’s sign-in only hears answers, so Claude Code can’t use it. Run: ${login}\n`);
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const token = await helperToken(io, options.origin, HELPER_WAIT_MS);
|
|
295
|
+
io.out(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
io.err(error instanceof SignInNeeded
|
|
300
|
+
? `pendingyou: this computer’s sign-in has ended. Run: ${login}\n`
|
|
301
|
+
: error instanceof Unavailable
|
|
302
|
+
? `pendingyou: ${error.message} Try again in a minute.\n`
|
|
303
|
+
: 'pendingyou: something went wrong.\n');
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
}
|