cloudhop 1.0.0 → 1.2.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 +27 -1
- package/dist/cli.js +2685 -575
- package/package.json +7 -4
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;
|
|
@@ -1567,7 +1511,7 @@ var require_stringify = __commonJS({
|
|
|
1567
1511
|
props.push(doc.directives.tagString(tag));
|
|
1568
1512
|
return props.join(" ");
|
|
1569
1513
|
}
|
|
1570
|
-
function
|
|
1514
|
+
function stringify3(item, ctx, onComment, onChompKeep) {
|
|
1571
1515
|
if (identity.isPair(item))
|
|
1572
1516
|
return item.toString(ctx, onComment, onChompKeep);
|
|
1573
1517
|
if (identity.isAlias(item)) {
|
|
@@ -1596,7 +1540,7 @@ var require_stringify = __commonJS({
|
|
|
1596
1540
|
${ctx.indent}${str}`;
|
|
1597
1541
|
}
|
|
1598
1542
|
exports.createStringifyContext = createStringifyContext;
|
|
1599
|
-
exports.stringify =
|
|
1543
|
+
exports.stringify = stringify3;
|
|
1600
1544
|
}
|
|
1601
1545
|
});
|
|
1602
1546
|
|
|
@@ -1606,7 +1550,7 @@ var require_stringifyPair = __commonJS({
|
|
|
1606
1550
|
"use strict";
|
|
1607
1551
|
var identity = require_identity();
|
|
1608
1552
|
var Scalar = require_Scalar();
|
|
1609
|
-
var
|
|
1553
|
+
var stringify3 = require_stringify();
|
|
1610
1554
|
var stringifyComment = require_stringifyComment();
|
|
1611
1555
|
function stringifyPair({ key, value }, ctx, onComment, onChompKeep) {
|
|
1612
1556
|
const { allNullValues, doc, indent, indentStep, options: { commentString, indentSeq, simpleKeys } } = ctx;
|
|
@@ -1628,7 +1572,7 @@ var require_stringifyPair = __commonJS({
|
|
|
1628
1572
|
});
|
|
1629
1573
|
let keyCommentDone = false;
|
|
1630
1574
|
let chompKeep = false;
|
|
1631
|
-
let str =
|
|
1575
|
+
let str = stringify3.stringify(key, ctx, () => keyCommentDone = true, () => chompKeep = true);
|
|
1632
1576
|
if (!explicitKey && !ctx.inFlow && str.length > 1024) {
|
|
1633
1577
|
if (simpleKeys)
|
|
1634
1578
|
throw new Error("With simple keys, single line scalar must not span more than 1024 characters");
|
|
@@ -1680,7 +1624,7 @@ ${indent}:`;
|
|
|
1680
1624
|
ctx.indent = ctx.indent.substring(2);
|
|
1681
1625
|
}
|
|
1682
1626
|
let valueCommentDone = false;
|
|
1683
|
-
const valueStr =
|
|
1627
|
+
const valueStr = stringify3.stringify(value, ctx, () => valueCommentDone = true, () => chompKeep = true);
|
|
1684
1628
|
let ws = " ";
|
|
1685
1629
|
if (keyComment || vsb || vcb) {
|
|
1686
1630
|
ws = vsb ? "\n" : "";
|
|
@@ -1821,7 +1765,7 @@ var require_addPairToJSMap = __commonJS({
|
|
|
1821
1765
|
"use strict";
|
|
1822
1766
|
var log2 = require_log();
|
|
1823
1767
|
var merge = require_merge();
|
|
1824
|
-
var
|
|
1768
|
+
var stringify3 = require_stringify();
|
|
1825
1769
|
var identity = require_identity();
|
|
1826
1770
|
var toJS = require_toJS();
|
|
1827
1771
|
function addPairToJSMap(ctx, map, { key, value }) {
|
|
@@ -1857,7 +1801,7 @@ var require_addPairToJSMap = __commonJS({
|
|
|
1857
1801
|
if (typeof jsKey !== "object")
|
|
1858
1802
|
return String(jsKey);
|
|
1859
1803
|
if (identity.isNode(key) && ctx?.doc) {
|
|
1860
|
-
const strCtx =
|
|
1804
|
+
const strCtx = stringify3.createStringifyContext(ctx.doc, {});
|
|
1861
1805
|
strCtx.anchors = /* @__PURE__ */ new Set();
|
|
1862
1806
|
for (const node of ctx.anchors.keys())
|
|
1863
1807
|
strCtx.anchors.add(node.anchor);
|
|
@@ -1924,12 +1868,12 @@ var require_stringifyCollection = __commonJS({
|
|
|
1924
1868
|
"../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/stringify/stringifyCollection.js"(exports) {
|
|
1925
1869
|
"use strict";
|
|
1926
1870
|
var identity = require_identity();
|
|
1927
|
-
var
|
|
1871
|
+
var stringify3 = require_stringify();
|
|
1928
1872
|
var stringifyComment = require_stringifyComment();
|
|
1929
1873
|
function stringifyCollection(collection, ctx, options) {
|
|
1930
1874
|
const flow = ctx.inFlow ?? collection.flow;
|
|
1931
|
-
const
|
|
1932
|
-
return
|
|
1875
|
+
const stringify4 = flow ? stringifyFlowCollection : stringifyBlockCollection;
|
|
1876
|
+
return stringify4(collection, ctx, options);
|
|
1933
1877
|
}
|
|
1934
1878
|
function stringifyBlockCollection({ comment, items }, ctx, { blockItemPrefix, flowChars, itemIndent, onChompKeep, onComment }) {
|
|
1935
1879
|
const { indent, options: { commentString } } = ctx;
|
|
@@ -1954,7 +1898,7 @@ var require_stringifyCollection = __commonJS({
|
|
|
1954
1898
|
}
|
|
1955
1899
|
}
|
|
1956
1900
|
chompKeep = false;
|
|
1957
|
-
let str2 =
|
|
1901
|
+
let str2 = stringify3.stringify(item, itemCtx, () => comment2 = null, () => chompKeep = true);
|
|
1958
1902
|
if (comment2)
|
|
1959
1903
|
str2 += stringifyComment.lineComment(str2, itemIndent, commentString(comment2));
|
|
1960
1904
|
if (chompKeep && comment2)
|
|
@@ -2021,7 +1965,7 @@ ${indent}${line}` : "\n";
|
|
|
2021
1965
|
}
|
|
2022
1966
|
if (comment)
|
|
2023
1967
|
reqNewline = true;
|
|
2024
|
-
let str =
|
|
1968
|
+
let str = stringify3.stringify(item, itemCtx, () => comment = null);
|
|
2025
1969
|
reqNewline || (reqNewline = lines.length > linesAtValue || str.includes("\n"));
|
|
2026
1970
|
if (i2 < items.length - 1) {
|
|
2027
1971
|
str += ",";
|
|
@@ -3382,7 +3326,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3382
3326
|
"../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/stringify/stringifyDocument.js"(exports) {
|
|
3383
3327
|
"use strict";
|
|
3384
3328
|
var identity = require_identity();
|
|
3385
|
-
var
|
|
3329
|
+
var stringify3 = require_stringify();
|
|
3386
3330
|
var stringifyComment = require_stringifyComment();
|
|
3387
3331
|
function stringifyDocument(doc, options) {
|
|
3388
3332
|
const lines = [];
|
|
@@ -3397,7 +3341,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3397
3341
|
}
|
|
3398
3342
|
if (hasDirectives)
|
|
3399
3343
|
lines.push("---");
|
|
3400
|
-
const ctx =
|
|
3344
|
+
const ctx = stringify3.createStringifyContext(doc, options);
|
|
3401
3345
|
const { commentString } = ctx.options;
|
|
3402
3346
|
if (doc.commentBefore) {
|
|
3403
3347
|
if (lines.length !== 1)
|
|
@@ -3419,7 +3363,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3419
3363
|
contentComment = doc.contents.comment;
|
|
3420
3364
|
}
|
|
3421
3365
|
const onChompKeep = contentComment ? void 0 : () => chompKeep = true;
|
|
3422
|
-
let body =
|
|
3366
|
+
let body = stringify3.stringify(doc.contents, ctx, () => contentComment = null, onChompKeep);
|
|
3423
3367
|
if (contentComment)
|
|
3424
3368
|
body += stringifyComment.lineComment(body, "", commentString(contentComment));
|
|
3425
3369
|
if ((body[0] === "|" || body[0] === ">") && lines[lines.length - 1] === "---") {
|
|
@@ -3427,7 +3371,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3427
3371
|
} else
|
|
3428
3372
|
lines.push(body);
|
|
3429
3373
|
} else {
|
|
3430
|
-
lines.push(
|
|
3374
|
+
lines.push(stringify3.stringify(doc.contents, ctx));
|
|
3431
3375
|
}
|
|
3432
3376
|
if (doc.directives?.docEnd) {
|
|
3433
3377
|
if (doc.comment) {
|
|
@@ -5563,7 +5507,7 @@ var require_cst_scalar = __commonJS({
|
|
|
5563
5507
|
var require_cst_stringify = __commonJS({
|
|
5564
5508
|
"../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/parse/cst-stringify.js"(exports) {
|
|
5565
5509
|
"use strict";
|
|
5566
|
-
var
|
|
5510
|
+
var stringify3 = (cst) => "type" in cst ? stringifyToken(cst) : stringifyItem(cst);
|
|
5567
5511
|
function stringifyToken(token) {
|
|
5568
5512
|
switch (token.type) {
|
|
5569
5513
|
case "block-scalar": {
|
|
@@ -5616,7 +5560,7 @@ var require_cst_stringify = __commonJS({
|
|
|
5616
5560
|
res += stringifyToken(value);
|
|
5617
5561
|
return res;
|
|
5618
5562
|
}
|
|
5619
|
-
exports.stringify =
|
|
5563
|
+
exports.stringify = stringify3;
|
|
5620
5564
|
}
|
|
5621
5565
|
});
|
|
5622
5566
|
|
|
@@ -6102,9 +6046,9 @@ var require_lexer = __commonJS({
|
|
|
6102
6046
|
}
|
|
6103
6047
|
}
|
|
6104
6048
|
*parseQuotedScalar() {
|
|
6105
|
-
const
|
|
6106
|
-
let end = this.buffer.indexOf(
|
|
6107
|
-
if (
|
|
6049
|
+
const quote2 = this.charAt(0);
|
|
6050
|
+
let end = this.buffer.indexOf(quote2, this.pos + 1);
|
|
6051
|
+
if (quote2 === "'") {
|
|
6108
6052
|
while (end !== -1 && this.buffer[end + 1] === "'")
|
|
6109
6053
|
end = this.buffer.indexOf("'", end + 2);
|
|
6110
6054
|
} else {
|
|
@@ -7327,7 +7271,7 @@ var require_public_api = __commonJS({
|
|
|
7327
7271
|
}
|
|
7328
7272
|
return doc;
|
|
7329
7273
|
}
|
|
7330
|
-
function
|
|
7274
|
+
function parse4(src, reviver, options) {
|
|
7331
7275
|
let _reviver = void 0;
|
|
7332
7276
|
if (typeof reviver === "function") {
|
|
7333
7277
|
_reviver = reviver;
|
|
@@ -7346,7 +7290,7 @@ var require_public_api = __commonJS({
|
|
|
7346
7290
|
}
|
|
7347
7291
|
return doc.toJS(Object.assign({ reviver: _reviver }, options));
|
|
7348
7292
|
}
|
|
7349
|
-
function
|
|
7293
|
+
function stringify3(value, replacer, options) {
|
|
7350
7294
|
let _replacer = null;
|
|
7351
7295
|
if (typeof replacer === "function" || Array.isArray(replacer)) {
|
|
7352
7296
|
_replacer = replacer;
|
|
@@ -7368,10 +7312,10 @@ var require_public_api = __commonJS({
|
|
|
7368
7312
|
return value.toString(options);
|
|
7369
7313
|
return new Document.Document(value, _replacer, options).toString(options);
|
|
7370
7314
|
}
|
|
7371
|
-
exports.parse =
|
|
7315
|
+
exports.parse = parse4;
|
|
7372
7316
|
exports.parseAllDocuments = parseAllDocuments;
|
|
7373
7317
|
exports.parseDocument = parseDocument;
|
|
7374
|
-
exports.stringify =
|
|
7318
|
+
exports.stringify = stringify3;
|
|
7375
7319
|
}
|
|
7376
7320
|
});
|
|
7377
7321
|
|
|
@@ -7427,90 +7371,378 @@ 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
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
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
|
+
// src/cli.ts
|
|
7431
|
+
import { basename } from "path";
|
|
7432
|
+
|
|
7433
|
+
// ../adapters-node/src/node-browser-opener.ts
|
|
7434
|
+
import { spawn } from "child_process";
|
|
7435
|
+
var NodeBrowserOpener = class {
|
|
7436
|
+
async open(url) {
|
|
7437
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
7438
|
+
await new Promise((resolve) => {
|
|
7439
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
7440
|
+
child.on("error", () => resolve());
|
|
7441
|
+
child.on("spawn", () => {
|
|
7442
|
+
child.unref();
|
|
7443
|
+
resolve();
|
|
7444
|
+
});
|
|
7445
|
+
});
|
|
7476
7446
|
}
|
|
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
|
-
}
|
|
7447
|
+
};
|
|
7482
7448
|
|
|
7483
|
-
// ../
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
if (
|
|
7449
|
+
// ../adapters-node/src/node-process-runner.ts
|
|
7450
|
+
import { spawn as spawn2 } from "child_process";
|
|
7451
|
+
var STDERR_TAIL_BYTES = 8 * 1024;
|
|
7452
|
+
function buildEnv(overrides) {
|
|
7453
|
+
const env = { ...process.env };
|
|
7454
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
7455
|
+
if (value === void 0) delete env[key];
|
|
7456
|
+
else env[key] = value;
|
|
7490
7457
|
}
|
|
7491
|
-
return
|
|
7458
|
+
return env;
|
|
7492
7459
|
}
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7460
|
+
var useShell = process.platform === "win32";
|
|
7461
|
+
var NodeProcessRunner = class {
|
|
7462
|
+
run(command, args, options = {}) {
|
|
7463
|
+
return new Promise((resolve, reject) => {
|
|
7464
|
+
const child = spawn2(command, args, {
|
|
7465
|
+
env: buildEnv(options.env),
|
|
7466
|
+
stdio: ["inherit", "inherit", options.captureStderr ? "pipe" : "inherit"],
|
|
7467
|
+
shell: useShell
|
|
7468
|
+
});
|
|
7469
|
+
let tail = "";
|
|
7470
|
+
child.stderr?.on("data", (chunk) => {
|
|
7471
|
+
process.stderr.write(chunk);
|
|
7472
|
+
tail = (tail + chunk.toString()).slice(-STDERR_TAIL_BYTES);
|
|
7473
|
+
});
|
|
7474
|
+
const ignore = () => {
|
|
7475
|
+
};
|
|
7476
|
+
process.on("SIGINT", ignore);
|
|
7477
|
+
process.on("SIGTERM", ignore);
|
|
7478
|
+
const cleanup = () => {
|
|
7479
|
+
process.off("SIGINT", ignore);
|
|
7480
|
+
process.off("SIGTERM", ignore);
|
|
7481
|
+
};
|
|
7482
|
+
child.on("error", (error) => {
|
|
7483
|
+
cleanup();
|
|
7484
|
+
reject(error);
|
|
7485
|
+
});
|
|
7486
|
+
child.on("close", (code, signal) => {
|
|
7487
|
+
cleanup();
|
|
7488
|
+
resolve({ exitCode: code ?? (signal ? 128 + 2 : 1), stderrTail: tail });
|
|
7489
|
+
});
|
|
7490
|
+
});
|
|
7498
7491
|
}
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7492
|
+
capture(command, args, options = {}) {
|
|
7493
|
+
return new Promise((resolve) => {
|
|
7494
|
+
const child = spawn2(command, args, { env: buildEnv(options.env), shell: useShell });
|
|
7495
|
+
child.stdin.end();
|
|
7496
|
+
let stdout2 = "";
|
|
7497
|
+
let stderr = "";
|
|
7498
|
+
child.stdout.on("data", (c3) => {
|
|
7499
|
+
stdout2 += c3.toString();
|
|
7500
|
+
});
|
|
7501
|
+
child.stderr.on("data", (c3) => {
|
|
7502
|
+
stderr += c3.toString();
|
|
7503
|
+
});
|
|
7504
|
+
child.on("error", (error) => resolve({ exitCode: 127, stdout: stdout2, stderr: error.message }));
|
|
7505
|
+
child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr }));
|
|
7506
|
+
});
|
|
7503
7507
|
}
|
|
7504
|
-
|
|
7505
|
-
const
|
|
7506
|
-
if (
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7508
|
+
async probe(command) {
|
|
7509
|
+
const result = await this.capture(command, ["--version"]);
|
|
7510
|
+
if (result.exitCode !== 0) return { installed: false };
|
|
7511
|
+
const output = `${result.stdout} ${result.stderr}`;
|
|
7512
|
+
const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
|
|
7513
|
+
return { installed: true, ...version ? { version } : {} };
|
|
7514
|
+
}
|
|
7515
|
+
};
|
|
7516
|
+
|
|
7517
|
+
// ../adapters-node/src/system-tool-installer.ts
|
|
7518
|
+
var TOOLS = {
|
|
7519
|
+
aws: {
|
|
7520
|
+
manualUrl: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
|
|
7521
|
+
recipes: {
|
|
7522
|
+
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "awscli"] }],
|
|
7523
|
+
win32: [
|
|
7524
|
+
{ via: "winget", command: "winget", args: ["install", "--exact", "--id", "Amazon.AWSCLI"] }
|
|
7525
|
+
]
|
|
7526
|
+
}
|
|
7527
|
+
},
|
|
7528
|
+
az: {
|
|
7529
|
+
manualUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli",
|
|
7530
|
+
recipes: {
|
|
7531
|
+
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "azure-cli"] }],
|
|
7532
|
+
win32: [
|
|
7533
|
+
{
|
|
7534
|
+
via: "winget",
|
|
7535
|
+
command: "winget",
|
|
7536
|
+
args: ["install", "--exact", "--id", "Microsoft.AzureCLI"]
|
|
7537
|
+
}
|
|
7538
|
+
]
|
|
7539
|
+
}
|
|
7540
|
+
},
|
|
7541
|
+
gcloud: {
|
|
7542
|
+
manualUrl: "https://cloud.google.com/sdk/docs/install",
|
|
7543
|
+
recipes: {
|
|
7544
|
+
darwin: [
|
|
7545
|
+
{ via: "Homebrew", command: "brew", args: ["install", "--cask", "google-cloud-sdk"] }
|
|
7546
|
+
],
|
|
7547
|
+
win32: [
|
|
7548
|
+
{
|
|
7549
|
+
via: "winget",
|
|
7550
|
+
command: "winget",
|
|
7551
|
+
args: ["install", "--exact", "--id", "Google.CloudSDK"]
|
|
7552
|
+
}
|
|
7553
|
+
]
|
|
7554
|
+
}
|
|
7555
|
+
}
|
|
7556
|
+
};
|
|
7557
|
+
var SystemToolInstaller = class {
|
|
7558
|
+
constructor(runner, platform = process.platform) {
|
|
7559
|
+
this.runner = runner;
|
|
7560
|
+
this.platform = platform;
|
|
7561
|
+
}
|
|
7562
|
+
runner;
|
|
7563
|
+
platform;
|
|
7564
|
+
available = /* @__PURE__ */ new Map();
|
|
7565
|
+
recipeFor(command) {
|
|
7566
|
+
const tool = TOOLS[command];
|
|
7567
|
+
if (!tool) return void 0;
|
|
7568
|
+
const recipe = tool.recipes[this.platform]?.[0];
|
|
7569
|
+
return recipe ? { ...recipe, manualUrl: tool.manualUrl } : { via: "official installer", command: "", args: [], manualUrl: tool.manualUrl };
|
|
7570
|
+
}
|
|
7571
|
+
async install(recipe) {
|
|
7572
|
+
if (!recipe.command) return false;
|
|
7573
|
+
if (!await this.hasCommand(recipe.command)) return false;
|
|
7574
|
+
const { exitCode } = await this.runner.run(recipe.command, recipe.args);
|
|
7575
|
+
return exitCode === 0;
|
|
7576
|
+
}
|
|
7577
|
+
async hasCommand(command) {
|
|
7578
|
+
if (!this.available.has(command)) {
|
|
7579
|
+
this.available.set(command, (await this.runner.probe(command)).installed);
|
|
7580
|
+
}
|
|
7581
|
+
return this.available.get(command) ?? false;
|
|
7582
|
+
}
|
|
7583
|
+
};
|
|
7584
|
+
|
|
7585
|
+
// ../adapters-node/src/yaml-config-store.ts
|
|
7586
|
+
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
7587
|
+
import { homedir } from "os";
|
|
7588
|
+
import { join } from "path";
|
|
7589
|
+
|
|
7590
|
+
// ../core/src/domain/errors.ts
|
|
7591
|
+
var CloudhopError = class extends Error {
|
|
7592
|
+
constructor(code, message, hint) {
|
|
7593
|
+
super(message);
|
|
7594
|
+
this.code = code;
|
|
7595
|
+
this.hint = hint;
|
|
7596
|
+
this.name = "CloudhopError";
|
|
7597
|
+
}
|
|
7598
|
+
code;
|
|
7599
|
+
hint;
|
|
7600
|
+
};
|
|
7601
|
+
|
|
7602
|
+
// ../core/src/domain/naming.ts
|
|
7603
|
+
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
7604
|
+
"add",
|
|
7605
|
+
"agent",
|
|
7606
|
+
"alias",
|
|
7607
|
+
"login",
|
|
7608
|
+
"team",
|
|
7609
|
+
"shell",
|
|
7610
|
+
"use",
|
|
7611
|
+
"each",
|
|
7612
|
+
"console",
|
|
7613
|
+
"prod",
|
|
7614
|
+
"rename",
|
|
7615
|
+
"mv",
|
|
7616
|
+
"mcp",
|
|
7617
|
+
"region",
|
|
7618
|
+
"setup",
|
|
7619
|
+
"status",
|
|
7620
|
+
"sync",
|
|
7621
|
+
"ls",
|
|
7622
|
+
"list",
|
|
7623
|
+
"rm",
|
|
7624
|
+
"remove",
|
|
7625
|
+
"doctor",
|
|
7626
|
+
"exec",
|
|
7627
|
+
"provider",
|
|
7628
|
+
"ui",
|
|
7629
|
+
"help",
|
|
7630
|
+
"version"
|
|
7631
|
+
]);
|
|
7632
|
+
var NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
7633
|
+
function assertValidName(kind, name) {
|
|
7634
|
+
if (!NAME_PATTERN.test(name)) {
|
|
7635
|
+
throw new CloudhopError(
|
|
7636
|
+
"INVALID_NAME",
|
|
7637
|
+
`Invalid ${kind} name "${name}".`,
|
|
7638
|
+
'Use lowercase letters, numbers, "-" or "_" (max 63 characters).'
|
|
7639
|
+
);
|
|
7640
|
+
}
|
|
7641
|
+
if (kind === "alias" && RESERVED_NAMES.has(name)) {
|
|
7642
|
+
throw new CloudhopError(
|
|
7643
|
+
"INVALID_NAME",
|
|
7644
|
+
`"${name}" is a cloudhop command.`,
|
|
7645
|
+
"Pick another alias name."
|
|
7646
|
+
);
|
|
7647
|
+
}
|
|
7648
|
+
}
|
|
7649
|
+
function suggestAliasName(...parts) {
|
|
7650
|
+
const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
7651
|
+
return slug || "alias";
|
|
7652
|
+
}
|
|
7653
|
+
var slugTokens = (text4) => text4.toLowerCase().replace(/access$/i, "").split(/[^a-z0-9]+/).filter(Boolean);
|
|
7654
|
+
var ROLE_WORDS = {
|
|
7655
|
+
administrator: "admin",
|
|
7656
|
+
administratoraccess: "admin",
|
|
7657
|
+
readonly: "ro",
|
|
7658
|
+
readonlyaccess: "ro",
|
|
7659
|
+
developer: "dev",
|
|
7660
|
+
poweruser: "power",
|
|
7661
|
+
poweruseraccess: "power",
|
|
7662
|
+
viewonly: "view"
|
|
7663
|
+
};
|
|
7664
|
+
var ROLE_NOISE = /* @__PURE__ */ new Set(["aws", "access", "role", "ps", "iam"]);
|
|
7665
|
+
var PRODUCTION = /(^|[^a-z])(prod|prd|production|live)([^a-z]|$)/i;
|
|
7666
|
+
var looksLikeProduction = (...texts) => texts.some((text4) => PRODUCTION.test(text4));
|
|
7667
|
+
function suggestAliases(targets, sourceId, taken = /* @__PURE__ */ new Set()) {
|
|
7668
|
+
const sourceWords = new Set(
|
|
7669
|
+
slugTokens(sourceId).filter((w) => w.length > 2 && !/^(test|dev|main)$/.test(w))
|
|
7670
|
+
);
|
|
7671
|
+
const accountOf = (t2) => {
|
|
7672
|
+
let words = slugTokens(t2.attributes.accountName ?? t2.label.split(" / ")[0] ?? t2.id);
|
|
7673
|
+
if (words.length > 1 && sourceWords.has(words[0])) words = words.slice(1);
|
|
7674
|
+
while (words.length > 1 && /^\d{1,2}$/.test(words[words.length - 1]))
|
|
7675
|
+
words = words.slice(0, -1);
|
|
7676
|
+
return words;
|
|
7677
|
+
};
|
|
7678
|
+
const roleOf = (t2, accountWords) => {
|
|
7679
|
+
const raw = t2.attributes.roleName ?? t2.label.split(" / ")[1] ?? "";
|
|
7680
|
+
const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/^aws/, "").replace(/access$/, "");
|
|
7681
|
+
if (ROLE_WORDS[compact]) return [ROLE_WORDS[compact]];
|
|
7682
|
+
const skip = /* @__PURE__ */ new Set([...accountWords, ...sourceWords, ...ROLE_NOISE]);
|
|
7683
|
+
const words = slugTokens(raw).filter((w) => !skip.has(w)).map((w) => ROLE_WORDS[w] ?? w);
|
|
7684
|
+
return words.length ? words : slugTokens(raw).slice(-1);
|
|
7685
|
+
};
|
|
7686
|
+
const accounts = targets.map(accountOf);
|
|
7687
|
+
const count = /* @__PURE__ */ new Map();
|
|
7688
|
+
for (const words of accounts) count.set(words.join("-"), (count.get(words.join("-")) ?? 0) + 1);
|
|
7689
|
+
const used = new Set(taken);
|
|
7690
|
+
const result = /* @__PURE__ */ new Map();
|
|
7691
|
+
targets.forEach((target, i2) => {
|
|
7692
|
+
const accountWords = accounts[i2];
|
|
7693
|
+
const base = accountWords.join("-");
|
|
7694
|
+
const words = (count.get(base) ?? 0) > 1 ? [...accountWords, ...roleOf(target, accountWords)] : accountWords;
|
|
7695
|
+
const stem = suggestAliasName(words.join("-"));
|
|
7696
|
+
let name = stem;
|
|
7697
|
+
for (let n3 = 2; used.has(name) || RESERVED_NAMES.has(name); n3++) name = `${stem}-${n3}`;
|
|
7698
|
+
used.add(name);
|
|
7699
|
+
result.set(target.id, name);
|
|
7700
|
+
});
|
|
7701
|
+
return result;
|
|
7702
|
+
}
|
|
7703
|
+
|
|
7704
|
+
// ../core/src/domain/version.ts
|
|
7705
|
+
function compareVersions(a2, b3) {
|
|
7706
|
+
const pa = a2.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
|
|
7707
|
+
const pb = b3.split(".").map((n3) => Number.parseInt(n3, 10) || 0);
|
|
7708
|
+
for (let i2 = 0; i2 < Math.max(pa.length, pb.length); i2++) {
|
|
7709
|
+
const diff = (pa[i2] ?? 0) - (pb[i2] ?? 0);
|
|
7710
|
+
if (diff !== 0) return diff;
|
|
7711
|
+
}
|
|
7712
|
+
return 0;
|
|
7713
|
+
}
|
|
7714
|
+
|
|
7715
|
+
// ../core/src/application/cloudhop-app.ts
|
|
7716
|
+
var loginRequired = (sourceId) => new CloudhopError(
|
|
7717
|
+
"LOGIN_REQUIRED",
|
|
7718
|
+
`The session of "${sourceId}" expired and needs a browser sign-in.`,
|
|
7719
|
+
`Run \`cloudhop login ${sourceId}\` (it opens the browser), then try again.`
|
|
7720
|
+
);
|
|
7721
|
+
var NEXT_BY_STATE = {
|
|
7722
|
+
valid: "ready",
|
|
7723
|
+
refreshable: "silent-renewal",
|
|
7724
|
+
expired: "browser-sign-in",
|
|
7725
|
+
unknown: "unknown"
|
|
7726
|
+
};
|
|
7727
|
+
var CloudhopApp = class {
|
|
7728
|
+
constructor(deps) {
|
|
7729
|
+
this.deps = deps;
|
|
7730
|
+
}
|
|
7731
|
+
deps;
|
|
7732
|
+
// ── Providers ────────────────────────────────────────────────────────────
|
|
7733
|
+
availableProviders() {
|
|
7734
|
+
return this.deps.providers;
|
|
7735
|
+
}
|
|
7736
|
+
provider(id) {
|
|
7737
|
+
const provider = this.deps.providers.find((p3) => p3.id === id);
|
|
7738
|
+
if (!provider) {
|
|
7739
|
+
throw new CloudhopError(
|
|
7740
|
+
"PROVIDER_NOT_AVAILABLE",
|
|
7741
|
+
`The ${id} provider is not available yet.`,
|
|
7742
|
+
"Follow https://github.com/dayepesb/cloudhop for upcoming providers."
|
|
7743
|
+
);
|
|
7744
|
+
}
|
|
7745
|
+
return provider;
|
|
7514
7746
|
}
|
|
7515
7747
|
async config() {
|
|
7516
7748
|
return this.deps.config.load();
|
|
@@ -7591,13 +7823,19 @@ var CloudhopApp = class {
|
|
|
7591
7823
|
return this.syncSource(sourceId);
|
|
7592
7824
|
}
|
|
7593
7825
|
/** Makes sure the source has a usable session, renewing it silently or via login. */
|
|
7594
|
-
|
|
7826
|
+
/**
|
|
7827
|
+
* Makes the source's session usable: renews it silently when possible, and
|
|
7828
|
+
* otherwise signs in through the browser, unless `interactive` is false, in
|
|
7829
|
+
* which case it fails with LOGIN_REQUIRED instead of opening a browser.
|
|
7830
|
+
*/
|
|
7831
|
+
async ensureSession(sourceId, { interactive: interactive2 = true } = {}) {
|
|
7595
7832
|
const config = await this.deps.config.load();
|
|
7596
7833
|
const source = this.findSource(config, sourceId);
|
|
7597
7834
|
const provider = this.provider(source.provider);
|
|
7598
7835
|
const state = await provider.sessionState(source);
|
|
7599
7836
|
if (state === "valid" || state === "unknown") return "none";
|
|
7600
7837
|
if (state === "refreshable" && await provider.refreshSession(source)) return "refreshed";
|
|
7838
|
+
if (!interactive2) throw loginRequired(sourceId);
|
|
7601
7839
|
await this.login(sourceId);
|
|
7602
7840
|
return "logged-in";
|
|
7603
7841
|
}
|
|
@@ -7661,6 +7899,81 @@ var CloudhopApp = class {
|
|
|
7661
7899
|
const config = await this.deps.config.load();
|
|
7662
7900
|
return Promise.all(config.aliases.map((a2) => this.resolve(config, a2)));
|
|
7663
7901
|
}
|
|
7902
|
+
/** Sources with their session state and every alias, checked without signing in. */
|
|
7903
|
+
async overview() {
|
|
7904
|
+
const config = await this.deps.config.load();
|
|
7905
|
+
const sources = await Promise.all(
|
|
7906
|
+
config.sources.map(async (source) => {
|
|
7907
|
+
const session = await this.provider(source.provider).sessionState(source);
|
|
7908
|
+
return {
|
|
7909
|
+
id: source.id,
|
|
7910
|
+
provider: source.provider,
|
|
7911
|
+
kind: source.kind,
|
|
7912
|
+
settings: source.settings,
|
|
7913
|
+
session,
|
|
7914
|
+
next: NEXT_BY_STATE[session]
|
|
7915
|
+
};
|
|
7916
|
+
})
|
|
7917
|
+
);
|
|
7918
|
+
const bySource = new Map(sources.map((s) => [s.id, s]));
|
|
7919
|
+
const aliases = await Promise.all(
|
|
7920
|
+
config.aliases.map(async (alias) => {
|
|
7921
|
+
const resolved = await this.resolve(config, alias);
|
|
7922
|
+
const source = bySource.get(alias.source);
|
|
7923
|
+
return {
|
|
7924
|
+
name: alias.name,
|
|
7925
|
+
source: alias.source,
|
|
7926
|
+
provider: resolved.source.provider,
|
|
7927
|
+
target: alias.target,
|
|
7928
|
+
label: resolved.target?.label,
|
|
7929
|
+
available: Boolean(resolved.target),
|
|
7930
|
+
region: resolved.region,
|
|
7931
|
+
attributes: resolved.target?.attributes ?? {},
|
|
7932
|
+
prod: alias.prod === true,
|
|
7933
|
+
session: source.session,
|
|
7934
|
+
next: source.next
|
|
7935
|
+
};
|
|
7936
|
+
})
|
|
7937
|
+
);
|
|
7938
|
+
return { sources, aliases };
|
|
7939
|
+
}
|
|
7940
|
+
/** The status of one alias: which account it points to and whether it can run right away. */
|
|
7941
|
+
async aliasStatus(name) {
|
|
7942
|
+
const { aliases } = await this.overview();
|
|
7943
|
+
const status = aliases.find((a2) => a2.name === name);
|
|
7944
|
+
if (!status) {
|
|
7945
|
+
throw new CloudhopError(
|
|
7946
|
+
"ALIAS_NOT_FOUND",
|
|
7947
|
+
`Alias "${name}" not found.`,
|
|
7948
|
+
"See your aliases with `cloudhop ls`."
|
|
7949
|
+
);
|
|
7950
|
+
}
|
|
7951
|
+
return status;
|
|
7952
|
+
}
|
|
7953
|
+
/** Renames an alias and its profile, keeping its account, role, region and production mark. */
|
|
7954
|
+
async renameAlias(from, to) {
|
|
7955
|
+
assertValidName("alias", to);
|
|
7956
|
+
const config = await this.deps.config.load();
|
|
7957
|
+
const alias = this.findAlias(config, from);
|
|
7958
|
+
if (from === to) return;
|
|
7959
|
+
if (config.aliases.some((a2) => a2.name === to)) {
|
|
7960
|
+
throw new CloudhopError("ALIAS_EXISTS", `Alias "${to}" already exists.`);
|
|
7961
|
+
}
|
|
7962
|
+
alias.name = to;
|
|
7963
|
+
const source = this.findSource(config, alias.source);
|
|
7964
|
+
await this.syncProfiles(source.provider, config);
|
|
7965
|
+
await this.deps.config.save(config);
|
|
7966
|
+
}
|
|
7967
|
+
/** Marks aliases as production (or not). Production aliases ask before changing anything. */
|
|
7968
|
+
async setProduction(names, prod) {
|
|
7969
|
+
const config = await this.deps.config.load();
|
|
7970
|
+
for (const name of names) {
|
|
7971
|
+
const alias = this.findAlias(config, name);
|
|
7972
|
+
if (prod) alias.prod = true;
|
|
7973
|
+
else delete alias.prod;
|
|
7974
|
+
}
|
|
7975
|
+
await this.deps.config.save(config);
|
|
7976
|
+
}
|
|
7664
7977
|
/** Changes an alias' region; matching the source's region clears the override. */
|
|
7665
7978
|
async setAliasRegion(name, region) {
|
|
7666
7979
|
const config = await this.deps.config.load();
|
|
@@ -7685,6 +7998,71 @@ var CloudhopApp = class {
|
|
|
7685
7998
|
return this.runWithSession(resolved, provider, "cli", provider.cli.command, args);
|
|
7686
7999
|
}
|
|
7687
8000
|
/** Runs any command with the alias' temporary credentials in its environment. */
|
|
8001
|
+
/**
|
|
8002
|
+
* Runs the alias' cloud CLI without a terminal and returns what it printed.
|
|
8003
|
+
* Used where stdin/stdout belong to someone else (the MCP server). It never
|
|
8004
|
+
* opens a browser: an expired session fails with LOGIN_REQUIRED.
|
|
8005
|
+
*/
|
|
8006
|
+
async captureAsAlias(name, args, overrides = {}) {
|
|
8007
|
+
const resolved = await this.usableAlias(name, overrides);
|
|
8008
|
+
const provider = this.provider(resolved.source.provider);
|
|
8009
|
+
await this.requireCli(provider);
|
|
8010
|
+
await this.ensureSession(resolved.source.id, { interactive: false });
|
|
8011
|
+
const env = await provider.processEnv(resolved, "cli");
|
|
8012
|
+
const result = await this.deps.runner.capture(provider.cli.command, args, { env });
|
|
8013
|
+
if (result.exitCode !== 0 && provider.isSessionError(`${result.stdout}
|
|
8014
|
+
${result.stderr}`)) {
|
|
8015
|
+
throw loginRequired(resolved.source.id);
|
|
8016
|
+
}
|
|
8017
|
+
return result;
|
|
8018
|
+
}
|
|
8019
|
+
/**
|
|
8020
|
+
* Whether running these CLI arguments (or, with `args` undefined, any
|
|
8021
|
+
* command through `cloudhop exec`) as this alias needs a person's OK: true
|
|
8022
|
+
* for production aliases unless the command surely only reads.
|
|
8023
|
+
*/
|
|
8024
|
+
async needsProductionConfirmation(name, args) {
|
|
8025
|
+
const resolved = await this.resolveAlias(name);
|
|
8026
|
+
if (!resolved.alias.prod) return false;
|
|
8027
|
+
if (!args) return true;
|
|
8028
|
+
return !this.provider(resolved.source.provider).isReadOnly(args);
|
|
8029
|
+
}
|
|
8030
|
+
/**
|
|
8031
|
+
* A URL that opens the web console as the alias, signing in first if the
|
|
8032
|
+
* session expired. The URL is a short-lived secret: open it, never print it.
|
|
8033
|
+
*/
|
|
8034
|
+
async consoleUrl(name, { service, region } = {}) {
|
|
8035
|
+
const resolved = await this.usableAlias(name, region ? { region } : {});
|
|
8036
|
+
const provider = this.provider(resolved.source.provider);
|
|
8037
|
+
if (!provider.consoleUrl) {
|
|
8038
|
+
throw new CloudhopError(
|
|
8039
|
+
"PROVIDER_NOT_AVAILABLE",
|
|
8040
|
+
`${provider.displayName} has no console sign-in in cloudhop yet.`
|
|
8041
|
+
);
|
|
8042
|
+
}
|
|
8043
|
+
if (service !== void 0 && !/^[a-z0-9-]{1,40}$/.test(service)) {
|
|
8044
|
+
throw new CloudhopError(
|
|
8045
|
+
"INVALID_NAME",
|
|
8046
|
+
`"${service}" doesn't look like a service name.`,
|
|
8047
|
+
"Examples: s3, ec2, lambda, cloudwatch."
|
|
8048
|
+
);
|
|
8049
|
+
}
|
|
8050
|
+
await this.ensureSession(resolved.source.id);
|
|
8051
|
+
return provider.consoleUrl(resolved, service);
|
|
8052
|
+
}
|
|
8053
|
+
/**
|
|
8054
|
+
* Opens the web console as the alias in the user's browser. The sign-in URL
|
|
8055
|
+
* stays inside cloudhop: callers only learn how the user got in.
|
|
8056
|
+
*/
|
|
8057
|
+
async openConsole(name, options = {}) {
|
|
8058
|
+
const { url, via } = await this.consoleUrl(name, options);
|
|
8059
|
+
await this.deps.browser.open(url);
|
|
8060
|
+
return via;
|
|
8061
|
+
}
|
|
8062
|
+
/** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
|
|
8063
|
+
identityArgs(name) {
|
|
8064
|
+
return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
|
|
8065
|
+
}
|
|
7688
8066
|
async execAsAlias(name, command, args, overrides = {}) {
|
|
7689
8067
|
const resolved = await this.usableAlias(name, overrides);
|
|
7690
8068
|
const provider = this.provider(resolved.source.provider);
|
|
@@ -7819,30 +8197,387 @@ var CloudhopApp = class {
|
|
|
7819
8197
|
var PROVIDER_IDS = ["aws", "azure", "gcp"];
|
|
7820
8198
|
var emptyConfig = () => ({ providers: [], sources: [], aliases: [] });
|
|
7821
8199
|
|
|
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 };
|
|
8200
|
+
// ../adapters-node/src/yaml-config-store.ts
|
|
8201
|
+
var import_yaml = __toESM(require_dist(), 1);
|
|
8202
|
+
function defaultConfigDir(env = process.env) {
|
|
8203
|
+
if (env.CLOUDHOP_HOME) return env.CLOUDHOP_HOME;
|
|
8204
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cloudhop");
|
|
7841
8205
|
}
|
|
7842
|
-
var
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
8206
|
+
var HEADER = "# cloudhop configuration. Contains no secrets.\n# Docs: https://github.com/dayepesb/cloudhop\n";
|
|
8207
|
+
var YamlConfigStore = class {
|
|
8208
|
+
constructor(dir = defaultConfigDir()) {
|
|
8209
|
+
this.dir = dir;
|
|
8210
|
+
}
|
|
8211
|
+
dir;
|
|
8212
|
+
get configPath() {
|
|
8213
|
+
return join(this.dir, "config.yaml");
|
|
8214
|
+
}
|
|
8215
|
+
get targetsPath() {
|
|
8216
|
+
return join(this.dir, "targets.json");
|
|
8217
|
+
}
|
|
8218
|
+
async load() {
|
|
8219
|
+
const raw = await this.read(this.configPath);
|
|
8220
|
+
if (!raw) return emptyConfig();
|
|
8221
|
+
const file = (0, import_yaml.parse)(raw) ?? {};
|
|
8222
|
+
return {
|
|
8223
|
+
providers: (file.providers ?? []).filter(
|
|
8224
|
+
(p3) => PROVIDER_IDS.includes(p3)
|
|
8225
|
+
),
|
|
8226
|
+
sources: Object.entries(file.sources ?? {}).map(([id, s]) => ({
|
|
8227
|
+
id,
|
|
8228
|
+
...s,
|
|
8229
|
+
settings: s.settings ?? {}
|
|
8230
|
+
})),
|
|
8231
|
+
aliases: Object.entries(file.aliases ?? {}).map(([name, a2]) => ({ name, ...a2 }))
|
|
8232
|
+
};
|
|
8233
|
+
}
|
|
8234
|
+
async save(config) {
|
|
8235
|
+
const file = {
|
|
8236
|
+
providers: config.providers,
|
|
8237
|
+
sources: Object.fromEntries(config.sources.map(({ id, ...rest }) => [id, rest])),
|
|
8238
|
+
aliases: Object.fromEntries(config.aliases.map(({ name, ...rest }) => [name, rest]))
|
|
8239
|
+
};
|
|
8240
|
+
await this.write(this.configPath, HEADER + (0, import_yaml.stringify)(file));
|
|
8241
|
+
}
|
|
8242
|
+
async loadTargets(sourceId) {
|
|
8243
|
+
return (await this.allTargets())[sourceId] ?? [];
|
|
8244
|
+
}
|
|
8245
|
+
async saveTargets(sourceId, targets) {
|
|
8246
|
+
const all = await this.allTargets();
|
|
8247
|
+
all[sourceId] = targets;
|
|
8248
|
+
await this.write(this.targetsPath, JSON.stringify(all, null, 2));
|
|
8249
|
+
}
|
|
8250
|
+
async removeTargets(sourceId) {
|
|
8251
|
+
const all = await this.allTargets();
|
|
8252
|
+
delete all[sourceId];
|
|
8253
|
+
await this.write(this.targetsPath, JSON.stringify(all, null, 2));
|
|
8254
|
+
}
|
|
8255
|
+
async reset() {
|
|
8256
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
8257
|
+
}
|
|
8258
|
+
async allTargets() {
|
|
8259
|
+
const raw = await this.read(this.targetsPath);
|
|
8260
|
+
return raw ? JSON.parse(raw) : {};
|
|
8261
|
+
}
|
|
8262
|
+
async read(path2) {
|
|
8263
|
+
try {
|
|
8264
|
+
return await readFile(path2, "utf8");
|
|
8265
|
+
} catch (error) {
|
|
8266
|
+
if (error.code === "ENOENT") return void 0;
|
|
8267
|
+
throw error;
|
|
8268
|
+
}
|
|
8269
|
+
}
|
|
8270
|
+
/** Writes through a temp file so a crash never leaves a half-written config. */
|
|
8271
|
+
async write(path2, content) {
|
|
8272
|
+
await mkdir(this.dir, { recursive: true, mode: 448 });
|
|
8273
|
+
const tmp = `${path2}.${process.pid}.tmp`;
|
|
8274
|
+
await writeFile(tmp, content, { mode: 384 });
|
|
8275
|
+
await rename(tmp, path2);
|
|
8276
|
+
}
|
|
8277
|
+
};
|
|
8278
|
+
|
|
8279
|
+
// ../mcp/src/server.ts
|
|
8280
|
+
import { createInterface } from "readline";
|
|
8281
|
+
|
|
8282
|
+
// ../mcp/src/tools.ts
|
|
8283
|
+
var MAX_OUTPUT = 1e5;
|
|
8284
|
+
var cap = (text4) => text4.length > MAX_OUTPUT ? `${text4.slice(0, MAX_OUTPUT)}
|
|
8285
|
+
\u2026 [truncated: ${text4.length - MAX_OUTPUT} more characters. Narrow the command with --query or filters.]` : text4;
|
|
8286
|
+
var WINDOWS_SHELL_CHARS = /[&|<>^%!"\r\n]/;
|
|
8287
|
+
var text = (args, key) => {
|
|
8288
|
+
const value = args[key];
|
|
8289
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
8290
|
+
throw new CloudhopError("INVALID_NAME", `"${key}" is required.`);
|
|
8291
|
+
}
|
|
8292
|
+
return value.trim();
|
|
8293
|
+
};
|
|
8294
|
+
var stringArray = (args, key) => {
|
|
8295
|
+
const value = args[key];
|
|
8296
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
8297
|
+
throw new CloudhopError("INVALID_NAME", `"${key}" must be an array of strings.`);
|
|
8298
|
+
}
|
|
8299
|
+
return value;
|
|
8300
|
+
};
|
|
8301
|
+
var aliasInput = {
|
|
8302
|
+
alias: {
|
|
8303
|
+
type: "string",
|
|
8304
|
+
description: "The cloudhop alias to use, as listed by list_aliases. Ask the user which one."
|
|
8305
|
+
}
|
|
8306
|
+
};
|
|
8307
|
+
function createTools(app) {
|
|
8308
|
+
return [
|
|
8309
|
+
{
|
|
8310
|
+
name: "list_aliases",
|
|
8311
|
+
title: "List cloudhop aliases",
|
|
8312
|
+
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.",
|
|
8313
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
8314
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
8315
|
+
run: () => app.overview()
|
|
8316
|
+
},
|
|
8317
|
+
{
|
|
8318
|
+
name: "alias_status",
|
|
8319
|
+
title: "Check an alias",
|
|
8320
|
+
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".',
|
|
8321
|
+
inputSchema: {
|
|
8322
|
+
type: "object",
|
|
8323
|
+
properties: aliasInput,
|
|
8324
|
+
required: ["alias"],
|
|
8325
|
+
additionalProperties: false
|
|
8326
|
+
},
|
|
8327
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
8328
|
+
run: (args) => app.aliasStatus(text(args, "alias"))
|
|
8329
|
+
},
|
|
8330
|
+
{
|
|
8331
|
+
name: "whoami",
|
|
8332
|
+
title: "Who am I in this alias",
|
|
8333
|
+
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.",
|
|
8334
|
+
inputSchema: {
|
|
8335
|
+
type: "object",
|
|
8336
|
+
properties: aliasInput,
|
|
8337
|
+
required: ["alias"],
|
|
8338
|
+
additionalProperties: false
|
|
8339
|
+
},
|
|
8340
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
8341
|
+
run: async (args) => {
|
|
8342
|
+
const alias = text(args, "alias");
|
|
8343
|
+
const result = await app.captureAsAlias(alias, await app.identityArgs(alias));
|
|
8344
|
+
return {
|
|
8345
|
+
alias,
|
|
8346
|
+
exitCode: result.exitCode,
|
|
8347
|
+
output: cap(result.stdout),
|
|
8348
|
+
errors: cap(result.stderr)
|
|
8349
|
+
};
|
|
8350
|
+
}
|
|
8351
|
+
},
|
|
8352
|
+
{
|
|
8353
|
+
name: "login",
|
|
8354
|
+
title: "Sign in to a source",
|
|
8355
|
+
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.",
|
|
8356
|
+
inputSchema: {
|
|
8357
|
+
type: "object",
|
|
8358
|
+
properties: {
|
|
8359
|
+
source: { type: "string", description: "The source id, as shown in list_aliases." }
|
|
8360
|
+
},
|
|
8361
|
+
required: ["source"],
|
|
8362
|
+
additionalProperties: false
|
|
8363
|
+
},
|
|
8364
|
+
annotations: {
|
|
8365
|
+
readOnlyHint: false,
|
|
8366
|
+
destructiveHint: false,
|
|
8367
|
+
idempotentHint: true,
|
|
8368
|
+
openWorldHint: true
|
|
8369
|
+
},
|
|
8370
|
+
run: async (args) => {
|
|
8371
|
+
const source = text(args, "source");
|
|
8372
|
+
const report = await app.login(source);
|
|
8373
|
+
return { source, signedIn: true, targets: report.targets.length };
|
|
8374
|
+
}
|
|
8375
|
+
},
|
|
8376
|
+
{
|
|
8377
|
+
name: "run_cloud_cli",
|
|
8378
|
+
title: "Run the cloud CLI as an alias",
|
|
8379
|
+
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.',
|
|
8380
|
+
inputSchema: {
|
|
8381
|
+
type: "object",
|
|
8382
|
+
properties: {
|
|
8383
|
+
...aliasInput,
|
|
8384
|
+
args: {
|
|
8385
|
+
type: "array",
|
|
8386
|
+
items: { type: "string" },
|
|
8387
|
+
description: 'CLI arguments, e.g. ["ec2", "describe-instances", "--output", "json"].'
|
|
8388
|
+
},
|
|
8389
|
+
region: {
|
|
8390
|
+
type: "string",
|
|
8391
|
+
description: "Optional region for this call only; defaults to the alias' region."
|
|
8392
|
+
},
|
|
8393
|
+
confirm_production: {
|
|
8394
|
+
type: "boolean",
|
|
8395
|
+
description: "Only for production aliases (prod: true) and commands that may change resources: set it to true after the user explicitly approved this exact command."
|
|
8396
|
+
}
|
|
8397
|
+
},
|
|
8398
|
+
required: ["alias", "args"],
|
|
8399
|
+
additionalProperties: false
|
|
8400
|
+
},
|
|
8401
|
+
annotations: {
|
|
8402
|
+
readOnlyHint: false,
|
|
8403
|
+
destructiveHint: true,
|
|
8404
|
+
idempotentHint: false,
|
|
8405
|
+
openWorldHint: true
|
|
8406
|
+
},
|
|
8407
|
+
run: async (args) => {
|
|
8408
|
+
const alias = text(args, "alias");
|
|
8409
|
+
const cliArgs = stringArray(args, "args");
|
|
8410
|
+
if (cliArgs.includes("--profile")) {
|
|
8411
|
+
throw new CloudhopError(
|
|
8412
|
+
"INVALID_NAME",
|
|
8413
|
+
"Do not pass --profile: the alias already chooses the account and role."
|
|
8414
|
+
);
|
|
8415
|
+
}
|
|
8416
|
+
if (process.platform === "win32" && cliArgs.some((a2) => WINDOWS_SHELL_CHARS.test(a2))) {
|
|
8417
|
+
throw new CloudhopError(
|
|
8418
|
+
"INVALID_NAME",
|
|
8419
|
+
'On Windows, arguments cannot contain & | < > ^ % ! " or line breaks.',
|
|
8420
|
+
"Ask the user to run this command in their terminal with hop instead."
|
|
8421
|
+
);
|
|
8422
|
+
}
|
|
8423
|
+
const region = typeof args.region === "string" && args.region.trim() ? args.region.trim() : void 0;
|
|
8424
|
+
if (args.confirm_production !== true && await app.needsProductionConfirmation(alias, cliArgs)) {
|
|
8425
|
+
const status = await app.aliasStatus(alias);
|
|
8426
|
+
throw new CloudhopError(
|
|
8427
|
+
"PROD_CONFIRMATION_REQUIRED",
|
|
8428
|
+
`${alias} is PRODUCTION (${status.label ?? status.target}, ${status.region ?? "default region"}) and this command may change resources.`,
|
|
8429
|
+
"Show the user the alias, account, region and the exact command, and ask for explicit approval. Only if they approve, call run_cloud_cli again with confirm_production: true."
|
|
8430
|
+
);
|
|
8431
|
+
}
|
|
8432
|
+
const result = await app.captureAsAlias(alias, cliArgs, region ? { region } : {});
|
|
8433
|
+
return {
|
|
8434
|
+
alias,
|
|
8435
|
+
exitCode: result.exitCode,
|
|
8436
|
+
stdout: cap(result.stdout),
|
|
8437
|
+
stderr: cap(result.stderr)
|
|
8438
|
+
};
|
|
8439
|
+
}
|
|
8440
|
+
}
|
|
8441
|
+
];
|
|
8442
|
+
}
|
|
8443
|
+
|
|
8444
|
+
// ../mcp/src/server.ts
|
|
8445
|
+
var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
|
|
8446
|
+
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.
|
|
8447
|
+
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. Aliases with prod: true are production: run_cloud_cli refuses changes there until the user approves and you pass confirm_production: true.
|
|
8448
|
+
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.`;
|
|
8449
|
+
var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
8450
|
+
function serveMcp({
|
|
8451
|
+
app,
|
|
8452
|
+
version,
|
|
8453
|
+
input = process.stdin,
|
|
8454
|
+
output = process.stdout
|
|
8455
|
+
}) {
|
|
8456
|
+
const tools = createTools(app);
|
|
8457
|
+
const byName = new Map(tools.map((t2) => [t2.name, t2]));
|
|
8458
|
+
const send2 = (message) => output.write(`${JSON.stringify(message)}
|
|
8459
|
+
`);
|
|
8460
|
+
const reply = (id, result) => send2({ jsonrpc: "2.0", id, result });
|
|
8461
|
+
const fail = (id, code, message) => send2({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
8462
|
+
const handle = async (request) => {
|
|
8463
|
+
const { id, method, params = {} } = request;
|
|
8464
|
+
const isNotification = id === void 0;
|
|
8465
|
+
switch (method) {
|
|
8466
|
+
case "initialize": {
|
|
8467
|
+
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
|
|
8468
|
+
reply(id, {
|
|
8469
|
+
protocolVersion: SUPPORTED_VERSIONS.includes(asked) ? asked : SUPPORTED_VERSIONS[0],
|
|
8470
|
+
capabilities: { tools: { listChanged: false } },
|
|
8471
|
+
serverInfo: { name: "cloudhop", title: "cloudhop", version },
|
|
8472
|
+
instructions: MCP_INSTRUCTIONS
|
|
8473
|
+
});
|
|
8474
|
+
return;
|
|
8475
|
+
}
|
|
8476
|
+
case "ping":
|
|
8477
|
+
reply(id, {});
|
|
8478
|
+
return;
|
|
8479
|
+
case "tools/list":
|
|
8480
|
+
reply(id, {
|
|
8481
|
+
tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
|
|
8482
|
+
name,
|
|
8483
|
+
title,
|
|
8484
|
+
description,
|
|
8485
|
+
inputSchema,
|
|
8486
|
+
annotations
|
|
8487
|
+
}))
|
|
8488
|
+
});
|
|
8489
|
+
return;
|
|
8490
|
+
case "tools/call": {
|
|
8491
|
+
const tool = byName.get(String(params.name));
|
|
8492
|
+
if (!tool) {
|
|
8493
|
+
fail(id, -32602, `Unknown tool: ${String(params.name)}`);
|
|
8494
|
+
return;
|
|
8495
|
+
}
|
|
8496
|
+
const args = params.arguments ?? {};
|
|
8497
|
+
try {
|
|
8498
|
+
const result = await tool.run(args);
|
|
8499
|
+
reply(id, {
|
|
8500
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
8501
|
+
structuredContent: result
|
|
8502
|
+
});
|
|
8503
|
+
} catch (error) {
|
|
8504
|
+
const known = error instanceof CloudhopError;
|
|
8505
|
+
const body = {
|
|
8506
|
+
error: known ? error.code : "UNEXPECTED",
|
|
8507
|
+
message: error.message,
|
|
8508
|
+
...known && error.hint ? { hint: error.hint } : {}
|
|
8509
|
+
};
|
|
8510
|
+
reply(id, {
|
|
8511
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
8512
|
+
structuredContent: body,
|
|
8513
|
+
isError: true
|
|
8514
|
+
});
|
|
8515
|
+
}
|
|
8516
|
+
return;
|
|
8517
|
+
}
|
|
8518
|
+
default:
|
|
8519
|
+
if (!isNotification) fail(id, -32601, `Method not found: ${method}`);
|
|
8520
|
+
}
|
|
8521
|
+
};
|
|
8522
|
+
return new Promise((resolve) => {
|
|
8523
|
+
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
|
8524
|
+
const pending = /* @__PURE__ */ new Set();
|
|
8525
|
+
lines.on("line", (line) => {
|
|
8526
|
+
if (!line.trim()) return;
|
|
8527
|
+
let request;
|
|
8528
|
+
try {
|
|
8529
|
+
request = JSON.parse(line);
|
|
8530
|
+
} catch {
|
|
8531
|
+
fail(null, -32700, "Parse error");
|
|
8532
|
+
return;
|
|
8533
|
+
}
|
|
8534
|
+
if (typeof request.method !== "string") return;
|
|
8535
|
+
const task = handle(request).catch((error) => {
|
|
8536
|
+
if (request.id !== void 0) fail(request.id, -32603, error.message);
|
|
8537
|
+
});
|
|
8538
|
+
pending.add(task);
|
|
8539
|
+
task.finally(() => pending.delete(task));
|
|
8540
|
+
});
|
|
8541
|
+
lines.on("close", () => {
|
|
8542
|
+
Promise.allSettled([...pending]).then(() => resolve());
|
|
8543
|
+
});
|
|
8544
|
+
});
|
|
8545
|
+
}
|
|
8546
|
+
|
|
8547
|
+
// src/flags.ts
|
|
8548
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
8549
|
+
"yes",
|
|
8550
|
+
"all",
|
|
8551
|
+
"list",
|
|
8552
|
+
"noOpen",
|
|
8553
|
+
"json",
|
|
8554
|
+
"noMcp",
|
|
8555
|
+
"force",
|
|
8556
|
+
"prod",
|
|
8557
|
+
"off"
|
|
8558
|
+
]);
|
|
8559
|
+
var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
|
|
8560
|
+
function parseArgs(argv) {
|
|
8561
|
+
const positional = [];
|
|
8562
|
+
const flags = {};
|
|
8563
|
+
for (let i2 = 0; i2 < argv.length; i2++) {
|
|
8564
|
+
const arg = argv[i2];
|
|
8565
|
+
if (!arg.startsWith("--")) {
|
|
8566
|
+
positional.push(arg);
|
|
8567
|
+
continue;
|
|
8568
|
+
}
|
|
8569
|
+
const [rawName, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
8570
|
+
const name = camel(rawName);
|
|
8571
|
+
if (inline !== void 0) flags[name] = inline;
|
|
8572
|
+
else if (BOOLEAN_FLAGS.has(name)) flags[name] = "true";
|
|
8573
|
+
else flags[name] = argv[++i2] ?? "";
|
|
8574
|
+
}
|
|
8575
|
+
return { positional, flags };
|
|
8576
|
+
}
|
|
8577
|
+
var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
8578
|
+
|
|
8579
|
+
// ../../node_modules/.pnpm/@clack+prompts@1.8.1/node_modules/@clack/prompts/dist/index.mjs
|
|
8580
|
+
var dist_exports = {};
|
|
7846
8581
|
__export(dist_exports, {
|
|
7847
8582
|
CANCEL_SYMBOL: () => CANCEL_SYMBOL,
|
|
7848
8583
|
MULTISELECT_INSTRUCTIONS: () => MULTISELECT_INSTRUCTIONS,
|
|
@@ -7903,7 +8638,7 @@ __export(dist_exports, {
|
|
|
7903
8638
|
symbolBar: () => symbolBar,
|
|
7904
8639
|
taskLog: () => taskLog,
|
|
7905
8640
|
tasks: () => tasks,
|
|
7906
|
-
text: () =>
|
|
8641
|
+
text: () => text2,
|
|
7907
8642
|
unicode: () => unicode,
|
|
7908
8643
|
unicodeOr: () => unicodeOr,
|
|
7909
8644
|
updateSettings: () => updateSettings
|
|
@@ -9240,7 +9975,7 @@ import { styleText as styleText2, stripVTControlCharacters } from "util";
|
|
|
9240
9975
|
import process$1 from "process";
|
|
9241
9976
|
var import_sisteransi2 = __toESM(require_src(), 1);
|
|
9242
9977
|
import { existsSync, lstatSync, readdirSync } from "fs";
|
|
9243
|
-
import { dirname, join } from "path";
|
|
9978
|
+
import { dirname, join as join2 } from "path";
|
|
9244
9979
|
function isUnicodeSupported() {
|
|
9245
9980
|
if (process$1.platform !== "win32") {
|
|
9246
9981
|
return process$1.env.TERM !== "linux";
|
|
@@ -10181,7 +10916,7 @@ var path = (e) => {
|
|
|
10181
10916
|
existsSync(t2) ? lstatSync(t2).isDirectory() && (!e.directory || t2.endsWith("/")) ? i2 = t2 : i2 = dirname(t2) : i2 = dirname(t2);
|
|
10182
10917
|
const c3 = t2.length > 1 && t2.endsWith("/") ? t2.slice(0, -1) : t2;
|
|
10183
10918
|
return readdirSync(i2).map((r2) => {
|
|
10184
|
-
const n3 =
|
|
10919
|
+
const n3 = join2(i2, r2), m3 = lstatSync(n3);
|
|
10185
10920
|
return {
|
|
10186
10921
|
name: r2,
|
|
10187
10922
|
path: n3,
|
|
@@ -10608,7 +11343,7 @@ ${e.value}` : e.value;
|
|
|
10608
11343
|
}
|
|
10609
11344
|
};
|
|
10610
11345
|
};
|
|
10611
|
-
var
|
|
11346
|
+
var text2 = (t2) => new n2({
|
|
10612
11347
|
validate: t2.validate,
|
|
10613
11348
|
placeholder: t2.placeholder,
|
|
10614
11349
|
defaultValue: t2.defaultValue,
|
|
@@ -10665,8 +11400,8 @@ function answer(value) {
|
|
|
10665
11400
|
}
|
|
10666
11401
|
var colorful = (stream2) => Boolean(stream2.isTTY) && !process.env.NO_COLOR;
|
|
10667
11402
|
var truecolor = /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
|
|
10668
|
-
var dim = (
|
|
10669
|
-
var bold = (
|
|
11403
|
+
var dim = (text4) => colorful(process.stderr) ? `\x1B[2m${text4}\x1B[22m` : text4;
|
|
11404
|
+
var bold = (text4) => colorful(process.stdout) ? `\x1B[1m${text4}\x1B[22m` : text4;
|
|
10670
11405
|
var STYLES = {
|
|
10671
11406
|
dim: ["2", "22"],
|
|
10672
11407
|
green: ["32", "39"],
|
|
@@ -10675,7 +11410,7 @@ var STYLES = {
|
|
|
10675
11410
|
sky: [truecolor ? "38;2;59;184;245" : "38;5;75", "39"],
|
|
10676
11411
|
coral: [truecolor ? "38;2;255;122;89" : "38;5;209", "39"]
|
|
10677
11412
|
};
|
|
10678
|
-
var paint = (style,
|
|
11413
|
+
var paint = (style, text4) => colorful(process.stdout) ? `\x1B[${STYLES[style][0]}m${text4}\x1B[${STYLES[style][1]}m` : text4;
|
|
10679
11414
|
var BANNER = [
|
|
10680
11415
|
" _ _ _",
|
|
10681
11416
|
" ___ | | ___ _ _ __| || |__ ___ _ __",
|
|
@@ -10778,14 +11513,12 @@ async function chooseAliases(app, source, targets) {
|
|
|
10778
11513
|
);
|
|
10779
11514
|
const validateRegion2 = regionValidator(app, source);
|
|
10780
11515
|
let defaultRegion = source.settings.region ?? "";
|
|
11516
|
+
const suggestions = suggestAliases(targets, source.id, existing);
|
|
10781
11517
|
const aliases = [];
|
|
10782
11518
|
for (const target of selected) {
|
|
10783
11519
|
dist_exports.log.step(target.label);
|
|
10784
11520
|
const suggestion = uniqueName(
|
|
10785
|
-
targets.length === 1 && target.id === "console" ? source.id : suggestAliasName(
|
|
10786
|
-
target.attributes.accountName ?? target.label,
|
|
10787
|
-
target.attributes.roleName ?? ""
|
|
10788
|
-
),
|
|
11521
|
+
targets.length === 1 && target.id === "console" ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
|
|
10789
11522
|
existing
|
|
10790
11523
|
);
|
|
10791
11524
|
const name = answer(
|
|
@@ -10804,13 +11537,20 @@ async function chooseAliases(app, source, targets) {
|
|
|
10804
11537
|
validate: (v) => v?.trim() ? validateRegion2?.(v.trim()) : void 0
|
|
10805
11538
|
})
|
|
10806
11539
|
).trim() || defaultRegion;
|
|
11540
|
+
const prod = answer(
|
|
11541
|
+
await dist_exports.confirm({
|
|
11542
|
+
message: `Is ${name} production? (cloudhop then asks before any change)`,
|
|
11543
|
+
initialValue: looksLikeProduction(name, target.label)
|
|
11544
|
+
})
|
|
11545
|
+
);
|
|
10807
11546
|
defaultRegion = region;
|
|
10808
11547
|
existing.add(name);
|
|
10809
11548
|
aliases.push({
|
|
10810
11549
|
name,
|
|
10811
11550
|
source: source.id,
|
|
10812
11551
|
target: target.id,
|
|
10813
|
-
...region && region !== source.settings.region ? { region } : {}
|
|
11552
|
+
...region && region !== source.settings.region ? { region } : {},
|
|
11553
|
+
...prod ? { prod: true } : {}
|
|
10814
11554
|
});
|
|
10815
11555
|
}
|
|
10816
11556
|
return aliases;
|
|
@@ -10902,14 +11642,20 @@ async function aliasCommand(app, { positional, flags }) {
|
|
|
10902
11642
|
`See the options with \`cloudhop alias ${id} --list\`.`
|
|
10903
11643
|
);
|
|
10904
11644
|
}
|
|
10905
|
-
const
|
|
10906
|
-
|
|
10907
|
-
target.attributes.roleName ?? ""
|
|
10908
|
-
);
|
|
11645
|
+
const taken = new Set((await app.config()).aliases.map((a2) => a2.name));
|
|
11646
|
+
const name = flags.name ?? suggestAliases(targets, id, taken).get(target.id) ?? id;
|
|
10909
11647
|
await app.addAliases([
|
|
10910
|
-
{
|
|
11648
|
+
{
|
|
11649
|
+
name,
|
|
11650
|
+
source: id,
|
|
11651
|
+
target: target.id,
|
|
11652
|
+
...flags.region ? { region: flags.region } : {},
|
|
11653
|
+
...flags.prod ? { prod: true } : {}
|
|
11654
|
+
}
|
|
10911
11655
|
]);
|
|
10912
|
-
console.log(
|
|
11656
|
+
console.log(
|
|
11657
|
+
`Alias "${name}" \u2192 ${target.label}${flags.prod ? " (production)" : ""}. Try: hop ${name}`
|
|
11658
|
+
);
|
|
10913
11659
|
return;
|
|
10914
11660
|
}
|
|
10915
11661
|
if (scripted) {
|
|
@@ -10922,65 +11668,803 @@ async function aliasCommand(app, { positional, flags }) {
|
|
|
10922
11668
|
dist_exports.outro(`Created ${aliases.length} alias(es).`);
|
|
10923
11669
|
}
|
|
10924
11670
|
|
|
10925
|
-
// src/commands/
|
|
10926
|
-
|
|
10927
|
-
|
|
10928
|
-
|
|
10929
|
-
|
|
10930
|
-
|
|
10931
|
-
|
|
10932
|
-
|
|
10933
|
-
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
10938
|
-
|
|
10939
|
-
|
|
10940
|
-
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
10949
|
-
|
|
11671
|
+
// src/commands/agent.ts
|
|
11672
|
+
import { homedir as homedir2 } from "os";
|
|
11673
|
+
import { isAbsolute, relative } from "path";
|
|
11674
|
+
|
|
11675
|
+
// ../assistants/src/installer.ts
|
|
11676
|
+
import { createHash } from "crypto";
|
|
11677
|
+
import { existsSync as existsSync2 } from "fs";
|
|
11678
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, rmdir, writeFile as writeFile2 } from "fs/promises";
|
|
11679
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
11680
|
+
|
|
11681
|
+
// ../assistants/src/assets/skill.md
|
|
11682
|
+
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- **The same command in several aliases** (after the user chose them):\n `cloudhop each a,b,c --yes -- <aws arguments>`; add `--json` to parse the\n results.\n- **The AWS web console**: `cloudhop console <alias> [service]` opens it in\n the user's browser, already signed in. Never try to print or fetch its URL.\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- **Production aliases** have `\"prod\": true` in `cloudhop ls --json` (and\n `PROD` in `cloudhop ls`). Confirm every change there and suggest a dry run\n (`--dryrun`, `terraform plan`) first. cloudhop enforces it: without a\n terminal, `hop`/`cloudhop exec` refuse changes on production with\n `PROD_CONFIRMATION_REQUIRED`. After the user explicitly approves the exact\n command, rerun it with `CLOUDHOP_CONFIRM_PROD=1` in that command's\n environment (with MCP: `confirm_production: true`). Never set it on your own.\n- Treat aliases or accounts that merely look like production (`prd`, `live`)\n the same way, even if they are not marked.\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";
|
|
11683
|
+
|
|
11684
|
+
// ../assistants/src/targets.ts
|
|
11685
|
+
import { join as join3 } from "path";
|
|
11686
|
+
var ASSISTANT_IDS = [
|
|
11687
|
+
"claude",
|
|
11688
|
+
"kiro",
|
|
11689
|
+
"copilot",
|
|
11690
|
+
"cursor",
|
|
11691
|
+
"codex"
|
|
11692
|
+
];
|
|
11693
|
+
var SKILL_NAME = "cloudhop";
|
|
11694
|
+
var MCP_COMMAND = "cloudhop";
|
|
11695
|
+
var MCP_ARGS = ["mcp"];
|
|
11696
|
+
var ASSISTANTS = {
|
|
11697
|
+
claude: {
|
|
11698
|
+
id: "claude",
|
|
11699
|
+
name: "Claude Code",
|
|
11700
|
+
markers: ({ home }) => [join3(home, ".claude")],
|
|
11701
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".claude", "skills", SKILL_NAME),
|
|
11702
|
+
mcp: (scope, { project }) => scope === "project" ? { kind: "json", file: join3(project, ".mcp.json"), key: "mcpServers", withType: false } : {
|
|
11703
|
+
kind: "cli",
|
|
11704
|
+
command: "claude",
|
|
11705
|
+
add: ["mcp", "add", "--scope", "user", "cloudhop", "--", MCP_COMMAND, ...MCP_ARGS],
|
|
11706
|
+
remove: ["mcp", "remove", "--scope", "user", "cloudhop"],
|
|
11707
|
+
manual: "claude mcp add --scope user cloudhop -- cloudhop mcp"
|
|
11708
|
+
}
|
|
11709
|
+
},
|
|
11710
|
+
kiro: {
|
|
11711
|
+
id: "kiro",
|
|
11712
|
+
name: "Kiro",
|
|
11713
|
+
markers: ({ home }) => [join3(home, ".kiro")],
|
|
11714
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".kiro", "skills", SKILL_NAME),
|
|
11715
|
+
mcp: (scope, { home, project }) => ({
|
|
11716
|
+
kind: "json",
|
|
11717
|
+
file: join3(scope === "user" ? home : project, ".kiro", "settings", "mcp.json"),
|
|
11718
|
+
key: "mcpServers",
|
|
11719
|
+
withType: false
|
|
11720
|
+
})
|
|
11721
|
+
},
|
|
11722
|
+
copilot: {
|
|
11723
|
+
id: "copilot",
|
|
11724
|
+
name: "GitHub Copilot (VS Code)",
|
|
11725
|
+
markers: ({ home }) => [join3(home, ".copilot"), join3(home, ".vscode")],
|
|
11726
|
+
skillDir: (scope, { home, project }) => scope === "user" ? join3(home, ".copilot", "skills", SKILL_NAME) : join3(project, ".github", "skills", SKILL_NAME),
|
|
11727
|
+
mcp: (scope, { project }) => scope === "project" ? {
|
|
11728
|
+
kind: "json",
|
|
11729
|
+
file: join3(project, ".vscode", "mcp.json"),
|
|
11730
|
+
key: "servers",
|
|
11731
|
+
withType: true
|
|
11732
|
+
} : {
|
|
11733
|
+
kind: "cli",
|
|
11734
|
+
command: "code",
|
|
11735
|
+
add: [
|
|
11736
|
+
"--add-mcp",
|
|
11737
|
+
JSON.stringify({
|
|
11738
|
+
name: "cloudhop",
|
|
11739
|
+
type: "stdio",
|
|
11740
|
+
command: MCP_COMMAND,
|
|
11741
|
+
args: MCP_ARGS
|
|
11742
|
+
})
|
|
11743
|
+
],
|
|
11744
|
+
remove: void 0,
|
|
11745
|
+
manual: 'VS Code \u2192 "MCP: Add Server" \u2192 stdio \u2192 command `cloudhop mcp`'
|
|
11746
|
+
}
|
|
11747
|
+
},
|
|
11748
|
+
cursor: {
|
|
11749
|
+
id: "cursor",
|
|
11750
|
+
name: "Cursor",
|
|
11751
|
+
markers: ({ home }) => [join3(home, ".cursor")],
|
|
11752
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".cursor", "skills", SKILL_NAME),
|
|
11753
|
+
mcp: (scope, { home, project }) => ({
|
|
11754
|
+
kind: "json",
|
|
11755
|
+
file: join3(scope === "user" ? home : project, ".cursor", "mcp.json"),
|
|
11756
|
+
key: "mcpServers",
|
|
11757
|
+
withType: true
|
|
11758
|
+
})
|
|
11759
|
+
},
|
|
11760
|
+
codex: {
|
|
11761
|
+
id: "codex",
|
|
11762
|
+
name: "Codex",
|
|
11763
|
+
markers: ({ home }) => [join3(home, ".codex"), join3(home, ".agents")],
|
|
11764
|
+
skillDir: (scope, { home, project }) => join3(scope === "user" ? home : project, ".agents", "skills", SKILL_NAME),
|
|
11765
|
+
mcp: (scope, { home, project }) => ({
|
|
11766
|
+
kind: "toml",
|
|
11767
|
+
file: join3(scope === "user" ? home : project, ".codex", "config.toml")
|
|
11768
|
+
})
|
|
10950
11769
|
}
|
|
11770
|
+
};
|
|
11771
|
+
|
|
11772
|
+
// ../assistants/src/installer.ts
|
|
11773
|
+
var sha = (text4) => createHash("sha256").update(text4).digest("hex");
|
|
11774
|
+
async function writeAtomic(file, content) {
|
|
11775
|
+
await mkdir2(dirname2(file), { recursive: true });
|
|
11776
|
+
const tmp = `${file}.cloudhop-${process.pid}.tmp`;
|
|
11777
|
+
await writeFile2(tmp, content);
|
|
11778
|
+
await rename2(tmp, file);
|
|
10951
11779
|
}
|
|
10952
|
-
async function
|
|
10953
|
-
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
message: "Log in to which source?",
|
|
10957
|
-
options: sources.map((s) => ({ value: s.id, label: s.id }))
|
|
10958
|
-
})
|
|
10959
|
-
);
|
|
10960
|
-
printReport(await app.login(id));
|
|
11780
|
+
async function removeFileAndEmptyDir(file) {
|
|
11781
|
+
await rm2(file, { force: true });
|
|
11782
|
+
await rmdir(dirname2(file)).catch(() => {
|
|
11783
|
+
});
|
|
10961
11784
|
}
|
|
10962
|
-
var
|
|
10963
|
-
|
|
10964
|
-
|
|
10965
|
-
|
|
10966
|
-
unknown: paint("dim", "\xB7 session managed by the cloud CLI")
|
|
10967
|
-
};
|
|
10968
|
-
async function listCommand(app, sourceId) {
|
|
10969
|
-
const config = await app.config();
|
|
10970
|
-
if (config.sources.length === 0) {
|
|
10971
|
-
console.log("Nothing here yet. Run `cloudhop setup` to get started.");
|
|
10972
|
-
return;
|
|
11785
|
+
var readText = (file) => readFile2(file, "utf8").catch(() => void 0);
|
|
11786
|
+
var AssistantsInstaller = class {
|
|
11787
|
+
constructor(options) {
|
|
11788
|
+
this.options = options;
|
|
10973
11789
|
}
|
|
10974
|
-
|
|
10975
|
-
|
|
10976
|
-
|
|
10977
|
-
|
|
10978
|
-
|
|
10979
|
-
|
|
10980
|
-
|
|
11790
|
+
options;
|
|
11791
|
+
/** The skill as written to disk: the bundled content plus a footer naming the version. */
|
|
11792
|
+
get skillContent() {
|
|
11793
|
+
return `${skill_default.replace(/\r\n/g, "\n").trimEnd()}
|
|
11794
|
+
|
|
11795
|
+
<!-- Installed by cloudhop ${this.options.cloudhopVersion}. Refresh with \`cloudhop agent update\`; edits here are kept. -->
|
|
11796
|
+
`;
|
|
10981
11797
|
}
|
|
10982
|
-
|
|
10983
|
-
|
|
11798
|
+
/** Assistants that look installed on this machine. */
|
|
11799
|
+
detect() {
|
|
11800
|
+
const { places } = this.options;
|
|
11801
|
+
return ASSISTANT_IDS.filter((id) => ASSISTANTS[id].markers(places).some((p3) => existsSync2(p3)));
|
|
11802
|
+
}
|
|
11803
|
+
async status(scope) {
|
|
11804
|
+
const detected = new Set(this.detect());
|
|
11805
|
+
const manifest = await this.readManifest();
|
|
11806
|
+
return Promise.all(
|
|
11807
|
+
ASSISTANT_IDS.map(async (id) => {
|
|
11808
|
+
const assistant = ASSISTANTS[id];
|
|
11809
|
+
const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
|
|
11810
|
+
const entry = manifest.find((e) => e.skillFile === skillPath);
|
|
11811
|
+
return {
|
|
11812
|
+
assistant: id,
|
|
11813
|
+
name: assistant.name,
|
|
11814
|
+
scope,
|
|
11815
|
+
detected: detected.has(id),
|
|
11816
|
+
skill: await this.skillState(skillPath, entry),
|
|
11817
|
+
skillPath,
|
|
11818
|
+
mcp: await this.mcpState(assistant.mcp(scope, this.options.places))
|
|
11819
|
+
};
|
|
11820
|
+
})
|
|
11821
|
+
);
|
|
11822
|
+
}
|
|
11823
|
+
async install(ids, scope, { mcp = true } = {}) {
|
|
11824
|
+
const results = [];
|
|
11825
|
+
const manifest = await this.readManifest();
|
|
11826
|
+
for (const id of ids) {
|
|
11827
|
+
const assistant = ASSISTANTS[id];
|
|
11828
|
+
const skillPath = join4(assistant.skillDir(scope, this.options.places), "SKILL.md");
|
|
11829
|
+
const content = this.skillContent;
|
|
11830
|
+
await writeAtomic(skillPath, content);
|
|
11831
|
+
const kept = manifest.filter((e) => e.skillFile !== skillPath);
|
|
11832
|
+
kept.push({
|
|
11833
|
+
assistant: id,
|
|
11834
|
+
scope,
|
|
11835
|
+
skillFile: skillPath,
|
|
11836
|
+
hash: sha(content),
|
|
11837
|
+
cloudhopVersion: this.options.cloudhopVersion
|
|
11838
|
+
});
|
|
11839
|
+
manifest.splice(0, manifest.length, ...kept);
|
|
11840
|
+
const target = assistant.mcp(scope, this.options.places);
|
|
11841
|
+
const outcome = mcp ? await this.addMcp(target) : { mcp: "skipped" };
|
|
11842
|
+
results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
|
|
11843
|
+
}
|
|
11844
|
+
await this.writeManifest(manifest);
|
|
11845
|
+
return results;
|
|
11846
|
+
}
|
|
11847
|
+
async remove(ids, scope) {
|
|
11848
|
+
const results = [];
|
|
11849
|
+
let manifest = await this.readManifest();
|
|
11850
|
+
for (const id of ids) {
|
|
11851
|
+
const assistant = ASSISTANTS[id];
|
|
11852
|
+
const dir = assistant.skillDir(scope, this.options.places);
|
|
11853
|
+
const skillPath = join4(dir, "SKILL.md");
|
|
11854
|
+
await rm2(dir, { recursive: true, force: true });
|
|
11855
|
+
await rmdir(dirname2(dir)).then(() => rmdir(dirname2(dirname2(dir)))).catch(() => {
|
|
11856
|
+
});
|
|
11857
|
+
manifest = manifest.filter((e) => e.skillFile !== skillPath);
|
|
11858
|
+
const outcome = await this.removeMcp(assistant.mcp(scope, this.options.places));
|
|
11859
|
+
results.push({ assistant: id, name: assistant.name, scope, skillPath, ...outcome });
|
|
11860
|
+
}
|
|
11861
|
+
await this.writeManifest(manifest);
|
|
11862
|
+
return results;
|
|
11863
|
+
}
|
|
11864
|
+
/**
|
|
11865
|
+
* Refreshes skills written by an older cloudhop. Copies the user edited are
|
|
11866
|
+
* left alone unless `force`. Cheap when there is nothing to do: one small read.
|
|
11867
|
+
*/
|
|
11868
|
+
async update({ force = false } = {}) {
|
|
11869
|
+
const manifest = await this.readManifest();
|
|
11870
|
+
const content = this.skillContent;
|
|
11871
|
+
const target = sha(content);
|
|
11872
|
+
const updated = [];
|
|
11873
|
+
const edited = [];
|
|
11874
|
+
let changed = false;
|
|
11875
|
+
for (const entry of manifest) {
|
|
11876
|
+
if (entry.hash === target) continue;
|
|
11877
|
+
const onDisk = await readText(entry.skillFile);
|
|
11878
|
+
if (onDisk === void 0) continue;
|
|
11879
|
+
if (sha(onDisk) !== entry.hash && !force) {
|
|
11880
|
+
edited.push(entry.skillFile);
|
|
11881
|
+
continue;
|
|
11882
|
+
}
|
|
11883
|
+
await writeAtomic(entry.skillFile, content);
|
|
11884
|
+
entry.hash = target;
|
|
11885
|
+
entry.cloudhopVersion = this.options.cloudhopVersion;
|
|
11886
|
+
updated.push(entry.skillFile);
|
|
11887
|
+
changed = true;
|
|
11888
|
+
}
|
|
11889
|
+
if (changed) await this.writeManifest(manifest);
|
|
11890
|
+
return { updated, edited };
|
|
11891
|
+
}
|
|
11892
|
+
// ───────────── skill state ─────────────
|
|
11893
|
+
async skillState(path2, entry) {
|
|
11894
|
+
const onDisk = await readText(path2);
|
|
11895
|
+
if (onDisk === void 0) return "missing";
|
|
11896
|
+
if (onDisk === this.skillContent) return "current";
|
|
11897
|
+
if (entry && sha(onDisk) !== entry.hash) return "modified";
|
|
11898
|
+
return "outdated";
|
|
11899
|
+
}
|
|
11900
|
+
// ───────────── MCP registration ─────────────
|
|
11901
|
+
async mcpState(target) {
|
|
11902
|
+
if (target.kind === "cli") return "unknown";
|
|
11903
|
+
const text4 = await readText(target.file);
|
|
11904
|
+
if (text4 === void 0) return "missing";
|
|
11905
|
+
if (target.kind === "toml")
|
|
11906
|
+
return /^\[mcp_servers\.cloudhop\]/m.test(text4) ? "registered" : "missing";
|
|
11907
|
+
try {
|
|
11908
|
+
const servers = JSON.parse(text4)[target.key];
|
|
11909
|
+
return servers?.cloudhop ? "registered" : "missing";
|
|
11910
|
+
} catch {
|
|
11911
|
+
return "unknown";
|
|
11912
|
+
}
|
|
11913
|
+
}
|
|
11914
|
+
async addMcp(target) {
|
|
11915
|
+
switch (target.kind) {
|
|
11916
|
+
case "json": {
|
|
11917
|
+
const config = await this.readJsonConfig(target.file);
|
|
11918
|
+
config[target.key] ??= {};
|
|
11919
|
+
const servers = config[target.key];
|
|
11920
|
+
const entry = {
|
|
11921
|
+
...target.withType ? { type: "stdio" } : {},
|
|
11922
|
+
command: MCP_COMMAND,
|
|
11923
|
+
args: MCP_ARGS
|
|
11924
|
+
};
|
|
11925
|
+
if (JSON.stringify(servers.cloudhop) === JSON.stringify(entry)) return { mcp: "already" };
|
|
11926
|
+
servers.cloudhop = entry;
|
|
11927
|
+
await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
|
|
11928
|
+
`);
|
|
11929
|
+
return { mcp: "added" };
|
|
11930
|
+
}
|
|
11931
|
+
case "toml": {
|
|
11932
|
+
const text4 = await readText(target.file) ?? "";
|
|
11933
|
+
if (/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
|
|
11934
|
+
const block2 = `${TOML_MARK}
|
|
11935
|
+
[mcp_servers.cloudhop]
|
|
11936
|
+
command = "${MCP_COMMAND}"
|
|
11937
|
+
args = [${MCP_ARGS.map((a2) => `"${a2}"`).join(", ")}]
|
|
11938
|
+
`;
|
|
11939
|
+
await writeAtomic(
|
|
11940
|
+
target.file,
|
|
11941
|
+
`${text4}${text4 && !text4.endsWith("\n") ? "\n" : ""}${text4 ? "\n" : ""}${block2}`
|
|
11942
|
+
);
|
|
11943
|
+
return { mcp: "added" };
|
|
11944
|
+
}
|
|
11945
|
+
case "cli": {
|
|
11946
|
+
if (!(await this.options.runner.probe(target.command)).installed) {
|
|
11947
|
+
return { mcp: "manual", mcpManual: target.manual };
|
|
11948
|
+
}
|
|
11949
|
+
const result = await this.options.runner.capture(target.command, target.add);
|
|
11950
|
+
if (result.exitCode === 0) return { mcp: "added" };
|
|
11951
|
+
if (/already exists/i.test(`${result.stdout}${result.stderr}`)) return { mcp: "already" };
|
|
11952
|
+
return { mcp: "manual", mcpManual: target.manual };
|
|
11953
|
+
}
|
|
11954
|
+
}
|
|
11955
|
+
}
|
|
11956
|
+
async removeMcp(target) {
|
|
11957
|
+
switch (target.kind) {
|
|
11958
|
+
case "json": {
|
|
11959
|
+
const text4 = await readText(target.file);
|
|
11960
|
+
if (text4 === void 0) return { mcp: "already" };
|
|
11961
|
+
const config = await this.readJsonConfig(target.file);
|
|
11962
|
+
const servers = config[target.key];
|
|
11963
|
+
if (!servers?.cloudhop) return { mcp: "already" };
|
|
11964
|
+
delete servers.cloudhop;
|
|
11965
|
+
const onlyEmptyKey = Object.keys(servers).length === 0 && Object.keys(config).length === 1;
|
|
11966
|
+
if (onlyEmptyKey) await removeFileAndEmptyDir(target.file);
|
|
11967
|
+
else await writeAtomic(target.file, `${JSON.stringify(config, null, 2)}
|
|
11968
|
+
`);
|
|
11969
|
+
return { mcp: "removed" };
|
|
11970
|
+
}
|
|
11971
|
+
case "toml": {
|
|
11972
|
+
const text4 = await readText(target.file);
|
|
11973
|
+
if (!text4 || !/^\[mcp_servers\.cloudhop\]/m.test(text4)) return { mcp: "already" };
|
|
11974
|
+
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");
|
|
11975
|
+
if (cleaned.trim()) await writeAtomic(target.file, `${cleaned.trimEnd()}
|
|
11976
|
+
`);
|
|
11977
|
+
else await removeFileAndEmptyDir(target.file);
|
|
11978
|
+
return { mcp: "removed" };
|
|
11979
|
+
}
|
|
11980
|
+
case "cli": {
|
|
11981
|
+
if (!target.remove || !(await this.options.runner.probe(target.command)).installed) {
|
|
11982
|
+
return {
|
|
11983
|
+
mcp: "manual",
|
|
11984
|
+
mcpManual: `Remove the "cloudhop" MCP server in ${target.command === "code" ? "VS Code (MCP: List Servers)" : target.command}.`
|
|
11985
|
+
};
|
|
11986
|
+
}
|
|
11987
|
+
await this.options.runner.capture(target.command, target.remove);
|
|
11988
|
+
return { mcp: "removed" };
|
|
11989
|
+
}
|
|
11990
|
+
}
|
|
11991
|
+
}
|
|
11992
|
+
async readJsonConfig(file) {
|
|
11993
|
+
const text4 = await readText(file);
|
|
11994
|
+
if (text4 === void 0 || !text4.trim()) return {};
|
|
11995
|
+
try {
|
|
11996
|
+
const value = JSON.parse(text4);
|
|
11997
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
11998
|
+
return value;
|
|
11999
|
+
} catch {
|
|
12000
|
+
}
|
|
12001
|
+
throw new CloudhopError(
|
|
12002
|
+
"INVALID_NAME",
|
|
12003
|
+
`${file} isn't plain JSON (it may have comments), so cloudhop won't rewrite it.`,
|
|
12004
|
+
`Add the server by hand: "cloudhop": { "command": "cloudhop", "args": ["mcp"] }, or rerun with --no-mcp.`
|
|
12005
|
+
);
|
|
12006
|
+
}
|
|
12007
|
+
// ───────────── manifest ─────────────
|
|
12008
|
+
get manifestPath() {
|
|
12009
|
+
return join4(this.options.configDir, "assistants.json");
|
|
12010
|
+
}
|
|
12011
|
+
async readManifest() {
|
|
12012
|
+
const text4 = await readText(this.manifestPath);
|
|
12013
|
+
if (!text4) return [];
|
|
12014
|
+
try {
|
|
12015
|
+
const parsed = JSON.parse(text4);
|
|
12016
|
+
return Array.isArray(parsed.installs) ? parsed.installs : [];
|
|
12017
|
+
} catch {
|
|
12018
|
+
return [];
|
|
12019
|
+
}
|
|
12020
|
+
}
|
|
12021
|
+
async writeManifest(installs) {
|
|
12022
|
+
await writeAtomic(this.manifestPath, `${JSON.stringify({ installs }, null, 2)}
|
|
12023
|
+
`);
|
|
12024
|
+
}
|
|
12025
|
+
};
|
|
12026
|
+
var TOML_MARK = "# Added by cloudhop (`cloudhop agent remove` deletes it)";
|
|
12027
|
+
var escapeRegExp = (text4) => text4.replace(/[.*+?^${}()|[\]\\`]/g, "\\$&");
|
|
12028
|
+
|
|
12029
|
+
// src/commands/agent.ts
|
|
12030
|
+
var MCP_TEXT = {
|
|
12031
|
+
added: paint("green", "MCP server added"),
|
|
12032
|
+
already: "MCP server already there",
|
|
12033
|
+
removed: "MCP server removed",
|
|
12034
|
+
manual: paint("yellow", "MCP server: add it by hand"),
|
|
12035
|
+
skipped: paint("dim", "MCP server skipped")
|
|
12036
|
+
};
|
|
12037
|
+
var tilde = (path2) => {
|
|
12038
|
+
const inProject = relative(process.cwd(), path2);
|
|
12039
|
+
if (!inProject.startsWith("..") && !isAbsolute(inProject)) return inProject;
|
|
12040
|
+
return path2.replace(homedir2(), "~");
|
|
12041
|
+
};
|
|
12042
|
+
function parseTools(value, fallback) {
|
|
12043
|
+
if (!value) return fallback;
|
|
12044
|
+
const ids = value.split(",").map((v) => v.trim().toLowerCase());
|
|
12045
|
+
const unknown = ids.filter((id) => !ASSISTANT_IDS.includes(id));
|
|
12046
|
+
if (unknown.length) {
|
|
12047
|
+
throw new CloudhopError(
|
|
12048
|
+
"INVALID_NAME",
|
|
12049
|
+
`Unknown assistant: ${unknown.join(", ")}.`,
|
|
12050
|
+
`Use any of: ${ASSISTANT_IDS.join(", ")}.`
|
|
12051
|
+
);
|
|
12052
|
+
}
|
|
12053
|
+
return ids;
|
|
12054
|
+
}
|
|
12055
|
+
function parseScope(value) {
|
|
12056
|
+
if (value === void 0) return void 0;
|
|
12057
|
+
if (value === "user" || value === "project") return value;
|
|
12058
|
+
throw new CloudhopError("INVALID_NAME", '--scope must be "user" or "project".');
|
|
12059
|
+
}
|
|
12060
|
+
function printResults(results) {
|
|
12061
|
+
for (const r2 of results) {
|
|
12062
|
+
console.log(`${paint("green", "\u2714")} ${bold(r2.name)}`);
|
|
12063
|
+
console.log(` skill ${tilde(r2.skillPath)}`);
|
|
12064
|
+
console.log(` ${MCP_TEXT[r2.mcp]}${r2.mcpManual ? `: ${r2.mcpManual}` : ""}`);
|
|
12065
|
+
}
|
|
12066
|
+
}
|
|
12067
|
+
async function agentCommand(installer, args) {
|
|
12068
|
+
const { positional, flags } = parseArgs(args);
|
|
12069
|
+
const action = positional[0] ?? "status";
|
|
12070
|
+
const scopeFlag = parseScope(flags.scope);
|
|
12071
|
+
switch (action) {
|
|
12072
|
+
case "status": {
|
|
12073
|
+
for (const scope of scopeFlag ? [scopeFlag] : ["user", "project"]) {
|
|
12074
|
+
console.log(
|
|
12075
|
+
bold(
|
|
12076
|
+
scope === "user" ? "For you (all projects)" : `This project (${tilde(process.cwd())})`
|
|
12077
|
+
)
|
|
12078
|
+
);
|
|
12079
|
+
for (const s of await installer.status(scope)) {
|
|
12080
|
+
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(
|
|
12081
|
+
"yellow",
|
|
12082
|
+
"\u25D0 skill edited by you (`cloudhop agent update --force` replaces it)"
|
|
12083
|
+
) : paint("dim", "\u25CB no skill");
|
|
12084
|
+
const mcp = s.mcp === "registered" ? ", MCP on" : s.mcp === "unknown" ? ", MCP: check in the app" : "";
|
|
12085
|
+
const seen = s.detected ? "" : paint("dim", " (not detected)");
|
|
12086
|
+
console.log(` ${s.name.padEnd(26)} ${skill}${s.skill === "missing" ? "" : mcp}${seen}`);
|
|
12087
|
+
}
|
|
12088
|
+
}
|
|
12089
|
+
return;
|
|
12090
|
+
}
|
|
12091
|
+
case "install": {
|
|
12092
|
+
const detected = installer.detect();
|
|
12093
|
+
let tools = parseTools(flags.tools, detected);
|
|
12094
|
+
let scope = scopeFlag ?? "user";
|
|
12095
|
+
let mcp = !flags.noMcp;
|
|
12096
|
+
if (!flags.tools && interactive() && !flags.yes) {
|
|
12097
|
+
dist_exports.intro("cloudhop agent install");
|
|
12098
|
+
dist_exports.note(
|
|
12099
|
+
"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).",
|
|
12100
|
+
"Teach your AI assistant cloudhop"
|
|
12101
|
+
);
|
|
12102
|
+
tools = answer(
|
|
12103
|
+
await dist_exports.multiselect({
|
|
12104
|
+
message: "Which assistants do you use?",
|
|
12105
|
+
options: ASSISTANT_IDS.map((id) => ({
|
|
12106
|
+
value: id,
|
|
12107
|
+
label: ASSISTANTS[id].name,
|
|
12108
|
+
...detected.includes(id) ? { hint: "detected" } : {}
|
|
12109
|
+
})),
|
|
12110
|
+
initialValues: detected,
|
|
12111
|
+
required: true
|
|
12112
|
+
})
|
|
12113
|
+
);
|
|
12114
|
+
if (!scopeFlag) {
|
|
12115
|
+
scope = answer(
|
|
12116
|
+
await dist_exports.select({
|
|
12117
|
+
message: "Where should it go?",
|
|
12118
|
+
options: [
|
|
12119
|
+
{ value: "user", label: "For me, in every project", hint: "your home folder" },
|
|
12120
|
+
{
|
|
12121
|
+
value: "project",
|
|
12122
|
+
label: "Only this project",
|
|
12123
|
+
hint: "commit it and your team gets it too"
|
|
12124
|
+
}
|
|
12125
|
+
]
|
|
12126
|
+
})
|
|
12127
|
+
);
|
|
12128
|
+
}
|
|
12129
|
+
if (!flags.noMcp) {
|
|
12130
|
+
mcp = answer(
|
|
12131
|
+
await dist_exports.confirm({
|
|
12132
|
+
message: "Also register the cloudhop MCP server?",
|
|
12133
|
+
initialValue: true
|
|
12134
|
+
})
|
|
12135
|
+
);
|
|
12136
|
+
}
|
|
12137
|
+
}
|
|
12138
|
+
if (tools.length === 0) {
|
|
12139
|
+
throw new CloudhopError(
|
|
12140
|
+
"INVALID_NAME",
|
|
12141
|
+
"No AI assistant detected.",
|
|
12142
|
+
`Choose them with --tools ${ASSISTANT_IDS.join(",")}.`
|
|
12143
|
+
);
|
|
12144
|
+
}
|
|
12145
|
+
printResults(await installer.install(tools, scope, { mcp }));
|
|
12146
|
+
console.log(
|
|
12147
|
+
paint("dim", "\nStart a new chat (or restart the assistant) so it picks up the skill.")
|
|
12148
|
+
);
|
|
12149
|
+
return;
|
|
12150
|
+
}
|
|
12151
|
+
case "update": {
|
|
12152
|
+
const { updated, edited } = await installer.update({ force: Boolean(flags.force) });
|
|
12153
|
+
for (const file of updated) console.log(`${paint("green", "\u2714")} updated ${tilde(file)}`);
|
|
12154
|
+
for (const file of edited)
|
|
12155
|
+
console.log(
|
|
12156
|
+
`${paint("yellow", "\u25D0")} kept your edits in ${tilde(file)} (use --force to replace)`
|
|
12157
|
+
);
|
|
12158
|
+
if (!updated.length && !edited.length) console.log("Every installed skill is up to date.");
|
|
12159
|
+
return;
|
|
12160
|
+
}
|
|
12161
|
+
case "remove": {
|
|
12162
|
+
const scope = scopeFlag ?? "user";
|
|
12163
|
+
const statuses = await installer.status(scope);
|
|
12164
|
+
const installed = statuses.filter((s) => s.skill !== "missing" || s.mcp === "registered").map((s) => s.assistant);
|
|
12165
|
+
const tools = parseTools(flags.tools, installed);
|
|
12166
|
+
if (tools.length === 0) {
|
|
12167
|
+
console.log(`Nothing to remove for scope "${scope}".`);
|
|
12168
|
+
return;
|
|
12169
|
+
}
|
|
12170
|
+
printResults(await installer.remove(tools, scope));
|
|
12171
|
+
return;
|
|
12172
|
+
}
|
|
12173
|
+
default:
|
|
12174
|
+
throw new CloudhopError(
|
|
12175
|
+
"INVALID_NAME",
|
|
12176
|
+
`Unknown action "${action}".`,
|
|
12177
|
+
"Use: cloudhop agent [status | install | update | remove]"
|
|
12178
|
+
);
|
|
12179
|
+
}
|
|
12180
|
+
}
|
|
12181
|
+
|
|
12182
|
+
// src/picker.ts
|
|
12183
|
+
async function pickAlias(app, message = "Which alias?") {
|
|
12184
|
+
if (!interactive()) {
|
|
12185
|
+
throw new CloudhopError(
|
|
12186
|
+
"NOT_INTERACTIVE",
|
|
12187
|
+
"Say which alias to use.",
|
|
12188
|
+
"See them with `cloudhop ls`."
|
|
12189
|
+
);
|
|
12190
|
+
}
|
|
12191
|
+
const { aliases } = await app.overview();
|
|
12192
|
+
if (aliases.length === 0) {
|
|
12193
|
+
throw new CloudhopError(
|
|
12194
|
+
"ALIAS_NOT_FOUND",
|
|
12195
|
+
"No aliases yet.",
|
|
12196
|
+
"Create one with `cloudhop alias <source>`."
|
|
12197
|
+
);
|
|
12198
|
+
}
|
|
12199
|
+
const haystack = new Map(
|
|
12200
|
+
aliases.map((a2) => [
|
|
12201
|
+
a2.name,
|
|
12202
|
+
`${a2.name} ${a2.label ?? a2.target} ${a2.region ?? ""} ${a2.source}`.toLowerCase()
|
|
12203
|
+
])
|
|
12204
|
+
);
|
|
12205
|
+
return answer(
|
|
12206
|
+
await dist_exports.autocomplete({
|
|
12207
|
+
message: `${message} ${paint("dim", "(type to filter)")}`,
|
|
12208
|
+
maxItems: 12,
|
|
12209
|
+
options: aliases.map((a2) => ({
|
|
12210
|
+
value: a2.name,
|
|
12211
|
+
label: a2.prod ? `${a2.name} ${paint("red", "PROD")}` : a2.name,
|
|
12212
|
+
hint: `${a2.label ?? a2.target} \xB7 ${a2.region ?? "no region"}`
|
|
12213
|
+
})),
|
|
12214
|
+
filter: (search, option) => {
|
|
12215
|
+
const text4 = haystack.get(option.value) ?? "";
|
|
12216
|
+
return search.toLowerCase().split(/\s+/).filter(Boolean).every((word) => text4.includes(word));
|
|
12217
|
+
}
|
|
12218
|
+
})
|
|
12219
|
+
);
|
|
12220
|
+
}
|
|
12221
|
+
|
|
12222
|
+
// src/commands/console.ts
|
|
12223
|
+
var HOW = {
|
|
12224
|
+
"signed-in": "signed in",
|
|
12225
|
+
portal: "through the SSO portal (AWS did not accept a direct sign-in)",
|
|
12226
|
+
"sign-in-page": "at the sign-in page (sign in with your console user)"
|
|
12227
|
+
};
|
|
12228
|
+
async function consoleCommand(app, args) {
|
|
12229
|
+
const { positional, flags } = parseArgs(args);
|
|
12230
|
+
const alias = positional[0] ?? await pickAlias(app, "Open the console as");
|
|
12231
|
+
const service = positional[1];
|
|
12232
|
+
const via = await app.openConsole(alias, {
|
|
12233
|
+
...service ? { service } : {},
|
|
12234
|
+
...flags.region ? { region: flags.region } : {}
|
|
12235
|
+
});
|
|
12236
|
+
notify(`Opened the ${service ? `${service} ` : ""}console for ${alias}, ${HOW[via]}.`);
|
|
12237
|
+
}
|
|
12238
|
+
|
|
12239
|
+
// src/commands/each.ts
|
|
12240
|
+
function matcher(selector) {
|
|
12241
|
+
const regex = /^\/(.+)\/([a-z]*)$/.exec(selector);
|
|
12242
|
+
if (regex) {
|
|
12243
|
+
try {
|
|
12244
|
+
const re = new RegExp(regex[1], regex[2]);
|
|
12245
|
+
return (name) => re.test(name);
|
|
12246
|
+
} catch {
|
|
12247
|
+
throw new CloudhopError("INVALID_NAME", `Invalid regular expression: ${selector}`);
|
|
12248
|
+
}
|
|
12249
|
+
}
|
|
12250
|
+
if (/[*?]/.test(selector)) {
|
|
12251
|
+
const escaped = selector.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
12252
|
+
const re = new RegExp(`^${escaped}$`);
|
|
12253
|
+
return (name) => re.test(name);
|
|
12254
|
+
}
|
|
12255
|
+
return (name) => name === selector;
|
|
12256
|
+
}
|
|
12257
|
+
function selectAliases(aliases, selectors, filters) {
|
|
12258
|
+
const patterns = selectors.flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
|
|
12259
|
+
const tests = patterns.map(matcher);
|
|
12260
|
+
for (const [i2, pattern] of patterns.entries()) {
|
|
12261
|
+
const plain = !/[*?]/.test(pattern) && !pattern.startsWith("/");
|
|
12262
|
+
if (plain && !aliases.some((a2) => tests[i2]?.(a2.name))) {
|
|
12263
|
+
throw new CloudhopError(
|
|
12264
|
+
"ALIAS_NOT_FOUND",
|
|
12265
|
+
`Alias "${pattern}" not found.`,
|
|
12266
|
+
"See your aliases with `cloudhop ls`."
|
|
12267
|
+
);
|
|
12268
|
+
}
|
|
12269
|
+
}
|
|
12270
|
+
return aliases.filter(
|
|
12271
|
+
(a2) => (tests.length === 0 || tests.some((t2) => t2(a2.name))) && (filters.source === void 0 || a2.source === filters.source) && (filters.prod === void 0 || a2.prod === filters.prod)
|
|
12272
|
+
);
|
|
12273
|
+
}
|
|
12274
|
+
async function mapLimit(items, limit, fn) {
|
|
12275
|
+
const results = new Array(items.length);
|
|
12276
|
+
let next = 0;
|
|
12277
|
+
const worker = async () => {
|
|
12278
|
+
while (next < items.length) {
|
|
12279
|
+
const i2 = next++;
|
|
12280
|
+
results[i2] = await fn(items[i2]);
|
|
12281
|
+
}
|
|
12282
|
+
};
|
|
12283
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
12284
|
+
return results;
|
|
12285
|
+
}
|
|
12286
|
+
async function eachCommand(app, args) {
|
|
12287
|
+
const separator = args.indexOf("--");
|
|
12288
|
+
if (separator === -1 || separator === args.length - 1) {
|
|
12289
|
+
throw new CloudhopError(
|
|
12290
|
+
"INVALID_NAME",
|
|
12291
|
+
"Usage: cloudhop each <alias|list|pattern|/regex/...> [--source <s>] [--prod|--no-prod] -- <command>",
|
|
12292
|
+
"Example: cloudhop each 'creci-*' -- s3 ls"
|
|
12293
|
+
);
|
|
12294
|
+
}
|
|
12295
|
+
const { positional, flags } = parseArgs(args.slice(0, separator));
|
|
12296
|
+
const cliArgs = args.slice(separator + 1);
|
|
12297
|
+
if (positional.length === 0 && !flags.source && !flags.prod && !flags.noProd) {
|
|
12298
|
+
throw new CloudhopError(
|
|
12299
|
+
"INVALID_NAME",
|
|
12300
|
+
'Say which aliases: names, a pattern like "globex-*", /regex/, --source or --prod.'
|
|
12301
|
+
);
|
|
12302
|
+
}
|
|
12303
|
+
const prodFilter = flags.prod ? true : flags.noProd ? false : void 0;
|
|
12304
|
+
const { aliases } = await app.overview();
|
|
12305
|
+
const selected = selectAliases(aliases, positional, {
|
|
12306
|
+
...flags.source ? { source: flags.source } : {},
|
|
12307
|
+
...prodFilter !== void 0 ? { prod: prodFilter } : {}
|
|
12308
|
+
});
|
|
12309
|
+
if (selected.length === 0)
|
|
12310
|
+
throw new CloudhopError("ALIAS_NOT_FOUND", "No alias matches that selection.");
|
|
12311
|
+
const command = `aws ${cliArgs.join(" ")}`;
|
|
12312
|
+
const width = Math.max(...selected.map((a2) => a2.name.length)) + 2;
|
|
12313
|
+
const listing = selected.map(
|
|
12314
|
+
(a2) => ` ${a2.name.padEnd(width)}${(a2.region ?? "-").padEnd(12)}${a2.label ?? a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`
|
|
12315
|
+
).join("\n");
|
|
12316
|
+
const risky = [];
|
|
12317
|
+
for (const a2 of selected)
|
|
12318
|
+
if (await app.needsProductionConfirmation(a2.name, cliArgs)) risky.push(a2.name);
|
|
12319
|
+
const tty = interactive();
|
|
12320
|
+
if (!flags.json)
|
|
12321
|
+
process.stderr.write(
|
|
12322
|
+
`${bold(`${selected.length} alias(es)`)} will run ${paint("sky", command)}
|
|
12323
|
+
${listing}
|
|
12324
|
+
`
|
|
12325
|
+
);
|
|
12326
|
+
if (!flags.yes) {
|
|
12327
|
+
if (!tty)
|
|
12328
|
+
throw new CloudhopError(
|
|
12329
|
+
"NOT_INTERACTIVE",
|
|
12330
|
+
"Confirm with --yes to run in several aliases without a terminal."
|
|
12331
|
+
);
|
|
12332
|
+
if (!answer(
|
|
12333
|
+
await dist_exports.confirm({
|
|
12334
|
+
message: `Run it in these ${selected.length} alias(es)?`,
|
|
12335
|
+
initialValue: false
|
|
12336
|
+
})
|
|
12337
|
+
))
|
|
12338
|
+
return 130;
|
|
12339
|
+
}
|
|
12340
|
+
if (risky.length > 0 && process.env.CLOUDHOP_CONFIRM_PROD !== "1") {
|
|
12341
|
+
if (!tty) {
|
|
12342
|
+
throw new CloudhopError(
|
|
12343
|
+
"PROD_CONFIRMATION_REQUIRED",
|
|
12344
|
+
`This may change resources in production: ${risky.join(", ")}.`,
|
|
12345
|
+
"Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
|
|
12346
|
+
);
|
|
12347
|
+
}
|
|
12348
|
+
dist_exports.log.warn(
|
|
12349
|
+
`${paint("red", bold("PRODUCTION"))} ${risky.join(", ")}: this may change resources.`
|
|
12350
|
+
);
|
|
12351
|
+
if (!answer(await dist_exports.confirm({ message: "Also run it in production?", initialValue: false })))
|
|
12352
|
+
return 130;
|
|
12353
|
+
}
|
|
12354
|
+
for (const source of new Set(selected.map((a2) => a2.source))) {
|
|
12355
|
+
await app.ensureSession(source, { interactive: tty });
|
|
12356
|
+
}
|
|
12357
|
+
const parallel = Math.max(1, Math.min(16, Number(flags.parallel) || 4));
|
|
12358
|
+
const outcomes = await mapLimit(selected, parallel, async (a2) => {
|
|
12359
|
+
try {
|
|
12360
|
+
const r2 = await app.captureAsAlias(a2.name, cliArgs);
|
|
12361
|
+
const outcome = { alias: a2.name, exitCode: r2.exitCode, stdout: r2.stdout, stderr: r2.stderr };
|
|
12362
|
+
if (!flags.json) printOutcome(outcome, width);
|
|
12363
|
+
return outcome;
|
|
12364
|
+
} catch (error) {
|
|
12365
|
+
const outcome = { alias: a2.name, exitCode: 1, stdout: "", stderr: error.message };
|
|
12366
|
+
if (!flags.json) printOutcome(outcome, width);
|
|
12367
|
+
return outcome;
|
|
12368
|
+
}
|
|
12369
|
+
});
|
|
12370
|
+
const failed = outcomes.filter((o2) => o2.exitCode !== 0);
|
|
12371
|
+
if (flags.json) {
|
|
12372
|
+
console.log(JSON.stringify({ version: 1, command: cliArgs, results: outcomes }, null, 2));
|
|
12373
|
+
} else {
|
|
12374
|
+
const summary = `${outcomes.length - failed.length} ok${failed.length ? `, ${failed.length} failed: ${failed.map((f2) => f2.alias).join(", ")}` : ""}`;
|
|
12375
|
+
process.stderr.write(`${failed.length ? paint("red", summary) : paint("green", summary)}
|
|
12376
|
+
`);
|
|
12377
|
+
}
|
|
12378
|
+
return failed.length ? 1 : 0;
|
|
12379
|
+
}
|
|
12380
|
+
function printOutcome({ alias, exitCode, stdout: stdout2, stderr }, width) {
|
|
12381
|
+
const tag = paint(exitCode === 0 ? "sky" : "red", `[${alias}]`.padEnd(width + 2));
|
|
12382
|
+
for (const line of stdout2.replace(/\n$/, "").split("\n"))
|
|
12383
|
+
if (line) process.stdout.write(`${tag} ${line}
|
|
12384
|
+
`);
|
|
12385
|
+
for (const line of stderr.replace(/\n$/, "").split("\n"))
|
|
12386
|
+
if (line) process.stderr.write(`${tag} ${paint("dim", line)}
|
|
12387
|
+
`);
|
|
12388
|
+
if (exitCode !== 0) process.stderr.write(`${tag} ${paint("red", `exit ${exitCode}`)}
|
|
12389
|
+
`);
|
|
12390
|
+
}
|
|
12391
|
+
|
|
12392
|
+
// src/commands/manage.ts
|
|
12393
|
+
function printReport(report) {
|
|
12394
|
+
const changes = [
|
|
12395
|
+
`${report.targets.length} available`,
|
|
12396
|
+
report.added.length ? `${report.added.length} new` : "",
|
|
12397
|
+
report.removed.length ? `${report.removed.length} removed` : ""
|
|
12398
|
+
].filter(Boolean);
|
|
12399
|
+
dist_exports.log.success(`${report.sourceId}: ${changes.join(", ")}.`);
|
|
12400
|
+
for (const t2 of report.added) dist_exports.log.message(` + ${t2.label}`);
|
|
12401
|
+
for (const t2 of report.removed) dist_exports.log.message(` - ${t2.label}`);
|
|
12402
|
+
if (report.orphanedAliases.length) {
|
|
12403
|
+
dist_exports.log.warn(
|
|
12404
|
+
`No longer available: ${report.orphanedAliases.join(", ")}. Remove them with \`cloudhop rm <alias>\`.`
|
|
12405
|
+
);
|
|
12406
|
+
}
|
|
12407
|
+
}
|
|
12408
|
+
async function syncCommand(app, sourceId) {
|
|
12409
|
+
const ids = sourceId ? [sourceId] : (await app.config()).sources.map((s) => s.id);
|
|
12410
|
+
if (ids.length === 0) {
|
|
12411
|
+
dist_exports.log.info("No sources yet. Run `cloudhop add`.");
|
|
12412
|
+
return;
|
|
12413
|
+
}
|
|
12414
|
+
for (const id of ids) {
|
|
12415
|
+
await app.ensureSession(id);
|
|
12416
|
+
printReport(await app.syncSource(id));
|
|
12417
|
+
}
|
|
12418
|
+
}
|
|
12419
|
+
async function loginCommand(app, sourceId) {
|
|
12420
|
+
const { sources } = await app.config();
|
|
12421
|
+
const id = sourceId ?? answer(
|
|
12422
|
+
await dist_exports.select({
|
|
12423
|
+
message: "Log in to which source?",
|
|
12424
|
+
options: sources.map((s) => ({ value: s.id, label: s.id }))
|
|
12425
|
+
})
|
|
12426
|
+
);
|
|
12427
|
+
printReport(await app.login(id));
|
|
12428
|
+
}
|
|
12429
|
+
var SESSION_LABEL = {
|
|
12430
|
+
valid: paint("green", "\u25CF session valid"),
|
|
12431
|
+
refreshable: paint("yellow", "\u25D0 renews automatically on next use"),
|
|
12432
|
+
expired: paint("red", "\u25CB expired, opens the browser on next use"),
|
|
12433
|
+
unknown: paint("dim", "\xB7 session managed by the cloud CLI")
|
|
12434
|
+
};
|
|
12435
|
+
var JSON_VERSION = 1;
|
|
12436
|
+
var printJson = (value) => console.log(JSON.stringify({ version: JSON_VERSION, ...value }, null, 2));
|
|
12437
|
+
async function listCommand(app, args = []) {
|
|
12438
|
+
const { positional, flags } = parseArgs(args);
|
|
12439
|
+
const sourceId = positional[0];
|
|
12440
|
+
if (flags.json) {
|
|
12441
|
+
const overview = await app.overview();
|
|
12442
|
+
if (sourceId && !overview.sources.some((s) => s.id === sourceId)) {
|
|
12443
|
+
throw new CloudhopError("SOURCE_NOT_FOUND", `Source "${sourceId}" not found.`);
|
|
12444
|
+
}
|
|
12445
|
+
printJson(
|
|
12446
|
+
sourceId ? {
|
|
12447
|
+
sources: overview.sources.filter((s) => s.id === sourceId),
|
|
12448
|
+
aliases: overview.aliases.filter((a2) => a2.source === sourceId)
|
|
12449
|
+
} : overview
|
|
12450
|
+
);
|
|
12451
|
+
return;
|
|
12452
|
+
}
|
|
12453
|
+
const config = await app.config();
|
|
12454
|
+
if (config.sources.length === 0) {
|
|
12455
|
+
console.log("Nothing here yet. Run `cloudhop setup` to get started.");
|
|
12456
|
+
return;
|
|
12457
|
+
}
|
|
12458
|
+
const sources = sourceId ? config.sources.filter((s) => s.id === sourceId) : config.sources;
|
|
12459
|
+
if (sources.length === 0) {
|
|
12460
|
+
throw new CloudhopError(
|
|
12461
|
+
"SOURCE_NOT_FOUND",
|
|
12462
|
+
`Source "${sourceId}" not found.`,
|
|
12463
|
+
`Sources: ${config.sources.map((s) => s.id).join(", ")}.`
|
|
12464
|
+
);
|
|
12465
|
+
}
|
|
12466
|
+
const aliases = await app.listAliases();
|
|
12467
|
+
const nameWidth = Math.max(5, ...aliases.map((a2) => a2.alias.name.length)) + 2;
|
|
10984
12468
|
const regionWidth = Math.max(6, ...aliases.map((a2) => (a2.region ?? "-").length)) + 2;
|
|
10985
12469
|
for (const [index, source] of sources.entries()) {
|
|
10986
12470
|
const provider = app.provider(source.provider);
|
|
@@ -11001,8 +12485,9 @@ async function listCommand(app, sourceId) {
|
|
|
11001
12485
|
);
|
|
11002
12486
|
for (const { alias, target, region } of own) {
|
|
11003
12487
|
const label = target ? target.label : paint("red", `${alias.target} (no longer available)`);
|
|
12488
|
+
const tag = alias.prod ? ` ${paint("red", "PROD")}` : "";
|
|
11004
12489
|
console.log(
|
|
11005
|
-
` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}`
|
|
12490
|
+
` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}${tag}`
|
|
11006
12491
|
);
|
|
11007
12492
|
}
|
|
11008
12493
|
}
|
|
@@ -11088,26 +12573,116 @@ async function regionCommand(app, args) {
|
|
|
11088
12573
|
})
|
|
11089
12574
|
);
|
|
11090
12575
|
const current = await app.resolveAlias(name);
|
|
11091
|
-
const
|
|
12576
|
+
const validate2 = regionValidator(app, current.source);
|
|
11092
12577
|
const region = regionArg?.trim() ?? (answer(
|
|
11093
12578
|
await dist_exports.text({
|
|
11094
12579
|
message: `Region for ${name}`,
|
|
11095
12580
|
placeholder: current.region,
|
|
11096
12581
|
defaultValue: current.region,
|
|
11097
|
-
validate: (v) => v?.trim() ?
|
|
12582
|
+
validate: (v) => v?.trim() ? validate2?.(v.trim()) : void 0
|
|
11098
12583
|
})
|
|
11099
12584
|
).trim() || current.region);
|
|
11100
|
-
const problem = region ?
|
|
12585
|
+
const problem = region ? validate2?.(region) : "Say which region.";
|
|
11101
12586
|
if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
|
|
11102
12587
|
await app.setAliasRegion(name, region);
|
|
11103
12588
|
console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
|
|
11104
12589
|
}
|
|
12590
|
+
var STATE_SHORT = {
|
|
12591
|
+
valid: paint("green", "\u25CF session valid"),
|
|
12592
|
+
refreshable: paint("yellow", "\u25D0 session expired, renewable"),
|
|
12593
|
+
expired: paint("red", "\u25CB session expired"),
|
|
12594
|
+
unknown: paint("dim", "\xB7 session")
|
|
12595
|
+
};
|
|
12596
|
+
var NEXT_TEXT = {
|
|
12597
|
+
ready: "ready to run",
|
|
12598
|
+
"silent-renewal": "the next command renews the session silently",
|
|
12599
|
+
"browser-sign-in": "the next command opens the browser to sign in",
|
|
12600
|
+
unknown: "the cloud CLI manages this session"
|
|
12601
|
+
};
|
|
12602
|
+
async function statusCommand(app, args) {
|
|
12603
|
+
const { positional, flags } = parseArgs(args);
|
|
12604
|
+
const name = positional[0];
|
|
12605
|
+
if (name) {
|
|
12606
|
+
const status = await app.aliasStatus(name);
|
|
12607
|
+
if (flags.json) return printJson({ alias: status });
|
|
12608
|
+
const account = status.attributes.accountId ? ` (${status.attributes.accountId})` : "";
|
|
12609
|
+
console.log(
|
|
12610
|
+
`${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}${status.prod ? ` ${paint("red", "PROD")}` : ""}`
|
|
12611
|
+
);
|
|
12612
|
+
console.log(` ${STATE_SHORT[status.session]}: ${NEXT_TEXT[status.next]}`);
|
|
12613
|
+
if (status.next === "browser-sign-in") {
|
|
12614
|
+
console.log(paint("dim", ` Sign in now with \`cloudhop login ${status.source}\`.`));
|
|
12615
|
+
}
|
|
12616
|
+
if (!status.available) console.log(paint("red", " This account/role is no longer available."));
|
|
12617
|
+
return;
|
|
12618
|
+
}
|
|
12619
|
+
const overview = await app.overview();
|
|
12620
|
+
if (flags.json) return printJson({ sources: overview.sources });
|
|
12621
|
+
if (overview.sources.length === 0) {
|
|
12622
|
+
console.log("Nothing here yet. Run `cloudhop setup` to get started.");
|
|
12623
|
+
return;
|
|
12624
|
+
}
|
|
12625
|
+
for (const source of overview.sources) {
|
|
12626
|
+
console.log(
|
|
12627
|
+
`${bold(paint("sky", source.id))} ${STATE_SHORT[source.session]}: ${NEXT_TEXT[source.next]}`
|
|
12628
|
+
);
|
|
12629
|
+
}
|
|
12630
|
+
}
|
|
12631
|
+
async function prodCommand(app, args) {
|
|
12632
|
+
const { positional, flags } = parseArgs(args);
|
|
12633
|
+
if (positional.length === 0) {
|
|
12634
|
+
const prod = (await app.overview()).aliases.filter((a2) => a2.prod).map((a2) => a2.name);
|
|
12635
|
+
console.log(
|
|
12636
|
+
prod.length ? `Production aliases: ${prod.join(", ")}` : "No alias is marked as production."
|
|
12637
|
+
);
|
|
12638
|
+
console.log(paint("dim", "Mark one with `cloudhop prod <alias>`, unmark with `--off`."));
|
|
12639
|
+
return;
|
|
12640
|
+
}
|
|
12641
|
+
const on = !flags.off;
|
|
12642
|
+
await app.setProduction(positional, on);
|
|
12643
|
+
for (const name of positional) {
|
|
12644
|
+
console.log(
|
|
12645
|
+
on ? `${paint("red", "PROD")} ${name}: cloudhop asks before any change.` : `${name} is no longer marked as production.`
|
|
12646
|
+
);
|
|
12647
|
+
}
|
|
12648
|
+
}
|
|
12649
|
+
async function renameCommand(app, args) {
|
|
12650
|
+
const [from, to] = args;
|
|
12651
|
+
if (!from || !to)
|
|
12652
|
+
throw new CloudhopError("INVALID_NAME", "Usage: cloudhop mv <alias> <new-name>");
|
|
12653
|
+
await app.renameAlias(from, to);
|
|
12654
|
+
console.log(`${from} \u2192 ${to}`);
|
|
12655
|
+
}
|
|
11105
12656
|
|
|
11106
12657
|
// src/commands/run.ts
|
|
12658
|
+
async function guardProduction(app, alias, args) {
|
|
12659
|
+
const status = await app.aliasStatus(alias).catch(() => void 0);
|
|
12660
|
+
if (!status?.prod) return;
|
|
12661
|
+
const where = `${status.label ?? status.target}${status.region ? `, ${status.region}` : ""}`;
|
|
12662
|
+
if (!await app.needsProductionConfirmation(alias, args)) {
|
|
12663
|
+
if (process.stderr.isTTY) notify(paint("red", `${alias} is production (${where})`));
|
|
12664
|
+
return;
|
|
12665
|
+
}
|
|
12666
|
+
if (process.env.CLOUDHOP_CONFIRM_PROD === "1") return;
|
|
12667
|
+
const what = args ? `aws ${args.join(" ")}` : "this command";
|
|
12668
|
+
if (!interactive()) {
|
|
12669
|
+
throw new CloudhopError(
|
|
12670
|
+
"PROD_CONFIRMATION_REQUIRED",
|
|
12671
|
+
`${alias} is production and ${what} may change resources.`,
|
|
12672
|
+
"Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
|
|
12673
|
+
);
|
|
12674
|
+
}
|
|
12675
|
+
dist_exports.log.warn(
|
|
12676
|
+
`${paint("red", bold("PRODUCTION"))} ${bold(alias)} \u2192 ${where}
|
|
12677
|
+
${paint("dim", "This may change resources:")} ${what}`
|
|
12678
|
+
);
|
|
12679
|
+
const ok = answer(await dist_exports.confirm({ message: `Run it in ${alias}?`, initialValue: false }));
|
|
12680
|
+
if (!ok) throw new CloudhopError("PROD_CONFIRMATION_REQUIRED", "Cancelled: nothing ran.");
|
|
12681
|
+
}
|
|
11107
12682
|
async function proxyCommand(app, alias, args) {
|
|
11108
|
-
const
|
|
11109
|
-
|
|
11110
|
-
return app.runAsAlias(alias,
|
|
12683
|
+
const cliArgs = args.length > 0 ? args : await app.identityArgs(alias);
|
|
12684
|
+
await guardProduction(app, alias, cliArgs);
|
|
12685
|
+
return app.runAsAlias(alias, cliArgs);
|
|
11111
12686
|
}
|
|
11112
12687
|
async function execCommand(app, args) {
|
|
11113
12688
|
const separator = args.indexOf("--");
|
|
@@ -11121,6 +12696,7 @@ async function execCommand(app, args) {
|
|
|
11121
12696
|
const regionIndex = flags.indexOf("--region");
|
|
11122
12697
|
if (regionIndex !== -1 && flags[regionIndex + 1])
|
|
11123
12698
|
overrides.region = flags[regionIndex + 1];
|
|
12699
|
+
await guardProduction(app, alias);
|
|
11124
12700
|
const [bin, ...rest] = command;
|
|
11125
12701
|
return app.execAsAlias(alias, bin, rest, overrides);
|
|
11126
12702
|
}
|
|
@@ -11154,7 +12730,7 @@ async function offerInstall(app, entry) {
|
|
|
11154
12730
|
else dist_exports.log.error(`Could not install automatically. See ${recipe.manualUrl}`);
|
|
11155
12731
|
return ok;
|
|
11156
12732
|
}
|
|
11157
|
-
async function setupCommand(app) {
|
|
12733
|
+
async function setupCommand(app, assistants) {
|
|
11158
12734
|
requireInteractive(
|
|
11159
12735
|
"setup",
|
|
11160
12736
|
"Run it in a regular terminal, or script it: `cloudhop provider enable aws`, then `cloudhop add aws sso --start-url <url> --region <r>`."
|
|
@@ -11181,11 +12757,24 @@ async function setupCommand(app) {
|
|
|
11181
12757
|
}
|
|
11182
12758
|
}
|
|
11183
12759
|
const addNow = answer(await dist_exports.confirm({ message: "Add an account or SSO portal now?" }));
|
|
11184
|
-
if (
|
|
11185
|
-
|
|
11186
|
-
|
|
12760
|
+
if (addNow) await addCommand(app, { positional: [], flags: {} });
|
|
12761
|
+
else dist_exports.log.info("Run `cloudhop add` whenever you are ready.");
|
|
12762
|
+
const detected = assistants.detect();
|
|
12763
|
+
if (detected.length > 0) {
|
|
12764
|
+
const names = detected.map((id) => ASSISTANTS[id].name).join(", ");
|
|
12765
|
+
const teach = answer(
|
|
12766
|
+
await dist_exports.confirm({
|
|
12767
|
+
message: `Teach your AI assistant (${names}) to use cloudhop?`,
|
|
12768
|
+
initialValue: true
|
|
12769
|
+
})
|
|
12770
|
+
);
|
|
12771
|
+
if (teach) {
|
|
12772
|
+
await agentCommand(assistants, ["install"]);
|
|
12773
|
+
return;
|
|
12774
|
+
}
|
|
12775
|
+
dist_exports.log.info("You can do it later with `cloudhop agent install`.");
|
|
11187
12776
|
}
|
|
11188
|
-
|
|
12777
|
+
if (!addNow) dist_exports.outro("All set.");
|
|
11189
12778
|
}
|
|
11190
12779
|
async function doctorCommand(app) {
|
|
11191
12780
|
dist_exports.intro("cloudhop doctor");
|
|
@@ -11195,253 +12784,422 @@ async function doctorCommand(app) {
|
|
|
11195
12784
|
return;
|
|
11196
12785
|
}
|
|
11197
12786
|
let healthy = true;
|
|
11198
|
-
for (const entry of entries) {
|
|
11199
|
-
if (entry.tool.installed && !entry.outdated) {
|
|
11200
|
-
dist_exports.log.success(`${entry.displayName}: ${entry.cli.displayName} ${entry.tool.version ?? ""}`);
|
|
11201
|
-
} else {
|
|
11202
|
-
healthy = await offerInstall(app, entry) && healthy;
|
|
11203
|
-
}
|
|
11204
|
-
}
|
|
11205
|
-
dist_exports.outro(
|
|
11206
|
-
healthy ? "Everything looks good." : "Some tools are missing; login and `cloudhop exec` still work."
|
|
11207
|
-
);
|
|
11208
|
-
}
|
|
11209
|
-
|
|
11210
|
-
// ../adapters-node/src/node-browser-opener.ts
|
|
11211
|
-
import { spawn } from "child_process";
|
|
11212
|
-
var NodeBrowserOpener = class {
|
|
11213
|
-
async open(url) {
|
|
11214
|
-
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
11215
|
-
await new Promise((resolve) => {
|
|
11216
|
-
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
11217
|
-
child.on("error", () => resolve());
|
|
11218
|
-
child.on("spawn", () => {
|
|
11219
|
-
child.unref();
|
|
11220
|
-
resolve();
|
|
11221
|
-
});
|
|
11222
|
-
});
|
|
11223
|
-
}
|
|
11224
|
-
};
|
|
11225
|
-
|
|
11226
|
-
// ../adapters-node/src/node-process-runner.ts
|
|
11227
|
-
import { spawn as spawn2 } from "child_process";
|
|
11228
|
-
var STDERR_TAIL_BYTES = 8 * 1024;
|
|
11229
|
-
function buildEnv(overrides) {
|
|
11230
|
-
const env = { ...process.env };
|
|
11231
|
-
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
11232
|
-
if (value === void 0) delete env[key];
|
|
11233
|
-
else env[key] = value;
|
|
11234
|
-
}
|
|
11235
|
-
return env;
|
|
11236
|
-
}
|
|
11237
|
-
var useShell = process.platform === "win32";
|
|
11238
|
-
var NodeProcessRunner = class {
|
|
11239
|
-
run(command, args, options = {}) {
|
|
11240
|
-
return new Promise((resolve, reject) => {
|
|
11241
|
-
const child = spawn2(command, args, {
|
|
11242
|
-
env: buildEnv(options.env),
|
|
11243
|
-
stdio: ["inherit", "inherit", options.captureStderr ? "pipe" : "inherit"],
|
|
11244
|
-
shell: useShell
|
|
11245
|
-
});
|
|
11246
|
-
let tail = "";
|
|
11247
|
-
child.stderr?.on("data", (chunk) => {
|
|
11248
|
-
process.stderr.write(chunk);
|
|
11249
|
-
tail = (tail + chunk.toString()).slice(-STDERR_TAIL_BYTES);
|
|
11250
|
-
});
|
|
11251
|
-
const ignore = () => {
|
|
11252
|
-
};
|
|
11253
|
-
process.on("SIGINT", ignore);
|
|
11254
|
-
process.on("SIGTERM", ignore);
|
|
11255
|
-
const cleanup = () => {
|
|
11256
|
-
process.off("SIGINT", ignore);
|
|
11257
|
-
process.off("SIGTERM", ignore);
|
|
11258
|
-
};
|
|
11259
|
-
child.on("error", (error) => {
|
|
11260
|
-
cleanup();
|
|
11261
|
-
reject(error);
|
|
11262
|
-
});
|
|
11263
|
-
child.on("close", (code, signal) => {
|
|
11264
|
-
cleanup();
|
|
11265
|
-
resolve({ exitCode: code ?? (signal ? 128 + 2 : 1), stderrTail: tail });
|
|
11266
|
-
});
|
|
11267
|
-
});
|
|
11268
|
-
}
|
|
11269
|
-
capture(command, args, options = {}) {
|
|
11270
|
-
return new Promise((resolve) => {
|
|
11271
|
-
const child = spawn2(command, args, { env: buildEnv(options.env), shell: useShell });
|
|
11272
|
-
let stdout2 = "";
|
|
11273
|
-
let stderr = "";
|
|
11274
|
-
child.stdout.on("data", (c3) => {
|
|
11275
|
-
stdout2 += c3.toString();
|
|
11276
|
-
});
|
|
11277
|
-
child.stderr.on("data", (c3) => {
|
|
11278
|
-
stderr += c3.toString();
|
|
11279
|
-
});
|
|
11280
|
-
child.on("error", (error) => resolve({ exitCode: 127, stdout: stdout2, stderr: error.message }));
|
|
11281
|
-
child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout: stdout2, stderr }));
|
|
11282
|
-
});
|
|
11283
|
-
}
|
|
11284
|
-
async probe(command) {
|
|
11285
|
-
const result = await this.capture(command, ["--version"]);
|
|
11286
|
-
if (result.exitCode !== 0) return { installed: false };
|
|
11287
|
-
const output = `${result.stdout} ${result.stderr}`;
|
|
11288
|
-
const version = output.match(/(\d+\.\d+\.\d+)/)?.[1];
|
|
11289
|
-
return { installed: true, ...version ? { version } : {} };
|
|
11290
|
-
}
|
|
11291
|
-
};
|
|
11292
|
-
|
|
11293
|
-
// ../adapters-node/src/system-tool-installer.ts
|
|
11294
|
-
var TOOLS = {
|
|
11295
|
-
aws: {
|
|
11296
|
-
manualUrl: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
|
|
11297
|
-
recipes: {
|
|
11298
|
-
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "awscli"] }],
|
|
11299
|
-
win32: [
|
|
11300
|
-
{ via: "winget", command: "winget", args: ["install", "--exact", "--id", "Amazon.AWSCLI"] }
|
|
11301
|
-
]
|
|
11302
|
-
}
|
|
11303
|
-
},
|
|
11304
|
-
az: {
|
|
11305
|
-
manualUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli",
|
|
11306
|
-
recipes: {
|
|
11307
|
-
darwin: [{ via: "Homebrew", command: "brew", args: ["install", "azure-cli"] }],
|
|
11308
|
-
win32: [
|
|
11309
|
-
{
|
|
11310
|
-
via: "winget",
|
|
11311
|
-
command: "winget",
|
|
11312
|
-
args: ["install", "--exact", "--id", "Microsoft.AzureCLI"]
|
|
11313
|
-
}
|
|
11314
|
-
]
|
|
11315
|
-
}
|
|
11316
|
-
},
|
|
11317
|
-
gcloud: {
|
|
11318
|
-
manualUrl: "https://cloud.google.com/sdk/docs/install",
|
|
11319
|
-
recipes: {
|
|
11320
|
-
darwin: [
|
|
11321
|
-
{ via: "Homebrew", command: "brew", args: ["install", "--cask", "google-cloud-sdk"] }
|
|
11322
|
-
],
|
|
11323
|
-
win32: [
|
|
11324
|
-
{
|
|
11325
|
-
via: "winget",
|
|
11326
|
-
command: "winget",
|
|
11327
|
-
args: ["install", "--exact", "--id", "Google.CloudSDK"]
|
|
11328
|
-
}
|
|
11329
|
-
]
|
|
12787
|
+
for (const entry of entries) {
|
|
12788
|
+
if (entry.tool.installed && !entry.outdated) {
|
|
12789
|
+
dist_exports.log.success(`${entry.displayName}: ${entry.cli.displayName} ${entry.tool.version ?? ""}`);
|
|
12790
|
+
} else {
|
|
12791
|
+
healthy = await offerInstall(app, entry) && healthy;
|
|
11330
12792
|
}
|
|
11331
12793
|
}
|
|
12794
|
+
dist_exports.outro(
|
|
12795
|
+
healthy ? "Everything looks good." : "Some tools are missing; login and `cloudhop exec` still work."
|
|
12796
|
+
);
|
|
12797
|
+
}
|
|
12798
|
+
|
|
12799
|
+
// src/commands/shell.ts
|
|
12800
|
+
import { existsSync as existsSync3 } from "fs";
|
|
12801
|
+
import { appendFile, mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
|
|
12802
|
+
import { homedir as homedir3 } from "os";
|
|
12803
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
12804
|
+
var SHELLS = ["zsh", "bash", "fish", "pwsh"];
|
|
12805
|
+
var ALIAS_COMMANDS = "use status console region mv prod rm exec";
|
|
12806
|
+
var SOURCE_COMMANDS = "alias login sync ls";
|
|
12807
|
+
var SCRIPTS = {
|
|
12808
|
+
zsh: `# cloudhop shell integration (zsh). Load it after compinit: eval "$(cloudhop shell zsh)"
|
|
12809
|
+
cloudhop() {
|
|
12810
|
+
if [ "$1" = "use" ]; then
|
|
12811
|
+
local out; out="$(command cloudhop use --emit zsh "\${@:2}")" || return $?
|
|
12812
|
+
eval "$out"
|
|
12813
|
+
else
|
|
12814
|
+
command cloudhop "$@"
|
|
12815
|
+
fi
|
|
12816
|
+
}
|
|
12817
|
+
_cloudhop_words() { command cloudhop __complete "$1" 2>/dev/null }
|
|
12818
|
+
_hop() { (( CURRENT == 2 )) && { local -a a; a=(\${(f)"$(_cloudhop_words aliases)"}); compadd -a a; } }
|
|
12819
|
+
_cloudhop() {
|
|
12820
|
+
local -a w
|
|
12821
|
+
if (( CURRENT == 2 )); then w=(\${(f)"$(_cloudhop_words commands)"}); compadd -a w; return; fi
|
|
12822
|
+
(( CURRENT == 3 )) || return
|
|
12823
|
+
case " ${ALIAS_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words aliases)"});; esac
|
|
12824
|
+
case " ${SOURCE_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words sources)"});; esac
|
|
12825
|
+
compadd -a w
|
|
12826
|
+
}
|
|
12827
|
+
(( $+functions[compdef] )) && { compdef _hop hop; compdef _cloudhop cloudhop; }
|
|
12828
|
+
# Add $(cloudhop_prompt) to your prompt to see the alias set with \`cloudhop use\`.
|
|
12829
|
+
cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
|
|
12830
|
+
`,
|
|
12831
|
+
bash: `# cloudhop shell integration (bash): eval "$(cloudhop shell bash)"
|
|
12832
|
+
cloudhop() {
|
|
12833
|
+
if [ "$1" = "use" ]; then
|
|
12834
|
+
local out; out="$(command cloudhop use --emit bash "\${@:2}")" || return $?
|
|
12835
|
+
eval "$out"
|
|
12836
|
+
else
|
|
12837
|
+
command cloudhop "$@"
|
|
12838
|
+
fi
|
|
12839
|
+
}
|
|
12840
|
+
_cloudhop_complete() {
|
|
12841
|
+
local cur=\${COMP_WORDS[COMP_CWORD]} cmd=\${COMP_WORDS[0]##*/} kind=""
|
|
12842
|
+
if [ "$cmd" = hop ]; then [ "$COMP_CWORD" -eq 1 ] && kind=aliases
|
|
12843
|
+
elif [ "$COMP_CWORD" -eq 1 ]; then kind=commands
|
|
12844
|
+
elif [ "$COMP_CWORD" -eq 2 ]; then
|
|
12845
|
+
case " ${ALIAS_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=aliases;; esac
|
|
12846
|
+
case " ${SOURCE_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=sources;; esac
|
|
12847
|
+
fi
|
|
12848
|
+
[ -n "$kind" ] && COMPREPLY=($(compgen -W "$(command cloudhop __complete $kind 2>/dev/null)" -- "$cur"))
|
|
12849
|
+
}
|
|
12850
|
+
complete -o default -F _cloudhop_complete hop cloudhop
|
|
12851
|
+
# Add $(cloudhop_prompt) to PS1 to see the alias set with \`cloudhop use\`.
|
|
12852
|
+
cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
|
|
12853
|
+
`,
|
|
12854
|
+
fish: `# cloudhop shell integration (fish): cloudhop shell fish | source
|
|
12855
|
+
function cloudhop
|
|
12856
|
+
if test "$argv[1]" = use
|
|
12857
|
+
set -l out (command cloudhop use --emit fish $argv[2..-1]); or return $status
|
|
12858
|
+
printf '%s\\n' $out | source
|
|
12859
|
+
else
|
|
12860
|
+
command cloudhop $argv
|
|
12861
|
+
end
|
|
12862
|
+
end
|
|
12863
|
+
complete -c hop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete aliases 2>/dev/null)'
|
|
12864
|
+
complete -c cloudhop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete commands 2>/dev/null)'
|
|
12865
|
+
complete -c cloudhop -f -n '__fish_seen_subcommand_from ${ALIAS_COMMANDS}; and test (count (commandline -opc)) -eq 2' -a '(command cloudhop __complete aliases 2>/dev/null)'
|
|
12866
|
+
complete -c cloudhop -f -n '__fish_seen_subcommand_from ${SOURCE_COMMANDS}; and test (count (commandline -opc)) -eq 2' -a '(command cloudhop __complete sources 2>/dev/null)'
|
|
12867
|
+
# Call cloudhop_prompt from fish_prompt to see the alias set with \`cloudhop use\`.
|
|
12868
|
+
function cloudhop_prompt
|
|
12869
|
+
test -n "$CLOUDHOP_ALIAS"; and printf '\u2601 %s%s' $CLOUDHOP_ALIAS (test -n "$CLOUDHOP_PROD"; and echo ' PROD')
|
|
12870
|
+
end
|
|
12871
|
+
`,
|
|
12872
|
+
pwsh: `# cloudhop shell integration (PowerShell): cloudhop shell pwsh | Out-String | Invoke-Expression
|
|
12873
|
+
$script:CloudhopExe = (Get-Command cloudhop -CommandType Application, ExternalScript | Select-Object -First 1).Source
|
|
12874
|
+
function cloudhop {
|
|
12875
|
+
if ($args.Count -gt 0 -and $args[0] -eq 'use') {
|
|
12876
|
+
$out = & $script:CloudhopExe use --emit pwsh @($args | Select-Object -Skip 1)
|
|
12877
|
+
if ($LASTEXITCODE -eq 0) { $out | Out-String | Invoke-Expression }
|
|
12878
|
+
} else { & $script:CloudhopExe @args }
|
|
12879
|
+
}
|
|
12880
|
+
Register-ArgumentCompleter -Native -CommandName hop, cloudhop -ScriptBlock {
|
|
12881
|
+
param($word, $ast)
|
|
12882
|
+
$n = $ast.CommandElements.Count - [int]($word -eq '')
|
|
12883
|
+
$kind = if ($ast.CommandElements[0].Value -match 'hop$' -and $ast.CommandElements[0].Value -notmatch 'cloudhop') { if ($n -le 1) { 'aliases' } }
|
|
12884
|
+
elseif ($n -le 1) { 'commands' }
|
|
12885
|
+
elseif ($n -eq 2 -and ' ${ALIAS_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'aliases' }
|
|
12886
|
+
elseif ($n -eq 2 -and ' ${SOURCE_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'sources' }
|
|
12887
|
+
if ($kind) { & $script:CloudhopExe __complete $kind | Where-Object { $_ -like "$word*" } }
|
|
12888
|
+
}
|
|
12889
|
+
function cloudhop_prompt { if ($env:CLOUDHOP_ALIAS) { "\u2601 $env:CLOUDHOP_ALIAS$(if ($env:CLOUDHOP_PROD) { ' PROD' })" } }
|
|
12890
|
+
`
|
|
11332
12891
|
};
|
|
11333
|
-
var
|
|
11334
|
-
|
|
11335
|
-
|
|
11336
|
-
|
|
11337
|
-
|
|
11338
|
-
|
|
11339
|
-
|
|
11340
|
-
|
|
11341
|
-
|
|
11342
|
-
|
|
11343
|
-
|
|
11344
|
-
|
|
11345
|
-
|
|
12892
|
+
var quote = (shell, value) => shell === "pwsh" ? `'${value.replace(/'/g, "''")}'` : `'${value.replace(/'/g, "'\\''")}'`;
|
|
12893
|
+
function shellExports(shell, env) {
|
|
12894
|
+
return Object.entries(env).map(([key, value]) => {
|
|
12895
|
+
if (value === void 0) {
|
|
12896
|
+
if (shell === "fish") return `set -e ${key}`;
|
|
12897
|
+
if (shell === "pwsh") return `Remove-Item Env:${key} -ErrorAction SilentlyContinue`;
|
|
12898
|
+
return `unset ${key}`;
|
|
12899
|
+
}
|
|
12900
|
+
if (shell === "fish") return `set -gx ${key} ${quote(shell, value)}`;
|
|
12901
|
+
if (shell === "pwsh") return `$env:${key} = ${quote(shell, value)}`;
|
|
12902
|
+
return `export ${key}=${quote(shell, value)}`;
|
|
12903
|
+
}).join("\n");
|
|
12904
|
+
}
|
|
12905
|
+
function detectShell() {
|
|
12906
|
+
if (process.platform === "win32") return "pwsh";
|
|
12907
|
+
const name = (process.env.SHELL ?? "").split("/").pop();
|
|
12908
|
+
return SHELLS.includes(name) ? name : "bash";
|
|
12909
|
+
}
|
|
12910
|
+
function parseShell(value) {
|
|
12911
|
+
if (!value) return detectShell();
|
|
12912
|
+
const shell = value === "powershell" ? "pwsh" : value;
|
|
12913
|
+
if (!SHELLS.includes(shell)) {
|
|
12914
|
+
throw new CloudhopError(
|
|
12915
|
+
"INVALID_NAME",
|
|
12916
|
+
`Unknown shell "${value}".`,
|
|
12917
|
+
`Use one of: ${SHELLS.join(", ")}.`
|
|
12918
|
+
);
|
|
11346
12919
|
}
|
|
11347
|
-
|
|
11348
|
-
|
|
11349
|
-
|
|
11350
|
-
|
|
11351
|
-
|
|
12920
|
+
return shell;
|
|
12921
|
+
}
|
|
12922
|
+
var RC_FILES = {
|
|
12923
|
+
zsh: (home) => join5(home, ".zshrc"),
|
|
12924
|
+
bash: (home) => join5(home, ".bashrc"),
|
|
12925
|
+
fish: (home) => join5(home, ".config", "fish", "config.fish"),
|
|
12926
|
+
pwsh: (home) => process.platform === "win32" ? join5(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") : join5(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")
|
|
12927
|
+
};
|
|
12928
|
+
var LOAD_LINE = {
|
|
12929
|
+
zsh: 'eval "$(cloudhop shell zsh)"',
|
|
12930
|
+
bash: 'eval "$(cloudhop shell bash)"',
|
|
12931
|
+
fish: "cloudhop shell fish | source",
|
|
12932
|
+
pwsh: "cloudhop shell pwsh | Out-String | Invoke-Expression"
|
|
12933
|
+
};
|
|
12934
|
+
async function shellCommand(args) {
|
|
12935
|
+
const { positional, flags } = parseArgs(args);
|
|
12936
|
+
if (positional[0] !== "install") {
|
|
12937
|
+
process.stdout.write(SCRIPTS[parseShell(positional[0])]);
|
|
12938
|
+
return;
|
|
11352
12939
|
}
|
|
11353
|
-
|
|
11354
|
-
|
|
11355
|
-
|
|
11356
|
-
|
|
11357
|
-
|
|
12940
|
+
const shell = parseShell(positional[1]);
|
|
12941
|
+
const file = RC_FILES[shell](homedir3());
|
|
12942
|
+
const line = LOAD_LINE[shell];
|
|
12943
|
+
const current = existsSync3(file) ? await readFile3(file, "utf8") : "";
|
|
12944
|
+
if (current.includes(line)) {
|
|
12945
|
+
console.log(`Already set up in ${file.replace(homedir3(), "~")}.`);
|
|
12946
|
+
return;
|
|
11358
12947
|
}
|
|
11359
|
-
|
|
11360
|
-
|
|
11361
|
-
|
|
11362
|
-
|
|
11363
|
-
|
|
11364
|
-
|
|
11365
|
-
|
|
11366
|
-
|
|
11367
|
-
|
|
11368
|
-
|
|
12948
|
+
if (!flags.yes) {
|
|
12949
|
+
if (!interactive())
|
|
12950
|
+
throw new CloudhopError(
|
|
12951
|
+
"NOT_INTERACTIVE",
|
|
12952
|
+
`Add this line to ${file} yourself, or rerun with --yes:
|
|
12953
|
+
${line}`
|
|
12954
|
+
);
|
|
12955
|
+
const ok = answer(
|
|
12956
|
+
await dist_exports.confirm({
|
|
12957
|
+
message: `Add cloudhop to ${file.replace(homedir3(), "~")}?`,
|
|
12958
|
+
initialValue: true
|
|
12959
|
+
})
|
|
12960
|
+
);
|
|
12961
|
+
if (!ok) return;
|
|
12962
|
+
}
|
|
12963
|
+
await mkdir3(dirname3(file), { recursive: true });
|
|
12964
|
+
const note2 = shell === "zsh" ? " # after compinit" : "";
|
|
12965
|
+
await appendFile(
|
|
12966
|
+
file,
|
|
12967
|
+
`${current && !current.endsWith("\n") ? "\n" : ""}
|
|
12968
|
+
# cloudhop: \`cloudhop use\`, tab completion and cloudhop_prompt${note2}
|
|
12969
|
+
${line}
|
|
12970
|
+
`
|
|
12971
|
+
);
|
|
12972
|
+
console.log(
|
|
12973
|
+
`${paint("green", "\u2714")} Added to ${file.replace(homedir3(), "~")}. Open a new terminal to use it.`
|
|
12974
|
+
);
|
|
11369
12975
|
}
|
|
11370
|
-
|
|
11371
|
-
|
|
11372
|
-
|
|
11373
|
-
|
|
12976
|
+
async function useCommand(app, args) {
|
|
12977
|
+
const { positional, flags } = parseArgs(args);
|
|
12978
|
+
const shell = flags.emit ? parseShell(flags.emit) : void 0;
|
|
12979
|
+
if (!shell) {
|
|
12980
|
+
throw new CloudhopError(
|
|
12981
|
+
"NOT_INTERACTIVE",
|
|
12982
|
+
"`cloudhop use` needs the shell integration to change your current shell.",
|
|
12983
|
+
`Set it up once with \`cloudhop shell install\` (or add ${LOAD_LINE[detectShell()]} to your shell's startup file), then open a new terminal.`
|
|
12984
|
+
);
|
|
11374
12985
|
}
|
|
11375
|
-
|
|
11376
|
-
|
|
11377
|
-
|
|
12986
|
+
const clear = {
|
|
12987
|
+
AWS_ACCESS_KEY_ID: void 0,
|
|
12988
|
+
AWS_SECRET_ACCESS_KEY: void 0,
|
|
12989
|
+
AWS_SESSION_TOKEN: void 0
|
|
12990
|
+
};
|
|
12991
|
+
if (flags.off) {
|
|
12992
|
+
process.stdout.write(
|
|
12993
|
+
`${shellExports(shell, { ...clear, AWS_PROFILE: void 0, AWS_REGION: void 0, AWS_DEFAULT_REGION: void 0, CLOUDHOP_ALIAS: void 0, CLOUDHOP_PROD: void 0 })}
|
|
12994
|
+
`
|
|
12995
|
+
);
|
|
12996
|
+
notify("No alias in this shell anymore.");
|
|
12997
|
+
return;
|
|
11378
12998
|
}
|
|
11379
|
-
|
|
11380
|
-
|
|
12999
|
+
const alias = positional[0] ?? await pickAlias(app, "Use which alias in this shell?");
|
|
13000
|
+
const env = await app.envForAlias(alias, "cli");
|
|
13001
|
+
const status = await app.aliasStatus(alias);
|
|
13002
|
+
process.stdout.write(
|
|
13003
|
+
`${shellExports(shell, { ...clear, ...env, CLOUDHOP_ALIAS: alias, CLOUDHOP_PROD: status.prod ? "1" : void 0 })}
|
|
13004
|
+
`
|
|
13005
|
+
);
|
|
13006
|
+
notify(
|
|
13007
|
+
`This shell now uses ${alias} (${status.label ?? status.target}, ${status.region ?? "no region"}).`
|
|
13008
|
+
);
|
|
13009
|
+
if (status.prod) {
|
|
13010
|
+
notify(
|
|
13011
|
+
paint(
|
|
13012
|
+
"red",
|
|
13013
|
+
`${alias} is production. Plain \`aws\` commands are not protected here: use \`hop ${alias}\` to be asked before changes.`
|
|
13014
|
+
)
|
|
13015
|
+
);
|
|
11381
13016
|
}
|
|
11382
|
-
|
|
11383
|
-
|
|
11384
|
-
|
|
11385
|
-
|
|
11386
|
-
|
|
11387
|
-
|
|
11388
|
-
|
|
11389
|
-
),
|
|
11390
|
-
sources: Object.entries(file.sources ?? {}).map(([id, s]) => ({
|
|
11391
|
-
id,
|
|
11392
|
-
...s,
|
|
11393
|
-
settings: s.settings ?? {}
|
|
11394
|
-
})),
|
|
11395
|
-
aliases: Object.entries(file.aliases ?? {}).map(([name, a2]) => ({ name, ...a2 }))
|
|
11396
|
-
};
|
|
13017
|
+
}
|
|
13018
|
+
async function completeCommand(app, kind) {
|
|
13019
|
+
if (kind === "commands") {
|
|
13020
|
+
console.log(
|
|
13021
|
+
[...RESERVED_NAMES, "each", "shell", "use"].filter((c3, i2, a2) => a2.indexOf(c3) === i2).sort().join("\n")
|
|
13022
|
+
);
|
|
13023
|
+
return;
|
|
11397
13024
|
}
|
|
11398
|
-
|
|
13025
|
+
const config = await app.config();
|
|
13026
|
+
const words = kind === "sources" ? config.sources.map((s) => s.id) : config.aliases.map((a2) => a2.name);
|
|
13027
|
+
if (words.length) console.log(words.join("\n"));
|
|
13028
|
+
}
|
|
13029
|
+
|
|
13030
|
+
// src/commands/team.ts
|
|
13031
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
13032
|
+
var import_yaml2 = __toESM(require_dist(), 1);
|
|
13033
|
+
var MAX_BYTES = 256 * 1024;
|
|
13034
|
+
var HEADER2 = `# cloudhop team setup: import it with \`cloudhop team import <this file or its https URL>\`.
|
|
13035
|
+
# No secrets: portal URLs, regions, account ids and role names only.
|
|
13036
|
+
`;
|
|
13037
|
+
function validate(app, raw) {
|
|
13038
|
+
const bad = (why) => new CloudhopError("INVALID_NAME", `Not a cloudhop team file: ${why}.`);
|
|
13039
|
+
if (!raw || typeof raw !== "object") throw bad("it is empty");
|
|
13040
|
+
const file = raw;
|
|
13041
|
+
if (file.version !== 1) throw bad('unknown "version" (expected 1)');
|
|
13042
|
+
const sources = file.sources ?? {};
|
|
13043
|
+
const aliases = file.aliases ?? {};
|
|
13044
|
+
for (const [id, source] of Object.entries(sources)) {
|
|
13045
|
+
assertValidName("source", id);
|
|
13046
|
+
if (!PROVIDER_IDS.includes(source?.provider))
|
|
13047
|
+
throw bad(`source "${id}" has an unknown provider`);
|
|
13048
|
+
const kind = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind);
|
|
13049
|
+
if (!kind) throw bad(`source "${id}" has an unknown kind`);
|
|
13050
|
+
for (const field of kind.fields) {
|
|
13051
|
+
const value = source.settings?.[field.key];
|
|
13052
|
+
if (typeof value !== "string" || !value) throw bad(`source "${id}" is missing ${field.key}`);
|
|
13053
|
+
const problem = field.validate?.(value);
|
|
13054
|
+
if (problem) throw bad(`source "${id}": ${problem}`);
|
|
13055
|
+
}
|
|
13056
|
+
}
|
|
13057
|
+
for (const [name, alias] of Object.entries(aliases)) {
|
|
13058
|
+
assertValidName("alias", name);
|
|
13059
|
+
if (!alias || typeof alias.target !== "string" || !sources[alias.source]) {
|
|
13060
|
+
throw bad(`alias "${name}" must name a source of this file and a target`);
|
|
13061
|
+
}
|
|
13062
|
+
}
|
|
13063
|
+
return { version: 1, sources, aliases };
|
|
13064
|
+
}
|
|
13065
|
+
async function load(location) {
|
|
13066
|
+
if (/^http:\/\//i.test(location)) throw new CloudhopError("INVALID_NAME", "Use an https:// URL.");
|
|
13067
|
+
if (/^https:\/\//i.test(location)) {
|
|
13068
|
+
const response = await fetch(location, { signal: AbortSignal.timeout(1e4) });
|
|
13069
|
+
if (!response.ok)
|
|
13070
|
+
throw new CloudhopError("INVALID_NAME", `Could not download it (HTTP ${response.status}).`);
|
|
13071
|
+
const text4 = await response.text();
|
|
13072
|
+
if (text4.length > MAX_BYTES)
|
|
13073
|
+
throw new CloudhopError("INVALID_NAME", "That file is too big for a team setup.");
|
|
13074
|
+
return text4;
|
|
13075
|
+
}
|
|
13076
|
+
return readFile4(location, "utf8");
|
|
13077
|
+
}
|
|
13078
|
+
async function teamCommand(app, args) {
|
|
13079
|
+
const { positional, flags } = parseArgs(args);
|
|
13080
|
+
const [action, location] = positional;
|
|
13081
|
+
if (action === "export") {
|
|
13082
|
+
const config2 = await app.config();
|
|
13083
|
+
const wanted = flags.sources?.split(",").map((s) => s.trim());
|
|
13084
|
+
const sources = config2.sources.filter(
|
|
13085
|
+
(s) => s.kind !== "console-login" && (!wanted || wanted.includes(s.id))
|
|
13086
|
+
);
|
|
13087
|
+
const ids = new Set(sources.map((s) => s.id));
|
|
11399
13088
|
const file = {
|
|
11400
|
-
|
|
11401
|
-
sources: Object.fromEntries(
|
|
11402
|
-
aliases: Object.fromEntries(
|
|
13089
|
+
version: 1,
|
|
13090
|
+
sources: Object.fromEntries(sources.map(({ id, ...rest }) => [id, rest])),
|
|
13091
|
+
aliases: Object.fromEntries(
|
|
13092
|
+
config2.aliases.filter((a2) => ids.has(a2.source)).map(({ name, ...rest }) => [name, rest])
|
|
13093
|
+
)
|
|
11403
13094
|
};
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
|
|
11407
|
-
|
|
11408
|
-
|
|
11409
|
-
|
|
11410
|
-
|
|
11411
|
-
|
|
11412
|
-
|
|
13095
|
+
const text4 = HEADER2 + (0, import_yaml2.stringify)(file);
|
|
13096
|
+
if (flags.out) {
|
|
13097
|
+
await writeFile3(flags.out, text4);
|
|
13098
|
+
console.error(
|
|
13099
|
+
`${paint("green", "\u2714")} Wrote ${sources.length} source(s) and ${Object.keys(file.aliases).length} alias(es) to ${flags.out}.`
|
|
13100
|
+
);
|
|
13101
|
+
} else {
|
|
13102
|
+
process.stdout.write(text4);
|
|
13103
|
+
}
|
|
13104
|
+
return;
|
|
11413
13105
|
}
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
|
|
13106
|
+
if (action !== "import" || !location) {
|
|
13107
|
+
throw new CloudhopError(
|
|
13108
|
+
"INVALID_NAME",
|
|
13109
|
+
"Usage: cloudhop team export [--out file] | cloudhop team import <file|https URL>"
|
|
13110
|
+
);
|
|
11418
13111
|
}
|
|
11419
|
-
|
|
11420
|
-
|
|
13112
|
+
const team = validate(app, (0, import_yaml2.parse)(await load(location)));
|
|
13113
|
+
const config = await app.config();
|
|
13114
|
+
const existingSources = new Map(config.sources.map((s) => [s.id, s]));
|
|
13115
|
+
const sameUrl = (a2, b3) => a2.startUrl !== void 0 && a2.startUrl === b3.startUrl;
|
|
13116
|
+
const newSources = [];
|
|
13117
|
+
const sourceMap = /* @__PURE__ */ new Map();
|
|
13118
|
+
for (const [id, source] of Object.entries(team.sources)) {
|
|
13119
|
+
const match = config.sources.find(
|
|
13120
|
+
(s) => s.provider === source.provider && sameUrl(s.settings, source.settings)
|
|
13121
|
+
);
|
|
13122
|
+
if (match) {
|
|
13123
|
+
sourceMap.set(id, match.id);
|
|
13124
|
+
continue;
|
|
13125
|
+
}
|
|
13126
|
+
if (existingSources.has(id))
|
|
13127
|
+
throw new CloudhopError(
|
|
13128
|
+
"SOURCE_EXISTS",
|
|
13129
|
+
`You already have a different source named "${id}".`,
|
|
13130
|
+
"Rename yours with `cloudhop rm` and add it again, or edit the team file."
|
|
13131
|
+
);
|
|
13132
|
+
newSources.push({ id, ...source });
|
|
13133
|
+
sourceMap.set(id, id);
|
|
13134
|
+
}
|
|
13135
|
+
const takenAliases = new Set(config.aliases.map((a2) => a2.name));
|
|
13136
|
+
const newAliases = [];
|
|
13137
|
+
const skipped = [];
|
|
13138
|
+
for (const [name, alias] of Object.entries(team.aliases)) {
|
|
13139
|
+
if (takenAliases.has(name)) skipped.push(name);
|
|
13140
|
+
else newAliases.push({ name, ...alias, source: sourceMap.get(alias.source) });
|
|
13141
|
+
}
|
|
13142
|
+
if (newSources.length === 0 && newAliases.length === 0) {
|
|
13143
|
+
console.log("Nothing to import: you already have everything in this file.");
|
|
13144
|
+
return;
|
|
11421
13145
|
}
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
|
|
13146
|
+
console.log(bold("This team setup will add:"));
|
|
13147
|
+
for (const s of newSources)
|
|
13148
|
+
console.log(
|
|
13149
|
+
` source ${bold(s.id)} ${paint("sky", s.settings.startUrl ?? s.kind)} ${s.settings.region ?? ""}`
|
|
13150
|
+
);
|
|
13151
|
+
for (const a2 of newAliases)
|
|
13152
|
+
console.log(` alias ${a2.name} ${a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`);
|
|
13153
|
+
if (skipped.length)
|
|
13154
|
+
console.log(paint("dim", ` (skipping aliases you already have: ${skipped.join(", ")})`));
|
|
13155
|
+
if (newSources.length)
|
|
13156
|
+
console.log(
|
|
13157
|
+
paint("yellow", "Check that these portal URLs are your organization's before signing in.")
|
|
13158
|
+
);
|
|
13159
|
+
if (!flags.yes) {
|
|
13160
|
+
if (!interactive())
|
|
13161
|
+
throw new CloudhopError(
|
|
13162
|
+
"NOT_INTERACTIVE",
|
|
13163
|
+
"Review the list above and rerun with --yes to import it."
|
|
13164
|
+
);
|
|
13165
|
+
if (!answer(
|
|
13166
|
+
await dist_exports.confirm({
|
|
13167
|
+
message: "Import it? Each new portal opens your browser to sign in.",
|
|
13168
|
+
initialValue: true
|
|
13169
|
+
})
|
|
13170
|
+
))
|
|
13171
|
+
return;
|
|
11425
13172
|
}
|
|
11426
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
13173
|
+
const providers = new Set((await app.config()).providers);
|
|
13174
|
+
for (const s of newSources) if (!providers.has(s.provider)) await app.enableProvider(s.provider);
|
|
13175
|
+
for (const source of newSources) {
|
|
13176
|
+
const report = await app.connectSource(source);
|
|
13177
|
+
console.log(
|
|
13178
|
+
`${paint("green", "\u2714")} ${source.id}: signed in, ${report.targets.length} account/role(s).`
|
|
13179
|
+
);
|
|
11433
13180
|
}
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
const
|
|
11438
|
-
|
|
11439
|
-
|
|
13181
|
+
const usable = [];
|
|
13182
|
+
const missing = [];
|
|
13183
|
+
for (const alias of newAliases) {
|
|
13184
|
+
const targets = await app.listTargets(alias.source);
|
|
13185
|
+
if (targets.some((t2) => t2.id === alias.target)) usable.push(alias);
|
|
13186
|
+
else missing.push(alias.name);
|
|
11440
13187
|
}
|
|
11441
|
-
|
|
13188
|
+
if (usable.length) await app.addAliases(usable);
|
|
13189
|
+
console.log(
|
|
13190
|
+
`${paint("green", "\u2714")} Created ${usable.length} alias(es)${usable.length ? `: ${usable.map((a2) => a2.name).join(", ")}` : ""}.`
|
|
13191
|
+
);
|
|
13192
|
+
if (missing.length)
|
|
13193
|
+
console.log(
|
|
13194
|
+
paint(
|
|
13195
|
+
"yellow",
|
|
13196
|
+
`Not created, you don't have access to their account/role: ${missing.join(", ")}.`
|
|
13197
|
+
)
|
|
13198
|
+
);
|
|
13199
|
+
}
|
|
11442
13200
|
|
|
11443
13201
|
// ../ui/src/api.ts
|
|
11444
|
-
var
|
|
13202
|
+
var text3 = (body, key) => {
|
|
11445
13203
|
const value = body[key];
|
|
11446
13204
|
return typeof value === "string" ? value.trim() : "";
|
|
11447
13205
|
};
|
|
@@ -11460,10 +13218,26 @@ var reportView = (report) => ({
|
|
|
11460
13218
|
removed: report.removed.length,
|
|
11461
13219
|
orphanedAliases: report.orphanedAliases
|
|
11462
13220
|
});
|
|
11463
|
-
|
|
13221
|
+
var SCOPES = /* @__PURE__ */ new Set(["user", "project"]);
|
|
13222
|
+
var scopeOf = (value) => {
|
|
13223
|
+
if (SCOPES.has(value)) return value;
|
|
13224
|
+
throw badRequest('Scope must be "user" or "project".');
|
|
13225
|
+
};
|
|
13226
|
+
var assistantIds = (value) => {
|
|
13227
|
+
const ids = Array.isArray(value) ? value : [];
|
|
13228
|
+
if (ids.length === 0 || !ids.every((id) => ASSISTANT_IDS.includes(id))) {
|
|
13229
|
+
throw badRequest("Choose at least one assistant.");
|
|
13230
|
+
}
|
|
13231
|
+
return ids;
|
|
13232
|
+
};
|
|
13233
|
+
function createApi(app, assistants) {
|
|
13234
|
+
const requireAssistants = () => {
|
|
13235
|
+
if (!assistants) throw badRequest("AI assistant setup is not available here.");
|
|
13236
|
+
return assistants;
|
|
13237
|
+
};
|
|
11464
13238
|
const regionCheck = (source, region) => {
|
|
11465
|
-
const
|
|
11466
|
-
const problem = region ?
|
|
13239
|
+
const validate2 = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
|
|
13240
|
+
const problem = region ? validate2?.(region) : "Region is required.";
|
|
11467
13241
|
if (problem) throw badRequest(problem);
|
|
11468
13242
|
};
|
|
11469
13243
|
const findSource = async (id) => {
|
|
@@ -11514,10 +13288,31 @@ function createApi(app) {
|
|
|
11514
13288
|
target: alias.target,
|
|
11515
13289
|
label: target?.label,
|
|
11516
13290
|
missing: !target,
|
|
11517
|
-
region
|
|
13291
|
+
region,
|
|
13292
|
+
prod: alias.prod === true
|
|
11518
13293
|
}))
|
|
11519
13294
|
};
|
|
11520
13295
|
},
|
|
13296
|
+
/** Which AI assistants know cloudhop, for both scopes. Paths are shown, never file contents. */
|
|
13297
|
+
"GET /api/assistants": async () => {
|
|
13298
|
+
const installer = requireAssistants();
|
|
13299
|
+
return {
|
|
13300
|
+
project: process.cwd(),
|
|
13301
|
+
user: await installer.status("user"),
|
|
13302
|
+
projectScope: await installer.status("project")
|
|
13303
|
+
};
|
|
13304
|
+
},
|
|
13305
|
+
"POST /api/assistants": async (_2, body) => {
|
|
13306
|
+
const tools = assistantIds(body.tools);
|
|
13307
|
+
const scope = scopeOf(body.scope);
|
|
13308
|
+
return {
|
|
13309
|
+
results: await requireAssistants().install(tools, scope, { mcp: body.mcp !== false })
|
|
13310
|
+
};
|
|
13311
|
+
},
|
|
13312
|
+
"POST /api/assistants/remove": async (_2, body) => ({
|
|
13313
|
+
results: await requireAssistants().remove(assistantIds(body.tools), scopeOf(body.scope))
|
|
13314
|
+
}),
|
|
13315
|
+
"POST /api/assistants/update": async (_2, body) => requireAssistants().update({ force: body.force === true }),
|
|
11521
13316
|
"GET /api/doctor": async () => (await app.doctor()).map((entry) => ({
|
|
11522
13317
|
provider: entry.provider,
|
|
11523
13318
|
cli: entry.cli.displayName,
|
|
@@ -11536,19 +13331,19 @@ function createApi(app) {
|
|
|
11536
13331
|
},
|
|
11537
13332
|
/** Adds a source and logs in right away, like `cloudhop add`; a failed login adds nothing. */
|
|
11538
13333
|
"POST /api/sources": async (_2, body) => {
|
|
11539
|
-
const provider = app.provider(
|
|
11540
|
-
const kind = provider.sourceKinds.find((k) => k.kind ===
|
|
13334
|
+
const provider = app.provider(text3(body, "provider"));
|
|
13335
|
+
const kind = provider.sourceKinds.find((k) => k.kind === text3(body, "kind"));
|
|
11541
13336
|
if (!kind) throw badRequest("Choose what you want to add.");
|
|
11542
13337
|
const input = body.settings ?? {};
|
|
11543
13338
|
const settings2 = {};
|
|
11544
13339
|
for (const field of kind.fields) {
|
|
11545
|
-
const value =
|
|
13340
|
+
const value = text3(input, field.key) || field.defaultValue || "";
|
|
11546
13341
|
if (!value) throw badRequest(`${field.label} is required.`);
|
|
11547
13342
|
const problem = field.validate?.(value);
|
|
11548
13343
|
if (problem) throw badRequest(`${field.label}: ${problem}`);
|
|
11549
13344
|
settings2[field.key] = value;
|
|
11550
13345
|
}
|
|
11551
|
-
const id =
|
|
13346
|
+
const id = text3(body, "id");
|
|
11552
13347
|
return reportView(
|
|
11553
13348
|
await app.connectSource({ id, provider: provider.id, kind: kind.kind, settings: settings2 })
|
|
11554
13349
|
);
|
|
@@ -11576,25 +13371,41 @@ function createApi(app) {
|
|
|
11576
13371
|
if (items.length === 0) throw badRequest("Pick at least one account/role.");
|
|
11577
13372
|
const aliases = [];
|
|
11578
13373
|
for (const item of items) {
|
|
11579
|
-
const source = await findSource(
|
|
11580
|
-
const region =
|
|
13374
|
+
const source = await findSource(text3(item, "source"));
|
|
13375
|
+
const region = text3(item, "region");
|
|
11581
13376
|
if (region) regionCheck(source, region);
|
|
11582
13377
|
aliases.push({
|
|
11583
|
-
name:
|
|
13378
|
+
name: text3(item, "name"),
|
|
11584
13379
|
source: source.id,
|
|
11585
|
-
target:
|
|
13380
|
+
target: text3(item, "target"),
|
|
11586
13381
|
...region && region !== source.settings.region ? { region } : {}
|
|
11587
13382
|
});
|
|
11588
13383
|
}
|
|
11589
13384
|
await app.addAliases(aliases);
|
|
11590
13385
|
return { created: aliases.map((a2) => a2.name) };
|
|
11591
13386
|
},
|
|
13387
|
+
/** Changes an alias: any of `region`, `prod` (production mark) and `name` (rename, applied last). */
|
|
11592
13388
|
"PATCH /api/aliases/:name": async ([name], body) => {
|
|
11593
|
-
|
|
11594
|
-
|
|
11595
|
-
|
|
11596
|
-
|
|
11597
|
-
|
|
13389
|
+
let current = name;
|
|
13390
|
+
if ("region" in body) {
|
|
13391
|
+
const resolved = await app.resolveAlias(current);
|
|
13392
|
+
const region = text3(body, "region");
|
|
13393
|
+
regionCheck(resolved.source, region);
|
|
13394
|
+
await app.setAliasRegion(current, region);
|
|
13395
|
+
}
|
|
13396
|
+
if (typeof body.prod === "boolean") await app.setProduction([current], body.prod);
|
|
13397
|
+
if ("name" in body) {
|
|
13398
|
+
const to = text3(body, "name");
|
|
13399
|
+
await app.renameAlias(current, to);
|
|
13400
|
+
current = to;
|
|
13401
|
+
}
|
|
13402
|
+
return { name: current };
|
|
13403
|
+
},
|
|
13404
|
+
/** Opens the web console in the user's browser; the sign-in URL never reaches the page. */
|
|
13405
|
+
"POST /api/aliases/:name/console": async ([name], body) => {
|
|
13406
|
+
const service = text3(body, "service");
|
|
13407
|
+
const via = await app.openConsole(name, service ? { service } : {});
|
|
13408
|
+
return { via };
|
|
11598
13409
|
},
|
|
11599
13410
|
"DELETE /api/aliases/:name": async ([name]) => {
|
|
11600
13411
|
await app.removeAlias(name);
|
|
@@ -11631,7 +13442,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
|
|
|
11631
13442
|
import { createServer } from "http";
|
|
11632
13443
|
|
|
11633
13444
|
// ../ui/src/assets/app.js
|
|
11634
|
-
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";
|
|
13445
|
+
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// ---------- pages ----------\n\nconst PAGES = {\n aliases: 'Aliases',\n sources: 'Sources',\n assistants: 'AI assistants',\n tools: 'Tools',\n};\nlet route = 'aliases';\nconst aliasFilter = { text: '', source: '', prodOnly: false };\n\n/** `#/aliases?source=acme` \u2192 route \"aliases\", filtered by that source. */\nfunction readRoute() {\n const match = /^#\\/([a-z]+)(?:\\?(.*))?$/.exec(location.hash);\n route = match && PAGES[match[1]] ? match[1] : 'aliases';\n const query = new URLSearchParams(match?.[2] ?? '');\n if (query.has('source')) aliasFilter.source = query.get('source');\n}\n\nfunction go(page, query) {\n location.hash = `#/${page}${query ? `?${new URLSearchParams(query)}` : ''}`;\n}\n\n// A fresh link opened in this same tab carries a new token: reload to pick it up.\n// Any other fragment is a page change.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) {\n location.reload();\n return;\n }\n readRoute();\n if (state) render();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nconst $nav = document.getElementById('nav');\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))?|aliases\\/[^/]+\\/console)$/;\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 $nav.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n for (const link of $nav.querySelectorAll('a')) {\n if (link.dataset.page === route) link.setAttribute('aria-current', 'page');\n else link.removeAttribute('aria-current');\n }\n document.title = `${PAGES[route]} \xB7 cloudhop`;\n const pages = {\n aliases: aliasesPage,\n sources: sourcesPage,\n assistants: assistantsPage,\n tools: toolsPage,\n };\n $main.replaceChildren(...pages[route]());\n if (route === 'assistants') loadAssistants();\n if (route === 'tools') loadTools();\n}\n\nfunction pageHead(title, subtitle, ...actions) {\n return h(\n 'div',\n { class: 'page-head' },\n h('div', {}, h('h1', {}, title), subtitle ? h('p', {}, subtitle) : null),\n h('div', { class: 'actions' }, actions),\n );\n}\n\nfunction firstSourceHero() {\n return 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\n// ---------- Aliases page ----------\n\nfunction aliasesPage() {\n if (state.sources.length === 0) return [firstSourceHero()];\n if (aliasFilter.source && !state.sources.some((s) => s.id === aliasFilter.source))\n aliasFilter.source = '';\n\n const addAliases = () => {\n const source =\n state.sources.find((s) => s.id === aliasFilter.source) ??\n (state.sources.length === 1 ? state.sources[0] : undefined);\n if (source) openAliasPicker(source);\n else openSourceChooser();\n };\n const head = pageHead(\n 'Aliases',\n 'Each alias is one account, role and region. Run it with hop <alias>.',\n h('button', { onclick: addAliases }, '+ Aliases'),\n );\n if (state.aliases.length === 0) {\n return [\n head,\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'No aliases yet'),\n h('p', {}, 'Pick the accounts and roles you use and give each one a short name.'),\n h('button', { class: 'primary', onclick: addAliases }, 'Pick accounts'),\n ),\n ];\n }\n\n const rows = h('tbody', { id: 'alias-rows' });\n const count = h('span', { class: 'count' });\n const fill = () => {\n const words = aliasFilter.text.toLowerCase().split(/\\s+/).filter(Boolean);\n const visible = state.aliases.filter((a) => {\n if (aliasFilter.source && a.source !== aliasFilter.source) return false;\n if (aliasFilter.prodOnly && !a.prod) return false;\n const text = `${a.name} ${a.label ?? a.target} ${a.region ?? ''} ${a.source}`.toLowerCase();\n return words.every((w) => text.includes(w));\n });\n rows.replaceChildren(\n ...(visible.length\n ? visible.map(aliasRow)\n : [\n h(\n 'tr',\n {},\n h('td', { colspan: '5', class: 'empty' }, 'No alias matches these filters.'),\n ),\n ]),\n );\n count.textContent = `${visible.length} of ${state.aliases.length}`;\n };\n\n const search = h('input', {\n type: 'search',\n placeholder: 'Filter: name, account, role or region',\n value: aliasFilter.text,\n 'aria-label': 'Filter aliases',\n oninput: (e) => {\n aliasFilter.text = e.currentTarget.value;\n fill();\n },\n });\n const sourceSelect = h(\n 'select',\n {\n 'aria-label': 'Source',\n onchange: (e) => {\n aliasFilter.source = e.currentTarget.value;\n fill();\n },\n },\n h('option', { value: '' }, 'All sources'),\n state.sources.map((s) =>\n h('option', { value: s.id, selected: s.id === aliasFilter.source }, s.id),\n ),\n );\n const prodOnly = h(\n 'label',\n { class: 'prod-toggle' },\n h('input', {\n type: 'checkbox',\n checked: aliasFilter.prodOnly,\n onchange: (e) => {\n aliasFilter.prodOnly = e.currentTarget.checked;\n fill();\n },\n }),\n 'Production only',\n );\n\n const table = h(\n 'table',\n { class: 'aliases' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Source'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n rows,\n );\n fill();\n return [\n head,\n h(\n 'div',\n { class: 'toolbar' },\n search,\n sourceSelect,\n prodOnly,\n h('div', { class: 'spacer' }),\n count,\n ),\n h('section', { class: 'card' }, table),\n ];\n}\n\n/** When several sources exist, ask which one to pick accounts from. */\nfunction openSourceChooser() {\n showDialog(\n 'Add aliases from',\n h(\n 'div',\n { class: 'choices' },\n state.sources.map((source) =>\n h(\n 'button',\n { class: 'choice', onclick: () => openAliasPicker(source) },\n h(\n 'span',\n {},\n h('strong', {}, source.id),\n h('small', {}, source.settings.startUrl ?? source.kindLabel),\n ),\n ),\n ),\n ),\n );\n}\n\n// ---------- Sources page ----------\n\nfunction sourcesPage() {\n const head = pageHead('Sources', 'The SSO portals and personal accounts you sign in to.');\n if (state.sources.length === 0) return [head, firstSourceHero()];\n return [head, ...state.sources.map(sourceCard)];\n}\n\n// ---------- AI assistants page ----------\n\nfunction assistantsPage() {\n return [assistantsCard()];\n}\n\n// ---------- Tools page ----------\n\nfunction snippet(command) {\n return h(\n 'div',\n { class: 'snippet' },\n h('code', {}, command),\n h(\n 'button',\n {\n class: 'link',\n title: 'Copy',\n 'aria-label': `Copy ${command}`,\n onclick: () =>\n navigator.clipboard\n ?.writeText(command)\n .then(() => toast('Copied.'))\n .catch(() =>\n toast('Copy it by hand: the browser blocked the clipboard.', { error: true }),\n ),\n },\n 'Copy',\n ),\n );\n}\n\nfunction toolCard(title, text, ...commands) {\n return h(\n 'section',\n { class: 'card' },\n h('div', { class: 'card-head' }, h('h2', {}, title)),\n h(\n 'div',\n { class: 'card-body padded' },\n h('p', { class: 'card-help' }, text),\n commands.map(snippet),\n ),\n );\n}\n\nfunction toolsPage() {\n const sample = state.aliases[0]?.name ?? 'acme-dev';\n return [\n pageHead('Tools', 'More ways to use cloudhop from your terminal.'),\n h(\n 'section',\n { class: 'card', id: 'tools-cli' },\n h('div', { class: 'card-head' }, h('h2', {}, 'Cloud CLIs')),\n h('div', { class: 'card-body padded' }, h('p', { class: 'card-help' }, 'Checking\u2026')),\n ),\n toolCard(\n 'Shell integration',\n 'Pick aliases from a list, tab-complete them, and use one in your shell so plain aws and Terraform work without hop.',\n 'cloudhop shell install',\n `cloudhop use ${sample}`,\n 'hop',\n ),\n toolCard(\n 'Run in many accounts',\n 'Run one command in many aliases at once: names, lists, wildcards or /regex/. It asks before running.',\n `cloudhop each '${sample.split('-')[0]}-*' -- s3 ls`,\n ),\n toolCard(\n 'Share with your team',\n 'Export your portals and aliases (no secrets) and let teammates import them in one command.',\n 'cloudhop team export --out team.yaml',\n 'cloudhop team import team.yaml',\n ),\n ];\n}\n\nasync function loadTools() {\n const card = document.querySelector('#tools-cli .card-body');\n if (!card) return;\n try {\n const entries = await api('GET', '/api/doctor');\n card.replaceChildren(\n ...entries.map((e) =>\n h(\n 'div',\n { class: 'tool-row' },\n h('span', { class: `badge ${e.installed && !e.outdated ? 'valid' : 'expired'}` }, e.cli),\n h(\n 'span',\n {},\n !e.installed\n ? 'Not installed.'\n : e.outdated\n ? `${e.version} is older than ${e.minVersion}.`\n : `Version ${e.version}.`,\n ),\n e.installed && !e.outdated ? null : snippet('cloudhop doctor'),\n ),\n ),\n );\n } catch (error) {\n card.replaceChildren(h('p', { class: 'card-help' }, error.message));\n }\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 prodCount = aliases.filter((a) => a.prod).length;\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 'div',\n { class: 'source-summary' },\n h(\n 'span',\n {},\n `${aliases.length} alias(es)`,\n prodCount ? h('span', { class: 'prod-tag' }, `${prodCount} PROD`) : null,\n ),\n h(\n 'button',\n { class: 'link', onclick: () => go('aliases', { source: source.id }) },\n 'View aliases \u2192',\n ),\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 const patch = (body, message) =>\n api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, body)\n .then(() => message && toast(message))\n .catch(fail)\n .then(refresh);\n const iconButton = (label, title, onclick, extra = {}) =>\n h('button', { class: 'link', title, 'aria-label': title, onclick, ...extra }, label);\n\n const actions = h(\n 'div',\n { class: 'row-actions' },\n h(\n 'button',\n {\n title: `Open the AWS console as ${alias.name}, already signed in`,\n disabled: alias.missing,\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const result = await api(\n 'POST',\n `/api/aliases/${encodeURIComponent(alias.name)}/console`,\n {},\n ).catch(fail);\n if (result) {\n toast(\n result.via === 'signed-in'\n ? `Opened the console for ${alias.name}.`\n : result.via === 'portal'\n ? `Opened ${alias.name} through the SSO portal.`\n : `Opened the console sign-in page for ${alias.name}.`,\n );\n }\n }),\n },\n 'Console',\n ),\n h(\n 'label',\n { class: 'prod-toggle', title: 'Production: hop and AI assistants ask before any change' },\n h('input', {\n type: 'checkbox',\n checked: alias.prod,\n 'aria-label': `${alias.name} is production`,\n onchange: (e) =>\n patch(\n { prod: e.currentTarget.checked },\n e.currentTarget.checked\n ? `${alias.name} is now production.`\n : `${alias.name} is no longer production.`,\n ),\n }),\n 'Prod',\n ),\n iconButton('\u270E', `Rename ${alias.name}`, () => {\n const to = prompt(`New name for \"${alias.name}\"`, alias.name)?.trim();\n if (to && to !== alias.name) patch({ name: to }, `${alias.name} is now ${to}.`);\n }),\n iconButton(\n '\u2715',\n `Remove ${alias.name}`,\n (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 { class: 'link danger-hover' },\n ),\n );\n\n return h(\n 'tr',\n { class: alias.prod ? 'prod' : undefined },\n h(\n 'td',\n { class: 'alias', title: `hop ${alias.name} <command>` },\n alias.name,\n alias.prod ? h('span', { class: 'prod-tag' }, 'PROD') : null,\n ),\n h('td', { class: 'muted' }, alias.source),\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('td', { style: 'text-align:right' }, actions),\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 readRoute();\n refresh();\n}\n";
|
|
11635
13446
|
|
|
11636
13447
|
// ../ui/src/assets/index.html
|
|
11637
13448
|
var assets_default = `<!doctype html>
|
|
@@ -11762,9 +13573,29 @@ var assets_default = `<!doctype html>
|
|
|
11762
13573
|
}
|
|
11763
13574
|
/* Fixed columns so every source's table lines up with the others. */
|
|
11764
13575
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
|
11765
|
-
th:nth-child(1) { width:
|
|
11766
|
-
th:nth-child(2) { width:
|
|
11767
|
-
th:nth-child(
|
|
13576
|
+
table.aliases th:nth-child(1) { width: 22%; }
|
|
13577
|
+
table.aliases th:nth-child(2) { width: 15%; }
|
|
13578
|
+
table.aliases th:nth-child(3) { width: 180px; }
|
|
13579
|
+
table.aliases th:nth-child(5) { width: 264px; }
|
|
13580
|
+
td.muted { color: var(--muted); font-size: 14px; }
|
|
13581
|
+
.nav { display: flex; gap: 4px; margin-left: 12px; }
|
|
13582
|
+
.nav a { color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 500; padding: 6px 12px; border-radius: 8px; }
|
|
13583
|
+
.nav a:hover { color: var(--text); background: var(--surface-2); }
|
|
13584
|
+
.nav a[aria-current="page"] { color: var(--accent-strong); background: var(--accent-soft); font-weight: 600; }
|
|
13585
|
+
.page-head { display: flex; align-items: flex-end; gap: 16px; margin: 4px 0 16px; flex-wrap: wrap; }
|
|
13586
|
+
.page-head > div:first-child { flex: 1; min-width: 220px; }
|
|
13587
|
+
.page-head h1 { font-size: 22px; margin: 0; letter-spacing: -0.01em; }
|
|
13588
|
+
.page-head p { margin: 4px 0 0; color: var(--muted); font-size: 14px; }
|
|
13589
|
+
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
|
13590
|
+
.toolbar input[type="search"] { flex: 1 1 260px; }
|
|
13591
|
+
.toolbar .count { color: var(--muted); font-size: 13px; }
|
|
13592
|
+
.source-summary { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--muted); }
|
|
13593
|
+
.card-body.padded { padding: 4px 16px 14px; }
|
|
13594
|
+
.snippet { display: flex; align-items: center; gap: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 6px 6px 6px 12px; margin-bottom: 8px; }
|
|
13595
|
+
.snippet code { flex: 1; overflow-x: auto; white-space: nowrap; }
|
|
13596
|
+
.tool-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 6px 0; }
|
|
13597
|
+
.tool-row .snippet { margin: 0; flex: 1 1 220px; }
|
|
13598
|
+
button.choice { width: 100%; text-align: left; background: var(--surface); }
|
|
11768
13599
|
td { overflow-wrap: anywhere; }
|
|
11769
13600
|
th {
|
|
11770
13601
|
text-align: left;
|
|
@@ -11877,14 +13708,40 @@ var assets_default = `<!doctype html>
|
|
|
11877
13708
|
margin-bottom: 18px;
|
|
11878
13709
|
}
|
|
11879
13710
|
.notice.error { border-left-color: var(--bad); }
|
|
13711
|
+
.row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; white-space: nowrap; }
|
|
13712
|
+
.prod-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
|
13713
|
+
.prod-tag { display: inline-block; white-space: nowrap; margin-left: 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; color: var(--bad); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; vertical-align: 1px; }
|
|
13714
|
+
tr.prod td:first-child { box-shadow: inset 3px 0 0 var(--bad); }
|
|
13715
|
+
button.link.danger-hover:hover { color: var(--bad); }
|
|
13716
|
+
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
13717
|
+
.seg button { border: 0; border-radius: 0; }
|
|
13718
|
+
.seg button + button { border-left: 1px solid var(--border); }
|
|
13719
|
+
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
|
|
13720
|
+
.card-help { color: var(--muted); font-size: 14px; margin: 10px 16px 6px; }
|
|
13721
|
+
.card-body.padded .card-help { margin: 8px 0 10px; }
|
|
13722
|
+
.muted-inline { color: var(--muted); font-weight: 400; font-size: 12px; }
|
|
13723
|
+
table.assistants th:nth-child(2) { width: 170px; }
|
|
13724
|
+
table.assistants th:nth-child(3) { width: 140px; }
|
|
13725
|
+
table.assistants th:nth-child(4) { width: 230px; }
|
|
11880
13726
|
@media (max-width: 640px) {
|
|
11881
13727
|
.tagline { display: none; }
|
|
11882
|
-
|
|
13728
|
+
.nav { margin-left: 4px; overflow-x: auto; }
|
|
13729
|
+
.nav a { padding: 6px 8px; }
|
|
13730
|
+
table.aliases th:nth-child(2), table.aliases td:nth-child(2),
|
|
13731
|
+
table.aliases th:nth-child(4), table.aliases td:nth-child(4),
|
|
13732
|
+
table.assistants th:nth-child(3), table.assistants td:nth-child(3) { display: none; }
|
|
11883
13733
|
td, th { padding: 8px 10px; }
|
|
11884
13734
|
td.alias { white-space: normal; overflow-wrap: anywhere; }
|
|
11885
13735
|
.region input { width: 96px; }
|
|
11886
|
-
th:nth-child(1) { width: auto; }
|
|
11887
|
-
th:nth-child(
|
|
13736
|
+
table.aliases th:nth-child(1) { width: auto; }
|
|
13737
|
+
table.aliases th:nth-child(3) { width: 124px; }
|
|
13738
|
+
table.aliases th:nth-child(5) { width: 132px; }
|
|
13739
|
+
.bar { flex-wrap: wrap; }
|
|
13740
|
+
.row-actions { flex-wrap: wrap; }
|
|
13741
|
+
table.assistants th:nth-child(2) { width: 112px; }
|
|
13742
|
+
table.assistants th:nth-child(4) { width: 96px; }
|
|
13743
|
+
table.assistants .actions { flex-direction: column; align-items: flex-end; }
|
|
13744
|
+
.card-help { margin: 10px 10px 6px; }
|
|
11888
13745
|
}
|
|
11889
13746
|
</style>
|
|
11890
13747
|
</head>
|
|
@@ -11892,7 +13749,12 @@ var assets_default = `<!doctype html>
|
|
|
11892
13749
|
<header>
|
|
11893
13750
|
<div class="bar">
|
|
11894
13751
|
<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>
|
|
11895
|
-
<
|
|
13752
|
+
<nav id="nav" class="nav" aria-label="Sections" hidden>
|
|
13753
|
+
<a href="#/aliases" data-page="aliases">Aliases</a>
|
|
13754
|
+
<a href="#/sources" data-page="sources">Sources</a>
|
|
13755
|
+
<a href="#/assistants" data-page="assistants">AI assistants</a>
|
|
13756
|
+
<a href="#/tools" data-page="tools">Tools</a>
|
|
13757
|
+
</nav>
|
|
11896
13758
|
<div class="spacer"></div>
|
|
11897
13759
|
<button type="button" class="primary" id="add-source" hidden>+ Add source</button>
|
|
11898
13760
|
</div>
|
|
@@ -11932,9 +13794,13 @@ async function readJson(req) {
|
|
|
11932
13794
|
return raw ? JSON.parse(raw) : {};
|
|
11933
13795
|
}
|
|
11934
13796
|
var sameSecret = (given, expected) => given !== void 0 && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
|
|
11935
|
-
async function startUiServer({
|
|
13797
|
+
async function startUiServer({
|
|
13798
|
+
app,
|
|
13799
|
+
assistants,
|
|
13800
|
+
port = 0
|
|
13801
|
+
}) {
|
|
11936
13802
|
const token = randomBytes(24).toString("base64url");
|
|
11937
|
-
const handle = createApi(app);
|
|
13803
|
+
const handle = createApi(app, assistants);
|
|
11938
13804
|
let allowedHosts = /* @__PURE__ */ new Set();
|
|
11939
13805
|
const server = createServer(async (req, res) => {
|
|
11940
13806
|
try {
|
|
@@ -11994,12 +13860,12 @@ async function startUiServer({ app, port = 0 }) {
|
|
|
11994
13860
|
}
|
|
11995
13861
|
|
|
11996
13862
|
// src/commands/ui.ts
|
|
11997
|
-
async function uiCommand(app, { flags }) {
|
|
13863
|
+
async function uiCommand(app, assistants, { flags }) {
|
|
11998
13864
|
const port = flags.port ? Number(flags.port) : 0;
|
|
11999
13865
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
12000
13866
|
throw new CloudhopError("INVALID_NAME", `--port must be a number between 1 and 65535.`);
|
|
12001
13867
|
}
|
|
12002
|
-
const server = await startUiServer({ app, port });
|
|
13868
|
+
const server = await startUiServer({ app, assistants, port });
|
|
12003
13869
|
console.log(`cloudhop UI running at ${bold(server.url)}`);
|
|
12004
13870
|
console.log("Only this computer can open it, and the link works only while this runs.");
|
|
12005
13871
|
console.log("Press Ctrl-C to stop.");
|
|
@@ -12012,15 +13878,15 @@ async function uiCommand(app, { flags }) {
|
|
|
12012
13878
|
}
|
|
12013
13879
|
|
|
12014
13880
|
// src/container.ts
|
|
12015
|
-
import { homedir as
|
|
12016
|
-
import { join as
|
|
13881
|
+
import { homedir as homedir4 } from "os";
|
|
13882
|
+
import { join as join8 } from "path";
|
|
12017
13883
|
|
|
12018
13884
|
// ../provider-aws/src/aws-provider.ts
|
|
12019
|
-
import { join as
|
|
13885
|
+
import { join as join7 } from "path";
|
|
12020
13886
|
|
|
12021
13887
|
// ../provider-aws/src/aws-config.ts
|
|
12022
|
-
import { mkdir as
|
|
12023
|
-
import { dirname as
|
|
13888
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
13889
|
+
import { dirname as dirname4 } from "path";
|
|
12024
13890
|
var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand)";
|
|
12025
13891
|
var BLOCK_END = "# <<< cloudhop managed <<<";
|
|
12026
13892
|
function renderBlock(sections) {
|
|
@@ -12067,13 +13933,64 @@ function replaceManagedBlock(content, sections) {
|
|
|
12067
13933
|
async function writeManagedBlock(path2, sections) {
|
|
12068
13934
|
let content = "";
|
|
12069
13935
|
try {
|
|
12070
|
-
content = await
|
|
13936
|
+
content = await readFile5(path2, "utf8");
|
|
12071
13937
|
} catch {
|
|
12072
13938
|
}
|
|
12073
13939
|
const next = replaceManagedBlock(content, sections);
|
|
12074
13940
|
if (next === content) return;
|
|
12075
|
-
await
|
|
12076
|
-
await
|
|
13941
|
+
await mkdir4(dirname4(path2), { recursive: true, mode: 448 });
|
|
13942
|
+
await writeFile4(path2, next, { mode: 384 });
|
|
13943
|
+
}
|
|
13944
|
+
|
|
13945
|
+
// ../provider-aws/src/console.ts
|
|
13946
|
+
var ISSUER = "https://dayepesb.github.io/cloudhop/";
|
|
13947
|
+
function partitionHosts(region) {
|
|
13948
|
+
if (region?.startsWith("cn-")) {
|
|
13949
|
+
return { signin: "https://signin.amazonaws.cn", console: "https://console.amazonaws.cn" };
|
|
13950
|
+
}
|
|
13951
|
+
if (region?.startsWith("us-gov-")) {
|
|
13952
|
+
return {
|
|
13953
|
+
signin: "https://signin.amazonaws-us-gov.com",
|
|
13954
|
+
console: "https://console.amazonaws-us-gov.com"
|
|
13955
|
+
};
|
|
13956
|
+
}
|
|
13957
|
+
return { signin: "https://signin.aws.amazon.com", console: "https://console.aws.amazon.com" };
|
|
13958
|
+
}
|
|
13959
|
+
function consoleDestination(region, service) {
|
|
13960
|
+
const { console: console2 } = partitionHosts(region);
|
|
13961
|
+
const path2 = service ? `${service}/home` : "console/home";
|
|
13962
|
+
return `${console2}/${path2}${region ? `?region=${encodeURIComponent(region)}` : ""}`;
|
|
13963
|
+
}
|
|
13964
|
+
async function federatedConsoleUrl(credentials, destination, region, fetchFn) {
|
|
13965
|
+
const {
|
|
13966
|
+
AWS_ACCESS_KEY_ID: id,
|
|
13967
|
+
AWS_SECRET_ACCESS_KEY: key,
|
|
13968
|
+
AWS_SESSION_TOKEN: token
|
|
13969
|
+
} = credentials;
|
|
13970
|
+
if (!id || !key || !token) return void 0;
|
|
13971
|
+
const { signin } = partitionHosts(region);
|
|
13972
|
+
const response = await fetchFn(`${signin}/federation`, {
|
|
13973
|
+
method: "POST",
|
|
13974
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
13975
|
+
body: new URLSearchParams({
|
|
13976
|
+
Action: "getSigninToken",
|
|
13977
|
+
Session: JSON.stringify({ sessionId: id, sessionKey: key, sessionToken: token })
|
|
13978
|
+
}).toString()
|
|
13979
|
+
}).catch(() => void 0);
|
|
13980
|
+
if (!response?.ok) return void 0;
|
|
13981
|
+
const signinToken = (await response.json().catch(() => ({}))).SigninToken;
|
|
13982
|
+
if (!signinToken) return void 0;
|
|
13983
|
+
const params = new URLSearchParams({
|
|
13984
|
+
Action: "login",
|
|
13985
|
+
Issuer: ISSUER,
|
|
13986
|
+
Destination: destination,
|
|
13987
|
+
SigninToken: signinToken
|
|
13988
|
+
});
|
|
13989
|
+
return `${signin}/federation?${params}`;
|
|
13990
|
+
}
|
|
13991
|
+
function portalShortcutUrl(startUrl, accountId, roleName, destination) {
|
|
13992
|
+
const params = new URLSearchParams({ account_id: accountId, role_name: roleName, destination });
|
|
13993
|
+
return `${startUrl.replace(/\/+$/, "")}/#/console?${params}`;
|
|
12077
13994
|
}
|
|
12078
13995
|
|
|
12079
13996
|
// ../provider-aws/src/http.ts
|
|
@@ -12087,12 +14004,12 @@ var AwsHttpError = class extends Error {
|
|
|
12087
14004
|
status;
|
|
12088
14005
|
code;
|
|
12089
14006
|
};
|
|
12090
|
-
async function
|
|
12091
|
-
const
|
|
12092
|
-
const body =
|
|
14007
|
+
async function parse3(response) {
|
|
14008
|
+
const text4 = await response.text();
|
|
14009
|
+
const body = text4 ? JSON.parse(text4) : {};
|
|
12093
14010
|
if (!response.ok) {
|
|
12094
14011
|
const code = body.error ?? body.__type ?? body.code;
|
|
12095
|
-
const description = body.error_description ?? body.message ??
|
|
14012
|
+
const description = body.error_description ?? body.message ?? text4;
|
|
12096
14013
|
throw new AwsHttpError(
|
|
12097
14014
|
response.status,
|
|
12098
14015
|
code,
|
|
@@ -12107,10 +14024,10 @@ async function postJson(fetchFn, url, body) {
|
|
|
12107
14024
|
headers: { "content-type": "application/json" },
|
|
12108
14025
|
body: JSON.stringify(body)
|
|
12109
14026
|
});
|
|
12110
|
-
return
|
|
14027
|
+
return parse3(response);
|
|
12111
14028
|
}
|
|
12112
14029
|
async function getJson(fetchFn, url, headers) {
|
|
12113
|
-
return
|
|
14030
|
+
return parse3(await fetchFn(url, { headers }));
|
|
12114
14031
|
}
|
|
12115
14032
|
|
|
12116
14033
|
// ../provider-aws/src/sso/portal.ts
|
|
@@ -12160,7 +14077,7 @@ var PortalClient = class {
|
|
|
12160
14077
|
};
|
|
12161
14078
|
|
|
12162
14079
|
// ../provider-aws/src/sso/sso-login.ts
|
|
12163
|
-
import { createHash as
|
|
14080
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
|
|
12164
14081
|
|
|
12165
14082
|
// ../provider-aws/src/sso/loopback.ts
|
|
12166
14083
|
import { createServer as createServer2 } from "http";
|
|
@@ -12293,24 +14210,24 @@ var OidcClient = class {
|
|
|
12293
14210
|
};
|
|
12294
14211
|
|
|
12295
14212
|
// ../provider-aws/src/sso/token-cache.ts
|
|
12296
|
-
import { createHash } from "crypto";
|
|
12297
|
-
import { mkdir as
|
|
12298
|
-
import { join as
|
|
14213
|
+
import { createHash as createHash2 } from "crypto";
|
|
14214
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
14215
|
+
import { join as join6 } from "path";
|
|
12299
14216
|
var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
|
|
12300
14217
|
function tokenCachePath(awsDir, sessionName) {
|
|
12301
|
-
const hash =
|
|
12302
|
-
return
|
|
14218
|
+
const hash = createHash2("sha1").update(sessionName).digest("hex");
|
|
14219
|
+
return join6(awsDir, "sso", "cache", `${hash}.json`);
|
|
12303
14220
|
}
|
|
12304
14221
|
async function readToken(awsDir, sessionName) {
|
|
12305
14222
|
try {
|
|
12306
|
-
return JSON.parse(await
|
|
14223
|
+
return JSON.parse(await readFile6(tokenCachePath(awsDir, sessionName), "utf8"));
|
|
12307
14224
|
} catch {
|
|
12308
14225
|
return void 0;
|
|
12309
14226
|
}
|
|
12310
14227
|
}
|
|
12311
14228
|
async function writeToken(awsDir, sessionName, token) {
|
|
12312
|
-
await
|
|
12313
|
-
await
|
|
14229
|
+
await mkdir5(join6(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
|
|
14230
|
+
await writeFile5(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
|
|
12314
14231
|
mode: 384
|
|
12315
14232
|
});
|
|
12316
14233
|
}
|
|
@@ -12353,7 +14270,7 @@ async function authorizationCodeLogin(options) {
|
|
|
12353
14270
|
clientId: client.clientId,
|
|
12354
14271
|
redirectUri: server.redirectUri,
|
|
12355
14272
|
state,
|
|
12356
|
-
codeChallenge: base64url(
|
|
14273
|
+
codeChallenge: base64url(createHash3("sha256").update(codeVerifier).digest())
|
|
12357
14274
|
});
|
|
12358
14275
|
options.interaction.notify("Opening your browser to log in\u2026");
|
|
12359
14276
|
options.interaction.notify(`If it doesn't open, visit: ${url}`);
|
|
@@ -12449,6 +14366,42 @@ var CONSOLE_LOGIN_KIND = "console-login";
|
|
|
12449
14366
|
var CONSOLE_TARGET = { id: "console", label: "Console sign-in", attributes: {} };
|
|
12450
14367
|
var ssoSessionName = (sourceId) => `cloudhop-${sourceId}`;
|
|
12451
14368
|
var consoleProfileName = (sourceId) => `cloudhop-${sourceId}`;
|
|
14369
|
+
var GLOBAL_WITH_VALUE = /* @__PURE__ */ new Set([
|
|
14370
|
+
"--region",
|
|
14371
|
+
"--output",
|
|
14372
|
+
"--profile",
|
|
14373
|
+
"--endpoint-url",
|
|
14374
|
+
"--query",
|
|
14375
|
+
"--color",
|
|
14376
|
+
"--ca-bundle",
|
|
14377
|
+
"--cli-read-timeout",
|
|
14378
|
+
"--cli-connect-timeout",
|
|
14379
|
+
"--cli-binary-format"
|
|
14380
|
+
]);
|
|
14381
|
+
var READ_VERBS = /^(describe|list|get|head|lookup|search|scan|query|select|filter|tail|batch-get|check|preview|simulate|validate|estimate|test|wait|help|download|ls|presign)(-|$)/;
|
|
14382
|
+
function isAwsReadOnly(args) {
|
|
14383
|
+
if (args.includes("--dryrun") || args.includes("--dry-run") || args.includes("help")) return true;
|
|
14384
|
+
const words = [];
|
|
14385
|
+
for (let i2 = 0; i2 < args.length; i2++) {
|
|
14386
|
+
const arg = args[i2];
|
|
14387
|
+
if (arg.startsWith("--")) {
|
|
14388
|
+
if (GLOBAL_WITH_VALUE.has(arg) && words.length < 2) i2++;
|
|
14389
|
+
continue;
|
|
14390
|
+
}
|
|
14391
|
+
words.push(arg);
|
|
14392
|
+
}
|
|
14393
|
+
const [service, operation] = words;
|
|
14394
|
+
if (!service || !operation) return true;
|
|
14395
|
+
if (service === "sts" || service === "configure") return true;
|
|
14396
|
+
if (service === "s3") {
|
|
14397
|
+
if (operation === "cp" || operation === "sync") {
|
|
14398
|
+
const destination = words[words.length - 1] ?? "";
|
|
14399
|
+
return !destination.startsWith("s3://");
|
|
14400
|
+
}
|
|
14401
|
+
return operation === "ls" || operation === "presign";
|
|
14402
|
+
}
|
|
14403
|
+
return READ_VERBS.test(operation);
|
|
14404
|
+
}
|
|
12452
14405
|
var AwsProvider = class {
|
|
12453
14406
|
constructor(options) {
|
|
12454
14407
|
this.options = options;
|
|
@@ -12458,6 +14411,7 @@ var AwsProvider = class {
|
|
|
12458
14411
|
id = "aws";
|
|
12459
14412
|
displayName = "AWS";
|
|
12460
14413
|
cli = { command: "aws", displayName: "AWS CLI v2", minVersion: "2.32.0" };
|
|
14414
|
+
identityArgs = ["sts", "get-caller-identity"];
|
|
12461
14415
|
sourceKinds = [
|
|
12462
14416
|
{
|
|
12463
14417
|
kind: SSO_KIND,
|
|
@@ -12566,7 +14520,7 @@ var AwsProvider = class {
|
|
|
12566
14520
|
}
|
|
12567
14521
|
});
|
|
12568
14522
|
}
|
|
12569
|
-
await writeManagedBlock(
|
|
14523
|
+
await writeManagedBlock(join7(this.options.awsDir, "config"), sections);
|
|
12570
14524
|
}
|
|
12571
14525
|
async processEnv(resolved, mode) {
|
|
12572
14526
|
const region = resolved.region;
|
|
@@ -12582,6 +14536,25 @@ var AwsProvider = class {
|
|
|
12582
14536
|
const credentials = resolved.source.kind === CONSOLE_LOGIN_KIND ? await this.exportedCredentials(profile) : await this.roleCredentials(resolved);
|
|
12583
14537
|
return { AWS_PROFILE: void 0, ...credentials, ...regionEnv };
|
|
12584
14538
|
}
|
|
14539
|
+
async consoleUrl(resolved, service) {
|
|
14540
|
+
const destination = consoleDestination(resolved.region, service);
|
|
14541
|
+
const sso = resolved.source.kind !== CONSOLE_LOGIN_KIND;
|
|
14542
|
+
const credentials = sso ? await this.roleCredentials(resolved) : await this.exportedCredentials(consoleProfileName(resolved.source.id)).catch(() => ({}));
|
|
14543
|
+
const url = await federatedConsoleUrl(credentials, destination, resolved.region, this.fetchFn);
|
|
14544
|
+
if (url) return { url, via: "signed-in" };
|
|
14545
|
+
const attrs = resolved.target?.attributes ?? {};
|
|
14546
|
+
const startUrl = resolved.source.settings.startUrl;
|
|
14547
|
+
if (sso && startUrl && attrs.accountId && attrs.roleName) {
|
|
14548
|
+
return {
|
|
14549
|
+
url: portalShortcutUrl(startUrl, attrs.accountId, attrs.roleName, destination),
|
|
14550
|
+
via: "portal"
|
|
14551
|
+
};
|
|
14552
|
+
}
|
|
14553
|
+
return { url: destination, via: "sign-in-page" };
|
|
14554
|
+
}
|
|
14555
|
+
isReadOnly(args) {
|
|
14556
|
+
return isAwsReadOnly(args);
|
|
14557
|
+
}
|
|
12585
14558
|
isSessionError(output) {
|
|
12586
14559
|
return SESSION_ERROR_PATTERNS.some((pattern) => pattern.test(output));
|
|
12587
14560
|
}
|
|
@@ -12629,9 +14602,10 @@ var AwsProvider = class {
|
|
|
12629
14602
|
};
|
|
12630
14603
|
|
|
12631
14604
|
// src/container.ts
|
|
14605
|
+
var browser = new NodeBrowserOpener();
|
|
12632
14606
|
function createApp(env = process.env) {
|
|
12633
14607
|
const runner = new NodeProcessRunner();
|
|
12634
|
-
const awsDir = env.CLOUDHOP_AWS_DIR ??
|
|
14608
|
+
const awsDir = env.CLOUDHOP_AWS_DIR ?? join8(homedir4(), ".aws");
|
|
12635
14609
|
return new CloudhopApp({
|
|
12636
14610
|
config: new YamlConfigStore(),
|
|
12637
14611
|
providers: [
|
|
@@ -12643,18 +14617,72 @@ function createApp(env = process.env) {
|
|
|
12643
14617
|
// Azure and Google Cloud providers plug in here.
|
|
12644
14618
|
],
|
|
12645
14619
|
runner,
|
|
12646
|
-
browser
|
|
14620
|
+
browser,
|
|
12647
14621
|
installer: new SystemToolInstaller(runner),
|
|
12648
14622
|
notify
|
|
12649
14623
|
});
|
|
12650
14624
|
}
|
|
14625
|
+
function createAssistants(version, env = process.env) {
|
|
14626
|
+
return new AssistantsInstaller({
|
|
14627
|
+
places: { home: homedir4(), project: process.cwd() },
|
|
14628
|
+
configDir: defaultConfigDir(env),
|
|
14629
|
+
runner: new NodeProcessRunner(),
|
|
14630
|
+
cloudhopVersion: version
|
|
14631
|
+
});
|
|
14632
|
+
}
|
|
14633
|
+
|
|
14634
|
+
// src/update-check.ts
|
|
14635
|
+
import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
14636
|
+
import { join as join9 } from "path";
|
|
14637
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
14638
|
+
var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
|
|
14639
|
+
function startUpdateCheck(configDir, current) {
|
|
14640
|
+
const env = process.env;
|
|
14641
|
+
if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
|
|
14642
|
+
return async () => {
|
|
14643
|
+
};
|
|
14644
|
+
const file = join9(configDir, "update-check.json");
|
|
14645
|
+
const work = (async () => {
|
|
14646
|
+
let cache = { checkedAt: 0 };
|
|
14647
|
+
try {
|
|
14648
|
+
cache = JSON.parse(await readFile7(file, "utf8"));
|
|
14649
|
+
} catch {
|
|
14650
|
+
}
|
|
14651
|
+
if (cache.latest && compareVersions(cache.latest, current) > 0) {
|
|
14652
|
+
process.stderr.write(
|
|
14653
|
+
`\x1B[2mcloudhop \u203A\x1B[22m cloudhop ${cache.latest} is available (you have ${current}): npm i -g cloudhop
|
|
14654
|
+
`
|
|
14655
|
+
);
|
|
14656
|
+
}
|
|
14657
|
+
if (Date.now() - cache.checkedAt < DAY_MS) return;
|
|
14658
|
+
const response = await fetch(REGISTRY_URL, {
|
|
14659
|
+
headers: { accept: "application/json" },
|
|
14660
|
+
signal: AbortSignal.timeout(1500)
|
|
14661
|
+
});
|
|
14662
|
+
if (!response.ok) return;
|
|
14663
|
+
const { version } = await response.json();
|
|
14664
|
+
if (typeof version !== "string") return;
|
|
14665
|
+
await mkdir6(configDir, { recursive: true });
|
|
14666
|
+
await writeFile6(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
|
|
14667
|
+
})().catch(() => {
|
|
14668
|
+
});
|
|
14669
|
+
return () => Promise.race([work, new Promise((resolve) => setTimeout(resolve, 300))]);
|
|
14670
|
+
}
|
|
12651
14671
|
|
|
12652
14672
|
// src/cli.ts
|
|
12653
|
-
var HELP = `cloudhop ${"1.
|
|
14673
|
+
var HELP = `cloudhop ${"1.2.0"}
|
|
12654
14674
|
Hop between cloud accounts, roles and regions with one command.
|
|
12655
14675
|
|
|
12656
14676
|
Usage
|
|
12657
14677
|
hop <alias> [args...] Run the cloud CLI as <alias> (no args: who am I)
|
|
14678
|
+
hop Pick the alias from a list, then type the command
|
|
14679
|
+
hop - [args...] Pick the alias from a list and run [args...]
|
|
14680
|
+
cloudhop console [alias] [service] [--region <r>]
|
|
14681
|
+
Open the web console as <alias>, already signed in
|
|
14682
|
+
cloudhop each <selection...> [--source <s>] [--prod|--no-prod] [--yes] [--json] -- <args...>
|
|
14683
|
+
Run one command in many aliases (names, a,b lists, 'globex-*', /regex/)
|
|
14684
|
+
cloudhop use [alias] | --off Use an alias in this shell (aws, terraform... without hop)
|
|
14685
|
+
cloudhop shell [install] Shell integration: cloudhop use, tab completion, cloudhop_prompt
|
|
12658
14686
|
cloudhop exec <alias> [--region <r>] -- <cmd...>
|
|
12659
14687
|
Run any tool with the alias' temporary credentials
|
|
12660
14688
|
|
|
@@ -12663,16 +14691,26 @@ Setup
|
|
|
12663
14691
|
cloudhop add Add an SSO portal or a personal account
|
|
12664
14692
|
cloudhop add aws sso --start-url <url> [--region <r>] [--name <id>]
|
|
12665
14693
|
cloudhop add aws console-login [--region <r>] [--name <id>]
|
|
14694
|
+
cloudhop team export [--out <file>] | team import <file|https URL>
|
|
14695
|
+
Share portals and aliases with your team (no secrets)
|
|
12666
14696
|
cloudhop alias [source] Create more aliases from a source
|
|
12667
14697
|
cloudhop alias <source> --list | --target <id> [--name <alias>] [--region <r>]
|
|
14698
|
+
cloudhop mv <alias> <new-name> Rename an alias
|
|
14699
|
+
cloudhop prod [alias...] [--off]
|
|
14700
|
+
Mark aliases as production (asks before changes)
|
|
12668
14701
|
cloudhop region <alias> [region]
|
|
12669
14702
|
Change the region an alias runs in
|
|
12670
14703
|
cloudhop login [source] Log in again and refresh its accounts
|
|
12671
14704
|
cloudhop sync [source] Refresh the accounts/roles you have access to
|
|
12672
|
-
cloudhop ls [source]
|
|
14705
|
+
cloudhop ls [source] [--json] List sources and their aliases
|
|
14706
|
+
cloudhop status [alias] [--json]
|
|
14707
|
+
Can it run now, or will it need a sign-in?
|
|
12673
14708
|
cloudhop rm <name...> [--yes] Remove aliases or sources (a source takes its aliases)
|
|
12674
14709
|
cloudhop provider [ls|enable|disable] <provider>
|
|
12675
14710
|
cloudhop doctor Check (and install) the cloud CLIs
|
|
14711
|
+
cloudhop agent [status|install|update|remove] [--tools <list>] [--scope user|project] [--no-mcp]
|
|
14712
|
+
Teach Claude Code, Kiro, Copilot, Cursor or Codex to use cloudhop
|
|
14713
|
+
cloudhop mcp Model Context Protocol server for AI assistants (stdio)
|
|
12676
14714
|
cloudhop ui [--port <n>] [--no-open]
|
|
12677
14715
|
Manage sources and aliases in a local web page
|
|
12678
14716
|
|
|
@@ -12691,19 +14729,67 @@ function takeGlobalFlags(argv) {
|
|
|
12691
14729
|
}
|
|
12692
14730
|
async function main(argv) {
|
|
12693
14731
|
const [command, ...args] = argv;
|
|
14732
|
+
const invokedAsHop = /^hop(\.js|\.cmd)?$/i.test(basename(process.argv[1] ?? ""));
|
|
14733
|
+
if (!command && invokedAsHop && interactive() || command === "-") {
|
|
14734
|
+
const app2 = createApp();
|
|
14735
|
+
const alias = await pickAlias(app2);
|
|
14736
|
+
let rest = args;
|
|
14737
|
+
if (!command) {
|
|
14738
|
+
const typed = answer(
|
|
14739
|
+
await dist_exports.text({
|
|
14740
|
+
message: `aws command to run as ${alias}`,
|
|
14741
|
+
placeholder: "empty = who am I, e.g. s3 ls"
|
|
14742
|
+
})
|
|
14743
|
+
).trim();
|
|
14744
|
+
rest = typed ? (typed.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^(["'])(.*)\1$/, "$2")) : [];
|
|
14745
|
+
}
|
|
14746
|
+
return proxyCommand(app2, alias, rest);
|
|
14747
|
+
}
|
|
12694
14748
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
12695
14749
|
console.log(HELP);
|
|
12696
14750
|
return 0;
|
|
12697
14751
|
}
|
|
12698
14752
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
12699
|
-
console.log("1.
|
|
14753
|
+
console.log("1.2.0");
|
|
12700
14754
|
return 0;
|
|
12701
14755
|
}
|
|
12702
14756
|
const app = createApp();
|
|
12703
14757
|
const local = RESERVED_NAMES.has(command) ? takeGlobalFlags(args) : args;
|
|
14758
|
+
if (command === "__complete") {
|
|
14759
|
+
await completeCommand(app, args[0]);
|
|
14760
|
+
return 0;
|
|
14761
|
+
}
|
|
14762
|
+
if (command === "shell") {
|
|
14763
|
+
await shellCommand(local);
|
|
14764
|
+
return 0;
|
|
14765
|
+
}
|
|
14766
|
+
if (command === "mcp") {
|
|
14767
|
+
await serveMcp({ app, version: "1.2.0" });
|
|
14768
|
+
return 0;
|
|
14769
|
+
}
|
|
14770
|
+
const assistants = createAssistants("1.2.0");
|
|
14771
|
+
await refreshAssistantSkills(assistants, command);
|
|
14772
|
+
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.2.0");
|
|
14773
|
+
try {
|
|
14774
|
+
return await dispatch(app, assistants, command, args, local);
|
|
14775
|
+
} finally {
|
|
14776
|
+
await finishUpdateCheck();
|
|
14777
|
+
}
|
|
14778
|
+
}
|
|
14779
|
+
async function refreshAssistantSkills(assistants, command) {
|
|
14780
|
+
if (command === "agent") return;
|
|
14781
|
+
try {
|
|
14782
|
+
const { updated } = await assistants.update();
|
|
14783
|
+
if (updated.length > 0) {
|
|
14784
|
+
notify(`Updated the cloudhop skill for your AI assistants (${updated.length} file(s)).`);
|
|
14785
|
+
}
|
|
14786
|
+
} catch {
|
|
14787
|
+
}
|
|
14788
|
+
}
|
|
14789
|
+
async function dispatch(app, assistants, command, args, local) {
|
|
12704
14790
|
switch (command) {
|
|
12705
14791
|
case "setup":
|
|
12706
|
-
await setupCommand(app);
|
|
14792
|
+
await setupCommand(app, assistants);
|
|
12707
14793
|
return 0;
|
|
12708
14794
|
case "add":
|
|
12709
14795
|
await addCommand(app, parseArgs(local));
|
|
@@ -12711,6 +14797,16 @@ async function main(argv) {
|
|
|
12711
14797
|
case "alias":
|
|
12712
14798
|
await aliasCommand(app, parseArgs(local));
|
|
12713
14799
|
return 0;
|
|
14800
|
+
case "status":
|
|
14801
|
+
await statusCommand(app, local);
|
|
14802
|
+
return 0;
|
|
14803
|
+
case "prod":
|
|
14804
|
+
await prodCommand(app, local);
|
|
14805
|
+
return 0;
|
|
14806
|
+
case "mv":
|
|
14807
|
+
case "rename":
|
|
14808
|
+
await renameCommand(app, local);
|
|
14809
|
+
return 0;
|
|
12714
14810
|
case "region":
|
|
12715
14811
|
await regionCommand(app, local);
|
|
12716
14812
|
return 0;
|
|
@@ -12722,7 +14818,7 @@ async function main(argv) {
|
|
|
12722
14818
|
return 0;
|
|
12723
14819
|
case "ls":
|
|
12724
14820
|
case "list":
|
|
12725
|
-
await listCommand(app, local
|
|
14821
|
+
await listCommand(app, local);
|
|
12726
14822
|
return 0;
|
|
12727
14823
|
case "rm":
|
|
12728
14824
|
case "remove":
|
|
@@ -12736,8 +14832,22 @@ async function main(argv) {
|
|
|
12736
14832
|
return 0;
|
|
12737
14833
|
case "exec":
|
|
12738
14834
|
return execCommand(app, local);
|
|
14835
|
+
case "agent":
|
|
14836
|
+
await agentCommand(assistants, local);
|
|
14837
|
+
return 0;
|
|
14838
|
+
case "team":
|
|
14839
|
+
await teamCommand(app, local);
|
|
14840
|
+
return 0;
|
|
14841
|
+
case "use":
|
|
14842
|
+
await useCommand(app, local);
|
|
14843
|
+
return 0;
|
|
14844
|
+
case "each":
|
|
14845
|
+
return eachCommand(app, args);
|
|
14846
|
+
case "console":
|
|
14847
|
+
await consoleCommand(app, local);
|
|
14848
|
+
return 0;
|
|
12739
14849
|
case "ui":
|
|
12740
|
-
await uiCommand(app, parseArgs(local));
|
|
14850
|
+
await uiCommand(app, assistants, parseArgs(local));
|
|
12741
14851
|
return 0;
|
|
12742
14852
|
default:
|
|
12743
14853
|
return proxyCommand(app, command, args);
|