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,367 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync, spawnSync } from 'node:child_process'
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, lstatSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { afterEach, test } from 'node:test'
7
+ import {
8
+ guidePaths,
9
+ ensureGuideFilesIgnored,
10
+ readGuideSyncState,
11
+ writeGuideSyncState,
12
+ materializeGuide,
13
+ activateGuideIdentity,
14
+ guideBodyHash,
15
+ } from './agent-guides-files.mjs'
16
+
17
+ const roots = []
18
+ const idA = '00000000-0000-4000-8000-000000000001'
19
+ const idB = '00000000-0000-4000-8000-000000000002'
20
+ const userA = '10000000-0000-4000-8000-000000000001'
21
+ const userB = '10000000-0000-4000-8000-000000000002'
22
+ function root() {
23
+ const value = mkdtempSync(join(tmpdir(), 'am-guide-'))
24
+ roots.push(value)
25
+ return value
26
+ }
27
+ afterEach(() => { for (const value of roots.splice(0)) rmSync(value, { recursive: true, force: true }) })
28
+
29
+ function initGit(workspace) {
30
+ execFileSync('git', ['init', '--quiet', workspace])
31
+ writeFileSync(join(workspace, '.git', 'info', 'exclude'), '# local excludes\n')
32
+ }
33
+
34
+ test('guide paths are fixed, separated, and sync state is bound to abstract and user', () => {
35
+ const workspace = root()
36
+ const paths = guidePaths(workspace, idA, userA)
37
+ assert.equal(paths.abstractGuide, join(paths.projectRoot, 'agent.md'))
38
+ assert.equal(paths.privateGuide, join(paths.projectRoot, 'private-agent.md'))
39
+ assert.notEqual(paths.syncState, guidePaths(workspace, idA, userB).syncState)
40
+ assert.notEqual(paths.syncState, guidePaths(workspace, idB, userA).syncState)
41
+ const hash = 'a'.repeat(64)
42
+ writeGuideSyncState(workspace, idA, userA, { abstract: { revision: 2, hash } })
43
+ assert.deepEqual(readGuideSyncState(workspace, idA, userA).guides.abstract, { revision: 2, hash })
44
+ assert.equal(readGuideSyncState(workspace, idA, userB), null)
45
+ })
46
+
47
+ test('guide ignore rules prefer Git info exclude and preserve user content', () => {
48
+ const workspace = root()
49
+ initGit(workspace)
50
+ const exclude = join(workspace, '.git', 'info', 'exclude')
51
+ ensureGuideFilesIgnored(workspace)
52
+ ensureGuideFilesIgnored(workspace)
53
+ const text = readFileSync(exclude, 'utf8')
54
+ assert.match(text, /# local excludes/)
55
+ assert.match(text, /\/agent\.md/)
56
+ assert.match(text, /\/private-agent\.md/)
57
+ assert.match(text, /\.amorphous-guides/)
58
+ assert.match(text, /\.amorphous-guide-\*\.tmp/)
59
+ assert.equal((text.match(/BEGIN amorphous agent guides/g) || []).length, 1)
60
+ assert.match(text, /\.amorphous-guides/)
61
+ assert.equal(existsSync(join(workspace, '.gitignore')), false)
62
+ })
63
+
64
+ test('Git exclude rules scope nested abstract guides to their actual workspace directory', () => {
65
+ const repo = root()
66
+ execFileSync('git', ['init', '--quiet', repo])
67
+ const workspace = join(repo, 'apps', 'my-abstract')
68
+ const secondWorkspace = join(repo, 'apps', 'second abstract')
69
+ const adjacent = join(repo, 'apps', 'other-abstract')
70
+ mkdirSync(workspace, { recursive: true })
71
+ mkdirSync(secondWorkspace, { recursive: true })
72
+ mkdirSync(adjacent, { recursive: true })
73
+ ensureGuideFilesIgnored(workspace)
74
+ ensureGuideFilesIgnored(secondWorkspace)
75
+
76
+ const exclude = readFileSync(join(repo, '.git', 'info', 'exclude'), 'utf8')
77
+ assert.match(exclude, /\/apps\/my-abstract\/agent\.md/)
78
+ assert.match(exclude, /\/apps\/my-abstract\/private-agent\.md/)
79
+ assert.match(exclude, /\/apps\/my-abstract\/\.amorphous-guides\//)
80
+ assert.match(exclude, /\/apps\/second\\ abstract\/private-agent\.md/)
81
+ const ignored = (path) => spawnSync('git', ['check-ignore', '--no-index', '--quiet', '--', path], { cwd: repo }).status === 0
82
+ assert.equal(ignored('apps/my-abstract/agent.md'), true)
83
+ assert.equal(ignored('apps/my-abstract/private-agent.md'), true)
84
+ assert.equal(ignored('apps/second abstract/private-agent.md'), true)
85
+ assert.equal(ignored('apps/other-abstract/private-agent.md'), false)
86
+ assert.equal(ignored('agent.md'), false)
87
+ })
88
+
89
+ test('worktree gitdir pointers keep the workspace-local ignore fallback', () => {
90
+ const base = root()
91
+ const workspace = join(base, 'worktree')
92
+ const gitDir = join(base, 'git-data')
93
+ mkdirSync(workspace)
94
+ mkdirSync(join(gitDir, 'info'), { recursive: true })
95
+ writeFileSync(join(gitDir, 'info', 'exclude'), 'external rules\n')
96
+ writeFileSync(join(workspace, '.git'), 'gitdir: ../git-data\n')
97
+
98
+ ensureGuideFilesIgnored(workspace)
99
+ assert.match(readFileSync(join(workspace, '.gitignore'), 'utf8'), /\/private-agent\.md/)
100
+ assert.equal(readFileSync(join(gitDir, 'info', 'exclude'), 'utf8'), 'external rules\n')
101
+ })
102
+
103
+ test('private guide refuses an effective ignore negation without writing private bytes', () => {
104
+ const workspace = root()
105
+ initGit(workspace)
106
+ writeFileSync(join(workspace, '.gitignore'), '!private-agent.md\n')
107
+ assert.throws(() => materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
108
+ kind: 'private', body: 'private text must not land here', revision: 1 }), /ignored|tracked|refusing/i)
109
+ assert.equal(existsSync(join(workspace, 'private-agent.md')), false)
110
+ assert.equal(existsSync(join(workspace, '.amorphous-guides')), false)
111
+ })
112
+
113
+ test('private guide refuses an already tracked active path without changing it', () => {
114
+ const workspace = root()
115
+ initGit(workspace)
116
+ writeFileSync(join(workspace, 'private-agent.md'), 'tracked user content')
117
+ execFileSync('git', ['add', '--', 'private-agent.md'], { cwd: workspace })
118
+ assert.throws(() => materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
119
+ kind: 'private', body: 'private text must not overwrite tracked content', revision: 1 }), /ignored|tracked|refusing/i)
120
+ assert.equal(readFileSync(join(workspace, 'private-agent.md'), 'utf8'), 'tracked user content')
121
+ assert.equal(existsSync(join(workspace, '.amorphous-guides')), false)
122
+ })
123
+
124
+ test('abstract guide refuses an already tracked control-plane path without changing it', () => {
125
+ const workspace = root()
126
+ initGit(workspace)
127
+ writeFileSync(join(workspace, 'agent.md'), 'tracked project instructions')
128
+ execFileSync('git', ['add', '--', 'agent.md'], { cwd: workspace })
129
+ assert.throws(() => materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
130
+ kind: 'abstract', body: 'remote guide must not overwrite tracked source', revision: 1 }), /tracked|refusing/i)
131
+ assert.equal(readFileSync(join(workspace, 'agent.md'), 'utf8'), 'tracked project instructions')
132
+ assert.equal(existsSync(join(workspace, '.amorphous-guides')), false)
133
+ })
134
+
135
+ test('guide ignore rules use a managed .gitignore block outside Git', () => {
136
+ const workspace = root()
137
+ writeFileSync(join(workspace, '.gitignore'), 'user-file\n')
138
+ ensureGuideFilesIgnored(workspace)
139
+ ensureGuideFilesIgnored(workspace)
140
+ const text = readFileSync(join(workspace, '.gitignore'), 'utf8')
141
+ assert.match(text, /^user-file\n/)
142
+ assert.match(text, /\/private-agent\.md/)
143
+ assert.equal((text.match(/BEGIN amorphous agent guides/g) || []).length, 1)
144
+ })
145
+
146
+ test('atomic materialization uses restrictive permissions and does not rewrite unchanged bytes', () => {
147
+ const workspace = root()
148
+ initGit(workspace)
149
+ ensureGuideFilesIgnored(workspace)
150
+ const first = materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
151
+ kind: 'abstract', body: 'shared guide', revision: 1 })
152
+ assert.equal(first.status, 'written')
153
+ const mode = lstatSync(join(workspace, 'agent.md')).mode & 0o777
154
+ assert.equal(mode, 0o600)
155
+ const mtime = lstatSync(join(workspace, 'agent.md')).mtimeMs
156
+ const same = materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
157
+ kind: 'abstract', body: 'shared guide', revision: 1 })
158
+ assert.equal(same.status, 'unchanged')
159
+ assert.equal(lstatSync(join(workspace, 'agent.md')).mtimeMs, mtime)
160
+ assert.deepEqual(readdirSync(workspace).filter(name => name.includes('.tmp')), [])
161
+ })
162
+
163
+ test('local edits and remote changes create ignored conflict copies without overwriting either', () => {
164
+ const workspace = root()
165
+ ensureGuideFilesIgnored(workspace)
166
+ materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
167
+ kind: 'abstract', body: 'base', revision: 1 })
168
+ writeFileSync(join(workspace, 'agent.md'), 'local edit')
169
+ const result = materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
170
+ kind: 'abstract', body: 'remote edit', revision: 2 })
171
+ assert.equal(result.status, 'conflict')
172
+ assert.equal(readFileSync(join(workspace, 'agent.md'), 'utf8'), 'local edit')
173
+ assert.equal(readFileSync(result.conflictPath, 'utf8'), 'remote edit')
174
+ assert.match(readFileSync(join(workspace, '.gitignore'), 'utf8'), /\.amorphous-guides/)
175
+ })
176
+
177
+ test('local-only edits are retained when the remote body still matches the saved base', () => {
178
+ const workspace = root()
179
+ ensureGuideFilesIgnored(workspace)
180
+ materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
181
+ kind: 'abstract', body: 'base', revision: 1 })
182
+ writeFileSync(join(workspace, 'agent.md'), 'local-only edit')
183
+ const result = materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
184
+ kind: 'abstract', body: 'base', revision: 2 })
185
+ assert.equal(result.status, 'local-only')
186
+ assert.equal(readFileSync(join(workspace, 'agent.md'), 'utf8'), 'local-only edit')
187
+ assert.deepEqual(readGuideSyncState(workspace, idA, userA).guides.abstract,
188
+ { revision: 2, hash: guideBodyHash('base') })
189
+ assert.equal(result.conflictPath, undefined)
190
+ })
191
+
192
+ test('identity switch moves prior private bytes to that identity recovery path and removes active file', () => {
193
+ const workspace = root()
194
+ ensureGuideFilesIgnored(workspace)
195
+ materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
196
+ kind: 'private', body: 'A private bytes', revision: 1 })
197
+ const result = activateGuideIdentity(workspace, idA, userB)
198
+ assert.equal(result.status, 'switched')
199
+ assert.equal(result.recovered.length, 1)
200
+ assert.match(result.recovered[0], new RegExp(`${idA}/${userA}/`))
201
+ assert.equal(readFileSync(result.recovered[0], 'utf8'), 'A private bytes')
202
+ assert.throws(() => lstatSync(join(workspace, 'private-agent.md')), { code: 'ENOENT' })
203
+ })
204
+
205
+ test('identity switch refuses recovery paths exposed by Git ignore negations', () => {
206
+ const workspace = root()
207
+ initGit(workspace)
208
+ materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
209
+ kind: 'private', body: 'private bytes must stay ignored', revision: 1 })
210
+ writeFileSync(join(workspace, '.gitignore'), [
211
+ '!.amorphous-guides/',
212
+ '.amorphous-guides/private-check',
213
+ '!.amorphous-guides/recovery/',
214
+ '!.amorphous-guides/recovery/**',
215
+ '',
216
+ ].join('\n'), { flag: 'a' })
217
+
218
+ assert.equal(spawnSync('git', ['check-ignore', '--quiet', '--', '.amorphous-guides/private-check'], { cwd: workspace }).status, 0)
219
+ assert.equal(spawnSync('git', ['check-ignore', '--quiet', '--', '.amorphous-guides/recovery/probe/private-agent.md'], { cwd: workspace }).status, 1)
220
+ assert.throws(() => activateGuideIdentity(workspace, idA, userB), /ignored|tracked|refusing/i)
221
+ assert.equal(readFileSync(join(workspace, 'private-agent.md'), 'utf8'), 'private bytes must stay ignored')
222
+ const recoveryRoot = join(workspace, '.amorphous-guides', 'recovery')
223
+ assert.deepEqual(existsSync(recoveryRoot)
224
+ ? readdirSync(recoveryRoot, { recursive: true }).filter((name) => name.endsWith('private-agent.md'))
225
+ : [], [])
226
+ const add = spawnSync('git', ['add', '-A', '--dry-run', '--ignore-errors'], { cwd: workspace, encoding: 'utf8' })
227
+ assert.equal(add.status, 0)
228
+ assert.doesNotMatch(add.stdout, /\.amorphous-guides\/recovery\/.*private-agent\.md/)
229
+ })
230
+
231
+ test('non-Git fallback refuses exposed internal recovery destinations', () => {
232
+ const workspace = root()
233
+ materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
234
+ kind: 'private', body: 'private fallback bytes', revision: 1 })
235
+ writeFileSync(join(workspace, '.gitignore'), [
236
+ '!.amorphous-guides/',
237
+ '.amorphous-guides/private-check',
238
+ '!.amorphous-guides/recovery/',
239
+ '!.amorphous-guides/recovery/**',
240
+ '',
241
+ ].join('\n'), { flag: 'a' })
242
+ assert.throws(() => activateGuideIdentity(workspace, idA, userB), /ignored|tracked|refusing/i)
243
+ assert.equal(readFileSync(join(workspace, 'private-agent.md'), 'utf8'), 'private fallback bytes')
244
+ const recoveryRoot = join(workspace, '.amorphous-guides', 'recovery')
245
+ assert.deepEqual(existsSync(recoveryRoot)
246
+ ? readdirSync(recoveryRoot, { recursive: true }).filter((name) => name.endsWith('private-agent.md'))
247
+ : [], [])
248
+ })
249
+
250
+ test('non-Git fallback evaluates nested ignore files before identity recovery', () => {
251
+ const workspace = root()
252
+ materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
253
+ kind: 'private', body: 'nested-ignore private bytes', revision: 1 })
254
+ writeFileSync(join(workspace, '.gitignore'), [
255
+ '!.amorphous-guides/',
256
+ '.amorphous-guides/*',
257
+ '!.amorphous-guides/.gitignore',
258
+ '',
259
+ ].join('\n'), { flag: 'a' })
260
+ writeFileSync(join(workspace, '.amorphous-guides', '.gitignore'), [
261
+ '!recovery/',
262
+ '!recovery/**',
263
+ '',
264
+ ].join('\n'))
265
+
266
+ assert.throws(() => activateGuideIdentity(workspace, idA, userB), /ignored|tracked|refusing/i)
267
+ assert.equal(readFileSync(join(workspace, 'private-agent.md'), 'utf8'), 'nested-ignore private bytes')
268
+ const recoveryRoot = join(workspace, '.amorphous-guides', 'recovery')
269
+ assert.deepEqual(existsSync(recoveryRoot)
270
+ ? readdirSync(recoveryRoot, { recursive: true }).filter((name) => name.endsWith('private-agent.md'))
271
+ : [], [])
272
+
273
+ execFileSync('git', ['init', '--quiet', workspace])
274
+ assert.equal(spawnSync('git', ['check-ignore', '--quiet', '--', '.amorphous-guides/recovery/probe/private-agent.md'], { cwd: workspace }).status, 1)
275
+ execFileSync('git', ['add', '-A', '--ignore-errors'], { cwd: workspace })
276
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: workspace, encoding: 'utf8' })
277
+ assert.doesNotMatch(staged, /recovery\/.*private-agent\.md/)
278
+ assert.doesNotMatch(staged, /private-agent\.md/)
279
+ })
280
+
281
+ test('same-identity activation rejects active guide symlinks without reading or removing them', () => {
282
+ const workspace = root()
283
+ ensureGuideFilesIgnored(workspace)
284
+ materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
285
+ kind: 'abstract', body: 'authorized abstract guide', revision: 1 })
286
+ const outside = join(root(), 'private-target.md')
287
+ writeFileSync(outside, 'must not be read or changed')
288
+ symlinkSync(outside, join(workspace, 'private-agent.md'))
289
+ assert.throws(() => activateGuideIdentity(workspace, idA, userA), /symlink/i)
290
+ assert.equal(lstatSync(join(workspace, 'private-agent.md')).isSymbolicLink(), true)
291
+ assert.equal(readFileSync(outside, 'utf8'), 'must not be read or changed')
292
+ })
293
+
294
+ test('materialization rejects guides above 32 KiB by UTF-8 byte count before writing', () => {
295
+ const workspace = root()
296
+ const oversizedUnicode = 'é'.repeat(16_385)
297
+ assert.ok(oversizedUnicode.length < 32 * 1024)
298
+ assert.ok(Buffer.byteLength(oversizedUnicode, 'utf8') > 32 * 1024)
299
+ assert.throws(() => materializeGuide({ projectRoot: workspace, abstractId: idA, userId: userA,
300
+ kind: 'private', body: oversizedUnicode, revision: 1 }), /32 KiB/i)
301
+ assert.equal(existsSync(join(workspace, 'private-agent.md')), false)
302
+ assert.equal(existsSync(join(workspace, '.amorphous-guides')), false)
303
+ })
304
+
305
+ test('malformed identity state fails closed and preserves private bytes only in recovery', () => {
306
+ const workspace = root()
307
+ ensureGuideFilesIgnored(workspace)
308
+ writeFileSync(join(workspace, 'private-agent.md'), 'possibly private')
309
+ mkdirSync(join(workspace, '.amorphous-guides'), { recursive: true })
310
+ writeFileSync(join(workspace, '.amorphous-guides', 'active.json'), '{bad')
311
+ const result = activateGuideIdentity(workspace, idA, userA)
312
+ assert.equal(result.status, 'recovered-unknown-identity')
313
+ assert.equal(result.privateSafeToLoad, false)
314
+ assert.equal(readFileSync(result.recovered[0], 'utf8'), 'possibly private')
315
+ assert.throws(() => lstatSync(join(workspace, 'private-agent.md')), { code: 'ENOENT' })
316
+ })
317
+
318
+ test('symlinked project roots, guide targets, and state parents are rejected', () => {
319
+ const base = root()
320
+ const real = join(base, 'real')
321
+ mkdirSync(real)
322
+ const rootLink = join(base, 'root-link')
323
+ symlinkSync(real, rootLink)
324
+ assert.throws(() => guidePaths(rootLink, idA, userA), /symlink|project root/i)
325
+
326
+ const targetWorkspace = root()
327
+ ensureGuideFilesIgnored(targetWorkspace)
328
+ const outside = join(base, 'outside.md')
329
+ writeFileSync(outside, 'outside')
330
+ symlinkSync(outside, join(targetWorkspace, 'agent.md'))
331
+ assert.throws(() => materializeGuide({ projectRoot: targetWorkspace, abstractId: idA, userId: userA,
332
+ kind: 'abstract', body: 'new', revision: 1 }), /symlink/i)
333
+ assert.equal(readFileSync(outside, 'utf8'), 'outside')
334
+ })
335
+
336
+ test('malformed state metadata is rejected and bytes are not trusted', () => {
337
+ const workspace = root()
338
+ const paths = guidePaths(workspace, idA, userA)
339
+ mkdirSync(join(workspace, '.amorphous-guides', 'state', idA, userA), { recursive: true })
340
+ writeFileSync(paths.syncState, '{bad')
341
+ assert.throws(() => readGuideSyncState(workspace, idA, userA), /state/i)
342
+ writeFileSync(paths.syncState, JSON.stringify({ schemaVersion: 1, abstractId: idA, userId: userA,
343
+ guides: { private: { revision: 1, hash: 'a'.repeat(64), body: 'must not be metadata' } } }))
344
+ assert.throws(() => readGuideSyncState(workspace, idA, userA), /metadata/i)
345
+ })
346
+
347
+ test('symlinked state parent components are rejected before metadata reads', () => {
348
+ const base = root()
349
+ const workspace = join(base, 'workspace')
350
+ const external = join(base, 'external')
351
+ mkdirSync(workspace)
352
+ mkdirSync(join(external, 'state', idA, userA), { recursive: true })
353
+ mkdirSync(join(workspace, '.amorphous-guides'))
354
+ symlinkSync(join(external, 'state'), join(workspace, '.amorphous-guides', 'state'))
355
+ assert.throws(() => readGuideSyncState(workspace, idA, userA), /symlink/i)
356
+ })
357
+
358
+ test('symlinked ignore targets are rejected without changing their destination', () => {
359
+ const base = root()
360
+ const workspace = join(base, 'workspace')
361
+ const external = join(base, 'external-ignore')
362
+ mkdirSync(workspace)
363
+ writeFileSync(external, 'outside rules\n')
364
+ symlinkSync(external, join(workspace, '.gitignore'))
365
+ assert.throws(() => ensureGuideFilesIgnored(workspace), /symlink/i)
366
+ assert.equal(readFileSync(external, 'utf8'), 'outside rules\n')
367
+ })
package/lib/api.mjs CHANGED
@@ -35,17 +35,22 @@ export function friendlyMessage(raw) {
35
35
  }
36
36
 
37
37
  export async function apiFetch(path, opts = {}) {
38
+ const { accessToken, ...fetchOptions } = opts
38
39
  const url = `${cliAppFetchOrigin()}${path}`
39
- const headers = { ...cliAppHostHeader(), ...(opts.headers || {}) }
40
- if (opts.auth !== false) {
41
- const session = await ensureFreshSession(opts.forceRefresh ? { force: true } : {})
42
- headers.Authorization = `Bearer ${session.access_token}`
40
+ const headers = { ...cliAppHostHeader(), ...(fetchOptions.headers || {}) }
41
+ if (fetchOptions.auth !== false) {
42
+ if (accessToken) {
43
+ headers.Authorization = `Bearer ${accessToken}`
44
+ } else {
45
+ const session = await ensureFreshSession(fetchOptions.forceRefresh ? { force: true } : {})
46
+ headers.Authorization = `Bearer ${session.access_token}`
47
+ }
43
48
  }
44
- if (opts.body && !headers['Content-Type']) {
49
+ if (fetchOptions.body && !headers['Content-Type']) {
45
50
  headers['Content-Type'] = 'application/json'
46
51
  }
47
52
  const res = await fetch(url, {
48
- ...opts,
53
+ ...fetchOptions,
49
54
  headers,
50
55
  })
51
56
  const text = await res.text()
@@ -55,7 +60,7 @@ export async function apiFetch(path, opts = {}) {
55
60
  } catch {
56
61
  data = text
57
62
  }
58
- if (res.status === 401 && opts.auth !== false && !opts.retried) {
63
+ if (res.status === 401 && fetchOptions.auth !== false && !accessToken && !fetchOptions.retried) {
59
64
  return apiFetch(path, { ...opts, retried: true, forceRefresh: true })
60
65
  }
61
66
  if (!res.ok) {
@@ -0,0 +1,52 @@
1
+ import { getDomain, getPublicSuffix } from 'tldts'
2
+
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
4
+
5
+ /** URL construction mirrors shared/app-origins.ts; hosted bases come from the platform. */
6
+ export function hostedAppUrls(id, platformOrigin, env = process.env) {
7
+ if (!UUID.test(id)) throw new Error('Invalid app ID')
8
+ const platform = new URL(platformOrigin)
9
+ if (platform.username || platform.password || platform.pathname !== '/' || platform.search || platform.hash ||
10
+ !['http:', 'https:'].includes(platform.protocol)) throw new Error('Invalid platform origin')
11
+ const local = platform.hostname.endsWith('.localhost') || platform.hostname.endsWith('.test')
12
+ const runtimeBase = env.RUNTIME_BASE_DOMAIN || (local ? 'apps.localhost' : '')
13
+ const previewBase = env.PREVIEW_BASE_DOMAIN || (local ? 'preview.localhost' : '')
14
+ if (!runtimeBase || !previewBase || runtimeBase === previewBase) {
15
+ throw new Error('Runtime and preview base domains are not configured')
16
+ }
17
+ const validBase = (base) => base.includes('.') && base.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)) &&
18
+ getDomain(base, { allowPrivateDomains: true }) && base !== getPublicSuffix(base, { allowPrivateDomains: true })
19
+ if (!validBase(runtimeBase) || !validBase(previewBase) || runtimeBase.endsWith(`.${previewBase}`) || previewBase.endsWith(`.${runtimeBase}`)) {
20
+ throw new Error('Invalid runtime base domains')
21
+ }
22
+ const scheme = env.RUNTIME_SCHEME || (local ? 'http' : 'https')
23
+ const port = env.RUNTIME_PORT ?? (local ? platform.port : '')
24
+ if (!['http', 'https'].includes(scheme) || (port !== '' && (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535))) {
25
+ throw new Error('Invalid runtime origin configuration')
26
+ }
27
+ if (!local && (scheme !== 'https' || port ||
28
+ getDomain(runtimeBase, { allowPrivateDomains: true }) === getDomain(platform.hostname, { allowPrivateDomains: true }) ||
29
+ getDomain(previewBase, { allowPrivateDomains: true }) === getDomain(platform.hostname, { allowPrivateDomains: true }))) {
30
+ throw new Error('Production runtime origins must be HTTPS and separate from the platform domain')
31
+ }
32
+ const suffix = port ? `:${port}` : ''
33
+ return {
34
+ open: `${platform.origin}/a/${id}`,
35
+ preview: `${scheme}://${id}.${previewBase}${suffix}/${id}/draft`,
36
+ live: `${scheme}://${id}.${runtimeBase}${suffix}/${id}`,
37
+ }
38
+ }
39
+
40
+ export function urlsFromDiscovery(id, platformOrigin, discovery, overrides = {}) {
41
+ if (!discovery || typeof discovery !== 'object' ||
42
+ discovery.appOrigin !== new URL(platformOrigin).origin) {
43
+ throw new Error('Runtime origin discovery did not match the configured platform')
44
+ }
45
+ return hostedAppUrls(id, platformOrigin, {
46
+ RUNTIME_BASE_DOMAIN: overrides.RUNTIME_BASE_DOMAIN || discovery.runtimeBase,
47
+ PREVIEW_BASE_DOMAIN: overrides.PREVIEW_BASE_DOMAIN || discovery.previewBase,
48
+ RUNTIME_SCHEME: overrides.RUNTIME_SCHEME || discovery.runtimeScheme,
49
+ RUNTIME_PORT: overrides.RUNTIME_PORT ?? discovery.runtimePort,
50
+ })
51
+ }
52
+
@@ -0,0 +1,40 @@
1
+ import { test } from 'node:test'
2
+ import { strict as assert } from 'node:assert'
3
+ import { hostedAppUrls, urlsFromDiscovery } from './app-origins.mjs'
4
+
5
+ const id = '11111111-1111-4111-8111-111111111111'
6
+
7
+ test('CLI URLs use per-app production and preview hosts', () => {
8
+ assert.deepEqual(hostedAppUrls(id, 'http://amorphous.localhost:3849', {}), {
9
+ open: `http://amorphous.localhost:3849/a/${id}`,
10
+ preview: `http://${id}.preview.localhost:3849/${id}/draft`,
11
+ live: `http://${id}.apps.localhost:3849/${id}`,
12
+ })
13
+ })
14
+
15
+ test('CLI production URLs require configured isolated domains', () => {
16
+ assert.throws(() => hostedAppUrls(id, 'https://platform.example.org', {}), /not configured/)
17
+ })
18
+
19
+ test('hosted CLI derives URLs from validated server discovery without local configuration', () => {
20
+ assert.deepEqual(urlsFromDiscovery(id, 'https://platform.example.org', {
21
+ appOrigin: 'https://platform.example.org', runtimeBase: 'apps.example.net',
22
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
23
+ }), {
24
+ open: `https://platform.example.org/a/${id}`,
25
+ preview: `https://${id}.preview.example.net/${id}/draft`,
26
+ live: `https://${id}.apps.example.net/${id}`,
27
+ })
28
+ assert.throws(() => urlsFromDiscovery(id, 'https://platform.example.org', {
29
+ appOrigin: 'https://other.example.org', runtimeBase: 'apps.example.net',
30
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
31
+ }), /did not match/)
32
+ assert.throws(() => urlsFromDiscovery(id, 'https://platform.example.org', {
33
+ appOrigin: 'https://platform.example.org/path', runtimeBase: 'apps.example.net',
34
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
35
+ }), /did not match/)
36
+ assert.throws(() => urlsFromDiscovery(id, 'https://platform.example.org', {
37
+ appOrigin: 'https://platform.example.org', runtimeBase: 'apps.platform.example.org',
38
+ previewBase: 'preview.example.net', runtimeScheme: 'https', runtimePort: '',
39
+ }), /separate/)
40
+ })