lattris 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,106 @@
1
- import { formatAudiences } from "../render/rows.js";
1
+ import { audienceOf, formatAccessRow, formatAudiences, waitsForNameSentence } from "../render/rows.js";
2
+ import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
2
3
  import { consent } from "./consent.js";
3
- const NOT_LINKED = 'This contact is not linked to an account yet. Invite them from this contact; accepting links it.\n';
4
+ const ALL_CONTACTS = 'all-contacts';
5
+ const AUDIENCE_WORDS = '--with is required. Use public, all-contacts, or one of your contact names or ids.\n';
6
+ const DENY_WORDS = '--to is required. Use all-contacts or one of your contact names or ids.\n';
7
+ const PUBLIC_CANNOT_BE_DENIED = 'public cannot be denied: a deny names a contact or all-contacts. To stop sharing with everyone, unshare --with public.\n';
4
8
  export const SHARE_HELP = [
5
- 'share — grant public or a person contact access to a memory.',
9
+ 'share — allow an audience to see a memory: public, all your contacts,',
10
+ 'or one contact. A contact needs no Lattris account; a linked contact also',
11
+ 'sees it on the website.',
6
12
  '',
7
13
  'Usage:',
8
- ' lattris share memory <id-or-prefix> --with <public-or-contact> [--yes]',
14
+ ' lattris share memory <id-or-prefix> --with <public | all-contacts | contact> [--yes]',
9
15
  '',
10
- ' --with public, or an exact contact name or id from `lattris contact list`.',
11
- ' --yes required when not at a terminal; at a terminal you',
12
- ' are always asked, --yes or not.',
16
+ ' --with public, all-contacts, or an exact contact name or id from',
17
+ ' `lattris contact list`. The words public and all-contacts win',
18
+ ' over a contact of that name; reach such a contact by its id.',
19
+ ' group:<name> is reserved for groups, which are not built yet.',
20
+ ' --yes required when not at a terminal; at a terminal you are always',
21
+ ' asked, --yes or not.',
13
22
  '',
14
- 'Idempotent: sharing with an audience that already has access succeeds and',
15
- 'changes nothing.',
23
+ 'A deny on the memory beats any allow: the answer names who is still shut',
24
+ 'out, and `lattris undeny` lifts it. Sharing with an audience that already',
25
+ 'has access succeeds and changes nothing.',
16
26
  '',
17
27
  'Alias: publish → share'
18
28
  ].join('\n');
29
+ export const DENY_HELP = [
30
+ 'deny — shut a memory to one contact or to all your contacts, whatever',
31
+ 'else allows it. Nothing is asked: a deny is the safe direction.',
32
+ '',
33
+ 'Usage:',
34
+ ' lattris deny memory <id-or-prefix> --to <all-contacts | contact>',
35
+ '',
36
+ ' --to all-contacts, or an exact contact name or id from',
37
+ ' `lattris contact list`. public cannot be denied. group:<name>',
38
+ ' is reserved for groups, which are not built yet.',
39
+ '',
40
+ 'A deny cannot hold while the memory is public or shared on the website',
41
+ 'with the person it would shut out; the refusal names the share in the',
42
+ 'way. `lattris undeny` lifts a deny.'
43
+ ].join('\n');
44
+ export const UNDENY_HELP = [
45
+ 'undeny — lift a deny from a memory, for one contact or for all your',
46
+ 'contacts. It opens the memory to whatever allows it, so it asks at a',
47
+ 'terminal and needs --yes on a pipe.',
48
+ '',
49
+ 'Usage:',
50
+ ' lattris undeny memory <id-or-prefix> --to <all-contacts | contact> [--yes]'
51
+ ].join('\n');
52
+ const isAccessGrant = (grant) => typeof grant === 'object' &&
53
+ grant !== null &&
54
+ 'shutBy' in grant &&
55
+ Array.isArray(grant.shutBy);
56
+ const whoWasShared = (grant, context) => context.contact?.displayName !== undefined
57
+ ? sanitizeForTerminal(context.contact.displayName)
58
+ : context.reserved === ALL_CONTACTS
59
+ ? 'all contacts'
60
+ : String(grant.kind ?? '');
61
+ const shareSentences = (grant, context) => {
62
+ const lines = [
63
+ `Shared memory ${context.memory?.id ?? context.positionals[0]} with ${whoWasShared(grant, context)}`
64
+ ];
65
+ if (!isAccessGrant(grant))
66
+ return lines;
67
+ if (grant.paired) {
68
+ lines.push(`${sanitizeForTerminal(grant.contactName ?? '')} is linked to an account, so the website shows it too.`);
69
+ }
70
+ else if (grant.unpairedBecause === 'owner-has-no-name') {
71
+ lines.push(waitsForNameSentence(grant.contactName ?? ''));
72
+ }
73
+ if (grant.shutBy.length > 0) {
74
+ const denies = grant.shutBy.map(formatAccessRow).join(', ');
75
+ lines.push(grant.kind === 'contact'
76
+ ? `${sanitizeForTerminal(grant.contactName ?? '')} is still shut out by: ${denies}`
77
+ : `Still shut out by a deny of their own: ${grant.shutBy.map(audienceOf).join(', ')}`);
78
+ }
79
+ return lines;
80
+ };
81
+ const otherEffectWords = (asked) => (body, context) => {
82
+ if (body.code !== 'memory:withdraw-effect-mismatch')
83
+ return '';
84
+ const found = body.detail?.effect === 'deny' ? 'deny' : 'allow';
85
+ if (found === asked)
86
+ return '';
87
+ const id = context.memory?.id ?? context.positionals[0];
88
+ const who = context.contact?.id ?? ALL_CONTACTS;
89
+ return found === 'deny'
90
+ ? `This is a deny, not a share. Lift it with: ${renderRunHint(['undeny', 'memory', id, '--to', who])}\n`
91
+ : `This is a share, not a deny. Withdraw it with: ${renderRunHint(['unshare', 'memory', id, '--with', who])}\n`;
92
+ };
93
+ const inTheWay = (body) => {
94
+ const rows = body.detail?.blockedBy;
95
+ if (!Array.isArray(rows) || rows.length === 0)
96
+ return '';
97
+ return `In the way: ${rows.map(formatAccessRow).join(', ')}\n`;
98
+ };
99
+ const contactBody = (effect) => ({
100
+ kind: { value: 'contact' },
101
+ contactId: { context: 'contact.id' },
102
+ effect: { value: effect }
103
+ });
19
104
  const shareVerb = {
20
105
  verb: 'share',
21
106
  nouns: ['memory', 'memories'],
@@ -26,14 +111,11 @@ const shareVerb = {
26
111
  positionals: 1,
27
112
  idPositionals: [0],
28
113
  steps: {
29
- requireFlag: {
30
- key: 'with',
31
- message: '--with is required. Use public or one of your contact names or ids.\n'
32
- },
114
+ requireFlag: { key: 'with', message: AUDIENCE_WORDS },
33
115
  pipedNeedsYes: {
34
116
  message: 'Refusing to share without confirmation on a non-interactive session. Pass --yes to skip the prompt.\n'
35
117
  },
36
- contact: { from: ['flag:with'], publicWord: 'public', mustBeLinked: NOT_LINKED },
118
+ contact: { from: ['flag:with'], publicWord: 'public', reserved: [ALL_CONTACTS] },
37
119
  memory: 'at-tty',
38
120
  confirm: {
39
121
  question: 'Share "{title}" ({memory.id}) with {audience}? [y/N] ',
@@ -44,14 +126,77 @@ const shareVerb = {
44
126
  request: {
45
127
  method: 'POST',
46
128
  path: '/memories/{memory.id}/grants',
47
- body: {
48
- kind: { value: 'person' },
49
- contactId: { context: 'contact.id' }
129
+ body: contactBody('allow'),
130
+ bodyWithPublic: { audience: 'public' },
131
+ bodyFor: { [ALL_CONTACTS]: { kind: 'all_contacts' } }
132
+ },
133
+ ambiguousRefusal: true,
134
+ refusalWords: inTheWay,
135
+ render: (grant, context) => shareSentences(grant, context).join('\n')
136
+ }
137
+ };
138
+ const denyVerb = {
139
+ verb: 'deny',
140
+ nouns: ['memory', 'memories'],
141
+ form: {
142
+ usage: 'Usage: lattris deny memory <id> --to <all-contacts | contact>',
143
+ flags: { '--to': 'to' },
144
+ positionals: 1,
145
+ idPositionals: [0],
146
+ steps: {
147
+ requireFlag: { key: 'to', message: DENY_WORDS },
148
+ contact: {
149
+ from: ['flag:to'],
150
+ reserved: [ALL_CONTACTS],
151
+ refusedWords: { public: PUBLIC_CANNOT_BE_DENIED }
152
+ }
153
+ },
154
+ request: {
155
+ method: 'POST',
156
+ path: '/memories/{memory.id}/grants',
157
+ body: contactBody('deny'),
158
+ bodyFor: { [ALL_CONTACTS]: { kind: 'all_contacts', effect: 'deny' } }
159
+ },
160
+ ambiguousRefusal: true,
161
+ refusalWords: inTheWay,
162
+ render: (_grant, context) => `Denied memory ${context.positionals[0]} to ${context.contact ? sanitizeForTerminal(context.contact.displayName) : 'all contacts'}`
163
+ }
164
+ };
165
+ const undenyVerb = {
166
+ verb: 'undeny',
167
+ nouns: ['memory', 'memories'],
168
+ form: {
169
+ usage: 'Usage: lattris undeny memory <id> --to <all-contacts | contact> [--yes]',
170
+ flags: { '--to': 'to' },
171
+ switches: ['--yes'],
172
+ positionals: 1,
173
+ idPositionals: [0],
174
+ steps: {
175
+ requireFlag: { key: 'to', message: DENY_WORDS },
176
+ pipedNeedsYes: {
177
+ message: 'Refusing to lift a deny without confirmation on a non-interactive session. Pass --yes to skip the prompt.\n'
50
178
  },
51
- bodyWithPublic: { audience: 'public' }
179
+ contact: {
180
+ from: ['flag:to'],
181
+ reserved: [ALL_CONTACTS],
182
+ refusedWords: { public: PUBLIC_CANNOT_BE_DENIED }
183
+ },
184
+ memory: 'at-tty',
185
+ confirm: {
186
+ question: 'Lift the deny on "{title}" ({memory.id}) for {audience}? [y/N] ',
187
+ onNo: { text: 'Cancelled.\n', to: 'stderr' },
188
+ onInterrupted: { text: 'Cancelled.\n', exit: 1 }
189
+ }
190
+ },
191
+ request: {
192
+ method: 'DELETE',
193
+ path: '/memories/{memory.id}/grants/contact/{contact.id}?effect=deny',
194
+ pathFor: { [ALL_CONTACTS]: '/memories/{memory.id}/grants/all-contacts?effect=deny' }
52
195
  },
53
196
  ambiguousRefusal: true,
54
- render: (grant, context) => `Shared memory ${context.memory?.id ?? context.positionals[0]} with ${context.contact?.displayName ?? grant.kind}`
197
+ refusalWords: otherEffectWords('deny'),
198
+ renderTo: 'stderr',
199
+ render: (_data, context) => `Lifted the deny on memory ${context.memory?.id ?? context.positionals[0]} for ${context.contact ? sanitizeForTerminal(context.contact.displayName) : 'all contacts'}`
55
200
  }
56
201
  };
57
202
  export const UNSHARE_HELP = [
@@ -59,12 +204,17 @@ export const UNSHARE_HELP = [
59
204
  'is the safe direction.',
60
205
  '',
61
206
  'Usage:',
62
- ' lattris unshare memory <id-or-prefix> --with <public-or-contact>',
207
+ ' lattris unshare memory <id-or-prefix> --with <public | all-contacts | contact>',
63
208
  '',
64
- ' --with public, or an exact contact name or id from `lattris contact list`.',
209
+ ' --with public, all-contacts, or an exact contact name or id from',
210
+ ' `lattris contact list`. For a contact, the website row paired',
211
+ ' with its allow goes too, and so does a website share written',
212
+ ' before contact shares existed. group:<name> is reserved for',
213
+ ' groups, which are not built yet.',
65
214
  '',
66
215
  'Bare (no --with): refuses and shows what the memory is currently shared',
67
- 'with, so you can see the state and the fix in one line.',
216
+ 'with, so you can see the state and the fix in one line. A deny is lifted',
217
+ 'with `lattris undeny`, not here.',
68
218
  '',
69
219
  'Alias: unpublish → unshare'
70
220
  ].join('\n');
@@ -77,22 +227,33 @@ const unshareVerb = {
77
227
  positionals: 1,
78
228
  idPositionals: [0],
79
229
  steps: {
80
- contact: { from: ['flag:with'], publicWord: 'public', mustBeLinked: NOT_LINKED },
230
+ contact: { from: ['flag:with'], publicWord: 'public', reserved: [ALL_CONTACTS] },
81
231
  memory: 'always',
82
232
  noAudienceReport: {
83
233
  key: 'with',
84
- first: '--with is required. Use public or one of your contact names or ids.\n',
234
+ first: AUDIENCE_WORDS,
85
235
  second: '"{title}" ({memory.id}) is currently shared with: {audiences}\n'
86
236
  }
87
237
  },
88
238
  request: {
89
239
  method: 'DELETE',
90
- path: '/memories/{memory.id}/grants/person/{contact.claimedPersonId}',
91
- pathWithPublic: '/memories/{memory.id}/grants/public'
240
+ path: '/memories/{memory.id}/grants/contact/{contact.id}?effect=allow',
241
+ pathWithPublic: '/memories/{memory.id}/grants/public',
242
+ pathFor: { [ALL_CONTACTS]: '/memories/{memory.id}/grants/all-contacts?effect=allow' },
243
+ fallback: {
244
+ status: 404,
245
+ path: '/memories/{memory.id}/grants/person/{contact.claimedPersonId}',
246
+ when: 'contact.linked'
247
+ }
92
248
  },
93
249
  ambiguousRefusal: true,
250
+ refusalWords: otherEffectWords('allow'),
94
251
  renderTo: 'stderr',
95
- render: (_data, context) => `Unshared memory ${context.memory?.id ?? ''} from ${context.contact?.displayName ?? 'public'}`
252
+ render: (_data, context) => `Unshared memory ${context.memory?.id ?? ''} from ${context.contact
253
+ ? sanitizeForTerminal(context.contact.displayName)
254
+ : context.reserved === ALL_CONTACTS
255
+ ? 'all contacts'
256
+ : 'public'}`
96
257
  }
97
258
  };
98
259
  export const audiencesOf = (memory) => formatAudiences(memory.audiences);
@@ -100,6 +261,6 @@ export const grants = {
100
261
  name: 'grants',
101
262
  words: { singular: 'grant', plural: 'grants' },
102
263
  help: SHARE_HELP,
103
- verbs: [shareVerb, unshareVerb, consent]
264
+ verbs: [shareVerb, unshareVerb, denyVerb, undenyVerb, consent]
104
265
  };
105
266
  //# sourceMappingURL=share.js.map
@@ -18,16 +18,19 @@ export const USAGE_LINES = [
18
18
  ' lattris capture memory Capture a memory — `echo "…" | lattris capture memory`',
19
19
  ' lattris capture session <transcript> Save an AI conversation — the whole chain from its file',
20
20
  ' lattris capture recording Keep a recording \u2014 held audio, a source, or a transcript file',
21
- ' lattris get memories List your memories, newest first, with filters',
22
- ' lattris contact create "<name>" Record a person or an AI you work with (--ai); add, list, get, update',
21
+ ' lattris get memories List your memories, newest first, with filters (--for <contact> for what they may see)',
22
+ ' lattris contact create "<name>" Record a person or an AI you work with (--ai, --phone); add, list, get, update, delete',
23
23
  ' lattris invite <contact> Mint an invitation for one of your person contacts',
24
24
  ' lattris get invites List the invitations you have minted',
25
25
  ' lattris get memory <id> Read one memory back \u2014 words and edges (--links/--depth/--content)',
26
26
  ' lattris ask <handle> Leave a question on someone\u2019s tree \u2014 `echo "\u2026" | lattris ask <handle>`',
27
27
  ' lattris get questions The questions on your tree, open first (--mine: ones you asked)',
28
28
  ' lattris close question <id> Close a question (--close-as updated|declined|already-answered)',
29
- ' lattris share memory <id> Grant an audience access (--with public)',
30
- ' lattris unshare memory <id> Withdraw an audience (--with public)',
29
+ ' lattris share memory <id> Allow an audience to see it (--with public | all-contacts | <contact>)',
30
+ ' lattris unshare memory <id> Withdraw an audience (--with public | all-contacts | <contact>)',
31
+ ' lattris deny memory <id> Shut it to a contact or all contacts, whatever allows it (--to)',
32
+ ' lattris undeny memory <id> Lift a deny (--to all-contacts | <contact>)',
33
+ ' lattris get access <id> May a contact or a number see it, and because of what (--for)',
31
34
  ' lattris consent <id> Put a memory shared with you on your graph (--withdraw reverses)',
32
35
  ' lattris link <from> <to> State that one memory leads to another (--verb, --reason)',
33
36
  ' lattris unlink <from> <to> Remove that claim (--verb; the blocks stay)',
@@ -65,6 +68,8 @@ export const COMMAND_WORDS = new Set([
65
68
  'invite',
66
69
  'share',
67
70
  'unshare',
71
+ 'deny',
72
+ 'undeny',
68
73
  'consent',
69
74
  'restore',
70
75
  'erase',
@@ -4,6 +4,7 @@ import { CAPTURE_INDEX, CAPTURE_NOTE_HELP, CAPTURE_SESSION_HELP } from "../memor
4
4
  import { asksForSessionForm, captureMemory } from "../memory/capture-note.js";
5
5
  import { DELETE_HELP, deleteMemory } from "../memory/delete.js";
6
6
  import { ERASE_HELP, eraseMemory } from "../memory/erase.js";
7
+ import { GET_ACCESS_HELP, getAccess } from "../memory/access.js";
7
8
  import { GET_HELP } from "../memory/get-help.js";
8
9
  import { getMemories } from "../memory/get.js";
9
10
  import { getDeleted, getHistory } from "../memory/history.js";
@@ -37,6 +38,7 @@ export const memoryNouns = [
37
38
  },
38
39
  { verb: 'get', nouns: ['history'], handler: getHistory },
39
40
  { verb: 'get', nouns: ['deleted'], handler: getDeleted },
41
+ { verb: 'get', nouns: ['access'], handler: getAccess },
40
42
  { verb: 'update', nouns: ['memory', 'memories'], handler: updateMemory },
41
43
  { verb: 'delete', nouns: ['memory', 'memories'], handler: deleteMemory },
42
44
  { verb: 'restore', nouns: ['memory', 'memories', 'note'], handler: restoreMemory },
@@ -73,10 +75,14 @@ export const memoryVerbs = {
73
75
  badNoun: { stderr: 'Unknown capture form: {noun}\n\n', stdout: `${CAPTURE_INDEX}\n` }
74
76
  },
75
77
  get: {
76
- help: (args) => ['datasets', 'dataset', 'rows', 'row'].includes(args[0]) ? GET_DATASETS_HELP : GET_HELP,
78
+ help: (args) => ['datasets', 'dataset', 'rows', 'row'].includes(args[0])
79
+ ? GET_DATASETS_HELP
80
+ : args[0] === 'access'
81
+ ? GET_ACCESS_HELP
82
+ : GET_HELP,
77
83
  bareIsHelp: true,
78
84
  badNoun: {
79
- stderr: 'Usage: lattris get memories | lattris get memory <id> | lattris get history <id> | lattris get deleted | lattris get invites | lattris get questions | lattris get datasets | lattris get dataset <name> | lattris get rows <name> | lattris get row <name> <id>\n'
85
+ stderr: 'Usage: lattris get memories | lattris get memory <id> | lattris get access <id> --for <contact | number> | lattris get history <id> | lattris get deleted | lattris get invites | lattris get questions | lattris get datasets | lattris get dataset <name> | lattris get rows <name> | lattris get row <name> <id>\n'
80
86
  }
81
87
  },
82
88
  update: {
@@ -1,8 +1,41 @@
1
- import { sanitizeForTerminal } from "./text.js";
1
+ import { renderRunHint, sanitizeForTerminal } from "./text.js";
2
+ import { isUuidKey } from "../store/write-identity.js";
2
3
  export const formatContact = (c) => `${sanitizeForTerminal(c.displayName)} · ${c.type} · ${c.id}` +
3
4
  `${c.claimedPersonId ? ' · linked' : ''}` +
4
5
  `${c.email ? ` · email ${sanitizeForTerminal(c.email)}` : ''}` +
6
+ `${c.phone ? ` · phone ${sanitizeForTerminal(c.phone)}` : ''}` +
5
7
  `${c.rootBlockId ? ` · root ${c.rootBlockId}` : ''}`;
8
+ export const resolveContact = (contacts, selector) => {
9
+ if (isUuidKey(selector)) {
10
+ const contact = contacts.find((c) => c.id.toLowerCase() === selector.toLowerCase());
11
+ return contact ? { kind: 'found', contact } : { kind: 'missing-id' };
12
+ }
13
+ const folded = selector.toLowerCase();
14
+ const matches = contacts.filter((c) => c.displayName.toLowerCase() === folded);
15
+ if (matches.length === 1)
16
+ return { kind: 'found', contact: matches[0] };
17
+ if (matches.length > 1)
18
+ return { kind: 'ambiguous', matches };
19
+ return { kind: 'missing-name', displayName: selector };
20
+ };
21
+ export const isGroupWord = (word) => word.trim().toLowerCase().startsWith('group:');
22
+ export const groupsNotBuilt = (word) => `${sanitizeForTerminal(word)} names a group, and groups are not built yet. A contact called that is reached by its id.\n`;
23
+ export const audienceOf = (r) => r.kind === 'public'
24
+ ? 'public'
25
+ : r.kind === 'all_contacts'
26
+ ? 'all contacts'
27
+ : r.kind === 'person'
28
+ ? `${sanitizeForTerminal(r.contactName ?? r.personId ?? '')} (website)`
29
+ : sanitizeForTerminal(r.contactName ?? r.contactId ?? '');
30
+ export const formatAccessRow = (r) => `${r.effect} ${audienceOf(r)}`;
31
+ export const waitsForNameSentence = (name) => `${sanitizeForTerminal(name)} is linked to an account, but the website share waits until you set your name (${renderRunHint(['set', 'name'])}).`;
32
+ export const waitsForNameLines = (rows) => (rows ?? [])
33
+ .filter((r) => r.unpairedBecause === 'owner-has-no-name' && r.contactName)
34
+ .map((r) => waitsForNameSentence(r.contactName ?? ''));
35
+ export const formatAccess = (rows) => rows.length === 0 ? '(nothing set)' : rows.map(formatAccessRow).join('; ');
36
+ export const describeForContact = (c, asked) => c === null
37
+ ? `no contact holds ${sanitizeForTerminal(asked ?? 'that number')}; a stranger sees public memories only`
38
+ : `${sanitizeForTerminal(c.displayName)} (${c.type}${c.phone ? `, ${sanitizeForTerminal(c.phone)}` : ''})`;
6
39
  export const formatQuestion = (q) => {
7
40
  const lines = [`id ${q.id}`, `createdAt ${q.createdAt}`];
8
41
  if (q.kind === 'share') {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "lattris",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
- "description": "First-party CLI for Lattris \u2014 sign in, capture notes and sessions, and read your own tree from the terminal.",
5
+ "description": "First-party CLI for Lattris — sign in, capture notes and sessions, and read your own tree from the terminal.",
6
6
  "keywords": [
7
7
  "lattris",
8
8
  "cli",