@tonbo/cli 0.16.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.
- package/dist/bin/tonbo.js +268 -240
- package/package.json +1 -1
package/dist/bin/tonbo.js
CHANGED
|
@@ -380,7 +380,8 @@ var managementOperations = {
|
|
|
380
380
|
"path": "/sessions/store",
|
|
381
381
|
"pathParams": [],
|
|
382
382
|
"query": [
|
|
383
|
-
"capture"
|
|
383
|
+
"capture",
|
|
384
|
+
"project"
|
|
384
385
|
],
|
|
385
386
|
"idempotencyKey": false
|
|
386
387
|
},
|
|
@@ -401,6 +402,7 @@ var managementOperations = {
|
|
|
401
402
|
"pathParams": [],
|
|
402
403
|
"query": [
|
|
403
404
|
"origin",
|
|
405
|
+
"scope",
|
|
404
406
|
"cursor",
|
|
405
407
|
"limit"
|
|
406
408
|
],
|
|
@@ -433,149 +435,30 @@ var managementOperations = {
|
|
|
433
435
|
"query": [],
|
|
434
436
|
"idempotencyKey": false
|
|
435
437
|
},
|
|
436
|
-
"
|
|
438
|
+
"listProjectPeople": {
|
|
437
439
|
"method": "GET",
|
|
438
|
-
"path": "/
|
|
440
|
+
"path": "/projects/{project_id}/people",
|
|
439
441
|
"pathParams": [
|
|
440
|
-
"
|
|
442
|
+
"project_id"
|
|
441
443
|
],
|
|
442
444
|
"query": [],
|
|
443
445
|
"idempotencyKey": false
|
|
444
446
|
},
|
|
445
|
-
"
|
|
446
|
-
"method": "GET",
|
|
447
|
-
"path": "/yield/handoffs",
|
|
448
|
-
"pathParams": [],
|
|
449
|
-
"query": [],
|
|
450
|
-
"idempotencyKey": false
|
|
451
|
-
},
|
|
452
|
-
"createPersonalHandoff": {
|
|
453
|
-
"method": "POST",
|
|
454
|
-
"path": "/yield/handoffs",
|
|
455
|
-
"pathParams": [],
|
|
456
|
-
"query": [],
|
|
457
|
-
"idempotencyKey": false
|
|
458
|
-
},
|
|
459
|
-
"listHandoffEvents": {
|
|
460
|
-
"method": "GET",
|
|
461
|
-
"path": "/yield/handoffs/events",
|
|
462
|
-
"pathParams": [],
|
|
463
|
-
"query": [
|
|
464
|
-
"after",
|
|
465
|
-
"wait",
|
|
466
|
-
"include"
|
|
467
|
-
],
|
|
468
|
-
"idempotencyKey": false
|
|
469
|
-
},
|
|
470
|
-
"listTeamHandoffs": {
|
|
471
|
-
"method": "GET",
|
|
472
|
-
"path": "/yield/handoffs/team",
|
|
473
|
-
"pathParams": [],
|
|
474
|
-
"query": [],
|
|
475
|
-
"idempotencyKey": false
|
|
476
|
-
},
|
|
477
|
-
"renewHandoffHolds": {
|
|
478
|
-
"method": "POST",
|
|
479
|
-
"path": "/yield/handoffs/holds",
|
|
480
|
-
"pathParams": [],
|
|
481
|
-
"query": [],
|
|
482
|
-
"idempotencyKey": false
|
|
483
|
-
},
|
|
484
|
-
"showPersonalHandoff": {
|
|
485
|
-
"method": "GET",
|
|
486
|
-
"path": "/yield/handoffs/{id}",
|
|
487
|
-
"pathParams": [
|
|
488
|
-
"id"
|
|
489
|
-
],
|
|
490
|
-
"query": [],
|
|
491
|
-
"idempotencyKey": false
|
|
492
|
-
},
|
|
493
|
-
"takePersonalHandoff": {
|
|
494
|
-
"method": "POST",
|
|
495
|
-
"path": "/yield/handoffs/{id}/take",
|
|
496
|
-
"pathParams": [
|
|
497
|
-
"id"
|
|
498
|
-
],
|
|
499
|
-
"query": [],
|
|
500
|
-
"idempotencyKey": false
|
|
501
|
-
},
|
|
502
|
-
"markPersonalHandoffRestored": {
|
|
503
|
-
"method": "POST",
|
|
504
|
-
"path": "/yield/handoffs/{id}/restored",
|
|
505
|
-
"pathParams": [
|
|
506
|
-
"id"
|
|
507
|
-
],
|
|
508
|
-
"query": [],
|
|
509
|
-
"idempotencyKey": false
|
|
510
|
-
},
|
|
511
|
-
"startPersonalHandoff": {
|
|
447
|
+
"inviteProjectPerson": {
|
|
512
448
|
"method": "POST",
|
|
513
|
-
"path": "/
|
|
449
|
+
"path": "/projects/{project_id}/people",
|
|
514
450
|
"pathParams": [
|
|
515
|
-
"
|
|
516
|
-
],
|
|
517
|
-
"query": [],
|
|
518
|
-
"idempotencyKey": false
|
|
519
|
-
},
|
|
520
|
-
"needHumanPersonalHandoff": {
|
|
521
|
-
"method": "POST",
|
|
522
|
-
"path": "/yield/handoffs/{id}/need-human",
|
|
523
|
-
"pathParams": [
|
|
524
|
-
"id"
|
|
525
|
-
],
|
|
526
|
-
"query": [],
|
|
527
|
-
"idempotencyKey": false
|
|
528
|
-
},
|
|
529
|
-
"cancelPersonalHandoff": {
|
|
530
|
-
"method": "POST",
|
|
531
|
-
"path": "/yield/handoffs/{id}/cancel",
|
|
532
|
-
"pathParams": [
|
|
533
|
-
"id"
|
|
534
|
-
],
|
|
535
|
-
"query": [],
|
|
536
|
-
"idempotencyKey": false
|
|
537
|
-
},
|
|
538
|
-
"renewPersonalHandoffSource": {
|
|
539
|
-
"method": "POST",
|
|
540
|
-
"path": "/yield/handoffs/{id}/renew-source",
|
|
541
|
-
"pathParams": [
|
|
542
|
-
"id"
|
|
451
|
+
"project_id"
|
|
543
452
|
],
|
|
544
453
|
"query": [],
|
|
545
454
|
"idempotencyKey": false
|
|
546
455
|
},
|
|
547
|
-
"
|
|
548
|
-
"method": "
|
|
549
|
-
"path": "/
|
|
550
|
-
"pathParams": [
|
|
551
|
-
"id"
|
|
552
|
-
],
|
|
553
|
-
"query": [],
|
|
554
|
-
"idempotencyKey": false
|
|
555
|
-
},
|
|
556
|
-
"pullPersonalHandoff": {
|
|
557
|
-
"method": "GET",
|
|
558
|
-
"path": "/yield/handoffs/{id}/result",
|
|
559
|
-
"pathParams": [
|
|
560
|
-
"id"
|
|
561
|
-
],
|
|
562
|
-
"query": [],
|
|
563
|
-
"idempotencyKey": false
|
|
564
|
-
},
|
|
565
|
-
"markPersonalHandoffPulled": {
|
|
566
|
-
"method": "POST",
|
|
567
|
-
"path": "/yield/handoffs/{id}/pulled",
|
|
568
|
-
"pathParams": [
|
|
569
|
-
"id"
|
|
570
|
-
],
|
|
571
|
-
"query": [],
|
|
572
|
-
"idempotencyKey": false
|
|
573
|
-
},
|
|
574
|
-
"renewPersonalHandoffResult": {
|
|
575
|
-
"method": "POST",
|
|
576
|
-
"path": "/yield/handoffs/{id}/renew-result",
|
|
456
|
+
"removeProjectPerson": {
|
|
457
|
+
"method": "DELETE",
|
|
458
|
+
"path": "/projects/{project_id}/people/{person}",
|
|
577
459
|
"pathParams": [
|
|
578
|
-
"
|
|
460
|
+
"project_id",
|
|
461
|
+
"person"
|
|
579
462
|
],
|
|
580
463
|
"query": [],
|
|
581
464
|
"idempotencyKey": false
|
|
@@ -650,68 +533,6 @@ var managementOperations = {
|
|
|
650
533
|
"query": [],
|
|
651
534
|
"idempotencyKey": false
|
|
652
535
|
},
|
|
653
|
-
"createYieldPickup": {
|
|
654
|
-
"method": "POST",
|
|
655
|
-
"path": "/yield/pickups",
|
|
656
|
-
"pathParams": [],
|
|
657
|
-
"query": [],
|
|
658
|
-
"idempotencyKey": false
|
|
659
|
-
},
|
|
660
|
-
"listYieldQuestions": {
|
|
661
|
-
"method": "GET",
|
|
662
|
-
"path": "/yield/questions",
|
|
663
|
-
"pathParams": [],
|
|
664
|
-
"query": [
|
|
665
|
-
"team",
|
|
666
|
-
"limit"
|
|
667
|
-
],
|
|
668
|
-
"idempotencyKey": false
|
|
669
|
-
},
|
|
670
|
-
"createYieldQuestion": {
|
|
671
|
-
"method": "POST",
|
|
672
|
-
"path": "/yield/questions",
|
|
673
|
-
"pathParams": [],
|
|
674
|
-
"query": [],
|
|
675
|
-
"idempotencyKey": false
|
|
676
|
-
},
|
|
677
|
-
"showYieldQuestion": {
|
|
678
|
-
"method": "GET",
|
|
679
|
-
"path": "/yield/questions/{id}",
|
|
680
|
-
"pathParams": [
|
|
681
|
-
"id"
|
|
682
|
-
],
|
|
683
|
-
"query": [],
|
|
684
|
-
"idempotencyKey": false
|
|
685
|
-
},
|
|
686
|
-
"replyYieldQuestion": {
|
|
687
|
-
"method": "POST",
|
|
688
|
-
"path": "/yield/questions/{id}/replies",
|
|
689
|
-
"pathParams": [
|
|
690
|
-
"id"
|
|
691
|
-
],
|
|
692
|
-
"query": [],
|
|
693
|
-
"idempotencyKey": false
|
|
694
|
-
},
|
|
695
|
-
"closeYieldQuestion": {
|
|
696
|
-
"method": "POST",
|
|
697
|
-
"path": "/yield/questions/{id}/close",
|
|
698
|
-
"pathParams": [
|
|
699
|
-
"id"
|
|
700
|
-
],
|
|
701
|
-
"query": [],
|
|
702
|
-
"idempotencyKey": false
|
|
703
|
-
},
|
|
704
|
-
"listYieldDecisions": {
|
|
705
|
-
"method": "GET",
|
|
706
|
-
"path": "/yield/decisions",
|
|
707
|
-
"pathParams": [],
|
|
708
|
-
"query": [
|
|
709
|
-
"project",
|
|
710
|
-
"path",
|
|
711
|
-
"limit"
|
|
712
|
-
],
|
|
713
|
-
"idempotencyKey": false
|
|
714
|
-
},
|
|
715
536
|
"listDatabases": {
|
|
716
537
|
"method": "GET",
|
|
717
538
|
"path": "/databases",
|
|
@@ -2188,7 +2009,7 @@ var ManagementApi = class {
|
|
|
2188
2009
|
apiOrigin;
|
|
2189
2010
|
loginHint;
|
|
2190
2011
|
pause;
|
|
2191
|
-
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))) {
|
|
2192
2013
|
this.fetcher = fetcher;
|
|
2193
2014
|
this.apiOrigin = apiOrigin2;
|
|
2194
2015
|
this.loginHint = loginHint;
|
|
@@ -2911,16 +2732,16 @@ function personalLogin(login) {
|
|
|
2911
2732
|
return accountLogins(login).find((entry) => entry.kind === "user") ?? null;
|
|
2912
2733
|
}
|
|
2913
2734
|
async function preparePrivateDirectory(directory) {
|
|
2914
|
-
const
|
|
2915
|
-
if (
|
|
2735
|
+
const stat3 = await lstat(directory);
|
|
2736
|
+
if (stat3.isSymbolicLink() || !stat3.isDirectory())
|
|
2916
2737
|
throw new Error("Tonbo config directory must be a real directory.");
|
|
2917
2738
|
if (process.platform !== "win32")
|
|
2918
2739
|
await chmod(directory, DIRECTORY_MODE);
|
|
2919
2740
|
}
|
|
2920
2741
|
async function assertExistingDestinationIsSafe(filename) {
|
|
2921
2742
|
try {
|
|
2922
|
-
const
|
|
2923
|
-
if (
|
|
2743
|
+
const stat3 = await lstat(filename);
|
|
2744
|
+
if (stat3.isSymbolicLink() || !stat3.isFile())
|
|
2924
2745
|
throw new Error("Tonbo credential path must be a regular file.");
|
|
2925
2746
|
} catch (error) {
|
|
2926
2747
|
if (error.code !== "ENOENT")
|
|
@@ -2928,10 +2749,10 @@ async function assertExistingDestinationIsSafe(filename) {
|
|
|
2928
2749
|
}
|
|
2929
2750
|
}
|
|
2930
2751
|
async function assertPrivateRegularFile(filename) {
|
|
2931
|
-
const
|
|
2932
|
-
if (
|
|
2752
|
+
const stat3 = await lstat(filename);
|
|
2753
|
+
if (stat3.isSymbolicLink() || !stat3.isFile())
|
|
2933
2754
|
throw new Error("Tonbo credential path must be a regular file.");
|
|
2934
|
-
if (process.platform !== "win32" && (
|
|
2755
|
+
if (process.platform !== "win32" && (stat3.mode & REFUSED_MODE_BITS) !== 0)
|
|
2935
2756
|
throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
|
|
2936
2757
|
}
|
|
2937
2758
|
|
|
@@ -3082,7 +2903,7 @@ var DeviceStore = class {
|
|
|
3082
2903
|
await rm2(lock, { recursive: true, force: true });
|
|
3083
2904
|
waited = -CREATE_POLL_MS;
|
|
3084
2905
|
} else
|
|
3085
|
-
await new Promise((
|
|
2906
|
+
await new Promise((resolve2) => setTimeout(resolve2, CREATE_POLL_MS));
|
|
3086
2907
|
}
|
|
3087
2908
|
try {
|
|
3088
2909
|
if (await exists(this.filename))
|
|
@@ -3735,7 +3556,7 @@ var AuthClient = class {
|
|
|
3735
3556
|
const deadline = Date.now() + started.expires_in * 1e3;
|
|
3736
3557
|
let interval = started.interval;
|
|
3737
3558
|
for (; ; ) {
|
|
3738
|
-
await new Promise((
|
|
3559
|
+
await new Promise((resolve2) => setTimeout(resolve2, interval * 1e3));
|
|
3739
3560
|
try {
|
|
3740
3561
|
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/device/token", this.loginOrigin).toString(), {
|
|
3741
3562
|
method: "POST",
|
|
@@ -3764,7 +3585,7 @@ var AuthClient = class {
|
|
|
3764
3585
|
}
|
|
3765
3586
|
}
|
|
3766
3587
|
listenForCode(expectedState, authorizationUrl, progress, openBrowser2) {
|
|
3767
|
-
return new Promise((
|
|
3588
|
+
return new Promise((resolve2, reject) => {
|
|
3768
3589
|
let settled = false;
|
|
3769
3590
|
const sockets = /* @__PURE__ */ new Set();
|
|
3770
3591
|
let authorizationStarted = false;
|
|
@@ -3785,7 +3606,7 @@ var AuthClient = class {
|
|
|
3785
3606
|
reject(closeError);
|
|
3786
3607
|
else if ("code" in result) {
|
|
3787
3608
|
progress({ status: "completed", step: "callback-close" });
|
|
3788
|
-
|
|
3609
|
+
resolve2(result.code);
|
|
3789
3610
|
} else
|
|
3790
3611
|
reject(result.error);
|
|
3791
3612
|
};
|
|
@@ -5468,6 +5289,8 @@ import { readFileSync } from "node:fs";
|
|
|
5468
5289
|
// src/volume.ts
|
|
5469
5290
|
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
5470
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}).`;
|
|
5471
5294
|
var VOLUME_ERROR_HINTS = {
|
|
5472
5295
|
payment_required: "Add credit at <billing>, then create the Volume again.",
|
|
5473
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.",
|
|
@@ -5514,7 +5337,7 @@ function volumeFields(volume) {
|
|
|
5514
5337
|
"Cold GETs",
|
|
5515
5338
|
volume.cold ? `${volume.cold.demand_gets} demand, ${volume.cold.hydrate_gets} hydration` : "none"
|
|
5516
5339
|
],
|
|
5517
|
-
["Durability", VOLUME_DURABILITY_LINK],
|
|
5340
|
+
["Durability", VOLUMES_PREVIEW ? VOLUME_PREVIEW_TEXT : VOLUME_DURABILITY_LINK],
|
|
5518
5341
|
["Created", volume.created_at]
|
|
5519
5342
|
]);
|
|
5520
5343
|
}
|
|
@@ -5542,6 +5365,7 @@ async function volumeListCommand(deps, options = {}) {
|
|
|
5542
5365
|
])
|
|
5543
5366
|
) : "No Volumes."
|
|
5544
5367
|
);
|
|
5368
|
+
if (VOLUMES_PREVIEW && list.data.length && !options.json) deps.notice(VOLUME_PREVIEW_TEXT);
|
|
5545
5369
|
if (list.unavailable_regions.length)
|
|
5546
5370
|
deps.notice(
|
|
5547
5371
|
`Could not read the Volumes in ${list.unavailable_regions.join(", ")}; they are missing from this list.`
|
|
@@ -5573,6 +5397,7 @@ async function volumeCreateCommand(deps, name, options = {}) {
|
|
|
5573
5397
|
[
|
|
5574
5398
|
`Created Volume ${volume.name} in ${volume.region} (${volume.size_gib} GiB; billed by live bytes, not size).`,
|
|
5575
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] : [],
|
|
5576
5401
|
volumeFields(volume)
|
|
5577
5402
|
].join("\n")
|
|
5578
5403
|
);
|
|
@@ -5823,6 +5648,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
|
|
|
5823
5648
|
["Region", machine.region],
|
|
5824
5649
|
["State", machineState(machine)],
|
|
5825
5650
|
["Power", powerText(machine)],
|
|
5651
|
+
...awakeFields(machine),
|
|
5826
5652
|
...activationFields(machine),
|
|
5827
5653
|
...lastEnd ? [["Last end", endText(lastEnd)]] : [],
|
|
5828
5654
|
...conditionFields(machine, billing),
|
|
@@ -5850,6 +5676,25 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
|
|
|
5850
5676
|
["Updated", machine.updated_at]
|
|
5851
5677
|
]);
|
|
5852
5678
|
}
|
|
5679
|
+
function holderText(hold) {
|
|
5680
|
+
const until = hold.until ? ` until ${hold.until}` : "";
|
|
5681
|
+
return hold.kind === "edge_outage" ? `edge_outage${until}` : `${hold.kind} ${hold.count}${until}`;
|
|
5682
|
+
}
|
|
5683
|
+
function awakeFields(machine) {
|
|
5684
|
+
if (machine.state !== "active" || machine.status.power !== "awake") return [];
|
|
5685
|
+
const awake = machine.status.awake;
|
|
5686
|
+
if (awake === void 0) return [];
|
|
5687
|
+
if (awake === null) return [["Held by", "not reported yet; read it again in a few seconds"]];
|
|
5688
|
+
const held = awake.held_by.length > 0;
|
|
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";
|
|
5690
|
+
return [
|
|
5691
|
+
["Held by", held ? awake.held_by.map(holderText).join(", ") : "nothing"],
|
|
5692
|
+
...awake.idle_since ? [["Idle since", awake.idle_since]] : [],
|
|
5693
|
+
["Quiet", awake.quiet.passes ? "yes" : `no: ${awake.quiet.reason ?? "measuring"}`],
|
|
5694
|
+
["Sleeps at", sleeps],
|
|
5695
|
+
["Judged at", awake.as_of]
|
|
5696
|
+
];
|
|
5697
|
+
}
|
|
5853
5698
|
function deleteFields(machine) {
|
|
5854
5699
|
const seconds = machine.delete_after_seconds;
|
|
5855
5700
|
if (seconds === void 0 || seconds === null) return [];
|
|
@@ -5940,7 +5785,7 @@ function waitTimeout(options) {
|
|
|
5940
5785
|
var DEFAULT_WAIT_SECONDS = 300;
|
|
5941
5786
|
async function waitForMachine(deps, key, name, firstWait, every, timeout, settled) {
|
|
5942
5787
|
const now = deps.now ?? Date.now;
|
|
5943
|
-
const pause = deps.pause ?? ((milliseconds) => new Promise((
|
|
5788
|
+
const pause = deps.pause ?? ((milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)));
|
|
5944
5789
|
const start = now();
|
|
5945
5790
|
let wait = firstWait;
|
|
5946
5791
|
let last = null;
|
|
@@ -7253,8 +7098,8 @@ function optionDescription(option) {
|
|
|
7253
7098
|
}
|
|
7254
7099
|
function applyHelp(program) {
|
|
7255
7100
|
const named = /* @__PURE__ */ new Set();
|
|
7256
|
-
const walk = (command,
|
|
7257
|
-
const path5 =
|
|
7101
|
+
const walk = (command, words2) => {
|
|
7102
|
+
const path5 = words2.join(" ");
|
|
7258
7103
|
command.configureHelp({ ...command.configureHelp(), optionDescription });
|
|
7259
7104
|
const help = COMMAND_HELP[path5];
|
|
7260
7105
|
if (help) {
|
|
@@ -7271,7 +7116,7 @@ function applyHelp(program) {
|
|
|
7271
7116
|
}
|
|
7272
7117
|
command.addHelpText("after", commandHelpText(help));
|
|
7273
7118
|
}
|
|
7274
|
-
for (const child of command.commands) walk(child, [...
|
|
7119
|
+
for (const child of command.commands) walk(child, [...words2, child.name()]);
|
|
7275
7120
|
};
|
|
7276
7121
|
walk(program, [program.name()]);
|
|
7277
7122
|
for (const path5 of Object.keys(COMMAND_HELP))
|
|
@@ -7669,7 +7514,7 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
|
|
|
7669
7514
|
try {
|
|
7670
7515
|
const key = await deps.auth.apiKey();
|
|
7671
7516
|
const region = await regionOf(deps, key, name);
|
|
7672
|
-
const
|
|
7517
|
+
const stat3 = await cellApi(deps).json(
|
|
7673
7518
|
"statMachineFile",
|
|
7674
7519
|
key,
|
|
7675
7520
|
region,
|
|
@@ -7679,14 +7524,14 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
|
|
|
7679
7524
|
headers: { "tonbo-expect-activation": options.expectActivation }
|
|
7680
7525
|
}
|
|
7681
7526
|
);
|
|
7682
|
-
noticeWake(deps,
|
|
7527
|
+
noticeWake(deps, stat3.machine, name);
|
|
7683
7528
|
print(
|
|
7684
7529
|
deps,
|
|
7685
7530
|
options.json,
|
|
7686
|
-
|
|
7531
|
+
stat3,
|
|
7687
7532
|
fields(
|
|
7688
7533
|
["path", "type", "size", "mode", "uid", "gid", "mtime", "etag", "target", "durability"].map(
|
|
7689
|
-
(field) => [field, cellText(
|
|
7534
|
+
(field) => [field, cellText(stat3[field])]
|
|
7690
7535
|
)
|
|
7691
7536
|
)
|
|
7692
7537
|
);
|
|
@@ -7840,12 +7685,167 @@ async function terminalAsk(question) {
|
|
|
7840
7685
|
// src/ssh.ts
|
|
7841
7686
|
import { spawn, spawnSync } from "node:child_process";
|
|
7842
7687
|
import { existsSync as existsSync2 } from "node:fs";
|
|
7843
|
-
import { chmod as
|
|
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";
|
|
7844
7694
|
import { homedir } from "node:os";
|
|
7845
|
-
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
|
|
7846
7846
|
function sshDirectory(env = process.env) {
|
|
7847
|
-
const config = env.XDG_CONFIG_HOME?.trim() ||
|
|
7848
|
-
return
|
|
7847
|
+
const config = env.XDG_CONFIG_HOME?.trim() || join2(homedir2(), ".config");
|
|
7848
|
+
return join2(config, "tonbo", "ssh");
|
|
7849
7849
|
}
|
|
7850
7850
|
var RENEW_BEFORE_MS = 12e4;
|
|
7851
7851
|
var REQUESTED_TTL_SECONDS = 3600;
|
|
@@ -7863,9 +7863,9 @@ function cellOrigin2(host) {
|
|
|
7863
7863
|
return `https://api.${host}`;
|
|
7864
7864
|
}
|
|
7865
7865
|
async function ensureKey(directory) {
|
|
7866
|
-
const key =
|
|
7866
|
+
const key = join2(directory, "id_ed25519");
|
|
7867
7867
|
await mkdir4(directory, { recursive: true, mode: 448 });
|
|
7868
|
-
await
|
|
7868
|
+
await chmod3(directory, 448);
|
|
7869
7869
|
if (!existsSync2(key)) {
|
|
7870
7870
|
const made = spawnSync(
|
|
7871
7871
|
"ssh-keygen",
|
|
@@ -7884,41 +7884,41 @@ async function ensureKey(directory) {
|
|
|
7884
7884
|
}
|
|
7885
7885
|
async function writeAtomic(path5, data) {
|
|
7886
7886
|
const temporary = `${path5}.${process.pid}.tmp`;
|
|
7887
|
-
await
|
|
7888
|
-
await
|
|
7887
|
+
await writeFile3(temporary, data, { mode: 384 });
|
|
7888
|
+
await rename5(temporary, path5);
|
|
7889
7889
|
}
|
|
7890
7890
|
function certificatePath(directory, user) {
|
|
7891
|
-
return
|
|
7891
|
+
return join2(directory, `${user}-cert.pub`);
|
|
7892
7892
|
}
|
|
7893
7893
|
function hostCAPath(directory, host) {
|
|
7894
7894
|
if (!/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
|
7895
7895
|
throw new Error(`${host} is not a Machine's SSH host`);
|
|
7896
7896
|
}
|
|
7897
|
-
return
|
|
7897
|
+
return join2(directory, "host-ca", host);
|
|
7898
7898
|
}
|
|
7899
7899
|
async function trustHostCA(directory, host, lines) {
|
|
7900
|
-
const cells =
|
|
7900
|
+
const cells = join2(directory, "host-ca");
|
|
7901
7901
|
await mkdir4(cells, { recursive: true, mode: 448 });
|
|
7902
7902
|
await writeAtomic(hostCAPath(directory, host), `${lines.join("\n")}
|
|
7903
7903
|
`);
|
|
7904
7904
|
const trusted = /* @__PURE__ */ new Set();
|
|
7905
|
-
for (const name of (await
|
|
7905
|
+
for (const name of (await readdir2(cells)).sort()) {
|
|
7906
7906
|
if (name.endsWith(".tmp")) {
|
|
7907
7907
|
continue;
|
|
7908
7908
|
}
|
|
7909
|
-
for (const line of (await
|
|
7909
|
+
for (const line of (await readFile6(join2(cells, name), "utf8")).split("\n")) {
|
|
7910
7910
|
if (line.trim() !== "") {
|
|
7911
7911
|
trusted.add(line.trim());
|
|
7912
7912
|
}
|
|
7913
7913
|
}
|
|
7914
7914
|
}
|
|
7915
|
-
await writeAtomic(
|
|
7915
|
+
await writeAtomic(join2(directory, "known_hosts"), `${[...trusted].join("\n")}
|
|
7916
7916
|
`);
|
|
7917
7917
|
}
|
|
7918
7918
|
async function heldUntil(directory, user) {
|
|
7919
7919
|
try {
|
|
7920
7920
|
const sidecar = JSON.parse(
|
|
7921
|
-
await
|
|
7921
|
+
await readFile6(`${certificatePath(directory, user)}.json`, "utf8")
|
|
7922
7922
|
);
|
|
7923
7923
|
return sidecar.valid_before ? Date.parse(sidecar.valid_before) : 0;
|
|
7924
7924
|
} catch {
|
|
@@ -7951,12 +7951,12 @@ async function cellRequest(bearer, method, url, body) {
|
|
|
7951
7951
|
async function refreshCertificate(bearer, destination, now = Date.now, directory = sshDirectory()) {
|
|
7952
7952
|
const key = await ensureKey(directory);
|
|
7953
7953
|
const certificate = certificatePath(directory, destination.user);
|
|
7954
|
-
const knownHosts =
|
|
7954
|
+
const knownHosts = join2(directory, "known_hosts");
|
|
7955
7955
|
if (await heldUntil(directory, destination.user) - now() > RENEW_BEFORE_MS && existsSync2(hostCAPath(directory, destination.host)) && existsSync2(knownHosts)) {
|
|
7956
7956
|
return { key, certificate, knownHosts };
|
|
7957
7957
|
}
|
|
7958
7958
|
const origin = cellOrigin2(destination.host);
|
|
7959
|
-
const publicKey = (await
|
|
7959
|
+
const publicKey = (await readFile6(`${key}.pub`, "utf8")).trim();
|
|
7960
7960
|
const issued = await cellRequest(bearer, "POST", `${origin}/v1/ssh/certificates`, {
|
|
7961
7961
|
public_key: publicKey,
|
|
7962
7962
|
ttl_seconds: REQUESTED_TTL_SECONDS
|
|
@@ -7993,6 +7993,7 @@ function sshOptions(paths) {
|
|
|
7993
7993
|
];
|
|
7994
7994
|
}
|
|
7995
7995
|
async function sshCommand(deps, name, command) {
|
|
7996
|
+
await removeLegacySshConfig({ notice: deps.notice });
|
|
7996
7997
|
let destination;
|
|
7997
7998
|
let paths;
|
|
7998
7999
|
try {
|
|
@@ -8007,32 +8008,54 @@ async function sshCommand(deps, name, command) {
|
|
|
8007
8008
|
throw explainMachineError(error);
|
|
8008
8009
|
}
|
|
8009
8010
|
const target = `${destination.user}@${destination.host}`;
|
|
8010
|
-
const status = await new Promise((
|
|
8011
|
+
const status = await new Promise((resolve2, reject) => {
|
|
8011
8012
|
const child = spawn("ssh", [...sshOptions(paths), target, ...command], { stdio: "inherit" });
|
|
8012
8013
|
child.on(
|
|
8013
8014
|
"error",
|
|
8014
8015
|
(error) => reject(new Error(`could not run ssh (${error.message}); install an OpenSSH client`))
|
|
8015
8016
|
);
|
|
8016
|
-
child.on("exit", (code, signal) =>
|
|
8017
|
+
child.on("exit", (code, signal) => resolve2(code ?? (signal ? 255 : 0)));
|
|
8017
8018
|
});
|
|
8018
8019
|
process.exitCode = status;
|
|
8019
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
|
+
}
|
|
8020
8037
|
async function sshRefreshCommand(makeDeps, target) {
|
|
8021
8038
|
const destination = parseDestination(target);
|
|
8022
8039
|
const deps = makeDeps(destination.account);
|
|
8040
|
+
if (destination.host === REGIONLESS_HOST) throw await regionlessHint(deps, destination);
|
|
8041
|
+
await removeLegacySshConfig({ notice: deps.notice });
|
|
8023
8042
|
await refreshCertificate(await deps.auth.apiKey(), destination, deps.now);
|
|
8024
8043
|
}
|
|
8044
|
+
async function sshConfigCommand(deps) {
|
|
8045
|
+
await removeLegacySshConfig({ notice: deps.notice });
|
|
8046
|
+
deps.output(sshConfigText());
|
|
8047
|
+
}
|
|
8025
8048
|
function sshConfigText(directory = sshDirectory()) {
|
|
8026
|
-
const home =
|
|
8027
|
-
const
|
|
8049
|
+
const home = homedir2();
|
|
8050
|
+
const shown2 = directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory;
|
|
8028
8051
|
return [
|
|
8029
8052
|
"# Tonbo Machines: append to ~/.ssh/config (tonbo ssh config >> ~/.ssh/config).",
|
|
8030
8053
|
"# Then: ssh <name>.<account>@<region>.tonbo.sh, scp, sftp and Remote-SSH work as usual.",
|
|
8031
8054
|
`Match host "*.tonbo.sh,tonbo.sh" exec "tonbo ssh refresh %r@%h"`,
|
|
8032
|
-
` IdentityFile ${
|
|
8033
|
-
` CertificateFile ${
|
|
8055
|
+
` IdentityFile ${shown2}/id_ed25519`,
|
|
8056
|
+
` CertificateFile ${shown2}/%r-cert.pub`,
|
|
8034
8057
|
" IdentitiesOnly yes",
|
|
8035
|
-
` UserKnownHostsFile ${
|
|
8058
|
+
` UserKnownHostsFile ${shown2}/known_hosts`,
|
|
8036
8059
|
" StrictHostKeyChecking yes",
|
|
8037
8060
|
" ServerAliveInterval 30"
|
|
8038
8061
|
].join("\n");
|
|
@@ -8048,7 +8071,7 @@ async function sshHostCaCommand(deps, options) {
|
|
|
8048
8071
|
print(deps, options.json, data, data.known_hosts.join("\n"));
|
|
8049
8072
|
}
|
|
8050
8073
|
async function sshCertificateIssueCommand(deps, options) {
|
|
8051
|
-
const publicKey = (await
|
|
8074
|
+
const publicKey = (await readFile6(options.publicKey, "utf8")).trim();
|
|
8052
8075
|
const key = await deps.auth.apiKey();
|
|
8053
8076
|
const data = await (deps.cell ?? new CellApi(fetch)).json(
|
|
8054
8077
|
"issueSshCertificate",
|
|
@@ -8715,12 +8738,17 @@ function createProgram(makeDependencies = createDependencies) {
|
|
|
8715
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));
|
|
8716
8739
|
ssh.command("config").description(
|
|
8717
8740
|
"print the ssh_config block that lets ssh, scp, sftp and Remote-SSH reach every Machine"
|
|
8718
|
-
).action(() => dependencies()
|
|
8741
|
+
).action(async () => sshConfigCommand(dependencies()));
|
|
8719
8742
|
ssh.command("refresh").argument("<destination>", "a Machine's SSH destination, <name>.<account>@<host>").description(
|
|
8720
8743
|
"renew the SSH certificate of <name>.<account>@<host> when it is about to expire (ssh_config's Match exec runs it)"
|
|
8721
8744
|
).action(
|
|
8722
8745
|
async (destination) => sshRefreshCommand((account2) => makeDependencies({ account: account2 }), destination)
|
|
8723
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
|
+
});
|
|
8724
8752
|
operationOptions(
|
|
8725
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"),
|
|
8726
8754
|
"getSshHostCa"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tonbo/cli",
|
|
3
|
-
"version": "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": {
|