@viraatdas/rudder 2.11.2 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud.d.ts +1 -0
- package/dist/cloud.js +666 -78
- package/dist/cloud.js.map +1 -1
- package/dist/jj.js +21 -6
- package/dist/jj.js.map +1 -1
- 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/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/cloud.js
CHANGED
|
@@ -99,6 +99,13 @@ const BULKY_HOME_BASENAME_PATTERNS = [
|
|
|
99
99
|
/\.jsonl$/,
|
|
100
100
|
];
|
|
101
101
|
export async function runCloudCommand(command, args, options = {}) {
|
|
102
|
+
// Hidden diagnostic flag: measure keystroke round-trip latency instead of
|
|
103
|
+
// starting an interactive attach. Parsed here because main.ts forwards
|
|
104
|
+
// unknown flags through as positional args.
|
|
105
|
+
if (args.includes("--latency-probe")) {
|
|
106
|
+
args = args.filter((arg) => arg !== "--latency-probe");
|
|
107
|
+
options = { ...options, latencyProbe: true };
|
|
108
|
+
}
|
|
102
109
|
const subcommand = args[0] ?? "";
|
|
103
110
|
const rest = args.slice(1);
|
|
104
111
|
if (command === "cloud" && subcommand === "help") {
|
|
@@ -203,6 +210,12 @@ export async function runCloudCommand(command, args, options = {}) {
|
|
|
203
210
|
case "runtime":
|
|
204
211
|
await runtime(rest, options);
|
|
205
212
|
return;
|
|
213
|
+
case "region":
|
|
214
|
+
await configureRegion(rest, options);
|
|
215
|
+
return;
|
|
216
|
+
case "secrets":
|
|
217
|
+
await secretsCommand(rest, options);
|
|
218
|
+
return;
|
|
206
219
|
default:
|
|
207
220
|
// A bare `rudder cloud "<text>"` / `rudder sail "<text>"` is the documented
|
|
208
221
|
// way to launch a worker ON that task (the instance name is derived from it).
|
|
@@ -463,20 +476,20 @@ async function onload(args, options) {
|
|
|
463
476
|
}
|
|
464
477
|
}
|
|
465
478
|
async function logs(args, options) {
|
|
466
|
-
const
|
|
467
|
-
if (!
|
|
479
|
+
const workerId = args[0];
|
|
480
|
+
if (!workerId) {
|
|
468
481
|
throw new Error("Usage: rudder cloud logs <id>");
|
|
469
482
|
}
|
|
470
483
|
const client = await cloudClient({ requireToken: true });
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const match = sails.find((item) => item && typeof item === "object" && !Array.isArray(item) && item.id ===
|
|
484
|
+
const [sailResult, workspaceResult] = await Promise.all([
|
|
485
|
+
client.request("/api/rudder/sail", { method: "GET" }),
|
|
486
|
+
client.request("/api/rudder/workspace", { method: "GET" }),
|
|
487
|
+
]);
|
|
488
|
+
const sails = collectionFromResult(sailResult, "sails");
|
|
489
|
+
const workspaces = collectionFromResult(workspaceResult, "workspaces");
|
|
490
|
+
const match = [...sails, ...workspaces].find((item) => item && typeof item === "object" && !Array.isArray(item) && item.id === workerId);
|
|
478
491
|
if (!match) {
|
|
479
|
-
throw new Error(`Cloud worker not found: ${
|
|
492
|
+
throw new Error(`Cloud worker not found: ${workerId}`);
|
|
480
493
|
}
|
|
481
494
|
if (options.json) {
|
|
482
495
|
printJson(match);
|
|
@@ -486,6 +499,16 @@ async function logs(args, options) {
|
|
|
486
499
|
console.log("Worker status:");
|
|
487
500
|
printSailList([match]);
|
|
488
501
|
}
|
|
502
|
+
function collectionFromResult(result, key) {
|
|
503
|
+
if (Array.isArray(result)) {
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
507
|
+
const collection = result[key];
|
|
508
|
+
return Array.isArray(collection) ? collection : [];
|
|
509
|
+
}
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
489
512
|
async function listSails(options) {
|
|
490
513
|
const client = await cloudClient({ requireToken: true });
|
|
491
514
|
const result = await client.request("/api/rudder/sail", { method: "GET" });
|
|
@@ -1103,27 +1126,30 @@ async function createSnapshot(repoRoot, requestedHomePaths, options = {}) {
|
|
|
1103
1126
|
? await copyProjectEnvFiles(repoRoot, repoStage)
|
|
1104
1127
|
: 0;
|
|
1105
1128
|
const rudderState = options.includeRudderState ? await copyRudderState(repoRoot, repoStage) : undefined;
|
|
1106
|
-
const
|
|
1129
|
+
const includeCredentials = options.includeCredentials !== false;
|
|
1107
1130
|
const includedHomePaths = [];
|
|
1108
|
-
for (const homePath of homePaths) {
|
|
1109
|
-
const copied = await copyHomePath(homePath, homeStage);
|
|
1110
|
-
if (copied) {
|
|
1111
|
-
includedHomePaths.push(shortenHome(homePath));
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
// On macOS, Claude Code stores its OAuth token in the Keychain rather than
|
|
1115
|
-
// ~/.claude/.credentials.json, so the home-paths copy above doesn't pick it
|
|
1116
|
-
// up. Extract it from the Keychain and stage it as a credentials file so
|
|
1117
|
-
// the cloud worker boots already logged in.
|
|
1118
|
-
if (await stageClaudeKeychainCredentials(homeStage)) {
|
|
1119
|
-
includedHomePaths.push("~/.claude/.credentials.json (keychain)");
|
|
1120
|
-
}
|
|
1121
|
-
const capturedEnv = captureCloudEnv(Boolean(options.migration));
|
|
1122
1131
|
let capturedEnvCount = 0;
|
|
1123
|
-
if (
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1132
|
+
if (includeCredentials) {
|
|
1133
|
+
const homePaths = normalizeHomePaths(requestedHomePaths);
|
|
1134
|
+
for (const homePath of homePaths) {
|
|
1135
|
+
const copied = await copyHomePath(homePath, homeStage);
|
|
1136
|
+
if (copied) {
|
|
1137
|
+
includedHomePaths.push(shortenHome(homePath));
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// On macOS, Claude Code stores its OAuth token in the Keychain rather than
|
|
1141
|
+
// ~/.claude/.credentials.json, so the home-paths copy above doesn't pick it
|
|
1142
|
+
// up. Extract it from the Keychain and stage it as a credentials file so
|
|
1143
|
+
// the cloud worker boots already logged in.
|
|
1144
|
+
if (await stageClaudeKeychainCredentials(homeStage)) {
|
|
1145
|
+
includedHomePaths.push("~/.claude/.credentials.json (keychain)");
|
|
1146
|
+
}
|
|
1147
|
+
const capturedEnv = captureCloudEnv(Boolean(options.migration));
|
|
1148
|
+
if (Object.keys(capturedEnv).length > 0) {
|
|
1149
|
+
await ensureDir(path.join(stageDir, "env"));
|
|
1150
|
+
await writeJson(path.join(stageDir, "env", "cloud-env.json"), capturedEnv);
|
|
1151
|
+
capturedEnvCount = Object.keys(capturedEnv).length;
|
|
1152
|
+
}
|
|
1127
1153
|
}
|
|
1128
1154
|
let migratedAgentsCount = 0;
|
|
1129
1155
|
if (options.migration && options.migration.plan.migrated.length > 0) {
|
|
@@ -1456,25 +1482,34 @@ function normalizeHomePaths(requested) {
|
|
|
1456
1482
|
}
|
|
1457
1483
|
return paths;
|
|
1458
1484
|
}
|
|
1459
|
-
|
|
1485
|
+
// On macOS, Claude Code keeps its OAuth token in the Keychain instead of
|
|
1486
|
+
// ~/.claude/.credentials.json. Read it so cloud workspaces boot logged in.
|
|
1487
|
+
async function readClaudeKeychainCredentials() {
|
|
1460
1488
|
if (process.platform !== "darwin") {
|
|
1461
|
-
return
|
|
1489
|
+
return null;
|
|
1462
1490
|
}
|
|
1463
1491
|
if (!commandExists("security")) {
|
|
1464
|
-
return
|
|
1492
|
+
return null;
|
|
1465
1493
|
}
|
|
1466
1494
|
const result = await runCommand("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { allowFailure: true });
|
|
1467
1495
|
if (result.code !== 0) {
|
|
1468
|
-
return
|
|
1496
|
+
return null;
|
|
1469
1497
|
}
|
|
1470
1498
|
const payload = result.stdout.trim();
|
|
1471
1499
|
if (!payload || !payload.startsWith("{")) {
|
|
1472
|
-
return
|
|
1500
|
+
return null;
|
|
1473
1501
|
}
|
|
1474
1502
|
try {
|
|
1475
1503
|
JSON.parse(payload);
|
|
1476
1504
|
}
|
|
1477
1505
|
catch {
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
return payload;
|
|
1509
|
+
}
|
|
1510
|
+
async function stageClaudeKeychainCredentials(homeStage) {
|
|
1511
|
+
const payload = await readClaudeKeychainCredentials();
|
|
1512
|
+
if (!payload) {
|
|
1478
1513
|
return false;
|
|
1479
1514
|
}
|
|
1480
1515
|
const targetDir = path.join(homeStage, ".claude");
|
|
@@ -1678,12 +1713,16 @@ async function workspaceCommand(args, options) {
|
|
|
1678
1713
|
await workspaceAttach(rest, options);
|
|
1679
1714
|
return;
|
|
1680
1715
|
}
|
|
1716
|
+
if (sub === "create") {
|
|
1717
|
+
await workspaceCreate(rest, options);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1681
1720
|
if (sub === "share") {
|
|
1682
1721
|
await workspaceShare(options);
|
|
1683
1722
|
return;
|
|
1684
1723
|
}
|
|
1685
1724
|
if (sub === "status") {
|
|
1686
|
-
await workspaceStatus(options);
|
|
1725
|
+
await workspaceStatus(rest, options);
|
|
1687
1726
|
return;
|
|
1688
1727
|
}
|
|
1689
1728
|
if (sub === "stop" || sub === "pause" || sub === "resume") {
|
|
@@ -1694,39 +1733,394 @@ async function workspaceCommand(args, options) {
|
|
|
1694
1733
|
await workspaceList(options);
|
|
1695
1734
|
return;
|
|
1696
1735
|
}
|
|
1697
|
-
throw new Error("Usage: rudder cloud workspace [attach [id]|share|status|pause|resume|stop|list]");
|
|
1736
|
+
throw new Error("Usage: rudder cloud workspace [attach [id|owner/repo]|create <owner/repo>|share|status|pause|resume|stop|list]");
|
|
1737
|
+
}
|
|
1738
|
+
const GITHUB_SLUG_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
1739
|
+
async function githubSlugFromOrigin(repoRoot) {
|
|
1740
|
+
const result = await runCommand("git", ["remote", "get-url", "origin"], {
|
|
1741
|
+
cwd: repoRoot,
|
|
1742
|
+
allowFailure: true,
|
|
1743
|
+
});
|
|
1744
|
+
if (result.code !== 0) {
|
|
1745
|
+
return null;
|
|
1746
|
+
}
|
|
1747
|
+
const match = result.stdout.trim().match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
1748
|
+
return match ? match[1] : null;
|
|
1749
|
+
}
|
|
1750
|
+
// Cloud-native workspace: the worker clones the repo from GitHub directly —
|
|
1751
|
+
// full history, real origin remote, no local-directory upload.
|
|
1752
|
+
async function workspaceCreate(args, options) {
|
|
1753
|
+
const slug = (args[0] ?? "").trim().replace(/\.git$/, "");
|
|
1754
|
+
if (!GITHUB_SLUG_RE.test(slug)) {
|
|
1755
|
+
throw new Error("Usage: rudder cloud workspace create <owner/repo> [--branch <name>] [--region <code>]");
|
|
1756
|
+
}
|
|
1757
|
+
let branch;
|
|
1758
|
+
let region;
|
|
1759
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
1760
|
+
const arg = args[i] ?? "";
|
|
1761
|
+
if (arg === "--branch")
|
|
1762
|
+
branch = args[++i];
|
|
1763
|
+
else if (arg.startsWith("--branch="))
|
|
1764
|
+
branch = arg.slice("--branch=".length);
|
|
1765
|
+
else if (arg === "--region")
|
|
1766
|
+
region = args[++i];
|
|
1767
|
+
else if (arg.startsWith("--region="))
|
|
1768
|
+
region = arg.slice("--region=".length);
|
|
1769
|
+
else
|
|
1770
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1771
|
+
}
|
|
1772
|
+
const client = await cloudClient({ requireToken: true });
|
|
1773
|
+
// Preflight: private clones and pushes need GitHub credentials from the vault.
|
|
1774
|
+
try {
|
|
1775
|
+
const secretsResult = await client.request("/api/rudder/secrets", { method: "GET" });
|
|
1776
|
+
const secrets = secretsResult?.secrets ?? [];
|
|
1777
|
+
const hasGitCreds = secrets.some((secret) => (secret.kind === "file" && secret.name.startsWith("~/.config/gh/"))
|
|
1778
|
+
|| (secret.kind === "env" && (secret.name === "GITHUB_TOKEN" || secret.name === "GH_TOKEN")));
|
|
1779
|
+
if (!hasGitCreds && !options.json) {
|
|
1780
|
+
process.stderr.write("Warning: no GitHub credentials in the cloud vault; private repos will fail to clone. Run `rudder cloud secrets sync` first.\n");
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
// Old server or unconfigured vault; the worker will report clone failures.
|
|
1785
|
+
}
|
|
1786
|
+
if (!options.json) {
|
|
1787
|
+
process.stderr.write(`Creating cloud workspace for ${slug}...\n`);
|
|
1788
|
+
}
|
|
1789
|
+
const effectiveRegion = region ?? await explicitCloudRegion();
|
|
1790
|
+
const result = await client.request("/api/rudder/workspace/create", {
|
|
1791
|
+
method: "POST",
|
|
1792
|
+
body: {
|
|
1793
|
+
repo: slug,
|
|
1794
|
+
...(branch ? { branch } : {}),
|
|
1795
|
+
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
1796
|
+
},
|
|
1797
|
+
});
|
|
1798
|
+
await attachToWorkspaceResult(result, options);
|
|
1799
|
+
}
|
|
1800
|
+
async function workspaceAttachByRepo(slug, options) {
|
|
1801
|
+
const normalized = slug.trim().replace(/\.git$/, "");
|
|
1802
|
+
const client = await cloudClient({ requireToken: true });
|
|
1803
|
+
try {
|
|
1804
|
+
await client.request(`/api/rudder/workspace/lookup?repo=${encodeURIComponent(normalized)}`, { method: "GET" });
|
|
1805
|
+
}
|
|
1806
|
+
catch {
|
|
1807
|
+
throw new Error(`No cloud workspace for ${normalized}. Create one with \`rudder cloud workspace create ${normalized}\`.`);
|
|
1808
|
+
}
|
|
1809
|
+
// The create endpoint reuses/warm-restarts an existing clone workspace.
|
|
1810
|
+
const result = await client.request("/api/rudder/workspace/create", {
|
|
1811
|
+
method: "POST",
|
|
1812
|
+
body: { repo: normalized },
|
|
1813
|
+
});
|
|
1814
|
+
await attachToWorkspaceResult(result, options);
|
|
1815
|
+
}
|
|
1816
|
+
const MAX_SECRET_VALUE_BYTES = 1024 * 1024;
|
|
1817
|
+
async function secretsCommand(args, options) {
|
|
1818
|
+
const sub = args[0] ?? "";
|
|
1819
|
+
const rest = args.slice(1);
|
|
1820
|
+
switch (sub) {
|
|
1821
|
+
case "set":
|
|
1822
|
+
await secretsSet(rest, options);
|
|
1823
|
+
return;
|
|
1824
|
+
case "list":
|
|
1825
|
+
case "ls":
|
|
1826
|
+
await secretsList(options);
|
|
1827
|
+
return;
|
|
1828
|
+
case "rm":
|
|
1829
|
+
case "remove":
|
|
1830
|
+
case "delete":
|
|
1831
|
+
await secretsRm(rest, options);
|
|
1832
|
+
return;
|
|
1833
|
+
case "sync":
|
|
1834
|
+
await secretsSync(options);
|
|
1835
|
+
return;
|
|
1836
|
+
default:
|
|
1837
|
+
throw new Error("Usage: rudder cloud secrets [set <NAME> [value] | set --file <~/path> [source] | list | rm <NAME> | sync]");
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
// Convert an absolute or ~-prefixed path into the canonical tilde form the
|
|
1841
|
+
// vault stores file secrets under. Only paths inside $HOME are allowed.
|
|
1842
|
+
function toTildePath(input) {
|
|
1843
|
+
const trimmed = input.trim();
|
|
1844
|
+
const resolved = path.resolve(expandHome(trimmed));
|
|
1845
|
+
const home = os.homedir();
|
|
1846
|
+
if (!isInside(home, resolved) || resolved === home) {
|
|
1847
|
+
throw new Error(`File secrets must live inside your home directory: ${input}`);
|
|
1848
|
+
}
|
|
1849
|
+
return `~/${path.relative(home, resolved).split(path.sep).join("/")}`;
|
|
1850
|
+
}
|
|
1851
|
+
async function readStdinAll() {
|
|
1852
|
+
const chunks = [];
|
|
1853
|
+
for await (const chunk of process.stdin) {
|
|
1854
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1855
|
+
}
|
|
1856
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1857
|
+
}
|
|
1858
|
+
async function secretsSet(args, options) {
|
|
1859
|
+
const client = await cloudClient({ requireToken: true });
|
|
1860
|
+
if (args[0] === "--file") {
|
|
1861
|
+
const target = args[1];
|
|
1862
|
+
if (!target) {
|
|
1863
|
+
throw new Error("Usage: rudder cloud secrets set --file <~/path> [localSourcePath]");
|
|
1864
|
+
}
|
|
1865
|
+
const tildePath = toTildePath(target);
|
|
1866
|
+
const sourcePath = path.resolve(expandHome(args[2] ?? target));
|
|
1867
|
+
const content = await fsp.readFile(sourcePath);
|
|
1868
|
+
if (content.length > MAX_SECRET_VALUE_BYTES) {
|
|
1869
|
+
throw new Error(`${sourcePath} is ${content.length} bytes; file secrets are capped at ${MAX_SECRET_VALUE_BYTES}`);
|
|
1870
|
+
}
|
|
1871
|
+
await client.request("/api/rudder/secrets/item", {
|
|
1872
|
+
method: "PUT",
|
|
1873
|
+
body: {
|
|
1874
|
+
name: tildePath,
|
|
1875
|
+
kind: "file",
|
|
1876
|
+
filePath: tildePath,
|
|
1877
|
+
valueBase64: content.toString("base64"),
|
|
1878
|
+
source: "manual",
|
|
1879
|
+
},
|
|
1880
|
+
});
|
|
1881
|
+
if (options.json) {
|
|
1882
|
+
printJson({ ok: true, name: tildePath, kind: "file" });
|
|
1883
|
+
}
|
|
1884
|
+
else {
|
|
1885
|
+
console.log(`Stored file secret ${tildePath} (${content.length} bytes). Takes effect on next workspace boot.`);
|
|
1886
|
+
}
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
const name = args[0];
|
|
1890
|
+
if (!name) {
|
|
1891
|
+
throw new Error("Usage: rudder cloud secrets set <NAME> [value] (or pipe the value on stdin)");
|
|
1892
|
+
}
|
|
1893
|
+
let value = args[1];
|
|
1894
|
+
if (value === undefined) {
|
|
1895
|
+
value = process.stdin.isTTY
|
|
1896
|
+
? await promptSecret(`Value for ${name}`)
|
|
1897
|
+
: (await readStdinAll()).replace(/\r?\n$/, "");
|
|
1898
|
+
}
|
|
1899
|
+
if (!value) {
|
|
1900
|
+
throw new Error(`No value provided for ${name}.`);
|
|
1901
|
+
}
|
|
1902
|
+
if (Buffer.byteLength(value, "utf8") > MAX_SECRET_VALUE_BYTES) {
|
|
1903
|
+
throw new Error(`Value for ${name} exceeds the ${MAX_SECRET_VALUE_BYTES}-byte cap`);
|
|
1904
|
+
}
|
|
1905
|
+
await client.request("/api/rudder/secrets/item", {
|
|
1906
|
+
method: "PUT",
|
|
1907
|
+
body: {
|
|
1908
|
+
name,
|
|
1909
|
+
kind: "env",
|
|
1910
|
+
valueBase64: Buffer.from(value, "utf8").toString("base64"),
|
|
1911
|
+
source: "manual",
|
|
1912
|
+
},
|
|
1913
|
+
});
|
|
1914
|
+
if (options.json) {
|
|
1915
|
+
printJson({ ok: true, name, kind: "env" });
|
|
1916
|
+
}
|
|
1917
|
+
else {
|
|
1918
|
+
console.log(`Stored env secret ${name}. Takes effect on next workspace boot.`);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
async function secretsList(options) {
|
|
1922
|
+
const client = await cloudClient({ requireToken: true });
|
|
1923
|
+
const result = await client.request("/api/rudder/secrets", { method: "GET" });
|
|
1924
|
+
const secrets = result?.secrets ?? [];
|
|
1925
|
+
if (options.json) {
|
|
1926
|
+
printJson(result);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (secrets.length === 0) {
|
|
1930
|
+
console.log("No cloud secrets stored. Run `rudder cloud secrets sync` to import your local credentials.");
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const nameWidth = Math.max(4, ...secrets.map((secret) => secret.name.length));
|
|
1934
|
+
console.log(`${"NAME".padEnd(nameWidth)} KIND SIZE UPDATED`);
|
|
1935
|
+
for (const secret of secrets) {
|
|
1936
|
+
const size = `${secret.sizeBytes}B`.padEnd(8);
|
|
1937
|
+
console.log(`${secret.name.padEnd(nameWidth)} ${secret.kind.padEnd(4)} ${size} ${secret.updatedAt}`);
|
|
1938
|
+
}
|
|
1939
|
+
console.log(`\n${secrets.length} secret(s). Values are never shown; rotate with \`rudder cloud secrets set\`.`);
|
|
1940
|
+
}
|
|
1941
|
+
async function secretsRm(args, options) {
|
|
1942
|
+
const name = args[0];
|
|
1943
|
+
if (!name) {
|
|
1944
|
+
throw new Error("Usage: rudder cloud secrets rm <NAME|~/path>");
|
|
1945
|
+
}
|
|
1946
|
+
const client = await cloudClient({ requireToken: true });
|
|
1947
|
+
const normalized = name.startsWith("~") || name.startsWith("/") ? toTildePath(name) : name;
|
|
1948
|
+
await client.request(`/api/rudder/secrets/item?name=${encodeURIComponent(normalized)}`, {
|
|
1949
|
+
method: "DELETE",
|
|
1950
|
+
});
|
|
1951
|
+
if (options.json) {
|
|
1952
|
+
printJson({ ok: true, name: normalized });
|
|
1953
|
+
}
|
|
1954
|
+
else {
|
|
1955
|
+
console.log(`Removed cloud secret ${normalized}.`);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
async function collectHomeSecretFiles() {
|
|
1959
|
+
const home = os.homedir();
|
|
1960
|
+
const out = [];
|
|
1961
|
+
const walk = async (target) => {
|
|
1962
|
+
const stat = await fsp.lstat(target).catch(() => null);
|
|
1963
|
+
if (!stat) {
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
if (stat.isSymbolicLink()) {
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
if (stat.isDirectory()) {
|
|
1970
|
+
if (!(await shouldIncludeSnapshotPath(target))) {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const entries = await fsp.readdir(target).catch(() => []);
|
|
1974
|
+
for (const entry of entries) {
|
|
1975
|
+
await walk(path.join(target, entry));
|
|
1976
|
+
}
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
if (!stat.isFile() || !(await shouldIncludeSnapshotPath(target))) {
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
out.push({
|
|
1983
|
+
tildePath: `~/${path.relative(home, target).split(path.sep).join("/")}`,
|
|
1984
|
+
absolute: target,
|
|
1985
|
+
size: stat.size,
|
|
1986
|
+
});
|
|
1987
|
+
};
|
|
1988
|
+
for (const root of normalizeHomePaths([])) {
|
|
1989
|
+
await walk(root);
|
|
1990
|
+
}
|
|
1991
|
+
return out;
|
|
1992
|
+
}
|
|
1993
|
+
// One-time (re-runnable) import of the credentials that used to ride inside
|
|
1994
|
+
// every workspace snapshot: the DEFAULT_HOME_PATHS allowlist, the macOS
|
|
1995
|
+
// Keychain Claude token, and the captured env vars.
|
|
1996
|
+
async function secretsSync(options) {
|
|
1997
|
+
const client = await cloudClient({ requireToken: true });
|
|
1998
|
+
const items = [];
|
|
1999
|
+
const skipped = [];
|
|
2000
|
+
for (const file of await collectHomeSecretFiles()) {
|
|
2001
|
+
if (file.size === 0) {
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
if (file.size > MAX_SECRET_VALUE_BYTES) {
|
|
2005
|
+
skipped.push({ name: file.tildePath, reason: `${file.size} bytes exceeds the per-secret cap` });
|
|
2006
|
+
continue;
|
|
2007
|
+
}
|
|
2008
|
+
const content = await fsp.readFile(file.absolute).catch(() => null);
|
|
2009
|
+
if (!content) {
|
|
2010
|
+
skipped.push({ name: file.tildePath, reason: "unreadable" });
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
items.push({
|
|
2014
|
+
name: file.tildePath,
|
|
2015
|
+
kind: "file",
|
|
2016
|
+
filePath: file.tildePath,
|
|
2017
|
+
valueBase64: content.toString("base64"),
|
|
2018
|
+
source: "sync",
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
// Keychain read can pop a macOS auth dialog, so only attempt it when a
|
|
2022
|
+
// human is at the terminal to answer it.
|
|
2023
|
+
if (isTty()) {
|
|
2024
|
+
const keychainPayload = await readClaudeKeychainCredentials();
|
|
2025
|
+
if (keychainPayload) {
|
|
2026
|
+
items.push({
|
|
2027
|
+
name: "~/.claude/.credentials.json",
|
|
2028
|
+
kind: "file",
|
|
2029
|
+
filePath: "~/.claude/.credentials.json",
|
|
2030
|
+
valueBase64: Buffer.from(keychainPayload + "\n", "utf8").toString("base64"),
|
|
2031
|
+
source: "sync",
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
for (const [name, value] of Object.entries(captureCloudEnv())) {
|
|
2036
|
+
items.push({
|
|
2037
|
+
name,
|
|
2038
|
+
kind: "env",
|
|
2039
|
+
valueBase64: Buffer.from(value, "utf8").toString("base64"),
|
|
2040
|
+
source: "sync",
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
if (items.length === 0) {
|
|
2044
|
+
throw new Error("Found nothing to sync: no allowlisted credential files or matching env vars.");
|
|
2045
|
+
}
|
|
2046
|
+
const response = await client.request("/api/rudder/secrets/bulk", { method: "POST", body: { items } });
|
|
2047
|
+
const results = response?.results ?? [];
|
|
2048
|
+
const stored = results.filter((entry) => entry.ok);
|
|
2049
|
+
const failed = results.filter((entry) => !entry.ok);
|
|
2050
|
+
if (options.json) {
|
|
2051
|
+
printJson({ stored: stored.length, failed, skipped });
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
console.log(`Synced ${stored.length} secret(s) to the cloud vault:`);
|
|
2055
|
+
for (const entry of stored) {
|
|
2056
|
+
console.log(` ${entry.name}`);
|
|
2057
|
+
}
|
|
2058
|
+
for (const entry of failed) {
|
|
2059
|
+
console.log(` FAILED ${entry.name}: ${entry.error ?? "unknown error"}`);
|
|
2060
|
+
}
|
|
2061
|
+
for (const entry of skipped) {
|
|
2062
|
+
console.log(` SKIPPED ${entry.name}: ${entry.reason}`);
|
|
2063
|
+
}
|
|
2064
|
+
console.log("\nNew cloud workspaces will now boot with these secrets; snapshots stop carrying local credentials.");
|
|
1698
2065
|
}
|
|
1699
2066
|
function computeWorkspaceKey(repoRoot) {
|
|
1700
2067
|
const normalized = path.resolve(repoRoot);
|
|
1701
2068
|
return createHash("sha256").update(normalized).digest("hex").slice(0, 32);
|
|
1702
2069
|
}
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
2070
|
+
// Worker placement policy: with the single-region relay in the middle of every
|
|
2071
|
+
// attach, echo latency = RTT(client↔relay) + RTT(relay↔worker), so the worker
|
|
2072
|
+
// belongs NEXT TO THE RELAY, not next to the user. We therefore only send a
|
|
2073
|
+
// region when the user explicitly asked for one (env var or `rudder cloud
|
|
2074
|
+
// region <code>`); otherwise the server places the worker in its own region.
|
|
2075
|
+
async function explicitCloudRegion() {
|
|
2076
|
+
const envRegion = process.env.RUDDER_CLOUD_REGION?.trim().toLowerCase();
|
|
2077
|
+
if (envRegion) {
|
|
2078
|
+
return envRegion;
|
|
2079
|
+
}
|
|
2080
|
+
const state = await loadCloudAuth().catch(() => null);
|
|
2081
|
+
return state?.defaultRegion?.trim().toLowerCase() || undefined;
|
|
2082
|
+
}
|
|
2083
|
+
async function configureRegion(args, options) {
|
|
2084
|
+
const value = (args[0] ?? "").trim().toLowerCase();
|
|
2085
|
+
const state = await loadCloudAuth();
|
|
2086
|
+
if (!state) {
|
|
2087
|
+
throw new Error("Not logged in to Rudder Cloud. Run `rudder login` first.");
|
|
1707
2088
|
}
|
|
1708
|
-
if (
|
|
1709
|
-
|
|
2089
|
+
if (!value) {
|
|
2090
|
+
const current = state.defaultRegion ?? "";
|
|
2091
|
+
if (options.json) {
|
|
2092
|
+
printJson({ region: current || null });
|
|
2093
|
+
}
|
|
2094
|
+
else if (current) {
|
|
2095
|
+
console.log(`Cloud worker region override: ${current} (run \`rudder cloud region clear\` to let the server choose).`);
|
|
2096
|
+
}
|
|
2097
|
+
else {
|
|
2098
|
+
console.log("No region override set: workers are placed next to the relay for lowest typing latency.");
|
|
2099
|
+
}
|
|
2100
|
+
return;
|
|
1710
2101
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
if (region && region.length <= 6 && /^[a-z]+$/.test(region)) {
|
|
1719
|
-
cachedFlyRegion = region;
|
|
1720
|
-
return region;
|
|
1721
|
-
}
|
|
2102
|
+
if (value === "clear" || value === "none" || value === "auto") {
|
|
2103
|
+
await saveCloudAuth({ ...state, defaultRegion: undefined, updatedAt: nowIso() });
|
|
2104
|
+
if (options.json) {
|
|
2105
|
+
printJson({ ok: true, region: null });
|
|
2106
|
+
}
|
|
2107
|
+
else {
|
|
2108
|
+
console.log("Cleared region override; the server will place workers next to the relay.");
|
|
1722
2109
|
}
|
|
2110
|
+
return;
|
|
1723
2111
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
2112
|
+
if (!/^[a-z]{3,6}$/.test(value)) {
|
|
2113
|
+
throw new Error(`Invalid Fly region code: ${value}`);
|
|
2114
|
+
}
|
|
2115
|
+
await saveCloudAuth({ ...state, defaultRegion: value, updatedAt: nowIso() });
|
|
2116
|
+
if (options.json) {
|
|
2117
|
+
printJson({ ok: true, region: value });
|
|
2118
|
+
}
|
|
2119
|
+
else {
|
|
2120
|
+
console.log(`Cloud worker region override set to ${value}. Note: placing workers away from the relay increases typing latency.`);
|
|
1726
2121
|
}
|
|
1727
|
-
return undefined;
|
|
1728
2122
|
}
|
|
1729
|
-
async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths) {
|
|
2123
|
+
async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths, vaultActive = false) {
|
|
1730
2124
|
const hash = createHash("sha256");
|
|
1731
2125
|
// Repo state: HEAD commit + the porcelain dirty file list. Two attaches
|
|
1732
2126
|
// from the same repo at the same commit with no edits should produce the
|
|
@@ -1740,6 +2134,13 @@ async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths) {
|
|
|
1740
2134
|
if (status.code === 0) {
|
|
1741
2135
|
hash.update(`repo:status:${status.stdout}\n`);
|
|
1742
2136
|
}
|
|
2137
|
+
// With the vault active the snapshot carries no credentials, so credential
|
|
2138
|
+
// changes must NOT change the fingerprint: a mismatch triggers the
|
|
2139
|
+
// destructive destroy+recreate path server-side, and rotation already takes
|
|
2140
|
+
// effect on the next boot via the supervisor's vault fetch.
|
|
2141
|
+
if (vaultActive) {
|
|
2142
|
+
return hash.digest("hex").slice(0, 32);
|
|
2143
|
+
}
|
|
1743
2144
|
// macOS Keychain claude credentials: hash content so re-logging in
|
|
1744
2145
|
// invalidates the cache but a steady-state user keeps it.
|
|
1745
2146
|
if (process.platform === "darwin") {
|
|
@@ -1760,9 +2161,28 @@ async function computeSnapshotFingerprint(repoRoot, _requestedHomePaths) {
|
|
|
1760
2161
|
}
|
|
1761
2162
|
return hash.digest("hex").slice(0, 32);
|
|
1762
2163
|
}
|
|
2164
|
+
// True when the account has vault secrets on this control plane, meaning
|
|
2165
|
+
// snapshots should stop carrying local credentials. Any failure (old server,
|
|
2166
|
+
// vault unconfigured, network) degrades to legacy snapshot behavior.
|
|
2167
|
+
async function accountHasVaultSecrets(client) {
|
|
2168
|
+
if (process.env.RUDDER_CLOUD_LEGACY_SNAPSHOT_SECRETS === "1") {
|
|
2169
|
+
return false;
|
|
2170
|
+
}
|
|
2171
|
+
try {
|
|
2172
|
+
const result = await client.request("/api/rudder/secrets", { method: "GET" });
|
|
2173
|
+
return (result?.secrets ?? []).length > 0;
|
|
2174
|
+
}
|
|
2175
|
+
catch {
|
|
2176
|
+
return false;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
1763
2179
|
async function workspaceAttach(args, options) {
|
|
1764
2180
|
const explicitId = args[0];
|
|
1765
2181
|
if (explicitId) {
|
|
2182
|
+
if (explicitId.includes("/")) {
|
|
2183
|
+
await workspaceAttachByRepo(explicitId, options);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
1766
2186
|
await workspaceAttachById(explicitId, options);
|
|
1767
2187
|
return;
|
|
1768
2188
|
}
|
|
@@ -1773,13 +2193,31 @@ async function workspaceAttach(args, options) {
|
|
|
1773
2193
|
if (!options.json) {
|
|
1774
2194
|
process.stderr.write(`Resolving cloud workspace for ${repoName}...\n`);
|
|
1775
2195
|
}
|
|
2196
|
+
// Prefer an existing cloud-native (clone-based) workspace for this repo's
|
|
2197
|
+
// origin over uploading a snapshot of the local directory.
|
|
2198
|
+
const originSlug = await githubSlugFromOrigin(repoRoot);
|
|
2199
|
+
if (originSlug && isTty() && !options.json) {
|
|
2200
|
+
const cloneWorkspace = await client.request(`/api/rudder/workspace/lookup?repo=${encodeURIComponent(originSlug)}`, { method: "GET" }).catch(() => null);
|
|
2201
|
+
if (cloneWorkspace) {
|
|
2202
|
+
const useClone = await promptConfirm(`A cloud-native workspace for ${originSlug} exists. Attach it instead of uploading a local snapshot?`, true);
|
|
2203
|
+
if (useClone) {
|
|
2204
|
+
const result = await client.request("/api/rudder/workspace/create", {
|
|
2205
|
+
method: "POST",
|
|
2206
|
+
body: { repo: originSlug },
|
|
2207
|
+
});
|
|
2208
|
+
await attachToWorkspaceResult(result, options);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
1776
2213
|
// Kick off the non-interactive work in parallel. planAgentMigration can
|
|
1777
2214
|
// call promptConfirm for a TTY prompt, so we serialize it AFTER the
|
|
1778
2215
|
// parallel work resolves to avoid garbled stdout during the prompt.
|
|
1779
|
-
const [region,
|
|
1780
|
-
|
|
1781
|
-
|
|
2216
|
+
const [region, vaultActive] = await Promise.all([
|
|
2217
|
+
explicitCloudRegion(),
|
|
2218
|
+
accountHasVaultSecrets(client),
|
|
1782
2219
|
]);
|
|
2220
|
+
const fingerprint = await computeSnapshotFingerprint(repoRoot, options.homePaths ?? [], vaultActive);
|
|
1783
2221
|
const migrationPlan = await planAgentMigration(repoRoot, options);
|
|
1784
2222
|
const mustUploadSnapshot = Boolean(migrationPlan && migrationPlan.migrated.length > 0);
|
|
1785
2223
|
const baseBody = {
|
|
@@ -1810,6 +2248,7 @@ async function workspaceAttach(args, options) {
|
|
|
1810
2248
|
}
|
|
1811
2249
|
const snapshot = await createSnapshot(repoRoot, options.homePaths ?? [], {
|
|
1812
2250
|
includeRudderState: true,
|
|
2251
|
+
includeCredentials: !vaultActive,
|
|
1813
2252
|
migration: migrationPlan ? { repoName, plan: migrationPlan } : undefined,
|
|
1814
2253
|
});
|
|
1815
2254
|
try {
|
|
@@ -1881,7 +2320,8 @@ async function attachToWorkspaceResult(result, options) {
|
|
|
1881
2320
|
printJson(record);
|
|
1882
2321
|
return;
|
|
1883
2322
|
}
|
|
1884
|
-
|
|
2323
|
+
// The latency probe is non-interactive by design; it must not be gated on a TTY.
|
|
2324
|
+
if (!options.latencyProbe && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
1885
2325
|
process.stderr.write(`Workspace ${workspaceId} is ready. Run \`rudder cloud workspace attach\` from a TTY to take over.\n`);
|
|
1886
2326
|
return;
|
|
1887
2327
|
}
|
|
@@ -1894,7 +2334,7 @@ async function workspaceAttachById(workspaceId, options) {
|
|
|
1894
2334
|
if (options.json) {
|
|
1895
2335
|
printJson({ id: workspaceId, attaching: true });
|
|
1896
2336
|
}
|
|
1897
|
-
else if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2337
|
+
else if (!options.latencyProbe && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
1898
2338
|
process.stderr.write(`Workspace ${workspaceId}: attach requires a TTY.\n`);
|
|
1899
2339
|
return;
|
|
1900
2340
|
}
|
|
@@ -1951,7 +2391,7 @@ async function workspaceShare(options) {
|
|
|
1951
2391
|
console.log("");
|
|
1952
2392
|
console.log("They must already be logged in to Rudder Cloud with their own account (run `rudder cloud login` if not).");
|
|
1953
2393
|
}
|
|
1954
|
-
async function workspaceStatus(options) {
|
|
2394
|
+
async function workspaceStatus(args, options) {
|
|
1955
2395
|
if (process.env.RUDDER_OFFLINE === "1") {
|
|
1956
2396
|
if (options.json) {
|
|
1957
2397
|
printJson({ offline: true, workspace: null });
|
|
@@ -1961,7 +2401,10 @@ async function workspaceStatus(options) {
|
|
|
1961
2401
|
}
|
|
1962
2402
|
return;
|
|
1963
2403
|
}
|
|
1964
|
-
const
|
|
2404
|
+
const explicitId = args[0];
|
|
2405
|
+
const workspace = await (explicitId
|
|
2406
|
+
? lookupWorkspaceById(explicitId)
|
|
2407
|
+
: lookupWorkspaceForRepo(options)).catch((error) => {
|
|
1965
2408
|
if (!options.json) {
|
|
1966
2409
|
console.warn(`Could not reach Rudder Cloud: ${error instanceof Error ? error.message : String(error)}`);
|
|
1967
2410
|
}
|
|
@@ -2003,6 +2446,15 @@ async function workspaceStatus(options) {
|
|
|
2003
2446
|
console.log("No recent activity.");
|
|
2004
2447
|
}
|
|
2005
2448
|
}
|
|
2449
|
+
async function lookupWorkspaceById(id) {
|
|
2450
|
+
const client = await cloudClient({ requireToken: true });
|
|
2451
|
+
const result = await client.request("/api/rudder/workspace", { method: "GET" });
|
|
2452
|
+
const match = collectionFromResult(result, "workspaces").find((item) => item && typeof item === "object" && !Array.isArray(item)
|
|
2453
|
+
&& item.id === id);
|
|
2454
|
+
return match && typeof match === "object" && !Array.isArray(match)
|
|
2455
|
+
? match
|
|
2456
|
+
: null;
|
|
2457
|
+
}
|
|
2006
2458
|
function computeIdleMinutes(lastActivityAt) {
|
|
2007
2459
|
if (!lastActivityAt) {
|
|
2008
2460
|
return null;
|
|
@@ -2027,7 +2479,15 @@ async function workspaceMutate(action, args, options) {
|
|
|
2027
2479
|
method: "POST",
|
|
2028
2480
|
body: {},
|
|
2029
2481
|
});
|
|
2030
|
-
|
|
2482
|
+
if (options.json) {
|
|
2483
|
+
printJson(result);
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
const record = result && typeof result === "object" && !Array.isArray(result)
|
|
2487
|
+
? result
|
|
2488
|
+
: {};
|
|
2489
|
+
const status = typeof record.status === "string" ? record.status : action;
|
|
2490
|
+
console.log(`Workspace ${id} ${status}.`);
|
|
2031
2491
|
}
|
|
2032
2492
|
async function workspaceList(options) {
|
|
2033
2493
|
const client = await cloudClient({ requireToken: true });
|
|
@@ -2054,7 +2514,8 @@ async function runAttach(target, options) {
|
|
|
2054
2514
|
+ `/api/rudder/${target.kind}/${encodeURIComponent(target.id)}/attach`;
|
|
2055
2515
|
const stdin = process.stdin;
|
|
2056
2516
|
const stdout = process.stdout;
|
|
2057
|
-
const
|
|
2517
|
+
const probeMode = Boolean(options.latencyProbe);
|
|
2518
|
+
const isInteractive = Boolean(stdin.isTTY && stdout.isTTY) && !probeMode;
|
|
2058
2519
|
return await new Promise((resolve, reject) => {
|
|
2059
2520
|
const socket = new WebSocket(wsUrl, {
|
|
2060
2521
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -2063,7 +2524,7 @@ async function runAttach(target, options) {
|
|
|
2063
2524
|
let opened = false;
|
|
2064
2525
|
let cleaned = false;
|
|
2065
2526
|
let firstFrameRendered = false;
|
|
2066
|
-
let result = "
|
|
2527
|
+
let result = "failed";
|
|
2067
2528
|
const splashAllowed = isInteractive && !options.json && !options.quietBanner;
|
|
2068
2529
|
const splash = splashAllowed ? new AttachSplash(stdout, target.label) : null;
|
|
2069
2530
|
const sendResize = () => {
|
|
@@ -2185,6 +2646,91 @@ async function runAttach(target, options) {
|
|
|
2185
2646
|
}
|
|
2186
2647
|
};
|
|
2187
2648
|
process.once("SIGINT", onSigint);
|
|
2649
|
+
// --latency-probe state: transport samples resolve on a `probe-reply`
|
|
2650
|
+
// control frame from the worker (pure WS relay round trip); echo samples
|
|
2651
|
+
// resolve on the next binary frame after sending a printable keystroke
|
|
2652
|
+
// (full pipeline including the remote TUI render).
|
|
2653
|
+
const probeReplies = new Map();
|
|
2654
|
+
let probeEchoWaiter = null;
|
|
2655
|
+
let probeSeq = 0;
|
|
2656
|
+
const runLatencyProbe = async () => {
|
|
2657
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2658
|
+
const SAMPLES = 20;
|
|
2659
|
+
const transport = [];
|
|
2660
|
+
const echo = [];
|
|
2661
|
+
// Let the remote dashboard finish its initial redraw burst so spinner
|
|
2662
|
+
// frames don't get mistaken for keystroke echoes.
|
|
2663
|
+
await sleep(750);
|
|
2664
|
+
for (let i = 0; i < SAMPLES; i += 1) {
|
|
2665
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
2666
|
+
break;
|
|
2667
|
+
const id = ++probeSeq;
|
|
2668
|
+
const sentAt = performance.now();
|
|
2669
|
+
const repliedAt = await new Promise((resolveReply) => {
|
|
2670
|
+
const timer = setTimeout(() => {
|
|
2671
|
+
probeReplies.delete(id);
|
|
2672
|
+
resolveReply(null);
|
|
2673
|
+
}, 2000);
|
|
2674
|
+
probeReplies.set(id, (at) => {
|
|
2675
|
+
clearTimeout(timer);
|
|
2676
|
+
probeReplies.delete(id);
|
|
2677
|
+
resolveReply(at);
|
|
2678
|
+
});
|
|
2679
|
+
socket.send(JSON.stringify({ type: "probe", id }));
|
|
2680
|
+
});
|
|
2681
|
+
if (repliedAt !== null)
|
|
2682
|
+
transport.push(repliedAt - sentAt);
|
|
2683
|
+
await sleep(100);
|
|
2684
|
+
}
|
|
2685
|
+
for (let i = 0; i < SAMPLES; i += 1) {
|
|
2686
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
2687
|
+
break;
|
|
2688
|
+
const sentAt = performance.now();
|
|
2689
|
+
const echoedAt = await new Promise((resolveFrame) => {
|
|
2690
|
+
const timer = setTimeout(() => {
|
|
2691
|
+
probeEchoWaiter = null;
|
|
2692
|
+
resolveFrame(null);
|
|
2693
|
+
}, 2000);
|
|
2694
|
+
probeEchoWaiter = (at) => {
|
|
2695
|
+
clearTimeout(timer);
|
|
2696
|
+
probeEchoWaiter = null;
|
|
2697
|
+
resolveFrame(at);
|
|
2698
|
+
};
|
|
2699
|
+
socket.send(Buffer.from("a"), { binary: true });
|
|
2700
|
+
});
|
|
2701
|
+
if (echoedAt !== null)
|
|
2702
|
+
echo.push(echoedAt - sentAt);
|
|
2703
|
+
// Undo the probe keystroke so the remote input box is left untouched.
|
|
2704
|
+
socket.send(Buffer.from("\x7f"), { binary: true });
|
|
2705
|
+
await sleep(250);
|
|
2706
|
+
}
|
|
2707
|
+
const stats = (values) => {
|
|
2708
|
+
if (values.length === 0)
|
|
2709
|
+
return "no samples (timed out)";
|
|
2710
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2711
|
+
const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0;
|
|
2712
|
+
const max = sorted[sorted.length - 1] ?? 0;
|
|
2713
|
+
return `p50 ${at(0.5).toFixed(1)}ms p95 ${at(0.95).toFixed(1)}ms max ${max.toFixed(1)}ms (${sorted.length}/${SAMPLES} samples)`;
|
|
2714
|
+
};
|
|
2715
|
+
const report = [
|
|
2716
|
+
"",
|
|
2717
|
+
`Latency probe · ${target.label}`,
|
|
2718
|
+
` transport RTT ${stats(transport)}`,
|
|
2719
|
+
` keystroke echo ${stats(echo)}`,
|
|
2720
|
+
"",
|
|
2721
|
+
].join("\n");
|
|
2722
|
+
if (options.json) {
|
|
2723
|
+
process.stdout.write(`${JSON.stringify({ target: target.label, transportMs: transport, echoMs: echo })}\n`);
|
|
2724
|
+
}
|
|
2725
|
+
else {
|
|
2726
|
+
process.stderr.write(report);
|
|
2727
|
+
}
|
|
2728
|
+
result = "exited";
|
|
2729
|
+
try {
|
|
2730
|
+
socket.close(1000, "probe-done");
|
|
2731
|
+
}
|
|
2732
|
+
catch { /* ignore */ }
|
|
2733
|
+
};
|
|
2188
2734
|
socket.on("open", () => {
|
|
2189
2735
|
opened = true;
|
|
2190
2736
|
// Disable Nagle on the underlying TCP socket so single keystrokes don't
|
|
@@ -2233,12 +2779,47 @@ async function runAttach(target, options) {
|
|
|
2233
2779
|
// ignore
|
|
2234
2780
|
}
|
|
2235
2781
|
}
|
|
2236
|
-
|
|
2237
|
-
|
|
2782
|
+
if (!probeMode) {
|
|
2783
|
+
stdin.resume();
|
|
2784
|
+
stdin.on("data", onStdin);
|
|
2785
|
+
}
|
|
2786
|
+
else {
|
|
2787
|
+
// A worker that never boots would otherwise hang the probe forever:
|
|
2788
|
+
// there is no TTY and no human to Ctrl+C it.
|
|
2789
|
+
const firstFrameDeadline = setTimeout(() => {
|
|
2790
|
+
if (!firstFrameRendered) {
|
|
2791
|
+
process.stderr.write("Latency probe timed out waiting for the first remote frame (worker did not boot?).\n");
|
|
2792
|
+
result = "failed";
|
|
2793
|
+
try {
|
|
2794
|
+
socket.close(1000, "probe-timeout");
|
|
2795
|
+
}
|
|
2796
|
+
catch { /* ignore */ }
|
|
2797
|
+
}
|
|
2798
|
+
}, 120_000);
|
|
2799
|
+
firstFrameDeadline.unref?.();
|
|
2800
|
+
}
|
|
2238
2801
|
stdout.on("resize", onResize);
|
|
2239
2802
|
});
|
|
2240
2803
|
socket.on("message", (data, isBinary) => {
|
|
2241
2804
|
if (isBinary && Buffer.isBuffer(data)) {
|
|
2805
|
+
if (probeMode) {
|
|
2806
|
+
// Frames are timing signals here, not screen content: the first one
|
|
2807
|
+
// marks the dashboard as live (start probing), later ones resolve a
|
|
2808
|
+
// pending keystroke-echo sample.
|
|
2809
|
+
if (!firstFrameRendered) {
|
|
2810
|
+
firstFrameRendered = true;
|
|
2811
|
+
void runLatencyProbe().catch((err) => {
|
|
2812
|
+
process.stderr.write(`Latency probe failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2813
|
+
try {
|
|
2814
|
+
socket.close(1000, "probe-failed");
|
|
2815
|
+
}
|
|
2816
|
+
catch { /* ignore */ }
|
|
2817
|
+
});
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
probeEchoWaiter?.(performance.now());
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
2242
2823
|
if (!firstFrameRendered) {
|
|
2243
2824
|
firstFrameRendered = true;
|
|
2244
2825
|
splash?.handoff();
|
|
@@ -2287,6 +2868,12 @@ async function runAttach(target, options) {
|
|
|
2287
2868
|
return;
|
|
2288
2869
|
}
|
|
2289
2870
|
const message = payload;
|
|
2871
|
+
if (message.type === "probe-reply") {
|
|
2872
|
+
if (typeof message.id === "number") {
|
|
2873
|
+
probeReplies.get(message.id)?.(performance.now());
|
|
2874
|
+
}
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2290
2877
|
if (message.type === "exit") {
|
|
2291
2878
|
result = message.code === 0 ? "exited" : "failed";
|
|
2292
2879
|
if (typeof process.exitCode !== "number" && message.code !== undefined) {
|
|
@@ -2320,16 +2907,13 @@ async function runAttach(target, options) {
|
|
|
2320
2907
|
splash.setStatus(`Waiting for cloud worker · ${target.label}`);
|
|
2321
2908
|
}
|
|
2322
2909
|
else if (message.state === "worker-connected") {
|
|
2323
|
-
|
|
2324
|
-
// is connected. Waiting for the first BINARY PTY frame can add
|
|
2325
|
-
// 3-6s on warm restart because the worker may not flush until
|
|
2326
|
-
// after its first render. The binary-frame path below still
|
|
2327
|
-
// calls handoff() as a safety net if we never see this status.
|
|
2328
|
-
firstFrameRendered = true;
|
|
2329
|
-
splash.handoff();
|
|
2910
|
+
splash.setStatus(`Cloud worker connected · ${target.label}`);
|
|
2330
2911
|
sendResize();
|
|
2331
2912
|
setTimeout(sendResize, 150);
|
|
2332
2913
|
}
|
|
2914
|
+
else if (message.state === "input-buffered") {
|
|
2915
|
+
splash.setStatus(`Reconnecting cloud input · ${target.label}`);
|
|
2916
|
+
}
|
|
2333
2917
|
}
|
|
2334
2918
|
else if (!options.json && !options.quietBanner) {
|
|
2335
2919
|
if (message.state === "worker-disconnected") {
|
|
@@ -2487,8 +3071,12 @@ Usage:
|
|
|
2487
3071
|
print the copy/paste setup for the whole flow
|
|
2488
3072
|
rudder cloud slack [manifest]
|
|
2489
3073
|
print Slack setup (one thread per instance in the shared channel)
|
|
2490
|
-
rudder cloud workspace [attach [id]|share|status [--json]|pause <id>|resume <id>|stop <id>|list]
|
|
2491
|
-
shared cloud workspace for this repo
|
|
3074
|
+
rudder cloud workspace [attach [id|owner/repo]|create <owner/repo>|share|status [--json]|pause <id>|resume <id>|stop <id>|list]
|
|
3075
|
+
shared cloud workspace for this repo; \`create\` clones from GitHub (cloud-native, no local upload)
|
|
3076
|
+
rudder cloud secrets [set <NAME> [value]|set --file <~/path>|list|rm <NAME>|sync]
|
|
3077
|
+
manage the encrypted cloud secrets vault; \`sync\` imports your local credentials once
|
|
3078
|
+
rudder cloud region [<fly-region>|clear]
|
|
3079
|
+
override worker placement (default: next to the relay for lowest typing latency)
|
|
2492
3080
|
rudder cloud bootstrap <id>
|
|
2493
3081
|
rudder cloud runtime [fly|byoc]
|
|
2494
3082
|
rudder cloud setup-byoc <ssh-host> compatibility alias
|