@viraatdas/rudder 1.0.6 → 1.0.8
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/README.md +364 -26
- package/dist/agent-attention.d.ts +5 -0
- package/dist/agent-attention.js +54 -0
- package/dist/agent-attention.js.map +1 -0
- package/dist/backends.d.ts +1 -0
- package/dist/backends.js +59 -11
- package/dist/backends.js.map +1 -1
- package/dist/brain.js +3 -3
- package/dist/brain.js.map +1 -1
- package/dist/cloud.d.ts +9 -0
- package/dist/cloud.js +2255 -0
- package/dist/cloud.js.map +1 -0
- package/dist/main.js +112 -2
- package/dist/main.js.map +1 -1
- package/dist/migration.d.ts +82 -0
- package/dist/migration.js +174 -0
- package/dist/migration.js.map +1 -0
- package/dist/native/rudder-native +0 -0
- package/dist/native-agents.d.ts +1 -0
- package/dist/native-agents.js +126 -8
- package/dist/native-agents.js.map +1 -1
- package/dist/plan-mode.d.ts +2 -0
- package/dist/plan-mode.js +22 -0
- package/dist/plan-mode.js.map +1 -0
- package/dist/repl.js +11 -3
- package/dist/repl.js.map +1 -1
- package/dist/run-manager.d.ts +11 -1
- package/dist/run-manager.js +164 -19
- package/dist/run-manager.js.map +1 -1
- package/dist/state.d.ts +10 -1
- package/dist/state.js +99 -1
- package/dist/state.js.map +1 -1
- package/dist/task-summary.d.ts +6 -0
- package/dist/task-summary.js +186 -0
- package/dist/task-summary.js.map +1 -0
- package/dist/tmux-dashboard.js +248 -64
- package/dist/tmux-dashboard.js.map +1 -1
- package/dist/tmux.js +1 -0
- package/dist/tmux.js.map +1 -1
- package/dist/tui.js +228 -46
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/util.d.ts +1 -0
- package/dist/util.js +23 -1
- package/dist/util.js.map +1 -1
- package/package.json +4 -2
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,2255 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fsp from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { WebSocket } from "ws";
|
|
7
|
+
import { currentBranch, currentCommit, findRepoRoot } from "./git.js";
|
|
8
|
+
import { applyDefaultDecisions, buildFreshHandoffPrompt, cloudWorktreeAbsolutePath, findMigrationCandidates, migrationSummary, summaryAsJson, } from "./migration.js";
|
|
9
|
+
import { cloudAuthPath } from "./state.js";
|
|
10
|
+
import { ensureDir, commandExists, expandHome, isTty, newRunId, nowIso, pathExists, promptConfirm, promptText, promptSelect, promptSecret, readJson, runCommand, shortenHome, shellQuote, writeJson, } from "./util.js";
|
|
11
|
+
const DEFAULT_LOGIN_INTERVAL_MS = 2000;
|
|
12
|
+
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
13
|
+
const DEFAULT_CLOUD_URL = "https://rudder-cloud-control.fly.dev";
|
|
14
|
+
const GITHUB_CLI_CLIENT_ID = "178c6fc778ccc68e1d6a";
|
|
15
|
+
const MAX_HOME_SECRET_SCAN_BYTES = 1024 * 1024;
|
|
16
|
+
const DEFAULT_HOME_PATHS = [
|
|
17
|
+
// Agent CLIs
|
|
18
|
+
"~/.claude/.credentials.json",
|
|
19
|
+
"~/.claude/settings.json",
|
|
20
|
+
"~/.claude/CLAUDE.md",
|
|
21
|
+
"~/.claude.json",
|
|
22
|
+
"~/.codex/auth.json",
|
|
23
|
+
"~/.codex/config.toml",
|
|
24
|
+
"~/.codex/AGENTS.md",
|
|
25
|
+
"~/.codex/hooks.json",
|
|
26
|
+
"~/.codex/rules",
|
|
27
|
+
// Git + GitHub
|
|
28
|
+
"~/.gitconfig",
|
|
29
|
+
"~/.config/gh",
|
|
30
|
+
// Shell rc so PATH/aliases/env exports come along
|
|
31
|
+
"~/.zshrc",
|
|
32
|
+
"~/.zprofile",
|
|
33
|
+
"~/.bashrc",
|
|
34
|
+
"~/.bash_profile",
|
|
35
|
+
"~/.profile",
|
|
36
|
+
"~/.envrc",
|
|
37
|
+
// Package managers
|
|
38
|
+
"~/.npmrc",
|
|
39
|
+
"~/.yarnrc",
|
|
40
|
+
"~/.yarnrc.yml",
|
|
41
|
+
"~/.cargo/config",
|
|
42
|
+
"~/.cargo/config.toml",
|
|
43
|
+
// Cloud provider CLIs
|
|
44
|
+
"~/.vercel",
|
|
45
|
+
"~/.config/vercel",
|
|
46
|
+
"~/.aws/config",
|
|
47
|
+
"~/.aws/credentials",
|
|
48
|
+
"~/.config/gcloud/configurations",
|
|
49
|
+
"~/.config/gcloud/active_config",
|
|
50
|
+
"~/.config/gcloud/credentials.db",
|
|
51
|
+
"~/.config/gcloud/access_tokens.db",
|
|
52
|
+
"~/.config/gcloud/application_default_credentials.json",
|
|
53
|
+
"~/.kube/config",
|
|
54
|
+
// Netrc for tools that auth via netrc
|
|
55
|
+
"~/.netrc",
|
|
56
|
+
// Rudder + Hunk
|
|
57
|
+
"~/.config/hunk",
|
|
58
|
+
];
|
|
59
|
+
// Paths or path components that should never be uploaded under any
|
|
60
|
+
// circumstance — even if a user's DEFAULT_HOME_PATHS entry references them.
|
|
61
|
+
// Specifically leaving out .aws/.kube/.docker so the corresponding configs
|
|
62
|
+
// can ship; .ssh/.gnupg/keychains stay blocked because they contain
|
|
63
|
+
// private key material that isn't recoverable if leaked.
|
|
64
|
+
const SECRET_PATH_PARTS = new Set([
|
|
65
|
+
".ssh",
|
|
66
|
+
".gnupg",
|
|
67
|
+
"keychains",
|
|
68
|
+
]);
|
|
69
|
+
const BULKY_HOME_PATH_PARTS = new Set([
|
|
70
|
+
"archived_sessions",
|
|
71
|
+
"backups",
|
|
72
|
+
"cache",
|
|
73
|
+
"file-history",
|
|
74
|
+
"log",
|
|
75
|
+
"paste-cache",
|
|
76
|
+
"plugins",
|
|
77
|
+
"projects",
|
|
78
|
+
"session",
|
|
79
|
+
"sessions",
|
|
80
|
+
"shell_snapshots",
|
|
81
|
+
"skills",
|
|
82
|
+
"telemetry",
|
|
83
|
+
"todos",
|
|
84
|
+
"worktrees",
|
|
85
|
+
]);
|
|
86
|
+
const SECRET_BASENAMES = new Set([
|
|
87
|
+
".env",
|
|
88
|
+
".env.local",
|
|
89
|
+
".env.production",
|
|
90
|
+
".env.development",
|
|
91
|
+
"id_rsa",
|
|
92
|
+
"id_ed25519",
|
|
93
|
+
"known_hosts",
|
|
94
|
+
]);
|
|
95
|
+
const BULKY_HOME_BASENAME_PATTERNS = [
|
|
96
|
+
/^history\./,
|
|
97
|
+
/^logs?_/,
|
|
98
|
+
/^state_\d+\.sqlite/,
|
|
99
|
+
/\.sqlite(?:-(?:wal|shm))?$/,
|
|
100
|
+
/\.log$/,
|
|
101
|
+
/\.jsonl$/,
|
|
102
|
+
];
|
|
103
|
+
export async function runCloudCommand(command, args, options = {}) {
|
|
104
|
+
const subcommand = args[0] ?? "";
|
|
105
|
+
const rest = args.slice(1);
|
|
106
|
+
if (command === "cloud" && subcommand === "help") {
|
|
107
|
+
printCloudHelp();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// `rudder cloud` with no further args means "open the cloud workspace for
|
|
111
|
+
// this repo". Explicit subcommands (sail, launch, etc.) keep their old
|
|
112
|
+
// behavior. `rudder sail` and `rudder cloud sail` still launch a sail.
|
|
113
|
+
if (command === "cloud" && subcommand === "") {
|
|
114
|
+
await workspaceCommand([], options);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
switch (subcommand) {
|
|
118
|
+
case "login":
|
|
119
|
+
await login(options);
|
|
120
|
+
return;
|
|
121
|
+
case "launch":
|
|
122
|
+
await launch(rest, options, "task");
|
|
123
|
+
return;
|
|
124
|
+
case "sail":
|
|
125
|
+
await launch(rest, options);
|
|
126
|
+
return;
|
|
127
|
+
case "byoc":
|
|
128
|
+
await setupByoc(rest, options);
|
|
129
|
+
return;
|
|
130
|
+
case "vm":
|
|
131
|
+
case "byo-vm":
|
|
132
|
+
await launch(rest, options, "task", "byo-vm");
|
|
133
|
+
return;
|
|
134
|
+
case "list":
|
|
135
|
+
case "ls":
|
|
136
|
+
await listSails(options);
|
|
137
|
+
return;
|
|
138
|
+
case "status":
|
|
139
|
+
await status(options);
|
|
140
|
+
return;
|
|
141
|
+
case "logs":
|
|
142
|
+
await logs(rest, options);
|
|
143
|
+
return;
|
|
144
|
+
case "attach":
|
|
145
|
+
await attach(rest, options);
|
|
146
|
+
return;
|
|
147
|
+
case "workspace":
|
|
148
|
+
await workspaceCommand(rest, options);
|
|
149
|
+
return;
|
|
150
|
+
case "onload":
|
|
151
|
+
await onload(rest, options);
|
|
152
|
+
return;
|
|
153
|
+
case "bootstrap":
|
|
154
|
+
await bootstrap(rest, options);
|
|
155
|
+
return;
|
|
156
|
+
case "pause":
|
|
157
|
+
await mutateSail("pause", rest, options);
|
|
158
|
+
return;
|
|
159
|
+
case "resume":
|
|
160
|
+
await mutateSail("resume", rest, options);
|
|
161
|
+
return;
|
|
162
|
+
case "stop":
|
|
163
|
+
await mutateSail("stop", rest, options);
|
|
164
|
+
return;
|
|
165
|
+
case "setup-github":
|
|
166
|
+
await setupOAuthProvider("github", rest, options);
|
|
167
|
+
return;
|
|
168
|
+
case "setup-google":
|
|
169
|
+
await setupOAuthProvider("google", rest, options);
|
|
170
|
+
return;
|
|
171
|
+
case "setup-byoc":
|
|
172
|
+
await setupByoc(rest, options);
|
|
173
|
+
return;
|
|
174
|
+
case "setup-vm":
|
|
175
|
+
await setupByoc(rest, options);
|
|
176
|
+
return;
|
|
177
|
+
case "setup-fly":
|
|
178
|
+
await configureDefaultRuntime("fly", options);
|
|
179
|
+
return;
|
|
180
|
+
case "setup":
|
|
181
|
+
if (rest[0] === "byoc" || rest[0] === "vm" || rest[0] === "byo-vm") {
|
|
182
|
+
await setupByoc(rest.slice(1), options);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (rest[0] === "fly") {
|
|
186
|
+
await configureDefaultRuntime("fly", options);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
throw new Error("Usage: rudder cloud setup byoc | rudder cloud setup fly");
|
|
190
|
+
case "runtime":
|
|
191
|
+
await runtime(rest, options);
|
|
192
|
+
return;
|
|
193
|
+
default:
|
|
194
|
+
await launch(command === "sail" ? args : [subcommand, ...rest], options);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function login(options) {
|
|
199
|
+
const client = await cloudClient({ requireToken: false });
|
|
200
|
+
const browserLogin = await tryBrowserLogin(client, options).catch((error) => {
|
|
201
|
+
if (!options.json) {
|
|
202
|
+
console.warn(`Browser login unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
203
|
+
console.warn("Trying local GitHub auth fallback...");
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
});
|
|
207
|
+
if (browserLogin?.token || browserLogin?.accessToken) {
|
|
208
|
+
const token = browserLogin.token ?? browserLogin.accessToken;
|
|
209
|
+
if (token) {
|
|
210
|
+
await saveCloudLogin(client, browserLogin, token, options, "browser");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const githubLogin = await tryGithubCliLogin(client).catch(() => null);
|
|
215
|
+
if (githubLogin?.token || githubLogin?.accessToken) {
|
|
216
|
+
const token = githubLogin.token ?? githubLogin.accessToken;
|
|
217
|
+
if (token) {
|
|
218
|
+
await saveCloudLogin(client, githubLogin, token, options, "GitHub CLI");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const githubDeviceLogin = await tryGithubDeviceLogin(client, options).catch(() => null);
|
|
223
|
+
if (githubDeviceLogin?.token || githubDeviceLogin?.accessToken) {
|
|
224
|
+
const token = githubDeviceLogin.token ?? githubDeviceLogin.accessToken;
|
|
225
|
+
if (token) {
|
|
226
|
+
await saveCloudLogin(client, githubDeviceLogin, token, options, "GitHub device");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function tryBrowserLogin(client, options) {
|
|
232
|
+
const response = await client.request("/api/cli/login", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: {
|
|
235
|
+
deviceName: os.hostname(),
|
|
236
|
+
client: "rudder",
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const deviceCode = response.deviceCode;
|
|
240
|
+
const loginUrl = response.loginUrl ?? response.verificationUri ?? withQuery(client.baseUrl, "/cli/login", deviceCode ? { device_code: deviceCode } : {});
|
|
241
|
+
const pollPath = response.pollUrl ?? "/api/cli/login/poll";
|
|
242
|
+
const intervalMs = Math.max(1000, (response.interval ?? DEFAULT_LOGIN_INTERVAL_MS / 1000) * 1000);
|
|
243
|
+
const timeoutMs = Math.max(intervalMs, (response.expiresIn ?? DEFAULT_LOGIN_TIMEOUT_MS / 1000) * 1000);
|
|
244
|
+
console.log(`Opening ${loginUrl}`);
|
|
245
|
+
if (!options.json) {
|
|
246
|
+
openBrowser(loginUrl);
|
|
247
|
+
}
|
|
248
|
+
console.log("Waiting for browser login to complete...");
|
|
249
|
+
const startedAt = Date.now();
|
|
250
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
251
|
+
await sleep(intervalMs);
|
|
252
|
+
const poll = await pollLogin(client, pollPath, deviceCode);
|
|
253
|
+
const token = poll.token ?? poll.accessToken;
|
|
254
|
+
if (token) {
|
|
255
|
+
return poll;
|
|
256
|
+
}
|
|
257
|
+
if (poll.pending === false) {
|
|
258
|
+
throw new Error("Cloud login was not approved.");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw new Error("Timed out waiting for cloud login.");
|
|
262
|
+
}
|
|
263
|
+
async function tryGithubCliLogin(client) {
|
|
264
|
+
if (process.env.RUDDER_SKIP_GH_CLI === "1") {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const gh = await runCommand("gh", ["auth", "token"], { allowFailure: true });
|
|
268
|
+
const token = gh.stdout.trim();
|
|
269
|
+
if (gh.code !== 0 || !token) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return await client.request("/api/cli/login/github-token", {
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: { token },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async function tryGithubDeviceLogin(client, options) {
|
|
278
|
+
const start = await githubOAuthRequest("https://github.com/login/device/code", {
|
|
279
|
+
client_id: GITHUB_CLI_CLIENT_ID,
|
|
280
|
+
scope: "read:user user:email",
|
|
281
|
+
});
|
|
282
|
+
if (!start.device_code || !start.user_code || !start.verification_uri) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if (!options.json) {
|
|
286
|
+
const url = start.verification_uri_complete ?? start.verification_uri;
|
|
287
|
+
console.log(`Opening ${url}`);
|
|
288
|
+
console.log(`GitHub code: ${start.user_code}`);
|
|
289
|
+
openBrowser(url);
|
|
290
|
+
}
|
|
291
|
+
const intervalMs = Math.max(1000, (start.interval ?? 5) * 1000);
|
|
292
|
+
const timeoutMs = Math.max(intervalMs, (start.expires_in ?? 900) * 1000);
|
|
293
|
+
const startedAt = Date.now();
|
|
294
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
295
|
+
await sleep(intervalMs);
|
|
296
|
+
const poll = await githubOAuthRequest("https://github.com/login/oauth/access_token", {
|
|
297
|
+
client_id: GITHUB_CLI_CLIENT_ID,
|
|
298
|
+
device_code: start.device_code,
|
|
299
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
300
|
+
});
|
|
301
|
+
if (poll.access_token) {
|
|
302
|
+
return await client.request("/api/cli/login/github-token", {
|
|
303
|
+
method: "POST",
|
|
304
|
+
body: { token: poll.access_token },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (poll.error === "authorization_pending") {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (poll.error === "slow_down") {
|
|
311
|
+
await sleep(Math.max(intervalMs, (poll.interval ?? 5) * 1000));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
async function githubOAuthRequest(url, body) {
|
|
319
|
+
const response = await fetch(url, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: {
|
|
322
|
+
Accept: "application/json",
|
|
323
|
+
"Content-Type": "application/json",
|
|
324
|
+
},
|
|
325
|
+
body: JSON.stringify(body),
|
|
326
|
+
});
|
|
327
|
+
const text = await response.text();
|
|
328
|
+
const parsed = text ? parseJson(text) : null;
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
throw new Error(responseErrorMessage(parsed) ?? text.trim() ?? `${response.status} ${response.statusText}`);
|
|
331
|
+
}
|
|
332
|
+
return parsed;
|
|
333
|
+
}
|
|
334
|
+
async function saveCloudLogin(client, login, token, options, source) {
|
|
335
|
+
const previous = await loadCloudAuth();
|
|
336
|
+
const previousRuntime = previous?.cloudUrl === client.baseUrl ? parseCloudRuntime(previous.defaultRuntime) : undefined;
|
|
337
|
+
const previousByocHost = previous?.cloudUrl === client.baseUrl ? previous.byocSshHost : undefined;
|
|
338
|
+
await saveCloudAuth({
|
|
339
|
+
version: 1,
|
|
340
|
+
token,
|
|
341
|
+
cloudUrl: client.baseUrl,
|
|
342
|
+
defaultRuntime: previousRuntime,
|
|
343
|
+
byocSshHost: previousByocHost,
|
|
344
|
+
accountId: login.accountId,
|
|
345
|
+
email: login.email,
|
|
346
|
+
expiresAt: login.expiresAt ?? (login.expiresIn ? new Date(Date.now() + login.expiresIn * 1000).toISOString() : undefined),
|
|
347
|
+
updatedAt: nowIso(),
|
|
348
|
+
});
|
|
349
|
+
if (options.json) {
|
|
350
|
+
const result = { ok: true, cloudUrl: client.baseUrl, source };
|
|
351
|
+
if (login.email) {
|
|
352
|
+
result.email = login.email;
|
|
353
|
+
}
|
|
354
|
+
if (login.accountId) {
|
|
355
|
+
result.accountId = login.accountId;
|
|
356
|
+
}
|
|
357
|
+
printJson(result);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
console.log(`Logged in to ${client.baseUrl}${login.email ? ` as ${login.email}` : ""} via ${source}.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async function launch(args, options, mode = "name", explicitRuntime) {
|
|
364
|
+
const raw = args.join(" ").trim();
|
|
365
|
+
const repoRoot = findRepoRoot();
|
|
366
|
+
const snapshot = await createSnapshot(repoRoot, options.homePaths ?? []);
|
|
367
|
+
try {
|
|
368
|
+
const client = await cloudClient({ requireToken: true });
|
|
369
|
+
const runtime = await selectedCloudRuntime(explicitRuntime);
|
|
370
|
+
const task = mode === "task" || runtime === "byo-vm" ? raw : "";
|
|
371
|
+
const name = task ? cloudNameFromTask(task) : raw || randomCloudName();
|
|
372
|
+
const body = {
|
|
373
|
+
repoName: path.basename(repoRoot),
|
|
374
|
+
name,
|
|
375
|
+
snapshot: {
|
|
376
|
+
name: path.basename(snapshot.archivePath),
|
|
377
|
+
contentType: "application/gzip",
|
|
378
|
+
base64: await fsp.readFile(snapshot.archivePath, "base64"),
|
|
379
|
+
manifest: snapshot.manifest,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
if (runtime !== "fly") {
|
|
383
|
+
body.runtime = runtime;
|
|
384
|
+
}
|
|
385
|
+
if (task) {
|
|
386
|
+
body.task = task;
|
|
387
|
+
}
|
|
388
|
+
const result = await client.request("/api/rudder/sail/launch", {
|
|
389
|
+
method: "POST",
|
|
390
|
+
body,
|
|
391
|
+
});
|
|
392
|
+
await printResult(result, options);
|
|
393
|
+
await maybeAutoAttach(result, options);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
await fsp.rm(snapshot.tempDir, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function onload(args, options) {
|
|
400
|
+
const runId = args[0];
|
|
401
|
+
const repoRoot = findRepoRoot();
|
|
402
|
+
const runRecord = runId
|
|
403
|
+
? await readJson(path.join(repoRoot, ".rudder", "runs", runId, "run.json"))
|
|
404
|
+
: null;
|
|
405
|
+
const worktreePath = runRecord && typeof runRecord === "object" && !Array.isArray(runRecord)
|
|
406
|
+
? runRecord.worktree
|
|
407
|
+
: undefined;
|
|
408
|
+
const sourceRoot = worktreePath && typeof worktreePath === "object" && !Array.isArray(worktreePath)
|
|
409
|
+
? worktreePath.path
|
|
410
|
+
: undefined;
|
|
411
|
+
const snapshotRoot = sourceRoot && await pathExists(sourceRoot) ? sourceRoot : repoRoot;
|
|
412
|
+
const snapshot = await createSnapshot(snapshotRoot, options.homePaths ?? [], { includeRudderState: !runId });
|
|
413
|
+
try {
|
|
414
|
+
const client = await cloudClient({ requireToken: true });
|
|
415
|
+
const runtime = await selectedCloudRuntime();
|
|
416
|
+
const name = runId ? undefined : `workspace-${path.basename(repoRoot)}`;
|
|
417
|
+
const body = {
|
|
418
|
+
repoName: path.basename(repoRoot),
|
|
419
|
+
run: runRecord ?? null,
|
|
420
|
+
workspace: !runId,
|
|
421
|
+
snapshot: {
|
|
422
|
+
name: path.basename(snapshot.archivePath),
|
|
423
|
+
contentType: "application/gzip",
|
|
424
|
+
base64: await fsp.readFile(snapshot.archivePath, "base64"),
|
|
425
|
+
manifest: snapshot.manifest,
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
if (runId) {
|
|
429
|
+
body.runId = runId;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
body.name = name ?? `workspace-${path.basename(repoRoot)}`;
|
|
433
|
+
}
|
|
434
|
+
if (runtime !== "fly") {
|
|
435
|
+
body.runtime = runtime;
|
|
436
|
+
}
|
|
437
|
+
const result = await client.request("/api/rudder/sail/onload", {
|
|
438
|
+
method: "POST",
|
|
439
|
+
body,
|
|
440
|
+
});
|
|
441
|
+
await printResult(result, options);
|
|
442
|
+
await maybeAutoAttach(result, options);
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
await fsp.rm(snapshot.tempDir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function logs(args, options) {
|
|
449
|
+
const sailId = args[0];
|
|
450
|
+
if (!sailId) {
|
|
451
|
+
throw new Error("Usage: rudder cloud logs <id>");
|
|
452
|
+
}
|
|
453
|
+
const client = await cloudClient({ requireToken: true });
|
|
454
|
+
const result = await client.request("/api/rudder/sail", { method: "GET" });
|
|
455
|
+
const sails = Array.isArray(result)
|
|
456
|
+
? result
|
|
457
|
+
: result && typeof result === "object" && !Array.isArray(result) && Array.isArray(result.sails)
|
|
458
|
+
? result.sails
|
|
459
|
+
: [];
|
|
460
|
+
const match = sails.find((item) => item && typeof item === "object" && !Array.isArray(item) && item.id === sailId);
|
|
461
|
+
if (!match) {
|
|
462
|
+
throw new Error(`Cloud worker not found: ${sailId}`);
|
|
463
|
+
}
|
|
464
|
+
if (options.json) {
|
|
465
|
+
printJson(match);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
console.log("Cloud log streaming is not available yet.");
|
|
469
|
+
console.log("Worker status:");
|
|
470
|
+
printSailList([match]);
|
|
471
|
+
}
|
|
472
|
+
async function listSails(options) {
|
|
473
|
+
const client = await cloudClient({ requireToken: true });
|
|
474
|
+
const result = await client.request("/api/rudder/sail", { method: "GET" });
|
|
475
|
+
await printResult(result, options);
|
|
476
|
+
}
|
|
477
|
+
async function status(options) {
|
|
478
|
+
const client = await cloudClient({ requireToken: true });
|
|
479
|
+
const state = await loadCloudAuth();
|
|
480
|
+
const runtime = await selectedCloudRuntime();
|
|
481
|
+
const sails = await client.request("/api/rudder/sail", { method: "GET" });
|
|
482
|
+
const sailRows = Array.isArray(sails)
|
|
483
|
+
? sails
|
|
484
|
+
: sails && typeof sails === "object" && !Array.isArray(sails) && Array.isArray(sails.sails)
|
|
485
|
+
? sails.sails
|
|
486
|
+
: [];
|
|
487
|
+
const sailCount = sailRows.length;
|
|
488
|
+
const result = {
|
|
489
|
+
ok: true,
|
|
490
|
+
cloudUrl: client.baseUrl,
|
|
491
|
+
runtime,
|
|
492
|
+
sails: sailCount,
|
|
493
|
+
};
|
|
494
|
+
if (state?.cloudUrl === client.baseUrl) {
|
|
495
|
+
if (state.email) {
|
|
496
|
+
result.email = state.email;
|
|
497
|
+
}
|
|
498
|
+
if (state.accountId) {
|
|
499
|
+
result.accountId = state.accountId;
|
|
500
|
+
}
|
|
501
|
+
if (state.byocSshHost) {
|
|
502
|
+
result.byocSshHost = state.byocSshHost;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (options.json) {
|
|
506
|
+
printJson(result);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
console.log(`Logged in to ${client.baseUrl}${state?.email ? ` as ${state.email}` : ""}.`);
|
|
510
|
+
console.log(`Runtime: ${runtime}`);
|
|
511
|
+
if (state?.byocSshHost) {
|
|
512
|
+
console.log(`BYOC SSH host: ${state.byocSshHost}`);
|
|
513
|
+
}
|
|
514
|
+
console.log(`Cloud workers: ${sailCount}`);
|
|
515
|
+
}
|
|
516
|
+
async function bootstrap(args, options) {
|
|
517
|
+
const sailId = args[0];
|
|
518
|
+
if (!sailId) {
|
|
519
|
+
throw new Error("Missing sail id. Usage: rudder cloud bootstrap <id>");
|
|
520
|
+
}
|
|
521
|
+
const client = await cloudClient({ requireToken: true });
|
|
522
|
+
const result = await client.request(`/api/rudder/sail/${encodeURIComponent(sailId)}/bootstrap`, {
|
|
523
|
+
method: "POST",
|
|
524
|
+
body: {},
|
|
525
|
+
});
|
|
526
|
+
await printResult(result, options);
|
|
527
|
+
}
|
|
528
|
+
async function mutateSail(action, args, options) {
|
|
529
|
+
const sailId = args[0];
|
|
530
|
+
if (!sailId) {
|
|
531
|
+
throw new Error(`Missing sail id. Usage: rudder sail ${action} <id>`);
|
|
532
|
+
}
|
|
533
|
+
const client = await cloudClient({ requireToken: true });
|
|
534
|
+
const result = await client.request(`/api/rudder/sail/${encodeURIComponent(sailId)}/${action}`, {
|
|
535
|
+
method: "POST",
|
|
536
|
+
body: args.length > 1 ? { args: args.slice(1) } : {},
|
|
537
|
+
});
|
|
538
|
+
await printResult(result, options);
|
|
539
|
+
}
|
|
540
|
+
async function setupOAuthProvider(provider, args, options) {
|
|
541
|
+
const envPrefix = provider === "github" ? "RUDDER_GITHUB" : "RUDDER_GOOGLE";
|
|
542
|
+
const clientId = args[0]?.trim() || process.env[`${envPrefix}_CLIENT_ID`]?.trim();
|
|
543
|
+
const clientSecret = process.env[`${envPrefix}_CLIENT_SECRET`]?.trim() ||
|
|
544
|
+
args[1]?.trim() ||
|
|
545
|
+
await promptSecret(`${provider === "github" ? "GitHub App" : "Google OAuth"} client secret`);
|
|
546
|
+
if (!clientId || !clientSecret) {
|
|
547
|
+
throw new Error([
|
|
548
|
+
`Missing ${provider === "github" ? "GitHub" : "Google"} OAuth credentials.`,
|
|
549
|
+
`Usage: rudder cloud setup-${provider} <client-id>`,
|
|
550
|
+
`Or set ${envPrefix}_CLIENT_ID and ${envPrefix}_CLIENT_SECRET.`,
|
|
551
|
+
].join("\n"));
|
|
552
|
+
}
|
|
553
|
+
const client = await cloudClient({ requireToken: true });
|
|
554
|
+
const result = await client.request(`/api/rudder/setup/${provider}`, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
body: {
|
|
557
|
+
clientId,
|
|
558
|
+
clientSecret,
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
await printResult(result, options);
|
|
562
|
+
}
|
|
563
|
+
async function setupByoc(args, options) {
|
|
564
|
+
const sshConfigPath = path.join(os.homedir(), ".ssh", "config");
|
|
565
|
+
const configuredHosts = await listSshConfigHosts(sshConfigPath);
|
|
566
|
+
const host = (options.sshHost ?? args.join(" ").trim()) || await chooseByocHost(configuredHosts);
|
|
567
|
+
if (!host) {
|
|
568
|
+
throw new Error([
|
|
569
|
+
"Missing BYOC SSH host.",
|
|
570
|
+
"Add your workstation/server to ~/.ssh/config, then run:",
|
|
571
|
+
"",
|
|
572
|
+
" rudder cloud byoc <ssh-host>",
|
|
573
|
+
"",
|
|
574
|
+
"Example ~/.ssh/config:",
|
|
575
|
+
" Host rudder-workstation",
|
|
576
|
+
" HostName 203.0.113.10",
|
|
577
|
+
" User ubuntu",
|
|
578
|
+
" IdentityFile ~/.ssh/id_ed25519",
|
|
579
|
+
"",
|
|
580
|
+
configuredHosts.length
|
|
581
|
+
? `Detected SSH hosts: ${configuredHosts.slice(0, 12).join(", ")}`
|
|
582
|
+
: `No usable hosts found in ${shortenHome(sshConfigPath)}.`,
|
|
583
|
+
].join("\n"));
|
|
584
|
+
}
|
|
585
|
+
const configMentionsHost = configuredHosts.includes(host) || await sshConfigMentions(sshConfigPath, host);
|
|
586
|
+
const diagnostics = await checkByocHost(host);
|
|
587
|
+
const client = await cloudClient({ requireToken: true });
|
|
588
|
+
const state = await loadCloudAuth();
|
|
589
|
+
if (!state || state.cloudUrl !== client.baseUrl) {
|
|
590
|
+
throw new Error("Not logged in to this Rudder Cloud control plane. Run `rudder login` first.");
|
|
591
|
+
}
|
|
592
|
+
await saveCloudAuth({
|
|
593
|
+
...state,
|
|
594
|
+
defaultRuntime: state.defaultRuntime === "byo-vm" ? "fly" : state.defaultRuntime,
|
|
595
|
+
byocSshHost: host,
|
|
596
|
+
updatedAt: nowIso(),
|
|
597
|
+
});
|
|
598
|
+
if (options.json) {
|
|
599
|
+
const result = {
|
|
600
|
+
ok: true,
|
|
601
|
+
cloudUrl: client.baseUrl,
|
|
602
|
+
byocSshHost: host,
|
|
603
|
+
};
|
|
604
|
+
const defaultRuntime = state.defaultRuntime === "byo-vm" ? "fly" : state.defaultRuntime;
|
|
605
|
+
if (defaultRuntime) {
|
|
606
|
+
result.defaultRuntime = defaultRuntime;
|
|
607
|
+
}
|
|
608
|
+
printJson(result);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
console.log(`Rudder BYOC host set to ${host}.`);
|
|
612
|
+
console.log("Plain `rudder cloud` and dashboard `/cloud` continue to use Fly by default.");
|
|
613
|
+
console.log("Use `rudder cloud vm <task>` when you want to run a task on this BYOC host.");
|
|
614
|
+
if (!configMentionsHost) {
|
|
615
|
+
console.log(`\nNote: ${shortenHome(sshConfigPath)} does not appear to define Host ${host}.`);
|
|
616
|
+
console.log("Rudder can still use it if SSH resolves it, but a ~/.ssh/config entry is recommended:");
|
|
617
|
+
console.log(` Host ${host}`);
|
|
618
|
+
console.log(" HostName <server-ip-or-dns>");
|
|
619
|
+
console.log(" User <user>");
|
|
620
|
+
console.log(" IdentityFile ~/.ssh/<private-key>");
|
|
621
|
+
}
|
|
622
|
+
if (diagnostics.ok) {
|
|
623
|
+
console.log(`SSH check passed for ${host}. Docker is available on the BYOC host.`);
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
console.log(`\nSSH check did not fully pass for ${host}: ${diagnostics.message}`);
|
|
627
|
+
console.log("Fix SSH/Docker before launching, or run the printed Docker command manually on that host.");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function chooseByocHost(hosts) {
|
|
631
|
+
if (hosts.length === 0) {
|
|
632
|
+
return await promptText("SSH host from ~/.ssh/config");
|
|
633
|
+
}
|
|
634
|
+
return await promptSelect("Choose a BYOC SSH host from ~/.ssh/config", hosts.slice(0, 24).map((host) => ({ value: host, label: host })), hosts[0]);
|
|
635
|
+
}
|
|
636
|
+
async function configureDefaultRuntime(runtime, options, byocSshHost) {
|
|
637
|
+
const client = await cloudClient({ requireToken: true });
|
|
638
|
+
const state = await loadCloudAuth();
|
|
639
|
+
if (!state || state.cloudUrl !== client.baseUrl) {
|
|
640
|
+
throw new Error("Not logged in to this Rudder Cloud control plane. Run `rudder login` first.");
|
|
641
|
+
}
|
|
642
|
+
await saveCloudAuth({
|
|
643
|
+
...state,
|
|
644
|
+
defaultRuntime: runtime,
|
|
645
|
+
byocSshHost: runtime === "byo-vm" ? byocSshHost ?? state.byocSshHost : undefined,
|
|
646
|
+
updatedAt: nowIso(),
|
|
647
|
+
});
|
|
648
|
+
const result = {
|
|
649
|
+
ok: true,
|
|
650
|
+
cloudUrl: client.baseUrl,
|
|
651
|
+
defaultRuntime: runtime,
|
|
652
|
+
};
|
|
653
|
+
const savedByocHost = byocSshHost ?? state.byocSshHost;
|
|
654
|
+
if (runtime === "byo-vm" && savedByocHost) {
|
|
655
|
+
result.byocSshHost = savedByocHost;
|
|
656
|
+
}
|
|
657
|
+
const envRuntime = envCloudRuntime();
|
|
658
|
+
if (envRuntime) {
|
|
659
|
+
result.envOverride = envRuntime;
|
|
660
|
+
}
|
|
661
|
+
if (options.json) {
|
|
662
|
+
printJson(result);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
console.log(`Rudder Cloud runtime set to ${runtime}.`);
|
|
666
|
+
if (runtime === "byo-vm") {
|
|
667
|
+
const host = byocSshHost ?? state.byocSshHost;
|
|
668
|
+
console.log("Future `rudder cloud <task>` and `/sail <task>` launches will prepare a BYOC worker instead of creating a Fly Machine.");
|
|
669
|
+
console.log(host
|
|
670
|
+
? `Rudder will try to start the worker over SSH on ${host}.`
|
|
671
|
+
: "Run `rudder cloud byoc <ssh-host>` to let Rudder start workers over SSH.");
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
console.log("Future `rudder cloud <task>` and `/sail <task>` launches will create Fly Machines.");
|
|
675
|
+
}
|
|
676
|
+
if (envRuntime) {
|
|
677
|
+
console.log(`RUDDER_CLOUD_RUNTIME=${envRuntime} is set and will override this saved default.`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async function runtime(args, options) {
|
|
681
|
+
const next = args[0] ? parseCloudRuntime(args[0]) : undefined;
|
|
682
|
+
if (args[0] && !next) {
|
|
683
|
+
throw new Error("Runtime must be `fly`, `byoc`, or `byo-vm`.");
|
|
684
|
+
}
|
|
685
|
+
if (next) {
|
|
686
|
+
await configureDefaultRuntime(next, options);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const client = await cloudClient({ requireToken: true });
|
|
690
|
+
const current = await selectedCloudRuntime();
|
|
691
|
+
const state = await loadCloudAuth();
|
|
692
|
+
const savedRuntime = parseCloudRuntime(state?.defaultRuntime);
|
|
693
|
+
const result = {
|
|
694
|
+
cloudUrl: client.baseUrl,
|
|
695
|
+
runtime: current,
|
|
696
|
+
};
|
|
697
|
+
const envRuntime = envCloudRuntime();
|
|
698
|
+
if (state?.cloudUrl === client.baseUrl && savedRuntime) {
|
|
699
|
+
result.savedDefaultRuntime = savedRuntime;
|
|
700
|
+
}
|
|
701
|
+
if (state?.cloudUrl === client.baseUrl && state.byocSshHost) {
|
|
702
|
+
result.byocSshHost = state.byocSshHost;
|
|
703
|
+
}
|
|
704
|
+
if (envRuntime) {
|
|
705
|
+
result.envOverride = envRuntime;
|
|
706
|
+
}
|
|
707
|
+
if (options.json) {
|
|
708
|
+
printJson(result);
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
console.log(`Rudder Cloud runtime: ${current}`);
|
|
712
|
+
if (envRuntime) {
|
|
713
|
+
console.log(`Set by RUDDER_CLOUD_RUNTIME=${envRuntime}.`);
|
|
714
|
+
}
|
|
715
|
+
else if (state?.cloudUrl === client.baseUrl && savedRuntime) {
|
|
716
|
+
console.log("Set in local Rudder Cloud config.");
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
console.log("Using default Fly Machines runtime.");
|
|
720
|
+
}
|
|
721
|
+
if (state?.cloudUrl === client.baseUrl && state.byocSshHost) {
|
|
722
|
+
console.log(`BYOC SSH host: ${state.byocSshHost}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async function sshConfigMentions(configPath, host) {
|
|
727
|
+
const text = await fsp.readFile(configPath, "utf8").catch(() => "");
|
|
728
|
+
if (!text.trim()) {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
const target = host.toLowerCase();
|
|
732
|
+
for (const line of text.split(/\r?\n/)) {
|
|
733
|
+
const trimmed = line.trim();
|
|
734
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const match = /^Host\s+(.+)$/i.exec(trimmed);
|
|
738
|
+
if (!match) {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const patterns = match[1].split(/\s+/).map((part) => part.toLowerCase());
|
|
742
|
+
if (patterns.includes(target)) {
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
async function listSshConfigHosts(configPath) {
|
|
749
|
+
const text = await fsp.readFile(configPath, "utf8").catch(() => "");
|
|
750
|
+
const hosts = [];
|
|
751
|
+
const seen = new Set();
|
|
752
|
+
for (const line of text.split(/\r?\n/)) {
|
|
753
|
+
const trimmed = line.trim();
|
|
754
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const match = /^Host\s+(.+)$/i.exec(trimmed);
|
|
758
|
+
if (!match) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
for (const host of match[1].split(/\s+/)) {
|
|
762
|
+
if (!host || host.includes("*") || host.includes("?") || host.startsWith("!")) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (seen.has(host)) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
seen.add(host);
|
|
769
|
+
hosts.push(host);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return hosts;
|
|
773
|
+
}
|
|
774
|
+
async function checkByocHost(host) {
|
|
775
|
+
if (!commandExists("ssh")) {
|
|
776
|
+
return { ok: false, message: "ssh is not installed or not on PATH" };
|
|
777
|
+
}
|
|
778
|
+
const result = await runCommand("ssh", [
|
|
779
|
+
"-o",
|
|
780
|
+
"BatchMode=yes",
|
|
781
|
+
"-o",
|
|
782
|
+
"ConnectTimeout=8",
|
|
783
|
+
host,
|
|
784
|
+
"command -v docker >/dev/null && docker info >/dev/null 2>&1",
|
|
785
|
+
], { allowFailure: true });
|
|
786
|
+
if (result.code === 0) {
|
|
787
|
+
return { ok: true, message: "ok" };
|
|
788
|
+
}
|
|
789
|
+
const detail = (result.stderr || result.stdout || `ssh exited ${result.code}`).trim();
|
|
790
|
+
return { ok: false, message: detail };
|
|
791
|
+
}
|
|
792
|
+
async function startByocWorkerOverSsh(host, bootstrapCommand) {
|
|
793
|
+
if (!commandExists("ssh")) {
|
|
794
|
+
throw new Error("ssh is not installed or not on PATH");
|
|
795
|
+
}
|
|
796
|
+
const remoteCommand = [
|
|
797
|
+
"mkdir -p ~/.rudder/byoc",
|
|
798
|
+
`nohup sh -lc ${shellQuote(nonInteractiveDockerCommand(bootstrapCommand))} > ~/.rudder/byoc/worker.log 2>&1 < /dev/null &`,
|
|
799
|
+
].join(" && ");
|
|
800
|
+
await runCommand("ssh", [
|
|
801
|
+
"-o",
|
|
802
|
+
"BatchMode=yes",
|
|
803
|
+
"-o",
|
|
804
|
+
"ConnectTimeout=10",
|
|
805
|
+
host,
|
|
806
|
+
remoteCommand,
|
|
807
|
+
]);
|
|
808
|
+
}
|
|
809
|
+
function nonInteractiveDockerCommand(command) {
|
|
810
|
+
return command
|
|
811
|
+
.replace(/\bdocker run --rm -it\b/g, "docker run --rm")
|
|
812
|
+
.replace(/\bdocker run --rm -i -t\b/g, "docker run --rm")
|
|
813
|
+
.replace(/\bdocker run --rm -t -i\b/g, "docker run --rm");
|
|
814
|
+
}
|
|
815
|
+
async function cloudClient(options) {
|
|
816
|
+
const baseUrl = normalizeCloudUrl(process.env.RUDDER_CLOUD_URL);
|
|
817
|
+
const state = await loadCloudAuth();
|
|
818
|
+
const envToken = process.env.RUDDER_CLOUD_TOKEN?.trim();
|
|
819
|
+
const token = envToken || (state?.cloudUrl === baseUrl ? state.token : undefined);
|
|
820
|
+
if (options.requireToken && !token) {
|
|
821
|
+
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
baseUrl,
|
|
825
|
+
async request(pathOrUrl, init) {
|
|
826
|
+
const url = pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")
|
|
827
|
+
? pathOrUrl
|
|
828
|
+
: new URL(pathOrUrl, `${baseUrl}/`).toString();
|
|
829
|
+
const headers = {
|
|
830
|
+
Accept: "application/json",
|
|
831
|
+
};
|
|
832
|
+
let body;
|
|
833
|
+
if (init.body !== undefined) {
|
|
834
|
+
headers["Content-Type"] = "application/json";
|
|
835
|
+
body = JSON.stringify(init.body);
|
|
836
|
+
}
|
|
837
|
+
if (token) {
|
|
838
|
+
headers.Authorization = `Bearer ${token}`;
|
|
839
|
+
}
|
|
840
|
+
const response = await fetch(url, {
|
|
841
|
+
method: init.method,
|
|
842
|
+
headers,
|
|
843
|
+
body,
|
|
844
|
+
});
|
|
845
|
+
const text = await response.text();
|
|
846
|
+
const parsed = text ? parseJson(text) : null;
|
|
847
|
+
if (!response.ok) {
|
|
848
|
+
const message = responseErrorMessage(parsed) ?? text.trim() ?? `${response.status} ${response.statusText}`;
|
|
849
|
+
throw new Error(`Rudder Cloud request failed: ${message}`);
|
|
850
|
+
}
|
|
851
|
+
return parsed;
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
function normalizeCloudUrl(raw) {
|
|
856
|
+
const value = raw?.trim() || DEFAULT_CLOUD_URL;
|
|
857
|
+
if (!value) {
|
|
858
|
+
throw new Error("RUDDER_CLOUD_URL is not configured. Set it to your Rudder Cloud control plane URL.");
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
const url = new URL(value);
|
|
862
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
863
|
+
throw new Error("bad protocol");
|
|
864
|
+
}
|
|
865
|
+
url.hash = "";
|
|
866
|
+
url.search = "";
|
|
867
|
+
return url.toString().replace(/\/$/, "");
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
throw new Error("RUDDER_CLOUD_URL must be a valid http(s) URL.");
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function selectedCloudRuntime(explicit) {
|
|
874
|
+
if (explicit) {
|
|
875
|
+
return explicit;
|
|
876
|
+
}
|
|
877
|
+
const envRuntime = envCloudRuntime();
|
|
878
|
+
if (envRuntime) {
|
|
879
|
+
return envRuntime;
|
|
880
|
+
}
|
|
881
|
+
const baseUrl = normalizeCloudUrl(process.env.RUDDER_CLOUD_URL);
|
|
882
|
+
const state = await loadCloudAuth();
|
|
883
|
+
const savedRuntime = parseCloudRuntime(state?.defaultRuntime);
|
|
884
|
+
return state?.cloudUrl === baseUrl && savedRuntime ? savedRuntime : "fly";
|
|
885
|
+
}
|
|
886
|
+
function parseCloudRuntime(raw) {
|
|
887
|
+
const value = raw?.trim().toLowerCase();
|
|
888
|
+
if (!value) {
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
891
|
+
if (value === "fly" || value === "fly-machine" || value === "fly-machines") {
|
|
892
|
+
return "fly";
|
|
893
|
+
}
|
|
894
|
+
if (value === "byo" || value === "byoc" || value === "byo-vm" || value === "manual" || value === "self-hosted" || value === "vm") {
|
|
895
|
+
return "byo-vm";
|
|
896
|
+
}
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
function envCloudRuntime() {
|
|
900
|
+
const runtime = parseCloudRuntime(process.env.RUDDER_CLOUD_RUNTIME);
|
|
901
|
+
if (process.env.RUDDER_CLOUD_RUNTIME?.trim() && !runtime) {
|
|
902
|
+
throw new Error("RUDDER_CLOUD_RUNTIME must be `fly`, `byoc`, or `byo-vm`.");
|
|
903
|
+
}
|
|
904
|
+
return runtime;
|
|
905
|
+
}
|
|
906
|
+
async function pollLogin(client, pollPath, deviceCode) {
|
|
907
|
+
if (pollPath.startsWith("http://") || pollPath.startsWith("https://") || !deviceCode) {
|
|
908
|
+
return await client.request(pollPath, { method: "GET" });
|
|
909
|
+
}
|
|
910
|
+
return await client.request(pollPath, {
|
|
911
|
+
method: "POST",
|
|
912
|
+
body: { deviceCode },
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
async function loadCloudAuth() {
|
|
916
|
+
const state = await readJson(cloudAuthPath());
|
|
917
|
+
return state?.version === 1 && typeof state.token === "string" ? state : null;
|
|
918
|
+
}
|
|
919
|
+
async function saveCloudAuth(state) {
|
|
920
|
+
await writeJson(cloudAuthPath(), state, { mode: 0o600 });
|
|
921
|
+
}
|
|
922
|
+
async function createSnapshot(repoRoot, requestedHomePaths, options = {}) {
|
|
923
|
+
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-cloud-"));
|
|
924
|
+
const stageDir = path.join(tempDir, "snapshot");
|
|
925
|
+
const repoStage = path.join(stageDir, "repo");
|
|
926
|
+
const homeStage = path.join(stageDir, "home");
|
|
927
|
+
await ensureDir(repoStage);
|
|
928
|
+
await copyRepoFiles(repoRoot, repoStage);
|
|
929
|
+
const rudderState = options.includeRudderState ? await copyRudderState(repoRoot, repoStage) : undefined;
|
|
930
|
+
const homePaths = normalizeHomePaths(requestedHomePaths);
|
|
931
|
+
const includedHomePaths = [];
|
|
932
|
+
for (const homePath of homePaths) {
|
|
933
|
+
const copied = await copyHomePath(homePath, homeStage);
|
|
934
|
+
if (copied) {
|
|
935
|
+
includedHomePaths.push(shortenHome(homePath));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
// On macOS, Claude Code stores its OAuth token in the Keychain rather than
|
|
939
|
+
// ~/.claude/.credentials.json, so the home-paths copy above doesn't pick it
|
|
940
|
+
// up. Extract it from the Keychain and stage it as a credentials file so
|
|
941
|
+
// the cloud worker boots already logged in.
|
|
942
|
+
if (await stageClaudeKeychainCredentials(homeStage)) {
|
|
943
|
+
includedHomePaths.push("~/.claude/.credentials.json (keychain)");
|
|
944
|
+
}
|
|
945
|
+
const capturedEnv = captureCloudEnv();
|
|
946
|
+
let capturedEnvCount = 0;
|
|
947
|
+
if (Object.keys(capturedEnv).length > 0) {
|
|
948
|
+
await ensureDir(path.join(stageDir, "env"));
|
|
949
|
+
await writeJson(path.join(stageDir, "env", "cloud-env.json"), capturedEnv);
|
|
950
|
+
capturedEnvCount = Object.keys(capturedEnv).length;
|
|
951
|
+
}
|
|
952
|
+
let migratedAgentsCount = 0;
|
|
953
|
+
if (options.migration && options.migration.plan.migrated.length > 0) {
|
|
954
|
+
const entries = await stageMigratedAgents(stageDir, repoRoot, options.migration.repoName, options.migration.plan.migrated);
|
|
955
|
+
const migrationManifest = {
|
|
956
|
+
version: 1,
|
|
957
|
+
createdAt: nowIso(),
|
|
958
|
+
agents: entries,
|
|
959
|
+
};
|
|
960
|
+
await writeJson(path.join(stageDir, "migration.json"), migrationManifest);
|
|
961
|
+
migratedAgentsCount = entries.length;
|
|
962
|
+
}
|
|
963
|
+
const manifest = {
|
|
964
|
+
version: 1,
|
|
965
|
+
createdAt: nowIso(),
|
|
966
|
+
repo: {
|
|
967
|
+
root: path.basename(repoRoot),
|
|
968
|
+
branch: await currentBranch(repoRoot),
|
|
969
|
+
commit: await currentCommit(repoRoot),
|
|
970
|
+
},
|
|
971
|
+
homePaths: includedHomePaths,
|
|
972
|
+
...(rudderState ? { rudderState } : {}),
|
|
973
|
+
...(migratedAgentsCount > 0 ? { migratedAgents: migratedAgentsCount } : {}),
|
|
974
|
+
...(capturedEnvCount > 0 ? { capturedEnvVars: capturedEnvCount } : {}),
|
|
975
|
+
};
|
|
976
|
+
await writeJson(path.join(stageDir, "manifest.json"), manifest);
|
|
977
|
+
const archivePath = path.join(tempDir, `${newRunId("cloud-snapshot")}.tgz`);
|
|
978
|
+
await runCommand("tar", ["-czf", archivePath, "-C", stageDir, "."], { cwd: stageDir });
|
|
979
|
+
return { tempDir, archivePath, manifest };
|
|
980
|
+
}
|
|
981
|
+
const CLOUD_ENV_DEFAULT_NAMES = new Set([
|
|
982
|
+
"ANTHROPIC_API_KEY",
|
|
983
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
984
|
+
"OPENAI_API_KEY",
|
|
985
|
+
"GOOGLE_API_KEY",
|
|
986
|
+
"AWS_ACCESS_KEY_ID",
|
|
987
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
988
|
+
"AWS_SESSION_TOKEN",
|
|
989
|
+
"AWS_REGION",
|
|
990
|
+
"AWS_DEFAULT_REGION",
|
|
991
|
+
"AWS_PROFILE",
|
|
992
|
+
"VERCEL_TOKEN",
|
|
993
|
+
"VERCEL_ORG_ID",
|
|
994
|
+
"VERCEL_PROJECT_ID",
|
|
995
|
+
"NETLIFY_AUTH_TOKEN",
|
|
996
|
+
"GITHUB_TOKEN",
|
|
997
|
+
"GH_TOKEN",
|
|
998
|
+
"GITLAB_TOKEN",
|
|
999
|
+
"NPM_TOKEN",
|
|
1000
|
+
"CARGO_REGISTRY_TOKEN",
|
|
1001
|
+
"HUGGING_FACE_HUB_TOKEN",
|
|
1002
|
+
"HF_TOKEN",
|
|
1003
|
+
"DATABASE_URL",
|
|
1004
|
+
"REDIS_URL",
|
|
1005
|
+
"POSTGRES_URL",
|
|
1006
|
+
"STRIPE_API_KEY",
|
|
1007
|
+
"STRIPE_SECRET_KEY",
|
|
1008
|
+
"SLACK_BOT_TOKEN",
|
|
1009
|
+
"DISCORD_TOKEN",
|
|
1010
|
+
]);
|
|
1011
|
+
const CLOUD_ENV_SUFFIX_PATTERNS = [/_API_KEY$/, /_AUTH_TOKEN$/, /_ACCESS_TOKEN$/, /_TOKEN$/, /_SECRET$/, /_SECRET_KEY$/];
|
|
1012
|
+
const CLOUD_ENV_BLOCKLIST = new Set([
|
|
1013
|
+
// Things we explicitly do not want shipping
|
|
1014
|
+
"PATH",
|
|
1015
|
+
"HOME",
|
|
1016
|
+
"USER",
|
|
1017
|
+
"PWD",
|
|
1018
|
+
"SHELL",
|
|
1019
|
+
"TERM",
|
|
1020
|
+
"TMPDIR",
|
|
1021
|
+
"LOGNAME",
|
|
1022
|
+
"SHLVL",
|
|
1023
|
+
"OLDPWD",
|
|
1024
|
+
"DISPLAY",
|
|
1025
|
+
"EDITOR",
|
|
1026
|
+
"PAGER",
|
|
1027
|
+
"LANG",
|
|
1028
|
+
"LC_ALL",
|
|
1029
|
+
// Rudder-internal vars that are set by the worker itself
|
|
1030
|
+
"RUDDER_WORKSPACE_ID",
|
|
1031
|
+
"RUDDER_SAIL_ID",
|
|
1032
|
+
"RUDDER_WORKER_TOKEN",
|
|
1033
|
+
"RUDDER_CLOUD_URL",
|
|
1034
|
+
"RUDDER_SNAPSHOT_URL",
|
|
1035
|
+
"RUDDER_CLOUD_TOKEN",
|
|
1036
|
+
"RUDDER_TASK",
|
|
1037
|
+
"RUDDER_REPO_NAME",
|
|
1038
|
+
"RUDDER_ACCOUNT_ID",
|
|
1039
|
+
"RUDDER_HANDOFF_PATH",
|
|
1040
|
+
]);
|
|
1041
|
+
function captureCloudEnv() {
|
|
1042
|
+
const extra = (process.env.RUDDER_CLOUD_ENV_VARS || "")
|
|
1043
|
+
.split(",")
|
|
1044
|
+
.map((name) => name.trim())
|
|
1045
|
+
.filter(Boolean);
|
|
1046
|
+
const blockExtra = (process.env.RUDDER_CLOUD_ENV_BLOCKLIST || "")
|
|
1047
|
+
.split(",")
|
|
1048
|
+
.map((name) => name.trim())
|
|
1049
|
+
.filter(Boolean);
|
|
1050
|
+
const blocked = new Set([...CLOUD_ENV_BLOCKLIST, ...blockExtra]);
|
|
1051
|
+
const out = {};
|
|
1052
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
1053
|
+
if (!name || typeof value !== "string" || !value) {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (blocked.has(name)) {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
const matches = CLOUD_ENV_DEFAULT_NAMES.has(name)
|
|
1060
|
+
|| CLOUD_ENV_SUFFIX_PATTERNS.some((pattern) => pattern.test(name))
|
|
1061
|
+
|| extra.includes(name);
|
|
1062
|
+
if (!matches) {
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
out[name] = value;
|
|
1066
|
+
}
|
|
1067
|
+
return out;
|
|
1068
|
+
}
|
|
1069
|
+
async function stageMigratedAgents(stageDir, repoRoot, repoName, migrated) {
|
|
1070
|
+
const worktreesStage = path.join(stageDir, "migrated-worktrees");
|
|
1071
|
+
const sessionsStage = path.join(stageDir, "migrated-sessions");
|
|
1072
|
+
const entries = [];
|
|
1073
|
+
for (const candidate of migrated) {
|
|
1074
|
+
if (!(await pathExists(candidate.worktreePath))) {
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
const hasSession = Boolean(candidate.sessionId
|
|
1078
|
+
&& candidate.sessionJsonlPath
|
|
1079
|
+
&& (await pathExists(candidate.sessionJsonlPath)));
|
|
1080
|
+
const worktreeDest = path.join(worktreesStage, candidate.runId);
|
|
1081
|
+
await ensureDir(worktreeDest);
|
|
1082
|
+
await copyWorktreeFiles(candidate.worktreePath, worktreeDest, repoRoot);
|
|
1083
|
+
let sessionJsonlSnapshotPath;
|
|
1084
|
+
if (hasSession) {
|
|
1085
|
+
const jsonlDest = path.join(sessionsStage, `${candidate.runId}.jsonl`);
|
|
1086
|
+
await ensureDir(path.dirname(jsonlDest));
|
|
1087
|
+
await fsp.cp(candidate.sessionJsonlPath, jsonlDest, { force: true });
|
|
1088
|
+
sessionJsonlSnapshotPath = path.posix.join("migrated-sessions", `${candidate.runId}.jsonl`);
|
|
1089
|
+
}
|
|
1090
|
+
const cloudWorktreeAbs = cloudWorktreeAbsolutePath(repoName, candidate.runId);
|
|
1091
|
+
// For fresh restarts, build a prompt-engineered handoff from the local
|
|
1092
|
+
// run record so the new agent gets context instead of just the bare task.
|
|
1093
|
+
let freshPrompt;
|
|
1094
|
+
if (!hasSession) {
|
|
1095
|
+
const runJsonPath = path.join(repoRoot, ".rudder", "runs", candidate.runId, "run.json");
|
|
1096
|
+
const record = await readJson(runJsonPath);
|
|
1097
|
+
const turns = Array.isArray(record?.turns) ? record.turns : [];
|
|
1098
|
+
freshPrompt = buildFreshHandoffPrompt(candidate, turns);
|
|
1099
|
+
}
|
|
1100
|
+
entries.push({
|
|
1101
|
+
runId: candidate.runId,
|
|
1102
|
+
task: candidate.task,
|
|
1103
|
+
taskSummary: candidate.taskSummary,
|
|
1104
|
+
backend: candidate.backend,
|
|
1105
|
+
sessionId: candidate.sessionId ?? "",
|
|
1106
|
+
localWorktreePath: candidate.worktreePath,
|
|
1107
|
+
cloudWorktreeRelativePath: cloudWorktreeAbs,
|
|
1108
|
+
sessionJsonlSnapshotPath: sessionJsonlSnapshotPath ?? "",
|
|
1109
|
+
worktreeBranch: candidate.worktreeBranch,
|
|
1110
|
+
freshPrompt,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
return entries;
|
|
1114
|
+
}
|
|
1115
|
+
async function copyWorktreeFiles(worktreePath, target, _repoRoot) {
|
|
1116
|
+
const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1117
|
+
cwd: worktreePath,
|
|
1118
|
+
allowFailure: true,
|
|
1119
|
+
});
|
|
1120
|
+
const files = result.code === 0
|
|
1121
|
+
? result.stdout.split("\0").filter(Boolean)
|
|
1122
|
+
: await listFiles(worktreePath);
|
|
1123
|
+
for (const relative of files) {
|
|
1124
|
+
if (!relative || relative.startsWith(".git/") || relative === ".git" || relative.startsWith(".rudder/")) {
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const source = path.join(worktreePath, relative);
|
|
1128
|
+
const dest = path.join(target, relative);
|
|
1129
|
+
if (!isInside(worktreePath, source) || !isInside(target, dest)) {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const stat = await fsp.lstat(source).catch(() => null);
|
|
1133
|
+
if (!stat || stat.isDirectory() || !(await shouldIncludeSnapshotPath(source))) {
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
await ensureDir(path.dirname(dest));
|
|
1137
|
+
await fsp.cp(source, dest, { dereference: false, force: true });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
async function copyRudderState(repoRoot, repoStage) {
|
|
1141
|
+
const copied = [];
|
|
1142
|
+
const rudderMd = path.join(repoRoot, "RUDDER.md");
|
|
1143
|
+
if (await pathExists(rudderMd)) {
|
|
1144
|
+
const target = path.join(repoStage, "RUDDER.md");
|
|
1145
|
+
await fsp.cp(rudderMd, target, { force: true });
|
|
1146
|
+
copied.push("RUDDER.md");
|
|
1147
|
+
}
|
|
1148
|
+
const runsDir = path.join(repoRoot, ".rudder", "runs");
|
|
1149
|
+
const entries = await fsp.readdir(runsDir, { withFileTypes: true }).catch(() => []);
|
|
1150
|
+
let runs = 0;
|
|
1151
|
+
for (const entry of entries) {
|
|
1152
|
+
if (!entry.isDirectory() || entry.name.includes("/") || entry.name.includes("\\")) {
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const runJson = path.join(runsDir, entry.name, "run.json");
|
|
1156
|
+
if (!(await pathExists(runJson))) {
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const relative = path.join(".rudder", "runs", entry.name, "run.json");
|
|
1160
|
+
const target = path.join(repoStage, relative);
|
|
1161
|
+
if (!isInside(repoRoot, runJson) || !isInside(repoStage, target)) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
await ensureDir(path.dirname(target));
|
|
1165
|
+
await fsp.cp(runJson, target, { force: true });
|
|
1166
|
+
copied.push(relative);
|
|
1167
|
+
runs += 1;
|
|
1168
|
+
}
|
|
1169
|
+
return { runs, files: copied };
|
|
1170
|
+
}
|
|
1171
|
+
async function copyRepoFiles(repoRoot, repoStage) {
|
|
1172
|
+
const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1173
|
+
cwd: repoRoot,
|
|
1174
|
+
allowFailure: true,
|
|
1175
|
+
});
|
|
1176
|
+
const files = result.code === 0
|
|
1177
|
+
? result.stdout.split("\0").filter(Boolean)
|
|
1178
|
+
: await listFiles(repoRoot);
|
|
1179
|
+
for (const relative of files) {
|
|
1180
|
+
if (!relative || relative.startsWith(".git/") || relative.startsWith(".rudder/")) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const source = path.join(repoRoot, relative);
|
|
1184
|
+
const target = path.join(repoStage, relative);
|
|
1185
|
+
if (!isInside(repoRoot, source) || !isInside(repoStage, target)) {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const stat = await fsp.lstat(source).catch(() => null);
|
|
1189
|
+
if (!stat || stat.isDirectory() || !(await shouldIncludeSnapshotPath(source))) {
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
await ensureDir(path.dirname(target));
|
|
1193
|
+
await fsp.cp(source, target, { dereference: false, force: true });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
async function listFiles(dir) {
|
|
1197
|
+
const files = [];
|
|
1198
|
+
async function walk(current) {
|
|
1199
|
+
const entries = await fsp.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
1200
|
+
for (const entry of entries) {
|
|
1201
|
+
if (entry.name === ".git" || entry.name === ".rudder" || entry.name === "node_modules") {
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
const full = path.join(current, entry.name);
|
|
1205
|
+
if (entry.isDirectory()) {
|
|
1206
|
+
await walk(full);
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
files.push(path.relative(dir, full));
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
await walk(dir);
|
|
1214
|
+
return files;
|
|
1215
|
+
}
|
|
1216
|
+
function normalizeHomePaths(requested) {
|
|
1217
|
+
const raw = [
|
|
1218
|
+
...DEFAULT_HOME_PATHS,
|
|
1219
|
+
...requested,
|
|
1220
|
+
...(process.env.RUDDER_CLOUD_HOME_PATHS?.split(",") ?? []),
|
|
1221
|
+
];
|
|
1222
|
+
const home = os.homedir();
|
|
1223
|
+
const seen = new Set();
|
|
1224
|
+
const paths = [];
|
|
1225
|
+
for (const item of raw) {
|
|
1226
|
+
const trimmed = item.trim();
|
|
1227
|
+
if (!trimmed) {
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
const resolved = path.resolve(expandHome(trimmed));
|
|
1231
|
+
if (!isInside(home, resolved) || seen.has(resolved)) {
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
seen.add(resolved);
|
|
1235
|
+
paths.push(resolved);
|
|
1236
|
+
}
|
|
1237
|
+
return paths;
|
|
1238
|
+
}
|
|
1239
|
+
async function stageClaudeKeychainCredentials(homeStage) {
|
|
1240
|
+
if (process.platform !== "darwin") {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
if (!commandExists("security")) {
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
const result = await runCommand("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { allowFailure: true });
|
|
1247
|
+
if (result.code !== 0) {
|
|
1248
|
+
return false;
|
|
1249
|
+
}
|
|
1250
|
+
const payload = result.stdout.trim();
|
|
1251
|
+
if (!payload || !payload.startsWith("{")) {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
try {
|
|
1255
|
+
JSON.parse(payload);
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1260
|
+
const targetDir = path.join(homeStage, ".claude");
|
|
1261
|
+
await ensureDir(targetDir);
|
|
1262
|
+
await fsp.writeFile(path.join(targetDir, ".credentials.json"), payload + "\n", { mode: 0o600 });
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
async function copyHomePath(source, homeStage) {
|
|
1266
|
+
if (!(await pathExists(source)) || !(await shouldIncludeSnapshotPath(source))) {
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
const relative = path.relative(os.homedir(), source);
|
|
1270
|
+
const target = path.join(homeStage, relative);
|
|
1271
|
+
if (!isInside(homeStage, target)) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
await fsp.cp(source, target, {
|
|
1275
|
+
dereference: false,
|
|
1276
|
+
recursive: true,
|
|
1277
|
+
force: true,
|
|
1278
|
+
filter: async (candidate) => await shouldIncludeSnapshotPath(candidate),
|
|
1279
|
+
});
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1282
|
+
async function shouldIncludeSnapshotPath(candidate) {
|
|
1283
|
+
const normalized = path.resolve(candidate);
|
|
1284
|
+
const parts = normalized.split(path.sep).map((part) => part.toLowerCase());
|
|
1285
|
+
const basename = path.basename(normalized).toLowerCase();
|
|
1286
|
+
if (basename.startsWith("._")) {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
if (parts.some((part) => SECRET_PATH_PARTS.has(part)) || SECRET_BASENAMES.has(basename)) {
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
if (isInside(os.homedir(), normalized)) {
|
|
1293
|
+
if (parts.some((part) => BULKY_HOME_PATH_PARTS.has(part))) {
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
if (BULKY_HOME_BASENAME_PATTERNS.some((pattern) => pattern.test(basename))) {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const stat = await fsp.lstat(normalized).catch(() => null);
|
|
1301
|
+
if (!stat || !stat.isFile() || stat.size > MAX_HOME_SECRET_SCAN_BYTES) {
|
|
1302
|
+
return true;
|
|
1303
|
+
}
|
|
1304
|
+
const text = await fsp.readFile(normalized, "utf8").catch(() => "");
|
|
1305
|
+
return !/(aws_access_key_id|aws_secret_access_key|aws_session_token|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)/.test(text);
|
|
1306
|
+
}
|
|
1307
|
+
function isInside(parent, child) {
|
|
1308
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child));
|
|
1309
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
1310
|
+
}
|
|
1311
|
+
function openBrowser(url) {
|
|
1312
|
+
const platform = process.platform;
|
|
1313
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
1314
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1315
|
+
const child = spawn(command, args, {
|
|
1316
|
+
detached: true,
|
|
1317
|
+
stdio: "ignore",
|
|
1318
|
+
});
|
|
1319
|
+
child.on("error", () => undefined);
|
|
1320
|
+
child.unref();
|
|
1321
|
+
}
|
|
1322
|
+
function withQuery(baseUrl, pathname, query) {
|
|
1323
|
+
const url = new URL(pathname, `${baseUrl}/`);
|
|
1324
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1325
|
+
url.searchParams.set(key, value);
|
|
1326
|
+
}
|
|
1327
|
+
return url.toString();
|
|
1328
|
+
}
|
|
1329
|
+
function parseJson(text) {
|
|
1330
|
+
try {
|
|
1331
|
+
return JSON.parse(text);
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
return text;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
function responseErrorMessage(value) {
|
|
1338
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1339
|
+
return undefined;
|
|
1340
|
+
}
|
|
1341
|
+
const record = value;
|
|
1342
|
+
return typeof record.error === "string"
|
|
1343
|
+
? record.error
|
|
1344
|
+
: typeof record.message === "string"
|
|
1345
|
+
? record.message
|
|
1346
|
+
: undefined;
|
|
1347
|
+
}
|
|
1348
|
+
async function printResult(result, options) {
|
|
1349
|
+
if (options.json) {
|
|
1350
|
+
printJson(result);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
if (Array.isArray(result)) {
|
|
1354
|
+
printSailList(result);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
1358
|
+
const record = result;
|
|
1359
|
+
if (typeof record.bootstrapCommand === "string") {
|
|
1360
|
+
const id = typeof record.id === "string" ? record.id : "BYOC sail";
|
|
1361
|
+
const status = typeof record.status === "string" ? record.status : undefined;
|
|
1362
|
+
const state = await loadCloudAuth();
|
|
1363
|
+
const host = options.sshHost ?? state?.byocSshHost;
|
|
1364
|
+
console.log(`${id}${status ? ` (${status})` : ""} is ready for BYOC.`);
|
|
1365
|
+
if (host && process.env.RUDDER_BYOC_AUTOSTART !== "0") {
|
|
1366
|
+
try {
|
|
1367
|
+
await startByocWorkerOverSsh(host, record.bootstrapCommand);
|
|
1368
|
+
console.log(`Started BYOC worker over SSH on ${host}.`);
|
|
1369
|
+
console.log(`Remote log: ssh ${host} 'tail -f ~/.rudder/byoc/worker.log'`);
|
|
1370
|
+
}
|
|
1371
|
+
catch (error) {
|
|
1372
|
+
console.log(`Could not start BYOC worker over SSH on ${host}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1373
|
+
console.log("Run this manually on your workstation/server:");
|
|
1374
|
+
console.log(record.bootstrapCommand);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
console.log("Run this on your workstation/server:");
|
|
1379
|
+
console.log(record.bootstrapCommand);
|
|
1380
|
+
if (!host) {
|
|
1381
|
+
console.log("\nTip: run `rudder cloud byoc <ssh-host>` to have Rudder start this over SSH next time.");
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (typeof record.updatedAt === "string") {
|
|
1385
|
+
console.log(`\nIf the command expires, run: rudder cloud bootstrap ${id}`);
|
|
1386
|
+
}
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
const sails = record.sails ?? record.items;
|
|
1390
|
+
if (Array.isArray(sails)) {
|
|
1391
|
+
printSailList(sails);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if (typeof record.id === "string" && (typeof record.status === "string" || typeof record.runtime === "string")) {
|
|
1395
|
+
const parts = [
|
|
1396
|
+
record.id,
|
|
1397
|
+
typeof record.status === "string" ? record.status : undefined,
|
|
1398
|
+
typeof record.runtime === "string" ? record.runtime : undefined,
|
|
1399
|
+
typeof record.repoName === "string" ? record.repoName : undefined,
|
|
1400
|
+
].filter(Boolean);
|
|
1401
|
+
console.log(parts.join(" "));
|
|
1402
|
+
if (record.workspace === true || (record.run === null && typeof record.task !== "string")) {
|
|
1403
|
+
console.log("Rudder workspace uploaded. Use /cloud list to track it.");
|
|
1404
|
+
}
|
|
1405
|
+
else {
|
|
1406
|
+
console.log("Cloud worker created. Use /cloud list to track it.");
|
|
1407
|
+
}
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1412
|
+
}
|
|
1413
|
+
function printSailList(items) {
|
|
1414
|
+
if (items.length === 0) {
|
|
1415
|
+
console.log("No cloud sails.");
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
for (const item of items) {
|
|
1419
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1420
|
+
console.log(String(item));
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
const sail = item;
|
|
1424
|
+
console.log([
|
|
1425
|
+
sail.id,
|
|
1426
|
+
sail.status,
|
|
1427
|
+
sail.runtime,
|
|
1428
|
+
typeof sail.task === "string" && sail.task ? sail.task : undefined,
|
|
1429
|
+
typeof sail.repoName === "string" && sail.repoName ? sail.repoName : undefined,
|
|
1430
|
+
sail.branch,
|
|
1431
|
+
sail.url,
|
|
1432
|
+
sail.updatedAt ?? sail.createdAt,
|
|
1433
|
+
].filter(Boolean).join(" "));
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
function printJson(value) {
|
|
1437
|
+
console.log(JSON.stringify(value, null, 2));
|
|
1438
|
+
}
|
|
1439
|
+
function sleep(ms) {
|
|
1440
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1441
|
+
}
|
|
1442
|
+
async function workspaceCommand(args, options) {
|
|
1443
|
+
const sub = args[0] ?? "";
|
|
1444
|
+
const rest = args.slice(1);
|
|
1445
|
+
if (sub === "" || sub === "attach") {
|
|
1446
|
+
await workspaceAttach(rest, options);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
if (sub === "share") {
|
|
1450
|
+
await workspaceShare(options);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
if (sub === "status") {
|
|
1454
|
+
await workspaceStatus(options);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (sub === "stop") {
|
|
1458
|
+
await workspaceMutate("stop", rest, options);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (sub === "list" || sub === "ls") {
|
|
1462
|
+
await workspaceList(options);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
throw new Error("Usage: rudder cloud workspace [attach [id]|share|status|stop|list]");
|
|
1466
|
+
}
|
|
1467
|
+
function computeWorkspaceKey(repoRoot) {
|
|
1468
|
+
const normalized = path.resolve(repoRoot);
|
|
1469
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 32);
|
|
1470
|
+
}
|
|
1471
|
+
let cachedFlyRegion;
|
|
1472
|
+
async function detectFlyRegion(baseUrl) {
|
|
1473
|
+
if (process.env.RUDDER_CLOUD_REGION) {
|
|
1474
|
+
return process.env.RUDDER_CLOUD_REGION.trim().toLowerCase();
|
|
1475
|
+
}
|
|
1476
|
+
if (cachedFlyRegion) {
|
|
1477
|
+
return cachedFlyRegion;
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/health`, { method: "GET" });
|
|
1481
|
+
const requestId = response.headers.get("fly-request-id");
|
|
1482
|
+
if (requestId) {
|
|
1483
|
+
// Fly request-id format: <ulid>-<region>
|
|
1484
|
+
const dash = requestId.lastIndexOf("-");
|
|
1485
|
+
const region = dash > 0 ? requestId.slice(dash + 1).trim().toLowerCase() : "";
|
|
1486
|
+
if (region && region.length <= 6 && /^[a-z]+$/.test(region)) {
|
|
1487
|
+
cachedFlyRegion = region;
|
|
1488
|
+
return region;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
// ignore — server will fall back to its default region
|
|
1494
|
+
}
|
|
1495
|
+
return undefined;
|
|
1496
|
+
}
|
|
1497
|
+
async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths) {
|
|
1498
|
+
const hash = createHash("sha256");
|
|
1499
|
+
// Repo state: HEAD commit + the porcelain dirty file list. Two attaches
|
|
1500
|
+
// from the same repo at the same commit with no edits should produce the
|
|
1501
|
+
// same fingerprint.
|
|
1502
|
+
const headCommit = await currentCommit(repoRoot).catch(() => "");
|
|
1503
|
+
hash.update(`repo:head:${headCommit}\n`);
|
|
1504
|
+
const status = await runCommand("git", ["status", "--porcelain", "-z"], {
|
|
1505
|
+
cwd: repoRoot,
|
|
1506
|
+
allowFailure: true,
|
|
1507
|
+
});
|
|
1508
|
+
if (status.code === 0) {
|
|
1509
|
+
hash.update(`repo:status:${status.stdout}\n`);
|
|
1510
|
+
}
|
|
1511
|
+
// macOS Keychain claude credentials: hash content so re-logging in
|
|
1512
|
+
// invalidates the cache but a steady-state user keeps it.
|
|
1513
|
+
if (process.platform === "darwin") {
|
|
1514
|
+
const creds = await runCommand("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], {
|
|
1515
|
+
allowFailure: true,
|
|
1516
|
+
});
|
|
1517
|
+
if (creds.code === 0) {
|
|
1518
|
+
hash.update(`keychain:claude:${createHash("sha256").update(creds.stdout).digest("hex")}\n`);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// Captured env vars (excluding ones we know rotate like AWS session tokens).
|
|
1522
|
+
const env = captureCloudEnv();
|
|
1523
|
+
const unstable = new Set(["AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN"]);
|
|
1524
|
+
for (const key of Object.keys(env).sort()) {
|
|
1525
|
+
if (unstable.has(key))
|
|
1526
|
+
continue;
|
|
1527
|
+
hash.update(`env:${key}:${createHash("sha256").update(env[key] ?? "").digest("hex")}\n`);
|
|
1528
|
+
}
|
|
1529
|
+
return hash.digest("hex").slice(0, 32);
|
|
1530
|
+
}
|
|
1531
|
+
async function workspaceAttach(args, options) {
|
|
1532
|
+
const explicitId = args[0];
|
|
1533
|
+
if (explicitId) {
|
|
1534
|
+
await workspaceAttachById(explicitId, options);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const repoRoot = findRepoRoot();
|
|
1538
|
+
const workspaceKey = computeWorkspaceKey(repoRoot);
|
|
1539
|
+
const repoName = path.basename(repoRoot);
|
|
1540
|
+
const client = await cloudClient({ requireToken: true });
|
|
1541
|
+
if (!options.json) {
|
|
1542
|
+
process.stderr.write(`Resolving cloud workspace for ${repoName}...\n`);
|
|
1543
|
+
}
|
|
1544
|
+
const migrationPlan = await planAgentMigration(repoRoot, options);
|
|
1545
|
+
const mustUploadSnapshot = Boolean(migrationPlan && migrationPlan.migrated.length > 0);
|
|
1546
|
+
const region = await detectFlyRegion(client.baseUrl).catch(() => undefined);
|
|
1547
|
+
const fingerprint = await computeSnapshotFingerprint(repoRoot, options.homePaths ?? []);
|
|
1548
|
+
const baseBody = {
|
|
1549
|
+
workspaceKey,
|
|
1550
|
+
repoName,
|
|
1551
|
+
snapshotFingerprint: fingerprint,
|
|
1552
|
+
...(region ? { region } : {}),
|
|
1553
|
+
};
|
|
1554
|
+
let result = null;
|
|
1555
|
+
if (!mustUploadSnapshot) {
|
|
1556
|
+
try {
|
|
1557
|
+
result = await client.request("/api/rudder/workspace/attach", {
|
|
1558
|
+
method: "POST",
|
|
1559
|
+
body: baseBody,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
catch (error) {
|
|
1563
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1564
|
+
if (!/snapshot/i.test(message)) {
|
|
1565
|
+
throw error;
|
|
1566
|
+
}
|
|
1567
|
+
result = null;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
if (!result) {
|
|
1571
|
+
if (!options.json) {
|
|
1572
|
+
process.stderr.write(`Uploading workspace snapshot...\n`);
|
|
1573
|
+
}
|
|
1574
|
+
const snapshot = await createSnapshot(repoRoot, options.homePaths ?? [], {
|
|
1575
|
+
includeRudderState: true,
|
|
1576
|
+
migration: migrationPlan ? { repoName, plan: migrationPlan } : undefined,
|
|
1577
|
+
});
|
|
1578
|
+
try {
|
|
1579
|
+
const body = {
|
|
1580
|
+
...baseBody,
|
|
1581
|
+
snapshot: {
|
|
1582
|
+
name: path.basename(snapshot.archivePath),
|
|
1583
|
+
contentType: "application/gzip",
|
|
1584
|
+
base64: await fsp.readFile(snapshot.archivePath, "base64"),
|
|
1585
|
+
manifest: snapshot.manifest,
|
|
1586
|
+
},
|
|
1587
|
+
};
|
|
1588
|
+
if (migrationPlan && migrationPlan.migrated.length > 0) {
|
|
1589
|
+
body.migratedAgents = summaryAsJson(migrationPlan);
|
|
1590
|
+
}
|
|
1591
|
+
result = await client.request("/api/rudder/workspace/attach", {
|
|
1592
|
+
method: "POST",
|
|
1593
|
+
body,
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
finally {
|
|
1597
|
+
await fsp.rm(snapshot.tempDir, { recursive: true, force: true });
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (migrationPlan && !options.json) {
|
|
1601
|
+
process.stderr.write(`${migrationSummary(migrationPlan)}\n`);
|
|
1602
|
+
}
|
|
1603
|
+
if (!result) {
|
|
1604
|
+
throw new Error("Workspace attach returned no result");
|
|
1605
|
+
}
|
|
1606
|
+
await attachToWorkspaceResult(result, options);
|
|
1607
|
+
}
|
|
1608
|
+
async function planAgentMigration(repoRoot, options) {
|
|
1609
|
+
const candidates = await findMigrationCandidates(repoRoot);
|
|
1610
|
+
if (candidates.length === 0) {
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
const plan = applyDefaultDecisions(candidates);
|
|
1614
|
+
if (options.json) {
|
|
1615
|
+
return plan;
|
|
1616
|
+
}
|
|
1617
|
+
if (!isTty()) {
|
|
1618
|
+
return plan;
|
|
1619
|
+
}
|
|
1620
|
+
console.log(migrationSummary(plan));
|
|
1621
|
+
if (plan.migrated.length === 0) {
|
|
1622
|
+
return plan;
|
|
1623
|
+
}
|
|
1624
|
+
const confirmed = await promptConfirm("Move resumable agents to cloud?", true);
|
|
1625
|
+
if (confirmed) {
|
|
1626
|
+
return plan;
|
|
1627
|
+
}
|
|
1628
|
+
return {
|
|
1629
|
+
candidates,
|
|
1630
|
+
migrated: [],
|
|
1631
|
+
stayedLocal: candidates.map((c) => ({ ...c, decision: "stay" })),
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
async function attachToWorkspaceResult(result, options) {
|
|
1635
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
1636
|
+
throw new Error("Unexpected workspace response from cloud");
|
|
1637
|
+
}
|
|
1638
|
+
const record = result;
|
|
1639
|
+
const workspaceId = typeof record.id === "string" ? record.id : undefined;
|
|
1640
|
+
if (!workspaceId) {
|
|
1641
|
+
throw new Error("Workspace response is missing id");
|
|
1642
|
+
}
|
|
1643
|
+
if (options.json) {
|
|
1644
|
+
printJson(record);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1648
|
+
process.stderr.write(`Workspace ${workspaceId} is ready. Run \`rudder cloud workspace attach\` from a TTY to take over.\n`);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
await waitForWorkspaceWorker(workspaceId);
|
|
1655
|
+
await runAttach({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, { ...options, quietBanner: false });
|
|
1656
|
+
}
|
|
1657
|
+
async function waitForWorkspaceWorker(workspaceId) {
|
|
1658
|
+
// Give the Fly machine a moment to boot before the WS attach so users see the dashboard, not a wait-for-worker banner.
|
|
1659
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1660
|
+
void workspaceId;
|
|
1661
|
+
}
|
|
1662
|
+
async function workspaceAttachById(workspaceId, options) {
|
|
1663
|
+
if (options.json) {
|
|
1664
|
+
printJson({ id: workspaceId, attaching: true });
|
|
1665
|
+
}
|
|
1666
|
+
else if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1667
|
+
process.stderr.write(`Workspace ${workspaceId}: attach requires a TTY.\n`);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
await runAttach({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, { ...options, quietBanner: false });
|
|
1674
|
+
}
|
|
1675
|
+
async function lookupWorkspaceForRepo(options) {
|
|
1676
|
+
void options;
|
|
1677
|
+
const repoRoot = findRepoRoot();
|
|
1678
|
+
const workspaceKey = computeWorkspaceKey(repoRoot);
|
|
1679
|
+
const client = await cloudClient({ requireToken: true });
|
|
1680
|
+
try {
|
|
1681
|
+
const result = await client.request(`/api/rudder/workspace/lookup?key=${encodeURIComponent(workspaceKey)}`, { method: "GET" });
|
|
1682
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
1683
|
+
return result;
|
|
1684
|
+
}
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
catch (error) {
|
|
1688
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1689
|
+
if (/not found/i.test(message) || /404/.test(message)) {
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
throw error;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
async function workspaceShare(options) {
|
|
1696
|
+
const workspace = await lookupWorkspaceForRepo(options);
|
|
1697
|
+
if (!workspace) {
|
|
1698
|
+
if (options.json) {
|
|
1699
|
+
printJson({ workspace: null });
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
console.log("No cloud workspace exists for this repo yet. Run `rudder cloud workspace attach` to create one.");
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const id = typeof workspace.id === "string" ? workspace.id : "";
|
|
1706
|
+
if (!id) {
|
|
1707
|
+
throw new Error("Workspace lookup returned no id");
|
|
1708
|
+
}
|
|
1709
|
+
if (options.json) {
|
|
1710
|
+
printJson({
|
|
1711
|
+
id,
|
|
1712
|
+
attachCommand: `rudder cloud workspace attach ${id}`,
|
|
1713
|
+
status: workspace.status ?? null,
|
|
1714
|
+
});
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
console.log("Share this workspace with a teammate by sending them:");
|
|
1718
|
+
console.log("");
|
|
1719
|
+
console.log(` rudder cloud workspace attach ${id}`);
|
|
1720
|
+
console.log("");
|
|
1721
|
+
console.log("They must already be logged in to Rudder Cloud with their own account (run `rudder cloud login` if not).");
|
|
1722
|
+
}
|
|
1723
|
+
async function workspaceStatus(options) {
|
|
1724
|
+
if (process.env.RUDDER_OFFLINE === "1") {
|
|
1725
|
+
if (options.json) {
|
|
1726
|
+
printJson({ offline: true, workspace: null });
|
|
1727
|
+
}
|
|
1728
|
+
else {
|
|
1729
|
+
console.log("RUDDER_OFFLINE is set; skipping cloud workspace status check.");
|
|
1730
|
+
}
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const workspace = await lookupWorkspaceForRepo(options).catch((error) => {
|
|
1734
|
+
if (!options.json) {
|
|
1735
|
+
console.warn(`Could not reach Rudder Cloud: ${error instanceof Error ? error.message : String(error)}`);
|
|
1736
|
+
}
|
|
1737
|
+
return null;
|
|
1738
|
+
});
|
|
1739
|
+
if (!workspace) {
|
|
1740
|
+
if (options.json) {
|
|
1741
|
+
printJson({ workspace: null });
|
|
1742
|
+
}
|
|
1743
|
+
else {
|
|
1744
|
+
console.log("No cloud workspace for this repo.");
|
|
1745
|
+
}
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
const id = typeof workspace.id === "string" ? workspace.id : "";
|
|
1749
|
+
const status = typeof workspace.status === "string" ? workspace.status : "unknown";
|
|
1750
|
+
const clientCount = typeof workspace.clientCount === "number" ? workspace.clientCount : 0;
|
|
1751
|
+
const lastActivityAt = typeof workspace.lastActivityAt === "string" ? workspace.lastActivityAt : undefined;
|
|
1752
|
+
const idleMinutes = computeIdleMinutes(lastActivityAt);
|
|
1753
|
+
const activeAgents = clientCount > 0 || (idleMinutes !== null && idleMinutes < 5);
|
|
1754
|
+
if (options.json) {
|
|
1755
|
+
printJson({
|
|
1756
|
+
id,
|
|
1757
|
+
status,
|
|
1758
|
+
clientCount,
|
|
1759
|
+
lastActivityAt: lastActivityAt ?? null,
|
|
1760
|
+
idleMinutes,
|
|
1761
|
+
activeAgents,
|
|
1762
|
+
repoName: typeof workspace.repoName === "string" ? workspace.repoName : null,
|
|
1763
|
+
});
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
const idlePart = idleMinutes !== null ? ` idle ${idleMinutes}m` : "";
|
|
1767
|
+
console.log(`workspace ${id} ${status} clients=${clientCount}${idlePart}`);
|
|
1768
|
+
if (activeAgents) {
|
|
1769
|
+
console.log("Active agents likely running.");
|
|
1770
|
+
}
|
|
1771
|
+
else {
|
|
1772
|
+
console.log("No recent activity.");
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
function computeIdleMinutes(lastActivityAt) {
|
|
1776
|
+
if (!lastActivityAt) {
|
|
1777
|
+
return null;
|
|
1778
|
+
}
|
|
1779
|
+
const ms = Date.parse(lastActivityAt);
|
|
1780
|
+
if (!Number.isFinite(ms)) {
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
const diff = Date.now() - ms;
|
|
1784
|
+
if (diff < 0) {
|
|
1785
|
+
return 0;
|
|
1786
|
+
}
|
|
1787
|
+
return Math.floor(diff / 60_000);
|
|
1788
|
+
}
|
|
1789
|
+
async function workspaceMutate(action, args, options) {
|
|
1790
|
+
const id = args[0];
|
|
1791
|
+
if (!id) {
|
|
1792
|
+
throw new Error(`Usage: rudder cloud workspace ${action} <id>`);
|
|
1793
|
+
}
|
|
1794
|
+
const client = await cloudClient({ requireToken: true });
|
|
1795
|
+
const result = await client.request(`/api/rudder/workspace/${encodeURIComponent(id)}/${action}`, {
|
|
1796
|
+
method: "POST",
|
|
1797
|
+
body: {},
|
|
1798
|
+
});
|
|
1799
|
+
await printResult(result, options);
|
|
1800
|
+
}
|
|
1801
|
+
async function workspaceList(options) {
|
|
1802
|
+
const client = await cloudClient({ requireToken: true });
|
|
1803
|
+
const result = await client.request("/api/rudder/workspace", { method: "GET" });
|
|
1804
|
+
await printResult(result, options);
|
|
1805
|
+
}
|
|
1806
|
+
async function attach(args, options) {
|
|
1807
|
+
const sailId = args[0];
|
|
1808
|
+
if (!sailId) {
|
|
1809
|
+
throw new Error("Usage: rudder cloud attach <id>");
|
|
1810
|
+
}
|
|
1811
|
+
await runAttach({ kind: "sail", id: sailId, label: sailId }, options);
|
|
1812
|
+
}
|
|
1813
|
+
async function runAttach(target, options) {
|
|
1814
|
+
const client = await cloudClient({ requireToken: true });
|
|
1815
|
+
const baseUrl = client.baseUrl;
|
|
1816
|
+
const state = await loadCloudAuth();
|
|
1817
|
+
const envToken = process.env.RUDDER_CLOUD_TOKEN?.trim();
|
|
1818
|
+
const token = envToken || (state?.cloudUrl === baseUrl ? state.token : undefined);
|
|
1819
|
+
if (!token) {
|
|
1820
|
+
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
1821
|
+
}
|
|
1822
|
+
const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
|
|
1823
|
+
+ `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach`;
|
|
1824
|
+
const stdin = process.stdin;
|
|
1825
|
+
const stdout = process.stdout;
|
|
1826
|
+
const isInteractive = Boolean(stdin.isTTY && stdout.isTTY);
|
|
1827
|
+
return await new Promise((resolve, reject) => {
|
|
1828
|
+
const socket = new WebSocket(wsUrl, {
|
|
1829
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1830
|
+
});
|
|
1831
|
+
socket.binaryType = "nodebuffer";
|
|
1832
|
+
let opened = false;
|
|
1833
|
+
let cleaned = false;
|
|
1834
|
+
let firstFrameRendered = false;
|
|
1835
|
+
let result = "exited";
|
|
1836
|
+
const splashAllowed = isInteractive && !options.json && !options.quietBanner;
|
|
1837
|
+
const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
|
|
1838
|
+
const sendResize = () => {
|
|
1839
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const cols = stdout.columns ?? 120;
|
|
1843
|
+
const rows = stdout.rows ?? 32;
|
|
1844
|
+
socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
1845
|
+
};
|
|
1846
|
+
let lastCtrlC = 0;
|
|
1847
|
+
const onStdin = (chunk) => {
|
|
1848
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
1852
|
+
const isCtrlC = buffer.length === 1 && buffer[0] === 0x03;
|
|
1853
|
+
// While the loading splash is up (no remote frame has rendered yet),
|
|
1854
|
+
// Ctrl+C should cancel the local attach instead of being forwarded to
|
|
1855
|
+
// a remote dashboard the user can't see.
|
|
1856
|
+
if (isCtrlC && !firstFrameRendered) {
|
|
1857
|
+
try {
|
|
1858
|
+
socket.close(1000, "client-cancel");
|
|
1859
|
+
}
|
|
1860
|
+
catch { /* ignore */ }
|
|
1861
|
+
cleanup();
|
|
1862
|
+
if (!opened) {
|
|
1863
|
+
reject(new Error("Cloud attach cancelled"));
|
|
1864
|
+
}
|
|
1865
|
+
else {
|
|
1866
|
+
process.stderr.write("\nCancelled.\n");
|
|
1867
|
+
resolve("exited");
|
|
1868
|
+
}
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
// After handoff: forward Ctrl+C to the remote so Claude/codex can be
|
|
1872
|
+
// cancelled, but if the user mashes Ctrl+C twice within 2 seconds we
|
|
1873
|
+
// take it as "the remote is unresponsive; get me out".
|
|
1874
|
+
if (isCtrlC && firstFrameRendered) {
|
|
1875
|
+
const now = Date.now();
|
|
1876
|
+
if (now - lastCtrlC < 2000) {
|
|
1877
|
+
process.stderr.write("\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
|
|
1878
|
+
try {
|
|
1879
|
+
socket.close(1000, "client-force-quit");
|
|
1880
|
+
}
|
|
1881
|
+
catch { /* ignore */ }
|
|
1882
|
+
cleanup();
|
|
1883
|
+
resolve("exited");
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
lastCtrlC = now;
|
|
1887
|
+
}
|
|
1888
|
+
socket.send(buffer, { binary: true });
|
|
1889
|
+
};
|
|
1890
|
+
const onResize = () => {
|
|
1891
|
+
sendResize();
|
|
1892
|
+
splash?.redraw();
|
|
1893
|
+
};
|
|
1894
|
+
const cleanup = () => {
|
|
1895
|
+
if (cleaned) {
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
cleaned = true;
|
|
1899
|
+
stdin.off("data", onStdin);
|
|
1900
|
+
stdout.off("resize", onResize);
|
|
1901
|
+
process.off("SIGINT", onSigint);
|
|
1902
|
+
splash?.dispose();
|
|
1903
|
+
if (isInteractive && stdin.isTTY) {
|
|
1904
|
+
try {
|
|
1905
|
+
stdin.setRawMode(false);
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
// ignore
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
stdin.pause();
|
|
1912
|
+
};
|
|
1913
|
+
const onSigint = () => {
|
|
1914
|
+
// Belt-and-suspenders: if raw mode failed for any reason, Node still
|
|
1915
|
+
// sees SIGINT. Close cleanly so the user never gets a frozen terminal.
|
|
1916
|
+
process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
|
|
1917
|
+
try {
|
|
1918
|
+
socket.close(1000, "sigint");
|
|
1919
|
+
}
|
|
1920
|
+
catch { /* ignore */ }
|
|
1921
|
+
cleanup();
|
|
1922
|
+
if (!opened) {
|
|
1923
|
+
reject(new Error("Cloud attach cancelled"));
|
|
1924
|
+
}
|
|
1925
|
+
else {
|
|
1926
|
+
resolve("exited");
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
process.once("SIGINT", onSigint);
|
|
1930
|
+
socket.on("open", () => {
|
|
1931
|
+
opened = true;
|
|
1932
|
+
// Disable Nagle on the underlying TCP socket so single keystrokes don't
|
|
1933
|
+
// sit in the send buffer waiting for piggyback ACKs (~40ms savings per
|
|
1934
|
+
// keypress on the WAN).
|
|
1935
|
+
try {
|
|
1936
|
+
const underlying = socket._socket;
|
|
1937
|
+
underlying?.setNoDelay?.(true);
|
|
1938
|
+
}
|
|
1939
|
+
catch {
|
|
1940
|
+
// ignore
|
|
1941
|
+
}
|
|
1942
|
+
if (splash) {
|
|
1943
|
+
splash.start();
|
|
1944
|
+
splash.setStatus(`Booting cloud workspace · ${target.label}`);
|
|
1945
|
+
}
|
|
1946
|
+
else if (!options.json && !options.quietBanner) {
|
|
1947
|
+
const tail = isInteractive ? " (Ctrl+C sends to remote; close this pane to detach)" : "";
|
|
1948
|
+
process.stderr.write(`Attached to ${target.label}${tail}\n`);
|
|
1949
|
+
}
|
|
1950
|
+
sendResize();
|
|
1951
|
+
if (isInteractive) {
|
|
1952
|
+
try {
|
|
1953
|
+
stdin.setRawMode(true);
|
|
1954
|
+
}
|
|
1955
|
+
catch {
|
|
1956
|
+
// ignore
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
stdin.resume();
|
|
1960
|
+
stdin.on("data", onStdin);
|
|
1961
|
+
stdout.on("resize", onResize);
|
|
1962
|
+
});
|
|
1963
|
+
socket.on("message", (data, isBinary) => {
|
|
1964
|
+
if (isBinary && Buffer.isBuffer(data)) {
|
|
1965
|
+
if (!firstFrameRendered) {
|
|
1966
|
+
firstFrameRendered = true;
|
|
1967
|
+
splash?.handoff();
|
|
1968
|
+
// Re-send resize right after handoff and once more on a small
|
|
1969
|
+
// delay; some terminals don't surface accurate columns/rows
|
|
1970
|
+
// until after alt-screen exits, so the initial resize at WS
|
|
1971
|
+
// open can race.
|
|
1972
|
+
sendResize();
|
|
1973
|
+
setTimeout(sendResize, 150);
|
|
1974
|
+
}
|
|
1975
|
+
stdout.write(data);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const text = Buffer.isBuffer(data)
|
|
1979
|
+
? data.toString("utf8")
|
|
1980
|
+
: Array.isArray(data)
|
|
1981
|
+
? Buffer.concat(data).toString("utf8")
|
|
1982
|
+
: Buffer.from(data).toString("utf8");
|
|
1983
|
+
handleControlText(text);
|
|
1984
|
+
});
|
|
1985
|
+
socket.on("close", (code, reason) => {
|
|
1986
|
+
cleanup();
|
|
1987
|
+
if (!opened) {
|
|
1988
|
+
const reasonText = reason && reason.length ? reason.toString("utf8") : "";
|
|
1989
|
+
reject(new Error(`Cloud attach failed (code ${code}${reasonText ? `: ${reasonText}` : ""})`));
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
resolve(result);
|
|
1993
|
+
});
|
|
1994
|
+
socket.on("error", (err) => {
|
|
1995
|
+
if (!opened) {
|
|
1996
|
+
cleanup();
|
|
1997
|
+
reject(new Error(`Cloud attach failed: ${err.message}`));
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
function handleControlText(text) {
|
|
2001
|
+
let payload;
|
|
2002
|
+
try {
|
|
2003
|
+
payload = JSON.parse(text);
|
|
2004
|
+
}
|
|
2005
|
+
catch {
|
|
2006
|
+
stdout.write(text);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
if (!payload || typeof payload !== "object") {
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
const message = payload;
|
|
2013
|
+
if (message.type === "exit") {
|
|
2014
|
+
result = message.code === 0 ? "exited" : "failed";
|
|
2015
|
+
if (typeof process.exitCode !== "number" && message.code !== undefined) {
|
|
2016
|
+
process.exitCode = message.code;
|
|
2017
|
+
}
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (message.type === "status") {
|
|
2021
|
+
// If the worker dies before we've ever seen a remote frame, the
|
|
2022
|
+
// session is effectively dead. Don't sit on a splash spinner pretending
|
|
2023
|
+
// it'll come back; bail.
|
|
2024
|
+
if (message.state === "worker-disconnected" && !firstFrameRendered) {
|
|
2025
|
+
splash?.dispose();
|
|
2026
|
+
try {
|
|
2027
|
+
socket.close(1000, "worker-gone");
|
|
2028
|
+
}
|
|
2029
|
+
catch { /* ignore */ }
|
|
2030
|
+
cleanup();
|
|
2031
|
+
if (!opened) {
|
|
2032
|
+
reject(new Error("Cloud worker exited before the dashboard started"));
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
process.stderr.write("\nCloud worker exited before the dashboard started.\n");
|
|
2036
|
+
result = "failed";
|
|
2037
|
+
resolve(result);
|
|
2038
|
+
}
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
if (splash && !firstFrameRendered) {
|
|
2042
|
+
if (message.state === "worker-waiting") {
|
|
2043
|
+
splash.setStatus(`Waiting for cloud worker · ${target.label}`);
|
|
2044
|
+
}
|
|
2045
|
+
else if (message.state === "worker-connected") {
|
|
2046
|
+
splash.setStatus(`Cloud worker connected · loading dashboard`);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
else if (!options.json && !options.quietBanner) {
|
|
2050
|
+
if (message.state === "worker-disconnected") {
|
|
2051
|
+
process.stderr.write("\nCloud worker disconnected; waiting for reconnect...\n");
|
|
2052
|
+
}
|
|
2053
|
+
else if (message.state === "worker-connected") {
|
|
2054
|
+
process.stderr.write("Cloud worker connected.\n");
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
class AttachSplash {
|
|
2062
|
+
stdout;
|
|
2063
|
+
label;
|
|
2064
|
+
frame = 0;
|
|
2065
|
+
status;
|
|
2066
|
+
timer;
|
|
2067
|
+
active = false;
|
|
2068
|
+
static FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2069
|
+
constructor(stdout, label) {
|
|
2070
|
+
this.stdout = stdout;
|
|
2071
|
+
this.label = label;
|
|
2072
|
+
this.status = `Connecting to ${label}`;
|
|
2073
|
+
}
|
|
2074
|
+
start() {
|
|
2075
|
+
if (this.active) {
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
this.active = true;
|
|
2079
|
+
this.stdout.write("\x1b[?1049h\x1b[?25l");
|
|
2080
|
+
this.redraw();
|
|
2081
|
+
this.timer = setInterval(() => {
|
|
2082
|
+
this.frame = (this.frame + 1) % AttachSplash.FRAMES.length;
|
|
2083
|
+
this.redraw();
|
|
2084
|
+
}, 100);
|
|
2085
|
+
this.timer.unref?.();
|
|
2086
|
+
}
|
|
2087
|
+
setStatus(status) {
|
|
2088
|
+
this.status = status;
|
|
2089
|
+
if (this.active) {
|
|
2090
|
+
this.redraw();
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
redraw() {
|
|
2094
|
+
if (!this.active) {
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
const cols = this.stdout.columns ?? 80;
|
|
2098
|
+
const rows = this.stdout.rows ?? 24;
|
|
2099
|
+
const spinner = AttachSplash.FRAMES[this.frame] ?? "·";
|
|
2100
|
+
const lines = [
|
|
2101
|
+
`${spinner} ${this.status}`,
|
|
2102
|
+
` Ctrl+C to disconnect`,
|
|
2103
|
+
];
|
|
2104
|
+
const top = Math.max(1, Math.floor(rows / 2) - 1);
|
|
2105
|
+
this.stdout.write("\x1b[2J");
|
|
2106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2107
|
+
const line = lines[i] ?? "";
|
|
2108
|
+
const visible = stripAnsi(line);
|
|
2109
|
+
const left = Math.max(1, Math.floor((cols - visible.length) / 2) + 1);
|
|
2110
|
+
this.stdout.write(`\x1b[${top + i};${left}H${line}`);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
handoff() {
|
|
2114
|
+
if (!this.active) {
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
this.stop();
|
|
2118
|
+
this.stdout.write("\x1b[?1049l\x1b[?25h");
|
|
2119
|
+
}
|
|
2120
|
+
dispose() {
|
|
2121
|
+
if (!this.active) {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
this.stop();
|
|
2125
|
+
this.stdout.write("\x1b[?25h");
|
|
2126
|
+
}
|
|
2127
|
+
stop() {
|
|
2128
|
+
this.active = false;
|
|
2129
|
+
if (this.timer) {
|
|
2130
|
+
clearInterval(this.timer);
|
|
2131
|
+
this.timer = undefined;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
function stripAnsi(value) {
|
|
2136
|
+
return value.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2137
|
+
}
|
|
2138
|
+
async function maybeAutoAttach(result, options) {
|
|
2139
|
+
if (options.json || options.noAttach) {
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
const record = result;
|
|
2152
|
+
if (typeof record.bootstrapCommand === "string") {
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const sailId = extractSailId(record);
|
|
2156
|
+
if (!sailId) {
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
try {
|
|
2160
|
+
await runAttach({ kind: "sail", id: sailId, label: sailId }, { ...options, quietBanner: false });
|
|
2161
|
+
}
|
|
2162
|
+
catch (error) {
|
|
2163
|
+
console.warn(`Could not attach to ${sailId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
function extractSailId(record) {
|
|
2167
|
+
if (typeof record.id === "string" && record.id) {
|
|
2168
|
+
return record.id;
|
|
2169
|
+
}
|
|
2170
|
+
return undefined;
|
|
2171
|
+
}
|
|
2172
|
+
function printCloudHelp() {
|
|
2173
|
+
console.log(`rudder cloud
|
|
2174
|
+
|
|
2175
|
+
Usage:
|
|
2176
|
+
rudder cloud login
|
|
2177
|
+
rudder cloud help
|
|
2178
|
+
rudder cloud [name or task]
|
|
2179
|
+
rudder cloud launch [--home-path <path>] ["task"]
|
|
2180
|
+
rudder cloud byoc [ssh-host]
|
|
2181
|
+
rudder cloud vm ["task"]
|
|
2182
|
+
rudder cloud list
|
|
2183
|
+
rudder cloud onload [runId]
|
|
2184
|
+
no runId uploads the current Rudder workspace state
|
|
2185
|
+
rudder cloud logs <id>
|
|
2186
|
+
rudder cloud attach <id>
|
|
2187
|
+
stream the live cloud worker terminal into this pane
|
|
2188
|
+
rudder cloud workspace [attach [id]|share|status [--json]|stop <id>|list]
|
|
2189
|
+
shared cloud workspace for this repo
|
|
2190
|
+
rudder cloud bootstrap <id>
|
|
2191
|
+
rudder cloud runtime [fly|byoc]
|
|
2192
|
+
rudder cloud setup-byoc <ssh-host> compatibility alias
|
|
2193
|
+
rudder cloud setup-fly
|
|
2194
|
+
rudder sail [name or task]
|
|
2195
|
+
rudder sail list
|
|
2196
|
+
rudder sail pause <id>
|
|
2197
|
+
rudder sail resume <id>
|
|
2198
|
+
rudder cloud setup-github <client-id>
|
|
2199
|
+
rudder cloud setup-google <client-id>
|
|
2200
|
+
|
|
2201
|
+
Environment:
|
|
2202
|
+
RUDDER_CLOUD_URL Cloud control plane URL (defaults to ${DEFAULT_CLOUD_URL})
|
|
2203
|
+
RUDDER_CLOUD_RUNTIME fly, byoc, or byo-vm (overrides saved local default)
|
|
2204
|
+
RUDDER_CLOUD_HOME_PATHS Extra comma-separated HOME paths to include in snapshots
|
|
2205
|
+
RUDDER_GITHUB_CLIENT_ID GitHub App OAuth client ID for setup-github
|
|
2206
|
+
RUDDER_GITHUB_CLIENT_SECRET GitHub App OAuth client secret for setup-github
|
|
2207
|
+
RUDDER_GOOGLE_CLIENT_ID Google OAuth client ID for setup-google
|
|
2208
|
+
RUDDER_GOOGLE_CLIENT_SECRET Google OAuth client secret for setup-google
|
|
2209
|
+
`);
|
|
2210
|
+
}
|
|
2211
|
+
const CLOUD_ADJECTIVES = [
|
|
2212
|
+
"amber",
|
|
2213
|
+
"bright",
|
|
2214
|
+
"calm",
|
|
2215
|
+
"clear",
|
|
2216
|
+
"cosmic",
|
|
2217
|
+
"gentle",
|
|
2218
|
+
"golden",
|
|
2219
|
+
"lucky",
|
|
2220
|
+
"rapid",
|
|
2221
|
+
"silver",
|
|
2222
|
+
"steady",
|
|
2223
|
+
"swift",
|
|
2224
|
+
];
|
|
2225
|
+
const CLOUD_NOUNS = [
|
|
2226
|
+
"atlas",
|
|
2227
|
+
"harbor",
|
|
2228
|
+
"signal",
|
|
2229
|
+
"summit",
|
|
2230
|
+
"orbit",
|
|
2231
|
+
"ranger",
|
|
2232
|
+
"river",
|
|
2233
|
+
"rocket",
|
|
2234
|
+
"sparrow",
|
|
2235
|
+
"station",
|
|
2236
|
+
"voyager",
|
|
2237
|
+
"wave",
|
|
2238
|
+
];
|
|
2239
|
+
function randomCloudName() {
|
|
2240
|
+
const seed = Date.now() + process.pid + Math.floor(Math.random() * 1_000_000);
|
|
2241
|
+
return [
|
|
2242
|
+
CLOUD_ADJECTIVES[Math.abs(seed) % CLOUD_ADJECTIVES.length],
|
|
2243
|
+
CLOUD_NOUNS[Math.abs(Math.floor(seed / CLOUD_ADJECTIVES.length)) % CLOUD_NOUNS.length],
|
|
2244
|
+
].join("-");
|
|
2245
|
+
}
|
|
2246
|
+
function cloudNameFromTask(task) {
|
|
2247
|
+
const slug = task
|
|
2248
|
+
.toLowerCase()
|
|
2249
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
2250
|
+
.replace(/^-+|-+$/g, "")
|
|
2251
|
+
.slice(0, 36)
|
|
2252
|
+
.replace(/-+$/g, "");
|
|
2253
|
+
return slug || randomCloudName();
|
|
2254
|
+
}
|
|
2255
|
+
//# sourceMappingURL=cloud.js.map
|