@exla/rudder 0.0.0-stage → 3.4.1
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/LICENSE +100 -0
- package/README.md +733 -2
- package/assets/rudder.icns +0 -0
- package/assets/sounds/NOTICE.md +8 -0
- package/assets/sounds/ping.mp3 +0 -0
- 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/analytics.d.ts +36 -0
- package/dist/analytics.js +178 -0
- package/dist/analytics.js.map +1 -0
- package/dist/attach-session.d.ts +91 -0
- package/dist/attach-session.js +290 -0
- package/dist/attach-session.js.map +1 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.js +397 -0
- package/dist/auth.js.map +1 -0
- package/dist/backends.d.ts +3 -0
- package/dist/backends.js +638 -0
- package/dist/backends.js.map +1 -0
- package/dist/board/board.css +1 -0
- package/dist/board/board.js +2 -0
- package/dist/board/daemon.d.ts +39 -0
- package/dist/board/daemon.js +1510 -0
- package/dist/board/daemon.js.map +1 -0
- package/dist/brain.d.ts +24 -0
- package/dist/brain.js +244 -0
- package/dist/brain.js.map +1 -0
- package/dist/bus.d.ts +9 -0
- package/dist/bus.js +23 -0
- package/dist/bus.js.map +1 -0
- package/dist/checkout-sync.d.ts +35 -0
- package/dist/checkout-sync.js +247 -0
- package/dist/checkout-sync.js.map +1 -0
- package/dist/cloud.d.ts +51 -0
- package/dist/cloud.js +4672 -0
- package/dist/cloud.js.map +1 -0
- package/dist/codex-binary.d.ts +12 -0
- package/dist/codex-binary.js +184 -0
- package/dist/codex-binary.js.map +1 -0
- package/dist/context-audit.d.ts +21 -0
- package/dist/context-audit.js +109 -0
- package/dist/context-audit.js.map +1 -0
- package/dist/daemon.d.ts +27 -0
- package/dist/daemon.js +203 -0
- package/dist/daemon.js.map +1 -0
- package/dist/effort.d.ts +9 -0
- package/dist/effort.js +72 -0
- package/dist/effort.js.map +1 -0
- package/dist/feedback.d.ts +46 -0
- package/dist/feedback.js +173 -0
- package/dist/feedback.js.map +1 -0
- package/dist/gc.d.ts +3 -0
- package/dist/gc.js +262 -0
- package/dist/gc.js.map +1 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +455 -0
- package/dist/git.js.map +1 -0
- package/dist/goal.d.ts +33 -0
- package/dist/goal.js +95 -0
- package/dist/goal.js.map +1 -0
- package/dist/graph.d.ts +93 -0
- package/dist/graph.js +372 -0
- package/dist/graph.js.map +1 -0
- package/dist/handoff.d.ts +110 -0
- package/dist/handoff.js +565 -0
- package/dist/handoff.js.map +1 -0
- package/dist/improve/advisor.d.ts +10 -0
- package/dist/improve/advisor.js +115 -0
- package/dist/improve/advisor.js.map +1 -0
- package/dist/improve/collect.d.ts +49 -0
- package/dist/improve/collect.js +274 -0
- package/dist/improve/collect.js.map +1 -0
- package/dist/improve/gate.d.ts +17 -0
- package/dist/improve/gate.js +60 -0
- package/dist/improve/gate.js.map +1 -0
- package/dist/improve/index.d.ts +9 -0
- package/dist/improve/index.js +376 -0
- package/dist/improve/index.js.map +1 -0
- package/dist/improve/judge.d.ts +32 -0
- package/dist/improve/judge.js +114 -0
- package/dist/improve/judge.js.map +1 -0
- package/dist/improve/mine.d.ts +31 -0
- package/dist/improve/mine.js +192 -0
- package/dist/improve/mine.js.map +1 -0
- package/dist/improve/propose.d.ts +51 -0
- package/dist/improve/propose.js +150 -0
- package/dist/improve/propose.js.map +1 -0
- package/dist/improve/rank.d.ts +16 -0
- package/dist/improve/rank.js +49 -0
- package/dist/improve/rank.js.map +1 -0
- package/dist/improve/schedule.d.ts +1 -0
- package/dist/improve/schedule.js +95 -0
- package/dist/improve/schedule.js.map +1 -0
- package/dist/improve/ship.d.ts +25 -0
- package/dist/improve/ship.js +82 -0
- package/dist/improve/ship.js.map +1 -0
- package/dist/improve/state.d.ts +157 -0
- package/dist/improve/state.js +264 -0
- package/dist/improve/state.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/jj-binary.d.ts +36 -0
- package/dist/jj-binary.js +143 -0
- package/dist/jj-binary.js.map +1 -0
- package/dist/jj.d.ts +163 -0
- package/dist/jj.js +926 -0
- package/dist/jj.js.map +1 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +1366 -0
- package/dist/main.js.map +1 -0
- package/dist/migration.d.ts +82 -0
- package/dist/migration.js +198 -0
- package/dist/migration.js.map +1 -0
- package/dist/models.d.ts +9 -0
- package/dist/models.js +623 -0
- package/dist/models.js.map +1 -0
- package/dist/move-home.d.ts +82 -0
- package/dist/move-home.js +472 -0
- package/dist/move-home.js.map +1 -0
- package/dist/native/darwin-arm64/rudder-native +0 -0
- package/dist/native/darwin-x64/rudder-native +0 -0
- package/dist/native/linux-x64/rudder-native +0 -0
- package/dist/native-agents.d.ts +8 -0
- package/dist/native-agents.js +178 -0
- package/dist/native-agents.js.map +1 -0
- package/dist/native-binary.d.ts +4 -0
- package/dist/native-binary.js +140 -0
- package/dist/native-binary.js.map +1 -0
- package/dist/onboarding.d.ts +11 -0
- package/dist/onboarding.js +111 -0
- package/dist/onboarding.js.map +1 -0
- package/dist/paste-bridge.d.ts +36 -0
- package/dist/paste-bridge.js +279 -0
- package/dist/paste-bridge.js.map +1 -0
- 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/planner.d.ts +27 -0
- package/dist/planner.js +587 -0
- package/dist/planner.js.map +1 -0
- package/dist/predict.d.ts +67 -0
- package/dist/predict.js +369 -0
- package/dist/predict.js.map +1 -0
- package/dist/qr.d.ts +13 -0
- package/dist/qr.js +377 -0
- package/dist/qr.js.map +1 -0
- package/dist/report.d.ts +74 -0
- package/dist/report.js +412 -0
- package/dist/report.js.map +1 -0
- package/dist/rudder-md.d.ts +33 -0
- package/dist/rudder-md.js +142 -0
- package/dist/rudder-md.js.map +1 -0
- package/dist/run-manager.d.ts +62 -0
- package/dist/run-manager.js +1400 -0
- package/dist/run-manager.js.map +1 -0
- package/dist/scheduler.d.ts +128 -0
- package/dist/scheduler.js +955 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/state.d.ts +127 -0
- package/dist/state.js +704 -0
- package/dist/state.js.map +1 -0
- package/dist/surfaces.d.ts +91 -0
- package/dist/surfaces.js +460 -0
- package/dist/surfaces.js.map +1 -0
- package/dist/task-summary.d.ts +30 -0
- package/dist/task-summary.js +472 -0
- package/dist/task-summary.js.map +1 -0
- package/dist/types.d.ts +492 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage.d.ts +198 -0
- package/dist/usage.js +830 -0
- package/dist/usage.js.map +1 -0
- package/dist/util.d.ts +73 -0
- package/dist/util.js +399 -0
- package/dist/util.js.map +1 -0
- package/dist/version-check.d.ts +16 -0
- package/dist/version-check.js +269 -0
- package/dist/version-check.js.map +1 -0
- package/package.json +68 -4
- package/scripts/copy-native.mjs +89 -0
- package/scripts/postinstall.mjs +34 -0
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,4672 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { AttachSession, containsCtrlC } from "./attach-session.js";
|
|
4
|
+
import { PasteBridge, readLocalClipboard } from "./paste-bridge.js";
|
|
5
|
+
import fsp from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { WebSocket } from "ws";
|
|
9
|
+
import { currentBranch, currentCommit, findRepoRoot } from "./git.js";
|
|
10
|
+
import { applyDefaultDecisions, buildFreshHandoffPrompt, cloudWorkspaceAbsolutePath, findMigrationCandidates, formatCandidateReason, migrationSummary, summaryAsJson, } from "./migration.js";
|
|
11
|
+
import { buildMoveBundle, findMoveCandidates, markRunsMovedHome } from "./move-home.js";
|
|
12
|
+
import { cloudAuthPath } from "./state.js";
|
|
13
|
+
import { runLaptopOnboarding } from "./onboarding.js";
|
|
14
|
+
import { renderQrForTerminal } from "./qr.js";
|
|
15
|
+
import { describeSync, exportWork, importWork, syncCheckout } from "./checkout-sync.js";
|
|
16
|
+
import { ensureDir, commandExists, expandHome, isTty, newRunId, nowIso, pathExists, promptConfirm, promptText, promptSelect, promptSecret, readJson, runCommand, shortenHome, shellQuote, writeJson, } from "./util.js";
|
|
17
|
+
const DEFAULT_LOGIN_INTERVAL_MS = 2000;
|
|
18
|
+
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
19
|
+
const DEFAULT_CLOUD_URL = "https://rudder-cloud-control.fly.dev";
|
|
20
|
+
const GITHUB_CLI_CLIENT_ID = "178c6fc778ccc68e1d6a";
|
|
21
|
+
const MAX_HOME_SECRET_SCAN_BYTES = 1024 * 1024;
|
|
22
|
+
const DEFAULT_HOME_PATHS = [
|
|
23
|
+
// Agent CLIs
|
|
24
|
+
"~/.claude/.credentials.json",
|
|
25
|
+
"~/.claude/settings.json",
|
|
26
|
+
// Hook scripts settings.json points at; the home machine rewrites their
|
|
27
|
+
// /Users/<name> paths to its own home (cloud/worker/hook-paths.mjs).
|
|
28
|
+
"~/.claude/hooks",
|
|
29
|
+
"~/.claude/CLAUDE.md",
|
|
30
|
+
"~/.claude.json",
|
|
31
|
+
"~/.codex/auth.json",
|
|
32
|
+
"~/.codex/config.toml",
|
|
33
|
+
"~/.codex/AGENTS.md",
|
|
34
|
+
"~/.codex/hooks.json",
|
|
35
|
+
"~/.codex/rules",
|
|
36
|
+
// Git + GitHub
|
|
37
|
+
"~/.gitconfig",
|
|
38
|
+
"~/.config/gh",
|
|
39
|
+
// Shell rc so PATH/aliases/env exports come along
|
|
40
|
+
"~/.zshrc",
|
|
41
|
+
"~/.zprofile",
|
|
42
|
+
"~/.bashrc",
|
|
43
|
+
"~/.bash_profile",
|
|
44
|
+
"~/.profile",
|
|
45
|
+
"~/.envrc",
|
|
46
|
+
// Package managers
|
|
47
|
+
"~/.npmrc",
|
|
48
|
+
"~/.yarnrc",
|
|
49
|
+
"~/.yarnrc.yml",
|
|
50
|
+
"~/.cargo/config",
|
|
51
|
+
"~/.cargo/config.toml",
|
|
52
|
+
// Cloud provider CLIs
|
|
53
|
+
"~/.vercel",
|
|
54
|
+
"~/.config/vercel",
|
|
55
|
+
"~/.aws/config",
|
|
56
|
+
"~/.aws/credentials",
|
|
57
|
+
"~/.config/gcloud/configurations",
|
|
58
|
+
"~/.config/gcloud/active_config",
|
|
59
|
+
"~/.config/gcloud/credentials.db",
|
|
60
|
+
"~/.config/gcloud/access_tokens.db",
|
|
61
|
+
"~/.config/gcloud/application_default_credentials.json",
|
|
62
|
+
"~/.kube/config",
|
|
63
|
+
// Netrc for tools that auth via netrc
|
|
64
|
+
"~/.netrc",
|
|
65
|
+
];
|
|
66
|
+
// Paths or path components that should never be uploaded under any
|
|
67
|
+
// circumstance — even if a user's DEFAULT_HOME_PATHS entry references them.
|
|
68
|
+
// Specifically leaving out .aws/.kube/.docker so the corresponding configs
|
|
69
|
+
// can ship; .ssh/.gnupg/keychains stay blocked because they contain
|
|
70
|
+
// private key material that isn't recoverable if leaked.
|
|
71
|
+
const SECRET_PATH_PARTS = new Set([
|
|
72
|
+
".ssh",
|
|
73
|
+
".gnupg",
|
|
74
|
+
"keychains",
|
|
75
|
+
]);
|
|
76
|
+
const BULKY_HOME_PATH_PARTS = new Set([
|
|
77
|
+
"archived_sessions",
|
|
78
|
+
"backups",
|
|
79
|
+
"cache",
|
|
80
|
+
"file-history",
|
|
81
|
+
"log",
|
|
82
|
+
"paste-cache",
|
|
83
|
+
"plugins",
|
|
84
|
+
"projects",
|
|
85
|
+
"session",
|
|
86
|
+
"sessions",
|
|
87
|
+
"shell_snapshots",
|
|
88
|
+
"skills",
|
|
89
|
+
"telemetry",
|
|
90
|
+
"todos",
|
|
91
|
+
"workspaces",
|
|
92
|
+
]);
|
|
93
|
+
const SECRET_BASENAMES = new Set([
|
|
94
|
+
".env",
|
|
95
|
+
".env.local",
|
|
96
|
+
".env.production",
|
|
97
|
+
".env.development",
|
|
98
|
+
"id_rsa",
|
|
99
|
+
"id_ed25519",
|
|
100
|
+
"known_hosts",
|
|
101
|
+
]);
|
|
102
|
+
const BULKY_HOME_BASENAME_PATTERNS = [
|
|
103
|
+
/^history\./,
|
|
104
|
+
/^logs?_/,
|
|
105
|
+
/^state_\d+\.sqlite/,
|
|
106
|
+
/\.sqlite(?:-(?:wal|shm))?$/,
|
|
107
|
+
/\.log$/,
|
|
108
|
+
/\.jsonl$/,
|
|
109
|
+
];
|
|
110
|
+
export async function runCloudCommand(command, args, options = {}) {
|
|
111
|
+
// Hidden diagnostic flag: measure keystroke round-trip latency instead of
|
|
112
|
+
// starting an interactive attach. Parsed here because main.ts forwards
|
|
113
|
+
// unknown flags through as positional args.
|
|
114
|
+
if (args.includes("--latency-probe")) {
|
|
115
|
+
args = args.filter((arg) => arg !== "--latency-probe");
|
|
116
|
+
options = { ...options, latencyProbe: true };
|
|
117
|
+
}
|
|
118
|
+
const subcommand = args[0] ?? "";
|
|
119
|
+
const rest = args.slice(1);
|
|
120
|
+
if (command === "cloud" && subcommand === "help") {
|
|
121
|
+
printCloudHelp();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// `rudder cloud` with no further args means "open the cloud workspace for
|
|
125
|
+
// this repo". Explicit subcommands (sail, launch, etc.) keep their old
|
|
126
|
+
// behavior. `rudder sail` and `rudder cloud sail` still launch a sail.
|
|
127
|
+
if (command === "cloud" && (subcommand === "" || subcommand === "--snapshot")) {
|
|
128
|
+
await openOnHome(subcommand === "--snapshot", options);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
switch (subcommand) {
|
|
132
|
+
case "login":
|
|
133
|
+
await login(options, rest);
|
|
134
|
+
return;
|
|
135
|
+
case "home":
|
|
136
|
+
await homeCommand(rest, options);
|
|
137
|
+
return;
|
|
138
|
+
case "doctor":
|
|
139
|
+
await cloudDoctor(options);
|
|
140
|
+
return;
|
|
141
|
+
case "sync":
|
|
142
|
+
await cloudSync(rest, options);
|
|
143
|
+
return;
|
|
144
|
+
case "app":
|
|
145
|
+
case "pair":
|
|
146
|
+
await printAppLink(options);
|
|
147
|
+
return;
|
|
148
|
+
case "launch":
|
|
149
|
+
await launch(rest, options, "task");
|
|
150
|
+
return;
|
|
151
|
+
case "sail":
|
|
152
|
+
await launch(rest, options);
|
|
153
|
+
return;
|
|
154
|
+
case "byoc":
|
|
155
|
+
await setupByoc(rest, options);
|
|
156
|
+
return;
|
|
157
|
+
case "vm":
|
|
158
|
+
case "byo-vm":
|
|
159
|
+
await launch(rest, options, "task", "byo-vm");
|
|
160
|
+
return;
|
|
161
|
+
case "list":
|
|
162
|
+
case "ls":
|
|
163
|
+
await listSails(options);
|
|
164
|
+
return;
|
|
165
|
+
case "status":
|
|
166
|
+
await status(options);
|
|
167
|
+
return;
|
|
168
|
+
case "logs":
|
|
169
|
+
await logs(rest, options);
|
|
170
|
+
return;
|
|
171
|
+
case "attach":
|
|
172
|
+
await attach(rest, options);
|
|
173
|
+
return;
|
|
174
|
+
case "talk":
|
|
175
|
+
case "say":
|
|
176
|
+
case "msg":
|
|
177
|
+
await talk(rest, options);
|
|
178
|
+
return;
|
|
179
|
+
case "output":
|
|
180
|
+
case "tail":
|
|
181
|
+
await tailOutput(rest, options);
|
|
182
|
+
return;
|
|
183
|
+
case "quickstart":
|
|
184
|
+
await quickstart(options);
|
|
185
|
+
return;
|
|
186
|
+
case "slack":
|
|
187
|
+
await slackSetup(rest, options);
|
|
188
|
+
return;
|
|
189
|
+
case "workspace":
|
|
190
|
+
await workspaceCommand(rest, options);
|
|
191
|
+
return;
|
|
192
|
+
case "onload":
|
|
193
|
+
await onload(rest, options);
|
|
194
|
+
return;
|
|
195
|
+
case "move":
|
|
196
|
+
await moveCommand(rest, options);
|
|
197
|
+
return;
|
|
198
|
+
case "bootstrap":
|
|
199
|
+
await bootstrap(rest, options);
|
|
200
|
+
return;
|
|
201
|
+
case "pause":
|
|
202
|
+
await mutateSail("pause", rest, options);
|
|
203
|
+
return;
|
|
204
|
+
case "resume":
|
|
205
|
+
await mutateSail("resume", rest, options);
|
|
206
|
+
return;
|
|
207
|
+
case "stop":
|
|
208
|
+
await mutateSail("stop", rest, options);
|
|
209
|
+
return;
|
|
210
|
+
case "delete":
|
|
211
|
+
case "rm":
|
|
212
|
+
await deleteSail(rest, options);
|
|
213
|
+
return;
|
|
214
|
+
case "setup-github":
|
|
215
|
+
await setupOAuthProvider("github", rest, options);
|
|
216
|
+
return;
|
|
217
|
+
case "setup-google":
|
|
218
|
+
await setupOAuthProvider("google", rest, options);
|
|
219
|
+
return;
|
|
220
|
+
case "setup-byoc":
|
|
221
|
+
await setupByoc(rest, options);
|
|
222
|
+
return;
|
|
223
|
+
case "setup-vm":
|
|
224
|
+
await setupByoc(rest, options);
|
|
225
|
+
return;
|
|
226
|
+
case "setup-fly":
|
|
227
|
+
await configureDefaultRuntime("fly", options);
|
|
228
|
+
return;
|
|
229
|
+
case "setup":
|
|
230
|
+
if (rest[0] === "byoc" || rest[0] === "vm" || rest[0] === "byo-vm") {
|
|
231
|
+
await setupByoc(rest.slice(1), options);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (rest[0] === "fly") {
|
|
235
|
+
await configureDefaultRuntime("fly", options);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
throw new Error("Usage: rudder cloud setup byoc | rudder cloud setup fly");
|
|
239
|
+
case "runtime":
|
|
240
|
+
await runtime(rest, options);
|
|
241
|
+
return;
|
|
242
|
+
case "region":
|
|
243
|
+
await configureRegion(rest, options);
|
|
244
|
+
return;
|
|
245
|
+
case "secrets":
|
|
246
|
+
await secretsCommand(rest, options);
|
|
247
|
+
return;
|
|
248
|
+
default:
|
|
249
|
+
// A bare `rudder cloud "<text>"` / `rudder sail "<text>"` is the documented
|
|
250
|
+
// way to launch a worker ON that task (the instance name is derived from it).
|
|
251
|
+
// Use task mode so the worker actually RUNS the task instead of opening an
|
|
252
|
+
// idle dashboard. Explicit `rudder cloud sail <name>` keeps name-only mode.
|
|
253
|
+
await launch(command === "sail" ? args : [subcommand, ...rest], options, "task");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function login(options, args = []) {
|
|
258
|
+
const client = await cloudClient({ requireToken: false });
|
|
259
|
+
// Google is the default sign-in: the browser opens straight on Google's
|
|
260
|
+
// account picker instead of a page of buttons. `--github` picks GitHub.
|
|
261
|
+
const provider = args.includes("--github") ? "github" : "google";
|
|
262
|
+
const browserLogin = await tryBrowserLogin(client, options, provider).catch((error) => {
|
|
263
|
+
// A no on the phone is an answer: never sign in some other way behind it.
|
|
264
|
+
if (error instanceof LoginDeniedError) {
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
if (!options.json) {
|
|
268
|
+
console.warn(`Browser login unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
269
|
+
console.warn("Trying local GitHub auth fallback...");
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
});
|
|
273
|
+
if (browserLogin?.token || browserLogin?.accessToken) {
|
|
274
|
+
const token = browserLogin.token ?? browserLogin.accessToken;
|
|
275
|
+
if (token) {
|
|
276
|
+
await saveCloudLogin(client, browserLogin, token, options, browserLogin.via ?? "browser");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const githubLogin = await tryGithubCliLogin(client).catch(() => null);
|
|
281
|
+
if (githubLogin?.token || githubLogin?.accessToken) {
|
|
282
|
+
const token = githubLogin.token ?? githubLogin.accessToken;
|
|
283
|
+
if (token) {
|
|
284
|
+
await saveCloudLogin(client, githubLogin, token, options, "GitHub CLI");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const githubDeviceLogin = await tryGithubDeviceLogin(client, options).catch(() => null);
|
|
289
|
+
if (githubDeviceLogin?.token || githubDeviceLogin?.accessToken) {
|
|
290
|
+
const token = githubDeviceLogin.token ?? githubDeviceLogin.accessToken;
|
|
291
|
+
if (token) {
|
|
292
|
+
await saveCloudLogin(client, githubDeviceLogin, token, options, "GitHub device");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function tryBrowserLogin(client, options, provider = "google") {
|
|
298
|
+
const response = await client.request("/api/cli/login", {
|
|
299
|
+
method: "POST",
|
|
300
|
+
body: {
|
|
301
|
+
deviceName: os.hostname(),
|
|
302
|
+
client: "rudder",
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const deviceCode = response.deviceCode;
|
|
306
|
+
const loginUrl = deviceCode
|
|
307
|
+
? withQuery(client.baseUrl, `/cli/oauth/${provider}/start`, { device_code: deviceCode })
|
|
308
|
+
: response.loginUrl ?? response.verificationUri ?? withQuery(client.baseUrl, "/cli/login", {});
|
|
309
|
+
const pollPath = response.pollUrl ?? "/api/cli/login/poll";
|
|
310
|
+
const intervalMs = Math.max(1000, (response.interval ?? DEFAULT_LOGIN_INTERVAL_MS / 1000) * 1000);
|
|
311
|
+
const timeoutMs = Math.max(intervalMs, (response.expiresIn ?? DEFAULT_LOGIN_TIMEOUT_MS / 1000) * 1000);
|
|
312
|
+
// A phone already signed in to Rudder can approve this computer: show the
|
|
313
|
+
// code and a QR of the approve page, and open the browser only on Enter.
|
|
314
|
+
// Without a terminal to press Enter in, the browser opens as before.
|
|
315
|
+
const interactive = !options.json && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
316
|
+
let browserOpened = false;
|
|
317
|
+
const openLoginBrowser = () => {
|
|
318
|
+
if (browserOpened) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
browserOpened = true;
|
|
322
|
+
console.log(`Opening ${loginUrl}`);
|
|
323
|
+
if (!options.json) {
|
|
324
|
+
openBrowser(loginUrl);
|
|
325
|
+
}
|
|
326
|
+
console.log("Waiting for browser login to complete...");
|
|
327
|
+
};
|
|
328
|
+
let enterListener = null;
|
|
329
|
+
if (response.userCode && response.approveUrl && !options.json) {
|
|
330
|
+
console.log("");
|
|
331
|
+
console.log("Sign this computer in from your phone: open Rudder, tap \"Sign in a computer\" and enter");
|
|
332
|
+
console.log("");
|
|
333
|
+
console.log(` ${response.userCode}`);
|
|
334
|
+
console.log("");
|
|
335
|
+
if (process.stdout.isTTY) {
|
|
336
|
+
try {
|
|
337
|
+
console.log("or scan this with the phone's camera:");
|
|
338
|
+
console.log(renderQrForTerminal(response.approveUrl, { indent: " " }));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// A url too long for the encoder: the code above still works.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
console.log(`(${response.approveUrl})`);
|
|
345
|
+
console.log("");
|
|
346
|
+
if (interactive) {
|
|
347
|
+
console.log("Press Enter to sign in in the browser instead.");
|
|
348
|
+
const readline = await import("node:readline");
|
|
349
|
+
enterListener = readline.createInterface({ input: process.stdin });
|
|
350
|
+
enterListener.on("line", openLoginBrowser);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
openLoginBrowser();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
openLoginBrowser();
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const startedAt = Date.now();
|
|
361
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
362
|
+
await sleep(intervalMs);
|
|
363
|
+
const poll = await pollLogin(client, pollPath, deviceCode);
|
|
364
|
+
const token = poll.token ?? poll.accessToken;
|
|
365
|
+
if (token) {
|
|
366
|
+
return { ...poll, via: poll.via === "phone" ? "your phone" : "browser" };
|
|
367
|
+
}
|
|
368
|
+
if (poll.denied) {
|
|
369
|
+
throw new LoginDeniedError();
|
|
370
|
+
}
|
|
371
|
+
if (poll.pending === false) {
|
|
372
|
+
throw new Error("Cloud login was not approved.");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
throw new Error("Timed out waiting for cloud login.");
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
enterListener?.close();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
class LoginDeniedError extends Error {
|
|
382
|
+
constructor() {
|
|
383
|
+
super("The sign-in was denied on your phone. Run `rudder login` again to retry.");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function tryGithubCliLogin(client) {
|
|
387
|
+
if (process.env.RUDDER_SKIP_GH_CLI === "1") {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const gh = await runCommand("gh", ["auth", "token"], { allowFailure: true });
|
|
391
|
+
const token = gh.stdout.trim();
|
|
392
|
+
if (gh.code !== 0 || !token) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
return await client.request("/api/cli/login/github-token", {
|
|
396
|
+
method: "POST",
|
|
397
|
+
body: { token },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async function tryGithubDeviceLogin(client, options) {
|
|
401
|
+
const start = await githubOAuthRequest("https://github.com/login/device/code", {
|
|
402
|
+
client_id: GITHUB_CLI_CLIENT_ID,
|
|
403
|
+
scope: "read:user user:email",
|
|
404
|
+
});
|
|
405
|
+
if (!start.device_code || !start.user_code || !start.verification_uri) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
if (!options.json) {
|
|
409
|
+
const url = start.verification_uri_complete ?? start.verification_uri;
|
|
410
|
+
console.log(`Opening ${url}`);
|
|
411
|
+
console.log(`GitHub code: ${start.user_code}`);
|
|
412
|
+
openBrowser(url);
|
|
413
|
+
}
|
|
414
|
+
const intervalMs = Math.max(1000, (start.interval ?? 5) * 1000);
|
|
415
|
+
const timeoutMs = Math.max(intervalMs, (start.expires_in ?? 900) * 1000);
|
|
416
|
+
const startedAt = Date.now();
|
|
417
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
418
|
+
await sleep(intervalMs);
|
|
419
|
+
const poll = await githubOAuthRequest("https://github.com/login/oauth/access_token", {
|
|
420
|
+
client_id: GITHUB_CLI_CLIENT_ID,
|
|
421
|
+
device_code: start.device_code,
|
|
422
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
423
|
+
});
|
|
424
|
+
if (poll.access_token) {
|
|
425
|
+
return await client.request("/api/cli/login/github-token", {
|
|
426
|
+
method: "POST",
|
|
427
|
+
body: { token: poll.access_token },
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (poll.error === "authorization_pending") {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (poll.error === "slow_down") {
|
|
434
|
+
await sleep(Math.max(intervalMs, (poll.interval ?? 5) * 1000));
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
async function githubOAuthRequest(url, body) {
|
|
442
|
+
const response = await fetch(url, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: {
|
|
445
|
+
Accept: "application/json",
|
|
446
|
+
"Content-Type": "application/json",
|
|
447
|
+
},
|
|
448
|
+
body: JSON.stringify(body),
|
|
449
|
+
});
|
|
450
|
+
const text = await response.text();
|
|
451
|
+
const parsed = text ? parseJson(text) : null;
|
|
452
|
+
if (!response.ok) {
|
|
453
|
+
throw new Error(responseErrorMessage(parsed) || text.trim() || `${response.status} ${response.statusText}`);
|
|
454
|
+
}
|
|
455
|
+
return parsed;
|
|
456
|
+
}
|
|
457
|
+
async function saveCloudLogin(client, login, token, options, source) {
|
|
458
|
+
const previous = await loadCloudAuth();
|
|
459
|
+
const previousRuntime = previous?.cloudUrl === client.baseUrl ? parseCloudRuntime(previous.defaultRuntime) : undefined;
|
|
460
|
+
const previousByocHost = previous?.cloudUrl === client.baseUrl ? previous.byocSshHost : undefined;
|
|
461
|
+
await saveCloudAuth({
|
|
462
|
+
version: 1,
|
|
463
|
+
token,
|
|
464
|
+
cloudUrl: client.baseUrl,
|
|
465
|
+
defaultRuntime: previousRuntime,
|
|
466
|
+
byocSshHost: previousByocHost,
|
|
467
|
+
accountId: login.accountId,
|
|
468
|
+
email: login.email,
|
|
469
|
+
expiresAt: login.expiresAt ?? (login.expiresIn ? new Date(Date.now() + login.expiresIn * 1000).toISOString() : undefined),
|
|
470
|
+
updatedAt: nowIso(),
|
|
471
|
+
});
|
|
472
|
+
if (options.json) {
|
|
473
|
+
const result = { ok: true, cloudUrl: client.baseUrl, source };
|
|
474
|
+
if (login.email) {
|
|
475
|
+
result.email = login.email;
|
|
476
|
+
}
|
|
477
|
+
if (login.accountId) {
|
|
478
|
+
result.accountId = login.accountId;
|
|
479
|
+
}
|
|
480
|
+
printJson(result);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
console.log(`Logged in to ${client.baseUrl}${login.email ? ` as ${login.email}` : ""} via ${source}.`);
|
|
484
|
+
// First run on this computer: agent CLIs, then what is in the cloud.
|
|
485
|
+
await runLaptopOnboarding({ json: options.json, cloudRepos: cloudRepoSummaries });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** The repos open on this account's cloud machine, for onboarding; null when signed out or unreachable. */
|
|
489
|
+
export async function cloudRepoSummaries() {
|
|
490
|
+
try {
|
|
491
|
+
const client = await cloudClient({ requireToken: true });
|
|
492
|
+
const view = await client.request("/api/rudder/home", { method: "GET" });
|
|
493
|
+
return (view.projects ?? [])
|
|
494
|
+
.filter((project) => typeof project.repo === "string" && project.repo)
|
|
495
|
+
.map((project) => ({ repo: String(project.repo), agents: Array.isArray(project.agents) ? project.agents.length : 0 }));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async function launch(args, options, mode = "name", explicitRuntime) {
|
|
502
|
+
const raw = args.join(" ").trim();
|
|
503
|
+
const repoRoot = findRepoRoot();
|
|
504
|
+
const snapshot = await createSnapshot(repoRoot, options.homePaths ?? []);
|
|
505
|
+
try {
|
|
506
|
+
const client = await cloudClient({ requireToken: true });
|
|
507
|
+
const runtime = await selectedCloudRuntime(explicitRuntime);
|
|
508
|
+
const task = mode === "task" || runtime === "byo-vm" ? raw : "";
|
|
509
|
+
const launchName = process.env.RUDDER_CLOUD_LAUNCH_NAME?.trim();
|
|
510
|
+
const name = launchName || (task ? cloudNameFromTask(task) : raw || randomCloudName());
|
|
511
|
+
const body = {
|
|
512
|
+
repoName: path.basename(repoRoot),
|
|
513
|
+
name,
|
|
514
|
+
snapshot: {
|
|
515
|
+
name: path.basename(snapshot.archivePath),
|
|
516
|
+
contentType: "application/gzip",
|
|
517
|
+
base64: await fsp.readFile(snapshot.archivePath, "base64"),
|
|
518
|
+
manifest: snapshot.manifest,
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
if (runtime !== "fly") {
|
|
522
|
+
body.runtime = runtime;
|
|
523
|
+
}
|
|
524
|
+
if (task) {
|
|
525
|
+
body.task = task;
|
|
526
|
+
}
|
|
527
|
+
const result = await client.request("/api/rudder/sail/launch", {
|
|
528
|
+
method: "POST",
|
|
529
|
+
body,
|
|
530
|
+
});
|
|
531
|
+
await printResult(result, options);
|
|
532
|
+
await maybeAutoAttach(result, options);
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
await fsp.rm(snapshot.tempDir, { recursive: true, force: true });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function onload(args, options) {
|
|
539
|
+
const runId = args[0];
|
|
540
|
+
const repoRoot = findRepoRoot();
|
|
541
|
+
if (runId) {
|
|
542
|
+
await handoffRunToCloud(repoRoot, runId, options);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const snapshot = await createSnapshot(repoRoot, options.homePaths ?? [], { includeRudderState: true });
|
|
546
|
+
try {
|
|
547
|
+
const client = await cloudClient({ requireToken: true });
|
|
548
|
+
const runtime = await selectedCloudRuntime();
|
|
549
|
+
const body = {
|
|
550
|
+
repoName: path.basename(repoRoot),
|
|
551
|
+
workspace: true,
|
|
552
|
+
name: `workspace-${path.basename(repoRoot)}`,
|
|
553
|
+
snapshot: {
|
|
554
|
+
name: path.basename(snapshot.archivePath),
|
|
555
|
+
contentType: "application/gzip",
|
|
556
|
+
base64: await fsp.readFile(snapshot.archivePath, "base64"),
|
|
557
|
+
manifest: snapshot.manifest,
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
if (runtime !== "fly") {
|
|
561
|
+
body.runtime = runtime;
|
|
562
|
+
}
|
|
563
|
+
const result = await client.request("/api/rudder/sail/onload", {
|
|
564
|
+
method: "POST",
|
|
565
|
+
body,
|
|
566
|
+
});
|
|
567
|
+
await printResult(result, options);
|
|
568
|
+
await maybeAutoAttach(result, options);
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
await fsp.rm(snapshot.tempDir, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Scope a fleet migration plan down to one run: only `runId` moves, every
|
|
576
|
+
* other candidate stays local. Fleet migration classifies EVERY non-terminal
|
|
577
|
+
* run with a workspace as movable, so without this a single-run handoff would
|
|
578
|
+
* drag the rest of the fleet's records into the snapshot marked "migrate" —
|
|
579
|
+
* and the cloud dashboard would resume agents that are still running locally.
|
|
580
|
+
*/
|
|
581
|
+
export function buildSingleRunPlan(candidates, runId) {
|
|
582
|
+
return applyDefaultDecisions(candidates.map((candidate) => candidate.runId === runId ? candidate : { ...candidate, decision: "stay" }));
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* `rudder cloud onload <runId>` — hand ONE local agent off to its own cloud
|
|
586
|
+
* machine. The snapshot carries the repo, that run's record (and only that
|
|
587
|
+
* run's), its isolated workspace, and its Claude session jsonl. The worker
|
|
588
|
+
* boots the bare dashboard (no task ⇒ no fresh worker), which finds the
|
|
589
|
+
* migration marker and resumes the conversation via `claude --resume` — the
|
|
590
|
+
* same machinery as fleet migration, scoped to a single run.
|
|
591
|
+
*/
|
|
592
|
+
async function handoffRunToCloud(repoRoot, runId, options) {
|
|
593
|
+
const candidates = await findMigrationCandidates(repoRoot);
|
|
594
|
+
const candidate = candidates.find((entry) => entry.runId === runId);
|
|
595
|
+
if (!candidate) {
|
|
596
|
+
throw new Error(`Run ${runId} has no migratable record under .rudder/runs — it needs a non-terminal status and an isolated workspace.`);
|
|
597
|
+
}
|
|
598
|
+
if (candidate.decision !== "migrate" && candidate.decision !== "migrate-fresh") {
|
|
599
|
+
throw new Error(`Run ${runId} cannot be handed off: ${formatCandidateReason(candidate)}.`);
|
|
600
|
+
}
|
|
601
|
+
const plan = buildSingleRunPlan(candidates, runId);
|
|
602
|
+
const repoName = path.basename(repoRoot);
|
|
603
|
+
const client = await cloudClient({ requireToken: true });
|
|
604
|
+
const vaultActive = await accountHasVaultSecrets(client);
|
|
605
|
+
if (!options.json) {
|
|
606
|
+
const how = candidate.decision === "migrate"
|
|
607
|
+
? `resuming session ${candidate.sessionId}`
|
|
608
|
+
: `fresh restart with a context handoff (${formatCandidateReason(candidate)})`;
|
|
609
|
+
process.stderr.write(`Handing off ${runId} to Rudder Cloud — ${how}...\n`);
|
|
610
|
+
}
|
|
611
|
+
const snapshot = await createSnapshot(repoRoot, options.homePaths ?? [], {
|
|
612
|
+
includeCredentials: !vaultActive,
|
|
613
|
+
migration: { repoName, plan },
|
|
614
|
+
rudderStateRunIds: [runId],
|
|
615
|
+
});
|
|
616
|
+
try {
|
|
617
|
+
const runtime = await selectedCloudRuntime();
|
|
618
|
+
const body = {
|
|
619
|
+
repoName,
|
|
620
|
+
runId,
|
|
621
|
+
snapshot: {
|
|
622
|
+
name: path.basename(snapshot.archivePath),
|
|
623
|
+
contentType: "application/gzip",
|
|
624
|
+
base64: await fsp.readFile(snapshot.archivePath, "base64"),
|
|
625
|
+
manifest: snapshot.manifest,
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
if (runtime !== "fly") {
|
|
629
|
+
body.runtime = runtime;
|
|
630
|
+
}
|
|
631
|
+
const result = await client.request("/api/rudder/sail/onload", {
|
|
632
|
+
method: "POST",
|
|
633
|
+
body,
|
|
634
|
+
});
|
|
635
|
+
await printResult(result, options);
|
|
636
|
+
// The sail owns the conversation now. The dashboard watches this record
|
|
637
|
+
// for exactly that ("migrated") instead of assuming it at spawn time.
|
|
638
|
+
await markRunsMigrated(repoRoot, [runId]);
|
|
639
|
+
await maybeAutoAttach(result, options);
|
|
640
|
+
// The handoff SUCCEEDED the moment the sail launched: the cloud machine
|
|
641
|
+
// owns the conversation now. An attach that drops afterwards (network
|
|
642
|
+
// blip, remote quit, machine still staging) must not fail this process —
|
|
643
|
+
// the dashboard reads a non-zero exit as "handoff failed" and resumes the
|
|
644
|
+
// agent LOCALLY, which would run a second copy of a conversation that is
|
|
645
|
+
// alive in the cloud.
|
|
646
|
+
process.exitCode = 0;
|
|
647
|
+
if (!options.json) {
|
|
648
|
+
process.stderr.write(`Handed off. Reattach any time: rudder cloud attach ${runId}\n`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
await fsp.rm(snapshot.tempDir, { recursive: true, force: true });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async function logs(args, options) {
|
|
656
|
+
const workerId = args[0];
|
|
657
|
+
if (!workerId) {
|
|
658
|
+
throw new Error("Usage: rudder cloud logs <id>");
|
|
659
|
+
}
|
|
660
|
+
const client = await cloudClient({ requireToken: true });
|
|
661
|
+
const [sailResult, workspaceResult] = await Promise.all([
|
|
662
|
+
client.request("/api/rudder/sail", { method: "GET" }),
|
|
663
|
+
client.request("/api/rudder/workspace", { method: "GET" }),
|
|
664
|
+
]);
|
|
665
|
+
const sails = collectionFromResult(sailResult, "sails");
|
|
666
|
+
const workspaces = collectionFromResult(workspaceResult, "workspaces");
|
|
667
|
+
const match = [...sails, ...workspaces].find((item) => item && typeof item === "object" && !Array.isArray(item) && item.id === workerId);
|
|
668
|
+
if (!match) {
|
|
669
|
+
throw new Error(`Cloud worker not found: ${workerId}`);
|
|
670
|
+
}
|
|
671
|
+
if (options.json) {
|
|
672
|
+
printJson(match);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
console.log("Cloud log streaming is not available yet.");
|
|
676
|
+
console.log("Worker status:");
|
|
677
|
+
printSailList([match]);
|
|
678
|
+
}
|
|
679
|
+
function collectionFromResult(result, key) {
|
|
680
|
+
if (Array.isArray(result)) {
|
|
681
|
+
return result;
|
|
682
|
+
}
|
|
683
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
684
|
+
const collection = result[key];
|
|
685
|
+
return Array.isArray(collection) ? collection : [];
|
|
686
|
+
}
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
async function listSails(options) {
|
|
690
|
+
const client = await cloudClient({ requireToken: true });
|
|
691
|
+
const result = await client.request("/api/rudder/sail", { method: "GET" });
|
|
692
|
+
await printResult(result, options);
|
|
693
|
+
}
|
|
694
|
+
async function status(options) {
|
|
695
|
+
const client = await cloudClient({ requireToken: true });
|
|
696
|
+
const state = await loadCloudAuth();
|
|
697
|
+
const runtime = await selectedCloudRuntime();
|
|
698
|
+
const sails = await client.request("/api/rudder/sail", { method: "GET" });
|
|
699
|
+
const sailRows = Array.isArray(sails)
|
|
700
|
+
? sails
|
|
701
|
+
: sails && typeof sails === "object" && !Array.isArray(sails) && Array.isArray(sails.sails)
|
|
702
|
+
? sails.sails
|
|
703
|
+
: [];
|
|
704
|
+
const sailCount = sailRows.length;
|
|
705
|
+
const result = {
|
|
706
|
+
ok: true,
|
|
707
|
+
cloudUrl: client.baseUrl,
|
|
708
|
+
runtime,
|
|
709
|
+
sails: sailCount,
|
|
710
|
+
};
|
|
711
|
+
if (state?.cloudUrl === client.baseUrl) {
|
|
712
|
+
if (state.email) {
|
|
713
|
+
result.email = state.email;
|
|
714
|
+
}
|
|
715
|
+
if (state.accountId) {
|
|
716
|
+
result.accountId = state.accountId;
|
|
717
|
+
}
|
|
718
|
+
if (state.byocSshHost) {
|
|
719
|
+
result.byocSshHost = state.byocSshHost;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (options.json) {
|
|
723
|
+
printJson(result);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
console.log(`Logged in to ${client.baseUrl}${state?.email ? ` as ${state.email}` : ""}.`);
|
|
727
|
+
console.log(`Runtime: ${runtime}`);
|
|
728
|
+
if (state?.byocSshHost) {
|
|
729
|
+
console.log(`BYOC SSH host: ${state.byocSshHost}`);
|
|
730
|
+
}
|
|
731
|
+
console.log(`Cloud workers: ${sailCount}`);
|
|
732
|
+
}
|
|
733
|
+
async function bootstrap(args, options) {
|
|
734
|
+
const sailId = args[0];
|
|
735
|
+
if (!sailId) {
|
|
736
|
+
throw new Error("Missing sail id. Usage: rudder cloud bootstrap <id>");
|
|
737
|
+
}
|
|
738
|
+
const client = await cloudClient({ requireToken: true });
|
|
739
|
+
const result = await client.request(`/api/rudder/sail/${encodeURIComponent(sailId)}/bootstrap`, {
|
|
740
|
+
method: "POST",
|
|
741
|
+
body: {},
|
|
742
|
+
});
|
|
743
|
+
await printResult(result, options);
|
|
744
|
+
}
|
|
745
|
+
async function mutateSail(action, args, options) {
|
|
746
|
+
const sailId = args[0];
|
|
747
|
+
if (!sailId) {
|
|
748
|
+
throw new Error(`Missing sail id. Usage: rudder sail ${action} <id>`);
|
|
749
|
+
}
|
|
750
|
+
const client = await cloudClient({ requireToken: true });
|
|
751
|
+
let result;
|
|
752
|
+
try {
|
|
753
|
+
result = await client.request(`/api/rudder/sail/${encodeURIComponent(sailId)}/${action}`, {
|
|
754
|
+
method: "POST",
|
|
755
|
+
body: args.length > 1 ? { args: args.slice(1) } : {},
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
if (action !== "onload" && isSailNotFound(error)) {
|
|
760
|
+
await workspaceMutate(action, args, options);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
await printResult(result, options);
|
|
766
|
+
}
|
|
767
|
+
function isSailNotFound(error) {
|
|
768
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
769
|
+
return /sail not found/i.test(message);
|
|
770
|
+
}
|
|
771
|
+
async function talk(args, options) {
|
|
772
|
+
const sailId = args[0];
|
|
773
|
+
const message = args.slice(1).join(" ").trim();
|
|
774
|
+
if (!sailId || !message) {
|
|
775
|
+
throw new Error('Usage: rudder cloud talk <id> "<message>"');
|
|
776
|
+
}
|
|
777
|
+
const client = await cloudClient({ requireToken: true });
|
|
778
|
+
const result = await client.request(`/api/rudder/sail/${encodeURIComponent(sailId)}/input`, { method: "POST", body: { text: message } });
|
|
779
|
+
if (options.json) {
|
|
780
|
+
printJson(result);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const delivered = result && typeof result === "object" && !Array.isArray(result)
|
|
784
|
+
? result.delivered === true
|
|
785
|
+
: false;
|
|
786
|
+
if (!delivered) {
|
|
787
|
+
console.log(`Could not reach ${sailId}: it is not connected right now.`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
console.log(`→ ${sailId}: ${message}`);
|
|
791
|
+
// Give the agent a moment, then show what it said back.
|
|
792
|
+
await sleep(3500);
|
|
793
|
+
await tailOutput([sailId], options);
|
|
794
|
+
}
|
|
795
|
+
async function tailOutput(args, options) {
|
|
796
|
+
const sailId = args[0];
|
|
797
|
+
if (!sailId) {
|
|
798
|
+
throw new Error("Usage: rudder cloud output <id>");
|
|
799
|
+
}
|
|
800
|
+
const client = await cloudClient({ requireToken: true });
|
|
801
|
+
const result = await client.request(`/api/rudder/sail/${encodeURIComponent(sailId)}/output`, { method: "GET" });
|
|
802
|
+
if (options.json) {
|
|
803
|
+
printJson(result);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const output = typeof result.output === "string" ? result.output : "";
|
|
807
|
+
const cleaned = stripAnsiForCli(output).trimEnd();
|
|
808
|
+
const lines = cleaned ? cleaned.split("\n").slice(-30).join("\n") : "(no output yet)";
|
|
809
|
+
console.log(lines);
|
|
810
|
+
}
|
|
811
|
+
function stripAnsiForCli(text) {
|
|
812
|
+
// eslint-disable-next-line no-control-regex
|
|
813
|
+
return text
|
|
814
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
815
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
816
|
+
.replace(/\x1b[@-Z\\-_=>()#][0-9;]*/g, "")
|
|
817
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
818
|
+
}
|
|
819
|
+
async function quickstart(options) {
|
|
820
|
+
const url = normalizeCloudUrl(process.env.RUDDER_CLOUD_URL) || DEFAULT_CLOUD_URL;
|
|
821
|
+
if (options.json) {
|
|
822
|
+
printJson({ cloudUrl: url, steps: ["install", "login", "launch", "talk", "slack"] });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const lines = [
|
|
826
|
+
"Rudder Cloud — copy/paste quickstart",
|
|
827
|
+
"",
|
|
828
|
+
"1. Install Rudder:",
|
|
829
|
+
" npm install -g @exla/rudder@latest",
|
|
830
|
+
"",
|
|
831
|
+
"2. Log in to the cloud control plane:",
|
|
832
|
+
" rudder login",
|
|
833
|
+
"",
|
|
834
|
+
"3. Launch a fresh cloud instance on any task (run inside a repo):",
|
|
835
|
+
' rudder cloud "fix the failing tests"',
|
|
836
|
+
"",
|
|
837
|
+
"4. See your instances and talk to any of them:",
|
|
838
|
+
" rudder cloud list",
|
|
839
|
+
' rudder cloud talk <id> "what are you working on?"',
|
|
840
|
+
" rudder cloud output <id>",
|
|
841
|
+
" rudder cloud attach <id> # full interactive terminal",
|
|
842
|
+
"",
|
|
843
|
+
"5. Talk to every instance from Slack (one thread per instance):",
|
|
844
|
+
" rudder cloud slack # prints the Slack setup",
|
|
845
|
+
"",
|
|
846
|
+
`Control plane: ${url}`,
|
|
847
|
+
];
|
|
848
|
+
console.log(lines.join("\n"));
|
|
849
|
+
}
|
|
850
|
+
async function slackSetup(args, options) {
|
|
851
|
+
const url = normalizeCloudUrl(process.env.RUDDER_CLOUD_URL) || DEFAULT_CLOUD_URL;
|
|
852
|
+
const eventsUrl = `${url.replace(/\/$/, "")}/api/slack/events`;
|
|
853
|
+
const channel = process.env.RUDDER_SLACK_CHANNEL || "C0B78TDLM5G";
|
|
854
|
+
const manifest = {
|
|
855
|
+
display_information: { name: "Rudder Cloud" },
|
|
856
|
+
features: {
|
|
857
|
+
bot_user: { display_name: "rudder", always_online: true },
|
|
858
|
+
},
|
|
859
|
+
oauth_config: {
|
|
860
|
+
scopes: { bot: ["app_mentions:read", "channels:history", "groups:history", "chat:write"] },
|
|
861
|
+
},
|
|
862
|
+
settings: {
|
|
863
|
+
event_subscriptions: {
|
|
864
|
+
request_url: eventsUrl,
|
|
865
|
+
bot_events: ["app_mention", "message.channels", "message.groups"],
|
|
866
|
+
},
|
|
867
|
+
org_deploy_enabled: false,
|
|
868
|
+
socket_mode_enabled: false,
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
if (args[0] === "manifest") {
|
|
872
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (options.json) {
|
|
876
|
+
printJson({ eventsUrl, channel, manifest });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const lines = [
|
|
880
|
+
"Rudder Cloud → Slack setup",
|
|
881
|
+
"",
|
|
882
|
+
`Channel: ${channel}`,
|
|
883
|
+
`Events request URL: ${eventsUrl}`,
|
|
884
|
+
"",
|
|
885
|
+
"1. Create a Slack app from the manifest (api.slack.com/apps → Create New App → From a manifest):",
|
|
886
|
+
" rudder cloud slack manifest # prints the JSON manifest to paste",
|
|
887
|
+
"",
|
|
888
|
+
"2. Install the app to your workspace and invite it to the channel:",
|
|
889
|
+
` /invite @rudder (in ${channel})`,
|
|
890
|
+
"",
|
|
891
|
+
"3. Set these on the control plane (Fly secrets), then redeploy:",
|
|
892
|
+
" SLACK_BOT_TOKEN=xoxb-... # Bot User OAuth Token",
|
|
893
|
+
" SLACK_SIGNING_SECRET=... # Basic Information → Signing Secret",
|
|
894
|
+
` RUDDER_SLACK_CHANNEL=${channel}`,
|
|
895
|
+
"",
|
|
896
|
+
" flyctl secrets set SLACK_BOT_TOKEN=xoxb-... SLACK_SIGNING_SECRET=... \\",
|
|
897
|
+
` RUDDER_SLACK_CHANNEL=${channel} -a rudder-cloud-control`,
|
|
898
|
+
"",
|
|
899
|
+
"That's it. Every `rudder cloud \"<task>\"` now opens a thread in the channel;",
|
|
900
|
+
"reply in a thread to talk to that instance, or use `list` / `talk <id> <msg>`.",
|
|
901
|
+
];
|
|
902
|
+
console.log(lines.join("\n"));
|
|
903
|
+
}
|
|
904
|
+
async function setupOAuthProvider(provider, args, options) {
|
|
905
|
+
const envPrefix = provider === "github" ? "RUDDER_GITHUB" : "RUDDER_GOOGLE";
|
|
906
|
+
const clientId = args[0]?.trim() || process.env[`${envPrefix}_CLIENT_ID`]?.trim();
|
|
907
|
+
const clientSecret = process.env[`${envPrefix}_CLIENT_SECRET`]?.trim() ||
|
|
908
|
+
args[1]?.trim() ||
|
|
909
|
+
await promptSecret(`${provider === "github" ? "GitHub App" : "Google OAuth"} client secret`);
|
|
910
|
+
if (!clientId || !clientSecret) {
|
|
911
|
+
throw new Error([
|
|
912
|
+
`Missing ${provider === "github" ? "GitHub" : "Google"} OAuth credentials.`,
|
|
913
|
+
`Usage: rudder cloud setup-${provider} <client-id>`,
|
|
914
|
+
`Or set ${envPrefix}_CLIENT_ID and ${envPrefix}_CLIENT_SECRET.`,
|
|
915
|
+
].join("\n"));
|
|
916
|
+
}
|
|
917
|
+
const client = await cloudClient({ requireToken: true });
|
|
918
|
+
const result = await client.request(`/api/rudder/setup/${provider}`, {
|
|
919
|
+
method: "POST",
|
|
920
|
+
body: {
|
|
921
|
+
clientId,
|
|
922
|
+
clientSecret,
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
await printResult(result, options);
|
|
926
|
+
}
|
|
927
|
+
async function setupByoc(args, options) {
|
|
928
|
+
const sshConfigPath = path.join(os.homedir(), ".ssh", "config");
|
|
929
|
+
const configuredHosts = await listSshConfigHosts(sshConfigPath);
|
|
930
|
+
const host = (options.sshHost ?? args.join(" ").trim()) || await chooseByocHost(configuredHosts);
|
|
931
|
+
if (!host) {
|
|
932
|
+
throw new Error([
|
|
933
|
+
"Missing BYOC SSH host.",
|
|
934
|
+
"Add your workstation/server to ~/.ssh/config, then run:",
|
|
935
|
+
"",
|
|
936
|
+
" rudder cloud byoc <ssh-host>",
|
|
937
|
+
"",
|
|
938
|
+
"Example ~/.ssh/config:",
|
|
939
|
+
" Host rudder-workstation",
|
|
940
|
+
" HostName 203.0.113.10",
|
|
941
|
+
" User ubuntu",
|
|
942
|
+
" IdentityFile ~/.ssh/id_ed25519",
|
|
943
|
+
"",
|
|
944
|
+
configuredHosts.length
|
|
945
|
+
? `Detected SSH hosts: ${configuredHosts.slice(0, 12).join(", ")}`
|
|
946
|
+
: `No usable hosts found in ${shortenHome(sshConfigPath)}.`,
|
|
947
|
+
].join("\n"));
|
|
948
|
+
}
|
|
949
|
+
const configMentionsHost = configuredHosts.includes(host) || await sshConfigMentions(sshConfigPath, host);
|
|
950
|
+
const diagnostics = await checkByocHost(host);
|
|
951
|
+
const client = await cloudClient({ requireToken: true });
|
|
952
|
+
const state = await loadCloudAuth();
|
|
953
|
+
if (!state || state.cloudUrl !== client.baseUrl) {
|
|
954
|
+
throw new Error("Not logged in to this Rudder Cloud control plane. Run `rudder login` first.");
|
|
955
|
+
}
|
|
956
|
+
await saveCloudAuth({
|
|
957
|
+
...state,
|
|
958
|
+
defaultRuntime: state.defaultRuntime === "byo-vm" ? "fly" : state.defaultRuntime,
|
|
959
|
+
byocSshHost: host,
|
|
960
|
+
updatedAt: nowIso(),
|
|
961
|
+
});
|
|
962
|
+
if (options.json) {
|
|
963
|
+
const result = {
|
|
964
|
+
ok: true,
|
|
965
|
+
cloudUrl: client.baseUrl,
|
|
966
|
+
byocSshHost: host,
|
|
967
|
+
};
|
|
968
|
+
const defaultRuntime = state.defaultRuntime === "byo-vm" ? "fly" : state.defaultRuntime;
|
|
969
|
+
if (defaultRuntime) {
|
|
970
|
+
result.defaultRuntime = defaultRuntime;
|
|
971
|
+
}
|
|
972
|
+
printJson(result);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
console.log(`Rudder BYOC host set to ${host}.`);
|
|
976
|
+
console.log("Plain `rudder cloud` and dashboard `/cloud` continue to use Fly by default.");
|
|
977
|
+
console.log("Use `rudder cloud vm <task>` when you want to run a task on this BYOC host.");
|
|
978
|
+
if (!configMentionsHost) {
|
|
979
|
+
console.log(`\nNote: ${shortenHome(sshConfigPath)} does not appear to define Host ${host}.`);
|
|
980
|
+
console.log("Rudder can still use it if SSH resolves it, but a ~/.ssh/config entry is recommended:");
|
|
981
|
+
console.log(` Host ${host}`);
|
|
982
|
+
console.log(" HostName <server-ip-or-dns>");
|
|
983
|
+
console.log(" User <user>");
|
|
984
|
+
console.log(" IdentityFile ~/.ssh/<private-key>");
|
|
985
|
+
}
|
|
986
|
+
if (diagnostics.ok) {
|
|
987
|
+
console.log(`SSH check passed for ${host}. Docker is available on the BYOC host.`);
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
console.log(`\nSSH check did not fully pass for ${host}: ${diagnostics.message}`);
|
|
991
|
+
console.log("Fix SSH/Docker before launching, or run the printed Docker command manually on that host.");
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async function chooseByocHost(hosts) {
|
|
995
|
+
if (hosts.length === 0) {
|
|
996
|
+
return await promptText("SSH host from ~/.ssh/config");
|
|
997
|
+
}
|
|
998
|
+
return await promptSelect("Choose a BYOC SSH host from ~/.ssh/config", hosts.slice(0, 24).map((host) => ({ value: host, label: host })), hosts[0]);
|
|
999
|
+
}
|
|
1000
|
+
async function configureDefaultRuntime(runtime, options, byocSshHost) {
|
|
1001
|
+
const client = await cloudClient({ requireToken: true });
|
|
1002
|
+
const state = await loadCloudAuth();
|
|
1003
|
+
if (!state || state.cloudUrl !== client.baseUrl) {
|
|
1004
|
+
throw new Error("Not logged in to this Rudder Cloud control plane. Run `rudder login` first.");
|
|
1005
|
+
}
|
|
1006
|
+
await saveCloudAuth({
|
|
1007
|
+
...state,
|
|
1008
|
+
defaultRuntime: runtime,
|
|
1009
|
+
byocSshHost: runtime === "byo-vm" ? byocSshHost ?? state.byocSshHost : undefined,
|
|
1010
|
+
updatedAt: nowIso(),
|
|
1011
|
+
});
|
|
1012
|
+
const result = {
|
|
1013
|
+
ok: true,
|
|
1014
|
+
cloudUrl: client.baseUrl,
|
|
1015
|
+
defaultRuntime: runtime,
|
|
1016
|
+
};
|
|
1017
|
+
const savedByocHost = byocSshHost ?? state.byocSshHost;
|
|
1018
|
+
if (runtime === "byo-vm" && savedByocHost) {
|
|
1019
|
+
result.byocSshHost = savedByocHost;
|
|
1020
|
+
}
|
|
1021
|
+
const envRuntime = envCloudRuntime();
|
|
1022
|
+
if (envRuntime) {
|
|
1023
|
+
result.envOverride = envRuntime;
|
|
1024
|
+
}
|
|
1025
|
+
if (options.json) {
|
|
1026
|
+
printJson(result);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
console.log(`Rudder Cloud runtime set to ${runtime}.`);
|
|
1030
|
+
if (runtime === "byo-vm") {
|
|
1031
|
+
const host = byocSshHost ?? state.byocSshHost;
|
|
1032
|
+
console.log("Future `rudder cloud <task>` and `/sail <task>` launches will prepare a BYOC worker instead of creating a Fly Machine.");
|
|
1033
|
+
console.log(host
|
|
1034
|
+
? `Rudder will try to start the worker over SSH on ${host}.`
|
|
1035
|
+
: "Run `rudder cloud byoc <ssh-host>` to let Rudder start workers over SSH.");
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
console.log("Future `rudder cloud <task>` and `/sail <task>` launches will create Fly Machines.");
|
|
1039
|
+
}
|
|
1040
|
+
if (envRuntime) {
|
|
1041
|
+
console.log(`RUDDER_CLOUD_RUNTIME=${envRuntime} is set and will override this saved default.`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
async function runtime(args, options) {
|
|
1045
|
+
const next = args[0] ? parseCloudRuntime(args[0]) : undefined;
|
|
1046
|
+
if (args[0] && !next) {
|
|
1047
|
+
throw new Error("Runtime must be `fly`, `byoc`, or `byo-vm`.");
|
|
1048
|
+
}
|
|
1049
|
+
if (next) {
|
|
1050
|
+
await configureDefaultRuntime(next, options);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const client = await cloudClient({ requireToken: true });
|
|
1054
|
+
const current = await selectedCloudRuntime();
|
|
1055
|
+
const state = await loadCloudAuth();
|
|
1056
|
+
const savedRuntime = parseCloudRuntime(state?.defaultRuntime);
|
|
1057
|
+
const result = {
|
|
1058
|
+
cloudUrl: client.baseUrl,
|
|
1059
|
+
runtime: current,
|
|
1060
|
+
};
|
|
1061
|
+
const envRuntime = envCloudRuntime();
|
|
1062
|
+
if (state?.cloudUrl === client.baseUrl && savedRuntime) {
|
|
1063
|
+
result.savedDefaultRuntime = savedRuntime;
|
|
1064
|
+
}
|
|
1065
|
+
if (state?.cloudUrl === client.baseUrl && state.byocSshHost) {
|
|
1066
|
+
result.byocSshHost = state.byocSshHost;
|
|
1067
|
+
}
|
|
1068
|
+
if (envRuntime) {
|
|
1069
|
+
result.envOverride = envRuntime;
|
|
1070
|
+
}
|
|
1071
|
+
if (options.json) {
|
|
1072
|
+
printJson(result);
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
console.log(`Rudder Cloud runtime: ${current}`);
|
|
1076
|
+
if (envRuntime) {
|
|
1077
|
+
console.log(`Set by RUDDER_CLOUD_RUNTIME=${envRuntime}.`);
|
|
1078
|
+
}
|
|
1079
|
+
else if (state?.cloudUrl === client.baseUrl && savedRuntime) {
|
|
1080
|
+
console.log("Set in local Rudder Cloud config.");
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
console.log("Using default Fly Machines runtime.");
|
|
1084
|
+
}
|
|
1085
|
+
if (state?.cloudUrl === client.baseUrl && state.byocSshHost) {
|
|
1086
|
+
console.log(`BYOC SSH host: ${state.byocSshHost}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
async function sshConfigMentions(configPath, host) {
|
|
1091
|
+
const text = await fsp.readFile(configPath, "utf8").catch(() => "");
|
|
1092
|
+
if (!text.trim()) {
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
const target = host.toLowerCase();
|
|
1096
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1097
|
+
const trimmed = line.trim();
|
|
1098
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
const match = /^Host\s+(.+)$/i.exec(trimmed);
|
|
1102
|
+
if (!match) {
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const patterns = match[1].split(/\s+/).map((part) => part.toLowerCase());
|
|
1106
|
+
if (patterns.includes(target)) {
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
async function listSshConfigHosts(configPath) {
|
|
1113
|
+
const text = await fsp.readFile(configPath, "utf8").catch(() => "");
|
|
1114
|
+
const hosts = [];
|
|
1115
|
+
const seen = new Set();
|
|
1116
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1117
|
+
const trimmed = line.trim();
|
|
1118
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
const match = /^Host\s+(.+)$/i.exec(trimmed);
|
|
1122
|
+
if (!match) {
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
for (const host of match[1].split(/\s+/)) {
|
|
1126
|
+
if (!host || host.includes("*") || host.includes("?") || host.startsWith("!")) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
if (seen.has(host)) {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
seen.add(host);
|
|
1133
|
+
hosts.push(host);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return hosts;
|
|
1137
|
+
}
|
|
1138
|
+
async function checkByocHost(host) {
|
|
1139
|
+
if (!commandExists("ssh")) {
|
|
1140
|
+
return { ok: false, message: "ssh is not installed or not on PATH" };
|
|
1141
|
+
}
|
|
1142
|
+
const result = await runCommand("ssh", [
|
|
1143
|
+
"-o",
|
|
1144
|
+
"BatchMode=yes",
|
|
1145
|
+
"-o",
|
|
1146
|
+
"ConnectTimeout=8",
|
|
1147
|
+
host,
|
|
1148
|
+
"command -v docker >/dev/null && docker info >/dev/null 2>&1",
|
|
1149
|
+
], { allowFailure: true });
|
|
1150
|
+
if (result.code === 0) {
|
|
1151
|
+
return { ok: true, message: "ok" };
|
|
1152
|
+
}
|
|
1153
|
+
const detail = (result.stderr || result.stdout || `ssh exited ${result.code}`).trim();
|
|
1154
|
+
return { ok: false, message: detail };
|
|
1155
|
+
}
|
|
1156
|
+
async function startByocWorkerOverSsh(host, bootstrapCommand) {
|
|
1157
|
+
if (!commandExists("ssh")) {
|
|
1158
|
+
throw new Error("ssh is not installed or not on PATH");
|
|
1159
|
+
}
|
|
1160
|
+
const remoteCommand = [
|
|
1161
|
+
"mkdir -p ~/.rudder/byoc",
|
|
1162
|
+
`nohup sh -lc ${shellQuote(nonInteractiveDockerCommand(bootstrapCommand))} > ~/.rudder/byoc/worker.log 2>&1 < /dev/null &`,
|
|
1163
|
+
].join(" && ");
|
|
1164
|
+
await runCommand("ssh", [
|
|
1165
|
+
"-o",
|
|
1166
|
+
"BatchMode=yes",
|
|
1167
|
+
"-o",
|
|
1168
|
+
"ConnectTimeout=10",
|
|
1169
|
+
host,
|
|
1170
|
+
remoteCommand,
|
|
1171
|
+
]);
|
|
1172
|
+
}
|
|
1173
|
+
function nonInteractiveDockerCommand(command) {
|
|
1174
|
+
return command
|
|
1175
|
+
.replace(/\bdocker run --rm -it\b/g, "docker run --rm")
|
|
1176
|
+
.replace(/\bdocker run --rm -i -t\b/g, "docker run --rm")
|
|
1177
|
+
.replace(/\bdocker run --rm -t -i\b/g, "docker run --rm");
|
|
1178
|
+
}
|
|
1179
|
+
async function cloudClient(options) {
|
|
1180
|
+
const baseUrl = normalizeCloudUrl(process.env.RUDDER_CLOUD_URL);
|
|
1181
|
+
const state = await loadCloudAuth();
|
|
1182
|
+
const envToken = process.env.RUDDER_CLOUD_TOKEN?.trim();
|
|
1183
|
+
const token = envToken || (state?.cloudUrl === baseUrl ? state.token : undefined);
|
|
1184
|
+
if (options.requireToken && !token) {
|
|
1185
|
+
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
baseUrl,
|
|
1189
|
+
async request(pathOrUrl, init) {
|
|
1190
|
+
const url = pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")
|
|
1191
|
+
? pathOrUrl
|
|
1192
|
+
: new URL(pathOrUrl, `${baseUrl}/`).toString();
|
|
1193
|
+
const headers = {
|
|
1194
|
+
Accept: "application/json",
|
|
1195
|
+
};
|
|
1196
|
+
let body;
|
|
1197
|
+
if (init.body !== undefined) {
|
|
1198
|
+
headers["Content-Type"] = "application/json";
|
|
1199
|
+
body = JSON.stringify(init.body);
|
|
1200
|
+
}
|
|
1201
|
+
if (token) {
|
|
1202
|
+
headers.Authorization = `Bearer ${token}`;
|
|
1203
|
+
}
|
|
1204
|
+
const response = await fetch(url, {
|
|
1205
|
+
method: init.method,
|
|
1206
|
+
headers,
|
|
1207
|
+
body,
|
|
1208
|
+
});
|
|
1209
|
+
const text = await response.text();
|
|
1210
|
+
const parsed = text ? parseJson(text) : null;
|
|
1211
|
+
if (!response.ok) {
|
|
1212
|
+
const message = responseErrorMessage(parsed) || text.trim() || `${response.status} ${response.statusText}`;
|
|
1213
|
+
throw new Error(`Rudder Cloud request failed: ${message}`);
|
|
1214
|
+
}
|
|
1215
|
+
return parsed;
|
|
1216
|
+
},
|
|
1217
|
+
async download(pathname) {
|
|
1218
|
+
const response = await fetch(new URL(pathname, `${baseUrl}/`).toString(), { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
|
1219
|
+
if (!response.ok) {
|
|
1220
|
+
const text = await response.text();
|
|
1221
|
+
throw new Error(`Rudder Cloud request failed: ${responseErrorMessage(text ? parseJson(text) : null) || `${response.status} ${response.statusText}`}`);
|
|
1222
|
+
}
|
|
1223
|
+
return Buffer.from(await response.arrayBuffer());
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
function normalizeCloudUrl(raw) {
|
|
1228
|
+
const value = raw?.trim() || DEFAULT_CLOUD_URL;
|
|
1229
|
+
if (!value) {
|
|
1230
|
+
throw new Error("RUDDER_CLOUD_URL is not configured. Set it to your Rudder Cloud control plane URL.");
|
|
1231
|
+
}
|
|
1232
|
+
let url;
|
|
1233
|
+
try {
|
|
1234
|
+
url = new URL(value);
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
throw new Error("RUDDER_CLOUD_URL must be a valid http(s) URL.");
|
|
1238
|
+
}
|
|
1239
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1240
|
+
throw new Error("RUDDER_CLOUD_URL must be a valid http(s) URL.");
|
|
1241
|
+
}
|
|
1242
|
+
// The control plane hands back a shell command (bootstrapCommand) that the client
|
|
1243
|
+
// can auto-run over SSH, so a plain-HTTP control plane is a MITM-to-RCE risk.
|
|
1244
|
+
// Require https except for loopback dev or an explicit opt-in.
|
|
1245
|
+
const isLoopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
|
|
1246
|
+
if (url.protocol === "http:" && !isLoopback && process.env.RUDDER_CLOUD_ALLOW_HTTP !== "1") {
|
|
1247
|
+
throw new Error("Refusing a plain-HTTP RUDDER_CLOUD_URL (it can be MITM'd into running arbitrary commands). " +
|
|
1248
|
+
"Use https://, or set RUDDER_CLOUD_ALLOW_HTTP=1 to override for a trusted local network.");
|
|
1249
|
+
}
|
|
1250
|
+
url.hash = "";
|
|
1251
|
+
url.search = "";
|
|
1252
|
+
return url.toString().replace(/\/$/, "");
|
|
1253
|
+
}
|
|
1254
|
+
async function selectedCloudRuntime(explicit) {
|
|
1255
|
+
if (explicit) {
|
|
1256
|
+
return explicit;
|
|
1257
|
+
}
|
|
1258
|
+
const envRuntime = envCloudRuntime();
|
|
1259
|
+
if (envRuntime) {
|
|
1260
|
+
return envRuntime;
|
|
1261
|
+
}
|
|
1262
|
+
const baseUrl = normalizeCloudUrl(process.env.RUDDER_CLOUD_URL);
|
|
1263
|
+
const state = await loadCloudAuth();
|
|
1264
|
+
const savedRuntime = parseCloudRuntime(state?.defaultRuntime);
|
|
1265
|
+
return state?.cloudUrl === baseUrl && savedRuntime ? savedRuntime : "fly";
|
|
1266
|
+
}
|
|
1267
|
+
function parseCloudRuntime(raw) {
|
|
1268
|
+
const value = raw?.trim().toLowerCase();
|
|
1269
|
+
if (!value) {
|
|
1270
|
+
return undefined;
|
|
1271
|
+
}
|
|
1272
|
+
if (value === "fly" || value === "fly-machine" || value === "fly-machines") {
|
|
1273
|
+
return "fly";
|
|
1274
|
+
}
|
|
1275
|
+
if (value === "byo" || value === "byoc" || value === "byo-vm" || value === "manual" || value === "self-hosted" || value === "vm") {
|
|
1276
|
+
return "byo-vm";
|
|
1277
|
+
}
|
|
1278
|
+
return undefined;
|
|
1279
|
+
}
|
|
1280
|
+
function envCloudRuntime() {
|
|
1281
|
+
const runtime = parseCloudRuntime(process.env.RUDDER_CLOUD_RUNTIME);
|
|
1282
|
+
if (process.env.RUDDER_CLOUD_RUNTIME?.trim() && !runtime) {
|
|
1283
|
+
throw new Error("RUDDER_CLOUD_RUNTIME must be `fly`, `byoc`, or `byo-vm`.");
|
|
1284
|
+
}
|
|
1285
|
+
return runtime;
|
|
1286
|
+
}
|
|
1287
|
+
async function pollLogin(client, pollPath, deviceCode) {
|
|
1288
|
+
if (pollPath.startsWith("http://") || pollPath.startsWith("https://") || !deviceCode) {
|
|
1289
|
+
return await client.request(pollPath, { method: "GET" });
|
|
1290
|
+
}
|
|
1291
|
+
return await client.request(pollPath, {
|
|
1292
|
+
method: "POST",
|
|
1293
|
+
body: { deviceCode },
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
async function loadCloudAuth() {
|
|
1297
|
+
const state = await readJson(cloudAuthPath());
|
|
1298
|
+
return state?.version === 1 && typeof state.token === "string" ? state : null;
|
|
1299
|
+
}
|
|
1300
|
+
async function saveCloudAuth(state) {
|
|
1301
|
+
await writeJson(cloudAuthPath(), state, { mode: 0o600 });
|
|
1302
|
+
}
|
|
1303
|
+
async function createSnapshot(repoRoot, requestedHomePaths, options = {}) {
|
|
1304
|
+
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-cloud-"));
|
|
1305
|
+
const stageDir = path.join(tempDir, "snapshot");
|
|
1306
|
+
const repoStage = path.join(stageDir, "repo");
|
|
1307
|
+
const homeStage = path.join(stageDir, "home");
|
|
1308
|
+
await ensureDir(repoStage);
|
|
1309
|
+
await copyRepoFiles(repoRoot, repoStage);
|
|
1310
|
+
const projectEnvFiles = options.migration
|
|
1311
|
+
? await copyProjectEnvFiles(repoRoot, repoStage)
|
|
1312
|
+
: 0;
|
|
1313
|
+
const rudderState = options.includeRudderState || (options.rudderStateRunIds?.length ?? 0) > 0
|
|
1314
|
+
? await copyRudderState(repoRoot, repoStage, options.includeRudderState ? undefined : options.rudderStateRunIds)
|
|
1315
|
+
: undefined;
|
|
1316
|
+
const includeCredentials = options.includeCredentials !== false;
|
|
1317
|
+
const includedHomePaths = [];
|
|
1318
|
+
let capturedEnvCount = 0;
|
|
1319
|
+
if (includeCredentials) {
|
|
1320
|
+
const homePaths = normalizeHomePaths(requestedHomePaths);
|
|
1321
|
+
for (const homePath of homePaths) {
|
|
1322
|
+
const copied = await copyHomePath(homePath, homeStage);
|
|
1323
|
+
if (copied) {
|
|
1324
|
+
includedHomePaths.push(shortenHome(homePath));
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
// On macOS, Claude Code stores its OAuth token in the Keychain rather than
|
|
1328
|
+
// ~/.claude/.credentials.json, so the home-paths copy above doesn't pick it
|
|
1329
|
+
// up. Extract it from the Keychain and stage it as a credentials file so
|
|
1330
|
+
// the cloud worker boots already logged in.
|
|
1331
|
+
if (await stageClaudeKeychainCredentials(homeStage)) {
|
|
1332
|
+
includedHomePaths.push("~/.claude/.credentials.json (keychain)");
|
|
1333
|
+
}
|
|
1334
|
+
const capturedEnv = captureCloudEnv(Boolean(options.migration));
|
|
1335
|
+
if (Object.keys(capturedEnv).length > 0) {
|
|
1336
|
+
await ensureDir(path.join(stageDir, "env"));
|
|
1337
|
+
await writeJson(path.join(stageDir, "env", "cloud-env.json"), capturedEnv);
|
|
1338
|
+
capturedEnvCount = Object.keys(capturedEnv).length;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
let migratedAgentsCount = 0;
|
|
1342
|
+
if (options.migration && options.migration.plan.migrated.length > 0) {
|
|
1343
|
+
const entries = await stageMigratedAgents(stageDir, repoRoot, options.migration.repoName, options.migration.plan.migrated);
|
|
1344
|
+
const migrationManifest = {
|
|
1345
|
+
version: 1,
|
|
1346
|
+
createdAt: nowIso(),
|
|
1347
|
+
agents: entries,
|
|
1348
|
+
};
|
|
1349
|
+
await writeJson(path.join(stageDir, "migration.json"), migrationManifest);
|
|
1350
|
+
migratedAgentsCount = entries.length;
|
|
1351
|
+
}
|
|
1352
|
+
const manifest = {
|
|
1353
|
+
version: 1,
|
|
1354
|
+
createdAt: nowIso(),
|
|
1355
|
+
repo: {
|
|
1356
|
+
root: path.basename(repoRoot),
|
|
1357
|
+
branch: await currentBranch(repoRoot),
|
|
1358
|
+
commit: await currentCommit(repoRoot),
|
|
1359
|
+
},
|
|
1360
|
+
homePaths: includedHomePaths,
|
|
1361
|
+
...(rudderState ? { rudderState } : {}),
|
|
1362
|
+
...(migratedAgentsCount > 0 ? { migratedAgents: migratedAgentsCount } : {}),
|
|
1363
|
+
...(capturedEnvCount > 0 ? { capturedEnvVars: capturedEnvCount } : {}),
|
|
1364
|
+
...(projectEnvFiles > 0 ? { projectEnvFiles } : {}),
|
|
1365
|
+
};
|
|
1366
|
+
await writeJson(path.join(stageDir, "manifest.json"), manifest);
|
|
1367
|
+
const archivePath = path.join(tempDir, `${newRunId("cloud-snapshot")}.tgz`);
|
|
1368
|
+
await runCommand("tar", ["-czf", archivePath, "-C", stageDir, "."], { cwd: stageDir });
|
|
1369
|
+
return { tempDir, archivePath, manifest };
|
|
1370
|
+
}
|
|
1371
|
+
const CLOUD_ENV_DEFAULT_NAMES = new Set([
|
|
1372
|
+
"ANTHROPIC_API_KEY",
|
|
1373
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
1374
|
+
"OPENAI_API_KEY",
|
|
1375
|
+
"GOOGLE_API_KEY",
|
|
1376
|
+
"AWS_ACCESS_KEY_ID",
|
|
1377
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
1378
|
+
"AWS_SESSION_TOKEN",
|
|
1379
|
+
"AWS_REGION",
|
|
1380
|
+
"AWS_DEFAULT_REGION",
|
|
1381
|
+
"AWS_PROFILE",
|
|
1382
|
+
"VERCEL_TOKEN",
|
|
1383
|
+
"VERCEL_ORG_ID",
|
|
1384
|
+
"VERCEL_PROJECT_ID",
|
|
1385
|
+
"NETLIFY_AUTH_TOKEN",
|
|
1386
|
+
"GITHUB_TOKEN",
|
|
1387
|
+
"GH_TOKEN",
|
|
1388
|
+
"GITLAB_TOKEN",
|
|
1389
|
+
"NPM_TOKEN",
|
|
1390
|
+
"CARGO_REGISTRY_TOKEN",
|
|
1391
|
+
"HUGGING_FACE_HUB_TOKEN",
|
|
1392
|
+
"HF_TOKEN",
|
|
1393
|
+
"DATABASE_URL",
|
|
1394
|
+
"REDIS_URL",
|
|
1395
|
+
"POSTGRES_URL",
|
|
1396
|
+
"STRIPE_API_KEY",
|
|
1397
|
+
"STRIPE_SECRET_KEY",
|
|
1398
|
+
"SLACK_BOT_TOKEN",
|
|
1399
|
+
"DISCORD_TOKEN",
|
|
1400
|
+
]);
|
|
1401
|
+
const CLOUD_ENV_SUFFIX_PATTERNS = [/_API_KEY$/, /_AUTH_TOKEN$/, /_ACCESS_TOKEN$/, /_TOKEN$/, /_SECRET$/, /_SECRET_KEY$/];
|
|
1402
|
+
const CLOUD_ENV_BLOCKLIST = new Set([
|
|
1403
|
+
// Things we explicitly do not want shipping
|
|
1404
|
+
"PATH",
|
|
1405
|
+
"HOME",
|
|
1406
|
+
"USER",
|
|
1407
|
+
"PWD",
|
|
1408
|
+
"SHELL",
|
|
1409
|
+
"TERM",
|
|
1410
|
+
"TMPDIR",
|
|
1411
|
+
"LOGNAME",
|
|
1412
|
+
"SHLVL",
|
|
1413
|
+
"OLDPWD",
|
|
1414
|
+
"DISPLAY",
|
|
1415
|
+
"EDITOR",
|
|
1416
|
+
"PAGER",
|
|
1417
|
+
"LANG",
|
|
1418
|
+
"LC_ALL",
|
|
1419
|
+
// Rudder-internal vars that are set by the worker itself
|
|
1420
|
+
"RUDDER_WORKSPACE_ID",
|
|
1421
|
+
"RUDDER_SAIL_ID",
|
|
1422
|
+
"RUDDER_WORKER_TOKEN",
|
|
1423
|
+
"RUDDER_CLOUD_URL",
|
|
1424
|
+
"RUDDER_SNAPSHOT_URL",
|
|
1425
|
+
"RUDDER_CLOUD_TOKEN",
|
|
1426
|
+
"RUDDER_CLOUD_ENV_VARS",
|
|
1427
|
+
"RUDDER_CLOUD_ENV_BLOCKLIST",
|
|
1428
|
+
"RUDDER_TASK",
|
|
1429
|
+
"RUDDER_REPO_NAME",
|
|
1430
|
+
"RUDDER_ACCOUNT_ID",
|
|
1431
|
+
"RUDDER_HANDOFF_PATH",
|
|
1432
|
+
]);
|
|
1433
|
+
export function captureCloudEnv(includeAll = false) {
|
|
1434
|
+
const extra = (process.env.RUDDER_CLOUD_ENV_VARS || "")
|
|
1435
|
+
.split(",")
|
|
1436
|
+
.map((name) => name.trim())
|
|
1437
|
+
.filter(Boolean);
|
|
1438
|
+
const blockExtra = (process.env.RUDDER_CLOUD_ENV_BLOCKLIST || "")
|
|
1439
|
+
.split(",")
|
|
1440
|
+
.map((name) => name.trim())
|
|
1441
|
+
.filter(Boolean);
|
|
1442
|
+
const blocked = new Set([...CLOUD_ENV_BLOCKLIST, ...blockExtra]);
|
|
1443
|
+
const out = {};
|
|
1444
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
1445
|
+
if (!name || typeof value !== "string" || !value) {
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
if (blocked.has(name)) {
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
const matches = includeAll
|
|
1452
|
+
|| CLOUD_ENV_DEFAULT_NAMES.has(name)
|
|
1453
|
+
|| CLOUD_ENV_SUFFIX_PATTERNS.some((pattern) => pattern.test(name))
|
|
1454
|
+
|| extra.includes(name);
|
|
1455
|
+
if (!matches) {
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
out[name] = value;
|
|
1459
|
+
}
|
|
1460
|
+
return out;
|
|
1461
|
+
}
|
|
1462
|
+
async function stageMigratedAgents(stageDir, repoRoot, repoName, migrated) {
|
|
1463
|
+
const workspacesStage = path.join(stageDir, "migrated-workspaces");
|
|
1464
|
+
const sessionsStage = path.join(stageDir, "migrated-sessions");
|
|
1465
|
+
const entries = [];
|
|
1466
|
+
for (const candidate of migrated) {
|
|
1467
|
+
if (!(await pathExists(candidate.workspacePath))) {
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
const hasSession = Boolean(candidate.sessionId
|
|
1471
|
+
&& candidate.sessionJsonlPath
|
|
1472
|
+
&& (await pathExists(candidate.sessionJsonlPath)));
|
|
1473
|
+
const workspaceDest = path.join(workspacesStage, candidate.runId);
|
|
1474
|
+
await ensureDir(workspaceDest);
|
|
1475
|
+
await copyWorkspaceFiles(candidate.workspacePath, workspaceDest, repoRoot);
|
|
1476
|
+
await copyProjectEnvFiles(candidate.workspacePath, workspaceDest);
|
|
1477
|
+
if (path.resolve(candidate.workspacePath) !== path.resolve(repoRoot)) {
|
|
1478
|
+
// Ignored dotenv files do not normally exist in a jj workspace. Overlay
|
|
1479
|
+
// the source repo's project environment into every migrated worker.
|
|
1480
|
+
await copyProjectEnvFiles(repoRoot, workspaceDest);
|
|
1481
|
+
}
|
|
1482
|
+
let sessionJsonlSnapshotPath;
|
|
1483
|
+
if (hasSession) {
|
|
1484
|
+
const jsonlDest = path.join(sessionsStage, `${candidate.runId}.jsonl`);
|
|
1485
|
+
await ensureDir(path.dirname(jsonlDest));
|
|
1486
|
+
await fsp.cp(candidate.sessionJsonlPath, jsonlDest, { force: true });
|
|
1487
|
+
sessionJsonlSnapshotPath = path.posix.join("migrated-sessions", `${candidate.runId}.jsonl`);
|
|
1488
|
+
}
|
|
1489
|
+
const cloudWorkspaceAbs = cloudWorkspaceAbsolutePath(repoName, candidate.runId, candidate.task);
|
|
1490
|
+
// For fresh restarts, build a prompt-engineered handoff from the local
|
|
1491
|
+
// run record so the new agent gets context instead of just the bare task.
|
|
1492
|
+
let freshPrompt;
|
|
1493
|
+
if (!hasSession) {
|
|
1494
|
+
const runJsonPath = path.join(repoRoot, ".rudder", "runs", candidate.runId, "run.json");
|
|
1495
|
+
const record = await readJson(runJsonPath);
|
|
1496
|
+
const turns = Array.isArray(record?.turns) ? record.turns : [];
|
|
1497
|
+
freshPrompt = buildFreshHandoffPrompt(candidate, turns);
|
|
1498
|
+
}
|
|
1499
|
+
entries.push({
|
|
1500
|
+
runId: candidate.runId,
|
|
1501
|
+
task: candidate.task,
|
|
1502
|
+
taskSummary: candidate.taskSummary,
|
|
1503
|
+
backend: candidate.backend,
|
|
1504
|
+
sessionId: candidate.sessionId ?? "",
|
|
1505
|
+
localWorkspacePath: candidate.workspacePath,
|
|
1506
|
+
cloudWorkspaceRelativePath: cloudWorkspaceAbs,
|
|
1507
|
+
sessionJsonlSnapshotPath: sessionJsonlSnapshotPath ?? "",
|
|
1508
|
+
workspaceBranch: candidate.workspaceBranch,
|
|
1509
|
+
freshPrompt,
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
return entries;
|
|
1513
|
+
}
|
|
1514
|
+
async function copyWorkspaceFiles(workspacePath, target, _repoRoot) {
|
|
1515
|
+
const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1516
|
+
cwd: workspacePath,
|
|
1517
|
+
allowFailure: true,
|
|
1518
|
+
});
|
|
1519
|
+
const files = result.code === 0
|
|
1520
|
+
? result.stdout.split("\0").filter(Boolean)
|
|
1521
|
+
: await listFiles(workspacePath);
|
|
1522
|
+
for (const relative of files) {
|
|
1523
|
+
if (!relative || relative.startsWith(".git/") || relative === ".git" || relative.startsWith(".rudder/")) {
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
const source = path.join(workspacePath, relative);
|
|
1527
|
+
const dest = path.join(target, relative);
|
|
1528
|
+
if (!isInside(workspacePath, source) || !isInside(target, dest)) {
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
const stat = await fsp.lstat(source).catch(() => null);
|
|
1532
|
+
if (!stat || stat.isDirectory() || !(await shouldIncludeSnapshotPath(source))) {
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
await ensureDir(path.dirname(dest));
|
|
1536
|
+
await fsp.cp(source, dest, { dereference: false, force: true });
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
/** Copy project dotenv files only for explicit multi-agent workspace migration. */
|
|
1540
|
+
export async function copyProjectEnvFiles(sourceRoot, targetRoot) {
|
|
1541
|
+
let copied = 0;
|
|
1542
|
+
const ignoredDirs = new Set([".git", ".jj", ".rudder", ".rudder-workspaces", ".rudder-worktrees", "node_modules"]);
|
|
1543
|
+
async function walk(current) {
|
|
1544
|
+
const entries = await fsp.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
1545
|
+
for (const entry of entries) {
|
|
1546
|
+
if (entry.isDirectory()) {
|
|
1547
|
+
if (!ignoredDirs.has(entry.name)) {
|
|
1548
|
+
await walk(path.join(current, entry.name));
|
|
1549
|
+
}
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
if (!entry.isFile() || !/^\.env(?:\..+)?$/.test(entry.name)) {
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
const source = path.join(current, entry.name);
|
|
1556
|
+
const relative = path.relative(sourceRoot, source);
|
|
1557
|
+
const target = path.join(targetRoot, relative);
|
|
1558
|
+
if (!isInside(sourceRoot, source) || !isInside(targetRoot, target)) {
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
const stat = await fsp.stat(source).catch(() => null);
|
|
1562
|
+
if (!stat || stat.size > MAX_HOME_SECRET_SCAN_BYTES) {
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
await ensureDir(path.dirname(target));
|
|
1566
|
+
await fsp.cp(source, target, { force: true });
|
|
1567
|
+
copied += 1;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
await walk(sourceRoot);
|
|
1571
|
+
return copied;
|
|
1572
|
+
}
|
|
1573
|
+
export async function copyRudderState(repoRoot, repoStage, onlyRuns) {
|
|
1574
|
+
const copied = [];
|
|
1575
|
+
const rudderMd = path.join(repoRoot, "RUDDER.md");
|
|
1576
|
+
if (await pathExists(rudderMd)) {
|
|
1577
|
+
const target = path.join(repoStage, "RUDDER.md");
|
|
1578
|
+
await fsp.cp(rudderMd, target, { force: true });
|
|
1579
|
+
copied.push("RUDDER.md");
|
|
1580
|
+
}
|
|
1581
|
+
const runsDir = path.join(repoRoot, ".rudder", "runs");
|
|
1582
|
+
const entries = await fsp.readdir(runsDir, { withFileTypes: true }).catch(() => []);
|
|
1583
|
+
let runs = 0;
|
|
1584
|
+
for (const entry of entries) {
|
|
1585
|
+
if (!entry.isDirectory() || entry.name.includes("/") || entry.name.includes("\\")) {
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
if (onlyRuns && !onlyRuns.includes(entry.name)) {
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
const runJson = path.join(runsDir, entry.name, "run.json");
|
|
1592
|
+
if (!(await pathExists(runJson))) {
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
const relative = path.join(".rudder", "runs", entry.name, "run.json");
|
|
1596
|
+
const target = path.join(repoStage, relative);
|
|
1597
|
+
if (!isInside(repoRoot, runJson) || !isInside(repoStage, target)) {
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
await ensureDir(path.dirname(target));
|
|
1601
|
+
await fsp.cp(runJson, target, { force: true });
|
|
1602
|
+
copied.push(relative);
|
|
1603
|
+
runs += 1;
|
|
1604
|
+
}
|
|
1605
|
+
return { runs, files: copied };
|
|
1606
|
+
}
|
|
1607
|
+
async function copyRepoFiles(repoRoot, repoStage) {
|
|
1608
|
+
const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1609
|
+
cwd: repoRoot,
|
|
1610
|
+
allowFailure: true,
|
|
1611
|
+
});
|
|
1612
|
+
const files = result.code === 0
|
|
1613
|
+
? result.stdout.split("\0").filter(Boolean)
|
|
1614
|
+
: await listFiles(repoRoot);
|
|
1615
|
+
for (const relative of files) {
|
|
1616
|
+
if (!relative || relative.startsWith(".git/") || relative.startsWith(".rudder/")) {
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
const source = path.join(repoRoot, relative);
|
|
1620
|
+
const target = path.join(repoStage, relative);
|
|
1621
|
+
if (!isInside(repoRoot, source) || !isInside(repoStage, target)) {
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
const stat = await fsp.lstat(source).catch(() => null);
|
|
1625
|
+
if (!stat || stat.isDirectory() || !(await shouldIncludeSnapshotPath(source))) {
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
await ensureDir(path.dirname(target));
|
|
1629
|
+
await fsp.cp(source, target, { dereference: false, force: true });
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
async function listFiles(dir) {
|
|
1633
|
+
const files = [];
|
|
1634
|
+
async function walk(current) {
|
|
1635
|
+
const entries = await fsp.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
1636
|
+
for (const entry of entries) {
|
|
1637
|
+
if (entry.name === ".git" || entry.name === ".rudder" || entry.name === "node_modules") {
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
const full = path.join(current, entry.name);
|
|
1641
|
+
if (entry.isDirectory()) {
|
|
1642
|
+
await walk(full);
|
|
1643
|
+
}
|
|
1644
|
+
else {
|
|
1645
|
+
files.push(path.relative(dir, full));
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
await walk(dir);
|
|
1650
|
+
return files;
|
|
1651
|
+
}
|
|
1652
|
+
function normalizeHomePaths(requested) {
|
|
1653
|
+
const raw = [
|
|
1654
|
+
...DEFAULT_HOME_PATHS,
|
|
1655
|
+
...requested,
|
|
1656
|
+
...(process.env.RUDDER_CLOUD_HOME_PATHS?.split(",") ?? []),
|
|
1657
|
+
];
|
|
1658
|
+
const home = os.homedir();
|
|
1659
|
+
const seen = new Set();
|
|
1660
|
+
const paths = [];
|
|
1661
|
+
for (const item of raw) {
|
|
1662
|
+
const trimmed = item.trim();
|
|
1663
|
+
if (!trimmed) {
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
const resolved = path.resolve(expandHome(trimmed));
|
|
1667
|
+
if (!isInside(home, resolved) || seen.has(resolved)) {
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
seen.add(resolved);
|
|
1671
|
+
paths.push(resolved);
|
|
1672
|
+
}
|
|
1673
|
+
return paths;
|
|
1674
|
+
}
|
|
1675
|
+
// On macOS, Claude Code keeps its OAuth token in the Keychain instead of
|
|
1676
|
+
// ~/.claude/.credentials.json. Read it so cloud workspaces boot logged in.
|
|
1677
|
+
async function readClaudeKeychainCredentials() {
|
|
1678
|
+
if (process.platform !== "darwin") {
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
if (!commandExists("security")) {
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
const result = await runCommand("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { allowFailure: true });
|
|
1685
|
+
if (result.code !== 0) {
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
const payload = result.stdout.trim();
|
|
1689
|
+
if (!payload || !payload.startsWith("{")) {
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
try {
|
|
1693
|
+
JSON.parse(payload);
|
|
1694
|
+
}
|
|
1695
|
+
catch {
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
return payload;
|
|
1699
|
+
}
|
|
1700
|
+
async function stageClaudeKeychainCredentials(homeStage) {
|
|
1701
|
+
const payload = await readClaudeKeychainCredentials();
|
|
1702
|
+
if (!payload) {
|
|
1703
|
+
return false;
|
|
1704
|
+
}
|
|
1705
|
+
const targetDir = path.join(homeStage, ".claude");
|
|
1706
|
+
await ensureDir(targetDir);
|
|
1707
|
+
await fsp.writeFile(path.join(targetDir, ".credentials.json"), payload + "\n", { mode: 0o600 });
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
async function copyHomePath(source, homeStage) {
|
|
1711
|
+
if (!(await pathExists(source)) || !(await shouldIncludeSnapshotPath(source))) {
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
const relative = path.relative(os.homedir(), source);
|
|
1715
|
+
const target = path.join(homeStage, relative);
|
|
1716
|
+
if (!isInside(homeStage, target)) {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
await fsp.cp(source, target, {
|
|
1720
|
+
dereference: false,
|
|
1721
|
+
recursive: true,
|
|
1722
|
+
force: true,
|
|
1723
|
+
filter: async (candidate) => await shouldIncludeSnapshotPath(candidate),
|
|
1724
|
+
});
|
|
1725
|
+
return true;
|
|
1726
|
+
}
|
|
1727
|
+
async function shouldIncludeSnapshotPath(candidate) {
|
|
1728
|
+
const normalized = path.resolve(candidate);
|
|
1729
|
+
const parts = normalized.split(path.sep).map((part) => part.toLowerCase());
|
|
1730
|
+
const basename = path.basename(normalized).toLowerCase();
|
|
1731
|
+
const explicitAwsCredentials = normalized === path.join(os.homedir(), ".aws", "credentials");
|
|
1732
|
+
if (basename.startsWith("._")) {
|
|
1733
|
+
return false;
|
|
1734
|
+
}
|
|
1735
|
+
if (parts.some((part) => SECRET_PATH_PARTS.has(part)) || SECRET_BASENAMES.has(basename)) {
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
if (isInside(os.homedir(), normalized)) {
|
|
1739
|
+
if (parts.some((part) => BULKY_HOME_PATH_PARTS.has(part))) {
|
|
1740
|
+
return false;
|
|
1741
|
+
}
|
|
1742
|
+
if (BULKY_HOME_BASENAME_PATTERNS.some((pattern) => pattern.test(basename))) {
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
const stat = await fsp.lstat(normalized).catch(() => null);
|
|
1747
|
+
if (!stat || !stat.isFile() || stat.size > MAX_HOME_SECRET_SCAN_BYTES) {
|
|
1748
|
+
return true;
|
|
1749
|
+
}
|
|
1750
|
+
if (explicitAwsCredentials) {
|
|
1751
|
+
return true;
|
|
1752
|
+
}
|
|
1753
|
+
const text = await fsp.readFile(normalized, "utf8").catch(() => "");
|
|
1754
|
+
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);
|
|
1755
|
+
}
|
|
1756
|
+
function isInside(parent, child) {
|
|
1757
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child));
|
|
1758
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
1759
|
+
}
|
|
1760
|
+
function openBrowser(url) {
|
|
1761
|
+
const platform = process.platform;
|
|
1762
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
1763
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1764
|
+
const child = spawn(command, args, {
|
|
1765
|
+
detached: true,
|
|
1766
|
+
stdio: "ignore",
|
|
1767
|
+
});
|
|
1768
|
+
child.on("error", () => undefined);
|
|
1769
|
+
child.unref();
|
|
1770
|
+
}
|
|
1771
|
+
function withQuery(baseUrl, pathname, query) {
|
|
1772
|
+
const url = new URL(pathname, `${baseUrl}/`);
|
|
1773
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1774
|
+
url.searchParams.set(key, value);
|
|
1775
|
+
}
|
|
1776
|
+
return url.toString();
|
|
1777
|
+
}
|
|
1778
|
+
function parseJson(text) {
|
|
1779
|
+
try {
|
|
1780
|
+
return JSON.parse(text);
|
|
1781
|
+
}
|
|
1782
|
+
catch {
|
|
1783
|
+
return text;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
function responseErrorMessage(value) {
|
|
1787
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1788
|
+
return undefined;
|
|
1789
|
+
}
|
|
1790
|
+
const record = value;
|
|
1791
|
+
return typeof record.error === "string"
|
|
1792
|
+
? record.error
|
|
1793
|
+
: typeof record.message === "string"
|
|
1794
|
+
? record.message
|
|
1795
|
+
: undefined;
|
|
1796
|
+
}
|
|
1797
|
+
async function printResult(result, options) {
|
|
1798
|
+
if (options.json) {
|
|
1799
|
+
printJson(result);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (Array.isArray(result)) {
|
|
1803
|
+
printSailList(result);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
1807
|
+
const record = result;
|
|
1808
|
+
if (typeof record.bootstrapCommand === "string") {
|
|
1809
|
+
const id = typeof record.id === "string" ? record.id : "BYOC sail";
|
|
1810
|
+
const status = typeof record.status === "string" ? record.status : undefined;
|
|
1811
|
+
const state = await loadCloudAuth();
|
|
1812
|
+
const host = options.sshHost ?? state?.byocSshHost;
|
|
1813
|
+
console.log(`${id}${status ? ` (${status})` : ""} is ready for BYOC.`);
|
|
1814
|
+
// bootstrapCommand is a shell command supplied by the control plane. Auto-running
|
|
1815
|
+
// it over SSH is RCE-on-your-host if the control plane is compromised or MITM'd,
|
|
1816
|
+
// so autostart is OPT-IN (RUDDER_BYOC_AUTOSTART=1) and the command is always
|
|
1817
|
+
// printed first so the user can review exactly what would run.
|
|
1818
|
+
console.log("BYOC bootstrap command (review before running):");
|
|
1819
|
+
console.log(record.bootstrapCommand);
|
|
1820
|
+
if (host && process.env.RUDDER_BYOC_AUTOSTART === "1") {
|
|
1821
|
+
try {
|
|
1822
|
+
console.log(`\nRUDDER_BYOC_AUTOSTART=1 set — running the above over SSH on ${host}...`);
|
|
1823
|
+
await startByocWorkerOverSsh(host, record.bootstrapCommand);
|
|
1824
|
+
console.log(`Started BYOC worker over SSH on ${host}.`);
|
|
1825
|
+
console.log(`Remote log: ssh ${host} 'tail -f ~/.rudder/byoc/worker.log'`);
|
|
1826
|
+
}
|
|
1827
|
+
catch (error) {
|
|
1828
|
+
console.log(`Could not start BYOC worker over SSH on ${host}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1829
|
+
console.log("Run the command above manually on your workstation/server.");
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
else {
|
|
1833
|
+
console.log("\nRun the command above on your workstation/server.");
|
|
1834
|
+
if (host) {
|
|
1835
|
+
console.log("To auto-run it over SSH next time, set RUDDER_BYOC_AUTOSTART=1.");
|
|
1836
|
+
}
|
|
1837
|
+
else {
|
|
1838
|
+
console.log("Tip: run `rudder cloud byoc <ssh-host>` to target an SSH host.");
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
if (typeof record.updatedAt === "string") {
|
|
1842
|
+
console.log(`\nIf the command expires, run: rudder cloud bootstrap ${id}`);
|
|
1843
|
+
}
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
const sails = record.sails ?? record.items;
|
|
1847
|
+
if (Array.isArray(sails)) {
|
|
1848
|
+
printSailList(sails);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (typeof record.id === "string" && (typeof record.status === "string" || typeof record.runtime === "string")) {
|
|
1852
|
+
const parts = [
|
|
1853
|
+
record.id,
|
|
1854
|
+
typeof record.status === "string" ? record.status : undefined,
|
|
1855
|
+
typeof record.runtime === "string" ? record.runtime : undefined,
|
|
1856
|
+
typeof record.repoName === "string" ? record.repoName : undefined,
|
|
1857
|
+
].filter(Boolean);
|
|
1858
|
+
console.log(parts.join(" "));
|
|
1859
|
+
if (record.workspace === true || (record.run === null && typeof record.task !== "string")) {
|
|
1860
|
+
console.log("Rudder workspace uploaded. Use /cloud list to track it.");
|
|
1861
|
+
}
|
|
1862
|
+
else {
|
|
1863
|
+
console.log("Cloud worker created. Use /cloud list to track it.");
|
|
1864
|
+
}
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1869
|
+
}
|
|
1870
|
+
function printSailList(items) {
|
|
1871
|
+
if (items.length === 0) {
|
|
1872
|
+
console.log("No cloud sails.");
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
for (const item of items) {
|
|
1876
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1877
|
+
console.log(String(item));
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
const sail = item;
|
|
1881
|
+
console.log([
|
|
1882
|
+
sail.id,
|
|
1883
|
+
sail.status,
|
|
1884
|
+
sail.runtime,
|
|
1885
|
+
typeof sail.task === "string" && sail.task ? sail.task : undefined,
|
|
1886
|
+
typeof sail.repoName === "string" && sail.repoName ? sail.repoName : undefined,
|
|
1887
|
+
sail.branch,
|
|
1888
|
+
sail.url,
|
|
1889
|
+
sail.updatedAt ?? sail.createdAt,
|
|
1890
|
+
].filter(Boolean).join(" "));
|
|
1891
|
+
if (sail.status === "failed" && typeof sail.failureReason === "string") {
|
|
1892
|
+
console.log(` why: ${sail.failureReason}`);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
function printJson(value) {
|
|
1897
|
+
console.log(JSON.stringify(value, null, 2));
|
|
1898
|
+
}
|
|
1899
|
+
function sleep(ms) {
|
|
1900
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1901
|
+
}
|
|
1902
|
+
async function workspaceCommand(args, options) {
|
|
1903
|
+
const sub = args[0] ?? "";
|
|
1904
|
+
const rest = args.slice(1);
|
|
1905
|
+
if (sub === "" || sub === "attach") {
|
|
1906
|
+
await workspaceAttach(rest, options);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
if (sub === "create") {
|
|
1910
|
+
await workspaceCreate(rest, options);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
if (sub === "share") {
|
|
1914
|
+
await workspaceShare(options);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (sub === "status") {
|
|
1918
|
+
await workspaceStatus(rest, options);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (sub === "stop" || sub === "pause" || sub === "resume") {
|
|
1922
|
+
await workspaceMutate(sub, rest, options);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
if (sub === "list" || sub === "ls") {
|
|
1926
|
+
await workspaceList(options);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (sub === "delete" || sub === "rm") {
|
|
1930
|
+
await workspaceDelete(rest, options);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
throw new Error("Usage: rudder cloud workspace [attach [id|owner/repo]|create <owner/repo>|share|status|pause|resume|stop|delete [id]|list]");
|
|
1934
|
+
}
|
|
1935
|
+
const GITHUB_SLUG_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
1936
|
+
async function githubSlugFromOrigin(repoRoot) {
|
|
1937
|
+
const result = await runCommand("git", ["remote", "get-url", "origin"], {
|
|
1938
|
+
cwd: repoRoot,
|
|
1939
|
+
allowFailure: true,
|
|
1940
|
+
});
|
|
1941
|
+
if (result.code !== 0) {
|
|
1942
|
+
return null;
|
|
1943
|
+
}
|
|
1944
|
+
const match = result.stdout.trim().match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
1945
|
+
return match ? match[1] : null;
|
|
1946
|
+
}
|
|
1947
|
+
// Cloud-native workspace: the worker clones the repo from GitHub directly —
|
|
1948
|
+
// full history, real origin remote, no local-directory upload.
|
|
1949
|
+
async function workspaceCreate(args, options) {
|
|
1950
|
+
const slug = (args[0] ?? "").trim().replace(/\.git$/, "");
|
|
1951
|
+
if (!GITHUB_SLUG_RE.test(slug)) {
|
|
1952
|
+
throw new Error("Usage: rudder cloud workspace create <owner/repo> [--branch <name>] [--region <code>]");
|
|
1953
|
+
}
|
|
1954
|
+
let branch;
|
|
1955
|
+
let region;
|
|
1956
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
1957
|
+
const arg = args[i] ?? "";
|
|
1958
|
+
if (arg === "--branch")
|
|
1959
|
+
branch = args[++i];
|
|
1960
|
+
else if (arg.startsWith("--branch="))
|
|
1961
|
+
branch = arg.slice("--branch=".length);
|
|
1962
|
+
else if (arg === "--region")
|
|
1963
|
+
region = args[++i];
|
|
1964
|
+
else if (arg.startsWith("--region="))
|
|
1965
|
+
region = arg.slice("--region=".length);
|
|
1966
|
+
else
|
|
1967
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1968
|
+
}
|
|
1969
|
+
const client = await cloudClient({ requireToken: true });
|
|
1970
|
+
// Preflight: private clones and pushes need GitHub credentials from the vault.
|
|
1971
|
+
try {
|
|
1972
|
+
const secretsResult = await client.request("/api/rudder/secrets", { method: "GET" });
|
|
1973
|
+
const secrets = secretsResult?.secrets ?? [];
|
|
1974
|
+
const hasGitCreds = secrets.some((secret) => (secret.kind === "file" && secret.name.startsWith("~/.config/gh/"))
|
|
1975
|
+
|| (secret.kind === "env" && (secret.name === "GITHUB_TOKEN" || secret.name === "GH_TOKEN")));
|
|
1976
|
+
if (!hasGitCreds && !options.json) {
|
|
1977
|
+
process.stderr.write("Warning: no GitHub credentials in the cloud vault; private repos will fail to clone. Run `rudder cloud secrets sync` first.\n");
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
catch {
|
|
1981
|
+
// Old server or unconfigured vault; the worker will report clone failures.
|
|
1982
|
+
}
|
|
1983
|
+
if (!options.json) {
|
|
1984
|
+
process.stderr.write(`Creating cloud workspace for ${slug}...\n`);
|
|
1985
|
+
}
|
|
1986
|
+
const effectiveRegion = region ?? await explicitCloudRegion();
|
|
1987
|
+
const result = await client.request("/api/rudder/workspace/create", {
|
|
1988
|
+
method: "POST",
|
|
1989
|
+
body: {
|
|
1990
|
+
repo: slug,
|
|
1991
|
+
...(branch ? { branch } : {}),
|
|
1992
|
+
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
1993
|
+
},
|
|
1994
|
+
});
|
|
1995
|
+
await attachToWorkspaceResult(result, options);
|
|
1996
|
+
}
|
|
1997
|
+
async function workspaceAttachByRepo(slug, options) {
|
|
1998
|
+
const normalized = slug.trim().replace(/\.git$/, "");
|
|
1999
|
+
const client = await cloudClient({ requireToken: true });
|
|
2000
|
+
try {
|
|
2001
|
+
await client.request(`/api/rudder/workspace/lookup?repo=${encodeURIComponent(normalized)}`, { method: "GET" });
|
|
2002
|
+
}
|
|
2003
|
+
catch {
|
|
2004
|
+
throw new Error(`No cloud workspace for ${normalized}. Create one with \`rudder cloud workspace create ${normalized}\`.`);
|
|
2005
|
+
}
|
|
2006
|
+
// The create endpoint reuses/warm-restarts an existing clone workspace.
|
|
2007
|
+
const result = await client.request("/api/rudder/workspace/create", {
|
|
2008
|
+
method: "POST",
|
|
2009
|
+
body: { repo: normalized },
|
|
2010
|
+
});
|
|
2011
|
+
await attachToWorkspaceResult(result, options);
|
|
2012
|
+
}
|
|
2013
|
+
const MAX_SECRET_VALUE_BYTES = 1024 * 1024;
|
|
2014
|
+
async function secretsCommand(args, options) {
|
|
2015
|
+
const sub = args[0] ?? "";
|
|
2016
|
+
const rest = args.slice(1);
|
|
2017
|
+
switch (sub) {
|
|
2018
|
+
case "set":
|
|
2019
|
+
await secretsSet(rest, options);
|
|
2020
|
+
return;
|
|
2021
|
+
case "list":
|
|
2022
|
+
case "ls":
|
|
2023
|
+
await secretsList(options);
|
|
2024
|
+
return;
|
|
2025
|
+
case "rm":
|
|
2026
|
+
case "remove":
|
|
2027
|
+
case "delete":
|
|
2028
|
+
await secretsRm(rest, options);
|
|
2029
|
+
return;
|
|
2030
|
+
case "sync":
|
|
2031
|
+
await secretsSync(options);
|
|
2032
|
+
return;
|
|
2033
|
+
default:
|
|
2034
|
+
throw new Error("Usage: rudder cloud secrets [set <NAME> [value] | set --file <~/path> [source] | list | rm <NAME> | sync]");
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
// Convert an absolute or ~-prefixed path into the canonical tilde form the
|
|
2038
|
+
// vault stores file secrets under. Only paths inside $HOME are allowed.
|
|
2039
|
+
function toTildePath(input) {
|
|
2040
|
+
const trimmed = input.trim();
|
|
2041
|
+
const resolved = path.resolve(expandHome(trimmed));
|
|
2042
|
+
const home = os.homedir();
|
|
2043
|
+
if (!isInside(home, resolved) || resolved === home) {
|
|
2044
|
+
throw new Error(`File secrets must live inside your home directory: ${input}`);
|
|
2045
|
+
}
|
|
2046
|
+
return `~/${path.relative(home, resolved).split(path.sep).join("/")}`;
|
|
2047
|
+
}
|
|
2048
|
+
async function readStdinAll() {
|
|
2049
|
+
const chunks = [];
|
|
2050
|
+
for await (const chunk of process.stdin) {
|
|
2051
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2052
|
+
}
|
|
2053
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2054
|
+
}
|
|
2055
|
+
async function secretsSet(args, options) {
|
|
2056
|
+
const client = await cloudClient({ requireToken: true });
|
|
2057
|
+
if (args[0] === "--file") {
|
|
2058
|
+
const target = args[1];
|
|
2059
|
+
if (!target) {
|
|
2060
|
+
throw new Error("Usage: rudder cloud secrets set --file <~/path> [localSourcePath]");
|
|
2061
|
+
}
|
|
2062
|
+
const tildePath = toTildePath(target);
|
|
2063
|
+
const sourcePath = path.resolve(expandHome(args[2] ?? target));
|
|
2064
|
+
const content = await fsp.readFile(sourcePath);
|
|
2065
|
+
if (content.length > MAX_SECRET_VALUE_BYTES) {
|
|
2066
|
+
throw new Error(`${sourcePath} is ${content.length} bytes; file secrets are capped at ${MAX_SECRET_VALUE_BYTES}`);
|
|
2067
|
+
}
|
|
2068
|
+
await client.request("/api/rudder/secrets/item", {
|
|
2069
|
+
method: "PUT",
|
|
2070
|
+
body: {
|
|
2071
|
+
name: tildePath,
|
|
2072
|
+
kind: "file",
|
|
2073
|
+
filePath: tildePath,
|
|
2074
|
+
valueBase64: content.toString("base64"),
|
|
2075
|
+
source: "manual",
|
|
2076
|
+
},
|
|
2077
|
+
});
|
|
2078
|
+
if (options.json) {
|
|
2079
|
+
printJson({ ok: true, name: tildePath, kind: "file" });
|
|
2080
|
+
}
|
|
2081
|
+
else {
|
|
2082
|
+
console.log(`Stored file secret ${tildePath} (${content.length} bytes). Takes effect on next workspace boot.`);
|
|
2083
|
+
}
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const name = args[0];
|
|
2087
|
+
if (!name) {
|
|
2088
|
+
throw new Error("Usage: rudder cloud secrets set <NAME> [value] (or pipe the value on stdin)");
|
|
2089
|
+
}
|
|
2090
|
+
let value = args[1];
|
|
2091
|
+
if (value === undefined) {
|
|
2092
|
+
value = process.stdin.isTTY
|
|
2093
|
+
? await promptSecret(`Value for ${name}`)
|
|
2094
|
+
: (await readStdinAll()).replace(/\r?\n$/, "");
|
|
2095
|
+
}
|
|
2096
|
+
if (!value) {
|
|
2097
|
+
throw new Error(`No value provided for ${name}.`);
|
|
2098
|
+
}
|
|
2099
|
+
if (Buffer.byteLength(value, "utf8") > MAX_SECRET_VALUE_BYTES) {
|
|
2100
|
+
throw new Error(`Value for ${name} exceeds the ${MAX_SECRET_VALUE_BYTES}-byte cap`);
|
|
2101
|
+
}
|
|
2102
|
+
await client.request("/api/rudder/secrets/item", {
|
|
2103
|
+
method: "PUT",
|
|
2104
|
+
body: {
|
|
2105
|
+
name,
|
|
2106
|
+
kind: "env",
|
|
2107
|
+
valueBase64: Buffer.from(value, "utf8").toString("base64"),
|
|
2108
|
+
source: "manual",
|
|
2109
|
+
},
|
|
2110
|
+
});
|
|
2111
|
+
if (options.json) {
|
|
2112
|
+
printJson({ ok: true, name, kind: "env" });
|
|
2113
|
+
}
|
|
2114
|
+
else {
|
|
2115
|
+
console.log(`Stored env secret ${name}. Takes effect on next workspace boot.`);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
async function secretsList(options) {
|
|
2119
|
+
const client = await cloudClient({ requireToken: true });
|
|
2120
|
+
const result = await client.request("/api/rudder/secrets", { method: "GET" });
|
|
2121
|
+
const secrets = result?.secrets ?? [];
|
|
2122
|
+
if (options.json) {
|
|
2123
|
+
printJson(result);
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
if (secrets.length === 0) {
|
|
2127
|
+
console.log("No cloud secrets stored. Run `rudder cloud secrets sync` to import your local credentials.");
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
const nameWidth = Math.max(4, ...secrets.map((secret) => secret.name.length));
|
|
2131
|
+
console.log(`${"NAME".padEnd(nameWidth)} KIND SIZE UPDATED`);
|
|
2132
|
+
for (const secret of secrets) {
|
|
2133
|
+
const size = `${secret.sizeBytes}B`.padEnd(8);
|
|
2134
|
+
console.log(`${secret.name.padEnd(nameWidth)} ${secret.kind.padEnd(4)} ${size} ${secret.updatedAt}`);
|
|
2135
|
+
}
|
|
2136
|
+
console.log(`\n${secrets.length} secret(s). Values are never shown; rotate with \`rudder cloud secrets set\`.`);
|
|
2137
|
+
}
|
|
2138
|
+
async function secretsRm(args, options) {
|
|
2139
|
+
const name = args[0];
|
|
2140
|
+
if (!name) {
|
|
2141
|
+
throw new Error("Usage: rudder cloud secrets rm <NAME|~/path>");
|
|
2142
|
+
}
|
|
2143
|
+
const client = await cloudClient({ requireToken: true });
|
|
2144
|
+
const normalized = name.startsWith("~") || name.startsWith("/") ? toTildePath(name) : name;
|
|
2145
|
+
await client.request(`/api/rudder/secrets/item?name=${encodeURIComponent(normalized)}`, {
|
|
2146
|
+
method: "DELETE",
|
|
2147
|
+
});
|
|
2148
|
+
if (options.json) {
|
|
2149
|
+
printJson({ ok: true, name: normalized });
|
|
2150
|
+
}
|
|
2151
|
+
else {
|
|
2152
|
+
console.log(`Removed cloud secret ${normalized}.`);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
async function collectHomeSecretFiles() {
|
|
2156
|
+
const home = os.homedir();
|
|
2157
|
+
const out = [];
|
|
2158
|
+
const walk = async (target) => {
|
|
2159
|
+
const stat = await fsp.lstat(target).catch(() => null);
|
|
2160
|
+
if (!stat) {
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
if (stat.isSymbolicLink()) {
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
if (stat.isDirectory()) {
|
|
2167
|
+
if (!(await shouldIncludeSnapshotPath(target))) {
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
const entries = await fsp.readdir(target).catch(() => []);
|
|
2171
|
+
for (const entry of entries) {
|
|
2172
|
+
await walk(path.join(target, entry));
|
|
2173
|
+
}
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
if (!stat.isFile() || !(await shouldIncludeSnapshotPath(target))) {
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
out.push({
|
|
2180
|
+
tildePath: `~/${path.relative(home, target).split(path.sep).join("/")}`,
|
|
2181
|
+
absolute: target,
|
|
2182
|
+
size: stat.size,
|
|
2183
|
+
});
|
|
2184
|
+
};
|
|
2185
|
+
for (const root of normalizeHomePaths([])) {
|
|
2186
|
+
await walk(root);
|
|
2187
|
+
}
|
|
2188
|
+
return out;
|
|
2189
|
+
}
|
|
2190
|
+
// One-time (re-runnable) import of the credentials that used to ride inside
|
|
2191
|
+
// every workspace snapshot: the DEFAULT_HOME_PATHS allowlist, the macOS
|
|
2192
|
+
// Keychain Claude token, and the captured env vars.
|
|
2193
|
+
async function secretsSync(options) {
|
|
2194
|
+
const client = await cloudClient({ requireToken: true });
|
|
2195
|
+
const items = [];
|
|
2196
|
+
const skipped = [];
|
|
2197
|
+
for (const file of await collectHomeSecretFiles()) {
|
|
2198
|
+
if (file.size === 0) {
|
|
2199
|
+
continue;
|
|
2200
|
+
}
|
|
2201
|
+
if (file.size > MAX_SECRET_VALUE_BYTES) {
|
|
2202
|
+
skipped.push({ name: file.tildePath, reason: `${file.size} bytes exceeds the per-secret cap` });
|
|
2203
|
+
continue;
|
|
2204
|
+
}
|
|
2205
|
+
const content = await fsp.readFile(file.absolute).catch(() => null);
|
|
2206
|
+
if (!content) {
|
|
2207
|
+
skipped.push({ name: file.tildePath, reason: "unreadable" });
|
|
2208
|
+
continue;
|
|
2209
|
+
}
|
|
2210
|
+
items.push({
|
|
2211
|
+
name: file.tildePath,
|
|
2212
|
+
kind: "file",
|
|
2213
|
+
filePath: file.tildePath,
|
|
2214
|
+
valueBase64: content.toString("base64"),
|
|
2215
|
+
source: "sync",
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
// Keychain read can pop a macOS auth dialog, so only attempt it when a
|
|
2219
|
+
// human is at the terminal to answer it.
|
|
2220
|
+
if (isTty()) {
|
|
2221
|
+
const keychainPayload = await readClaudeKeychainCredentials();
|
|
2222
|
+
if (keychainPayload) {
|
|
2223
|
+
items.push({
|
|
2224
|
+
name: "~/.claude/.credentials.json",
|
|
2225
|
+
kind: "file",
|
|
2226
|
+
filePath: "~/.claude/.credentials.json",
|
|
2227
|
+
valueBase64: Buffer.from(keychainPayload + "\n", "utf8").toString("base64"),
|
|
2228
|
+
source: "sync",
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
const capturedEnv = captureCloudEnv();
|
|
2233
|
+
for (const [name, value] of Object.entries(capturedEnv)) {
|
|
2234
|
+
items.push({
|
|
2235
|
+
name,
|
|
2236
|
+
kind: "env",
|
|
2237
|
+
valueBase64: Buffer.from(value, "utf8").toString("base64"),
|
|
2238
|
+
source: "sync",
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
// gh keeps its token in the OS keychain, not in ~/.config/gh/hosts.yml, so
|
|
2242
|
+
// the synced gh config alone cannot clone a private repo in the cloud.
|
|
2243
|
+
if (!capturedEnv.GH_TOKEN && !capturedEnv.GITHUB_TOKEN) {
|
|
2244
|
+
const ghToken = await localGithubToken();
|
|
2245
|
+
if (ghToken) {
|
|
2246
|
+
items.push({ name: "GH_TOKEN", kind: "env", valueBase64: Buffer.from(ghToken, "utf8").toString("base64"), source: "sync" });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
if (items.length === 0) {
|
|
2250
|
+
throw new Error("Found nothing to sync: no allowlisted credential files or matching env vars.");
|
|
2251
|
+
}
|
|
2252
|
+
const response = await client.request("/api/rudder/secrets/bulk", { method: "POST", body: { items } });
|
|
2253
|
+
const results = response?.results ?? [];
|
|
2254
|
+
const stored = results.filter((entry) => entry.ok);
|
|
2255
|
+
const failed = results.filter((entry) => !entry.ok);
|
|
2256
|
+
if (options.json) {
|
|
2257
|
+
printJson({ stored: stored.length, failed, skipped });
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
console.log(`Synced ${stored.length} secret(s) to the cloud vault:`);
|
|
2261
|
+
for (const entry of stored) {
|
|
2262
|
+
console.log(` ${entry.name}`);
|
|
2263
|
+
}
|
|
2264
|
+
for (const entry of failed) {
|
|
2265
|
+
console.log(` FAILED ${entry.name}: ${entry.error ?? "unknown error"}`);
|
|
2266
|
+
}
|
|
2267
|
+
for (const entry of skipped) {
|
|
2268
|
+
console.log(` SKIPPED ${entry.name}: ${entry.reason}`);
|
|
2269
|
+
}
|
|
2270
|
+
console.log("\nNew cloud workspaces will now boot with these secrets; snapshots stop carrying local credentials.");
|
|
2271
|
+
}
|
|
2272
|
+
function computeWorkspaceKey(repoRoot) {
|
|
2273
|
+
const normalized = path.resolve(repoRoot);
|
|
2274
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 32);
|
|
2275
|
+
}
|
|
2276
|
+
// Worker placement policy: with the single-region relay in the middle of every
|
|
2277
|
+
// attach, echo latency = RTT(client↔relay) + RTT(relay↔worker), so the worker
|
|
2278
|
+
// belongs NEXT TO THE RELAY, not next to the user. We therefore only send a
|
|
2279
|
+
// region when the user explicitly asked for one (env var or `rudder cloud
|
|
2280
|
+
// region <code>`); otherwise the server places the worker in its own region.
|
|
2281
|
+
async function explicitCloudRegion() {
|
|
2282
|
+
const envRegion = process.env.RUDDER_CLOUD_REGION?.trim().toLowerCase();
|
|
2283
|
+
if (envRegion) {
|
|
2284
|
+
return envRegion;
|
|
2285
|
+
}
|
|
2286
|
+
const state = await loadCloudAuth().catch(() => null);
|
|
2287
|
+
return state?.defaultRegion?.trim().toLowerCase() || undefined;
|
|
2288
|
+
}
|
|
2289
|
+
async function configureRegion(args, options) {
|
|
2290
|
+
const value = (args[0] ?? "").trim().toLowerCase();
|
|
2291
|
+
const state = await loadCloudAuth();
|
|
2292
|
+
if (!state) {
|
|
2293
|
+
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
2294
|
+
}
|
|
2295
|
+
if (!value) {
|
|
2296
|
+
const current = state.defaultRegion ?? "";
|
|
2297
|
+
if (options.json) {
|
|
2298
|
+
printJson({ region: current || null });
|
|
2299
|
+
}
|
|
2300
|
+
else if (current) {
|
|
2301
|
+
console.log(`Cloud worker region override: ${current} (run \`rudder cloud region clear\` to let the server choose).`);
|
|
2302
|
+
}
|
|
2303
|
+
else {
|
|
2304
|
+
console.log("No region override set: workers are placed next to the relay for lowest typing latency.");
|
|
2305
|
+
}
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
if (value === "clear" || value === "none" || value === "auto") {
|
|
2309
|
+
await saveCloudAuth({ ...state, defaultRegion: undefined, updatedAt: nowIso() });
|
|
2310
|
+
if (options.json) {
|
|
2311
|
+
printJson({ ok: true, region: null });
|
|
2312
|
+
}
|
|
2313
|
+
else {
|
|
2314
|
+
console.log("Cleared region override; the server will place workers next to the relay.");
|
|
2315
|
+
}
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
if (!/^[a-z]{3,6}$/.test(value)) {
|
|
2319
|
+
throw new Error(`Invalid Fly region code: ${value}`);
|
|
2320
|
+
}
|
|
2321
|
+
await saveCloudAuth({ ...state, defaultRegion: value, updatedAt: nowIso() });
|
|
2322
|
+
if (options.json) {
|
|
2323
|
+
printJson({ ok: true, region: value });
|
|
2324
|
+
}
|
|
2325
|
+
else {
|
|
2326
|
+
console.log(`Cloud worker region override set to ${value}. Note: placing workers away from the relay increases typing latency.`);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths, vaultActive = false) {
|
|
2330
|
+
const hash = createHash("sha256");
|
|
2331
|
+
// Repo state: HEAD commit + the porcelain dirty file list. Two attaches
|
|
2332
|
+
// from the same repo at the same commit with no edits should produce the
|
|
2333
|
+
// same fingerprint.
|
|
2334
|
+
const headCommit = await currentCommit(repoRoot).catch(() => "");
|
|
2335
|
+
hash.update(`repo:head:${headCommit}\n`);
|
|
2336
|
+
const status = await runCommand("git", ["status", "--porcelain", "-z"], {
|
|
2337
|
+
cwd: repoRoot,
|
|
2338
|
+
allowFailure: true,
|
|
2339
|
+
});
|
|
2340
|
+
if (status.code === 0) {
|
|
2341
|
+
hash.update(`repo:status:${status.stdout}\n`);
|
|
2342
|
+
}
|
|
2343
|
+
// With the vault active the snapshot carries no credentials, so credential
|
|
2344
|
+
// changes must NOT change the fingerprint: a mismatch triggers the
|
|
2345
|
+
// destructive destroy+recreate path server-side, and rotation already takes
|
|
2346
|
+
// effect on the next boot via the supervisor's vault fetch.
|
|
2347
|
+
if (vaultActive) {
|
|
2348
|
+
return hash.digest("hex").slice(0, 32);
|
|
2349
|
+
}
|
|
2350
|
+
// macOS Keychain claude credentials: hash content so re-logging in
|
|
2351
|
+
// invalidates the cache but a steady-state user keeps it.
|
|
2352
|
+
if (process.platform === "darwin") {
|
|
2353
|
+
const creds = await runCommand("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], {
|
|
2354
|
+
allowFailure: true,
|
|
2355
|
+
});
|
|
2356
|
+
if (creds.code === 0) {
|
|
2357
|
+
hash.update(`keychain:claude:${createHash("sha256").update(creds.stdout).digest("hex")}\n`);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
// Captured env vars (excluding ones we know rotate like AWS session tokens).
|
|
2361
|
+
const env = captureCloudEnv();
|
|
2362
|
+
const unstable = new Set(["AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN"]);
|
|
2363
|
+
for (const key of Object.keys(env).sort()) {
|
|
2364
|
+
if (unstable.has(key))
|
|
2365
|
+
continue;
|
|
2366
|
+
hash.update(`env:${key}:${createHash("sha256").update(env[key] ?? "").digest("hex")}\n`);
|
|
2367
|
+
}
|
|
2368
|
+
return hash.digest("hex").slice(0, 32);
|
|
2369
|
+
}
|
|
2370
|
+
// True when the account has vault secrets on this control plane, meaning
|
|
2371
|
+
// snapshots should stop carrying local credentials. Any failure (old server,
|
|
2372
|
+
// vault unconfigured, network) degrades to legacy snapshot behavior.
|
|
2373
|
+
async function accountHasVaultSecrets(client) {
|
|
2374
|
+
if (process.env.RUDDER_CLOUD_LEGACY_SNAPSHOT_SECRETS === "1") {
|
|
2375
|
+
return false;
|
|
2376
|
+
}
|
|
2377
|
+
try {
|
|
2378
|
+
const result = await client.request("/api/rudder/secrets", { method: "GET" });
|
|
2379
|
+
return (result?.secrets ?? []).length > 0;
|
|
2380
|
+
}
|
|
2381
|
+
catch {
|
|
2382
|
+
return false;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
async function workspaceAttach(args, options) {
|
|
2386
|
+
const explicitId = args[0];
|
|
2387
|
+
if (explicitId) {
|
|
2388
|
+
if (explicitId.includes("/")) {
|
|
2389
|
+
await workspaceAttachByRepo(explicitId, options);
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
await workspaceAttachById(explicitId, options);
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const repoRoot = findRepoRoot();
|
|
2396
|
+
const workspaceKey = computeWorkspaceKey(repoRoot);
|
|
2397
|
+
const repoName = path.basename(repoRoot);
|
|
2398
|
+
const client = await cloudClient({ requireToken: true });
|
|
2399
|
+
if (!options.json) {
|
|
2400
|
+
process.stderr.write(`Resolving cloud workspace for ${repoName}...\n`);
|
|
2401
|
+
}
|
|
2402
|
+
// Prefer an existing cloud-native (clone-based) workspace for this repo's
|
|
2403
|
+
// origin over uploading a snapshot of the local directory.
|
|
2404
|
+
const originSlug = await githubSlugFromOrigin(repoRoot);
|
|
2405
|
+
if (originSlug && isTty() && !options.json) {
|
|
2406
|
+
const cloneWorkspace = await client.request(`/api/rudder/workspace/lookup?repo=${encodeURIComponent(originSlug)}`, { method: "GET" }).catch(() => null);
|
|
2407
|
+
if (cloneWorkspace) {
|
|
2408
|
+
const useClone = await promptConfirm(`A cloud-native workspace for ${originSlug} exists. Attach it instead of uploading a local snapshot?`, true);
|
|
2409
|
+
if (useClone) {
|
|
2410
|
+
const result = await client.request("/api/rudder/workspace/create", {
|
|
2411
|
+
method: "POST",
|
|
2412
|
+
body: { repo: originSlug },
|
|
2413
|
+
});
|
|
2414
|
+
await attachToWorkspaceResult(result, options);
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
// Kick off the non-interactive work in parallel. planAgentMigration can
|
|
2420
|
+
// call promptConfirm for a TTY prompt, so we serialize it AFTER the
|
|
2421
|
+
// parallel work resolves to avoid garbled stdout during the prompt.
|
|
2422
|
+
const [region, vaultActive] = await Promise.all([
|
|
2423
|
+
explicitCloudRegion(),
|
|
2424
|
+
accountHasVaultSecrets(client),
|
|
2425
|
+
]);
|
|
2426
|
+
const fingerprint = await computeSnapshotFingerprint(repoRoot, options.homePaths ?? [], vaultActive);
|
|
2427
|
+
const migrationPlan = await planAgentMigration(repoRoot, options);
|
|
2428
|
+
const mustUploadSnapshot = Boolean(migrationPlan && migrationPlan.migrated.length > 0);
|
|
2429
|
+
const baseBody = {
|
|
2430
|
+
workspaceKey,
|
|
2431
|
+
repoName,
|
|
2432
|
+
snapshotFingerprint: fingerprint,
|
|
2433
|
+
...(region ? { region } : {}),
|
|
2434
|
+
};
|
|
2435
|
+
let result = null;
|
|
2436
|
+
if (!mustUploadSnapshot) {
|
|
2437
|
+
try {
|
|
2438
|
+
result = await client.request("/api/rudder/workspace/attach", {
|
|
2439
|
+
method: "POST",
|
|
2440
|
+
body: baseBody,
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
catch (error) {
|
|
2444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2445
|
+
if (!/snapshot/i.test(message)) {
|
|
2446
|
+
throw error;
|
|
2447
|
+
}
|
|
2448
|
+
result = null;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
if (!result) {
|
|
2452
|
+
if (!options.json) {
|
|
2453
|
+
process.stderr.write(`Uploading workspace snapshot...\n`);
|
|
2454
|
+
}
|
|
2455
|
+
const snapshot = await createSnapshot(repoRoot, options.homePaths ?? [], {
|
|
2456
|
+
includeRudderState: true,
|
|
2457
|
+
includeCredentials: !vaultActive,
|
|
2458
|
+
migration: migrationPlan ? { repoName, plan: migrationPlan } : undefined,
|
|
2459
|
+
});
|
|
2460
|
+
try {
|
|
2461
|
+
const body = {
|
|
2462
|
+
...baseBody,
|
|
2463
|
+
snapshot: {
|
|
2464
|
+
name: path.basename(snapshot.archivePath),
|
|
2465
|
+
contentType: "application/gzip",
|
|
2466
|
+
base64: await fsp.readFile(snapshot.archivePath, "base64"),
|
|
2467
|
+
manifest: snapshot.manifest,
|
|
2468
|
+
},
|
|
2469
|
+
};
|
|
2470
|
+
if (migrationPlan && migrationPlan.migrated.length > 0) {
|
|
2471
|
+
body.migratedAgents = summaryAsJson(migrationPlan);
|
|
2472
|
+
}
|
|
2473
|
+
result = await client.request("/api/rudder/workspace/attach", {
|
|
2474
|
+
method: "POST",
|
|
2475
|
+
body,
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
finally {
|
|
2479
|
+
await fsp.rm(snapshot.tempDir, { recursive: true, force: true });
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
if (migrationPlan && !options.json) {
|
|
2483
|
+
process.stderr.write(`${migrationSummary(migrationPlan)}\n`);
|
|
2484
|
+
}
|
|
2485
|
+
if (!result) {
|
|
2486
|
+
throw new Error("Workspace attach returned no result");
|
|
2487
|
+
}
|
|
2488
|
+
// The cloud workspace owns the migrated agents now. Mark their local
|
|
2489
|
+
// records so the next `rudder cloud` doesn't classify the same runs as
|
|
2490
|
+
// migratable again — without this, every attach re-offered (and re-staged)
|
|
2491
|
+
// agents that were already living in the cloud, forever. The dashboard's
|
|
2492
|
+
// migrate path does the same thing via AgentStatus::Migrated.
|
|
2493
|
+
if (migrationPlan) {
|
|
2494
|
+
await markRunsMigrated(repoRoot, migrationPlan.migrated.map((candidate) => candidate.runId));
|
|
2495
|
+
}
|
|
2496
|
+
await attachToWorkspaceResult(result, options);
|
|
2497
|
+
}
|
|
2498
|
+
async function markRunsMigrated(repoRoot, runIds) {
|
|
2499
|
+
for (const runId of runIds) {
|
|
2500
|
+
const recordPath = path.join(repoRoot, ".rudder", "runs", runId, "run.json");
|
|
2501
|
+
const record = await readJson(recordPath);
|
|
2502
|
+
if (!record || record.id !== runId) {
|
|
2503
|
+
continue;
|
|
2504
|
+
}
|
|
2505
|
+
record.status = "migrated";
|
|
2506
|
+
record.updatedAt = nowIso();
|
|
2507
|
+
await writeJson(recordPath, record);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
async function planAgentMigration(repoRoot, options) {
|
|
2511
|
+
const candidates = await findMigrationCandidates(repoRoot);
|
|
2512
|
+
if (candidates.length === 0) {
|
|
2513
|
+
return null;
|
|
2514
|
+
}
|
|
2515
|
+
const plan = applyDefaultDecisions(candidates);
|
|
2516
|
+
if (options.json) {
|
|
2517
|
+
return plan;
|
|
2518
|
+
}
|
|
2519
|
+
if (!isTty()) {
|
|
2520
|
+
return plan;
|
|
2521
|
+
}
|
|
2522
|
+
console.log(migrationSummary(plan));
|
|
2523
|
+
if (plan.migrated.length === 0) {
|
|
2524
|
+
return plan;
|
|
2525
|
+
}
|
|
2526
|
+
const confirmed = await promptConfirm("Move resumable agents to cloud?", true);
|
|
2527
|
+
if (confirmed) {
|
|
2528
|
+
return plan;
|
|
2529
|
+
}
|
|
2530
|
+
return {
|
|
2531
|
+
candidates,
|
|
2532
|
+
migrated: [],
|
|
2533
|
+
stayedLocal: candidates.map((c) => ({ ...c, decision: "stay" })),
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
async function attachToWorkspaceResult(result, options) {
|
|
2537
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
2538
|
+
throw new Error("Unexpected workspace response from cloud");
|
|
2539
|
+
}
|
|
2540
|
+
const record = result;
|
|
2541
|
+
const workspaceId = typeof record.id === "string" ? record.id : undefined;
|
|
2542
|
+
if (!workspaceId) {
|
|
2543
|
+
throw new Error("Workspace response is missing id");
|
|
2544
|
+
}
|
|
2545
|
+
if (options.json) {
|
|
2546
|
+
printJson(record);
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
// The latency probe is non-interactive by design; it must not be gated on a TTY.
|
|
2550
|
+
if (!options.latencyProbe && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
2551
|
+
process.stderr.write(`Workspace ${workspaceId} is ready. Run \`rudder cloud workspace attach\` from a TTY to take over.\n`);
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
|
|
2558
|
+
}
|
|
2559
|
+
async function workspaceAttachById(workspaceId, options) {
|
|
2560
|
+
if (options.json) {
|
|
2561
|
+
printJson({ id: workspaceId, attaching: true });
|
|
2562
|
+
}
|
|
2563
|
+
else if (!options.latencyProbe && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
2564
|
+
process.stderr.write(`Workspace ${workspaceId}: attach requires a TTY.\n`);
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `workspace ${workspaceId}` }, options);
|
|
2571
|
+
}
|
|
2572
|
+
async function lookupWorkspaceForRepo(options) {
|
|
2573
|
+
void options;
|
|
2574
|
+
const repoRoot = findRepoRoot();
|
|
2575
|
+
const workspaceKey = computeWorkspaceKey(repoRoot);
|
|
2576
|
+
const client = await cloudClient({ requireToken: true });
|
|
2577
|
+
try {
|
|
2578
|
+
const result = await client.request(`/api/rudder/workspace/lookup?key=${encodeURIComponent(workspaceKey)}`, { method: "GET" });
|
|
2579
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
2580
|
+
return result;
|
|
2581
|
+
}
|
|
2582
|
+
return null;
|
|
2583
|
+
}
|
|
2584
|
+
catch (error) {
|
|
2585
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2586
|
+
if (/not found/i.test(message) || /404/.test(message)) {
|
|
2587
|
+
return null;
|
|
2588
|
+
}
|
|
2589
|
+
throw error;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
async function workspaceShare(options) {
|
|
2593
|
+
const workspace = await lookupWorkspaceForRepo(options);
|
|
2594
|
+
if (!workspace) {
|
|
2595
|
+
if (options.json) {
|
|
2596
|
+
printJson({ workspace: null });
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
console.log("No cloud workspace exists for this repo yet. Run `rudder cloud workspace attach` to create one.");
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
const id = typeof workspace.id === "string" ? workspace.id : "";
|
|
2603
|
+
if (!id) {
|
|
2604
|
+
throw new Error("Workspace lookup returned no id");
|
|
2605
|
+
}
|
|
2606
|
+
if (options.json) {
|
|
2607
|
+
printJson({
|
|
2608
|
+
id,
|
|
2609
|
+
attachCommand: `rudder cloud workspace attach ${id}`,
|
|
2610
|
+
status: workspace.status ?? null,
|
|
2611
|
+
});
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
console.log("Share this workspace with a teammate by sending them:");
|
|
2615
|
+
console.log("");
|
|
2616
|
+
console.log(` rudder cloud workspace attach ${id}`);
|
|
2617
|
+
console.log("");
|
|
2618
|
+
console.log("They must already be logged in to Rudder Cloud with their own account (run `rudder cloud login` if not).");
|
|
2619
|
+
}
|
|
2620
|
+
async function workspaceStatus(args, options) {
|
|
2621
|
+
if (process.env.RUDDER_OFFLINE === "1") {
|
|
2622
|
+
if (options.json) {
|
|
2623
|
+
printJson({ offline: true, workspace: null });
|
|
2624
|
+
}
|
|
2625
|
+
else {
|
|
2626
|
+
console.log("RUDDER_OFFLINE is set; skipping cloud workspace status check.");
|
|
2627
|
+
}
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
const explicitId = args[0];
|
|
2631
|
+
const workspace = await (explicitId
|
|
2632
|
+
? lookupWorkspaceById(explicitId)
|
|
2633
|
+
: lookupWorkspaceForRepo(options)).catch((error) => {
|
|
2634
|
+
if (!options.json) {
|
|
2635
|
+
console.warn(`Could not reach Rudder Cloud: ${error instanceof Error ? error.message : String(error)}`);
|
|
2636
|
+
}
|
|
2637
|
+
return null;
|
|
2638
|
+
});
|
|
2639
|
+
if (!workspace) {
|
|
2640
|
+
if (options.json) {
|
|
2641
|
+
printJson({ workspace: null });
|
|
2642
|
+
}
|
|
2643
|
+
else {
|
|
2644
|
+
console.log("No cloud workspace for this repo.");
|
|
2645
|
+
}
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
const id = typeof workspace.id === "string" ? workspace.id : "";
|
|
2649
|
+
const status = typeof workspace.status === "string" ? workspace.status : "unknown";
|
|
2650
|
+
const clientCount = typeof workspace.clientCount === "number" ? workspace.clientCount : 0;
|
|
2651
|
+
const lastActivityAt = typeof workspace.lastActivityAt === "string" ? workspace.lastActivityAt : undefined;
|
|
2652
|
+
const idleMinutes = computeIdleMinutes(lastActivityAt);
|
|
2653
|
+
// Prefer the server's worker-reported busy count (a fact: how many rudder
|
|
2654
|
+
// runs are actively working in that machine). Fall back to the old activity
|
|
2655
|
+
// heuristic only for control planes that predate the field.
|
|
2656
|
+
const serverActiveAgents = typeof workspace.activeAgents === "number" ? workspace.activeAgents : null;
|
|
2657
|
+
const activeAgents = serverActiveAgents !== null
|
|
2658
|
+
? serverActiveAgents > 0
|
|
2659
|
+
: clientCount > 0 || (idleMinutes !== null && idleMinutes < 5);
|
|
2660
|
+
if (options.json) {
|
|
2661
|
+
printJson({
|
|
2662
|
+
id,
|
|
2663
|
+
status,
|
|
2664
|
+
clientCount,
|
|
2665
|
+
lastActivityAt: lastActivityAt ?? null,
|
|
2666
|
+
idleMinutes,
|
|
2667
|
+
activeAgents,
|
|
2668
|
+
activeAgentCount: serverActiveAgents,
|
|
2669
|
+
repoName: typeof workspace.repoName === "string" ? workspace.repoName : null,
|
|
2670
|
+
});
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
const idlePart = idleMinutes !== null ? ` idle ${idleMinutes}m` : "";
|
|
2674
|
+
console.log(`workspace ${id} ${status} clients=${clientCount}${idlePart}`);
|
|
2675
|
+
if (status === "failed" && typeof workspace.failureReason === "string" && workspace.failureReason) {
|
|
2676
|
+
console.log(`why: ${workspace.failureReason}`);
|
|
2677
|
+
}
|
|
2678
|
+
if (serverActiveAgents !== null) {
|
|
2679
|
+
if (serverActiveAgents > 0) {
|
|
2680
|
+
console.log(`${serverActiveAgents} agent(s) actively running in the cloud.`);
|
|
2681
|
+
}
|
|
2682
|
+
else {
|
|
2683
|
+
console.log("No agents running in the cloud right now.");
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
else if (activeAgents) {
|
|
2687
|
+
console.log("Active agents likely running.");
|
|
2688
|
+
}
|
|
2689
|
+
else {
|
|
2690
|
+
console.log("No recent activity.");
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
async function lookupWorkspaceById(id) {
|
|
2694
|
+
const client = await cloudClient({ requireToken: true });
|
|
2695
|
+
const result = await client.request("/api/rudder/workspace", { method: "GET" });
|
|
2696
|
+
const match = collectionFromResult(result, "workspaces").find((item) => item && typeof item === "object" && !Array.isArray(item)
|
|
2697
|
+
&& item.id === id);
|
|
2698
|
+
return match && typeof match === "object" && !Array.isArray(match)
|
|
2699
|
+
? match
|
|
2700
|
+
: null;
|
|
2701
|
+
}
|
|
2702
|
+
function computeIdleMinutes(lastActivityAt) {
|
|
2703
|
+
if (!lastActivityAt) {
|
|
2704
|
+
return null;
|
|
2705
|
+
}
|
|
2706
|
+
const ms = Date.parse(lastActivityAt);
|
|
2707
|
+
if (!Number.isFinite(ms)) {
|
|
2708
|
+
return null;
|
|
2709
|
+
}
|
|
2710
|
+
const diff = Date.now() - ms;
|
|
2711
|
+
if (diff < 0) {
|
|
2712
|
+
return 0;
|
|
2713
|
+
}
|
|
2714
|
+
return Math.floor(diff / 60_000);
|
|
2715
|
+
}
|
|
2716
|
+
async function workspaceMutate(action, args, options) {
|
|
2717
|
+
const id = args[0];
|
|
2718
|
+
if (!id) {
|
|
2719
|
+
throw new Error(`Usage: rudder cloud workspace ${action} <id>`);
|
|
2720
|
+
}
|
|
2721
|
+
const client = await cloudClient({ requireToken: true });
|
|
2722
|
+
const result = await client.request(`/api/rudder/workspace/${encodeURIComponent(id)}/${action}`, {
|
|
2723
|
+
method: "POST",
|
|
2724
|
+
body: {},
|
|
2725
|
+
});
|
|
2726
|
+
if (options.json) {
|
|
2727
|
+
printJson(result);
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
const record = result && typeof result === "object" && !Array.isArray(result)
|
|
2731
|
+
? result
|
|
2732
|
+
: {};
|
|
2733
|
+
const status = typeof record.status === "string" ? record.status : action;
|
|
2734
|
+
console.log(`Workspace ${id} ${status}.`);
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Strip `--yes`/`-y` out of a subcommand's args. Destructive commands confirm
|
|
2738
|
+
* unless one of these (or the global `--force`) is given.
|
|
2739
|
+
*/
|
|
2740
|
+
function takeYesFlag(args, options) {
|
|
2741
|
+
const rest = args.filter((arg) => arg !== "--yes" && arg !== "-y");
|
|
2742
|
+
return { args: rest, yes: Boolean(options.yes) || rest.length !== args.length };
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Ask before destroying something in the cloud. Never assumes yes: without a
|
|
2746
|
+
* terminal to ask on, the caller must pass --yes explicitly.
|
|
2747
|
+
*/
|
|
2748
|
+
async function confirmCloudDeletion(what, yes) {
|
|
2749
|
+
if (yes) {
|
|
2750
|
+
return true;
|
|
2751
|
+
}
|
|
2752
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2753
|
+
throw new Error(`Refusing to delete ${what} without confirmation; pass --yes to confirm.`);
|
|
2754
|
+
}
|
|
2755
|
+
const readline = await import("node:readline/promises");
|
|
2756
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2757
|
+
try {
|
|
2758
|
+
const answer = await rl.question(`Delete ${what}? This destroys its cloud machine and data and cannot be undone. [y/N] `);
|
|
2759
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
2760
|
+
}
|
|
2761
|
+
finally {
|
|
2762
|
+
rl.close();
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
function describeDeletion(kind, id, result) {
|
|
2766
|
+
const record = result && typeof result === "object" && !Array.isArray(result)
|
|
2767
|
+
? result
|
|
2768
|
+
: {};
|
|
2769
|
+
if (record.status === "deleted") {
|
|
2770
|
+
return `Deleted ${kind} ${id}.`;
|
|
2771
|
+
}
|
|
2772
|
+
const reason = typeof record.error === "string" ? ` (${record.error})` : "";
|
|
2773
|
+
return `Deleting ${kind} ${id}: Rudder Cloud is finishing the teardown and will retry until it is gone${reason}.`;
|
|
2774
|
+
}
|
|
2775
|
+
async function deleteSail(args, options) {
|
|
2776
|
+
const { args: rest, yes } = takeYesFlag(args, options);
|
|
2777
|
+
const id = rest[0];
|
|
2778
|
+
if (!id) {
|
|
2779
|
+
throw new Error("Usage: rudder cloud delete <sail-id> [--yes]");
|
|
2780
|
+
}
|
|
2781
|
+
if (!(await confirmCloudDeletion(`cloud sail ${id}`, yes))) {
|
|
2782
|
+
console.log("Kept it; nothing was deleted.");
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
const client = await cloudClient({ requireToken: true });
|
|
2786
|
+
const result = await client.request(`/api/rudder/sail/${encodeURIComponent(id)}/delete`, {
|
|
2787
|
+
method: "POST",
|
|
2788
|
+
body: {},
|
|
2789
|
+
});
|
|
2790
|
+
if (options.json) {
|
|
2791
|
+
printJson(result);
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
console.log(describeDeletion("sail", id, result));
|
|
2795
|
+
}
|
|
2796
|
+
async function workspaceDelete(args, options) {
|
|
2797
|
+
const { args: rest, yes } = takeYesFlag(args, options);
|
|
2798
|
+
// No id: this repo's workspace, the same one `workspace status` reports.
|
|
2799
|
+
const workspace = rest[0] ? await lookupWorkspaceById(rest[0]) : await lookupWorkspaceForRepo(options);
|
|
2800
|
+
const id = workspace && typeof workspace.id === "string" ? workspace.id : rest[0];
|
|
2801
|
+
if (!workspace || !id) {
|
|
2802
|
+
throw new Error(rest[0] ? `No cloud workspace ${rest[0]}.` : "No cloud workspace for this repo.");
|
|
2803
|
+
}
|
|
2804
|
+
const repo = typeof workspace.repoName === "string" ? ` (${workspace.repoName})` : "";
|
|
2805
|
+
const agents = typeof workspace.activeAgents === "number" && workspace.activeAgents > 0
|
|
2806
|
+
? `, which has ${workspace.activeAgents} agent(s) running`
|
|
2807
|
+
: "";
|
|
2808
|
+
if (!(await confirmCloudDeletion(`cloud workspace ${id}${repo}${agents}`, yes))) {
|
|
2809
|
+
console.log("Kept it; nothing was deleted.");
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
const client = await cloudClient({ requireToken: true });
|
|
2813
|
+
const result = await client.request(`/api/rudder/workspace/${encodeURIComponent(id)}/delete`, {
|
|
2814
|
+
method: "POST",
|
|
2815
|
+
body: {},
|
|
2816
|
+
});
|
|
2817
|
+
if (options.json) {
|
|
2818
|
+
printJson(result);
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
console.log(describeDeletion("workspace", id, result));
|
|
2822
|
+
}
|
|
2823
|
+
async function workspaceList(options) {
|
|
2824
|
+
const client = await cloudClient({ requireToken: true });
|
|
2825
|
+
const result = await client.request("/api/rudder/workspace", { method: "GET" });
|
|
2826
|
+
await printResult(result, options);
|
|
2827
|
+
}
|
|
2828
|
+
async function attach(args, options) {
|
|
2829
|
+
const sailId = args[0];
|
|
2830
|
+
if (!sailId) {
|
|
2831
|
+
// In a repo, `rudder cloud attach` is coming back to it: open it on the
|
|
2832
|
+
// home machine (waking it if needed) and attach, without the first-run
|
|
2833
|
+
// offers `rudder cloud` makes.
|
|
2834
|
+
await openOnHome(false, { ...options, reattach: true });
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
|
|
2838
|
+
}
|
|
2839
|
+
const ATTACH_PING_MS = 5_000;
|
|
2840
|
+
const ATTACH_PONG_TIMEOUT_MS = 20_000;
|
|
2841
|
+
const ATTACH_SLEEP_GAP_MS = 15_000;
|
|
2842
|
+
// Retry at once (a blip, a deploy), then back off; never more than 5s apart,
|
|
2843
|
+
// so coming back to a reachable cloud never waits long.
|
|
2844
|
+
const ATTACH_RETRY_DELAYS_MS = [250, 1_000, 2_000, 3_000, 5_000];
|
|
2845
|
+
// About half an hour of failed tries while awake (asleep, timers do not run).
|
|
2846
|
+
const ATTACH_MAX_RETRIES = 360;
|
|
2847
|
+
const ATTACH_REOPEN_TIMEOUT_MS = 20_000;
|
|
2848
|
+
const ATTACH_HANDSHAKE_TIMEOUT_MS = 20_000;
|
|
2849
|
+
// The remote TUI pushes the kitty keyboard enhancement flags once, in its own
|
|
2850
|
+
// setup_terminal(), and the worker only keeps the TAIL of its output — so by the
|
|
2851
|
+
// time an attach connects, that push is long gone and this terminal never sees
|
|
2852
|
+
// it. Same class of bug as the mouse modes below. Without the protocol, macOS
|
|
2853
|
+
// terminals report Option+h as the character it TYPES (˙ on a US layout) rather
|
|
2854
|
+
// than Alt+h, so ⌥h stops hiding panes, and Alt+[ / Alt+] cannot be encoded at
|
|
2855
|
+
// all. AttachSession.hold pushes exactly what the remote asked for:
|
|
2856
|
+
// DISAMBIGUATE_ESCAPE_CODES (1) | REPORT_ALTERNATE_KEYS (4). Terminals without
|
|
2857
|
+
// the protocol ignore it.
|
|
2858
|
+
// Stay attached through a dropped link, a control-plane deploy or a machine
|
|
2859
|
+
// restart. One AttachSession holds the terminal the whole time, so a drop is a
|
|
2860
|
+
// badge in the corner and a reconnect, never a trip back to the shell.
|
|
2861
|
+
async function attachWithReconnect(target, options, reopen) {
|
|
2862
|
+
if (options.latencyProbe) {
|
|
2863
|
+
return await runAttach(target, options);
|
|
2864
|
+
}
|
|
2865
|
+
const stdin = process.stdin;
|
|
2866
|
+
const stdout = process.stdout;
|
|
2867
|
+
const session = new AttachSession(target.label, stdin, stdout, {
|
|
2868
|
+
interactive: Boolean(stdin.isTTY && stdout.isTTY) && !options.json,
|
|
2869
|
+
predict: process.env.RUDDER_PREDICT !== "0",
|
|
2870
|
+
});
|
|
2871
|
+
let result = "failed";
|
|
2872
|
+
let gaveUp = false;
|
|
2873
|
+
try {
|
|
2874
|
+
result = await runAttach(target, { ...options, quietBanner: false }, session);
|
|
2875
|
+
let attempts = 0;
|
|
2876
|
+
while (result === "dropped" && !session.hasLeft) {
|
|
2877
|
+
attempts += 1;
|
|
2878
|
+
if (attempts > ATTACH_MAX_RETRIES) {
|
|
2879
|
+
gaveUp = true;
|
|
2880
|
+
break;
|
|
2881
|
+
}
|
|
2882
|
+
session.noteAttempt(attempts);
|
|
2883
|
+
const delay = ATTACH_RETRY_DELAYS_MS[Math.min(attempts, ATTACH_RETRY_DELAYS_MS.length) - 1] ?? 5_000;
|
|
2884
|
+
if (await session.wait(delay)) {
|
|
2885
|
+
break;
|
|
2886
|
+
}
|
|
2887
|
+
try {
|
|
2888
|
+
if (reopen) {
|
|
2889
|
+
// Wakes the machine if it was what went away. Bounded: a hung
|
|
2890
|
+
// request must not stall the retries, and Ctrl+C twice still leaves.
|
|
2891
|
+
await raceLeave(session, withTimeout(reopen(), ATTACH_REOPEN_TIMEOUT_MS));
|
|
2892
|
+
}
|
|
2893
|
+
if (session.hasLeft) {
|
|
2894
|
+
break;
|
|
2895
|
+
}
|
|
2896
|
+
result = await runAttach(target, { ...options, quietBanner: true }, session);
|
|
2897
|
+
attempts = 0;
|
|
2898
|
+
}
|
|
2899
|
+
catch {
|
|
2900
|
+
result = "dropped";
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (session.hasLeft && result === "dropped") {
|
|
2904
|
+
result = "exited";
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
finally {
|
|
2908
|
+
session.release();
|
|
2909
|
+
}
|
|
2910
|
+
if (gaveUp) {
|
|
2911
|
+
process.stderr.write(`\nCould not reach Rudder Cloud for a while. Your agents are still running there; run \`rudder cloud\` to come back.\n`);
|
|
2912
|
+
}
|
|
2913
|
+
else if (session.hasLeft && result === "exited") {
|
|
2914
|
+
process.stderr.write(`\nLeft ${target.label}. Your agents keep running; run \`rudder cloud\` to come back.\n`);
|
|
2915
|
+
}
|
|
2916
|
+
return result;
|
|
2917
|
+
}
|
|
2918
|
+
function withTimeout(promise, ms) {
|
|
2919
|
+
return new Promise((resolve, reject) => {
|
|
2920
|
+
const timer = setTimeout(() => reject(new Error("timed out")), ms);
|
|
2921
|
+
timer.unref?.();
|
|
2922
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
function raceLeave(session, promise) {
|
|
2926
|
+
return new Promise((resolve, reject) => {
|
|
2927
|
+
const off = session.onLeave(() => resolve(undefined));
|
|
2928
|
+
promise.then((value) => { off(); resolve(value); }, (error) => { off(); reject(error); });
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
// One pasted file up to the home machine, through the phone's upload route.
|
|
2932
|
+
async function uploadPastedFile(baseUrl, token, workspaceId, file, name) {
|
|
2933
|
+
const bytes = await fsp.readFile(file);
|
|
2934
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/app/upload?workspace=${encodeURIComponent(workspaceId)}&name=${encodeURIComponent(name)}`;
|
|
2935
|
+
const response = await fetch(url, {
|
|
2936
|
+
method: "POST",
|
|
2937
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/octet-stream" },
|
|
2938
|
+
body: bytes,
|
|
2939
|
+
signal: AbortSignal.timeout(90_000),
|
|
2940
|
+
});
|
|
2941
|
+
const body = await response.json().catch(() => ({}));
|
|
2942
|
+
if (!response.ok || typeof body.path !== "string") {
|
|
2943
|
+
throw new Error(body.error || `HTTP ${response.status}`);
|
|
2944
|
+
}
|
|
2945
|
+
return body.path;
|
|
2946
|
+
}
|
|
2947
|
+
async function runAttach(target, options, sharedSession) {
|
|
2948
|
+
const client = await cloudClient({ requireToken: true });
|
|
2949
|
+
const baseUrl = client.baseUrl;
|
|
2950
|
+
const state = await loadCloudAuth();
|
|
2951
|
+
const envToken = process.env.RUDDER_CLOUD_TOKEN?.trim();
|
|
2952
|
+
const token = envToken || (state?.cloudUrl === baseUrl ? state.token : undefined);
|
|
2953
|
+
if (!token) {
|
|
2954
|
+
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
2955
|
+
}
|
|
2956
|
+
const stdin = process.stdin;
|
|
2957
|
+
const stdout = process.stdout;
|
|
2958
|
+
const probeMode = Boolean(options.latencyProbe);
|
|
2959
|
+
const isInteractive = Boolean(stdin.isTTY && stdout.isTTY) && !probeMode;
|
|
2960
|
+
// A shared session outlives this connection (attachWithReconnect); an own
|
|
2961
|
+
// one is released with it.
|
|
2962
|
+
const session = sharedSession ?? new AttachSession(target.label, stdin, stdout, {
|
|
2963
|
+
interactive: isInteractive && !options.json,
|
|
2964
|
+
predict: process.env.RUDDER_PREDICT !== "0",
|
|
2965
|
+
});
|
|
2966
|
+
const reconnects = Boolean(sharedSession);
|
|
2967
|
+
// The dashboard is already on the screen (a reconnect, not a first attach).
|
|
2968
|
+
const resuming = session.connections > 0 && session.drewFrame;
|
|
2969
|
+
// The relay hands control back to this same terminal when it returns,
|
|
2970
|
+
// instead of keeping its dead socket in charge until a ping times out.
|
|
2971
|
+
const wsUrl = baseUrl.replace(/^http/, "ws").replace(/\/$/, "")
|
|
2972
|
+
+ `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach?client=${encodeURIComponent(session.clientId)}`;
|
|
2973
|
+
return await new Promise((resolve, reject) => {
|
|
2974
|
+
const socket = new WebSocket(wsUrl, {
|
|
2975
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2976
|
+
// Do NOT negotiate permessage-deflate on the attach client. Keystrokes and
|
|
2977
|
+
// echoes are tiny (uncompressed either way), the remote dashboard writes
|
|
2978
|
+
// cell-diffs, and with deflate enabled every redraw over 1KB pays a zlib
|
|
2979
|
+
// round on BOTH sides — on the relay's event loop that also head-of-line
|
|
2980
|
+
// blocks the keystroke frames queued behind a redraw burst, which is
|
|
2981
|
+
// exactly "typing feels slow while the agent is streaming". Raw frames
|
|
2982
|
+
// are bounded (~100KB worst-case repaint), so the bandwidth cost of
|
|
2983
|
+
// skipping compression is negligible next to the latency it removes.
|
|
2984
|
+
perMessageDeflate: false,
|
|
2985
|
+
// A connect into a dead network can otherwise sit for minutes.
|
|
2986
|
+
handshakeTimeout: ATTACH_HANDSHAKE_TIMEOUT_MS,
|
|
2987
|
+
});
|
|
2988
|
+
socket.binaryType = "nodebuffer";
|
|
2989
|
+
// Every deliberate close goes through socket.close() from this side; any
|
|
2990
|
+
// other close is the far end going away.
|
|
2991
|
+
let closedByUs = false;
|
|
2992
|
+
const closeSocket = socket.close.bind(socket);
|
|
2993
|
+
socket.close = ((code, reason) => {
|
|
2994
|
+
closedByUs = true;
|
|
2995
|
+
closeSocket(code, reason);
|
|
2996
|
+
});
|
|
2997
|
+
let opened = false;
|
|
2998
|
+
let cleaned = false;
|
|
2999
|
+
let firstFrameRendered = resuming;
|
|
3000
|
+
// What the relay last said about the machine end: "down" only once it has
|
|
3001
|
+
// said so, so a key typed before the first status still goes through.
|
|
3002
|
+
let workerState = "unknown";
|
|
3003
|
+
let hasControl = true;
|
|
3004
|
+
let result = "failed";
|
|
3005
|
+
const splashAllowed = isInteractive && !options.json && !options.quietBanner && !resuming;
|
|
3006
|
+
const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
|
|
3007
|
+
// Predictive local echo (src/predict.ts): typed characters appear at the
|
|
3008
|
+
// caret at once and the cloud's frame confirms them. RUDDER_PREDICT=0
|
|
3009
|
+
// turns the drawing off; the model stays, the reconnect badge uses it.
|
|
3010
|
+
const predictor = session.predictor;
|
|
3011
|
+
const sendResize = () => {
|
|
3012
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
const cols = stdout.columns ?? 120;
|
|
3016
|
+
const rows = stdout.rows ?? 32;
|
|
3017
|
+
predictor?.resize(cols, rows);
|
|
3018
|
+
socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
3019
|
+
};
|
|
3020
|
+
// Ask the dashboard to draw every cell again. The replay the relay sends
|
|
3021
|
+
// on attach is only the tail of its output; this makes the screen exact.
|
|
3022
|
+
const requestRepaint = () => {
|
|
3023
|
+
if (socket.readyState === WebSocket.OPEN && !probeMode) {
|
|
3024
|
+
socket.send(JSON.stringify({ type: "repaint" }));
|
|
3025
|
+
}
|
|
3026
|
+
};
|
|
3027
|
+
const finish = (value, message) => {
|
|
3028
|
+
cleanup(false);
|
|
3029
|
+
if (message) {
|
|
3030
|
+
process.stderr.write(message);
|
|
3031
|
+
}
|
|
3032
|
+
resolve(value);
|
|
3033
|
+
};
|
|
3034
|
+
// Files and clipboard images from this computer reach the cloud machine
|
|
3035
|
+
// as uploads (src/paste-bridge.ts); only the home's repo rows take them.
|
|
3036
|
+
const paste = new PasteBridge({
|
|
3037
|
+
send: (data) => {
|
|
3038
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
3039
|
+
socket.send(data, { binary: true });
|
|
3040
|
+
predictor?.onInput(data);
|
|
3041
|
+
}
|
|
3042
|
+
},
|
|
3043
|
+
upload: (file, name) => uploadPastedFile(baseUrl, token, target.id, file, name),
|
|
3044
|
+
readClipboard: target.kind === "workspace" && isInteractive ? readLocalClipboard : undefined,
|
|
3045
|
+
status: (text) => (text ? session.showStatus(text) : session.clearStatus()),
|
|
3046
|
+
});
|
|
3047
|
+
if (target.kind !== "workspace") {
|
|
3048
|
+
paste.disabled = true;
|
|
3049
|
+
}
|
|
3050
|
+
let lastCtrlC = 0;
|
|
3051
|
+
const onStdin = (chunk) => {
|
|
3052
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
3053
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
const hasCtrlC = containsCtrlC(buffer);
|
|
3057
|
+
// While the loading splash is up (no remote frame has rendered yet),
|
|
3058
|
+
// Ctrl+C should cancel the local attach instead of being forwarded to
|
|
3059
|
+
// a remote dashboard the user can't see.
|
|
3060
|
+
if (hasCtrlC && !firstFrameRendered) {
|
|
3061
|
+
try {
|
|
3062
|
+
socket.close(1000, "client-cancel");
|
|
3063
|
+
}
|
|
3064
|
+
catch { /* ignore */ }
|
|
3065
|
+
finish("exited", "\nCancelled.\n");
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
// An observer must always be able to leave without sending an interrupt
|
|
3069
|
+
// into the controller's shared PTY.
|
|
3070
|
+
if (hasCtrlC && !hasControl) {
|
|
3071
|
+
try {
|
|
3072
|
+
socket.close(1000, "read-only-client-cancel");
|
|
3073
|
+
}
|
|
3074
|
+
catch { /* ignore */ }
|
|
3075
|
+
finish("exited", "\nDetached read-only cloud attachment.\n");
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
if (!hasControl) {
|
|
3079
|
+
session.showStatus("Read-only · another terminal has control");
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
// The machine end is away (its dashboard restarting, the machine
|
|
3083
|
+
// waking). Forwarding Ctrl+C would kill the next dashboard as soon as it
|
|
3084
|
+
// is back, so it never goes; pressed twice it leaves this attach.
|
|
3085
|
+
if (hasCtrlC && workerState === "down") {
|
|
3086
|
+
if (session.ctrlCToLeave()) {
|
|
3087
|
+
try {
|
|
3088
|
+
socket.close(1000, "client-cancel-disconnected");
|
|
3089
|
+
}
|
|
3090
|
+
catch { /* ignore */ }
|
|
3091
|
+
finish("exited");
|
|
3092
|
+
}
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
// After handoff: forward Ctrl+C to the remote so Claude/codex can be
|
|
3096
|
+
// cancelled, but if the user mashes Ctrl+C twice within 2 seconds we
|
|
3097
|
+
// take it as "the remote is unresponsive; get me out".
|
|
3098
|
+
if (hasCtrlC && firstFrameRendered) {
|
|
3099
|
+
const now = Date.now();
|
|
3100
|
+
if (now - lastCtrlC < 2000) {
|
|
3101
|
+
try {
|
|
3102
|
+
socket.close(1000, "client-force-quit");
|
|
3103
|
+
}
|
|
3104
|
+
catch { /* ignore */ }
|
|
3105
|
+
finish("exited", "\nForce-exiting local attach (press Ctrl+C again to re-enter).\n");
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
lastCtrlC = now;
|
|
3109
|
+
}
|
|
3110
|
+
paste.forward(buffer);
|
|
3111
|
+
};
|
|
3112
|
+
const onResize = () => {
|
|
3113
|
+
if (hasControl) {
|
|
3114
|
+
sendResize();
|
|
3115
|
+
}
|
|
3116
|
+
splash?.redraw();
|
|
3117
|
+
session.noteResize();
|
|
3118
|
+
};
|
|
3119
|
+
// Liveness. A laptop that slept leaves this socket half-open: nothing
|
|
3120
|
+
// errors, the screen just freezes, and the relay has meanwhile dropped us
|
|
3121
|
+
// (or handed control to the next terminal). Ping the relay, and treat a
|
|
3122
|
+
// missed pong or a timer that fired far too late (the machine was asleep)
|
|
3123
|
+
// as a dropped connection, which attachWithReconnect reopens.
|
|
3124
|
+
let lastHeard = Date.now();
|
|
3125
|
+
let lastTick = Date.now();
|
|
3126
|
+
let pingSentAt = 0;
|
|
3127
|
+
socket.on("pong", () => {
|
|
3128
|
+
lastHeard = Date.now();
|
|
3129
|
+
if (pingSentAt) {
|
|
3130
|
+
predictor?.noteRtt(Date.now() - pingSentAt);
|
|
3131
|
+
pingSentAt = 0;
|
|
3132
|
+
}
|
|
3133
|
+
});
|
|
3134
|
+
socket.on("message", () => { lastHeard = Date.now(); });
|
|
3135
|
+
const liveness = setInterval(() => {
|
|
3136
|
+
const now = Date.now();
|
|
3137
|
+
const slept = now - lastTick > ATTACH_SLEEP_GAP_MS;
|
|
3138
|
+
lastTick = now;
|
|
3139
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
if (slept || now - lastHeard > ATTACH_PONG_TIMEOUT_MS) {
|
|
3143
|
+
socket.terminate();
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
try {
|
|
3147
|
+
socket.ping();
|
|
3148
|
+
pingSentAt = Date.now();
|
|
3149
|
+
}
|
|
3150
|
+
catch {
|
|
3151
|
+
socket.terminate();
|
|
3152
|
+
}
|
|
3153
|
+
}, ATTACH_PING_MS);
|
|
3154
|
+
liveness.unref?.();
|
|
3155
|
+
// Leaving (Ctrl+C twice) while this connection is still being made.
|
|
3156
|
+
const offLeave = session.onLeave(() => {
|
|
3157
|
+
if (!opened && !cleaned) {
|
|
3158
|
+
try {
|
|
3159
|
+
socket.terminate();
|
|
3160
|
+
}
|
|
3161
|
+
catch { /* ignore */ }
|
|
3162
|
+
cleanup(false);
|
|
3163
|
+
resolve("exited");
|
|
3164
|
+
}
|
|
3165
|
+
});
|
|
3166
|
+
// keepTerminal: the link dropped (or a reconnect could not open) and the
|
|
3167
|
+
// caller reconnects, so the screen, raw mode and the keys stay ours.
|
|
3168
|
+
const cleanup = (keepTerminal) => {
|
|
3169
|
+
if (cleaned) {
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
cleaned = true;
|
|
3173
|
+
offLeave();
|
|
3174
|
+
clearInterval(liveness);
|
|
3175
|
+
stdin.off("data", onStdin);
|
|
3176
|
+
stdout.off("resize", onResize);
|
|
3177
|
+
process.off("SIGINT", onSigint);
|
|
3178
|
+
splash?.stop();
|
|
3179
|
+
if (keepTerminal) {
|
|
3180
|
+
session.enterGap();
|
|
3181
|
+
}
|
|
3182
|
+
else {
|
|
3183
|
+
session.release();
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
const onSigint = () => {
|
|
3187
|
+
// Belt-and-suspenders: if raw mode failed for any reason, Node still
|
|
3188
|
+
// sees SIGINT. Close cleanly so the user never gets a frozen terminal.
|
|
3189
|
+
try {
|
|
3190
|
+
socket.close(1000, "sigint");
|
|
3191
|
+
}
|
|
3192
|
+
catch { /* ignore */ }
|
|
3193
|
+
cleanup(false);
|
|
3194
|
+
process.stderr.write("\nReceived SIGINT, closing cloud attach.\n");
|
|
3195
|
+
if (!opened) {
|
|
3196
|
+
reject(new Error("Cloud attach cancelled"));
|
|
3197
|
+
}
|
|
3198
|
+
else {
|
|
3199
|
+
resolve("exited");
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
process.once("SIGINT", onSigint);
|
|
3203
|
+
// --latency-probe state: transport samples resolve on a `probe-reply`
|
|
3204
|
+
// control frame from the worker (pure WS relay round trip); echo samples
|
|
3205
|
+
// resolve on the next binary frame after sending a printable keystroke
|
|
3206
|
+
// (full pipeline including the remote TUI render).
|
|
3207
|
+
const probeReplies = new Map();
|
|
3208
|
+
let probeEchoWaiter = null;
|
|
3209
|
+
let probeSeq = 0;
|
|
3210
|
+
const runLatencyProbe = async () => {
|
|
3211
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3212
|
+
const SAMPLES = 20;
|
|
3213
|
+
const transport = [];
|
|
3214
|
+
const echo = [];
|
|
3215
|
+
// Let the remote dashboard finish its initial redraw burst so spinner
|
|
3216
|
+
// frames don't get mistaken for keystroke echoes.
|
|
3217
|
+
await sleep(750);
|
|
3218
|
+
for (let i = 0; i < SAMPLES; i += 1) {
|
|
3219
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
3220
|
+
break;
|
|
3221
|
+
const id = ++probeSeq;
|
|
3222
|
+
const sentAt = performance.now();
|
|
3223
|
+
const repliedAt = await new Promise((resolveReply) => {
|
|
3224
|
+
const timer = setTimeout(() => {
|
|
3225
|
+
probeReplies.delete(id);
|
|
3226
|
+
resolveReply(null);
|
|
3227
|
+
}, 2000);
|
|
3228
|
+
probeReplies.set(id, (at) => {
|
|
3229
|
+
clearTimeout(timer);
|
|
3230
|
+
probeReplies.delete(id);
|
|
3231
|
+
resolveReply(at);
|
|
3232
|
+
});
|
|
3233
|
+
socket.send(JSON.stringify({ type: "probe", id }));
|
|
3234
|
+
});
|
|
3235
|
+
if (repliedAt !== null)
|
|
3236
|
+
transport.push(repliedAt - sentAt);
|
|
3237
|
+
await sleep(100);
|
|
3238
|
+
}
|
|
3239
|
+
for (let i = 0; i < SAMPLES; i += 1) {
|
|
3240
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
3241
|
+
break;
|
|
3242
|
+
const sentAt = performance.now();
|
|
3243
|
+
const echoedAt = await new Promise((resolveFrame) => {
|
|
3244
|
+
const timer = setTimeout(() => {
|
|
3245
|
+
probeEchoWaiter = null;
|
|
3246
|
+
resolveFrame(null);
|
|
3247
|
+
}, 2000);
|
|
3248
|
+
probeEchoWaiter = (at) => {
|
|
3249
|
+
clearTimeout(timer);
|
|
3250
|
+
probeEchoWaiter = null;
|
|
3251
|
+
resolveFrame(at);
|
|
3252
|
+
};
|
|
3253
|
+
socket.send(Buffer.from("a"), { binary: true });
|
|
3254
|
+
});
|
|
3255
|
+
if (echoedAt !== null)
|
|
3256
|
+
echo.push(echoedAt - sentAt);
|
|
3257
|
+
// Undo the probe keystroke so the remote input box is left untouched.
|
|
3258
|
+
socket.send(Buffer.from("\x7f"), { binary: true });
|
|
3259
|
+
await sleep(250);
|
|
3260
|
+
}
|
|
3261
|
+
const stats = (values) => {
|
|
3262
|
+
if (values.length === 0)
|
|
3263
|
+
return "no samples (timed out)";
|
|
3264
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
3265
|
+
const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0;
|
|
3266
|
+
const max = sorted[sorted.length - 1] ?? 0;
|
|
3267
|
+
return `p50 ${at(0.5).toFixed(1)}ms p95 ${at(0.95).toFixed(1)}ms max ${max.toFixed(1)}ms (${sorted.length}/${SAMPLES} samples)`;
|
|
3268
|
+
};
|
|
3269
|
+
const report = [
|
|
3270
|
+
"",
|
|
3271
|
+
`Latency probe · ${target.label}`,
|
|
3272
|
+
` transport RTT ${stats(transport)}`,
|
|
3273
|
+
` keystroke echo ${stats(echo)}`,
|
|
3274
|
+
"",
|
|
3275
|
+
].join("\n");
|
|
3276
|
+
if (options.json) {
|
|
3277
|
+
process.stdout.write(`${JSON.stringify({ target: target.label, transportMs: transport, echoMs: echo })}\n`);
|
|
3278
|
+
}
|
|
3279
|
+
else {
|
|
3280
|
+
process.stderr.write(report);
|
|
3281
|
+
}
|
|
3282
|
+
result = "exited";
|
|
3283
|
+
try {
|
|
3284
|
+
socket.close(1000, "probe-done");
|
|
3285
|
+
}
|
|
3286
|
+
catch { /* ignore */ }
|
|
3287
|
+
};
|
|
3288
|
+
socket.on("open", () => {
|
|
3289
|
+
opened = true;
|
|
3290
|
+
session.connections += 1;
|
|
3291
|
+
// From here this connection reads the keys; the gap hands them over.
|
|
3292
|
+
session.exitGap();
|
|
3293
|
+
// Measure the round trip at once: predictive echo waits on it.
|
|
3294
|
+
try {
|
|
3295
|
+
socket.ping();
|
|
3296
|
+
pingSentAt = Date.now();
|
|
3297
|
+
}
|
|
3298
|
+
catch {
|
|
3299
|
+
// the liveness timer retries
|
|
3300
|
+
}
|
|
3301
|
+
if (probeMode) {
|
|
3302
|
+
// An idle dashboard sends nothing; a resize makes it repaint, which is
|
|
3303
|
+
// the first frame the probe waits for.
|
|
3304
|
+
socket.send(JSON.stringify({ type: "resize", cols: 119, rows: 32 }));
|
|
3305
|
+
setTimeout(() => socket.send(JSON.stringify({ type: "resize", cols: 120, rows: 32 })), 200);
|
|
3306
|
+
}
|
|
3307
|
+
// Disable Nagle on the underlying TCP socket so single keystrokes don't
|
|
3308
|
+
// sit in the send buffer waiting for piggyback ACKs (~40ms savings per
|
|
3309
|
+
// keypress on the WAN).
|
|
3310
|
+
try {
|
|
3311
|
+
const underlying = socket._socket;
|
|
3312
|
+
underlying?.setNoDelay?.(true);
|
|
3313
|
+
}
|
|
3314
|
+
catch {
|
|
3315
|
+
// ignore
|
|
3316
|
+
}
|
|
3317
|
+
// Label this terminal tab so the user can find the right rudder cloud
|
|
3318
|
+
// session at a glance instead of squinting at a row of "ghostty" tabs.
|
|
3319
|
+
if (isInteractive) {
|
|
3320
|
+
try {
|
|
3321
|
+
stdout.write(`\x1b]0;Rudder cloud: ${target.label}\x07`);
|
|
3322
|
+
}
|
|
3323
|
+
catch {
|
|
3324
|
+
// ignore
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
// The alternate screen, raw keys, local mouse capture (the remote's own
|
|
3328
|
+
// enable sequences arrive as output, which the local terminal never
|
|
3329
|
+
// treats as mode switches) and the kitty keyboard flags.
|
|
3330
|
+
if (isInteractive) {
|
|
3331
|
+
session.hold();
|
|
3332
|
+
}
|
|
3333
|
+
if (splash) {
|
|
3334
|
+
splash.start();
|
|
3335
|
+
splash.setStatus(`Booting cloud workspace · ${target.label}`);
|
|
3336
|
+
}
|
|
3337
|
+
else if (!options.json && !options.quietBanner && !resuming) {
|
|
3338
|
+
const tail = isInteractive ? " (Ctrl+C sends to remote; close this pane to detach)" : "";
|
|
3339
|
+
process.stderr.write(`Attached to ${target.label}${tail}\n`);
|
|
3340
|
+
}
|
|
3341
|
+
sendResize();
|
|
3342
|
+
// Typed while the link was down: sent first, in order.
|
|
3343
|
+
const queued = session.takeQueued();
|
|
3344
|
+
if (queued) {
|
|
3345
|
+
socket.send(queued, { binary: true });
|
|
3346
|
+
}
|
|
3347
|
+
requestRepaint();
|
|
3348
|
+
if (!probeMode) {
|
|
3349
|
+
stdin.resume();
|
|
3350
|
+
stdin.on("data", onStdin);
|
|
3351
|
+
}
|
|
3352
|
+
else {
|
|
3353
|
+
// A worker that never boots would otherwise hang the probe forever:
|
|
3354
|
+
// there is no TTY and no human to Ctrl+C it.
|
|
3355
|
+
const firstFrameDeadline = setTimeout(() => {
|
|
3356
|
+
if (!firstFrameRendered) {
|
|
3357
|
+
process.stderr.write("Latency probe timed out waiting for the first remote frame (worker did not boot?).\n");
|
|
3358
|
+
result = "failed";
|
|
3359
|
+
try {
|
|
3360
|
+
socket.close(1000, "probe-timeout");
|
|
3361
|
+
}
|
|
3362
|
+
catch { /* ignore */ }
|
|
3363
|
+
}
|
|
3364
|
+
}, 120_000);
|
|
3365
|
+
firstFrameDeadline.unref?.();
|
|
3366
|
+
}
|
|
3367
|
+
stdout.on("resize", onResize);
|
|
3368
|
+
});
|
|
3369
|
+
socket.on("message", (data, isBinary) => {
|
|
3370
|
+
if (isBinary && Buffer.isBuffer(data)) {
|
|
3371
|
+
if (probeMode) {
|
|
3372
|
+
// Frames are timing signals here, not screen content: the first one
|
|
3373
|
+
// marks the dashboard as live (start probing), later ones resolve a
|
|
3374
|
+
// pending keystroke-echo sample.
|
|
3375
|
+
if (!firstFrameRendered) {
|
|
3376
|
+
firstFrameRendered = true;
|
|
3377
|
+
void runLatencyProbe().catch((err) => {
|
|
3378
|
+
process.stderr.write(`Latency probe failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
3379
|
+
try {
|
|
3380
|
+
socket.close(1000, "probe-failed");
|
|
3381
|
+
}
|
|
3382
|
+
catch { /* ignore */ }
|
|
3383
|
+
});
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
probeEchoWaiter?.(performance.now());
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
if (!session.drewFrame) {
|
|
3390
|
+
session.drewFrame = true;
|
|
3391
|
+
// A link that dropped while the loading screen was up leaves its
|
|
3392
|
+
// text behind; the splash wipes its own on handoff.
|
|
3393
|
+
if (!splash && isInteractive) {
|
|
3394
|
+
stdout.write("\x1b[2J\x1b[H");
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
if (!firstFrameRendered) {
|
|
3398
|
+
firstFrameRendered = true;
|
|
3399
|
+
splash?.handoff();
|
|
3400
|
+
// Re-send resize right after handoff and once more on a small
|
|
3401
|
+
// delay; some terminals don't surface accurate columns/rows
|
|
3402
|
+
// until after alt-screen exits, so the initial resize at WS
|
|
3403
|
+
// open can race.
|
|
3404
|
+
sendResize();
|
|
3405
|
+
setTimeout(sendResize, 150);
|
|
3406
|
+
}
|
|
3407
|
+
stdout.write(data);
|
|
3408
|
+
predictor?.onServerData(data);
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
const text = Buffer.isBuffer(data)
|
|
3412
|
+
? data.toString("utf8")
|
|
3413
|
+
: Array.isArray(data)
|
|
3414
|
+
? Buffer.concat(data).toString("utf8")
|
|
3415
|
+
: Buffer.from(data).toString("utf8");
|
|
3416
|
+
handleControlText(text);
|
|
3417
|
+
});
|
|
3418
|
+
socket.on("close", (code, reason) => {
|
|
3419
|
+
const dropped = opened && !closedByUs && code !== 4410 && code !== 1000;
|
|
3420
|
+
// A reconnect that could not open is part of the same gap.
|
|
3421
|
+
cleanup(reconnects && session.connections > 0 && (dropped || !opened));
|
|
3422
|
+
if (!opened) {
|
|
3423
|
+
const reasonText = reason && reason.length ? reason.toString("utf8") : "";
|
|
3424
|
+
reject(new Error(`Cloud attach failed (code ${code}${reasonText ? `: ${reasonText}` : ""})`));
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
// 4410: the workspace was deleted; nothing to come back to.
|
|
3428
|
+
resolve(dropped ? "dropped" : result);
|
|
3429
|
+
});
|
|
3430
|
+
socket.on("error", (err) => {
|
|
3431
|
+
if (!opened) {
|
|
3432
|
+
cleanup(reconnects && session.connections > 0);
|
|
3433
|
+
reject(new Error(`Cloud attach failed: ${err.message}`));
|
|
3434
|
+
}
|
|
3435
|
+
});
|
|
3436
|
+
function handleControlText(text) {
|
|
3437
|
+
let payload;
|
|
3438
|
+
try {
|
|
3439
|
+
payload = JSON.parse(text);
|
|
3440
|
+
}
|
|
3441
|
+
catch {
|
|
3442
|
+
stdout.write(text);
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
if (!payload || typeof payload !== "object") {
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
const message = payload;
|
|
3449
|
+
if (message.type === "probe-reply") {
|
|
3450
|
+
if (typeof message.id === "number") {
|
|
3451
|
+
probeReplies.get(message.id)?.(performance.now());
|
|
3452
|
+
}
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
if (message.type === "exit") {
|
|
3456
|
+
result = message.code === 0 ? "exited" : "failed";
|
|
3457
|
+
if (typeof process.exitCode !== "number" && message.code !== undefined) {
|
|
3458
|
+
process.exitCode = message.code;
|
|
3459
|
+
}
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
if (message.type !== "status") {
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
const wasDown = workerState === "down";
|
|
3466
|
+
if (message.state === "worker-connected") {
|
|
3467
|
+
workerState = "up";
|
|
3468
|
+
}
|
|
3469
|
+
else if (message.state === "worker-disconnected" || message.state === "worker-waiting") {
|
|
3470
|
+
workerState = "down";
|
|
3471
|
+
}
|
|
3472
|
+
if (message.control === "read-only" || message.state === "read-only") {
|
|
3473
|
+
hasControl = false;
|
|
3474
|
+
}
|
|
3475
|
+
else if (message.control === "active" || message.state === "controller-promoted") {
|
|
3476
|
+
const promoted = !hasControl;
|
|
3477
|
+
hasControl = true;
|
|
3478
|
+
if (promoted || message.state === "controller-promoted") {
|
|
3479
|
+
sendResize();
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
// If the worker dies before we've ever seen a remote frame, the
|
|
3483
|
+
// session is effectively dead. Don't sit on a splash spinner pretending
|
|
3484
|
+
// it'll come back; bail.
|
|
3485
|
+
if (message.state === "worker-disconnected" && !firstFrameRendered) {
|
|
3486
|
+
try {
|
|
3487
|
+
socket.close(1000, "worker-gone");
|
|
3488
|
+
}
|
|
3489
|
+
catch { /* ignore */ }
|
|
3490
|
+
cleanup(false);
|
|
3491
|
+
process.stderr.write("\nCloud worker exited before the dashboard started.\n");
|
|
3492
|
+
resolve("failed");
|
|
3493
|
+
return;
|
|
3494
|
+
}
|
|
3495
|
+
if (splash && !firstFrameRendered) {
|
|
3496
|
+
if (message.state === "worker-waiting") {
|
|
3497
|
+
splash.setStatus(`Waiting for cloud worker · ${target.label}`);
|
|
3498
|
+
}
|
|
3499
|
+
else if (message.state === "worker-connected") {
|
|
3500
|
+
splash.setStatus(`Cloud worker connected · ${target.label}`);
|
|
3501
|
+
sendResize();
|
|
3502
|
+
setTimeout(sendResize, 150);
|
|
3503
|
+
}
|
|
3504
|
+
else if (message.state === "input-buffered") {
|
|
3505
|
+
splash.setStatus(`Reconnecting cloud input · ${target.label}`);
|
|
3506
|
+
}
|
|
3507
|
+
return;
|
|
3508
|
+
}
|
|
3509
|
+
if (options.json || probeMode) {
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
// Past the splash nothing is written into the dashboard's picture: the
|
|
3513
|
+
// state goes in the corner badge and comes off when it is over.
|
|
3514
|
+
if (!hasControl) {
|
|
3515
|
+
session.showStatus("Read-only · another terminal has control");
|
|
3516
|
+
}
|
|
3517
|
+
else if (workerState === "down") {
|
|
3518
|
+
session.showStatus(message.state === "worker-waiting"
|
|
3519
|
+
? "Waiting for the cloud machine · your typing is kept"
|
|
3520
|
+
: "Cloud machine reconnecting · your typing is kept");
|
|
3521
|
+
}
|
|
3522
|
+
else if (workerState === "up" || message.state === "controller-promoted") {
|
|
3523
|
+
if (session.statusText) {
|
|
3524
|
+
session.clearStatus();
|
|
3525
|
+
}
|
|
3526
|
+
if (wasDown && workerState === "up") {
|
|
3527
|
+
// A dashboard that kept running behind a relay restart shows the
|
|
3528
|
+
// same picture; one that restarted paints anew. Either way, exact.
|
|
3529
|
+
requestRepaint();
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
3535
|
+
class AttachSplash {
|
|
3536
|
+
stdout;
|
|
3537
|
+
label;
|
|
3538
|
+
frame = 0;
|
|
3539
|
+
status;
|
|
3540
|
+
timer;
|
|
3541
|
+
active = false;
|
|
3542
|
+
static FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3543
|
+
constructor(stdout, label) {
|
|
3544
|
+
this.stdout = stdout;
|
|
3545
|
+
this.label = label;
|
|
3546
|
+
this.status = `Connecting to ${label}`;
|
|
3547
|
+
}
|
|
3548
|
+
// The alternate screen is AttachSession's (held for the whole attach,
|
|
3549
|
+
// reconnects included); the splash only draws inside it.
|
|
3550
|
+
start() {
|
|
3551
|
+
if (this.active) {
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
this.active = true;
|
|
3555
|
+
this.stdout.write("\x1b[?25l");
|
|
3556
|
+
this.redraw();
|
|
3557
|
+
this.timer = setInterval(() => {
|
|
3558
|
+
this.frame = (this.frame + 1) % AttachSplash.FRAMES.length;
|
|
3559
|
+
this.redraw();
|
|
3560
|
+
}, 100);
|
|
3561
|
+
this.timer.unref?.();
|
|
3562
|
+
}
|
|
3563
|
+
setStatus(status) {
|
|
3564
|
+
this.status = status;
|
|
3565
|
+
if (this.active) {
|
|
3566
|
+
this.redraw();
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
redraw() {
|
|
3570
|
+
if (!this.active) {
|
|
3571
|
+
return;
|
|
3572
|
+
}
|
|
3573
|
+
const cols = this.stdout.columns ?? 80;
|
|
3574
|
+
const rows = this.stdout.rows ?? 24;
|
|
3575
|
+
const spinner = AttachSplash.FRAMES[this.frame] ?? "·";
|
|
3576
|
+
const lines = [
|
|
3577
|
+
`${spinner} ${this.status}`,
|
|
3578
|
+
` Ctrl+C to disconnect`,
|
|
3579
|
+
];
|
|
3580
|
+
const top = Math.max(1, Math.floor(rows / 2) - 1);
|
|
3581
|
+
this.stdout.write("\x1b[2J");
|
|
3582
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3583
|
+
const line = lines[i] ?? "";
|
|
3584
|
+
const visible = stripAnsi(line);
|
|
3585
|
+
const left = Math.max(1, Math.floor((cols - visible.length) / 2) + 1);
|
|
3586
|
+
this.stdout.write(`\x1b[${top + i};${left}H${line}`);
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
handoff() {
|
|
3590
|
+
if (!this.active) {
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
this.stop();
|
|
3594
|
+
// Wipe the splash content first so the remote's partial frames don't
|
|
3595
|
+
// render on top of leftover spinner text.
|
|
3596
|
+
this.stdout.write("\x1b[2J\x1b[H\x1b[?25h");
|
|
3597
|
+
}
|
|
3598
|
+
stop() {
|
|
3599
|
+
if (!this.active) {
|
|
3600
|
+
return;
|
|
3601
|
+
}
|
|
3602
|
+
this.active = false;
|
|
3603
|
+
if (this.timer) {
|
|
3604
|
+
clearInterval(this.timer);
|
|
3605
|
+
this.timer = undefined;
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
function stripAnsi(value) {
|
|
3610
|
+
return value.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
3611
|
+
}
|
|
3612
|
+
async function maybeAutoAttach(result, options) {
|
|
3613
|
+
if (options.json || options.noAttach) {
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3622
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
const record = result;
|
|
3626
|
+
if (typeof record.bootstrapCommand === "string") {
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3629
|
+
const sailId = extractSailId(record);
|
|
3630
|
+
if (!sailId) {
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
try {
|
|
3634
|
+
await attachWithReconnect({ kind: "sail", id: sailId, label: sailId }, options);
|
|
3635
|
+
}
|
|
3636
|
+
catch (error) {
|
|
3637
|
+
console.warn(`Could not attach to ${sailId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
function extractSailId(record) {
|
|
3641
|
+
if (typeof record.id === "string" && record.id) {
|
|
3642
|
+
return record.id;
|
|
3643
|
+
}
|
|
3644
|
+
return undefined;
|
|
3645
|
+
}
|
|
3646
|
+
function printCloudHelp() {
|
|
3647
|
+
console.log(`rudder cloud
|
|
3648
|
+
|
|
3649
|
+
Usage:
|
|
3650
|
+
rudder cloud
|
|
3651
|
+
open this repo on your home machine (cloned from GitHub; one machine
|
|
3652
|
+
holds every repo) and attach to its dashboard. --snapshot uploads this
|
|
3653
|
+
folder to its own machine instead.
|
|
3654
|
+
rudder cloud home [status|start|stop|open <owner/repo>]
|
|
3655
|
+
rudder cloud move [runId...] [--yes]
|
|
3656
|
+
move this repo's unfinished agents (or just the named ones) to the
|
|
3657
|
+
home machine: their jj changes and conversations go along, nothing is
|
|
3658
|
+
pushed to GitHub, and they keep working with this laptop closed
|
|
3659
|
+
rudder cloud app
|
|
3660
|
+
a one-time code that signs the phone app in to this account
|
|
3661
|
+
rudder cloud doctor
|
|
3662
|
+
do the cloud machine's GitHub, Claude and Codex logins work, as the same
|
|
3663
|
+
accounts as this laptop? names the fix for anything that differs
|
|
3664
|
+
rudder cloud login [--github]
|
|
3665
|
+
signs in with Google (or GitHub)
|
|
3666
|
+
rudder cloud help
|
|
3667
|
+
rudder cloud [name or task]
|
|
3668
|
+
rudder cloud launch [--home-path <path>] ["task"]
|
|
3669
|
+
rudder cloud byoc [ssh-host]
|
|
3670
|
+
rudder cloud vm ["task"]
|
|
3671
|
+
rudder cloud list
|
|
3672
|
+
rudder cloud onload [runId]
|
|
3673
|
+
with a runId, hands that agent off to its own cloud machine and
|
|
3674
|
+
resumes its conversation there; no runId uploads the current
|
|
3675
|
+
Rudder workspace state
|
|
3676
|
+
rudder cloud logs <id>
|
|
3677
|
+
rudder cloud attach [id] (no id: reattach this repo on the home machine)
|
|
3678
|
+
stream the live cloud worker terminal into this pane
|
|
3679
|
+
rudder cloud talk <id> "<message>"
|
|
3680
|
+
send a message to a running instance and show its reply
|
|
3681
|
+
rudder cloud output <id>
|
|
3682
|
+
show an instance's latest output
|
|
3683
|
+
rudder cloud delete <id> [--yes]
|
|
3684
|
+
destroy an instance's cloud machine and snapshot (asks first)
|
|
3685
|
+
rudder cloud quickstart
|
|
3686
|
+
print the copy/paste setup for the whole flow
|
|
3687
|
+
rudder cloud slack [manifest]
|
|
3688
|
+
print Slack setup (one thread per instance in the shared channel)
|
|
3689
|
+
rudder cloud workspace [attach [id|owner/repo]|create <owner/repo>|share|status [--json]|pause <id>|resume <id>|stop <id>|delete [id] [--yes]|list]
|
|
3690
|
+
shared cloud workspace for this repo; \`create\` clones from GitHub (cloud-native, no local upload);
|
|
3691
|
+
\`delete\` destroys its machine, volume and snapshot after asking
|
|
3692
|
+
rudder cloud secrets [set <NAME> [value]|set --file <~/path>|list|rm <NAME>|sync]
|
|
3693
|
+
manage the encrypted cloud secrets vault; \`sync\` imports your local credentials once
|
|
3694
|
+
rudder cloud region [<fly-region>|clear]
|
|
3695
|
+
override worker placement (default: next to the relay for lowest typing latency)
|
|
3696
|
+
rudder cloud bootstrap <id>
|
|
3697
|
+
rudder cloud runtime [fly|byoc]
|
|
3698
|
+
rudder cloud setup-byoc <ssh-host> compatibility alias
|
|
3699
|
+
rudder cloud setup-fly
|
|
3700
|
+
rudder sail [name or task]
|
|
3701
|
+
rudder sail list
|
|
3702
|
+
rudder sail pause <id>
|
|
3703
|
+
rudder sail resume <id>
|
|
3704
|
+
rudder cloud setup-github <client-id>
|
|
3705
|
+
rudder cloud setup-google <client-id>
|
|
3706
|
+
|
|
3707
|
+
Environment:
|
|
3708
|
+
RUDDER_CLOUD_URL Cloud control plane URL (defaults to ${DEFAULT_CLOUD_URL})
|
|
3709
|
+
RUDDER_CLOUD_RUNTIME fly, byoc, or byo-vm (overrides saved local default)
|
|
3710
|
+
RUDDER_CLOUD_HOME_PATHS Extra comma-separated HOME paths to include in snapshots
|
|
3711
|
+
RUDDER_GITHUB_CLIENT_ID GitHub App OAuth client ID for setup-github
|
|
3712
|
+
RUDDER_GITHUB_CLIENT_SECRET GitHub App OAuth client secret for setup-github
|
|
3713
|
+
RUDDER_GOOGLE_CLIENT_ID Google OAuth client ID for setup-google
|
|
3714
|
+
RUDDER_GOOGLE_CLIENT_SECRET Google OAuth client secret for setup-google
|
|
3715
|
+
`);
|
|
3716
|
+
}
|
|
3717
|
+
const CLOUD_ADJECTIVES = [
|
|
3718
|
+
"amber",
|
|
3719
|
+
"bright",
|
|
3720
|
+
"calm",
|
|
3721
|
+
"clear",
|
|
3722
|
+
"cosmic",
|
|
3723
|
+
"gentle",
|
|
3724
|
+
"golden",
|
|
3725
|
+
"lucky",
|
|
3726
|
+
"rapid",
|
|
3727
|
+
"silver",
|
|
3728
|
+
"steady",
|
|
3729
|
+
"swift",
|
|
3730
|
+
];
|
|
3731
|
+
const CLOUD_NOUNS = [
|
|
3732
|
+
"atlas",
|
|
3733
|
+
"harbor",
|
|
3734
|
+
"signal",
|
|
3735
|
+
"summit",
|
|
3736
|
+
"orbit",
|
|
3737
|
+
"ranger",
|
|
3738
|
+
"river",
|
|
3739
|
+
"rocket",
|
|
3740
|
+
"sparrow",
|
|
3741
|
+
"station",
|
|
3742
|
+
"voyager",
|
|
3743
|
+
"wave",
|
|
3744
|
+
];
|
|
3745
|
+
function randomCloudName() {
|
|
3746
|
+
const seed = Date.now() + process.pid + Math.floor(Math.random() * 1_000_000);
|
|
3747
|
+
return [
|
|
3748
|
+
CLOUD_ADJECTIVES[Math.abs(seed) % CLOUD_ADJECTIVES.length],
|
|
3749
|
+
CLOUD_NOUNS[Math.abs(Math.floor(seed / CLOUD_ADJECTIVES.length)) % CLOUD_NOUNS.length],
|
|
3750
|
+
].join("-");
|
|
3751
|
+
}
|
|
3752
|
+
function cloudNameFromTask(task) {
|
|
3753
|
+
const slug = task
|
|
3754
|
+
.toLowerCase()
|
|
3755
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
3756
|
+
.replace(/^-+|-+$/g, "")
|
|
3757
|
+
.slice(0, 36)
|
|
3758
|
+
.replace(/-+$/g, "");
|
|
3759
|
+
return slug || randomCloudName();
|
|
3760
|
+
}
|
|
3761
|
+
async function repoOpenOnHome(client, slug) {
|
|
3762
|
+
try {
|
|
3763
|
+
const result = await client.request("/api/rudder/home", { method: "GET" });
|
|
3764
|
+
return (result.projects ?? []).some((project) => String(project.repo ?? "").toLowerCase() === slug.toLowerCase());
|
|
3765
|
+
}
|
|
3766
|
+
catch {
|
|
3767
|
+
return false;
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
async function openOnHome(forceSnapshot, options) {
|
|
3771
|
+
if (forceSnapshot) {
|
|
3772
|
+
await workspaceAttach([], options);
|
|
3773
|
+
return;
|
|
3774
|
+
}
|
|
3775
|
+
const repoRoot = findRepoRoot();
|
|
3776
|
+
const slug = await githubSlugFromOrigin(repoRoot);
|
|
3777
|
+
if (!slug) {
|
|
3778
|
+
if (!options.json) {
|
|
3779
|
+
process.stderr.write("This repo has no GitHub origin, so it can't be cloned on the home machine; uploading a snapshot workspace instead.\n");
|
|
3780
|
+
}
|
|
3781
|
+
await workspaceAttach([], options);
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
3784
|
+
const client = await cloudClient({ requireToken: true });
|
|
3785
|
+
const name = slug.split("/")[1];
|
|
3786
|
+
// Coming back (laptop reopened, terminal closed): a repo already open on
|
|
3787
|
+
// the home machine reattaches straight away, like `rudder cloud attach`.
|
|
3788
|
+
// Credentials are still compared, and running local agents still offered a
|
|
3789
|
+
// move, because those are about this laptop now.
|
|
3790
|
+
const reattach = options.reattach || await repoOpenOnHome(client, slug);
|
|
3791
|
+
if (!options.json) {
|
|
3792
|
+
if (!reattach) {
|
|
3793
|
+
await warnLocalOnlyWork(repoRoot);
|
|
3794
|
+
}
|
|
3795
|
+
await ensureVaultGithubToken(client, slug);
|
|
3796
|
+
await ensureVaultAws(client);
|
|
3797
|
+
if (!reattach) {
|
|
3798
|
+
await offerEnvFilesToVault(client, repoRoot, name);
|
|
3799
|
+
}
|
|
3800
|
+
await offerToMoveAgents(client, repoRoot, slug);
|
|
3801
|
+
process.stderr.write(`Opening ${slug} on your home machine...\n`);
|
|
3802
|
+
}
|
|
3803
|
+
const branch = await pushedBranch(repoRoot);
|
|
3804
|
+
const result = await client.request("/api/rudder/home/open", {
|
|
3805
|
+
method: "POST",
|
|
3806
|
+
body: { repo: slug, ...(branch ? { branch } : {}) },
|
|
3807
|
+
});
|
|
3808
|
+
const workspaceId = typeof result.workspace?.id === "string" ? result.workspace.id : "";
|
|
3809
|
+
if (!workspaceId) {
|
|
3810
|
+
throw new Error("the control plane did not return a workspace for this repo");
|
|
3811
|
+
}
|
|
3812
|
+
if (options.json) {
|
|
3813
|
+
printJson(result);
|
|
3814
|
+
return;
|
|
3815
|
+
}
|
|
3816
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
3817
|
+
process.stderr.write(`${slug} is open on the home machine (workspace ${workspaceId}). Run \`rudder cloud\` from a terminal to attach.\n`);
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
if (process.env.RUDDER_CLOUD_NO_ATTACH === "1") {
|
|
3821
|
+
return;
|
|
3822
|
+
}
|
|
3823
|
+
process.stderr.write(`Phone: ${appUrl(client.baseUrl)}\n`);
|
|
3824
|
+
await reportCredentialParity(client, { problemsOnly: true }).catch(() => undefined);
|
|
3825
|
+
await attachWithReconnect({ kind: "workspace", id: workspaceId, label: `${name} on home` }, options, async () => {
|
|
3826
|
+
// Wake the machine again if it was what went away.
|
|
3827
|
+
await client.request("/api/rudder/home/open", { method: "POST", body: { repo: slug, ...(branch ? { branch } : {}) } });
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
// The cloud clones from GitHub, so anything not pushed is not there. Say so
|
|
3831
|
+
// plainly; it is a warning, not a gate.
|
|
3832
|
+
async function warnLocalOnlyWork(repoRoot) {
|
|
3833
|
+
const status = await runCommand("git", ["status", "--porcelain"], { cwd: repoRoot, allowFailure: true });
|
|
3834
|
+
const changed = status.code === 0 ? status.stdout.split("\n").filter((line) => line.trim()).length : 0;
|
|
3835
|
+
let unpushed = 0;
|
|
3836
|
+
let branchMissing = "";
|
|
3837
|
+
const upstream = await runCommand("git", ["rev-list", "--count", "@{u}..HEAD"], { cwd: repoRoot, allowFailure: true });
|
|
3838
|
+
if (upstream.code === 0) {
|
|
3839
|
+
unpushed = Number(upstream.stdout.trim()) || 0;
|
|
3840
|
+
}
|
|
3841
|
+
else {
|
|
3842
|
+
const branch = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot, allowFailure: true });
|
|
3843
|
+
const name = branch.stdout.trim();
|
|
3844
|
+
if (name && name !== "HEAD") {
|
|
3845
|
+
branchMissing = name;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
const parts = [];
|
|
3849
|
+
if (changed > 0) {
|
|
3850
|
+
parts.push(`${changed} uncommitted file${changed === 1 ? "" : "s"}`);
|
|
3851
|
+
}
|
|
3852
|
+
if (unpushed > 0) {
|
|
3853
|
+
parts.push(`${unpushed} unpushed commit${unpushed === 1 ? "" : "s"}`);
|
|
3854
|
+
}
|
|
3855
|
+
if (branchMissing) {
|
|
3856
|
+
parts.push(`branch ${branchMissing} is not on GitHub`);
|
|
3857
|
+
}
|
|
3858
|
+
if (parts.length > 0) {
|
|
3859
|
+
process.stderr.write(`Heads up: ${parts.join(", ")}. The cloud clones from GitHub, so it won't see that. Push first, or use \`rudder cloud --snapshot\` to upload this folder instead.\n`);
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
// The cloud's AWS is whatever ~/.aws the vault holds. When this laptop's copy
|
|
3863
|
+
// has changed since (new keys, a new profile), offer to bring the vault level.
|
|
3864
|
+
// The vault's copy of the laptop's agent and AWS config must match the
|
|
3865
|
+
// laptop: the cloud machine rewrites these files from the vault every boot,
|
|
3866
|
+
// and a drifted copy means different behavior there (verbose output, another
|
|
3867
|
+
// default model, hooks that no longer exist). Compared by checksum; AWS is
|
|
3868
|
+
// asked about once, everything else that is already shared just follows.
|
|
3869
|
+
const LAPTOP_CONFIG_FILES = [
|
|
3870
|
+
"~/.claude/settings.json",
|
|
3871
|
+
"~/.claude/CLAUDE.md",
|
|
3872
|
+
"~/.codex/config.toml",
|
|
3873
|
+
"~/.codex/AGENTS.md",
|
|
3874
|
+
"~/.codex/hooks.json",
|
|
3875
|
+
// Service logins agents use from scripts (Kalshi signs requests with this key).
|
|
3876
|
+
"~/.kalshi/config.json",
|
|
3877
|
+
"~/.kalshi/private_key.pem",
|
|
3878
|
+
];
|
|
3879
|
+
async function laptopConfigFiles() {
|
|
3880
|
+
const names = [...LAPTOP_CONFIG_FILES, "~/.aws/config", "~/.aws/credentials"];
|
|
3881
|
+
// Hook scripts the settings above point at.
|
|
3882
|
+
for (const dir of [".claude/hooks", ".codex/libra-hooks"]) {
|
|
3883
|
+
for (const entry of await fsp.readdir(path.join(os.homedir(), dir), { withFileTypes: true }).catch(() => [])) {
|
|
3884
|
+
if (entry.isFile()) {
|
|
3885
|
+
names.push(`~/${dir}/${entry.name}`);
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
const out = [];
|
|
3890
|
+
for (const name of names) {
|
|
3891
|
+
const content = await fsp.readFile(path.join(os.homedir(), name.slice(2))).catch(() => null);
|
|
3892
|
+
if (content && content.length > 0 && content.length <= MAX_SECRET_VALUE_BYTES) {
|
|
3893
|
+
out.push({ name, content });
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
return out;
|
|
3897
|
+
}
|
|
3898
|
+
async function ensureVaultAws(client) {
|
|
3899
|
+
const present = await laptopConfigFiles();
|
|
3900
|
+
if (present.length === 0 || !isTty()) {
|
|
3901
|
+
return;
|
|
3902
|
+
}
|
|
3903
|
+
const vault = await client.request("/api/rudder/secrets", { method: "GET" }).catch(() => null);
|
|
3904
|
+
if (!vault) {
|
|
3905
|
+
return;
|
|
3906
|
+
}
|
|
3907
|
+
const stored = new Map((vault.secrets ?? []).map((secret) => [secret.name, secret.sha256 ?? ""]));
|
|
3908
|
+
const sha = (content) => createHash("sha256").update(content).digest("hex").slice(0, 8);
|
|
3909
|
+
const changed = present.filter((file) => stored.get(file.name) !== sha(file.content));
|
|
3910
|
+
if (changed.length === 0) {
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
const isAws = (name) => name.startsWith("~/.aws/");
|
|
3914
|
+
const awsShared = [...stored.keys()].some(isAws);
|
|
3915
|
+
let toSend = changed.filter((file) => !isAws(file.name) || awsShared);
|
|
3916
|
+
const newAws = changed.filter((file) => isAws(file.name) && !awsShared);
|
|
3917
|
+
if (newAws.length > 0) {
|
|
3918
|
+
const yes = await promptConfirm(`Copy your AWS setup (${newAws.map((file) => file.name).join(", ")}) to the cloud vault so cloud agents use the same AWS?`, true);
|
|
3919
|
+
if (yes) {
|
|
3920
|
+
toSend = [...toSend, ...newAws];
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
for (const file of toSend) {
|
|
3924
|
+
await client.request("/api/rudder/secrets/item", {
|
|
3925
|
+
method: "PUT",
|
|
3926
|
+
body: { name: file.name, kind: "file", filePath: file.name, valueBase64: file.content.toString("base64"), source: "rudder cloud" },
|
|
3927
|
+
});
|
|
3928
|
+
}
|
|
3929
|
+
if (toSend.length > 0) {
|
|
3930
|
+
process.stderr.write(`Updated the cloud copy of ${toSend.map((file) => file.name).join(", ")} to match this laptop.\n`);
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
async function localGithubToken() {
|
|
3934
|
+
if (!commandExists("gh")) {
|
|
3935
|
+
return null;
|
|
3936
|
+
}
|
|
3937
|
+
const result = await runCommand("gh", ["auth", "token"], { allowFailure: true });
|
|
3938
|
+
const token = result.stdout.trim();
|
|
3939
|
+
return result.code === 0 && /^(gh[opsu]_|github_pat_)[A-Za-z0-9_]+$/.test(token) ? token : null;
|
|
3940
|
+
}
|
|
3941
|
+
// The cloud machine clones from GitHub, so a private repo needs a GitHub token
|
|
3942
|
+
// in the vault. gh keeps its token in the OS keychain (hosts.yml has none), so
|
|
3943
|
+
// it never arrived with the synced gh config; offer to put it there.
|
|
3944
|
+
async function ensureVaultGithubToken(client, slug, interactive = true) {
|
|
3945
|
+
const vault = await client.request("/api/rudder/secrets", { method: "GET" }).catch(() => null);
|
|
3946
|
+
if (!vault || (vault.secrets ?? []).some((secret) => secret.name === "GH_TOKEN" || secret.name === "GITHUB_TOKEN")) {
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
const token = await localGithubToken();
|
|
3950
|
+
if (!token) {
|
|
3951
|
+
process.stderr.write(`The cloud vault has no GitHub token, so private repos can't be cloned there. Run \`gh auth login\` here, or \`rudder cloud secrets set GH_TOKEN\`.\n`);
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
3954
|
+
if (!isTty() || !interactive) {
|
|
3955
|
+
process.stderr.write("The cloud vault has no GitHub token; private repos will fail to clone. Run `rudder cloud secrets sync` from a terminal.\n");
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
const yes = await promptConfirm(`The cloud machine needs GitHub access to clone ${slug} (and to push branches and open PRs). Copy your GitHub CLI token into the cloud vault?`, true);
|
|
3959
|
+
if (!yes) {
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
await client.request("/api/rudder/secrets/item", {
|
|
3963
|
+
method: "PUT",
|
|
3964
|
+
body: { name: "GH_TOKEN", kind: "env", valueBase64: Buffer.from(token, "utf8").toString("base64"), source: "rudder cloud" },
|
|
3965
|
+
});
|
|
3966
|
+
process.stderr.write("Stored GH_TOKEN in the cloud vault.\n");
|
|
3967
|
+
}
|
|
3968
|
+
const HOME_MOVE_POLL_MS = 1500;
|
|
3969
|
+
const HOME_MOVE_DEADLINE_MS = 15 * 60 * 1000;
|
|
3970
|
+
async function moveCommand(args, options) {
|
|
3971
|
+
const yes = options.yes || args.includes("--yes") || args.includes("-y");
|
|
3972
|
+
const runIds = args.filter((arg) => !arg.startsWith("-"));
|
|
3973
|
+
const repoRoot = findRepoRoot();
|
|
3974
|
+
const slug = await githubSlugFromOrigin(repoRoot);
|
|
3975
|
+
if (!slug) {
|
|
3976
|
+
throw new Error("This repo has no GitHub origin, so the home machine can't clone it. `rudder cloud workspace attach` moves agents into a snapshot workspace instead.");
|
|
3977
|
+
}
|
|
3978
|
+
const candidates = await findMoveCandidates(repoRoot, runIds.length > 0 ? runIds : undefined);
|
|
3979
|
+
const missing = runIds.filter((id) => !candidates.some((candidate) => candidate.runId === id));
|
|
3980
|
+
if (missing.length > 0) {
|
|
3981
|
+
throw new Error(`Not movable (finished, not in a workspace of its own, or unknown): ${missing.join(", ")}`);
|
|
3982
|
+
}
|
|
3983
|
+
if (candidates.length === 0) {
|
|
3984
|
+
if (!options.json) {
|
|
3985
|
+
process.stderr.write("No unfinished agents with workspaces of their own to move.\n");
|
|
3986
|
+
}
|
|
3987
|
+
return;
|
|
3988
|
+
}
|
|
3989
|
+
if (!yes && !options.json) {
|
|
3990
|
+
if (!isTty()) {
|
|
3991
|
+
throw new Error("Refusing to move agents without a terminal to confirm in; pass --yes.");
|
|
3992
|
+
}
|
|
3993
|
+
process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
|
|
3994
|
+
if (!(await promptConfirm(`Move ${agentCount(candidates.length)} to the home machine?`, true))) {
|
|
3995
|
+
process.stderr.write("Nothing moved.\n");
|
|
3996
|
+
return;
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
const client = await cloudClient({ requireToken: true });
|
|
4000
|
+
if (!options.json) {
|
|
4001
|
+
await ensureVaultGithubToken(client, slug, !yes);
|
|
4002
|
+
}
|
|
4003
|
+
const moved = await moveAgentsToHome(client, repoRoot, slug, candidates, options);
|
|
4004
|
+
if (options.json) {
|
|
4005
|
+
printJson(moved);
|
|
4006
|
+
}
|
|
4007
|
+
if (moved.adopted.length === 0) {
|
|
4008
|
+
process.exitCode = 1;
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
function agentCount(count) {
|
|
4012
|
+
return `${count} agent${count === 1 ? "" : "s"}`;
|
|
4013
|
+
}
|
|
4014
|
+
function describeMoveCandidates(candidates) {
|
|
4015
|
+
return candidates
|
|
4016
|
+
.map((candidate) => ` ${candidate.status.padEnd(8)} ${(candidate.taskSummary || candidate.task || candidate.runId).replace(/\s+/g, " ").slice(0, 72)}`)
|
|
4017
|
+
.join("\n");
|
|
4018
|
+
}
|
|
4019
|
+
/** Bundle, upload, wait for the home machine to stage them, then hand the local records over. */
|
|
4020
|
+
export async function moveAgentsToHome(client, repoRoot, slug, candidates, options) {
|
|
4021
|
+
const say = (line) => {
|
|
4022
|
+
if (!options.json) {
|
|
4023
|
+
process.stderr.write(`${line}\n`);
|
|
4024
|
+
}
|
|
4025
|
+
};
|
|
4026
|
+
const stageDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-stage-"));
|
|
4027
|
+
const archiveDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-move-"));
|
|
4028
|
+
let job;
|
|
4029
|
+
try {
|
|
4030
|
+
say(`Packing ${agentCount(candidates.length)} (their jj changes and conversations)...`);
|
|
4031
|
+
await buildMoveBundle(repoRoot, candidates, stageDir);
|
|
4032
|
+
const archivePath = path.join(archiveDir, "move.tgz");
|
|
4033
|
+
await runCommand("tar", ["-czf", archivePath, "-C", stageDir, "."], { cwd: stageDir });
|
|
4034
|
+
const branch = await pushedBranch(repoRoot);
|
|
4035
|
+
job = await client.request("/api/rudder/home/move", {
|
|
4036
|
+
method: "POST",
|
|
4037
|
+
body: {
|
|
4038
|
+
repo: slug,
|
|
4039
|
+
...(branch ? { branch } : {}),
|
|
4040
|
+
bundle: await fsp.readFile(archivePath, "base64"),
|
|
4041
|
+
},
|
|
4042
|
+
});
|
|
4043
|
+
}
|
|
4044
|
+
finally {
|
|
4045
|
+
await fsp.rm(stageDir, { recursive: true, force: true });
|
|
4046
|
+
await fsp.rm(archiveDir, { recursive: true, force: true });
|
|
4047
|
+
}
|
|
4048
|
+
const moveId = job.move?.id;
|
|
4049
|
+
if (!moveId) {
|
|
4050
|
+
throw new Error("the control plane did not start the move");
|
|
4051
|
+
}
|
|
4052
|
+
const deadline = Date.now() + HOME_MOVE_DEADLINE_MS;
|
|
4053
|
+
let lastDetail = "";
|
|
4054
|
+
while (job.move?.status !== "done" && job.move?.status !== "failed") {
|
|
4055
|
+
if (Date.now() > deadline) {
|
|
4056
|
+
throw new Error("the home machine did not take the agents in time; they stay here");
|
|
4057
|
+
}
|
|
4058
|
+
const detail = job.move?.detail ?? "";
|
|
4059
|
+
if (detail && detail !== lastDetail) {
|
|
4060
|
+
say(detail);
|
|
4061
|
+
lastDetail = detail;
|
|
4062
|
+
}
|
|
4063
|
+
await new Promise((resolve) => setTimeout(resolve, HOME_MOVE_POLL_MS));
|
|
4064
|
+
job = await client.request(`/api/rudder/home/move/${encodeURIComponent(moveId)}`, { method: "GET" });
|
|
4065
|
+
}
|
|
4066
|
+
if (job.move?.status === "failed") {
|
|
4067
|
+
throw new Error(`the home machine could not take the agents: ${job.move.error || "unknown error"}`);
|
|
4068
|
+
}
|
|
4069
|
+
const adopted = (job.move?.result?.adopted ?? []).map((entry) => entry.runId);
|
|
4070
|
+
const failed = job.move?.result?.failed ?? [];
|
|
4071
|
+
await markRunsMovedHome(repoRoot, adopted, slug);
|
|
4072
|
+
if (adopted.length > 0) {
|
|
4073
|
+
const fresh = (job.move?.result?.adopted ?? []).filter((entry) => entry.resumable === false).length;
|
|
4074
|
+
say(`Moved ${agentCount(adopted.length)} to the home machine. They keep working there with this laptop closed.`);
|
|
4075
|
+
if (fresh > 0) {
|
|
4076
|
+
say(`${agentCount(fresh)} had no saved conversation and restart from a handoff over their diff.`);
|
|
4077
|
+
}
|
|
4078
|
+
say(`Watch them: \`rudder cloud\` here, or on your phone: ${appUrl(client.baseUrl)}`);
|
|
4079
|
+
}
|
|
4080
|
+
for (const entry of failed) {
|
|
4081
|
+
say(`Could not move ${entry.runId}: ${entry.error}. It stays here.`);
|
|
4082
|
+
}
|
|
4083
|
+
return { adopted, failed };
|
|
4084
|
+
}
|
|
4085
|
+
/**
|
|
4086
|
+
* `rudder cloud` in a repo with agents: offer to take them along. A live
|
|
4087
|
+
* dashboard owns its agents' processes, so it does the move (it pauses them
|
|
4088
|
+
* first); with no dashboard running the agents are already stopped and this
|
|
4089
|
+
* process can move them itself.
|
|
4090
|
+
*/
|
|
4091
|
+
async function offerToMoveAgents(client, repoRoot, slug) {
|
|
4092
|
+
if (!isTty() || process.env.RUDDER_CLOUD_RUNTIME === "home" || process.env.RUDDER_CLOUD_NO_MOVE_OFFER === "1") {
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
const candidates = await findMoveCandidates(repoRoot).catch(() => []);
|
|
4096
|
+
if (candidates.length === 0) {
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
4099
|
+
const dashboardLive = await dashboardServing(repoRoot);
|
|
4100
|
+
const running = candidates.filter((candidate) => candidate.status === "running");
|
|
4101
|
+
if (dashboardLive && running.length > 0) {
|
|
4102
|
+
const yes = await promptConfirm(`${agentCount(running.length)} ${running.length === 1 ? "is" : "are"} running here. Move them to the home machine?`, true);
|
|
4103
|
+
if (!yes) {
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
const receipt = await requestDashboardMove(repoRoot);
|
|
4107
|
+
process.stderr.write(receipt === "failed"
|
|
4108
|
+
? "The dashboard could not start the move; press H there to try again.\n"
|
|
4109
|
+
: "The dashboard is moving them; they appear on the home machine as they land.\n");
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
if (dashboardLive) {
|
|
4113
|
+
return;
|
|
4114
|
+
}
|
|
4115
|
+
process.stderr.write(`${describeMoveCandidates(candidates)}\n`);
|
|
4116
|
+
const yes = await promptConfirm(`${agentCount(candidates.length)} ${candidates.length === 1 ? "was" : "were"} left unfinished here. Move them to the home machine?`, true);
|
|
4117
|
+
if (!yes) {
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4120
|
+
try {
|
|
4121
|
+
await moveAgentsToHome(client, repoRoot, slug, candidates, {});
|
|
4122
|
+
}
|
|
4123
|
+
catch (error) {
|
|
4124
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
4127
|
+
async function dashboardServing(repoRoot) {
|
|
4128
|
+
let socketPath;
|
|
4129
|
+
try {
|
|
4130
|
+
socketPath = await fsp.realpath(path.join(repoRoot, ".rudder", "attach.sock"));
|
|
4131
|
+
}
|
|
4132
|
+
catch {
|
|
4133
|
+
return false;
|
|
4134
|
+
}
|
|
4135
|
+
const net = await import("node:net");
|
|
4136
|
+
return await new Promise((resolve) => {
|
|
4137
|
+
const socket = net.connect(socketPath);
|
|
4138
|
+
const timer = setTimeout(() => {
|
|
4139
|
+
socket.destroy();
|
|
4140
|
+
resolve(false);
|
|
4141
|
+
}, 500);
|
|
4142
|
+
socket.once("connect", () => {
|
|
4143
|
+
clearTimeout(timer);
|
|
4144
|
+
socket.destroy();
|
|
4145
|
+
resolve(true);
|
|
4146
|
+
});
|
|
4147
|
+
socket.once("error", () => {
|
|
4148
|
+
clearTimeout(timer);
|
|
4149
|
+
resolve(false);
|
|
4150
|
+
});
|
|
4151
|
+
});
|
|
4152
|
+
}
|
|
4153
|
+
/** Ask the live dashboard (through its steer inbox) to move every agent it is running. */
|
|
4154
|
+
async function requestDashboardMove(repoRoot) {
|
|
4155
|
+
const requestId = randomUUID();
|
|
4156
|
+
const inbox = path.join(repoRoot, ".rudder", "steer");
|
|
4157
|
+
await ensureDir(inbox);
|
|
4158
|
+
const file = path.join(inbox, `${String(Date.now()).padStart(13, "0")}-move-home-${requestId}.json`);
|
|
4159
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
4160
|
+
await fsp.writeFile(temp, JSON.stringify({ requestId, kind: "move-home", taskId: "", instruction: "move-home", ts: nowIso() }));
|
|
4161
|
+
await fsp.rename(temp, file);
|
|
4162
|
+
const receiptPath = path.join(repoRoot, ".rudder", "steer-receipts", `${requestId}.json`);
|
|
4163
|
+
const deadline = Date.now() + 10_000;
|
|
4164
|
+
while (Date.now() < deadline) {
|
|
4165
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
4166
|
+
const receipt = await readJson(receiptPath);
|
|
4167
|
+
if (receipt?.status === "delivered" || receipt?.status === "failed") {
|
|
4168
|
+
return receipt.status;
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
return "queued";
|
|
4172
|
+
}
|
|
4173
|
+
async function localIdentities() {
|
|
4174
|
+
const [github, claude] = await Promise.all([
|
|
4175
|
+
commandExists("gh")
|
|
4176
|
+
? runCommand("gh", ["api", "user", "--jq", ".login"], { allowFailure: true }).then((r) => (r.code === 0 ? r.stdout.trim() || null : null))
|
|
4177
|
+
: Promise.resolve(null),
|
|
4178
|
+
commandExists("claude")
|
|
4179
|
+
? runCommand("claude", ["auth", "status", "--json"], { allowFailure: true }).then((r) => {
|
|
4180
|
+
try {
|
|
4181
|
+
const parsed = JSON.parse(r.stdout);
|
|
4182
|
+
return parsed.loggedIn ? parsed.email ?? "logged in" : null;
|
|
4183
|
+
}
|
|
4184
|
+
catch {
|
|
4185
|
+
return null;
|
|
4186
|
+
}
|
|
4187
|
+
})
|
|
4188
|
+
: Promise.resolve(null),
|
|
4189
|
+
]);
|
|
4190
|
+
const aws = commandExists("aws")
|
|
4191
|
+
? await runCommand("aws", ["sts", "get-caller-identity", "--query", "Arn", "--output", "text"], { allowFailure: true })
|
|
4192
|
+
.then((r) => (r.code === 0 && r.stdout.trim().startsWith("arn:") ? r.stdout.trim() : null))
|
|
4193
|
+
: null;
|
|
4194
|
+
let codex = null;
|
|
4195
|
+
try {
|
|
4196
|
+
const auth = JSON.parse(await fsp.readFile(path.join(os.homedir(), ".codex", "auth.json"), "utf8"));
|
|
4197
|
+
const payload = (auth.tokens?.id_token ?? "").split(".")[1];
|
|
4198
|
+
codex = payload ? JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).email ?? null : null;
|
|
4199
|
+
}
|
|
4200
|
+
catch {
|
|
4201
|
+
codex = null;
|
|
4202
|
+
}
|
|
4203
|
+
return { github, claude, codex, aws };
|
|
4204
|
+
}
|
|
4205
|
+
const CREDENTIAL_FIXES = {
|
|
4206
|
+
github: "run `rudder cloud` in a repo here (it offers to copy `gh auth token` into the vault)",
|
|
4207
|
+
claude: "run `rudder cloud`, then `/login` in a Claude pane there (the cloud keeps its own login)",
|
|
4208
|
+
codex: "run `rudder cloud`, then `$ codex login --device-auth` in the task pane there",
|
|
4209
|
+
aws: "run `rudder cloud` in a repo here: it offers to copy your current ~/.aws into the vault",
|
|
4210
|
+
};
|
|
4211
|
+
async function credentialParity(client) {
|
|
4212
|
+
const [local, home] = await Promise.all([
|
|
4213
|
+
localIdentities(),
|
|
4214
|
+
client.request("/api/rudder/home", { method: "GET" }),
|
|
4215
|
+
]);
|
|
4216
|
+
const cloud = home.home?.credentials ?? null;
|
|
4217
|
+
const rows = ["github", "claude", "codex", "aws"].map((tool) => {
|
|
4218
|
+
const remote = cloud?.[tool] ?? null;
|
|
4219
|
+
const mine = local[tool];
|
|
4220
|
+
if (!remote) {
|
|
4221
|
+
return { tool, local: mine, cloud: null, status: "unknown" };
|
|
4222
|
+
}
|
|
4223
|
+
if (!remote.ok) {
|
|
4224
|
+
// ENOENT: the CLI was being updated when the machine checked; it rechecks.
|
|
4225
|
+
const fix = /ENOENT/.test(remote.detail ?? "")
|
|
4226
|
+
? `${tool} was updating when the cloud checked; run \`rudder cloud doctor\` again in a minute`
|
|
4227
|
+
: CREDENTIAL_FIXES[tool];
|
|
4228
|
+
return { tool, local: mine, cloud: remote, status: mine ? "missing" : "unknown", fix };
|
|
4229
|
+
}
|
|
4230
|
+
if (mine && remote.account && remote.account.toLowerCase() !== mine.toLowerCase()) {
|
|
4231
|
+
return { tool, local: mine, cloud: remote, status: "different", fix: CREDENTIAL_FIXES[tool] };
|
|
4232
|
+
}
|
|
4233
|
+
return { tool, local: mine, cloud: remote, status: "match" };
|
|
4234
|
+
});
|
|
4235
|
+
return { rows, checkedAt: typeof cloud?.checkedAt === "string" ? cloud.checkedAt : null };
|
|
4236
|
+
}
|
|
4237
|
+
async function reportCredentialParity(client, { problemsOnly }) {
|
|
4238
|
+
const { rows, checkedAt } = await credentialParity(client);
|
|
4239
|
+
const problems = rows.filter((row) => row.status === "missing" || row.status === "different");
|
|
4240
|
+
if (problemsOnly && problems.length === 0) {
|
|
4241
|
+
return 0;
|
|
4242
|
+
}
|
|
4243
|
+
const label = (tool) => ({ github: "GitHub", claude: "Claude", codex: "Codex", aws: "AWS" })[tool] ?? tool;
|
|
4244
|
+
for (const row of problemsOnly ? problems : rows) {
|
|
4245
|
+
const here = row.local ?? "not logged in";
|
|
4246
|
+
const there = row.cloud ? (row.cloud.ok ? row.cloud.account ?? "logged in" : `not working (${row.cloud.detail ?? "unknown"})`) : "not checked yet";
|
|
4247
|
+
const mark = row.status === "match" ? "✓" : row.status === "unknown" ? "·" : row.status === "different" ? "⚠" : "✗";
|
|
4248
|
+
process.stderr.write(`${mark} ${label(row.tool).padEnd(7)} here: ${here} cloud: ${there}\n`);
|
|
4249
|
+
if (row.fix) {
|
|
4250
|
+
process.stderr.write(` fix: ${row.fix}\n`);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
if (!problemsOnly && !checkedAt) {
|
|
4254
|
+
process.stderr.write("The home machine has not finished checking its logins yet; try again in a minute.\n");
|
|
4255
|
+
}
|
|
4256
|
+
return problems.length;
|
|
4257
|
+
}
|
|
4258
|
+
// `rudder cloud sync` (and `/sync` in the dashboard): GitHub is the meeting
|
|
4259
|
+
// point, so this checkout and the home machine's clone each fetch and move to
|
|
4260
|
+
// the newest default branch when that is safe, and the vault's copy of this
|
|
4261
|
+
// laptop's config is brought level. Unpushed or uncommitted work on either side
|
|
4262
|
+
// is reported, never pushed or overwritten. `--all` syncs every repo open on
|
|
4263
|
+
// the home machine.
|
|
4264
|
+
async function cloudSync(args, options) {
|
|
4265
|
+
const client = await cloudClient({ requireToken: true });
|
|
4266
|
+
const all = args.includes("--all");
|
|
4267
|
+
const named = args.find((arg) => !arg.startsWith("-"));
|
|
4268
|
+
const root = named ? await localCheckoutFor(client, named, options) : findRepoRoot();
|
|
4269
|
+
const slug = await githubSlugFromOrigin(root);
|
|
4270
|
+
const lines = [];
|
|
4271
|
+
let local = null;
|
|
4272
|
+
let work;
|
|
4273
|
+
const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-sync-"));
|
|
4274
|
+
if (slug) {
|
|
4275
|
+
local = await syncCheckout(root);
|
|
4276
|
+
lines.push("");
|
|
4277
|
+
// This laptop's work GitHub lacks goes to the cloud machine directly.
|
|
4278
|
+
const sent = await exportWork(root, path.join(scratch, "out.bundle")).catch((error) => {
|
|
4279
|
+
lines.push(`work: could not bundle this laptop's work (${error instanceof Error ? error.message : String(error)})`);
|
|
4280
|
+
return null;
|
|
4281
|
+
});
|
|
4282
|
+
if (sent) {
|
|
4283
|
+
local.work = { sent };
|
|
4284
|
+
work = { bundle: (await fsp.readFile(path.join(scratch, "out.bundle"))).toString("base64"), head: sent.head };
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
else if (!all) {
|
|
4288
|
+
throw new Error("This repo has no GitHub origin, so there is nothing on the home machine to sync it with. `rudder cloud sync --all` syncs every repo there.");
|
|
4289
|
+
}
|
|
4290
|
+
await ensureVaultAws(client).catch((error) => {
|
|
4291
|
+
lines.push(`config: could not update the vault (${error instanceof Error ? error.message : String(error)})`);
|
|
4292
|
+
});
|
|
4293
|
+
if (!options.json) {
|
|
4294
|
+
process.stderr.write("Asking the cloud machine (waking it if it is asleep)...\n");
|
|
4295
|
+
}
|
|
4296
|
+
const cloud = await client.request("/api/rudder/home/sync", {
|
|
4297
|
+
method: "POST",
|
|
4298
|
+
body: all || !slug ? {} : { repos: [slug], ...(work ? { work } : {}) },
|
|
4299
|
+
}).catch((error) => {
|
|
4300
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4301
|
+
throw new Error(message === "fetch failed" ? "the cloud machine did not answer in time (it may be restarting); run /sync again in a minute" : message);
|
|
4302
|
+
}).finally(() => fsp.rm(scratch, { recursive: true, force: true }).catch(() => undefined));
|
|
4303
|
+
const results = cloud.results ?? [];
|
|
4304
|
+
// And the cloud's work comes back here.
|
|
4305
|
+
const mine = slug ? results.find((result) => result.repo.toLowerCase() === slug.toLowerCase()) : undefined;
|
|
4306
|
+
const incoming = mine?.report?.work?.sent;
|
|
4307
|
+
if (local && incoming?.download) {
|
|
4308
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "rudder-sync-in-"));
|
|
4309
|
+
try {
|
|
4310
|
+
const file = path.join(dir, "in.bundle");
|
|
4311
|
+
await fsp.writeFile(file, await client.download(incoming.download));
|
|
4312
|
+
local.work = { ...local.work, received: await importWork(root, file, incoming.head, "cloud") };
|
|
4313
|
+
}
|
|
4314
|
+
catch (error) {
|
|
4315
|
+
local.work = { ...local.work, received: { from: "cloud", head: incoming.head, outcome: "failed", note: `could not take the cloud's work (${error instanceof Error ? error.message : String(error)})` } };
|
|
4316
|
+
}
|
|
4317
|
+
finally {
|
|
4318
|
+
await fsp.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
4321
|
+
// Work the other side already had was not news: report only what moved.
|
|
4322
|
+
if (local?.work?.sent && mine?.report?.work?.received?.outcome === "have") {
|
|
4323
|
+
delete local.work.sent;
|
|
4324
|
+
}
|
|
4325
|
+
if (mine?.report?.work?.sent && local?.work?.received?.outcome === "have") {
|
|
4326
|
+
delete mine.report.work.sent;
|
|
4327
|
+
}
|
|
4328
|
+
for (const side of [local?.work, mine?.report?.work]) {
|
|
4329
|
+
if (side?.received?.outcome === "have") {
|
|
4330
|
+
delete side.received;
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
if (local) {
|
|
4334
|
+
lines[0] = describeSync(`this laptop (${slug})`, local);
|
|
4335
|
+
}
|
|
4336
|
+
if (options.json) {
|
|
4337
|
+
printJson({ local, cloud: results });
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
4340
|
+
for (const result of results) {
|
|
4341
|
+
lines.push(result.report ? describeSync(`cloud (${result.repo})`, result.report) : `cloud (${result.repo}): ${result.error ?? "no answer"}`);
|
|
4342
|
+
}
|
|
4343
|
+
if (slug && results.length === 0) {
|
|
4344
|
+
lines.push(`cloud: ${slug} is not open on the cloud machine (\`rudder cloud\` opens it there)`);
|
|
4345
|
+
}
|
|
4346
|
+
// The agents, on each side. Cloud ones are listed from what the home machine
|
|
4347
|
+
// last reported; laptop ones that are still running are what /sync move (or H
|
|
4348
|
+
// in the dashboard) would hand over.
|
|
4349
|
+
const homeView = await client.request("/api/rudder/home", { method: "GET" }).catch(() => null);
|
|
4350
|
+
for (const project of homeView?.projects ?? []) {
|
|
4351
|
+
if (!project.repo || !results.some((result) => result.repo.toLowerCase() === project.repo.toLowerCase())) {
|
|
4352
|
+
continue;
|
|
4353
|
+
}
|
|
4354
|
+
lines.push(...describeCloudAgents(project.repo, project.agents ?? []));
|
|
4355
|
+
}
|
|
4356
|
+
if (slug) {
|
|
4357
|
+
const running = await findMoveCandidates(root).catch(() => []);
|
|
4358
|
+
if (running.length > 0) {
|
|
4359
|
+
lines.push(`${running.length} agent(s) still running on this laptop: ${running.slice(0, 4).map((run) => `"${(run.taskSummary || run.task).slice(0, 50)}"`).join(", ")}${running.length > 4 ? ", ..." : ""}. They stop when the laptop sleeps; \`/sync move\` (or H) moves them to the cloud machine.`);
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
console.log(lines.filter(Boolean).join("\n"));
|
|
4363
|
+
}
|
|
4364
|
+
// `rudder cloud sync <name>`: the laptop's clone of a repo open on the cloud
|
|
4365
|
+
// machine, cloned from GitHub into ~/code/<name> the first time.
|
|
4366
|
+
async function localCheckoutFor(client, name, options) {
|
|
4367
|
+
const view = await client.request("/api/rudder/home", { method: "GET" });
|
|
4368
|
+
const wanted = name.replace(/\.git$/, "").toLowerCase();
|
|
4369
|
+
const slug = (view.projects ?? []).map((project) => project.repo ?? "").find((repo) => repo.toLowerCase() === wanted || repo.split("/").pop()?.toLowerCase() === wanted);
|
|
4370
|
+
if (!slug) {
|
|
4371
|
+
throw new Error(`${name} is not open on the cloud machine.`);
|
|
4372
|
+
}
|
|
4373
|
+
const dir = path.join(os.homedir(), "code", slug.split("/").pop());
|
|
4374
|
+
if (await pathExists(path.join(dir, ".git"))) {
|
|
4375
|
+
const existing = await githubSlugFromOrigin(dir);
|
|
4376
|
+
if (existing?.toLowerCase() !== slug.toLowerCase()) {
|
|
4377
|
+
throw new Error(`${shortenHome(dir)} already exists and is not a clone of ${slug}.`);
|
|
4378
|
+
}
|
|
4379
|
+
return dir;
|
|
4380
|
+
}
|
|
4381
|
+
if (await pathExists(dir)) {
|
|
4382
|
+
throw new Error(`${shortenHome(dir)} already exists and is not a git checkout.`);
|
|
4383
|
+
}
|
|
4384
|
+
if (!options.json) {
|
|
4385
|
+
process.stderr.write(`Cloning ${slug} into ${shortenHome(dir)}...\n`);
|
|
4386
|
+
}
|
|
4387
|
+
await fsp.mkdir(path.dirname(dir), { recursive: true });
|
|
4388
|
+
const cloned = commandExists("gh")
|
|
4389
|
+
? await runCommand("gh", ["repo", "clone", slug, dir], { allowFailure: true })
|
|
4390
|
+
: await runCommand("git", ["clone", `https://github.com/${slug}.git`, dir], { allowFailure: true });
|
|
4391
|
+
if (cloned.code !== 0) {
|
|
4392
|
+
throw new Error(`could not clone ${slug}: ${(cloned.stderr || cloned.stdout).trim().split("\n").pop()}`);
|
|
4393
|
+
}
|
|
4394
|
+
return dir;
|
|
4395
|
+
}
|
|
4396
|
+
function describeCloudAgents(repo, agents) {
|
|
4397
|
+
const live = agents.filter((agent) => !["merged", "stopped", "deleted"].includes(String(agent.status)));
|
|
4398
|
+
if (live.length === 0) {
|
|
4399
|
+
return [`cloud agents (${repo}): none running`];
|
|
4400
|
+
}
|
|
4401
|
+
const label = (agent) => (Array.isArray(agent.asks) && agent.asks.length > 0 ? "needs you" : String(agent.status || "?"));
|
|
4402
|
+
const order = ["needs you", "waiting", "working", "starting", "review", "paused", "done", "failed"];
|
|
4403
|
+
const sorted = [...live].sort((a, b) => (order.indexOf(label(a)) + 1 || 99) - (order.indexOf(label(b)) + 1 || 99));
|
|
4404
|
+
return [
|
|
4405
|
+
`cloud agents (${repo}): ${live.length}`,
|
|
4406
|
+
...sorted.slice(0, 8).map((agent) => ` ${label(agent).padEnd(10)} ${(agent.title || "").slice(0, 70)}`),
|
|
4407
|
+
...(sorted.length > 8 ? [` ... and ${sorted.length - 8} more (\`rudder cloud\` to open them)`] : []),
|
|
4408
|
+
];
|
|
4409
|
+
}
|
|
4410
|
+
async function cloudDoctor(options) {
|
|
4411
|
+
const client = await cloudClient({ requireToken: true });
|
|
4412
|
+
if (options.json) {
|
|
4413
|
+
printJson((await credentialParity(client)));
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
4416
|
+
const problems = await reportCredentialParity(client, { problemsOnly: false });
|
|
4417
|
+
if (problems > 0) {
|
|
4418
|
+
process.exitCode = 1;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
// The current branch, if GitHub has it; otherwise the default branch is used.
|
|
4422
|
+
async function pushedBranch(repoRoot) {
|
|
4423
|
+
const branch = (await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot, allowFailure: true })).stdout.trim();
|
|
4424
|
+
if (!branch || branch === "HEAD") {
|
|
4425
|
+
return undefined;
|
|
4426
|
+
}
|
|
4427
|
+
const remote = await runCommand("git", ["ls-remote", "--heads", "origin", branch], { cwd: repoRoot, allowFailure: true });
|
|
4428
|
+
return remote.code === 0 && remote.stdout.trim() ? branch : undefined;
|
|
4429
|
+
}
|
|
4430
|
+
// A GitHub clone has no .env files (they are gitignored). Offer to put this
|
|
4431
|
+
// repo's into the vault, stored at ~/code/<repo>/<path> so the home machine
|
|
4432
|
+
// writes them into its clone.
|
|
4433
|
+
async function offerEnvFilesToVault(client, repoRoot, repoName) {
|
|
4434
|
+
if (!isTty()) {
|
|
4435
|
+
return;
|
|
4436
|
+
}
|
|
4437
|
+
const listed = await runCommand("git", ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"], {
|
|
4438
|
+
cwd: repoRoot,
|
|
4439
|
+
allowFailure: true,
|
|
4440
|
+
});
|
|
4441
|
+
if (listed.code !== 0) {
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4444
|
+
const envFiles = listed.stdout
|
|
4445
|
+
.split("\n")
|
|
4446
|
+
.map((line) => line.trim())
|
|
4447
|
+
.filter((line) => line && !line.endsWith("/"))
|
|
4448
|
+
.filter((line) => /^\.env(\.[\w.-]+)?$/.test(path.basename(line)))
|
|
4449
|
+
.filter((line) => !/\.(example|sample|template|dist)$/.test(line))
|
|
4450
|
+
.filter((line) => !line.split("/").includes("node_modules"))
|
|
4451
|
+
.slice(0, 20);
|
|
4452
|
+
if (envFiles.length === 0) {
|
|
4453
|
+
return;
|
|
4454
|
+
}
|
|
4455
|
+
const vault = await client.request("/api/rudder/secrets", { method: "GET" }).catch(() => null);
|
|
4456
|
+
if (!vault) {
|
|
4457
|
+
return;
|
|
4458
|
+
}
|
|
4459
|
+
const stored = new Map((vault.secrets ?? []).map((secret) => [secret.name, secret.size ?? -1]));
|
|
4460
|
+
const pending = [];
|
|
4461
|
+
for (const relative of envFiles) {
|
|
4462
|
+
const content = await fsp.readFile(path.join(repoRoot, relative)).catch(() => null);
|
|
4463
|
+
if (!content || content.length > MAX_SECRET_VALUE_BYTES) {
|
|
4464
|
+
continue;
|
|
4465
|
+
}
|
|
4466
|
+
const name = `~/code/${repoName}/${relative}`;
|
|
4467
|
+
if (stored.get(name) === content.length) {
|
|
4468
|
+
continue;
|
|
4469
|
+
}
|
|
4470
|
+
pending.push({ relative, name, content });
|
|
4471
|
+
}
|
|
4472
|
+
if (pending.length === 0) {
|
|
4473
|
+
return;
|
|
4474
|
+
}
|
|
4475
|
+
const list = pending.map((entry) => entry.relative).join(", ");
|
|
4476
|
+
const verb = pending.some((entry) => stored.has(entry.name)) ? "Update" : "Copy";
|
|
4477
|
+
const yes = await promptConfirm(`${verb} ${list} in the cloud vault so agents on the home machine have it?`, true);
|
|
4478
|
+
if (!yes) {
|
|
4479
|
+
return;
|
|
4480
|
+
}
|
|
4481
|
+
for (const entry of pending) {
|
|
4482
|
+
await client.request("/api/rudder/secrets/item", {
|
|
4483
|
+
method: "PUT",
|
|
4484
|
+
body: {
|
|
4485
|
+
name: entry.name,
|
|
4486
|
+
kind: "file",
|
|
4487
|
+
filePath: entry.name,
|
|
4488
|
+
valueBase64: entry.content.toString("base64"),
|
|
4489
|
+
source: "rudder cloud",
|
|
4490
|
+
},
|
|
4491
|
+
});
|
|
4492
|
+
}
|
|
4493
|
+
process.stderr.write(`Stored ${pending.length} file${pending.length === 1 ? "" : "s"} in the vault.\n`);
|
|
4494
|
+
}
|
|
4495
|
+
function appUrl(baseUrl) {
|
|
4496
|
+
return `${baseUrl.replace(/\/$/, "")}/app`;
|
|
4497
|
+
}
|
|
4498
|
+
// Sign the phone in from this (already signed-in) laptop: a short, single-use
|
|
4499
|
+
// code the Rudder app trades for a token on the same account. No OAuth.
|
|
4500
|
+
async function printAppLink(options) {
|
|
4501
|
+
const client = await cloudClient({ requireToken: true });
|
|
4502
|
+
const pairing = await client.request("/api/app/pair/start", { method: "POST", body: {} });
|
|
4503
|
+
if (options.json) {
|
|
4504
|
+
printJson({ app: appUrl(client.baseUrl), ...pairing });
|
|
4505
|
+
return;
|
|
4506
|
+
}
|
|
4507
|
+
console.log(`Pair your phone (code works once, for 10 minutes):
|
|
4508
|
+
|
|
4509
|
+
Code: ${pairing.code}
|
|
4510
|
+
|
|
4511
|
+
iPhone app: tap "Pair with this code" on the sign-in screen and type it,
|
|
4512
|
+
or open ${pairing.link}
|
|
4513
|
+
Browser: ${pairing.url}`);
|
|
4514
|
+
}
|
|
4515
|
+
/**
|
|
4516
|
+
* `rudder cloud home runs <runId...>`: which of these agents (moved here from a
|
|
4517
|
+
* laptop) still exist on the home machine; `known: false` when it cannot
|
|
4518
|
+
* answer, which callers must read as "unknown", never "deleted".
|
|
4519
|
+
* `rudder cloud home delete <runId...>`: delete them there, through that repo's
|
|
4520
|
+
* dashboard, which also takes them off the phone. Queued if the machine is
|
|
4521
|
+
* asleep. This is what `dd` on a moved row in the laptop's dashboard runs.
|
|
4522
|
+
*/
|
|
4523
|
+
async function homeAgentsCommand(client, sub, args, options) {
|
|
4524
|
+
const yes = options.yes || args.includes("--yes") || args.includes("-y");
|
|
4525
|
+
const runIds = args.filter((arg) => !arg.startsWith("-"));
|
|
4526
|
+
if (runIds.length === 0) {
|
|
4527
|
+
throw new Error(`Usage: rudder cloud home ${sub} <runId...>`);
|
|
4528
|
+
}
|
|
4529
|
+
const repo = await githubSlugFromOrigin(findRepoRoot());
|
|
4530
|
+
if (!repo) {
|
|
4531
|
+
throw new Error("This repo has no GitHub origin, so none of its agents live on the home machine.");
|
|
4532
|
+
}
|
|
4533
|
+
if (sub === "delete" && !yes && !options.json) {
|
|
4534
|
+
if (!isTty()) {
|
|
4535
|
+
throw new Error("Refusing to delete agents without a terminal to confirm in; pass --yes.");
|
|
4536
|
+
}
|
|
4537
|
+
if (!(await promptConfirm(`Delete ${runIds.length === 1 ? "this agent" : `${runIds.length} agents`} on the home machine? This cannot be undone.`, false))) {
|
|
4538
|
+
console.log("Nothing deleted.");
|
|
4539
|
+
return;
|
|
4540
|
+
}
|
|
4541
|
+
}
|
|
4542
|
+
const result = await client.request("/api/rudder/home/agents", {
|
|
4543
|
+
method: "POST",
|
|
4544
|
+
body: { repo, action: sub === "runs" ? "check" : "delete", runIds },
|
|
4545
|
+
});
|
|
4546
|
+
if (options.json) {
|
|
4547
|
+
printJson(result);
|
|
4548
|
+
return;
|
|
4549
|
+
}
|
|
4550
|
+
if (sub === "runs") {
|
|
4551
|
+
if (!result.known) {
|
|
4552
|
+
console.log("The home machine is not connected, so it can't say.");
|
|
4553
|
+
return;
|
|
4554
|
+
}
|
|
4555
|
+
const existing = new Set(result.existing ?? []);
|
|
4556
|
+
for (const id of runIds) {
|
|
4557
|
+
console.log(`${existing.has(id) ? "there " : "gone "} ${id}`);
|
|
4558
|
+
}
|
|
4559
|
+
return;
|
|
4560
|
+
}
|
|
4561
|
+
console.log(result.status === "queued"
|
|
4562
|
+
? "The home machine is asleep; it deletes them as soon as it is back."
|
|
4563
|
+
: "Deleted on the home machine.");
|
|
4564
|
+
}
|
|
4565
|
+
async function homeCommand(args, options) {
|
|
4566
|
+
const client = await cloudClient({ requireToken: true });
|
|
4567
|
+
const sub = args[0] ?? "status";
|
|
4568
|
+
// An agent on the home machine stopping it would kill itself and every other
|
|
4569
|
+
// agent there mid-task (and a "restart" from inside never gets to the start
|
|
4570
|
+
// half). Restarts come from a laptop or the phone.
|
|
4571
|
+
if (sub === "stop" && process.env.RUDDER_CLOUD_RUNTIME === "home" && !options.yes) {
|
|
4572
|
+
throw new Error("refusing to stop the home machine from inside it: that ends this session and every agent on it. Run it from your laptop or the phone (or pass --force).");
|
|
4573
|
+
}
|
|
4574
|
+
if (sub === "start" || sub === "stop") {
|
|
4575
|
+
const result = await client.request(`/api/rudder/home/${sub}`, { method: "POST", body: {} });
|
|
4576
|
+
if (options.json) {
|
|
4577
|
+
printJson(result);
|
|
4578
|
+
}
|
|
4579
|
+
else {
|
|
4580
|
+
console.log(sub === "start" ? "Home machine starting." : "Home machine stopped.");
|
|
4581
|
+
}
|
|
4582
|
+
return;
|
|
4583
|
+
}
|
|
4584
|
+
if (sub === "region") {
|
|
4585
|
+
const region = (args[1] ?? "").trim().toLowerCase();
|
|
4586
|
+
if (!region) {
|
|
4587
|
+
throw new Error("Usage: rudder cloud home region <fly region, e.g. sjc>");
|
|
4588
|
+
}
|
|
4589
|
+
if (process.env.RUDDER_CLOUD_RUNTIME === "home") {
|
|
4590
|
+
throw new Error("run this from your laptop: moving the home machine restarts it.");
|
|
4591
|
+
}
|
|
4592
|
+
const started = await client.request("/api/rudder/home/region", { method: "POST", body: { region } });
|
|
4593
|
+
console.log(`Moving the home machine ${started.move.from} -> ${started.move.to}. Its disk is copied; the old one is kept as a backup.`);
|
|
4594
|
+
let last = "";
|
|
4595
|
+
for (let i = 0; i < 400; i += 1) {
|
|
4596
|
+
await sleep(3000);
|
|
4597
|
+
const { move } = await client.request("/api/rudder/home/region", { method: "GET" });
|
|
4598
|
+
if (!move) {
|
|
4599
|
+
continue;
|
|
4600
|
+
}
|
|
4601
|
+
if (move.state !== last) {
|
|
4602
|
+
console.log(` ${move.state}`);
|
|
4603
|
+
last = move.state;
|
|
4604
|
+
}
|
|
4605
|
+
if (move.state === "failed") {
|
|
4606
|
+
throw new Error(`the move failed: ${move.error ?? "unknown error"}`);
|
|
4607
|
+
}
|
|
4608
|
+
if (move.state === "done") {
|
|
4609
|
+
console.log(`Done. The old disk (${move.oldVolumeId ?? "?"}) in ${move.from} is untouched; delete it once everything checks out.`);
|
|
4610
|
+
return;
|
|
4611
|
+
}
|
|
4612
|
+
}
|
|
4613
|
+
console.log("Still moving; check `rudder cloud home` in a few minutes.");
|
|
4614
|
+
return;
|
|
4615
|
+
}
|
|
4616
|
+
if (sub === "open") {
|
|
4617
|
+
const repo = args[1];
|
|
4618
|
+
if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
4619
|
+
throw new Error("Usage: rudder cloud home open <owner/repo>");
|
|
4620
|
+
}
|
|
4621
|
+
const result = await client.request("/api/rudder/home/open", { method: "POST", body: { repo } });
|
|
4622
|
+
if (options.json) {
|
|
4623
|
+
printJson(result);
|
|
4624
|
+
}
|
|
4625
|
+
else {
|
|
4626
|
+
console.log(`Opened ${repo} on the home machine.`);
|
|
4627
|
+
}
|
|
4628
|
+
return;
|
|
4629
|
+
}
|
|
4630
|
+
if (sub === "runs" || sub === "delete") {
|
|
4631
|
+
await homeAgentsCommand(client, sub, args.slice(1), options);
|
|
4632
|
+
return;
|
|
4633
|
+
}
|
|
4634
|
+
if (sub !== "status") {
|
|
4635
|
+
throw new Error("Usage: rudder cloud home [status|start|stop|region <code>|open <owner/repo>|runs <runId...>|delete <runId...>]");
|
|
4636
|
+
}
|
|
4637
|
+
const result = await client.request("/api/rudder/home", { method: "GET" });
|
|
4638
|
+
if (options.json) {
|
|
4639
|
+
printJson(result);
|
|
4640
|
+
return;
|
|
4641
|
+
}
|
|
4642
|
+
if (!result.home) {
|
|
4643
|
+
console.log("No home machine yet. Run `rudder cloud` in a repo with a GitHub origin to create it.");
|
|
4644
|
+
return;
|
|
4645
|
+
}
|
|
4646
|
+
const home = result.home;
|
|
4647
|
+
const tailnet = home.tailnet && typeof home.tailnet === "object" && !Array.isArray(home.tailnet)
|
|
4648
|
+
? home.tailnet
|
|
4649
|
+
: null;
|
|
4650
|
+
console.log(`home ${home.id} ${home.status}${home.connected ? " connected" : ""} ${home.activeAgents ?? 0} agent(s) working`);
|
|
4651
|
+
if (home.failureReason) {
|
|
4652
|
+
console.log(`why: ${home.failureReason}`);
|
|
4653
|
+
}
|
|
4654
|
+
if (tailnet) {
|
|
4655
|
+
console.log(`tailnet: ${tailnet.dnsName ?? tailnet.hostname ?? "?"}${tailnet.online ? "" : " (offline)"}${tailnet.error ? ` ${tailnet.error}` : ""}`);
|
|
4656
|
+
}
|
|
4657
|
+
for (const project of result.projects) {
|
|
4658
|
+
const agents = Array.isArray(project.agents) ? project.agents : [];
|
|
4659
|
+
const working = agents.filter((agent) => agent.status === "working").length;
|
|
4660
|
+
const waiting = agents.filter((agent) => agent.status === "waiting" || agent.status === "review").length;
|
|
4661
|
+
console.log(` ${project.name} ${project.repo} ${project.dashboard ?? project.status} ${working} working, ${waiting} need you`);
|
|
4662
|
+
if (project.failureReason) {
|
|
4663
|
+
console.log(` why: ${project.failureReason}`);
|
|
4664
|
+
}
|
|
4665
|
+
const ports = Array.isArray(project.ports) ? project.ports : [];
|
|
4666
|
+
for (const port of ports) {
|
|
4667
|
+
console.log(` :${port.port} ${port.label ?? ""} ${port.url ?? ""}`);
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
console.log(`Phone: ${appUrl(client.baseUrl)}`);
|
|
4671
|
+
}
|
|
4672
|
+
//# sourceMappingURL=cloud.js.map
|