mango-cms 0.3.34 → 0.3.35

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.
Files changed (32) hide show
  1. package/cli.js +57 -0
  2. package/default/infra/vibe/README.md +43 -0
  3. package/default/infra/vibe/cloudflare.ini.template +26 -0
  4. package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
  5. package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
  6. package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
  7. package/default/infra/vibe/vibe-gateway.service +38 -0
  8. package/default/infra/vibe/vibe-orchestrator.service +44 -0
  9. package/default/infra/vibe/vibe.env.template +24 -0
  10. package/default/mango/config/settings.json +40 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/vibe-orchestrator/README.md +76 -0
  13. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  14. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  15. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  16. package/lib/vibe-orchestrator/server.js +344 -0
  17. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  18. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  19. package/lib/vibe-orchestrator/src/config.js +227 -0
  20. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  21. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  22. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  23. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  24. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  25. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  26. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  27. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  28. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  29. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  30. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  31. package/package.json +1 -1
  32. package/readme.md +6 -0
@@ -0,0 +1,376 @@
1
+ // publisher.js
2
+ //
3
+ // Staging → production publish via the EXISTING pipeline only (CEO Decision 3,
4
+ // HAP-1096 / HAP-1103). No parallel deploy: we commit on the staging clone and
5
+ // `git push` the deploy branch, which triggers `.github/workflows/ssh-deploy.yml`
6
+ // → `mango/helpers/update.sh` on the prod droplet.
7
+ //
8
+ // Hard constraint: the env-specific `mango/config/settings.json` is NEVER
9
+ // pushed. The staging overlay (`mango/helpers/staging.mjs up`) materializes the
10
+ // staging block (ports/db/domains) into that file, so before committing we run
11
+ // the overlay `down` semantics — `git checkout -- <settings>` — restoring the
12
+ // push-safe prod tree so prod config is never clobbered.
13
+ //
14
+ // Two operations, mirroring the gated UI:
15
+ // diff(site) — pure, read-only: what *would* be published (settings excluded)
16
+ // publish(site) — restore settings → add → commit → push; requires confirm:true
17
+
18
+ import { execFile } from 'node:child_process'
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+ import { resolveSiteDir, config } from './config.js'
23
+
24
+ /** Promisified `git` invocation scoped to a working dir. Never throws on a
25
+ * non-zero exit unless `mustSucceed` — returns { code, stdout, stderr } so the
26
+ * caller can branch (e.g. "nothing to commit"). */
27
+ function git(cwd, args, { mustSucceed = false } = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
30
+ const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0
31
+ if (err && mustSucceed) {
32
+ const e = new Error(`git ${args[0]} failed: ${(stderr || err.message || '').trim()}`)
33
+ e.code = code
34
+ e.stderr = stderr
35
+ return reject(e)
36
+ }
37
+ resolve({ code, stdout: stdout || '', stderr: stderr || '' })
38
+ })
39
+ })
40
+ }
41
+
42
+ const SETTINGS_PATH = config.settingsPath
43
+ // Exclude pathspec keeps env-specific settings out of every diff/commit.
44
+ const EXCLUDE_SETTINGS = `:(exclude)${SETTINGS_PATH}`
45
+
46
+ /** Parse `git status --porcelain=v1 -z` into { path, status, untracked } rows. */
47
+ function parsePorcelain(out) {
48
+ const rows = []
49
+ // -z separates entries with NUL; rename entries carry an extra NUL field.
50
+ const parts = out.split('\0')
51
+ for (let i = 0; i < parts.length; i++) {
52
+ const entry = parts[i]
53
+ if (!entry) continue
54
+ const xy = entry.slice(0, 2)
55
+ const path = entry.slice(3)
56
+ // Renames/copies (R/C) consume the following NUL field (the new path).
57
+ if (xy[0] === 'R' || xy[0] === 'C') i++
58
+ rows.push({ status: xy, path, untracked: xy === '??' })
59
+ }
60
+ return rows
61
+ }
62
+
63
+ /** Current branch name (or 'HEAD' when detached). */
64
+ async function currentBranch(cwd) {
65
+ const { stdout } = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], { mustSucceed: true })
66
+ return stdout.trim()
67
+ }
68
+
69
+ /** ahead/behind counts vs the upstream deploy ref; nulls when no upstream. */
70
+ async function aheadBehind(cwd, remote, branch) {
71
+ const ref = `${remote}/${branch}`
72
+ const { code, stdout } = await git(cwd, ['rev-list', '--left-right', '--count', `${ref}...HEAD`])
73
+ if (code !== 0) return { ahead: null, behind: null, upstream: ref, hasUpstream: false }
74
+ const [behind, ahead] = stdout.trim().split(/\s+/).map((n) => Number.parseInt(n, 10))
75
+ return { ahead: ahead || 0, behind: behind || 0, upstream: ref, hasUpstream: true }
76
+ }
77
+
78
+ /**
79
+ * Read-only: compute exactly what a publish would push. Excludes settings.json.
80
+ *
81
+ * @returns {Promise<{
82
+ * site, dir, branch, remote, upstream, hasUpstream, ahead, behind,
83
+ * files: {path,status,untracked}[], settingsDirty, settingsExcluded,
84
+ * diff, diffTruncated, publishable, reason
85
+ * }>}
86
+ */
87
+ async function diff(site, opts = {}) {
88
+ const dir = resolveSiteDir(site)
89
+ const remote = opts.remote || config.publishRemote
90
+ const branch = await currentBranch(dir)
91
+ const { ahead, behind, upstream, hasUpstream } = await aheadBehind(dir, remote, branch)
92
+
93
+ const status = await git(dir, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { mustSucceed: true })
94
+ const allRows = parsePorcelain(status.stdout)
95
+ const settingsDirty = allRows.some((r) => r.path === SETTINGS_PATH)
96
+ const files = allRows.filter((r) => r.path !== SETTINGS_PATH)
97
+
98
+ // Tracked-change diff (working tree vs HEAD), settings excluded.
99
+ const tracked = await git(dir, ['-c', 'core.quotepath=false', 'diff', 'HEAD', '--', '.', EXCLUDE_SETTINGS])
100
+ let diffText = tracked.stdout
101
+ // Surface untracked files explicitly — `git diff HEAD` omits them.
102
+ const untracked = files.filter((f) => f.untracked).map((f) => f.path)
103
+ if (untracked.length) {
104
+ diffText += `\n${untracked.map((p) => `?? new file: ${p}`).join('\n')}\n`
105
+ }
106
+
107
+ const MAX = 200 * 1024
108
+ let diffTruncated = false
109
+ if (diffText.length > MAX) {
110
+ diffText = diffText.slice(0, MAX)
111
+ diffTruncated = true
112
+ }
113
+
114
+ const publishable = files.length > 0 || (hasUpstream && ahead > 0)
115
+ let reason = ''
116
+ if (!publishable) {
117
+ reason = hasUpstream
118
+ ? 'Working tree is clean and nothing is ahead of production — nothing to publish.'
119
+ : 'Working tree is clean (no upstream to compare commits against).'
120
+ }
121
+
122
+ return {
123
+ site,
124
+ dir,
125
+ branch,
126
+ remote,
127
+ upstream,
128
+ hasUpstream,
129
+ ahead,
130
+ behind,
131
+ files,
132
+ settingsDirty,
133
+ settingsExcluded: settingsDirty, // we exclude it whenever it's dirty
134
+ settingsPath: SETTINGS_PATH,
135
+ diff: diffText,
136
+ diffTruncated,
137
+ publishable,
138
+ reason,
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Merge a workspace branch into the deploy branch (e.g. main) in a THROWAWAY
144
+ * worktree and push it — this is what actually triggers ssh-deploy → prod
145
+ * (HAP-1174). The primary worktree (`dir`) stays on `<branch>` and is never
146
+ * touched; the deploy branch may be checked out in another worktree, which is
147
+ * why we use a detached throwaway worktree rather than checking main out here.
148
+ *
149
+ * Hard rules:
150
+ * - `settings.json` is NEVER carried into the deploy branch — after the merge we
151
+ * restore the deploy branch's own settings and amend, so prod config is intact.
152
+ * - A conflict aborts cleanly and throws 409 with the file list; `<publishBranch>`
153
+ * on the remote is never pushed/corrupted.
154
+ *
155
+ * @returns {Promise<{merged, alreadyUpToDate, mergeSha, deployBranch, pushOutput, steps}>}
156
+ */
157
+ async function mergeToDeployBranch(dir, { site, branch, remote, publishBranch, message }) {
158
+ const steps = []
159
+ const deployRef = `${remote}/${publishBranch}`
160
+
161
+ // Always merge from the latest published main, not a stale local ref.
162
+ await git(dir, ['fetch', remote, publishBranch], { mustSucceed: true })
163
+ steps.push({ step: 'fetch', ok: true, ref: deployRef })
164
+
165
+ // Detached throwaway worktree at the tip of the deploy branch. Detached so it
166
+ // never collides with `<publishBranch>` being checked out in another worktree.
167
+ await git(dir, ['worktree', 'prune'])
168
+ const wt = path.join(os.tmpdir(), `vibe-publish-${site}-${process.pid}-${Date.now()}`)
169
+ await git(dir, ['worktree', 'add', '--detach', wt, deployRef], { mustSucceed: true })
170
+
171
+ try {
172
+ // Merge the (just-committed) workspace branch. --no-ff guarantees one merge
173
+ // commit we own, so the settings restore below can amend it safely.
174
+ const m = await git(wt, ['merge', '--no-ff', '-m', message, branch])
175
+
176
+ if (m.code !== 0) {
177
+ // Conflict. settings.json can't really conflict (publish never commits it),
178
+ // but if it's the ONLY conflict, resolve to the deploy branch's copy and go
179
+ // on — otherwise abort and surface 409 with the file list; main untouched.
180
+ const conflictOut = await git(wt, ['diff', '--name-only', '--diff-filter=U'])
181
+ const conflicts = conflictOut.stdout.split('\n').map((s) => s.trim()).filter(Boolean)
182
+ const onlySettings = conflicts.length > 0 && conflicts.every((p) => p === SETTINGS_PATH)
183
+ if (!onlySettings) {
184
+ await git(wt, ['merge', '--abort'])
185
+ const e = new Error(`merge conflict publishing ${branch} → ${publishBranch}`)
186
+ e.statusCode = 409
187
+ e.conflicts = conflicts
188
+ e.deployBranch = publishBranch
189
+ throw e
190
+ }
191
+ // Take the deploy branch's settings ("ours" — the worktree is on main).
192
+ await git(wt, ['checkout', '--ours', '--', SETTINGS_PATH])
193
+ await git(wt, ['add', '--', SETTINGS_PATH], { mustSucceed: true })
194
+ await git(wt, ['commit', '--no-edit'], { mustSucceed: true })
195
+ }
196
+
197
+ // Already merged (no new commits on the branch) ⇒ HEAD still == deploy tip.
198
+ const head = await git(wt, ['rev-parse', 'HEAD'], { mustSucceed: true })
199
+ const tip = await git(wt, ['rev-parse', deployRef], { mustSucceed: true })
200
+ const alreadyUpToDate = head.stdout.trim() === tip.stdout.trim()
201
+ if (alreadyUpToDate) {
202
+ steps.push({ step: 'merge', ok: true, skipped: `${branch} already merged into ${publishBranch}` })
203
+ return { merged: false, alreadyUpToDate: true, mergeSha: null, deployBranch: publishBranch, pushOutput: '', steps }
204
+ }
205
+ steps.push({ step: 'merge', ok: true, branch, into: publishBranch })
206
+
207
+ // Belt-and-suspenders: never let the merge carry settings.json into prod.
208
+ // Normally a no-op (branch never commits it); amend the merge commit if it did.
209
+ await git(wt, ['checkout', deployRef, '--', SETTINGS_PATH])
210
+ const settingsDirty = await git(wt, ['status', '--porcelain', '--', SETTINGS_PATH])
211
+ if (settingsDirty.stdout.trim()) {
212
+ await git(wt, ['add', '--', SETTINGS_PATH], { mustSucceed: true })
213
+ await git(wt, ['commit', '--amend', '--no-edit'], { mustSucceed: true })
214
+ steps.push({ step: 'exclude-settings-on-merge', ok: true, path: SETTINGS_PATH })
215
+ }
216
+
217
+ const mergeShaRes = await git(wt, ['rev-parse', 'HEAD'], { mustSucceed: true })
218
+ const mergeSha = mergeShaRes.stdout.trim()
219
+
220
+ // Push the merge to the deploy branch → ssh-deploy → update.sh on prod.
221
+ const p = await git(wt, ['push', remote, `HEAD:${publishBranch}`])
222
+ const pushOutput = (p.stderr || p.stdout || '').trim()
223
+ if (p.code !== 0) {
224
+ steps.push({ step: 'push-deploy', ok: false, remote, branch: publishBranch, output: pushOutput })
225
+ const e = new Error(`git push to ${publishBranch} failed: ${pushOutput}`)
226
+ e.statusCode = 502
227
+ e.steps = steps
228
+ throw e
229
+ }
230
+ steps.push({ step: 'push-deploy', ok: true, remote, branch: publishBranch, sha: mergeSha, output: pushOutput })
231
+
232
+ return { merged: true, alreadyUpToDate: false, mergeSha, deployBranch: publishBranch, pushOutput, steps }
233
+ } finally {
234
+ // Always tear the throwaway worktree down, even on conflict/abort.
235
+ await git(dir, ['worktree', 'remove', '--force', wt])
236
+ try { fs.rmSync(wt, { recursive: true, force: true }) } catch { /* already gone */ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Settings-safe publish. Requires an explicit confirm — this is the owner-gated,
242
+ * intentional production deploy. Steps:
243
+ * 1. exclude settings.json so prod config is never clobbered
244
+ * 2. git add -A (settings excluded)
245
+ * 3. commit on the workspace branch (skipped if nothing staged)
246
+ * 4. push the workspace branch (preserves origin/<branch> for collaborators)
247
+ * 5. if <branch> is not the deploy branch: merge it → <publishBranch> in a
248
+ * throwaway worktree and push → ssh-deploy → prod (HAP-1174). When the
249
+ * workspace IS on the deploy branch, step 4's push is itself the deploy.
250
+ *
251
+ * @param {string} site
252
+ * @param {{message?:string, confirm?:boolean, remote?:string, push?:boolean}} opts
253
+ */
254
+ async function publish(site, opts = {}) {
255
+ if (opts.confirm !== true) {
256
+ const e = new Error('publish requires explicit confirm:true')
257
+ e.statusCode = 400
258
+ throw e
259
+ }
260
+ if (!config.publishEnabled) {
261
+ const e = new Error('publishing is disabled (set VIBE_PUBLISH_ENABLED=true)')
262
+ e.statusCode = 403
263
+ throw e
264
+ }
265
+
266
+ const dir = resolveSiteDir(site)
267
+ const remote = opts.remote || config.publishRemote
268
+ const branch = await currentBranch(dir)
269
+ const publishBranch = opts.publishBranch || config.publishBranch
270
+ const doPush = opts.push !== false
271
+ const isDeployBranch = branch === publishBranch
272
+ const message = (opts.message && String(opts.message).trim()) ||
273
+ `Publish from staging (${site})`
274
+
275
+ const steps = []
276
+
277
+ // 1. Keep env-specific settings.json OUT of the commit WITHOUT touching the
278
+ // working file. The staging overlay (`staging.mjs up`) leaves settings.json
279
+ // dirty with staging ports/db/domains. The old approach reverted it here
280
+ // (`git checkout -- settings.json`), but this clone runs `mango dev` with a
281
+ // live file watcher: reverting to prod ports made the watcher rebuild
282
+ // build/index.js on the PROD mango port and rebind it, EADDRINUSE-crashing
283
+ // the live prod mango in a restart loop (the HAP-1096 publish outage). So we
284
+ // never modify the file — we only ensure it is not staged, then stage the rest.
285
+ const tracked = await git(dir, ['ls-files', '--error-unmatch', SETTINGS_PATH])
286
+ let settingsExcluded = false
287
+ if (tracked.code === 0) {
288
+ // Unstage settings.json if a prior op left it in the index (working tree untouched).
289
+ await git(dir, ['reset', '-q', '--', SETTINGS_PATH])
290
+ settingsExcluded = true
291
+ steps.push({ step: 'exclude-settings', ok: true, path: SETTINGS_PATH, note: 'working-tree settings preserved; excluded from commit' })
292
+ } else {
293
+ steps.push({ step: 'exclude-settings', ok: true, skipped: 'settings.json not tracked' })
294
+ }
295
+
296
+ // 2. Stage everything EXCEPT settings.json. The exclude pathspec keeps the
297
+ // staging overlay out of the commit even though it stays dirty on disk, so
298
+ // the pushed tree carries the committed (prod) settings untouched.
299
+ await git(dir, ['add', '-A', '--', '.', EXCLUDE_SETTINGS], { mustSucceed: true })
300
+ steps.push({ step: 'add', ok: true })
301
+
302
+ // 3. Commit only if there is something staged.
303
+ const staged = await git(dir, ['diff', '--cached', '--quiet'])
304
+ let committed = false
305
+ let commitSha = null
306
+ if (staged.code !== 0) {
307
+ const c = await git(dir, ['commit', '-m', message], { mustSucceed: true })
308
+ committed = true
309
+ const sha = await git(dir, ['rev-parse', 'HEAD'], { mustSucceed: true })
310
+ commitSha = sha.stdout.trim()
311
+ steps.push({ step: 'commit', ok: true, sha: commitSha, message })
312
+ } else {
313
+ steps.push({ step: 'commit', ok: true, skipped: 'nothing to commit after restore' })
314
+ }
315
+
316
+ // 4. Push the workspace branch. When the workspace IS on the deploy branch,
317
+ // this push is itself the prod deploy (ssh-deploy triggers on it). Otherwise
318
+ // it just keeps origin/<branch> current for same-branch collaborators; the
319
+ // actual prod deploy happens in step 5.
320
+ let pushed = false
321
+ let pushOutput = ''
322
+ if (doPush) {
323
+ const p = await git(dir, ['push', remote, `HEAD:${branch}`])
324
+ pushed = p.code === 0
325
+ pushOutput = (p.stderr || p.stdout || '').trim()
326
+ steps.push({ step: 'push', ok: pushed, remote, branch, output: pushOutput })
327
+ if (!pushed) {
328
+ const e = new Error(`git push failed: ${pushOutput}`)
329
+ e.statusCode = 502
330
+ e.steps = steps
331
+ throw e
332
+ }
333
+ } else {
334
+ steps.push({ step: 'push', ok: true, skipped: 'push:false (dry commit only)' })
335
+ }
336
+
337
+ // 5. Merge the workspace branch into the deploy branch and push it → ssh-deploy
338
+ // → prod (HAP-1174). Skipped when the workspace is already on the deploy
339
+ // branch (step 4 deployed) or when push is disabled (dry commit).
340
+ let deployed = false
341
+ let merge = null
342
+ if (doPush && !isDeployBranch) {
343
+ merge = await mergeToDeployBranch(dir, { site, branch, remote, publishBranch, message })
344
+ deployed = merge.merged
345
+ steps.push(...merge.steps)
346
+ } else if (isDeployBranch && pushed) {
347
+ deployed = committed
348
+ steps.push({ step: 'merge', ok: true, skipped: `workspace is on the deploy branch (${publishBranch}); push deployed directly` })
349
+ } else {
350
+ steps.push({ step: 'merge', ok: true, skipped: 'push:false (no deploy)' })
351
+ }
352
+
353
+ const nothingToPublish = !committed && !pushed && !deployed
354
+ return {
355
+ ok: true,
356
+ site,
357
+ branch,
358
+ remote,
359
+ publishBranch,
360
+ deployBranch: publishBranch,
361
+ settingsExcluded,
362
+ settingsPath: SETTINGS_PATH,
363
+ committed,
364
+ commitSha,
365
+ message,
366
+ pushed,
367
+ pushOutput,
368
+ deployed,
369
+ mergeSha: merge ? merge.mergeSha : (isDeployBranch ? commitSha : null),
370
+ mergedToDeployBranch: !!(merge && merge.merged),
371
+ nothingToPublish,
372
+ steps,
373
+ }
374
+ }
375
+
376
+ export { diff, publish, parsePorcelain, SETTINGS_PATH }
@@ -0,0 +1,199 @@
1
+ // recovery.js
2
+ //
3
+ // Always a way back (HAP-1124, protection #2). A vibe edit must never leave the
4
+ // staging clone in a non-recoverable state, so:
5
+ //
6
+ // • autoCommit(site) — after every run, stage the clone (settings.json
7
+ // excluded, exactly like publish) and commit any
8
+ // change with a structured message. History always
9
+ // exists, so any edit is verifiable + reversible.
10
+ // • ensureBaseline(site) — on the first auto-commit, tag the pre-edit tree as
11
+ // the known-good baseline `vibe-baseline`.
12
+ // • revertLast(site) — undo the most recent vibe edit (git revert, keeps
13
+ // history; non-destructive).
14
+ // • resetToBaseline(site) — hard-reset the clone back to the baseline tag,
15
+ // discarding all vibe edits (recoverable via reflog).
16
+ // • status(site) — baseline + recent vibe commits + what's available.
17
+ //
18
+ // All of this is plain git on the staging clone — no engineer required. The
19
+ // orchestrator exposes it as endpoints and a one-command script (scripts/
20
+ // vibe-recover.sh) so the client/board can recover with one call/click.
21
+
22
+ import { execFile } from 'node:child_process'
23
+ import { resolveSiteDir, config } from './config.js'
24
+ import { SETTINGS_PATH } from './publisher.js'
25
+
26
+ const EXCLUDE_SETTINGS = `:(exclude)${SETTINGS_PATH}`
27
+ const COMMIT_PREFIX = 'vibe:'
28
+
29
+ /** Promisified git scoped to a dir. Resolves { code, stdout, stderr }; rejects
30
+ * only when mustSucceed and the command fails. */
31
+ function git(cwd, args, { mustSucceed = false } = {}) {
32
+ return new Promise((resolve, reject) => {
33
+ execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
34
+ const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0
35
+ if (err && mustSucceed) {
36
+ const e = new Error(`git ${args[0]} failed: ${(stderr || err.message || '').trim()}`)
37
+ e.code = code
38
+ return reject(e)
39
+ }
40
+ resolve({ code, stdout: stdout || '', stderr: stderr || '' })
41
+ })
42
+ })
43
+ }
44
+
45
+ async function headSha(cwd) {
46
+ const r = await git(cwd, ['rev-parse', 'HEAD'])
47
+ return r.code === 0 ? r.stdout.trim() : null
48
+ }
49
+
50
+ async function tagSha(cwd, tag) {
51
+ const r = await git(cwd, ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`])
52
+ return r.code === 0 ? r.stdout.trim() : null
53
+ }
54
+
55
+ /** Build a single-line, structured commit message for an applied vibe edit. */
56
+ function commitMessage({ summary, sessionId }) {
57
+ const s = String(summary || 'edit').replace(/\s+/g, ' ').trim().slice(0, 140) || 'edit'
58
+ const sess = sessionId ? ` [session ${sessionId}]` : ''
59
+ return `${COMMIT_PREFIX} ${s}${sess}`
60
+ }
61
+
62
+ /**
63
+ * Ensure the known-good baseline tag exists, pointing at the current HEAD. Called
64
+ * before the first auto-commit so the baseline captures the pre-edit tree.
65
+ * Idempotent — never moves an existing tag.
66
+ * @returns {Promise<{tag, sha, created}>}
67
+ */
68
+ async function ensureBaseline(site, opts = {}) {
69
+ const dir = opts.dir || resolveSiteDir(site)
70
+ const tag = opts.tag || config.baselineTag
71
+ const existing = await tagSha(dir, tag)
72
+ if (existing) return { tag, sha: existing, created: false }
73
+ const sha = await headSha(dir)
74
+ if (!sha) return { tag, sha: null, created: false } // no commits yet — nothing to tag
75
+ await git(dir, ['tag', '-a', tag, '-m', 'vibe known-good baseline (HAP-1124)'], { mustSucceed: true })
76
+ return { tag, sha, created: true }
77
+ }
78
+
79
+ /**
80
+ * Auto-commit any working-tree change from a vibe run. settings.json is excluded
81
+ * (same hard rule as publish). No-op when the tree is clean. Tags the baseline on
82
+ * the first commit.
83
+ * @returns {Promise<{committed, sha?, message?, baseline}>}
84
+ */
85
+ async function autoCommit(site, opts = {}) {
86
+ const dir = opts.dir || resolveSiteDir(site)
87
+
88
+ // Tag the pre-edit tree as baseline before the first commit lands.
89
+ const baseline = await ensureBaseline(site, { dir, tag: opts.tag })
90
+
91
+ // Stage everything except settings.json (overlay-materialized; never committed
92
+ // by vibe — it is also on the protected denylist).
93
+ await git(dir, ['add', '-A', '--', '.', EXCLUDE_SETTINGS], { mustSucceed: true })
94
+
95
+ const staged = await git(dir, ['diff', '--cached', '--quiet'])
96
+ if (staged.code === 0) return { committed: false, baseline } // nothing changed
97
+
98
+ const message = commitMessage(opts)
99
+ await git(dir, ['commit', '-m', message], { mustSucceed: true })
100
+ const sha = await headSha(dir)
101
+ return { committed: true, sha, message, baseline }
102
+ }
103
+
104
+ /** Recent vibe commits (newest first), most-recent `limit`. */
105
+ async function vibeCommits(dir, limit = 20) {
106
+ const r = await git(dir, ['log', `-n${limit}`, '--pretty=format:%H%x1f%s%x1f%cI'])
107
+ if (r.code !== 0 || !r.stdout.trim()) return []
108
+ return r.stdout.trim().split('\n').map((line) => {
109
+ const [sha, subject, date] = line.split('\x1f')
110
+ return { sha, subject, date, isVibe: (subject || '').startsWith(COMMIT_PREFIX) }
111
+ })
112
+ }
113
+
114
+ /**
115
+ * Recovery status: baseline, current HEAD, recent commits, and which recovery
116
+ * actions are currently available.
117
+ */
118
+ async function status(site, opts = {}) {
119
+ const dir = opts.dir || resolveSiteDir(site)
120
+ const tag = opts.tag || config.baselineTag
121
+ const [head, baseline, commits] = await Promise.all([
122
+ headSha(dir),
123
+ tagSha(dir, tag),
124
+ vibeCommits(dir),
125
+ ])
126
+ const lastIsVibe = commits.length > 0 && commits[0].isVibe
127
+ const aheadOfBaseline = !!(baseline && head && baseline !== head)
128
+ return {
129
+ site,
130
+ dir,
131
+ head,
132
+ baselineTag: tag,
133
+ baselineSha: baseline,
134
+ hasBaseline: !!baseline,
135
+ aheadOfBaseline,
136
+ recentCommits: commits,
137
+ canRevertLast: lastIsVibe,
138
+ canResetToBaseline: !!baseline && aheadOfBaseline,
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Revert the most recent vibe edit (git revert HEAD — keeps full history, never
144
+ * destructive). Refuses when HEAD is not a vibe commit so we never revert manual
145
+ * engineer work.
146
+ * @returns {Promise<{ok, revertedSha, newSha, message}>}
147
+ */
148
+ async function revertLast(site, opts = {}) {
149
+ const dir = opts.dir || resolveSiteDir(site)
150
+ const commits = await vibeCommits(dir, 1)
151
+ if (!commits.length || !commits[0].isVibe) {
152
+ const e = new Error('the most recent commit is not a vibe edit — nothing to revert')
153
+ e.statusCode = 409
154
+ throw e
155
+ }
156
+ const revertedSha = commits[0].sha
157
+ // Guard against a dirty tree wedging the revert: commit any stray change first.
158
+ await git(dir, ['add', '-A', '--', '.', EXCLUDE_SETTINGS])
159
+ const dirty = await git(dir, ['diff', '--cached', '--quiet'])
160
+ if (dirty.code !== 0) {
161
+ await git(dir, ['commit', '-m', `${COMMIT_PREFIX} snapshot before revert`], { mustSucceed: true })
162
+ }
163
+ await git(dir, ['revert', '--no-edit', revertedSha], { mustSucceed: true })
164
+ const newSha = await headSha(dir)
165
+ return { ok: true, revertedSha, newSha, message: `Reverted ${revertedSha.slice(0, 8)}` }
166
+ }
167
+
168
+ /**
169
+ * Reset the clone hard back to the known-good baseline, discarding all vibe edits
170
+ * since. Destructive on the working branch but fully recoverable via the reflog,
171
+ * and the baseline tag itself is never moved.
172
+ * @returns {Promise<{ok, baselineTag, baselineSha, previousHead}>}
173
+ */
174
+ async function resetToBaseline(site, opts = {}) {
175
+ const dir = opts.dir || resolveSiteDir(site)
176
+ const tag = opts.tag || config.baselineTag
177
+ const baseline = await tagSha(dir, tag)
178
+ if (!baseline) {
179
+ const e = new Error(`no baseline tag (${tag}) to reset to`)
180
+ e.statusCode = 409
181
+ throw e
182
+ }
183
+ const previousHead = await headSha(dir)
184
+ await git(dir, ['reset', '--hard', tag], { mustSucceed: true })
185
+ // Drop untracked files a vibe edit may have created so the tree truly matches
186
+ // the baseline. settings.json (and anything gitignored) is preserved with -e.
187
+ await git(dir, ['clean', '-fd', '-e', SETTINGS_PATH])
188
+ return { ok: true, baselineTag: tag, baselineSha: baseline, previousHead }
189
+ }
190
+
191
+ export {
192
+ autoCommit,
193
+ ensureBaseline,
194
+ revertLast,
195
+ resetToBaseline,
196
+ status,
197
+ commitMessage,
198
+ COMMIT_PREFIX,
199
+ }
@@ -0,0 +1,38 @@
1
+ // screenshot.js
2
+ //
3
+ // Persist a client-sent viewport screenshot (a base64 data URL) to a temp file
4
+ // the agent can Read — Claude Code reads images. The file lives in the OS temp
5
+ // dir, NOT in the staging repo, so it is never committed or published. One file
6
+ // per site is reused (overwritten each turn) to avoid unbounded growth.
7
+
8
+ import { writeFileSync, mkdirSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+
12
+ const DIR = join(tmpdir(), 'vibe-orchestrator')
13
+ const DATA_URL = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/
14
+
15
+ /**
16
+ * Write a data-URL screenshot to disk and return its absolute path, or null when
17
+ * the input is missing/invalid or the write fails (best-effort — never throws).
18
+ * @param {string} site
19
+ * @param {string} dataUrl
20
+ * @returns {string|null}
21
+ */
22
+ function persistScreenshot(site, dataUrl) {
23
+ if (!dataUrl || typeof dataUrl !== 'string') return null
24
+ const m = DATA_URL.exec(dataUrl.trim())
25
+ if (!m) return null
26
+ try {
27
+ mkdirSync(DIR, { recursive: true })
28
+ const ext = m[1] === 'jpeg' ? 'jpg' : m[1]
29
+ const safe = String(site || 'site').replace(/[^a-z0-9_-]/gi, '_')
30
+ const path = join(DIR, `${safe}-viewport.${ext}`)
31
+ writeFileSync(path, Buffer.from(m[2], 'base64'))
32
+ return path
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ export { persistScreenshot }