mango-cms 0.3.33 → 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 (34) 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 +35 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/devProxyGateway.js +173 -0
  13. package/lib/staging-gateway.js +23 -1
  14. package/lib/vibe-orchestrator/README.md +76 -0
  15. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  16. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  17. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  18. package/lib/vibe-orchestrator/server.js +344 -0
  19. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  20. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  21. package/lib/vibe-orchestrator/src/config.js +227 -0
  22. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  23. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  24. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  25. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  26. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  27. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  28. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  29. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  30. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  31. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  32. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  33. package/package.json +1 -1
  34. package/readme.md +6 -0
@@ -0,0 +1,114 @@
1
+ // pathGuard.js
2
+ //
3
+ // Server-side denylist for vibe edits (HAP-1124, protection #1). The in-page
4
+ // assistant must never be able to edit the Vibe system itself — the ⌘K drawer
5
+ // and its client, the orchestrator service, the staging spine, the settings /
6
+ // secrets build, the vibe Mango endpoints, and the deploy/CI scripts. If it
7
+ // could, a single prompt could brick the very thing the owner uses to recover.
8
+ //
9
+ // This is PURE and prompt-independent: the preamble also tells the agent which
10
+ // files are off-limits (good UX), but enforcement lives in the PreToolUse hook
11
+ // (`scripts/path-guard-hook.mjs`) which imports `isProtectedPath` from here and
12
+ // blocks the tool BEFORE the edit lands — regardless of what the prompt says.
13
+ //
14
+ // Patterns are matched against a POSIX, repo-root-relative path. Supported glob
15
+ // tokens: `**` (any run of path segments, including none), `*` (any run within a
16
+ // single segment). Everything else is matched literally.
17
+
18
+ const PROTECTED_PATTERNS = [
19
+ // ⌘K palette / drawer UI and its orchestrator client
20
+ 'src/components/layout/vibeAssistant.vue',
21
+ 'src/components/layout/publishDialog.vue',
22
+ 'src/helpers/orchestrator.js',
23
+
24
+ // The orchestrator service itself (entire tree)
25
+ 'orchestrator/**',
26
+
27
+ // Staging spine — the overlay + build config that make the clone real
28
+ 'mango/helpers/staging.mjs',
29
+ 'vite.config.js',
30
+ 'vite.staging.config.js',
31
+
32
+ // Settings / secrets and the settings build
33
+ 'mango/config/settings.json',
34
+ 'mango/config/settings.local.json',
35
+ 'build-settings.mjs',
36
+ 'mango/helpers/build-settings.mjs',
37
+
38
+ // Vibe server endpoints: owner-token mint + cost ingest
39
+ 'mango/helpers/vibeToken.js',
40
+ 'mango/helpers/vibeCosts.js',
41
+
42
+ // Deploy / CI / process management
43
+ 'mango/helpers/deploy.sh',
44
+ 'mango/helpers/update.sh',
45
+ '.github/**',
46
+ 'nginx/**',
47
+ '**/*.nginx',
48
+ '**/nginx.conf',
49
+ '**/ecosystem.config.js',
50
+ '**/ecosystem.config.cjs',
51
+ '**/pm2.config.js',
52
+ ]
53
+
54
+ /** Clear, owner-facing message surfaced when an edit is blocked. */
55
+ const PROTECTED_MESSAGE =
56
+ 'That file is part of the Vibe system and is protected — I can’t edit it.'
57
+
58
+ /** Escape a string for literal use inside a RegExp. */
59
+ function escapeRe(s) {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
61
+ }
62
+
63
+ /** Compile a glob pattern (with `**` and `*`) into an anchored RegExp. */
64
+ function globToRe(glob) {
65
+ let re = ''
66
+ let i = 0
67
+ while (i < glob.length) {
68
+ const c = glob[i]
69
+ if (c === '*' && glob[i + 1] === '*') {
70
+ // `**/` ⇒ optional run of segments; bare `**` ⇒ anything.
71
+ if (glob[i + 2] === '/') { re += '(?:.*/)?'; i += 3 } else { re += '.*'; i += 2 }
72
+ } else if (c === '*') {
73
+ re += '[^/]*'
74
+ i += 1
75
+ } else {
76
+ re += escapeRe(c)
77
+ i += 1
78
+ }
79
+ }
80
+ return new RegExp(`^${re}$`)
81
+ }
82
+
83
+ const COMPILED = PROTECTED_PATTERNS.map(globToRe)
84
+
85
+ /**
86
+ * Normalize an arbitrary path into a POSIX, root-relative form with no leading
87
+ * `./` and no trailing slash. Backslashes are treated as separators.
88
+ * @param {string} p
89
+ * @returns {string}
90
+ */
91
+ function normalizeRel(p) {
92
+ let s = String(p || '').trim().replace(/\\/g, '/')
93
+ // Collapse any `a/b/../c` etc. without touching the filesystem.
94
+ const out = []
95
+ for (const seg of s.split('/')) {
96
+ if (seg === '' || seg === '.') continue
97
+ if (seg === '..') out.pop()
98
+ else out.push(seg)
99
+ }
100
+ return out.join('/')
101
+ }
102
+
103
+ /**
104
+ * True when a repo-root-relative path targets a protected Vibe-system file.
105
+ * @param {string} relPath - path relative to the staging clone root
106
+ * @returns {boolean}
107
+ */
108
+ function isProtectedPath(relPath) {
109
+ const rel = normalizeRel(relPath)
110
+ if (!rel) return false
111
+ return COMPILED.some((re) => re.test(rel))
112
+ }
113
+
114
+ export { isProtectedPath, normalizeRel, PROTECTED_PATTERNS, PROTECTED_MESSAGE }
@@ -0,0 +1,139 @@
1
+ // preamble.js
2
+ //
3
+ // Builds the full prompt sent to the in-page Vibe Assistant agent (HAP-1111).
4
+ // Without this the agent sees little more than the raw instruction. Here we give
5
+ // it (a) durable orientation — who it is, where things live, the one hard rule —
6
+ // injected once at the start of a conversation, and (b) a per-turn snapshot of
7
+ // what the owner is looking at (route, theme, selection, clicked element,
8
+ // console errors, a screenshot the agent can Read).
9
+
10
+ const NEVER_EDIT = 'mango/config/settings.json'
11
+ const MAX_SELECTION = 400
12
+ const MAX_HTML = 600
13
+ const MAX_ERROR = 300
14
+ const MAX_ERRORS = 10
15
+
16
+ /**
17
+ * Static orientation for the agent. Injected once per conversation (resumed
18
+ * turns already carry it in history). Plain, declarative, no fluff.
19
+ * @returns {string}
20
+ */
21
+ function contextPreamble() {
22
+ return [
23
+ 'You are the Vibe Assistant — an in-page coding assistant embedded in a live',
24
+ 'website. The site is a Mango + Vue 3 project, and you are editing a private',
25
+ 'staging clone of it. Your edits hot-reload: the owner sees them in the page',
26
+ 'within a few seconds of you saving a file.',
27
+ '',
28
+ 'Where things live:',
29
+ ' • src/components/pages/ — one Vue component per route (the page views)',
30
+ ' • src/components/layout/ — shared chrome (header, footer, nav, drawers)',
31
+ ' • src/components/ — reusable building blocks',
32
+ ' • src/helpers/ — JS helpers (data, the Mango client, utilities)',
33
+ ' • src/assets/, src/style/ — styling and static assets',
34
+ ' • mango/ — server config and CMS (Mango)',
35
+ '',
36
+ 'How to work:',
37
+ ' • Make the smallest change that satisfies the request, then stop.',
38
+ ' • Explain what you changed in plain language the owner can understand —',
39
+ ' no jargon and no code diffs in your reply.',
40
+ ' • Prefer editing an existing component over creating a new one.',
41
+ ' • When the owner says "this" or "here", use the page context below',
42
+ ' (route, selection, clicked element, screenshot) to find the target.',
43
+ '',
44
+ `HARD RULE: NEVER edit ${NEVER_EDIT}. It holds secrets and environment`,
45
+ 'config that must not change. If a task seems to require it, stop and explain.',
46
+ '',
47
+ 'PROTECTED FILES: the Vibe system itself is off-limits — the ⌘K drawer and its',
48
+ 'client, the orchestrator service, the staging spine (staging.mjs, vite config),',
49
+ 'the settings build, and the deploy/CI scripts. These edits are also blocked',
50
+ 'server-side, so if a request needs one, stop and explain rather than trying.',
51
+ '',
52
+ 'FETCHING FROM THE WEB: use the WebFetch and WebSearch tools for anything on the',
53
+ 'internet. Shell network commands (curl, wget) are intentionally disabled and',
54
+ 'will always fail — do NOT retry them; reach for WebFetch instead. WebFetch works',
55
+ 'on ANY url, not just pages: to find a site\'s brand colors, WebFetch its',
56
+ 'site.webmanifest / manifest.json (the theme_color field) and the <link rel=',
57
+ '"stylesheet"> CSS asset urls from its HTML, then read the hex values there —',
58
+ 'fetching the homepage alone only returns the pre-render shell of a JS app. If a',
59
+ 'value is still unclear, just ask the owner; they may have already pasted the',
60
+ 'logo or color as an attachment above.',
61
+ ].join('\n')
62
+ }
63
+
64
+ function truncate(value, n) {
65
+ const s = String(value)
66
+ return s.length > n ? `${s.slice(0, n)}…` : s
67
+ }
68
+
69
+ /**
70
+ * Render a per-turn page-context object into a readable text block. Returns ''
71
+ * when there is nothing useful to say.
72
+ * @param {object} [ctx]
73
+ * @returns {string}
74
+ */
75
+ function formatPageContext(ctx) {
76
+ if (!ctx || typeof ctx !== 'object') return ''
77
+ const lines = []
78
+ const push = (label, val) => {
79
+ if (val === undefined || val === null || val === '') return
80
+ lines.push(` • ${label}: ${typeof val === 'string' ? val : JSON.stringify(val)}`)
81
+ }
82
+
83
+ push('URL', ctx.url)
84
+ push('Route', ctx.route)
85
+ push('Page title', ctx.title)
86
+ push('Theme', ctx.theme)
87
+ if (ctx.viewport && ctx.viewport.w) push('Viewport', `${ctx.viewport.w}×${ctx.viewport.h}`)
88
+ push('Site owner', ctx.owner)
89
+ if (ctx.selection) push('Selected text', truncate(ctx.selection, MAX_SELECTION))
90
+
91
+ if (ctx.clickTarget && (ctx.clickTarget.selector || ctx.clickTarget.html)) {
92
+ push('Clicked element (selector)', ctx.clickTarget.selector)
93
+ if (ctx.clickTarget.html) push('Clicked element (HTML)', truncate(ctx.clickTarget.html, MAX_HTML))
94
+ }
95
+
96
+ const errs = Array.isArray(ctx.consoleErrors) ? ctx.consoleErrors.filter(Boolean) : []
97
+ if (errs.length) {
98
+ lines.push(' • Recent console errors the owner is seeing:')
99
+ for (const e of errs.slice(-MAX_ERRORS)) lines.push(` - ${truncate(String(e), MAX_ERROR)}`)
100
+ }
101
+
102
+ if (ctx.screenshotPath) {
103
+ push('Viewport screenshot', `${ctx.screenshotPath} (Read this image to see what the owner sees)`)
104
+ }
105
+
106
+ // HAP-1126: files the owner pasted/uploaded with this turn. Claude Code reads
107
+ // images AND PDFs natively, so just hand it the absolute paths.
108
+ const atts = Array.isArray(ctx.attachmentPaths) ? ctx.attachmentPaths.filter(Boolean) : []
109
+ if (atts.length) {
110
+ lines.push(' • Files the owner attached (Read each — images and PDFs are supported):')
111
+ for (const a of atts) {
112
+ const label = a && a.name ? `${a.name} → ` : ''
113
+ lines.push(` - ${label}${a && a.path ? a.path : a}`)
114
+ }
115
+ }
116
+
117
+ if (!lines.length) return ''
118
+ return ['Current page context (what the owner is looking at right now):', ...lines].join('\n')
119
+ }
120
+
121
+ /**
122
+ * Compose the final prompt: orientation (first turn only) + page context +
123
+ * the owner's request.
124
+ * @param {object} opts
125
+ * @param {string} opts.prompt
126
+ * @param {object} [opts.pageContext]
127
+ * @param {boolean} [opts.includePreamble=true]
128
+ * @returns {string}
129
+ */
130
+ function composePrompt({ prompt, pageContext, includePreamble = true } = {}) {
131
+ const parts = []
132
+ if (includePreamble) parts.push(contextPreamble())
133
+ const ctxBlock = formatPageContext(pageContext)
134
+ if (ctxBlock) parts.push(ctxBlock)
135
+ parts.push(`Owner request:\n${String(prompt || '').trim()}`)
136
+ return parts.join('\n\n')
137
+ }
138
+
139
+ export { contextPreamble, formatPageContext, composePrompt }
@@ -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 }