amorphous-cli 0.1.7 → 0.1.8
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 +1 -1
- package/lib/agent-contract.mjs +1 -1
- package/lib/commands.mjs +37 -8
- package/lib/config.mjs +30 -0
- package/lib/files.mjs +42 -0
- package/lib/help.mjs +9 -2
- package/lib/serve.mjs +360 -0
- package/lib/serve.test.mjs +123 -0
- package/mcp.mjs +5 -3
- package/package.json +2 -2
- package/skill/amorphous/SKILL.md +9 -6
- package/skill/amorphous/assets/app.js +1 -19
- package/skill/amorphous/assets/index.html +0 -5
- package/skill/amorphous/assets/styles.css +0 -20
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# amorphous-cli
|
|
2
2
|
|
|
3
|
-
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users
|
|
3
|
+
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx amorphous-cli@latest setup
|
package/lib/agent-contract.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
import { homedir } from 'node:os'
|
|
6
6
|
import { CLI_VERSION } from './version.mjs'
|
|
7
7
|
|
|
8
|
-
export const CONTRACT_VERSION =
|
|
8
|
+
export const CONTRACT_VERSION = 10
|
|
9
9
|
|
|
10
10
|
const skillRoot = join(dirname(fileURLToPath(import.meta.url)), '../skill/amorphous')
|
|
11
11
|
|
package/lib/commands.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { homedir } from 'node:os'
|
|
|
7
7
|
import { apiFetch } from './api.mjs'
|
|
8
8
|
import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
|
|
9
9
|
import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
|
|
10
|
+
import { startServe } from './serve.mjs'
|
|
10
11
|
import { ALIASES, printHelp, wantsHelp } from './help.mjs'
|
|
11
12
|
import { loginWithBrowser, loginWithMagicLink } from './login.mjs'
|
|
12
13
|
|
|
@@ -23,6 +24,7 @@ const BOOL_FLAGS = new Set([
|
|
|
23
24
|
'open',
|
|
24
25
|
'closed',
|
|
25
26
|
'quiet',
|
|
27
|
+
'data',
|
|
26
28
|
])
|
|
27
29
|
|
|
28
30
|
function parseFlags(args) {
|
|
@@ -330,20 +332,45 @@ async function cmdPush(cwd, rest) {
|
|
|
330
332
|
const { flags } = parseFlags(rest)
|
|
331
333
|
if (flags.help) return printHelp('push')
|
|
332
334
|
const id = requireMeta(cwd)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
335
|
+
if (flags.data) {
|
|
336
|
+
const localRecords = recordsFromLocal(cwd)
|
|
337
|
+
const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
body: JSON.stringify({ records: localRecords }),
|
|
340
|
+
})
|
|
341
|
+
if (refused?.length) {
|
|
342
|
+
console.error(`Skipped (already live): ${refused.join(', ')}. Run: npx amorphous-cli@latest pull`)
|
|
343
|
+
}
|
|
340
344
|
}
|
|
341
345
|
const { html, css, js } = readProjectFiles(cwd)
|
|
342
346
|
await apiFetch(`/api/abstracts/${id}/push`, {
|
|
343
347
|
method: 'POST',
|
|
344
348
|
body: JSON.stringify({ html, css, js }),
|
|
345
349
|
})
|
|
346
|
-
console.log('Pushed.')
|
|
350
|
+
console.log(flags.data ? 'Pushed source and records.' : 'Pushed source.')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function cmdServe(cwd, rest) {
|
|
354
|
+
const { flags } = parseFlags(rest)
|
|
355
|
+
if (flags.help) return printHelp('serve')
|
|
356
|
+
const id = requireMeta(cwd)
|
|
357
|
+
const port = flags.port ? Number(flags.port) : 0
|
|
358
|
+
if (flags.port && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
359
|
+
throw new Error('Use --port <number>.')
|
|
360
|
+
}
|
|
361
|
+
const running = await startServe({
|
|
362
|
+
cwd,
|
|
363
|
+
abstractId: id,
|
|
364
|
+
port,
|
|
365
|
+
json: jsonMode(flags),
|
|
366
|
+
})
|
|
367
|
+
await new Promise((resolve) => {
|
|
368
|
+
const stop = () => {
|
|
369
|
+
running.close().then(resolve, resolve)
|
|
370
|
+
}
|
|
371
|
+
process.once('SIGINT', stop)
|
|
372
|
+
process.once('SIGTERM', stop)
|
|
373
|
+
})
|
|
347
374
|
}
|
|
348
375
|
|
|
349
376
|
function urlsFor(id) {
|
|
@@ -737,6 +764,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
|
|
|
737
764
|
return cmdOpen(cwd, rest)
|
|
738
765
|
case 'preview':
|
|
739
766
|
return cmdPreview(cwd, rest)
|
|
767
|
+
case 'serve':
|
|
768
|
+
return cmdServe(cwd, rest)
|
|
740
769
|
case 'publish':
|
|
741
770
|
return cmdPublish(cwd, rest)
|
|
742
771
|
case 'live':
|
package/lib/config.mjs
CHANGED
|
@@ -72,6 +72,36 @@ export function abstractsOrigin() {
|
|
|
72
72
|
return process.env.ABSTRACTS_ORIGIN || HOSTED.abstractsOrigin
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Local Herd HTTPS: hit Nuxt on loopback with the abstracts Host header (no TLS). */
|
|
76
|
+
export function cliAbstractsFetchOrigin() {
|
|
77
|
+
if (process.env.AMORPHOUS_CLI_ABSTRACTS_URL) {
|
|
78
|
+
return process.env.AMORPHOUS_CLI_ABSTRACTS_URL.replace(/\/$/, '')
|
|
79
|
+
}
|
|
80
|
+
const origin = abstractsOrigin()
|
|
81
|
+
try {
|
|
82
|
+
const u = new URL(origin)
|
|
83
|
+
if (u.protocol === 'https:' && (u.hostname.endsWith('.test') || u.hostname === 'localhost')) {
|
|
84
|
+
const port = process.env.AMORPHOUS_DEV_PORT || '3847'
|
|
85
|
+
return `http://127.0.0.1:${port}`
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
90
|
+
return origin.replace(/\/$/, '')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function cliAbstractsHostHeader() {
|
|
94
|
+
const origin = abstractsOrigin()
|
|
95
|
+
const fetchOrigin = cliAbstractsFetchOrigin()
|
|
96
|
+
if (!fetchOrigin.startsWith('http://127.0.0.1')) return {}
|
|
97
|
+
try {
|
|
98
|
+
const u = new URL(origin)
|
|
99
|
+
return { Host: u.host }
|
|
100
|
+
} catch {
|
|
101
|
+
return {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
export function supabaseUrl() {
|
|
76
106
|
return process.env.SUPABASE_URL || HOSTED.supabaseUrl
|
|
77
107
|
}
|
package/lib/files.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
|
|
@@ -47,4 +48,45 @@ export function recordsFromLocal(cwd) {
|
|
|
47
48
|
]
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
export function writeLocalRecords(cwd, rows) {
|
|
52
|
+
writeNdjson(
|
|
53
|
+
join(cwd, 'data.ndjson'),
|
|
54
|
+
rows.filter((r) => r.visibility !== 'private'),
|
|
55
|
+
)
|
|
56
|
+
writeNdjson(
|
|
57
|
+
join(cwd, 'private.ndjson'),
|
|
58
|
+
rows.filter((r) => r.visibility === 'private'),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function insertLocalRecord(cwd, row) {
|
|
63
|
+
const body = { ...(row && typeof row === 'object' ? row : {}) }
|
|
64
|
+
if (body.visibility && body.visibility !== 'shared' && body.visibility !== 'private') {
|
|
65
|
+
return { status: 400, message: 'Invalid visibility' }
|
|
66
|
+
}
|
|
67
|
+
body.visibility = body.visibility === 'private' ? 'private' : 'shared'
|
|
68
|
+
if (!body.id) body.id = randomUUID()
|
|
69
|
+
const rows = recordsFromLocal(cwd)
|
|
70
|
+
if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
|
|
71
|
+
rows.push(body)
|
|
72
|
+
writeLocalRecords(cwd, rows)
|
|
73
|
+
return { status: 200, row: body }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function replaceLocalRecord(cwd, id, row) {
|
|
77
|
+
const rows = recordsFromLocal(cwd)
|
|
78
|
+
const index = rows.findIndex((r) => r.id === id)
|
|
79
|
+
if (index < 0) return { status: 404, message: 'Not Found' }
|
|
80
|
+
const existing = rows[index]
|
|
81
|
+
const next = {
|
|
82
|
+
...(existing && typeof existing === 'object' ? existing : {}),
|
|
83
|
+
...(row && typeof row === 'object' ? row : {}),
|
|
84
|
+
id,
|
|
85
|
+
visibility: existing.visibility,
|
|
86
|
+
}
|
|
87
|
+
rows[index] = next
|
|
88
|
+
writeLocalRecords(cwd, rows)
|
|
89
|
+
return { status: 200, row: next }
|
|
90
|
+
}
|
|
91
|
+
|
|
50
92
|
export { FILES }
|
package/lib/help.mjs
CHANGED
|
@@ -7,6 +7,7 @@ export const ROOT_HELP = `Amorphous
|
|
|
7
7
|
create New abstract
|
|
8
8
|
open [abstract] Open URL
|
|
9
9
|
preview [abstract] Preview URL (draft)
|
|
10
|
+
serve Serve this folder locally
|
|
10
11
|
pull Pull draft and records to this folder
|
|
11
12
|
push Push local files
|
|
12
13
|
publish [--live] Publish a version; --live is Go live
|
|
@@ -64,13 +65,19 @@ Print the Open URL.`,
|
|
|
64
65
|
|
|
65
66
|
Print the Preview URL (draft).`,
|
|
66
67
|
|
|
68
|
+
serve: `serve [--port <n>] [--json]
|
|
69
|
+
|
|
70
|
+
Serve this folder on 127.0.0.1 and reload when index.html, styles.css, or app.js changes.
|
|
71
|
+
Records start local, in this folder. The page can switch to prod, which reads and writes the abstract's records.`,
|
|
72
|
+
|
|
67
73
|
pull: `pull
|
|
68
74
|
|
|
69
75
|
Pull the draft and records into this directory.`,
|
|
70
76
|
|
|
71
|
-
push: `push
|
|
77
|
+
push: `push [--data]
|
|
72
78
|
|
|
73
|
-
Push local index.html, styles.css, app.js
|
|
79
|
+
Push local index.html, styles.css, and app.js.
|
|
80
|
+
--data also merges records from this folder. A record changed both here and on the abstract is refused.`,
|
|
74
81
|
|
|
75
82
|
publish: `publish [--live]
|
|
76
83
|
|
package/lib/serve.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { existsSync, readFileSync, watch } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { apiFetch } from './api.mjs'
|
|
5
|
+
import { cliAbstractsFetchOrigin, cliAbstractsHostHeader } from './config.mjs'
|
|
6
|
+
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
|
|
7
|
+
|
|
8
|
+
const BAR_CSS = `#amorphous-serve{position:fixed;z-index:2147483646;left:12px;bottom:12px;display:flex;gap:6px;align-items:center;padding:4px 6px 4px 10px;border-radius:999px;background:#18181b;color:#fff;font:500 12px/1 system-ui,-apple-system,sans-serif}
|
|
9
|
+
#amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
|
|
10
|
+
#amorphous-serve button[aria-pressed="true"]{background:#fff;color:#18181b}`
|
|
11
|
+
|
|
12
|
+
function stripScriptTags(html) {
|
|
13
|
+
return String(html || '').replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readCookie(req, name) {
|
|
17
|
+
const raw = req.headers.cookie || ''
|
|
18
|
+
const match = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(raw)
|
|
19
|
+
return match ? decodeURIComponent(match[1]) : ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function recordsMode(req, url) {
|
|
23
|
+
const query = url.searchParams.get('records')
|
|
24
|
+
if (query === 'prod' || query === 'local') return query
|
|
25
|
+
const cookie = readCookie(req, 'amorphous_records')
|
|
26
|
+
return cookie === 'prod' ? 'prod' : 'local'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readJson(req) {
|
|
30
|
+
const chunks = []
|
|
31
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
32
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
33
|
+
if (!text) return {}
|
|
34
|
+
return JSON.parse(text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function send(res, status, body, headers = {}) {
|
|
38
|
+
const payload = body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body)
|
|
39
|
+
res.writeHead(status, {
|
|
40
|
+
'Cache-Control': 'no-store',
|
|
41
|
+
...headers,
|
|
42
|
+
...(payload && typeof body !== 'string' ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
|
|
43
|
+
})
|
|
44
|
+
res.end(payload)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pageHtml(html, mode) {
|
|
48
|
+
const pressed = (value) => (mode === value ? ' aria-pressed="true"' : '')
|
|
49
|
+
return `<!DOCTYPE html>
|
|
50
|
+
<html>
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="utf-8">
|
|
53
|
+
<link rel="stylesheet" href="/styles.css">
|
|
54
|
+
<style>${BAR_CSS}</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<div id="amorphous-root">${stripScriptTags(html)}</div>
|
|
58
|
+
<div id="amorphous-serve" data-mode="${mode}">
|
|
59
|
+
<span>Records</span>
|
|
60
|
+
<button type="button" data-set="local"${pressed('local')}>Local</button>
|
|
61
|
+
<button type="button" data-set="prod"${pressed('prod')}>Prod</button>
|
|
62
|
+
</div>
|
|
63
|
+
<script>
|
|
64
|
+
window.amorphous = {
|
|
65
|
+
login() {},
|
|
66
|
+
async me() {
|
|
67
|
+
const res = await fetch('/__amorphous/me')
|
|
68
|
+
if (!res.ok || res.status === 204) return null
|
|
69
|
+
const text = await res.text()
|
|
70
|
+
if (!text) return null
|
|
71
|
+
return JSON.parse(text)
|
|
72
|
+
},
|
|
73
|
+
async names(ids) {
|
|
74
|
+
const res = await fetch('/__amorphous/names', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ ids })
|
|
78
|
+
})
|
|
79
|
+
if (!res.ok) return { names: {} }
|
|
80
|
+
return res.json()
|
|
81
|
+
},
|
|
82
|
+
data: {
|
|
83
|
+
async find() {
|
|
84
|
+
const res = await fetch('/__amorphous/data')
|
|
85
|
+
if (!res.ok) return []
|
|
86
|
+
const payload = await res.json()
|
|
87
|
+
return payload.records || []
|
|
88
|
+
},
|
|
89
|
+
async insert(row) {
|
|
90
|
+
const body = { ...row }
|
|
91
|
+
if (!body.id && crypto.randomUUID) body.id = crypto.randomUUID()
|
|
92
|
+
const res = await fetch('/__amorphous/data', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify(body)
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) throw new Error('insert failed: ' + res.status)
|
|
98
|
+
return body
|
|
99
|
+
},
|
|
100
|
+
async replace(id, row) {
|
|
101
|
+
const res = await fetch('/__amorphous/data/' + encodeURIComponent(id), {
|
|
102
|
+
method: 'PUT',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify(row)
|
|
105
|
+
})
|
|
106
|
+
if (!res.ok) throw new Error('replace failed: ' + res.status)
|
|
107
|
+
return row
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
document.getElementById('amorphous-serve').addEventListener('click', function (event) {
|
|
112
|
+
var mode = event.target && event.target.getAttribute('data-set')
|
|
113
|
+
if (!mode) return
|
|
114
|
+
fetch('/__amorphous/mode', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ mode: mode })
|
|
118
|
+
}).then(function () { location.reload() })
|
|
119
|
+
})
|
|
120
|
+
new EventSource('/__amorphous/events').addEventListener('reload', function () {
|
|
121
|
+
location.reload()
|
|
122
|
+
})
|
|
123
|
+
</script>
|
|
124
|
+
<script src="/app.js"></script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function passFromResponse(res) {
|
|
130
|
+
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
131
|
+
const lines = list.length ? list : [res.headers.get('set-cookie') || '']
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const match = /(?:^|,\s*)am_pass=([^;]+)/.exec(line)
|
|
134
|
+
if (match) return `am_pass=${match[1]}`
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function mintAbstractPass(abstractId) {
|
|
140
|
+
const data = await apiFetch(`/api/abstracts/${abstractId}/code`, { method: 'POST', body: '{}' })
|
|
141
|
+
const code = data?.code
|
|
142
|
+
if (!code) throw new Error('Could not open the abstract records.')
|
|
143
|
+
const res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}?code=${encodeURIComponent(code)}`, {
|
|
144
|
+
redirect: 'manual',
|
|
145
|
+
headers: { ...cliAbstractsHostHeader() },
|
|
146
|
+
})
|
|
147
|
+
const cookie = passFromResponse(res)
|
|
148
|
+
if (!cookie) throw new Error('Could not open the abstract records.')
|
|
149
|
+
return cookie
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function proxyAbstract(abstractId, path, init, session) {
|
|
153
|
+
const headers = {
|
|
154
|
+
...cliAbstractsHostHeader(),
|
|
155
|
+
...(init.headers || {}),
|
|
156
|
+
}
|
|
157
|
+
if (session.cookie) headers.Cookie = session.cookie
|
|
158
|
+
let res
|
|
159
|
+
try {
|
|
160
|
+
res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}${path}`, {
|
|
161
|
+
method: init.method || 'GET',
|
|
162
|
+
headers,
|
|
163
|
+
body: init.body,
|
|
164
|
+
redirect: 'manual',
|
|
165
|
+
})
|
|
166
|
+
} catch {
|
|
167
|
+
return new Response(JSON.stringify({ message: 'Could not reach the abstract.' }), {
|
|
168
|
+
status: 502,
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (res.status === 401 && session.cookie && !init.retried) {
|
|
173
|
+
session.cookie = null
|
|
174
|
+
session.cookie = await session.mint()
|
|
175
|
+
return proxyAbstract(abstractId, path, { ...init, retried: true }, session)
|
|
176
|
+
}
|
|
177
|
+
return res
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function startServe(options) {
|
|
181
|
+
const cwd = options.cwd
|
|
182
|
+
const abstractId = options.abstractId
|
|
183
|
+
const host = options.host || '127.0.0.1'
|
|
184
|
+
const session = {
|
|
185
|
+
cookie: null,
|
|
186
|
+
failed: false,
|
|
187
|
+
mint: options.mintPass || (() => mintAbstractPass(abstractId)),
|
|
188
|
+
}
|
|
189
|
+
const clients = new Set()
|
|
190
|
+
let reloadTimer = null
|
|
191
|
+
|
|
192
|
+
function notifyReload() {
|
|
193
|
+
clearTimeout(reloadTimer)
|
|
194
|
+
reloadTimer = setTimeout(() => {
|
|
195
|
+
for (const client of clients) client.write('event: reload\ndata: 1\n\n')
|
|
196
|
+
}, 60)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const watchers = []
|
|
200
|
+
for (const name of ['index.html', 'styles.css', 'app.js']) {
|
|
201
|
+
const path = join(cwd, name)
|
|
202
|
+
if (!existsSync(path)) continue
|
|
203
|
+
watchers.push(watch(path, notifyReload))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function ensurePass() {
|
|
207
|
+
if (session.cookie) return session.cookie
|
|
208
|
+
if (session.failed) return null
|
|
209
|
+
try {
|
|
210
|
+
session.cookie = await session.mint()
|
|
211
|
+
return session.cookie
|
|
212
|
+
} catch {
|
|
213
|
+
session.failed = true
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function prod(path, init) {
|
|
219
|
+
if (options.prodFetch) return options.prodFetch(abstractId, path, init, session)
|
|
220
|
+
const cookie = await ensurePass()
|
|
221
|
+
if (!cookie && init.auth !== false) {
|
|
222
|
+
return new Response(JSON.stringify({ message: 'Not signed in. Run: npx amorphous-cli@latest login' }), {
|
|
223
|
+
status: 401,
|
|
224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
const res = await proxyAbstract(abstractId, path, { ...init, headers: { ...(init.headers || {}), Cookie: cookie || '' } }, session)
|
|
228
|
+
const text = await res.text()
|
|
229
|
+
return new Response(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') || 'application/json' } })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const server = createServer(async (req, res) => {
|
|
233
|
+
try {
|
|
234
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
235
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
236
|
+
const mode = recordsMode(req, url)
|
|
237
|
+
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
|
|
238
|
+
if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
|
|
239
|
+
headers['Set-Cookie'] = `amorphous_records=${mode}; Path=/; SameSite=Lax`
|
|
240
|
+
}
|
|
241
|
+
send(res, 200, pageHtml(readProjectFiles(cwd).html, mode), headers)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (req.method === 'GET' && url.pathname === '/styles.css') {
|
|
245
|
+
send(res, 200, readProjectFiles(cwd).css, { 'Content-Type': 'text/css; charset=utf-8' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (req.method === 'GET' && url.pathname === '/app.js') {
|
|
249
|
+
send(res, 200, readProjectFiles(cwd).js, { 'Content-Type': 'text/javascript; charset=utf-8' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (req.method === 'GET' && url.pathname === '/__amorphous/events') {
|
|
253
|
+
res.writeHead(200, {
|
|
254
|
+
'Content-Type': 'text/event-stream',
|
|
255
|
+
'Cache-Control': 'no-cache',
|
|
256
|
+
Connection: 'keep-alive',
|
|
257
|
+
})
|
|
258
|
+
res.write(':\n\n')
|
|
259
|
+
clients.add(res)
|
|
260
|
+
req.on('close', () => clients.delete(res))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (req.method === 'POST' && url.pathname === '/__amorphous/mode') {
|
|
264
|
+
const body = await readJson(req)
|
|
265
|
+
const mode = body.mode === 'prod' ? 'prod' : 'local'
|
|
266
|
+
send(res, 204, null, { 'Set-Cookie': `amorphous_records=${mode}; Path=/; SameSite=Lax` })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const mode = recordsMode(req, url)
|
|
270
|
+
if (req.method === 'GET' && url.pathname === '/__amorphous/me') {
|
|
271
|
+
const upstream = await prod('/api/me', { method: 'GET', auth: false })
|
|
272
|
+
const text = await upstream.text()
|
|
273
|
+
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
274
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
275
|
+
})
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (req.method === 'POST' && url.pathname === '/__amorphous/names') {
|
|
279
|
+
const body = await readJson(req)
|
|
280
|
+
const upstream = await prod('/api/names', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
auth: false,
|
|
283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify({ ids: Array.isArray(body.ids) ? body.ids : [] }),
|
|
285
|
+
})
|
|
286
|
+
const text = await upstream.text()
|
|
287
|
+
send(res, upstream.ok ? 200 : upstream.status, text, {
|
|
288
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
289
|
+
})
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (url.pathname === '/__amorphous/data' || url.pathname.startsWith('/__amorphous/data/')) {
|
|
293
|
+
if (mode === 'prod') {
|
|
294
|
+
const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
|
|
295
|
+
const path = req.method === 'PUT' ? `/api/data/${encodeURIComponent(recordId)}` : '/api/data'
|
|
296
|
+
const init = { method: req.method, auth: true }
|
|
297
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
298
|
+
init.headers = { 'Content-Type': 'application/json' }
|
|
299
|
+
init.body = JSON.stringify(await readJson(req))
|
|
300
|
+
}
|
|
301
|
+
const upstream = await prod(path, init)
|
|
302
|
+
const text = await upstream.text()
|
|
303
|
+
send(res, upstream.status, text, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
if (req.method === 'GET') {
|
|
307
|
+
send(res, 200, { records: recordsFromLocal(cwd) })
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (req.method === 'POST') {
|
|
311
|
+
const result = insertLocalRecord(cwd, await readJson(req))
|
|
312
|
+
send(res, result.status, result.row || { message: result.message })
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if (req.method === 'PUT') {
|
|
316
|
+
const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
|
|
317
|
+
const result = replaceLocalRecord(cwd, recordId, await readJson(req))
|
|
318
|
+
send(res, result.status, result.row || { message: result.message })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
send(res, 404, { message: 'Not found' })
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof SyntaxError) {
|
|
325
|
+
send(res, 400, { message: 'Invalid request' })
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
send(res, 500, { message: error instanceof Error ? error.message : 'Request failed' })
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const ready = new Promise((resolve, reject) => {
|
|
333
|
+
server.once('error', reject)
|
|
334
|
+
server.listen(options.port || 0, host, () => {
|
|
335
|
+
server.removeListener('error', reject)
|
|
336
|
+
const address = server.address()
|
|
337
|
+
const port = typeof address === 'object' && address ? address.port : options.port
|
|
338
|
+
const url = `http://${host}:${port}/`
|
|
339
|
+
resolve({ url, port })
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
return ready.then((info) => {
|
|
344
|
+
if (!options.silent) {
|
|
345
|
+
if (options.json) console.log(JSON.stringify({ url: info.url }))
|
|
346
|
+
else console.log(info.url)
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
...info,
|
|
350
|
+
close() {
|
|
351
|
+
clearTimeout(reloadTimer)
|
|
352
|
+
for (const watcher of watchers) watcher.close()
|
|
353
|
+
for (const client of clients) client.end()
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
server.close((error) => (error ? reject(error) : resolve()))
|
|
356
|
+
})
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { startServe } from './serve.mjs'
|
|
7
|
+
|
|
8
|
+
function project() {
|
|
9
|
+
const cwd = mkdtempSync(join(tmpdir(), 'amorphous-serve-'))
|
|
10
|
+
writeFileSync(join(cwd, 'index.html'), '<main><script>alert(1)</script><p id="who">Hi</p></main>')
|
|
11
|
+
writeFileSync(join(cwd, 'styles.css'), 'main{color:black}')
|
|
12
|
+
writeFileSync(join(cwd, 'app.js'), 'document.title = "served"')
|
|
13
|
+
writeFileSync(join(cwd, 'data.ndjson'), '')
|
|
14
|
+
writeFileSync(join(cwd, 'private.ndjson'), '')
|
|
15
|
+
return cwd
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cookieFrom(res) {
|
|
19
|
+
const line = res.headers.get('set-cookie') || ''
|
|
20
|
+
const match = /amorphous_records=(local|prod)/.exec(line)
|
|
21
|
+
return match ? `amorphous_records=${match[1]}` : ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('serve reloads local source and keeps prod records off disk', async () => {
|
|
25
|
+
const cwd = project()
|
|
26
|
+
const calls = []
|
|
27
|
+
const running = await startServe({
|
|
28
|
+
cwd,
|
|
29
|
+
abstractId: '00000000-0000-4000-8000-000000000001',
|
|
30
|
+
silent: true,
|
|
31
|
+
prodFetch: async (_id, path, init) => {
|
|
32
|
+
calls.push({ path, method: init.method, body: init.body })
|
|
33
|
+
if (path === '/api/data' && init.method === 'GET') {
|
|
34
|
+
return new Response(JSON.stringify({ records: [{ id: 'live', visibility: 'shared', text: 'remote' }] }), {
|
|
35
|
+
status: 200,
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
if (path === '/api/data' && init.method === 'POST') {
|
|
40
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 })
|
|
41
|
+
}
|
|
42
|
+
if (path === '/api/me') {
|
|
43
|
+
return new Response(JSON.stringify({ id: 'user-1', name: 'Ada' }), { status: 200 })
|
|
44
|
+
}
|
|
45
|
+
return new Response(JSON.stringify({ names: { 'user-1': 'Ada' } }), { status: 200 })
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const page = await fetch(running.url)
|
|
51
|
+
const html = await page.text()
|
|
52
|
+
assert.match(html, /id="who"/)
|
|
53
|
+
assert.doesNotMatch(html, /alert\(1\)/)
|
|
54
|
+
assert.match(html, /data-set="local" aria-pressed="true"/)
|
|
55
|
+
assert.match(html, /data-set="prod"/)
|
|
56
|
+
assert.equal(await (await fetch(`${running.url}styles.css`)).text(), 'main{color:black}')
|
|
57
|
+
assert.equal(await (await fetch(`${running.url}app.js`)).text(), 'document.title = "served"')
|
|
58
|
+
|
|
59
|
+
const inserted = await fetch(`${running.url}__amorphous/data`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ id: 'local-1', text: 'note' }),
|
|
63
|
+
})
|
|
64
|
+
assert.equal(inserted.status, 200)
|
|
65
|
+
const found = await (await fetch(`${running.url}__amorphous/data`)).json()
|
|
66
|
+
assert.deepEqual(found.records, [{ id: 'local-1', text: 'note', visibility: 'shared' }])
|
|
67
|
+
|
|
68
|
+
const replaced = await fetch(`${running.url}__amorphous/data/local-1`, {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ text: 'edited' }),
|
|
72
|
+
})
|
|
73
|
+
assert.equal(replaced.status, 200)
|
|
74
|
+
assert.match(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /"text":"edited"/)
|
|
75
|
+
assert.equal(calls.filter((call) => call.path === '/api/data').length, 0)
|
|
76
|
+
|
|
77
|
+
const again = await fetch(`${running.url}__amorphous/data`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ id: 'local-1', text: 'nope' }),
|
|
81
|
+
})
|
|
82
|
+
assert.equal(again.status, 409)
|
|
83
|
+
|
|
84
|
+
const switched = await fetch(`${running.url}__amorphous/mode`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ mode: 'prod' }),
|
|
88
|
+
})
|
|
89
|
+
const jar = cookieFrom(switched)
|
|
90
|
+
assert.equal(jar, 'amorphous_records=prod')
|
|
91
|
+
const live = await (
|
|
92
|
+
await fetch(`${running.url}__amorphous/data`, { headers: { Cookie: jar } })
|
|
93
|
+
).json()
|
|
94
|
+
assert.equal(live.records[0].text, 'remote')
|
|
95
|
+
const pushed = await fetch(`${running.url}__amorphous/data`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json', Cookie: jar },
|
|
98
|
+
body: JSON.stringify({ id: 'from-prod', text: 'up' }),
|
|
99
|
+
})
|
|
100
|
+
assert.equal(pushed.status, 200)
|
|
101
|
+
assert.doesNotMatch(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /from-prod/)
|
|
102
|
+
assert.equal(calls.filter((call) => call.method === 'POST' && call.path === '/api/data').length, 1)
|
|
103
|
+
|
|
104
|
+
const me = await (await fetch(`${running.url}__amorphous/me`)).json()
|
|
105
|
+
assert.equal(me.name, 'Ada')
|
|
106
|
+
|
|
107
|
+
const events = await fetch(`${running.url}__amorphous/events`)
|
|
108
|
+
const reader = events.body.getReader()
|
|
109
|
+
const first = await reader.read()
|
|
110
|
+
assert.match(Buffer.from(first.value).toString(), /:/)
|
|
111
|
+
writeFileSync(join(cwd, 'styles.css'), 'main{color:white}')
|
|
112
|
+
let chunk = ''
|
|
113
|
+
const deadline = Date.now() + 2000
|
|
114
|
+
while (!chunk.includes('reload') && Date.now() < deadline) {
|
|
115
|
+
const next = await reader.read()
|
|
116
|
+
chunk += Buffer.from(next.value).toString()
|
|
117
|
+
}
|
|
118
|
+
assert.match(chunk, /reload/)
|
|
119
|
+
reader.cancel()
|
|
120
|
+
} finally {
|
|
121
|
+
await running.close()
|
|
122
|
+
}
|
|
123
|
+
})
|
package/mcp.mjs
CHANGED
|
@@ -84,11 +84,13 @@ await serveStdio(() => {
|
|
|
84
84
|
{
|
|
85
85
|
title: 'Push the abstract',
|
|
86
86
|
description:
|
|
87
|
-
'Upload the local
|
|
88
|
-
inputSchema: z.object({
|
|
87
|
+
'Upload the local source. Set data to also merge records from this folder. A record changed both here and on the live abstract is refused; edit it and push again.',
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
data: z.boolean().optional().describe('Also merge records from this folder'),
|
|
90
|
+
}),
|
|
89
91
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
90
92
|
},
|
|
91
|
-
async () => call(['push']),
|
|
93
|
+
async ({ data }) => call(data ? ['push', '--data'] : ['push']),
|
|
92
94
|
)
|
|
93
95
|
|
|
94
96
|
server.registerTool(
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amorphous-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"mcpName": "io.github.amorphous-one/amorphous",
|
|
5
|
-
"description": "Make whatever you want, yourself, with your own agent, or with others.
|
|
5
|
+
"description": "Make whatever you want, yourself, with your own agent, or with others.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"amorphous-cli": "index.mjs",
|
package/skill/amorphous/SKILL.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: amorphous
|
|
3
|
-
description: Make whatever you want, yourself, with your own agent, or with others.
|
|
3
|
+
description: Make whatever you want, yourself, with your own agent, or with others. Use when someone wants an abstract other people can open, or wants to invite people, publish, or change an abstract.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users
|
|
6
|
+
Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users.
|
|
7
7
|
|
|
8
8
|
## Files
|
|
9
9
|
|
|
@@ -37,13 +37,16 @@ Call the CLI as `npx amorphous-cli@latest <command>`. If a command prints `Run:
|
|
|
37
37
|
1. If you are not signed in, run `npx amorphous-cli@latest login`. Show the person the link it prints and ask them to finish in the browser. Do not ask for an email or a code.
|
|
38
38
|
2. `npx amorphous-cli@latest create --name "..."` in a folder, or `npx amorphous-cli@latest pull` when `.amorphous.json` is already there.
|
|
39
39
|
3. Edit the files to match what they asked for. `me` and `names` are how the abstract knows who is here.
|
|
40
|
-
4. `npx amorphous-cli@latest
|
|
41
|
-
5. `npx amorphous-cli@latest
|
|
42
|
-
6. `npx amorphous-cli@latest
|
|
43
|
-
7. `npx amorphous-cli@latest
|
|
40
|
+
4. `npx amorphous-cli@latest serve` and open that URL. It reloads when the files change. Records on that page start in this folder. Switch the page to prod to read and write the abstract's records.
|
|
41
|
+
5. `npx amorphous-cli@latest push` sends the source. `npx amorphous-cli@latest push --data` also sends the records in this folder.
|
|
42
|
+
6. `npx amorphous-cli@latest preview` and open that URL.
|
|
43
|
+
7. `npx amorphous-cli@latest publish --live`
|
|
44
|
+
8. `npx amorphous-cli@latest invite <email> use` or `edit` or `view`. It prints a link and does not send an email. Give them that link. They open it and sign in with that email.
|
|
44
45
|
|
|
45
46
|
`npx amorphous-cli@latest visibility anyone` lets anyone view and write shared records. `npx amorphous-cli@latest visibility members` keeps the abstract to invited people.
|
|
46
47
|
|
|
48
|
+
Do not make a public abstract that lets anyone keep adding shared records, such as an open list. Those records stay on the abstract and use up its storage. Keep shared records to a set you replace, or keep the abstract to invited people.
|
|
49
|
+
|
|
47
50
|
A person's access is view, use, or edit. Member tiers, scores, and roles inside an abstract are records on that abstract.
|
|
48
51
|
|
|
49
52
|
When a command fails, run the command it names next.
|
|
@@ -1,26 +1,8 @@
|
|
|
1
1
|
const who = document.getElementById('who')
|
|
2
|
-
const list = document.getElementById('items')
|
|
3
|
-
const form = document.getElementById('add')
|
|
4
2
|
|
|
5
3
|
async function refresh() {
|
|
6
4
|
const me = await amorphous.me()
|
|
7
|
-
who.textContent = me ? me.name : 'Sign in
|
|
8
|
-
const rows = await amorphous.data.find()
|
|
9
|
-
list.replaceChildren()
|
|
10
|
-
for (const row of rows) {
|
|
11
|
-
const li = document.createElement('li')
|
|
12
|
-
li.textContent = typeof row.text === 'string' ? row.text : ''
|
|
13
|
-
list.appendChild(li)
|
|
14
|
-
}
|
|
5
|
+
who.textContent = me ? me.name : 'Sign in'
|
|
15
6
|
}
|
|
16
7
|
|
|
17
|
-
form.addEventListener('submit', async (event) => {
|
|
18
|
-
event.preventDefault()
|
|
19
|
-
const text = new FormData(form).get('text')
|
|
20
|
-
if (typeof text !== 'string' || !text.trim()) return
|
|
21
|
-
await amorphous.data.insert({ text: text.trim() })
|
|
22
|
-
form.reset()
|
|
23
|
-
await refresh()
|
|
24
|
-
})
|
|
25
|
-
|
|
26
8
|
refresh()
|
|
@@ -9,23 +9,3 @@ main {
|
|
|
9
9
|
#who {
|
|
10
10
|
color: #52525b;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
ul {
|
|
14
|
-
padding-left: 1.1rem;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
form {
|
|
18
|
-
display: flex;
|
|
19
|
-
gap: 0.5rem;
|
|
20
|
-
margin-top: 1rem;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
input {
|
|
24
|
-
flex: 1;
|
|
25
|
-
font: inherit;
|
|
26
|
-
padding: 0.4rem 0.55rem;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
button {
|
|
30
|
-
font: inherit;
|
|
31
|
-
}
|