@tonbo/cli 0.17.0 → 0.17.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.
Files changed (2) hide show
  1. package/dist/bin/tonbo.js +234 -47
  2. package/package.json +1 -1
package/dist/bin/tonbo.js CHANGED
@@ -2009,7 +2009,7 @@ var ManagementApi = class {
2009
2009
  apiOrigin;
2010
2010
  loginHint;
2011
2011
  pause;
2012
- constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT2, pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))) {
2012
+ constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT2, pause = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))) {
2013
2013
  this.fetcher = fetcher;
2014
2014
  this.apiOrigin = apiOrigin2;
2015
2015
  this.loginHint = loginHint;
@@ -2732,16 +2732,16 @@ function personalLogin(login) {
2732
2732
  return accountLogins(login).find((entry) => entry.kind === "user") ?? null;
2733
2733
  }
2734
2734
  async function preparePrivateDirectory(directory) {
2735
- const stat2 = await lstat(directory);
2736
- if (stat2.isSymbolicLink() || !stat2.isDirectory())
2735
+ const stat3 = await lstat(directory);
2736
+ if (stat3.isSymbolicLink() || !stat3.isDirectory())
2737
2737
  throw new Error("Tonbo config directory must be a real directory.");
2738
2738
  if (process.platform !== "win32")
2739
2739
  await chmod(directory, DIRECTORY_MODE);
2740
2740
  }
2741
2741
  async function assertExistingDestinationIsSafe(filename) {
2742
2742
  try {
2743
- const stat2 = await lstat(filename);
2744
- if (stat2.isSymbolicLink() || !stat2.isFile())
2743
+ const stat3 = await lstat(filename);
2744
+ if (stat3.isSymbolicLink() || !stat3.isFile())
2745
2745
  throw new Error("Tonbo credential path must be a regular file.");
2746
2746
  } catch (error) {
2747
2747
  if (error.code !== "ENOENT")
@@ -2749,10 +2749,10 @@ async function assertExistingDestinationIsSafe(filename) {
2749
2749
  }
2750
2750
  }
2751
2751
  async function assertPrivateRegularFile(filename) {
2752
- const stat2 = await lstat(filename);
2753
- if (stat2.isSymbolicLink() || !stat2.isFile())
2752
+ const stat3 = await lstat(filename);
2753
+ if (stat3.isSymbolicLink() || !stat3.isFile())
2754
2754
  throw new Error("Tonbo credential path must be a regular file.");
2755
- if (process.platform !== "win32" && (stat2.mode & REFUSED_MODE_BITS) !== 0)
2755
+ if (process.platform !== "win32" && (stat3.mode & REFUSED_MODE_BITS) !== 0)
2756
2756
  throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
2757
2757
  }
2758
2758
 
@@ -2903,7 +2903,7 @@ var DeviceStore = class {
2903
2903
  await rm2(lock, { recursive: true, force: true });
2904
2904
  waited = -CREATE_POLL_MS;
2905
2905
  } else
2906
- await new Promise((resolve) => setTimeout(resolve, CREATE_POLL_MS));
2906
+ await new Promise((resolve2) => setTimeout(resolve2, CREATE_POLL_MS));
2907
2907
  }
2908
2908
  try {
2909
2909
  if (await exists(this.filename))
@@ -3556,7 +3556,7 @@ var AuthClient = class {
3556
3556
  const deadline = Date.now() + started.expires_in * 1e3;
3557
3557
  let interval = started.interval;
3558
3558
  for (; ; ) {
3559
- await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
3559
+ await new Promise((resolve2) => setTimeout(resolve2, interval * 1e3));
3560
3560
  try {
3561
3561
  const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/device/token", this.loginOrigin).toString(), {
3562
3562
  method: "POST",
@@ -3585,7 +3585,7 @@ var AuthClient = class {
3585
3585
  }
3586
3586
  }
3587
3587
  listenForCode(expectedState, authorizationUrl, progress, openBrowser2) {
3588
- return new Promise((resolve, reject) => {
3588
+ return new Promise((resolve2, reject) => {
3589
3589
  let settled = false;
3590
3590
  const sockets = /* @__PURE__ */ new Set();
3591
3591
  let authorizationStarted = false;
@@ -3606,7 +3606,7 @@ var AuthClient = class {
3606
3606
  reject(closeError);
3607
3607
  else if ("code" in result) {
3608
3608
  progress({ status: "completed", step: "callback-close" });
3609
- resolve(result.code);
3609
+ resolve2(result.code);
3610
3610
  } else
3611
3611
  reject(result.error);
3612
3612
  };
@@ -5289,6 +5289,8 @@ import { readFileSync } from "node:fs";
5289
5289
  // src/volume.ts
5290
5290
  import { randomUUID as randomUUID6 } from "node:crypto";
5291
5291
  var VOLUME_DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees/data#volume";
5292
+ var VOLUMES_PREVIEW = true;
5293
+ var VOLUME_PREVIEW_TEXT = `Preview: Tonbo does not yet promise when a write to a Volume is durable; keep data you cannot lose in a database too (${VOLUME_DURABILITY_LINK}).`;
5292
5294
  var VOLUME_ERROR_HINTS = {
5293
5295
  payment_required: "Add credit at <billing>, then create the Volume again.",
5294
5296
  region_not_offered: "Volumes are not offered to this account in that region yet; nothing was created. Name a region where they are offered.",
@@ -5335,7 +5337,7 @@ function volumeFields(volume) {
5335
5337
  "Cold GETs",
5336
5338
  volume.cold ? `${volume.cold.demand_gets} demand, ${volume.cold.hydrate_gets} hydration` : "none"
5337
5339
  ],
5338
- ["Durability", VOLUME_DURABILITY_LINK],
5340
+ ["Durability", VOLUMES_PREVIEW ? VOLUME_PREVIEW_TEXT : VOLUME_DURABILITY_LINK],
5339
5341
  ["Created", volume.created_at]
5340
5342
  ]);
5341
5343
  }
@@ -5363,6 +5365,7 @@ async function volumeListCommand(deps, options = {}) {
5363
5365
  ])
5364
5366
  ) : "No Volumes."
5365
5367
  );
5368
+ if (VOLUMES_PREVIEW && list.data.length && !options.json) deps.notice(VOLUME_PREVIEW_TEXT);
5366
5369
  if (list.unavailable_regions.length)
5367
5370
  deps.notice(
5368
5371
  `Could not read the Volumes in ${list.unavailable_regions.join(", ")}; they are missing from this list.`
@@ -5394,6 +5397,7 @@ async function volumeCreateCommand(deps, name, options = {}) {
5394
5397
  [
5395
5398
  `Created Volume ${volume.name} in ${volume.region} (${volume.size_gib} GiB; billed by live bytes, not size).`,
5396
5399
  `Mount it on a Machine of ${volume.region} with \`tonbo machine update <machine> --volume ${volume.name}:/data\`; it attaches at the Machine's next boot.`,
5400
+ ...VOLUMES_PREVIEW ? [VOLUME_PREVIEW_TEXT] : [],
5397
5401
  volumeFields(volume)
5398
5402
  ].join("\n")
5399
5403
  );
@@ -5781,7 +5785,7 @@ function waitTimeout(options) {
5781
5785
  var DEFAULT_WAIT_SECONDS = 300;
5782
5786
  async function waitForMachine(deps, key, name, firstWait, every, timeout, settled) {
5783
5787
  const now = deps.now ?? Date.now;
5784
- const pause = deps.pause ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
5788
+ const pause = deps.pause ?? ((milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)));
5785
5789
  const start = now();
5786
5790
  let wait = firstWait;
5787
5791
  let last = null;
@@ -7094,8 +7098,8 @@ function optionDescription(option) {
7094
7098
  }
7095
7099
  function applyHelp(program) {
7096
7100
  const named = /* @__PURE__ */ new Set();
7097
- const walk = (command, words) => {
7098
- const path5 = words.join(" ");
7101
+ const walk = (command, words2) => {
7102
+ const path5 = words2.join(" ");
7099
7103
  command.configureHelp({ ...command.configureHelp(), optionDescription });
7100
7104
  const help = COMMAND_HELP[path5];
7101
7105
  if (help) {
@@ -7112,7 +7116,7 @@ function applyHelp(program) {
7112
7116
  }
7113
7117
  command.addHelpText("after", commandHelpText(help));
7114
7118
  }
7115
- for (const child of command.commands) walk(child, [...words, child.name()]);
7119
+ for (const child of command.commands) walk(child, [...words2, child.name()]);
7116
7120
  };
7117
7121
  walk(program, [program.name()]);
7118
7122
  for (const path5 of Object.keys(COMMAND_HELP))
@@ -7510,7 +7514,7 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
7510
7514
  try {
7511
7515
  const key = await deps.auth.apiKey();
7512
7516
  const region = await regionOf(deps, key, name);
7513
- const stat2 = await cellApi(deps).json(
7517
+ const stat3 = await cellApi(deps).json(
7514
7518
  "statMachineFile",
7515
7519
  key,
7516
7520
  region,
@@ -7520,14 +7524,14 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
7520
7524
  headers: { "tonbo-expect-activation": options.expectActivation }
7521
7525
  }
7522
7526
  );
7523
- noticeWake(deps, stat2.machine, name);
7527
+ noticeWake(deps, stat3.machine, name);
7524
7528
  print(
7525
7529
  deps,
7526
7530
  options.json,
7527
- stat2,
7531
+ stat3,
7528
7532
  fields(
7529
7533
  ["path", "type", "size", "mode", "uid", "gid", "mtime", "etag", "target", "durability"].map(
7530
- (field) => [field, cellText(stat2[field])]
7534
+ (field) => [field, cellText(stat3[field])]
7531
7535
  )
7532
7536
  )
7533
7537
  );
@@ -7681,12 +7685,167 @@ async function terminalAsk(question) {
7681
7685
  // src/ssh.ts
7682
7686
  import { spawn, spawnSync } from "node:child_process";
7683
7687
  import { existsSync as existsSync2 } from "node:fs";
7684
- import { chmod as chmod2, mkdir as mkdir4, readFile as readFile5, readdir, rename as rename4, writeFile as writeFile2 } from "node:fs/promises";
7688
+ import { chmod as chmod3, mkdir as mkdir4, readFile as readFile6, readdir as readdir2, rename as rename5, writeFile as writeFile3 } from "node:fs/promises";
7689
+ import { homedir as homedir2 } from "node:os";
7690
+ import { join as join2 } from "node:path";
7691
+
7692
+ // src/ssh-legacy.ts
7693
+ import { chmod as chmod2, readdir, readFile as readFile5, realpath, rename as rename4, rm as rm4, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
7685
7694
  import { homedir } from "node:os";
7686
- import { join } from "node:path";
7695
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
7696
+ var LEGACY_BEGIN = "# >>> tonbo project ssh >>>";
7697
+ var LEGACY_END = "# <<< tonbo project ssh <<<";
7698
+ var PROXY_COMMAND = /^\s*proxycommand(\s|=)/i;
7699
+ var SSH_PROXY = /(^|[\s'"/])ssh-proxy(\s|['"]|$)/;
7700
+ function shown(path5, home) {
7701
+ return path5.startsWith(home + sep) ? `~${path5.slice(home.length)}` : path5;
7702
+ }
7703
+ function words(text) {
7704
+ const found = [];
7705
+ for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) found.push(match[1] ?? match[2]);
7706
+ return found;
7707
+ }
7708
+ function wildcard(segment) {
7709
+ const pattern = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
7710
+ return new RegExp(`^${pattern}$`);
7711
+ }
7712
+ async function expand(pattern) {
7713
+ const parts = pattern.split(sep).filter((part) => part !== "");
7714
+ let paths = [sep];
7715
+ for (const part of parts) {
7716
+ if (!/[*?]/.test(part)) {
7717
+ paths = paths.map((path5) => join(path5, part));
7718
+ continue;
7719
+ }
7720
+ const matcher = wildcard(part);
7721
+ const next = [];
7722
+ for (const path5 of paths) {
7723
+ const names = await readdir(path5).catch(() => []);
7724
+ for (const name of names.sort())
7725
+ if (matcher.test(name) && !name.startsWith(".")) next.push(join(path5, name));
7726
+ }
7727
+ paths = next;
7728
+ }
7729
+ return paths;
7730
+ }
7731
+ async function scan(home) {
7732
+ const sshDir = join(home, ".ssh");
7733
+ const files = [];
7734
+ const seen = /* @__PURE__ */ new Set();
7735
+ const visit = async (path5, depth) => {
7736
+ if (depth > 16 || seen.has(path5) || !path5.startsWith(sshDir + sep)) return;
7737
+ seen.add(path5);
7738
+ let text;
7739
+ try {
7740
+ if (!(await stat2(path5)).isFile()) return;
7741
+ text = await readFile5(path5, "utf8");
7742
+ } catch {
7743
+ return;
7744
+ }
7745
+ const lines = text.split("\n");
7746
+ const file = { path: path5, lines, blocks: [], handEdited: [] };
7747
+ const includes = [];
7748
+ for (let index = 0; index < lines.length; index++) {
7749
+ if (lines[index].trim() === LEGACY_BEGIN) {
7750
+ const end = lines.findIndex((line2, at) => at > index && line2.trim() === LEGACY_END);
7751
+ if (end !== -1) {
7752
+ file.blocks.push([index, end]);
7753
+ index = end;
7754
+ continue;
7755
+ }
7756
+ }
7757
+ const line = lines[index];
7758
+ if (PROXY_COMMAND.test(line) && SSH_PROXY.test(line)) file.handEdited.push(index + 1);
7759
+ const include = /^\s*include(?:\s+|\s*=\s*)(.*)$/i.exec(line);
7760
+ if (include) includes.push(...words(include[1].replace(/\s#.*$/, "")));
7761
+ }
7762
+ if (file.blocks.length || file.handEdited.length) files.push(file);
7763
+ for (const name of includes) {
7764
+ const expanded = name.startsWith("~/") ? join(home, name.slice(2)) : name;
7765
+ const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(sshDir, expanded);
7766
+ for (const match of await expand(absolute)) await visit(match, depth + 1);
7767
+ }
7768
+ };
7769
+ await visit(join(sshDir, "config"), 0);
7770
+ return files;
7771
+ }
7772
+ function stamp(now) {
7773
+ return new Date(now).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
7774
+ }
7775
+ function blockLines(file, home) {
7776
+ const out = [`${shown(file.path, home)}: delete these lines, the outdated Tonbo block:`];
7777
+ for (const [start, end] of file.blocks)
7778
+ for (let index = start; index <= end; index++)
7779
+ out.push(` ${index + 1}: ${file.lines[index].replace(/\r$/, "")}`);
7780
+ return out.join("\n");
7781
+ }
7782
+ async function legacySshGuidance(home = homedir()) {
7783
+ const files = await scan(home);
7784
+ const found = [
7785
+ ...files.filter((file) => file.blocks.length).map((file) => blockLines(file, home)),
7786
+ ...files.flatMap(
7787
+ (file) => file.handEdited.map(
7788
+ (line) => `${shown(file.path, home)}:${line}: this ProxyCommand runs \`tonbo ssh-proxy\`; delete the line.`
7789
+ )
7790
+ )
7791
+ ];
7792
+ return [
7793
+ "tonbo ssh-proxy is gone: an outdated Tonbo block in your SSH config still runs it.",
7794
+ ...found.length ? found : [`Delete the lines from "${LEGACY_BEGIN}" to "${LEGACY_END}" in ~/.ssh/config.`],
7795
+ "Running `tonbo ssh <machine>` or `tonbo ssh config` removes the block for you, with a backup."
7796
+ ].join("\n");
7797
+ }
7798
+ async function removeLegacySshConfig(options) {
7799
+ const home = options.home ?? homedir();
7800
+ const now = options.now ?? Date.now;
7801
+ const files = await scan(home);
7802
+ const unwritable = [];
7803
+ for (const file of files) {
7804
+ if (!file.blocks.length) continue;
7805
+ const original = file.lines.join("\n");
7806
+ const kept = file.lines.filter(
7807
+ (_, index) => !file.blocks.some(([start, end]) => index >= start && index <= end)
7808
+ );
7809
+ const sshDir = join(home, ".ssh");
7810
+ const name = file.path.slice(sshDir.length + 1).split(sep).join("-");
7811
+ const backup = join(sshDir, `${name}.tonbo-${stamp(now())}.bak`);
7812
+ let temporary;
7813
+ try {
7814
+ const target = await realpath(file.path);
7815
+ const mode = (await stat2(target)).mode & 4095;
7816
+ await writeFile2(backup, original, { mode, flag: "wx" });
7817
+ await chmod2(backup, mode);
7818
+ temporary = join(dirname(target), `.${process.pid}.tonbo-ssh-config.tmp`);
7819
+ await writeFile2(temporary, kept.join("\n"), { mode, flag: "wx" });
7820
+ await chmod2(temporary, mode);
7821
+ await rename4(temporary, target);
7822
+ temporary = void 0;
7823
+ } catch (error) {
7824
+ if (temporary) await rm4(temporary, { force: true }).catch(() => {
7825
+ });
7826
+ unwritable.push(
7827
+ `${blockLines(file, home)}
7828
+ (could not rewrite it: ${error instanceof Error ? error.message : String(error)})`
7829
+ );
7830
+ continue;
7831
+ }
7832
+ options.notice(
7833
+ `Removed an outdated Tonbo block from ${shown(file.path, home)} (backup: ${shown(backup, home)})`
7834
+ );
7835
+ }
7836
+ const handEdited = files.flatMap(
7837
+ (file) => file.handEdited.map(
7838
+ (line) => `${shown(file.path, home)}:${line}: a ProxyCommand runs \`tonbo ssh-proxy\`, which this CLI no longer has; delete or change that line, then run the command again`
7839
+ )
7840
+ );
7841
+ if (unwritable.length || handEdited.length)
7842
+ throw new Error([...unwritable, ...handEdited].join("\n"));
7843
+ }
7844
+
7845
+ // src/ssh.ts
7687
7846
  function sshDirectory(env = process.env) {
7688
- const config = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
7689
- return join(config, "tonbo", "ssh");
7847
+ const config = env.XDG_CONFIG_HOME?.trim() || join2(homedir2(), ".config");
7848
+ return join2(config, "tonbo", "ssh");
7690
7849
  }
7691
7850
  var RENEW_BEFORE_MS = 12e4;
7692
7851
  var REQUESTED_TTL_SECONDS = 3600;
@@ -7704,9 +7863,9 @@ function cellOrigin2(host) {
7704
7863
  return `https://api.${host}`;
7705
7864
  }
7706
7865
  async function ensureKey(directory) {
7707
- const key = join(directory, "id_ed25519");
7866
+ const key = join2(directory, "id_ed25519");
7708
7867
  await mkdir4(directory, { recursive: true, mode: 448 });
7709
- await chmod2(directory, 448);
7868
+ await chmod3(directory, 448);
7710
7869
  if (!existsSync2(key)) {
7711
7870
  const made = spawnSync(
7712
7871
  "ssh-keygen",
@@ -7725,41 +7884,41 @@ async function ensureKey(directory) {
7725
7884
  }
7726
7885
  async function writeAtomic(path5, data) {
7727
7886
  const temporary = `${path5}.${process.pid}.tmp`;
7728
- await writeFile2(temporary, data, { mode: 384 });
7729
- await rename4(temporary, path5);
7887
+ await writeFile3(temporary, data, { mode: 384 });
7888
+ await rename5(temporary, path5);
7730
7889
  }
7731
7890
  function certificatePath(directory, user) {
7732
- return join(directory, `${user}-cert.pub`);
7891
+ return join2(directory, `${user}-cert.pub`);
7733
7892
  }
7734
7893
  function hostCAPath(directory, host) {
7735
7894
  if (!/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
7736
7895
  throw new Error(`${host} is not a Machine's SSH host`);
7737
7896
  }
7738
- return join(directory, "host-ca", host);
7897
+ return join2(directory, "host-ca", host);
7739
7898
  }
7740
7899
  async function trustHostCA(directory, host, lines) {
7741
- const cells = join(directory, "host-ca");
7900
+ const cells = join2(directory, "host-ca");
7742
7901
  await mkdir4(cells, { recursive: true, mode: 448 });
7743
7902
  await writeAtomic(hostCAPath(directory, host), `${lines.join("\n")}
7744
7903
  `);
7745
7904
  const trusted = /* @__PURE__ */ new Set();
7746
- for (const name of (await readdir(cells)).sort()) {
7905
+ for (const name of (await readdir2(cells)).sort()) {
7747
7906
  if (name.endsWith(".tmp")) {
7748
7907
  continue;
7749
7908
  }
7750
- for (const line of (await readFile5(join(cells, name), "utf8")).split("\n")) {
7909
+ for (const line of (await readFile6(join2(cells, name), "utf8")).split("\n")) {
7751
7910
  if (line.trim() !== "") {
7752
7911
  trusted.add(line.trim());
7753
7912
  }
7754
7913
  }
7755
7914
  }
7756
- await writeAtomic(join(directory, "known_hosts"), `${[...trusted].join("\n")}
7915
+ await writeAtomic(join2(directory, "known_hosts"), `${[...trusted].join("\n")}
7757
7916
  `);
7758
7917
  }
7759
7918
  async function heldUntil(directory, user) {
7760
7919
  try {
7761
7920
  const sidecar = JSON.parse(
7762
- await readFile5(`${certificatePath(directory, user)}.json`, "utf8")
7921
+ await readFile6(`${certificatePath(directory, user)}.json`, "utf8")
7763
7922
  );
7764
7923
  return sidecar.valid_before ? Date.parse(sidecar.valid_before) : 0;
7765
7924
  } catch {
@@ -7792,12 +7951,12 @@ async function cellRequest(bearer, method, url, body) {
7792
7951
  async function refreshCertificate(bearer, destination, now = Date.now, directory = sshDirectory()) {
7793
7952
  const key = await ensureKey(directory);
7794
7953
  const certificate = certificatePath(directory, destination.user);
7795
- const knownHosts = join(directory, "known_hosts");
7954
+ const knownHosts = join2(directory, "known_hosts");
7796
7955
  if (await heldUntil(directory, destination.user) - now() > RENEW_BEFORE_MS && existsSync2(hostCAPath(directory, destination.host)) && existsSync2(knownHosts)) {
7797
7956
  return { key, certificate, knownHosts };
7798
7957
  }
7799
7958
  const origin = cellOrigin2(destination.host);
7800
- const publicKey = (await readFile5(`${key}.pub`, "utf8")).trim();
7959
+ const publicKey = (await readFile6(`${key}.pub`, "utf8")).trim();
7801
7960
  const issued = await cellRequest(bearer, "POST", `${origin}/v1/ssh/certificates`, {
7802
7961
  public_key: publicKey,
7803
7962
  ttl_seconds: REQUESTED_TTL_SECONDS
@@ -7834,6 +7993,7 @@ function sshOptions(paths) {
7834
7993
  ];
7835
7994
  }
7836
7995
  async function sshCommand(deps, name, command) {
7996
+ await removeLegacySshConfig({ notice: deps.notice });
7837
7997
  let destination;
7838
7998
  let paths;
7839
7999
  try {
@@ -7848,32 +8008,54 @@ async function sshCommand(deps, name, command) {
7848
8008
  throw explainMachineError(error);
7849
8009
  }
7850
8010
  const target = `${destination.user}@${destination.host}`;
7851
- const status = await new Promise((resolve, reject) => {
8011
+ const status = await new Promise((resolve2, reject) => {
7852
8012
  const child = spawn("ssh", [...sshOptions(paths), target, ...command], { stdio: "inherit" });
7853
8013
  child.on(
7854
8014
  "error",
7855
8015
  (error) => reject(new Error(`could not run ssh (${error.message}); install an OpenSSH client`))
7856
8016
  );
7857
- child.on("exit", (code, signal) => resolve(code ?? (signal ? 255 : 0)));
8017
+ child.on("exit", (code, signal) => resolve2(code ?? (signal ? 255 : 0)));
7858
8018
  });
7859
8019
  process.exitCode = status;
7860
8020
  }
8021
+ var REGIONLESS_HOST = "tonbo.sh";
8022
+ async function regionlessHint(deps, destination) {
8023
+ const name = destination.user.slice(0, destination.user.lastIndexOf("."));
8024
+ try {
8025
+ const { machine } = await deps.api.getMachineWithEtag(await deps.auth.apiKey(), name);
8026
+ if (machine.kind !== "managed")
8027
+ return new Error(`${name} is one of your computers; SSH reaches only Tonbo-run Machines`);
8028
+ return new Error(
8029
+ `Use <name>.<account>@<region>.tonbo.sh (region-less SSH isn't available yet), e.g. ssh ${machine.hostnames.ssh}`
8030
+ );
8031
+ } catch (error) {
8032
+ if (error instanceof HttpError && error.status === 404)
8033
+ return new Error(`The account ${destination.account} has no Machine named ${name}`);
8034
+ throw explainMachineError(error);
8035
+ }
8036
+ }
7861
8037
  async function sshRefreshCommand(makeDeps, target) {
7862
8038
  const destination = parseDestination(target);
7863
8039
  const deps = makeDeps(destination.account);
8040
+ if (destination.host === REGIONLESS_HOST) throw await regionlessHint(deps, destination);
8041
+ await removeLegacySshConfig({ notice: deps.notice });
7864
8042
  await refreshCertificate(await deps.auth.apiKey(), destination, deps.now);
7865
8043
  }
8044
+ async function sshConfigCommand(deps) {
8045
+ await removeLegacySshConfig({ notice: deps.notice });
8046
+ deps.output(sshConfigText());
8047
+ }
7866
8048
  function sshConfigText(directory = sshDirectory()) {
7867
- const home = homedir();
7868
- const shown = directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory;
8049
+ const home = homedir2();
8050
+ const shown2 = directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory;
7869
8051
  return [
7870
8052
  "# Tonbo Machines: append to ~/.ssh/config (tonbo ssh config >> ~/.ssh/config).",
7871
8053
  "# Then: ssh <name>.<account>@<region>.tonbo.sh, scp, sftp and Remote-SSH work as usual.",
7872
8054
  `Match host "*.tonbo.sh,tonbo.sh" exec "tonbo ssh refresh %r@%h"`,
7873
- ` IdentityFile ${shown}/id_ed25519`,
7874
- ` CertificateFile ${shown}/%r-cert.pub`,
8055
+ ` IdentityFile ${shown2}/id_ed25519`,
8056
+ ` CertificateFile ${shown2}/%r-cert.pub`,
7875
8057
  " IdentitiesOnly yes",
7876
- ` UserKnownHostsFile ${shown}/known_hosts`,
8058
+ ` UserKnownHostsFile ${shown2}/known_hosts`,
7877
8059
  " StrictHostKeyChecking yes",
7878
8060
  " ServerAliveInterval 30"
7879
8061
  ].join("\n");
@@ -7889,7 +8071,7 @@ async function sshHostCaCommand(deps, options) {
7889
8071
  print(deps, options.json, data, data.known_hosts.join("\n"));
7890
8072
  }
7891
8073
  async function sshCertificateIssueCommand(deps, options) {
7892
- const publicKey = (await readFile5(options.publicKey, "utf8")).trim();
8074
+ const publicKey = (await readFile6(options.publicKey, "utf8")).trim();
7893
8075
  const key = await deps.auth.apiKey();
7894
8076
  const data = await (deps.cell ?? new CellApi(fetch)).json(
7895
8077
  "issueSshCertificate",
@@ -8556,12 +8738,17 @@ function createProgram(makeDependencies = createDependencies) {
8556
8738
  const ssh = program.command("ssh").description("open a terminal on a Machine, or run a command there, over SSH").argument("<machine>", "the Machine's name").argument("[command...]", "a command to run instead of a shell, after --").action(async (name, command) => sshCommand(dependencies(), name, command));
8557
8739
  ssh.command("config").description(
8558
8740
  "print the ssh_config block that lets ssh, scp, sftp and Remote-SSH reach every Machine"
8559
- ).action(() => dependencies().output(sshConfigText()));
8741
+ ).action(async () => sshConfigCommand(dependencies()));
8560
8742
  ssh.command("refresh").argument("<destination>", "a Machine's SSH destination, <name>.<account>@<host>").description(
8561
8743
  "renew the SSH certificate of <name>.<account>@<host> when it is about to expire (ssh_config's Match exec runs it)"
8562
8744
  ).action(
8563
8745
  async (destination) => sshRefreshCommand((account2) => makeDependencies({ account: account2 }), destination)
8564
8746
  );
8747
+ program.command("ssh-proxy", { hidden: true }).argument("[arguments...]").allowUnknownOption().helpOption(false).action(async () => {
8748
+ process.stderr.write(`${await legacySshGuidance()}
8749
+ `);
8750
+ process.exitCode = 1;
8751
+ });
8565
8752
  operationOptions(
8566
8753
  ssh.command("host-ca").description("print the known_hosts lines that trust Tonbo's SSH hosts").requiredOption("--region <region>", "the region whose cell origin to ask"),
8567
8754
  "getSshHostCa"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "The Tonbo CLI. Compute, disks, databases and streams for AI agents: your agent picks what it needs, sets it up, and knows what it costs, from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {