amorphous-cli 0.1.12 → 0.2.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.
package/README.md CHANGED
@@ -5,3 +5,5 @@ Make whatever you want, yourself, with your own agent, or with others. And whate
5
5
  ```bash
6
6
  npx amorphous-cli@latest setup
7
7
  ```
8
+
9
+ Create a purpose-built starter with `amorphous-cli create --template list|board|landing` (or omit the flag for a blank page). Collaborators can run `amorphous-cli recommend --note "…"` to send local source to the owner for review. The owner can publish a recommendation live or decline it in the abstract.
@@ -53,12 +53,16 @@ export function syncProjectContract(cwd) {
53
53
  return 'refreshed'
54
54
  }
55
55
 
56
- export function writeStarter(cwd) {
56
+ export function writeStarter(cwd, template = 'blank') {
57
57
  const assets = join(skillRoot, 'assets')
58
+ const templates = JSON.parse(readFileSync(join(dirname(skillRoot), '../templates.json'), 'utf8'))
59
+ const selected = templates[template]
60
+ if (!selected) throw new Error(`Unknown template “${template}”. Choose blank, list, board, or landing.`)
61
+ const starterFiles = { 'index.html': selected.html, 'styles.css': selected.css, 'app.js': selected.js }
58
62
  for (const name of ['index.html', 'styles.css', 'app.js']) {
59
63
  const dest = join(cwd, name)
60
64
  if (!existsSync(dest)) {
61
- writeFileSync(dest, readFileSync(join(assets, name)))
65
+ writeFileSync(dest, starterFiles[name] ?? readFileSync(join(assets, name)))
62
66
  }
63
67
  }
64
68
  for (const name of ['data.ndjson', 'private.ndjson']) {
@@ -31,7 +31,18 @@ test('setup copies the skill, and an edited contract is left alone', () => {
31
31
  const project = mkdtempSync(join(tmpdir(), 'amorphous-page-'))
32
32
  assert.equal(syncProjectContract(project), 'wrote')
33
33
  writeStarter(project)
34
- assert.match(readFileSync(join(project, 'index.html'), 'utf8'), /id="who"/)
34
+ assert.match(readFileSync(join(project, 'index.html'), 'utf8'), /Make this abstract your own/)
35
+ assert.equal(readFileSync(join(project, 'styles.css'), 'utf8').length > 0, true)
36
+ const board = mkdtempSync(join(tmpdir(), 'amorphous-board-'))
37
+ writeStarter(board, 'board')
38
+ assert.match(readFileSync(join(board, 'index.html'), 'utf8'), /Project board/)
39
+ assert.match(readFileSync(join(board, 'app.js'), 'utf8'), /board-card/)
40
+ for (const [template, expected] of [['list', 'SHARED LIST'], ['landing', 'A CLEAR, HUMAN INTRODUCTION']]) {
41
+ const dir = mkdtempSync(join(tmpdir(), `amorphous-${template}-`))
42
+ writeStarter(dir, template)
43
+ assert.match(readFileSync(join(dir, 'index.html'), 'utf8'), new RegExp(expected))
44
+ }
45
+ assert.throws(() => writeStarter(project, 'unknown'), /Unknown template/)
35
46
  assert.match(readFileSync(join(project, 'AGENTS.md'), 'utf8'), new RegExp(`amorphous-contract: ${CONTRACT_VERSION}`))
36
47
 
37
48
  const edited = readFileSync(join(project, 'AGENTS.md'), 'utf8') + '\nEdited.\n'
@@ -0,0 +1,52 @@
1
+ import { getDomain, getPublicSuffix } from 'tldts'
2
+
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
4
+
5
+ /** URL construction mirrors shared/app-origins.ts; hosted bases come from the platform. */
6
+ export function hostedAppUrls(id, platformOrigin, env = process.env) {
7
+ if (!UUID.test(id)) throw new Error('Invalid app ID')
8
+ const platform = new URL(platformOrigin)
9
+ if (platform.username || platform.password || platform.pathname !== '/' || platform.search || platform.hash ||
10
+ !['http:', 'https:'].includes(platform.protocol)) throw new Error('Invalid platform origin')
11
+ const local = platform.hostname.endsWith('.localhost') || platform.hostname.endsWith('.test')
12
+ const runtimeBase = env.RUNTIME_BASE_DOMAIN || (local ? 'apps.localhost' : '')
13
+ const previewBase = env.PREVIEW_BASE_DOMAIN || (local ? 'preview.localhost' : '')
14
+ if (!runtimeBase || !previewBase || runtimeBase === previewBase) {
15
+ throw new Error('Runtime and preview base domains are not configured')
16
+ }
17
+ const validBase = (base) => base.includes('.') && base.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)) &&
18
+ getDomain(base, { allowPrivateDomains: true }) && base !== getPublicSuffix(base, { allowPrivateDomains: true })
19
+ if (!validBase(runtimeBase) || !validBase(previewBase) || runtimeBase.endsWith(`.${previewBase}`) || previewBase.endsWith(`.${runtimeBase}`)) {
20
+ throw new Error('Invalid runtime base domains')
21
+ }
22
+ const scheme = env.RUNTIME_SCHEME || (local ? 'http' : 'https')
23
+ const port = env.RUNTIME_PORT ?? (local ? platform.port : '')
24
+ if (!['http', 'https'].includes(scheme) || (port !== '' && (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535))) {
25
+ throw new Error('Invalid runtime origin configuration')
26
+ }
27
+ if (!local && (scheme !== 'https' || port ||
28
+ getDomain(runtimeBase, { allowPrivateDomains: true }) === getDomain(platform.hostname, { allowPrivateDomains: true }) ||
29
+ getDomain(previewBase, { allowPrivateDomains: true }) === getDomain(platform.hostname, { allowPrivateDomains: true }))) {
30
+ throw new Error('Production runtime origins must be HTTPS and separate from the platform domain')
31
+ }
32
+ const suffix = port ? `:${port}` : ''
33
+ return {
34
+ open: `${platform.origin}/a/${id}`,
35
+ preview: `${scheme}://${id}.${previewBase}${suffix}/${id}/draft`,
36
+ live: `${scheme}://${id}.${runtimeBase}${suffix}/${id}`,
37
+ }
38
+ }
39
+
40
+ export function urlsFromDiscovery(id, platformOrigin, discovery, overrides = {}) {
41
+ if (!discovery || typeof discovery !== 'object' ||
42
+ discovery.appOrigin !== new URL(platformOrigin).origin) {
43
+ throw new Error('Runtime origin discovery did not match the configured platform')
44
+ }
45
+ return hostedAppUrls(id, platformOrigin, {
46
+ RUNTIME_BASE_DOMAIN: overrides.RUNTIME_BASE_DOMAIN || discovery.runtimeBase,
47
+ PREVIEW_BASE_DOMAIN: overrides.PREVIEW_BASE_DOMAIN || discovery.previewBase,
48
+ RUNTIME_SCHEME: overrides.RUNTIME_SCHEME || discovery.runtimeScheme,
49
+ RUNTIME_PORT: overrides.RUNTIME_PORT ?? discovery.runtimePort,
50
+ })
51
+ }
52
+
@@ -0,0 +1,40 @@
1
+ import { test } from 'node:test'
2
+ import { strict as assert } from 'node:assert'
3
+ import { hostedAppUrls, urlsFromDiscovery } from './app-origins.mjs'
4
+
5
+ const id = '11111111-1111-4111-8111-111111111111'
6
+
7
+ test('CLI URLs use per-app production and preview hosts', () => {
8
+ assert.deepEqual(hostedAppUrls(id, 'http://amorphous.localhost:3849', {}), {
9
+ open: `http://amorphous.localhost:3849/a/${id}`,
10
+ preview: `http://${id}.preview.localhost:3849/${id}/draft`,
11
+ live: `http://${id}.apps.localhost:3849/${id}`,
12
+ })
13
+ })
14
+
15
+ test('CLI production URLs require configured isolated domains', () => {
16
+ assert.throws(() => hostedAppUrls(id, 'https://platform.example.org', {}), /not configured/)
17
+ })
18
+
19
+ test('hosted CLI derives URLs from validated server discovery without local configuration', () => {
20
+ assert.deepEqual(urlsFromDiscovery(id, 'https://platform.example.org', {
21
+ appOrigin: 'https://platform.example.org', runtimeBase: 'apps.example.net',
22
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
23
+ }), {
24
+ open: `https://platform.example.org/a/${id}`,
25
+ preview: `https://${id}.preview.example.net/${id}/draft`,
26
+ live: `https://${id}.apps.example.net/${id}`,
27
+ })
28
+ assert.throws(() => urlsFromDiscovery(id, 'https://platform.example.org', {
29
+ appOrigin: 'https://other.example.org', runtimeBase: 'apps.example.net',
30
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
31
+ }), /did not match/)
32
+ assert.throws(() => urlsFromDiscovery(id, 'https://platform.example.org', {
33
+ appOrigin: 'https://platform.example.org/path', runtimeBase: 'apps.example.net',
34
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
35
+ }), /did not match/)
36
+ assert.throws(() => urlsFromDiscovery(id, 'https://platform.example.org', {
37
+ appOrigin: 'https://platform.example.org', runtimeBase: 'apps.platform.example.org',
38
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
39
+ }), /separate/)
40
+ })
package/lib/commands.mjs CHANGED
@@ -1,11 +1,13 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs'
1
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
2
3
  import { installUserSkill, syncProjectContract, writeStarter } from './agent-contract.mjs'
3
4
  import { ensureSessionHooks } from './agent-hooks.mjs'
4
5
  import { shouldRefreshSkill } from './update-check.mjs'
5
6
  import { CLI_VERSION } from './version.mjs'
6
7
  import { homedir } from 'node:os'
7
8
  import { apiFetch } from './api.mjs'
8
- import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
9
+ import { appOrigin, projectMetaPath } from './config.mjs'
10
+ import { urlsFromDiscovery } from './app-origins.mjs'
9
11
  import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
10
12
  import { startServe } from './serve.mjs'
11
13
  import { ALIASES, printHelp, wantsHelp } from './help.mjs'
@@ -80,6 +82,18 @@ function writeMeta(cwd, abstractId) {
80
82
  writeFileSync(projectMetaPath(cwd), JSON.stringify({ abstractId }, null, 2) + '\n')
81
83
  }
82
84
 
85
+ export function writeRecipeFiles(cwd, source) {
86
+ const files = [['index.html', source?.html], ['styles.css', source?.css], ['app.js', source?.js]]
87
+ if (files.some(([, content]) => typeof content !== 'string')) throw new Error('Recipe response did not include source files.')
88
+ const paths = files.map(([name]) => join(cwd, name))
89
+ if ([...paths, join(cwd, 'data.ndjson'), join(cwd, 'private.ndjson')].some(existsSync)) {
90
+ throw new Error('Recipe creation needs an empty source folder so it can write the generated files safely.')
91
+ }
92
+ for (let index = 0; index < files.length; index++) writeFileSync(paths[index], files[index][1])
93
+ writeFileSync(join(cwd, 'data.ndjson'), '')
94
+ writeFileSync(join(cwd, 'private.ndjson'), '')
95
+ }
96
+
83
97
  function requireMeta(cwd) {
84
98
  const meta = readMeta(cwd)
85
99
  if (!meta?.abstractId) {
@@ -215,7 +229,37 @@ They tell you when to push, preview, publish, or invite.`)
215
229
  async function cmdCreate(cwd, rest) {
216
230
  const { flags } = parseFlags(rest)
217
231
  if (flags.help) return printHelp('create')
218
- const data = await apiFetch('/api/abstracts', { method: 'POST' })
232
+ if (flags['recipe-config'] === true) throw new Error('Pass a JSON file with --recipe-config <file>.')
233
+ if (typeof flags['recipe-config'] === 'string') {
234
+ const recipePath = flags['recipe-config'].startsWith('/') ? flags['recipe-config'] : join(cwd, flags['recipe-config'])
235
+ let config
236
+ try { config = JSON.parse(readFileSync(recipePath, 'utf8')) } catch { throw new Error('Could not read recipe config JSON.') }
237
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Recipe config must be a JSON object.')
238
+ if (typeof flags.name === 'string' && flags.name.trim()) config.name = flags.name.trim()
239
+ const sourceTargets = ['index.html', 'styles.css', 'app.js', 'data.ndjson', 'private.ndjson', '.amorphous.json']
240
+ if (sourceTargets.some(name => existsSync(join(cwd, name)))) {
241
+ throw new Error('Recipe creation needs an empty project folder.')
242
+ }
243
+ const data = await apiFetch('/api/abstracts/recipe', { method: 'POST', body: JSON.stringify(config) })
244
+ writeRecipeFiles(cwd, data.source)
245
+ writeMeta(cwd, data.id)
246
+ if (data.recoveryContract) {
247
+ writeFileSync(projectMetaPath(cwd), JSON.stringify({ abstractId: data.id, recoveryContract: data.recoveryContract }, null, 2) + '\n')
248
+ }
249
+ const folderName = folderToken(flags)
250
+ if (folderName) {
251
+ const folder = await resolveFolder(folderName)
252
+ await apiFetch(`/api/abstracts/${data.id}/filing`, {
253
+ method: 'PATCH', body: JSON.stringify({ collection_id: folder.id }),
254
+ })
255
+ }
256
+ syncProjectContract(cwd)
257
+ if (jsonMode(flags)) printJson({ id: data.id, name: data.name })
258
+ else { console.log(data.id); console.log('Prefer amorphous-cli serve while you edit.') }
259
+ return
260
+ }
261
+ const template = typeof flags.template === 'string' ? flags.template : 'blank'
262
+ const data = await apiFetch('/api/abstracts', { method: 'POST', body: JSON.stringify({ template }) })
219
263
  const id = data.id
220
264
  writeMeta(cwd, id)
221
265
  const name = typeof flags.name === 'string' ? flags.name.trim() : ''
@@ -233,7 +277,7 @@ async function cmdCreate(cwd, rest) {
233
277
  body: JSON.stringify({ collection_id: folder.id }),
234
278
  })
235
279
  }
236
- writeStarter(cwd)
280
+ writeStarter(cwd, template)
237
281
  syncProjectContract(cwd)
238
282
  if (jsonMode(flags)) printJson({ id, name: name || 'Untitled' })
239
283
  else {
@@ -340,24 +384,38 @@ async function cmdPeople(cwd, rest) {
340
384
  }
341
385
  }
342
386
 
387
+ export function writePulledProject(cwd, id, detail, records, { fresh = false } = {}) {
388
+ if (fresh && readdirSync(cwd).length) {
389
+ throw new Error('Pulling into a new folder needs an empty directory.')
390
+ }
391
+ const abstract = detail?.abstract
392
+ if (!abstract || typeof abstract.draft_html !== 'string' || typeof abstract.draft_css !== 'string' || typeof abstract.draft_js !== 'string') {
393
+ throw new Error('Abstract response did not include editable source files.')
394
+ }
395
+ const rows = records?.records || []
396
+ writeProjectFiles(cwd, {
397
+ html: abstract.draft_html,
398
+ css: abstract.draft_css,
399
+ js: abstract.draft_js,
400
+ shared: rows.filter((r) => r.visibility !== 'private'),
401
+ private: rows.filter((r) => r.visibility === 'private'),
402
+ })
403
+ const recoveryContract = abstract.draft_recovery_contract
404
+ writeFileSync(projectMetaPath(cwd), JSON.stringify({
405
+ abstractId: id,
406
+ ...(recoveryContract && typeof recoveryContract === 'object' ? { recoveryContract } : {}),
407
+ }, null, 2) + '\n')
408
+ syncProjectContract(cwd)
409
+ }
410
+
343
411
  async function cmdPull(cwd, rest) {
344
- const { flags } = parseFlags(rest)
412
+ const { flags, positional } = parseFlags(rest)
345
413
  if (flags.help) return printHelp('pull')
346
- const id = requireMeta(cwd)
414
+ const id = await resolveAbstractId(cwd, flags, positional)
347
415
  const detail = await apiFetch(`/api/abstracts/${id}`)
348
416
  warnIfRemoteChanged(detail)
349
417
  const records = await apiFetch(`/api/abstracts/${id}/records`)
350
- const rows = records.records || []
351
- const shared = rows.filter((r) => r.visibility !== 'private')
352
- const priv = rows.filter((r) => r.visibility === 'private')
353
- writeProjectFiles(cwd, {
354
- html: detail.abstract.draft_html,
355
- css: detail.abstract.draft_css,
356
- js: detail.abstract.draft_js,
357
- shared,
358
- private: priv,
359
- })
360
- syncProjectContract(cwd)
418
+ writePulledProject(cwd, id, detail, records, { fresh: positional.length > 0 || Boolean(flags.abstract) })
361
419
  console.log('Pulled.')
362
420
  console.log('Prefer amorphous-cli serve while you edit.')
363
421
  }
@@ -366,7 +424,8 @@ async function cmdPush(cwd, rest) {
366
424
  const { flags } = parseFlags(rest)
367
425
  if (flags.help) return printHelp('push')
368
426
  const id = requireMeta(cwd)
369
- warnIfRemoteChanged(await apiFetch(`/api/abstracts/${id}`))
427
+ const detail = await apiFetch(`/api/abstracts/${id}`)
428
+ warnIfRemoteChanged(detail)
370
429
  if (flags.data) {
371
430
  const localRecords = recordsFromLocal(cwd)
372
431
  const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
@@ -380,7 +439,8 @@ async function cmdPush(cwd, rest) {
380
439
  const { html, css, js } = readProjectFiles(cwd)
381
440
  await apiFetch(`/api/abstracts/${id}/push`, {
382
441
  method: 'POST',
383
- body: JSON.stringify({ html, css, js }),
442
+ body: JSON.stringify({ html, css, js, expectedRevision: detail.abstract.draft_revision,
443
+ ...(readMeta(cwd)?.recoveryContract ? { recoveryContract: readMeta(cwd).recoveryContract } : {}) }),
384
444
  })
385
445
  console.log(flags.data ? 'Pushed source and records.' : 'Pushed source.')
386
446
  }
@@ -397,6 +457,7 @@ async function cmdServe(cwd, rest) {
397
457
  cwd,
398
458
  abstractId: id,
399
459
  port,
460
+ environment: flags.preview ? 'preview' : 'production',
400
461
  json: jsonMode(flags),
401
462
  })
402
463
  let warnedRemote = false
@@ -427,19 +488,16 @@ async function cmdServe(cwd, rest) {
427
488
  })
428
489
  }
429
490
 
430
- function urlsFor(id) {
431
- return {
432
- open: `${appOrigin().replace(/\/$/, '')}/a/${id}`,
433
- preview: `${abstractsOrigin().replace(/\/$/, '')}/${id}/draft`,
434
- live: `${abstractsOrigin().replace(/\/$/, '')}/${id}`,
435
- }
491
+ async function urlsFor(id) {
492
+ const discovery = await apiFetch('/api/runtime/origins', { auth: false })
493
+ return urlsFromDiscovery(id, appOrigin(), discovery, process.env)
436
494
  }
437
495
 
438
496
  async function cmdOpen(cwd, rest) {
439
497
  const { flags, positional } = parseFlags(rest)
440
498
  if (flags.help) return printHelp('open')
441
499
  const id = await resolveAbstractId(cwd, flags, positional)
442
- const urls = urlsFor(id)
500
+ const urls = await urlsFor(id)
443
501
  if (jsonMode(flags)) printJson(urls)
444
502
  else console.log(urls.open)
445
503
  }
@@ -450,7 +508,7 @@ async function cmdPreview(cwd, rest) {
450
508
  const id = await resolveAbstractId(cwd, flags, positional)
451
509
  const detail = await apiFetch(`/api/abstracts/${id}`)
452
510
  warnIfRemoteChanged(detail)
453
- const urls = urlsFor(id)
511
+ const urls = await urlsFor(id)
454
512
  if (jsonMode(flags)) printJson({ preview: urls.preview, remoteChangedSincePush: Boolean(detail.remoteChangedSincePush) })
455
513
  else console.log(urls.preview)
456
514
  }
@@ -459,16 +517,29 @@ async function cmdPublish(cwd, rest) {
459
517
  const { flags, positional } = parseFlags(rest)
460
518
  if (flags.help) return printHelp('publish')
461
519
  const id = await resolveAbstractId(cwd, flags, positional)
462
- warnIfRemoteChanged(await apiFetch(`/api/abstracts/${id}`))
520
+ const detail = await apiFetch(`/api/abstracts/${id}`)
521
+ warnIfRemoteChanged(detail)
463
522
  const live = Boolean(flags.live)
464
523
  const version = await apiFetch(`/api/abstracts/${id}/publish`, {
465
524
  method: 'POST',
466
- body: JSON.stringify({ live }),
525
+ body: JSON.stringify({ live, expectedRevision: detail.abstract.draft_revision }),
467
526
  })
468
527
  if (jsonMode(flags)) printJson({ id: version.id, label: version.label, live })
469
528
  else console.log(live ? 'It’s live.' : 'Published.')
470
529
  }
471
530
 
531
+ async function cmdRecommend(cwd, rest) {
532
+ const { flags } = parseFlags(rest)
533
+ if (flags.help) return printHelp('recommend')
534
+ const id = requireMeta(cwd)
535
+ const { html, css, js } = readProjectFiles(cwd)
536
+ const result = await apiFetch(`/api/abstracts/${id}/recommendations`, {
537
+ method: 'POST', body: JSON.stringify({ html, css, js, note: typeof flags.note === 'string' ? flags.note : '' }),
538
+ })
539
+ if (jsonMode(flags)) printJson(result)
540
+ else console.log('Recommendation sent to the abstract owner.')
541
+ }
542
+
472
543
  async function cmdLive(cwd, rest) {
473
544
  const { flags, positional } = parseFlags(rest)
474
545
  if (flags.help) return printHelp('live')
@@ -520,7 +591,6 @@ async function cmdList(rest) {
520
591
  if (folder) params.set('collection', String(folder))
521
592
  if (flags.tag) params.set('tag', String(flags.tag))
522
593
  if (flags.search) params.set('search', String(flags.search))
523
- if (flags.space) params.set('space', String(flags.space))
524
594
  for (const key of ['starred', 'archived', 'owned', 'member', 'open', 'closed']) {
525
595
  if (flags[key]) params.set(key, 'true')
526
596
  }
@@ -703,58 +773,6 @@ async function cmdFolderMove(rest) {
703
773
  console.log('Moved folder.')
704
774
  }
705
775
 
706
- async function cmdFolderInvite(rest) {
707
- const { flags, positional } = parseFlags(rest)
708
- const token = positional[0]
709
- const email = flags.email || positional[1]
710
- if (!token || !email) throw new Error('Pass a folder and --email <email>.')
711
- const folder = await resolveFolder(token)
712
- await apiFetch(`/api/organise/collections/${folder.id}/invite`, {
713
- method: 'POST',
714
- body: JSON.stringify({ email }),
715
- })
716
- console.log('Added.')
717
- }
718
-
719
- async function cmdFolderRemove(rest) {
720
- const { flags, positional } = parseFlags(rest)
721
- const token = positional[0]
722
- const who = flags.email || positional[1]
723
- if (!token || !who) throw new Error('Pass a folder and an email or id.')
724
- const folder = await resolveFolder(token)
725
- const body = /@/.test(who) ? { email: who } : { userId: who }
726
- await apiFetch(`/api/organise/collections/${folder.id}/revoke`, {
727
- method: 'POST',
728
- body: JSON.stringify(body),
729
- })
730
- console.log('Removed.')
731
- }
732
-
733
- async function cmdFolderPeople(rest) {
734
- const { flags, positional } = parseFlags(rest)
735
- const token = positional[0]
736
- if (!token) throw new Error('Pass a folder.')
737
- const folder = await resolveFolder(token)
738
- const share = await apiFetch(`/api/organise/collections/${folder.id}`)
739
- const people = (share.people || []).map((m) => ({
740
- id: m.user_id,
741
- name: m.name,
742
- access: accessLabel(m.role),
743
- role: m.role,
744
- }))
745
- if (jsonMode(flags)) {
746
- printJson({ folder: folder.path, people })
747
- return
748
- }
749
- if (!people.length) {
750
- console.log('No people yet.')
751
- return
752
- }
753
- for (const p of people) {
754
- console.log(`${p.name} · ${p.access} · ${p.id}`)
755
- }
756
- }
757
-
758
776
  async function cmdFolder(rest) {
759
777
  const { flags } = parseFlags(rest)
760
778
  if (flags.help || !rest[0]) return printHelp('folder')
@@ -762,33 +780,7 @@ async function cmdFolder(rest) {
762
780
  if (sub === 'new') return cmdFolderNew(tail)
763
781
  if (sub === 'list') return cmdFolderList(tail)
764
782
  if (sub === 'move') return cmdFolderMove(tail)
765
- if (sub === 'invite') return cmdFolderInvite(tail)
766
- if (sub === 'remove') return cmdFolderRemove(tail)
767
- if (sub === 'people') return cmdFolderPeople(tail)
768
- throw new Error('folder needs new, list, move, invite, remove, or people.')
769
- }
770
-
771
- async function cmdSpaceNew(rest) {
772
- const { flags } = parseFlags(rest)
773
- const name = flags.name || rest.find((a) => !a.startsWith('--'))
774
- if (!name) throw new Error('Use folder new instead.')
775
- const data = await apiFetch('/api/spaces', {
776
- method: 'POST',
777
- body: JSON.stringify({ name }),
778
- })
779
- console.log(data.id, data.name)
780
- }
781
-
782
- async function cmdSpaceMember(rest) {
783
- const { flags, positional } = parseFlags(rest)
784
- const spaceId = flags.space || positional[0]
785
- const email = flags.email || positional[1]
786
- if (!spaceId || !email) throw new Error('Use folder invite instead.')
787
- await apiFetch(`/api/spaces/${spaceId}/members`, {
788
- method: 'POST',
789
- body: JSON.stringify({ email, role: flags.role || 'member' }),
790
- })
791
- console.log('Added.')
783
+ throw new Error('folder needs new, list, or move.')
792
784
  }
793
785
 
794
786
  export async function runCommand(argv, cwd = process.cwd()) {
@@ -827,6 +819,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
827
819
  return cmdServe(cwd, rest)
828
820
  case 'publish':
829
821
  return cmdPublish(cwd, rest)
822
+ case 'recommend':
823
+ return cmdRecommend(cwd, rest)
830
824
  case 'live':
831
825
  return cmdLive(cwd, rest)
832
826
  case 'visibility':
@@ -853,10 +847,6 @@ export async function runCommand(argv, cwd = process.cwd()) {
853
847
  return cmdFolder(rest)
854
848
  case 'rename':
855
849
  return cmdRename(cwd, rest)
856
- case 'space':
857
- if (rest[0] === 'new') return cmdSpaceNew(rest.slice(1))
858
- if (rest[0] === 'member') return cmdSpaceMember(rest.slice(1))
859
- throw new Error('Use folder instead.')
860
850
  default:
861
851
  throw new Error(`Unknown command “${raw}”. Run amorphous-cli --help.`)
862
852
  }
package/lib/files.mjs CHANGED
@@ -43,8 +43,10 @@ export function writeProjectFiles(cwd, payload) {
43
43
  export function recordsFromLocal(cwd) {
44
44
  const { shared, private: priv } = readProjectFiles(cwd)
45
45
  return [
46
- ...shared.map((r) => ({ ...r, visibility: r.visibility || 'shared' })),
47
- ...priv.map((r) => ({ ...r, visibility: 'private' })),
46
+ ...shared.map((r) => ({ ...r, revision: r.revision || 1, visibility: r.visibility || 'shared',
47
+ collection: r.collection || 'default', created_by: r.created_by || null })),
48
+ ...priv.map((r) => ({ ...r, revision: r.revision || 1, visibility: 'private',
49
+ collection: r.collection || 'default', created_by: r.created_by || null })),
48
50
  ]
49
51
  }
50
52
 
@@ -66,6 +68,9 @@ export function insertLocalRecord(cwd, row) {
66
68
  }
67
69
  body.visibility = body.visibility === 'private' ? 'private' : 'shared'
68
70
  if (!body.id) body.id = randomUUID()
71
+ body.revision = 1
72
+ body.collection = 'default'
73
+ body.created_by = null
69
74
  const rows = recordsFromLocal(cwd)
70
75
  if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
71
76
  rows.push(body)
@@ -83,6 +88,9 @@ export function replaceLocalRecord(cwd, id, row) {
83
88
  ...(row && typeof row === 'object' ? row : {}),
84
89
  id,
85
90
  visibility: existing.visibility,
91
+ revision: existing.revision + 1,
92
+ collection: existing.collection,
93
+ created_by: existing.created_by,
86
94
  }
87
95
  rows[index] = next
88
96
  writeLocalRecords(cwd, rows)
package/lib/help.mjs CHANGED
@@ -11,6 +11,7 @@ export const ROOT_HELP = `Amorphous
11
11
  pull Pull draft and records to this folder
12
12
  push Push local files
13
13
  publish [--live] Publish a version; --live is Go live
14
+ recommend [--note <text>] Send this draft to the owner for review
14
15
  live [abstract] Make a version live
15
16
  visibility anyone|members Anyone can view / Members only
16
17
  invite <email> Email an invite and print the link
@@ -25,9 +26,6 @@ export const ROOT_HELP = `Amorphous
25
26
  folder new New folder
26
27
  folder list Folders
27
28
  folder move Move folder
28
- folder invite Invite to a folder
29
- folder remove Remove from a folder
30
- folder people People on a folder
31
29
  rename --name Rename
32
30
 
33
31
  Run amorphous-cli <command> --help for flags. Pass --json where you need machine output.
@@ -54,9 +52,9 @@ Start the local MCP server on stdio.`,
54
52
  List abstracts. Default view is all (All Abstracts).
55
53
  --folder filters to a folder. --tag filters to a tag.`,
56
54
 
57
- create: `create [--name <name>] [--folder <name>] [--json]
55
+ create: `create [--name <name>] [--folder <name>] [--template blank|list|board|landing] [--recipe-config <file>] [--json]
58
56
 
59
- New abstract. Writes .amorphous.json in this directory.`,
57
+ New abstract. --recipe-config creates a supported recipe and writes its generated source files. Writes .amorphous.json in this directory.`,
60
58
 
61
59
  open: `open [abstract] [--json]
62
60
 
@@ -84,6 +82,10 @@ Push local index.html, styles.css, and app.js.
84
82
 
85
83
  Publish a new version. --live also makes it the live version (Go live).`,
86
84
 
85
+ recommend: `recommend [--note <text>] [--json]
86
+
87
+ Send this folder's HTML, CSS, and JavaScript to the owner as a proposal. It does not change the live version.`,
88
+
87
89
  live: `live [abstract] [--version <id>]
88
90
 
89
91
  Make this the live version. Uses the latest published version if you omit --version.`,
@@ -140,16 +142,12 @@ Unstar.`,
140
142
 
141
143
  Suggest folder from the name. Default is a suggestion only. --apply uses it.`,
142
144
 
143
- folder: `folder <new|list|move|invite|remove|people> …
145
+ folder: `folder <new|list|move> …
144
146
 
145
147
  folder new --name <name> [--parent <id>]
146
148
  folder list [--json]
147
149
  folder move <folder> --parent <id|none>
148
- folder invite <folder> --email <email>
149
- folder remove <folder> <email|id>
150
- folder people <folder> [--json]
151
-
152
- New folder, or move a folder. folder invite adds someone who already has an account. It does not send an email.`,
150
+ New folder, or move a folder. Folders organize your library and do not grant access.`,
153
151
 
154
152
  rename: `rename [abstract] --name <name>
155
153
 
@@ -0,0 +1,82 @@
1
+ const ACTION = /^[a-z][a-z0-9_.-]{0,79}$/
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
3
+ const TOKEN = /^[A-Za-z0-9_-]{40,64}$/
4
+ const RECEIPT = /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i
5
+ const ACTION_NAMES = new Set([
6
+ 'records.query', 'records.create', 'records.patch', 'records.delete',
7
+ 'items.add', 'items.done', 'items.move', 'items.edit', 'items.delete',
8
+ 'private.query', 'private.create', 'private.patch', 'private.delete',
9
+ 'form.submit', 'form.mine', 'form.edit',
10
+ ])
11
+
12
+ export function createParticipantClient({ origin, appId, token, hostHeaders = {}, fetchImpl = fetch }) {
13
+ const base = new URL(origin)
14
+ if (!['https:', 'http:'].includes(base.protocol) || base.username || base.password ||
15
+ base.search || base.hash || (base.pathname !== '/' && base.pathname !== '')) {
16
+ throw new TypeError('Participant app origin must be an HTTP(S) origin')
17
+ }
18
+ if (base.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(base.hostname) &&
19
+ !base.hostname.endsWith('.localhost')) {
20
+ throw new TypeError('Participant grant requires HTTPS outside local development')
21
+ }
22
+ if (!UUID.test(appId) || !TOKEN.test(token)) throw new TypeError('Invalid participant connection settings')
23
+ if (Object.keys(hostHeaders).some((key) => key.toLowerCase() !== 'host') ||
24
+ Object.values(hostHeaders).some((value) => typeof value !== 'string' || /[\r\n]/.test(value))) {
25
+ throw new TypeError('Invalid participant host configuration')
26
+ }
27
+ const root = `/api/v1/apps/${appId.toLowerCase()}`
28
+
29
+ async function request(path, { method = 'GET', body, idempotencyKey, approvalId } = {}) {
30
+ if (typeof path !== 'string' || !path.startsWith(`${root}/`) || path.includes('..') || path.includes('?')) {
31
+ throw new Error('Unsupported participant endpoint')
32
+ }
33
+ const actionRoute = new RegExp(`^${root}/actions/([^/]+)/invoke$`).exec(path)
34
+ const receiptPrefix = `${root}/receipts/`
35
+ const getOnly = path === `${root}/me` || path === `${root}/actions` ||
36
+ path === `${root}/status` || path === `${root}/activity` ||
37
+ (path.startsWith(receiptPrefix) && RECEIPT.test(path.slice(receiptPrefix.length)) )
38
+ const postAction = Boolean(actionRoute && ACTION_NAMES.has(actionRoute[1]))
39
+ const allowed = (getOnly && method === 'GET') || (postAction && method === 'POST')
40
+ if (!allowed) throw new Error('Unsupported participant endpoint')
41
+ const headers = { ...hostHeaders, Authorization: `AmorphousGrant ${token}`, Accept: 'application/json' }
42
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
43
+ if (idempotencyKey !== undefined) {
44
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.length < 1 || idempotencyKey.length > 200) {
45
+ throw new TypeError('Invalid idempotency key')
46
+ }
47
+ headers['Idempotency-Key'] = idempotencyKey
48
+ }
49
+ if (approvalId !== undefined) {
50
+ if (typeof approvalId !== 'string' || !UUID.test(approvalId)) throw new TypeError('Invalid approval id')
51
+ headers['Amorphous-Approval'] = approvalId
52
+ }
53
+ const response = await fetchImpl(new URL(path, base), {
54
+ method, headers, redirect: 'error', ...(body === undefined ? {} : { body: JSON.stringify(body) }),
55
+ })
56
+ const raw = await response.text()
57
+ let payload
58
+ try { payload = raw ? JSON.parse(raw) : null } catch { payload = null }
59
+ if (!response.ok) throw new Error(`Participant request failed (${response.status})`)
60
+ return payload
61
+ }
62
+
63
+ return Object.freeze({
64
+ request,
65
+ me: () => request(`${root}/me`),
66
+ discover: () => request(`${root}/actions`),
67
+ invoke: (action, input, idempotencyKey, approvalId) => {
68
+ if (typeof action !== 'string' || !ACTION.test(action) || !ACTION_NAMES.has(action)) throw new TypeError('Invalid action')
69
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('Invalid action input')
70
+ if (approvalId !== undefined && (typeof approvalId !== 'string' || !RECEIPT.test(approvalId))) {
71
+ throw new TypeError('Invalid approval id')
72
+ }
73
+ return request(`${root}/actions/${action}/invoke`, { method: 'POST', body: input, idempotencyKey, approvalId })
74
+ },
75
+ receipt: (receiptId) => {
76
+ if (typeof receiptId !== 'string' || !RECEIPT.test(receiptId)) throw new TypeError('Invalid receipt id')
77
+ return request(`${root}/receipts/${receiptId}`)
78
+ },
79
+ status: () => request(`${root}/status`),
80
+ activity: () => request(`${root}/activity`),
81
+ })
82
+ }