@vibecompass/vibecompass 0.11.2 → 0.12.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 +31 -0
- package/README.md +11 -0
- package/package.json +1 -1
- package/src/cli.js +200 -0
- package/src/docs-review.js +103 -16
- package/src/index.js +3 -0
- package/src/mode-transition.js +265 -0
- package/src/serialization.js +26 -0
- package/src/status.js +78 -0
- package/src/sync.js +264 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.12.0 - 2026-07-10
|
|
6
|
+
|
|
7
|
+
- New `vibecompass bootstrap --bundle <file>` materializes a complete local
|
|
8
|
+
root from a hosted bootstrap-export bundle (fail-closed hash validation,
|
|
9
|
+
verbatim writes, sync-cursor seeding for local-primary bundles).
|
|
10
|
+
- New `vibecompass sync-adopt [--accept-divergence]` re-baselines a root's
|
|
11
|
+
sync cursor onto the hosted head after a divergence preview (D-215) — the
|
|
12
|
+
recovery path for fresh clones and second devices.
|
|
13
|
+
- New `vibecompass promote-hosted [--resume|--abort]` and
|
|
14
|
+
`vibecompass demote-hosted [--accept-divergence]` run the D-288 two-phase
|
|
15
|
+
hosting-mode cutover: fresh verified baseline, server-side transition
|
|
16
|
+
intent with a completeness report, local flip + promoted-root marker, and
|
|
17
|
+
confirm — resumable after any crash and fully reversible.
|
|
18
|
+
- Promoted roots hard-refuse canonical write commands (override with
|
|
19
|
+
VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1); `vibecompass status` now checks
|
|
20
|
+
the hosted mode and warns on local/hosted mode mismatches.
|
|
21
|
+
- `apply-export` pre-validates every operation before writing and treats
|
|
22
|
+
already-applied operations as converged, so retries after partial failure
|
|
23
|
+
succeed. `docs-review --apply-decision-artifact` is idempotent and
|
|
24
|
+
crash-safe (provenance Source line, marker repair, post-write verification,
|
|
25
|
+
code-enforced append-only) and warns on hosted-only roots.
|
|
26
|
+
|
|
27
|
+
- The missing-sync-token error now explains how to recover: re-export the bound
|
|
28
|
+
env var, persist it in a shell profile, or rotate the token from the hosted
|
|
29
|
+
dashboard (previously a bare "requires VIBECOMPASS_SYNC_TOKEN" with no
|
|
30
|
+
guidance). README documents the persistence recipe.
|
|
31
|
+
- AGENTS.md gains a release checklist, including verifying that
|
|
32
|
+
`vibecompass-mcp`'s dependency range still spans the new version so core
|
|
33
|
+
releases stop stranding the MCP package (its `^0.1.0` pin had drifted 27
|
|
34
|
+
releases behind).
|
|
35
|
+
|
|
5
36
|
## 0.11.2 - 2026-07-06
|
|
6
37
|
|
|
7
38
|
- Complete the remaining Phase 8 start-session overlap warnings: lanes can now
|
package/README.md
CHANGED
|
@@ -250,6 +250,17 @@ changed canonical project-memory files and the hosted dashboard should catch
|
|
|
250
250
|
up. Hosted-only projects do not use a local authoritative push; update or
|
|
251
251
|
refresh them through the hosted dashboard/proposal flow.
|
|
252
252
|
|
|
253
|
+
The sync token is read from the environment variable you bind (for example
|
|
254
|
+
`VIBECOMPASS_SYNC_TOKEN`). One-off `export`s do not survive new terminals —
|
|
255
|
+
persist the token in your shell profile (`~/.zshenv` or `~/.bashrc`):
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
echo 'export VIBECOMPASS_SYNC_TOKEN="<your sync token>"' >> ~/.zshenv
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
If the token is lost, rotate it on the hosted dashboard under Setup -> Hosted
|
|
262
|
+
sync and export the new value.
|
|
263
|
+
|
|
253
264
|
### Multiple environments
|
|
254
265
|
|
|
255
266
|
One project can have named sync targets (for example a local dev server and
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -17,8 +17,11 @@ import { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
|
|
|
17
17
|
import { refreshWorkflow } from './refresh-workflow.js';
|
|
18
18
|
import { inspectProjectCompatibility, formatCompatibilityWarnings } from './compatibility.js';
|
|
19
19
|
import { PACKAGE_VERSION } from './version.js';
|
|
20
|
+
import { demoteHosted, promoteHosted } from './mode-transition.js';
|
|
20
21
|
import {
|
|
22
|
+
adoptRemoteHead,
|
|
21
23
|
applyPullExport,
|
|
24
|
+
bootstrapFromBundle,
|
|
22
25
|
pullExportProjectMemory,
|
|
23
26
|
pullPreviewProjectMemory,
|
|
24
27
|
pushProjectMemory,
|
|
@@ -437,6 +440,71 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
|
|
|
437
440
|
return 0;
|
|
438
441
|
}
|
|
439
442
|
|
|
443
|
+
if (parsed.command === 'promote-hosted' || parsed.command === 'demote-hosted') {
|
|
444
|
+
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
445
|
+
const run = parsed.command === 'promote-hosted' ? promoteHosted : demoteHosted;
|
|
446
|
+
const result = await run(parsed.options, {
|
|
447
|
+
cwd: runtime.cwd,
|
|
448
|
+
env: runtime.env,
|
|
449
|
+
runtime,
|
|
450
|
+
});
|
|
451
|
+
io.stdout.write(`Mode transition: ${result.status}\n`);
|
|
452
|
+
if (result.completeness) {
|
|
453
|
+
io.stdout.write(`Carries over: ${result.completeness.carries_over}\n`);
|
|
454
|
+
for (const [kind, total] of Object.entries(result.completeness.documents_by_kind ?? {})) {
|
|
455
|
+
io.stdout.write(` ${kind}: ${total}\n`);
|
|
456
|
+
}
|
|
457
|
+
io.stdout.write('Does not carry over:\n');
|
|
458
|
+
for (const item of result.completeness.does_not_carry_over ?? []) {
|
|
459
|
+
io.stdout.write(` - ${item}\n`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const warning of result.warnings) {
|
|
463
|
+
io.stdout.write(`Note: ${warning}\n`);
|
|
464
|
+
}
|
|
465
|
+
io.stdout.write(`${result.message}\n`);
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (parsed.command === 'sync-adopt') {
|
|
470
|
+
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
471
|
+
const result = await adoptRemoteHead(parsed.options, {
|
|
472
|
+
cwd: runtime.cwd,
|
|
473
|
+
env: runtime.env,
|
|
474
|
+
runtime,
|
|
475
|
+
});
|
|
476
|
+
io.stdout.write(`Sync adopt: ${result.status}\n`);
|
|
477
|
+
io.stdout.write(`Adopted head: ${result.remoteRevisionId}\n`);
|
|
478
|
+
io.stdout.write(`Recorded ${result.manifestPath}\n`);
|
|
479
|
+
for (const warning of result.warnings) {
|
|
480
|
+
io.stdout.write(`Warning: ${warning}\n`);
|
|
481
|
+
}
|
|
482
|
+
io.stdout.write(`${result.message}\n`);
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (parsed.command === 'bootstrap') {
|
|
487
|
+
const result = await bootstrapFromBundle(parsed.options, {
|
|
488
|
+
cwd: runtime.cwd,
|
|
489
|
+
env: runtime.env,
|
|
490
|
+
runtime,
|
|
491
|
+
});
|
|
492
|
+
io.stdout.write(`Bootstrap: ${result.status}\n`);
|
|
493
|
+
io.stdout.write(`Root: ${result.rootDir}\n`);
|
|
494
|
+
io.stdout.write(`Documents: ${result.documentCount}\n`);
|
|
495
|
+
if (result.mode) {
|
|
496
|
+
io.stdout.write(`Mode: ${result.mode}\n`);
|
|
497
|
+
}
|
|
498
|
+
if (result.manifestPath) {
|
|
499
|
+
io.stdout.write(`Recorded ${result.manifestPath}\n`);
|
|
500
|
+
}
|
|
501
|
+
for (const warning of result.warnings) {
|
|
502
|
+
io.stdout.write(`Warning: ${warning}\n`);
|
|
503
|
+
}
|
|
504
|
+
io.stdout.write(`${result.message}\n`);
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
440
508
|
if (parsed.command === 'pull-preview') {
|
|
441
509
|
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
442
510
|
const result = await pullPreviewProjectMemory(parsed.options, {
|
|
@@ -708,6 +776,18 @@ export function parseCliArgs(argv) {
|
|
|
708
776
|
return parsePushArgs(rest);
|
|
709
777
|
}
|
|
710
778
|
|
|
779
|
+
if (command === 'promote-hosted' || command === 'demote-hosted') {
|
|
780
|
+
return parseModeTransitionArgs(command, rest);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (command === 'sync-adopt') {
|
|
784
|
+
return parseSyncAdoptArgs(rest);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (command === 'bootstrap') {
|
|
788
|
+
return parseBootstrapArgs(rest);
|
|
789
|
+
}
|
|
790
|
+
|
|
711
791
|
if (command === 'pull-preview') {
|
|
712
792
|
return parsePullPreviewArgs(rest);
|
|
713
793
|
}
|
|
@@ -1776,6 +1856,122 @@ function parseDocsReviewArgs(argv) {
|
|
|
1776
1856
|
};
|
|
1777
1857
|
}
|
|
1778
1858
|
|
|
1859
|
+
function parseModeTransitionArgs(command, argv) {
|
|
1860
|
+
const parsed = {};
|
|
1861
|
+
|
|
1862
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1863
|
+
const token = argv[index];
|
|
1864
|
+
if (!token.startsWith('--')) {
|
|
1865
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
if (token === '--resume') {
|
|
1869
|
+
parsed.resume = true;
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
if (token === '--abort') {
|
|
1873
|
+
parsed.abort = true;
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
if (token === '--accept-divergence') {
|
|
1877
|
+
parsed.acceptDivergence = true;
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const value = argv[index + 1];
|
|
1882
|
+
if (value === undefined) {
|
|
1883
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1884
|
+
}
|
|
1885
|
+
index += 1;
|
|
1886
|
+
|
|
1887
|
+
switch (token) {
|
|
1888
|
+
case '--root':
|
|
1889
|
+
parsed.rootDir = value;
|
|
1890
|
+
break;
|
|
1891
|
+
case '--sync-target':
|
|
1892
|
+
parsed.syncTarget = value;
|
|
1893
|
+
break;
|
|
1894
|
+
default:
|
|
1895
|
+
throw new Error(`Unknown flag "${token}" for ${command}.`);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
return { command, options: parsed };
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function parseSyncAdoptArgs(argv) {
|
|
1903
|
+
const parsed = {};
|
|
1904
|
+
|
|
1905
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1906
|
+
const token = argv[index];
|
|
1907
|
+
if (!token.startsWith('--')) {
|
|
1908
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
if (token === '--accept-divergence') {
|
|
1912
|
+
parsed.acceptDivergence = true;
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
const value = argv[index + 1];
|
|
1917
|
+
if (value === undefined) {
|
|
1918
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1919
|
+
}
|
|
1920
|
+
index += 1;
|
|
1921
|
+
|
|
1922
|
+
switch (token) {
|
|
1923
|
+
case '--root':
|
|
1924
|
+
parsed.rootDir = value;
|
|
1925
|
+
break;
|
|
1926
|
+
case '--sync-target':
|
|
1927
|
+
parsed.syncTarget = value;
|
|
1928
|
+
break;
|
|
1929
|
+
default:
|
|
1930
|
+
throw new Error(`Unknown flag "${token}" for sync-adopt.`);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return {
|
|
1935
|
+
command: 'sync-adopt',
|
|
1936
|
+
options: parsed,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function parseBootstrapArgs(argv) {
|
|
1941
|
+
const parsed = {};
|
|
1942
|
+
|
|
1943
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1944
|
+
const token = argv[index];
|
|
1945
|
+
if (!token.startsWith('--')) {
|
|
1946
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1947
|
+
}
|
|
1948
|
+
const value = argv[index + 1];
|
|
1949
|
+
if (value === undefined) {
|
|
1950
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1951
|
+
}
|
|
1952
|
+
index += 1;
|
|
1953
|
+
|
|
1954
|
+
switch (token) {
|
|
1955
|
+
case '--root':
|
|
1956
|
+
parsed.rootDir = value;
|
|
1957
|
+
break;
|
|
1958
|
+
case '--bundle':
|
|
1959
|
+
parsed.bundlePath = value;
|
|
1960
|
+
break;
|
|
1961
|
+
case '--sync-target':
|
|
1962
|
+
parsed.syncTarget = value;
|
|
1963
|
+
break;
|
|
1964
|
+
default:
|
|
1965
|
+
throw new Error(`Unknown flag "${token}" for bootstrap.`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
return {
|
|
1970
|
+
command: 'bootstrap',
|
|
1971
|
+
options: parsed,
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1779
1975
|
function parsePushArgs(argv) {
|
|
1780
1976
|
return {
|
|
1781
1977
|
command: 'push',
|
|
@@ -2183,6 +2379,10 @@ function usageText() {
|
|
|
2183
2379
|
' vibecompass next-decision-id [options]',
|
|
2184
2380
|
' vibecompass sync-agents [options]',
|
|
2185
2381
|
' vibecompass push [options]',
|
|
2382
|
+
' vibecompass bootstrap --bundle <file> [--root <dir>]',
|
|
2383
|
+
' vibecompass sync-adopt [--accept-divergence] [options]',
|
|
2384
|
+
' vibecompass promote-hosted [--resume|--abort] [options]',
|
|
2385
|
+
' vibecompass demote-hosted [--accept-divergence] [options]',
|
|
2186
2386
|
' vibecompass pull-preview [options]',
|
|
2187
2387
|
' vibecompass pull-export [options]',
|
|
2188
2388
|
' vibecompass apply-export [options]',
|
package/src/docs-review.js
CHANGED
|
@@ -434,6 +434,15 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
434
434
|
throw new Error(`Decision artifact ${artifactId} must include a non-empty rationale before it can be appended locally.`);
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
// Idempotency guard: never append the same artifact twice. The marker is
|
|
438
|
+
// the fast path; the file-content check covers a crash between the file
|
|
439
|
+
// write and the marker update.
|
|
440
|
+
if (artifact.local_status === 'applied') {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Decision artifact ${artifactId} is already applied locally as ${artifact.local_decision_id ? `decision ${artifact.local_decision_id}` : 'a decision entry'}. Re-applying would append a duplicate entry.`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
437
446
|
const nextDecisionId = await readNextDecisionId(options.rootDir);
|
|
438
447
|
const targetFilePath = path.join(options.rootDir, targetPath);
|
|
439
448
|
await mkdir(path.dirname(targetFilePath), { recursive: true });
|
|
@@ -446,27 +455,99 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
446
455
|
existingContent = `# ${path.basename(targetPath, '.md')} decisions\n`;
|
|
447
456
|
}
|
|
448
457
|
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
458
|
+
const applyWarnings = [];
|
|
459
|
+
const alreadyInFile = existingContent.includes(
|
|
460
|
+
`hosted docs-review artifact ${artifactId}`,
|
|
461
|
+
);
|
|
462
|
+
let appliedDecisionIdForMarker = nextDecisionId;
|
|
463
|
+
|
|
464
|
+
if (alreadyInFile) {
|
|
465
|
+
// A previous apply wrote the file but crashed before recording the
|
|
466
|
+
// marker. Repair the marker instead of appending a duplicate.
|
|
467
|
+
applyWarnings.push(
|
|
468
|
+
`Artifact ${artifactId} was already present in ${targetPath}; repaired the marker without appending a duplicate entry.`,
|
|
469
|
+
);
|
|
470
|
+
const existingIdMatch = existingContent.match(
|
|
471
|
+
new RegExp(
|
|
472
|
+
`### (D-\\d+)[^\\n]*(?:\\n(?!### ).*)*hosted docs-review artifact ${artifactId.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}`,
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
appliedDecisionIdForMarker = existingIdMatch
|
|
476
|
+
? Number.parseInt(existingIdMatch[1].slice(2), 10)
|
|
477
|
+
: nextDecisionId;
|
|
478
|
+
} else {
|
|
479
|
+
const entry = renderDecisionEntry({
|
|
480
|
+
decisionId: nextDecisionId,
|
|
481
|
+
title,
|
|
482
|
+
decision,
|
|
483
|
+
rationale,
|
|
484
|
+
context,
|
|
485
|
+
artifactId,
|
|
486
|
+
});
|
|
487
|
+
const nextContent = `${existingContent.trimEnd()}\n\n${entry}`;
|
|
488
|
+
// Append-only enforced in code, not just convention: the previous file
|
|
489
|
+
// content must survive verbatim as a prefix of what we write.
|
|
490
|
+
if (!nextContent.startsWith(existingContent.trimEnd())) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`Refusing to apply artifact ${artifactId}: the write would not preserve the existing content of ${targetPath} (decisions are append-only).`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
await writeFile(targetFilePath, nextContent, 'utf8');
|
|
496
|
+
|
|
497
|
+
// Post-write verification: the canonical write must be durable and
|
|
498
|
+
// intact before the marker records the artifact as applied.
|
|
499
|
+
const verification = await readFile(targetFilePath, 'utf8');
|
|
500
|
+
if (verification !== nextContent) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`Post-write verification failed for ${targetPath}: the file on disk does not match the appended content. The marker was NOT updated; inspect the file and retry.`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Hosted-only roots can apply locally but can never push the result back
|
|
508
|
+
// until the project is demoted to local-primary — say so instead of letting
|
|
509
|
+
// the user discover it at push time.
|
|
510
|
+
try {
|
|
511
|
+
const projectConfig = parseSimpleYaml(
|
|
512
|
+
await readFile(path.join(options.rootDir, 'project.yaml'), 'utf8'),
|
|
513
|
+
{ sourceName: 'project.yaml' },
|
|
514
|
+
);
|
|
515
|
+
if (projectConfig?.mode === 'hosted-only') {
|
|
516
|
+
applyWarnings.push(
|
|
517
|
+
'This is a hosted-only project: the locally applied decision cannot be pushed back to the hosted canonical store. The hosted copy was already recorded when the artifact was accepted; treat this local root as a working copy.',
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
// Unreadable project.yaml is surfaced by other commands; not fatal here.
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let indexResult;
|
|
525
|
+
if (options.refreshIndex) {
|
|
526
|
+
try {
|
|
527
|
+
indexResult = await refreshDecisionIndex(options.rootDir);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
// An index-refresh failure after a verified canonical write must not
|
|
530
|
+
// strand the marker (a retry would duplicate the entry): degrade to a
|
|
531
|
+
// warning and keep going.
|
|
532
|
+
indexResult = {
|
|
533
|
+
refreshed: false,
|
|
534
|
+
warnings: [
|
|
535
|
+
`decisions/INDEX.md refresh failed after the entry was appended: ${error.message}. Run vibecompass refresh-decision-index manually.`,
|
|
536
|
+
],
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
indexResult = {
|
|
461
541
|
refreshed: false,
|
|
462
542
|
warnings: [
|
|
463
543
|
'decisions/INDEX.md was not refreshed. Run again with --refresh-index if this root uses the package-generated flat index, or update the project-specific index manually.',
|
|
464
544
|
],
|
|
465
545
|
};
|
|
546
|
+
}
|
|
466
547
|
|
|
467
548
|
const appliedDecisionArtifact = {
|
|
468
549
|
artifact_id: artifactId,
|
|
469
|
-
decision_id:
|
|
550
|
+
decision_id: appliedDecisionIdForMarker,
|
|
470
551
|
target_path: targetPath,
|
|
471
552
|
applied_at: new Date().toISOString(),
|
|
472
553
|
refreshed_index: indexResult.refreshed,
|
|
@@ -478,7 +559,7 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
478
559
|
...current.hosted,
|
|
479
560
|
artifacts: (current.hosted?.artifacts ?? []).map((item) =>
|
|
480
561
|
item?.artifact_id === artifactId
|
|
481
|
-
? { ...item, local_status: 'applied', local_decision_id:
|
|
562
|
+
? { ...item, local_status: 'applied', local_decision_id: appliedDecisionIdForMarker }
|
|
482
563
|
: item,
|
|
483
564
|
),
|
|
484
565
|
},
|
|
@@ -494,8 +575,10 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
494
575
|
hosted: current.hosted ?? null,
|
|
495
576
|
appliedDecisionArtifact,
|
|
496
577
|
reviewPrompt: null,
|
|
497
|
-
warnings: indexResult.warnings,
|
|
498
|
-
message:
|
|
578
|
+
warnings: [...applyWarnings, ...indexResult.warnings],
|
|
579
|
+
message: alreadyInFile
|
|
580
|
+
? `Repaired the marker for decision artifact ${artifactId} (already present in ${targetPath} as D-${String(appliedDecisionIdForMarker).padStart(3, '0')}).`
|
|
581
|
+
: `Applied decision artifact ${artifactId} as D-${String(appliedDecisionIdForMarker).padStart(3, '0')} in ${targetPath}.`,
|
|
499
582
|
};
|
|
500
583
|
}
|
|
501
584
|
|
|
@@ -1972,6 +2055,10 @@ function renderDecisionEntry(options) {
|
|
|
1972
2055
|
`**Decision:** ${options.decision}`,
|
|
1973
2056
|
`**Rationale:** ${options.rationale}`,
|
|
1974
2057
|
...(options.context ? [`**Context:** ${options.context}`] : []),
|
|
2058
|
+
// Provenance makes re-application detectable from the file itself, so a
|
|
2059
|
+
// crash between the file write and the marker update can never cause a
|
|
2060
|
+
// silent duplicate append on retry.
|
|
2061
|
+
...(options.artifactId ? [`**Source:** hosted docs-review artifact ${options.artifactId}`] : []),
|
|
1975
2062
|
'',
|
|
1976
2063
|
].join('\n');
|
|
1977
2064
|
}
|
package/src/index.js
CHANGED
|
@@ -6,8 +6,11 @@ export { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
|
|
|
6
6
|
export { initializeProjectMemory } from './init.js';
|
|
7
7
|
export { preflightDocsReview } from './docs-review.js';
|
|
8
8
|
export { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
|
|
9
|
+
export { demoteHosted, promoteHosted } from './mode-transition.js';
|
|
9
10
|
export {
|
|
11
|
+
adoptRemoteHead,
|
|
10
12
|
applyPullExport,
|
|
13
|
+
bootstrapFromBundle,
|
|
11
14
|
pullExportProjectMemory,
|
|
12
15
|
pullPreviewProjectMemory,
|
|
13
16
|
pushProjectMemory,
|
|
@@ -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
|
+
}
|
package/src/serialization.js
CHANGED
|
@@ -46,7 +46,33 @@ export function memoryRootLockPath(rootDir) {
|
|
|
46
46
|
* Runs `fn` while holding the serialization lock for `rootDir`. Reentrant:
|
|
47
47
|
* a nested call from inside the lock holder runs immediately.
|
|
48
48
|
*/
|
|
49
|
+
// Commands that stay allowed on a promoted root: the transition flows
|
|
50
|
+
// themselves, plus recovery tooling that must be able to repair state.
|
|
51
|
+
const PROMOTED_ROOT_ALLOWED_COMMANDS = new Set([
|
|
52
|
+
'promote-hosted',
|
|
53
|
+
'demote-hosted',
|
|
54
|
+
'bootstrap',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
async function assertNotPromotedRoot(rootDir, label) {
|
|
58
|
+
if (PROMOTED_ROOT_ALLOWED_COMMANDS.has(label)) return;
|
|
59
|
+
if (process.env.VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES === '1') return;
|
|
60
|
+
const markerPath = path.join(rootDir, 'state', 'promoted-root.json');
|
|
61
|
+
let marker;
|
|
62
|
+
try {
|
|
63
|
+
marker = JSON.parse(await readFile(markerPath, 'utf8'));
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw new Error(
|
|
68
|
+
`This root was promoted to hosted-only${marker?.promoted_at ? ` on ${marker.promoted_at}` : ''}: the hosted app is canonical and local writes here can never sync back (D-288). `
|
|
69
|
+
+ 'Make changes on the hosted dashboard, run vibecompass demote-hosted to make this root canonical again, '
|
|
70
|
+
+ 'or set VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1 to override deliberately.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
75
|
+
await assertNotPromotedRoot(rootDir, label);
|
|
50
76
|
const key = await canonicalizeMemoryRootKey(rootDir);
|
|
51
77
|
const held = heldRoots.getStore();
|
|
52
78
|
if (await findHeldMemoryRootKey(held, key)) {
|
package/src/status.js
CHANGED
|
@@ -4,6 +4,7 @@ import { inspectProjectCompatibility } from './compatibility.js';
|
|
|
4
4
|
import { sha256Text } from './hash.js';
|
|
5
5
|
import { resolveLaneMarkerContext } from './lane-marker.js';
|
|
6
6
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
7
|
+
import { resolveSyncBinding } from './sync-binding.js';
|
|
7
8
|
import { inferToolingRootForMemoryRoot, listProjectSessions } from './session.js';
|
|
8
9
|
import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
|
|
9
10
|
import { scanProjectMemory } from './project-memory.js';
|
|
@@ -30,12 +31,18 @@ export async function getProjectStatus(options = {}) {
|
|
|
30
31
|
readAgentFileStatus(rootDir, toolingRootDir),
|
|
31
32
|
readProjectMemoryStatus(rootDir),
|
|
32
33
|
]);
|
|
34
|
+
const hostedSync = await readHostedModeCheck(
|
|
35
|
+
compatibility.projectFilePath,
|
|
36
|
+
project.mode ?? null,
|
|
37
|
+
options,
|
|
38
|
+
);
|
|
33
39
|
|
|
34
40
|
return {
|
|
35
41
|
generatedAt: new Date().toISOString(),
|
|
36
42
|
rootDir,
|
|
37
43
|
toolingRootDir,
|
|
38
44
|
project,
|
|
45
|
+
hostedSync,
|
|
39
46
|
compatibility: {
|
|
40
47
|
cliPackageVersion: compatibility.cliPackageVersion,
|
|
41
48
|
package: compatibility.package,
|
|
@@ -111,6 +118,13 @@ export function renderStatusText(status) {
|
|
|
111
118
|
`Generated: ${status.generatedAt}`,
|
|
112
119
|
`Project: ${status.project.name ?? 'Unknown'}`,
|
|
113
120
|
`Mode: ${status.project.mode ?? 'Unknown'}`,
|
|
121
|
+
...(status.hostedSync?.status === 'ok' && status.hostedSync.mismatch
|
|
122
|
+
? [
|
|
123
|
+
`WARNING: mode mismatch — local project.yaml says "${status.hostedSync.localMode}" but the hosted project is "${status.hostedSync.hostedMode}". The two mode records are unreconciled: sync and dashboard actions will refuse until they agree. Do not hand-edit mode; a supported promote/demote flow flips both records together.`,
|
|
124
|
+
]
|
|
125
|
+
: status.hostedSync?.status === 'unreachable'
|
|
126
|
+
? [`Hosted mode check: unreachable (${status.hostedSync.detail}).`]
|
|
127
|
+
: []),
|
|
114
128
|
`Root: ${status.rootDir}`,
|
|
115
129
|
`Repos: ${status.project.repos.length > 0 ? status.project.repos.map((repo) => repo.id).join(', ') : 'None recorded'}`,
|
|
116
130
|
`Active lanes: ${formatActiveLanes(status.sessions)}`,
|
|
@@ -162,6 +176,7 @@ export function toStatusJson(status) {
|
|
|
162
176
|
return {
|
|
163
177
|
generatedAt: status.generatedAt,
|
|
164
178
|
rootDir: status.rootDir,
|
|
179
|
+
hostedSync: status.hostedSync ?? null,
|
|
165
180
|
toolingRootDir: status.toolingRootDir,
|
|
166
181
|
project: status.project,
|
|
167
182
|
compatibility: status.compatibility,
|
|
@@ -173,6 +188,69 @@ export function toStatusJson(status) {
|
|
|
173
188
|
};
|
|
174
189
|
}
|
|
175
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Mode-mismatch diagnostic (plan 3.8): compares the local project.yaml mode
|
|
193
|
+
* against the hosted project's mode via the side-effect-free sync status
|
|
194
|
+
* endpoint. A hand-edited mode previously wedged a project with opaque 409s
|
|
195
|
+
* on both sides and no diagnostic anywhere. Network problems degrade to an
|
|
196
|
+
* "unreachable" note — status stays offline-safe.
|
|
197
|
+
*/
|
|
198
|
+
async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
|
|
199
|
+
let binding;
|
|
200
|
+
try {
|
|
201
|
+
const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
202
|
+
sourceName: projectFilePath,
|
|
203
|
+
});
|
|
204
|
+
binding = resolveSyncBinding(config, null);
|
|
205
|
+
} catch {
|
|
206
|
+
return { status: 'not-configured' };
|
|
207
|
+
}
|
|
208
|
+
if (!binding) return { status: 'not-configured' };
|
|
209
|
+
|
|
210
|
+
const env = options.env ?? process.env;
|
|
211
|
+
const credential = typeof env[binding.credentialEnvVar] === 'string'
|
|
212
|
+
? env[binding.credentialEnvVar].trim()
|
|
213
|
+
: '';
|
|
214
|
+
if (!credential) {
|
|
215
|
+
return { status: 'no-credential', credentialEnvVar: binding.credentialEnvVar };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
219
|
+
if (typeof fetchImpl !== 'function') return { status: 'not-configured' };
|
|
220
|
+
|
|
221
|
+
const baseUrl = binding.apiUrl.endsWith('/') ? binding.apiUrl : `${binding.apiUrl}/`;
|
|
222
|
+
const endpoint = new URL(
|
|
223
|
+
`api/sync/projects/${encodeURIComponent(binding.projectId)}/status`,
|
|
224
|
+
baseUrl,
|
|
225
|
+
);
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
228
|
+
try {
|
|
229
|
+
const response = await fetchImpl(endpoint.href, {
|
|
230
|
+
headers: {
|
|
231
|
+
accept: 'application/json',
|
|
232
|
+
authorization: `Bearer ${credential}`,
|
|
233
|
+
},
|
|
234
|
+
signal: controller.signal,
|
|
235
|
+
});
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
return { status: 'unreachable', detail: `HTTP ${response.status}` };
|
|
238
|
+
}
|
|
239
|
+
const data = typeof response.json === 'function' ? await response.json() : {};
|
|
240
|
+
const hostedMode = typeof data.mode === 'string' ? data.mode : null;
|
|
241
|
+
return {
|
|
242
|
+
status: 'ok',
|
|
243
|
+
hostedMode,
|
|
244
|
+
localMode,
|
|
245
|
+
mismatch: Boolean(hostedMode && localMode && hostedMode !== localMode),
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return { status: 'unreachable', detail: error?.message ?? 'network error' };
|
|
249
|
+
} finally {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
176
254
|
async function readProjectSummary(projectFilePath) {
|
|
177
255
|
try {
|
|
178
256
|
const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
package/src/sync.js
CHANGED
|
@@ -155,21 +155,28 @@ async function applyPullExportLocked(options, rootDir) {
|
|
|
155
155
|
throw new Error(`Pull-export bundle at ${outputPath} has no operations.`);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
// Pre-validate EVERY operation before writing anything, and classify
|
|
159
|
+
// operations whose target already matches the after-state as
|
|
160
|
+
// already-applied. This makes a retry after a partial failure converge
|
|
161
|
+
// instead of failing forever on the ops that succeeded last time.
|
|
162
|
+
const plan = [];
|
|
159
163
|
for (const operation of operations) {
|
|
160
164
|
const relativePath = normalizeCanonicalPath(operation.path);
|
|
161
165
|
const targetPath = path.join(rootDir, relativePath);
|
|
162
166
|
const currentHash = await readCurrentHash(targetPath);
|
|
163
167
|
const expectedBefore = operation.before_content_hash ?? null;
|
|
164
|
-
if (currentHash !== expectedBefore) {
|
|
165
|
-
throw new Error(
|
|
166
|
-
`Cannot apply ${relativePath}: local content hash ${currentHash ?? 'missing'} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
168
|
|
|
170
169
|
if (operation.op === 'delete') {
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
if (currentHash === null) {
|
|
171
|
+
plan.push({ relativePath, targetPath, action: 'skip', status: 'already-deleted' });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (currentHash !== expectedBefore) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Cannot apply ${relativePath}: local content hash ${currentHash} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
plan.push({ relativePath, targetPath, action: 'delete' });
|
|
173
180
|
continue;
|
|
174
181
|
}
|
|
175
182
|
|
|
@@ -180,10 +187,32 @@ async function applyPullExportLocked(options, rootDir) {
|
|
|
180
187
|
if (afterHash !== operation.after_content_hash) {
|
|
181
188
|
throw new Error(`Exported content hash does not match raw_text for ${relativePath}.`);
|
|
182
189
|
}
|
|
190
|
+
if (currentHash === afterHash) {
|
|
191
|
+
plan.push({ relativePath, targetPath, action: 'skip', status: 'already-applied' });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (currentHash !== expectedBefore) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Cannot apply ${relativePath}: local content hash ${currentHash ?? 'missing'} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
plan.push({ relativePath, targetPath, action: 'write', rawText: operation.raw_text });
|
|
200
|
+
}
|
|
183
201
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
202
|
+
const applied = [];
|
|
203
|
+
for (const step of plan) {
|
|
204
|
+
if (step.action === 'skip') {
|
|
205
|
+
applied.push({ path: step.relativePath, status: step.status });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (step.action === 'delete') {
|
|
209
|
+
await unlink(step.targetPath);
|
|
210
|
+
applied.push({ path: step.relativePath, status: 'deleted' });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
await mkdir(path.dirname(step.targetPath), { recursive: true });
|
|
214
|
+
await writeFile(step.targetPath, ensureTrailingNewline(step.rawText), 'utf8');
|
|
215
|
+
applied.push({ path: step.relativePath, status: 'written' });
|
|
187
216
|
}
|
|
188
217
|
|
|
189
218
|
bundle.applied_at = new Date().toISOString();
|
|
@@ -200,6 +229,220 @@ async function applyPullExportLocked(options, rootDir) {
|
|
|
200
229
|
};
|
|
201
230
|
}
|
|
202
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Re-baselines this root's sync cursor onto the hosted head (D-287 sync
|
|
234
|
+
* trust): the recovery path for a fresh clone or second device whose
|
|
235
|
+
* gitignored cursor is missing, which previously hit a permanent 409
|
|
236
|
+
* needs_rebase. Preserves D-215's inspect-then-choose workflow: a pull
|
|
237
|
+
* preview always runs first, and pending hosted proposals or conflicts
|
|
238
|
+
* refuse the adopt unless --accept-divergence is passed.
|
|
239
|
+
*/
|
|
240
|
+
export async function adoptRemoteHead(options = {}, environment = {}) {
|
|
241
|
+
const context = await loadSyncContext(options, environment);
|
|
242
|
+
const existingManifest = await readJsonIfPresent(context.manifestPath);
|
|
243
|
+
const manifestResult = await writeStateManifest(context.rootDir, {
|
|
244
|
+
sync: existingManifest?.sync,
|
|
245
|
+
});
|
|
246
|
+
const manifest = manifestResult.manifest;
|
|
247
|
+
const cursor = readSyncCursor(manifest.sync, context.binding);
|
|
248
|
+
|
|
249
|
+
const response = await postJson(context, 'pull-preview', {
|
|
250
|
+
state_version: manifest.state_version,
|
|
251
|
+
base_remote_revision_id: normalizeUuid(cursor.last_successful_remote_revision),
|
|
252
|
+
local_root_revision: manifest.canonical.local_root_revision,
|
|
253
|
+
local_manifest_hash: manifest.canonical.manifest_hash,
|
|
254
|
+
local_documents: buildLocalDocumentSummaries(manifest.documents),
|
|
255
|
+
include_pending_proposals: true,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const targetRemoteRevisionId = normalizeUuid(response.target_remote_revision_id);
|
|
259
|
+
if (!targetRemoteRevisionId) {
|
|
260
|
+
throw new Error('The hosted project has no baseline revision to adopt yet. Run vibecompass push first.');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const proposals = Array.isArray(response.proposals) ? response.proposals : [];
|
|
264
|
+
const conflicts = Array.isArray(response.conflicts) ? response.conflicts : [];
|
|
265
|
+
if ((proposals.length > 0 || conflicts.length > 0) && options.acceptDivergence !== true) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Refusing to adopt the hosted head: ${proposals.length} pending proposal${proposals.length === 1 ? '' : 's'} and ${conflicts.length} conflict${conflicts.length === 1 ? '' : 's'} exist for this project (D-215: inspect divergence before choosing a baseline). Review with vibecompass pull-preview, then re-run sync-adopt --accept-divergence to adopt anyway.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const sync = buildSyncStateWithCursor(manifest.sync, context.binding, {
|
|
272
|
+
...cursor,
|
|
273
|
+
last_successful_remote_revision: targetRemoteRevisionId,
|
|
274
|
+
last_successful_local_root_revision: manifest.canonical.local_root_revision,
|
|
275
|
+
last_successful_manifest_hash: manifest.canonical.manifest_hash,
|
|
276
|
+
last_sync_direction: 'adopt',
|
|
277
|
+
last_sync_at: new Date().toISOString(),
|
|
278
|
+
pending_previews: [],
|
|
279
|
+
});
|
|
280
|
+
const updatedManifest = await writeStateManifest(context.rootDir, { sync });
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
status: 'adopted',
|
|
284
|
+
rootDir: context.rootDir,
|
|
285
|
+
manifestPath: updatedManifest.manifestPath,
|
|
286
|
+
remoteRevisionId: targetRemoteRevisionId,
|
|
287
|
+
proposalCount: proposals.length,
|
|
288
|
+
conflictCount: conflicts.length,
|
|
289
|
+
warnings: [
|
|
290
|
+
'The next push will record this root\'s content as a new revision on top of the adopted head. If local files differ from the hosted head, the hosted content is replaced (visibly, as a new revision).',
|
|
291
|
+
],
|
|
292
|
+
message: `Adopted hosted head ${targetRemoteRevisionId} as this root's sync baseline.`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Materializes a complete local memory root from a hosted bootstrap bundle
|
|
298
|
+
* (D-289): the escape hatch, disaster-recovery, and second-device path. All
|
|
299
|
+
* document hashes are verified before anything is written (fail closed). For
|
|
300
|
+
* local-primary bundles with a resolvable sync binding, the bundle's server
|
|
301
|
+
* head is seeded as the sync cursor so the first push has the correct parent;
|
|
302
|
+
* hosted-only bundles materialize a fully usable offline root (the server
|
|
303
|
+
* rejects hosted-only push until the project is demoted).
|
|
304
|
+
*/
|
|
305
|
+
export async function bootstrapFromBundle(options = {}, environment = {}) {
|
|
306
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
307
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
308
|
+
if (!options.bundlePath) {
|
|
309
|
+
throw new Error('bootstrap requires --bundle <file> (download it from the hosted Setup page).');
|
|
310
|
+
}
|
|
311
|
+
const bundlePath = path.resolve(cwd, options.bundlePath);
|
|
312
|
+
return withMemoryRootLock(rootDir, 'bootstrap', () =>
|
|
313
|
+
bootstrapFromBundleLocked(options, rootDir, bundlePath),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function bootstrapFromBundleLocked(options, rootDir, bundlePath) {
|
|
318
|
+
const bundle = await readJsonRequired(bundlePath, 'bootstrap bundle');
|
|
319
|
+
if (bundle.bundle_kind !== 'bootstrap_export') {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`File at ${bundlePath} is not a bootstrap bundle (bundle_kind: ${bundle.bundle_kind ?? 'missing'}).`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const documents = Array.isArray(bundle.documents) ? bundle.documents : [];
|
|
325
|
+
if (documents.length === 0) {
|
|
326
|
+
throw new Error(`Bootstrap bundle at ${bundlePath} has no documents.`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
330
|
+
let existingProjectFile = false;
|
|
331
|
+
try {
|
|
332
|
+
await readFile(projectFilePath, 'utf8');
|
|
333
|
+
existingProjectFile = true;
|
|
334
|
+
} catch {
|
|
335
|
+
existingProjectFile = false;
|
|
336
|
+
}
|
|
337
|
+
if (existingProjectFile) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`${rootDir} already contains project.yaml. Bootstrap only materializes fresh roots — pick an empty --root or move the existing one aside.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Fail closed BEFORE writing anything: verify every document's hash and
|
|
344
|
+
// path, and require the bundle to carry project.yaml.
|
|
345
|
+
let hasProjectFile = false;
|
|
346
|
+
const validated = documents.map((document) => {
|
|
347
|
+
const relativePath = normalizeCanonicalPath(document.path);
|
|
348
|
+
if (document.op !== 'write' || typeof document.raw_text !== 'string') {
|
|
349
|
+
throw new Error(`Unsupported bootstrap operation for ${relativePath}.`);
|
|
350
|
+
}
|
|
351
|
+
const hash = sha256Text(document.raw_text);
|
|
352
|
+
if (hash !== document.after_content_hash) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Bootstrap bundle content hash does not match raw_text for ${relativePath}. The bundle is corrupt — download it again.`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (relativePath === 'project.yaml') hasProjectFile = true;
|
|
358
|
+
return { relativePath, rawText: document.raw_text };
|
|
359
|
+
});
|
|
360
|
+
if (!hasProjectFile) {
|
|
361
|
+
throw new Error('Bootstrap bundle does not include project.yaml.');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Content is written verbatim (no newline normalization) so the local root
|
|
365
|
+
// byte-matches the hosted head the bundle's cursor points at.
|
|
366
|
+
const written = [];
|
|
367
|
+
for (const document of validated) {
|
|
368
|
+
const targetPath = path.join(rootDir, document.relativePath);
|
|
369
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
370
|
+
await writeFile(targetPath, document.rawText, 'utf8');
|
|
371
|
+
written.push(document.relativePath);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const warnings = [];
|
|
375
|
+
let manifestPath = null;
|
|
376
|
+
let cursorSeeded = false;
|
|
377
|
+
let projectMode = null;
|
|
378
|
+
try {
|
|
379
|
+
const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
380
|
+
sourceName: projectFilePath,
|
|
381
|
+
});
|
|
382
|
+
projectMode = typeof project.mode === 'string' ? project.mode : null;
|
|
383
|
+
|
|
384
|
+
let sync;
|
|
385
|
+
const serverHead = bundle.server_head ?? null;
|
|
386
|
+
if (projectMode === 'local-primary' && serverHead?.remote_revision_id) {
|
|
387
|
+
const binding = resolveSyncBinding(project, options.syncTarget ?? null);
|
|
388
|
+
if (binding) {
|
|
389
|
+
const manifestResult = await writeStateManifest(rootDir);
|
|
390
|
+
// Fail closed on cursor seeding: the cursor claims "this root's
|
|
391
|
+
// content IS the server head" — only true when the materialized
|
|
392
|
+
// manifest hash matches the head hash captured in the bundle. A
|
|
393
|
+
// mismatched bundle still materializes (files are hash-verified
|
|
394
|
+
// individually) but must not claim the head as its baseline.
|
|
395
|
+
const localHash = manifestResult.manifest.canonical.manifest_hash;
|
|
396
|
+
if (serverHead.manifest_hash && localHash !== serverHead.manifest_hash) {
|
|
397
|
+
warnings.push(
|
|
398
|
+
`Sync cursor NOT seeded: the materialized root's manifest hash (${localHash}) does not match the bundle's server head (${serverHead.manifest_hash}). `
|
|
399
|
+
+ 'The files are intact, but run vibecompass sync-adopt (after reviewing divergence) before pushing.',
|
|
400
|
+
);
|
|
401
|
+
} else {
|
|
402
|
+
sync = buildSyncStateWithCursor(manifestResult.manifest.sync, binding, {
|
|
403
|
+
last_successful_remote_revision: serverHead.remote_revision_id,
|
|
404
|
+
last_successful_local_root_revision:
|
|
405
|
+
manifestResult.manifest.canonical.local_root_revision,
|
|
406
|
+
last_successful_manifest_hash: localHash,
|
|
407
|
+
last_sync_direction: 'bootstrap',
|
|
408
|
+
last_sync_at: new Date().toISOString(),
|
|
409
|
+
pending_previews: [],
|
|
410
|
+
});
|
|
411
|
+
cursorSeeded = true;
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
warnings.push(
|
|
415
|
+
'No sync binding found in the bundled project.yaml; run connect-hosted before pushing.',
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const manifestResult = await writeStateManifest(rootDir, sync ? { sync } : undefined);
|
|
420
|
+
manifestPath = manifestResult.manifestPath;
|
|
421
|
+
} catch (error) {
|
|
422
|
+
warnings.push(
|
|
423
|
+
`Documents were written, but the state manifest could not be generated: ${error.message}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (projectMode === 'hosted-only') {
|
|
428
|
+
warnings.push(
|
|
429
|
+
'This is a hosted-only project: the root is fully usable offline, but hosted sync rejects local push until the project is demoted. '
|
|
430
|
+
+ 'To make this root canonical: run vibecompass connect-hosted (with the API URL, project id, and token env var from the hosted Setup page) to add the sync binding, then vibecompass demote-hosted.',
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
status: 'bootstrapped',
|
|
436
|
+
rootDir,
|
|
437
|
+
manifestPath,
|
|
438
|
+
documentCount: written.length,
|
|
439
|
+
mode: projectMode,
|
|
440
|
+
cursorSeeded,
|
|
441
|
+
warnings,
|
|
442
|
+
message: `Materialized ${written.length} document${written.length === 1 ? '' : 's'} into ${rootDir}${cursorSeeded ? ' and seeded the sync cursor from the bundle head' : ''}.`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
203
446
|
async function loadSyncContext(options, environment) {
|
|
204
447
|
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
205
448
|
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
@@ -210,7 +453,11 @@ async function loadSyncContext(options, environment) {
|
|
|
210
453
|
});
|
|
211
454
|
|
|
212
455
|
if (project.mode !== 'local-primary') {
|
|
213
|
-
throw new Error(
|
|
456
|
+
throw new Error(
|
|
457
|
+
`Hosted sync commands require project.yaml mode: local-primary (this root says "${project.mode}"). `
|
|
458
|
+
+ 'Mode is recorded twice — locally in project.yaml and on the hosted project — and they must agree. '
|
|
459
|
+
+ 'Run vibecompass status to compare both records; use promote-hosted/demote-hosted to change modes on both sides together instead of hand-editing.',
|
|
460
|
+
);
|
|
214
461
|
}
|
|
215
462
|
|
|
216
463
|
const binding = resolveSyncBinding(project, options.syncTarget ?? null);
|
|
@@ -220,7 +467,11 @@ async function loadSyncContext(options, environment) {
|
|
|
220
467
|
|
|
221
468
|
const credential = normalizeOptionalString((environment.env ?? process.env)[binding.credentialEnvVar]);
|
|
222
469
|
if (!credential) {
|
|
223
|
-
throw new Error(
|
|
470
|
+
throw new Error(
|
|
471
|
+
`Hosted sync command requires ${binding.credentialEnvVar}. New terminals do not inherit one-off exports: `
|
|
472
|
+
+ `re-export it (export ${binding.credentialEnvVar}="<sync token>") or persist it in your shell profile (~/.zshenv or ~/.bashrc). `
|
|
473
|
+
+ 'Lost the token? Rotate it on the hosted dashboard under Setup -> Hosted sync, then export the new value.',
|
|
474
|
+
);
|
|
224
475
|
}
|
|
225
476
|
|
|
226
477
|
const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
|