@vibecompass/vibecompass 0.6.0 → 0.8.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 +14 -0
- package/README.md +95 -691
- package/package.json +1 -1
- package/src/cli.js +170 -4
- package/src/docs-review.js +641 -24
- package/src/init.js +139 -1
- package/src/setup.js +1 -0
- package/src/status.js +55 -1
- package/src/sync-binding.js +307 -0
- package/src/sync.js +31 -24
- package/src/workflows/registry.js +9 -5
package/src/init.js
CHANGED
|
@@ -6,6 +6,13 @@ import { scaffoldInitFiles } from './scaffold.js';
|
|
|
6
6
|
import { applyPlacementDefaults } from './setup.js';
|
|
7
7
|
import { buildWorkflowMetadata } from './workflow.js';
|
|
8
8
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
9
|
+
import {
|
|
10
|
+
assertValidSyncTargetName,
|
|
11
|
+
buildSyncSectionWithTargets,
|
|
12
|
+
mirrorTargetCursorToFlat,
|
|
13
|
+
readSyncTargets,
|
|
14
|
+
reconcileCursorsAfterConnect,
|
|
15
|
+
} from './sync-binding.js';
|
|
9
16
|
import { PACKAGE_VERSION } from './version.js';
|
|
10
17
|
|
|
11
18
|
const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
|
|
@@ -68,15 +75,146 @@ export async function connectHostedProjectMemory(options) {
|
|
|
68
75
|
throw new Error('connect-hosted requires project.yaml mode to be local-primary or hosted-only.');
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
const targetName = typeof options?.targetName === 'string' && options.targetName.trim() !== ''
|
|
79
|
+
? assertValidSyncTargetName(options.targetName.trim())
|
|
80
|
+
: null;
|
|
81
|
+
const existingTargets = readSyncTargets(projectConfig);
|
|
82
|
+
|
|
83
|
+
if (!targetName && existingTargets) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`This project uses named sync targets (${Object.keys(existingTargets.targets).join(', ')}). Pass --target <name> to add or update one, or use "vibecompass sync-target <name>" to switch the default.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const previousFlatSync = projectConfig.sync && typeof projectConfig.sync === 'object'
|
|
90
|
+
? projectConfig.sync
|
|
91
|
+
: null;
|
|
71
92
|
const sync = normalizeSyncOptions(options?.sync, projectConfig.mode);
|
|
72
|
-
|
|
93
|
+
|
|
94
|
+
let syncTarget = null;
|
|
95
|
+
if (targetName) {
|
|
96
|
+
const targets = { ...(existingTargets?.targets ?? {}) };
|
|
97
|
+
targets[targetName] = {
|
|
98
|
+
api_url: sync.api_url,
|
|
99
|
+
project_id: sync.project_id,
|
|
100
|
+
credential_env_var: sync.credential_env_var,
|
|
101
|
+
};
|
|
102
|
+
const defaultTarget = existingTargets?.defaultTarget ?? targetName;
|
|
103
|
+
projectConfig.sync = buildSyncSectionWithTargets(targets, defaultTarget);
|
|
104
|
+
syncTarget = { name: targetName, defaultTarget };
|
|
105
|
+
} else {
|
|
106
|
+
projectConfig.sync = sync;
|
|
107
|
+
}
|
|
108
|
+
|
|
73
109
|
await writeFile(projectFilePath, serializeProjectConfig(projectConfig), 'utf8');
|
|
74
110
|
|
|
111
|
+
// D-237: reconcile manifest cursor state with the (re)bound target —
|
|
112
|
+
// first-conversion cursor migration, dropping a rebound target's stale
|
|
113
|
+
// cursor, and re-mirroring/clearing the flat cursor when the default
|
|
114
|
+
// target's binding changed (so ≤0.7.0 CLIs can't pair the new flat binding
|
|
115
|
+
// with a previous environment's cursor).
|
|
116
|
+
if (targetName) {
|
|
117
|
+
await updateManifestSyncState(rootDir, (manifestSync) =>
|
|
118
|
+
reconcileCursorsAfterConnect(
|
|
119
|
+
manifestSync,
|
|
120
|
+
targetName,
|
|
121
|
+
{ apiUrl: sync.api_url, projectId: sync.project_id },
|
|
122
|
+
{
|
|
123
|
+
isDefault: syncTarget.defaultTarget === targetName,
|
|
124
|
+
isFirstConversion: !existingTargets,
|
|
125
|
+
flatBindingMatches: Boolean(
|
|
126
|
+
previousFlatSync &&
|
|
127
|
+
previousFlatSync.api_url === sync.api_url &&
|
|
128
|
+
previousFlatSync.project_id === sync.project_id,
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
75
135
|
return {
|
|
76
136
|
rootDir,
|
|
77
137
|
projectFilePath,
|
|
78
138
|
mode: projectConfig.mode,
|
|
79
139
|
syncEnvVar: sync.credential_env_var,
|
|
140
|
+
syncTarget,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function updateManifestSyncState(rootDir, updater) {
|
|
145
|
+
let manifest;
|
|
146
|
+
try {
|
|
147
|
+
manifest = JSON.parse(
|
|
148
|
+
await readFile(path.join(rootDir, 'state', 'manifest.json'), 'utf8'),
|
|
149
|
+
);
|
|
150
|
+
} catch {
|
|
151
|
+
return; // no manifest yet — nothing to migrate
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const nextSync = updater(
|
|
155
|
+
manifest?.sync && typeof manifest.sync === 'object' ? manifest.sync : {},
|
|
156
|
+
);
|
|
157
|
+
if (!nextSync) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await writeStateManifest(rootDir, { sync: nextSync });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function setDefaultSyncTarget(options) {
|
|
165
|
+
const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
166
|
+
const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
|
|
167
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
168
|
+
const projectConfig = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
169
|
+
sourceName: projectFilePath,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const named = readSyncTargets(projectConfig);
|
|
173
|
+
if (!named) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'No named sync targets are defined in project.yaml. Run "vibecompass connect-hosted --target <name>" to add one.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const targetName = typeof options?.targetName === 'string' && options.targetName.trim() !== ''
|
|
180
|
+
? options.targetName.trim()
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
if (!targetName) {
|
|
184
|
+
return {
|
|
185
|
+
rootDir,
|
|
186
|
+
projectFilePath,
|
|
187
|
+
changed: false,
|
|
188
|
+
defaultTarget: named.defaultTarget,
|
|
189
|
+
targets: named.targets,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!named.targets[targetName]) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Unknown sync target "${targetName}". Available targets: ${Object.keys(named.targets).join(', ')}.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
projectConfig.sync = buildSyncSectionWithTargets(named.targets, targetName);
|
|
200
|
+
await writeFile(projectFilePath, serializeProjectConfig(projectConfig), 'utf8');
|
|
201
|
+
|
|
202
|
+
// D-237: the flat manifest cursor mirrors the default target so ≤0.7.0 CLIs
|
|
203
|
+
// stay correct; re-mirror it from the new default (clearing it when the new
|
|
204
|
+
// default has no identity-matching cursor yet).
|
|
205
|
+
await updateManifestSyncState(rootDir, (manifestSync) =>
|
|
206
|
+
mirrorTargetCursorToFlat(manifestSync, targetName, {
|
|
207
|
+
apiUrl: named.targets[targetName].api_url,
|
|
208
|
+
projectId: named.targets[targetName].project_id,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
rootDir,
|
|
214
|
+
projectFilePath,
|
|
215
|
+
changed: named.defaultTarget !== targetName,
|
|
216
|
+
defaultTarget: targetName,
|
|
217
|
+
targets: named.targets,
|
|
80
218
|
};
|
|
81
219
|
}
|
|
82
220
|
|
package/src/setup.js
CHANGED
|
@@ -139,6 +139,7 @@ export async function resolveConnectHostedCliOptions(options, environment = {})
|
|
|
139
139
|
|
|
140
140
|
return {
|
|
141
141
|
rootDir: resolved.rootDir ? toRelativeIfInsideCwd(resolved.rootDir, cwd) : undefined,
|
|
142
|
+
...(resolved.targetName ? { targetName: resolved.targetName } : {}),
|
|
142
143
|
sync: resolved.sync,
|
|
143
144
|
};
|
|
144
145
|
}
|
package/src/status.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { inspectProjectCompatibility } from './compatibility.js';
|
|
4
|
+
import { sha256Text } from './hash.js';
|
|
4
5
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
5
6
|
import { listProjectSessions } from './session.js';
|
|
6
7
|
import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
|
|
@@ -94,6 +95,9 @@ export function renderStatusText(status) {
|
|
|
94
95
|
'',
|
|
95
96
|
'Docs review:',
|
|
96
97
|
`- ${formatDocsReviewStatus(status.docsReview)}`,
|
|
98
|
+
...(status.docsReview.outputDrift?.hasDrift
|
|
99
|
+
? [`- Output drift: ${status.docsReview.outputDrift.reasons.join('; ')}`]
|
|
100
|
+
: []),
|
|
97
101
|
'',
|
|
98
102
|
'Agent files:',
|
|
99
103
|
...formatAgentFileStatus(status.agentFiles),
|
|
@@ -151,6 +155,20 @@ async function readProjectSummary(projectFilePath) {
|
|
|
151
155
|
projectId: typeof config.sync.project_id === 'string' ? config.sync.project_id : null,
|
|
152
156
|
credentialSource: typeof config.sync.credential_source === 'string' ? config.sync.credential_source : null,
|
|
153
157
|
credentialEnvVar: typeof config.sync.credential_env_var === 'string' ? config.sync.credential_env_var : null,
|
|
158
|
+
defaultTarget:
|
|
159
|
+
typeof config.sync.default_target === 'string' ? config.sync.default_target : null,
|
|
160
|
+
targets:
|
|
161
|
+
config.sync.targets && typeof config.sync.targets === 'object'
|
|
162
|
+
? Object.entries(config.sync.targets)
|
|
163
|
+
.filter(([, target]) => target && typeof target === 'object')
|
|
164
|
+
.map(([name, target]) => ({
|
|
165
|
+
name,
|
|
166
|
+
apiUrl: typeof target.api_url === 'string' ? target.api_url : null,
|
|
167
|
+
projectId: typeof target.project_id === 'string' ? target.project_id : null,
|
|
168
|
+
credentialEnvVar:
|
|
169
|
+
typeof target.credential_env_var === 'string' ? target.credential_env_var : null,
|
|
170
|
+
}))
|
|
171
|
+
: [],
|
|
154
172
|
}
|
|
155
173
|
: null,
|
|
156
174
|
};
|
|
@@ -192,12 +210,15 @@ async function readDocsReviewStatus(rootDir) {
|
|
|
192
210
|
const markerPath = path.join(rootDir, 'state', 'docs-review.json');
|
|
193
211
|
try {
|
|
194
212
|
const marker = JSON.parse(await readFile(markerPath, 'utf8'));
|
|
213
|
+
const outputDrift = await readDocsReviewOutputDrift(rootDir, marker);
|
|
195
214
|
return {
|
|
196
215
|
status: typeof marker.status === 'string' ? marker.status : 'unknown',
|
|
197
216
|
markerPath,
|
|
198
217
|
completedAt: typeof marker.completed_at === 'string' ? marker.completed_at : null,
|
|
199
218
|
provider: typeof marker.provider === 'string' ? marker.provider : null,
|
|
200
219
|
model: typeof marker.model === 'string' ? marker.model : null,
|
|
220
|
+
applied: marker.applied ?? null,
|
|
221
|
+
outputDrift,
|
|
201
222
|
};
|
|
202
223
|
} catch (error) {
|
|
203
224
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
@@ -215,6 +236,36 @@ async function readDocsReviewStatus(rootDir) {
|
|
|
215
236
|
}
|
|
216
237
|
}
|
|
217
238
|
|
|
239
|
+
async function readDocsReviewOutputDrift(rootDir, marker) {
|
|
240
|
+
const applied = marker?.applied;
|
|
241
|
+
if (!applied || typeof applied !== 'object') {
|
|
242
|
+
return { status: 'not-tracked', hasDrift: false, reasons: [] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const reasons = [];
|
|
246
|
+
if (typeof applied.output_path === 'string' && typeof applied.output_hash === 'string') {
|
|
247
|
+
try {
|
|
248
|
+
const outputContent = await readFile(applied.output_path, 'utf8');
|
|
249
|
+
const currentOutputHash = sha256Text(outputContent);
|
|
250
|
+
if (currentOutputHash !== applied.output_hash) {
|
|
251
|
+
reasons.push('state/docs-review-output.md changed since apply');
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
255
|
+
reasons.push('state/docs-review-output.md is missing since apply');
|
|
256
|
+
} else {
|
|
257
|
+
reasons.push('state/docs-review-output.md could not be read for drift check');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
status: reasons.length > 0 ? 'drift' : 'current',
|
|
264
|
+
hasDrift: reasons.length > 0,
|
|
265
|
+
reasons,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
218
269
|
async function readAgentFileStatus(rootDir, toolingRootDir) {
|
|
219
270
|
try {
|
|
220
271
|
const result = await syncAgentInstructionFiles({
|
|
@@ -257,8 +308,10 @@ function buildRecommendations({ project, compatibility, sessions, docsReview, ag
|
|
|
257
308
|
recommendations.push('vibecompass refresh-workflow --dry-run');
|
|
258
309
|
}
|
|
259
310
|
|
|
260
|
-
if (docsReview.status === 'missing') {
|
|
311
|
+
if (docsReview.status === 'missing' || docsReview.status === 'rebuild-ready') {
|
|
261
312
|
recommendations.push('vibecompass docs-review --guided');
|
|
313
|
+
} else if (docsReview.outputDrift?.hasDrift) {
|
|
314
|
+
recommendations.push('vibecompass docs-review --apply-output');
|
|
262
315
|
}
|
|
263
316
|
|
|
264
317
|
if (agentFiles.status === 'ok' && agentFiles.results.some((item) => item.changed || item.warning)) {
|
|
@@ -278,6 +331,7 @@ function hasStatusDrift(status) {
|
|
|
278
331
|
return (
|
|
279
332
|
status.compatibility.warnings.length > 0 ||
|
|
280
333
|
status.docsReview.status !== 'completed' ||
|
|
334
|
+
Boolean(status.docsReview.outputDrift?.hasDrift) ||
|
|
281
335
|
status.agentFiles.status !== 'ok' ||
|
|
282
336
|
status.agentFiles.results.some((item) => item.changed || item.warning)
|
|
283
337
|
);
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
const TARGET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
2
|
+
|
|
3
|
+
function normalizeBindingFields(source) {
|
|
4
|
+
const apiUrl = typeof source?.api_url === 'string' && source.api_url.trim() !== ''
|
|
5
|
+
? source.api_url
|
|
6
|
+
: null;
|
|
7
|
+
const projectId = typeof source?.project_id === 'string' && source.project_id.trim() !== ''
|
|
8
|
+
? source.project_id
|
|
9
|
+
: null;
|
|
10
|
+
const credentialEnvVar = typeof source?.credential_env_var === 'string' && source.credential_env_var.trim() !== ''
|
|
11
|
+
? source.credential_env_var
|
|
12
|
+
: null;
|
|
13
|
+
|
|
14
|
+
if (!apiUrl || !projectId || !credentialEnvVar) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { apiUrl, projectId, credentialEnvVar };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertValidSyncTargetName(name) {
|
|
22
|
+
if (typeof name !== 'string' || !TARGET_NAME_PATTERN.test(name)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Sync target name "${name}" is invalid. Use lowercase letters, digits, hyphens, or underscores (e.g. dev, prod).`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readSyncTargets(projectConfig) {
|
|
31
|
+
const sync = projectConfig?.sync;
|
|
32
|
+
if (!sync || typeof sync !== 'object') {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const targets = sync.targets && typeof sync.targets === 'object' ? sync.targets : null;
|
|
37
|
+
if (!targets || Object.keys(targets).length === 0) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultTarget = typeof sync.default_target === 'string' && targets[sync.default_target]
|
|
42
|
+
? sync.default_target
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
return { defaultTarget, targets };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolves the effective hosted sync binding for a command (D-236).
|
|
50
|
+
*
|
|
51
|
+
* Resolution order: explicit target name -> sync.default_target -> legacy
|
|
52
|
+
* flat sync fields. Returns null when no complete binding exists. Throws on
|
|
53
|
+
* an explicitly requested target that is unknown or incomplete, because a
|
|
54
|
+
* typo must never silently fall back to a different environment.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveSyncBinding(projectConfig, targetName) {
|
|
57
|
+
const sync = projectConfig?.sync;
|
|
58
|
+
if (!sync || typeof sync !== 'object') {
|
|
59
|
+
if (targetName) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`--sync-target ${targetName} requires a sync section in project.yaml. Run "vibecompass connect-hosted --target ${targetName}" first.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const named = readSyncTargets(projectConfig);
|
|
68
|
+
|
|
69
|
+
if (targetName) {
|
|
70
|
+
const target = named?.targets?.[targetName];
|
|
71
|
+
if (!target || typeof target !== 'object') {
|
|
72
|
+
const available = named ? Object.keys(named.targets) : [];
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Unknown sync target "${targetName}". ${
|
|
75
|
+
available.length > 0
|
|
76
|
+
? `Available targets: ${available.join(', ')}.`
|
|
77
|
+
: 'No named sync targets are defined in project.yaml; run "vibecompass connect-hosted --target <name>" to add one.'
|
|
78
|
+
}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const fields = normalizeBindingFields(target);
|
|
83
|
+
if (!fields) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Sync target "${targetName}" is incomplete. It must define api_url, project_id, and credential_env_var.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { target: targetName, isDefault: targetName === named.defaultTarget, ...fields };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (named?.defaultTarget) {
|
|
93
|
+
const fields = normalizeBindingFields(named.targets[named.defaultTarget]);
|
|
94
|
+
if (fields) {
|
|
95
|
+
return { target: named.defaultTarget, isDefault: true, ...fields };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const flat = normalizeBindingFields(sync);
|
|
100
|
+
return flat ? { target: null, isDefault: true, ...flat } : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const CURSOR_FIELDS = [
|
|
104
|
+
'last_successful_remote_revision',
|
|
105
|
+
'last_successful_local_root_revision',
|
|
106
|
+
'last_successful_manifest_hash',
|
|
107
|
+
'last_sync_direction',
|
|
108
|
+
'last_sync_at',
|
|
109
|
+
'pending_previews',
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
function pickCursorFields(source) {
|
|
113
|
+
const cursor = {};
|
|
114
|
+
for (const field of CURSOR_FIELDS) {
|
|
115
|
+
if (source?.[field] !== undefined) {
|
|
116
|
+
cursor[field] = source[field];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return cursor;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reads the sync cursor for the resolved binding (D-237). Legacy flat
|
|
124
|
+
* bindings keep using the flat manifest.sync fields. Named targets read only
|
|
125
|
+
* their own cursor, and only when its recorded api_url/project_id identity
|
|
126
|
+
* matches the current binding — otherwise the cursor is treated as absent so
|
|
127
|
+
* a stale or cross-environment cursor can never become another environment's
|
|
128
|
+
* baseline.
|
|
129
|
+
*/
|
|
130
|
+
export function readSyncCursor(manifestSync, binding) {
|
|
131
|
+
if (!manifestSync || typeof manifestSync !== 'object') {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!binding?.target) {
|
|
136
|
+
return pickCursorFields(manifestSync);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const cursor = manifestSync.targets?.[binding.target];
|
|
140
|
+
if (
|
|
141
|
+
!cursor ||
|
|
142
|
+
typeof cursor !== 'object' ||
|
|
143
|
+
cursor.api_url !== binding.apiUrl ||
|
|
144
|
+
cursor.project_id !== binding.projectId
|
|
145
|
+
) {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return pickCursorFields(cursor);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Builds the next manifest.sync value after a sync operation for the resolved
|
|
154
|
+
* binding (D-237). Named targets write into manifest.sync.targets.<name>
|
|
155
|
+
* (stamped with their api_url/project_id identity); when the binding is the
|
|
156
|
+
* default target, the flat fields mirror the same cursor so ≤0.7.0 CLIs stay
|
|
157
|
+
* correct on the default environment. Legacy flat bindings keep writing the
|
|
158
|
+
* flat fields only.
|
|
159
|
+
*/
|
|
160
|
+
export function buildSyncStateWithCursor(manifestSync, binding, cursor) {
|
|
161
|
+
const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
|
|
162
|
+
const cursorFields = pickCursorFields(cursor);
|
|
163
|
+
|
|
164
|
+
if (!binding?.target) {
|
|
165
|
+
return { ...existing, ...cursorFields };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const targets = {
|
|
169
|
+
...(existing.targets && typeof existing.targets === 'object' ? existing.targets : {}),
|
|
170
|
+
[binding.target]: {
|
|
171
|
+
api_url: binding.apiUrl,
|
|
172
|
+
project_id: binding.projectId,
|
|
173
|
+
...cursorFields,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...existing,
|
|
179
|
+
...(binding.isDefault ? cursorFields : {}),
|
|
180
|
+
targets,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Migrates a legacy flat manifest cursor into a named target's cursor slot
|
|
186
|
+
* (D-237). Only applies when the new target's binding values match the flat
|
|
187
|
+
* binding the cursor was written against, the target has no cursor yet, and
|
|
188
|
+
* a flat cursor actually exists. Returns the next manifest.sync value, or
|
|
189
|
+
* null when nothing should change.
|
|
190
|
+
*/
|
|
191
|
+
export function migrateFlatCursorToTarget(manifestSync, targetName, bindingFields) {
|
|
192
|
+
const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
|
|
193
|
+
const targets = existing.targets && typeof existing.targets === 'object' ? existing.targets : {};
|
|
194
|
+
const flatCursor = pickCursorFields(existing);
|
|
195
|
+
|
|
196
|
+
if (targets[targetName] || Object.keys(flatCursor).length === 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...existing,
|
|
202
|
+
targets: {
|
|
203
|
+
...targets,
|
|
204
|
+
[targetName]: {
|
|
205
|
+
api_url: bindingFields.apiUrl,
|
|
206
|
+
project_id: bindingFields.projectId,
|
|
207
|
+
...flatCursor,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Reconciles manifest cursor state after connect-hosted (re)binds a named
|
|
215
|
+
* target (D-237). Handles, in order:
|
|
216
|
+
* 1. first flat→named conversion with matching values: migrate the flat
|
|
217
|
+
* cursor into the target's slot;
|
|
218
|
+
* 2. rebinding an existing target to a different api_url/project_id: drop its
|
|
219
|
+
* now-wrong cursor;
|
|
220
|
+
* 3. when the (re)bound target is the default: re-mirror the flat cursor from
|
|
221
|
+
* the target's identity-matching cursor, or clear it — so an older CLI can
|
|
222
|
+
* never combine the new flat binding with a previous environment's cursor.
|
|
223
|
+
* Returns the next manifest.sync value, or null when nothing changes.
|
|
224
|
+
*/
|
|
225
|
+
export function reconcileCursorsAfterConnect(manifestSync, targetName, bindingFields, options = {}) {
|
|
226
|
+
const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
|
|
227
|
+
let next = existing;
|
|
228
|
+
let changed = false;
|
|
229
|
+
|
|
230
|
+
if (options.isFirstConversion && options.flatBindingMatches) {
|
|
231
|
+
const migrated = migrateFlatCursorToTarget(next, targetName, bindingFields);
|
|
232
|
+
if (migrated) {
|
|
233
|
+
next = migrated;
|
|
234
|
+
changed = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const targetCursor = next.targets?.[targetName];
|
|
239
|
+
if (
|
|
240
|
+
targetCursor &&
|
|
241
|
+
typeof targetCursor === 'object' &&
|
|
242
|
+
(targetCursor.api_url !== bindingFields.apiUrl ||
|
|
243
|
+
targetCursor.project_id !== bindingFields.projectId)
|
|
244
|
+
) {
|
|
245
|
+
const targets = { ...next.targets };
|
|
246
|
+
delete targets[targetName];
|
|
247
|
+
next = { ...next, targets };
|
|
248
|
+
changed = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (options.isDefault) {
|
|
252
|
+
const mirrored = mirrorTargetCursorToFlat(next, targetName, bindingFields);
|
|
253
|
+
if (JSON.stringify(mirrored) !== JSON.stringify(next)) {
|
|
254
|
+
next = mirrored;
|
|
255
|
+
changed = true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return changed ? next : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Re-mirrors the flat manifest cursor from a (new default) target's cursor
|
|
264
|
+
* (D-237). When the target has no identity-matching cursor, the flat cursor
|
|
265
|
+
* fields are cleared so an older CLI cannot reuse the previous default's
|
|
266
|
+
* cursor against the new default environment.
|
|
267
|
+
*/
|
|
268
|
+
export function mirrorTargetCursorToFlat(manifestSync, targetName, bindingFields) {
|
|
269
|
+
const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
|
|
270
|
+
const base = {};
|
|
271
|
+
for (const [key, value] of Object.entries(existing)) {
|
|
272
|
+
if (!CURSOR_FIELDS.includes(key)) {
|
|
273
|
+
base[key] = value;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const target = existing.targets?.[targetName];
|
|
278
|
+
const identityMatches =
|
|
279
|
+
target &&
|
|
280
|
+
typeof target === 'object' &&
|
|
281
|
+
target.api_url === bindingFields.apiUrl &&
|
|
282
|
+
target.project_id === bindingFields.projectId;
|
|
283
|
+
|
|
284
|
+
return identityMatches ? { ...base, ...pickCursorFields(target) } : base;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Builds the project.yaml sync section for a set of named targets. The flat
|
|
289
|
+
* fields always mirror the default target so older CLIs that only read the
|
|
290
|
+
* flat binding keep working against the default environment.
|
|
291
|
+
*/
|
|
292
|
+
export function buildSyncSectionWithTargets(targets, defaultTarget) {
|
|
293
|
+
const defaultFields = normalizeBindingFields(targets[defaultTarget]);
|
|
294
|
+
if (!defaultFields) {
|
|
295
|
+
throw new Error(`Default sync target "${defaultTarget}" is incomplete.`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
provider: 'vibecompass',
|
|
300
|
+
api_url: defaultFields.apiUrl,
|
|
301
|
+
project_id: defaultFields.projectId,
|
|
302
|
+
credential_source: 'env',
|
|
303
|
+
credential_env_var: defaultFields.credentialEnvVar,
|
|
304
|
+
default_target: defaultTarget,
|
|
305
|
+
targets,
|
|
306
|
+
};
|
|
307
|
+
}
|