@vibecompass/vibecompass 0.14.0 → 0.15.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.
@@ -19,6 +19,7 @@ import {
19
19
  writeDocsReviewSourceInventory,
20
20
  } from './source-inventory.js';
21
21
  import { readSyncCursor, resolveSyncBinding } from './sync-binding.js';
22
+ import { formatMissingCredentialError, resolveSyncCredential } from './credential-store.js';
22
23
  import { PACKAGE_VERSION } from './version.js';
23
24
  import { withMemoryRootLock } from './serialization.js';
24
25
  import { listDecisionFileNames, readNextDecisionId } from './decisions.js';
@@ -89,6 +90,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
89
90
  rootDir,
90
91
  statePath,
91
92
  env: environment.env ?? process.env,
93
+ credentialStore: environment.runtime?.credentialStore ?? null,
92
94
  fetch: environment.runtime?.fetch ?? globalThis.fetch,
93
95
  });
94
96
  }
@@ -137,6 +139,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
137
139
  const hosted = options.submitHosted
138
140
  ? await submitHostedDocsReview({
139
141
  env,
142
+ credentialStore: environment.runtime?.credentialStore ?? null,
140
143
  project,
141
144
  reviewConfig,
142
145
  reviewPrompt,
@@ -1588,9 +1591,20 @@ async function submitHostedDocsReview(options) {
1588
1591
  throw new Error('docs-review --submit-hosted requires sync.credential_env_var in project.yaml.');
1589
1592
  }
1590
1593
 
1591
- const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
1594
+ // D-355: env override, then the local credential store.
1595
+ const submitBinding = {
1596
+ apiUrl: options.runtime.api_url,
1597
+ projectId: options.runtime.project_id,
1598
+ credentialEnvVar,
1599
+ target: options.runtime.sync_target ?? null,
1600
+ };
1601
+ const submitResolution = await resolveSyncCredential(submitBinding, {
1602
+ env: options.env ?? process.env,
1603
+ ...(options.credentialStore ?? {}),
1604
+ });
1605
+ const credential = submitResolution.credential;
1592
1606
  if (!credential) {
1593
- throw new Error(`docs-review --submit-hosted requires ${credentialEnvVar}.`);
1607
+ throw new Error(formatMissingCredentialError(submitBinding, { action: 'docs-review --submit-hosted', detail: submitResolution.detail }));
1594
1608
  }
1595
1609
 
1596
1610
  const endpoint = new URL(
@@ -1758,9 +1772,19 @@ async function pollHostedDocsReview(options) {
1758
1772
  throw new Error('docs-review --poll-hosted requires runtime.credential_env_var in state/docs-review.json.');
1759
1773
  }
1760
1774
 
1761
- const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
1775
+ const pollBinding = {
1776
+ apiUrl: runtime.api_url,
1777
+ projectId: runtime.project_id,
1778
+ credentialEnvVar,
1779
+ target: runtime.sync_target ?? null,
1780
+ };
1781
+ const pollResolution = await resolveSyncCredential(pollBinding, {
1782
+ env: options.env ?? process.env,
1783
+ ...(options.credentialStore ?? {}),
1784
+ });
1785
+ const credential = pollResolution.credential;
1762
1786
  if (!credential) {
1763
- throw new Error(`docs-review --poll-hosted requires ${credentialEnvVar}.`);
1787
+ throw new Error(formatMissingCredentialError(pollBinding, { action: 'docs-review --poll-hosted', detail: pollResolution.detail }));
1764
1788
  }
1765
1789
 
1766
1790
  const endpoint = new URL(
package/src/index.js CHANGED
@@ -7,6 +7,16 @@ export { initializeProjectMemory } from './init.js';
7
7
  export { preflightDocsReview } from './docs-review.js';
8
8
  export { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
9
9
  export { demoteHosted, promoteHosted } from './mode-transition.js';
10
+ export { loginHosted } from './login.js';
11
+ export {
12
+ describeCredentialSource,
13
+ formatMissingCredentialError,
14
+ listSyncCredentials,
15
+ removeSyncCredential,
16
+ resolveCredentialStoreDir,
17
+ resolveSyncCredential,
18
+ storeSyncCredential,
19
+ } from './credential-store.js';
10
20
  export {
11
21
  adoptRemoteHead,
12
22
  applyPullExport,
package/src/login.js ADDED
@@ -0,0 +1,257 @@
1
+ import { execFile as nodeExecFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { connectHostedProjectMemory } from './init.js';
6
+ import { parseSimpleYaml } from './simple-yaml.js';
7
+ import { resolveSyncBinding } from './sync-binding.js';
8
+ import {
9
+ credentialStoreOptionsFrom,
10
+ normalizeApiUrl,
11
+ storeSyncCredential,
12
+ } from './credential-store.js';
13
+ import { PACKAGE_VERSION } from './version.js';
14
+
15
+ /**
16
+ * Browser device-code sign-in for hosted sync (D-355).
17
+ *
18
+ * The CLI asks the hosted app for a device code, shows (and opens) the
19
+ * verification link plus a short user code, and polls until the signed-in
20
+ * project owner approves the code in the dashboard. The claim response is the
21
+ * only place the plaintext token ever exists: the CLI stores it in the local
22
+ * credential store and, when the root has no sync binding yet, writes the
23
+ * non-secret binding into project.yaml exactly as `connect-hosted` would.
24
+ */
25
+
26
+ export const DEFAULT_HOSTED_API_URL = 'https://vibecompass.dev';
27
+ export const DEFAULT_SYNC_CREDENTIAL_ENV_VAR = 'VIBECOMPASS_SYNC_TOKEN';
28
+ export const DEVICE_AUTH_START_ROUTE = 'api/cli/device-auth';
29
+ export const DEVICE_AUTH_TOKEN_ROUTE = 'api/cli/device-auth/token';
30
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
31
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 600;
32
+ const REQUEST_TIMEOUT_MS = 15_000;
33
+ const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
34
+
35
+ export async function loginHosted(options = {}, environment = {}) {
36
+ const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
37
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
38
+ const projectFilePath = path.join(rootDir, 'project.yaml');
39
+ const io = environment.io ?? {};
40
+ const write = (text) => (io.stdout ?? process.stdout).write(text);
41
+ const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
42
+ if (typeof fetchImpl !== 'function') {
43
+ throw new Error('login requires a fetch implementation.');
44
+ }
45
+ const sleep = environment.runtime?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
46
+ const now = environment.runtime?.now ?? (() => Date.now());
47
+
48
+ let project;
49
+ try {
50
+ project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), { sourceName: projectFilePath });
51
+ } catch (error) {
52
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
53
+ throw new Error(`No project.yaml found in ${rootDir}. Run "vibecompass init" first, then "vibecompass login".`);
54
+ }
55
+ throw error;
56
+ }
57
+ if (!VALID_MODES.has(project.mode)) {
58
+ throw new Error('login requires project.yaml mode to be local-only, local-primary, or hosted-only.');
59
+ }
60
+
61
+ const binding = resolveLoginBinding(project, options);
62
+ const apiUrl = binding?.apiUrl ?? options.apiUrl ?? DEFAULT_HOSTED_API_URL;
63
+ if (binding && options.apiUrl && normalizeApiUrl(options.apiUrl) !== normalizeApiUrl(binding.apiUrl)) {
64
+ throw new Error(
65
+ `--api-url ${options.apiUrl} does not match the bound hosted URL ${binding.apiUrl}${binding.target ? ` for target ${binding.target}` : ''}. `
66
+ + 'Pass --sync-target <name> to sign in to a different environment, or rebind with connect-hosted.',
67
+ );
68
+ }
69
+ const projectHint = binding?.projectId ?? options.projectId ?? null;
70
+ const credentialEnvVar = binding?.credentialEnvVar ?? options.credentialEnvVar ?? DEFAULT_SYNC_CREDENTIAL_ENV_VAR;
71
+
72
+ // Every network wait (start, each poll, and body reads) is bounded by the
73
+ // overall deadline so a stalled server can never outlive --poll-timeout.
74
+ const requestedTimeoutSeconds = positiveNumber(options.pollTimeoutSeconds, DEFAULT_POLL_TIMEOUT_SECONDS);
75
+ let deadline = now() + requestedTimeoutSeconds * 1000;
76
+ const timing = { now, deadline: () => deadline };
77
+
78
+ const start = await postJson(fetchImpl, apiUrl, DEVICE_AUTH_START_ROUTE, {
79
+ ...(projectHint ? { project_id: projectHint } : {}),
80
+ label: options.label ?? os.hostname(),
81
+ package_version: PACKAGE_VERSION,
82
+ }, timing);
83
+ if (!start.ok) {
84
+ throw new Error(`Hosted sign-in could not start (HTTP ${start.status}${start.body?.error ? `: ${start.body.error}` : ''}). Check the hosted URL ${apiUrl} and try again.`);
85
+ }
86
+ const deviceCode = requireString(start.body, 'device_code');
87
+ const userCode = requireString(start.body, 'user_code');
88
+ const verificationUri = requireString(start.body, 'verification_uri_complete', 'verification_uri');
89
+ const expiresIn = positiveNumber(start.body.expires_in, DEFAULT_POLL_TIMEOUT_SECONDS);
90
+ let intervalMs = positiveNumber(start.body.interval, DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
91
+
92
+ write('Sign in to VibeCompass\n');
93
+ write(`Open: ${verificationUri}\n`);
94
+ write(`Code: ${userCode}\n`);
95
+ if (!options.noBrowser) {
96
+ const opened = await openBrowser(verificationUri, environment);
97
+ write(opened ? 'Opening your browser…\n' : 'Could not open a browser automatically; paste the link into any browser.\n');
98
+ }
99
+ write(`Waiting for approval in the dashboard (expires in ${Math.round(expiresIn / 60)} minutes)…\n`);
100
+
101
+ deadline = Math.min(deadline, now() + expiresIn * 1000);
102
+ let claim = null;
103
+ while (!claim) {
104
+ const remaining = deadline - now();
105
+ if (remaining <= 0) {
106
+ throw new Error('Timed out waiting for the sign-in to be approved. Run "vibecompass login" again.');
107
+ }
108
+ await sleep(Math.min(intervalMs, remaining));
109
+ const poll = await postJson(fetchImpl, apiUrl, DEVICE_AUTH_TOKEN_ROUTE, { device_code: deviceCode }, timing);
110
+ if (poll.ok && poll.body?.status === 'approved') {
111
+ claim = poll.body;
112
+ break;
113
+ }
114
+ const code = typeof poll.body?.error === 'string' ? poll.body.error : null;
115
+ if (code === 'authorization_pending') {
116
+ continue;
117
+ }
118
+ if (code === 'slow_down') {
119
+ intervalMs += 5000;
120
+ continue;
121
+ }
122
+ if (code === 'expired_token') {
123
+ throw new Error('The sign-in code expired before it was approved. Run "vibecompass login" again.');
124
+ }
125
+ if (code === 'access_denied') {
126
+ throw new Error('The sign-in request was denied in the dashboard.');
127
+ }
128
+ throw new Error(`Hosted sign-in failed (HTTP ${poll.status}${code ? `: ${code}` : ''}).`);
129
+ }
130
+
131
+ const token = requireString(claim, 'token');
132
+ const approvedProject = claim.project && typeof claim.project === 'object' ? claim.project : {};
133
+ const projectId = requireString(approvedProject, 'id');
134
+ const projectName = typeof approvedProject.name === 'string' ? approvedProject.name : projectId;
135
+
136
+ const stored = await storeSyncCredential(
137
+ {
138
+ apiUrl,
139
+ projectId,
140
+ token,
141
+ label: `VibeCompass sync token (${projectName})`,
142
+ source: 'login',
143
+ backend: options.credentialStore,
144
+ },
145
+ credentialStoreOptionsFrom(environment),
146
+ );
147
+
148
+ const warnings = [];
149
+ let bindingWritten = null;
150
+ if (!binding) {
151
+ bindingWritten = await connectHostedProjectMemory({
152
+ cwd,
153
+ rootDir: options.rootDir ?? '.compass',
154
+ ...(options.syncTarget ? { targetName: options.syncTarget } : {}),
155
+ sync: { apiUrl, projectId, credentialEnvVar },
156
+ });
157
+ } else if (binding.projectId !== projectId) {
158
+ warnings.push(
159
+ `This root is bound to project ${binding.projectId}${binding.target ? ` (target ${binding.target})` : ''}, but you approved ${projectName} (${projectId}). `
160
+ + `The token is stored for the approved project; rebind with "vibecompass connect-hosted${binding.target ? ` --target ${binding.target}` : ''} --sync-api-url ${apiUrl} --sync-project-id ${projectId} --sync-credential-env-var ${credentialEnvVar}" if that was intended.`,
161
+ );
162
+ }
163
+
164
+ return {
165
+ apiUrl,
166
+ project: { id: projectId, name: projectName },
167
+ credential: claim.credential && typeof claim.credential === 'object' ? claim.credential : null,
168
+ stored,
169
+ binding: bindingWritten,
170
+ previousBinding: binding,
171
+ mode: bindingWritten?.mode ?? project.mode,
172
+ warnings,
173
+ };
174
+ }
175
+
176
+ function resolveLoginBinding(project, options) {
177
+ try {
178
+ return resolveSyncBinding(project, options.syncTarget ?? null);
179
+ } catch (error) {
180
+ // An unknown --sync-target may be created by login only when the caller
181
+ // is explicit about which hosted environment it points at (D-236: a typo
182
+ // must never silently land in another environment).
183
+ if (options.syncTarget && options.apiUrl) {
184
+ return null;
185
+ }
186
+ throw new Error(`${error.message} To create it through login, add --api-url <hosted url>.`);
187
+ }
188
+ }
189
+
190
+ async function postJson(fetchImpl, apiUrl, routeName, body, timing) {
191
+ const endpoint = new URL(routeName, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
192
+ const remaining = timing ? timing.deadline() - timing.now() : REQUEST_TIMEOUT_MS;
193
+ if (remaining <= 0) {
194
+ throw new Error('Timed out waiting for the sign-in to be approved. Run "vibecompass login" again.');
195
+ }
196
+ const controller = new AbortController();
197
+ const timer = setTimeout(() => controller.abort(), Math.min(REQUEST_TIMEOUT_MS, remaining));
198
+ try {
199
+ let response;
200
+ try {
201
+ response = await fetchImpl(endpoint.href, {
202
+ method: 'POST',
203
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
204
+ body: JSON.stringify(body),
205
+ signal: controller.signal,
206
+ });
207
+ } catch (error) {
208
+ if (controller.signal.aborted) {
209
+ throw new Error(`Hosted sign-in request to ${endpoint.origin} timed out. Check the connection and run "vibecompass login" again.`);
210
+ }
211
+ throw new Error(`Could not reach ${endpoint.origin} (${error?.message ?? 'network error'}).`);
212
+ }
213
+ let parsed = null;
214
+ try {
215
+ parsed = typeof response.json === 'function' ? await response.json() : null;
216
+ } catch {
217
+ if (controller.signal.aborted) {
218
+ throw new Error(`Hosted sign-in response from ${endpoint.origin} stalled. Check the connection and run "vibecompass login" again.`);
219
+ }
220
+ parsed = null;
221
+ }
222
+ return { ok: Boolean(response.ok), status: response.status, body: parsed && typeof parsed === 'object' ? parsed : {} };
223
+ } finally {
224
+ clearTimeout(timer);
225
+ }
226
+ }
227
+
228
+ async function openBrowser(url, environment) {
229
+ const platform = environment.runtime?.platform ?? process.platform;
230
+ const execFileImpl = environment.runtime?.execFile ?? nodeExecFile;
231
+ const [file, args] = platform === 'darwin'
232
+ ? ['open', [url]]
233
+ : platform === 'win32'
234
+ ? ['cmd', ['/c', 'start', '', url]]
235
+ : ['xdg-open', [url]];
236
+ return new Promise((resolve) => {
237
+ try {
238
+ execFileImpl(file, args, { windowsHide: true }, (error) => resolve(!error));
239
+ } catch {
240
+ resolve(false);
241
+ }
242
+ });
243
+ }
244
+
245
+ function requireString(source, ...keys) {
246
+ for (const key of keys) {
247
+ const value = source?.[key];
248
+ if (typeof value === 'string' && value.trim() !== '') {
249
+ return value.trim();
250
+ }
251
+ }
252
+ throw new Error(`Hosted sign-in response is missing ${keys[0]}.`);
253
+ }
254
+
255
+ function positiveNumber(value, fallback) {
256
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
257
+ }
@@ -2,6 +2,11 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parseSimpleYaml } from './simple-yaml.js';
4
4
  import { resolveSyncBinding } from './sync-binding.js';
5
+ import {
6
+ credentialStoreOptionsFrom,
7
+ formatMissingCredentialError,
8
+ resolveSyncCredential,
9
+ } from './credential-store.js';
5
10
  import { withMemoryRootLock } from './serialization.js';
6
11
  import { adoptRemoteHead, pushProjectMemory } from './sync.js';
7
12
 
@@ -186,9 +191,10 @@ async function loadModeAgnosticSyncContext(rootDir, options, environment) {
186
191
  if (!binding) {
187
192
  throw new Error('Mode transitions require a sync binding in project.yaml (run connect-hosted first).');
188
193
  }
189
- const credential = ((environment.env ?? process.env)[binding.credentialEnvVar] ?? '').trim();
194
+ const resolution = await resolveSyncCredential(binding, credentialStoreOptionsFrom(environment));
195
+ const credential = resolution.credential;
190
196
  if (!credential) {
191
- throw new Error(`Mode transitions require ${binding.credentialEnvVar} to be set.`);
197
+ throw new Error(formatMissingCredentialError(binding, { action: 'This mode transition', detail: resolution.detail }));
192
198
  }
193
199
  const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
194
200
  if (typeof fetchImpl !== 'function') {
@@ -0,0 +1,64 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { Writable } from 'node:stream';
3
+
4
+ /**
5
+ * Hidden-input prompt for secrets (D-355). Typed characters are never echoed:
6
+ * readline runs in terminal mode against a muted output while the visible
7
+ * prompt text is written to the real stdout. A `runtime.prompt` adapter
8
+ * (tests, embedders) receives `{ type: 'password', message }` instead.
9
+ */
10
+ export function canPromptSecret(io = {}, runtime = {}) {
11
+ if (typeof runtime.prompt === 'function') {
12
+ return true;
13
+ }
14
+ const input = runtime.stdin ?? io.stdin ?? process.stdin;
15
+ return Boolean(input?.isTTY);
16
+ }
17
+
18
+ export async function promptSecret(message, io = {}, runtime = {}) {
19
+ if (typeof runtime.prompt === 'function') {
20
+ return normalizeSecret(await runtime.prompt({ type: 'password', message }));
21
+ }
22
+
23
+ const input = runtime.stdin ?? io.stdin ?? process.stdin;
24
+ const output = io.stdout ?? process.stdout;
25
+ if (!input?.isTTY) {
26
+ throw new Error('Secret prompts require an interactive terminal. Pipe the value with --token-stdin instead.');
27
+ }
28
+
29
+ output.write(`${message}: `);
30
+ const muted = new Writable({
31
+ write(_chunk, _encoding, callback) {
32
+ callback();
33
+ },
34
+ });
35
+ const rl = createInterface({ input, output: muted, terminal: true });
36
+ try {
37
+ return normalizeSecret(await rl.question(''));
38
+ } finally {
39
+ rl.close();
40
+ output.write('\n');
41
+ }
42
+ }
43
+
44
+ /** Reads one secret from stdin: the first non-empty line, trimmed. */
45
+ export async function readSecretFromStdin(io = {}, runtime = {}) {
46
+ const input = runtime.stdin ?? io.stdin ?? process.stdin;
47
+ if (typeof input?.setEncoding === 'function') {
48
+ input.setEncoding('utf8');
49
+ }
50
+ let data = '';
51
+ for await (const chunk of input) {
52
+ data += chunk;
53
+ }
54
+ const firstLine = data
55
+ .split(/\r?\n/)
56
+ .map((line) => line.trim())
57
+ .find((line) => line !== '');
58
+ return firstLine ?? null;
59
+ }
60
+
61
+ function normalizeSecret(value) {
62
+ const trimmed = String(value ?? '').trim();
63
+ return trimmed === '' ? null : trimmed;
64
+ }
package/src/status.js CHANGED
@@ -5,6 +5,7 @@ import { sha256Text } from './hash.js';
5
5
  import { resolveLaneMarkerContext } from './lane-marker.js';
6
6
  import { parseSimpleYaml } from './simple-yaml.js';
7
7
  import { resolveSyncBinding } from './sync-binding.js';
8
+ import { describeCredentialSource, resolveSyncCredential } from './credential-store.js';
8
9
  import { inferToolingRootForMemoryRoot, listProjectSessions } from './session.js';
9
10
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
10
11
  import { scanProjectMemory } from './project-memory.js';
@@ -68,6 +69,7 @@ export async function getProjectStatus(options = {}) {
68
69
  docsReview,
69
70
  agentFiles,
70
71
  projectMemory,
72
+ hostedSync,
71
73
  }),
72
74
  };
73
75
  }
@@ -125,6 +127,7 @@ export function renderStatusText(status) {
125
127
  : status.hostedSync?.status === 'unreachable'
126
128
  ? [`Hosted mode check: unreachable (${status.hostedSync.detail}).`]
127
129
  : []),
130
+ ...formatHostedSyncLines(status.hostedSync),
128
131
  `Root: ${status.rootDir}`,
129
132
  `Repos: ${status.project.repos.length > 0 ? status.project.repos.map((repo) => repo.id).join(', ') : 'None recorded'}`,
130
133
  `Active lanes: ${formatActiveLanes(status.sessions)}`,
@@ -207,16 +210,36 @@ async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
207
210
  }
208
211
  if (!binding) return { status: 'not-configured' };
209
212
 
210
- const env = options.env ?? process.env;
211
- const credential = typeof env[binding.credentialEnvVar] === 'string'
212
- ? env[binding.credentialEnvVar].trim()
213
- : '';
213
+ const bindingSummary = {
214
+ apiUrl: binding.apiUrl,
215
+ projectId: binding.projectId,
216
+ target: binding.target ?? null,
217
+ credentialEnvVar: binding.credentialEnvVar,
218
+ };
219
+ // D-355: report only the source of the credential (env / keychain / file /
220
+ // missing), never its value.
221
+ const resolution = await resolveSyncCredential(binding, {
222
+ env: options.env ?? process.env,
223
+ ...(options.credentialStore ?? {}),
224
+ });
225
+ const credential = resolution.credential;
226
+ const credentialSource = credential ? resolution.source : 'missing';
227
+ const credentialSourceLabel = credential ? describeCredentialSource(resolution) : 'missing';
214
228
  if (!credential) {
215
- return { status: 'no-credential', credentialEnvVar: binding.credentialEnvVar };
229
+ return {
230
+ status: 'no-credential',
231
+ credentialEnvVar: binding.credentialEnvVar,
232
+ credentialSource,
233
+ credentialSourceLabel,
234
+ ...(resolution.detail ? { detail: resolution.detail } : {}),
235
+ binding: bindingSummary,
236
+ };
216
237
  }
217
238
 
218
239
  const fetchImpl = options.fetch ?? globalThis.fetch;
219
- if (typeof fetchImpl !== 'function') return { status: 'not-configured' };
240
+ if (typeof fetchImpl !== 'function') {
241
+ return { status: 'not-configured', credentialSource, credentialSourceLabel, binding: bindingSummary };
242
+ }
220
243
 
221
244
  const baseUrl = binding.apiUrl.endsWith('/') ? binding.apiUrl : `${binding.apiUrl}/`;
222
245
  const endpoint = new URL(
@@ -234,7 +257,7 @@ async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
234
257
  signal: controller.signal,
235
258
  });
236
259
  if (!response.ok) {
237
- return { status: 'unreachable', detail: `HTTP ${response.status}` };
260
+ return { status: 'unreachable', detail: `HTTP ${response.status}`, credentialSource, credentialSourceLabel, binding: bindingSummary };
238
261
  }
239
262
  const data = typeof response.json === 'function' ? await response.json() : {};
240
263
  const hostedMode = typeof data.mode === 'string' ? data.mode : null;
@@ -243,9 +266,12 @@ async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
243
266
  hostedMode,
244
267
  localMode,
245
268
  mismatch: Boolean(hostedMode && localMode && hostedMode !== localMode),
269
+ credentialSource,
270
+ credentialSourceLabel,
271
+ binding: bindingSummary,
246
272
  };
247
273
  } catch (error) {
248
- return { status: 'unreachable', detail: error?.message ?? 'network error' };
274
+ return { status: 'unreachable', detail: error?.message ?? 'network error', credentialSource, credentialSourceLabel, binding: bindingSummary };
249
275
  } finally {
250
276
  clearTimeout(timer);
251
277
  }
@@ -460,13 +486,32 @@ async function readProjectMemoryStatus(rootDir) {
460
486
  }
461
487
  }
462
488
 
463
- function buildRecommendations({ project, compatibility, sessions, docsReview, agentFiles, projectMemory }) {
489
+ function formatHostedSyncLines(hostedSync) {
490
+ if (!hostedSync?.binding) {
491
+ return [];
492
+ }
493
+ const { binding } = hostedSync;
494
+ const where = `${binding.apiUrl} (project ${binding.projectId}${binding.target ? `, target ${binding.target}` : ''})`;
495
+ const credential = hostedSync.credentialSource === 'missing'
496
+ ? `missing${hostedSync.detail ? ` (${hostedSync.detail})` : ''} — run \`vibecompass login\` to sign in from this terminal`
497
+ : hostedSync.credentialSourceLabel ?? hostedSync.credentialSource;
498
+ return [
499
+ `Hosted sync: ${where}`,
500
+ `Sync credential: ${credential}`,
501
+ ];
502
+ }
503
+
504
+ function buildRecommendations({ project, compatibility, sessions, docsReview, agentFiles, projectMemory, hostedSync }) {
464
505
  const recommendations = [];
465
506
  if (project.status === 'unreadable') {
466
507
  recommendations.push('vibecompass init --guided');
467
508
  return recommendations;
468
509
  }
469
510
 
511
+ if (hostedSync?.status === 'no-credential') {
512
+ recommendations.push('vibecompass login');
513
+ }
514
+
470
515
  const packageStatus = compatibility.package.status;
471
516
  const stateStatus = compatibility.state.status;
472
517
  if (['legacy', 'behind', 'ahead', 'invalid'].includes(packageStatus) || ['missing', 'unreadable', 'unsupported'].includes(stateStatus)) {
package/src/sync.js CHANGED
@@ -4,6 +4,11 @@ import { sha256Text } from './hash.js';
4
4
  import { writeStateManifest } from './manifest.js';
5
5
  import { withMemoryRootLock } from './serialization.js';
6
6
  import { parseSimpleYaml } from './simple-yaml.js';
7
+ import {
8
+ credentialStoreOptionsFrom,
9
+ formatMissingCredentialError,
10
+ resolveSyncCredential,
11
+ } from './credential-store.js';
7
12
  import {
8
13
  buildSyncStateWithCursor,
9
14
  readSyncCursor,
@@ -465,12 +470,14 @@ async function loadSyncContext(options, environment) {
465
470
  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).');
466
471
  }
467
472
 
468
- const credential = normalizeOptionalString((environment.env ?? process.env)[binding.credentialEnvVar]);
473
+ // D-355: env var override first, then the local credential store
474
+ // (keychain or per-user file). The resolved value is used only as the
475
+ // bearer credential and never written anywhere.
476
+ const resolution = await resolveSyncCredential(binding, credentialStoreOptionsFrom(environment));
477
+ const credential = resolution.credential;
469
478
  if (!credential) {
470
479
  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.',
480
+ formatMissingCredentialError(binding, { action: 'This hosted sync command', detail: resolution.detail }),
474
481
  );
475
482
  }
476
483
 
@@ -485,6 +492,7 @@ async function loadSyncContext(options, environment) {
485
492
  manifestPath,
486
493
  fetch: fetchImpl,
487
494
  credential,
495
+ credentialSource: resolution.source,
488
496
  apiUrl: binding.apiUrl,
489
497
  projectId: binding.projectId,
490
498
  syncTarget: binding.target,