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.
package/lib/commands.mjs CHANGED
@@ -1,15 +1,34 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs'
1
+ import { readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, openSync, closeSync, mkdirSync, unlinkSync } from 'node:fs'
2
+ import { join } from 'node:path'
2
3
  import { installUserSkill, syncProjectContract, writeStarter } from './agent-contract.mjs'
3
4
  import { ensureSessionHooks } from './agent-hooks.mjs'
4
5
  import { shouldRefreshSkill } from './update-check.mjs'
5
6
  import { CLI_VERSION } from './version.mjs'
6
7
  import { homedir } from 'node:os'
7
8
  import { apiFetch } from './api.mjs'
8
- import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
9
+ import { appOrigin, projectMetaPath } from './config.mjs'
10
+ import { urlsFromDiscovery } from './app-origins.mjs'
9
11
  import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
10
12
  import { startServe } from './serve.mjs'
11
13
  import { ALIASES, printHelp, wantsHelp } from './help.mjs'
12
14
  import { loginWithBrowser, loginWithMagicLink } from './login.mjs'
15
+ import { ensureFreshSession } from './session.mjs'
16
+ import {
17
+ activateGuideIdentity,
18
+ guideBodyHash,
19
+ guidePaths,
20
+ ensureGuideFilesIgnored,
21
+ materializeGuide,
22
+ readGuideSyncState,
23
+ writeGuideSyncState,
24
+ } from './agent-guides-files.mjs'
25
+
26
+ const COMBINED_GUIDE_MAX_BYTES = 48 * 1024
27
+ const GUIDE_MAX_BYTES = 32 * 1024
28
+ const QUARANTINE_USER_IDS = [
29
+ 'ffffffff-ffff-4fff-8fff-ffffffffffff',
30
+ 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
31
+ ]
13
32
 
14
33
  const BOOL_FLAGS = new Set([
15
34
  'json',
@@ -80,6 +99,18 @@ function writeMeta(cwd, abstractId) {
80
99
  writeFileSync(projectMetaPath(cwd), JSON.stringify({ abstractId }, null, 2) + '\n')
81
100
  }
82
101
 
102
+ export function writeRecipeFiles(cwd, source) {
103
+ const files = [['index.html', source?.html], ['styles.css', source?.css], ['app.js', source?.js]]
104
+ if (files.some(([, content]) => typeof content !== 'string')) throw new Error('Recipe response did not include source files.')
105
+ const paths = files.map(([name]) => join(cwd, name))
106
+ if ([...paths, join(cwd, 'data.ndjson'), join(cwd, 'private.ndjson')].some(existsSync)) {
107
+ throw new Error('Recipe creation needs an empty source folder so it can write the generated files safely.')
108
+ }
109
+ for (let index = 0; index < files.length; index++) writeFileSync(paths[index], files[index][1])
110
+ writeFileSync(join(cwd, 'data.ndjson'), '')
111
+ writeFileSync(join(cwd, 'private.ndjson'), '')
112
+ }
113
+
83
114
  function requireMeta(cwd) {
84
115
  const meta = readMeta(cwd)
85
116
  if (!meta?.abstractId) {
@@ -88,12 +119,38 @@ function requireMeta(cwd) {
88
119
  return meta.abstractId
89
120
  }
90
121
 
91
- async function resolveAbstractId(cwd, flags, positional) {
122
+ async function initializeEmptyAbstractGuide(cwd, abstractId) {
123
+ ensureGuideFilesIgnored(cwd)
124
+ const path = join(cwd, 'agent.md')
125
+ try {
126
+ // Preserve anything already at this path, including a symlink or directory.
127
+ lstatSync(path)
128
+ return false
129
+ } catch (error) {
130
+ if (error?.code !== 'ENOENT') throw error
131
+ }
132
+ const fd = openSync(path, 'wx', 0o600)
133
+ closeSync(fd)
134
+ const { userId } = await guideSession()
135
+ const paths = guidePaths(cwd, abstractId, userId)
136
+ if (!existsSync(paths.privateGuide) && !existsSync(paths.activeIdentity)) {
137
+ const internal = join(cwd, '.amorphous-guides')
138
+ try { mkdirSync(internal, { mode: 0o700 }) } catch (error) { if (error?.code !== 'EEXIST') throw error }
139
+ if (lstatSync(internal).isSymbolicLink()) throw new Error('Unsafe agent-guide state directory.')
140
+ const marker = JSON.stringify({ schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId }) + '\n'
141
+ const stateFd = openSync(paths.activeIdentity, 'wx', 0o600)
142
+ try { writeFileSync(stateFd, marker) } finally { closeSync(stateFd) }
143
+ materializeGuide({ projectRoot: cwd, abstractId, userId, kind: 'abstract', body: '', revision: 0 })
144
+ }
145
+ return true
146
+ }
147
+
148
+ async function resolveAbstractId(cwd, flags, positional, request = apiFetch) {
92
149
  if (flags.abstract) return flags.abstract
93
150
  if (positional[0]) {
94
151
  const token = positional[0]
95
152
  if (/^[0-9a-f-]{36}$/i.test(token)) return token
96
- const data = await apiFetch('/api/abstracts?view=all')
153
+ const data = await request('/api/abstracts?view=all')
97
154
  const hit = (data.abstracts || []).find(
98
155
  (a) => a.name.toLowerCase() === token.toLowerCase(),
99
156
  )
@@ -107,6 +164,176 @@ async function resolveAbstractId(cwd, flags, positional) {
107
164
  }
108
165
  }
109
166
 
167
+ function sessionUserId(accessToken) {
168
+ try {
169
+ const payload = accessToken.split('.')[1]
170
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
171
+ if (typeof decoded.sub !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(decoded.sub)) throw new Error()
172
+ return decoded.sub.toLowerCase()
173
+ } catch {
174
+ throw new Error('The signed-in Amorphous session has no valid user identity. Run amorphous-cli login.')
175
+ }
176
+ }
177
+
178
+ function validateGuideResponse(value, label) {
179
+ if (!value || typeof value !== 'object' || typeof value.body !== 'string' ||
180
+ !Number.isSafeInteger(value.revision) || value.revision < 0) {
181
+ throw new Error(`The ${label} response is unavailable or invalid.`)
182
+ }
183
+ if (Buffer.byteLength(value.body, 'utf8') > 32 * 1024) {
184
+ throw new Error(`The ${label} exceeds the 32 KiB UTF-8 limit. Neither guide was loaded.`)
185
+ }
186
+ return value
187
+ }
188
+
189
+ async function guideSession() {
190
+ const session = await ensureFreshSession()
191
+ return { accessToken: session.access_token, userId: sessionUserId(session.access_token) }
192
+ }
193
+
194
+ function quarantineActiveGuides(cwd, fallbackAbstractId) {
195
+ const markerPath = join(cwd, '.amorphous-guides', 'active.json')
196
+ let marker = null
197
+ try {
198
+ const info = lstatSync(markerPath)
199
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error('Unsafe active guide identity file; local guide bytes were not moved.')
200
+ try { marker = JSON.parse(readFileSync(markerPath, 'utf8')) } catch { marker = null }
201
+ } catch (error) {
202
+ if (error?.code !== 'ENOENT') throw error
203
+ }
204
+ const validUuid = (value) => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
205
+ const abstractId = validUuid(marker?.abstractId)
206
+ ? marker.abstractId
207
+ : validUuid(fallbackAbstractId)
208
+ ? fallbackAbstractId
209
+ : '00000000-0000-4000-8000-000000000000'
210
+ const markedUser = marker?.userId
211
+ let switched
212
+ let lastError
213
+ for (const userId of QUARANTINE_USER_IDS) {
214
+ if (userId === markedUser) continue
215
+ try {
216
+ switched = activateGuideIdentity(cwd, abstractId, userId)
217
+ break
218
+ } catch (error) { lastError = error }
219
+ }
220
+ if (!switched) throw lastError || new Error('Could not safely quarantine active guide files.')
221
+ try {
222
+ const info = lstatSync(markerPath)
223
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error('Unsafe active guide identity file after recovery.')
224
+ unlinkSync(markerPath)
225
+ } catch (error) { if (error?.code !== 'ENOENT') throw error }
226
+ }
227
+
228
+ function enforceActiveGuideCaps(cwd, abstractId, userId) {
229
+ const paths = guidePaths(cwd, abstractId, userId)
230
+ const files = [paths.abstractGuide, paths.privateGuide]
231
+ let combinedBytes = 0
232
+ for (const path of files) {
233
+ const info = lstatSync(path)
234
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error('Active guide files must be regular non-symlink files.')
235
+ if (info.size > GUIDE_MAX_BYTES) {
236
+ throw new Error('An active guide exceeds the 32 KiB UTF-8 context limit. Neither guide was loaded; local text is preserved without truncation.')
237
+ }
238
+ combinedBytes += info.size
239
+ }
240
+ if (combinedBytes > COMBINED_GUIDE_MAX_BYTES) {
241
+ throw new Error('Active guide files exceed the combined 48 KiB UTF-8 context limit. Neither guide was loaded; local text is preserved without truncation.')
242
+ }
243
+ }
244
+
245
+ async function cmdContext(cwd, rest) {
246
+ const { flags, positional } = parseFlags(rest)
247
+ if (flags.help) return printHelp('context')
248
+ let authorizationComplete = false
249
+ let id
250
+ try {
251
+ const { accessToken, userId } = await guideSession()
252
+ const request = (path) => apiFetch(path, { accessToken })
253
+ id = await resolveAbstractId(cwd, flags, positional, request)
254
+ const [abstractGuide, privateGuide] = await Promise.all([
255
+ request(`/api/abstracts/${id}/agent-guide`).then((value) => validateGuideResponse(value, 'abstract guide')),
256
+ request(`/api/abstracts/${id}/private-agent-guide`).then((value) => validateGuideResponse(value, 'private guide')),
257
+ ])
258
+ authorizationComplete = true
259
+ const combinedBytes = Buffer.byteLength(abstractGuide.body, 'utf8') + Buffer.byteLength(privateGuide.body, 'utf8')
260
+ if (combinedBytes > COMBINED_GUIDE_MAX_BYTES) {
261
+ throw new Error('Agent guides exceed the combined 48 KiB UTF-8 context limit. Neither guide was loaded; reduce their size and run amorphous-cli context again.')
262
+ }
263
+ const identity = activateGuideIdentity(cwd, id, userId)
264
+ if (!identity.privateSafeToLoad) {
265
+ throw new Error('Guide identity is uncertain. Existing local files were preserved in recovery and neither guide was loaded; confirm the account and run amorphous-cli context again.')
266
+ }
267
+
268
+ const documents = [
269
+ { kind: 'abstract', guide: abstractGuide },
270
+ { kind: 'private', guide: privateGuide },
271
+ ]
272
+ const outcomes = []
273
+ for (const { kind, guide } of documents) {
274
+ const outcome = materializeGuide({ projectRoot: cwd, abstractId: id, userId, kind, body: guide.body, revision: guide.revision })
275
+ if (outcome.status === 'identity-uncertain') {
276
+ throw new Error('Guide identity changed during context loading. Do not use either local guide; confirm the account and run amorphous-cli context again.')
277
+ }
278
+ outcomes.push(outcome)
279
+ }
280
+ enforceActiveGuideCaps(cwd, id, userId)
281
+ const conflicts = outcomes.filter((outcome) => outcome.status === 'conflict')
282
+ if (conflicts.length) {
283
+ const paths = conflicts.map((item) => item.conflictPath).join(', ')
284
+ throw new Error(`An agent guide changed locally and remotely. Both copies were preserved; review the ignored conflict file${conflicts.length > 1 ? 's' : ''}: ${paths}. Resolve before asking the agent to use guide context.`)
285
+ }
286
+ console.log(`Loaded guide context for ${id} (abstract revision ${abstractGuide.revision}; private revision ${privateGuide.revision}).`)
287
+ console.log('Context does not fetch source; source release/version is separate from these guide revisions.')
288
+ console.log('The private guide is supplied to this local agent process and cannot be recalled after download.')
289
+ console.log('Read agent.md, then private-agent.md before working on the abstract.')
290
+ } catch (error) {
291
+ if (!authorizationComplete) {
292
+ try { quarantineActiveGuides(cwd, id) } catch (quarantineError) {
293
+ throw new Error(`${error.message} Active guide files could not be safely quarantined: ${quarantineError.message}`)
294
+ }
295
+ }
296
+ throw error
297
+ }
298
+ }
299
+
300
+ async function cmdGuide(cwd, rest) {
301
+ const { flags, positional } = parseFlags(rest)
302
+ if (flags.help || positional[0] !== 'save') return printHelp('guide')
303
+ const kind = positional[1]
304
+ if (!['abstract', 'private'].includes(kind)) throw new Error('Use: amorphous-cli guide save <abstract|private> [abstract].')
305
+ const abstractToken = positional[2]
306
+ const { accessToken, userId } = await guideSession()
307
+ const request = (path, options) => apiFetch(path, { ...options, accessToken })
308
+ const id = await resolveAbstractId(cwd, flags, abstractToken ? [abstractToken] : [], request)
309
+ const endpoint = kind === 'abstract' ? 'agent-guide' : 'private-agent-guide'
310
+ const current = validateGuideResponse(await request(`/api/abstracts/${id}/${endpoint}`), `${kind} guide`)
311
+ const paths = guidePaths(cwd, id, userId)
312
+ const identity = activateGuideIdentity(cwd, id, userId)
313
+ if (!identity.privateSafeToLoad) throw new Error('Guide identity is uncertain; preserved local files are not eligible for saving. Run context after confirming the signed-in account.')
314
+ const state = readGuideSyncState(cwd, id, userId)
315
+ const base = state?.guides?.[kind]
316
+ if (!base) throw new Error('No saved guide revision is available. Run amorphous-cli context first.')
317
+ if (base.revision !== current.revision) throw new Error('The server guide changed since context was loaded. Run amorphous-cli context, resolve any conflict, then save again.')
318
+ const path = kind === 'abstract' ? paths.abstractGuide : paths.privateGuide
319
+ const file = lstatSync(path)
320
+ if (file.isSymbolicLink() || !file.isFile()) throw new Error('Guide file must be a regular non-symlink file.')
321
+ const body = readFileSync(path, 'utf8')
322
+ if (Buffer.byteLength(body, 'utf8') > 32 * 1024) throw new Error('Guide exceeds the 32 KiB UTF-8 limit; it was not saved.')
323
+ if (guideBodyHash(body) === base.hash) {
324
+ console.log(`No ${kind} guide changes to save.`)
325
+ return
326
+ }
327
+ const saved = validateGuideResponse(await request(`/api/abstracts/${id}/${endpoint}`, {
328
+ method: 'PUT', body: JSON.stringify({ body, expectedRevision: base.revision }),
329
+ }), `${kind} guide save`)
330
+ writeGuideSyncState(cwd, id, userId, {
331
+ ...(state?.guides ?? {}),
332
+ [kind]: { revision: saved.revision, hash: guideBodyHash(body) },
333
+ })
334
+ console.log(`Saved ${kind} guide revision ${saved.revision}.`)
335
+ }
336
+
110
337
  async function loadFolders() {
111
338
  const meta = await apiFetch('/api/organise/meta')
112
339
  return meta.all_collections || meta.collections || []
@@ -215,6 +442,36 @@ They tell you when to push, preview, publish, or invite.`)
215
442
  async function cmdCreate(cwd, rest) {
216
443
  const { flags } = parseFlags(rest)
217
444
  if (flags.help) return printHelp('create')
445
+ if (flags['recipe-config'] === true) throw new Error('Pass a JSON file with --recipe-config <file>.')
446
+ if (typeof flags['recipe-config'] === 'string') {
447
+ const recipePath = flags['recipe-config'].startsWith('/') ? flags['recipe-config'] : join(cwd, flags['recipe-config'])
448
+ let config
449
+ try { config = JSON.parse(readFileSync(recipePath, 'utf8')) } catch { throw new Error('Could not read recipe config JSON.') }
450
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Recipe config must be a JSON object.')
451
+ if (typeof flags.name === 'string' && flags.name.trim()) config.name = flags.name.trim()
452
+ const sourceTargets = ['index.html', 'styles.css', 'app.js', 'data.ndjson', 'private.ndjson', '.amorphous.json']
453
+ if (sourceTargets.some(name => existsSync(join(cwd, name)))) {
454
+ throw new Error('Recipe creation needs an empty project folder.')
455
+ }
456
+ const data = await apiFetch('/api/abstracts/recipe', { method: 'POST', body: JSON.stringify(config) })
457
+ writeRecipeFiles(cwd, data.source)
458
+ writeMeta(cwd, data.id)
459
+ await initializeEmptyAbstractGuide(cwd, data.id)
460
+ if (data.recoveryContract) {
461
+ writeFileSync(projectMetaPath(cwd), JSON.stringify({ abstractId: data.id, recoveryContract: data.recoveryContract }, null, 2) + '\n')
462
+ }
463
+ const folderName = folderToken(flags)
464
+ if (folderName) {
465
+ const folder = await resolveFolder(folderName)
466
+ await apiFetch(`/api/abstracts/${data.id}/filing`, {
467
+ method: 'PATCH', body: JSON.stringify({ collection_id: folder.id }),
468
+ })
469
+ }
470
+ syncProjectContract(cwd)
471
+ if (jsonMode(flags)) printJson({ id: data.id, name: data.name })
472
+ else { console.log(data.id); console.log('Prefer amorphous-cli serve while you edit.') }
473
+ return
474
+ }
218
475
  const template = typeof flags.template === 'string' ? flags.template : 'blank'
219
476
  const data = await apiFetch('/api/abstracts', { method: 'POST', body: JSON.stringify({ template }) })
220
477
  const id = data.id
@@ -235,6 +492,7 @@ async function cmdCreate(cwd, rest) {
235
492
  })
236
493
  }
237
494
  writeStarter(cwd, template)
495
+ await initializeEmptyAbstractGuide(cwd, id)
238
496
  syncProjectContract(cwd)
239
497
  if (jsonMode(flags)) printJson({ id, name: name || 'Untitled' })
240
498
  else {
@@ -341,24 +599,38 @@ async function cmdPeople(cwd, rest) {
341
599
  }
342
600
  }
343
601
 
602
+ export function writePulledProject(cwd, id, detail, records, { fresh = false } = {}) {
603
+ if (fresh && readdirSync(cwd).length) {
604
+ throw new Error('Pulling into a new folder needs an empty directory.')
605
+ }
606
+ const abstract = detail?.abstract
607
+ if (!abstract || typeof abstract.draft_html !== 'string' || typeof abstract.draft_css !== 'string' || typeof abstract.draft_js !== 'string') {
608
+ throw new Error('Abstract response did not include editable source files.')
609
+ }
610
+ const rows = records?.records || []
611
+ writeProjectFiles(cwd, {
612
+ html: abstract.draft_html,
613
+ css: abstract.draft_css,
614
+ js: abstract.draft_js,
615
+ shared: rows.filter((r) => r.visibility !== 'private'),
616
+ private: rows.filter((r) => r.visibility === 'private'),
617
+ })
618
+ const recoveryContract = abstract.draft_recovery_contract
619
+ writeFileSync(projectMetaPath(cwd), JSON.stringify({
620
+ abstractId: id,
621
+ ...(recoveryContract && typeof recoveryContract === 'object' ? { recoveryContract } : {}),
622
+ }, null, 2) + '\n')
623
+ syncProjectContract(cwd)
624
+ }
625
+
344
626
  async function cmdPull(cwd, rest) {
345
- const { flags } = parseFlags(rest)
627
+ const { flags, positional } = parseFlags(rest)
346
628
  if (flags.help) return printHelp('pull')
347
- const id = requireMeta(cwd)
629
+ const id = await resolveAbstractId(cwd, flags, positional)
348
630
  const detail = await apiFetch(`/api/abstracts/${id}`)
349
631
  warnIfRemoteChanged(detail)
350
632
  const records = await apiFetch(`/api/abstracts/${id}/records`)
351
- const rows = records.records || []
352
- const shared = rows.filter((r) => r.visibility !== 'private')
353
- const priv = rows.filter((r) => r.visibility === 'private')
354
- writeProjectFiles(cwd, {
355
- html: detail.abstract.draft_html,
356
- css: detail.abstract.draft_css,
357
- js: detail.abstract.draft_js,
358
- shared,
359
- private: priv,
360
- })
361
- syncProjectContract(cwd)
633
+ writePulledProject(cwd, id, detail, records, { fresh: positional.length > 0 || Boolean(flags.abstract) })
362
634
  console.log('Pulled.')
363
635
  console.log('Prefer amorphous-cli serve while you edit.')
364
636
  }
@@ -367,7 +639,8 @@ async function cmdPush(cwd, rest) {
367
639
  const { flags } = parseFlags(rest)
368
640
  if (flags.help) return printHelp('push')
369
641
  const id = requireMeta(cwd)
370
- warnIfRemoteChanged(await apiFetch(`/api/abstracts/${id}`))
642
+ const detail = await apiFetch(`/api/abstracts/${id}`)
643
+ warnIfRemoteChanged(detail)
371
644
  if (flags.data) {
372
645
  const localRecords = recordsFromLocal(cwd)
373
646
  const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
@@ -381,7 +654,8 @@ async function cmdPush(cwd, rest) {
381
654
  const { html, css, js } = readProjectFiles(cwd)
382
655
  await apiFetch(`/api/abstracts/${id}/push`, {
383
656
  method: 'POST',
384
- body: JSON.stringify({ html, css, js }),
657
+ body: JSON.stringify({ html, css, js, expectedRevision: detail.abstract.draft_revision,
658
+ ...(readMeta(cwd)?.recoveryContract ? { recoveryContract: readMeta(cwd).recoveryContract } : {}) }),
385
659
  })
386
660
  console.log(flags.data ? 'Pushed source and records.' : 'Pushed source.')
387
661
  }
@@ -398,6 +672,7 @@ async function cmdServe(cwd, rest) {
398
672
  cwd,
399
673
  abstractId: id,
400
674
  port,
675
+ environment: flags.preview ? 'preview' : 'production',
401
676
  json: jsonMode(flags),
402
677
  })
403
678
  let warnedRemote = false
@@ -428,19 +703,16 @@ async function cmdServe(cwd, rest) {
428
703
  })
429
704
  }
430
705
 
431
- function urlsFor(id) {
432
- return {
433
- open: `${appOrigin().replace(/\/$/, '')}/a/${id}`,
434
- preview: `${abstractsOrigin().replace(/\/$/, '')}/${id}/draft`,
435
- live: `${abstractsOrigin().replace(/\/$/, '')}/${id}`,
436
- }
706
+ async function urlsFor(id) {
707
+ const discovery = await apiFetch('/api/runtime/origins', { auth: false })
708
+ return urlsFromDiscovery(id, appOrigin(), discovery, process.env)
437
709
  }
438
710
 
439
711
  async function cmdOpen(cwd, rest) {
440
712
  const { flags, positional } = parseFlags(rest)
441
713
  if (flags.help) return printHelp('open')
442
714
  const id = await resolveAbstractId(cwd, flags, positional)
443
- const urls = urlsFor(id)
715
+ const urls = await urlsFor(id)
444
716
  if (jsonMode(flags)) printJson(urls)
445
717
  else console.log(urls.open)
446
718
  }
@@ -451,7 +723,7 @@ async function cmdPreview(cwd, rest) {
451
723
  const id = await resolveAbstractId(cwd, flags, positional)
452
724
  const detail = await apiFetch(`/api/abstracts/${id}`)
453
725
  warnIfRemoteChanged(detail)
454
- const urls = urlsFor(id)
726
+ const urls = await urlsFor(id)
455
727
  if (jsonMode(flags)) printJson({ preview: urls.preview, remoteChangedSincePush: Boolean(detail.remoteChangedSincePush) })
456
728
  else console.log(urls.preview)
457
729
  }
@@ -460,11 +732,12 @@ async function cmdPublish(cwd, rest) {
460
732
  const { flags, positional } = parseFlags(rest)
461
733
  if (flags.help) return printHelp('publish')
462
734
  const id = await resolveAbstractId(cwd, flags, positional)
463
- warnIfRemoteChanged(await apiFetch(`/api/abstracts/${id}`))
735
+ const detail = await apiFetch(`/api/abstracts/${id}`)
736
+ warnIfRemoteChanged(detail)
464
737
  const live = Boolean(flags.live)
465
738
  const version = await apiFetch(`/api/abstracts/${id}/publish`, {
466
739
  method: 'POST',
467
- body: JSON.stringify({ live }),
740
+ body: JSON.stringify({ live, expectedRevision: detail.abstract.draft_revision }),
468
741
  })
469
742
  if (jsonMode(flags)) printJson({ id: version.id, label: version.label, live })
470
743
  else console.log(live ? 'It’s live.' : 'Published.')
@@ -751,6 +1024,10 @@ export async function runCommand(argv, cwd = process.cwd()) {
751
1024
  return cmdPeople(cwd, rest)
752
1025
  case 'pull':
753
1026
  return cmdPull(cwd, rest)
1027
+ case 'context':
1028
+ return cmdContext(cwd, rest)
1029
+ case 'guide':
1030
+ return cmdGuide(cwd, rest)
754
1031
  case 'push':
755
1032
  return cmdPush(cwd, rest)
756
1033
  case 'open':
package/lib/files.mjs CHANGED
@@ -43,8 +43,10 @@ export function writeProjectFiles(cwd, payload) {
43
43
  export function recordsFromLocal(cwd) {
44
44
  const { shared, private: priv } = readProjectFiles(cwd)
45
45
  return [
46
- ...shared.map((r) => ({ ...r, visibility: r.visibility || 'shared' })),
47
- ...priv.map((r) => ({ ...r, visibility: 'private' })),
46
+ ...shared.map((r) => ({ ...r, revision: r.revision || 1, visibility: r.visibility || 'shared',
47
+ collection: r.collection || 'default', created_by: r.created_by || null })),
48
+ ...priv.map((r) => ({ ...r, revision: r.revision || 1, visibility: 'private',
49
+ collection: r.collection || 'default', created_by: r.created_by || null })),
48
50
  ]
49
51
  }
50
52
 
@@ -66,6 +68,9 @@ export function insertLocalRecord(cwd, row) {
66
68
  }
67
69
  body.visibility = body.visibility === 'private' ? 'private' : 'shared'
68
70
  if (!body.id) body.id = randomUUID()
71
+ body.revision = 1
72
+ body.collection = 'default'
73
+ body.created_by = null
69
74
  const rows = recordsFromLocal(cwd)
70
75
  if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
71
76
  rows.push(body)
@@ -83,6 +88,9 @@ export function replaceLocalRecord(cwd, id, row) {
83
88
  ...(row && typeof row === 'object' ? row : {}),
84
89
  id,
85
90
  visibility: existing.visibility,
91
+ revision: existing.revision + 1,
92
+ collection: existing.collection,
93
+ created_by: existing.created_by,
86
94
  }
87
95
  rows[index] = next
88
96
  writeLocalRecords(cwd, rows)
package/lib/help.mjs CHANGED
@@ -9,6 +9,8 @@ export const ROOT_HELP = `Amorphous
9
9
  preview [abstract] Preview URL (draft)
10
10
  serve Develop in this folder
11
11
  pull Pull draft and records to this folder
12
+ context [abstract] Load authorized agent guides only
13
+ guide save <abstract|private> Save the selected guide explicitly
12
14
  push Push local files
13
15
  publish [--live] Publish a version; --live is Go live
14
16
  recommend [--note <text>] Send this draft to the owner for review
@@ -52,9 +54,9 @@ Start the local MCP server on stdio.`,
52
54
  List abstracts. Default view is all (All Abstracts).
53
55
  --folder filters to a folder. --tag filters to a tag.`,
54
56
 
55
- create: `create [--name <name>] [--folder <name>] [--template blank|list|board|landing] [--json]
57
+ create: `create [--name <name>] [--folder <name>] [--template blank|list|board|landing] [--recipe-config <file>] [--json]
56
58
 
57
- New abstract. Writes .amorphous.json in this directory.`,
59
+ New abstract. --recipe-config creates a supported recipe and writes its generated source files. Writes .amorphous.json in this directory.`,
58
60
 
59
61
  open: `open [abstract] [--json]
60
62
 
@@ -73,18 +75,28 @@ Records start local, in this folder. The page can switch to prod, which reads an
73
75
 
74
76
  Pull the draft and records into this directory.`,
75
77
 
78
+ context: `context [abstract]
79
+
80
+ Load the current user's authorized agent guides without pulling source or records.
81
+ Run once when beginning work on an existing abstract. The guides are ignored local files; private guide text is supplied to this local agent process and cannot be recalled after download.`,
82
+
83
+ guide: `guide save <abstract|private> [abstract]
84
+
85
+ Explicitly save the selected local guide using its last loaded revision. A stale revision is refused so neither copy is overwritten. Generic push, recommend, and publish never save or include guides.`,
86
+
76
87
  push: `push [--data]
77
88
 
78
89
  Push local index.html, styles.css, and app.js.
79
- --data also merges records from this folder. A record changed both here and on the abstract is refused.`,
90
+ --data also merges records from this folder. A record changed both here and on the abstract is refused.
91
+ Agent guides are separate: push never uploads them. Use amorphous-cli guide save explicitly.`,
80
92
 
81
93
  publish: `publish [--live]
82
94
 
83
- Publish a new version. --live also makes it the live version (Go live).`,
95
+ Publish a new version. --live also makes it the live version (Go live). Guides are not included.`,
84
96
 
85
97
  recommend: `recommend [--note <text>] [--json]
86
98
 
87
- Send this folder's HTML, CSS, and JavaScript to the owner as a proposal. It does not change the live version.`,
99
+ Send this folder's HTML, CSS, and JavaScript to the owner as a proposal. It does not change the live version or include agent guides.`,
88
100
 
89
101
  live: `live [abstract] [--version <id>]
90
102
 
@@ -0,0 +1,82 @@
1
+ const ACTION = /^[a-z][a-z0-9_.-]{0,79}$/
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
3
+ const TOKEN = /^[A-Za-z0-9_-]{40,64}$/
4
+ const RECEIPT = /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i
5
+ const ACTION_NAMES = new Set([
6
+ 'records.query', 'records.create', 'records.patch', 'records.delete',
7
+ 'items.add', 'items.done', 'items.move', 'items.edit', 'items.delete',
8
+ 'private.query', 'private.create', 'private.patch', 'private.delete',
9
+ 'form.submit', 'form.mine', 'form.edit',
10
+ ])
11
+
12
+ export function createParticipantClient({ origin, appId, token, hostHeaders = {}, fetchImpl = fetch }) {
13
+ const base = new URL(origin)
14
+ if (!['https:', 'http:'].includes(base.protocol) || base.username || base.password ||
15
+ base.search || base.hash || (base.pathname !== '/' && base.pathname !== '')) {
16
+ throw new TypeError('Participant app origin must be an HTTP(S) origin')
17
+ }
18
+ if (base.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(base.hostname) &&
19
+ !base.hostname.endsWith('.localhost')) {
20
+ throw new TypeError('Participant grant requires HTTPS outside local development')
21
+ }
22
+ if (!UUID.test(appId) || !TOKEN.test(token)) throw new TypeError('Invalid participant connection settings')
23
+ if (Object.keys(hostHeaders).some((key) => key.toLowerCase() !== 'host') ||
24
+ Object.values(hostHeaders).some((value) => typeof value !== 'string' || /[\r\n]/.test(value))) {
25
+ throw new TypeError('Invalid participant host configuration')
26
+ }
27
+ const root = `/api/v1/apps/${appId.toLowerCase()}`
28
+
29
+ async function request(path, { method = 'GET', body, idempotencyKey, approvalId } = {}) {
30
+ if (typeof path !== 'string' || !path.startsWith(`${root}/`) || path.includes('..') || path.includes('?')) {
31
+ throw new Error('Unsupported participant endpoint')
32
+ }
33
+ const actionRoute = new RegExp(`^${root}/actions/([^/]+)/invoke$`).exec(path)
34
+ const receiptPrefix = `${root}/receipts/`
35
+ const getOnly = path === `${root}/me` || path === `${root}/actions` ||
36
+ path === `${root}/status` || path === `${root}/activity` ||
37
+ (path.startsWith(receiptPrefix) && RECEIPT.test(path.slice(receiptPrefix.length)) )
38
+ const postAction = Boolean(actionRoute && ACTION_NAMES.has(actionRoute[1]))
39
+ const allowed = (getOnly && method === 'GET') || (postAction && method === 'POST')
40
+ if (!allowed) throw new Error('Unsupported participant endpoint')
41
+ const headers = { ...hostHeaders, Authorization: `AmorphousGrant ${token}`, Accept: 'application/json' }
42
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
43
+ if (idempotencyKey !== undefined) {
44
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.length < 1 || idempotencyKey.length > 200) {
45
+ throw new TypeError('Invalid idempotency key')
46
+ }
47
+ headers['Idempotency-Key'] = idempotencyKey
48
+ }
49
+ if (approvalId !== undefined) {
50
+ if (typeof approvalId !== 'string' || !UUID.test(approvalId)) throw new TypeError('Invalid approval id')
51
+ headers['Amorphous-Approval'] = approvalId
52
+ }
53
+ const response = await fetchImpl(new URL(path, base), {
54
+ method, headers, redirect: 'error', ...(body === undefined ? {} : { body: JSON.stringify(body) }),
55
+ })
56
+ const raw = await response.text()
57
+ let payload
58
+ try { payload = raw ? JSON.parse(raw) : null } catch { payload = null }
59
+ if (!response.ok) throw new Error(`Participant request failed (${response.status})`)
60
+ return payload
61
+ }
62
+
63
+ return Object.freeze({
64
+ request,
65
+ me: () => request(`${root}/me`),
66
+ discover: () => request(`${root}/actions`),
67
+ invoke: (action, input, idempotencyKey, approvalId) => {
68
+ if (typeof action !== 'string' || !ACTION.test(action) || !ACTION_NAMES.has(action)) throw new TypeError('Invalid action')
69
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('Invalid action input')
70
+ if (approvalId !== undefined && (typeof approvalId !== 'string' || !RECEIPT.test(approvalId))) {
71
+ throw new TypeError('Invalid approval id')
72
+ }
73
+ return request(`${root}/actions/${action}/invoke`, { method: 'POST', body: input, idempotencyKey, approvalId })
74
+ },
75
+ receipt: (receiptId) => {
76
+ if (typeof receiptId !== 'string' || !RECEIPT.test(receiptId)) throw new TypeError('Invalid receipt id')
77
+ return request(`${root}/receipts/${receiptId}`)
78
+ },
79
+ status: () => request(`${root}/status`),
80
+ activity: () => request(`${root}/activity`),
81
+ })
82
+ }
@@ -0,0 +1,34 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createParticipantClient } from './participant-client.mjs'
4
+
5
+ const id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
6
+
7
+ test('participant client sends only its grant credential to selected-app endpoints', async () => {
8
+ const calls = []
9
+ const client = createParticipantClient({
10
+ origin: 'https://amorphous.test/', appId: id, token: 'A'.repeat(43),
11
+ fetchImpl: async (url, options) => {
12
+ calls.push({ url: String(url), options })
13
+ return new Response('{"ok":true}', { status: 200, headers: { 'Content-Type': 'application/json' } })
14
+ },
15
+ })
16
+ await client.me()
17
+ await client.invoke('items.add', { collection: 'items', visibility: 'shared', body: { title: 'Milk' } }, 'req-1', id)
18
+ assert.equal(calls[0].url, `https://amorphous.test/api/v1/apps/${id}/me`)
19
+ assert.equal(calls[0].options.headers.Authorization, `AmorphousGrant ${'A'.repeat(43)}`)
20
+ assert.equal(calls[0].options.redirect, 'error')
21
+ assert.equal(calls[1].options.headers['Idempotency-Key'], 'req-1')
22
+ assert.equal(calls[1].options.headers['Amorphous-Approval'], id)
23
+ assert.equal(calls[1].options.headers.Authorization, `AmorphousGrant ${'A'.repeat(43)}`)
24
+ assert.equal(calls.some(({ url }) => !url.includes(`/apps/${id}/`)), false)
25
+ })
26
+
27
+ test('participant client rejects non-selected or privileged routes and invalid actions', async () => {
28
+ const client = createParticipantClient({ origin: 'https://amorphous.test', appId: id,
29
+ token: 'A'.repeat(43), fetchImpl: async () => { throw new Error('unexpected network') } })
30
+ await assert.rejects(client.request('/api/v1/apps'), /Unsupported participant endpoint/)
31
+ await assert.rejects(client.request(`/api/v1/apps/${id}/actions`, { method: 'POST' }), /Unsupported participant endpoint/)
32
+ assert.throws(() => client.invoke('items.add', null, 'req-1'), /Invalid action input/)
33
+ assert.throws(() => client.invoke('publish', {}, 'req-1'), /Invalid action/)
34
+ })