@vibecompass/vibecompass 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/cli.js +200 -0
- package/src/docs-review.js +131 -22
- package/src/docs-update.js +15 -3
- package/src/index.js +8 -1
- package/src/mode-transition.js +265 -0
- package/src/project-memory.js +422 -0
- package/src/scaffold.js +7 -5
- package/src/serialization.js +26 -0
- package/src/status.js +78 -0
- package/src/sync.js +264 -13
- package/src/workflow.js +3 -0
- package/src/workflows/registry.js +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
4
|
+
import { resolveSyncBinding } from './sync-binding.js';
|
|
5
|
+
import { withMemoryRootLock } from './serialization.js';
|
|
6
|
+
import { adoptRemoteHead, pushProjectMemory } from './sync.js';
|
|
7
|
+
|
|
8
|
+
const PROMOTED_MARKER = ['state', 'promoted-root.json'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* D-288 promote: local-primary -> hosted-only as a crash-safe, resumable
|
|
12
|
+
* two-phase transition. Fresh verified push baseline -> server records the
|
|
13
|
+
* intent (with a completeness report) -> local changes (project.yaml mode,
|
|
14
|
+
* promoted-root marker, agent guidance) -> server confirm. `--resume`
|
|
15
|
+
* continues an interrupted run from whatever state it reached; `--abort`
|
|
16
|
+
* backs the whole thing out on both sides.
|
|
17
|
+
*/
|
|
18
|
+
export async function promoteHosted(options = {}, environment = {}) {
|
|
19
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
20
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
21
|
+
return withMemoryRootLock(rootDir, 'promote-hosted', () =>
|
|
22
|
+
promoteHostedLocked(options, environment, rootDir),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function promoteHostedLocked(options, environment, rootDir) {
|
|
27
|
+
const context = await loadModeAgnosticSyncContext(rootDir, options, environment);
|
|
28
|
+
const server = await getJson(context, 'status');
|
|
29
|
+
const localMode = context.project.mode;
|
|
30
|
+
const notes = [];
|
|
31
|
+
|
|
32
|
+
if (options.abort === true) {
|
|
33
|
+
// A confirmed promotion cannot be aborted: the server is already
|
|
34
|
+
// hosted-only, and rewriting only the local record would create exactly
|
|
35
|
+
// the split-brain the two-phase flow exists to prevent. Reversing a
|
|
36
|
+
// finished cutover is demote-hosted's job.
|
|
37
|
+
if (server.mode === 'hosted-only' && !server.pending_mode_transition) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'This promotion was already confirmed — the hosted project is hosted-only. '
|
|
40
|
+
+ 'Aborting now would only rewrite the local record and split the two mode records. '
|
|
41
|
+
+ 'Use vibecompass demote-hosted to reverse the cutover on both sides.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
await postJson(context, 'mode-transition', { action: 'abort' });
|
|
45
|
+
if (localMode === 'hosted-only') {
|
|
46
|
+
await writeProjectMode(rootDir, 'local-primary');
|
|
47
|
+
await removePromotedMarker(rootDir);
|
|
48
|
+
notes.push('Restored local project.yaml to local-primary and removed the promoted-root marker.');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status: 'aborted',
|
|
52
|
+
rootDir,
|
|
53
|
+
warnings: notes,
|
|
54
|
+
message: 'Promotion aborted; the project remains local-primary on both sides.',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (server.mode === 'hosted-only' && localMode === 'hosted-only' && !server.pending_mode_transition) {
|
|
59
|
+
await ensurePromotedMarker(rootDir, context);
|
|
60
|
+
return {
|
|
61
|
+
status: 'already-promoted',
|
|
62
|
+
rootDir,
|
|
63
|
+
warnings: [],
|
|
64
|
+
message: 'This project is already hosted-only on both sides.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const resuming = options.resume === true || Boolean(server.pending_mode_transition) || localMode === 'hosted-only';
|
|
69
|
+
let completeness = null;
|
|
70
|
+
|
|
71
|
+
if (localMode === 'local-primary') {
|
|
72
|
+
// Phase 1: fresh verified baseline. Idempotent push makes retries safe.
|
|
73
|
+
const pushResult = await pushProjectMemory(
|
|
74
|
+
{ rootDir, syncTarget: options.syncTarget ?? null },
|
|
75
|
+
environment,
|
|
76
|
+
);
|
|
77
|
+
notes.push(`Baseline push: ${pushResult.status} (${pushResult.remoteRevisionId}).`);
|
|
78
|
+
|
|
79
|
+
const manifest = JSON.parse(await readFile(path.join(rootDir, 'state', 'manifest.json'), 'utf8'));
|
|
80
|
+
const begin = await postJson(context, 'mode-transition', {
|
|
81
|
+
action: 'begin',
|
|
82
|
+
target_mode: 'hosted-only',
|
|
83
|
+
expected_manifest_hash: manifest.canonical.manifest_hash,
|
|
84
|
+
});
|
|
85
|
+
completeness = begin.completeness ?? null;
|
|
86
|
+
|
|
87
|
+
// Phase 2: local changes.
|
|
88
|
+
await writeProjectMode(rootDir, 'hosted-only');
|
|
89
|
+
await ensurePromotedMarker(rootDir, context);
|
|
90
|
+
} else if (resuming) {
|
|
91
|
+
// Crash-window repair: the mode flipped locally but the marker (or the
|
|
92
|
+
// confirm) may be missing.
|
|
93
|
+
await ensurePromotedMarker(rootDir, context);
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(`promote-hosted requires a local-primary root (found mode: ${localMode ?? 'unknown'}).`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Phase 3: confirm (safe to retry; the server treats a repeated confirm as done).
|
|
99
|
+
await postJson(context, 'mode-transition', { action: 'confirm', target_mode: 'hosted-only' });
|
|
100
|
+
|
|
101
|
+
notes.push(
|
|
102
|
+
'Agent re-pointing: hosted context is now canonical. In your MCP configuration set VIBECOMPASS_API_KEY '
|
|
103
|
+
+ '(create a key on the hosted Setup page), remove VIBECOMPASS_ROOT, and restart your MCP server. '
|
|
104
|
+
+ 'Agent instruction files keep working for reading; canonical writes belong on the hosted dashboard.',
|
|
105
|
+
);
|
|
106
|
+
notes.push(
|
|
107
|
+
'This root is now a promoted working copy: canonical write commands refuse by default '
|
|
108
|
+
+ '(vibecompass demote-hosted reverses the cutover; VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1 overrides deliberately).',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
status: resuming && localMode !== 'local-primary' ? 'resumed-and-promoted' : 'promoted',
|
|
113
|
+
rootDir,
|
|
114
|
+
completeness,
|
|
115
|
+
warnings: notes,
|
|
116
|
+
message: 'Promotion complete: the hosted app is now this project\'s source of truth (both mode records agree).',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* D-289 demote: hosted-only -> local-primary. Run from a local root that
|
|
122
|
+
* holds the hosted content (freshly bootstrapped from the export bundle, or
|
|
123
|
+
* the original promoted root). Flips both records via the same two-phase
|
|
124
|
+
* endpoint, then adopts the hosted head as the sync cursor so the first push
|
|
125
|
+
* works immediately.
|
|
126
|
+
*/
|
|
127
|
+
export async function demoteHosted(options = {}, environment = {}) {
|
|
128
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
129
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
130
|
+
return withMemoryRootLock(rootDir, 'demote-hosted', () =>
|
|
131
|
+
demoteHostedLocked(options, environment, rootDir),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function demoteHostedLocked(options, environment, rootDir) {
|
|
136
|
+
const context = await loadModeAgnosticSyncContext(rootDir, options, environment);
|
|
137
|
+
const server = await getJson(context, 'status');
|
|
138
|
+
const notes = [];
|
|
139
|
+
|
|
140
|
+
if (server.mode === 'local-primary' && context.project.mode === 'local-primary') {
|
|
141
|
+
return {
|
|
142
|
+
status: 'already-local-primary',
|
|
143
|
+
rootDir,
|
|
144
|
+
warnings: [],
|
|
145
|
+
message: 'This project is already local-primary on both sides.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (server.mode === 'hosted-only') {
|
|
150
|
+
await postJson(context, 'mode-transition', { action: 'begin', target_mode: 'local-primary' });
|
|
151
|
+
}
|
|
152
|
+
if (context.project.mode !== 'local-primary') {
|
|
153
|
+
await writeProjectMode(rootDir, 'local-primary');
|
|
154
|
+
notes.push('Local project.yaml mode set to local-primary.');
|
|
155
|
+
}
|
|
156
|
+
await removePromotedMarker(rootDir);
|
|
157
|
+
await postJson(context, 'mode-transition', { action: 'confirm', target_mode: 'local-primary' });
|
|
158
|
+
|
|
159
|
+
// Adopt the hosted head as this root's baseline so the first push has the
|
|
160
|
+
// correct parent (bundle-restored roots already carry it; this makes the
|
|
161
|
+
// flow correct for any root).
|
|
162
|
+
const adoption = await adoptRemoteHead(
|
|
163
|
+
{
|
|
164
|
+
rootDir,
|
|
165
|
+
syncTarget: options.syncTarget ?? null,
|
|
166
|
+
acceptDivergence: options.acceptDivergence === true,
|
|
167
|
+
},
|
|
168
|
+
environment,
|
|
169
|
+
);
|
|
170
|
+
notes.push(adoption.message);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
status: 'demoted',
|
|
174
|
+
rootDir,
|
|
175
|
+
warnings: notes,
|
|
176
|
+
message: 'Demotion complete: this local root is canonical again (local-primary on both sides). Local files win; push to publish changes.',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function loadModeAgnosticSyncContext(rootDir, options, environment) {
|
|
181
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
182
|
+
const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
183
|
+
sourceName: projectFilePath,
|
|
184
|
+
});
|
|
185
|
+
const binding = resolveSyncBinding(project, options.syncTarget ?? null);
|
|
186
|
+
if (!binding) {
|
|
187
|
+
throw new Error('Mode transitions require a sync binding in project.yaml (run connect-hosted first).');
|
|
188
|
+
}
|
|
189
|
+
const credential = ((environment.env ?? process.env)[binding.credentialEnvVar] ?? '').trim();
|
|
190
|
+
if (!credential) {
|
|
191
|
+
throw new Error(`Mode transitions require ${binding.credentialEnvVar} to be set.`);
|
|
192
|
+
}
|
|
193
|
+
const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
|
|
194
|
+
if (typeof fetchImpl !== 'function') {
|
|
195
|
+
throw new Error('Mode transitions require a fetch implementation.');
|
|
196
|
+
}
|
|
197
|
+
return { rootDir, project, binding, credential, fetch: fetchImpl };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function endpointUrl(context, routeName) {
|
|
201
|
+
const base = context.binding.apiUrl.endsWith('/')
|
|
202
|
+
? context.binding.apiUrl
|
|
203
|
+
: `${context.binding.apiUrl}/`;
|
|
204
|
+
return new URL(
|
|
205
|
+
`api/sync/projects/${encodeURIComponent(context.binding.projectId)}/${routeName}`,
|
|
206
|
+
base,
|
|
207
|
+
).href;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function getJson(context, routeName) {
|
|
211
|
+
const response = await context.fetch(endpointUrl(context, routeName), {
|
|
212
|
+
headers: {
|
|
213
|
+
accept: 'application/json',
|
|
214
|
+
authorization: `Bearer ${context.credential}`,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const text = typeof response.text === 'function' ? await response.text() : '';
|
|
219
|
+
throw new Error(`Hosted ${routeName} failed with ${response.status}${text ? `: ${text}` : ''}`);
|
|
220
|
+
}
|
|
221
|
+
return typeof response.json === 'function' ? response.json() : {};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function postJson(context, routeName, body) {
|
|
225
|
+
const response = await context.fetch(endpointUrl(context, routeName), {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
accept: 'application/json',
|
|
229
|
+
authorization: `Bearer ${context.credential}`,
|
|
230
|
+
'content-type': 'application/json',
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify(body),
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
const text = typeof response.text === 'function' ? await response.text() : '';
|
|
236
|
+
throw new Error(`Hosted ${routeName} failed with ${response.status}${text ? `: ${text}` : ''}`);
|
|
237
|
+
}
|
|
238
|
+
return typeof response.json === 'function' ? response.json() : {};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function writeProjectMode(rootDir, mode) {
|
|
242
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
243
|
+
const raw = await readFile(projectFilePath, 'utf8');
|
|
244
|
+
const next = raw.replace(/^mode:\s*\S+\s*$/m, `mode: ${mode}`);
|
|
245
|
+
if (next === raw && !new RegExp(`^mode:\\s*${mode}\\s*$`, 'm').test(raw)) {
|
|
246
|
+
throw new Error(`Could not update the mode line in ${projectFilePath}; edit it to "mode: ${mode}" and re-run with --resume.`);
|
|
247
|
+
}
|
|
248
|
+
await writeFile(projectFilePath, next, 'utf8');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function ensurePromotedMarker(rootDir, context) {
|
|
252
|
+
const markerPath = path.join(rootDir, ...PROMOTED_MARKER);
|
|
253
|
+
await mkdir(path.dirname(markerPath), { recursive: true });
|
|
254
|
+
const marker = {
|
|
255
|
+
promoted_at: new Date().toISOString(),
|
|
256
|
+
api_url: context.binding.apiUrl,
|
|
257
|
+
project_id: context.binding.projectId,
|
|
258
|
+
note: 'This root was promoted to hosted-only (D-288). The hosted app is canonical; local canonical writes refuse by default.',
|
|
259
|
+
};
|
|
260
|
+
await writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function removePromotedMarker(rootDir) {
|
|
264
|
+
await rm(path.join(rootDir, ...PROMOTED_MARKER), { force: true });
|
|
265
|
+
}
|