@vibecompass/vibecompass 0.7.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 +9 -0
- package/README.md +23 -1
- package/package.json +1 -1
- package/src/cli.js +120 -3
- package/src/docs-review.js +22 -16
- package/src/init.js +139 -1
- package/src/setup.js +1 -0
- package/src/status.js +14 -0
- package/src/sync-binding.js +307 -0
- package/src/sync.js +31 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 - 2026-06-12
|
|
4
|
+
|
|
5
|
+
- Add named sync targets (D-236): `connect-hosted --target <name>` binds one project root to multiple hosted environments (for example `dev` on localhost and `prod` on vibecompass.dev).
|
|
6
|
+
- Add a `sync-target` command to list named targets and switch the default; flat `sync` fields in project.yaml always mirror the default target so older CLIs keep working.
|
|
7
|
+
- Add `--sync-target <name>` to `push`, `pull-preview`, `pull-export`, and `docs-review` for per-command environment selection; unknown target names error instead of falling back.
|
|
8
|
+
- Partition local sync cursor state per target (D-237): each target keeps its own last-pushed revision and pull previews, identity-checked against its URL and project id, so one environment's history can never become another environment's baseline.
|
|
9
|
+
- Carry the existing sync history over when a flat binding is converted into its first named target with the same URL and project id.
|
|
10
|
+
- Record the submitted sync target in the docs-review marker so `--poll-hosted` stays pinned to the environment the run was submitted to.
|
|
11
|
+
|
|
3
12
|
## 0.7.0 - 2026-05-30
|
|
4
13
|
|
|
5
14
|
- Add `docs-review --rebuild` with dry-run preview, scoped architecture paths, and archive-based stale-doc preparation.
|
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ After setup, use these phrases inside your AI coding tool:
|
|
|
125
125
|
- `planning mode`
|
|
126
126
|
- `review handoff`
|
|
127
127
|
- `address review`
|
|
128
|
-
- `close session`
|
|
128
|
+
- `close session` / `end session`
|
|
129
129
|
- `docs review`
|
|
130
130
|
|
|
131
131
|
The npm package owns the file mechanics. Your AI session owns reading the
|
|
@@ -146,6 +146,28 @@ vibecompass connect-hosted \
|
|
|
146
146
|
vibecompass push --root .compass
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
### Multiple environments
|
|
150
|
+
|
|
151
|
+
One project can have named sync targets (for example a local dev server and
|
|
152
|
+
production) so you never have to rebind:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# add targets once — the first one becomes the default
|
|
156
|
+
vibecompass connect-hosted --root .compass --target dev \
|
|
157
|
+
--sync-api-url http://localhost:3000 --sync-project-id vc_proj_dev \
|
|
158
|
+
--sync-credential-env-var VIBECOMPASS_SYNC_TOKEN
|
|
159
|
+
vibecompass connect-hosted --root .compass --target prod \
|
|
160
|
+
--sync-api-url https://vibecompass.dev --sync-project-id vc_proj_prod \
|
|
161
|
+
--sync-credential-env-var VIBECOMPASS_SYNC_TOKEN_PROD
|
|
162
|
+
|
|
163
|
+
# then pick per command, or switch the default
|
|
164
|
+
vibecompass push --root .compass --sync-target prod
|
|
165
|
+
vibecompass sync-target prod --root .compass
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Tip: if you already have a single (flat) binding, add it as your first named
|
|
169
|
+
target using the same URL and project id — its sync history carries over.
|
|
170
|
+
|
|
149
171
|
See https://vibecompass.dev/developers for the full local-primary workflow.
|
|
150
172
|
|
|
151
173
|
## JavaScript API
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { realpathSync } from 'node:fs';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { connectHostedProjectMemory, initializeProjectMemory } from './init.js';
|
|
5
|
+
import { connectHostedProjectMemory, initializeProjectMemory, setDefaultSyncTarget } from './init.js';
|
|
6
6
|
import { preflightDocsReview } from './docs-review.js';
|
|
7
7
|
import { resolveConnectHostedCliOptions, resolveInitCliOptions } from './setup.js';
|
|
8
8
|
import { closeProjectSession, listProjectSessions, startProjectSession, switchProjectSession } from './session.js';
|
|
@@ -140,6 +140,10 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
|
|
|
140
140
|
});
|
|
141
141
|
io.stdout.write(`Connected hosted VibeCompass for ${result.mode}\n`);
|
|
142
142
|
io.stdout.write(`Updated ${result.projectFilePath}\n`);
|
|
143
|
+
if (result.syncTarget) {
|
|
144
|
+
io.stdout.write(`Sync target: ${result.syncTarget.name} (default: ${result.syncTarget.defaultTarget})\n`);
|
|
145
|
+
io.stdout.write(`Select per command with --sync-target ${result.syncTarget.name}, or switch the default with: vibecompass sync-target ${result.syncTarget.name}\n`);
|
|
146
|
+
}
|
|
143
147
|
io.stdout.write(`Next step: set ${result.syncEnvVar} locally before your first hosted command.\n`);
|
|
144
148
|
if (result.mode === 'local-primary') {
|
|
145
149
|
io.stdout.write('Then run: vibecompass push\n');
|
|
@@ -150,6 +154,28 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
|
|
|
150
154
|
return 0;
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
if (parsed.command === 'sync-target') {
|
|
158
|
+
const result = await setDefaultSyncTarget({
|
|
159
|
+
...parsed.options,
|
|
160
|
+
...(runtime.cwd ? { cwd: runtime.cwd } : {}),
|
|
161
|
+
});
|
|
162
|
+
if (parsed.options.targetName) {
|
|
163
|
+
io.stdout.write(
|
|
164
|
+
result.changed
|
|
165
|
+
? `Default sync target set to ${result.defaultTarget}\n`
|
|
166
|
+
: `Default sync target already ${result.defaultTarget}\n`,
|
|
167
|
+
);
|
|
168
|
+
io.stdout.write(`Updated ${result.projectFilePath}\n`);
|
|
169
|
+
} else {
|
|
170
|
+
io.stdout.write(`Default sync target: ${result.defaultTarget ?? '(none)'}\n`);
|
|
171
|
+
}
|
|
172
|
+
for (const [name, target] of Object.entries(result.targets)) {
|
|
173
|
+
const marker = name === result.defaultTarget ? '*' : ' ';
|
|
174
|
+
io.stdout.write(`${marker} ${name}: ${target.api_url} (project ${target.project_id}, env ${target.credential_env_var})\n`);
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
if (parsed.command === 'status') {
|
|
154
180
|
const result = await getProjectStatus({
|
|
155
181
|
...parsed.options,
|
|
@@ -401,6 +427,10 @@ export function parseCliArgs(argv) {
|
|
|
401
427
|
return parseConnectHostedArgs(rest);
|
|
402
428
|
}
|
|
403
429
|
|
|
430
|
+
if (command === 'sync-target') {
|
|
431
|
+
return parseSyncTargetArgs(rest);
|
|
432
|
+
}
|
|
433
|
+
|
|
404
434
|
if (command === 'status') {
|
|
405
435
|
return parseStatusArgs(rest);
|
|
406
436
|
}
|
|
@@ -702,6 +732,9 @@ function parseConnectHostedArgs(argv) {
|
|
|
702
732
|
case '--root':
|
|
703
733
|
parsed.rootDir = value;
|
|
704
734
|
break;
|
|
735
|
+
case '--target':
|
|
736
|
+
parsed.targetName = value;
|
|
737
|
+
break;
|
|
705
738
|
case '--sync-api-url':
|
|
706
739
|
parsed.syncApiUrl = value;
|
|
707
740
|
break;
|
|
@@ -726,6 +759,7 @@ function parseConnectHostedArgs(argv) {
|
|
|
726
759
|
command: 'connect-hosted',
|
|
727
760
|
options: {
|
|
728
761
|
rootDir: parsed.rootDir,
|
|
762
|
+
...(parsed.targetName ? { targetName: parsed.targetName } : {}),
|
|
729
763
|
...(syncValues.length > 0
|
|
730
764
|
? {
|
|
731
765
|
sync: {
|
|
@@ -1128,6 +1162,9 @@ function parseDocsReviewArgs(argv) {
|
|
|
1128
1162
|
case '--artifact':
|
|
1129
1163
|
parsed.artifactId = value;
|
|
1130
1164
|
break;
|
|
1165
|
+
case '--sync-target':
|
|
1166
|
+
parsed.syncTarget = value;
|
|
1167
|
+
break;
|
|
1131
1168
|
case '--max-tokens':
|
|
1132
1169
|
parsed.maxTokens = value;
|
|
1133
1170
|
break;
|
|
@@ -1145,7 +1182,72 @@ function parseDocsReviewArgs(argv) {
|
|
|
1145
1182
|
function parsePushArgs(argv) {
|
|
1146
1183
|
return {
|
|
1147
1184
|
command: 'push',
|
|
1148
|
-
options:
|
|
1185
|
+
options: parseRootAndSyncTargetArgs(argv, 'push'),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function parseRootAndSyncTargetArgs(argv, commandName) {
|
|
1190
|
+
const parsed = {};
|
|
1191
|
+
|
|
1192
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1193
|
+
const token = argv[index];
|
|
1194
|
+
if (!token.startsWith('--')) {
|
|
1195
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const value = argv[index + 1];
|
|
1199
|
+
if (value === undefined) {
|
|
1200
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1201
|
+
}
|
|
1202
|
+
index += 1;
|
|
1203
|
+
|
|
1204
|
+
switch (token) {
|
|
1205
|
+
case '--root':
|
|
1206
|
+
parsed.rootDir = value;
|
|
1207
|
+
break;
|
|
1208
|
+
case '--sync-target':
|
|
1209
|
+
parsed.syncTarget = value;
|
|
1210
|
+
break;
|
|
1211
|
+
default:
|
|
1212
|
+
throw new Error(`Unknown flag "${token}" for ${commandName}.`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return parsed;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function parseSyncTargetArgs(argv) {
|
|
1220
|
+
const parsed = {};
|
|
1221
|
+
|
|
1222
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1223
|
+
const token = argv[index];
|
|
1224
|
+
|
|
1225
|
+
if (!token.startsWith('--')) {
|
|
1226
|
+
if (parsed.targetName !== undefined) {
|
|
1227
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1228
|
+
}
|
|
1229
|
+
parsed.targetName = token;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const value = argv[index + 1];
|
|
1234
|
+
if (value === undefined) {
|
|
1235
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1236
|
+
}
|
|
1237
|
+
index += 1;
|
|
1238
|
+
|
|
1239
|
+
switch (token) {
|
|
1240
|
+
case '--root':
|
|
1241
|
+
parsed.rootDir = value;
|
|
1242
|
+
break;
|
|
1243
|
+
default:
|
|
1244
|
+
throw new Error(`Unknown flag "${token}" for sync-target.`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
return {
|
|
1249
|
+
command: 'sync-target',
|
|
1250
|
+
options: parsed,
|
|
1149
1251
|
};
|
|
1150
1252
|
}
|
|
1151
1253
|
|
|
@@ -1173,6 +1275,9 @@ function parsePullPreviewArgs(argv) {
|
|
|
1173
1275
|
case '--root':
|
|
1174
1276
|
parsed.rootDir = value;
|
|
1175
1277
|
break;
|
|
1278
|
+
case '--sync-target':
|
|
1279
|
+
parsed.syncTarget = value;
|
|
1280
|
+
break;
|
|
1176
1281
|
default:
|
|
1177
1282
|
throw new Error(`Unknown flag "${token}".`);
|
|
1178
1283
|
}
|
|
@@ -1206,6 +1311,9 @@ function parsePullExportArgs(argv) {
|
|
|
1206
1311
|
case '--root':
|
|
1207
1312
|
parsed.rootDir = value;
|
|
1208
1313
|
break;
|
|
1314
|
+
case '--sync-target':
|
|
1315
|
+
parsed.syncTarget = value;
|
|
1316
|
+
break;
|
|
1209
1317
|
case '--preview-token':
|
|
1210
1318
|
parsed.previewToken = value;
|
|
1211
1319
|
break;
|
|
@@ -1364,6 +1472,7 @@ function usageText() {
|
|
|
1364
1472
|
'Usage:',
|
|
1365
1473
|
' vibecompass init --name <project-name> --mode <local-only|local-primary|hosted-only> --repo <id=remote> [options]',
|
|
1366
1474
|
' vibecompass connect-hosted [options]',
|
|
1475
|
+
' vibecompass sync-target [<name>] [options]',
|
|
1367
1476
|
' vibecompass status [options]',
|
|
1368
1477
|
' vibecompass refresh-workflow [--dry-run|--apply] [options]',
|
|
1369
1478
|
' vibecompass start-session --id <lane-id> --working-on <text> [options]',
|
|
@@ -1405,10 +1514,17 @@ function usageText() {
|
|
|
1405
1514
|
'',
|
|
1406
1515
|
'Connect-hosted options:',
|
|
1407
1516
|
' --root <path> Project-memory root. Defaults to .compass',
|
|
1517
|
+
' --target <name> Add or update a named sync target (e.g. dev, prod)',
|
|
1518
|
+
' First named target becomes the default; flat sync fields mirror the default target',
|
|
1408
1519
|
' --sync-api-url <url> Hosted sync api_url',
|
|
1409
1520
|
' --sync-project-id <id> Hosted sync project_id',
|
|
1410
1521
|
' --sync-credential-env-var <name> Hosted sync env var reference',
|
|
1411
|
-
'
|
|
1522
|
+
' Without --target, replaces any existing flat project.yaml sync binding',
|
|
1523
|
+
'',
|
|
1524
|
+
'Sync-target options:',
|
|
1525
|
+
' vibecompass sync-target List named sync targets and the current default',
|
|
1526
|
+
' vibecompass sync-target <name> Switch the default sync target (re-mirrors flat sync fields)',
|
|
1527
|
+
' --root <path> Project-memory root. Defaults to .compass',
|
|
1412
1528
|
'',
|
|
1413
1529
|
'Status options:',
|
|
1414
1530
|
' --root <path> Project-memory root. Defaults to .compass',
|
|
@@ -1463,6 +1579,7 @@ function usageText() {
|
|
|
1463
1579
|
'',
|
|
1464
1580
|
'Sync transport options:',
|
|
1465
1581
|
' push --root <path> Push canonical local project memory to hosted',
|
|
1582
|
+
' --sync-target <name> Use a named sync target for this command (push, pull-preview, pull-export, docs-review)',
|
|
1466
1583
|
' pull-preview --root <path> Preview hosted proposals and remote state',
|
|
1467
1584
|
' pull-preview --no-pending-proposals Exclude pending proposals from preview',
|
|
1468
1585
|
' pull-export --proposal <id> Export a selected proposal; repeatable',
|
package/src/docs-review.js
CHANGED
|
@@ -5,6 +5,7 @@ import { parseFrontmatter } from './frontmatter.js';
|
|
|
5
5
|
import { sha256Text } from './hash.js';
|
|
6
6
|
import { writeStateManifest } from './manifest.js';
|
|
7
7
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
8
|
+
import { readSyncCursor, resolveSyncBinding } from './sync-binding.js';
|
|
8
9
|
|
|
9
10
|
const DOCS_REVIEW_PROMPT_VERSION = 'VibeCompass Docs Review Prompt v2';
|
|
10
11
|
const ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES = 12000;
|
|
@@ -83,6 +84,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
|
|
|
83
84
|
localProvider,
|
|
84
85
|
runLocal: shouldRunLocal(options),
|
|
85
86
|
anthropicEnvVar: options.anthropicEnvVar ?? 'ANTHROPIC_API_KEY',
|
|
87
|
+
syncTarget: options.syncTarget ?? null,
|
|
86
88
|
});
|
|
87
89
|
const reviewPrompt = renderReviewPrompt({
|
|
88
90
|
project,
|
|
@@ -617,11 +619,10 @@ function resolveDocsReviewRuntime(project, options) {
|
|
|
617
619
|
localProvider &&
|
|
618
620
|
typeof options.env?.[localProvider.credentialEnvVar] === 'string' &&
|
|
619
621
|
options.env[localProvider.credentialEnvVar].trim() !== '';
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
typeof project.sync.project_id === 'string';
|
|
622
|
+
// Throws on an explicitly requested unknown/incomplete --sync-target so a
|
|
623
|
+
// typo can never fall back to a different environment (D-236).
|
|
624
|
+
const hostedBinding = resolveSyncBinding(project, options.syncTarget ?? null);
|
|
625
|
+
const hasHostedBinding = Boolean(hostedBinding);
|
|
625
626
|
|
|
626
627
|
if (mode === 'local-only') {
|
|
627
628
|
if (options.runLocal && !localProvider) {
|
|
@@ -663,11 +664,10 @@ function resolveDocsReviewRuntime(project, options) {
|
|
|
663
664
|
if (hasHostedBinding) {
|
|
664
665
|
return {
|
|
665
666
|
provider: 'hosted',
|
|
666
|
-
api_url:
|
|
667
|
-
project_id:
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
: {}),
|
|
667
|
+
api_url: hostedBinding.apiUrl,
|
|
668
|
+
project_id: hostedBinding.projectId,
|
|
669
|
+
credential_env_var: hostedBinding.credentialEnvVar,
|
|
670
|
+
...(hostedBinding.target ? { sync_target: hostedBinding.target } : {}),
|
|
671
671
|
};
|
|
672
672
|
}
|
|
673
673
|
|
|
@@ -699,11 +699,10 @@ function resolveDocsReviewRuntime(project, options) {
|
|
|
699
699
|
|
|
700
700
|
return {
|
|
701
701
|
provider: 'hosted',
|
|
702
|
-
api_url:
|
|
703
|
-
project_id:
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
: {}),
|
|
702
|
+
api_url: hostedBinding.apiUrl,
|
|
703
|
+
project_id: hostedBinding.projectId,
|
|
704
|
+
credential_env_var: hostedBinding.credentialEnvVar,
|
|
705
|
+
...(hostedBinding.target ? { sync_target: hostedBinding.target } : {}),
|
|
707
706
|
};
|
|
708
707
|
}
|
|
709
708
|
|
|
@@ -1107,8 +1106,15 @@ async function submitHostedDocsReview(options) {
|
|
|
1107
1106
|
`api/sync/projects/${encodeURIComponent(options.runtime.project_id)}/docs-review`,
|
|
1108
1107
|
ensureTrailingSlash(options.runtime.api_url),
|
|
1109
1108
|
);
|
|
1109
|
+
// D-237: the baseline must come from the submitted target's own cursor so a
|
|
1110
|
+
// dev revision can never be sent as another environment's baseline.
|
|
1111
|
+
const submitCursor = readSyncCursor(options.manifest?.manifest?.sync, {
|
|
1112
|
+
target: options.runtime.sync_target ?? null,
|
|
1113
|
+
apiUrl: options.runtime.api_url,
|
|
1114
|
+
projectId: options.runtime.project_id,
|
|
1115
|
+
});
|
|
1110
1116
|
const baselineRemoteRevisionId = normalizeUuid(
|
|
1111
|
-
|
|
1117
|
+
submitCursor.last_successful_remote_revision,
|
|
1112
1118
|
);
|
|
1113
1119
|
const response = await options.fetch(endpoint.href, {
|
|
1114
1120
|
method: 'POST',
|
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
|
@@ -155,6 +155,20 @@ async function readProjectSummary(projectFilePath) {
|
|
|
155
155
|
projectId: typeof config.sync.project_id === 'string' ? config.sync.project_id : null,
|
|
156
156
|
credentialSource: typeof config.sync.credential_source === 'string' ? config.sync.credential_source : null,
|
|
157
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
|
+
: [],
|
|
158
172
|
}
|
|
159
173
|
: null,
|
|
160
174
|
};
|
|
@@ -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
|
+
}
|
package/src/sync.js
CHANGED
|
@@ -3,6 +3,11 @@ import path from 'node:path';
|
|
|
3
3
|
import { sha256Text } from './hash.js';
|
|
4
4
|
import { writeStateManifest } from './manifest.js';
|
|
5
5
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
6
|
+
import {
|
|
7
|
+
buildSyncStateWithCursor,
|
|
8
|
+
readSyncCursor,
|
|
9
|
+
resolveSyncBinding,
|
|
10
|
+
} from './sync-binding.js';
|
|
6
11
|
|
|
7
12
|
const DEFAULT_EXPORT_PATH = 'state/pull-export.json';
|
|
8
13
|
|
|
@@ -14,7 +19,8 @@ export async function pushProjectMemory(options = {}, environment = {}) {
|
|
|
14
19
|
});
|
|
15
20
|
const manifest = manifestResult.manifest;
|
|
16
21
|
const documents = await readPushDocuments(context.rootDir, manifest.documents);
|
|
17
|
-
const
|
|
22
|
+
const cursor = readSyncCursor(manifest.sync, context.binding);
|
|
23
|
+
const baseRemoteRevisionId = normalizeUuid(cursor.last_successful_remote_revision);
|
|
18
24
|
const body = {
|
|
19
25
|
...(baseRemoteRevisionId ? { base_remote_revision_id: baseRemoteRevisionId } : {}),
|
|
20
26
|
local_root_revision: manifest.canonical.local_root_revision,
|
|
@@ -25,15 +31,15 @@ export async function pushProjectMemory(options = {}, environment = {}) {
|
|
|
25
31
|
|
|
26
32
|
const response = await postJson(context, 'push', body);
|
|
27
33
|
const now = new Date().toISOString();
|
|
28
|
-
const sync = {
|
|
29
|
-
...
|
|
34
|
+
const sync = buildSyncStateWithCursor(manifest.sync, context.binding, {
|
|
35
|
+
...cursor,
|
|
30
36
|
last_successful_remote_revision: response.remote_revision_id,
|
|
31
37
|
last_successful_local_root_revision: manifest.canonical.local_root_revision,
|
|
32
38
|
last_successful_manifest_hash: manifest.canonical.manifest_hash,
|
|
33
39
|
last_sync_direction: 'push',
|
|
34
40
|
last_sync_at: now,
|
|
35
41
|
pending_previews: [],
|
|
36
|
-
};
|
|
42
|
+
});
|
|
37
43
|
const updatedManifest = await writeStateManifest(context.rootDir, { sync });
|
|
38
44
|
|
|
39
45
|
return {
|
|
@@ -55,10 +61,11 @@ export async function pullPreviewProjectMemory(options = {}, environment = {}) {
|
|
|
55
61
|
sync: existingManifest?.sync,
|
|
56
62
|
});
|
|
57
63
|
const manifest = manifestResult.manifest;
|
|
64
|
+
const cursor = readSyncCursor(manifest.sync, context.binding);
|
|
58
65
|
|
|
59
66
|
const response = await postJson(context, 'pull-preview', {
|
|
60
67
|
state_version: manifest.state_version,
|
|
61
|
-
base_remote_revision_id: normalizeUuid(
|
|
68
|
+
base_remote_revision_id: normalizeUuid(cursor.last_successful_remote_revision),
|
|
62
69
|
local_root_revision: manifest.canonical.local_root_revision,
|
|
63
70
|
local_manifest_hash: manifest.canonical.manifest_hash,
|
|
64
71
|
local_documents: buildLocalDocumentSummaries(manifest.documents),
|
|
@@ -81,13 +88,13 @@ export async function pullPreviewProjectMemory(options = {}, environment = {}) {
|
|
|
81
88
|
created_at: response.created_at ?? new Date().toISOString(),
|
|
82
89
|
expires_at: response.expires_at,
|
|
83
90
|
};
|
|
84
|
-
const sync = {
|
|
85
|
-
...
|
|
91
|
+
const sync = buildSyncStateWithCursor(manifest.sync, context.binding, {
|
|
92
|
+
...cursor,
|
|
86
93
|
pending_previews: [
|
|
87
|
-
...(Array.isArray(
|
|
94
|
+
...(Array.isArray(cursor.pending_previews) ? cursor.pending_previews : []),
|
|
88
95
|
previewRecord,
|
|
89
96
|
].slice(-5),
|
|
90
|
-
};
|
|
97
|
+
});
|
|
91
98
|
const updatedManifest = await writeStateManifest(context.rootDir, { sync });
|
|
92
99
|
|
|
93
100
|
return {
|
|
@@ -105,7 +112,7 @@ export async function pullPreviewProjectMemory(options = {}, environment = {}) {
|
|
|
105
112
|
export async function pullExportProjectMemory(options = {}, environment = {}) {
|
|
106
113
|
const context = await loadSyncContext(options, environment);
|
|
107
114
|
const manifest = await readRequiredManifest(context.manifestPath);
|
|
108
|
-
const preview = resolvePreview(manifest, options.previewToken);
|
|
115
|
+
const preview = resolvePreview(manifest, options.previewToken, context.binding);
|
|
109
116
|
const proposalIds = options.proposalIds?.length
|
|
110
117
|
? options.proposalIds
|
|
111
118
|
: preview.available_proposal_ids ?? [];
|
|
@@ -200,18 +207,15 @@ async function loadSyncContext(options, environment) {
|
|
|
200
207
|
if (project.mode !== 'local-primary') {
|
|
201
208
|
throw new Error('Hosted sync commands require project.yaml mode: local-primary.');
|
|
202
209
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
typeof project.sync.credential_env_var !== 'string'
|
|
208
|
-
) {
|
|
209
|
-
throw new Error('Hosted sync commands require sync.api_url, sync.project_id, and sync.credential_env_var in project.yaml.');
|
|
210
|
+
|
|
211
|
+
const binding = resolveSyncBinding(project, options.syncTarget ?? null);
|
|
212
|
+
if (!binding) {
|
|
213
|
+
throw new Error('Hosted sync commands require sync.api_url, sync.project_id, and sync.credential_env_var in project.yaml (or a named target under sync.targets).');
|
|
210
214
|
}
|
|
211
215
|
|
|
212
|
-
const credential = normalizeOptionalString((environment.env ?? process.env)[
|
|
216
|
+
const credential = normalizeOptionalString((environment.env ?? process.env)[binding.credentialEnvVar]);
|
|
213
217
|
if (!credential) {
|
|
214
|
-
throw new Error(`Hosted sync command requires ${
|
|
218
|
+
throw new Error(`Hosted sync command requires ${binding.credentialEnvVar}.`);
|
|
215
219
|
}
|
|
216
220
|
|
|
217
221
|
const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
|
|
@@ -225,8 +229,10 @@ async function loadSyncContext(options, environment) {
|
|
|
225
229
|
manifestPath,
|
|
226
230
|
fetch: fetchImpl,
|
|
227
231
|
credential,
|
|
228
|
-
apiUrl:
|
|
229
|
-
projectId:
|
|
232
|
+
apiUrl: binding.apiUrl,
|
|
233
|
+
projectId: binding.projectId,
|
|
234
|
+
syncTarget: binding.target,
|
|
235
|
+
binding,
|
|
230
236
|
};
|
|
231
237
|
}
|
|
232
238
|
|
|
@@ -308,9 +314,10 @@ function buildLocalDocumentExtractedSummary(kind, extracted = {}) {
|
|
|
308
314
|
return {};
|
|
309
315
|
}
|
|
310
316
|
|
|
311
|
-
function resolvePreview(manifest, previewToken) {
|
|
312
|
-
const
|
|
313
|
-
|
|
317
|
+
function resolvePreview(manifest, previewToken, binding) {
|
|
318
|
+
const cursor = readSyncCursor(manifest.sync, binding);
|
|
319
|
+
const previews = Array.isArray(cursor.pending_previews)
|
|
320
|
+
? cursor.pending_previews
|
|
314
321
|
: [];
|
|
315
322
|
if (previewToken) {
|
|
316
323
|
const match = previews.find((preview) => preview.preview_token === previewToken);
|