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 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 of its own.
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
@@ -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 = 7
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
- const localRecords = recordsFromLocal(cwd)
334
- const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
335
- method: 'POST',
336
- body: JSON.stringify({ records: localRecords }),
337
- })
338
- if (refused?.length) {
339
- console.error(`Skipped (already live): ${refused.join(', ')}. Run: npx amorphous-cli@latest pull`)
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, and records.`,
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 abstract and merge records. A record changed both here and on the live abstract is refused; edit it and push again.',
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.7",
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. And whatever you want can be private, public, or even have users of its own.",
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",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: amorphous
3
- description: Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users of its own. Use when someone wants an abstract other people can open, or wants to invite people, publish, or change an abstract.
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 of its own.
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 push`
41
- 5. `npx amorphous-cli@latest preview` and open that URL.
42
- 6. `npx amorphous-cli@latest publish --live`
43
- 7. `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.
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 to take part'
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()
@@ -1,8 +1,3 @@
1
1
  <main>
2
2
  <p id="who"></p>
3
- <ul id="items"></ul>
4
- <form id="add">
5
- <input name="text" required placeholder="Add something">
6
- <button type="submit">Add</button>
7
- </form>
8
3
  </main>
@@ -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
- }