mango-cms 0.3.34 → 0.3.36
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/cli.js +113 -23
- package/default/infra/vibe/README.md +43 -0
- package/default/infra/vibe/cloudflare.ini.template +26 -0
- package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
- package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
- package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
- package/default/infra/vibe/vibe-gateway.service +38 -0
- package/default/infra/vibe/vibe-orchestrator.service +44 -0
- package/default/infra/vibe/vibe.env.template +24 -0
- package/default/mango/config/settings.json +40 -1
- package/default/package.json +1 -1
- package/default/vite.config.js +46 -0
- package/lib/vibe-orchestrator/README.md +76 -0
- package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
- package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
- package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
- package/lib/vibe-orchestrator/server.js +344 -0
- package/lib/vibe-orchestrator/src/attachments.js +98 -0
- package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
- package/lib/vibe-orchestrator/src/config.js +227 -0
- package/lib/vibe-orchestrator/src/costMirror.js +64 -0
- package/lib/vibe-orchestrator/src/costStore.js +209 -0
- package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
- package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
- package/lib/vibe-orchestrator/src/preamble.js +139 -0
- package/lib/vibe-orchestrator/src/publisher.js +376 -0
- package/lib/vibe-orchestrator/src/recovery.js +199 -0
- package/lib/vibe-orchestrator/src/screenshot.js +38 -0
- package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
- package/lib/vibe-orchestrator/src/streamParser.js +188 -0
- package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
- package/package.json +1 -1
- 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 }
|