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 CHANGED
@@ -1,4 +1 @@
1
- {
2
- "commit": "e813390b3f7e3c6eed78e607fed6125a10c9cf2c",
3
- "builtAt": "2026-09-12T04:33:05.921Z"
4
- }
1
+ {"commit":"92b27e26b7bbfb87059aec6ca19287eb8f2830f2","builtAt":"2026-09-15T23:11:21Z"}
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 res = await fetch(url, {
1179
- ...init,
1180
- headers: { ...init.headers, Authorization: `Bearer ${accessToken}` }
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 200 that is not JSON is not the API — almost always a gateway
1207
- // falling through to the SPA because the environment's `api` URL is
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 (finding 2's principle: this receipt always prints as
1765
- // text, TTY or not, so it is never the wire-verbatim channel).
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
- process.stdout.write(`${formatSaved(result.data)}\n`);
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 ?? result.data.id;
2327
- const toId = flags.linkedFrom !== undefined ? result.data.id : flags.linksTo;
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
- // The caller's own handle, fetched quietly for the address line. Every failure
2384
- // — no session, a 500, a shape that is not what we expect — resolves to "no
2385
- // address", never to an error the person did not ask for.
2386
- const composeAddressFor = async (cfg, accessToken, block) => {
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 ? composeAddress(cfg.webUrl, handle, block) : undefined;
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 = (apiUrl, idOrPrefix, flags) => {
2528
- const url = new URL(`${apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
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, idOrPrefix, flags) => {
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
- try {
2543
- accessToken = await validAccessToken(cfg);
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
- catch {
2546
- renderNotLoggedIn();
2547
- process.exitCode = EXIT.NOAUTH;
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
- const url = buildGetMemoryUrl(cfg.apiUrl, idOrPrefix, flags);
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
- let address;
2577
- if (process.stdout.isTTY) {
2578
- address = await composeAddressFor(cfg, accessToken, record);
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, address)
2582
- : formatMemoryDetail(result.data, address));
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 back.',
2765
+ 'get — read your memories or a public wall.',
2670
2766
  '',
2671
2767
  'Nouns:',
2672
- ' memories list, newest first',
2673
- ' memory one, by full id or a >=4-char prefix \u2014 its edges ride along',
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 memory <id-or-prefix> [--links 0|1] [--depth <n>] [--content 0|1]',
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 YOU see them. An edit sends the fingerprint back as --base-fingerprint.',
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/<your-handle>/memories/<memory-handle> —",
2722
- 'the handle column above, after your own from whoami. `get memory` prints the',
2723
- 'finished address; the site adds the readable title itself, so the bare form lands.',
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 readJson = (path) => {
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
- return JSON.parse(readFileSync(path, 'utf8'));
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
- return undefined;
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
- const pkg = readJson(join(here, '..', 'package.json'));
2761
- const build = readJson(join(here, 'build.json'));
2762
- const version = typeof pkg?.version === 'string' ? pkg.version : 'unknown';
2763
- const commit = typeof build?.commit === 'string' ? build.commit.slice(0, 12) : undefined;
2764
- process.stdout.write(commit ? `lattris ${version} (${commit})\n` : `lattris ${version}\n`);
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
- await getMemories(cfg, flags);
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('An id (or unique prefix) is required for a single memory. To list them all: lattris get memories\n');
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
- // HELP BEFORE EVERYTHING: a help token in the subcommand's args means
6610
- // help — never a session check, never a network call, exit 0. Commands
6611
- // whose own handler already treats help first return null and fall
6612
- // through unchanged.
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattris",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "First-party CLI for Lattris — sign in, capture notes and sessions, and read your own tree from the terminal.",
6
6
  "keywords": [