amorphous-cli 0.1.0

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.
@@ -0,0 +1,736 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
+ import { apiFetch } from './api.mjs'
3
+ import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
4
+ import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
5
+ import { ALIASES, printHelp, wantsHelp } from './help.mjs'
6
+ import { loginWithMagicLink } from './login.mjs'
7
+
8
+ const BOOL_FLAGS = new Set([
9
+ 'json',
10
+ 'help',
11
+ 'none',
12
+ 'live',
13
+ 'apply',
14
+ 'starred',
15
+ 'archived',
16
+ 'owned',
17
+ 'member',
18
+ 'open',
19
+ 'closed',
20
+ ])
21
+
22
+ function parseFlags(args) {
23
+ const flags = {}
24
+ const positional = []
25
+ for (let i = 0; i < args.length; i++) {
26
+ const a = args[i]
27
+ if (a === '-h' || a === '--help') {
28
+ flags.help = true
29
+ continue
30
+ }
31
+ if (a.startsWith('--')) {
32
+ const key = a.slice(2)
33
+ if (BOOL_FLAGS.has(key)) {
34
+ flags[key] = true
35
+ continue
36
+ }
37
+ const next = args[i + 1]
38
+ if (next && !next.startsWith('-')) {
39
+ flags[key] = next
40
+ i++
41
+ } else {
42
+ flags[key] = true
43
+ }
44
+ } else {
45
+ positional.push(a)
46
+ }
47
+ }
48
+ return { flags, positional }
49
+ }
50
+
51
+ function jsonMode(flags) {
52
+ return Boolean(flags.json)
53
+ }
54
+
55
+ function printJson(data) {
56
+ console.log(JSON.stringify(data, null, 2))
57
+ }
58
+
59
+ function readMeta(cwd) {
60
+ const path = projectMetaPath(cwd)
61
+ if (!existsSync(path)) return null
62
+ return JSON.parse(readFileSync(path, 'utf8'))
63
+ }
64
+
65
+ function writeMeta(cwd, abstractId) {
66
+ writeFileSync(projectMetaPath(cwd), JSON.stringify({ abstractId }, null, 2) + '\n')
67
+ }
68
+
69
+ function requireMeta(cwd) {
70
+ const meta = readMeta(cwd)
71
+ if (!meta?.abstractId) {
72
+ throw new Error('No .amorphous.json here. Run amorphous create first.')
73
+ }
74
+ return meta.abstractId
75
+ }
76
+
77
+ async function resolveAbstractId(cwd, flags, positional) {
78
+ if (flags.abstract) return flags.abstract
79
+ if (positional[0]) {
80
+ const token = positional[0]
81
+ if (/^[0-9a-f-]{36}$/i.test(token)) return token
82
+ const data = await apiFetch('/api/abstracts?view=all')
83
+ const hit = (data.abstracts || []).find(
84
+ (a) => a.name.toLowerCase() === token.toLowerCase(),
85
+ )
86
+ if (hit) return hit.id
87
+ throw new Error(`No abstract named “${token}”`)
88
+ }
89
+ try {
90
+ return requireMeta(cwd)
91
+ } catch {
92
+ throw new Error('Pass an abstract or run this from its directory.')
93
+ }
94
+ }
95
+
96
+ async function loadFolders() {
97
+ const meta = await apiFetch('/api/organise/meta')
98
+ return meta.all_collections || meta.collections || []
99
+ }
100
+
101
+ async function resolveFolder(token) {
102
+ if (!token) return null
103
+ const folders = await loadFolders()
104
+ const want = String(token)
105
+ const hit = folders.find(
106
+ (c) =>
107
+ c.id === want ||
108
+ c.path === want ||
109
+ c.name.toLowerCase() === want.toLowerCase(),
110
+ )
111
+ if (!hit) throw new Error(`No folder named “${token}”`)
112
+ return hit
113
+ }
114
+
115
+ function folderToken(flags) {
116
+ return flags.folder || flags.collection
117
+ }
118
+
119
+ function parseAccess(raw) {
120
+ const v = String(raw || 'view').trim().toLowerCase()
121
+ if (['view', 'user', 'can-view', 'can view'].includes(v)) return 'user'
122
+ if (['use', 'can-use', 'can use'].includes(v)) return 'use'
123
+ if (['edit', 'editor', 'can-edit', 'can edit'].includes(v)) return 'editor'
124
+ throw new Error('Access must be view, use, or edit.')
125
+ }
126
+
127
+ function accessLabel(role) {
128
+ if (role === 'owner') return 'Owner'
129
+ if (role === 'editor' || role === 'admin') return 'Can edit'
130
+ if (role === 'use') return 'Can use'
131
+ return 'Can view'
132
+ }
133
+
134
+ function listRow(row) {
135
+ const tags = (row.tags || []).join(', ')
136
+ return [
137
+ row.name,
138
+ row.id,
139
+ accessLabel(row.role),
140
+ row.starred ? 'starred' : null,
141
+ row.archived ? 'archived' : null,
142
+ row.collection_path ? `in ${row.collection_path}` : null,
143
+ tags ? `#${tags.replace(/,\s*/g, ' #')}` : null,
144
+ row.live_open ? 'open' : 'closed',
145
+ ]
146
+ .filter(Boolean)
147
+ .join(' · ')
148
+ }
149
+
150
+ function publicAbstract(row) {
151
+ return {
152
+ id: row.id,
153
+ name: row.name,
154
+ role: row.role,
155
+ starred: row.starred,
156
+ archived: row.archived,
157
+ tags: row.tags || [],
158
+ folder: row.collection_path || null,
159
+ folder_id: row.collection_id || null,
160
+ open: Boolean(row.live_open),
161
+ updated_at: row.updated_at,
162
+ }
163
+ }
164
+
165
+ async function cmdLogin(rest) {
166
+ const { flags, positional } = parseFlags(rest)
167
+ if (flags.help) return printHelp('login')
168
+ const email = positional[0]
169
+ if (!email) throw new Error('Pass an email.')
170
+ await loginWithMagicLink(email.trim())
171
+ }
172
+
173
+ async function cmdCreate(cwd, rest) {
174
+ const { flags } = parseFlags(rest)
175
+ if (flags.help) return printHelp('create')
176
+ const data = await apiFetch('/api/abstracts', { method: 'POST' })
177
+ const id = data.id
178
+ writeMeta(cwd, id)
179
+ const name = typeof flags.name === 'string' ? flags.name.trim() : ''
180
+ if (name) {
181
+ await apiFetch(`/api/abstracts/${id}`, {
182
+ method: 'PATCH',
183
+ body: JSON.stringify({ name }),
184
+ })
185
+ }
186
+ const folderName = folderToken(flags)
187
+ if (folderName) {
188
+ const folder = await resolveFolder(folderName)
189
+ await apiFetch(`/api/abstracts/${id}/filing`, {
190
+ method: 'PATCH',
191
+ body: JSON.stringify({ collection_id: folder.id }),
192
+ })
193
+ }
194
+ if (jsonMode(flags)) printJson({ id, name: name || 'Untitled' })
195
+ else console.log(id)
196
+ }
197
+
198
+ function takeAccess(flags, positional) {
199
+ if (flags.access || flags.role) {
200
+ return { access: flags.access || flags.role, rest: positional }
201
+ }
202
+ if (positional[0] && /^(view|use|edit|user|editor)$/i.test(positional[0])) {
203
+ return { access: positional[0], rest: positional.slice(1) }
204
+ }
205
+ return { access: 'view', rest: positional }
206
+ }
207
+
208
+ async function resolvePerson(abstractId, who) {
209
+ if (/^[0-9a-f-]{36}$/i.test(who) && !/@/.test(who)) return { userId: who }
210
+ const detail = await apiFetch(`/api/abstracts/${abstractId}`)
211
+ const needle = who.includes('@') ? who.split('@')[0].toLowerCase() : who.toLowerCase()
212
+ const hit = (detail.members || []).find((m) => {
213
+ const name = (m.name || '').toLowerCase()
214
+ return m.user_id === who || name === needle || name === who.toLowerCase()
215
+ })
216
+ if (hit) return { userId: hit.user_id }
217
+ if (who.includes('@')) return { email: who }
218
+ throw new Error('No person matches that.')
219
+ }
220
+
221
+ async function cmdInvite(cwd, rest) {
222
+ const { flags, positional } = parseFlags(rest)
223
+ if (flags.help) return printHelp('invite')
224
+ const email = flags.email || positional[0]
225
+ if (!email) throw new Error('Pass an email.')
226
+ const afterEmail = flags.email ? positional : positional.slice(1)
227
+ const { access, rest: absArgs } = takeAccess(flags, afterEmail)
228
+ const id = await resolveAbstractId(cwd, flags, absArgs)
229
+ const role = parseAccess(access)
230
+ await apiFetch(`/api/abstracts/${id}/grant`, {
231
+ method: 'POST',
232
+ body: JSON.stringify({ email, role }),
233
+ })
234
+ console.log('Invited.')
235
+ }
236
+
237
+ async function cmdRemove(cwd, rest) {
238
+ const { flags, positional } = parseFlags(rest)
239
+ if (flags.help) return printHelp('remove')
240
+ const who = flags.email || positional[0]
241
+ if (!who) throw new Error('Pass an email or id.')
242
+ const id = await resolveAbstractId(cwd, flags, positional.slice(flags.email ? 0 : 1))
243
+ const body = await resolvePerson(id, who)
244
+ await apiFetch(`/api/abstracts/${id}/revoke`, {
245
+ method: 'POST',
246
+ body: JSON.stringify(body),
247
+ })
248
+ console.log('Removed.')
249
+ }
250
+
251
+ async function cmdPeople(cwd, rest) {
252
+ const { flags, positional } = parseFlags(rest)
253
+ if (flags.help) return printHelp('people')
254
+ const id = await resolveAbstractId(cwd, flags, positional)
255
+ const detail = await apiFetch(`/api/abstracts/${id}`)
256
+ const people = (detail.members || []).map((m) => ({
257
+ id: m.user_id,
258
+ name: m.name,
259
+ access: accessLabel(m.role),
260
+ role: m.role,
261
+ }))
262
+ if (jsonMode(flags)) {
263
+ printJson({ people })
264
+ return
265
+ }
266
+ if (!people.length) {
267
+ console.log('No people yet.')
268
+ return
269
+ }
270
+ for (const p of people) {
271
+ console.log(`${p.name} · ${p.access} · ${p.id}`)
272
+ }
273
+ }
274
+
275
+ async function cmdPull(cwd, rest) {
276
+ const { flags } = parseFlags(rest)
277
+ if (flags.help) return printHelp('pull')
278
+ const id = requireMeta(cwd)
279
+ const detail = await apiFetch(`/api/abstracts/${id}`)
280
+ const records = await apiFetch(`/api/abstracts/${id}/records`)
281
+ const rows = records.records || []
282
+ const shared = rows.filter((r) => r.visibility !== 'private')
283
+ const priv = rows.filter((r) => r.visibility === 'private')
284
+ writeProjectFiles(cwd, {
285
+ html: detail.abstract.draft_html,
286
+ css: detail.abstract.draft_css,
287
+ js: detail.abstract.draft_js,
288
+ shared,
289
+ private: priv,
290
+ })
291
+ console.log('Pulled.')
292
+ }
293
+
294
+ async function cmdPush(cwd, rest) {
295
+ const { flags } = parseFlags(rest)
296
+ if (flags.help) return printHelp('push')
297
+ const id = requireMeta(cwd)
298
+ const localRecords = recordsFromLocal(cwd)
299
+ const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
300
+ method: 'POST',
301
+ body: JSON.stringify({ records: localRecords }),
302
+ })
303
+ if (refused?.length) {
304
+ console.error('Skipped (already live):', refused.join(', '))
305
+ }
306
+ const { html, css, js } = readProjectFiles(cwd)
307
+ await apiFetch(`/api/abstracts/${id}/push`, {
308
+ method: 'POST',
309
+ body: JSON.stringify({ html, css, js }),
310
+ })
311
+ console.log('Pushed.')
312
+ }
313
+
314
+ function urlsFor(id) {
315
+ return {
316
+ open: `${appOrigin().replace(/\/$/, '')}/a/${id}`,
317
+ preview: `${abstractsOrigin().replace(/\/$/, '')}/${id}/draft`,
318
+ live: `${abstractsOrigin().replace(/\/$/, '')}/${id}`,
319
+ }
320
+ }
321
+
322
+ async function cmdOpen(cwd, rest) {
323
+ const { flags, positional } = parseFlags(rest)
324
+ if (flags.help) return printHelp('open')
325
+ const id = await resolveAbstractId(cwd, flags, positional)
326
+ const urls = urlsFor(id)
327
+ if (jsonMode(flags)) printJson(urls)
328
+ else console.log(urls.open)
329
+ }
330
+
331
+ async function cmdPreview(cwd, rest) {
332
+ const { flags, positional } = parseFlags(rest)
333
+ if (flags.help) return printHelp('preview')
334
+ const id = await resolveAbstractId(cwd, flags, positional)
335
+ const urls = urlsFor(id)
336
+ if (jsonMode(flags)) printJson({ preview: urls.preview })
337
+ else console.log(urls.preview)
338
+ }
339
+
340
+ async function cmdPublish(cwd, rest) {
341
+ const { flags, positional } = parseFlags(rest)
342
+ if (flags.help) return printHelp('publish')
343
+ const id = await resolveAbstractId(cwd, flags, positional)
344
+ const live = Boolean(flags.live)
345
+ const version = await apiFetch(`/api/abstracts/${id}/publish`, {
346
+ method: 'POST',
347
+ body: JSON.stringify({ live }),
348
+ })
349
+ if (jsonMode(flags)) printJson({ id: version.id, label: version.label, live })
350
+ else console.log(live ? 'It’s live.' : 'Published.')
351
+ }
352
+
353
+ async function cmdLive(cwd, rest) {
354
+ const { flags, positional } = parseFlags(rest)
355
+ if (flags.help) return printHelp('live')
356
+ const id = await resolveAbstractId(cwd, flags, positional)
357
+ const detail = await apiFetch(`/api/abstracts/${id}`)
358
+ const versionId =
359
+ flags.version ||
360
+ detail.abstract.live_version_id ||
361
+ detail.versions?.[detail.versions.length - 1]?.id
362
+ if (!versionId) throw new Error('Nothing published yet.')
363
+ await apiFetch(`/api/abstracts/${id}/live`, {
364
+ method: 'POST',
365
+ body: JSON.stringify({ versionId }),
366
+ })
367
+ console.log('This is the live version.')
368
+ }
369
+
370
+ async function cmdVisibility(cwd, rest) {
371
+ const { flags, positional } = parseFlags(rest)
372
+ if (flags.help) return printHelp('visibility')
373
+ const mode = String(positional[0] || '').toLowerCase()
374
+ if (mode !== 'anyone' && mode !== 'members') {
375
+ throw new Error('Pass anyone or members.')
376
+ }
377
+ const id = await resolveAbstractId(cwd, flags, positional.slice(1))
378
+ const detail = await apiFetch(`/api/abstracts/${id}`)
379
+ const versionId =
380
+ flags.version ||
381
+ detail.abstract.live_version_id ||
382
+ detail.versions?.[detail.versions.length - 1]?.id
383
+ if (!versionId) throw new Error('Nothing published yet.')
384
+ const open = mode === 'anyone'
385
+ await apiFetch(`/api/abstracts/${id}/open`, {
386
+ method: 'POST',
387
+ body: JSON.stringify({ versionId, open }),
388
+ })
389
+ console.log(
390
+ open ? 'Anyone can view this version.' : 'Only people you invite can view it.',
391
+ )
392
+ }
393
+
394
+ async function cmdList(rest) {
395
+ const { flags } = parseFlags(rest)
396
+ if (flags.help) return printHelp('list')
397
+ const params = new URLSearchParams()
398
+ const view = flags.view ? String(flags.view) : 'all'
399
+ params.set('view', view)
400
+ const folder = folderToken(flags)
401
+ if (folder) params.set('collection', String(folder))
402
+ if (flags.tag) params.set('tag', String(flags.tag))
403
+ if (flags.search) params.set('search', String(flags.search))
404
+ if (flags.space) params.set('space', String(flags.space))
405
+ for (const key of ['starred', 'archived', 'owned', 'member', 'open', 'closed']) {
406
+ if (flags[key]) params.set(key, 'true')
407
+ }
408
+ const data = await apiFetch(`/api/abstracts?${params}`)
409
+ const rows = data.abstracts || []
410
+ if (jsonMode(flags)) {
411
+ printJson({ abstracts: rows.map(publicAbstract) })
412
+ return
413
+ }
414
+ if (!rows.length) {
415
+ console.log('Nothing here.')
416
+ return
417
+ }
418
+ for (const row of rows) console.log(listRow(row))
419
+ }
420
+
421
+ async function cmdStar(cwd, rest, starred) {
422
+ const { flags, positional } = parseFlags(rest)
423
+ if (flags.help) return printHelp(starred ? 'star' : 'unstar')
424
+ const id = await resolveAbstractId(cwd, flags, positional)
425
+ await apiFetch(`/api/abstracts/${id}/filing`, {
426
+ method: 'PATCH',
427
+ body: JSON.stringify({ starred }),
428
+ })
429
+ console.log(starred ? 'Starred.' : 'Unstarred.')
430
+ }
431
+
432
+ async function cmdTag(cwd, rest, add) {
433
+ const { flags, positional } = parseFlags(rest)
434
+ if (flags.help) return printHelp(add ? 'tag' : 'untag')
435
+ const tag = flags.tag || positional[1]
436
+ if (!tag || typeof tag !== 'string') throw new Error('Pass --tag <name>.')
437
+ const id = await resolveAbstractId(cwd, flags, positional)
438
+ const data = await apiFetch('/api/abstracts?view=all')
439
+ const row = (data.abstracts || []).find((a) => a.id === id)
440
+ const tags = new Set(row?.tags || [])
441
+ if (add) tags.add(tag.trim().toLowerCase())
442
+ else tags.delete(tag.trim().toLowerCase())
443
+ await apiFetch(`/api/abstracts/${id}/filing`, {
444
+ method: 'PATCH',
445
+ body: JSON.stringify({ tags: [...tags] }),
446
+ })
447
+ console.log(add ? 'Tagged.' : 'Untagged.')
448
+ }
449
+
450
+ async function cmdMove(cwd, rest) {
451
+ const { flags, positional } = parseFlags(rest)
452
+ if (flags.help) return printHelp('move')
453
+ const id = await resolveAbstractId(cwd, flags, positional)
454
+ let collection_id = null
455
+ const token = folderToken(flags)
456
+ if (flags.none) {
457
+ collection_id = null
458
+ } else if (token) {
459
+ collection_id = (await resolveFolder(token)).id
460
+ } else {
461
+ throw new Error('Pass --folder <name> or --none.')
462
+ }
463
+ await apiFetch(`/api/abstracts/${id}/filing`, {
464
+ method: 'PATCH',
465
+ body: JSON.stringify({ collection_id }),
466
+ })
467
+ console.log(collection_id ? 'Moved to folder.' : 'No folder.')
468
+ }
469
+
470
+ async function cmdArchive(cwd, rest, archived) {
471
+ const { flags, positional } = parseFlags(rest)
472
+ if (flags.help) return printHelp(archived ? 'archive' : 'restore')
473
+ const id = await resolveAbstractId(cwd, flags, positional)
474
+ await apiFetch(`/api/abstracts/${id}/filing`, {
475
+ method: 'PATCH',
476
+ body: JSON.stringify({ archived }),
477
+ })
478
+ console.log(archived ? 'Archived.' : 'Restored.')
479
+ }
480
+
481
+ async function cmdSuggestFolder(cwd, rest) {
482
+ const { flags, positional } = parseFlags(rest)
483
+ if (flags.help) return printHelp('suggest-folder')
484
+ const id = await resolveAbstractId(cwd, flags, positional)
485
+ const data = await apiFetch(`/api/abstracts/${id}/organise`, {
486
+ method: 'POST',
487
+ body: JSON.stringify({ apply: Boolean(flags.apply) }),
488
+ })
489
+ const folders = await loadFolders()
490
+ const folderId = data.suggestion?.collection_id ?? null
491
+ const folder = folders.find((f) => f.id === folderId)
492
+ const payload = {
493
+ folder: folder?.path ?? null,
494
+ folder_id: folderId,
495
+ reason: data.suggestion?.collection_reason ?? null,
496
+ tags: data.suggestion?.tags ?? [],
497
+ applied: Boolean(data.applied),
498
+ }
499
+ if (jsonMode(flags)) {
500
+ printJson(payload)
501
+ return
502
+ }
503
+ if (flags.apply && folder) {
504
+ console.log('Moved to that folder.')
505
+ return
506
+ }
507
+ if (!folder) {
508
+ console.log('No folder to suggest.')
509
+ return
510
+ }
511
+ console.log(`Folder: ${folder.path}`)
512
+ if (payload.reason) console.log(payload.reason)
513
+ if (payload.tags.length) console.log(`Tags: ${payload.tags.join(', ')}`)
514
+ }
515
+
516
+ async function cmdRename(cwd, rest) {
517
+ const { flags, positional } = parseFlags(rest)
518
+ if (flags.help) return printHelp('rename')
519
+ const name = typeof flags.name === 'string' ? flags.name.trim() : ''
520
+ if (!name) throw new Error('Pass --name <name>.')
521
+ const id = await resolveAbstractId(cwd, flags, positional)
522
+ await apiFetch(`/api/abstracts/${id}`, {
523
+ method: 'PATCH',
524
+ body: JSON.stringify({ name }),
525
+ })
526
+ console.log('Renamed.')
527
+ }
528
+
529
+ async function cmdFolderNew(rest) {
530
+ const { flags, positional } = parseFlags(rest)
531
+ const name = flags.name || positional[0]
532
+ if (!name) throw new Error('Pass --name <name>.')
533
+ const data = await apiFetch('/api/organise/collections', {
534
+ method: 'POST',
535
+ body: JSON.stringify({
536
+ name,
537
+ parent_id: flags.parent && flags.parent !== 'none' ? flags.parent : null,
538
+ }),
539
+ })
540
+ if (jsonMode(flags)) printJson({ id: data.id, name: data.name })
541
+ else console.log(data.id, data.name)
542
+ }
543
+
544
+ async function cmdFolderList(rest) {
545
+ const { flags } = parseFlags(rest)
546
+ const folders = await loadFolders()
547
+ if (jsonMode(flags)) {
548
+ printJson({
549
+ folders: folders.map((f) => ({
550
+ id: f.id,
551
+ name: f.name,
552
+ path: f.path,
553
+ parent_id: f.parent_id,
554
+ abstracts: f.abstract_count,
555
+ })),
556
+ })
557
+ return
558
+ }
559
+ if (!folders.length) {
560
+ console.log('No folders yet.')
561
+ return
562
+ }
563
+ for (const f of folders) {
564
+ console.log(`${f.path} · ${f.id}`)
565
+ }
566
+ }
567
+
568
+ async function cmdFolderMove(rest) {
569
+ const { flags, positional } = parseFlags(rest)
570
+ const token = flags.id || positional[0]
571
+ if (!token) throw new Error('Pass a folder.')
572
+ if (flags.parent === undefined) {
573
+ throw new Error('Pass --parent <id|none>.')
574
+ }
575
+ const folder = await resolveFolder(token)
576
+ const parent_id =
577
+ !flags.parent || flags.parent === 'none' || flags.parent === 'null'
578
+ ? null
579
+ : (await resolveFolder(flags.parent)).id
580
+ await apiFetch(`/api/organise/collections/${folder.id}/move`, {
581
+ method: 'POST',
582
+ body: JSON.stringify({ parent_id }),
583
+ })
584
+ console.log('Moved folder.')
585
+ }
586
+
587
+ async function cmdFolderInvite(rest) {
588
+ const { flags, positional } = parseFlags(rest)
589
+ const token = positional[0]
590
+ const email = flags.email || positional[1]
591
+ if (!token || !email) throw new Error('Pass a folder and --email <email>.')
592
+ const folder = await resolveFolder(token)
593
+ await apiFetch(`/api/organise/collections/${folder.id}/invite`, {
594
+ method: 'POST',
595
+ body: JSON.stringify({ email }),
596
+ })
597
+ console.log('Invited.')
598
+ }
599
+
600
+ async function cmdFolderRemove(rest) {
601
+ const { flags, positional } = parseFlags(rest)
602
+ const token = positional[0]
603
+ const who = flags.email || positional[1]
604
+ if (!token || !who) throw new Error('Pass a folder and an email or id.')
605
+ const folder = await resolveFolder(token)
606
+ const body = /@/.test(who) ? { email: who } : { userId: who }
607
+ await apiFetch(`/api/organise/collections/${folder.id}/revoke`, {
608
+ method: 'POST',
609
+ body: JSON.stringify(body),
610
+ })
611
+ console.log('Removed.')
612
+ }
613
+
614
+ async function cmdFolderPeople(rest) {
615
+ const { flags, positional } = parseFlags(rest)
616
+ const token = positional[0]
617
+ if (!token) throw new Error('Pass a folder.')
618
+ const folder = await resolveFolder(token)
619
+ const share = await apiFetch(`/api/organise/collections/${folder.id}`)
620
+ const people = (share.people || []).map((m) => ({
621
+ id: m.user_id,
622
+ name: m.name,
623
+ access: accessLabel(m.role),
624
+ role: m.role,
625
+ }))
626
+ if (jsonMode(flags)) {
627
+ printJson({ folder: folder.path, people })
628
+ return
629
+ }
630
+ if (!people.length) {
631
+ console.log('No people yet.')
632
+ return
633
+ }
634
+ for (const p of people) {
635
+ console.log(`${p.name} · ${p.access} · ${p.id}`)
636
+ }
637
+ }
638
+
639
+ async function cmdFolder(rest) {
640
+ const { flags } = parseFlags(rest)
641
+ if (flags.help || !rest[0]) return printHelp('folder')
642
+ const [sub, ...tail] = rest
643
+ if (sub === 'new') return cmdFolderNew(tail)
644
+ if (sub === 'list') return cmdFolderList(tail)
645
+ if (sub === 'move') return cmdFolderMove(tail)
646
+ if (sub === 'invite') return cmdFolderInvite(tail)
647
+ if (sub === 'remove') return cmdFolderRemove(tail)
648
+ if (sub === 'people') return cmdFolderPeople(tail)
649
+ throw new Error('folder needs new, list, move, invite, remove, or people.')
650
+ }
651
+
652
+ async function cmdSpaceNew(rest) {
653
+ const { flags } = parseFlags(rest)
654
+ const name = flags.name || rest.find((a) => !a.startsWith('--'))
655
+ if (!name) throw new Error('Use folder new instead.')
656
+ const data = await apiFetch('/api/spaces', {
657
+ method: 'POST',
658
+ body: JSON.stringify({ name }),
659
+ })
660
+ console.log(data.id, data.name)
661
+ }
662
+
663
+ async function cmdSpaceMember(rest) {
664
+ const { flags, positional } = parseFlags(rest)
665
+ const spaceId = flags.space || positional[0]
666
+ const email = flags.email || positional[1]
667
+ if (!spaceId || !email) throw new Error('Use folder invite instead.')
668
+ await apiFetch(`/api/spaces/${spaceId}/members`, {
669
+ method: 'POST',
670
+ body: JSON.stringify({ email, role: flags.role || 'member' }),
671
+ })
672
+ console.log('Invited.')
673
+ }
674
+
675
+ export async function runCommand(argv, cwd = process.cwd()) {
676
+ if (!argv[0] || argv[0] === 'help' || argv[0] === '--help' || argv[0] === '-h') {
677
+ printHelp()
678
+ return
679
+ }
680
+ const [raw, ...rest] = argv
681
+ const cmd = ALIASES[raw] || raw
682
+ switch (cmd) {
683
+ case 'login':
684
+ return cmdLogin(rest)
685
+ case 'create':
686
+ return cmdCreate(cwd, rest)
687
+ case 'invite':
688
+ return cmdInvite(cwd, rest)
689
+ case 'remove':
690
+ return cmdRemove(cwd, rest)
691
+ case 'people':
692
+ return cmdPeople(cwd, rest)
693
+ case 'pull':
694
+ return cmdPull(cwd, rest)
695
+ case 'push':
696
+ return cmdPush(cwd, rest)
697
+ case 'open':
698
+ return cmdOpen(cwd, rest)
699
+ case 'preview':
700
+ return cmdPreview(cwd, rest)
701
+ case 'publish':
702
+ return cmdPublish(cwd, rest)
703
+ case 'live':
704
+ return cmdLive(cwd, rest)
705
+ case 'visibility':
706
+ return cmdVisibility(cwd, rest)
707
+ case 'list':
708
+ return cmdList(rest)
709
+ case 'star':
710
+ return cmdStar(cwd, rest, true)
711
+ case 'unstar':
712
+ return cmdStar(cwd, rest, false)
713
+ case 'tag':
714
+ return cmdTag(cwd, rest, true)
715
+ case 'untag':
716
+ return cmdTag(cwd, rest, false)
717
+ case 'move':
718
+ return cmdMove(cwd, rest)
719
+ case 'archive':
720
+ return cmdArchive(cwd, rest, true)
721
+ case 'restore':
722
+ return cmdArchive(cwd, rest, false)
723
+ case 'suggest-folder':
724
+ return cmdSuggestFolder(cwd, rest)
725
+ case 'folder':
726
+ return cmdFolder(rest)
727
+ case 'rename':
728
+ return cmdRename(cwd, rest)
729
+ case 'space':
730
+ if (rest[0] === 'new') return cmdSpaceNew(rest.slice(1))
731
+ if (rest[0] === 'member') return cmdSpaceMember(rest.slice(1))
732
+ throw new Error('Use folder instead.')
733
+ default:
734
+ throw new Error(`Unknown command “${raw}”. Run amorphous --help.`)
735
+ }
736
+ }