lattris 0.1.0 → 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/README.md +21 -12
- package/dist/build.json +1 -0
- package/dist/main.js +526 -160
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -57,22 +57,32 @@ Run `lattris help <command>` for any of them, or `lattris --help` for the full l
|
|
|
57
57
|
|
|
58
58
|
## Where it keeps things
|
|
59
59
|
|
|
60
|
-
Credentials live
|
|
61
|
-
logout` removes them. Nothing is written to the directory you run
|
|
60
|
+
Credentials live at `~/lattris-machines/<account>/<env>/session.json` and are readable
|
|
61
|
+
only by you. `lattris logout` removes them. Nothing is written to the directory you run
|
|
62
|
+
it from.
|
|
62
63
|
|
|
63
|
-
##
|
|
64
|
+
## Release
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
the registry records an attestation linking the tarball to the commit on `main` it was
|
|
67
|
-
built from and the workflow run that built it. The first published version was published
|
|
68
|
-
by hand to claim the name and carries no attestation, so do not assume one — check:
|
|
66
|
+
Releasing has two steps. First, open a normal version-bump PR from a branch:
|
|
69
67
|
|
|
70
68
|
```bash
|
|
71
|
-
|
|
69
|
+
cd apps/lattris/cli
|
|
70
|
+
npm version patch --no-git-tag-version
|
|
71
|
+
git add package.json
|
|
72
|
+
git commit -m "chore(lattris-cli): bump package version"
|
|
72
73
|
```
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
After that PR merges, whoever holds npm publish rights updates a clean local `main` and runs:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd apps/lattris/cli
|
|
79
|
+
pnpm release
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The release script fetches `origin/main` and refuses unless the current branch is `main`,
|
|
83
|
+
`HEAD` equals `origin/main`, and the whole working tree is clean. It builds the CLI, writes
|
|
84
|
+
the release commit to `dist/build.json`, publishes to npm, and pushes the annotated
|
|
85
|
+
`lattris-v<version>` tag. `lattris --version` then names that release commit.
|
|
76
86
|
|
|
77
87
|
## Source, terms and reporting
|
|
78
88
|
|
|
@@ -82,5 +92,4 @@ Nothing here grants you rights beyond installing and running the tool, and no
|
|
|
82
92
|
permission should be inferred from the absence of a licence file.
|
|
83
93
|
|
|
84
94
|
There is no public issue tracker, so this package names none — a link you cannot
|
|
85
|
-
open is worse than no link.
|
|
86
|
-
series@lattris.com.
|
|
95
|
+
open is worse than no link.
|
package/dist/build.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"commit":"cecf7e60eb450bce092dc992155ff77002fc1379","builtAt":"2026-09-15T22:27:41Z"}
|
package/dist/main.js
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
// deputy / scoped-grant machinery here. It reuses the exact SPA-minted-Hydra-JWT
|
|
15
15
|
// path the API already validates; zero server-side auth changes.
|
|
16
16
|
//
|
|
17
|
-
// Zero runtime dependencies — Node ≥
|
|
18
|
-
// node:crypto).
|
|
19
|
-
//
|
|
20
|
-
// exported for unit tests; `main()` runs only
|
|
17
|
+
// Zero runtime dependencies — the compiled bin runs on Node ≥ 22 built-ins only
|
|
18
|
+
// (global fetch, node:http, node:crypto). Running the TypeScript source directly
|
|
19
|
+
// still uses the repo's Node 24 toolchain via `node --experimental-strip-types
|
|
20
|
+
// src/main.ts <cmd>`. Functions are exported for unit tests; `main()` runs only
|
|
21
|
+
// when this file is the entry point.
|
|
21
22
|
// Strip-only mode erases TYPES, never TS-only syntax: no constructor parameter
|
|
22
23
|
// properties, enums, or namespaces anywhere in src/ — a parameter property
|
|
23
24
|
// crashes the no-build run outright (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
|
|
@@ -30,11 +31,11 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
|
30
31
|
import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
31
32
|
import { createServer } from 'node:http';
|
|
32
33
|
import { homedir } from 'node:os';
|
|
33
|
-
import { dirname, join, resolve, sep } from 'node:path';
|
|
34
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
34
35
|
import { createInterface } from 'node:readline';
|
|
35
36
|
import { fileURLToPath } from 'node:url';
|
|
36
|
-
import { extractChain } from
|
|
37
|
-
import { ChainSaveError, driveChain, loadReceipts, saveReceipts } from
|
|
37
|
+
import { extractChain } from "./extract-claude-jsonl.js";
|
|
38
|
+
import { ChainSaveError, driveChain, loadReceipts, saveReceipts } from "./save-session-chain.js";
|
|
38
39
|
// ─── exit codes ────────────────────────────────────────────────────────────────
|
|
39
40
|
// Stable, meaningful exit codes so a script can tell failure kinds apart without
|
|
40
41
|
// parsing prose (CLI client-contract spec §3). Code 4 is the machine twin of a
|
|
@@ -48,6 +49,18 @@ export const EXIT = {
|
|
|
48
49
|
DOOR: 4 // a door must be walked first (e.g. person:uninitiated)
|
|
49
50
|
};
|
|
50
51
|
const stripTrailingSlash = (s) => s.replace(/\/+$/, '');
|
|
52
|
+
// A published package contains only the runtime files allow-listed in
|
|
53
|
+
// package.json. A worktree build also contains installed-check.js, whose whole
|
|
54
|
+
// purpose is to judge the repo-backed symlink install. That absent/present seam
|
|
55
|
+
// lets a clean npm install go to the product by default while source and worktree
|
|
56
|
+
// development keep their existing local default. Explicit --env, .lattris and
|
|
57
|
+
// LATTRIS_ENV always win over this fallback.
|
|
58
|
+
export const defaultEnvironmentForEntry = (entryUrl = import.meta.url) => {
|
|
59
|
+
const entryPath = fileURLToPath(entryUrl);
|
|
60
|
+
const entryDir = dirname(entryPath);
|
|
61
|
+
const isBuiltMain = basename(entryPath) === 'main.js' && basename(entryDir) === 'dist';
|
|
62
|
+
return isBuiltMain && !existsSync(join(entryDir, 'installed-check.js')) ? 'prod' : 'local';
|
|
63
|
+
};
|
|
51
64
|
// The environments registry — a JSON file at a well-known location. Missing
|
|
52
65
|
// file or missing key → the built-in localdev defaults below, so the CLI
|
|
53
66
|
// works out of the box with no registry (the existing behavior).
|
|
@@ -646,6 +659,7 @@ export const CLI_COMMAND_WORDS = new Set([
|
|
|
646
659
|
'invite',
|
|
647
660
|
'share',
|
|
648
661
|
'unshare',
|
|
662
|
+
'consent',
|
|
649
663
|
'restore',
|
|
650
664
|
'erase',
|
|
651
665
|
'resend',
|
|
@@ -1005,6 +1019,18 @@ export const validAccessToken = async (cfg) => {
|
|
|
1005
1019
|
throw new Error('not-logged-in');
|
|
1006
1020
|
}
|
|
1007
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
|
+
};
|
|
1008
1034
|
// Render a door to STDERR (stdout stays clean for piped data):
|
|
1009
1035
|
// <message>
|
|
1010
1036
|
// Run: lattris <next.action>
|
|
@@ -1160,10 +1186,13 @@ export const edgeRefusal = (status, headers, bytes) => {
|
|
|
1160
1186
|
// ALREADY rendered the door and the caller just propagates the exit code. 204 (the
|
|
1161
1187
|
// delete route's success) has no body to parse.
|
|
1162
1188
|
const HTTP_NO_CONTENT = 204;
|
|
1163
|
-
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}`;
|
|
1164
1193
|
const res = await fetch(url, {
|
|
1165
1194
|
...init,
|
|
1166
|
-
headers
|
|
1195
|
+
headers
|
|
1167
1196
|
});
|
|
1168
1197
|
if (!res.ok) {
|
|
1169
1198
|
// Bytes, not text: the edge excerpt is cut by byte before decoding, and
|
|
@@ -1189,11 +1218,8 @@ export const apiFetch = async (accessToken, url, init = {}) => {
|
|
|
1189
1218
|
data = JSON.parse(rawText);
|
|
1190
1219
|
}
|
|
1191
1220
|
catch {
|
|
1192
|
-
// A
|
|
1193
|
-
|
|
1194
|
-
// wrong; without this branch it surfaces as "Unexpected token '<'"
|
|
1195
|
-
// AFTER a successful login.
|
|
1196
|
-
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`);
|
|
1197
1223
|
return { ok: false, body: {}, exit: EXIT.GENERIC };
|
|
1198
1224
|
}
|
|
1199
1225
|
return { ok: true, data, rawText };
|
|
@@ -1201,11 +1227,10 @@ export const apiFetch = async (accessToken, url, init = {}) => {
|
|
|
1201
1227
|
// ─── the client-makes-no-decisions output rule ──────────────────────────────────
|
|
1202
1228
|
// A person reading a terminal and a program reading a pipe want different things
|
|
1203
1229
|
// from the SAME response: a person wants the labelled/columnar form, a program
|
|
1204
|
-
// wants
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
// structured output can never drift from the API (deliberate).
|
|
1230
|
+
// wants the API's wire body without re-stringification. Piped output preserves
|
|
1231
|
+
// the response bytes (key order, whitespace and number formatting included) and
|
|
1232
|
+
// adds only a missing final newline, so successive shell output cannot run into
|
|
1233
|
+
// the JSON. A response that already ends in a newline is unchanged.
|
|
1209
1234
|
// renderHuman picks what a person needs to see; it never changes what a machine
|
|
1210
1235
|
// receives. An empty human rendering (e.g. no memories) writes nothing rather
|
|
1211
1236
|
// than a stray blank line.
|
|
@@ -1216,7 +1241,7 @@ export const emit = (data, rawText, renderHuman) => {
|
|
|
1216
1241
|
process.stdout.write(`${human}\n`);
|
|
1217
1242
|
}
|
|
1218
1243
|
else {
|
|
1219
|
-
process.stdout.write(rawText);
|
|
1244
|
+
process.stdout.write(rawText.endsWith('\n') ? rawText : `${rawText}\n`);
|
|
1220
1245
|
}
|
|
1221
1246
|
};
|
|
1222
1247
|
// The product word is "lattris number", never
|
|
@@ -1743,13 +1768,13 @@ export const buildCreateNoteBody = (content, flags, allowBannedWords = false) =>
|
|
|
1743
1768
|
// receipt. Not cosmetic: capturedAt is uneditable (PATCH carries only title/content),
|
|
1744
1769
|
// so this is the person's ONE immediate chance to catch a wrong-as-recorded
|
|
1745
1770
|
// timestamp — the only remedy past this point is delete + recapture.
|
|
1746
|
-
export const formatSaved = (b) => {
|
|
1771
|
+
export const formatSaved = (b, address) => {
|
|
1747
1772
|
const lines = [`Saved memory ${b.id} (type: ${b.type})`];
|
|
1748
1773
|
if (b.intent)
|
|
1749
1774
|
lines.push(` intent ${b.intent}`);
|
|
1750
1775
|
// title is echoed back from the caller's own --title — sanitized like every
|
|
1751
|
-
// other rendered title
|
|
1752
|
-
//
|
|
1776
|
+
// other rendered title. The machine receipt is JSON; this formatter is only
|
|
1777
|
+
// the terminal channel.
|
|
1753
1778
|
if (b.title)
|
|
1754
1779
|
lines.push(` title ${sanitizeForTerminal(b.title)}`);
|
|
1755
1780
|
if (b.aiSummary)
|
|
@@ -1759,6 +1784,8 @@ export const formatSaved = (b) => {
|
|
|
1759
1784
|
lines.push(` fingerprint ${b.fingerprint}`);
|
|
1760
1785
|
if (b.revisionNo)
|
|
1761
1786
|
lines.push(` revisionNo ${b.revisionNo}`);
|
|
1787
|
+
if (address)
|
|
1788
|
+
lines.push(` address ${address}`);
|
|
1762
1789
|
return lines.join('\n');
|
|
1763
1790
|
};
|
|
1764
1791
|
// Read all of stdin. Strips a SINGLE trailing newline — the ubiquitous echo/heredoc
|
|
@@ -2304,14 +2331,22 @@ const captureMemory = async (cfg, args) => {
|
|
|
2304
2331
|
process.exitCode = result.exit;
|
|
2305
2332
|
return;
|
|
2306
2333
|
}
|
|
2307
|
-
|
|
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));
|
|
2308
2343
|
// Capture-and-link: a CLI-side composition of the two doors the wire
|
|
2309
2344
|
// actually has (capture, then edge) — never a pretended atomicity.
|
|
2310
2345
|
if (flags.linkedFrom !== undefined || flags.linksTo !== undefined) {
|
|
2311
2346
|
// --linked-from: the existing node points AT the new one; --links-to:
|
|
2312
2347
|
// the new node points outward.
|
|
2313
|
-
const fromRef = flags.linkedFrom ??
|
|
2314
|
-
const toId = flags.linkedFrom !== undefined ?
|
|
2348
|
+
const fromRef = flags.linkedFrom ?? record.id;
|
|
2349
|
+
const toId = flags.linkedFrom !== undefined ? record.id : flags.linksTo;
|
|
2315
2350
|
const edge = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fromRef)}/edges`, {
|
|
2316
2351
|
method: 'POST',
|
|
2317
2352
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -2343,7 +2378,9 @@ export const formatAudiences = (audiences) => audiences.length === 0 ? '(private
|
|
|
2343
2378
|
// title is the caller's own free text — sanitized before a TTY renders it (the
|
|
2344
2379
|
// piped/JSON path never calls this formatter). id/capturedAt/intent are all
|
|
2345
2380
|
// API-constrained shapes, never free text.
|
|
2346
|
-
export const formatBlockLine = (b) =>
|
|
2381
|
+
export const formatBlockLine = (b) => b.ownerHandle === undefined
|
|
2382
|
+
? `${b.id} ${b.handle} ${b.capturedAt} ${b.intent ?? '-'} ${b.visibility.public ? 'public' : '-'} ${sanitizeForTerminal(b.title ?? '(untitled)')}`
|
|
2383
|
+
: `${b.id} ${b.handle} ${b.capturedAt} by ${sanitizeForTerminal(b.ownerDisplayName ?? b.ownerHandle)} (@${b.ownerHandle}) ${b.consented ? 'on graph' : 'off graph'} ${sanitizeForTerminal(b.title ?? '(untitled)')}`;
|
|
2347
2384
|
// ─── composing a memory's web address ──────────────────────────────────────────
|
|
2348
2385
|
// The client COMPOSES an address; only the server RESOLVES one, and it resolves
|
|
2349
2386
|
// on the block handle alone. That split is why carrying these rules here is
|
|
@@ -2365,10 +2402,30 @@ export const composeAddress = (webUrl, personHandle, block) => {
|
|
|
2365
2402
|
const ref = slug ? `${slug}-${block.handle}` : block.handle;
|
|
2366
2403
|
return `${webUrl}/by/${personHandle}/memories/${ref}`;
|
|
2367
2404
|
};
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
const
|
|
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) => {
|
|
2372
2429
|
try {
|
|
2373
2430
|
const res = await fetch(`${cfg.apiUrl}/person/me`, {
|
|
2374
2431
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
@@ -2376,12 +2433,17 @@ const composeAddressFor = async (cfg, accessToken, block) => {
|
|
|
2376
2433
|
if (!res.ok)
|
|
2377
2434
|
return undefined;
|
|
2378
2435
|
const { handle } = (await res.json());
|
|
2379
|
-
return handle
|
|
2436
|
+
return typeof handle === 'string' && handle.length > 0 ? handle : undefined;
|
|
2380
2437
|
}
|
|
2381
2438
|
catch {
|
|
2382
2439
|
return undefined;
|
|
2383
2440
|
}
|
|
2384
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
|
+
};
|
|
2385
2447
|
// title/content are the caller's own free text — sanitized before a TTY ever
|
|
2386
2448
|
// renders them (piped JSON stays wire-verbatim; that channel is machine data by
|
|
2387
2449
|
// contract, sanitization is a rendering concern, not a storage one). id/type/
|
|
@@ -2509,8 +2571,8 @@ export const formatNodeWalk = (w, address) => {
|
|
|
2509
2571
|
// no other lever restores the bare pre-walk read, byte-identical on the
|
|
2510
2572
|
// wire. Lever values ride through verbatim — the API owns validation (depth
|
|
2511
2573
|
// range, depth-requires-links); the client makes no decisions.
|
|
2512
|
-
export const buildGetMemoryUrl = (
|
|
2513
|
-
const url = new URL(`${
|
|
2574
|
+
export const buildGetMemoryUrl = (webUrl, ownerHandle, ref, flags) => {
|
|
2575
|
+
const url = new URL(`${webUrl}/by/${encodeURIComponent(ownerHandle)}/memories/${encodeURIComponent(ref)}`);
|
|
2514
2576
|
const links = flags.links ?? '1';
|
|
2515
2577
|
const bare = links === '0' && flags.depth === undefined && flags.content === undefined;
|
|
2516
2578
|
if (!bare) {
|
|
@@ -2522,51 +2584,63 @@ export const buildGetMemoryUrl = (apiUrl, idOrPrefix, flags) => {
|
|
|
2522
2584
|
}
|
|
2523
2585
|
return url;
|
|
2524
2586
|
};
|
|
2525
|
-
const getMemory = async (cfg,
|
|
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;
|
|
2526
2602
|
let accessToken;
|
|
2527
|
-
|
|
2528
|
-
accessToken = await
|
|
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
|
+
}
|
|
2529
2614
|
}
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
process.exitCode = EXIT.
|
|
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;
|
|
2533
2618
|
return;
|
|
2534
2619
|
}
|
|
2535
|
-
|
|
2620
|
+
accessToken ??= await optionalAccessToken(cfg);
|
|
2621
|
+
const url = buildGetMemoryUrl(cfg.webUrl, ownerHandle, address?.ref ?? idOrAddress, flags);
|
|
2536
2622
|
const enveloped = url.search !== '';
|
|
2537
|
-
const result = await apiFetch(accessToken, url
|
|
2623
|
+
const result = await apiFetch(accessToken, url, {
|
|
2624
|
+
headers: { Accept: 'application/json' }
|
|
2625
|
+
}, 'web');
|
|
2538
2626
|
if (!result.ok) {
|
|
2539
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
|
+
}
|
|
2540
2631
|
process.exitCode = result.exit;
|
|
2541
2632
|
return;
|
|
2542
2633
|
}
|
|
2543
|
-
// The address needs the caller's OWN handle, which this response does not
|
|
2544
|
-
// carry — so it costs a second request. Paid ONLY at a TTY: piped output is
|
|
2545
|
-
// the wire verbatim and gains nothing from an address, so a script never
|
|
2546
|
-
// pays for a line it will not receive. Fetched rather than cached: caching
|
|
2547
|
-
// would be safe (a handle is assigned once and never reassigned, so it
|
|
2548
|
-
// cannot go stale), but it would mean writing state from a read path and
|
|
2549
|
-
// carrying a miss-path for sessions that predate it — for one extra request
|
|
2550
|
-
// on an interactive command that is already making one. Cache later if it
|
|
2551
|
-
// ever matters; the immutability that makes it safe is not going anywhere.
|
|
2552
|
-
//
|
|
2553
|
-
// Deliberately NOT through apiFetch: that renders a door on failure and hands
|
|
2554
|
-
// back an exit code, which is right for a call the caller asked for and wrong
|
|
2555
|
-
// for this one. The record is already in hand; a convenience that cannot be
|
|
2556
|
-
// built should vanish quietly, not print "Session invalid or expired" over a
|
|
2557
|
-
// memory that printed perfectly well.
|
|
2558
|
-
// Bare or enveloped, the block whose address we'd compose is the same
|
|
2559
|
-
// record — the envelope's anchor carries handle+title either way.
|
|
2560
2634
|
const record = enveloped ? result.data.node : result.data;
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
}
|
|
2635
|
+
const displayAddress = process.stdout.isTTY
|
|
2636
|
+
? composeAddress(cfg.webUrl, ownerHandle, record)
|
|
2637
|
+
: undefined;
|
|
2565
2638
|
emit(result.data, result.rawText, () => enveloped
|
|
2566
|
-
? formatNodeWalk(result.data,
|
|
2567
|
-
: formatMemoryDetail(result.data,
|
|
2639
|
+
? formatNodeWalk(result.data, displayAddress)
|
|
2640
|
+
: formatMemoryDetail(result.data, displayAddress));
|
|
2568
2641
|
};
|
|
2569
2642
|
const GET_FLAG_NAMES = {
|
|
2643
|
+
'--by': 'by',
|
|
2570
2644
|
'--revision': 'revision',
|
|
2571
2645
|
'--type': 'type',
|
|
2572
2646
|
'--intent': 'intent',
|
|
@@ -2580,7 +2654,7 @@ const GET_FLAG_NAMES = {
|
|
|
2580
2654
|
};
|
|
2581
2655
|
// Which side of get each flag belongs to — the applicability refusals in the
|
|
2582
2656
|
// dispatcher read these, so a new flag must pick a side to compile at all.
|
|
2583
|
-
const LIST_ONLY_FLAGS = ['type', 'intent', 'limit', 'cursor', 'q', 'with'];
|
|
2657
|
+
const LIST_ONLY_FLAGS = ['type', 'intent', 'limit', 'cursor', 'q', 'with', 'sharedWithMe'];
|
|
2584
2658
|
// --content is the shape lever on BOTH reads: on the one memory it drops the
|
|
2585
2659
|
// words, on the list it drops the excerpts. The rest of these are the walk's.
|
|
2586
2660
|
const WALK_ONLY_FLAGS = ['links', 'depth', 'revision'];
|
|
@@ -2614,16 +2688,15 @@ const getMemories = async (cfg, flags) => {
|
|
|
2614
2688
|
url.searchParams.set('audience', flags.with);
|
|
2615
2689
|
if (flags.content !== undefined)
|
|
2616
2690
|
url.searchParams.set('content', flags.content);
|
|
2691
|
+
if (flags.sharedWithMe)
|
|
2692
|
+
url.searchParams.set('sharedWithMe', '1');
|
|
2617
2693
|
const result = await apiFetch(accessToken, url);
|
|
2618
2694
|
if (!result.ok) {
|
|
2619
2695
|
process.exitCode = result.exit;
|
|
2620
2696
|
return;
|
|
2621
2697
|
}
|
|
2622
2698
|
emit(result.data, result.rawText, formatMemoryPage);
|
|
2623
|
-
if (result.data.
|
|
2624
|
-
process.stderr.write('No memories yet.\n');
|
|
2625
|
-
}
|
|
2626
|
-
else if (result.data.nextCursor) {
|
|
2699
|
+
if (result.data.nextCursor) {
|
|
2627
2700
|
// Reconstruct the FULL continuation — active filters + the new cursor — so
|
|
2628
2701
|
// copy-pasting the hint never silently drops back to an unfiltered page.
|
|
2629
2702
|
// ARGV elements through the one renderer, never a preformed string: a
|
|
@@ -2637,6 +2710,35 @@ const getMemories = async (cfg, flags) => {
|
|
|
2637
2710
|
argv.push('--q', flags.q);
|
|
2638
2711
|
if (flags.with !== undefined)
|
|
2639
2712
|
argv.push('--with', flags.with);
|
|
2713
|
+
if (flags.limit !== undefined)
|
|
2714
|
+
argv.push('--limit', flags.limit);
|
|
2715
|
+
if (flags.content !== undefined)
|
|
2716
|
+
argv.push('--content', flags.content);
|
|
2717
|
+
if (flags.sharedWithMe)
|
|
2718
|
+
argv.push('--shared-with-me');
|
|
2719
|
+
argv.push('--cursor', result.data.nextCursor);
|
|
2720
|
+
process.stderr.write(`More: ${renderRunHint(argv)}\n`);
|
|
2721
|
+
}
|
|
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];
|
|
2640
2742
|
if (flags.limit !== undefined)
|
|
2641
2743
|
argv.push('--limit', flags.limit);
|
|
2642
2744
|
if (flags.content !== undefined)
|
|
@@ -2650,18 +2752,19 @@ const getMemories = async (cfg, flags) => {
|
|
|
2650
2752
|
// capture uses, one level: get has no separate "form" to go deeper into, so the
|
|
2651
2753
|
// nouns, the positional, and the flags all live at this one level.
|
|
2652
2754
|
export const GET_HELP = [
|
|
2653
|
-
'get — read your memories
|
|
2755
|
+
'get — read your memories or a public wall.',
|
|
2654
2756
|
'',
|
|
2655
2757
|
'Nouns:',
|
|
2656
|
-
' memories list,
|
|
2657
|
-
' memory one, by
|
|
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',
|
|
2658
2760
|
' history one note\u2019s history: every change, newest first, and the head fingerprint',
|
|
2659
2761
|
' deleted your deleted items, with the fingerprint restore and erase need',
|
|
2660
2762
|
' questions the questions left on your tree, open first',
|
|
2661
2763
|
'',
|
|
2662
2764
|
'Usage:',
|
|
2663
|
-
' lattris get memories [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--content 0|1]',
|
|
2664
|
-
' lattris get
|
|
2765
|
+
' lattris get memories [--shared-with-me] [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--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]',
|
|
2665
2768
|
' lattris get memory <id-or-prefix> --revision <n> one revision, as history kept it',
|
|
2666
2769
|
' lattris get history <id-or-prefix> [--limit <n>] [--before <operation-id>]',
|
|
2667
2770
|
' lattris get deleted [--limit <n>] [--cursor <token>]',
|
|
@@ -2678,6 +2781,10 @@ export const GET_HELP = [
|
|
|
2678
2781
|
' (private — no grant at all)',
|
|
2679
2782
|
' --limit <n> page size, 1-100 (default 50)',
|
|
2680
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',
|
|
2786
|
+
' --shared-with-me list memories other people granted to you; each row',
|
|
2787
|
+
' names its owner and whether it is on your graph',
|
|
2681
2788
|
'',
|
|
2682
2789
|
' --links 0|1 edges on the one memory (default 1): direction, verb,',
|
|
2683
2790
|
' reason, and each neighbour\u2019s finding aid. --links 0',
|
|
@@ -2698,11 +2805,11 @@ export const GET_HELP = [
|
|
|
2698
2805
|
'',
|
|
2699
2806
|
'Every read of a note prints its fingerprint (the head of its history) and',
|
|
2700
2807
|
'revisionNo; every read prints structureHash, a hash of the block\u2019s links and',
|
|
2701
|
-
'grants as
|
|
2808
|
+
'grants as this reader sees them. An edit sends the fingerprint back as --base-fingerprint.',
|
|
2702
2809
|
'',
|
|
2703
|
-
"A memory's web address is <site>/by/<
|
|
2704
|
-
'
|
|
2705
|
-
'
|
|
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.',
|
|
2706
2813
|
'',
|
|
2707
2814
|
'Aliases: read, show, view, fetch → get'
|
|
2708
2815
|
].join('\n');
|
|
@@ -2729,21 +2836,113 @@ const GET_NOUNS = new Set([
|
|
|
2729
2836
|
// when the build wrote one, and is ABSENT rather than guessed when it did not —
|
|
2730
2837
|
// `tsc` alone does not write that stamp, the install path does, so a package
|
|
2731
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
|
+
};
|
|
2732
2852
|
const printVersion = () => {
|
|
2733
2853
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2734
|
-
const
|
|
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;
|
|
2735
2931
|
try {
|
|
2736
|
-
|
|
2932
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
2933
|
+
writeFileSync(cachePath, `${JSON.stringify({ checkedAt: now, latestVersion: body.version })}\n`, { mode: 0o600 });
|
|
2737
2934
|
}
|
|
2738
2935
|
catch {
|
|
2739
|
-
|
|
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.
|
|
2740
2938
|
}
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
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
|
+
}
|
|
2747
2946
|
};
|
|
2748
2947
|
const HELP_TOKENS = new Set(['--help', '-h', 'help']);
|
|
2749
2948
|
export const LOGIN_HELP = [
|
|
@@ -2792,6 +2991,8 @@ export const commandHelpText = (command, args) => {
|
|
|
2792
2991
|
return ERASE_HELP;
|
|
2793
2992
|
case 'contact':
|
|
2794
2993
|
return CONTACT_HELP;
|
|
2994
|
+
case 'consent':
|
|
2995
|
+
return CONSENT_HELP;
|
|
2795
2996
|
case 'capture': {
|
|
2796
2997
|
// The form's own help when a form was named — mirroring the
|
|
2797
2998
|
// sub-dispatcher's routing (positional 'session' OR --type session);
|
|
@@ -2871,7 +3072,15 @@ const get = async (cfg, noun, rest) => {
|
|
|
2871
3072
|
await getDeleted(cfg, rest);
|
|
2872
3073
|
return;
|
|
2873
3074
|
}
|
|
2874
|
-
const
|
|
3075
|
+
const sharedWithMeCount = rest.filter((arg) => arg === '--shared-with-me').length;
|
|
3076
|
+
if (sharedWithMeCount > 1) {
|
|
3077
|
+
process.stderr.write('--shared-with-me was given more than once. Send it once.\n');
|
|
3078
|
+
process.exitCode = EXIT.USAGE;
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
const { flags, positionals, unknown } = parseArgs(GET_FLAG_NAMES, rest.filter((arg) => arg !== '--shared-with-me'));
|
|
3082
|
+
if (sharedWithMeCount === 1)
|
|
3083
|
+
flags.sharedWithMe = '1';
|
|
2875
3084
|
if (unknown) {
|
|
2876
3085
|
process.stderr.write(`Unknown flag: ${unknown}\n`);
|
|
2877
3086
|
process.exitCode = EXIT.USAGE;
|
|
@@ -2905,6 +3114,11 @@ const get = async (cfg, noun, rest) => {
|
|
|
2905
3114
|
return;
|
|
2906
3115
|
}
|
|
2907
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
|
+
}
|
|
2908
3122
|
const walkFlag = ['links', 'depth', 'content'].find((k) => flags[k] !== undefined);
|
|
2909
3123
|
if (walkFlag !== undefined) {
|
|
2910
3124
|
process.stderr.write(`--${walkFlag} does not apply to a revision read.\n`);
|
|
@@ -2914,7 +3128,7 @@ const get = async (cfg, noun, rest) => {
|
|
|
2914
3128
|
await getRevision(cfg, id, flags.revision);
|
|
2915
3129
|
return;
|
|
2916
3130
|
}
|
|
2917
|
-
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);
|
|
2918
3132
|
}
|
|
2919
3133
|
else if (noun === 'memories') {
|
|
2920
3134
|
// The symmetric refusal: a walk lever on a list would be parsed clean and
|
|
@@ -2925,10 +3139,22 @@ const get = async (cfg, noun, rest) => {
|
|
|
2925
3139
|
process.exitCode = EXIT.USAGE;
|
|
2926
3140
|
return;
|
|
2927
3141
|
}
|
|
2928
|
-
|
|
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
|
+
}
|
|
2929
3155
|
}
|
|
2930
3156
|
else {
|
|
2931
|
-
process.stderr.write('
|
|
3157
|
+
process.stderr.write('A website address or id is required for a single memory. To list your own: lattris get memories\n');
|
|
2932
3158
|
process.exitCode = EXIT.USAGE;
|
|
2933
3159
|
}
|
|
2934
3160
|
};
|
|
@@ -2936,6 +3162,10 @@ const get = async (cfg, noun, rest) => {
|
|
|
2936
3162
|
// like a note's content. Everything else is API-constrained shape.
|
|
2937
3163
|
export const formatQuestion = (q) => {
|
|
2938
3164
|
const lines = [`id ${q.id}`, `createdAt ${q.createdAt}`];
|
|
3165
|
+
if (q.kind === 'share') {
|
|
3166
|
+
lines.push(`shared by @${sanitizeForTerminal(q.sharerHandle ?? '(unknown)')}`, `memory ${q.aboutBlockId ?? '(missing)'}`, `seen ${q.seenAt ?? '(unseen)'}`);
|
|
3167
|
+
return lines.join('\n');
|
|
3168
|
+
}
|
|
2939
3169
|
if (q.aboutBlockId)
|
|
2940
3170
|
lines.push(`about ${q.aboutBlockId}`);
|
|
2941
3171
|
lines.push(q.closedAs === null
|
|
@@ -2945,7 +3175,7 @@ export const formatQuestion = (q) => {
|
|
|
2945
3175
|
// here, but the same sanitize-before-TTY law applies to any rendered field.
|
|
2946
3176
|
if (q.closeNote)
|
|
2947
3177
|
lines.push(`note ${sanitizeForTerminal(q.closeNote, Infinity)}`);
|
|
2948
|
-
lines.push('', sanitizeForTerminal(q.body, Infinity));
|
|
3178
|
+
lines.push('', sanitizeForTerminal(q.body ?? '', Infinity));
|
|
2949
3179
|
return lines.join('\n');
|
|
2950
3180
|
};
|
|
2951
3181
|
export const formatQuestionList = (qs) => qs.map((q) => formatQuestion(q)).join('\n\n');
|
|
@@ -2965,9 +3195,6 @@ const getQuestions = async (cfg, mine) => {
|
|
|
2965
3195
|
return;
|
|
2966
3196
|
}
|
|
2967
3197
|
emit(result.data, result.rawText, formatQuestionList);
|
|
2968
|
-
if (result.data.length === 0) {
|
|
2969
|
-
process.stderr.write(mine ? 'No questions asked yet.\n' : 'No questions.\n');
|
|
2970
|
-
}
|
|
2971
3198
|
};
|
|
2972
3199
|
// ask — POST /by/:handle/questions. THE BODY IS THE GRANT (v1 doctrine): the
|
|
2973
3200
|
// prose from stdin is exactly what crosses to the tree's owner — no
|
|
@@ -4170,9 +4397,9 @@ const update = async (cfg, noun, rest) => {
|
|
|
4170
4397
|
// touches it. So a link is not admission, and the CLI says so rather than
|
|
4171
4398
|
// letting a person discover it at the door.
|
|
4172
4399
|
//
|
|
4173
|
-
// Minting is
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4400
|
+
// Minting is open only on accounts with the server-side entitlement. The
|
|
4401
|
+
// server's refusal message rides the shared API error path unchanged. There is
|
|
4402
|
+
// no quota once open, and revoking one link does not close the door.
|
|
4176
4403
|
export const INVITE_HELP = [
|
|
4177
4404
|
'invite — bring one person in.',
|
|
4178
4405
|
'',
|
|
@@ -4185,13 +4412,14 @@ export const INVITE_HELP = [
|
|
|
4185
4412
|
'when you send it.',
|
|
4186
4413
|
'',
|
|
4187
4414
|
'Usage:',
|
|
4188
|
-
' lattris invite
|
|
4189
|
-
' lattris invite --for
|
|
4415
|
+
' lattris invite <contact-name-or-id> [--yes]',
|
|
4416
|
+
' lattris invite --for <name-or-id> [--yes] compatibility spelling',
|
|
4417
|
+
'',
|
|
4418
|
+
'The contact must be one of yours. A name is matched exactly, ignoring case.',
|
|
4419
|
+
'If that name does not exist, a person contact is created before minting.',
|
|
4420
|
+
'Duplicate names are never guessed: use the contact id shown by',
|
|
4421
|
+
'`lattris contact list`.',
|
|
4190
4422
|
'',
|
|
4191
|
-
' --for optional; YOUR label for who this is for, so you can tell your',
|
|
4192
|
-
' outstanding links apart. It is shown on the invitation page and',
|
|
4193
|
-
' to you. It is not their name and not contact information — their',
|
|
4194
|
-
' real name comes from them, at signup.',
|
|
4195
4423
|
' --yes required when not at a terminal (there is no prompt to answer',
|
|
4196
4424
|
' there); at a terminal you are always asked.',
|
|
4197
4425
|
'',
|
|
@@ -4218,6 +4446,22 @@ const formatInvite = (cfg, i) => {
|
|
|
4218
4446
|
return `revoked${label}\n minted ${i.createdAt}`;
|
|
4219
4447
|
return `live${label}\n ${inviteLink(cfg, i.token)}\n minted ${i.createdAt}`;
|
|
4220
4448
|
};
|
|
4449
|
+
// Names are labels, so exact duplicate labels must never be guessed. UUIDs are
|
|
4450
|
+
// ids, never names: an unknown UUID refuses instead of creating a strangely
|
|
4451
|
+
// named contact that only looks like an existing id.
|
|
4452
|
+
export const resolveInviteContact = (contacts, selector) => {
|
|
4453
|
+
if (isUuidKey(selector)) {
|
|
4454
|
+
const contact = contacts.find((c) => c.id.toLowerCase() === selector.toLowerCase());
|
|
4455
|
+
return contact ? { kind: 'found', contact } : { kind: 'missing-id' };
|
|
4456
|
+
}
|
|
4457
|
+
const folded = selector.toLowerCase();
|
|
4458
|
+
const matches = contacts.filter((c) => c.displayName.toLowerCase() === folded);
|
|
4459
|
+
if (matches.length === 1)
|
|
4460
|
+
return { kind: 'found', contact: matches[0] };
|
|
4461
|
+
if (matches.length > 1)
|
|
4462
|
+
return { kind: 'ambiguous', matches };
|
|
4463
|
+
return { kind: 'missing-name', displayName: selector };
|
|
4464
|
+
};
|
|
4221
4465
|
const invite = async (cfg, rest) => {
|
|
4222
4466
|
if (rest.some((t) => HELP_TOKENS.has(t))) {
|
|
4223
4467
|
process.stdout.write(`${INVITE_HELP}\n`);
|
|
@@ -4230,25 +4474,25 @@ const invite = async (cfg, rest) => {
|
|
|
4230
4474
|
process.exitCode = EXIT.USAGE;
|
|
4231
4475
|
return;
|
|
4232
4476
|
}
|
|
4233
|
-
if (positionals.length >
|
|
4234
|
-
process.stderr.write(
|
|
4477
|
+
if (positionals.length > 1 || (positionals.length === 1 && flags.for !== undefined)) {
|
|
4478
|
+
process.stderr.write(`${INVITE_HELP}\n`);
|
|
4479
|
+
process.exitCode = EXIT.USAGE;
|
|
4480
|
+
return;
|
|
4481
|
+
}
|
|
4482
|
+
const selector = positionals[0] ?? flags.for;
|
|
4483
|
+
if (selector === undefined || selector.trim().length === 0) {
|
|
4484
|
+
process.stderr.write('A contact name or id is required.\n');
|
|
4235
4485
|
process.exitCode = EXIT.USAGE;
|
|
4236
4486
|
return;
|
|
4237
4487
|
}
|
|
4238
4488
|
// Same law as delete and born-public capture: an AI proposes, it cannot
|
|
4239
|
-
// commit. A
|
|
4240
|
-
// destructive, but it spends something real — a slot a person will hand to
|
|
4241
|
-
// someone — and it should never happen because a model thought it helpful.
|
|
4489
|
+
// commit. A non-TTY caller must make the person's approval explicit.
|
|
4242
4490
|
if (!process.stdin.isTTY && !yes) {
|
|
4243
4491
|
process.stderr.write('Refusing to mint without confirmation. Pass --yes once the person has ' +
|
|
4244
4492
|
'asked you to invite someone.\n');
|
|
4245
4493
|
process.exitCode = EXIT.USAGE;
|
|
4246
4494
|
return;
|
|
4247
4495
|
}
|
|
4248
|
-
if (process.stdin.isTTY && !(await confirm('Mint an invitation to hand to one person?'))) {
|
|
4249
|
-
process.stdout.write('Not minted.\n');
|
|
4250
|
-
return;
|
|
4251
|
-
}
|
|
4252
4496
|
let accessToken;
|
|
4253
4497
|
try {
|
|
4254
4498
|
accessToken = await validAccessToken(cfg);
|
|
@@ -4258,16 +4502,64 @@ const invite = async (cfg, rest) => {
|
|
|
4258
4502
|
process.exitCode = EXIT.NOAUTH;
|
|
4259
4503
|
return;
|
|
4260
4504
|
}
|
|
4505
|
+
const contactsResult = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`);
|
|
4506
|
+
if (!contactsResult.ok) {
|
|
4507
|
+
process.exitCode = contactsResult.exit;
|
|
4508
|
+
return;
|
|
4509
|
+
}
|
|
4510
|
+
const resolution = resolveInviteContact(contactsResult.data, selector.trim());
|
|
4511
|
+
if (resolution.kind === 'missing-id') {
|
|
4512
|
+
process.stderr.write(`No contact ${sanitizeForTerminal(selector)} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`);
|
|
4513
|
+
process.exitCode = EXIT.GENERIC;
|
|
4514
|
+
return;
|
|
4515
|
+
}
|
|
4516
|
+
if (resolution.kind === 'ambiguous') {
|
|
4517
|
+
process.stderr.write(`${sanitizeForTerminal(selector)} matches more than one of your contacts:\n${resolution.matches.map((c) => ` ${formatContact(c)}`).join('\n')}\nUse a contact id instead.\n`);
|
|
4518
|
+
process.exitCode = EXIT.USAGE;
|
|
4519
|
+
return;
|
|
4520
|
+
}
|
|
4521
|
+
const displayName = resolution.kind === 'found' ? resolution.contact.displayName : resolution.displayName;
|
|
4522
|
+
if (resolution.kind === 'found' && resolution.contact.type !== 'person') {
|
|
4523
|
+
process.stderr.write(`${sanitizeForTerminal(displayName)} is an AI contact. An invitation must go to a person contact.\n`);
|
|
4524
|
+
process.exitCode = EXIT.USAGE;
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
const action = resolution.kind === 'missing-name'
|
|
4528
|
+
? `Create person contact "${sanitizeForTerminal(displayName)}" and mint their invitation?`
|
|
4529
|
+
: `Mint an invitation for ${sanitizeForTerminal(displayName)}?`;
|
|
4530
|
+
if (process.stdin.isTTY && !(await confirm(action))) {
|
|
4531
|
+
process.stdout.write('Not minted.\n');
|
|
4532
|
+
return;
|
|
4533
|
+
}
|
|
4534
|
+
let contact;
|
|
4535
|
+
let createdContact = false;
|
|
4536
|
+
if (resolution.kind === 'missing-name') {
|
|
4537
|
+
const created = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`, {
|
|
4538
|
+
method: 'POST',
|
|
4539
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4540
|
+
body: JSON.stringify({ displayName, type: 'person' })
|
|
4541
|
+
});
|
|
4542
|
+
if (!created.ok) {
|
|
4543
|
+
process.exitCode = created.exit;
|
|
4544
|
+
return;
|
|
4545
|
+
}
|
|
4546
|
+
contact = created.data;
|
|
4547
|
+
createdContact = true;
|
|
4548
|
+
}
|
|
4549
|
+
else {
|
|
4550
|
+
contact = resolution.contact;
|
|
4551
|
+
}
|
|
4261
4552
|
const result = await apiFetch(accessToken, `${cfg.apiUrl}/invites`, {
|
|
4262
4553
|
method: 'POST',
|
|
4263
4554
|
headers: { 'Content-Type': 'application/json' },
|
|
4264
|
-
body: JSON.stringify(
|
|
4555
|
+
body: JSON.stringify({ contactId: contact.id })
|
|
4265
4556
|
});
|
|
4266
4557
|
if (!result.ok) {
|
|
4267
4558
|
process.exitCode = result.exit;
|
|
4268
4559
|
return;
|
|
4269
4560
|
}
|
|
4270
|
-
emit(result.data, result.rawText, (i) =>
|
|
4561
|
+
emit(result.data, result.rawText, (i) => `${createdContact ? `Recorded person contact ${formatContact(contact)}.\n\n` : ''}` +
|
|
4562
|
+
`Invitation minted${i.targetName ? ` for ${i.targetName}` : ''}.\n\n` +
|
|
4271
4563
|
` ${inviteLink(cfg, i.token)}\n\n` +
|
|
4272
4564
|
`Hand it to ONE person — it is spent when someone signs up with it.\n` +
|
|
4273
4565
|
`It is not permission to join; whether they may register is decided separately.\n` +
|
|
@@ -4289,7 +4581,7 @@ const getInvites = async (cfg) => {
|
|
|
4289
4581
|
return;
|
|
4290
4582
|
}
|
|
4291
4583
|
emit(result.data, result.rawText, (list) => list.length === 0
|
|
4292
|
-
? 'No invitations yet. `lattris invite
|
|
4584
|
+
? 'No invitations yet. `lattris invite <contact>` mints one.'
|
|
4293
4585
|
: list.map((i) => formatInvite(cfg, i)).join('\n\n'));
|
|
4294
4586
|
};
|
|
4295
4587
|
// ─── share (memory <id> --with <audience>) ─────────────────────────────────────
|
|
@@ -4305,17 +4597,36 @@ const describeAudience = (audience) => audience === 'public' ? 'public — anyon
|
|
|
4305
4597
|
const SHARE_FLAG_NAMES = {
|
|
4306
4598
|
'--with': 'with'
|
|
4307
4599
|
};
|
|
4600
|
+
const resolveExistingPersonContact = async (cfg, accessToken, selector) => {
|
|
4601
|
+
const result = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`);
|
|
4602
|
+
if (!result.ok) {
|
|
4603
|
+
process.exitCode = result.exit;
|
|
4604
|
+
return undefined;
|
|
4605
|
+
}
|
|
4606
|
+
const resolution = resolveInviteContact(result.data, selector.trim());
|
|
4607
|
+
if (resolution.kind === 'ambiguous') {
|
|
4608
|
+
process.stderr.write(`${sanitizeForTerminal(selector)} matches more than one of your contacts:\n${resolution.matches.map((c) => ` ${formatContact(c)}`).join('\n')}\nUse a contact id instead.\n`);
|
|
4609
|
+
process.exitCode = EXIT.USAGE;
|
|
4610
|
+
return undefined;
|
|
4611
|
+
}
|
|
4612
|
+
if (resolution.kind !== 'found') {
|
|
4613
|
+
process.stderr.write(`No contact ${sanitizeForTerminal(selector)} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`);
|
|
4614
|
+
process.exitCode = EXIT.GENERIC;
|
|
4615
|
+
return undefined;
|
|
4616
|
+
}
|
|
4617
|
+
if (resolution.contact.type !== 'person' || resolution.contact.claimedPersonId == null) {
|
|
4618
|
+
process.stderr.write('This person has no account yet — invite them first.\n');
|
|
4619
|
+
process.exitCode = EXIT.USAGE;
|
|
4620
|
+
return undefined;
|
|
4621
|
+
}
|
|
4622
|
+
return { ...resolution.contact, claimedPersonId: resolution.contact.claimedPersonId };
|
|
4623
|
+
};
|
|
4308
4624
|
const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
|
|
4309
4625
|
// ALL local preconditions settle before any network/token touch — same law
|
|
4310
4626
|
// as delete: a question that was always going to be refused must never
|
|
4311
4627
|
// spend a token refresh first.
|
|
4312
4628
|
if (audience === undefined) {
|
|
4313
|
-
process.stderr.write(
|
|
4314
|
-
process.exitCode = EXIT.USAGE;
|
|
4315
|
-
return;
|
|
4316
|
-
}
|
|
4317
|
-
if (!SHARE_AUDIENCES.includes(audience)) {
|
|
4318
|
-
process.stderr.write(`Unknown audience: ${audience}. Accepted: ${SHARE_AUDIENCES.join(', ')}\n`);
|
|
4629
|
+
process.stderr.write('--with is required. Use public or one of your contact names or ids.\n');
|
|
4319
4630
|
process.exitCode = EXIT.USAGE;
|
|
4320
4631
|
return;
|
|
4321
4632
|
}
|
|
@@ -4337,6 +4648,11 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
|
|
|
4337
4648
|
// the confirm fetch and share by the FULL id from then on — not the raw
|
|
4338
4649
|
// (possibly a prefix) argument again.
|
|
4339
4650
|
let shareId = idOrPrefix;
|
|
4651
|
+
const contact = audience === 'public'
|
|
4652
|
+
? undefined
|
|
4653
|
+
: await resolveExistingPersonContact(cfg, accessToken, audience);
|
|
4654
|
+
if (audience !== 'public' && !contact)
|
|
4655
|
+
return;
|
|
4340
4656
|
if (process.stdin.isTTY) {
|
|
4341
4657
|
const fetched = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
|
|
4342
4658
|
if (!fetched.ok) {
|
|
@@ -4345,7 +4661,7 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
|
|
|
4345
4661
|
return;
|
|
4346
4662
|
}
|
|
4347
4663
|
const title = sanitizeForTerminal(fetched.data.title ?? '(untitled)');
|
|
4348
|
-
const confirmed = await confirmAction(`Share "${title}" (${fetched.data.id}) with ${describeAudience(audience)}? [y/N] `);
|
|
4664
|
+
const confirmed = await confirmAction(`Share "${title}" (${fetched.data.id}) with ${describeAudience(contact?.displayName ?? audience)}? [y/N] `);
|
|
4349
4665
|
if (confirmed === 'interrupted') {
|
|
4350
4666
|
process.stderr.write('Cancelled.\n');
|
|
4351
4667
|
process.exitCode = EXIT.GENERIC;
|
|
@@ -4360,7 +4676,7 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
|
|
|
4360
4676
|
const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(shareId)}/grants`, {
|
|
4361
4677
|
method: 'POST',
|
|
4362
4678
|
headers: { 'Content-Type': 'application/json' },
|
|
4363
|
-
body: JSON.stringify({ audience })
|
|
4679
|
+
body: JSON.stringify(contact ? { kind: 'person', contactId: contact.id } : { audience: 'public' })
|
|
4364
4680
|
});
|
|
4365
4681
|
if (!result.ok) {
|
|
4366
4682
|
renderAmbiguousIfPresent(result.body);
|
|
@@ -4373,15 +4689,15 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
|
|
|
4373
4689
|
// wire, byte-verbatim (emit's non-TTY branch); a TTY gets the human line
|
|
4374
4690
|
// instead. shareId (not the response body, which never echoes an id) is
|
|
4375
4691
|
// what makes the human line locatable — closed over, not a second field.
|
|
4376
|
-
emit(result.data, result.rawText, (grant) => `Shared memory ${shareId} with ${grant.kind}`);
|
|
4692
|
+
emit(result.data, result.rawText, (grant) => `Shared memory ${shareId} with ${contact?.displayName ?? grant.kind}`);
|
|
4377
4693
|
};
|
|
4378
4694
|
export const SHARE_HELP = [
|
|
4379
|
-
'share — grant
|
|
4695
|
+
'share — grant public or a person contact access to a memory.',
|
|
4380
4696
|
'',
|
|
4381
4697
|
'Usage:',
|
|
4382
|
-
' lattris share memory <id-or-prefix> --with <
|
|
4698
|
+
' lattris share memory <id-or-prefix> --with <public-or-contact> [--yes]',
|
|
4383
4699
|
'',
|
|
4384
|
-
' --with
|
|
4700
|
+
' --with public, or an exact contact name or id from `lattris contact list`.',
|
|
4385
4701
|
' --yes required when not at a terminal; at a terminal you',
|
|
4386
4702
|
' are always asked, --yes or not.',
|
|
4387
4703
|
'',
|
|
@@ -4415,29 +4731,10 @@ const share = async (cfg, noun, rest) => {
|
|
|
4415
4731
|
}
|
|
4416
4732
|
await shareMemory(cfg, positionals[0], flags.with, yes);
|
|
4417
4733
|
};
|
|
4418
|
-
// ─── unshare (memory <id> --with <audience>) ───────────────────────────────────
|
|
4419
|
-
// Withdraws an audience — v1 accepts only "public". No confirmation: spec
|
|
4420
|
-
// rules withdrawal the safe direction (recoverable by re-sharing). The
|
|
4421
|
-
// record is ALWAYS resolved first — needed either way: to withdraw by the
|
|
4422
|
-
// FULL id (never re-resolve a prefix, same law as share/delete), or to echo
|
|
4423
|
-
// its current audiences + title in the bare-unshare refusal (owner-only
|
|
4424
|
-
// operation, so the echo leaks nothing this caller couldn't already see via
|
|
4425
|
-
// a plain get).
|
|
4426
|
-
export const UNSHARE_AUDIENCES = ['public'];
|
|
4427
4734
|
const UNSHARE_FLAG_NAMES = {
|
|
4428
4735
|
'--with': 'with'
|
|
4429
4736
|
};
|
|
4430
4737
|
const unshareMemory = async (cfg, idOrPrefix, audience) => {
|
|
4431
|
-
// A PRESENT but unknown audience is a pure value check — it needs nothing
|
|
4432
|
-
// fetched, so it settles before any network/token touch like every other
|
|
4433
|
-
// local precondition in this file. A MISSING audience is different: its
|
|
4434
|
-
// refusal echoes the record's current state, which only the fetch below
|
|
4435
|
-
// can supply, so that one stays gated behind it.
|
|
4436
|
-
if (audience !== undefined && !UNSHARE_AUDIENCES.includes(audience)) {
|
|
4437
|
-
process.stderr.write(`Unknown audience: ${audience}. Accepted: ${UNSHARE_AUDIENCES.join(', ')}\n`);
|
|
4438
|
-
process.exitCode = EXIT.USAGE;
|
|
4439
|
-
return;
|
|
4440
|
-
}
|
|
4441
4738
|
let accessToken;
|
|
4442
4739
|
try {
|
|
4443
4740
|
accessToken = await validAccessToken(cfg);
|
|
@@ -4447,6 +4744,11 @@ const unshareMemory = async (cfg, idOrPrefix, audience) => {
|
|
|
4447
4744
|
process.exitCode = EXIT.NOAUTH;
|
|
4448
4745
|
return;
|
|
4449
4746
|
}
|
|
4747
|
+
const contact = audience === undefined || audience === 'public'
|
|
4748
|
+
? undefined
|
|
4749
|
+
: await resolveExistingPersonContact(cfg, accessToken, audience);
|
|
4750
|
+
if (audience !== undefined && audience !== 'public' && !contact)
|
|
4751
|
+
return;
|
|
4450
4752
|
const fetched = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
|
|
4451
4753
|
if (!fetched.ok) {
|
|
4452
4754
|
renderAmbiguousIfPresent(fetched.body);
|
|
@@ -4455,26 +4757,28 @@ const unshareMemory = async (cfg, idOrPrefix, audience) => {
|
|
|
4455
4757
|
}
|
|
4456
4758
|
if (audience === undefined) {
|
|
4457
4759
|
const title = sanitizeForTerminal(fetched.data.title ?? '(untitled)');
|
|
4458
|
-
process.stderr.write(
|
|
4760
|
+
process.stderr.write('--with is required. Use public or one of your contact names or ids.\n');
|
|
4459
4761
|
process.stderr.write(`"${title}" (${fetched.data.id}) is currently shared with: ${formatAudiences(fetched.data.audiences)}\n`);
|
|
4460
4762
|
process.exitCode = EXIT.USAGE;
|
|
4461
4763
|
return;
|
|
4462
4764
|
}
|
|
4463
|
-
const result = await apiFetch(accessToken,
|
|
4765
|
+
const result = await apiFetch(accessToken, contact
|
|
4766
|
+
? `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/person/${contact.claimedPersonId}`
|
|
4767
|
+
: `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/public`, { method: 'DELETE' });
|
|
4464
4768
|
if (!result.ok) {
|
|
4465
4769
|
process.exitCode = result.exit;
|
|
4466
4770
|
return;
|
|
4467
4771
|
}
|
|
4468
|
-
process.stderr.write(`Unshared memory ${fetched.data.id} from ${
|
|
4772
|
+
process.stderr.write(`Unshared memory ${fetched.data.id} from ${contact?.displayName ?? 'public'}\n`);
|
|
4469
4773
|
};
|
|
4470
4774
|
export const UNSHARE_HELP = [
|
|
4471
4775
|
'unshare — withdraw an audience from a memory. No confirmation — withdrawal',
|
|
4472
4776
|
'is the safe direction.',
|
|
4473
4777
|
'',
|
|
4474
4778
|
'Usage:',
|
|
4475
|
-
' lattris unshare memory <id-or-prefix> --with <
|
|
4779
|
+
' lattris unshare memory <id-or-prefix> --with <public-or-contact>',
|
|
4476
4780
|
'',
|
|
4477
|
-
' --with
|
|
4781
|
+
' --with public, or an exact contact name or id from `lattris contact list`.',
|
|
4478
4782
|
'',
|
|
4479
4783
|
'Bare (no --with): refuses and shows what the memory is currently shared',
|
|
4480
4784
|
'with, so you can see the state and the fix in one line.',
|
|
@@ -4505,6 +4809,58 @@ const unshare = async (cfg, noun, rest) => {
|
|
|
4505
4809
|
}
|
|
4506
4810
|
await unshareMemory(cfg, positionals[0], flags.with);
|
|
4507
4811
|
};
|
|
4812
|
+
// ─── consent (<shared-memory-id> [--withdraw]) ─────────────────────────────
|
|
4813
|
+
export const CONSENT_HELP = [
|
|
4814
|
+
'consent — choose whether a memory shared with you appears on your graph.',
|
|
4815
|
+
'',
|
|
4816
|
+
'Usage:',
|
|
4817
|
+
' lattris consent <id-or-prefix>',
|
|
4818
|
+
' lattris consent <id-or-prefix> --withdraw',
|
|
4819
|
+
'',
|
|
4820
|
+
'The choice is yours alone. Withdrawing a share hides the block regardless;',
|
|
4821
|
+
'if its owner shares it again later, your previous choice still stands.'
|
|
4822
|
+
].join('\n');
|
|
4823
|
+
const consent = async (cfg, rest) => {
|
|
4824
|
+
if (rest.some((arg) => HELP_TOKENS.has(arg))) {
|
|
4825
|
+
process.stdout.write(`${CONSENT_HELP}\n`);
|
|
4826
|
+
return;
|
|
4827
|
+
}
|
|
4828
|
+
const withdrawCount = rest.filter((arg) => arg === '--withdraw').length;
|
|
4829
|
+
const args = rest.filter((arg) => arg !== '--withdraw');
|
|
4830
|
+
const unknown = args.find((arg) => arg.startsWith('--'));
|
|
4831
|
+
if (unknown !== undefined) {
|
|
4832
|
+
process.stderr.write(`Unknown flag: ${unknown}\n`);
|
|
4833
|
+
process.exitCode = EXIT.USAGE;
|
|
4834
|
+
return;
|
|
4835
|
+
}
|
|
4836
|
+
if (withdrawCount > 1 || args.length !== 1) {
|
|
4837
|
+
process.stderr.write(`Usage: lattris consent <id-or-prefix> [--withdraw]\n`);
|
|
4838
|
+
process.exitCode = EXIT.USAGE;
|
|
4839
|
+
return;
|
|
4840
|
+
}
|
|
4841
|
+
let accessToken;
|
|
4842
|
+
try {
|
|
4843
|
+
accessToken = await validAccessToken(cfg);
|
|
4844
|
+
}
|
|
4845
|
+
catch {
|
|
4846
|
+
renderNotLoggedIn();
|
|
4847
|
+
process.exitCode = EXIT.NOAUTH;
|
|
4848
|
+
return;
|
|
4849
|
+
}
|
|
4850
|
+
const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(args[0])}/consent`, {
|
|
4851
|
+
method: 'PUT',
|
|
4852
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4853
|
+
body: JSON.stringify({ consented: withdrawCount === 0 })
|
|
4854
|
+
});
|
|
4855
|
+
if (!result.ok) {
|
|
4856
|
+
renderAmbiguousIfPresent(result.body);
|
|
4857
|
+
process.exitCode = result.exit;
|
|
4858
|
+
return;
|
|
4859
|
+
}
|
|
4860
|
+
emit(result.data, result.rawText, (state) => state.consented
|
|
4861
|
+
? `Memory ${args[0]} may appear on your graph.`
|
|
4862
|
+
: `Memory ${args[0]} will stay off your graph.`);
|
|
4863
|
+
};
|
|
4508
4864
|
// ─── capture recording (train 8) ────────────────────────────────────────────
|
|
4509
4865
|
// The API meets ONE recording shape; vendor transcript formats translate HERE,
|
|
4510
4866
|
// CLI-side, so the door never grows a per-vendor arm. A recording is one door,
|
|
@@ -5889,7 +6245,7 @@ const usage = () => {
|
|
|
5889
6245
|
' lattris capture session <transcript> Save an AI conversation — the whole chain from its file',
|
|
5890
6246
|
' lattris get memories List your memories, newest first, with filters',
|
|
5891
6247
|
' lattris contact create "<name>" Record a person or an AI you work with (--ai); list, get, update',
|
|
5892
|
-
' lattris invite
|
|
6248
|
+
' lattris invite <contact> Mint an invitation for one of your person contacts',
|
|
5893
6249
|
' lattris get invites List the invitations you have minted',
|
|
5894
6250
|
' lattris get memory <id> Read one memory back \u2014 words and edges (--links/--depth/--content)',
|
|
5895
6251
|
' lattris ask <handle> Leave a question on someone\u2019s tree \u2014 `echo "\u2026" | lattris ask <handle>`',
|
|
@@ -5897,6 +6253,7 @@ const usage = () => {
|
|
|
5897
6253
|
' lattris close question <id> Close a question (--close-as updated|declined|already-answered)',
|
|
5898
6254
|
' lattris share memory <id> Grant an audience access (--with public)',
|
|
5899
6255
|
' lattris unshare memory <id> Withdraw an audience (--with public)',
|
|
6256
|
+
' lattris consent <id> Put a memory shared with you on your graph (--withdraw reverses)',
|
|
5900
6257
|
' lattris link <from> <to> State that one memory leads to another (--verb, --reason)',
|
|
5901
6258
|
' lattris unlink <from> <to> Remove that claim (--verb; the blocks stay)',
|
|
5902
6259
|
' lattris update memory <id> Correct a memory — `echo "…" | lattris update memory <id> --base-fingerprint <fp>`',
|
|
@@ -6420,11 +6777,13 @@ export const main = async () => {
|
|
|
6420
6777
|
process.env.LATTRIS_HOME = asResolvedDir;
|
|
6421
6778
|
}
|
|
6422
6779
|
}
|
|
6423
|
-
// Resolve the environment name: --env > .lattris env= > LATTRIS_ENV >
|
|
6780
|
+
// Resolve the environment name: --env > .lattris env= > LATTRIS_ENV > the
|
|
6781
|
+
// install-aware fallback (published package = prod; worktree/source = local).
|
|
6424
6782
|
// Must run AFTER --as (which sets LATTRIS_HOME, affecting resolveSessionHome
|
|
6425
6783
|
// which findBinding reads for the env= field).
|
|
6426
6784
|
const home = resolveSessionHome();
|
|
6427
|
-
resolvedEnvName =
|
|
6785
|
+
resolvedEnvName =
|
|
6786
|
+
envName ?? home.bindingEnv ?? process.env.LATTRIS_ENV ?? defaultEnvironmentForEntry();
|
|
6428
6787
|
envWasExplicit =
|
|
6429
6788
|
envName !== undefined || home.bindingEnv !== undefined || process.env.LATTRIS_ENV !== undefined;
|
|
6430
6789
|
const forceFlag = argv.includes('--force');
|
|
@@ -6445,10 +6804,14 @@ export const main = async () => {
|
|
|
6445
6804
|
? VERB_ALIASES[rawCommand]
|
|
6446
6805
|
: rawCommand
|
|
6447
6806
|
: rawCommand;
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
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.
|
|
6452
6815
|
const subArgs = argv.slice(3);
|
|
6453
6816
|
if (command !== undefined && subArgs.some((t) => HELP_TOKENS.has(t))) {
|
|
6454
6817
|
const help = commandHelpText(command, subArgs);
|
|
@@ -6525,6 +6888,9 @@ export const main = async () => {
|
|
|
6525
6888
|
case 'unshare':
|
|
6526
6889
|
await unshare(cfg, argv[3], argv.slice(4));
|
|
6527
6890
|
break;
|
|
6891
|
+
case 'consent':
|
|
6892
|
+
await consent(cfg, argv.slice(3));
|
|
6893
|
+
break;
|
|
6528
6894
|
case 'contact':
|
|
6529
6895
|
await contact(cfg, argv[3], argv.slice(4));
|
|
6530
6896
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lattris",
|
|
3
|
-
"version": "0.1.
|
|
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": [
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
],
|
|
12
12
|
"license": "UNLICENSED",
|
|
13
13
|
"homepage": "https://lattris.com",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node ../../../node_modules/vitest/vitest.mjs run --config vite.config.ts",
|
|
16
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && node ../../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
17
|
+
"release": "node scripts/release.mjs"
|
|
18
|
+
},
|
|
14
19
|
"bin": {
|
|
15
20
|
"lattris": "./dist/main.js"
|
|
16
21
|
},
|