@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.
- package/CHANGELOG.md +65 -0
- package/README.md +40 -8
- package/package.json +1 -1
- package/src/cli.js +462 -4
- package/src/credential-store.js +856 -0
- package/src/docs-review.js +28 -4
- package/src/index.js +10 -0
- package/src/login.js +257 -0
- package/src/mode-transition.js +8 -2
- package/src/secret-prompt.js +64 -0
- package/src/status.js +54 -9
- package/src/sync.js +12 -4
package/src/docs-review.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/src/mode-transition.js
CHANGED
|
@@ -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
|
|
194
|
+
const resolution = await resolveSyncCredential(binding, credentialStoreOptionsFrom(environment));
|
|
195
|
+
const credential = resolution.credential;
|
|
190
196
|
if (!credential) {
|
|
191
|
-
throw new Error(
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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 {
|
|
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')
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|