lattris 0.1.4 → 0.2.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.
- package/README.md +2 -2
- package/dist/build.json +1 -1
- package/dist/datasets/capture.js +118 -0
- package/dist/datasets/page.js +64 -0
- package/dist/datasets/pieces.js +78 -0
- package/dist/dispatch-form.js +202 -0
- package/dist/dispatch-words.js +133 -0
- package/dist/dispatch.js +67 -0
- package/dist/entry/session.js +219 -0
- package/dist/entry/set.js +130 -0
- package/dist/entry/setup-boot.js +151 -0
- package/dist/entry/setup.js +145 -0
- package/dist/entry/use.js +127 -0
- package/dist/help.js +65 -0
- package/dist/main.js +143 -6887
- package/dist/memory/access.js +88 -0
- package/dist/memory/capture-common.js +171 -0
- package/dist/memory/capture-help.js +195 -0
- package/dist/memory/capture-note.js +200 -0
- package/dist/memory/capture-session.js +168 -0
- package/dist/memory/delete.js +88 -0
- package/dist/memory/erase.js +67 -0
- package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
- package/dist/memory/get-help.js +72 -0
- package/dist/memory/get.js +246 -0
- package/dist/memory/history.js +134 -0
- package/dist/memory/recording-update.js +128 -0
- package/dist/memory/recording.js +258 -0
- package/dist/memory/resend.js +82 -0
- package/dist/memory/restore.js +73 -0
- package/dist/memory/save-session-chain.js +59 -0
- package/dist/memory/transcript.js +88 -0
- package/dist/memory/update-help.js +126 -0
- package/dist/memory/update.js +220 -0
- package/dist/memory/views.js +146 -0
- package/dist/nouns/consent.js +29 -0
- package/dist/nouns/contact.js +203 -0
- package/dist/nouns/datasets.js +109 -0
- package/dist/nouns/edges.js +106 -0
- package/dist/nouns/form.js +2 -0
- package/dist/nouns/index.js +48 -0
- package/dist/nouns/invite.js +98 -0
- package/dist/nouns/questions.js +116 -0
- package/dist/nouns/share.js +266 -0
- package/dist/nouns/words.js +81 -0
- package/dist/registry/datasets.js +15 -0
- package/dist/registry/entry.js +51 -0
- package/dist/registry/index.js +17 -0
- package/dist/registry/memory.js +116 -0
- package/dist/render/datasets.js +77 -0
- package/dist/render/door.js +39 -0
- package/dist/render/output.js +19 -0
- package/dist/render/prompt.js +4 -0
- package/dist/render/rows.js +82 -0
- package/dist/render/text.js +21 -0
- package/dist/request/access-token.js +67 -0
- package/dist/request/api-fetch.js +76 -0
- package/dist/request/callback.js +86 -0
- package/dist/request/door.js +74 -0
- package/dist/request/flags.js +38 -0
- package/dist/request/io.js +2 -0
- package/dist/request/paste-and-device.js +99 -0
- package/dist/request/stdin.js +22 -0
- package/dist/request/token.js +66 -0
- package/dist/request/upload.js +6 -0
- package/dist/request/version-check.js +79 -0
- package/dist/store/chains.js +25 -0
- package/dist/store/drafts.js +48 -0
- package/dist/store/environment.js +57 -0
- package/dist/store/exit.js +13 -0
- package/dist/store/io.js +2 -0
- package/dist/store/json-file.js +9 -0
- package/dist/store/session-home.js +189 -0
- package/dist/store/session-lock.js +169 -0
- package/dist/store/session.js +70 -0
- package/dist/store/write-identity.js +153 -0
- package/dist/version.js +16 -0
- package/package.json +3 -4
- package/dist/save-session-chain.js +0 -136
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { sanitizeForTerminal } from "./text.js";
|
|
2
|
+
const LABEL = 13;
|
|
3
|
+
const valueText = (value, max = 200) => typeof value === 'string'
|
|
4
|
+
? sanitizeForTerminal(value, max)
|
|
5
|
+
: sanitizeForTerminal(JSON.stringify(value) ?? 'null', max);
|
|
6
|
+
const wordsText = (words) => words.length === 0 ? '(none)' : words.map((w) => sanitizeForTerminal(w)).join(', ');
|
|
7
|
+
const widest = (cells) => Math.max(0, ...cells.map((c) => c.length));
|
|
8
|
+
const table = (rows) => {
|
|
9
|
+
const widths = rows[0]?.map((_, i) => widest(rows.map((r) => r[i] ?? ''))) ?? [];
|
|
10
|
+
return rows
|
|
11
|
+
.map((r) => r
|
|
12
|
+
.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i])))
|
|
13
|
+
.join(' ')
|
|
14
|
+
.trimEnd())
|
|
15
|
+
.join('\n');
|
|
16
|
+
};
|
|
17
|
+
export const formatCatalogue = (entries) => entries.length === 0
|
|
18
|
+
? 'No datasets.'
|
|
19
|
+
: table(entries.map((e) => [
|
|
20
|
+
sanitizeForTerminal(e.name),
|
|
21
|
+
sanitizeForTerminal(e.storage),
|
|
22
|
+
e.holds ? 'holds rows' : 'no rows',
|
|
23
|
+
valueText(e.earliest ?? '-'),
|
|
24
|
+
valueText(e.latest ?? '-')
|
|
25
|
+
]));
|
|
26
|
+
const boundText = (value) => Array.isArray(value) ? value.map((v) => valueText(v)).join(', ') : valueText(value);
|
|
27
|
+
const fieldCells = (f) => {
|
|
28
|
+
const { name, type, required, nullable, ...bounds } = f;
|
|
29
|
+
const rest = [
|
|
30
|
+
required ? 'required' : 'optional',
|
|
31
|
+
...(nullable ? ['nullable'] : []),
|
|
32
|
+
...Object.entries(bounds).map(([k, v]) => `${sanitizeForTerminal(k)} ${boundText(v)}`)
|
|
33
|
+
];
|
|
34
|
+
return [` ${sanitizeForTerminal(name)}`, sanitizeForTerminal(type), rest.join(' ')];
|
|
35
|
+
};
|
|
36
|
+
export const formatDescription = (d) => {
|
|
37
|
+
const lines = [
|
|
38
|
+
`${'dataset'.padEnd(LABEL)}${sanitizeForTerminal(d.name)} (${sanitizeForTerminal(d.storage)})`,
|
|
39
|
+
`${'identity'.padEnd(LABEL)}${wordsText(d.identity)}`,
|
|
40
|
+
`${'filters'.padEnd(LABEL)}required ${wordsText(d.filters.required)}`,
|
|
41
|
+
`${''.padEnd(LABEL)}optional ${wordsText(d.filters.optional)}`
|
|
42
|
+
];
|
|
43
|
+
if (d.page) {
|
|
44
|
+
const limits = d.limits ? `, ${d.limits.default} rows a page, at most ${d.limits.maximum}` : '';
|
|
45
|
+
lines.push(`${'page'.padEnd(LABEL)}by ${valueText(d.page.by)} on ${valueText(d.page.field)}${limits}`);
|
|
46
|
+
}
|
|
47
|
+
lines.push('', 'fields', table(d.fields.map(fieldCells)));
|
|
48
|
+
if (d.example) {
|
|
49
|
+
lines.push('', 'example line', ` ${valueText(JSON.stringify(d.example), Infinity)}`);
|
|
50
|
+
}
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
};
|
|
53
|
+
export const formatRow = (row) => {
|
|
54
|
+
const width = widest(Object.keys(row)) + 2;
|
|
55
|
+
return Object.entries(row)
|
|
56
|
+
.map(([k, v]) => `${sanitizeForTerminal(k).padEnd(width)}${valueText(v, Infinity)}`)
|
|
57
|
+
.join('\n');
|
|
58
|
+
};
|
|
59
|
+
const formatRowLine = (row) => [
|
|
60
|
+
valueText(row.id),
|
|
61
|
+
...Object.entries(row)
|
|
62
|
+
.filter(([k, v]) => k !== 'id' && k !== 'ownerId' && v !== null)
|
|
63
|
+
.map(([k, v]) => `${sanitizeForTerminal(k)}=${valueText(v)}`)
|
|
64
|
+
].join(' ');
|
|
65
|
+
export const formatRowPage = (page) => page.items.length === 0 ? 'No rows.' : page.items.map(formatRowLine).join('\n');
|
|
66
|
+
export const formatCaptured = (name, c) => {
|
|
67
|
+
const lines = [
|
|
68
|
+
`Sent ${c.sent} ${c.sent === 1 ? 'line' : 'lines'} to ${sanitizeForTerminal(name)}: ` +
|
|
69
|
+
`${c.added} added, ${c.replaced} replaced, ${c.refused} refused.`,
|
|
70
|
+
...c.refusals.map((r) => ` ${valueText(r.message ?? '', Infinity)}`)
|
|
71
|
+
];
|
|
72
|
+
if (c.refused > c.refusals.length) {
|
|
73
|
+
lines.push(` ${c.refused - c.refusals.length} more refused, counted and not named.`);
|
|
74
|
+
}
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
};
|
|
77
|
+
//# sourceMappingURL=datasets.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { renderRunHint, sanitizeForTerminal } from "./text.js";
|
|
2
|
+
const EDGE_REFUSAL_EXCERPT_BYTES = 200;
|
|
3
|
+
export const truncateToUtf8Bytes = (s, max) => {
|
|
4
|
+
let bytes = 0;
|
|
5
|
+
let out = '';
|
|
6
|
+
for (const ch of s) {
|
|
7
|
+
const n = Buffer.byteLength(ch, 'utf8');
|
|
8
|
+
if (bytes + n > max)
|
|
9
|
+
break;
|
|
10
|
+
bytes += n;
|
|
11
|
+
out += ch;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
};
|
|
15
|
+
export const doorText = (commands, message, next, messageLimit = 200) => {
|
|
16
|
+
let text = `${sanitizeForTerminal(message, messageLimit)}\n`;
|
|
17
|
+
if (next?.action) {
|
|
18
|
+
const argv = next.action.trim().split(/\s+/);
|
|
19
|
+
text += commands.has(argv[0])
|
|
20
|
+
? `Run: ${renderRunHint(argv)}\n`
|
|
21
|
+
: `The door suggests: ${sanitizeForTerminal(next.action)}\n`;
|
|
22
|
+
}
|
|
23
|
+
return text;
|
|
24
|
+
};
|
|
25
|
+
export const edgeText = (excerpt) => {
|
|
26
|
+
const shown = truncateToUtf8Bytes(sanitizeForTerminal(excerpt.replace(/\r/g, ''), Infinity), EDGE_REFUSAL_EXCERPT_BYTES);
|
|
27
|
+
return ('Refused at the edge before reaching Lattris (403). The words or the request shape ' +
|
|
28
|
+
"tripped the gateway's screen; nothing was written." +
|
|
29
|
+
(shown.length > 0 ? `\n${shown}` : ''));
|
|
30
|
+
};
|
|
31
|
+
export const answerText = (commands, answer) => {
|
|
32
|
+
if (answer.kind === 'door') {
|
|
33
|
+
return doorText(commands, answer.message, answer.next, answer.whole ? Infinity : 200);
|
|
34
|
+
}
|
|
35
|
+
if (answer.kind === 'edge')
|
|
36
|
+
return `${edgeText(answer.excerpt)}\n`;
|
|
37
|
+
return `The server answered with a web page, not the API — check the environment’s \`${answer.address}\` URL.\n`;
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=door.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { answerText } from "./door.js";
|
|
2
|
+
export const emit = (term, data, rawText, renderHuman) => {
|
|
3
|
+
if (term.stdout.isTTY) {
|
|
4
|
+
const human = renderHuman(data);
|
|
5
|
+
if (human.length > 0)
|
|
6
|
+
term.stdout.write(`${human}\n`);
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
term.stdout.write(rawText.endsWith('\n') ? rawText : `${rawText}\n`);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
export const renderRefusal = (term, refused) => {
|
|
13
|
+
if (refused.answer)
|
|
14
|
+
term.stderr.write(answerText(term.commands, refused.answer));
|
|
15
|
+
if (refused.message.length > 0)
|
|
16
|
+
term.stderr.write(refused.message);
|
|
17
|
+
term.exit(refused.exit);
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=output.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const readYesNo = (answer) => answer === null ? 'interrupted' : /^y(es)?$/i.test(answer.trim()) ? 'yes' : 'no';
|
|
2
|
+
export const confirmAction = async (term, prompt) => readYesNo(await term.stdin.ask(prompt));
|
|
3
|
+
export const promptLine = async (term, question) => (await term.stdin.ask(question))?.trim() ?? null;
|
|
4
|
+
//# sourceMappingURL=prompt.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { renderRunHint, sanitizeForTerminal } from "./text.js";
|
|
2
|
+
import { isUuidKey } from "../store/write-identity.js";
|
|
3
|
+
export const formatContact = (c) => `${sanitizeForTerminal(c.displayName)} · ${c.type} · ${c.id}` +
|
|
4
|
+
`${c.claimedPersonId ? ' · linked' : ''}` +
|
|
5
|
+
`${c.email ? ` · email ${sanitizeForTerminal(c.email)}` : ''}` +
|
|
6
|
+
`${c.phone ? ` · phone ${sanitizeForTerminal(c.phone)}` : ''}` +
|
|
7
|
+
`${c.rootBlockId ? ` · root ${c.rootBlockId}` : ''}`;
|
|
8
|
+
export const resolveContact = (contacts, selector) => {
|
|
9
|
+
if (isUuidKey(selector)) {
|
|
10
|
+
const contact = contacts.find((c) => c.id.toLowerCase() === selector.toLowerCase());
|
|
11
|
+
return contact ? { kind: 'found', contact } : { kind: 'missing-id' };
|
|
12
|
+
}
|
|
13
|
+
const folded = selector.toLowerCase();
|
|
14
|
+
const matches = contacts.filter((c) => c.displayName.toLowerCase() === folded);
|
|
15
|
+
if (matches.length === 1)
|
|
16
|
+
return { kind: 'found', contact: matches[0] };
|
|
17
|
+
if (matches.length > 1)
|
|
18
|
+
return { kind: 'ambiguous', matches };
|
|
19
|
+
return { kind: 'missing-name', displayName: selector };
|
|
20
|
+
};
|
|
21
|
+
export const isGroupWord = (word) => word.trim().toLowerCase().startsWith('group:');
|
|
22
|
+
export const groupsNotBuilt = (word) => `${sanitizeForTerminal(word)} names a group, and groups are not built yet. A contact called that is reached by its id.\n`;
|
|
23
|
+
export const audienceOf = (r) => r.kind === 'public'
|
|
24
|
+
? 'public'
|
|
25
|
+
: r.kind === 'all_contacts'
|
|
26
|
+
? 'all contacts'
|
|
27
|
+
: r.kind === 'person'
|
|
28
|
+
? `${sanitizeForTerminal(r.contactName ?? r.personId ?? '')} (website)`
|
|
29
|
+
: sanitizeForTerminal(r.contactName ?? r.contactId ?? '');
|
|
30
|
+
export const formatAccessRow = (r) => `${r.effect} ${audienceOf(r)}`;
|
|
31
|
+
export const waitsForNameSentence = (name) => `${sanitizeForTerminal(name)} is linked to an account, but the website share waits until you set your name (${renderRunHint(['set', 'name'])}).`;
|
|
32
|
+
export const waitsForNameLines = (rows) => (rows ?? [])
|
|
33
|
+
.filter((r) => r.unpairedBecause === 'owner-has-no-name' && r.contactName)
|
|
34
|
+
.map((r) => waitsForNameSentence(r.contactName ?? ''));
|
|
35
|
+
export const formatAccess = (rows) => rows.length === 0 ? '(nothing set)' : rows.map(formatAccessRow).join('; ');
|
|
36
|
+
export const describeForContact = (c, asked) => c === null
|
|
37
|
+
? `no contact holds ${sanitizeForTerminal(asked ?? 'that number')}; a stranger sees public memories only`
|
|
38
|
+
: `${sanitizeForTerminal(c.displayName)} (${c.type}${c.phone ? `, ${sanitizeForTerminal(c.phone)}` : ''})`;
|
|
39
|
+
export const formatQuestion = (q) => {
|
|
40
|
+
const lines = [`id ${q.id}`, `createdAt ${q.createdAt}`];
|
|
41
|
+
if (q.kind === 'share') {
|
|
42
|
+
lines.push(`shared by @${sanitizeForTerminal(q.sharerHandle ?? '(unknown)')}`, `memory ${q.aboutBlockId ?? '(missing)'}`, `seen ${q.seenAt ?? '(unseen)'}`);
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
45
|
+
if (q.aboutBlockId)
|
|
46
|
+
lines.push(`about ${q.aboutBlockId}`);
|
|
47
|
+
lines.push(q.closedAs === null
|
|
48
|
+
? 'closed as (open)'
|
|
49
|
+
: `closed as ${sanitizeForTerminal(q.closedAs)}${q.closeBlockId ? ` → ${q.closeBlockId}` : ''}${q.closedAt ? ` ${q.closedAt}` : ''}`);
|
|
50
|
+
if (q.closeNote)
|
|
51
|
+
lines.push(`note ${sanitizeForTerminal(q.closeNote, Infinity)}`);
|
|
52
|
+
lines.push('', sanitizeForTerminal(q.body ?? '', Infinity));
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
};
|
|
55
|
+
export const formatQuestionList = (qs) => qs.map((q) => formatQuestion(q)).join('\n\n');
|
|
56
|
+
export const formatAsked = (q) => {
|
|
57
|
+
const lines = [`Sent question ${q.id}`];
|
|
58
|
+
if (q.aboutBlockId)
|
|
59
|
+
lines.push(` about ${q.aboutBlockId}`);
|
|
60
|
+
lines.push(` createdAt ${q.createdAt}`);
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
};
|
|
63
|
+
export const formatClosed = (q) => `Closed question ${q.id} (${sanitizeForTerminal(q.closedAs ?? '')})${q.closeBlockId ? ` → ${q.closeBlockId}` : ''}`;
|
|
64
|
+
export const formatEdgeReceipt = (heading, e) => [
|
|
65
|
+
`${heading} (verb: ${sanitizeForTerminal(e.verb)})`,
|
|
66
|
+
` from ${e.fromBlockId} (${e.fromType})`,
|
|
67
|
+
` to ${e.toBlockId} (${e.toType})`,
|
|
68
|
+
` reason ${sanitizeForTerminal(e.reason, Infinity)}`
|
|
69
|
+
].join('\n');
|
|
70
|
+
export const formatAmendedEdge = (e) => formatEdgeReceipt('Updated edge reason.', e);
|
|
71
|
+
export const formatAudiences = (audiences) => audiences.length === 0 ? '(private)' : audiences.map((a) => a.kind).join(', ');
|
|
72
|
+
export const ambiguousText = (body) => {
|
|
73
|
+
if (body.code !== 'memory:ambiguous-id')
|
|
74
|
+
return '';
|
|
75
|
+
const matches = body.detail?.matches;
|
|
76
|
+
if (!Array.isArray(matches))
|
|
77
|
+
return '';
|
|
78
|
+
return matches
|
|
79
|
+
.map((m) => ` ${m.id} ${sanitizeForTerminal(m.title ?? '(untitled)')}\n`)
|
|
80
|
+
.join('');
|
|
81
|
+
};
|
|
82
|
+
//# sourceMappingURL=rows.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
export const base64url = (b) => b.toString('base64url');
|
|
3
|
+
export const makePkce = () => {
|
|
4
|
+
const verifier = base64url(randomBytes(32));
|
|
5
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
6
|
+
return { verifier, challenge };
|
|
7
|
+
};
|
|
8
|
+
/* eslint-disable no-control-regex */
|
|
9
|
+
const ESCAPE_SEQUENCES = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*\x1b\\|\x1b\[[0-9:;<=>?]*[ -/]*[@-~]|\x1b[@-Z\\^_]/g;
|
|
10
|
+
const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u0080-\u009f]/g;
|
|
11
|
+
/* eslint-enable no-control-regex */
|
|
12
|
+
const BIDI_FORMAT_CHARS = /[\u202a-\u202e\u2066-\u2069]/g;
|
|
13
|
+
export const sanitizeForTerminal = (s, maxLen = 200) => s
|
|
14
|
+
.replace(ESCAPE_SEQUENCES, '')
|
|
15
|
+
.replace(CONTROL_CHARS, '')
|
|
16
|
+
.replace(BIDI_FORMAT_CHARS, '')
|
|
17
|
+
.slice(0, maxLen);
|
|
18
|
+
export const shellQuote = (token) => `'${sanitizeForTerminal(token, Infinity).replace(/'/g, `'\\''`)}'`;
|
|
19
|
+
const SAFE_HINT_TOKEN = /^[A-Za-z0-9@._:/-]+$/;
|
|
20
|
+
export const renderRunHint = (argv) => ['lattris', ...argv.map((t) => (SAFE_HINT_TOKEN.test(t) ? t : shellQuote(t)))].join(' ');
|
|
21
|
+
//# sourceMappingURL=text.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { EXIT, refusal } from "../store/exit.js";
|
|
2
|
+
import { RENEWAL_TIMEOUT_MS, SessionLockHeld, WAIT_AT_MOST_MS, withSessionLock } from "../store/session-lock.js";
|
|
3
|
+
import { loadSession, notLoggedInWords, saveSession } from "../store/session.js";
|
|
4
|
+
import { postToken, refreshBody, toSession } from "./token.js";
|
|
5
|
+
const SKEW_SECONDS = 30;
|
|
6
|
+
const stillValid = (session, now) => session.expiresAt > Math.floor(now / 1000) + SKEW_SECONDS;
|
|
7
|
+
const renewFrom = async (io, cfg, session) => {
|
|
8
|
+
if (!session)
|
|
9
|
+
throw new Error('not-logged-in');
|
|
10
|
+
if (stillValid(session, io.store.now()))
|
|
11
|
+
return session.accessToken;
|
|
12
|
+
if (!session.refreshToken)
|
|
13
|
+
throw new Error('not-logged-in');
|
|
14
|
+
const token = await postToken(io.network, cfg, refreshBody(cfg, session.refreshToken), io.store.timeout(RENEWAL_TIMEOUT_MS));
|
|
15
|
+
const refreshed = toSession(token, io.store.now());
|
|
16
|
+
refreshed.refreshToken = refreshed.refreshToken ?? session.refreshToken;
|
|
17
|
+
refreshed.personId = session.personId;
|
|
18
|
+
refreshed.handle = session.handle;
|
|
19
|
+
saveSession(io.store, refreshed);
|
|
20
|
+
return refreshed.accessToken;
|
|
21
|
+
};
|
|
22
|
+
export const validAccessToken = async (io, cfg) => {
|
|
23
|
+
const session = loadSession(io.store);
|
|
24
|
+
if (!session)
|
|
25
|
+
throw new Error('not-logged-in');
|
|
26
|
+
if (stillValid(session, io.store.now()))
|
|
27
|
+
return session.accessToken;
|
|
28
|
+
if (!session.refreshToken)
|
|
29
|
+
throw new Error('not-logged-in');
|
|
30
|
+
try {
|
|
31
|
+
return await withSessionLock(io.store, () => renewFrom(io, cfg, loadSession(io.store)), () => io.stderr.write('Waiting for another lattris command to finish renewing the sign-in…\n'));
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
if (e instanceof SessionLockHeld)
|
|
35
|
+
throw e;
|
|
36
|
+
throw new Error('not-logged-in');
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
export const optionalAccessToken = async (io, cfg) => {
|
|
40
|
+
try {
|
|
41
|
+
return await io.token(cfg);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const notLoggedIn = (io) => ({
|
|
48
|
+
message: '',
|
|
49
|
+
exit: EXIT.NOAUTH,
|
|
50
|
+
answer: {
|
|
51
|
+
kind: 'door',
|
|
52
|
+
message: notLoggedInWords(io.store),
|
|
53
|
+
next: { action: 'login' },
|
|
54
|
+
whole: true
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const gaveUpWaiting = (lock) => refusal(`Gave up after ${WAIT_AT_MOST_MS / 1000} seconds waiting for another lattris command to renew the sign-in. ` +
|
|
58
|
+
`If none is running, remove ${lock} and run this again.\n`, EXIT.GENERIC);
|
|
59
|
+
export const signedIn = async (io, cfg) => {
|
|
60
|
+
try {
|
|
61
|
+
return await io.token(cfg);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
return e instanceof SessionLockHeld ? gaveUpWaiting(e.path) : notLoggedIn(io);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=access-token.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { EXIT } from "../store/exit.js";
|
|
2
|
+
import { classifyApiError, edgeExcerpt, parseErrorBody } from "./door.js";
|
|
3
|
+
const HTTP_NO_CONTENT = 204;
|
|
4
|
+
export const apiFetch = async (network, accessToken, url, init = {}, addressKind = 'api') => {
|
|
5
|
+
const { retryAnonymousOn401 = false, ...requestInit } = init;
|
|
6
|
+
const request = (token) => {
|
|
7
|
+
const headers = { ...requestInit.headers };
|
|
8
|
+
if (token !== undefined)
|
|
9
|
+
headers.Authorization = `Bearer ${token}`;
|
|
10
|
+
else if (retryAnonymousOn401)
|
|
11
|
+
delete headers.Authorization;
|
|
12
|
+
return network(url, { ...requestInit, headers });
|
|
13
|
+
};
|
|
14
|
+
let res = await request(accessToken);
|
|
15
|
+
const isRead = requestInit.method === undefined || requestInit.method.toUpperCase() === 'GET';
|
|
16
|
+
if (retryAnonymousOn401 && isRead && accessToken !== undefined && res.status === 401) {
|
|
17
|
+
await res.body?.cancel().catch(() => { });
|
|
18
|
+
res = await request(undefined);
|
|
19
|
+
}
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const bytes = new Uint8Array(await res.arrayBuffer().catch(() => new ArrayBuffer(0)));
|
|
22
|
+
const rawText = new TextDecoder().decode(bytes);
|
|
23
|
+
const excerpt = edgeExcerpt(res.status, { contentType: res.headers.get('content-type'), via: res.headers.get('via') }, bytes);
|
|
24
|
+
if (excerpt !== null) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
body: {},
|
|
28
|
+
exit: EXIT.GENERIC,
|
|
29
|
+
answer: { kind: 'edge', excerpt },
|
|
30
|
+
status: res.status,
|
|
31
|
+
rawText
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const body = parseErrorBody(rawText);
|
|
35
|
+
const { message, next, exit } = classifyApiError(res.status, body);
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
body,
|
|
39
|
+
exit,
|
|
40
|
+
answer: { kind: 'door', message, next },
|
|
41
|
+
status: res.status,
|
|
42
|
+
rawText
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (res.status === HTTP_NO_CONTENT) {
|
|
46
|
+
return { ok: true, data: undefined, rawText: '', status: res.status };
|
|
47
|
+
}
|
|
48
|
+
let rawText;
|
|
49
|
+
try {
|
|
50
|
+
rawText = await res.text();
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw new TypeError('fetch failed', { cause: e });
|
|
54
|
+
}
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
data = JSON.parse(rawText);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
body: {},
|
|
63
|
+
exit: EXIT.GENERIC,
|
|
64
|
+
answer: { kind: 'page', address: addressKind },
|
|
65
|
+
status: res.status,
|
|
66
|
+
rawText
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, data, rawText, status: res.status };
|
|
70
|
+
};
|
|
71
|
+
export const refusalOf = (failed, message = '') => ({
|
|
72
|
+
message,
|
|
73
|
+
exit: failed.exit,
|
|
74
|
+
answer: failed.answer
|
|
75
|
+
});
|
|
76
|
+
//# sourceMappingURL=api-fetch.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
export const htmlEscape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
3
|
+
export class AuthorizationError extends Error {
|
|
4
|
+
error;
|
|
5
|
+
constructor(error) {
|
|
6
|
+
super(`authorization error: ${error}`);
|
|
7
|
+
this.error = error;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export const decideCallback = (params, expectedState) => {
|
|
11
|
+
const state = params.get('state');
|
|
12
|
+
if (!state || state !== expectedState)
|
|
13
|
+
return { kind: 'state-mismatch' };
|
|
14
|
+
const error = params.get('error');
|
|
15
|
+
if (error)
|
|
16
|
+
return { kind: 'error', error };
|
|
17
|
+
const code = params.get('code');
|
|
18
|
+
if (!code)
|
|
19
|
+
return { kind: 'missing-code' };
|
|
20
|
+
return { kind: 'success', code };
|
|
21
|
+
};
|
|
22
|
+
const callbackHtml = (ok, message) => `<!doctype html><meta charset="utf-8"><title>lattris</title>` +
|
|
23
|
+
`<body style="font:16px system-ui;padding:3rem;text-align:center">` +
|
|
24
|
+
`<h1>${ok ? '✓ Signed in' : '✗ Sign-in failed'}</h1>` +
|
|
25
|
+
`<p>${htmlEscape(message)}</p>` +
|
|
26
|
+
`<p style="color:#888">You can close this tab and return to the terminal.</p>`;
|
|
27
|
+
export const waitForCode = (port, expectedState, timeoutMs = 300_000, handle) => new Promise((resolve, reject) => {
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
30
|
+
if (url.pathname !== '/callback') {
|
|
31
|
+
res.writeHead(404).end('Not found');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const decision = decideCallback(url.searchParams, expectedState);
|
|
35
|
+
const corsHeaders = (verdict) => ({
|
|
36
|
+
'Content-Type': 'text/html',
|
|
37
|
+
'Access-Control-Allow-Origin': '*',
|
|
38
|
+
'Access-Control-Expose-Headers': 'X-Lattris-Login',
|
|
39
|
+
'X-Lattris-Login': verdict
|
|
40
|
+
});
|
|
41
|
+
if (decision.kind === 'state-mismatch') {
|
|
42
|
+
res.writeHead(400, corsHeaders('refused'));
|
|
43
|
+
res.end(callbackHtml(false, 'Invalid or missing state; ignoring this callback.'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const settle = (status, ok, message) => {
|
|
47
|
+
res.writeHead(status, corsHeaders(ok ? 'ok' : 'refused'));
|
|
48
|
+
res.end(callbackHtml(ok, message));
|
|
49
|
+
shutdown();
|
|
50
|
+
};
|
|
51
|
+
if (decision.kind === 'success') {
|
|
52
|
+
settle(200, true, 'Your terminal now holds your Lattris session.');
|
|
53
|
+
resolve(decision.code);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (decision.kind === 'error') {
|
|
57
|
+
settle(400, false, `Authorization server returned: ${htmlEscape(decision.error)}`);
|
|
58
|
+
reject(new AuthorizationError(decision.error));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
settle(400, false, 'Missing authorization code.');
|
|
62
|
+
reject(new Error('missing authorization code'));
|
|
63
|
+
});
|
|
64
|
+
const shutdown = () => {
|
|
65
|
+
server.close();
|
|
66
|
+
server.closeAllConnections();
|
|
67
|
+
server.unref();
|
|
68
|
+
};
|
|
69
|
+
server.on('error', reject);
|
|
70
|
+
server.listen(port, '127.0.0.1');
|
|
71
|
+
if (handle)
|
|
72
|
+
handle.close = shutdown;
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
shutdown();
|
|
75
|
+
reject(new Error('timed out waiting for the sign-in redirect'));
|
|
76
|
+
}, timeoutMs).unref();
|
|
77
|
+
});
|
|
78
|
+
export const openBrowser = (machine, url) => {
|
|
79
|
+
const [cmd, args] = machine.platform === 'darwin'
|
|
80
|
+
? ['open', [url]]
|
|
81
|
+
: machine.platform === 'win32'
|
|
82
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
83
|
+
: ['xdg-open', [url]];
|
|
84
|
+
machine.detach(cmd, args);
|
|
85
|
+
};
|
|
86
|
+
//# sourceMappingURL=callback.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { EXIT } from "../store/exit.js";
|
|
2
|
+
export const parseErrorBody = (text) => {
|
|
3
|
+
try {
|
|
4
|
+
const b = JSON.parse(text);
|
|
5
|
+
return b && typeof b === 'object' ? b : {};
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
export const classifyApiError = (status, body) => {
|
|
12
|
+
if (body.next?.action) {
|
|
13
|
+
return { message: body.message ?? 'Action required.', next: body.next, exit: EXIT.DOOR };
|
|
14
|
+
}
|
|
15
|
+
if (status === 401) {
|
|
16
|
+
return {
|
|
17
|
+
message: body.message ?? 'Session invalid or expired.',
|
|
18
|
+
next: { action: 'login' },
|
|
19
|
+
exit: EXIT.NOAUTH
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (status === 403) {
|
|
23
|
+
return {
|
|
24
|
+
message: body.message ?? 'Your session lacks a required scope.',
|
|
25
|
+
next: { action: 'login' },
|
|
26
|
+
exit: EXIT.NOAUTH
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
message: body.message ?? `Unexpected response from lattris-api: ${status}`,
|
|
31
|
+
exit: EXIT.GENERIC
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
const EDGE_REFUSAL_EXCERPT_BYTES = 200;
|
|
35
|
+
export const trimCutUtf8 = (bytes) => {
|
|
36
|
+
let i = bytes.length - 1;
|
|
37
|
+
let back = 0;
|
|
38
|
+
while (i >= 0 && back < 3 && (bytes[i] & 0xc0) === 0x80) {
|
|
39
|
+
i--;
|
|
40
|
+
back++;
|
|
41
|
+
}
|
|
42
|
+
if (i < 0)
|
|
43
|
+
return bytes;
|
|
44
|
+
const lead = bytes[i];
|
|
45
|
+
const need = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
|
|
46
|
+
const have = bytes.length - i;
|
|
47
|
+
return have < need ? bytes.subarray(0, i) : bytes;
|
|
48
|
+
};
|
|
49
|
+
const looksLikeHouseError = (text) => {
|
|
50
|
+
try {
|
|
51
|
+
const b = JSON.parse(text);
|
|
52
|
+
return (b !== null &&
|
|
53
|
+
typeof b === 'object' &&
|
|
54
|
+
(typeof b.code === 'string' || typeof b.message === 'string'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
export const edgeExcerpt = (status, headers, bytes) => {
|
|
61
|
+
if (status !== 403)
|
|
62
|
+
return null;
|
|
63
|
+
const text = new TextDecoder().decode(bytes);
|
|
64
|
+
if (looksLikeHouseError(text))
|
|
65
|
+
return null;
|
|
66
|
+
const contentType = (headers.contentType ?? '').toLowerCase();
|
|
67
|
+
const via = (headers.via ?? '').toLowerCase();
|
|
68
|
+
const isHtml = contentType.includes('text/html') || /^\s*<(!doctype|html)/i.test(text);
|
|
69
|
+
if (!isHtml && !via.includes('1.1 google'))
|
|
70
|
+
return null;
|
|
71
|
+
const prefix = trimCutUtf8(bytes.subarray(0, EDGE_REFUSAL_EXCERPT_BYTES));
|
|
72
|
+
return new TextDecoder().decode(prefix);
|
|
73
|
+
};
|
|
74
|
+
//# sourceMappingURL=door.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const HELP_TOKENS = new Set(['--help', '-h', 'help']);
|
|
2
|
+
export const parseArgs = (flagNames, args, ids = []) => {
|
|
3
|
+
const flags = {};
|
|
4
|
+
const positionals = [];
|
|
5
|
+
for (let i = 0; i < args.length; i++) {
|
|
6
|
+
const arg = args[i];
|
|
7
|
+
if (!arg.startsWith('--')) {
|
|
8
|
+
positionals.push(arg);
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const eq = arg.indexOf('=');
|
|
12
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
13
|
+
const key = flagNames[name];
|
|
14
|
+
if (!key)
|
|
15
|
+
return { flags, positionals, unknown: name };
|
|
16
|
+
if (eq !== -1) {
|
|
17
|
+
flags[key] = arg.slice(eq + 1);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const value = args[i + 1];
|
|
21
|
+
if (value === undefined || value.startsWith('--')) {
|
|
22
|
+
return { flags, positionals, unknown: name };
|
|
23
|
+
}
|
|
24
|
+
flags[key] = value;
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
const notAnId = ids
|
|
28
|
+
.map((k) => positionals[k])
|
|
29
|
+
.find((v) => v === '.' || v === '..' || v?.includes('/'));
|
|
30
|
+
return notAnId === undefined
|
|
31
|
+
? { flags, positionals }
|
|
32
|
+
: {
|
|
33
|
+
flags,
|
|
34
|
+
positionals,
|
|
35
|
+
notAnId: `Not an id: ${notAnId} — an id is never . or .. and never holds a /.\n`
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=flags.js.map
|