lattris 0.1.2 → 0.1.3

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":"cecf7e60eb450bce092dc992155ff77002fc1379","builtAt":"2026-09-15T22:27:41Z"}
package/dist/main.js CHANGED
@@ -1019,6 +1019,18 @@ export const validAccessToken = async (cfg) => {
1019
1019
  throw new Error('not-logged-in');
1020
1020
  }
1021
1021
  };
1022
+ // An absent or unrefreshable session sends no Authorization header, since the
1023
+ // 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 (error) {
1029
+ if (error instanceof Error && error.message === 'not-logged-in')
1030
+ return undefined;
1031
+ throw error;
1032
+ }
1033
+ };
1022
1034
  // Render a door to STDERR (stdout stays clean for piped data):
1023
1035
  // <message>
1024
1036
  // Run: lattris <next.action>
@@ -1174,10 +1186,13 @@ export const edgeRefusal = (status, headers, bytes) => {
1174
1186
  // ALREADY rendered the door and the caller just propagates the exit code. 204 (the
1175
1187
  // delete route's success) has no body to parse.
1176
1188
  const HTTP_NO_CONTENT = 204;
1177
- export const apiFetch = async (accessToken, url, init = {}) => {
1189
+ export const apiFetch = async (accessToken, url, init = {}, addressKind = 'api') => {
1190
+ const headers = { ...init.headers };
1191
+ if (accessToken !== undefined)
1192
+ headers.Authorization = `Bearer ${accessToken}`;
1178
1193
  const res = await fetch(url, {
1179
1194
  ...init,
1180
- headers: { ...init.headers, Authorization: `Bearer ${accessToken}` }
1195
+ headers
1181
1196
  });
1182
1197
  if (!res.ok) {
1183
1198
  // Bytes, not text: the edge excerpt is cut by byte before decoding, and
@@ -1203,11 +1218,8 @@ export const apiFetch = async (accessToken, url, init = {}) => {
1203
1218
  data = JSON.parse(rawText);
1204
1219
  }
1205
1220
  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');
1221
+ // A gateway fallthrough can be 200 HTML; it must not pass as a JSON read.
1222
+ process.stderr.write(`The server answered with a web page, not the API — check the environment\u2019s \`${addressKind}\` URL.\n`);
1211
1223
  return { ok: false, body: {}, exit: EXIT.GENERIC };
1212
1224
  }
1213
1225
  return { ok: true, data, rawText };
@@ -1756,13 +1768,13 @@ export const buildCreateNoteBody = (content, flags, allowBannedWords = false) =>
1756
1768
  // receipt. Not cosmetic: capturedAt is uneditable (PATCH carries only title/content),
1757
1769
  // so this is the person's ONE immediate chance to catch a wrong-as-recorded
1758
1770
  // timestamp — the only remedy past this point is delete + recapture.
1759
- export const formatSaved = (b) => {
1771
+ export const formatSaved = (b, address) => {
1760
1772
  const lines = [`Saved memory ${b.id} (type: ${b.type})`];
1761
1773
  if (b.intent)
1762
1774
  lines.push(` intent ${b.intent}`);
1763
1775
  // 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).
1776
+ // other rendered title. The machine receipt is JSON; this formatter is only
1777
+ // the terminal channel.
1766
1778
  if (b.title)
1767
1779
  lines.push(` title ${sanitizeForTerminal(b.title)}`);
1768
1780
  if (b.aiSummary)
@@ -1772,6 +1784,8 @@ export const formatSaved = (b) => {
1772
1784
  lines.push(` fingerprint ${b.fingerprint}`);
1773
1785
  if (b.revisionNo)
1774
1786
  lines.push(` revisionNo ${b.revisionNo}`);
1787
+ if (address)
1788
+ lines.push(` address ${address}`);
1775
1789
  return lines.join('\n');
1776
1790
  };
1777
1791
  // Read all of stdin. Strips a SINGLE trailing newline — the ubiquitous echo/heredoc
@@ -2317,14 +2331,22 @@ const captureMemory = async (cfg, args) => {
2317
2331
  process.exitCode = result.exit;
2318
2332
  return;
2319
2333
  }
2320
- process.stdout.write(`${formatSaved(result.data)}\n`);
2334
+ const record = result.data;
2335
+ const address = await composeAddressFor(cfg, accessToken, record);
2336
+ const { href, ...fields } = record;
2337
+ const receipt = {
2338
+ ...fields,
2339
+ ...(href === undefined ? {} : { href }),
2340
+ ...(address === undefined ? {} : { address })
2341
+ };
2342
+ emit(receipt, JSON.stringify(receipt), () => formatSaved(record, address));
2321
2343
  // Capture-and-link: a CLI-side composition of the two doors the wire
2322
2344
  // actually has (capture, then edge) — never a pretended atomicity.
2323
2345
  if (flags.linkedFrom !== undefined || flags.linksTo !== undefined) {
2324
2346
  // --linked-from: the existing node points AT the new one; --links-to:
2325
2347
  // the new node points outward.
2326
- const fromRef = flags.linkedFrom ?? result.data.id;
2327
- const toId = flags.linkedFrom !== undefined ? result.data.id : flags.linksTo;
2348
+ const fromRef = flags.linkedFrom ?? record.id;
2349
+ const toId = flags.linkedFrom !== undefined ? record.id : flags.linksTo;
2328
2350
  const edge = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fromRef)}/edges`, {
2329
2351
  method: 'POST',
2330
2352
  headers: { 'Content-Type': 'application/json' },
@@ -2380,10 +2402,30 @@ export const composeAddress = (webUrl, personHandle, block) => {
2380
2402
  const ref = slug ? `${slug}-${block.handle}` : block.handle;
2381
2403
  return `${webUrl}/by/${personHandle}/memories/${ref}`;
2382
2404
  };
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) => {
2405
+ export const parseWebsiteMemoryAddress = (webUrl, value) => {
2406
+ try {
2407
+ const base = new URL(webUrl);
2408
+ const address = new URL(value);
2409
+ if (address.origin !== base.origin || address.username || address.password)
2410
+ return undefined;
2411
+ const basePath = stripTrailingSlash(base.pathname);
2412
+ const route = address.pathname.slice(basePath.length);
2413
+ if (!address.pathname.startsWith(`${basePath}/by/`))
2414
+ return undefined;
2415
+ const parts = route.split('/');
2416
+ if (parts.length !== 5 || parts[1] !== 'by' || parts[3] !== 'memories')
2417
+ return undefined;
2418
+ const ownerHandle = decodeURIComponent(parts[2]);
2419
+ const ref = decodeURIComponent(parts[4]);
2420
+ if (!ownerHandle || !ref || ownerHandle.includes('/') || ref.includes('/'))
2421
+ return undefined;
2422
+ return { ownerHandle, ref };
2423
+ }
2424
+ catch {
2425
+ return undefined;
2426
+ }
2427
+ };
2428
+ const ownHandleFor = async (cfg, accessToken) => {
2387
2429
  try {
2388
2430
  const res = await fetch(`${cfg.apiUrl}/person/me`, {
2389
2431
  headers: { Authorization: `Bearer ${accessToken}` }
@@ -2391,12 +2433,17 @@ const composeAddressFor = async (cfg, accessToken, block) => {
2391
2433
  if (!res.ok)
2392
2434
  return undefined;
2393
2435
  const { handle } = (await res.json());
2394
- return handle ? composeAddress(cfg.webUrl, handle, block) : undefined;
2436
+ return typeof handle === 'string' && handle.length > 0 ? handle : undefined;
2395
2437
  }
2396
2438
  catch {
2397
2439
  return undefined;
2398
2440
  }
2399
2441
  };
2442
+ // A missing convenience address must not fail the record already in hand.
2443
+ const composeAddressFor = async (cfg, accessToken, block) => {
2444
+ const handle = await ownHandleFor(cfg, accessToken);
2445
+ return handle ? composeAddress(cfg.webUrl, handle, block) : undefined;
2446
+ };
2400
2447
  // title/content are the caller's own free text — sanitized before a TTY ever
2401
2448
  // renders them (piped JSON stays wire-verbatim; that channel is machine data by
2402
2449
  // contract, sanitization is a rendering concern, not a storage one). id/type/
@@ -2524,8 +2571,8 @@ export const formatNodeWalk = (w, address) => {
2524
2571
  // no other lever restores the bare pre-walk read, byte-identical on the
2525
2572
  // wire. Lever values ride through verbatim — the API owns validation (depth
2526
2573
  // 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)}`);
2574
+ export const buildGetMemoryUrl = (webUrl, ownerHandle, ref, flags) => {
2575
+ const url = new URL(`${webUrl}/by/${encodeURIComponent(ownerHandle)}/memories/${encodeURIComponent(ref)}`);
2529
2576
  const links = flags.links ?? '1';
2530
2577
  const bare = links === '0' && flags.depth === undefined && flags.content === undefined;
2531
2578
  if (!bare) {
@@ -2537,51 +2584,63 @@ export const buildGetMemoryUrl = (apiUrl, idOrPrefix, flags) => {
2537
2584
  }
2538
2585
  return url;
2539
2586
  };
2540
- const getMemory = async (cfg, idOrPrefix, flags) => {
2587
+ const getMemory = async (cfg, idOrAddress, flags, by) => {
2588
+ const isAddress = /^https?:\/\//i.test(idOrAddress);
2589
+ const address = isAddress ? parseWebsiteMemoryAddress(cfg.webUrl, idOrAddress) : undefined;
2590
+ if (isAddress && !address) {
2591
+ process.stderr.write(`The memory address must be on ${cfg.webUrl}/by/<owner>/memories/<ref>.\n`);
2592
+ process.exitCode = EXIT.USAGE;
2593
+ return;
2594
+ }
2595
+ if (address && by && address.ownerHandle !== by) {
2596
+ process.stderr.write('--by must name the same owner as the memory address.\n');
2597
+ process.exitCode = EXIT.USAGE;
2598
+ return;
2599
+ }
2600
+ const storedSession = loadSession();
2601
+ let ownerHandle = address?.ownerHandle ?? by ?? storedSession?.handle;
2541
2602
  let accessToken;
2542
- try {
2543
- accessToken = await validAccessToken(cfg);
2603
+ if (!ownerHandle && storedSession) {
2604
+ accessToken = await optionalAccessToken(cfg);
2605
+ if (accessToken) {
2606
+ ownerHandle = await ownHandleFor(cfg, accessToken);
2607
+ if (ownerHandle) {
2608
+ const currentSession = loadSession();
2609
+ if (currentSession && currentSession.handle !== ownerHandle) {
2610
+ saveSession({ ...currentSession, handle: ownerHandle });
2611
+ }
2612
+ }
2613
+ }
2544
2614
  }
2545
- catch {
2546
- renderNotLoggedIn();
2547
- process.exitCode = EXIT.NOAUTH;
2615
+ if (!ownerHandle) {
2616
+ process.stderr.write('A memory id needs its wall: use get memory <website-address> or get memory <id> --by <owner-handle>.\n');
2617
+ process.exitCode = EXIT.USAGE;
2548
2618
  return;
2549
2619
  }
2550
- const url = buildGetMemoryUrl(cfg.apiUrl, idOrPrefix, flags);
2620
+ accessToken ??= await optionalAccessToken(cfg);
2621
+ const url = buildGetMemoryUrl(cfg.webUrl, ownerHandle, address?.ref ?? idOrAddress, flags);
2551
2622
  const enveloped = url.search !== '';
2552
- const result = await apiFetch(accessToken, url);
2623
+ const result = await apiFetch(accessToken, url, {
2624
+ headers: { Accept: 'application/json' }
2625
+ }, 'web');
2553
2626
  if (!result.ok) {
2554
2627
  renderAmbiguousIfPresent(result.body);
2628
+ if (result.body.code === 'memory:not-found' && !address && !by) {
2629
+ process.stderr.write('For another wall, use get memory <website-address> or get memory <id> --by <owner-handle>.\n');
2630
+ }
2555
2631
  process.exitCode = result.exit;
2556
2632
  return;
2557
2633
  }
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
2634
  const record = enveloped ? result.data.node : result.data;
2576
- let address;
2577
- if (process.stdout.isTTY) {
2578
- address = await composeAddressFor(cfg, accessToken, record);
2579
- }
2635
+ const displayAddress = process.stdout.isTTY
2636
+ ? composeAddress(cfg.webUrl, ownerHandle, record)
2637
+ : undefined;
2580
2638
  emit(result.data, result.rawText, () => enveloped
2581
- ? formatNodeWalk(result.data, address)
2582
- : formatMemoryDetail(result.data, address));
2639
+ ? formatNodeWalk(result.data, displayAddress)
2640
+ : formatMemoryDetail(result.data, displayAddress));
2583
2641
  };
2584
2642
  const GET_FLAG_NAMES = {
2643
+ '--by': 'by',
2585
2644
  '--revision': 'revision',
2586
2645
  '--type': 'type',
2587
2646
  '--intent': 'intent',
@@ -2661,23 +2720,51 @@ const getMemories = async (cfg, flags) => {
2661
2720
  process.stderr.write(`More: ${renderRunHint(argv)}\n`);
2662
2721
  }
2663
2722
  };
2723
+ const getWallMemories = async (cfg, ownerHandle, flags) => {
2724
+ const accessToken = await optionalAccessToken(cfg);
2725
+ const url = new URL(`${cfg.webUrl}/by/${encodeURIComponent(ownerHandle)}/memories`);
2726
+ if (flags.limit !== undefined)
2727
+ url.searchParams.set('limit', flags.limit);
2728
+ if (flags.cursor !== undefined)
2729
+ url.searchParams.set('cursor', flags.cursor);
2730
+ if (flags.content !== undefined)
2731
+ url.searchParams.set('content', flags.content);
2732
+ const result = await apiFetch(accessToken, url, {
2733
+ headers: { Accept: 'application/json' }
2734
+ }, 'web');
2735
+ if (!result.ok) {
2736
+ process.exitCode = result.exit;
2737
+ return;
2738
+ }
2739
+ emit(result.data, result.rawText, formatMemoryPage);
2740
+ if (result.data.nextCursor) {
2741
+ const argv = ['get', 'memories', '--by', ownerHandle];
2742
+ if (flags.limit !== undefined)
2743
+ argv.push('--limit', flags.limit);
2744
+ if (flags.content !== undefined)
2745
+ argv.push('--content', flags.content);
2746
+ argv.push('--cursor', result.data.nextCursor);
2747
+ process.stderr.write(`More: ${renderRunHint(argv)}\n`);
2748
+ }
2749
+ };
2664
2750
  // Printed by bare `lattris get` and `lattris get --help` (spec: everything the
2665
2751
  // tool accepts is discoverable from the tool). Same discovery-by-depth pattern
2666
2752
  // capture uses, one level: get has no separate "form" to go deeper into, so the
2667
2753
  // nouns, the positional, and the flags all live at this one level.
2668
2754
  export const GET_HELP = [
2669
- 'get — read your memories back.',
2755
+ 'get — read your memories or a public wall.',
2670
2756
  '',
2671
2757
  'Nouns:',
2672
- ' memories list, newest first',
2673
- ' memory one, by full id or a >=4-char prefix \u2014 its edges ride along',
2758
+ ' memories your list, or one wall with --by <owner-handle>',
2759
+ ' memory one, by website address or id with --by <owner-handle> \u2014 its edges ride along',
2674
2760
  ' history one note\u2019s history: every change, newest first, and the head fingerprint',
2675
2761
  ' deleted your deleted items, with the fingerprint restore and erase need',
2676
2762
  ' questions the questions left on your tree, open first',
2677
2763
  '',
2678
2764
  'Usage:',
2679
2765
  ' 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]',
2766
+ ' lattris get memories --by <owner-handle> [--limit <n>] [--cursor <token>] [--content 0|1]',
2767
+ ' lattris get memory <website-address|id> [--by <owner-handle>] [--links 0|1] [--depth <n>] [--content 0|1]',
2681
2768
  ' lattris get memory <id-or-prefix> --revision <n> one revision, as history kept it',
2682
2769
  ' lattris get history <id-or-prefix> [--limit <n>] [--before <operation-id>]',
2683
2770
  ' lattris get deleted [--limit <n>] [--cursor <token>]',
@@ -2694,6 +2781,8 @@ export const GET_HELP = [
2694
2781
  ' (private — no grant at all)',
2695
2782
  ' --limit <n> page size, 1-100 (default 50)',
2696
2783
  ' --cursor <token> the nextCursor from a previous page',
2784
+ ' --by <handle> one wall on the website address; an id without a',
2785
+ ' session needs it, while a full address carries it',
2697
2786
  ' --shared-with-me list memories other people granted to you; each row',
2698
2787
  ' names its owner and whether it is on your graph',
2699
2788
  '',
@@ -2716,11 +2805,11 @@ export const GET_HELP = [
2716
2805
  '',
2717
2806
  'Every read of a note prints its fingerprint (the head of its history) and',
2718
2807
  '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.',
2808
+ 'grants as this reader sees them. An edit sends the fingerprint back as --base-fingerprint.',
2720
2809
  '',
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.',
2810
+ "A memory's web address is <site>/by/<owner-handle>/memories/<slug>-<memory-handle>.",
2811
+ 'A /by read asks for JSON with Accept: application/json; it sends a bearer',
2812
+ 'only when the session has a live one. `get memory` prints the address at a TTY.',
2724
2813
  '',
2725
2814
  'Aliases: read, show, view, fetch → get'
2726
2815
  ].join('\n');
@@ -2747,21 +2836,113 @@ const GET_NOUNS = new Set([
2747
2836
  // when the build wrote one, and is ABSENT rather than guessed when it did not —
2748
2837
  // `tsc` alone does not write that stamp, the install path does, so a package
2749
2838
  // built by CI carries it only if CI writes it.
2839
+ const runtimeJson = (path) => {
2840
+ try {
2841
+ return JSON.parse(readFileSync(path, 'utf8'));
2842
+ }
2843
+ catch {
2844
+ return undefined;
2845
+ }
2846
+ };
2847
+ const currentPackageVersion = () => {
2848
+ const here = dirname(fileURLToPath(import.meta.url));
2849
+ const pkg = runtimeJson(join(here, '..', 'package.json'));
2850
+ return typeof pkg?.version === 'string' ? pkg.version : 'unknown';
2851
+ };
2750
2852
  const printVersion = () => {
2751
2853
  const here = dirname(fileURLToPath(import.meta.url));
2752
- const readJson = (path) => {
2854
+ const build = runtimeJson(join(here, 'build.json'));
2855
+ const version = currentPackageVersion();
2856
+ const commit = typeof build?.commit === 'string' ? build.commit.slice(0, 12) : undefined;
2857
+ process.stdout.write(commit ? `lattris ${version} (${commit})\n` : `lattris ${version}\n`);
2858
+ };
2859
+ const VERSION_CHECK_URL = 'https://registry.npmjs.org/lattris/latest';
2860
+ const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
2861
+ const VERSION_CHECK_TIMEOUT_MS = 1500;
2862
+ const parseVersion = (version) => {
2863
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
2864
+ if (!match)
2865
+ return undefined;
2866
+ return {
2867
+ core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
2868
+ prerelease: match[4] === undefined ? null : match[4].split('.')
2869
+ };
2870
+ };
2871
+ // SemVer precedence without adding a runtime dependency to the zero-dependency
2872
+ // CLI. Returns negative when left is older, positive when it is newer.
2873
+ const compareVersions = (left, right) => {
2874
+ const a = parseVersion(left);
2875
+ const b = parseVersion(right);
2876
+ if (!a || !b)
2877
+ return undefined;
2878
+ for (let i = 0; i < a.core.length; i++) {
2879
+ if (a.core[i] < b.core[i])
2880
+ return -1;
2881
+ if (a.core[i] > b.core[i])
2882
+ return 1;
2883
+ }
2884
+ if (a.prerelease === null && b.prerelease === null)
2885
+ return 0;
2886
+ if (a.prerelease === null)
2887
+ return 1;
2888
+ if (b.prerelease === null)
2889
+ return -1;
2890
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
2891
+ for (let i = 0; i < length; i++) {
2892
+ const av = a.prerelease[i];
2893
+ const bv = b.prerelease[i];
2894
+ if (av === undefined)
2895
+ return -1;
2896
+ if (bv === undefined)
2897
+ return 1;
2898
+ if (av === bv)
2899
+ continue;
2900
+ const aNumber = /^\d+$/.test(av);
2901
+ const bNumber = /^\d+$/.test(bv);
2902
+ if (aNumber && bNumber)
2903
+ return BigInt(av) < BigInt(bv) ? -1 : 1;
2904
+ if (aNumber !== bNumber)
2905
+ return aNumber ? -1 : 1;
2906
+ return av < bv ? -1 : 1;
2907
+ }
2908
+ return 0;
2909
+ };
2910
+ // A courtesy, never a gate: every read, request, parse and write failure is
2911
+ // swallowed, and this function never changes the command's exit code. Startup
2912
+ // launches it without awaiting it, so the command begins while the registry
2913
+ // request is in flight; the bounded request cannot hold a quick command open
2914
+ // indefinitely.
2915
+ export const checkForNewVersion = async ({ currentVersion, stateDir, now = Date.now(), fetchImpl = fetch }) => {
2916
+ try {
2917
+ const cachePath = join(stateDir, 'version-check.json');
2918
+ const cache = runtimeJson(cachePath);
2919
+ if (typeof cache?.checkedAt === 'number' && now - cache.checkedAt <= VERSION_CHECK_TTL_MS) {
2920
+ return;
2921
+ }
2922
+ const response = await fetchImpl(VERSION_CHECK_URL, {
2923
+ headers: { Accept: 'application/json' },
2924
+ signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS)
2925
+ });
2926
+ if (!response.ok)
2927
+ return;
2928
+ const body = (await response.json());
2929
+ if (typeof body.version !== 'string' || parseVersion(body.version) === undefined)
2930
+ return;
2753
2931
  try {
2754
- return JSON.parse(readFileSync(path, 'utf8'));
2932
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
2933
+ writeFileSync(cachePath, `${JSON.stringify({ checkedAt: now, latestVersion: body.version })}\n`, { mode: 0o600 });
2755
2934
  }
2756
2935
  catch {
2757
- return undefined;
2936
+ // An unwritable cache means a later startup may retry; it never makes
2937
+ // this command fail or hides an update the registry already answered.
2758
2938
  }
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`);
2939
+ if (compareVersions(currentVersion, body.version) === -1) {
2940
+ process.stderr.write(`lattris ${currentVersion} → ${body.version} available: npm update -g lattris\n`);
2941
+ }
2942
+ }
2943
+ catch {
2944
+ // Offline, timed out, malformed response, unreadable cache: all silent.
2945
+ }
2765
2946
  };
2766
2947
  const HELP_TOKENS = new Set(['--help', '-h', 'help']);
2767
2948
  export const LOGIN_HELP = [
@@ -2933,6 +3114,11 @@ const get = async (cfg, noun, rest) => {
2933
3114
  return;
2934
3115
  }
2935
3116
  if (flags.revision !== undefined) {
3117
+ if (flags.by !== undefined || /^https?:\/\//i.test(id)) {
3118
+ process.stderr.write('A revision read uses your own id or prefix; --by and website addresses do not apply.\n');
3119
+ process.exitCode = EXIT.USAGE;
3120
+ return;
3121
+ }
2936
3122
  const walkFlag = ['links', 'depth', 'content'].find((k) => flags[k] !== undefined);
2937
3123
  if (walkFlag !== undefined) {
2938
3124
  process.stderr.write(`--${walkFlag} does not apply to a revision read.\n`);
@@ -2942,7 +3128,7 @@ const get = async (cfg, noun, rest) => {
2942
3128
  await getRevision(cfg, id, flags.revision);
2943
3129
  return;
2944
3130
  }
2945
- await getMemory(cfg, id, { links: flags.links, depth: flags.depth, content: flags.content });
3131
+ await getMemory(cfg, id, { links: flags.links, depth: flags.depth, content: flags.content }, flags.by);
2946
3132
  }
2947
3133
  else if (noun === 'memories') {
2948
3134
  // The symmetric refusal: a walk lever on a list would be parsed clean and
@@ -2953,10 +3139,22 @@ const get = async (cfg, noun, rest) => {
2953
3139
  process.exitCode = EXIT.USAGE;
2954
3140
  return;
2955
3141
  }
2956
- await getMemories(cfg, flags);
3142
+ if (flags.by !== undefined) {
3143
+ const ownerOnly = ['type', 'intent', 'q', 'with', 'sharedWithMe'].find((key) => flags[key] !== undefined);
3144
+ if (ownerOnly !== undefined) {
3145
+ const flagName = ownerOnly === 'sharedWithMe' ? 'shared-with-me' : ownerOnly;
3146
+ process.stderr.write(`--${flagName} does not apply to a wall listing.\n`);
3147
+ process.exitCode = EXIT.USAGE;
3148
+ return;
3149
+ }
3150
+ await getWallMemories(cfg, flags.by, flags);
3151
+ }
3152
+ else {
3153
+ await getMemories(cfg, flags);
3154
+ }
2957
3155
  }
2958
3156
  else {
2959
- process.stderr.write('An id (or unique prefix) is required for a single memory. To list them all: lattris get memories\n');
3157
+ process.stderr.write('A website address or id is required for a single memory. To list your own: lattris get memories\n');
2960
3158
  process.exitCode = EXIT.USAGE;
2961
3159
  }
2962
3160
  };
@@ -6606,10 +6804,14 @@ export const main = async () => {
6606
6804
  ? VERB_ALIASES[rawCommand]
6607
6805
  : rawCommand
6608
6806
  : 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.
6807
+ void checkForNewVersion({
6808
+ currentVersion: currentPackageVersion(),
6809
+ stateDir: join(stateHome(), 'lattris')
6810
+ });
6811
+ // HELP BEFORE COMMAND WORK: a help token in the subcommand's args means
6812
+ // help — never a session or API call, exit 0. The best-effort registry
6813
+ // courtesy above is startup work rather than command work. Commands whose
6814
+ // own handler already treats help first return null and fall through unchanged.
6613
6815
  const subArgs = argv.slice(3);
6614
6816
  if (command !== undefined && subArgs.some((t) => HELP_TOKENS.has(t))) {
6615
6817
  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.3",
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": [