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 +2 -0
- package/lib/agent-contract.mjs +6 -2
- package/lib/agent-contract.test.mjs +12 -1
- package/lib/app-origins.mjs +52 -0
- package/lib/app-origins.test.mjs +40 -0
- package/lib/commands.mjs +103 -113
- package/lib/files.mjs +10 -2
- package/lib/help.mjs +9 -11
- package/lib/participant-client.mjs +82 -0
- package/lib/participant-client.test.mjs +34 -0
- package/lib/pull.test.mjs +42 -0
- package/lib/recipe-create.test.mjs +37 -0
- package/lib/serve.mjs +152 -111
- package/lib/serve.test.mjs +180 -22
- package/mcp.mjs +11 -0
- package/package.json +6 -2
- package/participant-mcp.mjs +55 -0
- package/skill/amorphous/SKILL.md +2 -1
- package/templates.json +30 -0
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.
|
package/lib/agent-contract.mjs
CHANGED
|
@@ -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'), /
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|