cloudhop 0.1.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -48
- package/dist/cli.js +2015 -664
- package/package.json +11 -5
package/dist/cli.js
CHANGED
|
@@ -41,62 +41,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
41
41
|
mod
|
|
42
42
|
));
|
|
43
43
|
|
|
44
|
-
// ../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js
|
|
45
|
-
var require_src = __commonJS({
|
|
46
|
-
"../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js"(exports, module) {
|
|
47
|
-
"use strict";
|
|
48
|
-
var ESC2 = "\x1B";
|
|
49
|
-
var CSI2 = `${ESC2}[`;
|
|
50
|
-
var beep = "\x07";
|
|
51
|
-
var cursor3 = {
|
|
52
|
-
to(x2, y2) {
|
|
53
|
-
if (!y2) return `${CSI2}${x2 + 1}G`;
|
|
54
|
-
return `${CSI2}${y2 + 1};${x2 + 1}H`;
|
|
55
|
-
},
|
|
56
|
-
move(x2, y2) {
|
|
57
|
-
let ret = "";
|
|
58
|
-
if (x2 < 0) ret += `${CSI2}${-x2}D`;
|
|
59
|
-
else if (x2 > 0) ret += `${CSI2}${x2}C`;
|
|
60
|
-
if (y2 < 0) ret += `${CSI2}${-y2}A`;
|
|
61
|
-
else if (y2 > 0) ret += `${CSI2}${y2}B`;
|
|
62
|
-
return ret;
|
|
63
|
-
},
|
|
64
|
-
up: (count = 1) => `${CSI2}${count}A`,
|
|
65
|
-
down: (count = 1) => `${CSI2}${count}B`,
|
|
66
|
-
forward: (count = 1) => `${CSI2}${count}C`,
|
|
67
|
-
backward: (count = 1) => `${CSI2}${count}D`,
|
|
68
|
-
nextLine: (count = 1) => `${CSI2}E`.repeat(count),
|
|
69
|
-
prevLine: (count = 1) => `${CSI2}F`.repeat(count),
|
|
70
|
-
left: `${CSI2}G`,
|
|
71
|
-
hide: `${CSI2}?25l`,
|
|
72
|
-
show: `${CSI2}?25h`,
|
|
73
|
-
save: `${ESC2}7`,
|
|
74
|
-
restore: `${ESC2}8`
|
|
75
|
-
};
|
|
76
|
-
var scroll = {
|
|
77
|
-
up: (count = 1) => `${CSI2}S`.repeat(count),
|
|
78
|
-
down: (count = 1) => `${CSI2}T`.repeat(count)
|
|
79
|
-
};
|
|
80
|
-
var erase3 = {
|
|
81
|
-
screen: `${CSI2}2J`,
|
|
82
|
-
up: (count = 1) => `${CSI2}1J`.repeat(count),
|
|
83
|
-
down: (count = 1) => `${CSI2}J`.repeat(count),
|
|
84
|
-
line: `${CSI2}2K`,
|
|
85
|
-
lineEnd: `${CSI2}K`,
|
|
86
|
-
lineStart: `${CSI2}1K`,
|
|
87
|
-
lines(count) {
|
|
88
|
-
let clear = "";
|
|
89
|
-
for (let i2 = 0; i2 < count; i2++)
|
|
90
|
-
clear += this.line + (i2 < count - 1 ? cursor3.up() : "");
|
|
91
|
-
if (count)
|
|
92
|
-
clear += cursor3.left;
|
|
93
|
-
return clear;
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
module.exports = { cursor: cursor3, scroll, erase: erase3, beep };
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
44
|
// ../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/nodes/identity.js
|
|
101
45
|
var require_identity = __commonJS({
|
|
102
46
|
"../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/nodes/identity.js"(exports) {
|
|
@@ -1064,14 +1008,14 @@ var require_foldFlowLines = __commonJS({
|
|
|
1064
1008
|
var FOLD_FLOW = "flow";
|
|
1065
1009
|
var FOLD_BLOCK = "block";
|
|
1066
1010
|
var FOLD_QUOTED = "quoted";
|
|
1067
|
-
function foldFlowLines(
|
|
1011
|
+
function foldFlowLines(text4, indent, mode = "flow", { indentAtStart, lineWidth = 80, minContentWidth = 20, onFold, onOverflow } = {}) {
|
|
1068
1012
|
if (!lineWidth || lineWidth < 0)
|
|
1069
|
-
return
|
|
1013
|
+
return text4;
|
|
1070
1014
|
if (lineWidth < minContentWidth)
|
|
1071
1015
|
minContentWidth = 0;
|
|
1072
1016
|
const endStep = Math.max(1 + minContentWidth, 1 + lineWidth - indent.length);
|
|
1073
|
-
if (
|
|
1074
|
-
return
|
|
1017
|
+
if (text4.length <= endStep)
|
|
1018
|
+
return text4;
|
|
1075
1019
|
const folds = [];
|
|
1076
1020
|
const escapedFolds = {};
|
|
1077
1021
|
let end = lineWidth - indent.length;
|
|
@@ -1088,14 +1032,14 @@ var require_foldFlowLines = __commonJS({
|
|
|
1088
1032
|
let escStart = -1;
|
|
1089
1033
|
let escEnd = -1;
|
|
1090
1034
|
if (mode === FOLD_BLOCK) {
|
|
1091
|
-
i2 = consumeMoreIndentedLines(
|
|
1035
|
+
i2 = consumeMoreIndentedLines(text4, i2, indent.length);
|
|
1092
1036
|
if (i2 !== -1)
|
|
1093
1037
|
end = i2 + endStep;
|
|
1094
1038
|
}
|
|
1095
|
-
for (let ch; ch =
|
|
1039
|
+
for (let ch; ch = text4[i2 += 1]; ) {
|
|
1096
1040
|
if (mode === FOLD_QUOTED && ch === "\\") {
|
|
1097
1041
|
escStart = i2;
|
|
1098
|
-
switch (
|
|
1042
|
+
switch (text4[i2 + 1]) {
|
|
1099
1043
|
case "x":
|
|
1100
1044
|
i2 += 3;
|
|
1101
1045
|
break;
|
|
@@ -1112,12 +1056,12 @@ var require_foldFlowLines = __commonJS({
|
|
|
1112
1056
|
}
|
|
1113
1057
|
if (ch === "\n") {
|
|
1114
1058
|
if (mode === FOLD_BLOCK)
|
|
1115
|
-
i2 = consumeMoreIndentedLines(
|
|
1059
|
+
i2 = consumeMoreIndentedLines(text4, i2, indent.length);
|
|
1116
1060
|
end = i2 + indent.length + endStep;
|
|
1117
1061
|
split = void 0;
|
|
1118
1062
|
} else {
|
|
1119
1063
|
if (ch === " " && prev && prev !== " " && prev !== "\n" && prev !== " ") {
|
|
1120
|
-
const next =
|
|
1064
|
+
const next = text4[i2 + 1];
|
|
1121
1065
|
if (next && next !== " " && next !== "\n" && next !== " ")
|
|
1122
1066
|
split = i2;
|
|
1123
1067
|
}
|
|
@@ -1129,12 +1073,12 @@ var require_foldFlowLines = __commonJS({
|
|
|
1129
1073
|
} else if (mode === FOLD_QUOTED) {
|
|
1130
1074
|
while (prev === " " || prev === " ") {
|
|
1131
1075
|
prev = ch;
|
|
1132
|
-
ch =
|
|
1076
|
+
ch = text4[i2 += 1];
|
|
1133
1077
|
overflow = true;
|
|
1134
1078
|
}
|
|
1135
1079
|
const j2 = i2 > escEnd + 1 ? i2 - 2 : escStart - 1;
|
|
1136
1080
|
if (escapedFolds[j2])
|
|
1137
|
-
return
|
|
1081
|
+
return text4;
|
|
1138
1082
|
folds.push(j2);
|
|
1139
1083
|
escapedFolds[j2] = true;
|
|
1140
1084
|
end = j2 + endStep;
|
|
@@ -1149,39 +1093,39 @@ var require_foldFlowLines = __commonJS({
|
|
|
1149
1093
|
if (overflow && onOverflow)
|
|
1150
1094
|
onOverflow();
|
|
1151
1095
|
if (folds.length === 0)
|
|
1152
|
-
return
|
|
1096
|
+
return text4;
|
|
1153
1097
|
if (onFold)
|
|
1154
1098
|
onFold();
|
|
1155
|
-
let res =
|
|
1099
|
+
let res = text4.slice(0, folds[0]);
|
|
1156
1100
|
for (let i3 = 0; i3 < folds.length; ++i3) {
|
|
1157
1101
|
const fold = folds[i3];
|
|
1158
|
-
const end2 = folds[i3 + 1] ||
|
|
1102
|
+
const end2 = folds[i3 + 1] || text4.length;
|
|
1159
1103
|
if (fold === 0)
|
|
1160
1104
|
res = `
|
|
1161
|
-
${indent}${
|
|
1105
|
+
${indent}${text4.slice(0, end2)}`;
|
|
1162
1106
|
else {
|
|
1163
1107
|
if (mode === FOLD_QUOTED && escapedFolds[fold])
|
|
1164
|
-
res += `${
|
|
1108
|
+
res += `${text4[fold]}\\`;
|
|
1165
1109
|
res += `
|
|
1166
|
-
${indent}${
|
|
1110
|
+
${indent}${text4.slice(fold + 1, end2)}`;
|
|
1167
1111
|
}
|
|
1168
1112
|
}
|
|
1169
1113
|
return res;
|
|
1170
1114
|
}
|
|
1171
|
-
function consumeMoreIndentedLines(
|
|
1115
|
+
function consumeMoreIndentedLines(text4, i2, indent) {
|
|
1172
1116
|
let end = i2;
|
|
1173
1117
|
let start = i2 + 1;
|
|
1174
|
-
let ch =
|
|
1118
|
+
let ch = text4[start];
|
|
1175
1119
|
while (ch === " " || ch === " ") {
|
|
1176
1120
|
if (i2 < start + indent) {
|
|
1177
|
-
ch =
|
|
1121
|
+
ch = text4[++i2];
|
|
1178
1122
|
} else {
|
|
1179
1123
|
do {
|
|
1180
|
-
ch =
|
|
1124
|
+
ch = text4[++i2];
|
|
1181
1125
|
} while (ch && ch !== "\n");
|
|
1182
1126
|
end = i2;
|
|
1183
1127
|
start = i2 + 1;
|
|
1184
|
-
ch =
|
|
1128
|
+
ch = text4[start];
|
|
1185
1129
|
}
|
|
1186
1130
|
}
|
|
1187
1131
|
return end;
|
|
@@ -7427,92 +7371,319 @@ var require_dist = __commonJS({
|
|
|
7427
7371
|
}
|
|
7428
7372
|
});
|
|
7429
7373
|
|
|
7430
|
-
//
|
|
7431
|
-
var
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7374
|
+
// ../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js
|
|
7375
|
+
var require_src = __commonJS({
|
|
7376
|
+
"../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js"(exports, module) {
|
|
7377
|
+
"use strict";
|
|
7378
|
+
var ESC2 = "\x1B";
|
|
7379
|
+
var CSI2 = `${ESC2}[`;
|
|
7380
|
+
var beep = "\x07";
|
|
7381
|
+
var cursor3 = {
|
|
7382
|
+
to(x2, y2) {
|
|
7383
|
+
if (!y2) return `${CSI2}${x2 + 1}G`;
|
|
7384
|
+
return `${CSI2}${y2 + 1};${x2 + 1}H`;
|
|
7385
|
+
},
|
|
7386
|
+
move(x2, y2) {
|
|
7387
|
+
let ret = "";
|
|
7388
|
+
if (x2 < 0) ret += `${CSI2}${-x2}D`;
|
|
7389
|
+
else if (x2 > 0) ret += `${CSI2}${x2}C`;
|
|
7390
|
+
if (y2 < 0) ret += `${CSI2}${-y2}A`;
|
|
7391
|
+
else if (y2 > 0) ret += `${CSI2}${y2}B`;
|
|
7392
|
+
return ret;
|
|
7393
|
+
},
|
|
7394
|
+
up: (count = 1) => `${CSI2}${count}A`,
|
|
7395
|
+
down: (count = 1) => `${CSI2}${count}B`,
|
|
7396
|
+
forward: (count = 1) => `${CSI2}${count}C`,
|
|
7397
|
+
backward: (count = 1) => `${CSI2}${count}D`,
|
|
7398
|
+
nextLine: (count = 1) => `${CSI2}E`.repeat(count),
|
|
7399
|
+
prevLine: (count = 1) => `${CSI2}F`.repeat(count),
|
|
7400
|
+
left: `${CSI2}G`,
|
|
7401
|
+
hide: `${CSI2}?25l`,
|
|
7402
|
+
show: `${CSI2}?25h`,
|
|
7403
|
+
save: `${ESC2}7`,
|
|
7404
|
+
restore: `${ESC2}8`
|
|
7405
|
+
};
|
|
7406
|
+
var scroll = {
|
|
7407
|
+
up: (count = 1) => `${CSI2}S`.repeat(count),
|
|
7408
|
+
down: (count = 1) => `${CSI2}T`.repeat(count)
|
|
7409
|
+
};
|
|
7410
|
+
var erase3 = {
|
|
7411
|
+
screen: `${CSI2}2J`,
|
|
7412
|
+
up: (count = 1) => `${CSI2}1J`.repeat(count),
|
|
7413
|
+
down: (count = 1) => `${CSI2}J`.repeat(count),
|
|
7414
|
+
line: `${CSI2}2K`,
|
|
7415
|
+
lineEnd: `${CSI2}K`,
|
|
7416
|
+
lineStart: `${CSI2}1K`,
|
|
7417
|
+
lines(count) {
|
|
7418
|
+
let clear = "";
|
|
7419
|
+
for (let i2 = 0; i2 < count; i2++)
|
|
7420
|
+
clear += this.line + (i2 < count - 1 ? cursor3.up() : "");
|
|
7421
|
+
if (count)
|
|
7422
|
+
clear += cursor3.left;
|
|
7423
|
+
return clear;
|
|
7424
|
+
}
|
|
7425
|
+
};
|
|
7426
|
+
module.exports = { cursor: cursor3, scroll, erase: erase3, beep };
|
|
7437
7427
|
}
|
|
7438
|
-
|
|
7439
|
-
hint;
|
|
7440
|
-
};
|
|
7428
|
+
});
|
|
7441
7429
|
|
|
7442
|
-
// ../
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
"exec",
|
|
7456
|
-
"provider",
|
|
7457
|
-
"ui",
|
|
7458
|
-
"help",
|
|
7459
|
-
"version"
|
|
7460
|
-
]);
|
|
7461
|
-
var NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
7462
|
-
function assertValidName(kind, name) {
|
|
7463
|
-
if (!NAME_PATTERN.test(name)) {
|
|
7464
|
-
throw new CloudhopError(
|
|
7465
|
-
"INVALID_NAME",
|
|
7466
|
-
`Invalid ${kind} name "${name}".`,
|
|
7467
|
-
'Use lowercase letters, numbers, "-" or "_" (max 63 characters).'
|
|
7468
|
-
);
|
|
7469
|
-
}
|
|
7470
|
-
if (kind === "alias" && RESERVED_NAMES.has(name)) {
|
|
7471
|
-
throw new CloudhopError(
|
|
7472
|
-
"INVALID_NAME",
|
|
7473
|
-
`"${name}" is a cloudhop command.`,
|
|
7474
|
-
"Pick another alias name."
|
|
7475
|
-
);
|
|
7430
|
+
// ../adapters-node/src/node-browser-opener.ts
|
|
7431
|
+
import { spawn } from "child_process";
|
|
7432
|
+
var NodeBrowserOpener = class {
|
|
7433
|
+
async open(url) {
|
|
7434
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
7435
|
+
await new Promise((resolve) => {
|
|
7436
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
7437
|
+
child.on("error", () => resolve());
|
|
7438
|
+
child.on("spawn", () => {
|
|
7439
|
+
child.unref();
|
|
7440
|
+
resolve();
|
|
7441
|
+
});
|
|
7442
|
+
});
|
|
7476
7443
|
}
|
|
7477
|
-
}
|
|
7478
|
-
function suggestAliasName(...parts) {
|
|
7479
|
-
const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
7480
|
-
return slug || "alias";
|
|
7481
|
-
}
|
|
7444
|
+
};
|
|
7482
7445
|
|
|
7483
|
-
// ../
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
if (
|
|
7446
|
+
// ../adapters-node/src/node-process-runner.ts
|
|
7447
|
+
import { spawn as spawn2 } from "child_process";
|
|
7448
|
+
var STDERR_TAIL_BYTES = 8 * 1024;
|
|
7449
|
+
function buildEnv(overrides) {
|
|
7450
|
+
const env = { ...process.env };
|
|
7451
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
7452
|
+
if (value === void 0) delete env[key];
|
|
7453
|
+
else env[key] = value;
|
|
7490
7454
|
}
|
|
7491
|
-
return
|
|
7455
|
+
return env;
|
|
7492
7456
|
}
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7457
|
+
var useShell = process.platform === "win32";
|
|
7458
|
+
var NodeProcessRunner = class {
|
|
7459
|
+
run(command, args, options = {}) {
|
|
7460
|
+
return new Promise((resolve, reject) => {
|
|
7461
|
+
const child = spawn2(command, args, {
|
|
7462
|
+
env: buildEnv(options.env),
|
|
7463
|
+
stdio: ["inherit", "inherit", options.captureStderr ? "pipe" : "inherit"],
|
|
7464
|
+
shell: useShell
|
|
7465
|
+
});
|
|
7466
|
+
let tail = "";
|
|
7467
|
+
child.stderr?.on("data", (chunk) => {
|
|
7468
|
+
process.stderr.write(chunk);
|
|
7469
|
+
tail = (tail + chunk.toString()).slice(-STDERR_TAIL_BYTES);
|
|
7470
|
+
});
|
|
7471
|
+
const ignore = () => {
|
|
7472
|
+
};
|
|
7473
|
+
process.on("SIGINT", ignore);
|
|
7474
|
+
process.on("SIGTERM", ignore);
|
|
7475
|
+
const cleanup = () => {
|
|
7476
|
+
process.off("SIGINT", ignore);
|
|
7477
|
+
process.off("SIGTERM", ignore);
|
|
7478
|
+
};
|
|
7479
|
+
child.on("error", (error) => {
|
|
7480
|
+
cleanup();
|
|
7481
|
+
reject(error);
|
|
7482
|
+
});
|
|
7483
|
+
child.on("close", (code, signal) => {
|
|
7484
|
+
cleanup();
|
|
7485
|
+
resolve({ exitCode: code ?? (signal ? 128 + 2 : 1), stderrTail: tail });
|
|
7486
|
+
});
|
|
7487
|
+
});
|
|
7503
7488
|
}
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7489
|
+
capture(command, args, options = {}) {
|
|
7490
|
+
return new Promise((resolve) => {
|
|
7491
|
+
const child = spawn2(command, args, { env: buildEnv(options.env), shell: useShell });
|
|
7492
|
+
child.stdin.end();
|
|
7493
|
+
let stdout2 = "";
|
|
7494
|
+
let stderr = "";
|
|
7495
|
+
child.stdout.on("data", (c3) => {
|
|
7496
|
+
stdout2 += c3.toString();
|
|
7497
|
+
});
|
|
7498
|
+
child.stderr.on("data", (c3) => {
|
|
7499
|
+
stderr += c3.toString();
|
|
7500
|
+
});
|
|
7501
|
+
child.on("error", (error) => resolve({ exitCode: 127, stdout: stdout2, stderr: error.message }));
|
|
7502
|
+
child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr }));
|
|
7503
|
+
});
|
|
7514
7504
|
}
|
|
7515
|
-
async
|
|
7505
|
+
async probe(command) {
|
|
7506
|
+
const result = await this.capture(command, ["--version"]);
|
|
7507
|
+
if (result.exitCode !== 0) return { installed: false };
|
|
7508
|
+
const output = `${result.stdout} ${result.stderr}`;
|
|
7509
|
+
const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
|
|
7510
|
+
return { installed: true, ...version ? { version } : {} };
|
|
7511
|
+
}
|
|
7512
|
+
};
|
|
7513
|
+
|
|
7514
|
+
// ../adapters-node/src/system-tool-installer.ts
|
|
7515
|
+
var TOOLS = {
|
|
7516
|
+
aws: {
|
|
7517
|
+
manualUrl: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
|
|
7518
|
+
recipes: {
|
|
7519
|
+
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "awscli"] }],
|
|
7520
|
+
win32: [
|
|
7521
|
+
{ via: "winget", command: "winget", args: ["install", "--exact", "--id", "Amazon.AWSCLI"] }
|
|
7522
|
+
]
|
|
7523
|
+
}
|
|
7524
|
+
},
|
|
7525
|
+
az: {
|
|
7526
|
+
manualUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli",
|
|
7527
|
+
recipes: {
|
|
7528
|
+
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "azure-cli"] }],
|
|
7529
|
+
win32: [
|
|
7530
|
+
{
|
|
7531
|
+
via: "winget",
|
|
7532
|
+
command: "winget",
|
|
7533
|
+
args: ["install", "--exact", "--id", "Microsoft.AzureCLI"]
|
|
7534
|
+
}
|
|
7535
|
+
]
|
|
7536
|
+
}
|
|
7537
|
+
},
|
|
7538
|
+
gcloud: {
|
|
7539
|
+
manualUrl: "https://cloud.google.com/sdk/docs/install",
|
|
7540
|
+
recipes: {
|
|
7541
|
+
darwin: [
|
|
7542
|
+
{ via: "Homebrew", command: "brew", args: ["install", "--cask", "google-cloud-sdk"] }
|
|
7543
|
+
],
|
|
7544
|
+
win32: [
|
|
7545
|
+
{
|
|
7546
|
+
via: "winget",
|
|
7547
|
+
command: "winget",
|
|
7548
|
+
args: ["install", "--exact", "--id", "Google.CloudSDK"]
|
|
7549
|
+
}
|
|
7550
|
+
]
|
|
7551
|
+
}
|
|
7552
|
+
}
|
|
7553
|
+
};
|
|
7554
|
+
var SystemToolInstaller = class {
|
|
7555
|
+
constructor(runner, platform = process.platform) {
|
|
7556
|
+
this.runner = runner;
|
|
7557
|
+
this.platform = platform;
|
|
7558
|
+
}
|
|
7559
|
+
runner;
|
|
7560
|
+
platform;
|
|
7561
|
+
available = /* @__PURE__ */ new Map();
|
|
7562
|
+
recipeFor(command) {
|
|
7563
|
+
const tool = TOOLS[command];
|
|
7564
|
+
if (!tool) return void 0;
|
|
7565
|
+
const recipe = tool.recipes[this.platform]?.[0];
|
|
7566
|
+
return recipe ? { ...recipe, manualUrl: tool.manualUrl } : { via: "official installer", command: "", args: [], manualUrl: tool.manualUrl };
|
|
7567
|
+
}
|
|
7568
|
+
async install(recipe) {
|
|
7569
|
+
if (!recipe.command) return false;
|
|
7570
|
+
if (!await this.hasCommand(recipe.command)) return false;
|
|
7571
|
+
const { exitCode } = await this.runner.run(recipe.command, recipe.args);
|
|
7572
|
+
return exitCode === 0;
|
|
7573
|
+
}
|
|
7574
|
+
async hasCommand(command) {
|
|
7575
|
+
if (!this.available.has(command)) {
|
|
7576
|
+
this.available.set(command, (await this.runner.probe(command)).installed);
|
|
7577
|
+
}
|
|
7578
|
+
return this.available.get(command) ?? false;
|
|
7579
|
+
}
|
|
7580
|
+
};
|
|
7581
|
+
|
|
7582
|
+
// ../adapters-node/src/yaml-config-store.ts
|
|
7583
|
+
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
7584
|
+
import { homedir } from "os";
|
|
7585
|
+
import { join } from "path";
|
|
7586
|
+
|
|
7587
|
+
// ../core/src/domain/errors.ts
|
|
7588
|
+
var CloudhopError = class extends Error {
|
|
7589
|
+
constructor(code, message, hint) {
|
|
7590
|
+
super(message);
|
|
7591
|
+
this.code = code;
|
|
7592
|
+
this.hint = hint;
|
|
7593
|
+
this.name = "CloudhopError";
|
|
7594
|
+
}
|
|
7595
|
+
code;
|
|
7596
|
+
hint;
|
|
7597
|
+
};
|
|
7598
|
+
|
|
7599
|
+
// ../core/src/domain/naming.ts
|
|
7600
|
+
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
7601
|
+
"add",
|
|
7602
|
+
"agent",
|
|
7603
|
+
"alias",
|
|
7604
|
+
"login",
|
|
7605
|
+
"mcp",
|
|
7606
|
+
"region",
|
|
7607
|
+
"setup",
|
|
7608
|
+
"status",
|
|
7609
|
+
"sync",
|
|
7610
|
+
"ls",
|
|
7611
|
+
"list",
|
|
7612
|
+
"rm",
|
|
7613
|
+
"remove",
|
|
7614
|
+
"doctor",
|
|
7615
|
+
"exec",
|
|
7616
|
+
"provider",
|
|
7617
|
+
"ui",
|
|
7618
|
+
"help",
|
|
7619
|
+
"version"
|
|
7620
|
+
]);
|
|
7621
|
+
var NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
7622
|
+
function assertValidName(kind, name) {
|
|
7623
|
+
if (!NAME_PATTERN.test(name)) {
|
|
7624
|
+
throw new CloudhopError(
|
|
7625
|
+
"INVALID_NAME",
|
|
7626
|
+
`Invalid ${kind} name "${name}".`,
|
|
7627
|
+
'Use lowercase letters, numbers, "-" or "_" (max 63 characters).'
|
|
7628
|
+
);
|
|
7629
|
+
}
|
|
7630
|
+
if (kind === "alias" && RESERVED_NAMES.has(name)) {
|
|
7631
|
+
throw new CloudhopError(
|
|
7632
|
+
"INVALID_NAME",
|
|
7633
|
+
`"${name}" is a cloudhop command.`,
|
|
7634
|
+
"Pick another alias name."
|
|
7635
|
+
);
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7638
|
+
function suggestAliasName(...parts) {
|
|
7639
|
+
const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
7640
|
+
return slug || "alias";
|
|
7641
|
+
}
|
|
7642
|
+
|
|
7643
|
+
// ../core/src/domain/version.ts
|
|
7644
|
+
function compareVersions(a2, b3) {
|
|
7645
|
+
const pa = a2.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
|
|
7646
|
+
const pb = b3.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
|
|
7647
|
+
for (let i2 = 0; i2 < Math.max(pa.length, pb.length); i2++) {
|
|
7648
|
+
const diff = (pa[i2] ?? 0) - (pb[i2] ?? 0);
|
|
7649
|
+
if (diff !== 0) return diff;
|
|
7650
|
+
}
|
|
7651
|
+
return 0;
|
|
7652
|
+
}
|
|
7653
|
+
|
|
7654
|
+
// ../core/src/application/cloudhop-app.ts
|
|
7655
|
+
var loginRequired = (sourceId) => new CloudhopError(
|
|
7656
|
+
"LOGIN_REQUIRED",
|
|
7657
|
+
`The session of "${sourceId}" expired and needs a browser sign-in.`,
|
|
7658
|
+
`Run \`cloudhop login ${sourceId}\` (it opens the browser), then try again.`
|
|
7659
|
+
);
|
|
7660
|
+
var NEXT_BY_STATE = {
|
|
7661
|
+
valid: "ready",
|
|
7662
|
+
refreshable: "silent-renewal",
|
|
7663
|
+
expired: "browser-sign-in",
|
|
7664
|
+
unknown: "unknown"
|
|
7665
|
+
};
|
|
7666
|
+
var CloudhopApp = class {
|
|
7667
|
+
constructor(deps) {
|
|
7668
|
+
this.deps = deps;
|
|
7669
|
+
}
|
|
7670
|
+
deps;
|
|
7671
|
+
// ── Providers ────────────────────────────────────────────────────────────
|
|
7672
|
+
availableProviders() {
|
|
7673
|
+
return this.deps.providers;
|
|
7674
|
+
}
|
|
7675
|
+
provider(id) {
|
|
7676
|
+
const provider = this.deps.providers.find((p3) => p3.id === id);
|
|
7677
|
+
if (!provider) {
|
|
7678
|
+
throw new CloudhopError(
|
|
7679
|
+
"PROVIDER_NOT_AVAILABLE",
|
|
7680
|
+
`The ${id} provider is not available yet.`,
|
|
7681
|
+
"Follow https://github.com/dayepesb/cloudhop for upcoming providers."
|
|
7682
|
+
);
|
|
7683
|
+
}
|
|
7684
|
+
return provider;
|
|
7685
|
+
}
|
|
7686
|
+
async config() {
|
|
7516
7687
|
return this.deps.config.load();
|
|
7517
7688
|
}
|
|
7518
7689
|
async setEnabledProviders(ids) {
|
|
@@ -7591,13 +7762,19 @@ var CloudhopApp = class {
|
|
|
7591
7762
|
return this.syncSource(sourceId);
|
|
7592
7763
|
}
|
|
7593
7764
|
/** Makes sure the source has a usable session, renewing it silently or via login. */
|
|
7594
|
-
|
|
7765
|
+
/**
|
|
7766
|
+
* Makes the source's session usable: renews it silently when possible, and
|
|
7767
|
+
* otherwise signs in through the browser, unless `interactive` is false, in
|
|
7768
|
+
* which case it fails with LOGIN_REQUIRED instead of opening a browser.
|
|
7769
|
+
*/
|
|
7770
|
+
async ensureSession(sourceId, { interactive: interactive2 = true } = {}) {
|
|
7595
7771
|
const config = await this.deps.config.load();
|
|
7596
7772
|
const source = this.findSource(config, sourceId);
|
|
7597
7773
|
const provider = this.provider(source.provider);
|
|
7598
7774
|
const state = await provider.sessionState(source);
|
|
7599
7775
|
if (state === "valid" || state === "unknown") return "none";
|
|
7600
7776
|
if (state === "refreshable" && await provider.refreshSession(source)) return "refreshed";
|
|
7777
|
+
if (!interactive2) throw loginRequired(sourceId);
|
|
7601
7778
|
await this.login(sourceId);
|
|
7602
7779
|
return "logged-in";
|
|
7603
7780
|
}
|
|
@@ -7661,6 +7838,56 @@ var CloudhopApp = class {
|
|
|
7661
7838
|
const config = await this.deps.config.load();
|
|
7662
7839
|
return Promise.all(config.aliases.map((a2) => this.resolve(config, a2)));
|
|
7663
7840
|
}
|
|
7841
|
+
/** Sources with their session state and every alias, checked without signing in. */
|
|
7842
|
+
async overview() {
|
|
7843
|
+
const config = await this.deps.config.load();
|
|
7844
|
+
const sources = await Promise.all(
|
|
7845
|
+
config.sources.map(async (source) => {
|
|
7846
|
+
const session = await this.provider(source.provider).sessionState(source);
|
|
7847
|
+
return {
|
|
7848
|
+
id: source.id,
|
|
7849
|
+
provider: source.provider,
|
|
7850
|
+
kind: source.kind,
|
|
7851
|
+
settings: source.settings,
|
|
7852
|
+
session,
|
|
7853
|
+
next: NEXT_BY_STATE[session]
|
|
7854
|
+
};
|
|
7855
|
+
})
|
|
7856
|
+
);
|
|
7857
|
+
const bySource = new Map(sources.map((s) => [s.id, s]));
|
|
7858
|
+
const aliases = await Promise.all(
|
|
7859
|
+
config.aliases.map(async (alias) => {
|
|
7860
|
+
const resolved = await this.resolve(config, alias);
|
|
7861
|
+
const source = bySource.get(alias.source);
|
|
7862
|
+
return {
|
|
7863
|
+
name: alias.name,
|
|
7864
|
+
source: alias.source,
|
|
7865
|
+
provider: resolved.source.provider,
|
|
7866
|
+
target: alias.target,
|
|
7867
|
+
label: resolved.target?.label,
|
|
7868
|
+
available: Boolean(resolved.target),
|
|
7869
|
+
region: resolved.region,
|
|
7870
|
+
attributes: resolved.target?.attributes ?? {},
|
|
7871
|
+
session: source.session,
|
|
7872
|
+
next: source.next
|
|
7873
|
+
};
|
|
7874
|
+
})
|
|
7875
|
+
);
|
|
7876
|
+
return { sources, aliases };
|
|
7877
|
+
}
|
|
7878
|
+
/** The status of one alias: which account it points to and whether it can run right away. */
|
|
7879
|
+
async aliasStatus(name) {
|
|
7880
|
+
const { aliases } = await this.overview();
|
|
7881
|
+
const status = aliases.find((a2) => a2.name === name);
|
|
7882
|
+
if (!status) {
|
|
7883
|
+
throw new CloudhopError(
|
|
7884
|
+
"ALIAS_NOT_FOUND",
|
|
7885
|
+
`Alias "${name}" not found.`,
|
|
7886
|
+
"See your aliases with `cloudhop ls`."
|
|
7887
|
+
);
|
|
7888
|
+
}
|
|
7889
|
+
return status;
|
|
7890
|
+
}
|
|
7664
7891
|
/** Changes an alias' region; matching the source's region clears the override. */
|
|
7665
7892
|
async setAliasRegion(name, region) {
|
|
7666
7893
|
const config = await this.deps.config.load();
|
|
@@ -7685,6 +7912,28 @@ var CloudhopApp = class {
|
|
|
7685
7912
|
return this.runWithSession(resolved, provider, "cli", provider.cli.command, args);
|
|
7686
7913
|
}
|
|
7687
7914
|
/** Runs any command with the alias' temporary credentials in its environment. */
|
|
7915
|
+
/**
|
|
7916
|
+
* Runs the alias' cloud CLI without a terminal and returns what it printed.
|
|
7917
|
+
* Used where stdin/stdout belong to someone else (the MCP server). It never
|
|
7918
|
+
* opens a browser: an expired session fails with LOGIN_REQUIRED.
|
|
7919
|
+
*/
|
|
7920
|
+
async captureAsAlias(name, args, overrides = {}) {
|
|
7921
|
+
const resolved = await this.usableAlias(name, overrides);
|
|
7922
|
+
const provider = this.provider(resolved.source.provider);
|
|
7923
|
+
await this.requireCli(provider);
|
|
7924
|
+
await this.ensureSession(resolved.source.id, { interactive: false });
|
|
7925
|
+
const env = await provider.processEnv(resolved, "cli");
|
|
7926
|
+
const result = await this.deps.runner.capture(provider.cli.command, args, { env });
|
|
7927
|
+
if (result.exitCode !== 0 && provider.isSessionError(`${result.stdout}
|
|
7928
|
+
${result.stderr}`)) {
|
|
7929
|
+
throw loginRequired(resolved.source.id);
|
|
7930
|
+
}
|
|
7931
|
+
return result;
|
|
7932
|
+
}
|
|
7933
|
+
/** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
|
|
7934
|
+
identityArgs(name) {
|
|
7935
|
+
return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
|
|
7936
|
+
}
|
|
7688
7937
|
async execAsAlias(name, command, args, overrides = {}) {
|
|
7689
7938
|
const resolved = await this.usableAlias(name, overrides);
|
|
7690
7939
|
const provider = this.provider(resolved.source.provider);
|
|
@@ -7819,62 +8068,397 @@ var CloudhopApp = class {
|
|
|
7819
8068
|
var PROVIDER_IDS = ["aws", "azure", "gcp"];
|
|
7820
8069
|
var emptyConfig = () => ({ providers: [], sources: [], aliases: [] });
|
|
7821
8070
|
|
|
7822
|
-
// src/
|
|
7823
|
-
var
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
const flags = {};
|
|
7828
|
-
for (let i2 = 0; i2 < argv.length; i2++) {
|
|
7829
|
-
const arg = argv[i2];
|
|
7830
|
-
if (!arg.startsWith("--")) {
|
|
7831
|
-
positional.push(arg);
|
|
7832
|
-
continue;
|
|
7833
|
-
}
|
|
7834
|
-
const [rawName, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
7835
|
-
const name = camel(rawName);
|
|
7836
|
-
if (inline !== void 0) flags[name] = inline;
|
|
7837
|
-
else if (BOOLEAN_FLAGS.has(name)) flags[name] = "true";
|
|
7838
|
-
else flags[name] = argv[++i2] ?? "";
|
|
7839
|
-
}
|
|
7840
|
-
return { positional, flags };
|
|
8071
|
+
// ../adapters-node/src/yaml-config-store.ts
|
|
8072
|
+
var import_yaml = __toESM(require_dist(), 1);
|
|
8073
|
+
function defaultConfigDir(env = process.env) {
|
|
8074
|
+
if (env.CLOUDHOP_HOME) return env.CLOUDHOP_HOME;
|
|
8075
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cloudhop");
|
|
7841
8076
|
}
|
|
7842
|
-
var
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
8077
|
+
var HEADER = "# cloudhop configuration. Contains no secrets.\n# Docs: https://github.com/dayepesb/cloudhop\n";
|
|
8078
|
+
var YamlConfigStore = class {
|
|
8079
|
+
constructor(dir = defaultConfigDir()) {
|
|
8080
|
+
this.dir = dir;
|
|
8081
|
+
}
|
|
8082
|
+
dir;
|
|
8083
|
+
get configPath() {
|
|
8084
|
+
return join(this.dir, "config.yaml");
|
|
8085
|
+
}
|
|
8086
|
+
get targetsPath() {
|
|
8087
|
+
return join(this.dir, "targets.json");
|
|
8088
|
+
}
|
|
8089
|
+
async load() {
|
|
8090
|
+
const raw = await this.read(this.configPath);
|
|
8091
|
+
if (!raw) return emptyConfig();
|
|
8092
|
+
const file = (0, import_yaml.parse)(raw) ?? {};
|
|
8093
|
+
return {
|
|
8094
|
+
providers: (file.providers ?? []).filter(
|
|
8095
|
+
(p3) => PROVIDER_IDS.includes(p3)
|
|
8096
|
+
),
|
|
8097
|
+
sources: Object.entries(file.sources ?? {}).map(([id, s]) => ({
|
|
8098
|
+
id,
|
|
8099
|
+
...s,
|
|
8100
|
+
settings: s.settings ?? {}
|
|
8101
|
+
})),
|
|
8102
|
+
aliases: Object.entries(file.aliases ?? {}).map(([name, a2]) => ({ name, ...a2 }))
|
|
8103
|
+
};
|
|
8104
|
+
}
|
|
8105
|
+
async save(config) {
|
|
8106
|
+
const file = {
|
|
8107
|
+
providers: config.providers,
|
|
8108
|
+
sources: Object.fromEntries(config.sources.map(({ id, ...rest }) => [id, rest])),
|
|
8109
|
+
aliases: Object.fromEntries(config.aliases.map(({ name, ...rest }) => [name, rest]))
|
|
8110
|
+
};
|
|
8111
|
+
await this.write(this.configPath, HEADER + (0, import_yaml.stringify)(file));
|
|
8112
|
+
}
|
|
8113
|
+
async loadTargets(sourceId) {
|
|
8114
|
+
return (await this.allTargets())[sourceId] ?? [];
|
|
8115
|
+
}
|
|
8116
|
+
async saveTargets(sourceId, targets) {
|
|
8117
|
+
const all = await this.allTargets();
|
|
8118
|
+
all[sourceId] = targets;
|
|
8119
|
+
await this.write(this.targetsPath, JSON.stringify(all, null, 2));
|
|
8120
|
+
}
|
|
8121
|
+
async removeTargets(sourceId) {
|
|
8122
|
+
const all = await this.allTargets();
|
|
8123
|
+
delete all[sourceId];
|
|
8124
|
+
await this.write(this.targetsPath, JSON.stringify(all, null, 2));
|
|
8125
|
+
}
|
|
8126
|
+
async reset() {
|
|
8127
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
8128
|
+
}
|
|
8129
|
+
async allTargets() {
|
|
8130
|
+
const raw = await this.read(this.targetsPath);
|
|
8131
|
+
return raw ? JSON.parse(raw) : {};
|
|
8132
|
+
}
|
|
8133
|
+
async read(path2) {
|
|
8134
|
+
try {
|
|
8135
|
+
return await readFile(path2, "utf8");
|
|
8136
|
+
} catch (error) {
|
|
8137
|
+
if (error.code === "ENOENT") return void 0;
|
|
8138
|
+
throw error;
|
|
8139
|
+
}
|
|
8140
|
+
}
|
|
8141
|
+
/** Writes through a temp file so a crash never leaves a half-written config. */
|
|
8142
|
+
async write(path2, content) {
|
|
8143
|
+
await mkdir(this.dir, { recursive: true, mode: 448 });
|
|
8144
|
+
const tmp = `${path2}.${process.pid}.tmp`;
|
|
8145
|
+
await writeFile(tmp, content, { mode: 384 });
|
|
8146
|
+
await rename(tmp, path2);
|
|
8147
|
+
}
|
|
8148
|
+
};
|
|
8149
|
+
|
|
8150
|
+
// ../mcp/src/server.ts
|
|
8151
|
+
import { createInterface } from "readline";
|
|
8152
|
+
|
|
8153
|
+
// ../mcp/src/tools.ts
|
|
8154
|
+
var MAX_OUTPUT = 1e5;
|
|
8155
|
+
var cap = (text4) => text4.length > MAX_OUTPUT ? `${text4.slice(0, MAX_OUTPUT)}
|
|
8156
|
+
\u2026 [truncated: ${text4.length - MAX_OUTPUT} more characters. Narrow the command with --query or filters.]` : text4;
|
|
8157
|
+
var WINDOWS_SHELL_CHARS = /[&|<>^%!"\r\n]/;
|
|
8158
|
+
var text = (args, key) => {
|
|
8159
|
+
const value = args[key];
|
|
8160
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
8161
|
+
throw new CloudhopError("INVALID_NAME", `"${key}" is required.`);
|
|
8162
|
+
}
|
|
8163
|
+
return value.trim();
|
|
8164
|
+
};
|
|
8165
|
+
var stringArray = (args, key) => {
|
|
8166
|
+
const value = args[key];
|
|
8167
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
8168
|
+
throw new CloudhopError("INVALID_NAME", `"${key}" must be an array of strings.`);
|
|
8169
|
+
}
|
|
8170
|
+
return value;
|
|
8171
|
+
};
|
|
8172
|
+
var aliasInput = {
|
|
8173
|
+
alias: {
|
|
8174
|
+
type: "string",
|
|
8175
|
+
description: "The cloudhop alias to use, as listed by list_aliases. Ask the user which one."
|
|
8176
|
+
}
|
|
8177
|
+
};
|
|
8178
|
+
function createTools(app) {
|
|
8179
|
+
return [
|
|
8180
|
+
{
|
|
8181
|
+
name: "list_aliases",
|
|
8182
|
+
title: "List cloudhop aliases",
|
|
8183
|
+
description: "Lists every cloudhop source (SSO portal or personal account) with its session state, and every alias: name, account/role label, region and whether a sign-in is needed. Call this first, then ask the user which alias to work with. Never contains secrets.",
|
|
8184
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
8185
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
8186
|
+
run: () => app.overview()
|
|
8187
|
+
},
|
|
8188
|
+
{
|
|
8189
|
+
name: "alias_status",
|
|
8190
|
+
title: "Check an alias",
|
|
8191
|
+
description: 'Shows which account, role and region an alias points to and whether it can run now. `next` is "ready", "silent-renewal" (runs without asking), "browser-sign-in" (call login first, or ask the user to run `cloudhop login <source>`) or "unknown".',
|
|
8192
|
+
inputSchema: {
|
|
8193
|
+
type: "object",
|
|
8194
|
+
properties: aliasInput,
|
|
8195
|
+
required: ["alias"],
|
|
8196
|
+
additionalProperties: false
|
|
8197
|
+
},
|
|
8198
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
8199
|
+
run: (args) => app.aliasStatus(text(args, "alias"))
|
|
8200
|
+
},
|
|
8201
|
+
{
|
|
8202
|
+
name: "whoami",
|
|
8203
|
+
title: "Who am I in this alias",
|
|
8204
|
+
description: "Asks the cloud who the alias really is (for AWS: `aws sts get-caller-identity`). Use it to confirm the account before doing anything important.",
|
|
8205
|
+
inputSchema: {
|
|
8206
|
+
type: "object",
|
|
8207
|
+
properties: aliasInput,
|
|
8208
|
+
required: ["alias"],
|
|
8209
|
+
additionalProperties: false
|
|
8210
|
+
},
|
|
8211
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
8212
|
+
run: async (args) => {
|
|
8213
|
+
const alias = text(args, "alias");
|
|
8214
|
+
const result = await app.captureAsAlias(alias, await app.identityArgs(alias));
|
|
8215
|
+
return {
|
|
8216
|
+
alias,
|
|
8217
|
+
exitCode: result.exitCode,
|
|
8218
|
+
output: cap(result.stdout),
|
|
8219
|
+
errors: cap(result.stderr)
|
|
8220
|
+
};
|
|
8221
|
+
}
|
|
8222
|
+
},
|
|
8223
|
+
{
|
|
8224
|
+
name: "login",
|
|
8225
|
+
title: "Sign in to a source",
|
|
8226
|
+
description: "Opens the user's browser to sign in to a source (an SSO portal or a personal account) and waits up to 5 minutes for them to approve, then refreshes its accounts and roles. Tell the user to look at their browser before calling it. Only call it when alias_status says `browser-sign-in` or a tool returned LOGIN_REQUIRED.",
|
|
8227
|
+
inputSchema: {
|
|
8228
|
+
type: "object",
|
|
8229
|
+
properties: {
|
|
8230
|
+
source: { type: "string", description: "The source id, as shown in list_aliases." }
|
|
8231
|
+
},
|
|
8232
|
+
required: ["source"],
|
|
8233
|
+
additionalProperties: false
|
|
8234
|
+
},
|
|
8235
|
+
annotations: {
|
|
8236
|
+
readOnlyHint: false,
|
|
8237
|
+
destructiveHint: false,
|
|
8238
|
+
idempotentHint: true,
|
|
8239
|
+
openWorldHint: true
|
|
8240
|
+
},
|
|
8241
|
+
run: async (args) => {
|
|
8242
|
+
const source = text(args, "source");
|
|
8243
|
+
const report = await app.login(source);
|
|
8244
|
+
return { source, signedIn: true, targets: report.targets.length };
|
|
8245
|
+
}
|
|
8246
|
+
},
|
|
8247
|
+
{
|
|
8248
|
+
name: "run_cloud_cli",
|
|
8249
|
+
title: "Run the cloud CLI as an alias",
|
|
8250
|
+
description: 'Runs the alias\' cloud CLI (`aws` for AWS aliases) with the alias\' account, role and region, like `hop <alias> <args>`. `args` are the CLI arguments without the program name and without --profile, e.g. ["s3", "ls"]. Returns stdout, stderr and the exit code. It can create, change or delete cloud resources: before any command that is not read-only, show the user the alias, account, region and exact command and get their explicit confirmation.',
|
|
8251
|
+
inputSchema: {
|
|
8252
|
+
type: "object",
|
|
8253
|
+
properties: {
|
|
8254
|
+
...aliasInput,
|
|
8255
|
+
args: {
|
|
8256
|
+
type: "array",
|
|
8257
|
+
items: { type: "string" },
|
|
8258
|
+
description: 'CLI arguments, e.g. ["ec2", "describe-instances", "--output", "json"].'
|
|
8259
|
+
},
|
|
8260
|
+
region: {
|
|
8261
|
+
type: "string",
|
|
8262
|
+
description: "Optional region for this call only; defaults to the alias' region."
|
|
8263
|
+
}
|
|
8264
|
+
},
|
|
8265
|
+
required: ["alias", "args"],
|
|
8266
|
+
additionalProperties: false
|
|
8267
|
+
},
|
|
8268
|
+
annotations: {
|
|
8269
|
+
readOnlyHint: false,
|
|
8270
|
+
destructiveHint: true,
|
|
8271
|
+
idempotentHint: false,
|
|
8272
|
+
openWorldHint: true
|
|
8273
|
+
},
|
|
8274
|
+
run: async (args) => {
|
|
8275
|
+
const alias = text(args, "alias");
|
|
8276
|
+
const cliArgs = stringArray(args, "args");
|
|
8277
|
+
if (cliArgs.includes("--profile")) {
|
|
8278
|
+
throw new CloudhopError(
|
|
8279
|
+
"INVALID_NAME",
|
|
8280
|
+
"Do not pass --profile: the alias already chooses the account and role."
|
|
8281
|
+
);
|
|
8282
|
+
}
|
|
8283
|
+
if (process.platform === "win32" && cliArgs.some((a2) => WINDOWS_SHELL_CHARS.test(a2))) {
|
|
8284
|
+
throw new CloudhopError(
|
|
8285
|
+
"INVALID_NAME",
|
|
8286
|
+
'On Windows, arguments cannot contain & | < > ^ % ! " or line breaks.',
|
|
8287
|
+
"Ask the user to run this command in their terminal with hop instead."
|
|
8288
|
+
);
|
|
8289
|
+
}
|
|
8290
|
+
const region = typeof args.region === "string" && args.region.trim() ? args.region.trim() : void 0;
|
|
8291
|
+
const result = await app.captureAsAlias(alias, cliArgs, region ? { region } : {});
|
|
8292
|
+
return {
|
|
8293
|
+
alias,
|
|
8294
|
+
exitCode: result.exitCode,
|
|
8295
|
+
stdout: cap(result.stdout),
|
|
8296
|
+
stderr: cap(result.stderr)
|
|
8297
|
+
};
|
|
8298
|
+
}
|
|
8299
|
+
}
|
|
8300
|
+
];
|
|
8301
|
+
}
|
|
8302
|
+
|
|
8303
|
+
// ../mcp/src/server.ts
|
|
8304
|
+
var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
|
|
8305
|
+
Before the first cloud operation in a conversation: call list_aliases, show the user the aliases (name, account/role, region) and ask which one to use. Never guess the alias.
|
|
8306
|
+
Use run_cloud_cli for cloud CLI commands, never --profile. Read-only commands may run once the alias is chosen; before anything that creates, changes or deletes resources, show the alias, account, region and exact command, and wait for the user's explicit OK. Be extra careful with production aliases.
|
|
8307
|
+
If a tool returns LOGIN_REQUIRED or alias_status says browser-sign-in: tell the user their browser will open, then call login (it waits up to 5 minutes for them to approve). Never print or ask for credentials.`;
|
|
8308
|
+
var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
8309
|
+
function serveMcp({
|
|
8310
|
+
app,
|
|
8311
|
+
version,
|
|
8312
|
+
input = process.stdin,
|
|
8313
|
+
output = process.stdout
|
|
8314
|
+
}) {
|
|
8315
|
+
const tools = createTools(app);
|
|
8316
|
+
const byName = new Map(tools.map((t2) => [t2.name, t2]));
|
|
8317
|
+
const send2 = (message) => output.write(`${JSON.stringify(message)}
|
|
8318
|
+
`);
|
|
8319
|
+
const reply = (id, result) => send2({ jsonrpc: "2.0", id, result });
|
|
8320
|
+
const fail = (id, code, message) => send2({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
8321
|
+
const handle = async (request) => {
|
|
8322
|
+
const { id, method, params = {} } = request;
|
|
8323
|
+
const isNotification = id === void 0;
|
|
8324
|
+
switch (method) {
|
|
8325
|
+
case "initialize": {
|
|
8326
|
+
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
|
|
8327
|
+
reply(id, {
|
|
8328
|
+
protocolVersion: SUPPORTED_VERSIONS.includes(asked) ? asked : SUPPORTED_VERSIONS[0],
|
|
8329
|
+
capabilities: { tools: { listChanged: false } },
|
|
8330
|
+
serverInfo: { name: "cloudhop", title: "cloudhop", version },
|
|
8331
|
+
instructions: MCP_INSTRUCTIONS
|
|
8332
|
+
});
|
|
8333
|
+
return;
|
|
8334
|
+
}
|
|
8335
|
+
case "ping":
|
|
8336
|
+
reply(id, {});
|
|
8337
|
+
return;
|
|
8338
|
+
case "tools/list":
|
|
8339
|
+
reply(id, {
|
|
8340
|
+
tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
|
|
8341
|
+
name,
|
|
8342
|
+
title,
|
|
8343
|
+
description,
|
|
8344
|
+
inputSchema,
|
|
8345
|
+
annotations
|
|
8346
|
+
}))
|
|
8347
|
+
});
|
|
8348
|
+
return;
|
|
8349
|
+
case "tools/call": {
|
|
8350
|
+
const tool = byName.get(String(params.name));
|
|
8351
|
+
if (!tool) {
|
|
8352
|
+
fail(id, -32602, `Unknown tool: ${String(params.name)}`);
|
|
8353
|
+
return;
|
|
8354
|
+
}
|
|
8355
|
+
const args = params.arguments ?? {};
|
|
8356
|
+
try {
|
|
8357
|
+
const result = await tool.run(args);
|
|
8358
|
+
reply(id, {
|
|
8359
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
8360
|
+
structuredContent: result
|
|
8361
|
+
});
|
|
8362
|
+
} catch (error) {
|
|
8363
|
+
const known = error instanceof CloudhopError;
|
|
8364
|
+
const body = {
|
|
8365
|
+
error: known ? error.code : "UNEXPECTED",
|
|
8366
|
+
message: error.message,
|
|
8367
|
+
...known && error.hint ? { hint: error.hint } : {}
|
|
8368
|
+
};
|
|
8369
|
+
reply(id, {
|
|
8370
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
8371
|
+
structuredContent: body,
|
|
8372
|
+
isError: true
|
|
8373
|
+
});
|
|
8374
|
+
}
|
|
8375
|
+
return;
|
|
8376
|
+
}
|
|
8377
|
+
default:
|
|
8378
|
+
if (!isNotification) fail(id, -32601, `Method not found: ${method}`);
|
|
8379
|
+
}
|
|
8380
|
+
};
|
|
8381
|
+
return new Promise((resolve) => {
|
|
8382
|
+
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
|
8383
|
+
const pending = /* @__PURE__ */ new Set();
|
|
8384
|
+
lines.on("line", (line) => {
|
|
8385
|
+
if (!line.trim()) return;
|
|
8386
|
+
let request;
|
|
8387
|
+
try {
|
|
8388
|
+
request = JSON.parse(line);
|
|
8389
|
+
} catch {
|
|
8390
|
+
fail(null, -32700, "Parse error");
|
|
8391
|
+
return;
|
|
8392
|
+
}
|
|
8393
|
+
if (typeof request.method !== "string") return;
|
|
8394
|
+
const task = handle(request).catch((error) => {
|
|
8395
|
+
if (request.id !== void 0) fail(request.id, -32603, error.message);
|
|
8396
|
+
});
|
|
8397
|
+
pending.add(task);
|
|
8398
|
+
task.finally(() => pending.delete(task));
|
|
8399
|
+
});
|
|
8400
|
+
lines.on("close", () => {
|
|
8401
|
+
Promise.allSettled([...pending]).then(() => resolve());
|
|
8402
|
+
});
|
|
8403
|
+
});
|
|
8404
|
+
}
|
|
8405
|
+
|
|
8406
|
+
// src/flags.ts
|
|
8407
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["yes", "all", "list", "noOpen", "json", "noMcp", "force"]);
|
|
8408
|
+
var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
|
|
8409
|
+
function parseArgs(argv) {
|
|
8410
|
+
const positional = [];
|
|
8411
|
+
const flags = {};
|
|
8412
|
+
for (let i2 = 0; i2 < argv.length; i2++) {
|
|
8413
|
+
const arg = argv[i2];
|
|
8414
|
+
if (!arg.startsWith("--")) {
|
|
8415
|
+
positional.push(arg);
|
|
8416
|
+
continue;
|
|
8417
|
+
}
|
|
8418
|
+
const [rawName, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
8419
|
+
const name = camel(rawName);
|
|
8420
|
+
if (inline !== void 0) flags[name] = inline;
|
|
8421
|
+
else if (BOOLEAN_FLAGS.has(name)) flags[name] = "true";
|
|
8422
|
+
else flags[name] = argv[++i2] ?? "";
|
|
8423
|
+
}
|
|
8424
|
+
return { positional, flags };
|
|
8425
|
+
}
|
|
8426
|
+
var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
8427
|
+
|
|
8428
|
+
// ../../node_modules/.pnpm/@clack+prompts@1.8.1/node_modules/@clack/prompts/dist/index.mjs
|
|
8429
|
+
var dist_exports = {};
|
|
8430
|
+
__export(dist_exports, {
|
|
8431
|
+
CANCEL_SYMBOL: () => CANCEL_SYMBOL,
|
|
8432
|
+
MULTISELECT_INSTRUCTIONS: () => MULTISELECT_INSTRUCTIONS,
|
|
8433
|
+
SELECT_INSTRUCTIONS: () => SELECT_INSTRUCTIONS,
|
|
8434
|
+
S_BAR: () => S_BAR,
|
|
8435
|
+
S_BAR_END: () => S_BAR_END,
|
|
8436
|
+
S_BAR_END_RIGHT: () => S_BAR_END_RIGHT,
|
|
8437
|
+
S_BAR_H: () => S_BAR_H,
|
|
8438
|
+
S_BAR_START: () => S_BAR_START,
|
|
8439
|
+
S_BAR_START_RIGHT: () => S_BAR_START_RIGHT,
|
|
8440
|
+
S_CHECKBOX_ACTIVE: () => S_CHECKBOX_ACTIVE,
|
|
8441
|
+
S_CHECKBOX_INACTIVE: () => S_CHECKBOX_INACTIVE,
|
|
8442
|
+
S_CHECKBOX_SELECTED: () => S_CHECKBOX_SELECTED,
|
|
8443
|
+
S_CONNECT_LEFT: () => S_CONNECT_LEFT,
|
|
8444
|
+
S_CORNER_BOTTOM_LEFT: () => S_CORNER_BOTTOM_LEFT,
|
|
8445
|
+
S_CORNER_BOTTOM_RIGHT: () => S_CORNER_BOTTOM_RIGHT,
|
|
8446
|
+
S_CORNER_TOP_LEFT: () => S_CORNER_TOP_LEFT,
|
|
8447
|
+
S_CORNER_TOP_RIGHT: () => S_CORNER_TOP_RIGHT,
|
|
8448
|
+
S_ERROR: () => S_ERROR,
|
|
8449
|
+
S_INFO: () => S_INFO,
|
|
8450
|
+
S_PASSWORD_MASK: () => S_PASSWORD_MASK,
|
|
8451
|
+
S_RADIO_ACTIVE: () => S_RADIO_ACTIVE,
|
|
8452
|
+
S_RADIO_INACTIVE: () => S_RADIO_INACTIVE,
|
|
8453
|
+
S_STEP_ACTIVE: () => S_STEP_ACTIVE,
|
|
8454
|
+
S_STEP_CANCEL: () => S_STEP_CANCEL,
|
|
8455
|
+
S_STEP_ERROR: () => S_STEP_ERROR,
|
|
8456
|
+
S_STEP_SUBMIT: () => S_STEP_SUBMIT,
|
|
8457
|
+
S_SUCCESS: () => S_SUCCESS,
|
|
8458
|
+
S_WARN: () => S_WARN,
|
|
8459
|
+
autocomplete: () => autocomplete,
|
|
8460
|
+
autocompleteMultiselect: () => autocompleteMultiselect,
|
|
8461
|
+
box: () => box,
|
|
7878
8462
|
cancel: () => cancel,
|
|
7879
8463
|
confirm: () => confirm,
|
|
7880
8464
|
date: () => date,
|
|
@@ -7903,7 +8487,7 @@ __export(dist_exports, {
|
|
|
7903
8487
|
symbolBar: () => symbolBar,
|
|
7904
8488
|
taskLog: () => taskLog,
|
|
7905
8489
|
tasks: () => tasks,
|
|
7906
|
-
text: () =>
|
|
8490
|
+
text: () => text2,
|
|
7907
8491
|
unicode: () => unicode,
|
|
7908
8492
|
unicodeOr: () => unicodeOr,
|
|
7909
8493
|
updateSettings: () => updateSettings
|
|
@@ -9240,7 +9824,7 @@ import { styleText as styleText2, stripVTControlCharacters } from "util";
|
|
|
9240
9824
|
import process$1 from "process";
|
|
9241
9825
|
var import_sisteransi2 = __toESM(require_src(), 1);
|
|
9242
9826
|
import { existsSync, lstatSync, readdirSync } from "fs";
|
|
9243
|
-
import { dirname, join } from "path";
|
|
9827
|
+
import { dirname, join as join2 } from "path";
|
|
9244
9828
|
function isUnicodeSupported() {
|
|
9245
9829
|
if (process$1.platform !== "win32") {
|
|
9246
9830
|
return process$1.env.TERM !== "linux";
|
|
@@ -10181,7 +10765,7 @@ var path = (e) => {
|
|
|
10181
10765
|
existsSync(t2) ? lstatSync(t2).isDirectory() && (!e.directory || t2.endsWith("/")) ? i2 = t2 : i2 = dirname(t2) : i2 = dirname(t2);
|
|
10182
10766
|
const c3 = t2.length > 1 && t2.endsWith("/") ? t2.slice(0, -1) : t2;
|
|
10183
10767
|
return readdirSync(i2).map((r2) => {
|
|
10184
|
-
const n3 =
|
|
10768
|
+
const n3 = join2(i2, r2), m3 = lstatSync(n3);
|
|
10185
10769
|
return {
|
|
10186
10770
|
name: r2,
|
|
10187
10771
|
path: n3,
|
|
@@ -10608,7 +11192,7 @@ ${e.value}` : e.value;
|
|
|
10608
11192
|
}
|
|
10609
11193
|
};
|
|
10610
11194
|
};
|
|
10611
|
-
var
|
|
11195
|
+
var text2 = (t2) => new n2({
|
|
10612
11196
|
validate: t2.validate,
|
|
10613
11197
|
placeholder: t2.placeholder,
|
|
10614
11198
|
defaultValue: t2.defaultValue,
|
|
@@ -10663,10 +11247,19 @@ function answer(value) {
|
|
|
10663
11247
|
if (isCancel(value)) throw new PromptCancelled();
|
|
10664
11248
|
return value;
|
|
10665
11249
|
}
|
|
10666
|
-
var
|
|
10667
|
-
var
|
|
10668
|
-
var
|
|
10669
|
-
var
|
|
11250
|
+
var colorful = (stream2) => Boolean(stream2.isTTY) && !process.env.NO_COLOR;
|
|
11251
|
+
var truecolor = /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
|
|
11252
|
+
var dim = (text4) => colorful(process.stderr) ? `\x1B[2m${text4}\x1B[22m` : text4;
|
|
11253
|
+
var bold = (text4) => colorful(process.stdout) ? `\x1B[1m${text4}\x1B[22m` : text4;
|
|
11254
|
+
var STYLES = {
|
|
11255
|
+
dim: ["2", "22"],
|
|
11256
|
+
green: ["32", "39"],
|
|
11257
|
+
yellow: ["33", "39"],
|
|
11258
|
+
red: ["31", "39"],
|
|
11259
|
+
sky: [truecolor ? "38;2;59;184;245" : "38;5;75", "39"],
|
|
11260
|
+
coral: [truecolor ? "38;2;255;122;89" : "38;5;209", "39"]
|
|
11261
|
+
};
|
|
11262
|
+
var paint = (style, text4) => colorful(process.stdout) ? `\x1B[${STYLES[style][0]}m${text4}\x1B[${STYLES[style][1]}m` : text4;
|
|
10670
11263
|
var BANNER = [
|
|
10671
11264
|
" _ _ _",
|
|
10672
11265
|
" ___ | | ___ _ _ __| || |__ ___ _ __",
|
|
@@ -10674,9 +11267,12 @@ var BANNER = [
|
|
|
10674
11267
|
"| (__ | || (_) || |_| || (_| || | | || (_) || |_) |",
|
|
10675
11268
|
" \\___||_| \\___/ \\__,_| \\__,_||_| |_| \\___/ | .__/",
|
|
10676
11269
|
" |_|"
|
|
10677
|
-
]
|
|
11270
|
+
];
|
|
11271
|
+
var HOP_COLUMN = 30;
|
|
10678
11272
|
function printBanner() {
|
|
10679
|
-
|
|
11273
|
+
for (const line of BANNER) {
|
|
11274
|
+
console.log(paint("sky", line.slice(0, HOP_COLUMN)) + paint("coral", line.slice(HOP_COLUMN)));
|
|
11275
|
+
}
|
|
10680
11276
|
}
|
|
10681
11277
|
function notify(message) {
|
|
10682
11278
|
process.stderr.write(`${dim("cloudhop \u203A")} ${message}
|
|
@@ -10910,39 +11506,550 @@ async function aliasCommand(app, { positional, flags }) {
|
|
|
10910
11506
|
dist_exports.outro(`Created ${aliases.length} alias(es).`);
|
|
10911
11507
|
}
|
|
10912
11508
|
|
|
10913
|
-
// src/commands/
|
|
10914
|
-
|
|
10915
|
-
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
|
|
10920
|
-
|
|
10921
|
-
|
|
10922
|
-
|
|
10923
|
-
|
|
10924
|
-
|
|
10925
|
-
|
|
10926
|
-
|
|
11509
|
+
// src/commands/agent.ts
|
|
11510
|
+
import { homedir as homedir2 } from "os";
|
|
11511
|
+
import { isAbsolute, relative } from "path";
|
|
11512
|
+
|
|
11513
|
+
// ../assistants/src/installer.ts
|
|
11514
|
+
import { createHash } from "crypto";
|
|
11515
|
+
import { existsSync as existsSync2 } from "fs";
|
|
11516
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, rmdir, writeFile as writeFile2 } from "fs/promises";
|
|
11517
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
11518
|
+
|
|
11519
|
+
// ../assistants/src/assets/skill.md
|
|
11520
|
+
var skill_default = "---\nname: cloudhop\ndescription: Run AWS CLI commands and other cloud tools as a specific account, role and region through cloudhop aliases (the `hop` and `cloudhop` commands). Use whenever the user asks to do anything in their AWS accounts, inspect or change cloud resources, run aws/terraform/cdk against an account, list their cloud accounts or aliases, or mentions hop or cloudhop.\n---\n\n# Working with cloud accounts through cloudhop\n\nThe user manages their cloud access with **cloudhop**. Each **alias** (for\nexample `acme-dev`) points to one account, one role and one region. cloudhop\nsigns the user in through their browser only when a session has expired. There\nare no access keys: never ask for, create, print or store credentials.\n\n## 1. Pick the alias with the user, every time\n\nBefore the first cloud operation in a conversation:\n\n1. List the aliases. Use the `list_aliases` tool if the cloudhop MCP server is\n connected; otherwise run `cloudhop ls --json`.\n2. Show the user the options (alias, account / role, region) and **ask which\n one to use**. Don't guess, even if one name looks right, and don't pick\n one just because there is only one: confirm it.\n3. Use that alias for the rest of the conversation. Ask again if the user\n moves to another environment, account or task.\n\n## 2. Check the session before running anything\n\nRun `cloudhop status <alias> --json` (or the `alias_status` tool) and read\n`next`:\n\n| `next` | Meaning | What to do |\n| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ready` | Session valid | Go ahead |\n| `silent-renewal` | Expired but renewable without the user | Go ahead; cloudhop renews it |\n| `browser-sign-in` | The user must sign in | Tell the user their browser will open and they need to approve, then run `cloudhop login <source>` (or the `login` tool) and wait up to 5 minutes |\n| `unknown` | Managed by the cloud CLI (personal accounts) | Go ahead; if it fails, run `cloudhop login <source>` |\n\nDon't start several cloud commands in parallel while a sign-in is pending.\n\n## 3. Run commands through the alias\n\n- **AWS CLI**: `hop <alias> <aws arguments>`, e.g. `hop acme-dev s3 ls`.\n It is exactly `aws <arguments>` with the alias' account, role and region.\n Never add `--profile`. Use `--region <r>` only to override the region once.\n With the MCP server, use `run_cloud_cli` with `args` (no program name).\n- **Who am I**: `hop <alias>` with no arguments (or the `whoami` tool).\n- **Other tools** (Terraform, CDK, SDK scripts):\n `cloudhop exec <alias> -- <command>`, e.g.\n `cloudhop exec acme-dev -- terraform plan`.\n- Prefer `--output json` and `--query` to keep output small.\n- Don't export `AWS_PROFILE`, `AWS_ACCESS_KEY_ID` or similar variables, don't\n run commands that print credentials (`env`, `printenv`, `export-credentials`)\n and never edit `~/.aws/config`, `~/.aws/credentials` or cloudhop's own files.\n\n## 4. Confirm before changing anything\n\n- Read-only commands (`list`, `describe`, `get`, `s3 ls`, `logs tail`) can run\n once the user has chosen the alias.\n- Before any command that **creates, changes, deletes or costs money**, show\n the alias, account, region and the exact command, and wait for the user's\n explicit \"yes\". One confirmation covers one command, not a series.\n- Treat aliases or accounts that look like production (`prod`, `prd`,\n `production`, `live`) with extra care: confirm every change, and suggest a\n dry run (`--dryrun`, `terraform plan`) first.\n- Don't change cloudhop itself (`cloudhop add`, `rm`, `region`, `login` for\n another source) unless the user asked for it.\n\n## 5. When something goes wrong\n\n| You see | Do this |\n| --------------------------------------------------- | ------------------------------------------------------------------------------ |\n| \"Opening your browser to log in\" | Tell the user to approve in the browser; the command continues by itself |\n| The command was cut off while waiting for a sign-in | Run `cloudhop login <source>` with a timeout of at least 5 minutes, then retry |\n| `LOGIN_REQUIRED` | Same as `browser-sign-in` above |\n| No browser on this machine (remote, container) | Run `cloudhop login <source> --device-code` and show the user the URL and code |\n| `Alias \"x\" not found` | Run `cloudhop ls` and ask the user again |\n| AWS CLI missing or too old | Suggest `cloudhop doctor`; don't install anything without asking |\n| `AccessDenied` | The role lacks permission: tell the user; don't try other aliases on your own |\n\nMore: https://dayepesb.github.io/cloudhop/guides/hop-like-aws/\n";
|
|
11521
|
+
|
|
11522
|
+
// ../assistants/src/targets.ts
|
|
11523
|
+
import { join as join3 } from "path";
|
|
11524
|
+
var ASSISTANT_IDS = [
|
|
11525
|
+
"claude",
|
|
11526
|
+
"kiro",
|
|
11527
|
+
"copilot",
|
|
11528
|
+
"cursor",
|
|
11529
|
+
"codex"
|
|
11530
|
+
];
|
|
11531
|
+
var SKILL_NAME = "cloudhop";
|
|
11532
|
+
var MCP_COMMAND = "cloudhop";
|
|
11533
|
+
var MCP_ARGS = ["mcp"];
|
|
11534
|
+
var ASSISTANTS = {
|
|
11535
|
+
claude: {
|
|
11536
|
+
id: "claude",
|
|
11537
|
+
name: "Claude Code",
|
|
11538
|
+
markers: ({ home }) => [join3(home, ".claude")],
|
|
11539
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".claude", "skills", SKILL_NAME),
|
|
11540
|
+
mcp: (scope, { project }) => scope === "project" ? { kind: "json", file: join3(project, ".mcp.json"), key: "mcpServers", withType: false } : {
|
|
11541
|
+
kind: "cli",
|
|
11542
|
+
command: "claude",
|
|
11543
|
+
add: ["mcp", "add", "--scope", "user", "cloudhop", "--", MCP_COMMAND, ...MCP_ARGS],
|
|
11544
|
+
remove: ["mcp", "remove", "--scope", "user", "cloudhop"],
|
|
11545
|
+
manual: "claude mcp add --scope user cloudhop -- cloudhop mcp"
|
|
11546
|
+
}
|
|
11547
|
+
},
|
|
11548
|
+
kiro: {
|
|
11549
|
+
id: "kiro",
|
|
11550
|
+
name: "Kiro",
|
|
11551
|
+
markers: ({ home }) => [join3(home, ".kiro")],
|
|
11552
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".kiro", "skills", SKILL_NAME),
|
|
11553
|
+
mcp: (scope, { home, project }) => ({
|
|
11554
|
+
kind: "json",
|
|
11555
|
+
file: join3(scope === "user" ? home : project, ".kiro", "settings", "mcp.json"),
|
|
11556
|
+
key: "mcpServers",
|
|
11557
|
+
withType: false
|
|
11558
|
+
})
|
|
11559
|
+
},
|
|
11560
|
+
copilot: {
|
|
11561
|
+
id: "copilot",
|
|
11562
|
+
name: "GitHub Copilot (VS Code)",
|
|
11563
|
+
markers: ({ home }) => [join3(home, ".copilot"), join3(home, ".vscode")],
|
|
11564
|
+
skillDir: (scope, { home, project }) => scope === "user" ? join3(home, ".copilot", "skills", SKILL_NAME) : join3(project, ".github", "skills", SKILL_NAME),
|
|
11565
|
+
mcp: (scope, { project }) => scope === "project" ? {
|
|
11566
|
+
kind: "json",
|
|
11567
|
+
file: join3(project, ".vscode", "mcp.json"),
|
|
11568
|
+
key: "servers",
|
|
11569
|
+
withType: true
|
|
11570
|
+
} : {
|
|
11571
|
+
kind: "cli",
|
|
11572
|
+
command: "code",
|
|
11573
|
+
add: [
|
|
11574
|
+
"--add-mcp",
|
|
11575
|
+
JSON.stringify({
|
|
11576
|
+
name: "cloudhop",
|
|
11577
|
+
type: "stdio",
|
|
11578
|
+
command: MCP_COMMAND,
|
|
11579
|
+
args: MCP_ARGS
|
|
11580
|
+
})
|
|
11581
|
+
],
|
|
11582
|
+
remove: void 0,
|
|
11583
|
+
manual: 'VS Code \u2192 "MCP: Add Server" \u2192 stdio \u2192 command `cloudhop mcp`'
|
|
11584
|
+
}
|
|
11585
|
+
},
|
|
11586
|
+
cursor: {
|
|
11587
|
+
id: "cursor",
|
|
11588
|
+
name: "Cursor",
|
|
11589
|
+
markers: ({ home }) => [join3(home, ".cursor")],
|
|
11590
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".cursor", "skills", SKILL_NAME),
|
|
11591
|
+
mcp: (scope, { home, project }) => ({
|
|
11592
|
+
kind: "json",
|
|
11593
|
+
file: join3(scope === "user" ? home : project, ".cursor", "mcp.json"),
|
|
11594
|
+
key: "mcpServers",
|
|
11595
|
+
withType: true
|
|
11596
|
+
})
|
|
11597
|
+
},
|
|
11598
|
+
codex: {
|
|
11599
|
+
id: "codex",
|
|
11600
|
+
name: "Codex",
|
|
11601
|
+
markers: ({ home }) => [join3(home, ".codex"), join3(home, ".agents")],
|
|
11602
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".agents", "skills", SKILL_NAME),
|
|
11603
|
+
mcp: (scope, { home, project }) => ({
|
|
11604
|
+
kind: "toml",
|
|
11605
|
+
file: join3(scope === "user" ? home : project, ".codex", "config.toml")
|
|
11606
|
+
})
|
|
10927
11607
|
}
|
|
11608
|
+
};
|
|
11609
|
+
|
|
11610
|
+
// ../assistants/src/installer.ts
|
|
11611
|
+
var sha = (text4) => createHash("sha256").update(text4).digest("hex");
|
|
11612
|
+
async function writeAtomic(file, content) {
|
|
11613
|
+
await mkdir2(dirname2(file), { recursive: true });
|
|
11614
|
+
const tmp = `${file}.cloudhop-${process.pid}.tmp`;
|
|
11615
|
+
await writeFile2(tmp, content);
|
|
11616
|
+
await rename2(tmp, file);
|
|
10928
11617
|
}
|
|
10929
|
-
async function
|
|
10930
|
-
|
|
10931
|
-
|
|
10932
|
-
|
|
10933
|
-
|
|
11618
|
+
async function removeFileAndEmptyDir(file) {
|
|
11619
|
+
await rm2(file, { force: true });
|
|
11620
|
+
await rmdir(dirname2(file)).catch(() => {
|
|
11621
|
+
});
|
|
11622
|
+
}
|
|
11623
|
+
var readText = (file) => readFile2(file, "utf8").catch(() => void 0);
|
|
11624
|
+
var AssistantsInstaller = class {
|
|
11625
|
+
constructor(options) {
|
|
11626
|
+
this.options = options;
|
|
10934
11627
|
}
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
11628
|
+
options;
|
|
11629
|
+
/** The skill as written to disk: the bundled content plus a footer naming the version. */
|
|
11630
|
+
get skillContent() {
|
|
11631
|
+
return `${skill_default.replace(/\r\n/g, "\n").trimEnd()}
|
|
11632
|
+
|
|
11633
|
+
<!-- Installed by cloudhop ${this.options.cloudhopVersion}. Refresh with \`cloudhop agent update\`; edits here are kept. -->
|
|
11634
|
+
`;
|
|
10938
11635
|
}
|
|
10939
|
-
|
|
10940
|
-
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
|
|
11636
|
+
/** Assistants that look installed on this machine. */
|
|
11637
|
+
detect() {
|
|
11638
|
+
const { places } = this.options;
|
|
11639
|
+
return ASSISTANT_IDS.filter((id) => ASSISTANTS[id].markers(places).some((p3) => existsSync2(p3)));
|
|
11640
|
+
}
|
|
11641
|
+
async status(scope) {
|
|
11642
|
+
const detected = new Set(this.detect());
|
|
11643
|
+
const manifest = await this.readManifest();
|
|
11644
|
+
return Promise.all(
|
|
11645
|
+
ASSISTANT_IDS.map(async (id) => {
|
|
11646
|
+
const assistant = ASSISTANTS[id];
|
|
11647
|
+
const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
|
|
11648
|
+
const entry = manifest.find((e) => e.skillFile === skillPath);
|
|
11649
|
+
return {
|
|
11650
|
+
assistant: id,
|
|
11651
|
+
name: assistant.name,
|
|
11652
|
+
scope,
|
|
11653
|
+
detected: detected.has(id),
|
|
11654
|
+
skill: await this.skillState(skillPath, entry),
|
|
11655
|
+
skillPath,
|
|
11656
|
+
mcp: await this.mcpState(assistant.mcp(scope, this.options.places))
|
|
11657
|
+
};
|
|
11658
|
+
})
|
|
11659
|
+
);
|
|
11660
|
+
}
|
|
11661
|
+
async install(ids, scope, { mcp = true } = {}) {
|
|
11662
|
+
const results = [];
|
|
11663
|
+
const manifest = await this.readManifest();
|
|
11664
|
+
for (const id of ids) {
|
|
11665
|
+
const assistant = ASSISTANTS[id];
|
|
11666
|
+
const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
|
|
11667
|
+
const content = this.skillContent;
|
|
11668
|
+
await writeAtomic(skillPath, content);
|
|
11669
|
+
const kept = manifest.filter((e) => e.skillFile !== skillPath);
|
|
11670
|
+
kept.push({
|
|
11671
|
+
assistant: id,
|
|
11672
|
+
scope,
|
|
11673
|
+
skillFile: skillPath,
|
|
11674
|
+
hash: sha(content),
|
|
11675
|
+
cloudhopVersion: this.options.cloudhopVersion
|
|
11676
|
+
});
|
|
11677
|
+
manifest.splice(0, manifest.length, ...kept);
|
|
11678
|
+
const target = assistant.mcp(scope, this.options.places);
|
|
11679
|
+
const outcome = mcp ? await this.addMcp(target) : { mcp: "skipped" };
|
|
11680
|
+
results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
|
|
11681
|
+
}
|
|
11682
|
+
await this.writeManifest(manifest);
|
|
11683
|
+
return results;
|
|
11684
|
+
}
|
|
11685
|
+
async remove(ids, scope) {
|
|
11686
|
+
const results = [];
|
|
11687
|
+
let manifest = await this.readManifest();
|
|
11688
|
+
for (const id of ids) {
|
|
11689
|
+
const assistant = ASSISTANTS[id];
|
|
11690
|
+
const dir = assistant.skillDir(scope, this.options.places);
|
|
11691
|
+
const skillPath = join4(dir, "SKILL.md");
|
|
11692
|
+
await rm2(dir, { recursive: true, force: true });
|
|
11693
|
+
await rmdir(dirname2(dir)).then(() => rmdir(dirname2(dirname2(dir)))).catch(() => {
|
|
11694
|
+
});
|
|
11695
|
+
manifest = manifest.filter((e) => e.skillFile !== skillPath);
|
|
11696
|
+
const outcome = await this.removeMcp(assistant.mcp(scope, this.options.places));
|
|
11697
|
+
results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
|
|
11698
|
+
}
|
|
11699
|
+
await this.writeManifest(manifest);
|
|
11700
|
+
return results;
|
|
11701
|
+
}
|
|
11702
|
+
/**
|
|
11703
|
+
* Refreshes skills written by an older cloudhop. Copies the user edited are
|
|
11704
|
+
* left alone unless `force`. Cheap when there is nothing to do: one small read.
|
|
11705
|
+
*/
|
|
11706
|
+
async update({ force = false } = {}) {
|
|
11707
|
+
const manifest = await this.readManifest();
|
|
11708
|
+
const content = this.skillContent;
|
|
11709
|
+
const target = sha(content);
|
|
11710
|
+
const updated = [];
|
|
11711
|
+
const edited = [];
|
|
11712
|
+
let changed = false;
|
|
11713
|
+
for (const entry of manifest) {
|
|
11714
|
+
if (entry.hash === target) continue;
|
|
11715
|
+
const onDisk = await readText(entry.skillFile);
|
|
11716
|
+
if (onDisk === void 0) continue;
|
|
11717
|
+
if (sha(onDisk) !== entry.hash && !force) {
|
|
11718
|
+
edited.push(entry.skillFile);
|
|
11719
|
+
continue;
|
|
11720
|
+
}
|
|
11721
|
+
await writeAtomic(entry.skillFile, content);
|
|
11722
|
+
entry.hash = target;
|
|
11723
|
+
entry.cloudhopVersion = this.options.cloudhopVersion;
|
|
11724
|
+
updated.push(entry.skillFile);
|
|
11725
|
+
changed = true;
|
|
11726
|
+
}
|
|
11727
|
+
if (changed) await this.writeManifest(manifest);
|
|
11728
|
+
return { updated, edited };
|
|
11729
|
+
}
|
|
11730
|
+
// ───────────── skill state ─────────────
|
|
11731
|
+
async skillState(path2, entry) {
|
|
11732
|
+
const onDisk = await readText(path2);
|
|
11733
|
+
if (onDisk === void 0) return "missing";
|
|
11734
|
+
if (onDisk === this.skillContent) return "current";
|
|
11735
|
+
if (entry && sha(onDisk) !== entry.hash) return "modified";
|
|
11736
|
+
return "outdated";
|
|
11737
|
+
}
|
|
11738
|
+
// ───────────── MCP registration ─────────────
|
|
11739
|
+
async mcpState(target) {
|
|
11740
|
+
if (target.kind === "cli") return "unknown";
|
|
11741
|
+
const text4 = await readText(target.file);
|
|
11742
|
+
if (text4 === void 0) return "missing";
|
|
11743
|
+
if (target.kind === "toml")
|
|
11744
|
+
return /^\[mcp_servers\.cloudhop\]/m.test(text4) ? "registered" : "missing";
|
|
11745
|
+
try {
|
|
11746
|
+
const servers = JSON.parse(text4)[target.key];
|
|
11747
|
+
return servers?.cloudhop ? "registered" : "missing";
|
|
11748
|
+
} catch {
|
|
11749
|
+
return "unknown";
|
|
11750
|
+
}
|
|
11751
|
+
}
|
|
11752
|
+
async addMcp(target) {
|
|
11753
|
+
switch (target.kind) {
|
|
11754
|
+
case "json": {
|
|
11755
|
+
const config = await this.readJsonConfig(target.file);
|
|
11756
|
+
config[target.key] ??= {};
|
|
11757
|
+
const servers = config[target.key];
|
|
11758
|
+
const entry = {
|
|
11759
|
+
...target.withType ? { type: "stdio" } : {},
|
|
11760
|
+
command: MCP_COMMAND,
|
|
11761
|
+
args: MCP_ARGS
|
|
11762
|
+
};
|
|
11763
|
+
if (JSON.stringify(servers.cloudhop) === JSON.stringify(entry)) return { mcp: "already" };
|
|
11764
|
+
servers.cloudhop = entry;
|
|
11765
|
+
await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
|
|
11766
|
+
`);
|
|
11767
|
+
return { mcp: "added" };
|
|
11768
|
+
}
|
|
11769
|
+
case "toml": {
|
|
11770
|
+
const text4 = await readText(target.file) ?? "";
|
|
11771
|
+
if (/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
|
|
11772
|
+
const block2 = `${TOML_MARK}
|
|
11773
|
+
[mcp_servers.cloudhop]
|
|
11774
|
+
command = "${MCP_COMMAND}"
|
|
11775
|
+
args = [${MCP_ARGS.map((a2) => `"${a2}"`).join(", ")}]
|
|
11776
|
+
`;
|
|
11777
|
+
await writeAtomic(
|
|
11778
|
+
target.file,
|
|
11779
|
+
`${text4}${text4 && !text4.endsWith("\n") ? "\n" : ""}${text4 ? "\n" : ""}${block2}`
|
|
11780
|
+
);
|
|
11781
|
+
return { mcp: "added" };
|
|
11782
|
+
}
|
|
11783
|
+
case "cli": {
|
|
11784
|
+
if (!(await this.options.runner.probe(target.command)).installed) {
|
|
11785
|
+
return { mcp: "manual", mcpManual: target.manual };
|
|
11786
|
+
}
|
|
11787
|
+
const result = await this.options.runner.capture(target.command, target.add);
|
|
11788
|
+
if (result.exitCode === 0) return { mcp: "added" };
|
|
11789
|
+
if (/already exists/i.test(`${result.stdout}${result.stderr}`)) return { mcp: "already" };
|
|
11790
|
+
return { mcp: "manual", mcpManual: target.manual };
|
|
11791
|
+
}
|
|
11792
|
+
}
|
|
11793
|
+
}
|
|
11794
|
+
async removeMcp(target) {
|
|
11795
|
+
switch (target.kind) {
|
|
11796
|
+
case "json": {
|
|
11797
|
+
const text4 = await readText(target.file);
|
|
11798
|
+
if (text4 === void 0) return { mcp: "already" };
|
|
11799
|
+
const config = await this.readJsonConfig(target.file);
|
|
11800
|
+
const servers = config[target.key];
|
|
11801
|
+
if (!servers?.cloudhop) return { mcp: "already" };
|
|
11802
|
+
delete servers.cloudhop;
|
|
11803
|
+
const onlyEmptyKey = Object.keys(servers).length === 0 && Object.keys(config).length === 1;
|
|
11804
|
+
if (onlyEmptyKey) await removeFileAndEmptyDir(target.file);
|
|
11805
|
+
else await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
|
|
11806
|
+
`);
|
|
11807
|
+
return { mcp: "removed" };
|
|
11808
|
+
}
|
|
11809
|
+
case "toml": {
|
|
11810
|
+
const text4 = await readText(target.file);
|
|
11811
|
+
if (!text4 || !/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
|
|
11812
|
+
const cleaned = text4.replace(new RegExp(`${escapeRegExp(TOML_MARK)}\\n`, "g"), "").replace(/^\[mcp_servers\.cloudhop\]\n(?:(?!\[)[^\n]*\n?)*/m, "").replace(/\n{3,}/g, "\n\n");
|
|
11813
|
+
if (cleaned.trim()) await writeAtomic(target.file, `${cleaned.trimEnd()}
|
|
11814
|
+
`);
|
|
11815
|
+
else await removeFileAndEmptyDir(target.file);
|
|
11816
|
+
return { mcp: "removed" };
|
|
11817
|
+
}
|
|
11818
|
+
case "cli": {
|
|
11819
|
+
if (!target.remove || !(await this.options.runner.probe(target.command)).installed) {
|
|
11820
|
+
return {
|
|
11821
|
+
mcp: "manual",
|
|
11822
|
+
mcpManual: `Remove the "cloudhop" MCP server in ${target.command === "code" ? "VS Code (MCP: List Servers)" : target.command}.`
|
|
11823
|
+
};
|
|
11824
|
+
}
|
|
11825
|
+
await this.options.runner.capture(target.command, target.remove);
|
|
11826
|
+
return { mcp: "removed" };
|
|
11827
|
+
}
|
|
11828
|
+
}
|
|
11829
|
+
}
|
|
11830
|
+
async readJsonConfig(file) {
|
|
11831
|
+
const text4 = await readText(file);
|
|
11832
|
+
if (text4 === void 0 || !text4.trim()) return {};
|
|
11833
|
+
try {
|
|
11834
|
+
const value = JSON.parse(text4);
|
|
11835
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
11836
|
+
return value;
|
|
11837
|
+
} catch {
|
|
11838
|
+
}
|
|
11839
|
+
throw new CloudhopError(
|
|
11840
|
+
"INVALID_NAME",
|
|
11841
|
+
`${file} isn't plain JSON (it may have comments), so cloudhop won't rewrite it.`,
|
|
11842
|
+
`Add the server by hand: "cloudhop": { "command": "cloudhop", "args": ["mcp"] }, or rerun with --no-mcp.`
|
|
11843
|
+
);
|
|
11844
|
+
}
|
|
11845
|
+
// ───────────── manifest ─────────────
|
|
11846
|
+
get manifestPath() {
|
|
11847
|
+
return join4(this.options.configDir, "assistants.json");
|
|
11848
|
+
}
|
|
11849
|
+
async readManifest() {
|
|
11850
|
+
const text4 = await readText(this.manifestPath);
|
|
11851
|
+
if (!text4) return [];
|
|
11852
|
+
try {
|
|
11853
|
+
const parsed = JSON.parse(text4);
|
|
11854
|
+
return Array.isArray(parsed.installs) ? parsed.installs : [];
|
|
11855
|
+
} catch {
|
|
11856
|
+
return [];
|
|
11857
|
+
}
|
|
11858
|
+
}
|
|
11859
|
+
async writeManifest(installs) {
|
|
11860
|
+
await writeAtomic(this.manifestPath, `${JSON.stringify({ installs }, null, 2)}
|
|
11861
|
+
`);
|
|
11862
|
+
}
|
|
11863
|
+
};
|
|
11864
|
+
var TOML_MARK = "# Added by cloudhop (`cloudhop agent remove` deletes it)";
|
|
11865
|
+
var escapeRegExp = (text4) => text4.replace(/[.*+?^${}()|[\]\\`]/g, "\\$&");
|
|
11866
|
+
|
|
11867
|
+
// src/commands/agent.ts
|
|
11868
|
+
var MCP_TEXT = {
|
|
11869
|
+
added: paint("green", "MCP server added"),
|
|
11870
|
+
already: "MCP server already there",
|
|
11871
|
+
removed: "MCP server removed",
|
|
11872
|
+
manual: paint("yellow", "MCP server: add it by hand"),
|
|
11873
|
+
skipped: paint("dim", "MCP server skipped")
|
|
11874
|
+
};
|
|
11875
|
+
var tilde = (path2) => {
|
|
11876
|
+
const inProject = relative(process.cwd(), path2);
|
|
11877
|
+
if (!inProject.startsWith("..") && !isAbsolute(inProject)) return inProject;
|
|
11878
|
+
return path2.replace(homedir2(), "~");
|
|
11879
|
+
};
|
|
11880
|
+
function parseTools(value, fallback) {
|
|
11881
|
+
if (!value) return fallback;
|
|
11882
|
+
const ids = value.split(",").map((v) => v.trim().toLowerCase());
|
|
11883
|
+
const unknown = ids.filter((id) => !ASSISTANT_IDS.includes(id));
|
|
11884
|
+
if (unknown.length) {
|
|
11885
|
+
throw new CloudhopError(
|
|
11886
|
+
"INVALID_NAME",
|
|
11887
|
+
`Unknown assistant: ${unknown.join(", ")}.`,
|
|
11888
|
+
`Use any of: ${ASSISTANT_IDS.join(", ")}.`
|
|
11889
|
+
);
|
|
11890
|
+
}
|
|
11891
|
+
return ids;
|
|
11892
|
+
}
|
|
11893
|
+
function parseScope(value) {
|
|
11894
|
+
if (value === void 0) return void 0;
|
|
11895
|
+
if (value === "user" || value === "project") return value;
|
|
11896
|
+
throw new CloudhopError("INVALID_NAME", '--scope must be "user" or "project".');
|
|
11897
|
+
}
|
|
11898
|
+
function printResults(results) {
|
|
11899
|
+
for (const r2 of results) {
|
|
11900
|
+
console.log(`${paint("green", "\u2714")} ${bold(r2.name)}`);
|
|
11901
|
+
console.log(` skill ${tilde(r2.skillPath)}`);
|
|
11902
|
+
console.log(` ${MCP_TEXT[r2.mcp]}${r2.mcpManual ? `: ${r2.mcpManual}` : ""}`);
|
|
11903
|
+
}
|
|
11904
|
+
}
|
|
11905
|
+
async function agentCommand(installer, args) {
|
|
11906
|
+
const { positional, flags } = parseArgs(args);
|
|
11907
|
+
const action = positional[0] ?? "status";
|
|
11908
|
+
const scopeFlag = parseScope(flags.scope);
|
|
11909
|
+
switch (action) {
|
|
11910
|
+
case "status": {
|
|
11911
|
+
for (const scope of scopeFlag ? [scopeFlag] : ["user", "project"]) {
|
|
11912
|
+
console.log(
|
|
11913
|
+
bold(
|
|
11914
|
+
scope === "user" ? "For you (all projects)" : `This project (${tilde(process.cwd())})`
|
|
11915
|
+
)
|
|
11916
|
+
);
|
|
11917
|
+
for (const s of await installer.status(scope)) {
|
|
11918
|
+
const skill = s.skill === "current" ? paint("green", "\u25CF skill up to date") : s.skill === "outdated" ? paint("yellow", "\u25D0 skill outdated (run `cloudhop agent update`)") : s.skill === "modified" ? paint(
|
|
11919
|
+
"yellow",
|
|
11920
|
+
"\u25D0 skill edited by you (`cloudhop agent update --force` replaces it)"
|
|
11921
|
+
) : paint("dim", "\u25CB no skill");
|
|
11922
|
+
const mcp = s.mcp === "registered" ? ", MCP on" : s.mcp === "unknown" ? ", MCP: check in the app" : "";
|
|
11923
|
+
const seen = s.detected ? "" : paint("dim", " (not detected)");
|
|
11924
|
+
console.log(` ${s.name.padEnd(26)} ${skill}${s.skill === "missing" ? "" : mcp}${seen}`);
|
|
11925
|
+
}
|
|
11926
|
+
}
|
|
11927
|
+
return;
|
|
11928
|
+
}
|
|
11929
|
+
case "install": {
|
|
11930
|
+
const detected = installer.detect();
|
|
11931
|
+
let tools = parseTools(flags.tools, detected);
|
|
11932
|
+
let scope = scopeFlag ?? "user";
|
|
11933
|
+
let mcp = !flags.noMcp;
|
|
11934
|
+
if (!flags.tools && interactive() && !flags.yes) {
|
|
11935
|
+
dist_exports.intro("cloudhop agent install");
|
|
11936
|
+
dist_exports.note(
|
|
11937
|
+
"Installs a skill that teaches your AI assistant to ask which cloudhop alias to use,\nrun commands with hop, and confirm before changing anything. Optionally also\nregisters the cloudhop MCP server (tools to list aliases, check sessions and run the CLI).",
|
|
11938
|
+
"Teach your AI assistant cloudhop"
|
|
11939
|
+
);
|
|
11940
|
+
tools = answer(
|
|
11941
|
+
await dist_exports.multiselect({
|
|
11942
|
+
message: "Which assistants do you use?",
|
|
11943
|
+
options: ASSISTANT_IDS.map((id) => ({
|
|
11944
|
+
value: id,
|
|
11945
|
+
label: ASSISTANTS[id].name,
|
|
11946
|
+
...detected.includes(id) ? { hint: "detected" } : {}
|
|
11947
|
+
})),
|
|
11948
|
+
initialValues: detected,
|
|
11949
|
+
required: true
|
|
11950
|
+
})
|
|
11951
|
+
);
|
|
11952
|
+
if (!scopeFlag) {
|
|
11953
|
+
scope = answer(
|
|
11954
|
+
await dist_exports.select({
|
|
11955
|
+
message: "Where should it go?",
|
|
11956
|
+
options: [
|
|
11957
|
+
{ value: "user", label: "For me, in every project", hint: "your home folder" },
|
|
11958
|
+
{
|
|
11959
|
+
value: "project",
|
|
11960
|
+
label: "Only this project",
|
|
11961
|
+
hint: "commit it and your team gets it too"
|
|
11962
|
+
}
|
|
11963
|
+
]
|
|
11964
|
+
})
|
|
11965
|
+
);
|
|
11966
|
+
}
|
|
11967
|
+
if (!flags.noMcp) {
|
|
11968
|
+
mcp = answer(
|
|
11969
|
+
await dist_exports.confirm({
|
|
11970
|
+
message: "Also register the cloudhop MCP server?",
|
|
11971
|
+
initialValue: true
|
|
11972
|
+
})
|
|
11973
|
+
);
|
|
11974
|
+
}
|
|
11975
|
+
}
|
|
11976
|
+
if (tools.length === 0) {
|
|
11977
|
+
throw new CloudhopError(
|
|
11978
|
+
"INVALID_NAME",
|
|
11979
|
+
"No AI assistant detected.",
|
|
11980
|
+
`Choose them with --tools ${ASSISTANT_IDS.join(",")}.`
|
|
11981
|
+
);
|
|
11982
|
+
}
|
|
11983
|
+
printResults(await installer.install(tools, scope, { mcp }));
|
|
11984
|
+
console.log(
|
|
11985
|
+
paint("dim", "\nStart a new chat (or restart the assistant) so it picks up the skill.")
|
|
11986
|
+
);
|
|
11987
|
+
return;
|
|
11988
|
+
}
|
|
11989
|
+
case "update": {
|
|
11990
|
+
const { updated, edited } = await installer.update({ force: Boolean(flags.force) });
|
|
11991
|
+
for (const file of updated) console.log(`${paint("green", "\u2714")} updated ${tilde(file)}`);
|
|
11992
|
+
for (const file of edited)
|
|
11993
|
+
console.log(
|
|
11994
|
+
`${paint("yellow", "\u25D0")} kept your edits in ${tilde(file)} (use --force to replace)`
|
|
11995
|
+
);
|
|
11996
|
+
if (!updated.length && !edited.length) console.log("Every installed skill is up to date.");
|
|
11997
|
+
return;
|
|
11998
|
+
}
|
|
11999
|
+
case "remove": {
|
|
12000
|
+
const scope = scopeFlag ?? "user";
|
|
12001
|
+
const statuses = await installer.status(scope);
|
|
12002
|
+
const installed = statuses.filter((s) => s.skill !== "missing" || s.mcp === "registered").map((s) => s.assistant);
|
|
12003
|
+
const tools = parseTools(flags.tools, installed);
|
|
12004
|
+
if (tools.length === 0) {
|
|
12005
|
+
console.log(`Nothing to remove for scope "${scope}".`);
|
|
12006
|
+
return;
|
|
12007
|
+
}
|
|
12008
|
+
printResults(await installer.remove(tools, scope));
|
|
12009
|
+
return;
|
|
12010
|
+
}
|
|
12011
|
+
default:
|
|
12012
|
+
throw new CloudhopError(
|
|
12013
|
+
"INVALID_NAME",
|
|
12014
|
+
`Unknown action "${action}".`,
|
|
12015
|
+
"Use: cloudhop agent [status | install | update | remove]"
|
|
12016
|
+
);
|
|
12017
|
+
}
|
|
12018
|
+
}
|
|
12019
|
+
|
|
12020
|
+
// src/commands/manage.ts
|
|
12021
|
+
function printReport(report) {
|
|
12022
|
+
const changes = [
|
|
12023
|
+
`${report.targets.length} available`,
|
|
12024
|
+
report.added.length ? `${report.added.length} new` : "",
|
|
12025
|
+
report.removed.length ? `${report.removed.length} removed` : ""
|
|
12026
|
+
].filter(Boolean);
|
|
12027
|
+
dist_exports.log.success(`${report.sourceId}: ${changes.join(", ")}.`);
|
|
12028
|
+
for (const t2 of report.added) dist_exports.log.message(` + ${t2.label}`);
|
|
12029
|
+
for (const t2 of report.removed) dist_exports.log.message(` - ${t2.label}`);
|
|
12030
|
+
if (report.orphanedAliases.length) {
|
|
12031
|
+
dist_exports.log.warn(
|
|
12032
|
+
`No longer available: ${report.orphanedAliases.join(", ")}. Remove them with \`cloudhop rm <alias>\`.`
|
|
12033
|
+
);
|
|
12034
|
+
}
|
|
12035
|
+
}
|
|
12036
|
+
async function syncCommand(app, sourceId) {
|
|
12037
|
+
const ids = sourceId ? [sourceId] : (await app.config()).sources.map((s) => s.id);
|
|
12038
|
+
if (ids.length === 0) {
|
|
12039
|
+
dist_exports.log.info("No sources yet. Run `cloudhop add`.");
|
|
12040
|
+
return;
|
|
12041
|
+
}
|
|
12042
|
+
for (const id of ids) {
|
|
12043
|
+
await app.ensureSession(id);
|
|
12044
|
+
printReport(await app.syncSource(id));
|
|
12045
|
+
}
|
|
12046
|
+
}
|
|
12047
|
+
async function loginCommand(app, sourceId) {
|
|
12048
|
+
const { sources } = await app.config();
|
|
12049
|
+
const id = sourceId ?? answer(
|
|
12050
|
+
await dist_exports.select({
|
|
12051
|
+
message: "Log in to which source?",
|
|
12052
|
+
options: sources.map((s) => ({ value: s.id, label: s.id }))
|
|
10946
12053
|
})
|
|
10947
12054
|
);
|
|
10948
12055
|
printReport(await app.login(id));
|
|
@@ -10953,7 +12060,24 @@ var SESSION_LABEL = {
|
|
|
10953
12060
|
expired: paint("red", "\u25CB expired, opens the browser on next use"),
|
|
10954
12061
|
unknown: paint("dim", "\xB7 session managed by the cloud CLI")
|
|
10955
12062
|
};
|
|
10956
|
-
|
|
12063
|
+
var JSON_VERSION = 1;
|
|
12064
|
+
var printJson = (value) => console.log(JSON.stringify({ version: JSON_VERSION, ...value }, null, 2));
|
|
12065
|
+
async function listCommand(app, args = []) {
|
|
12066
|
+
const { positional, flags } = parseArgs(args);
|
|
12067
|
+
const sourceId = positional[0];
|
|
12068
|
+
if (flags.json) {
|
|
12069
|
+
const overview = await app.overview();
|
|
12070
|
+
if (sourceId && !overview.sources.some((s) => s.id === sourceId)) {
|
|
12071
|
+
throw new CloudhopError("SOURCE_NOT_FOUND", `Source "${sourceId}" not found.`);
|
|
12072
|
+
}
|
|
12073
|
+
printJson(
|
|
12074
|
+
sourceId ? {
|
|
12075
|
+
sources: overview.sources.filter((s) => s.id === sourceId),
|
|
12076
|
+
aliases: overview.aliases.filter((a2) => a2.source === sourceId)
|
|
12077
|
+
} : overview
|
|
12078
|
+
);
|
|
12079
|
+
return;
|
|
12080
|
+
}
|
|
10957
12081
|
const config = await app.config();
|
|
10958
12082
|
if (config.sources.length === 0) {
|
|
10959
12083
|
console.log("Nothing here yet. Run `cloudhop setup` to get started.");
|
|
@@ -10975,7 +12099,9 @@ async function listCommand(app, sourceId) {
|
|
|
10975
12099
|
const state = await provider.sessionState(source);
|
|
10976
12100
|
const where = source.settings.startUrl ?? source.kind;
|
|
10977
12101
|
if (index > 0) console.log("");
|
|
10978
|
-
console.log(
|
|
12102
|
+
console.log(
|
|
12103
|
+
`${bold(paint("sky", source.id))} ${paint("dim", `${provider.displayName} \xB7 ${where}`)}`
|
|
12104
|
+
);
|
|
10979
12105
|
console.log(` ${SESSION_LABEL[state]}`);
|
|
10980
12106
|
const own = aliases.filter((a2) => a2.alias.source === source.id);
|
|
10981
12107
|
if (own.length === 0) {
|
|
@@ -11047,387 +12173,206 @@ async function providerCommand(app, args) {
|
|
|
11047
12173
|
const status = providers.includes(pid) ? "enabled" : available ? "disabled" : "coming soon";
|
|
11048
12174
|
console.log(` ${pid.padEnd(8)} ${status}`);
|
|
11049
12175
|
}
|
|
11050
|
-
return;
|
|
11051
|
-
}
|
|
11052
|
-
if (!id || !PROVIDER_IDS.includes(id)) {
|
|
11053
|
-
throw new Error(`Usage: cloudhop provider ${action} <${PROVIDER_IDS.join("|")}>`);
|
|
11054
|
-
}
|
|
11055
|
-
if (action === "enable") await app.enableProvider(id);
|
|
11056
|
-
else if (action === "disable") await app.disableProvider(id);
|
|
11057
|
-
else throw new Error("Usage: cloudhop provider [ls|enable|disable] <provider>");
|
|
11058
|
-
dist_exports.log.success(`${id} ${action}d.`);
|
|
11059
|
-
}
|
|
11060
|
-
async function regionCommand(app, args) {
|
|
11061
|
-
const [aliasArg, regionArg] = args;
|
|
11062
|
-
if ((!aliasArg || !regionArg) && !interactive()) {
|
|
11063
|
-
throw new CloudhopError("INVALID_NAME", "Usage: cloudhop region <alias> <region>");
|
|
11064
|
-
}
|
|
11065
|
-
const aliases = await app.listAliases();
|
|
11066
|
-
const name = aliasArg ?? answer(
|
|
11067
|
-
await dist_exports.select({
|
|
11068
|
-
message: "Which alias?",
|
|
11069
|
-
options: aliases.map((a2) => ({
|
|
11070
|
-
value: a2.alias.name,
|
|
11071
|
-
label: a2.alias.name,
|
|
11072
|
-
hint: `${a2.target?.label ?? a2.alias.target}, ${a2.region}`
|
|
11073
|
-
}))
|
|
11074
|
-
})
|
|
11075
|
-
);
|
|
11076
|
-
const current = await app.resolveAlias(name);
|
|
11077
|
-
const validate = regionValidator(app, current.source);
|
|
11078
|
-
const region = regionArg?.trim() ?? (answer(
|
|
11079
|
-
await dist_exports.text({
|
|
11080
|
-
message: `Region for ${name}`,
|
|
11081
|
-
placeholder: current.region,
|
|
11082
|
-
defaultValue: current.region,
|
|
11083
|
-
validate: (v) => v?.trim() ? validate?.(v.trim()) : void 0
|
|
11084
|
-
})
|
|
11085
|
-
).trim() || current.region);
|
|
11086
|
-
const problem = region ? validate?.(region) : "Say which region.";
|
|
11087
|
-
if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
|
|
11088
|
-
await app.setAliasRegion(name, region);
|
|
11089
|
-
console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
|
|
11090
|
-
}
|
|
11091
|
-
|
|
11092
|
-
// src/commands/run.ts
|
|
11093
|
-
async function proxyCommand(app, alias, args) {
|
|
11094
|
-
const resolved = await app.resolveAlias(alias);
|
|
11095
|
-
const defaultArgs = resolved.source.provider === "aws" ? ["sts", "get-caller-identity"] : [];
|
|
11096
|
-
return app.runAsAlias(alias, args.length > 0 ? args : defaultArgs);
|
|
11097
|
-
}
|
|
11098
|
-
async function execCommand(app, args) {
|
|
11099
|
-
const separator = args.indexOf("--");
|
|
11100
|
-
const before = separator === -1 ? args.slice(0, 1) : args.slice(0, separator);
|
|
11101
|
-
const command = separator === -1 ? args.slice(1) : args.slice(separator + 1);
|
|
11102
|
-
const [alias, ...flags] = before;
|
|
11103
|
-
if (!alias || command.length === 0) {
|
|
11104
|
-
throw new Error("Usage: cloudhop exec <alias> [--region <region>] -- <command> [args...]");
|
|
11105
|
-
}
|
|
11106
|
-
const overrides = {};
|
|
11107
|
-
const regionIndex = flags.indexOf("--region");
|
|
11108
|
-
if (regionIndex !== -1 && flags[regionIndex + 1])
|
|
11109
|
-
overrides.region = flags[regionIndex + 1];
|
|
11110
|
-
const [bin, ...rest] = command;
|
|
11111
|
-
return app.execAsAlias(alias, bin, rest, overrides);
|
|
11112
|
-
}
|
|
11113
|
-
|
|
11114
|
-
// src/commands/setup.ts
|
|
11115
|
-
var UPCOMING = ["Azure", "Google Cloud"];
|
|
11116
|
-
var WELCOME = [
|
|
11117
|
-
"cloudhop lets you hop between cloud accounts, roles and regions with one command.",
|
|
11118
|
-
"Add your SSO portals and personal accounts once and give each account/role a short",
|
|
11119
|
-
"alias. Then `hop <alias> <command>` runs the cloud CLI as that account: cloudhop",
|
|
11120
|
-
"logs you in when a session expires, and never stores access keys."
|
|
11121
|
-
].join("\n");
|
|
11122
|
-
async function offerInstall(app, entry) {
|
|
11123
|
-
const { cli, tool, recipe, outdated } = entry;
|
|
11124
|
-
if (tool.installed && !outdated) return true;
|
|
11125
|
-
const problem = outdated ? `${cli.displayName} ${tool.version} is too old (cloudhop needs ${cli.minVersion}+).` : `${cli.displayName} is not installed. cloudhop can still log you in, but needs it to run \`${cli.command}\` commands.`;
|
|
11126
|
-
dist_exports.log.warn(problem);
|
|
11127
|
-
if (!recipe?.command) {
|
|
11128
|
-
dist_exports.log.info(
|
|
11129
|
-
`Install it from ${recipe?.manualUrl ?? "the official docs"}, then run \`cloudhop doctor\`.`
|
|
11130
|
-
);
|
|
11131
|
-
return false;
|
|
11132
|
-
}
|
|
11133
|
-
const command = [recipe.command, ...recipe.args].join(" ");
|
|
11134
|
-
const install = answer(
|
|
11135
|
-
await dist_exports.confirm({ message: `Install it with ${recipe.via}? (${command})` })
|
|
11136
|
-
);
|
|
11137
|
-
if (!install) return false;
|
|
11138
|
-
const ok = await app.installCli(recipe);
|
|
11139
|
-
if (ok) dist_exports.log.success(`${cli.displayName} installed.`);
|
|
11140
|
-
else dist_exports.log.error(`Could not install automatically. See ${recipe.manualUrl}`);
|
|
11141
|
-
return ok;
|
|
11142
|
-
}
|
|
11143
|
-
async function setupCommand(app) {
|
|
11144
|
-
requireInteractive(
|
|
11145
|
-
"setup",
|
|
11146
|
-
"Run it in a regular terminal, or script it: `cloudhop provider enable aws`, then `cloudhop add aws sso --start-url <url> --region <r>`."
|
|
11147
|
-
);
|
|
11148
|
-
printBanner();
|
|
11149
|
-
dist_exports.intro("cloudhop setup");
|
|
11150
|
-
dist_exports.note(WELCOME, "Welcome");
|
|
11151
|
-
const config = await app.config();
|
|
11152
|
-
const providers = answer(
|
|
11153
|
-
await dist_exports.multiselect({
|
|
11154
|
-
message: "Which clouds do you use?",
|
|
11155
|
-
options: app.availableProviders().map((provider) => ({ value: provider.id, label: provider.displayName })),
|
|
11156
|
-
initialValues: config.providers.length > 0 ? config.providers : ["aws"],
|
|
11157
|
-
required: true
|
|
11158
|
-
})
|
|
11159
|
-
);
|
|
11160
|
-
dist_exports.log.info(`Coming soon: ${UPCOMING.join(", ")}.`);
|
|
11161
|
-
await app.setEnabledProviders(providers);
|
|
11162
|
-
for (const entry of await app.doctor()) {
|
|
11163
|
-
if (entry.tool.installed && !entry.outdated) {
|
|
11164
|
-
dist_exports.log.success(`${entry.cli.displayName} ${entry.tool.version ?? ""} found.`);
|
|
11165
|
-
} else {
|
|
11166
|
-
await offerInstall(app, entry);
|
|
11167
|
-
}
|
|
11168
|
-
}
|
|
11169
|
-
const addNow = answer(await dist_exports.confirm({ message: "Add an account or SSO portal now?" }));
|
|
11170
|
-
if (!addNow) {
|
|
11171
|
-
dist_exports.outro("All set. Run `cloudhop add` whenever you are ready.");
|
|
11172
|
-
return;
|
|
11173
|
-
}
|
|
11174
|
-
await addCommand(app, { positional: [], flags: {} });
|
|
11175
|
-
}
|
|
11176
|
-
async function doctorCommand(app) {
|
|
11177
|
-
dist_exports.intro("cloudhop doctor");
|
|
11178
|
-
const entries = await app.doctor();
|
|
11179
|
-
if (entries.length === 0) {
|
|
11180
|
-
dist_exports.outro("No cloud enabled. Run `cloudhop setup`.");
|
|
11181
|
-
return;
|
|
11182
|
-
}
|
|
11183
|
-
let healthy = true;
|
|
11184
|
-
for (const entry of entries) {
|
|
11185
|
-
if (entry.tool.installed && !entry.outdated) {
|
|
11186
|
-
dist_exports.log.success(`${entry.displayName}: ${entry.cli.displayName} ${entry.tool.version ?? ""}`);
|
|
11187
|
-
} else {
|
|
11188
|
-
healthy = await offerInstall(app, entry) && healthy;
|
|
11189
|
-
}
|
|
11190
|
-
}
|
|
11191
|
-
dist_exports.outro(
|
|
11192
|
-
healthy ? "Everything looks good." : "Some tools are missing; login and `cloudhop exec` still work."
|
|
11193
|
-
);
|
|
11194
|
-
}
|
|
11195
|
-
|
|
11196
|
-
// ../adapters-node/src/node-browser-opener.ts
|
|
11197
|
-
import { spawn } from "child_process";
|
|
11198
|
-
var NodeBrowserOpener = class {
|
|
11199
|
-
async open(url) {
|
|
11200
|
-
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
11201
|
-
await new Promise((resolve) => {
|
|
11202
|
-
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
11203
|
-
child.on("error", () => resolve());
|
|
11204
|
-
child.on("spawn", () => {
|
|
11205
|
-
child.unref();
|
|
11206
|
-
resolve();
|
|
11207
|
-
});
|
|
11208
|
-
});
|
|
11209
|
-
}
|
|
11210
|
-
};
|
|
11211
|
-
|
|
11212
|
-
// ../adapters-node/src/node-process-runner.ts
|
|
11213
|
-
import { spawn as spawn2 } from "child_process";
|
|
11214
|
-
var STDERR_TAIL_BYTES = 8 * 1024;
|
|
11215
|
-
function buildEnv(overrides) {
|
|
11216
|
-
const env = { ...process.env };
|
|
11217
|
-
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
11218
|
-
if (value === void 0) delete env[key];
|
|
11219
|
-
else env[key] = value;
|
|
11220
|
-
}
|
|
11221
|
-
return env;
|
|
11222
|
-
}
|
|
11223
|
-
var useShell = process.platform === "win32";
|
|
11224
|
-
var NodeProcessRunner = class {
|
|
11225
|
-
run(command, args, options = {}) {
|
|
11226
|
-
return new Promise((resolve, reject) => {
|
|
11227
|
-
const child = spawn2(command, args, {
|
|
11228
|
-
env: buildEnv(options.env),
|
|
11229
|
-
stdio: ["inherit", "inherit", options.captureStderr ? "pipe" : "inherit"],
|
|
11230
|
-
shell: useShell
|
|
11231
|
-
});
|
|
11232
|
-
let tail = "";
|
|
11233
|
-
child.stderr?.on("data", (chunk) => {
|
|
11234
|
-
process.stderr.write(chunk);
|
|
11235
|
-
tail = (tail + chunk.toString()).slice(-STDERR_TAIL_BYTES);
|
|
11236
|
-
});
|
|
11237
|
-
const ignore = () => {
|
|
11238
|
-
};
|
|
11239
|
-
process.on("SIGINT", ignore);
|
|
11240
|
-
process.on("SIGTERM", ignore);
|
|
11241
|
-
const cleanup = () => {
|
|
11242
|
-
process.off("SIGINT", ignore);
|
|
11243
|
-
process.off("SIGTERM", ignore);
|
|
11244
|
-
};
|
|
11245
|
-
child.on("error", (error) => {
|
|
11246
|
-
cleanup();
|
|
11247
|
-
reject(error);
|
|
11248
|
-
});
|
|
11249
|
-
child.on("close", (code, signal) => {
|
|
11250
|
-
cleanup();
|
|
11251
|
-
resolve({ exitCode: code ?? (signal ? 128 + 2 : 1), stderrTail: tail });
|
|
11252
|
-
});
|
|
11253
|
-
});
|
|
11254
|
-
}
|
|
11255
|
-
capture(command, args, options = {}) {
|
|
11256
|
-
return new Promise((resolve) => {
|
|
11257
|
-
const child = spawn2(command, args, { env: buildEnv(options.env), shell: useShell });
|
|
11258
|
-
let stdout2 = "";
|
|
11259
|
-
let stderr = "";
|
|
11260
|
-
child.stdout.on("data", (c3) => {
|
|
11261
|
-
stdout2 += c3.toString();
|
|
11262
|
-
});
|
|
11263
|
-
child.stderr.on("data", (c3) => {
|
|
11264
|
-
stderr += c3.toString();
|
|
11265
|
-
});
|
|
11266
|
-
child.on("error", (error) => resolve({ exitCode: 127, stdout: stdout2, stderr: error.message }));
|
|
11267
|
-
child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr }));
|
|
11268
|
-
});
|
|
11269
|
-
}
|
|
11270
|
-
async probe(command) {
|
|
11271
|
-
const result = await this.capture(command, ["--version"]);
|
|
11272
|
-
if (result.exitCode !== 0) return { installed: false };
|
|
11273
|
-
const output = `${result.stdout} ${result.stderr}`;
|
|
11274
|
-
const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
|
|
11275
|
-
return { installed: true, ...version ? { version } : {} };
|
|
11276
|
-
}
|
|
11277
|
-
};
|
|
11278
|
-
|
|
11279
|
-
// ../adapters-node/src/system-tool-installer.ts
|
|
11280
|
-
var TOOLS = {
|
|
11281
|
-
aws: {
|
|
11282
|
-
manualUrl: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
|
|
11283
|
-
recipes: {
|
|
11284
|
-
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "awscli"] }],
|
|
11285
|
-
win32: [
|
|
11286
|
-
{ via: "winget", command: "winget", args: ["install", "--exact", "--id", "Amazon.AWSCLI"] }
|
|
11287
|
-
]
|
|
11288
|
-
}
|
|
11289
|
-
},
|
|
11290
|
-
az: {
|
|
11291
|
-
manualUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli",
|
|
11292
|
-
recipes: {
|
|
11293
|
-
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "azure-cli"] }],
|
|
11294
|
-
win32: [
|
|
11295
|
-
{
|
|
11296
|
-
via: "winget",
|
|
11297
|
-
command: "winget",
|
|
11298
|
-
args: ["install", "--exact", "--id", "Microsoft.AzureCLI"]
|
|
11299
|
-
}
|
|
11300
|
-
]
|
|
11301
|
-
}
|
|
11302
|
-
},
|
|
11303
|
-
gcloud: {
|
|
11304
|
-
manualUrl: "https://cloud.google.com/sdk/docs/install",
|
|
11305
|
-
recipes: {
|
|
11306
|
-
darwin: [
|
|
11307
|
-
{ via: "Homebrew", command: "brew", args: ["install", "--cask", "google-cloud-sdk"] }
|
|
11308
|
-
],
|
|
11309
|
-
win32: [
|
|
11310
|
-
{
|
|
11311
|
-
via: "winget",
|
|
11312
|
-
command: "winget",
|
|
11313
|
-
args: ["install", "--exact", "--id", "Google.CloudSDK"]
|
|
11314
|
-
}
|
|
11315
|
-
]
|
|
11316
|
-
}
|
|
11317
|
-
}
|
|
11318
|
-
};
|
|
11319
|
-
var SystemToolInstaller = class {
|
|
11320
|
-
constructor(runner, platform = process.platform) {
|
|
11321
|
-
this.runner = runner;
|
|
11322
|
-
this.platform = platform;
|
|
11323
|
-
}
|
|
11324
|
-
runner;
|
|
11325
|
-
platform;
|
|
11326
|
-
available = /* @__PURE__ */ new Map();
|
|
11327
|
-
recipeFor(command) {
|
|
11328
|
-
const tool = TOOLS[command];
|
|
11329
|
-
if (!tool) return void 0;
|
|
11330
|
-
const recipe = tool.recipes[this.platform]?.[0];
|
|
11331
|
-
return recipe ? { ...recipe, manualUrl: tool.manualUrl } : { via: "official installer", command: "", args: [], manualUrl: tool.manualUrl };
|
|
11332
|
-
}
|
|
11333
|
-
async install(recipe) {
|
|
11334
|
-
if (!recipe.command) return false;
|
|
11335
|
-
if (!await this.hasCommand(recipe.command)) return false;
|
|
11336
|
-
const { exitCode } = await this.runner.run(recipe.command, recipe.args);
|
|
11337
|
-
return exitCode === 0;
|
|
11338
|
-
}
|
|
11339
|
-
async hasCommand(command) {
|
|
11340
|
-
if (!this.available.has(command)) {
|
|
11341
|
-
this.available.set(command, (await this.runner.probe(command)).installed);
|
|
11342
|
-
}
|
|
11343
|
-
return this.available.get(command) ?? false;
|
|
12176
|
+
return;
|
|
11344
12177
|
}
|
|
11345
|
-
|
|
11346
|
-
|
|
11347
|
-
// ../adapters-node/src/yaml-config-store.ts
|
|
11348
|
-
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
11349
|
-
import { homedir } from "os";
|
|
11350
|
-
import { join as join2 } from "path";
|
|
11351
|
-
var import_yaml = __toESM(require_dist(), 1);
|
|
11352
|
-
function defaultConfigDir(env = process.env) {
|
|
11353
|
-
if (env.CLOUDHOP_HOME) return env.CLOUDHOP_HOME;
|
|
11354
|
-
return join2(env.XDG_CONFIG_HOME || join2(homedir(), ".config"), "cloudhop");
|
|
11355
|
-
}
|
|
11356
|
-
var HEADER = "# cloudhop configuration. Contains no secrets.\n# Docs: https://github.com/dayepesb/cloudhop\n";
|
|
11357
|
-
var YamlConfigStore = class {
|
|
11358
|
-
constructor(dir = defaultConfigDir()) {
|
|
11359
|
-
this.dir = dir;
|
|
12178
|
+
if (!id || !PROVIDER_IDS.includes(id)) {
|
|
12179
|
+
throw new Error(`Usage: cloudhop provider ${action} <${PROVIDER_IDS.join("|")}>`);
|
|
11360
12180
|
}
|
|
11361
|
-
|
|
11362
|
-
|
|
11363
|
-
|
|
12181
|
+
if (action === "enable") await app.enableProvider(id);
|
|
12182
|
+
else if (action === "disable") await app.disableProvider(id);
|
|
12183
|
+
else throw new Error("Usage: cloudhop provider [ls|enable|disable] <provider>");
|
|
12184
|
+
dist_exports.log.success(`${id} ${action}d.`);
|
|
12185
|
+
}
|
|
12186
|
+
async function regionCommand(app, args) {
|
|
12187
|
+
const [aliasArg, regionArg] = args;
|
|
12188
|
+
if ((!aliasArg || !regionArg) && !interactive()) {
|
|
12189
|
+
throw new CloudhopError("INVALID_NAME", "Usage: cloudhop region <alias> <region>");
|
|
11364
12190
|
}
|
|
11365
|
-
|
|
11366
|
-
|
|
12191
|
+
const aliases = await app.listAliases();
|
|
12192
|
+
const name = aliasArg ?? answer(
|
|
12193
|
+
await dist_exports.select({
|
|
12194
|
+
message: "Which alias?",
|
|
12195
|
+
options: aliases.map((a2) => ({
|
|
12196
|
+
value: a2.alias.name,
|
|
12197
|
+
label: a2.alias.name,
|
|
12198
|
+
hint: `${a2.target?.label ?? a2.alias.target}, ${a2.region}`
|
|
12199
|
+
}))
|
|
12200
|
+
})
|
|
12201
|
+
);
|
|
12202
|
+
const current = await app.resolveAlias(name);
|
|
12203
|
+
const validate = regionValidator(app, current.source);
|
|
12204
|
+
const region = regionArg?.trim() ?? (answer(
|
|
12205
|
+
await dist_exports.text({
|
|
12206
|
+
message: `Region for ${name}`,
|
|
12207
|
+
placeholder: current.region,
|
|
12208
|
+
defaultValue: current.region,
|
|
12209
|
+
validate: (v) => v?.trim() ? validate?.(v.trim()) : void 0
|
|
12210
|
+
})
|
|
12211
|
+
).trim() || current.region);
|
|
12212
|
+
const problem = region ? validate?.(region) : "Say which region.";
|
|
12213
|
+
if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
|
|
12214
|
+
await app.setAliasRegion(name, region);
|
|
12215
|
+
console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
|
|
12216
|
+
}
|
|
12217
|
+
var STATE_SHORT = {
|
|
12218
|
+
valid: paint("green", "\u25CF session valid"),
|
|
12219
|
+
refreshable: paint("yellow", "\u25D0 session expired, renewable"),
|
|
12220
|
+
expired: paint("red", "\u25CB session expired"),
|
|
12221
|
+
unknown: paint("dim", "\xB7 session")
|
|
12222
|
+
};
|
|
12223
|
+
var NEXT_TEXT = {
|
|
12224
|
+
ready: "ready to run",
|
|
12225
|
+
"silent-renewal": "the next command renews the session silently",
|
|
12226
|
+
"browser-sign-in": "the next command opens the browser to sign in",
|
|
12227
|
+
unknown: "the cloud CLI manages this session"
|
|
12228
|
+
};
|
|
12229
|
+
async function statusCommand(app, args) {
|
|
12230
|
+
const { positional, flags } = parseArgs(args);
|
|
12231
|
+
const name = positional[0];
|
|
12232
|
+
if (name) {
|
|
12233
|
+
const status = await app.aliasStatus(name);
|
|
12234
|
+
if (flags.json) return printJson({ alias: status });
|
|
12235
|
+
const account = status.attributes.accountId ? ` (${status.attributes.accountId})` : "";
|
|
12236
|
+
console.log(
|
|
12237
|
+
`${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}`
|
|
12238
|
+
);
|
|
12239
|
+
console.log(` ${STATE_SHORT[status.session]}: ${NEXT_TEXT[status.next]}`);
|
|
12240
|
+
if (status.next === "browser-sign-in") {
|
|
12241
|
+
console.log(paint("dim", ` Sign in now with \`cloudhop login ${status.source}\`.`));
|
|
12242
|
+
}
|
|
12243
|
+
if (!status.available) console.log(paint("red", " This account/role is no longer available."));
|
|
12244
|
+
return;
|
|
11367
12245
|
}
|
|
11368
|
-
|
|
11369
|
-
|
|
11370
|
-
|
|
11371
|
-
|
|
11372
|
-
return
|
|
11373
|
-
providers: (file.providers ?? []).filter(
|
|
11374
|
-
(p3) => PROVIDER_IDS.includes(p3)
|
|
11375
|
-
),
|
|
11376
|
-
sources: Object.entries(file.sources ?? {}).map(([id, s]) => ({
|
|
11377
|
-
id,
|
|
11378
|
-
...s,
|
|
11379
|
-
settings: s.settings ?? {}
|
|
11380
|
-
})),
|
|
11381
|
-
aliases: Object.entries(file.aliases ?? {}).map(([name, a2]) => ({ name, ...a2 }))
|
|
11382
|
-
};
|
|
12246
|
+
const overview = await app.overview();
|
|
12247
|
+
if (flags.json) return printJson({ sources: overview.sources });
|
|
12248
|
+
if (overview.sources.length === 0) {
|
|
12249
|
+
console.log("Nothing here yet. Run `cloudhop setup` to get started.");
|
|
12250
|
+
return;
|
|
11383
12251
|
}
|
|
11384
|
-
|
|
11385
|
-
|
|
11386
|
-
|
|
11387
|
-
|
|
11388
|
-
aliases: Object.fromEntries(config.aliases.map(({ name, ...rest }) => [name, rest]))
|
|
11389
|
-
};
|
|
11390
|
-
await this.write(this.configPath, HEADER + (0, import_yaml.stringify)(file));
|
|
12252
|
+
for (const source of overview.sources) {
|
|
12253
|
+
console.log(
|
|
12254
|
+
`${bold(paint("sky", source.id))} ${STATE_SHORT[source.session]}: ${NEXT_TEXT[source.next]}`
|
|
12255
|
+
);
|
|
11391
12256
|
}
|
|
11392
|
-
|
|
11393
|
-
|
|
12257
|
+
}
|
|
12258
|
+
|
|
12259
|
+
// src/commands/run.ts
|
|
12260
|
+
async function proxyCommand(app, alias, args) {
|
|
12261
|
+
return app.runAsAlias(alias, args.length > 0 ? args : await app.identityArgs(alias));
|
|
12262
|
+
}
|
|
12263
|
+
async function execCommand(app, args) {
|
|
12264
|
+
const separator = args.indexOf("--");
|
|
12265
|
+
const before = separator === -1 ? args.slice(0, 1) : args.slice(0, separator);
|
|
12266
|
+
const command = separator === -1 ? args.slice(1) : args.slice(separator + 1);
|
|
12267
|
+
const [alias, ...flags] = before;
|
|
12268
|
+
if (!alias || command.length === 0) {
|
|
12269
|
+
throw new Error("Usage: cloudhop exec <alias> [--region <region>] -- <command> [args...]");
|
|
11394
12270
|
}
|
|
11395
|
-
|
|
11396
|
-
|
|
11397
|
-
|
|
11398
|
-
|
|
12271
|
+
const overrides = {};
|
|
12272
|
+
const regionIndex = flags.indexOf("--region");
|
|
12273
|
+
if (regionIndex !== -1 && flags[regionIndex + 1])
|
|
12274
|
+
overrides.region = flags[regionIndex + 1];
|
|
12275
|
+
const [bin, ...rest] = command;
|
|
12276
|
+
return app.execAsAlias(alias, bin, rest, overrides);
|
|
12277
|
+
}
|
|
12278
|
+
|
|
12279
|
+
// src/commands/setup.ts
|
|
12280
|
+
var UPCOMING = ["Azure", "Google Cloud"];
|
|
12281
|
+
var WELCOME = [
|
|
12282
|
+
"cloudhop lets you hop between cloud accounts, roles and regions with one command.",
|
|
12283
|
+
"Add your SSO portals and personal accounts once and give each account/role a short",
|
|
12284
|
+
"alias. Then `hop <alias> <command>` runs the cloud CLI as that account: cloudhop",
|
|
12285
|
+
"logs you in when a session expires, and never stores access keys."
|
|
12286
|
+
].join("\n");
|
|
12287
|
+
async function offerInstall(app, entry) {
|
|
12288
|
+
const { cli, tool, recipe, outdated } = entry;
|
|
12289
|
+
if (tool.installed && !outdated) return true;
|
|
12290
|
+
const problem = outdated ? `${cli.displayName} ${tool.version} is too old (cloudhop needs ${cli.minVersion}+).` : `${cli.displayName} is not installed. cloudhop can still log you in, but needs it to run \`${cli.command}\` commands.`;
|
|
12291
|
+
dist_exports.log.warn(problem);
|
|
12292
|
+
if (!recipe?.command) {
|
|
12293
|
+
dist_exports.log.info(
|
|
12294
|
+
`Install it from ${recipe?.manualUrl ?? "the official docs"}, then run \`cloudhop doctor\`.`
|
|
12295
|
+
);
|
|
12296
|
+
return false;
|
|
11399
12297
|
}
|
|
11400
|
-
|
|
11401
|
-
|
|
11402
|
-
|
|
11403
|
-
|
|
12298
|
+
const command = [recipe.command, ...recipe.args].join(" ");
|
|
12299
|
+
const install = answer(
|
|
12300
|
+
await dist_exports.confirm({ message: `Install it with ${recipe.via}? (${command})` })
|
|
12301
|
+
);
|
|
12302
|
+
if (!install) return false;
|
|
12303
|
+
const ok = await app.installCli(recipe);
|
|
12304
|
+
if (ok) dist_exports.log.success(`${cli.displayName} installed.`);
|
|
12305
|
+
else dist_exports.log.error(`Could not install automatically. See ${recipe.manualUrl}`);
|
|
12306
|
+
return ok;
|
|
12307
|
+
}
|
|
12308
|
+
async function setupCommand(app, assistants) {
|
|
12309
|
+
requireInteractive(
|
|
12310
|
+
"setup",
|
|
12311
|
+
"Run it in a regular terminal, or script it: `cloudhop provider enable aws`, then `cloudhop add aws sso --start-url <url> --region <r>`."
|
|
12312
|
+
);
|
|
12313
|
+
printBanner();
|
|
12314
|
+
dist_exports.intro("cloudhop setup");
|
|
12315
|
+
dist_exports.note(WELCOME, "Welcome");
|
|
12316
|
+
const config = await app.config();
|
|
12317
|
+
const providers = answer(
|
|
12318
|
+
await dist_exports.multiselect({
|
|
12319
|
+
message: "Which clouds do you use?",
|
|
12320
|
+
options: app.availableProviders().map((provider) => ({ value: provider.id, label: provider.displayName })),
|
|
12321
|
+
initialValues: config.providers.length > 0 ? config.providers : ["aws"],
|
|
12322
|
+
required: true
|
|
12323
|
+
})
|
|
12324
|
+
);
|
|
12325
|
+
dist_exports.log.info(`Coming soon: ${UPCOMING.join(", ")}.`);
|
|
12326
|
+
await app.setEnabledProviders(providers);
|
|
12327
|
+
for (const entry of await app.doctor()) {
|
|
12328
|
+
if (entry.tool.installed && !entry.outdated) {
|
|
12329
|
+
dist_exports.log.success(`${entry.cli.displayName} ${entry.tool.version ?? ""} found.`);
|
|
12330
|
+
} else {
|
|
12331
|
+
await offerInstall(app, entry);
|
|
12332
|
+
}
|
|
11404
12333
|
}
|
|
11405
|
-
|
|
11406
|
-
|
|
12334
|
+
const addNow = answer(await dist_exports.confirm({ message: "Add an account or SSO portal now?" }));
|
|
12335
|
+
if (addNow) await addCommand(app, { positional: [], flags: {} });
|
|
12336
|
+
else dist_exports.log.info("Run `cloudhop add` whenever you are ready.");
|
|
12337
|
+
const detected = assistants.detect();
|
|
12338
|
+
if (detected.length > 0) {
|
|
12339
|
+
const names = detected.map((id) => ASSISTANTS[id].name).join(", ");
|
|
12340
|
+
const teach = answer(
|
|
12341
|
+
await dist_exports.confirm({
|
|
12342
|
+
message: `Teach your AI assistant (${names}) to use cloudhop?`,
|
|
12343
|
+
initialValue: true
|
|
12344
|
+
})
|
|
12345
|
+
);
|
|
12346
|
+
if (teach) {
|
|
12347
|
+
await agentCommand(assistants, ["install"]);
|
|
12348
|
+
return;
|
|
12349
|
+
}
|
|
12350
|
+
dist_exports.log.info("You can do it later with `cloudhop agent install`.");
|
|
11407
12351
|
}
|
|
11408
|
-
|
|
11409
|
-
|
|
11410
|
-
|
|
12352
|
+
if (!addNow) dist_exports.outro("All set.");
|
|
12353
|
+
}
|
|
12354
|
+
async function doctorCommand(app) {
|
|
12355
|
+
dist_exports.intro("cloudhop doctor");
|
|
12356
|
+
const entries = await app.doctor();
|
|
12357
|
+
if (entries.length === 0) {
|
|
12358
|
+
dist_exports.outro("No cloud enabled. Run `cloudhop setup`.");
|
|
12359
|
+
return;
|
|
11411
12360
|
}
|
|
11412
|
-
|
|
11413
|
-
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
|
|
12361
|
+
let healthy = true;
|
|
12362
|
+
for (const entry of entries) {
|
|
12363
|
+
if (entry.tool.installed && !entry.outdated) {
|
|
12364
|
+
dist_exports.log.success(`${entry.displayName}: ${entry.cli.displayName} ${entry.tool.version ?? ""}`);
|
|
12365
|
+
} else {
|
|
12366
|
+
healthy = await offerInstall(app, entry) && healthy;
|
|
11418
12367
|
}
|
|
11419
12368
|
}
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
await writeFile(tmp, content, { mode: 384 });
|
|
11425
|
-
await rename(tmp, path2);
|
|
11426
|
-
}
|
|
11427
|
-
};
|
|
12369
|
+
dist_exports.outro(
|
|
12370
|
+
healthy ? "Everything looks good." : "Some tools are missing; login and `cloudhop exec` still work."
|
|
12371
|
+
);
|
|
12372
|
+
}
|
|
11428
12373
|
|
|
11429
12374
|
// ../ui/src/api.ts
|
|
11430
|
-
var
|
|
12375
|
+
var text3 = (body, key) => {
|
|
11431
12376
|
const value = body[key];
|
|
11432
12377
|
return typeof value === "string" ? value.trim() : "";
|
|
11433
12378
|
};
|
|
@@ -11446,7 +12391,23 @@ var reportView = (report) => ({
|
|
|
11446
12391
|
removed: report.removed.length,
|
|
11447
12392
|
orphanedAliases: report.orphanedAliases
|
|
11448
12393
|
});
|
|
11449
|
-
|
|
12394
|
+
var SCOPES = /* @__PURE__ */ new Set(["user", "project"]);
|
|
12395
|
+
var scopeOf = (value) => {
|
|
12396
|
+
if (SCOPES.has(value)) return value;
|
|
12397
|
+
throw badRequest('Scope must be "user" or "project".');
|
|
12398
|
+
};
|
|
12399
|
+
var assistantIds = (value) => {
|
|
12400
|
+
const ids = Array.isArray(value) ? value : [];
|
|
12401
|
+
if (ids.length === 0 || !ids.every((id) => ASSISTANT_IDS.includes(id))) {
|
|
12402
|
+
throw badRequest("Choose at least one assistant.");
|
|
12403
|
+
}
|
|
12404
|
+
return ids;
|
|
12405
|
+
};
|
|
12406
|
+
function createApi(app, assistants) {
|
|
12407
|
+
const requireAssistants = () => {
|
|
12408
|
+
if (!assistants) throw badRequest("AI assistant setup is not available here.");
|
|
12409
|
+
return assistants;
|
|
12410
|
+
};
|
|
11450
12411
|
const regionCheck = (source, region) => {
|
|
11451
12412
|
const validate = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
|
|
11452
12413
|
const problem = region ? validate?.(region) : "Region is required.";
|
|
@@ -11504,6 +12465,26 @@ function createApi(app) {
|
|
|
11504
12465
|
}))
|
|
11505
12466
|
};
|
|
11506
12467
|
},
|
|
12468
|
+
/** Which AI assistants know cloudhop, for both scopes. Paths are shown, never file contents. */
|
|
12469
|
+
"GET /api/assistants": async () => {
|
|
12470
|
+
const installer = requireAssistants();
|
|
12471
|
+
return {
|
|
12472
|
+
project: process.cwd(),
|
|
12473
|
+
user: await installer.status("user"),
|
|
12474
|
+
projectScope: await installer.status("project")
|
|
12475
|
+
};
|
|
12476
|
+
},
|
|
12477
|
+
"POST /api/assistants": async (_2, body) => {
|
|
12478
|
+
const tools = assistantIds(body.tools);
|
|
12479
|
+
const scope = scopeOf(body.scope);
|
|
12480
|
+
return {
|
|
12481
|
+
results: await requireAssistants().install(tools, scope, { mcp: body.mcp !== false })
|
|
12482
|
+
};
|
|
12483
|
+
},
|
|
12484
|
+
"POST /api/assistants/remove": async (_2, body) => ({
|
|
12485
|
+
results: await requireAssistants().remove(assistantIds(body.tools), scopeOf(body.scope))
|
|
12486
|
+
}),
|
|
12487
|
+
"POST /api/assistants/update": async (_2, body) => requireAssistants().update({ force: body.force === true }),
|
|
11507
12488
|
"GET /api/doctor": async () => (await app.doctor()).map((entry) => ({
|
|
11508
12489
|
provider: entry.provider,
|
|
11509
12490
|
cli: entry.cli.displayName,
|
|
@@ -11522,19 +12503,19 @@ function createApi(app) {
|
|
|
11522
12503
|
},
|
|
11523
12504
|
/** Adds a source and logs in right away, like `cloudhop add`; a failed login adds nothing. */
|
|
11524
12505
|
"POST /api/sources": async (_2, body) => {
|
|
11525
|
-
const provider = app.provider(
|
|
11526
|
-
const kind = provider.sourceKinds.find((k) => k.kind ===
|
|
12506
|
+
const provider = app.provider(text3(body, "provider"));
|
|
12507
|
+
const kind = provider.sourceKinds.find((k) => k.kind === text3(body, "kind"));
|
|
11527
12508
|
if (!kind) throw badRequest("Choose what you want to add.");
|
|
11528
12509
|
const input = body.settings ?? {};
|
|
11529
12510
|
const settings2 = {};
|
|
11530
12511
|
for (const field of kind.fields) {
|
|
11531
|
-
const value =
|
|
12512
|
+
const value = text3(input, field.key) || field.defaultValue || "";
|
|
11532
12513
|
if (!value) throw badRequest(`${field.label} is required.`);
|
|
11533
12514
|
const problem = field.validate?.(value);
|
|
11534
12515
|
if (problem) throw badRequest(`${field.label}: ${problem}`);
|
|
11535
12516
|
settings2[field.key] = value;
|
|
11536
12517
|
}
|
|
11537
|
-
const id =
|
|
12518
|
+
const id = text3(body, "id");
|
|
11538
12519
|
return reportView(
|
|
11539
12520
|
await app.connectSource({ id, provider: provider.id, kind: kind.kind, settings: settings2 })
|
|
11540
12521
|
);
|
|
@@ -11562,13 +12543,13 @@ function createApi(app) {
|
|
|
11562
12543
|
if (items.length === 0) throw badRequest("Pick at least one account/role.");
|
|
11563
12544
|
const aliases = [];
|
|
11564
12545
|
for (const item of items) {
|
|
11565
|
-
const source = await findSource(
|
|
11566
|
-
const region =
|
|
12546
|
+
const source = await findSource(text3(item, "source"));
|
|
12547
|
+
const region = text3(item, "region");
|
|
11567
12548
|
if (region) regionCheck(source, region);
|
|
11568
12549
|
aliases.push({
|
|
11569
|
-
name:
|
|
12550
|
+
name: text3(item, "name"),
|
|
11570
12551
|
source: source.id,
|
|
11571
|
-
target:
|
|
12552
|
+
target: text3(item, "target"),
|
|
11572
12553
|
...region && region !== source.settings.region ? { region } : {}
|
|
11573
12554
|
});
|
|
11574
12555
|
}
|
|
@@ -11577,7 +12558,7 @@ function createApi(app) {
|
|
|
11577
12558
|
},
|
|
11578
12559
|
"PATCH /api/aliases/:name": async ([name], body) => {
|
|
11579
12560
|
const resolved = await app.resolveAlias(name);
|
|
11580
|
-
const region =
|
|
12561
|
+
const region = text3(body, "region");
|
|
11581
12562
|
regionCheck(resolved.source, region);
|
|
11582
12563
|
await app.setAliasRegion(name, region);
|
|
11583
12564
|
return { name, region };
|
|
@@ -11617,10 +12598,294 @@ import { randomBytes, timingSafeEqual } from "crypto";
|
|
|
11617
12598
|
import { createServer } from "http";
|
|
11618
12599
|
|
|
11619
12600
|
// ../ui/src/assets/app.js
|
|
11620
|
-
var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// A fresh link opened in this same tab only changes the fragment: reload to pick up its token.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) location.reload();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/sources(\\/[^/]+\\/(login|sync|targets))?$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n if (state.sources.length === 0) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n ),\n );\n return;\n }\n $main.replaceChildren(...state.sources.map(sourceCard));\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'table',\n {},\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n h('tbody', {}, aliases.map(aliasRow)),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n return h(\n 'tr',\n {},\n h('td', { class: 'alias', title: `hop ${alias.name} <command>` }, alias.name),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h(\n 'td',\n { style: 'text-align:right' },\n h(\n 'button',\n {\n class: 'link',\n title: `Remove ${alias.name}`,\n 'aria-label': `Remove ${alias.name}`,\n onclick: (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n },\n '\u2715',\n ),\n ),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n refresh();\n}\n";
|
|
12601
|
+
var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// A fresh link opened in this same tab only changes the fragment: reload to pick up its token.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) location.reload();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/sources(\\/[^/]+\\/(login|sync|targets))?$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n if (state.sources.length === 0) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n ),\n assistantsCard(),\n );\n loadAssistants();\n return;\n }\n $main.replaceChildren(...state.sources.map(sourceCard), assistantsCard());\n loadAssistants();\n}\n\n// ---------- AI assistants ----------\n\nlet assistantScope = 'user';\ntry {\n assistantScope =\n localStorage.getItem('cloudhop-assistant-scope') === 'project' ? 'project' : 'user';\n} catch {}\nlet assistantsData;\n\nconst SKILL_TEXT = {\n current: ['valid', 'Up to date'],\n outdated: ['refreshable', 'Outdated'],\n modified: ['refreshable', 'Edited by you'],\n missing: ['', 'Not installed'],\n};\nconst MCP_TEXT = { registered: 'On', missing: 'Off', unknown: 'Check in the app' };\n\nfunction assistantsCard() {\n return h('section', { class: 'card', id: 'assistants', hidden: true });\n}\n\nasync function loadAssistants() {\n try {\n assistantsData = await api('GET', '/api/assistants');\n renderAssistants();\n } catch {\n document.getElementById('assistants')?.remove();\n }\n}\n\nfunction setAssistantScope(scope) {\n assistantScope = scope;\n try {\n localStorage.setItem('cloudhop-assistant-scope', scope);\n } catch {}\n renderAssistants();\n}\n\nfunction assistantAction(label, run, { danger = false, confirmText } = {}) {\n return h(\n 'button',\n {\n class: danger ? 'danger' : undefined,\n onclick: (e) => {\n if (confirmText && !confirm(confirmText)) return;\n busy(e.currentTarget, async () => {\n try {\n await run();\n } catch (error) {\n fail(error);\n }\n await loadAssistants();\n });\n },\n },\n label,\n );\n}\n\nfunction reportInstall(results) {\n for (const r of results) {\n if (r.mcp === 'manual') {\n toast(`${r.name}: skill installed. Add the MCP server by hand.`, { hint: r.mcpManual });\n } else {\n toast(`${r.name} now knows cloudhop. Start a new chat so it picks it up.`);\n }\n }\n}\n\nfunction renderAssistants() {\n const el = document.getElementById('assistants');\n if (!el || !assistantsData) return;\n const rows = assistantScope === 'user' ? assistantsData.user : assistantsData.projectScope;\n const scope = assistantScope;\n const install = (tools) =>\n api('POST', '/api/assistants', { tools, scope, mcp: true }).then((r) =>\n reportInstall(r.results),\n );\n const missingDetected = rows\n .filter((r) => r.detected && r.skill === 'missing')\n .map((r) => r.assistant);\n\n const seg = h(\n 'div',\n { class: 'seg', role: 'group', 'aria-label': 'Where' },\n h(\n 'button',\n { 'aria-pressed': String(scope === 'user'), onclick: () => setAssistantScope('user') },\n 'For me',\n ),\n h(\n 'button',\n { 'aria-pressed': String(scope === 'project'), onclick: () => setAssistantScope('project') },\n 'This project',\n ),\n );\n\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, 'AI assistants'),\n h(\n 'span',\n { class: 'meta' },\n scope === 'user'\n ? 'Installed in your home folder, for every project'\n : assistantsData.project,\n ),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n seg,\n missingDetected.length > 1\n ? assistantAction('Install for all detected', () => install(missingDetected))\n : null,\n ),\n );\n\n const table = h(\n 'table',\n { class: 'assistants' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Assistant'),\n h('th', {}, 'Skill'),\n h('th', {}, 'MCP server'),\n h('th'),\n ),\n ),\n h(\n 'tbody',\n {},\n rows.map((r) => {\n const [badgeClass, skillText] = SKILL_TEXT[r.skill];\n const actions = [];\n if (r.skill === 'missing')\n actions.push(assistantAction('Install', () => install([r.assistant])));\n if (r.skill === 'outdated') {\n actions.push(\n assistantAction('Update', () =>\n api('POST', '/api/assistants/update', {}).then(() =>\n toast(`${r.name}: skill updated.`),\n ),\n ),\n );\n }\n if (r.skill === 'modified') {\n actions.push(\n assistantAction(\n 'Replace',\n () =>\n api('POST', '/api/assistants/update', { force: true }).then(() =>\n toast('Edited skills replaced.'),\n ),\n {\n confirmText: 'Replace your edited copies with the current cloudhop skill?',\n },\n ),\n );\n }\n if (r.skill !== 'missing' || r.mcp === 'registered') {\n actions.push(\n assistantAction(\n 'Remove',\n () => api('POST', '/api/assistants/remove', { tools: [r.assistant], scope }),\n {\n danger: true,\n confirmText: `Remove the cloudhop skill and MCP server from ${r.name}?`,\n },\n ),\n );\n }\n return h(\n 'tr',\n {},\n h(\n 'td',\n { class: 'alias' },\n r.name,\n r.detected ? null : h('small', { class: 'muted-inline' }, ' not detected'),\n ),\n h('td', {}, h('span', { class: `badge ${badgeClass}`, title: r.skillPath }, skillText)),\n h('td', {}, r.skill === 'missing' && r.mcp !== 'registered' ? '' : MCP_TEXT[r.mcp]),\n h(\n 'td',\n { style: 'text-align:right' },\n h('div', { class: 'actions', style: 'justify-content:flex-end' }, actions),\n ),\n );\n }),\n ),\n );\n\n el.replaceChildren(\n head,\n h(\n 'div',\n { class: 'card-body' },\n h(\n 'p',\n { class: 'card-help' },\n 'Teach your AI assistant to use cloudhop: it asks you which alias to work with, runs commands with hop, and confirms before changing anything. Includes the cloudhop MCP server.',\n ),\n table,\n ),\n );\n el.hidden = false;\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'table',\n {},\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n h('tbody', {}, aliases.map(aliasRow)),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n return h(\n 'tr',\n {},\n h('td', { class: 'alias', title: `hop ${alias.name} <command>` }, alias.name),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h(\n 'td',\n { style: 'text-align:right' },\n h(\n 'button',\n {\n class: 'link',\n title: `Remove ${alias.name}`,\n 'aria-label': `Remove ${alias.name}`,\n onclick: (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n },\n '\u2715',\n ),\n ),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n refresh();\n}\n";
|
|
11621
12602
|
|
|
11622
12603
|
// ../ui/src/assets/index.html
|
|
11623
|
-
var assets_default = '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>cloudhop</title>\n <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%2319c3d1%22/%3E%3Cstop offset=%221%22 stop-color=%22%230a6a82%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" />\n <style>\n :root {\n --bg: #f6f7f9;\n --surface: #ffffff;\n --surface-2: #f1f3f6;\n --border: #e2e6eb;\n --text: #18202b;\n --muted: #5d6877;\n --accent: #0b8fa0;\n --accent-strong: #08707e;\n --accent-soft: #e0f4f6;\n --ok: #1f8a4c;\n --warn: #a86400;\n --bad: #c23934;\n --radius: 10px;\n --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);\n color-scheme: light;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --bg: #0f141a;\n --surface: #161d25;\n --surface-2: #1d2630;\n --border: #2a3542;\n --text: #e6ebf1;\n --muted: #93a1b1;\n --accent: #35c2d3;\n --accent-strong: #5fd3e1;\n --accent-soft: #123a41;\n --ok: #4cc27f;\n --warn: #e0a341;\n --bad: #f06b66;\n --shadow: none;\n color-scheme: dark;\n }\n }\n * { box-sizing: border-box; }\n /* biome-ignore lint/complexity/noImportantStyles: the hidden attribute must beat component display rules */\n [hidden] { display: none !important; }\n body {\n margin: 0;\n background: var(--bg);\n color: var(--text);\n font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;\n }\n code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }\n header {\n background: var(--surface);\n border-bottom: 1px solid var(--border);\n }\n .bar {\n max-width: 1040px;\n margin: 0 auto;\n padding: 14px 20px;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .logo { font-weight: 700; font-size: 20px; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }\n .logo span span { color: var(--accent); }\n .tagline { color: var(--muted); font-size: 14px; }\n .spacer { flex: 1; }\n main { max-width: 1040px; margin: 0 auto; padding: 24px 20px 64px; }\n h2 { font-size: 15px; margin: 0; }\n .card {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n margin-bottom: 18px;\n }\n .card-head {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 10px 14px;\n padding: 14px 16px;\n border-bottom: 1px solid var(--border);\n }\n .card-head .meta { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }\n .card-body { padding: 4px 0; }\n .actions { display: flex; gap: 8px; flex-wrap: wrap; }\n button {\n font: inherit;\n font-size: 14px;\n border-radius: 8px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text);\n padding: 6px 12px;\n cursor: pointer;\n }\n button:hover { background: var(--surface-2); }\n button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }\n button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }\n button.primary:hover { background: var(--accent-strong); }\n button.danger { color: var(--bad); }\n button.link { border: none; background: none; color: var(--muted); padding: 4px 6px; }\n button.link:hover { color: var(--bad); }\n button:disabled { opacity: 0.55; cursor: progress; }\n input, select {\n font: inherit;\n font-size: 14px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 6px 10px;\n min-width: 0;\n }\n table { width: 100%; border-collapse: collapse; }\n th {\n text-align: left;\n font-size: 12px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--muted);\n font-weight: 600;\n padding: 8px 16px;\n }\n td { padding: 8px 16px; border-top: 1px solid var(--border); vertical-align: middle; }\n td.alias { font-weight: 600; white-space: nowrap; }\n .region { display: flex; gap: 6px; align-items: center; }\n .region input { width: 140px; }\n .badge {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 12px;\n font-weight: 600;\n padding: 2px 10px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--muted);\n white-space: nowrap;\n }\n .badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }\n .badge.valid { color: var(--ok); }\n .badge.refreshable { color: var(--warn); }\n .badge.expired { color: var(--bad); }\n .empty { padding: 14px 16px; color: var(--muted); }\n .missing { color: var(--bad); }\n .hero {\n text-align: center;\n padding: 48px 20px;\n }\n .hero p { color: var(--muted); max-width: 560px; margin: 8px auto 20px; }\n dialog {\n border: 1px solid var(--border);\n border-radius: 14px;\n padding: 0;\n width: min(720px, calc(100vw - 32px));\n max-height: calc(100vh - 48px);\n background: var(--surface);\n color: var(--text);\n box-shadow: 0 20px 60px rgb(0 0 0 / 30%);\n }\n dialog::backdrop { background: rgb(10 15 20 / 45%); }\n .dlg-head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }\n .dlg-head h2 { font-size: 17px; flex: 1; }\n .dlg-body { padding: 18px 20px; overflow: auto; max-height: calc(100vh - 200px); overflow-wrap: anywhere; }\n .dlg-foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; align-items: center; }\n .dlg-foot .note { flex: 1; color: var(--muted); font-size: 13px; }\n .field { display: grid; gap: 4px; margin-bottom: 14px; }\n .field label { font-weight: 600; font-size: 14px; }\n .field .help { color: var(--muted); font-size: 13px; }\n .choices { display: grid; gap: 8px; margin-bottom: 16px; }\n .choice {\n display: flex;\n gap: 10px;\n align-items: flex-start;\n border: 1px solid var(--border);\n border-radius: 10px;\n padding: 10px 12px;\n cursor: pointer;\n }\n .choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }\n .choice small { display: block; color: var(--muted); }\n .targets { display: grid; gap: 6px; }\n .target {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 6px 10px;\n align-items: center;\n border: 1px solid var(--border);\n border-radius: 10px;\n padding: 8px 12px;\n }\n .target:has(input[type="checkbox"]:checked) { border-color: var(--accent); }\n .target .pick { display: contents; cursor: pointer; }\n .target .already { display: block; color: var(--muted); font-size: 13px; }\n .target .inputs { grid-column: 2; display: none; gap: 8px; flex-wrap: wrap; }\n .target:has(input[type="checkbox"]:checked) .inputs { display: flex; }\n .target .inputs input { flex: 1 1 160px; }\n .waiting { text-align: center; padding: 28px 8px; }\n .spinner {\n width: 28px; height: 28px; margin: 0 auto 14px;\n border: 3px solid var(--border); border-top-color: var(--accent);\n border-radius: 50%; animation: spin 0.9s linear infinite;\n }\n @keyframes spin { to { transform: rotate(360deg); } }\n #toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 10; }\n .toast {\n background: var(--text);\n color: var(--bg);\n padding: 10px 14px;\n border-radius: 10px;\n max-width: 420px;\n box-shadow: 0 6px 20px rgb(0 0 0 / 25%);\n font-size: 14px;\n }\n .toast.error { background: var(--bad); color: #fff; }\n .toast small { display: block; opacity: 0.85; }\n .notice {\n border: 1px solid var(--border);\n border-left: 4px solid var(--warn);\n background: var(--surface);\n border-radius: var(--radius);\n padding: 10px 14px;\n margin-bottom: 18px;\n }\n .notice.error { border-left-color: var(--bad); }\n @media (max-width: 640px) {\n .tagline { display: none; }\n th:nth-child(3), td:nth-child(3) { display: none; }\n td, th { padding: 8px 10px; }\n td.alias { white-space: normal; overflow-wrap: anywhere; }\n .region input { width: 96px; }\n }\n </style>\n </head>\n <body>\n <header>\n <div class="bar">\n <div class="logo"><img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%2319c3d1%22/%3E%3Cstop offset=%221%22 stop-color=%22%230a6a82%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" alt="" width="28" height="28" /><span>cloud<span>hop</span></span></div>\n <div class="tagline">Hop between cloud accounts, roles and regions</div>\n <div class="spacer"></div>\n <button type="button" class="primary" id="add-source" hidden>+ Add source</button>\n </div>\n </header>\n <main id="main" aria-live="polite">\n <div class="waiting"><div class="spinner"></div>Loading\u2026</div>\n </main>\n <dialog id="dialog"></dialog>\n <div id="toasts" role="status"></div>\n <script type="module" src="/app.js"></script>\n </body>\n</html>\n';
|
|
12604
|
+
var assets_default = `<!doctype html>
|
|
12605
|
+
<html lang="en">
|
|
12606
|
+
<head>
|
|
12607
|
+
<meta charset="utf-8" />
|
|
12608
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
12609
|
+
<title>cloudhop</title>
|
|
12610
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%233BB8F5%22/%3E%3Cstop offset=%221%22 stop-color=%22%231450A8%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" />
|
|
12611
|
+
<style>
|
|
12612
|
+
/* cloudhop palette "sky and coral": sky/blue for accents and focus, coral for the main action. */
|
|
12613
|
+
:root {
|
|
12614
|
+
--bg: #f6f8fc;
|
|
12615
|
+
--surface: #ffffff;
|
|
12616
|
+
--surface-2: #eef2f9;
|
|
12617
|
+
--border: #dde3ef;
|
|
12618
|
+
--text: #0f1730;
|
|
12619
|
+
--muted: #55617d;
|
|
12620
|
+
--accent: #1767c2;
|
|
12621
|
+
--accent-strong: #0f4e99;
|
|
12622
|
+
--accent-soft: #dcebfb;
|
|
12623
|
+
--cta: #c2410c;
|
|
12624
|
+
--cta-strong: #9a3412;
|
|
12625
|
+
--on-cta: #ffffff;
|
|
12626
|
+
--ok: #1a7f52;
|
|
12627
|
+
--warn: #8a5a00;
|
|
12628
|
+
--bad: #be123c;
|
|
12629
|
+
--on-bad: #ffffff;
|
|
12630
|
+
--radius: 10px;
|
|
12631
|
+
--shadow: 0 1px 2px rgb(15 23 48 / 6%), 0 1px 3px rgb(15 23 48 / 8%);
|
|
12632
|
+
color-scheme: light;
|
|
12633
|
+
}
|
|
12634
|
+
@media (prefers-color-scheme: dark) {
|
|
12635
|
+
:root {
|
|
12636
|
+
--bg: #0b1124;
|
|
12637
|
+
--surface: #141c33;
|
|
12638
|
+
--surface-2: #1b2540;
|
|
12639
|
+
--border: #2a3555;
|
|
12640
|
+
--text: #e8ecf7;
|
|
12641
|
+
--muted: #9aa6c3;
|
|
12642
|
+
--accent: #3bb8f5;
|
|
12643
|
+
--accent-strong: #7fd0fa;
|
|
12644
|
+
--accent-soft: #12304f;
|
|
12645
|
+
--cta: #ff7a59;
|
|
12646
|
+
--cta-strong: #ff9478;
|
|
12647
|
+
--on-cta: #2a0e05;
|
|
12648
|
+
--ok: #4cd19a;
|
|
12649
|
+
--warn: #f5b84b;
|
|
12650
|
+
--bad: #fb7185;
|
|
12651
|
+
--on-bad: #2b0509;
|
|
12652
|
+
--shadow: none;
|
|
12653
|
+
color-scheme: dark;
|
|
12654
|
+
}
|
|
12655
|
+
}
|
|
12656
|
+
* { box-sizing: border-box; }
|
|
12657
|
+
/* biome-ignore lint/complexity/noImportantStyles: the hidden attribute must beat component display rules */
|
|
12658
|
+
[hidden] { display: none !important; }
|
|
12659
|
+
body {
|
|
12660
|
+
margin: 0;
|
|
12661
|
+
background: var(--bg);
|
|
12662
|
+
color: var(--text);
|
|
12663
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
12664
|
+
}
|
|
12665
|
+
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
|
|
12666
|
+
header {
|
|
12667
|
+
background: var(--surface);
|
|
12668
|
+
border-bottom: 1px solid var(--border);
|
|
12669
|
+
}
|
|
12670
|
+
.bar {
|
|
12671
|
+
max-width: 1040px;
|
|
12672
|
+
margin: 0 auto;
|
|
12673
|
+
padding: 14px 20px;
|
|
12674
|
+
display: flex;
|
|
12675
|
+
align-items: center;
|
|
12676
|
+
gap: 16px;
|
|
12677
|
+
}
|
|
12678
|
+
.logo { font-weight: 700; font-size: 20px; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }
|
|
12679
|
+
.logo span span { color: var(--cta); }
|
|
12680
|
+
.tagline { color: var(--muted); font-size: 14px; }
|
|
12681
|
+
.spacer { flex: 1; }
|
|
12682
|
+
main { max-width: 1040px; margin: 0 auto; padding: 24px 20px 64px; }
|
|
12683
|
+
h2 { font-size: 15px; margin: 0; }
|
|
12684
|
+
.card {
|
|
12685
|
+
background: var(--surface);
|
|
12686
|
+
border: 1px solid var(--border);
|
|
12687
|
+
border-radius: var(--radius);
|
|
12688
|
+
box-shadow: var(--shadow);
|
|
12689
|
+
margin-bottom: 18px;
|
|
12690
|
+
}
|
|
12691
|
+
.card-head {
|
|
12692
|
+
display: flex;
|
|
12693
|
+
flex-wrap: wrap;
|
|
12694
|
+
align-items: center;
|
|
12695
|
+
gap: 10px 14px;
|
|
12696
|
+
padding: 14px 16px;
|
|
12697
|
+
border-bottom: 1px solid var(--border);
|
|
12698
|
+
}
|
|
12699
|
+
.card-head .meta { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
|
|
12700
|
+
.card-body { padding: 4px 0; }
|
|
12701
|
+
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
12702
|
+
button {
|
|
12703
|
+
font: inherit;
|
|
12704
|
+
font-size: 14px;
|
|
12705
|
+
border-radius: 8px;
|
|
12706
|
+
border: 1px solid var(--border);
|
|
12707
|
+
background: var(--surface);
|
|
12708
|
+
color: var(--text);
|
|
12709
|
+
padding: 6px 12px;
|
|
12710
|
+
cursor: pointer;
|
|
12711
|
+
}
|
|
12712
|
+
button:hover { background: var(--surface-2); }
|
|
12713
|
+
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
12714
|
+
button.primary { background: var(--cta); border-color: var(--cta); color: var(--on-cta); font-weight: 600; }
|
|
12715
|
+
button.primary:hover { background: var(--cta-strong); border-color: var(--cta-strong); }
|
|
12716
|
+
button.danger { color: var(--bad); }
|
|
12717
|
+
button.link { border: none; background: none; color: var(--muted); padding: 4px 6px; }
|
|
12718
|
+
button.link:hover { color: var(--bad); }
|
|
12719
|
+
button:disabled { opacity: 0.55; cursor: progress; }
|
|
12720
|
+
input, select {
|
|
12721
|
+
font: inherit;
|
|
12722
|
+
font-size: 14px;
|
|
12723
|
+
color: var(--text);
|
|
12724
|
+
background: var(--surface);
|
|
12725
|
+
border: 1px solid var(--border);
|
|
12726
|
+
border-radius: 8px;
|
|
12727
|
+
padding: 6px 10px;
|
|
12728
|
+
min-width: 0;
|
|
12729
|
+
}
|
|
12730
|
+
/* Fixed columns so every source's table lines up with the others. */
|
|
12731
|
+
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
|
12732
|
+
th:nth-child(1) { width: 26%; }
|
|
12733
|
+
th:nth-child(2) { width: 190px; }
|
|
12734
|
+
th:nth-child(4) { width: 56px; }
|
|
12735
|
+
td { overflow-wrap: anywhere; }
|
|
12736
|
+
th {
|
|
12737
|
+
text-align: left;
|
|
12738
|
+
font-size: 12px;
|
|
12739
|
+
text-transform: uppercase;
|
|
12740
|
+
letter-spacing: 0.04em;
|
|
12741
|
+
color: var(--muted);
|
|
12742
|
+
font-weight: 600;
|
|
12743
|
+
padding: 8px 16px;
|
|
12744
|
+
}
|
|
12745
|
+
td { padding: 8px 16px; border-top: 1px solid var(--border); vertical-align: middle; }
|
|
12746
|
+
td.alias { font-weight: 600; }
|
|
12747
|
+
.region { display: flex; gap: 6px; align-items: center; }
|
|
12748
|
+
.region input { width: 140px; }
|
|
12749
|
+
.badge {
|
|
12750
|
+
display: inline-flex;
|
|
12751
|
+
align-items: center;
|
|
12752
|
+
gap: 6px;
|
|
12753
|
+
font-size: 12px;
|
|
12754
|
+
font-weight: 600;
|
|
12755
|
+
padding: 2px 10px;
|
|
12756
|
+
border-radius: 999px;
|
|
12757
|
+
background: var(--surface-2);
|
|
12758
|
+
color: var(--muted);
|
|
12759
|
+
white-space: nowrap;
|
|
12760
|
+
}
|
|
12761
|
+
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
|
12762
|
+
.badge.valid { color: var(--ok); }
|
|
12763
|
+
.badge.refreshable { color: var(--warn); }
|
|
12764
|
+
.badge.expired { color: var(--bad); }
|
|
12765
|
+
.empty { padding: 14px 16px; color: var(--muted); }
|
|
12766
|
+
.missing { color: var(--bad); }
|
|
12767
|
+
.hero {
|
|
12768
|
+
text-align: center;
|
|
12769
|
+
padding: 48px 20px;
|
|
12770
|
+
}
|
|
12771
|
+
.hero p { color: var(--muted); max-width: 560px; margin: 8px auto 20px; }
|
|
12772
|
+
dialog {
|
|
12773
|
+
border: 1px solid var(--border);
|
|
12774
|
+
border-radius: 14px;
|
|
12775
|
+
padding: 0;
|
|
12776
|
+
width: min(720px, calc(100vw - 32px));
|
|
12777
|
+
max-height: calc(100vh - 48px);
|
|
12778
|
+
background: var(--surface);
|
|
12779
|
+
color: var(--text);
|
|
12780
|
+
box-shadow: 0 20px 60px rgb(0 0 0 / 30%);
|
|
12781
|
+
}
|
|
12782
|
+
dialog::backdrop { background: rgb(10 15 20 / 45%); }
|
|
12783
|
+
.dlg-head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }
|
|
12784
|
+
.dlg-head h2 { font-size: 17px; flex: 1; }
|
|
12785
|
+
.dlg-body { padding: 18px 20px; overflow: auto; max-height: calc(100vh - 200px); overflow-wrap: anywhere; }
|
|
12786
|
+
.dlg-foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
|
|
12787
|
+
.dlg-foot .note { flex: 1; color: var(--muted); font-size: 13px; }
|
|
12788
|
+
.field { display: grid; gap: 4px; margin-bottom: 14px; }
|
|
12789
|
+
.field label { font-weight: 600; font-size: 14px; }
|
|
12790
|
+
.field .help { color: var(--muted); font-size: 13px; }
|
|
12791
|
+
.choices { display: grid; gap: 8px; margin-bottom: 16px; }
|
|
12792
|
+
.choice {
|
|
12793
|
+
display: flex;
|
|
12794
|
+
gap: 10px;
|
|
12795
|
+
align-items: flex-start;
|
|
12796
|
+
border: 1px solid var(--border);
|
|
12797
|
+
border-radius: 10px;
|
|
12798
|
+
padding: 10px 12px;
|
|
12799
|
+
cursor: pointer;
|
|
12800
|
+
}
|
|
12801
|
+
.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
|
12802
|
+
.choice small { display: block; color: var(--muted); }
|
|
12803
|
+
.targets { display: grid; gap: 6px; }
|
|
12804
|
+
.target {
|
|
12805
|
+
display: grid;
|
|
12806
|
+
grid-template-columns: auto 1fr;
|
|
12807
|
+
gap: 6px 10px;
|
|
12808
|
+
align-items: center;
|
|
12809
|
+
border: 1px solid var(--border);
|
|
12810
|
+
border-radius: 10px;
|
|
12811
|
+
padding: 8px 12px;
|
|
12812
|
+
}
|
|
12813
|
+
.target:has(input[type="checkbox"]:checked) { border-color: var(--accent); }
|
|
12814
|
+
.target .pick { display: contents; cursor: pointer; }
|
|
12815
|
+
.target .already { display: block; color: var(--muted); font-size: 13px; }
|
|
12816
|
+
.target .inputs { grid-column: 2; display: none; gap: 8px; flex-wrap: wrap; }
|
|
12817
|
+
.target:has(input[type="checkbox"]:checked) .inputs { display: flex; }
|
|
12818
|
+
.target .inputs input { flex: 1 1 160px; }
|
|
12819
|
+
.waiting { text-align: center; padding: 28px 8px; }
|
|
12820
|
+
.spinner {
|
|
12821
|
+
width: 28px; height: 28px; margin: 0 auto 14px;
|
|
12822
|
+
border: 3px solid var(--border); border-top-color: var(--accent);
|
|
12823
|
+
border-radius: 50%; animation: spin 0.9s linear infinite;
|
|
12824
|
+
}
|
|
12825
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
12826
|
+
#toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 10; }
|
|
12827
|
+
.toast {
|
|
12828
|
+
background: var(--text);
|
|
12829
|
+
color: var(--bg);
|
|
12830
|
+
padding: 10px 14px;
|
|
12831
|
+
border-radius: 10px;
|
|
12832
|
+
max-width: 420px;
|
|
12833
|
+
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
|
|
12834
|
+
font-size: 14px;
|
|
12835
|
+
}
|
|
12836
|
+
.toast.error { background: var(--bad); color: var(--on-bad); }
|
|
12837
|
+
.toast small { display: block; opacity: 0.85; }
|
|
12838
|
+
.notice {
|
|
12839
|
+
border: 1px solid var(--border);
|
|
12840
|
+
border-left: 4px solid var(--warn);
|
|
12841
|
+
background: var(--surface);
|
|
12842
|
+
border-radius: var(--radius);
|
|
12843
|
+
padding: 10px 14px;
|
|
12844
|
+
margin-bottom: 18px;
|
|
12845
|
+
}
|
|
12846
|
+
.notice.error { border-left-color: var(--bad); }
|
|
12847
|
+
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
12848
|
+
.seg button { border: 0; border-radius: 0; }
|
|
12849
|
+
.seg button + button { border-left: 1px solid var(--border); }
|
|
12850
|
+
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
|
|
12851
|
+
.card-help { color: var(--muted); font-size: 14px; margin: 10px 16px 6px; }
|
|
12852
|
+
.muted-inline { color: var(--muted); font-weight: 400; font-size: 12px; }
|
|
12853
|
+
table.assistants th:nth-child(2) { width: 170px; }
|
|
12854
|
+
table.assistants th:nth-child(3) { width: 140px; }
|
|
12855
|
+
table.assistants th:nth-child(4) { width: 230px; }
|
|
12856
|
+
@media (max-width: 640px) {
|
|
12857
|
+
.tagline { display: none; }
|
|
12858
|
+
th:nth-child(3), td:nth-child(3) { display: none; }
|
|
12859
|
+
td, th { padding: 8px 10px; }
|
|
12860
|
+
td.alias { white-space: normal; overflow-wrap: anywhere; }
|
|
12861
|
+
.region input { width: 96px; }
|
|
12862
|
+
th:nth-child(1) { width: auto; }
|
|
12863
|
+
th:nth-child(2) { width: 124px; }
|
|
12864
|
+
table.assistants th:nth-child(2) { width: 112px; }
|
|
12865
|
+
table.assistants th:nth-child(4) { width: 96px; }
|
|
12866
|
+
table.assistants .actions { flex-direction: column; align-items: flex-end; }
|
|
12867
|
+
.card-help { margin: 10px 10px 6px; }
|
|
12868
|
+
}
|
|
12869
|
+
</style>
|
|
12870
|
+
</head>
|
|
12871
|
+
<body>
|
|
12872
|
+
<header>
|
|
12873
|
+
<div class="bar">
|
|
12874
|
+
<div class="logo"><img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%233BB8F5%22/%3E%3Cstop offset=%221%22 stop-color=%22%231450A8%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" alt="" width="28" height="28" /><span>cloud<span>hop</span></span></div>
|
|
12875
|
+
<div class="tagline">Hop between cloud accounts, roles and regions</div>
|
|
12876
|
+
<div class="spacer"></div>
|
|
12877
|
+
<button type="button" class="primary" id="add-source" hidden>+ Add source</button>
|
|
12878
|
+
</div>
|
|
12879
|
+
</header>
|
|
12880
|
+
<main id="main" aria-live="polite">
|
|
12881
|
+
<div class="waiting"><div class="spinner"></div>Loading\u2026</div>
|
|
12882
|
+
</main>
|
|
12883
|
+
<dialog id="dialog"></dialog>
|
|
12884
|
+
<div id="toasts" role="status"></div>
|
|
12885
|
+
<script type="module" src="/app.js"></script>
|
|
12886
|
+
</body>
|
|
12887
|
+
</html>
|
|
12888
|
+
`;
|
|
11624
12889
|
|
|
11625
12890
|
// ../ui/src/server.ts
|
|
11626
12891
|
var MAX_BODY = 64 * 1024;
|
|
@@ -11647,9 +12912,13 @@ async function readJson(req) {
|
|
|
11647
12912
|
return raw ? JSON.parse(raw) : {};
|
|
11648
12913
|
}
|
|
11649
12914
|
var sameSecret = (given, expected) => given !== void 0 && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
|
|
11650
|
-
async function startUiServer({
|
|
12915
|
+
async function startUiServer({
|
|
12916
|
+
app,
|
|
12917
|
+
assistants,
|
|
12918
|
+
port = 0
|
|
12919
|
+
}) {
|
|
11651
12920
|
const token = randomBytes(24).toString("base64url");
|
|
11652
|
-
const handle = createApi(app);
|
|
12921
|
+
const handle = createApi(app, assistants);
|
|
11653
12922
|
let allowedHosts = /* @__PURE__ */ new Set();
|
|
11654
12923
|
const server = createServer(async (req, res) => {
|
|
11655
12924
|
try {
|
|
@@ -11709,12 +12978,12 @@ async function startUiServer({ app, port = 0 }) {
|
|
|
11709
12978
|
}
|
|
11710
12979
|
|
|
11711
12980
|
// src/commands/ui.ts
|
|
11712
|
-
async function uiCommand(app, { flags }) {
|
|
12981
|
+
async function uiCommand(app, assistants, { flags }) {
|
|
11713
12982
|
const port = flags.port ? Number(flags.port) : 0;
|
|
11714
12983
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
11715
12984
|
throw new CloudhopError("INVALID_NAME", `--port must be a number between 1 and 65535.`);
|
|
11716
12985
|
}
|
|
11717
|
-
const server = await startUiServer({ app, port });
|
|
12986
|
+
const server = await startUiServer({ app, assistants, port });
|
|
11718
12987
|
console.log(`cloudhop UI running at ${bold(server.url)}`);
|
|
11719
12988
|
console.log("Only this computer can open it, and the link works only while this runs.");
|
|
11720
12989
|
console.log("Press Ctrl-C to stop.");
|
|
@@ -11727,15 +12996,15 @@ async function uiCommand(app, { flags }) {
|
|
|
11727
12996
|
}
|
|
11728
12997
|
|
|
11729
12998
|
// src/container.ts
|
|
11730
|
-
import { homedir as
|
|
11731
|
-
import { join as
|
|
12999
|
+
import { homedir as homedir3 } from "os";
|
|
13000
|
+
import { join as join7 } from "path";
|
|
11732
13001
|
|
|
11733
13002
|
// ../provider-aws/src/aws-provider.ts
|
|
11734
|
-
import { join as
|
|
13003
|
+
import { join as join6 } from "path";
|
|
11735
13004
|
|
|
11736
13005
|
// ../provider-aws/src/aws-config.ts
|
|
11737
|
-
import { mkdir as
|
|
11738
|
-
import { dirname as
|
|
13006
|
+
import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
13007
|
+
import { dirname as dirname3 } from "path";
|
|
11739
13008
|
var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand)";
|
|
11740
13009
|
var BLOCK_END = "# <<< cloudhop managed <<<";
|
|
11741
13010
|
function renderBlock(sections) {
|
|
@@ -11782,13 +13051,13 @@ function replaceManagedBlock(content, sections) {
|
|
|
11782
13051
|
async function writeManagedBlock(path2, sections) {
|
|
11783
13052
|
let content = "";
|
|
11784
13053
|
try {
|
|
11785
|
-
content = await
|
|
13054
|
+
content = await readFile3(path2, "utf8");
|
|
11786
13055
|
} catch {
|
|
11787
13056
|
}
|
|
11788
13057
|
const next = replaceManagedBlock(content, sections);
|
|
11789
13058
|
if (next === content) return;
|
|
11790
|
-
await
|
|
11791
|
-
await
|
|
13059
|
+
await mkdir3(dirname3(path2), { recursive: true, mode: 448 });
|
|
13060
|
+
await writeFile3(path2, next, { mode: 384 });
|
|
11792
13061
|
}
|
|
11793
13062
|
|
|
11794
13063
|
// ../provider-aws/src/http.ts
|
|
@@ -11803,11 +13072,11 @@ var AwsHttpError = class extends Error {
|
|
|
11803
13072
|
code;
|
|
11804
13073
|
};
|
|
11805
13074
|
async function parse2(response) {
|
|
11806
|
-
const
|
|
11807
|
-
const body =
|
|
13075
|
+
const text4 = await response.text();
|
|
13076
|
+
const body = text4 ? JSON.parse(text4) : {};
|
|
11808
13077
|
if (!response.ok) {
|
|
11809
13078
|
const code = body.error ?? body.__type ?? body.code;
|
|
11810
|
-
const description = body.error_description ?? body.message ??
|
|
13079
|
+
const description = body.error_description ?? body.message ?? text4;
|
|
11811
13080
|
throw new AwsHttpError(
|
|
11812
13081
|
response.status,
|
|
11813
13082
|
code,
|
|
@@ -11875,7 +13144,7 @@ var PortalClient = class {
|
|
|
11875
13144
|
};
|
|
11876
13145
|
|
|
11877
13146
|
// ../provider-aws/src/sso/sso-login.ts
|
|
11878
|
-
import { createHash as
|
|
13147
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
|
|
11879
13148
|
|
|
11880
13149
|
// ../provider-aws/src/sso/loopback.ts
|
|
11881
13150
|
import { createServer as createServer2 } from "http";
|
|
@@ -12008,24 +13277,24 @@ var OidcClient = class {
|
|
|
12008
13277
|
};
|
|
12009
13278
|
|
|
12010
13279
|
// ../provider-aws/src/sso/token-cache.ts
|
|
12011
|
-
import { createHash } from "crypto";
|
|
12012
|
-
import { mkdir as
|
|
12013
|
-
import { join as
|
|
13280
|
+
import { createHash as createHash2 } from "crypto";
|
|
13281
|
+
import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
|
|
13282
|
+
import { join as join5 } from "path";
|
|
12014
13283
|
var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
|
|
12015
13284
|
function tokenCachePath(awsDir, sessionName) {
|
|
12016
|
-
const hash =
|
|
12017
|
-
return
|
|
13285
|
+
const hash = createHash2("sha1").update(sessionName).digest("hex");
|
|
13286
|
+
return join5(awsDir, "sso", "cache", `${hash}.json`);
|
|
12018
13287
|
}
|
|
12019
13288
|
async function readToken(awsDir, sessionName) {
|
|
12020
13289
|
try {
|
|
12021
|
-
return JSON.parse(await
|
|
13290
|
+
return JSON.parse(await readFile4(tokenCachePath(awsDir, sessionName), "utf8"));
|
|
12022
13291
|
} catch {
|
|
12023
13292
|
return void 0;
|
|
12024
13293
|
}
|
|
12025
13294
|
}
|
|
12026
13295
|
async function writeToken(awsDir, sessionName, token) {
|
|
12027
|
-
await
|
|
12028
|
-
await
|
|
13296
|
+
await mkdir4(join5(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
|
|
13297
|
+
await writeFile4(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
|
|
12029
13298
|
mode: 384
|
|
12030
13299
|
});
|
|
12031
13300
|
}
|
|
@@ -12068,7 +13337,7 @@ async function authorizationCodeLogin(options) {
|
|
|
12068
13337
|
clientId: client.clientId,
|
|
12069
13338
|
redirectUri: server.redirectUri,
|
|
12070
13339
|
state,
|
|
12071
|
-
codeChallenge: base64url(
|
|
13340
|
+
codeChallenge: base64url(createHash3("sha256").update(codeVerifier).digest())
|
|
12072
13341
|
});
|
|
12073
13342
|
options.interaction.notify("Opening your browser to log in\u2026");
|
|
12074
13343
|
options.interaction.notify(`If it doesn't open, visit: ${url}`);
|
|
@@ -12173,6 +13442,7 @@ var AwsProvider = class {
|
|
|
12173
13442
|
id = "aws";
|
|
12174
13443
|
displayName = "AWS";
|
|
12175
13444
|
cli = { command: "aws", displayName: "AWS CLI v2", minVersion: "2.32.0" };
|
|
13445
|
+
identityArgs = ["sts", "get-caller-identity"];
|
|
12176
13446
|
sourceKinds = [
|
|
12177
13447
|
{
|
|
12178
13448
|
kind: SSO_KIND,
|
|
@@ -12281,7 +13551,7 @@ var AwsProvider = class {
|
|
|
12281
13551
|
}
|
|
12282
13552
|
});
|
|
12283
13553
|
}
|
|
12284
|
-
await writeManagedBlock(
|
|
13554
|
+
await writeManagedBlock(join6(this.options.awsDir, "config"), sections);
|
|
12285
13555
|
}
|
|
12286
13556
|
async processEnv(resolved, mode) {
|
|
12287
13557
|
const region = resolved.region;
|
|
@@ -12346,7 +13616,7 @@ var AwsProvider = class {
|
|
|
12346
13616
|
// src/container.ts
|
|
12347
13617
|
function createApp(env = process.env) {
|
|
12348
13618
|
const runner = new NodeProcessRunner();
|
|
12349
|
-
const awsDir = env.CLOUDHOP_AWS_DIR ??
|
|
13619
|
+
const awsDir = env.CLOUDHOP_AWS_DIR ?? join7(homedir3(), ".aws");
|
|
12350
13620
|
return new CloudhopApp({
|
|
12351
13621
|
config: new YamlConfigStore(),
|
|
12352
13622
|
providers: [
|
|
@@ -12363,9 +13633,55 @@ function createApp(env = process.env) {
|
|
|
12363
13633
|
notify
|
|
12364
13634
|
});
|
|
12365
13635
|
}
|
|
13636
|
+
function createAssistants(version, env = process.env) {
|
|
13637
|
+
return new AssistantsInstaller({
|
|
13638
|
+
places: { home: homedir3(), project: process.cwd() },
|
|
13639
|
+
configDir: defaultConfigDir(env),
|
|
13640
|
+
runner: new NodeProcessRunner(),
|
|
13641
|
+
cloudhopVersion: version
|
|
13642
|
+
});
|
|
13643
|
+
}
|
|
13644
|
+
|
|
13645
|
+
// src/update-check.ts
|
|
13646
|
+
import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
|
|
13647
|
+
import { join as join8 } from "path";
|
|
13648
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
13649
|
+
var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
|
|
13650
|
+
function startUpdateCheck(configDir, current) {
|
|
13651
|
+
const env = process.env;
|
|
13652
|
+
if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
|
|
13653
|
+
return async () => {
|
|
13654
|
+
};
|
|
13655
|
+
const file = join8(configDir, "update-check.json");
|
|
13656
|
+
const work = (async () => {
|
|
13657
|
+
let cache = { checkedAt: 0 };
|
|
13658
|
+
try {
|
|
13659
|
+
cache = JSON.parse(await readFile5(file, "utf8"));
|
|
13660
|
+
} catch {
|
|
13661
|
+
}
|
|
13662
|
+
if (cache.latest && compareVersions(cache.latest, current) > 0) {
|
|
13663
|
+
process.stderr.write(
|
|
13664
|
+
`\x1B[2mcloudhop \u203A\x1B[22m cloudhop ${cache.latest} is available (you have ${current}): npm i -g cloudhop
|
|
13665
|
+
`
|
|
13666
|
+
);
|
|
13667
|
+
}
|
|
13668
|
+
if (Date.now() - cache.checkedAt < DAY_MS) return;
|
|
13669
|
+
const response = await fetch(REGISTRY_URL, {
|
|
13670
|
+
headers: { accept: "application/json" },
|
|
13671
|
+
signal: AbortSignal.timeout(1500)
|
|
13672
|
+
});
|
|
13673
|
+
if (!response.ok) return;
|
|
13674
|
+
const { version } = await response.json();
|
|
13675
|
+
if (typeof version !== "string") return;
|
|
13676
|
+
await mkdir5(configDir, { recursive: true });
|
|
13677
|
+
await writeFile5(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
|
|
13678
|
+
})().catch(() => {
|
|
13679
|
+
});
|
|
13680
|
+
return () => Promise.race([work, new Promise((resolve) => setTimeout(resolve, 300))]);
|
|
13681
|
+
}
|
|
12366
13682
|
|
|
12367
13683
|
// src/cli.ts
|
|
12368
|
-
var HELP = `cloudhop ${"
|
|
13684
|
+
var HELP = `cloudhop ${"1.1.0"}
|
|
12369
13685
|
Hop between cloud accounts, roles and regions with one command.
|
|
12370
13686
|
|
|
12371
13687
|
Usage
|
|
@@ -12384,10 +13700,15 @@ Setup
|
|
|
12384
13700
|
Change the region an alias runs in
|
|
12385
13701
|
cloudhop login [source] Log in again and refresh its accounts
|
|
12386
13702
|
cloudhop sync [source] Refresh the accounts/roles you have access to
|
|
12387
|
-
cloudhop ls [source]
|
|
13703
|
+
cloudhop ls [source] [--json] List sources and their aliases
|
|
13704
|
+
cloudhop status [alias] [--json]
|
|
13705
|
+
Can it run now, or will it need a sign-in?
|
|
12388
13706
|
cloudhop rm <name...> [--yes] Remove aliases or sources (a source takes its aliases)
|
|
12389
13707
|
cloudhop provider [ls|enable|disable] <provider>
|
|
12390
13708
|
cloudhop doctor Check (and install) the cloud CLIs
|
|
13709
|
+
cloudhop agent [status|install|update|remove] [--tools <list>] [--scope user|project] [--no-mcp]
|
|
13710
|
+
Teach Claude Code, Kiro, Copilot, Cursor or Codex to use cloudhop
|
|
13711
|
+
cloudhop mcp Model Context Protocol server for AI assistants (stdio)
|
|
12391
13712
|
cloudhop ui [--port <n>] [--no-open]
|
|
12392
13713
|
Manage sources and aliases in a local web page
|
|
12393
13714
|
|
|
@@ -12411,14 +13732,38 @@ async function main(argv) {
|
|
|
12411
13732
|
return 0;
|
|
12412
13733
|
}
|
|
12413
13734
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
12414
|
-
console.log("
|
|
13735
|
+
console.log("1.1.0");
|
|
12415
13736
|
return 0;
|
|
12416
13737
|
}
|
|
12417
13738
|
const app = createApp();
|
|
12418
13739
|
const local = RESERVED_NAMES.has(command) ? takeGlobalFlags(args) : args;
|
|
13740
|
+
if (command === "mcp") {
|
|
13741
|
+
await serveMcp({ app, version: "1.1.0" });
|
|
13742
|
+
return 0;
|
|
13743
|
+
}
|
|
13744
|
+
const assistants = createAssistants("1.1.0");
|
|
13745
|
+
await refreshAssistantSkills(assistants, command);
|
|
13746
|
+
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.1.0");
|
|
13747
|
+
try {
|
|
13748
|
+
return await dispatch(app, assistants, command, args, local);
|
|
13749
|
+
} finally {
|
|
13750
|
+
await finishUpdateCheck();
|
|
13751
|
+
}
|
|
13752
|
+
}
|
|
13753
|
+
async function refreshAssistantSkills(assistants, command) {
|
|
13754
|
+
if (command === "agent") return;
|
|
13755
|
+
try {
|
|
13756
|
+
const { updated } = await assistants.update();
|
|
13757
|
+
if (updated.length > 0) {
|
|
13758
|
+
notify(`Updated the cloudhop skill for your AI assistants (${updated.length} file(s)).`);
|
|
13759
|
+
}
|
|
13760
|
+
} catch {
|
|
13761
|
+
}
|
|
13762
|
+
}
|
|
13763
|
+
async function dispatch(app, assistants, command, args, local) {
|
|
12419
13764
|
switch (command) {
|
|
12420
13765
|
case "setup":
|
|
12421
|
-
await setupCommand(app);
|
|
13766
|
+
await setupCommand(app, assistants);
|
|
12422
13767
|
return 0;
|
|
12423
13768
|
case "add":
|
|
12424
13769
|
await addCommand(app, parseArgs(local));
|
|
@@ -12426,6 +13771,9 @@ async function main(argv) {
|
|
|
12426
13771
|
case "alias":
|
|
12427
13772
|
await aliasCommand(app, parseArgs(local));
|
|
12428
13773
|
return 0;
|
|
13774
|
+
case "status":
|
|
13775
|
+
await statusCommand(app, local);
|
|
13776
|
+
return 0;
|
|
12429
13777
|
case "region":
|
|
12430
13778
|
await regionCommand(app, local);
|
|
12431
13779
|
return 0;
|
|
@@ -12437,7 +13785,7 @@ async function main(argv) {
|
|
|
12437
13785
|
return 0;
|
|
12438
13786
|
case "ls":
|
|
12439
13787
|
case "list":
|
|
12440
|
-
await listCommand(app, local
|
|
13788
|
+
await listCommand(app, local);
|
|
12441
13789
|
return 0;
|
|
12442
13790
|
case "rm":
|
|
12443
13791
|
case "remove":
|
|
@@ -12451,8 +13799,11 @@ async function main(argv) {
|
|
|
12451
13799
|
return 0;
|
|
12452
13800
|
case "exec":
|
|
12453
13801
|
return execCommand(app, local);
|
|
13802
|
+
case "agent":
|
|
13803
|
+
await agentCommand(assistants, local);
|
|
13804
|
+
return 0;
|
|
12454
13805
|
case "ui":
|
|
12455
|
-
await uiCommand(app, parseArgs(local));
|
|
13806
|
+
await uiCommand(app, assistants, parseArgs(local));
|
|
12456
13807
|
return 0;
|
|
12457
13808
|
default:
|
|
12458
13809
|
return proxyCommand(app, command, args);
|