amorphous-cli 0.1.13 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import test from 'node:test'
6
+ import { writePulledProject } from './commands.mjs'
7
+
8
+ const detail = { abstract: {
9
+ draft_html: '<main>Editable</main>', draft_css: 'main{color:black}', draft_js: 'window.ready=true',
10
+ draft_recovery_contract: { version: 1, status: 'declared', privateReferences: [] },
11
+ } }
12
+
13
+ test('explicit pull writes editable source, empty data files, metadata, and the current contract', () => {
14
+ const cwd = mkdtempSync(join(tmpdir(), 'amorphous-pull-'))
15
+ writePulledProject(cwd, '00000000-0000-4000-8000-000000000001', detail, { records: [] }, { fresh: true })
16
+ assert.equal(readFileSync(join(cwd, 'index.html'), 'utf8'), detail.abstract.draft_html)
17
+ assert.equal(readFileSync(join(cwd, 'styles.css'), 'utf8'), detail.abstract.draft_css)
18
+ assert.equal(readFileSync(join(cwd, 'app.js'), 'utf8'), detail.abstract.draft_js)
19
+ assert.equal(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), '')
20
+ assert.equal(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), '')
21
+ const metadata = JSON.parse(readFileSync(join(cwd, '.amorphous.json'), 'utf8'))
22
+ assert.equal(metadata.abstractId, '00000000-0000-4000-8000-000000000001')
23
+ assert.deepEqual(metadata.recoveryContract, detail.abstract.draft_recovery_contract)
24
+ assert.ok(readdirSync(cwd).includes('AGENTS.md'))
25
+ })
26
+
27
+ test('explicit pull refuses a populated unrelated directory without changing it', () => {
28
+ const cwd = mkdtempSync(join(tmpdir(), 'amorphous-pull-'))
29
+ writeFileSync(join(cwd, 'notes.txt'), 'keep')
30
+ assert.throws(() => writePulledProject(cwd, '00000000-0000-4000-8000-000000000001', detail,
31
+ { records: [] }, { fresh: true }), /empty directory/)
32
+ assert.deepEqual(readdirSync(cwd), ['notes.txt'])
33
+ assert.equal(readFileSync(join(cwd, 'notes.txt'), 'utf8'), 'keep')
34
+ })
35
+
36
+ test('ordinary pull into an existing connected folder remains supported', () => {
37
+ const cwd = mkdtempSync(join(tmpdir(), 'amorphous-pull-'))
38
+ mkdirSync(join(cwd, 'keep'))
39
+ writePulledProject(cwd, '00000000-0000-4000-8000-000000000001', detail, { records: [] })
40
+ assert.equal(readFileSync(join(cwd, 'index.html'), 'utf8'), detail.abstract.draft_html)
41
+ assert.equal(statSync(join(cwd, 'keep')).isDirectory(), true)
42
+ })
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import test from 'node:test'
6
+ import { writeRecipeFiles } from './commands.mjs'
7
+ import { printHelp } from './help.mjs'
8
+
9
+ test('recipe source writer saves the exact returned source bundle', () => {
10
+ const cwd = mkdtempSync(join(tmpdir(), 'amorphous-recipe-'))
11
+ const source = { html: '<h1>My app</h1>\n', css: 'body{color:black}\n', js: 'console.log("ok")\n' }
12
+ try {
13
+ writeRecipeFiles(cwd, source)
14
+ assert.equal(readFileSync(join(cwd, 'index.html'), 'utf8'), source.html)
15
+ assert.equal(readFileSync(join(cwd, 'styles.css'), 'utf8'), source.css)
16
+ assert.equal(readFileSync(join(cwd, 'app.js'), 'utf8'), source.js)
17
+ assert.equal(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), '')
18
+ assert.equal(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), '')
19
+ } finally { rmSync(cwd, { recursive: true, force: true }) }
20
+ })
21
+
22
+ test('recipe source writer refuses to overwrite an existing project file', () => {
23
+ const cwd = mkdtempSync(join(tmpdir(), 'amorphous-recipe-'))
24
+ try {
25
+ writeFileSync(join(cwd, 'app.js'), 'existing')
26
+ assert.throws(() => writeRecipeFiles(cwd, { html: '', css: '', js: 'new' }), /empty source folder/)
27
+ assert.equal(readFileSync(join(cwd, 'app.js'), 'utf8'), 'existing')
28
+ } finally { rmSync(cwd, { recursive: true, force: true }) }
29
+ })
30
+
31
+ test('create help documents recipe config mode', () => {
32
+ const previous = console.log
33
+ let output = ''
34
+ console.log = value => { output = String(value) }
35
+ try { printHelp('create') } finally { console.log = previous }
36
+ assert.match(output, /--recipe-config <file>/)
37
+ })
package/lib/serve.mjs CHANGED
@@ -2,12 +2,13 @@ import { createServer } from 'node:http'
2
2
  import { existsSync, readFileSync, watch } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { apiFetch } from './api.mjs'
5
- import { cliAbstractsFetchOrigin, cliAbstractsHostHeader } from './config.mjs'
5
+ import { randomUUID } from 'node:crypto'
6
6
  import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
7
7
 
8
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
9
  #amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
10
10
  #amorphous-serve button[aria-pressed="true"]{background:#fff;color:#18181b}`
11
+ 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
11
12
 
12
13
  function stripScriptTags(html) {
13
14
  return String(html || '').replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
@@ -28,7 +29,16 @@ function recordsMode(req, url) {
28
29
 
29
30
  async function readJson(req) {
30
31
  const chunks = []
31
- for await (const chunk of req) chunks.push(chunk)
32
+ let length = 0
33
+ for await (const chunk of req) {
34
+ length += chunk.length
35
+ if (length > 64 * 1024) {
36
+ const error = new Error('Request too large')
37
+ error.status = 413
38
+ throw error
39
+ }
40
+ chunks.push(chunk)
41
+ }
32
42
  const text = Buffer.concat(chunks).toString('utf8')
33
43
  if (!text) return {}
34
44
  return JSON.parse(text)
@@ -44,7 +54,7 @@ function send(res, status, body, headers = {}) {
44
54
  res.end(payload)
45
55
  }
46
56
 
47
- function pageHtml(html, mode) {
57
+ function pageHtml(html, mode, environment) {
48
58
  const pressed = (value) => (mode === value ? ' aria-pressed="true"' : '')
49
59
  return `<!DOCTYPE html>
50
60
  <html>
@@ -58,9 +68,15 @@ function pageHtml(html, mode) {
58
68
  <div id="amorphous-serve" data-mode="${mode}">
59
69
  <span>Records</span>
60
70
  <button type="button" data-set="local"${pressed('local')}>Local</button>
61
- <button type="button" data-set="prod"${pressed('prod')}>Prod</button>
71
+ <button type="button" data-set="prod"${pressed('prod')}>${environment === 'preview' ? 'Preview' : 'Live'}</button>
62
72
  </div>
63
73
  <script>
74
+ function recordFromResponse(payload) {
75
+ var body = payload && payload.body && typeof payload.body === 'object' ? payload.body : payload
76
+ return Object.assign({}, body, { id: payload.id, revision: payload.revision,
77
+ visibility: payload.visibility, collection: payload.collection,
78
+ created_by: payload.created_by == null ? null : payload.created_by })
79
+ }
64
80
  window.amorphous = {
65
81
  login() {},
66
82
  async me() {
@@ -95,7 +111,20 @@ window.amorphous = {
95
111
  body: JSON.stringify(body)
96
112
  })
97
113
  if (!res.ok) throw new Error('insert failed: ' + res.status)
98
- return body
114
+ return recordFromResponse(await res.json())
115
+ },
116
+ async history(recordId) {
117
+ const res = await fetch('/__amorphous/recovery/' + encodeURIComponent(recordId))
118
+ if (!res.ok) throw new Error('recovery history unavailable: ' + res.status)
119
+ return res.json()
120
+ },
121
+ async undo(recordId, historyId) {
122
+ const res = await fetch('/__amorphous/recovery/' + encodeURIComponent(recordId), {
123
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ historyId, operationKey: crypto.randomUUID() })
125
+ })
126
+ if (!res.ok) throw new Error('undo failed: ' + res.status)
127
+ return res.json()
99
128
  },
100
129
  async replace(id, row) {
101
130
  const res = await fetch('/__amorphous/data/' + encodeURIComponent(id), {
@@ -104,7 +133,7 @@ window.amorphous = {
104
133
  body: JSON.stringify(row)
105
134
  })
106
135
  if (!res.ok) throw new Error('replace failed: ' + res.status)
107
- return row
136
+ return recordFromResponse(await res.json())
108
137
  }
109
138
  }
110
139
  }
@@ -126,66 +155,13 @@ new EventSource('/__amorphous/events').addEventListener('reload', function () {
126
155
  </html>`
127
156
  }
128
157
 
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
158
  export function startServe(options) {
181
159
  const cwd = options.cwd
182
160
  const abstractId = options.abstractId
183
161
  const host = options.host || '127.0.0.1'
184
- const session = {
185
- cookie: null,
186
- failed: false,
187
- mint: options.mintPass || (() => mintAbstractPass(abstractId)),
188
- }
162
+ const environment = options.environment === 'preview' ? 'preview' : 'production'
163
+ const appPath = `/api/v1/apps/${abstractId}`
164
+ const environmentQuery = `?environment=${environment}`
189
165
  const clients = new Set()
190
166
  let reloadTimer = null
191
167
 
@@ -203,42 +179,85 @@ export function startServe(options) {
203
179
  watchers.push(watch(path, notifyReload))
204
180
  }
205
181
 
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
182
+ const hosted = (path, init = {}) => options.hostedFetch
183
+ ? options.hostedFetch(path, init)
184
+ : apiFetch(path, init)
185
+ const invoke = (name, input) => hosted(`${appPath}/actions/${name}/invoke${environmentQuery}`, {
186
+ method: 'POST', headers: { 'Idempotency-Key': randomUUID() }, body: JSON.stringify(input),
187
+ })
188
+ const recordFromOutput = (row) => ({ ...row.body, id: row.id, revision: row.revision,
189
+ visibility: row.visibility, collection: row.collection, created_by: row.created_by })
190
+ async function hostedRecords(method, url, req) {
191
+ if (method === 'GET' && url.pathname === '/__amorphous/data') {
192
+ const discovery = await hosted(`${appPath}/actions${environmentQuery}`)
193
+ const query = discovery.actions?.find((action) => action.name === 'records.query' && action.collection === 'default')
194
+ const limit = Math.min(100, query?.limits?.maxPage)
195
+ if (!Number.isSafeInteger(limit) || limit < 1) return { status: 503, body: { message: 'Invalid record page policy' } }
196
+ const records = []
197
+ for (const visibility of ['shared', 'private']) {
198
+ let cursor
199
+ const seen = new Set()
200
+ for (let pageNumber = 0; pageNumber < 1000; pageNumber++) {
201
+ const result = await invoke('records.query', { collection: 'default', visibility, limit,
202
+ ...(cursor ? { cursor } : {}) })
203
+ if (result.status !== 'ok') return { status: 403, body: { message: result.status } }
204
+ const page = Array.isArray(result.output) ? result.output : []
205
+ records.push(...page.map(recordFromOutput))
206
+ if (page.length < limit) break
207
+ cursor = page.at(-1)?.id
208
+ if (!cursor || seen.has(cursor)) return { status: 502, body: { message: 'Record cursor repeated' } }
209
+ seen.add(cursor)
210
+ if (pageNumber === 999) return { status: 413, body: { message: 'Record page limit reached' } }
211
+ }
212
+ }
213
+ return { status: 200, body: { records } }
215
214
  }
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: amorphous-cli login' }), {
223
- status: 401,
224
- headers: { 'Content-Type': 'application/json' },
225
- })
215
+ if (method === 'POST' && url.pathname === '/__amorphous/data') {
216
+ const row = await readJson(req)
217
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return { status: 400, body: { message: 'Invalid record' } }
218
+ const { id, visibility = 'shared', revision: _revision, collection: _collection,
219
+ created_by: _author, user_id: _subject, ...body } = row
220
+ const result = await invoke('records.create', { collection: 'default', visibility, ...(id ? { id } : {}), body })
221
+ return { status: result.status === 'ok' ? 200 : result.status === 'conflict' ? 409 : 403,
222
+ body: result.status === 'ok' ? { ok: true, ...result.output } : { message: result.status } }
226
223
  }
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' } })
224
+ if (method === 'PUT' && url.pathname.startsWith('/__amorphous/data/')) {
225
+ const id = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
226
+ const row = await readJson(req)
227
+ if (!row || typeof row !== 'object' || !Number.isSafeInteger(row.revision)) {
228
+ return { status: 400, body: { message: 'Current record revision required' } }
229
+ }
230
+ const { revision, id: _id, visibility = 'shared', collection: _collection,
231
+ created_by: _author, user_id: _subject, ...body } = row
232
+ const result = await invoke('records.patch', { collection: 'default', visibility, id,
233
+ expectedRevision: revision, body })
234
+ return { status: result.status === 'ok' ? 200 : result.status === 'conflict' ? 409 : 403,
235
+ body: result.status === 'ok' ? { ok: true, ...result.output } : { message: result.status } }
236
+ }
237
+ return { status: 405, body: { message: 'Method not allowed' } }
230
238
  }
231
239
 
232
240
  const server = createServer(async (req, res) => {
233
241
  try {
234
242
  const url = new URL(req.url || '/', 'http://127.0.0.1')
243
+ const address = server.address()
244
+ const expectedHost = `127.0.0.1:${address.port}`
245
+ if (req.headers.host !== expectedHost) {
246
+ send(res, 403, { message: 'Invalid local host' })
247
+ return
248
+ }
249
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method) &&
250
+ req.headers.origin !== `http://${expectedHost}`) {
251
+ send(res, 403, { message: 'Invalid local origin' })
252
+ return
253
+ }
235
254
  if (req.method === 'GET' && url.pathname === '/') {
236
255
  const mode = recordsMode(req, url)
237
256
  const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
238
257
  if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
239
258
  headers['Set-Cookie'] = `amorphous_records=${mode}; Path=/; SameSite=Lax`
240
259
  }
241
- send(res, 200, pageHtml(readProjectFiles(cwd).html, mode), headers)
260
+ send(res, 200, pageHtml(readProjectFiles(cwd).html, mode, environment), headers)
242
261
  return
243
262
  }
244
263
  if (req.method === 'GET' && url.pathname === '/styles.css') {
@@ -267,40 +286,62 @@ export function startServe(options) {
267
286
  return
268
287
  }
269
288
  const mode = recordsMode(req, url)
289
+ const recoveryMatch = /^\/__amorphous\/recovery\/([^/]+)$/.exec(url.pathname)
290
+ if (recoveryMatch) {
291
+ let recordId
292
+ try { recordId = decodeURIComponent(recoveryMatch[1]) } catch { recordId = '' }
293
+ if (!UUID.test(recordId)) {
294
+ send(res, 400, { message: 'Invalid record id' })
295
+ return
296
+ }
297
+ if (mode !== 'prod') {
298
+ if (req.method === 'GET') send(res, 200, { revisions: [] })
299
+ else if (req.method === 'POST') send(res, 409, { message: 'Undo is available for hosted records only.' })
300
+ else send(res, 405, { message: 'Method not allowed' })
301
+ return
302
+ }
303
+ if (req.method === 'GET') {
304
+ const result = await hosted(`${appPath}/recovery/revisions?recordId=${encodeURIComponent(recordId)}${environmentQuery.replace('?', '&')}`)
305
+ send(res, 200, { revisions: Array.isArray(result?.revisions) ? result.revisions : [] })
306
+ return
307
+ }
308
+ if (req.method === 'POST') {
309
+ const body = await readJson(req)
310
+ if (!body || typeof body !== 'object' || Array.isArray(body) ||
311
+ Object.keys(body).sort().join(',') !== 'historyId,operationKey' ||
312
+ !UUID.test(String(body.historyId || '')) || !UUID.test(String(body.operationKey || ''))) {
313
+ send(res, 400, { message: 'Invalid recovery request' })
314
+ return
315
+ }
316
+ const revisions = await hosted(`${appPath}/recovery/revisions?recordId=${encodeURIComponent(recordId)}&environment=${environment}`)
317
+ if (!Array.isArray(revisions?.revisions) || !revisions.revisions.some((item) => item?.historyId === body.historyId)) {
318
+ send(res, 404, { message: 'Recovery revision unavailable' })
319
+ return
320
+ }
321
+ const result = await hosted(`${appPath}/recovery/revision?environment=${environment}`, {
322
+ method: 'POST', body: JSON.stringify({ historyId: body.historyId, operationKey: body.operationKey }),
323
+ })
324
+ send(res, 200, result)
325
+ return
326
+ }
327
+ send(res, 405, { message: 'Method not allowed' })
328
+ return
329
+ }
270
330
  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
- })
331
+ send(res, 200, await hosted(`${appPath}/me${environmentQuery}`))
276
332
  return
277
333
  }
278
334
  if (req.method === 'POST' && url.pathname === '/__amorphous/names') {
279
335
  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
- })
336
+ send(res, 200, await hosted(`${appPath}/names${environmentQuery}`, {
337
+ method: 'POST', body: JSON.stringify({ ids: Array.isArray(body.ids) ? body.ids : [] }),
338
+ }))
290
339
  return
291
340
  }
292
341
  if (url.pathname === '/__amorphous/data' || url.pathname.startsWith('/__amorphous/data/')) {
293
342
  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' })
343
+ const result = await hostedRecords(req.method, url, req)
344
+ send(res, result.status, result.body)
304
345
  return
305
346
  }
306
347
  if (req.method === 'GET') {
@@ -325,7 +366,7 @@ export function startServe(options) {
325
366
  send(res, 400, { message: 'Invalid request' })
326
367
  return
327
368
  }
328
- send(res, 500, { message: error instanceof Error ? error.message : 'Request failed' })
369
+ send(res, error?.status || 500, { message: error instanceof Error ? error.message : 'Request failed' })
329
370
  }
330
371
  })
331
372