@yolo-labs/yolobridge 0.1.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/dist/api-client.js +136 -0
- package/dist/attach-cmd.js +299 -0
- package/dist/browser-open.js +46 -0
- package/dist/cli.js +353 -0
- package/dist/config-store.js +99 -0
- package/dist/detach-cmd.js +37 -0
- package/dist/device-auth.js +149 -0
- package/dist/frame-actions.js +34 -0
- package/dist/heartbeat.js +31 -0
- package/dist/local-agent.js +437 -0
- package/dist/login-cmd.js +50 -0
- package/dist/reconnect.js +41 -0
- package/dist/sse-frame-parser.js +66 -0
- package/dist/status-cmd.js +52 -0
- package/dist/workspaces-cmd.js +37 -0
- package/package.json +34 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `yolo-bridge` — YoloBridge local daemon CLI (docs/YOLOBRIDGE_PLAN.md,
|
|
4
|
+
* Implementation Plan → build-order step 5).
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* yolo-bridge login — device-authorization flow (login-cmd.ts)
|
|
8
|
+
* yolo-bridge workspaces — list selectable workspaces (workspaces-cmd.ts)
|
|
9
|
+
* yolo-bridge attach [workspaceId] — attach + hold the SSE stream (attach-cmd.ts)
|
|
10
|
+
* (omit the id for an interactive picker)
|
|
11
|
+
* yolo-bridge detach — DELETE the current attachment (detach-cmd.ts)
|
|
12
|
+
* yolo-bridge status — print local login/attach state (status-cmd.ts)
|
|
13
|
+
*
|
|
14
|
+
* Base URLs default to this repo's real hostnames (CLAUDE.md → Project
|
|
15
|
+
* Overview): common-api `https://api.yolo.studio`, auth-service
|
|
16
|
+
* `https://auth.yololabs.ai`. Override with YOLOBRIDGE_API_URL /
|
|
17
|
+
* YOLOBRIDGE_AUTH_URL for local dev against a different environment.
|
|
18
|
+
*/
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { realpathSync } from 'node:fs';
|
|
21
|
+
import { runLogin } from './login-cmd.js';
|
|
22
|
+
import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
|
|
23
|
+
import { runDetach } from './detach-cmd.js';
|
|
24
|
+
import { getStatus, formatStatus } from './status-cmd.js';
|
|
25
|
+
import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
|
|
26
|
+
import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
|
|
27
|
+
const DEFAULT_API_URL = 'https://api.yolo.studio';
|
|
28
|
+
const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
|
|
29
|
+
function apiUrl() {
|
|
30
|
+
return process.env.YOLOBRIDGE_API_URL || DEFAULT_API_URL;
|
|
31
|
+
}
|
|
32
|
+
function authUrl() {
|
|
33
|
+
return process.env.YOLOBRIDGE_AUTH_URL || DEFAULT_AUTH_URL;
|
|
34
|
+
}
|
|
35
|
+
// Mongo ObjectId shape: 24 hex chars. A workspace name could theoretically
|
|
36
|
+
// collide with this (unlikely, but possible), in which case the id-shaped
|
|
37
|
+
// value wins — same tradeoff the rest of this codebase's id-or-slug lookups
|
|
38
|
+
// make, and matches user expectation: someone who types a raw id wants that
|
|
39
|
+
// exact workspace, not a name lookup that happens to match the same string.
|
|
40
|
+
const OBJECT_ID_RE = /^[0-9a-f]{24}$/i;
|
|
41
|
+
/**
|
|
42
|
+
* Resolves an `attach` positional argument that may be a raw workspace id OR
|
|
43
|
+
* a workspace NAME. An id-shaped value is used as-is (no network round trip
|
|
44
|
+
* — unchanged behavior for every existing caller). Anything else is treated
|
|
45
|
+
* as a name and resolved via the same `GET .../workspaces/selectable` list
|
|
46
|
+
* `yolo-bridge workspaces` and the interactive picker already use — a
|
|
47
|
+
* case-insensitive exact match. Zero or multiple matches is a clear error
|
|
48
|
+
* (never a silent first-match guess); multiple matches lists the candidate
|
|
49
|
+
* ids so the caller can disambiguate with the id form instead.
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveWorkspaceIdOrName(value, deps,
|
|
52
|
+
// Injectable for tests (same convention as attach-cmd.ts's RefreshTokenFn)
|
|
53
|
+
// — avoids mocking module-level network calls to exercise the pure
|
|
54
|
+
// matching/error logic below.
|
|
55
|
+
listWorkspacesFn = runListWorkspaces) {
|
|
56
|
+
if (OBJECT_ID_RE.test(value))
|
|
57
|
+
return { ok: true, workspaceId: value };
|
|
58
|
+
const listed = await listWorkspacesFn({ commonApiBaseUrl: deps.commonApiBaseUrl });
|
|
59
|
+
if (!listed.ok) {
|
|
60
|
+
return { ok: false, message: listed.message };
|
|
61
|
+
}
|
|
62
|
+
const needle = value.toLowerCase();
|
|
63
|
+
const matches = listed.workspaces.filter((w) => (w.name || '').toLowerCase() === needle);
|
|
64
|
+
if (matches.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
message: `no workspace named "${value}" found (run \`yolo-bridge workspaces\` to see your workspaces)`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (matches.length > 1) {
|
|
71
|
+
const candidates = matches.map((w) => `${w.id} [${w.status}]`).join(', ');
|
|
72
|
+
return { ok: false, message: `multiple workspaces are named "${value}" — attach by id instead: ${candidates}` };
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, workspaceId: matches[0].id };
|
|
75
|
+
}
|
|
76
|
+
function printHelp() {
|
|
77
|
+
process.stdout.write([
|
|
78
|
+
'Usage: yolo-bridge <command> [args]',
|
|
79
|
+
'',
|
|
80
|
+
'Commands:',
|
|
81
|
+
' login Device-authorization login against auth-service.',
|
|
82
|
+
' workspaces List your own workspaces (id, name, status) that can be attached to.',
|
|
83
|
+
' attach [workspace] Attach this machine to a workspace and hold the daemon loop open.',
|
|
84
|
+
' Accepts either a raw workspace id or its NAME (case-insensitive',
|
|
85
|
+
' exact match against `yolo-bridge workspaces`); an ambiguous or',
|
|
86
|
+
' unmatched name errors instead of guessing. Omit it entirely to pick',
|
|
87
|
+
' interactively from `yolo-bridge workspaces`.',
|
|
88
|
+
' [--label <name>] Operator-facing host label (reported to the workspace).',
|
|
89
|
+
' [--agent <binary>] Local coding-agent binary to spawn (default: $YOLOBRIDGE_AGENT_BIN or "claude").',
|
|
90
|
+
' detach Detach the current workspace attachment.',
|
|
91
|
+
' status Print local login/attach state.',
|
|
92
|
+
' --help Print this help.',
|
|
93
|
+
'',
|
|
94
|
+
`API base: ${apiUrl()} (override: YOLOBRIDGE_API_URL)`,
|
|
95
|
+
`Auth base: ${authUrl()} (override: YOLOBRIDGE_AUTH_URL)`,
|
|
96
|
+
`Agent bin: ${DEFAULT_AGENT_BIN} (override: --agent or YOLOBRIDGE_AGENT_BIN)`,
|
|
97
|
+
'',
|
|
98
|
+
].join('\n'));
|
|
99
|
+
}
|
|
100
|
+
async function cmdLogin() {
|
|
101
|
+
const result = await runLogin({ authBaseUrl: authUrl() });
|
|
102
|
+
if (result.ok)
|
|
103
|
+
return 0;
|
|
104
|
+
process.stderr.write(`yolo-bridge login: ${result.message}\n`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parses `attach`'s argv into its recognized `--label <name>` / `--agent
|
|
109
|
+
* <binary>` flag pairs plus a leftover positional workspaceId — consuming
|
|
110
|
+
* each flag's value together with the flag itself *before* deciding what's
|
|
111
|
+
* left over for the positional, so e.g. `attach --label laptop` doesn't
|
|
112
|
+
* mistake "laptop" for a workspace id (it should still fall through to the
|
|
113
|
+
* interactive picker). An unrecognized `--something` is a hard error rather
|
|
114
|
+
* than being silently swallowed as some other flag's value.
|
|
115
|
+
*/
|
|
116
|
+
export function parseAttachArgs(args) {
|
|
117
|
+
let workspaceId;
|
|
118
|
+
let hostLabel;
|
|
119
|
+
let agentBin;
|
|
120
|
+
for (let i = 0; i < args.length; i++) {
|
|
121
|
+
const a = args[i];
|
|
122
|
+
if (a === '--label' || a === '--agent') {
|
|
123
|
+
const value = args[i + 1];
|
|
124
|
+
if (value === undefined || value.startsWith('--')) {
|
|
125
|
+
return { error: `${a} requires a value` };
|
|
126
|
+
}
|
|
127
|
+
if (a === '--label')
|
|
128
|
+
hostLabel = value;
|
|
129
|
+
else
|
|
130
|
+
agentBin = value;
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (a.startsWith('--')) {
|
|
135
|
+
return { error: `unrecognized option '${a}'` };
|
|
136
|
+
}
|
|
137
|
+
if (workspaceId === undefined) {
|
|
138
|
+
workspaceId = a;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { workspaceId, hostLabel, agentBin };
|
|
142
|
+
}
|
|
143
|
+
async function cmdAttach(args) {
|
|
144
|
+
const parsed = parseAttachArgs(args);
|
|
145
|
+
if ('error' in parsed) {
|
|
146
|
+
process.stderr.write(`yolo-bridge attach: ${parsed.error}\n`);
|
|
147
|
+
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>]\n');
|
|
148
|
+
return 64;
|
|
149
|
+
}
|
|
150
|
+
let workspaceId = parsed.workspaceId;
|
|
151
|
+
const hostLabel = parsed.hostLabel;
|
|
152
|
+
const agentBin = parsed.agentBin;
|
|
153
|
+
if (workspaceId) {
|
|
154
|
+
const resolved = await resolveWorkspaceIdOrName(workspaceId, { commonApiBaseUrl: apiUrl() });
|
|
155
|
+
if (!resolved.ok) {
|
|
156
|
+
process.stderr.write(`yolo-bridge attach: ${resolved.message}\n`);
|
|
157
|
+
return 64;
|
|
158
|
+
}
|
|
159
|
+
workspaceId = resolved.workspaceId;
|
|
160
|
+
}
|
|
161
|
+
if (!workspaceId) {
|
|
162
|
+
// No positional id — fall back to an interactive picker over the
|
|
163
|
+
// caller's own `GET .../workspaces/selectable` list instead of just
|
|
164
|
+
// failing (nobody has a raw workspace ObjectId memorized).
|
|
165
|
+
const pick = await pickWorkspaceFromDisk({ commonApiBaseUrl: apiUrl() });
|
|
166
|
+
if (!pick.ok) {
|
|
167
|
+
switch (pick.reason) {
|
|
168
|
+
case 'not-logged-in':
|
|
169
|
+
process.stderr.write('yolo-bridge attach: not logged in — run `yolo-bridge login` first.\n');
|
|
170
|
+
break;
|
|
171
|
+
case 'no-workspaces':
|
|
172
|
+
process.stderr.write('yolo-bridge attach: no workspaces found for your account.\n');
|
|
173
|
+
break;
|
|
174
|
+
case 'no-selection':
|
|
175
|
+
process.stderr.write('yolo-bridge attach: no workspace selected.\n');
|
|
176
|
+
break;
|
|
177
|
+
case 'list-failed':
|
|
178
|
+
process.stderr.write(`yolo-bridge attach: ${pick.message}\n`);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>]\n');
|
|
182
|
+
return 64;
|
|
183
|
+
}
|
|
184
|
+
workspaceId = pick.workspaceId;
|
|
185
|
+
}
|
|
186
|
+
let stopRequested = false;
|
|
187
|
+
let localAgentExited = false;
|
|
188
|
+
const onSignal = () => {
|
|
189
|
+
if (stopRequested)
|
|
190
|
+
return;
|
|
191
|
+
stopRequested = true;
|
|
192
|
+
process.stdout.write('\nyolo-bridge: caught interrupt, detaching...\n');
|
|
193
|
+
};
|
|
194
|
+
process.on('SIGINT', onSignal);
|
|
195
|
+
process.on('SIGTERM', onSignal);
|
|
196
|
+
// Spawns the local coding agent under a real PTY right away — this
|
|
197
|
+
// command is what launches the user's local session (see
|
|
198
|
+
// docs/YOLOBRIDGE_PLAN.md's "⚠ Not yet functional" section). The PTY's
|
|
199
|
+
// output streams live to this process's own stdout and this process's
|
|
200
|
+
// stdin is piped into the PTY, so the terminal running `attach` is a
|
|
201
|
+
// live view onto the exact session remote prompts land in.
|
|
202
|
+
startLocalAgent({
|
|
203
|
+
agentBin,
|
|
204
|
+
onExit: ({ exitCode, signal }) => {
|
|
205
|
+
localAgentExited = true;
|
|
206
|
+
stopRequested = true;
|
|
207
|
+
process.stdout.write(`\nyolo-bridge: local agent exited (code=${exitCode}${signal ? `, signal=${signal}` : ''}), detaching...\n`);
|
|
208
|
+
// Fire-and-forget: don't wait on the SSE loop to unwind on its own
|
|
209
|
+
// (it only re-checks shouldStop() at loop boundaries) to report the
|
|
210
|
+
// status change — tell the server immediately so the tile flips to
|
|
211
|
+
// `stopped` right away instead of riding out the heartbeat
|
|
212
|
+
// staleness window (~90s, Decision Q2). The daemon loop below still
|
|
213
|
+
// exits promptly too, via `shouldStop`.
|
|
214
|
+
runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
const result = await runAttachFromDisk({
|
|
218
|
+
workspaceId,
|
|
219
|
+
commonApiBaseUrl: apiUrl(),
|
|
220
|
+
hostLabel,
|
|
221
|
+
shouldStop: () => stopRequested,
|
|
222
|
+
});
|
|
223
|
+
process.removeListener('SIGINT', onSignal);
|
|
224
|
+
process.removeListener('SIGTERM', onSignal);
|
|
225
|
+
// Whatever ended the attach loop — local Ctrl+C, a server-initiated
|
|
226
|
+
// `detached` frame, or the agent process exiting on its own — also ends
|
|
227
|
+
// the PTY session `attach` spawned. Safe no-op if it already exited.
|
|
228
|
+
stopLocalAgent();
|
|
229
|
+
if (!result.ok) {
|
|
230
|
+
if (result.reason === 'not-logged-in') {
|
|
231
|
+
process.stderr.write('yolo-bridge attach: not logged in — run `yolo-bridge login` first.\n');
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
process.stderr.write(`yolo-bridge attach: ${result.message}\n`);
|
|
235
|
+
}
|
|
236
|
+
// Codex-found gap: `refresh-failed` can fire on a RECONNECT cycle, well
|
|
237
|
+
// after the initial `apiClient.attach` created the server-side
|
|
238
|
+
// attachment and saved attachment.json — this early return used to skip
|
|
239
|
+
// the runDetach() cleanup below (guarded on `result.ok`), leaving a
|
|
240
|
+
// permanent stale attachment behind (the tile never flips to `stopped`,
|
|
241
|
+
// and a later `attach` piles on a second one instead of replacing it).
|
|
242
|
+
// Best-effort and safe even when `refresh-failed` happened on the VERY
|
|
243
|
+
// FIRST refresh (before any attach ever succeeded, so nothing is
|
|
244
|
+
// attached): runDetach() reads local attachment.json first and returns
|
|
245
|
+
// a harmless `not-attached` when there's nothing to clean up. The
|
|
246
|
+
// refresh-buffer window (proactive refresh starts 5min before expiry —
|
|
247
|
+
// see DEFAULT_REFRESH_BUFFER_MS in attach-cmd.ts) means the token used
|
|
248
|
+
// to reach that point is usually still valid for one more request, so
|
|
249
|
+
// this detach call has a real chance of succeeding rather than just
|
|
250
|
+
// failing the same way the refresh did.
|
|
251
|
+
if (result.reason === 'refresh-failed') {
|
|
252
|
+
await runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
|
|
253
|
+
}
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
if (stopRequested && !localAgentExited) {
|
|
257
|
+
// Local Ctrl+C stop (the agent-exit path above already detached):
|
|
258
|
+
// best-effort tell the server we're leaving too, so the tile flips to
|
|
259
|
+
// stopped promptly instead of waiting out the heartbeat staleness
|
|
260
|
+
// window.
|
|
261
|
+
await runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
|
|
262
|
+
}
|
|
263
|
+
process.stdout.write(`yolo-bridge: stopped (${result.reason}).\n`);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
async function cmdDetach() {
|
|
267
|
+
const result = await runDetach({ commonApiBaseUrl: apiUrl() });
|
|
268
|
+
if (result.ok) {
|
|
269
|
+
process.stdout.write('Detached.\n');
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
process.stderr.write(`yolo-bridge detach: ${result.message}\n`);
|
|
273
|
+
return result.reason === 'not-logged-in' || result.reason === 'not-attached' ? 1 : 1;
|
|
274
|
+
}
|
|
275
|
+
function cmdStatus() {
|
|
276
|
+
process.stdout.write(`${formatStatus(getStatus())}\n`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
async function cmdWorkspaces() {
|
|
280
|
+
const result = await runListWorkspaces({ commonApiBaseUrl: apiUrl() });
|
|
281
|
+
if (!result.ok) {
|
|
282
|
+
if (result.reason === 'not-logged-in') {
|
|
283
|
+
process.stderr.write('yolo-bridge workspaces: not logged in — run `yolo-bridge login` first.\n');
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
process.stderr.write(`yolo-bridge workspaces: ${result.message}\n`);
|
|
287
|
+
}
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
process.stdout.write(`${formatWorkspacesTable(result.workspaces)}\n`);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
async function main() {
|
|
294
|
+
const [, , cmd, ...rest] = process.argv;
|
|
295
|
+
switch (cmd) {
|
|
296
|
+
case 'login':
|
|
297
|
+
return cmdLogin();
|
|
298
|
+
case 'workspaces':
|
|
299
|
+
return cmdWorkspaces();
|
|
300
|
+
case 'attach':
|
|
301
|
+
return cmdAttach(rest);
|
|
302
|
+
case 'detach':
|
|
303
|
+
return cmdDetach();
|
|
304
|
+
case 'status':
|
|
305
|
+
return cmdStatus();
|
|
306
|
+
case '--help':
|
|
307
|
+
case '-h':
|
|
308
|
+
case undefined:
|
|
309
|
+
printHelp();
|
|
310
|
+
return cmd === undefined ? 64 : 0;
|
|
311
|
+
default:
|
|
312
|
+
process.stderr.write(`yolo-bridge: unknown command '${cmd}'\n`);
|
|
313
|
+
printHelp();
|
|
314
|
+
return 64;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Guard direct execution vs. being imported (e.g. by cli.test.ts to reach
|
|
318
|
+
// `parseAttachArgs`) — without this, importing this module would run `main`
|
|
319
|
+
// against whatever process's argv happened to be doing the importing.
|
|
320
|
+
//
|
|
321
|
+
// `process.argv[1]` is the RAW path used to invoke node — for a bin entry
|
|
322
|
+
// invoked via a symlink (exactly how `npm link` and every real
|
|
323
|
+
// `npm install -g` set up a package's bin — never a plain copy), that's the
|
|
324
|
+
// symlink's own path, unresolved. `import.meta.url`, on the other hand, is
|
|
325
|
+
// resolved by Node's ESM loader THROUGH any symlink to the real underlying
|
|
326
|
+
// file. Comparing the two directly therefore NEVER matches under a symlinked
|
|
327
|
+
// invocation — this shipped broken: `yolo-bridge` on PATH (via `npm link` or
|
|
328
|
+
// a real global install) silently did nothing, exit 0, no output, because
|
|
329
|
+
// `main()` never ran. Only `node dist/cli.js <path-to-the-real-file>` (never
|
|
330
|
+
// how an installed CLI is actually invoked) happened to pass. Fixed by
|
|
331
|
+
// realpath-resolving argv[1] before comparing, so both sides refer to the
|
|
332
|
+
// same underlying file regardless of how many symlinks sit in between.
|
|
333
|
+
function resolveRealpath(p) {
|
|
334
|
+
if (!p)
|
|
335
|
+
return undefined;
|
|
336
|
+
try {
|
|
337
|
+
return realpathSync(p);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return p; // argv[1] should always exist as a real file when actually running; fall back rather than throw
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const isMainModule = fileURLToPath(import.meta.url) === resolveRealpath(process.argv[1]);
|
|
344
|
+
if (isMainModule) {
|
|
345
|
+
main()
|
|
346
|
+
.then((code) => {
|
|
347
|
+
process.exitCode = code;
|
|
348
|
+
})
|
|
349
|
+
.catch((err) => {
|
|
350
|
+
process.stderr.write(`yolo-bridge: unexpected error: ${err instanceof Error ? err.stack || err.message : String(err)}\n`);
|
|
351
|
+
process.exitCode = 1;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local on-disk state for the YoloBridge daemon: the device-flow auth
|
|
3
|
+
* tokens from `yolo-bridge login`, and the current attachment record from
|
|
4
|
+
* `yolo-bridge attach`. Config dir choice (docs/YOLOBRIDGE_PLAN.md's
|
|
5
|
+
* historical §1.1 already named this location for the superseded design;
|
|
6
|
+
* kept for this build too): `~/.config/yolobridge/`.
|
|
7
|
+
*
|
|
8
|
+
* ~/.config/yolobridge/auth.json — device-flow token pair
|
|
9
|
+
* ~/.config/yolobridge/attachment.json — current workspace attachment
|
|
10
|
+
*
|
|
11
|
+
* Both files are written with mode 0600 (best-effort — not enforced on
|
|
12
|
+
* every platform) since `auth.json` holds a live Bearer-equivalent
|
|
13
|
+
* credential. Follows the same injectable-I/O pattern as
|
|
14
|
+
* `packages/yolo-cli/src/auth-context.ts` (`ReadFileImpl`) so callers can
|
|
15
|
+
* unit test against an in-memory stub instead of the real filesystem.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
export const defaultIO = {
|
|
21
|
+
readFile(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
writeFile(filePath, contents) {
|
|
30
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
31
|
+
fs.writeFileSync(filePath, contents, { mode: 0o600 });
|
|
32
|
+
},
|
|
33
|
+
removeFile(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
fs.unlinkSync(filePath);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* already gone — fine */
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
export function configDir(env = process.env) {
|
|
43
|
+
const home = env.HOME || os.homedir();
|
|
44
|
+
return path.join(home, '.config', 'yolobridge');
|
|
45
|
+
}
|
|
46
|
+
function authPath(env) {
|
|
47
|
+
return path.join(configDir(env), 'auth.json');
|
|
48
|
+
}
|
|
49
|
+
function attachmentPath(env) {
|
|
50
|
+
return path.join(configDir(env), 'attachment.json');
|
|
51
|
+
}
|
|
52
|
+
export function loadAuth(env = process.env, io = defaultIO) {
|
|
53
|
+
const raw = io.readFile(authPath(env));
|
|
54
|
+
if (!raw)
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
if (typeof parsed.accessToken === 'string' &&
|
|
59
|
+
typeof parsed.refreshToken === 'string' &&
|
|
60
|
+
typeof parsed.tokenType === 'string' &&
|
|
61
|
+
typeof parsed.expiresAtMs === 'number') {
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function saveAuth(auth, env = process.env, io = defaultIO) {
|
|
71
|
+
io.writeFile(authPath(env), `${JSON.stringify(auth, null, 2)}\n`);
|
|
72
|
+
}
|
|
73
|
+
export function clearAuth(env = process.env, io = defaultIO) {
|
|
74
|
+
io.removeFile(authPath(env));
|
|
75
|
+
}
|
|
76
|
+
export function loadAttachment(env = process.env, io = defaultIO) {
|
|
77
|
+
const raw = io.readFile(attachmentPath(env));
|
|
78
|
+
if (!raw)
|
|
79
|
+
return undefined;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(raw);
|
|
82
|
+
if (typeof parsed.workspaceId === 'string' &&
|
|
83
|
+
typeof parsed.tileId === 'string' &&
|
|
84
|
+
typeof parsed.attachmentId === 'string' &&
|
|
85
|
+
typeof parsed.attachedAt === 'string') {
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function saveAttachment(attachment, env = process.env, io = defaultIO) {
|
|
95
|
+
io.writeFile(attachmentPath(env), `${JSON.stringify(attachment, null, 2)}\n`);
|
|
96
|
+
}
|
|
97
|
+
export function clearAttachment(env = process.env, io = defaultIO) {
|
|
98
|
+
io.removeFile(attachmentPath(env));
|
|
99
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo-bridge detach` — calls `DELETE /attach/:attachmentId` and clears
|
|
3
|
+
* the locally stored attachment record.
|
|
4
|
+
*
|
|
5
|
+
* Note on "stops the daemon loop" (build-order step 5's requirement):
|
|
6
|
+
* this command and a running `yolo-bridge attach` daemon are typically
|
|
7
|
+
* two SEPARATE OS processes (attach runs in the foreground holding the
|
|
8
|
+
* SSE connection). `detach` doesn't reach into that other process
|
|
9
|
+
* directly — instead it relies on the real server behavior already
|
|
10
|
+
* implemented in `yolobridge-service.ts`'s `detachDaemon`: the DELETE
|
|
11
|
+
* writes a `detached` SSE frame onto the held stream before closing it
|
|
12
|
+
* (`writeFrame(held.res, 'detached', ...)`). The running attach daemon's
|
|
13
|
+
* frame dispatcher (frame-actions.ts → attach-cmd.ts) treats that frame
|
|
14
|
+
* as a clean stop signal and exits its own loop. So `detach` from a
|
|
15
|
+
* second terminal really does stop the daemon loop, just via the
|
|
16
|
+
* existing server round trip rather than an OS-level signal. Ctrl+C on
|
|
17
|
+
* the attach process itself is the separate, local stop path (wired in
|
|
18
|
+
* cli.ts).
|
|
19
|
+
*/
|
|
20
|
+
import { detach as apiDetach } from './api-client.js';
|
|
21
|
+
import { loadAuth, loadAttachment, clearAttachment } from './config-store.js';
|
|
22
|
+
export async function runDetach(deps) {
|
|
23
|
+
const auth = loadAuth(deps.env, deps.io);
|
|
24
|
+
if (!auth)
|
|
25
|
+
return { ok: false, reason: 'not-logged-in', message: 'Not logged in — run `yolo-bridge login` first.' };
|
|
26
|
+
const attachment = loadAttachment(deps.env, deps.io);
|
|
27
|
+
if (!attachment)
|
|
28
|
+
return { ok: false, reason: 'not-attached', message: 'No active attachment found.' };
|
|
29
|
+
try {
|
|
30
|
+
await apiDetach({ commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl: deps.fetchImpl }, attachment.workspaceId, attachment.attachmentId);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return { ok: false, reason: 'error', message: err instanceof Error ? err.message : String(err) };
|
|
34
|
+
}
|
|
35
|
+
clearAttachment(deps.env, deps.io);
|
|
36
|
+
return { ok: true };
|
|
37
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth device-authorization flow client against auth-service
|
|
3
|
+
* (`auth/src/routes/auth.routes.ts:145-146`, controller in
|
|
4
|
+
* `auth/src/controllers/AuthController.ts` — `initiateDeviceFlow` /
|
|
5
|
+
* `pollDeviceFlow`). Field names below were read directly off that
|
|
6
|
+
* controller, not guessed or ported from yolomax (whose source is gone):
|
|
7
|
+
*
|
|
8
|
+
* POST /api/v1/auth/device/code → { device_code, user_code,
|
|
9
|
+
* verification_uri, verification_url, expires_in, interval }
|
|
10
|
+
* POST /api/v1/auth/device/token body { device_code } →
|
|
11
|
+
* 200 { access_token, refresh_token, token_type, expires_in, expires_at }
|
|
12
|
+
* 4xx { error: { message, statusCode } } — `message` is one of
|
|
13
|
+
* 'authorization_pending' | 'access_denied' | 'expired_token'
|
|
14
|
+
* | 'Invalid device code' | 'Invalid device code state'
|
|
15
|
+
*
|
|
16
|
+
* NOTE (discrepancy from docs/YOLOBRIDGE_PLAN.md): the plan's Architecture
|
|
17
|
+
* section says login "opens the browser to the verification URL with the
|
|
18
|
+
* code pre-filled". The actual `initiateDeviceFlow` controller returns a
|
|
19
|
+
* bare `verification_uri` (`${FRONTEND_URL}/device`, no query string) —
|
|
20
|
+
* there is no code-prefill parameter in the real response. The CLI below
|
|
21
|
+
* prints the user_code alongside the URL and expects the user to type it
|
|
22
|
+
* in manually, same as GitHub's device flow UX. If prefill lands later on
|
|
23
|
+
* the webapp `/device` page, this client doesn't need to change — it just
|
|
24
|
+
* won't benefit from it.
|
|
25
|
+
*/
|
|
26
|
+
export class DeviceAuthError extends Error {
|
|
27
|
+
}
|
|
28
|
+
function normalizeBase(authBaseUrl) {
|
|
29
|
+
return authBaseUrl.replace(/\/+$/, '');
|
|
30
|
+
}
|
|
31
|
+
export async function requestDeviceCode(authBaseUrl, fetchImpl = fetch) {
|
|
32
|
+
const res = await fetchImpl(`${normalizeBase(authBaseUrl)}/api/v1/auth/device/code`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: '{}',
|
|
36
|
+
});
|
|
37
|
+
const body = await safeJson(res);
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new DeviceAuthError(`device/code failed: ${res.status} ${extractErrorMessage(body)}`);
|
|
40
|
+
}
|
|
41
|
+
if (typeof body?.device_code !== 'string' ||
|
|
42
|
+
typeof body?.user_code !== 'string' ||
|
|
43
|
+
typeof body?.verification_uri !== 'string') {
|
|
44
|
+
throw new DeviceAuthError('device/code returned an unexpected shape (missing device_code/user_code/verification_uri)');
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
deviceCode: body.device_code,
|
|
48
|
+
userCode: body.user_code,
|
|
49
|
+
verificationUri: body.verification_uri,
|
|
50
|
+
expiresInSec: typeof body.expires_in === 'number' ? body.expires_in : 600,
|
|
51
|
+
intervalSec: typeof body.interval === 'number' ? body.interval : 5,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function pollDeviceToken(authBaseUrl, deviceCode, fetchImpl = fetch) {
|
|
55
|
+
const res = await fetchImpl(`${normalizeBase(authBaseUrl)}/api/v1/auth/device/token`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
59
|
+
});
|
|
60
|
+
const body = await safeJson(res);
|
|
61
|
+
if (res.ok) {
|
|
62
|
+
if (typeof body?.access_token !== 'string' ||
|
|
63
|
+
typeof body?.refresh_token !== 'string' ||
|
|
64
|
+
typeof body?.token_type !== 'string') {
|
|
65
|
+
return { status: 'error', message: 'device/token returned 200 with an unexpected shape' };
|
|
66
|
+
}
|
|
67
|
+
const expiresInSec = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
|
68
|
+
const expiresAtMs = typeof body.expires_at === 'number' ? body.expires_at : Date.now() + expiresInSec * 1000;
|
|
69
|
+
return {
|
|
70
|
+
status: 'authorized',
|
|
71
|
+
tokens: {
|
|
72
|
+
accessToken: body.access_token,
|
|
73
|
+
refreshToken: body.refresh_token,
|
|
74
|
+
tokenType: body.token_type,
|
|
75
|
+
expiresInSec,
|
|
76
|
+
expiresAtMs,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const message = extractErrorMessage(body);
|
|
81
|
+
if (message === 'authorization_pending')
|
|
82
|
+
return { status: 'pending' };
|
|
83
|
+
if (message === 'access_denied')
|
|
84
|
+
return { status: 'denied' };
|
|
85
|
+
if (message === 'expired_token')
|
|
86
|
+
return { status: 'expired' };
|
|
87
|
+
return { status: 'error', message: message || `device/token failed: ${res.status}` };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `POST /api/v1/auth/refresh` — used to proactively rotate the daemon's
|
|
91
|
+
* access token before it expires (see attach-cmd.ts). Field names read
|
|
92
|
+
* directly off `auth/src/controllers/AuthController.ts:995` (`refreshToken`)
|
|
93
|
+
* and `auth/src/services/TokenService.ts`'s `TokenPair` interface — NOTE
|
|
94
|
+
* this is a *different* shape from the device-flow endpoints above:
|
|
95
|
+
*
|
|
96
|
+
* request body { refresh_token } (snake_case)
|
|
97
|
+
* response 200 { tokens: { accessToken, refreshToken,
|
|
98
|
+
* expiresIn } } (camelCase,
|
|
99
|
+
* no token_type, no absolute expiry — this route hands back the same
|
|
100
|
+
* `TokenPair` shape `res.json({ tokens })` serializes with no
|
|
101
|
+
* case-conversion middleware in front of it)
|
|
102
|
+
* 4xx { error: { message, statusCode } } — same errorHandler as the
|
|
103
|
+
* device-flow routes, so `extractErrorMessage` below is reused as-is.
|
|
104
|
+
*
|
|
105
|
+
* Unauthenticated (no `authenticate` middleware on this route) — the
|
|
106
|
+
* refresh token in the body is the credential.
|
|
107
|
+
*/
|
|
108
|
+
export async function refreshAccessToken(authBaseUrl, refreshToken, fetchImpl = fetch) {
|
|
109
|
+
const res = await fetchImpl(`${normalizeBase(authBaseUrl)}/api/v1/auth/refresh`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
113
|
+
});
|
|
114
|
+
const body = await safeJson(res);
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
return { status: 'failed', message: extractErrorMessage(body) || `refresh failed: ${res.status}` };
|
|
117
|
+
}
|
|
118
|
+
const tokens = body?.tokens;
|
|
119
|
+
if (typeof tokens?.accessToken !== 'string' ||
|
|
120
|
+
typeof tokens?.refreshToken !== 'string' ||
|
|
121
|
+
typeof tokens?.expiresIn !== 'number') {
|
|
122
|
+
return { status: 'failed', message: 'refresh returned an unexpected shape (missing tokens.accessToken/refreshToken/expiresIn)' };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
status: 'ok',
|
|
126
|
+
tokens: {
|
|
127
|
+
accessToken: tokens.accessToken,
|
|
128
|
+
refreshToken: tokens.refreshToken,
|
|
129
|
+
expiresInSec: tokens.expiresIn,
|
|
130
|
+
expiresAtMs: Date.now() + tokens.expiresIn * 1000,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function safeJson(res) {
|
|
135
|
+
try {
|
|
136
|
+
return await res.json();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** auth-service's errorHandler shape is `{ error: { message, statusCode } }`. */
|
|
143
|
+
function extractErrorMessage(body) {
|
|
144
|
+
if (typeof body?.error?.message === 'string')
|
|
145
|
+
return body.error.message;
|
|
146
|
+
if (typeof body?.error === 'string')
|
|
147
|
+
return body.error;
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mapping from a parsed `SseFrame` (sse-frame-parser.ts) to a typed
|
|
3
|
+
* action the attach daemon should take. Kept separate from the actual
|
|
4
|
+
* network/process-lifecycle glue in attach-cmd.ts so the dispatch logic
|
|
5
|
+
* itself — "a `prompt` frame means deliver-then-log, a `read-output`
|
|
6
|
+
* frame means capture-then-reply, a `detached` frame means stop
|
|
7
|
+
* reconnecting" — is unit-testable without a live stream or a real
|
|
8
|
+
* daemon loop.
|
|
9
|
+
*/
|
|
10
|
+
export function actionForFrame(frame) {
|
|
11
|
+
const data = (frame.data ?? {});
|
|
12
|
+
switch (frame.event) {
|
|
13
|
+
case 'connected':
|
|
14
|
+
return {
|
|
15
|
+
kind: 'connected',
|
|
16
|
+
attachmentId: String(data.attachmentId ?? ''),
|
|
17
|
+
workspaceId: String(data.workspaceId ?? ''),
|
|
18
|
+
};
|
|
19
|
+
case 'ping':
|
|
20
|
+
return { kind: 'ping' };
|
|
21
|
+
case 'prompt':
|
|
22
|
+
return { kind: 'prompt', attachmentId: String(data.attachmentId ?? ''), prompt: String(data.prompt ?? '') };
|
|
23
|
+
case 'read-output':
|
|
24
|
+
return {
|
|
25
|
+
kind: 'read-output',
|
|
26
|
+
attachmentId: String(data.attachmentId ?? ''),
|
|
27
|
+
requestId: String(data.requestId ?? ''),
|
|
28
|
+
};
|
|
29
|
+
case 'detached':
|
|
30
|
+
return { kind: 'detached', attachmentId: String(data.attachmentId ?? '') };
|
|
31
|
+
default:
|
|
32
|
+
return { kind: 'unknown', event: frame.event };
|
|
33
|
+
}
|
|
34
|
+
}
|