lattris 0.1.2 → 0.1.4
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/dist/build.json +1 -4
- package/dist/main.js +290 -78
- package/package.json +1 -1
package/dist/build.json
CHANGED
package/dist/main.js
CHANGED
|
@@ -1019,6 +1019,16 @@ export const validAccessToken = async (cfg) => {
|
|
|
1019
1019
|
throw new Error('not-logged-in');
|
|
1020
1020
|
}
|
|
1021
1021
|
};
|
|
1022
|
+
// A public read sends no Authorization header when obtaining an optional token
|
|
1023
|
+
// fails for any reason; the API refuses any present invalid bearer by its own rule.
|
|
1024
|
+
export const optionalAccessToken = async (cfg) => {
|
|
1025
|
+
try {
|
|
1026
|
+
return await validAccessToken(cfg);
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
return undefined;
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1022
1032
|
// Render a door to STDERR (stdout stays clean for piped data):
|
|
1023
1033
|
// <message>
|
|
1024
1034
|
// Run: lattris <next.action>
|
|
@@ -1174,11 +1184,24 @@ export const edgeRefusal = (status, headers, bytes) => {
|
|
|
1174
1184
|
// ALREADY rendered the door and the caller just propagates the exit code. 204 (the
|
|
1175
1185
|
// delete route's success) has no body to parse.
|
|
1176
1186
|
const HTTP_NO_CONTENT = 204;
|
|
1177
|
-
export const apiFetch = async (accessToken, url, init = {}) => {
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1180
|
-
headers
|
|
1181
|
-
|
|
1187
|
+
export const apiFetch = async (accessToken, url, init = {}, addressKind = 'api') => {
|
|
1188
|
+
const { retryAnonymousOn401 = false, ...requestInit } = init;
|
|
1189
|
+
const request = (token) => {
|
|
1190
|
+
const headers = { ...requestInit.headers };
|
|
1191
|
+
if (token !== undefined)
|
|
1192
|
+
headers.Authorization = `Bearer ${token}`;
|
|
1193
|
+
else if (retryAnonymousOn401)
|
|
1194
|
+
delete headers.Authorization;
|
|
1195
|
+
return fetch(url, { ...requestInit, headers });
|
|
1196
|
+
};
|
|
1197
|
+
let res = await request(accessToken);
|
|
1198
|
+
const isRead = requestInit.method === undefined || requestInit.method.toUpperCase() === 'GET';
|
|
1199
|
+
if (retryAnonymousOn401 && isRead && accessToken !== undefined && res.status === 401) {
|
|
1200
|
+
// Suppress the rejected optional bearer’s login door; only the anonymous
|
|
1201
|
+
// answer is rendered. Never retry a write or an already-anonymous call.
|
|
1202
|
+
await res.body?.cancel().catch(() => { });
|
|
1203
|
+
res = await request(undefined);
|
|
1204
|
+
}
|
|
1182
1205
|
if (!res.ok) {
|
|
1183
1206
|
// Bytes, not text: the edge excerpt is cut by byte before decoding, and
|
|
1184
1207
|
// the house-error parse below decodes the same bytes (BOM dropped, as
|
|
@@ -1203,11 +1226,8 @@ export const apiFetch = async (accessToken, url, init = {}) => {
|
|
|
1203
1226
|
data = JSON.parse(rawText);
|
|
1204
1227
|
}
|
|
1205
1228
|
catch {
|
|
1206
|
-
// A
|
|
1207
|
-
|
|
1208
|
-
// wrong; without this branch it surfaces as "Unexpected token '<'"
|
|
1209
|
-
// AFTER a successful login.
|
|
1210
|
-
process.stderr.write('The server answered with a web page, not the API — check the environment\u2019s `api` URL.\n');
|
|
1229
|
+
// A gateway fallthrough can be 200 HTML; it must not pass as a JSON read.
|
|
1230
|
+
process.stderr.write(`The server answered with a web page, not the API — check the environment\u2019s \`${addressKind}\` URL.\n`);
|
|
1211
1231
|
return { ok: false, body: {}, exit: EXIT.GENERIC };
|
|
1212
1232
|
}
|
|
1213
1233
|
return { ok: true, data, rawText };
|
|
@@ -1756,13 +1776,13 @@ export const buildCreateNoteBody = (content, flags, allowBannedWords = false) =>
|
|
|
1756
1776
|
// receipt. Not cosmetic: capturedAt is uneditable (PATCH carries only title/content),
|
|
1757
1777
|
// so this is the person's ONE immediate chance to catch a wrong-as-recorded
|
|
1758
1778
|
// timestamp — the only remedy past this point is delete + recapture.
|
|
1759
|
-
export const formatSaved = (b) => {
|
|
1779
|
+
export const formatSaved = (b, address) => {
|
|
1760
1780
|
const lines = [`Saved memory ${b.id} (type: ${b.type})`];
|
|
1761
1781
|
if (b.intent)
|
|
1762
1782
|
lines.push(` intent ${b.intent}`);
|
|
1763
1783
|
// title is echoed back from the caller's own --title — sanitized like every
|
|
1764
|
-
// other rendered title
|
|
1765
|
-
//
|
|
1784
|
+
// other rendered title. The machine receipt is JSON; this formatter is only
|
|
1785
|
+
// the terminal channel.
|
|
1766
1786
|
if (b.title)
|
|
1767
1787
|
lines.push(` title ${sanitizeForTerminal(b.title)}`);
|
|
1768
1788
|
if (b.aiSummary)
|
|
@@ -1772,6 +1792,8 @@ export const formatSaved = (b) => {
|
|
|
1772
1792
|
lines.push(` fingerprint ${b.fingerprint}`);
|
|
1773
1793
|
if (b.revisionNo)
|
|
1774
1794
|
lines.push(` revisionNo ${b.revisionNo}`);
|
|
1795
|
+
if (address)
|
|
1796
|
+
lines.push(` address ${address}`);
|
|
1775
1797
|
return lines.join('\n');
|
|
1776
1798
|
};
|
|
1777
1799
|
// Read all of stdin. Strips a SINGLE trailing newline — the ubiquitous echo/heredoc
|
|
@@ -2317,14 +2339,22 @@ const captureMemory = async (cfg, args) => {
|
|
|
2317
2339
|
process.exitCode = result.exit;
|
|
2318
2340
|
return;
|
|
2319
2341
|
}
|
|
2320
|
-
|
|
2342
|
+
const record = result.data;
|
|
2343
|
+
const address = await composeAddressFor(cfg, accessToken, record);
|
|
2344
|
+
const { href, ...fields } = record;
|
|
2345
|
+
const receipt = {
|
|
2346
|
+
...fields,
|
|
2347
|
+
...(href === undefined ? {} : { href }),
|
|
2348
|
+
...(address === undefined ? {} : { address })
|
|
2349
|
+
};
|
|
2350
|
+
emit(receipt, JSON.stringify(receipt), () => formatSaved(record, address));
|
|
2321
2351
|
// Capture-and-link: a CLI-side composition of the two doors the wire
|
|
2322
2352
|
// actually has (capture, then edge) — never a pretended atomicity.
|
|
2323
2353
|
if (flags.linkedFrom !== undefined || flags.linksTo !== undefined) {
|
|
2324
2354
|
// --linked-from: the existing node points AT the new one; --links-to:
|
|
2325
2355
|
// the new node points outward.
|
|
2326
|
-
const fromRef = flags.linkedFrom ??
|
|
2327
|
-
const toId = flags.linkedFrom !== undefined ?
|
|
2356
|
+
const fromRef = flags.linkedFrom ?? record.id;
|
|
2357
|
+
const toId = flags.linkedFrom !== undefined ? record.id : flags.linksTo;
|
|
2328
2358
|
const edge = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fromRef)}/edges`, {
|
|
2329
2359
|
method: 'POST',
|
|
2330
2360
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -2380,10 +2410,30 @@ export const composeAddress = (webUrl, personHandle, block) => {
|
|
|
2380
2410
|
const ref = slug ? `${slug}-${block.handle}` : block.handle;
|
|
2381
2411
|
return `${webUrl}/by/${personHandle}/memories/${ref}`;
|
|
2382
2412
|
};
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
const
|
|
2413
|
+
export const parseWebsiteMemoryAddress = (webUrl, value) => {
|
|
2414
|
+
try {
|
|
2415
|
+
const base = new URL(webUrl);
|
|
2416
|
+
const address = new URL(value);
|
|
2417
|
+
if (address.origin !== base.origin || address.username || address.password)
|
|
2418
|
+
return undefined;
|
|
2419
|
+
const basePath = stripTrailingSlash(base.pathname);
|
|
2420
|
+
const route = address.pathname.slice(basePath.length);
|
|
2421
|
+
if (!address.pathname.startsWith(`${basePath}/by/`))
|
|
2422
|
+
return undefined;
|
|
2423
|
+
const parts = route.split('/');
|
|
2424
|
+
if (parts.length !== 5 || parts[1] !== 'by' || parts[3] !== 'memories')
|
|
2425
|
+
return undefined;
|
|
2426
|
+
const ownerHandle = decodeURIComponent(parts[2]);
|
|
2427
|
+
const ref = decodeURIComponent(parts[4]);
|
|
2428
|
+
if (!ownerHandle || !ref || ownerHandle.includes('/') || ref.includes('/'))
|
|
2429
|
+
return undefined;
|
|
2430
|
+
return { ownerHandle, ref };
|
|
2431
|
+
}
|
|
2432
|
+
catch {
|
|
2433
|
+
return undefined;
|
|
2434
|
+
}
|
|
2435
|
+
};
|
|
2436
|
+
const ownHandleFor = async (cfg, accessToken) => {
|
|
2387
2437
|
try {
|
|
2388
2438
|
const res = await fetch(`${cfg.apiUrl}/person/me`, {
|
|
2389
2439
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
@@ -2391,12 +2441,17 @@ const composeAddressFor = async (cfg, accessToken, block) => {
|
|
|
2391
2441
|
if (!res.ok)
|
|
2392
2442
|
return undefined;
|
|
2393
2443
|
const { handle } = (await res.json());
|
|
2394
|
-
return handle
|
|
2444
|
+
return typeof handle === 'string' && handle.length > 0 ? handle : undefined;
|
|
2395
2445
|
}
|
|
2396
2446
|
catch {
|
|
2397
2447
|
return undefined;
|
|
2398
2448
|
}
|
|
2399
2449
|
};
|
|
2450
|
+
// A missing convenience address must not fail the record already in hand.
|
|
2451
|
+
const composeAddressFor = async (cfg, accessToken, block) => {
|
|
2452
|
+
const handle = await ownHandleFor(cfg, accessToken);
|
|
2453
|
+
return handle ? composeAddress(cfg.webUrl, handle, block) : undefined;
|
|
2454
|
+
};
|
|
2400
2455
|
// title/content are the caller's own free text — sanitized before a TTY ever
|
|
2401
2456
|
// renders them (piped JSON stays wire-verbatim; that channel is machine data by
|
|
2402
2457
|
// contract, sanitization is a rendering concern, not a storage one). id/type/
|
|
@@ -2524,8 +2579,8 @@ export const formatNodeWalk = (w, address) => {
|
|
|
2524
2579
|
// no other lever restores the bare pre-walk read, byte-identical on the
|
|
2525
2580
|
// wire. Lever values ride through verbatim — the API owns validation (depth
|
|
2526
2581
|
// range, depth-requires-links); the client makes no decisions.
|
|
2527
|
-
export const buildGetMemoryUrl = (
|
|
2528
|
-
const url = new URL(`${
|
|
2582
|
+
export const buildGetMemoryUrl = (webUrl, ownerHandle, ref, flags) => {
|
|
2583
|
+
const url = new URL(`${webUrl}/by/${encodeURIComponent(ownerHandle)}/memories/${encodeURIComponent(ref)}`);
|
|
2529
2584
|
const links = flags.links ?? '1';
|
|
2530
2585
|
const bare = links === '0' && flags.depth === undefined && flags.content === undefined;
|
|
2531
2586
|
if (!bare) {
|
|
@@ -2537,51 +2592,64 @@ export const buildGetMemoryUrl = (apiUrl, idOrPrefix, flags) => {
|
|
|
2537
2592
|
}
|
|
2538
2593
|
return url;
|
|
2539
2594
|
};
|
|
2540
|
-
const getMemory = async (cfg,
|
|
2595
|
+
const getMemory = async (cfg, idOrAddress, flags, by) => {
|
|
2596
|
+
const isAddress = /^https?:\/\//i.test(idOrAddress);
|
|
2597
|
+
const address = isAddress ? parseWebsiteMemoryAddress(cfg.webUrl, idOrAddress) : undefined;
|
|
2598
|
+
if (isAddress && !address) {
|
|
2599
|
+
process.stderr.write(`The memory address must be on ${cfg.webUrl}/by/<owner>/memories/<ref>.\n`);
|
|
2600
|
+
process.exitCode = EXIT.USAGE;
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
if (address && by && address.ownerHandle !== by) {
|
|
2604
|
+
process.stderr.write('--by must name the same owner as the memory address.\n');
|
|
2605
|
+
process.exitCode = EXIT.USAGE;
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
const storedSession = loadSession();
|
|
2609
|
+
let ownerHandle = address?.ownerHandle ?? by ?? storedSession?.handle;
|
|
2541
2610
|
let accessToken;
|
|
2542
|
-
|
|
2543
|
-
accessToken = await
|
|
2611
|
+
if (!ownerHandle && storedSession) {
|
|
2612
|
+
accessToken = await optionalAccessToken(cfg);
|
|
2613
|
+
if (accessToken) {
|
|
2614
|
+
ownerHandle = await ownHandleFor(cfg, accessToken);
|
|
2615
|
+
if (ownerHandle) {
|
|
2616
|
+
const currentSession = loadSession();
|
|
2617
|
+
if (currentSession && currentSession.handle !== ownerHandle) {
|
|
2618
|
+
saveSession({ ...currentSession, handle: ownerHandle });
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2544
2622
|
}
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
process.exitCode = EXIT.
|
|
2623
|
+
if (!ownerHandle) {
|
|
2624
|
+
process.stderr.write('A memory id needs its wall: use get memory <website-address> or get memory <id> --by <owner-handle>.\n');
|
|
2625
|
+
process.exitCode = EXIT.USAGE;
|
|
2548
2626
|
return;
|
|
2549
2627
|
}
|
|
2550
|
-
|
|
2628
|
+
accessToken ??= await optionalAccessToken(cfg);
|
|
2629
|
+
const url = buildGetMemoryUrl(cfg.webUrl, ownerHandle, address?.ref ?? idOrAddress, flags);
|
|
2551
2630
|
const enveloped = url.search !== '';
|
|
2552
|
-
const result = await apiFetch(accessToken, url
|
|
2631
|
+
const result = await apiFetch(accessToken, url, {
|
|
2632
|
+
headers: { Accept: 'application/json' },
|
|
2633
|
+
retryAnonymousOn401: true
|
|
2634
|
+
}, 'web');
|
|
2553
2635
|
if (!result.ok) {
|
|
2554
2636
|
renderAmbiguousIfPresent(result.body);
|
|
2637
|
+
if (result.body.code === 'memory:not-found' && !address && !by) {
|
|
2638
|
+
process.stderr.write('For another wall, use get memory <website-address> or get memory <id> --by <owner-handle>.\n');
|
|
2639
|
+
}
|
|
2555
2640
|
process.exitCode = result.exit;
|
|
2556
2641
|
return;
|
|
2557
2642
|
}
|
|
2558
|
-
// The address needs the caller's OWN handle, which this response does not
|
|
2559
|
-
// carry — so it costs a second request. Paid ONLY at a TTY: piped output is
|
|
2560
|
-
// the wire verbatim and gains nothing from an address, so a script never
|
|
2561
|
-
// pays for a line it will not receive. Fetched rather than cached: caching
|
|
2562
|
-
// would be safe (a handle is assigned once and never reassigned, so it
|
|
2563
|
-
// cannot go stale), but it would mean writing state from a read path and
|
|
2564
|
-
// carrying a miss-path for sessions that predate it — for one extra request
|
|
2565
|
-
// on an interactive command that is already making one. Cache later if it
|
|
2566
|
-
// ever matters; the immutability that makes it safe is not going anywhere.
|
|
2567
|
-
//
|
|
2568
|
-
// Deliberately NOT through apiFetch: that renders a door on failure and hands
|
|
2569
|
-
// back an exit code, which is right for a call the caller asked for and wrong
|
|
2570
|
-
// for this one. The record is already in hand; a convenience that cannot be
|
|
2571
|
-
// built should vanish quietly, not print "Session invalid or expired" over a
|
|
2572
|
-
// memory that printed perfectly well.
|
|
2573
|
-
// Bare or enveloped, the block whose address we'd compose is the same
|
|
2574
|
-
// record — the envelope's anchor carries handle+title either way.
|
|
2575
2643
|
const record = enveloped ? result.data.node : result.data;
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
}
|
|
2644
|
+
const displayAddress = process.stdout.isTTY
|
|
2645
|
+
? composeAddress(cfg.webUrl, ownerHandle, record)
|
|
2646
|
+
: undefined;
|
|
2580
2647
|
emit(result.data, result.rawText, () => enveloped
|
|
2581
|
-
? formatNodeWalk(result.data,
|
|
2582
|
-
: formatMemoryDetail(result.data,
|
|
2648
|
+
? formatNodeWalk(result.data, displayAddress)
|
|
2649
|
+
: formatMemoryDetail(result.data, displayAddress));
|
|
2583
2650
|
};
|
|
2584
2651
|
const GET_FLAG_NAMES = {
|
|
2652
|
+
'--by': 'by',
|
|
2585
2653
|
'--revision': 'revision',
|
|
2586
2654
|
'--type': 'type',
|
|
2587
2655
|
'--intent': 'intent',
|
|
@@ -2661,23 +2729,52 @@ const getMemories = async (cfg, flags) => {
|
|
|
2661
2729
|
process.stderr.write(`More: ${renderRunHint(argv)}\n`);
|
|
2662
2730
|
}
|
|
2663
2731
|
};
|
|
2732
|
+
const getWallMemories = async (cfg, ownerHandle, flags) => {
|
|
2733
|
+
const accessToken = await optionalAccessToken(cfg);
|
|
2734
|
+
const url = new URL(`${cfg.webUrl}/by/${encodeURIComponent(ownerHandle)}/memories`);
|
|
2735
|
+
if (flags.limit !== undefined)
|
|
2736
|
+
url.searchParams.set('limit', flags.limit);
|
|
2737
|
+
if (flags.cursor !== undefined)
|
|
2738
|
+
url.searchParams.set('cursor', flags.cursor);
|
|
2739
|
+
if (flags.content !== undefined)
|
|
2740
|
+
url.searchParams.set('content', flags.content);
|
|
2741
|
+
const result = await apiFetch(accessToken, url, {
|
|
2742
|
+
headers: { Accept: 'application/json' },
|
|
2743
|
+
retryAnonymousOn401: true
|
|
2744
|
+
}, 'web');
|
|
2745
|
+
if (!result.ok) {
|
|
2746
|
+
process.exitCode = result.exit;
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
emit(result.data, result.rawText, formatMemoryPage);
|
|
2750
|
+
if (result.data.nextCursor) {
|
|
2751
|
+
const argv = ['get', 'memories', '--by', ownerHandle];
|
|
2752
|
+
if (flags.limit !== undefined)
|
|
2753
|
+
argv.push('--limit', flags.limit);
|
|
2754
|
+
if (flags.content !== undefined)
|
|
2755
|
+
argv.push('--content', flags.content);
|
|
2756
|
+
argv.push('--cursor', result.data.nextCursor);
|
|
2757
|
+
process.stderr.write(`More: ${renderRunHint(argv)}\n`);
|
|
2758
|
+
}
|
|
2759
|
+
};
|
|
2664
2760
|
// Printed by bare `lattris get` and `lattris get --help` (spec: everything the
|
|
2665
2761
|
// tool accepts is discoverable from the tool). Same discovery-by-depth pattern
|
|
2666
2762
|
// capture uses, one level: get has no separate "form" to go deeper into, so the
|
|
2667
2763
|
// nouns, the positional, and the flags all live at this one level.
|
|
2668
2764
|
export const GET_HELP = [
|
|
2669
|
-
'get — read your memories
|
|
2765
|
+
'get — read your memories or a public wall.',
|
|
2670
2766
|
'',
|
|
2671
2767
|
'Nouns:',
|
|
2672
|
-
' memories list,
|
|
2673
|
-
' memory one, by
|
|
2768
|
+
' memories your list, or one wall with --by <owner-handle>',
|
|
2769
|
+
' memory one, by website address or id with --by <owner-handle> \u2014 its edges ride along',
|
|
2674
2770
|
' history one note\u2019s history: every change, newest first, and the head fingerprint',
|
|
2675
2771
|
' deleted your deleted items, with the fingerprint restore and erase need',
|
|
2676
2772
|
' questions the questions left on your tree, open first',
|
|
2677
2773
|
'',
|
|
2678
2774
|
'Usage:',
|
|
2679
2775
|
' lattris get memories [--shared-with-me] [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--content 0|1]',
|
|
2680
|
-
' lattris get
|
|
2776
|
+
' lattris get memories --by <owner-handle> [--limit <n>] [--cursor <token>] [--content 0|1]',
|
|
2777
|
+
' lattris get memory <website-address|id> [--by <owner-handle>] [--links 0|1] [--depth <n>] [--content 0|1]',
|
|
2681
2778
|
' lattris get memory <id-or-prefix> --revision <n> one revision, as history kept it',
|
|
2682
2779
|
' lattris get history <id-or-prefix> [--limit <n>] [--before <operation-id>]',
|
|
2683
2780
|
' lattris get deleted [--limit <n>] [--cursor <token>]',
|
|
@@ -2694,6 +2791,8 @@ export const GET_HELP = [
|
|
|
2694
2791
|
' (private — no grant at all)',
|
|
2695
2792
|
' --limit <n> page size, 1-100 (default 50)',
|
|
2696
2793
|
' --cursor <token> the nextCursor from a previous page',
|
|
2794
|
+
' --by <handle> one wall on the website address; an id without a',
|
|
2795
|
+
' session needs it, while a full address carries it',
|
|
2697
2796
|
' --shared-with-me list memories other people granted to you; each row',
|
|
2698
2797
|
' names its owner and whether it is on your graph',
|
|
2699
2798
|
'',
|
|
@@ -2716,11 +2815,11 @@ export const GET_HELP = [
|
|
|
2716
2815
|
'',
|
|
2717
2816
|
'Every read of a note prints its fingerprint (the head of its history) and',
|
|
2718
2817
|
'revisionNo; every read prints structureHash, a hash of the block\u2019s links and',
|
|
2719
|
-
'grants as
|
|
2818
|
+
'grants as this reader sees them. An edit sends the fingerprint back as --base-fingerprint.',
|
|
2720
2819
|
'',
|
|
2721
|
-
"A memory's web address is <site>/by/<
|
|
2722
|
-
'
|
|
2723
|
-
'
|
|
2820
|
+
"A memory's web address is <site>/by/<owner-handle>/memories/<slug>-<memory-handle>.",
|
|
2821
|
+
'A /by read asks for JSON with Accept: application/json; it sends a bearer',
|
|
2822
|
+
'only when the session has a live one. `get memory` prints the address at a TTY.',
|
|
2724
2823
|
'',
|
|
2725
2824
|
'Aliases: read, show, view, fetch → get'
|
|
2726
2825
|
].join('\n');
|
|
@@ -2747,21 +2846,113 @@ const GET_NOUNS = new Set([
|
|
|
2747
2846
|
// when the build wrote one, and is ABSENT rather than guessed when it did not —
|
|
2748
2847
|
// `tsc` alone does not write that stamp, the install path does, so a package
|
|
2749
2848
|
// built by CI carries it only if CI writes it.
|
|
2849
|
+
const runtimeJson = (path) => {
|
|
2850
|
+
try {
|
|
2851
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
2852
|
+
}
|
|
2853
|
+
catch {
|
|
2854
|
+
return undefined;
|
|
2855
|
+
}
|
|
2856
|
+
};
|
|
2857
|
+
const currentPackageVersion = () => {
|
|
2858
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2859
|
+
const pkg = runtimeJson(join(here, '..', 'package.json'));
|
|
2860
|
+
return typeof pkg?.version === 'string' ? pkg.version : 'unknown';
|
|
2861
|
+
};
|
|
2750
2862
|
const printVersion = () => {
|
|
2751
2863
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2752
|
-
const
|
|
2864
|
+
const build = runtimeJson(join(here, 'build.json'));
|
|
2865
|
+
const version = currentPackageVersion();
|
|
2866
|
+
const commit = typeof build?.commit === 'string' ? build.commit.slice(0, 12) : undefined;
|
|
2867
|
+
process.stdout.write(commit ? `lattris ${version} (${commit})\n` : `lattris ${version}\n`);
|
|
2868
|
+
};
|
|
2869
|
+
const VERSION_CHECK_URL = 'https://registry.npmjs.org/lattris/latest';
|
|
2870
|
+
const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
2871
|
+
const VERSION_CHECK_TIMEOUT_MS = 1500;
|
|
2872
|
+
const parseVersion = (version) => {
|
|
2873
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
|
|
2874
|
+
if (!match)
|
|
2875
|
+
return undefined;
|
|
2876
|
+
return {
|
|
2877
|
+
core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
|
|
2878
|
+
prerelease: match[4] === undefined ? null : match[4].split('.')
|
|
2879
|
+
};
|
|
2880
|
+
};
|
|
2881
|
+
// SemVer precedence without adding a runtime dependency to the zero-dependency
|
|
2882
|
+
// CLI. Returns negative when left is older, positive when it is newer.
|
|
2883
|
+
const compareVersions = (left, right) => {
|
|
2884
|
+
const a = parseVersion(left);
|
|
2885
|
+
const b = parseVersion(right);
|
|
2886
|
+
if (!a || !b)
|
|
2887
|
+
return undefined;
|
|
2888
|
+
for (let i = 0; i < a.core.length; i++) {
|
|
2889
|
+
if (a.core[i] < b.core[i])
|
|
2890
|
+
return -1;
|
|
2891
|
+
if (a.core[i] > b.core[i])
|
|
2892
|
+
return 1;
|
|
2893
|
+
}
|
|
2894
|
+
if (a.prerelease === null && b.prerelease === null)
|
|
2895
|
+
return 0;
|
|
2896
|
+
if (a.prerelease === null)
|
|
2897
|
+
return 1;
|
|
2898
|
+
if (b.prerelease === null)
|
|
2899
|
+
return -1;
|
|
2900
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
|
2901
|
+
for (let i = 0; i < length; i++) {
|
|
2902
|
+
const av = a.prerelease[i];
|
|
2903
|
+
const bv = b.prerelease[i];
|
|
2904
|
+
if (av === undefined)
|
|
2905
|
+
return -1;
|
|
2906
|
+
if (bv === undefined)
|
|
2907
|
+
return 1;
|
|
2908
|
+
if (av === bv)
|
|
2909
|
+
continue;
|
|
2910
|
+
const aNumber = /^\d+$/.test(av);
|
|
2911
|
+
const bNumber = /^\d+$/.test(bv);
|
|
2912
|
+
if (aNumber && bNumber)
|
|
2913
|
+
return BigInt(av) < BigInt(bv) ? -1 : 1;
|
|
2914
|
+
if (aNumber !== bNumber)
|
|
2915
|
+
return aNumber ? -1 : 1;
|
|
2916
|
+
return av < bv ? -1 : 1;
|
|
2917
|
+
}
|
|
2918
|
+
return 0;
|
|
2919
|
+
};
|
|
2920
|
+
// A courtesy, never a gate: every read, request, parse and write failure is
|
|
2921
|
+
// swallowed, and this function never changes the command's exit code. Startup
|
|
2922
|
+
// launches it without awaiting it, so the command begins while the registry
|
|
2923
|
+
// request is in flight; the bounded request cannot hold a quick command open
|
|
2924
|
+
// indefinitely.
|
|
2925
|
+
export const checkForNewVersion = async ({ currentVersion, stateDir, now = Date.now(), fetchImpl = fetch }) => {
|
|
2926
|
+
try {
|
|
2927
|
+
const cachePath = join(stateDir, 'version-check.json');
|
|
2928
|
+
const cache = runtimeJson(cachePath);
|
|
2929
|
+
if (typeof cache?.checkedAt === 'number' && now - cache.checkedAt <= VERSION_CHECK_TTL_MS) {
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
const response = await fetchImpl(VERSION_CHECK_URL, {
|
|
2933
|
+
headers: { Accept: 'application/json' },
|
|
2934
|
+
signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS)
|
|
2935
|
+
});
|
|
2936
|
+
if (!response.ok)
|
|
2937
|
+
return;
|
|
2938
|
+
const body = (await response.json());
|
|
2939
|
+
if (typeof body.version !== 'string' || parseVersion(body.version) === undefined)
|
|
2940
|
+
return;
|
|
2753
2941
|
try {
|
|
2754
|
-
|
|
2942
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
2943
|
+
writeFileSync(cachePath, `${JSON.stringify({ checkedAt: now, latestVersion: body.version })}\n`, { mode: 0o600 });
|
|
2755
2944
|
}
|
|
2756
2945
|
catch {
|
|
2757
|
-
|
|
2946
|
+
// An unwritable cache means a later startup may retry; it never makes
|
|
2947
|
+
// this command fail or hides an update the registry already answered.
|
|
2758
2948
|
}
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2949
|
+
if (compareVersions(currentVersion, body.version) === -1) {
|
|
2950
|
+
process.stderr.write(`lattris ${currentVersion} → ${body.version} available: npm update -g lattris\n`);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
catch {
|
|
2954
|
+
// Offline, timed out, malformed response, unreadable cache: all silent.
|
|
2955
|
+
}
|
|
2765
2956
|
};
|
|
2766
2957
|
const HELP_TOKENS = new Set(['--help', '-h', 'help']);
|
|
2767
2958
|
export const LOGIN_HELP = [
|
|
@@ -2933,6 +3124,11 @@ const get = async (cfg, noun, rest) => {
|
|
|
2933
3124
|
return;
|
|
2934
3125
|
}
|
|
2935
3126
|
if (flags.revision !== undefined) {
|
|
3127
|
+
if (flags.by !== undefined || /^https?:\/\//i.test(id)) {
|
|
3128
|
+
process.stderr.write('A revision read uses your own id or prefix; --by and website addresses do not apply.\n');
|
|
3129
|
+
process.exitCode = EXIT.USAGE;
|
|
3130
|
+
return;
|
|
3131
|
+
}
|
|
2936
3132
|
const walkFlag = ['links', 'depth', 'content'].find((k) => flags[k] !== undefined);
|
|
2937
3133
|
if (walkFlag !== undefined) {
|
|
2938
3134
|
process.stderr.write(`--${walkFlag} does not apply to a revision read.\n`);
|
|
@@ -2942,7 +3138,7 @@ const get = async (cfg, noun, rest) => {
|
|
|
2942
3138
|
await getRevision(cfg, id, flags.revision);
|
|
2943
3139
|
return;
|
|
2944
3140
|
}
|
|
2945
|
-
await getMemory(cfg, id, { links: flags.links, depth: flags.depth, content: flags.content });
|
|
3141
|
+
await getMemory(cfg, id, { links: flags.links, depth: flags.depth, content: flags.content }, flags.by);
|
|
2946
3142
|
}
|
|
2947
3143
|
else if (noun === 'memories') {
|
|
2948
3144
|
// The symmetric refusal: a walk lever on a list would be parsed clean and
|
|
@@ -2953,10 +3149,22 @@ const get = async (cfg, noun, rest) => {
|
|
|
2953
3149
|
process.exitCode = EXIT.USAGE;
|
|
2954
3150
|
return;
|
|
2955
3151
|
}
|
|
2956
|
-
|
|
3152
|
+
if (flags.by !== undefined) {
|
|
3153
|
+
const ownerOnly = ['type', 'intent', 'q', 'with', 'sharedWithMe'].find((key) => flags[key] !== undefined);
|
|
3154
|
+
if (ownerOnly !== undefined) {
|
|
3155
|
+
const flagName = ownerOnly === 'sharedWithMe' ? 'shared-with-me' : ownerOnly;
|
|
3156
|
+
process.stderr.write(`--${flagName} does not apply to a wall listing.\n`);
|
|
3157
|
+
process.exitCode = EXIT.USAGE;
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
await getWallMemories(cfg, flags.by, flags);
|
|
3161
|
+
}
|
|
3162
|
+
else {
|
|
3163
|
+
await getMemories(cfg, flags);
|
|
3164
|
+
}
|
|
2957
3165
|
}
|
|
2958
3166
|
else {
|
|
2959
|
-
process.stderr.write('
|
|
3167
|
+
process.stderr.write('A website address or id is required for a single memory. To list your own: lattris get memories\n');
|
|
2960
3168
|
process.exitCode = EXIT.USAGE;
|
|
2961
3169
|
}
|
|
2962
3170
|
};
|
|
@@ -6606,10 +6814,14 @@ export const main = async () => {
|
|
|
6606
6814
|
? VERB_ALIASES[rawCommand]
|
|
6607
6815
|
: rawCommand
|
|
6608
6816
|
: rawCommand;
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6817
|
+
void checkForNewVersion({
|
|
6818
|
+
currentVersion: currentPackageVersion(),
|
|
6819
|
+
stateDir: join(stateHome(), 'lattris')
|
|
6820
|
+
});
|
|
6821
|
+
// HELP BEFORE COMMAND WORK: a help token in the subcommand's args means
|
|
6822
|
+
// help — never a session or API call, exit 0. The best-effort registry
|
|
6823
|
+
// courtesy above is startup work rather than command work. Commands whose
|
|
6824
|
+
// own handler already treats help first return null and fall through unchanged.
|
|
6613
6825
|
const subArgs = argv.slice(3);
|
|
6614
6826
|
if (command !== undefined && subArgs.some((t) => HELP_TOKENS.has(t))) {
|
|
6615
6827
|
const help = commandHelpText(command, subArgs);
|