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.
- package/cli.js +57 -0
- 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 +35 -1
- package/default/vite.config.js +46 -0
- package/lib/devProxyGateway.js +173 -0
- package/lib/staging-gateway.js +23 -1
- 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,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 }
|