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/README.md +4 -0
- package/lib/agent-contract.mjs +1 -1
- package/lib/agent-contract.test.mjs +15 -0
- package/lib/agent-guide-commands.test.mjs +429 -0
- package/lib/agent-guides-files.mjs +541 -0
- package/lib/agent-guides-files.test.mjs +367 -0
- package/lib/api.mjs +12 -7
- package/lib/app-origins.mjs +52 -0
- package/lib/app-origins.test.mjs +40 -0
- package/lib/commands.mjs +306 -29
- package/lib/files.mjs +10 -2
- package/lib/help.mjs +17 -5
- package/lib/participant-client.mjs +82 -0
- package/lib/participant-client.test.mjs +34 -0
- package/lib/pull.test.mjs +42 -0
- package/lib/recipe-create.test.mjs +37 -0
- package/lib/serve.mjs +152 -111
- package/lib/serve.test.mjs +180 -22
- package/package.json +5 -2
- package/participant-mcp.mjs +55 -0
- package/skill/amorphous/SKILL.md +6 -1
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync, closeSync, existsSync, fchmodSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync,
|
|
4
|
+
realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
|
|
5
|
+
} from 'node:fs'
|
|
6
|
+
import { homedir, tmpdir } from 'node:os'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
11
|
+
const BEGIN = '# BEGIN amorphous agent guides'
|
|
12
|
+
const END = '# END amorphous agent guides'
|
|
13
|
+
const GUIDE_MAX_BYTES = 32 * 1024
|
|
14
|
+
const FILES = ['agent.md', 'private-agent.md']
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* Filesystem contract for the context command:
|
|
18
|
+
* - guidePaths(root, abstractId, userId) returns active, identity-bound paths;
|
|
19
|
+
* - ensureGuideFilesIgnored(root) installs non-destructive ignore rules;
|
|
20
|
+
* - activateGuideIdentity(root, abstractId, userId) must run only after fresh
|
|
21
|
+
* server authorization and archives files from any prior identity;
|
|
22
|
+
* - readGuideSyncState/writeGuideSyncState store only revisions and body hashes;
|
|
23
|
+
* - materializeGuide({ projectRoot, abstractId, userId, kind, body, revision })
|
|
24
|
+
* writes unchanged-base remote content, leaves local edits intact on conflict,
|
|
25
|
+
* and returns { status, path, conflictPath? }. Handle conflict/identity-uncertain
|
|
26
|
+
* without loading the active file as newly authorized remote context.
|
|
27
|
+
* The active paths are agent.md and private-agent.md. Private sync state and all
|
|
28
|
+
* conflict/recovery/temp artifacts live under ignored .amorphous-guides/.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
function assertId(value, label) {
|
|
32
|
+
if (typeof value !== 'string' || !UUID.test(value)) throw new Error(`Invalid ${label}.`)
|
|
33
|
+
return value.toLowerCase()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectRootOf(projectRoot) {
|
|
37
|
+
if (typeof projectRoot !== 'string' || !projectRoot) throw new Error('Project root is required.')
|
|
38
|
+
const requested = resolve(projectRoot)
|
|
39
|
+
const info = lstatSync(requested)
|
|
40
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Project root must be a real directory, not a symlink.')
|
|
41
|
+
const actual = realpathSync(requested)
|
|
42
|
+
if (actual === parseRoot(actual) || actual === homedir()) throw new Error('Refusing to use a filesystem or home directory as the project root.')
|
|
43
|
+
return actual
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseRoot(path) {
|
|
47
|
+
return parse(path).root
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkedPath(root, ...parts) {
|
|
51
|
+
const path = resolve(root, ...parts)
|
|
52
|
+
const rel = relative(root, path)
|
|
53
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
54
|
+
if (rel === '') return path
|
|
55
|
+
throw new Error('Guide path escapes the project root.')
|
|
56
|
+
}
|
|
57
|
+
return path
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assertNoSymlink(path, { allowMissing = true } = {}) {
|
|
61
|
+
try {
|
|
62
|
+
if (lstatSync(path).isSymbolicLink()) throw new Error(`Symlinked guide path is not allowed: ${path}`)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code === 'ENOENT' && allowMissing) return
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertNoSymlinkChain(path) {
|
|
70
|
+
const absolute = resolve(path)
|
|
71
|
+
const root = parseRoot(absolute)
|
|
72
|
+
let current = root
|
|
73
|
+
for (const part of relative(root, absolute).split(sep).filter(Boolean)) {
|
|
74
|
+
current = join(current, part)
|
|
75
|
+
assertNoSymlink(current)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ensureDirectory(root, path, mode = 0o700) {
|
|
80
|
+
const rel = relative(root, path)
|
|
81
|
+
let current = root
|
|
82
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
83
|
+
current = join(current, part)
|
|
84
|
+
assertNoSymlink(current)
|
|
85
|
+
try { mkdirSync(current, { mode }) } catch (error) { if (error?.code !== 'EEXIST') throw error }
|
|
86
|
+
const info = lstatSync(current)
|
|
87
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Unsafe guide directory: ${current}`)
|
|
88
|
+
try { chmodSync(current, mode) } catch { /* preserve operation result; target remains private by file mode */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureIgnoreParent(root, path) {
|
|
93
|
+
const parent = dirname(path)
|
|
94
|
+
const rel = relative(root, parent)
|
|
95
|
+
let current = root
|
|
96
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
97
|
+
current = join(current, part)
|
|
98
|
+
assertNoSymlink(current)
|
|
99
|
+
try { mkdirSync(current) } catch (error) { if (error?.code !== 'EEXIST') throw error }
|
|
100
|
+
const info = lstatSync(current)
|
|
101
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`Unsafe guide ignore directory: ${current}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sha256(value) {
|
|
106
|
+
return createHash('sha256').update(value).digest('hex')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function git(root, args, options = {}) {
|
|
110
|
+
return spawnSync('git', args, { cwd: root, encoding: 'utf8', ...options })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function assertArtifactPathsSafeToWrite(root, absolutePaths) {
|
|
114
|
+
const paths = absolutePaths.map((path) => {
|
|
115
|
+
const rel = relative(root, resolve(path)).split(sep).join('/')
|
|
116
|
+
if (!rel || rel === '..' || rel.startsWith('../')) throw new Error('Guide artifact path escapes project root.')
|
|
117
|
+
return rel
|
|
118
|
+
})
|
|
119
|
+
const repo = git(root, ['rev-parse', '--show-toplevel'])
|
|
120
|
+
if (repo.error) throw new Error('Cannot verify guide artifact ignore/tracking status; refusing to write guide context.')
|
|
121
|
+
if (repo.status === 0) {
|
|
122
|
+
const gitRoot = realpathSync(repo.stdout.trim())
|
|
123
|
+
for (const path of paths) {
|
|
124
|
+
const repoPath = relative(gitRoot, resolve(root, path)).split(sep).join('/')
|
|
125
|
+
const tracked = git(gitRoot, ['ls-files', '--', repoPath])
|
|
126
|
+
const ignored = git(gitRoot, ['check-ignore', '--quiet', '--', repoPath])
|
|
127
|
+
if (tracked.status !== 0 || tracked.stdout.trim() || ignored.status !== 0) {
|
|
128
|
+
throw new Error('Guide artifact path is tracked or not effectively ignored; refusing to write guide context.')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (repo.status !== 128) throw new Error('Cannot verify guide artifact ignore status; refusing to write guide context.')
|
|
134
|
+
|
|
135
|
+
// A disposable repo can model the root .gitignore but not assume that no
|
|
136
|
+
// deeper .gitignore changes the rules. Fail closed for artifacts beneath
|
|
137
|
+
// any nested ignore file rather than materializing content under uncertain
|
|
138
|
+
// effective rules.
|
|
139
|
+
for (const path of paths) {
|
|
140
|
+
let directory = dirname(resolve(root, path))
|
|
141
|
+
while (directory !== root) {
|
|
142
|
+
const nestedIgnore = join(directory, '.gitignore')
|
|
143
|
+
assertNoSymlinkChain(nestedIgnore)
|
|
144
|
+
try {
|
|
145
|
+
const info = lstatSync(nestedIgnore)
|
|
146
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
147
|
+
throw new Error('Nested guide ignore path is unsafe; refusing to write guide context.')
|
|
148
|
+
}
|
|
149
|
+
throw new Error('Nested guide ignore rules cannot be proven safe outside Git; refusing to write guide context.')
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error?.code !== 'ENOENT') throw error
|
|
152
|
+
}
|
|
153
|
+
directory = dirname(directory)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const temp = mkdtempSync(join(tmpdir(), 'am-guide-artifact-check-'))
|
|
158
|
+
try {
|
|
159
|
+
const init = git(temp, ['init', '--quiet'])
|
|
160
|
+
if (init.status !== 0) throw new Error('Cannot verify guide artifact ignore status; refusing to write guide context.')
|
|
161
|
+
let ignoreText = ''
|
|
162
|
+
try { ignoreText = readFileSync(join(root, '.gitignore'), 'utf8') } catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
163
|
+
writeFileSync(join(temp, '.gitignore'), ignoreText)
|
|
164
|
+
for (const path of paths) {
|
|
165
|
+
const ignored = git(temp, ['check-ignore', '--quiet', '--', path])
|
|
166
|
+
if (ignored.status !== 0) throw new Error('Guide artifact path is not effectively ignored; refusing to write guide context.')
|
|
167
|
+
}
|
|
168
|
+
} finally { rmSync(temp, { recursive: true, force: true }) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertGuidePathsSafeToWrite(root) {
|
|
172
|
+
const repo = git(root, ['rev-parse', '--show-toplevel'])
|
|
173
|
+
if (repo.error) throw new Error('Cannot verify private guide ignore and tracking status; refusing to write private context.')
|
|
174
|
+
if (repo.status === 0) {
|
|
175
|
+
const gitRoot = realpathSync(repo.stdout.trim())
|
|
176
|
+
const trackedPaths = ['agent.md', 'private-agent.md', '.amorphous-guides']
|
|
177
|
+
.map((path) => relative(gitRoot, resolve(root, path)).split(sep).join('/'))
|
|
178
|
+
for (const path of trackedPaths) {
|
|
179
|
+
const tracked = git(gitRoot, ['ls-files', '--', path])
|
|
180
|
+
if (tracked.status !== 0 || tracked.stdout.trim()) {
|
|
181
|
+
throw new Error('Agent guide paths are tracked or tracking status is unknown; refusing to write guide context.')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const ignoredPaths = ['agent.md', 'private-agent.md', '.amorphous-guides/private-check']
|
|
185
|
+
.map((path) => relative(gitRoot, resolve(root, path)).split(sep).join('/'))
|
|
186
|
+
for (const path of ignoredPaths) {
|
|
187
|
+
const ignored = git(gitRoot, ['check-ignore', '--quiet', '--', path])
|
|
188
|
+
if (ignored.status !== 0) {
|
|
189
|
+
throw new Error('Agent guide paths are not effectively ignored; refusing to write guide context.')
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (repo.status !== 128) throw new Error('Cannot verify guide ignore status; refusing to write guide context.')
|
|
195
|
+
|
|
196
|
+
// Outside Git, check the workspace's .gitignore in a disposable real repo so
|
|
197
|
+
// negations and directory semantics are evaluated by Git itself.
|
|
198
|
+
const temp = mkdtempSync(join(tmpdir(), 'am-guide-ignore-check-'))
|
|
199
|
+
try {
|
|
200
|
+
const init = git(temp, ['init', '--quiet'])
|
|
201
|
+
if (init.status !== 0) throw new Error('Cannot verify guide ignore status; refusing to write guide context.')
|
|
202
|
+
let ignoreText = ''
|
|
203
|
+
try { ignoreText = readFileSync(join(root, '.gitignore'), 'utf8') } catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
204
|
+
writeFileSync(join(temp, '.gitignore'), ignoreText)
|
|
205
|
+
for (const path of ['agent.md', 'private-agent.md', '.amorphous-guides/private-check']) {
|
|
206
|
+
const ignored = git(temp, ['check-ignore', '--quiet', '--', path])
|
|
207
|
+
if (ignored.status !== 0) throw new Error('Agent guide paths are not effectively ignored; refusing to write guide context.')
|
|
208
|
+
}
|
|
209
|
+
} finally { rmSync(temp, { recursive: true, force: true }) }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function atomicWrite(path, data, mode = 0o600, safetyRoot = null) {
|
|
213
|
+
const parent = dirname(path)
|
|
214
|
+
assertNoSymlink(parent, { allowMissing: false })
|
|
215
|
+
assertNoSymlink(path)
|
|
216
|
+
const temp = join(parent, `.amorphous-guide-${process.pid}-${randomUUID()}.tmp`)
|
|
217
|
+
if (safetyRoot) assertArtifactPathsSafeToWrite(safetyRoot, [path, temp])
|
|
218
|
+
let fd
|
|
219
|
+
try {
|
|
220
|
+
fd = openSync(temp, 'wx', mode)
|
|
221
|
+
writeFileSync(fd, data)
|
|
222
|
+
fchmodSync(fd, mode)
|
|
223
|
+
closeSync(fd)
|
|
224
|
+
fd = undefined
|
|
225
|
+
assertNoSymlink(path)
|
|
226
|
+
renameSync(temp, path)
|
|
227
|
+
chmodSync(path, mode)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (fd !== undefined) closeSync(fd)
|
|
230
|
+
try { unlinkSync(temp) } catch { /* no temporary file remains */ }
|
|
231
|
+
throw error
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readJsonFile(path) {
|
|
236
|
+
assertNoSymlinkChain(dirname(path))
|
|
237
|
+
assertNoSymlink(path)
|
|
238
|
+
try {
|
|
239
|
+
const info = lstatSync(path)
|
|
240
|
+
if (!info.isFile()) throw new Error(`Expected a regular guide metadata file: ${path}`)
|
|
241
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error?.code === 'ENOENT') return null
|
|
244
|
+
throw error
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validRevisionEntry(entry) {
|
|
249
|
+
return entry && typeof entry === 'object' && !Array.isArray(entry) &&
|
|
250
|
+
Number.isSafeInteger(entry.revision) && entry.revision >= 0 &&
|
|
251
|
+
typeof entry.hash === 'string' && /^[a-f0-9]{64}$/.test(entry.hash)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function validateState(value, abstractId, userId) {
|
|
255
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== 1 ||
|
|
256
|
+
value.abstractId !== abstractId || value.userId !== userId || !value.guides ||
|
|
257
|
+
typeof value.guides !== 'object' || Array.isArray(value.guides) ||
|
|
258
|
+
Object.keys(value).some((key) => !['schemaVersion', 'abstractId', 'userId', 'guides'].includes(key))) {
|
|
259
|
+
throw new Error('Invalid guide sync state identity or format.')
|
|
260
|
+
}
|
|
261
|
+
for (const key of Object.keys(value.guides)) {
|
|
262
|
+
if (!['abstract', 'private'].includes(key) || !validRevisionEntry(value.guides[key]) ||
|
|
263
|
+
Object.keys(value.guides[key]).some((entryKey) => !['revision', 'hash'].includes(entryKey))) {
|
|
264
|
+
throw new Error('Invalid guide sync state metadata.')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return value
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Return validated local paths for one abstract and authenticated user. */
|
|
271
|
+
export function guidePaths(projectRoot, abstractId, userId) {
|
|
272
|
+
const root = projectRootOf(projectRoot)
|
|
273
|
+
const app = assertId(abstractId, 'abstract id')
|
|
274
|
+
const user = assertId(userId, 'user id')
|
|
275
|
+
const internal = (...parts) => checkedPath(root, '.amorphous-guides', ...parts)
|
|
276
|
+
return {
|
|
277
|
+
projectRoot: root,
|
|
278
|
+
abstractId: app,
|
|
279
|
+
userId: user,
|
|
280
|
+
abstractGuide: checkedPath(root, 'agent.md'),
|
|
281
|
+
privateGuide: checkedPath(root, 'private-agent.md'),
|
|
282
|
+
activeIdentity: internal('active.json'),
|
|
283
|
+
syncState: internal('state', app, user, 'sync.json'),
|
|
284
|
+
conflictsDirectory: internal('conflicts', app, user),
|
|
285
|
+
recoveryDirectory: internal('recovery', app, user),
|
|
286
|
+
tempDirectory: internal('tmp'),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** SHA-256 of the exact UTF-8 guide body, for state comparisons only. */
|
|
291
|
+
export function guideBodyHash(body) {
|
|
292
|
+
if (typeof body !== 'string') throw new Error('Guide body must be text.')
|
|
293
|
+
return sha256(body)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function gitIgnoreTarget(root) {
|
|
297
|
+
let current = root
|
|
298
|
+
for (;;) {
|
|
299
|
+
const dotGit = join(current, '.git')
|
|
300
|
+
assertNoSymlink(dotGit)
|
|
301
|
+
try {
|
|
302
|
+
const info = lstatSync(dotGit)
|
|
303
|
+
if (info.isDirectory()) return { path: join(dotGit, 'info', 'exclude'), gitRoot: current }
|
|
304
|
+
if (info.isFile()) {
|
|
305
|
+
const text = readFileSync(dotGit, 'utf8').trim()
|
|
306
|
+
const match = /^gitdir:\s*(.+)$/.exec(text)
|
|
307
|
+
if (match) {
|
|
308
|
+
const gitDir = resolve(current, match[1])
|
|
309
|
+
assertNoSymlinkChain(gitDir)
|
|
310
|
+
if (!lstatSync(gitDir).isDirectory()) throw new Error('Invalid Git directory for guide ignores.')
|
|
311
|
+
return { path: join(gitDir, 'info', 'exclude'), gitRoot: current, worktree: true }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
315
|
+
const parent = dirname(current)
|
|
316
|
+
if (parent === current) return null
|
|
317
|
+
current = parent
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ignoreBlock(prefix) {
|
|
322
|
+
const encodedPrefix = prefix ? prefix.split('/').map((part) => part
|
|
323
|
+
.replace(/([\\*?\[\]])/g, '\\$1')
|
|
324
|
+
.replace(/ /g, '\\ ')
|
|
325
|
+
.replace(/^([#!])/, '\\$1')).join('/') : ''
|
|
326
|
+
const base = encodedPrefix ? `/${encodedPrefix}/` : '/'
|
|
327
|
+
return `${BEGIN}\n${base}agent.md\n${base}private-agent.md\n${base}.amorphous-guide-*.tmp\n${base}.amorphous-guides/\n${END}`
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function managedBlock(text, block) {
|
|
331
|
+
const begin = text.indexOf(BEGIN)
|
|
332
|
+
const end = text.indexOf(END)
|
|
333
|
+
if ((begin < 0) !== (end < 0) || (begin >= 0 && end < begin)) throw new Error('Malformed managed guide ignore block.')
|
|
334
|
+
if (begin < 0) return `${text}${text && !text.endsWith('\n') ? '\n' : ''}${block}\n`
|
|
335
|
+
const endAfter = end + END.length
|
|
336
|
+
if (text.indexOf(BEGIN, begin + BEGIN.length) >= 0 || text.indexOf(END, endAfter) >= 0) {
|
|
337
|
+
throw new Error('Duplicate managed guide ignore block.')
|
|
338
|
+
}
|
|
339
|
+
const existingEntries = text.slice(begin + BEGIN.length, end).split('\n').map((line) => line.trim()).filter(Boolean)
|
|
340
|
+
const proposedEntries = block.split('\n').slice(1, -1)
|
|
341
|
+
const entries = [...new Set([...existingEntries, ...proposedEntries])]
|
|
342
|
+
return `${text.slice(0, begin)}${[BEGIN, ...entries, END].join('\n')}${text.slice(endAfter)}`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Add private guide/cache ignores without replacing user-authored ignore rules. */
|
|
346
|
+
export function ensureGuideFilesIgnored(projectRoot) {
|
|
347
|
+
const root = projectRootOf(projectRoot)
|
|
348
|
+
const git = gitIgnoreTarget(root)
|
|
349
|
+
// A real .git directory has repo-root-relative excludes. A worktree pointer
|
|
350
|
+
// uses a workspace-local .gitignore fallback so its paths remain scoped.
|
|
351
|
+
const path = git && !git.worktree ? git.path : checkedPath(root, '.gitignore')
|
|
352
|
+
const ignoreRoot = git && !git.worktree ? git.gitRoot : root
|
|
353
|
+
const prefix = relative(ignoreRoot, root).split(sep).filter(Boolean).join('/')
|
|
354
|
+
assertNoSymlinkChain(dirname(path))
|
|
355
|
+
if (relative(root, dirname(path)).startsWith(`..${sep}`)) {
|
|
356
|
+
if (!existsSync(dirname(path))) throw new Error('Git ignore directory is unavailable.')
|
|
357
|
+
} else {
|
|
358
|
+
ensureIgnoreParent(root, path)
|
|
359
|
+
}
|
|
360
|
+
assertNoSymlink(path)
|
|
361
|
+
let current = ''
|
|
362
|
+
try {
|
|
363
|
+
const info = lstatSync(path)
|
|
364
|
+
if (!info.isFile()) throw new Error('Guide ignore target must be a regular file.')
|
|
365
|
+
current = readFileSync(path, 'utf8')
|
|
366
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
367
|
+
const next = managedBlock(current, ignoreBlock(prefix))
|
|
368
|
+
if (next !== current) {
|
|
369
|
+
let mode = 0o644
|
|
370
|
+
try { mode = lstatSync(path).mode & 0o777 } catch { /* use normal ignore-file permissions */ }
|
|
371
|
+
atomicWrite(path, next, mode)
|
|
372
|
+
}
|
|
373
|
+
return path
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Load metadata only when its embedded abstract/user identity matches the requested key. */
|
|
377
|
+
export function readGuideSyncState(projectRoot, abstractId, userId) {
|
|
378
|
+
const paths = guidePaths(projectRoot, abstractId, userId)
|
|
379
|
+
let value
|
|
380
|
+
try { value = readJsonFile(paths.syncState) } catch (error) {
|
|
381
|
+
if (error instanceof SyntaxError) throw new Error('Invalid guide sync state JSON.')
|
|
382
|
+
throw error
|
|
383
|
+
}
|
|
384
|
+
if (value === null) return null
|
|
385
|
+
return validateState(value, paths.abstractId, paths.userId)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Persist only revision/hash metadata under a path keyed by abstract and authenticated user. */
|
|
389
|
+
export function writeGuideSyncState(projectRoot, abstractId, userId, guides) {
|
|
390
|
+
const paths = guidePaths(projectRoot, abstractId, userId)
|
|
391
|
+
ensureGuideFilesIgnored(paths.projectRoot)
|
|
392
|
+
assertGuidePathsSafeToWrite(paths.projectRoot)
|
|
393
|
+
if (!guides || typeof guides !== 'object' || Array.isArray(guides)) throw new Error('Guide state is required.')
|
|
394
|
+
const clean = {}
|
|
395
|
+
for (const [key, value] of Object.entries(guides)) {
|
|
396
|
+
if (!['abstract', 'private'].includes(key) || !validRevisionEntry(value)) throw new Error('Invalid guide revision metadata.')
|
|
397
|
+
clean[key] = { revision: value.revision, hash: value.hash }
|
|
398
|
+
}
|
|
399
|
+
const state = { schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId, guides: clean }
|
|
400
|
+
validateState(state, paths.abstractId, paths.userId)
|
|
401
|
+
ensureDirectory(paths.projectRoot, dirname(paths.syncState))
|
|
402
|
+
const existing = readJsonFile(paths.syncState)
|
|
403
|
+
if (existing && JSON.stringify(validateState(existing, paths.abstractId, paths.userId)) === JSON.stringify(state)) return paths.syncState
|
|
404
|
+
atomicWrite(paths.syncState, `${JSON.stringify(state, null, 2)}\n`, 0o600, paths.projectRoot)
|
|
405
|
+
return paths.syncState
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function activeIdentity(paths) {
|
|
409
|
+
let value
|
|
410
|
+
try { value = readJsonFile(paths.activeIdentity) } catch (error) {
|
|
411
|
+
if (error instanceof SyntaxError) return { value: null, malformed: true }
|
|
412
|
+
throw error
|
|
413
|
+
}
|
|
414
|
+
if (value === null) return { value: null, malformed: false }
|
|
415
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== 1 ||
|
|
416
|
+
!UUID.test(value.abstractId || '') || !UUID.test(value.userId || '')) return { value: null, malformed: true }
|
|
417
|
+
return { value: { abstractId: value.abstractId.toLowerCase(), userId: value.userId.toLowerCase() }, malformed: false }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function validateActiveGuidePaths(paths) {
|
|
421
|
+
for (const path of [paths.abstractGuide, paths.privateGuide]) {
|
|
422
|
+
assertNoSymlinkChain(dirname(path))
|
|
423
|
+
assertNoSymlink(path)
|
|
424
|
+
try {
|
|
425
|
+
if (!lstatSync(path).isFile()) throw new Error(`Active guide must be a regular file: ${path}`)
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error?.code !== 'ENOENT') throw error
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function recoverActiveFile(root, activePath, recoveryDir, filename) {
|
|
433
|
+
assertNoSymlinkChain(dirname(activePath))
|
|
434
|
+
assertNoSymlink(activePath)
|
|
435
|
+
let info
|
|
436
|
+
try { info = lstatSync(activePath) } catch (error) { if (error?.code === 'ENOENT') return null; throw error }
|
|
437
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`Unsafe active guide file: ${activePath}`)
|
|
438
|
+
ensureDirectory(root, recoveryDir)
|
|
439
|
+
const name = `${Date.now()}-${randomUUID()}-${filename}`
|
|
440
|
+
const destination = checkedPath(root, relative(root, recoveryDir), name)
|
|
441
|
+
const temp = join(dirname(destination), `.amorphous-guide-${process.pid}-recovery-preflight.tmp`)
|
|
442
|
+
assertArtifactPathsSafeToWrite(root, [destination, temp])
|
|
443
|
+
const bytes = readFileSync(activePath)
|
|
444
|
+
atomicWrite(destination, bytes, 0o600, root)
|
|
445
|
+
unlinkSync(activePath)
|
|
446
|
+
return destination
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Bind active local guide files to the authenticated (abstract,user) pair.
|
|
451
|
+
* Call only after fresh server authorization. On mismatch, old files are moved
|
|
452
|
+
* to ignored identity-specific recovery paths and never remain active.
|
|
453
|
+
*/
|
|
454
|
+
export function activateGuideIdentity(projectRoot, abstractId, userId) {
|
|
455
|
+
const paths = guidePaths(projectRoot, abstractId, userId)
|
|
456
|
+
validateActiveGuidePaths(paths)
|
|
457
|
+
ensureGuideFilesIgnored(paths.projectRoot)
|
|
458
|
+
assertGuidePathsSafeToWrite(paths.projectRoot)
|
|
459
|
+
const active = activeIdentity(paths)
|
|
460
|
+
const same = active.value?.abstractId === paths.abstractId && active.value?.userId === paths.userId
|
|
461
|
+
if (same) return { status: 'active', privateSafeToLoad: true, recovered: [] }
|
|
462
|
+
if (!active.value && !active.malformed && !existsSync(paths.abstractGuide) && !existsSync(paths.privateGuide)) {
|
|
463
|
+
ensureDirectory(paths.projectRoot, dirname(paths.activeIdentity))
|
|
464
|
+
atomicWrite(paths.activeIdentity, `${JSON.stringify({ schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId })}\n`, 0o600, paths.projectRoot)
|
|
465
|
+
return { status: 'active', privateSafeToLoad: true, recovered: [] }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let recoveryDirectory
|
|
469
|
+
if (active.value) {
|
|
470
|
+
const prior = guidePaths(paths.projectRoot, active.value.abstractId, active.value.userId)
|
|
471
|
+
recoveryDirectory = prior.recoveryDirectory
|
|
472
|
+
} else {
|
|
473
|
+
let opaque = 'unbound'
|
|
474
|
+
try { opaque = sha256(readFileSync(paths.activeIdentity)).slice(0, 16) } catch {
|
|
475
|
+
const bytes = FILES.map((file) => {
|
|
476
|
+
try { return readFileSync(checkedPath(paths.projectRoot, file)) } catch { return Buffer.alloc(0) }
|
|
477
|
+
})
|
|
478
|
+
opaque = sha256(Buffer.concat(bytes)).slice(0, 16)
|
|
479
|
+
}
|
|
480
|
+
recoveryDirectory = checkedPath(paths.projectRoot, '.amorphous-guides', 'recovery', paths.abstractId, `unknown-${opaque}`)
|
|
481
|
+
}
|
|
482
|
+
const recovered = []
|
|
483
|
+
for (const filename of FILES) {
|
|
484
|
+
const found = recoverActiveFile(paths.projectRoot, checkedPath(paths.projectRoot, filename), recoveryDirectory, filename)
|
|
485
|
+
if (found) recovered.push(found)
|
|
486
|
+
}
|
|
487
|
+
ensureDirectory(paths.projectRoot, dirname(paths.activeIdentity))
|
|
488
|
+
atomicWrite(paths.activeIdentity, `${JSON.stringify({ schemaVersion: 1, abstractId: paths.abstractId, userId: paths.userId })}\n`, 0o600, paths.projectRoot)
|
|
489
|
+
return {
|
|
490
|
+
status: active.malformed || !active.value ? 'recovered-unknown-identity' : 'switched',
|
|
491
|
+
privateSafeToLoad: Boolean(active.value) && !active.malformed,
|
|
492
|
+
recovered,
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Materialize one already-authorized guide revision. Remote changes never
|
|
498
|
+
* replace a local edit: the remote body goes to an ignored conflict file.
|
|
499
|
+
*/
|
|
500
|
+
export function materializeGuide({ projectRoot, abstractId, userId, kind, body, revision }) {
|
|
501
|
+
if (!['abstract', 'private'].includes(kind)) throw new Error('Guide kind must be abstract or private.')
|
|
502
|
+
if (typeof body !== 'string' || !Number.isSafeInteger(revision) || revision < 0) throw new Error('Invalid guide document.')
|
|
503
|
+
if (Buffer.byteLength(body, 'utf8') > GUIDE_MAX_BYTES) throw new Error('Guide exceeds the 32 KiB UTF-8 limit.')
|
|
504
|
+
const paths = guidePaths(projectRoot, abstractId, userId)
|
|
505
|
+
const identity = activateGuideIdentity(paths.projectRoot, paths.abstractId, paths.userId)
|
|
506
|
+
if (!identity.privateSafeToLoad) return { status: 'identity-uncertain', recovered: identity.recovered }
|
|
507
|
+
const path = kind === 'abstract' ? paths.abstractGuide : paths.privateGuide
|
|
508
|
+
assertNoSymlinkChain(dirname(path))
|
|
509
|
+
const hash = sha256(body)
|
|
510
|
+
const state = readGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId)
|
|
511
|
+
const base = state?.guides?.[kind]
|
|
512
|
+
assertNoSymlink(path)
|
|
513
|
+
let local = null
|
|
514
|
+
try {
|
|
515
|
+
const info = lstatSync(path)
|
|
516
|
+
if (!info.isFile()) throw new Error(`Guide target must be a regular file: ${path}`)
|
|
517
|
+
local = readFileSync(path, 'utf8')
|
|
518
|
+
} catch (error) { if (error?.code !== 'ENOENT') throw error }
|
|
519
|
+
|
|
520
|
+
if (local === body) {
|
|
521
|
+
const guides = { ...(state?.guides ?? {}), [kind]: { revision, hash } }
|
|
522
|
+
writeGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId, guides)
|
|
523
|
+
return { status: 'unchanged', path }
|
|
524
|
+
}
|
|
525
|
+
if (local !== null && base && sha256(local) !== base.hash && hash === base.hash) {
|
|
526
|
+
const guides = { ...(state?.guides ?? {}), [kind]: { revision, hash } }
|
|
527
|
+
writeGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId, guides)
|
|
528
|
+
return { status: 'local-only', path, localHash: sha256(local) }
|
|
529
|
+
}
|
|
530
|
+
if (local !== null && (!base || sha256(local) !== base.hash)) {
|
|
531
|
+
ensureDirectory(paths.projectRoot, paths.conflictsDirectory)
|
|
532
|
+
const conflictPath = checkedPath(paths.projectRoot, relative(paths.projectRoot, paths.conflictsDirectory), `${kind}-remote-r${revision}-${randomUUID()}.md`)
|
|
533
|
+
atomicWrite(conflictPath, body, 0o600, paths.projectRoot)
|
|
534
|
+
return { status: 'conflict', path, conflictPath }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
atomicWrite(path, body, 0o600, paths.projectRoot)
|
|
538
|
+
const guides = { ...(state?.guides ?? {}), [kind]: { revision, hash } }
|
|
539
|
+
writeGuideSyncState(paths.projectRoot, paths.abstractId, paths.userId, guides)
|
|
540
|
+
return { status: 'written', path }
|
|
541
|
+
}
|