@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.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 sailId = args[0];
467
- if (!sailId) {
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 result = await client.request("/api/rudder/sail", { method: "GET" });
472
- const sails = Array.isArray(result)
473
- ? result
474
- : result && typeof result === "object" && !Array.isArray(result) && Array.isArray(result.sails)
475
- ? result.sails
476
- : [];
477
- const match = sails.find((item) => item && typeof item === "object" && !Array.isArray(item) && item.id === sailId);
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: ${sailId}`);
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 homePaths = normalizeHomePaths(requestedHomePaths);
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 (Object.keys(capturedEnv).length > 0) {
1124
- await ensureDir(path.join(stageDir, "env"));
1125
- await writeJson(path.join(stageDir, "env", "cloud-env.json"), capturedEnv);
1126
- capturedEnvCount = Object.keys(capturedEnv).length;
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
- async function stageClaudeKeychainCredentials(homeStage) {
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 false;
1489
+ return null;
1462
1490
  }
1463
1491
  if (!commandExists("security")) {
1464
- return false;
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 false;
1496
+ return null;
1469
1497
  }
1470
1498
  const payload = result.stdout.trim();
1471
1499
  if (!payload || !payload.startsWith("{")) {
1472
- return false;
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
- let cachedFlyRegion;
1704
- async function detectFlyRegion(baseUrl) {
1705
- if (process.env.RUDDER_CLOUD_REGION) {
1706
- return process.env.RUDDER_CLOUD_REGION.trim().toLowerCase();
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 (cachedFlyRegion) {
1709
- return cachedFlyRegion;
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
- try {
1712
- const response = await fetch(`${baseUrl.replace(/\/$/, "")}/health`, { method: "GET" });
1713
- const requestId = response.headers.get("fly-request-id");
1714
- if (requestId) {
1715
- // Fly request-id format: <ulid>-<region>
1716
- const dash = requestId.lastIndexOf("-");
1717
- const region = dash > 0 ? requestId.slice(dash + 1).trim().toLowerCase() : "";
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
- catch {
1725
- // ignore server will fall back to its default region
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, fingerprint] = await Promise.all([
1780
- detectFlyRegion(client.baseUrl).catch(() => undefined),
1781
- computeSnapshotFingerprint(repoRoot, options.homePaths ?? []),
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
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
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 workspace = await lookupWorkspaceForRepo(options).catch((error) => {
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
- await printResult(result, options);
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 isInteractive = Boolean(stdin.isTTY && stdout.isTTY);
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 = "exited";
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
- stdin.resume();
2237
- stdin.on("data", onStdin);
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
- // Hand off the splash as soon as the server reports the worker
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