@tonbo/cli 0.17.0 → 0.17.2
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/bin/tonbo.js +270 -50
- 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((
|
|
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
|
|
2736
|
-
if (
|
|
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
|
|
2744
|
-
if (
|
|
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
|
|
2753
|
-
if (
|
|
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" && (
|
|
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((
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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
|
);
|
|
@@ -5684,7 +5688,10 @@ function awakeFields(machine) {
|
|
|
5684
5688
|
const held = awake.held_by.length > 0;
|
|
5685
5689
|
const sleeps = awake.sleeps_at_earliest ? `${awake.sleeps_at_earliest} (${awake.sleeps_by === "idle_early" ? `idle_early: quiet for ${duration(earlyReclaimSeconds)}` : `sleep_after: idle for ${duration(machine.sleep_after_seconds)}`})` : held ? "not while anything holds it" : "not known yet";
|
|
5686
5690
|
return [
|
|
5687
|
-
[
|
|
5691
|
+
[
|
|
5692
|
+
"Held by",
|
|
5693
|
+
held ? awake.held_by.map(holderText).join(", ") : `nothing${awake.sleeps_at_earliest ? `, so it sleeps at ${awake.sleeps_at_earliest}` : ""}; a detached process (tonbo machine exec ${machine.name} --detach -- <command>) or a hold (tonbo machine hold set ${machine.name} <hold> --for 15m) keeps it awake`
|
|
5694
|
+
],
|
|
5688
5695
|
...awake.idle_since ? [["Idle since", awake.idle_since]] : [],
|
|
5689
5696
|
["Quiet", awake.quiet.passes ? "yes" : `no: ${awake.quiet.reason ?? "measuring"}`],
|
|
5690
5697
|
["Sleeps at", sleeps],
|
|
@@ -5781,7 +5788,7 @@ function waitTimeout(options) {
|
|
|
5781
5788
|
var DEFAULT_WAIT_SECONDS = 300;
|
|
5782
5789
|
async function waitForMachine(deps, key, name, firstWait, every, timeout, settled) {
|
|
5783
5790
|
const now = deps.now ?? Date.now;
|
|
5784
|
-
const pause = deps.pause ?? ((milliseconds) => new Promise((
|
|
5791
|
+
const pause = deps.pause ?? ((milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)));
|
|
5785
5792
|
const start = now();
|
|
5786
5793
|
let wait = firstWait;
|
|
5787
5794
|
let last = null;
|
|
@@ -6821,7 +6828,7 @@ var COMMAND_HELP = {
|
|
|
6821
6828
|
},
|
|
6822
6829
|
"tonbo machine exec": {
|
|
6823
6830
|
about: [
|
|
6824
|
-
"Runs one command in the Machine's running activation through its region's cell origin, waking the Machine first when it is asleep, and streams its stdout and stderr here as they come; a call that woke the Machine prints its hourly ceiling on stderr. The command runs without a shell, as the image's User, and lives exactly as long as the call:
|
|
6831
|
+
"Runs one command in the Machine's running activation through its region's cell origin, waking the Machine first when it is asleep, and streams its stdout and stderr here as they come; a call that woke the Machine prints its hourly ceiling on stderr. The command runs without a shell, as the image's User, and lives exactly as long as the call: what it starts in the background, with `nohup`, `setsid` or `&`, is ended when it returns, and the CLI then says how many it ended. There is no terminal and no streamed stdin, and `--stdin` sends at most 1 MiB read before the call.",
|
|
6825
6832
|
"The CLI exits with the command's status: 128 plus the signal's number when a signal ended it, 124 at `--timeout`, 125 when it was refused, killed or passed 64 MiB of output, 126 or 127 when it could not run, and 255 when the connection ended first and the outcome is unknown. `--json` prints the answer's document instead, with the first and last 512 KiB of each stream.",
|
|
6826
6833
|
"`--detach` starts the command in the background instead, prints its process id and returns at once: it runs whatever happens to this call, keeps the Machine awake until it ends, and is ended at `--timeout` (default 3600, at most 21600). Read it with `tonbo machine output`, wait for it with `tonbo machine wait`, list them with `tonbo machine ps` and end one with `tonbo machine kill`. A budget or funding stop, or a sleep you ask for, still ends it."
|
|
6827
6834
|
],
|
|
@@ -7094,8 +7101,8 @@ function optionDescription(option) {
|
|
|
7094
7101
|
}
|
|
7095
7102
|
function applyHelp(program) {
|
|
7096
7103
|
const named = /* @__PURE__ */ new Set();
|
|
7097
|
-
const walk = (command,
|
|
7098
|
-
const path5 =
|
|
7104
|
+
const walk = (command, words2) => {
|
|
7105
|
+
const path5 = words2.join(" ");
|
|
7099
7106
|
command.configureHelp({ ...command.configureHelp(), optionDescription });
|
|
7100
7107
|
const help = COMMAND_HELP[path5];
|
|
7101
7108
|
if (help) {
|
|
@@ -7112,7 +7119,7 @@ function applyHelp(program) {
|
|
|
7112
7119
|
}
|
|
7113
7120
|
command.addHelpText("after", commandHelpText(help));
|
|
7114
7121
|
}
|
|
7115
|
-
for (const child of command.commands) walk(child, [...
|
|
7122
|
+
for (const child of command.commands) walk(child, [...words2, child.name()]);
|
|
7116
7123
|
};
|
|
7117
7124
|
walk(program, [program.name()]);
|
|
7118
7125
|
for (const path5 of Object.keys(COMMAND_HELP))
|
|
@@ -7211,9 +7218,30 @@ function parseEnv2(pairs) {
|
|
|
7211
7218
|
}
|
|
7212
7219
|
return env;
|
|
7213
7220
|
}
|
|
7221
|
+
var BACKGROUNDERS = /* @__PURE__ */ new Set(["nohup", "setsid"]);
|
|
7222
|
+
var SHELLS = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "mksh"]);
|
|
7223
|
+
function backgroundsWork(command) {
|
|
7224
|
+
const base = (arg) => arg.slice(arg.lastIndexOf("/") + 1);
|
|
7225
|
+
if (command.some((arg) => BACKGROUNDERS.has(base(arg)))) return true;
|
|
7226
|
+
const shell = command.findIndex((arg) => SHELLS.has(base(arg)));
|
|
7227
|
+
if (shell < 0) return false;
|
|
7228
|
+
const flag = command.findIndex((arg, at) => at > shell && /^-[a-zA-Z]*c[a-zA-Z]*$/.test(arg));
|
|
7229
|
+
const script = flag < 0 ? void 0 : command[flag + 1]?.trimEnd();
|
|
7230
|
+
if (script === void 0) return false;
|
|
7231
|
+
return script.endsWith("&") && !script.endsWith("&&") || /\b(nohup|disown|setsid)\b/.test(script);
|
|
7232
|
+
}
|
|
7233
|
+
var detachWay = (name) => `To keep work running after the call, use: tonbo machine exec ${name} --detach -- <command>`;
|
|
7234
|
+
function backgroundEndedNotice(name, count) {
|
|
7235
|
+
const processes = count === 1 ? "1 background process" : `${count} background processes`;
|
|
7236
|
+
return `${processes} this command started ${count === 1 ? "was" : "were"} ended when it returned. ${detachWay(name)}`;
|
|
7237
|
+
}
|
|
7238
|
+
function backgroundsWorkNotice(name, detach) {
|
|
7239
|
+
return detach ? `--detach already keeps the command running after the call, and what it puts in the background with nohup or & is ended when the command itself returns. Run the work in the foreground: tonbo machine exec ${name} --detach -- <command>` : `This command puts work in the background, which is ended when the command returns. ${detachWay(name)}`;
|
|
7240
|
+
}
|
|
7214
7241
|
async function machineExecCommand(deps, name, command, options = {}) {
|
|
7215
7242
|
if (command.length === 0)
|
|
7216
7243
|
throw new Error("Name the command after --, such as `tonbo machine exec web -- ls /`.");
|
|
7244
|
+
if (backgroundsWork(command)) deps.notice(backgroundsWorkNotice(name, false));
|
|
7217
7245
|
const key = await deps.auth.apiKey();
|
|
7218
7246
|
const region = await regionOf(deps, key, name);
|
|
7219
7247
|
const body = { command };
|
|
@@ -7240,6 +7268,7 @@ async function machineExecCommand(deps, name, command, options = {}) {
|
|
|
7240
7268
|
if (options.json) {
|
|
7241
7269
|
const answer = await response.json();
|
|
7242
7270
|
print(deps, true, answer.data, "");
|
|
7271
|
+
noticeEnded(deps, name, answer.data);
|
|
7243
7272
|
exitWith(deps, answer.data.reason ? execExitCode(answer.data) : 0);
|
|
7244
7273
|
return;
|
|
7245
7274
|
}
|
|
@@ -7272,10 +7301,17 @@ async function machineExecCommand(deps, name, command, options = {}) {
|
|
|
7272
7301
|
}
|
|
7273
7302
|
line(buffered);
|
|
7274
7303
|
const ended = end ?? { reason: "lost", exit_code: null, signal: null };
|
|
7275
|
-
if (ended.reason
|
|
7304
|
+
if (ended.reason === "timeout") deps.notice(`The command ended: timeout. ${detachWay(name)}`);
|
|
7305
|
+
else if (ended.reason !== "exited" && ended.reason !== "signaled")
|
|
7276
7306
|
deps.notice(`The command ended: ${ended.reason}.`);
|
|
7307
|
+
noticeEnded(deps, name, ended);
|
|
7277
7308
|
exitWith(deps, execExitCode(ended));
|
|
7278
7309
|
}
|
|
7310
|
+
function noticeEnded(deps, name, end) {
|
|
7311
|
+
const count = end.killed_processes ?? 0;
|
|
7312
|
+
if (count > 0 && (end.reason === "exited" || end.reason === "signaled"))
|
|
7313
|
+
deps.notice(backgroundEndedNotice(name, count));
|
|
7314
|
+
}
|
|
7279
7315
|
function processEndText(process2) {
|
|
7280
7316
|
const end = process2.end;
|
|
7281
7317
|
if (!end) return "-";
|
|
@@ -7295,6 +7331,7 @@ async function machineProcessStartCommand(deps, name, command, options = {}) {
|
|
|
7295
7331
|
throw new Error(
|
|
7296
7332
|
"Name the command after --, such as `tonbo machine exec web --detach -- npm run build`."
|
|
7297
7333
|
);
|
|
7334
|
+
if (backgroundsWork(command)) deps.notice(backgroundsWorkNotice(name, true));
|
|
7298
7335
|
const body = { command };
|
|
7299
7336
|
if (options.cwd) body.cwd = options.cwd;
|
|
7300
7337
|
if (options.env?.length) body.env = parseEnv2(options.env);
|
|
@@ -7510,7 +7547,7 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
|
|
|
7510
7547
|
try {
|
|
7511
7548
|
const key = await deps.auth.apiKey();
|
|
7512
7549
|
const region = await regionOf(deps, key, name);
|
|
7513
|
-
const
|
|
7550
|
+
const stat3 = await cellApi(deps).json(
|
|
7514
7551
|
"statMachineFile",
|
|
7515
7552
|
key,
|
|
7516
7553
|
region,
|
|
@@ -7520,14 +7557,14 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
|
|
|
7520
7557
|
headers: { "tonbo-expect-activation": options.expectActivation }
|
|
7521
7558
|
}
|
|
7522
7559
|
);
|
|
7523
|
-
noticeWake(deps,
|
|
7560
|
+
noticeWake(deps, stat3.machine, name);
|
|
7524
7561
|
print(
|
|
7525
7562
|
deps,
|
|
7526
7563
|
options.json,
|
|
7527
|
-
|
|
7564
|
+
stat3,
|
|
7528
7565
|
fields(
|
|
7529
7566
|
["path", "type", "size", "mode", "uid", "gid", "mtime", "etag", "target", "durability"].map(
|
|
7530
|
-
(field) => [field, cellText(
|
|
7567
|
+
(field) => [field, cellText(stat3[field])]
|
|
7531
7568
|
)
|
|
7532
7569
|
)
|
|
7533
7570
|
);
|
|
@@ -7681,12 +7718,167 @@ async function terminalAsk(question) {
|
|
|
7681
7718
|
// src/ssh.ts
|
|
7682
7719
|
import { spawn, spawnSync } from "node:child_process";
|
|
7683
7720
|
import { existsSync as existsSync2 } from "node:fs";
|
|
7684
|
-
import { chmod as
|
|
7721
|
+
import { chmod as chmod3, mkdir as mkdir4, readFile as readFile6, readdir as readdir2, rename as rename5, writeFile as writeFile3 } from "node:fs/promises";
|
|
7722
|
+
import { homedir as homedir2 } from "node:os";
|
|
7723
|
+
import { join as join2 } from "node:path";
|
|
7724
|
+
|
|
7725
|
+
// src/ssh-legacy.ts
|
|
7726
|
+
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
7727
|
import { homedir } from "node:os";
|
|
7686
|
-
import { join } from "node:path";
|
|
7728
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
7729
|
+
var LEGACY_BEGIN = "# >>> tonbo project ssh >>>";
|
|
7730
|
+
var LEGACY_END = "# <<< tonbo project ssh <<<";
|
|
7731
|
+
var PROXY_COMMAND = /^\s*proxycommand(\s|=)/i;
|
|
7732
|
+
var SSH_PROXY = /(^|[\s'"/])ssh-proxy(\s|['"]|$)/;
|
|
7733
|
+
function shown(path5, home) {
|
|
7734
|
+
return path5.startsWith(home + sep) ? `~${path5.slice(home.length)}` : path5;
|
|
7735
|
+
}
|
|
7736
|
+
function words(text) {
|
|
7737
|
+
const found = [];
|
|
7738
|
+
for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) found.push(match[1] ?? match[2]);
|
|
7739
|
+
return found;
|
|
7740
|
+
}
|
|
7741
|
+
function wildcard(segment) {
|
|
7742
|
+
const pattern = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
7743
|
+
return new RegExp(`^${pattern}$`);
|
|
7744
|
+
}
|
|
7745
|
+
async function expand(pattern) {
|
|
7746
|
+
const parts = pattern.split(sep).filter((part) => part !== "");
|
|
7747
|
+
let paths = [sep];
|
|
7748
|
+
for (const part of parts) {
|
|
7749
|
+
if (!/[*?]/.test(part)) {
|
|
7750
|
+
paths = paths.map((path5) => join(path5, part));
|
|
7751
|
+
continue;
|
|
7752
|
+
}
|
|
7753
|
+
const matcher = wildcard(part);
|
|
7754
|
+
const next = [];
|
|
7755
|
+
for (const path5 of paths) {
|
|
7756
|
+
const names = await readdir(path5).catch(() => []);
|
|
7757
|
+
for (const name of names.sort())
|
|
7758
|
+
if (matcher.test(name) && !name.startsWith(".")) next.push(join(path5, name));
|
|
7759
|
+
}
|
|
7760
|
+
paths = next;
|
|
7761
|
+
}
|
|
7762
|
+
return paths;
|
|
7763
|
+
}
|
|
7764
|
+
async function scan(home) {
|
|
7765
|
+
const sshDir = join(home, ".ssh");
|
|
7766
|
+
const files = [];
|
|
7767
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7768
|
+
const visit = async (path5, depth) => {
|
|
7769
|
+
if (depth > 16 || seen.has(path5) || !path5.startsWith(sshDir + sep)) return;
|
|
7770
|
+
seen.add(path5);
|
|
7771
|
+
let text;
|
|
7772
|
+
try {
|
|
7773
|
+
if (!(await stat2(path5)).isFile()) return;
|
|
7774
|
+
text = await readFile5(path5, "utf8");
|
|
7775
|
+
} catch {
|
|
7776
|
+
return;
|
|
7777
|
+
}
|
|
7778
|
+
const lines = text.split("\n");
|
|
7779
|
+
const file = { path: path5, lines, blocks: [], handEdited: [] };
|
|
7780
|
+
const includes = [];
|
|
7781
|
+
for (let index = 0; index < lines.length; index++) {
|
|
7782
|
+
if (lines[index].trim() === LEGACY_BEGIN) {
|
|
7783
|
+
const end = lines.findIndex((line2, at) => at > index && line2.trim() === LEGACY_END);
|
|
7784
|
+
if (end !== -1) {
|
|
7785
|
+
file.blocks.push([index, end]);
|
|
7786
|
+
index = end;
|
|
7787
|
+
continue;
|
|
7788
|
+
}
|
|
7789
|
+
}
|
|
7790
|
+
const line = lines[index];
|
|
7791
|
+
if (PROXY_COMMAND.test(line) && SSH_PROXY.test(line)) file.handEdited.push(index + 1);
|
|
7792
|
+
const include = /^\s*include(?:\s+|\s*=\s*)(.*)$/i.exec(line);
|
|
7793
|
+
if (include) includes.push(...words(include[1].replace(/\s#.*$/, "")));
|
|
7794
|
+
}
|
|
7795
|
+
if (file.blocks.length || file.handEdited.length) files.push(file);
|
|
7796
|
+
for (const name of includes) {
|
|
7797
|
+
const expanded = name.startsWith("~/") ? join(home, name.slice(2)) : name;
|
|
7798
|
+
const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(sshDir, expanded);
|
|
7799
|
+
for (const match of await expand(absolute)) await visit(match, depth + 1);
|
|
7800
|
+
}
|
|
7801
|
+
};
|
|
7802
|
+
await visit(join(sshDir, "config"), 0);
|
|
7803
|
+
return files;
|
|
7804
|
+
}
|
|
7805
|
+
function stamp(now) {
|
|
7806
|
+
return new Date(now).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
7807
|
+
}
|
|
7808
|
+
function blockLines(file, home) {
|
|
7809
|
+
const out = [`${shown(file.path, home)}: delete these lines, the outdated Tonbo block:`];
|
|
7810
|
+
for (const [start, end] of file.blocks)
|
|
7811
|
+
for (let index = start; index <= end; index++)
|
|
7812
|
+
out.push(` ${index + 1}: ${file.lines[index].replace(/\r$/, "")}`);
|
|
7813
|
+
return out.join("\n");
|
|
7814
|
+
}
|
|
7815
|
+
async function legacySshGuidance(home = homedir()) {
|
|
7816
|
+
const files = await scan(home);
|
|
7817
|
+
const found = [
|
|
7818
|
+
...files.filter((file) => file.blocks.length).map((file) => blockLines(file, home)),
|
|
7819
|
+
...files.flatMap(
|
|
7820
|
+
(file) => file.handEdited.map(
|
|
7821
|
+
(line) => `${shown(file.path, home)}:${line}: this ProxyCommand runs \`tonbo ssh-proxy\`; delete the line.`
|
|
7822
|
+
)
|
|
7823
|
+
)
|
|
7824
|
+
];
|
|
7825
|
+
return [
|
|
7826
|
+
"tonbo ssh-proxy is gone: an outdated Tonbo block in your SSH config still runs it.",
|
|
7827
|
+
...found.length ? found : [`Delete the lines from "${LEGACY_BEGIN}" to "${LEGACY_END}" in ~/.ssh/config.`],
|
|
7828
|
+
"Running `tonbo ssh <machine>` or `tonbo ssh config` removes the block for you, with a backup."
|
|
7829
|
+
].join("\n");
|
|
7830
|
+
}
|
|
7831
|
+
async function removeLegacySshConfig(options) {
|
|
7832
|
+
const home = options.home ?? homedir();
|
|
7833
|
+
const now = options.now ?? Date.now;
|
|
7834
|
+
const files = await scan(home);
|
|
7835
|
+
const unwritable = [];
|
|
7836
|
+
for (const file of files) {
|
|
7837
|
+
if (!file.blocks.length) continue;
|
|
7838
|
+
const original = file.lines.join("\n");
|
|
7839
|
+
const kept = file.lines.filter(
|
|
7840
|
+
(_, index) => !file.blocks.some(([start, end]) => index >= start && index <= end)
|
|
7841
|
+
);
|
|
7842
|
+
const sshDir = join(home, ".ssh");
|
|
7843
|
+
const name = file.path.slice(sshDir.length + 1).split(sep).join("-");
|
|
7844
|
+
const backup = join(sshDir, `${name}.tonbo-${stamp(now())}.bak`);
|
|
7845
|
+
let temporary;
|
|
7846
|
+
try {
|
|
7847
|
+
const target = await realpath(file.path);
|
|
7848
|
+
const mode = (await stat2(target)).mode & 4095;
|
|
7849
|
+
await writeFile2(backup, original, { mode, flag: "wx" });
|
|
7850
|
+
await chmod2(backup, mode);
|
|
7851
|
+
temporary = join(dirname(target), `.${process.pid}.tonbo-ssh-config.tmp`);
|
|
7852
|
+
await writeFile2(temporary, kept.join("\n"), { mode, flag: "wx" });
|
|
7853
|
+
await chmod2(temporary, mode);
|
|
7854
|
+
await rename4(temporary, target);
|
|
7855
|
+
temporary = void 0;
|
|
7856
|
+
} catch (error) {
|
|
7857
|
+
if (temporary) await rm4(temporary, { force: true }).catch(() => {
|
|
7858
|
+
});
|
|
7859
|
+
unwritable.push(
|
|
7860
|
+
`${blockLines(file, home)}
|
|
7861
|
+
(could not rewrite it: ${error instanceof Error ? error.message : String(error)})`
|
|
7862
|
+
);
|
|
7863
|
+
continue;
|
|
7864
|
+
}
|
|
7865
|
+
options.notice(
|
|
7866
|
+
`Removed an outdated Tonbo block from ${shown(file.path, home)} (backup: ${shown(backup, home)})`
|
|
7867
|
+
);
|
|
7868
|
+
}
|
|
7869
|
+
const handEdited = files.flatMap(
|
|
7870
|
+
(file) => file.handEdited.map(
|
|
7871
|
+
(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`
|
|
7872
|
+
)
|
|
7873
|
+
);
|
|
7874
|
+
if (unwritable.length || handEdited.length)
|
|
7875
|
+
throw new Error([...unwritable, ...handEdited].join("\n"));
|
|
7876
|
+
}
|
|
7877
|
+
|
|
7878
|
+
// src/ssh.ts
|
|
7687
7879
|
function sshDirectory(env = process.env) {
|
|
7688
|
-
const config = env.XDG_CONFIG_HOME?.trim() ||
|
|
7689
|
-
return
|
|
7880
|
+
const config = env.XDG_CONFIG_HOME?.trim() || join2(homedir2(), ".config");
|
|
7881
|
+
return join2(config, "tonbo", "ssh");
|
|
7690
7882
|
}
|
|
7691
7883
|
var RENEW_BEFORE_MS = 12e4;
|
|
7692
7884
|
var REQUESTED_TTL_SECONDS = 3600;
|
|
@@ -7704,9 +7896,9 @@ function cellOrigin2(host) {
|
|
|
7704
7896
|
return `https://api.${host}`;
|
|
7705
7897
|
}
|
|
7706
7898
|
async function ensureKey(directory) {
|
|
7707
|
-
const key =
|
|
7899
|
+
const key = join2(directory, "id_ed25519");
|
|
7708
7900
|
await mkdir4(directory, { recursive: true, mode: 448 });
|
|
7709
|
-
await
|
|
7901
|
+
await chmod3(directory, 448);
|
|
7710
7902
|
if (!existsSync2(key)) {
|
|
7711
7903
|
const made = spawnSync(
|
|
7712
7904
|
"ssh-keygen",
|
|
@@ -7725,41 +7917,41 @@ async function ensureKey(directory) {
|
|
|
7725
7917
|
}
|
|
7726
7918
|
async function writeAtomic(path5, data) {
|
|
7727
7919
|
const temporary = `${path5}.${process.pid}.tmp`;
|
|
7728
|
-
await
|
|
7729
|
-
await
|
|
7920
|
+
await writeFile3(temporary, data, { mode: 384 });
|
|
7921
|
+
await rename5(temporary, path5);
|
|
7730
7922
|
}
|
|
7731
7923
|
function certificatePath(directory, user) {
|
|
7732
|
-
return
|
|
7924
|
+
return join2(directory, `${user}-cert.pub`);
|
|
7733
7925
|
}
|
|
7734
7926
|
function hostCAPath(directory, host) {
|
|
7735
7927
|
if (!/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
|
7736
7928
|
throw new Error(`${host} is not a Machine's SSH host`);
|
|
7737
7929
|
}
|
|
7738
|
-
return
|
|
7930
|
+
return join2(directory, "host-ca", host);
|
|
7739
7931
|
}
|
|
7740
7932
|
async function trustHostCA(directory, host, lines) {
|
|
7741
|
-
const cells =
|
|
7933
|
+
const cells = join2(directory, "host-ca");
|
|
7742
7934
|
await mkdir4(cells, { recursive: true, mode: 448 });
|
|
7743
7935
|
await writeAtomic(hostCAPath(directory, host), `${lines.join("\n")}
|
|
7744
7936
|
`);
|
|
7745
7937
|
const trusted = /* @__PURE__ */ new Set();
|
|
7746
|
-
for (const name of (await
|
|
7938
|
+
for (const name of (await readdir2(cells)).sort()) {
|
|
7747
7939
|
if (name.endsWith(".tmp")) {
|
|
7748
7940
|
continue;
|
|
7749
7941
|
}
|
|
7750
|
-
for (const line of (await
|
|
7942
|
+
for (const line of (await readFile6(join2(cells, name), "utf8")).split("\n")) {
|
|
7751
7943
|
if (line.trim() !== "") {
|
|
7752
7944
|
trusted.add(line.trim());
|
|
7753
7945
|
}
|
|
7754
7946
|
}
|
|
7755
7947
|
}
|
|
7756
|
-
await writeAtomic(
|
|
7948
|
+
await writeAtomic(join2(directory, "known_hosts"), `${[...trusted].join("\n")}
|
|
7757
7949
|
`);
|
|
7758
7950
|
}
|
|
7759
7951
|
async function heldUntil(directory, user) {
|
|
7760
7952
|
try {
|
|
7761
7953
|
const sidecar = JSON.parse(
|
|
7762
|
-
await
|
|
7954
|
+
await readFile6(`${certificatePath(directory, user)}.json`, "utf8")
|
|
7763
7955
|
);
|
|
7764
7956
|
return sidecar.valid_before ? Date.parse(sidecar.valid_before) : 0;
|
|
7765
7957
|
} catch {
|
|
@@ -7792,12 +7984,12 @@ async function cellRequest(bearer, method, url, body) {
|
|
|
7792
7984
|
async function refreshCertificate(bearer, destination, now = Date.now, directory = sshDirectory()) {
|
|
7793
7985
|
const key = await ensureKey(directory);
|
|
7794
7986
|
const certificate = certificatePath(directory, destination.user);
|
|
7795
|
-
const knownHosts =
|
|
7987
|
+
const knownHosts = join2(directory, "known_hosts");
|
|
7796
7988
|
if (await heldUntil(directory, destination.user) - now() > RENEW_BEFORE_MS && existsSync2(hostCAPath(directory, destination.host)) && existsSync2(knownHosts)) {
|
|
7797
7989
|
return { key, certificate, knownHosts };
|
|
7798
7990
|
}
|
|
7799
7991
|
const origin = cellOrigin2(destination.host);
|
|
7800
|
-
const publicKey = (await
|
|
7992
|
+
const publicKey = (await readFile6(`${key}.pub`, "utf8")).trim();
|
|
7801
7993
|
const issued = await cellRequest(bearer, "POST", `${origin}/v1/ssh/certificates`, {
|
|
7802
7994
|
public_key: publicKey,
|
|
7803
7995
|
ttl_seconds: REQUESTED_TTL_SECONDS
|
|
@@ -7834,6 +8026,7 @@ function sshOptions(paths) {
|
|
|
7834
8026
|
];
|
|
7835
8027
|
}
|
|
7836
8028
|
async function sshCommand(deps, name, command) {
|
|
8029
|
+
await removeLegacySshConfig({ notice: deps.notice });
|
|
7837
8030
|
let destination;
|
|
7838
8031
|
let paths;
|
|
7839
8032
|
try {
|
|
@@ -7848,32 +8041,54 @@ async function sshCommand(deps, name, command) {
|
|
|
7848
8041
|
throw explainMachineError(error);
|
|
7849
8042
|
}
|
|
7850
8043
|
const target = `${destination.user}@${destination.host}`;
|
|
7851
|
-
const status = await new Promise((
|
|
8044
|
+
const status = await new Promise((resolve2, reject) => {
|
|
7852
8045
|
const child = spawn("ssh", [...sshOptions(paths), target, ...command], { stdio: "inherit" });
|
|
7853
8046
|
child.on(
|
|
7854
8047
|
"error",
|
|
7855
8048
|
(error) => reject(new Error(`could not run ssh (${error.message}); install an OpenSSH client`))
|
|
7856
8049
|
);
|
|
7857
|
-
child.on("exit", (code, signal) =>
|
|
8050
|
+
child.on("exit", (code, signal) => resolve2(code ?? (signal ? 255 : 0)));
|
|
7858
8051
|
});
|
|
7859
8052
|
process.exitCode = status;
|
|
7860
8053
|
}
|
|
8054
|
+
var REGIONLESS_HOST = "tonbo.sh";
|
|
8055
|
+
async function regionlessHint(deps, destination) {
|
|
8056
|
+
const name = destination.user.slice(0, destination.user.lastIndexOf("."));
|
|
8057
|
+
try {
|
|
8058
|
+
const { machine } = await deps.api.getMachineWithEtag(await deps.auth.apiKey(), name);
|
|
8059
|
+
if (machine.kind !== "managed")
|
|
8060
|
+
return new Error(`${name} is one of your computers; SSH reaches only Tonbo-run Machines`);
|
|
8061
|
+
return new Error(
|
|
8062
|
+
`Use <name>.<account>@<region>.tonbo.sh (region-less SSH isn't available yet), e.g. ssh ${machine.hostnames.ssh}`
|
|
8063
|
+
);
|
|
8064
|
+
} catch (error) {
|
|
8065
|
+
if (error instanceof HttpError && error.status === 404)
|
|
8066
|
+
return new Error(`The account ${destination.account} has no Machine named ${name}`);
|
|
8067
|
+
throw explainMachineError(error);
|
|
8068
|
+
}
|
|
8069
|
+
}
|
|
7861
8070
|
async function sshRefreshCommand(makeDeps, target) {
|
|
7862
8071
|
const destination = parseDestination(target);
|
|
7863
8072
|
const deps = makeDeps(destination.account);
|
|
8073
|
+
if (destination.host === REGIONLESS_HOST) throw await regionlessHint(deps, destination);
|
|
8074
|
+
await removeLegacySshConfig({ notice: deps.notice });
|
|
7864
8075
|
await refreshCertificate(await deps.auth.apiKey(), destination, deps.now);
|
|
7865
8076
|
}
|
|
8077
|
+
async function sshConfigCommand(deps) {
|
|
8078
|
+
await removeLegacySshConfig({ notice: deps.notice });
|
|
8079
|
+
deps.output(sshConfigText());
|
|
8080
|
+
}
|
|
7866
8081
|
function sshConfigText(directory = sshDirectory()) {
|
|
7867
|
-
const home =
|
|
7868
|
-
const
|
|
8082
|
+
const home = homedir2();
|
|
8083
|
+
const shown2 = directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory;
|
|
7869
8084
|
return [
|
|
7870
8085
|
"# Tonbo Machines: append to ~/.ssh/config (tonbo ssh config >> ~/.ssh/config).",
|
|
7871
8086
|
"# Then: ssh <name>.<account>@<region>.tonbo.sh, scp, sftp and Remote-SSH work as usual.",
|
|
7872
8087
|
`Match host "*.tonbo.sh,tonbo.sh" exec "tonbo ssh refresh %r@%h"`,
|
|
7873
|
-
` IdentityFile ${
|
|
7874
|
-
` CertificateFile ${
|
|
8088
|
+
` IdentityFile ${shown2}/id_ed25519`,
|
|
8089
|
+
` CertificateFile ${shown2}/%r-cert.pub`,
|
|
7875
8090
|
" IdentitiesOnly yes",
|
|
7876
|
-
` UserKnownHostsFile ${
|
|
8091
|
+
` UserKnownHostsFile ${shown2}/known_hosts`,
|
|
7877
8092
|
" StrictHostKeyChecking yes",
|
|
7878
8093
|
" ServerAliveInterval 30"
|
|
7879
8094
|
].join("\n");
|
|
@@ -7889,7 +8104,7 @@ async function sshHostCaCommand(deps, options) {
|
|
|
7889
8104
|
print(deps, options.json, data, data.known_hosts.join("\n"));
|
|
7890
8105
|
}
|
|
7891
8106
|
async function sshCertificateIssueCommand(deps, options) {
|
|
7892
|
-
const publicKey = (await
|
|
8107
|
+
const publicKey = (await readFile6(options.publicKey, "utf8")).trim();
|
|
7893
8108
|
const key = await deps.auth.apiKey();
|
|
7894
8109
|
const data = await (deps.cell ?? new CellApi(fetch)).json(
|
|
7895
8110
|
"issueSshCertificate",
|
|
@@ -8556,12 +8771,17 @@ function createProgram(makeDependencies = createDependencies) {
|
|
|
8556
8771
|
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
8772
|
ssh.command("config").description(
|
|
8558
8773
|
"print the ssh_config block that lets ssh, scp, sftp and Remote-SSH reach every Machine"
|
|
8559
|
-
).action(() => dependencies()
|
|
8774
|
+
).action(async () => sshConfigCommand(dependencies()));
|
|
8560
8775
|
ssh.command("refresh").argument("<destination>", "a Machine's SSH destination, <name>.<account>@<host>").description(
|
|
8561
8776
|
"renew the SSH certificate of <name>.<account>@<host> when it is about to expire (ssh_config's Match exec runs it)"
|
|
8562
8777
|
).action(
|
|
8563
8778
|
async (destination) => sshRefreshCommand((account2) => makeDependencies({ account: account2 }), destination)
|
|
8564
8779
|
);
|
|
8780
|
+
program.command("ssh-proxy", { hidden: true }).argument("[arguments...]").allowUnknownOption().helpOption(false).action(async () => {
|
|
8781
|
+
process.stderr.write(`${await legacySshGuidance()}
|
|
8782
|
+
`);
|
|
8783
|
+
process.exitCode = 1;
|
|
8784
|
+
});
|
|
8565
8785
|
operationOptions(
|
|
8566
8786
|
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
8787
|
"getSshHostCa"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tonbo/cli",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
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": {
|