cloudhop 1.1.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 +7 -0
- package/dist/cli.js +1137 -93
- package/package.json +5 -4
package/dist/cli.js
CHANGED
|
@@ -1511,7 +1511,7 @@ var require_stringify = __commonJS({
|
|
|
1511
1511
|
props.push(doc.directives.tagString(tag));
|
|
1512
1512
|
return props.join(" ");
|
|
1513
1513
|
}
|
|
1514
|
-
function
|
|
1514
|
+
function stringify3(item, ctx, onComment, onChompKeep) {
|
|
1515
1515
|
if (identity.isPair(item))
|
|
1516
1516
|
return item.toString(ctx, onComment, onChompKeep);
|
|
1517
1517
|
if (identity.isAlias(item)) {
|
|
@@ -1540,7 +1540,7 @@ var require_stringify = __commonJS({
|
|
|
1540
1540
|
${ctx.indent}${str}`;
|
|
1541
1541
|
}
|
|
1542
1542
|
exports.createStringifyContext = createStringifyContext;
|
|
1543
|
-
exports.stringify =
|
|
1543
|
+
exports.stringify = stringify3;
|
|
1544
1544
|
}
|
|
1545
1545
|
});
|
|
1546
1546
|
|
|
@@ -1550,7 +1550,7 @@ var require_stringifyPair = __commonJS({
|
|
|
1550
1550
|
"use strict";
|
|
1551
1551
|
var identity = require_identity();
|
|
1552
1552
|
var Scalar = require_Scalar();
|
|
1553
|
-
var
|
|
1553
|
+
var stringify3 = require_stringify();
|
|
1554
1554
|
var stringifyComment = require_stringifyComment();
|
|
1555
1555
|
function stringifyPair({ key, value }, ctx, onComment, onChompKeep) {
|
|
1556
1556
|
const { allNullValues, doc, indent, indentStep, options: { commentString, indentSeq, simpleKeys } } = ctx;
|
|
@@ -1572,7 +1572,7 @@ var require_stringifyPair = __commonJS({
|
|
|
1572
1572
|
});
|
|
1573
1573
|
let keyCommentDone = false;
|
|
1574
1574
|
let chompKeep = false;
|
|
1575
|
-
let str =
|
|
1575
|
+
let str = stringify3.stringify(key, ctx, () => keyCommentDone = true, () => chompKeep = true);
|
|
1576
1576
|
if (!explicitKey && !ctx.inFlow && str.length > 1024) {
|
|
1577
1577
|
if (simpleKeys)
|
|
1578
1578
|
throw new Error("With simple keys, single line scalar must not span more than 1024 characters");
|
|
@@ -1624,7 +1624,7 @@ ${indent}:`;
|
|
|
1624
1624
|
ctx.indent = ctx.indent.substring(2);
|
|
1625
1625
|
}
|
|
1626
1626
|
let valueCommentDone = false;
|
|
1627
|
-
const valueStr =
|
|
1627
|
+
const valueStr = stringify3.stringify(value, ctx, () => valueCommentDone = true, () => chompKeep = true);
|
|
1628
1628
|
let ws = " ";
|
|
1629
1629
|
if (keyComment || vsb || vcb) {
|
|
1630
1630
|
ws = vsb ? "\n" : "";
|
|
@@ -1765,7 +1765,7 @@ var require_addPairToJSMap = __commonJS({
|
|
|
1765
1765
|
"use strict";
|
|
1766
1766
|
var log2 = require_log();
|
|
1767
1767
|
var merge = require_merge();
|
|
1768
|
-
var
|
|
1768
|
+
var stringify3 = require_stringify();
|
|
1769
1769
|
var identity = require_identity();
|
|
1770
1770
|
var toJS = require_toJS();
|
|
1771
1771
|
function addPairToJSMap(ctx, map, { key, value }) {
|
|
@@ -1801,7 +1801,7 @@ var require_addPairToJSMap = __commonJS({
|
|
|
1801
1801
|
if (typeof jsKey !== "object")
|
|
1802
1802
|
return String(jsKey);
|
|
1803
1803
|
if (identity.isNode(key) && ctx?.doc) {
|
|
1804
|
-
const strCtx =
|
|
1804
|
+
const strCtx = stringify3.createStringifyContext(ctx.doc, {});
|
|
1805
1805
|
strCtx.anchors = /* @__PURE__ */ new Set();
|
|
1806
1806
|
for (const node of ctx.anchors.keys())
|
|
1807
1807
|
strCtx.anchors.add(node.anchor);
|
|
@@ -1868,12 +1868,12 @@ var require_stringifyCollection = __commonJS({
|
|
|
1868
1868
|
"../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/stringify/stringifyCollection.js"(exports) {
|
|
1869
1869
|
"use strict";
|
|
1870
1870
|
var identity = require_identity();
|
|
1871
|
-
var
|
|
1871
|
+
var stringify3 = require_stringify();
|
|
1872
1872
|
var stringifyComment = require_stringifyComment();
|
|
1873
1873
|
function stringifyCollection(collection, ctx, options) {
|
|
1874
1874
|
const flow = ctx.inFlow ?? collection.flow;
|
|
1875
|
-
const
|
|
1876
|
-
return
|
|
1875
|
+
const stringify4 = flow ? stringifyFlowCollection : stringifyBlockCollection;
|
|
1876
|
+
return stringify4(collection, ctx, options);
|
|
1877
1877
|
}
|
|
1878
1878
|
function stringifyBlockCollection({ comment, items }, ctx, { blockItemPrefix, flowChars, itemIndent, onChompKeep, onComment }) {
|
|
1879
1879
|
const { indent, options: { commentString } } = ctx;
|
|
@@ -1898,7 +1898,7 @@ var require_stringifyCollection = __commonJS({
|
|
|
1898
1898
|
}
|
|
1899
1899
|
}
|
|
1900
1900
|
chompKeep = false;
|
|
1901
|
-
let str2 =
|
|
1901
|
+
let str2 = stringify3.stringify(item, itemCtx, () => comment2 = null, () => chompKeep = true);
|
|
1902
1902
|
if (comment2)
|
|
1903
1903
|
str2 += stringifyComment.lineComment(str2, itemIndent, commentString(comment2));
|
|
1904
1904
|
if (chompKeep && comment2)
|
|
@@ -1965,7 +1965,7 @@ ${indent}${line}` : "\n";
|
|
|
1965
1965
|
}
|
|
1966
1966
|
if (comment)
|
|
1967
1967
|
reqNewline = true;
|
|
1968
|
-
let str =
|
|
1968
|
+
let str = stringify3.stringify(item, itemCtx, () => comment = null);
|
|
1969
1969
|
reqNewline || (reqNewline = lines.length > linesAtValue || str.includes("\n"));
|
|
1970
1970
|
if (i2 < items.length - 1) {
|
|
1971
1971
|
str += ",";
|
|
@@ -3326,7 +3326,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3326
3326
|
"../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/stringify/stringifyDocument.js"(exports) {
|
|
3327
3327
|
"use strict";
|
|
3328
3328
|
var identity = require_identity();
|
|
3329
|
-
var
|
|
3329
|
+
var stringify3 = require_stringify();
|
|
3330
3330
|
var stringifyComment = require_stringifyComment();
|
|
3331
3331
|
function stringifyDocument(doc, options) {
|
|
3332
3332
|
const lines = [];
|
|
@@ -3341,7 +3341,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3341
3341
|
}
|
|
3342
3342
|
if (hasDirectives)
|
|
3343
3343
|
lines.push("---");
|
|
3344
|
-
const ctx =
|
|
3344
|
+
const ctx = stringify3.createStringifyContext(doc, options);
|
|
3345
3345
|
const { commentString } = ctx.options;
|
|
3346
3346
|
if (doc.commentBefore) {
|
|
3347
3347
|
if (lines.length !== 1)
|
|
@@ -3363,7 +3363,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3363
3363
|
contentComment = doc.contents.comment;
|
|
3364
3364
|
}
|
|
3365
3365
|
const onChompKeep = contentComment ? void 0 : () => chompKeep = true;
|
|
3366
|
-
let body =
|
|
3366
|
+
let body = stringify3.stringify(doc.contents, ctx, () => contentComment = null, onChompKeep);
|
|
3367
3367
|
if (contentComment)
|
|
3368
3368
|
body += stringifyComment.lineComment(body, "", commentString(contentComment));
|
|
3369
3369
|
if ((body[0] === "|" || body[0] === ">") && lines[lines.length - 1] === "---") {
|
|
@@ -3371,7 +3371,7 @@ var require_stringifyDocument = __commonJS({
|
|
|
3371
3371
|
} else
|
|
3372
3372
|
lines.push(body);
|
|
3373
3373
|
} else {
|
|
3374
|
-
lines.push(
|
|
3374
|
+
lines.push(stringify3.stringify(doc.contents, ctx));
|
|
3375
3375
|
}
|
|
3376
3376
|
if (doc.directives?.docEnd) {
|
|
3377
3377
|
if (doc.comment) {
|
|
@@ -5507,7 +5507,7 @@ var require_cst_scalar = __commonJS({
|
|
|
5507
5507
|
var require_cst_stringify = __commonJS({
|
|
5508
5508
|
"../../node_modules/.pnpm/yaml@2.9.1/node_modules/yaml/dist/parse/cst-stringify.js"(exports) {
|
|
5509
5509
|
"use strict";
|
|
5510
|
-
var
|
|
5510
|
+
var stringify3 = (cst) => "type" in cst ? stringifyToken(cst) : stringifyItem(cst);
|
|
5511
5511
|
function stringifyToken(token) {
|
|
5512
5512
|
switch (token.type) {
|
|
5513
5513
|
case "block-scalar": {
|
|
@@ -5560,7 +5560,7 @@ var require_cst_stringify = __commonJS({
|
|
|
5560
5560
|
res += stringifyToken(value);
|
|
5561
5561
|
return res;
|
|
5562
5562
|
}
|
|
5563
|
-
exports.stringify =
|
|
5563
|
+
exports.stringify = stringify3;
|
|
5564
5564
|
}
|
|
5565
5565
|
});
|
|
5566
5566
|
|
|
@@ -6046,9 +6046,9 @@ var require_lexer = __commonJS({
|
|
|
6046
6046
|
}
|
|
6047
6047
|
}
|
|
6048
6048
|
*parseQuotedScalar() {
|
|
6049
|
-
const
|
|
6050
|
-
let end = this.buffer.indexOf(
|
|
6051
|
-
if (
|
|
6049
|
+
const quote2 = this.charAt(0);
|
|
6050
|
+
let end = this.buffer.indexOf(quote2, this.pos + 1);
|
|
6051
|
+
if (quote2 === "'") {
|
|
6052
6052
|
while (end !== -1 && this.buffer[end + 1] === "'")
|
|
6053
6053
|
end = this.buffer.indexOf("'", end + 2);
|
|
6054
6054
|
} else {
|
|
@@ -7271,7 +7271,7 @@ var require_public_api = __commonJS({
|
|
|
7271
7271
|
}
|
|
7272
7272
|
return doc;
|
|
7273
7273
|
}
|
|
7274
|
-
function
|
|
7274
|
+
function parse4(src, reviver, options) {
|
|
7275
7275
|
let _reviver = void 0;
|
|
7276
7276
|
if (typeof reviver === "function") {
|
|
7277
7277
|
_reviver = reviver;
|
|
@@ -7290,7 +7290,7 @@ var require_public_api = __commonJS({
|
|
|
7290
7290
|
}
|
|
7291
7291
|
return doc.toJS(Object.assign({ reviver: _reviver }, options));
|
|
7292
7292
|
}
|
|
7293
|
-
function
|
|
7293
|
+
function stringify3(value, replacer, options) {
|
|
7294
7294
|
let _replacer = null;
|
|
7295
7295
|
if (typeof replacer === "function" || Array.isArray(replacer)) {
|
|
7296
7296
|
_replacer = replacer;
|
|
@@ -7312,10 +7312,10 @@ var require_public_api = __commonJS({
|
|
|
7312
7312
|
return value.toString(options);
|
|
7313
7313
|
return new Document.Document(value, _replacer, options).toString(options);
|
|
7314
7314
|
}
|
|
7315
|
-
exports.parse =
|
|
7315
|
+
exports.parse = parse4;
|
|
7316
7316
|
exports.parseAllDocuments = parseAllDocuments;
|
|
7317
7317
|
exports.parseDocument = parseDocument;
|
|
7318
|
-
exports.stringify =
|
|
7318
|
+
exports.stringify = stringify3;
|
|
7319
7319
|
}
|
|
7320
7320
|
});
|
|
7321
7321
|
|
|
@@ -7427,6 +7427,9 @@ var require_src = __commonJS({
|
|
|
7427
7427
|
}
|
|
7428
7428
|
});
|
|
7429
7429
|
|
|
7430
|
+
// src/cli.ts
|
|
7431
|
+
import { basename } from "path";
|
|
7432
|
+
|
|
7430
7433
|
// ../adapters-node/src/node-browser-opener.ts
|
|
7431
7434
|
import { spawn } from "child_process";
|
|
7432
7435
|
var NodeBrowserOpener = class {
|
|
@@ -7602,6 +7605,14 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
|
7602
7605
|
"agent",
|
|
7603
7606
|
"alias",
|
|
7604
7607
|
"login",
|
|
7608
|
+
"team",
|
|
7609
|
+
"shell",
|
|
7610
|
+
"use",
|
|
7611
|
+
"each",
|
|
7612
|
+
"console",
|
|
7613
|
+
"prod",
|
|
7614
|
+
"rename",
|
|
7615
|
+
"mv",
|
|
7605
7616
|
"mcp",
|
|
7606
7617
|
"region",
|
|
7607
7618
|
"setup",
|
|
@@ -7639,6 +7650,56 @@ function suggestAliasName(...parts) {
|
|
|
7639
7650
|
const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
7640
7651
|
return slug || "alias";
|
|
7641
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
|
+
}
|
|
7642
7703
|
|
|
7643
7704
|
// ../core/src/domain/version.ts
|
|
7644
7705
|
function compareVersions(a2, b3) {
|
|
@@ -7868,6 +7929,7 @@ var CloudhopApp = class {
|
|
|
7868
7929
|
available: Boolean(resolved.target),
|
|
7869
7930
|
region: resolved.region,
|
|
7870
7931
|
attributes: resolved.target?.attributes ?? {},
|
|
7932
|
+
prod: alias.prod === true,
|
|
7871
7933
|
session: source.session,
|
|
7872
7934
|
next: source.next
|
|
7873
7935
|
};
|
|
@@ -7888,6 +7950,30 @@ var CloudhopApp = class {
|
|
|
7888
7950
|
}
|
|
7889
7951
|
return status;
|
|
7890
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
|
+
}
|
|
7891
7977
|
/** Changes an alias' region; matching the source's region clears the override. */
|
|
7892
7978
|
async setAliasRegion(name, region) {
|
|
7893
7979
|
const config = await this.deps.config.load();
|
|
@@ -7930,6 +8016,49 @@ ${result.stderr}`)) {
|
|
|
7930
8016
|
}
|
|
7931
8017
|
return result;
|
|
7932
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
|
+
}
|
|
7933
8062
|
/** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
|
|
7934
8063
|
identityArgs(name) {
|
|
7935
8064
|
return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
|
|
@@ -8260,6 +8389,10 @@ function createTools(app) {
|
|
|
8260
8389
|
region: {
|
|
8261
8390
|
type: "string",
|
|
8262
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."
|
|
8263
8396
|
}
|
|
8264
8397
|
},
|
|
8265
8398
|
required: ["alias", "args"],
|
|
@@ -8288,6 +8421,14 @@ function createTools(app) {
|
|
|
8288
8421
|
);
|
|
8289
8422
|
}
|
|
8290
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
|
+
}
|
|
8291
8432
|
const result = await app.captureAsAlias(alias, cliArgs, region ? { region } : {});
|
|
8292
8433
|
return {
|
|
8293
8434
|
alias,
|
|
@@ -8303,7 +8444,7 @@ function createTools(app) {
|
|
|
8303
8444
|
// ../mcp/src/server.ts
|
|
8304
8445
|
var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
|
|
8305
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.
|
|
8306
|
-
Use run_cloud_cli for cloud CLI commands, never --profile. Read-only commands may run once the alias is chosen; before anything that creates, changes or deletes resources, show the alias, account, region and exact command, and wait for the user's explicit OK.
|
|
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.
|
|
8307
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.`;
|
|
8308
8449
|
var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
8309
8450
|
function serveMcp({
|
|
@@ -8404,7 +8545,17 @@ function serveMcp({
|
|
|
8404
8545
|
}
|
|
8405
8546
|
|
|
8406
8547
|
// src/flags.ts
|
|
8407
|
-
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
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
|
+
]);
|
|
8408
8559
|
var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
|
|
8409
8560
|
function parseArgs(argv) {
|
|
8410
8561
|
const positional = [];
|
|
@@ -11362,14 +11513,12 @@ async function chooseAliases(app, source, targets) {
|
|
|
11362
11513
|
);
|
|
11363
11514
|
const validateRegion2 = regionValidator(app, source);
|
|
11364
11515
|
let defaultRegion = source.settings.region ?? "";
|
|
11516
|
+
const suggestions = suggestAliases(targets, source.id, existing);
|
|
11365
11517
|
const aliases = [];
|
|
11366
11518
|
for (const target of selected) {
|
|
11367
11519
|
dist_exports.log.step(target.label);
|
|
11368
11520
|
const suggestion = uniqueName(
|
|
11369
|
-
targets.length === 1 && target.id === "console" ? source.id : suggestAliasName(
|
|
11370
|
-
target.attributes.accountName ?? target.label,
|
|
11371
|
-
target.attributes.roleName ?? ""
|
|
11372
|
-
),
|
|
11521
|
+
targets.length === 1 && target.id === "console" ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
|
|
11373
11522
|
existing
|
|
11374
11523
|
);
|
|
11375
11524
|
const name = answer(
|
|
@@ -11388,13 +11537,20 @@ async function chooseAliases(app, source, targets) {
|
|
|
11388
11537
|
validate: (v) => v?.trim() ? validateRegion2?.(v.trim()) : void 0
|
|
11389
11538
|
})
|
|
11390
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
|
+
);
|
|
11391
11546
|
defaultRegion = region;
|
|
11392
11547
|
existing.add(name);
|
|
11393
11548
|
aliases.push({
|
|
11394
11549
|
name,
|
|
11395
11550
|
source: source.id,
|
|
11396
11551
|
target: target.id,
|
|
11397
|
-
...region && region !== source.settings.region ? { region } : {}
|
|
11552
|
+
...region && region !== source.settings.region ? { region } : {},
|
|
11553
|
+
...prod ? { prod: true } : {}
|
|
11398
11554
|
});
|
|
11399
11555
|
}
|
|
11400
11556
|
return aliases;
|
|
@@ -11486,14 +11642,20 @@ async function aliasCommand(app, { positional, flags }) {
|
|
|
11486
11642
|
`See the options with \`cloudhop alias ${id} --list\`.`
|
|
11487
11643
|
);
|
|
11488
11644
|
}
|
|
11489
|
-
const
|
|
11490
|
-
|
|
11491
|
-
target.attributes.roleName ?? ""
|
|
11492
|
-
);
|
|
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;
|
|
11493
11647
|
await app.addAliases([
|
|
11494
|
-
{
|
|
11648
|
+
{
|
|
11649
|
+
name,
|
|
11650
|
+
source: id,
|
|
11651
|
+
target: target.id,
|
|
11652
|
+
...flags.region ? { region: flags.region } : {},
|
|
11653
|
+
...flags.prod ? { prod: true } : {}
|
|
11654
|
+
}
|
|
11495
11655
|
]);
|
|
11496
|
-
console.log(
|
|
11656
|
+
console.log(
|
|
11657
|
+
`Alias "${name}" \u2192 ${target.label}${flags.prod ? " (production)" : ""}. Try: hop ${name}`
|
|
11658
|
+
);
|
|
11497
11659
|
return;
|
|
11498
11660
|
}
|
|
11499
11661
|
if (scripted) {
|
|
@@ -11517,7 +11679,7 @@ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, r
|
|
|
11517
11679
|
import { dirname as dirname2, join as join4 } from "path";
|
|
11518
11680
|
|
|
11519
11681
|
// ../assistants/src/assets/skill.md
|
|
11520
|
-
var skill_default = "---\nname: cloudhop\ndescription: Run AWS CLI commands and other cloud tools as a specific account, role and region through cloudhop aliases (the `hop` and `cloudhop` commands). Use whenever the user asks to do anything in their AWS accounts, inspect or change cloud resources, run aws/terraform/cdk against an account, list their cloud accounts or aliases, or mentions hop or cloudhop.\n---\n\n# Working with cloud accounts through cloudhop\n\nThe user manages their cloud access with **cloudhop**. Each **alias** (for\nexample `acme-dev`) points to one account, one role and one region. cloudhop\nsigns the user in through their browser only when a session has expired. There\nare no access keys: never ask for, create, print or store credentials.\n\n## 1. Pick the alias with the user, every time\n\nBefore the first cloud operation in a conversation:\n\n1. List the aliases. Use the `list_aliases` tool if the cloudhop MCP server is\n connected; otherwise run `cloudhop ls --json`.\n2. Show the user the options (alias, account / role, region) and **ask which\n one to use**. Don't guess, even if one name looks right, and don't pick\n one just because there is only one: confirm it.\n3. Use that alias for the rest of the conversation. Ask again if the user\n moves to another environment, account or task.\n\n## 2. Check the session before running anything\n\nRun `cloudhop status <alias> --json` (or the `alias_status` tool) and read\n`next`:\n\n| `next` | Meaning | What to do |\n| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ready` | Session valid | Go ahead |\n| `silent-renewal` | Expired but renewable without the user | Go ahead; cloudhop renews it |\n| `browser-sign-in` | The user must sign in | Tell the user their browser will open and they need to approve, then run `cloudhop login <source>` (or the `login` tool) and wait up to 5 minutes |\n| `unknown` | Managed by the cloud CLI (personal accounts) | Go ahead; if it fails, run `cloudhop login <source>` |\n\nDon't start several cloud commands in parallel while a sign-in is pending.\n\n## 3. Run commands through the alias\n\n- **AWS CLI**: `hop <alias> <aws arguments>`, e.g. `hop acme-dev s3 ls`.\n It is exactly `aws <arguments>` with the alias' account, role and region.\n Never add `--profile`. Use `--region <r>` only to override the region once.\n With the MCP server, use `run_cloud_cli` with `args` (no program name).\n- **Who am I**: `hop <alias>` with no arguments (or the `whoami` tool).\n- **Other tools** (Terraform, CDK, SDK scripts):\n `cloudhop exec <alias> -- <command>`, e.g.\n `cloudhop exec acme-dev -- terraform plan`.\n- Prefer `--output json` and `--query` to keep output small.\n- Don't export `AWS_PROFILE`, `AWS_ACCESS_KEY_ID` or similar variables, don't\n run commands that print credentials (`env`, `printenv`, `export-credentials`)\n and never edit `~/.aws/config`, `~/.aws/credentials` or cloudhop's own files.\n\n## 4. Confirm before changing anything\n\n- Read-only commands (`list`, `describe`, `get`, `s3 ls`, `logs tail`) can run\n once the user has chosen the alias.\n- Before any command that **creates, changes, deletes or costs money**, show\n the alias, account, region and the exact command, and wait for the user's\n explicit \"yes\". One confirmation covers one command, not a series.\n-
|
|
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";
|
|
11521
11683
|
|
|
11522
11684
|
// ../assistants/src/targets.ts
|
|
11523
11685
|
import { join as join3 } from "path";
|
|
@@ -12017,6 +12179,216 @@ async function agentCommand(installer, args) {
|
|
|
12017
12179
|
}
|
|
12018
12180
|
}
|
|
12019
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
|
+
|
|
12020
12392
|
// src/commands/manage.ts
|
|
12021
12393
|
function printReport(report) {
|
|
12022
12394
|
const changes = [
|
|
@@ -12113,8 +12485,9 @@ async function listCommand(app, args = []) {
|
|
|
12113
12485
|
);
|
|
12114
12486
|
for (const { alias, target, region } of own) {
|
|
12115
12487
|
const label = target ? target.label : paint("red", `${alias.target} (no longer available)`);
|
|
12488
|
+
const tag = alias.prod ? ` ${paint("red", "PROD")}` : "";
|
|
12116
12489
|
console.log(
|
|
12117
|
-
` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}`
|
|
12490
|
+
` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}${tag}`
|
|
12118
12491
|
);
|
|
12119
12492
|
}
|
|
12120
12493
|
}
|
|
@@ -12200,16 +12573,16 @@ async function regionCommand(app, args) {
|
|
|
12200
12573
|
})
|
|
12201
12574
|
);
|
|
12202
12575
|
const current = await app.resolveAlias(name);
|
|
12203
|
-
const
|
|
12576
|
+
const validate2 = regionValidator(app, current.source);
|
|
12204
12577
|
const region = regionArg?.trim() ?? (answer(
|
|
12205
12578
|
await dist_exports.text({
|
|
12206
12579
|
message: `Region for ${name}`,
|
|
12207
12580
|
placeholder: current.region,
|
|
12208
12581
|
defaultValue: current.region,
|
|
12209
|
-
validate: (v) => v?.trim() ?
|
|
12582
|
+
validate: (v) => v?.trim() ? validate2?.(v.trim()) : void 0
|
|
12210
12583
|
})
|
|
12211
12584
|
).trim() || current.region);
|
|
12212
|
-
const problem = region ?
|
|
12585
|
+
const problem = region ? validate2?.(region) : "Say which region.";
|
|
12213
12586
|
if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
|
|
12214
12587
|
await app.setAliasRegion(name, region);
|
|
12215
12588
|
console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
|
|
@@ -12234,7 +12607,7 @@ async function statusCommand(app, args) {
|
|
|
12234
12607
|
if (flags.json) return printJson({ alias: status });
|
|
12235
12608
|
const account = status.attributes.accountId ? ` (${status.attributes.accountId})` : "";
|
|
12236
12609
|
console.log(
|
|
12237
|
-
`${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}`
|
|
12610
|
+
`${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}${status.prod ? ` ${paint("red", "PROD")}` : ""}`
|
|
12238
12611
|
);
|
|
12239
12612
|
console.log(` ${STATE_SHORT[status.session]}: ${NEXT_TEXT[status.next]}`);
|
|
12240
12613
|
if (status.next === "browser-sign-in") {
|
|
@@ -12255,10 +12628,61 @@ async function statusCommand(app, args) {
|
|
|
12255
12628
|
);
|
|
12256
12629
|
}
|
|
12257
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
|
+
}
|
|
12258
12656
|
|
|
12259
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
|
+
}
|
|
12260
12682
|
async function proxyCommand(app, alias, args) {
|
|
12261
|
-
|
|
12683
|
+
const cliArgs = args.length > 0 ? args : await app.identityArgs(alias);
|
|
12684
|
+
await guardProduction(app, alias, cliArgs);
|
|
12685
|
+
return app.runAsAlias(alias, cliArgs);
|
|
12262
12686
|
}
|
|
12263
12687
|
async function execCommand(app, args) {
|
|
12264
12688
|
const separator = args.indexOf("--");
|
|
@@ -12272,6 +12696,7 @@ async function execCommand(app, args) {
|
|
|
12272
12696
|
const regionIndex = flags.indexOf("--region");
|
|
12273
12697
|
if (regionIndex !== -1 && flags[regionIndex + 1])
|
|
12274
12698
|
overrides.region = flags[regionIndex + 1];
|
|
12699
|
+
await guardProduction(app, alias);
|
|
12275
12700
|
const [bin, ...rest] = command;
|
|
12276
12701
|
return app.execAsAlias(alias, bin, rest, overrides);
|
|
12277
12702
|
}
|
|
@@ -12371,6 +12796,408 @@ async function doctorCommand(app) {
|
|
|
12371
12796
|
);
|
|
12372
12797
|
}
|
|
12373
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
|
+
`
|
|
12891
|
+
};
|
|
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
|
+
);
|
|
12919
|
+
}
|
|
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;
|
|
12939
|
+
}
|
|
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;
|
|
12947
|
+
}
|
|
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
|
+
);
|
|
12975
|
+
}
|
|
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
|
+
);
|
|
12985
|
+
}
|
|
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;
|
|
12998
|
+
}
|
|
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
|
+
);
|
|
13016
|
+
}
|
|
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;
|
|
13024
|
+
}
|
|
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));
|
|
13088
|
+
const file = {
|
|
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
|
+
)
|
|
13094
|
+
};
|
|
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;
|
|
13105
|
+
}
|
|
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
|
+
);
|
|
13111
|
+
}
|
|
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;
|
|
13145
|
+
}
|
|
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;
|
|
13172
|
+
}
|
|
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
|
+
);
|
|
13180
|
+
}
|
|
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);
|
|
13187
|
+
}
|
|
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
|
+
}
|
|
13200
|
+
|
|
12374
13201
|
// ../ui/src/api.ts
|
|
12375
13202
|
var text3 = (body, key) => {
|
|
12376
13203
|
const value = body[key];
|
|
@@ -12409,8 +13236,8 @@ function createApi(app, assistants) {
|
|
|
12409
13236
|
return assistants;
|
|
12410
13237
|
};
|
|
12411
13238
|
const regionCheck = (source, region) => {
|
|
12412
|
-
const
|
|
12413
|
-
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.";
|
|
12414
13241
|
if (problem) throw badRequest(problem);
|
|
12415
13242
|
};
|
|
12416
13243
|
const findSource = async (id) => {
|
|
@@ -12461,7 +13288,8 @@ function createApi(app, assistants) {
|
|
|
12461
13288
|
target: alias.target,
|
|
12462
13289
|
label: target?.label,
|
|
12463
13290
|
missing: !target,
|
|
12464
|
-
region
|
|
13291
|
+
region,
|
|
13292
|
+
prod: alias.prod === true
|
|
12465
13293
|
}))
|
|
12466
13294
|
};
|
|
12467
13295
|
},
|
|
@@ -12556,12 +13384,28 @@ function createApi(app, assistants) {
|
|
|
12556
13384
|
await app.addAliases(aliases);
|
|
12557
13385
|
return { created: aliases.map((a2) => a2.name) };
|
|
12558
13386
|
},
|
|
13387
|
+
/** Changes an alias: any of `region`, `prod` (production mark) and `name` (rename, applied last). */
|
|
12559
13388
|
"PATCH /api/aliases/:name": async ([name], body) => {
|
|
12560
|
-
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
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 };
|
|
12565
13409
|
},
|
|
12566
13410
|
"DELETE /api/aliases/:name": async ([name]) => {
|
|
12567
13411
|
await app.removeAlias(name);
|
|
@@ -12598,7 +13442,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
|
|
|
12598
13442
|
import { createServer } from "http";
|
|
12599
13443
|
|
|
12600
13444
|
// ../ui/src/assets/app.js
|
|
12601
|
-
var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// A fresh link opened in this same tab only changes the fragment: reload to pick up its token.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) location.reload();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/sources(\\/[^/]+\\/(login|sync|targets))?$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n if (state.sources.length === 0) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n ),\n assistantsCard(),\n );\n loadAssistants();\n return;\n }\n $main.replaceChildren(...state.sources.map(sourceCard), assistantsCard());\n loadAssistants();\n}\n\n// ---------- AI assistants ----------\n\nlet assistantScope = 'user';\ntry {\n assistantScope =\n localStorage.getItem('cloudhop-assistant-scope') === 'project' ? 'project' : 'user';\n} catch {}\nlet assistantsData;\n\nconst SKILL_TEXT = {\n current: ['valid', 'Up to date'],\n outdated: ['refreshable', 'Outdated'],\n modified: ['refreshable', 'Edited by you'],\n missing: ['', 'Not installed'],\n};\nconst MCP_TEXT = { registered: 'On', missing: 'Off', unknown: 'Check in the app' };\n\nfunction assistantsCard() {\n return h('section', { class: 'card', id: 'assistants', hidden: true });\n}\n\nasync function loadAssistants() {\n try {\n assistantsData = await api('GET', '/api/assistants');\n renderAssistants();\n } catch {\n document.getElementById('assistants')?.remove();\n }\n}\n\nfunction setAssistantScope(scope) {\n assistantScope = scope;\n try {\n localStorage.setItem('cloudhop-assistant-scope', scope);\n } catch {}\n renderAssistants();\n}\n\nfunction assistantAction(label, run, { danger = false, confirmText } = {}) {\n return h(\n 'button',\n {\n class: danger ? 'danger' : undefined,\n onclick: (e) => {\n if (confirmText && !confirm(confirmText)) return;\n busy(e.currentTarget, async () => {\n try {\n await run();\n } catch (error) {\n fail(error);\n }\n await loadAssistants();\n });\n },\n },\n label,\n );\n}\n\nfunction reportInstall(results) {\n for (const r of results) {\n if (r.mcp === 'manual') {\n toast(`${r.name}: skill installed. Add the MCP server by hand.`, { hint: r.mcpManual });\n } else {\n toast(`${r.name} now knows cloudhop. Start a new chat so it picks it up.`);\n }\n }\n}\n\nfunction renderAssistants() {\n const el = document.getElementById('assistants');\n if (!el || !assistantsData) return;\n const rows = assistantScope === 'user' ? assistantsData.user : assistantsData.projectScope;\n const scope = assistantScope;\n const install = (tools) =>\n api('POST', '/api/assistants', { tools, scope, mcp: true }).then((r) =>\n reportInstall(r.results),\n );\n const missingDetected = rows\n .filter((r) => r.detected && r.skill === 'missing')\n .map((r) => r.assistant);\n\n const seg = h(\n 'div',\n { class: 'seg', role: 'group', 'aria-label': 'Where' },\n h(\n 'button',\n { 'aria-pressed': String(scope === 'user'), onclick: () => setAssistantScope('user') },\n 'For me',\n ),\n h(\n 'button',\n { 'aria-pressed': String(scope === 'project'), onclick: () => setAssistantScope('project') },\n 'This project',\n ),\n );\n\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, 'AI assistants'),\n h(\n 'span',\n { class: 'meta' },\n scope === 'user'\n ? 'Installed in your home folder, for every project'\n : assistantsData.project,\n ),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n seg,\n missingDetected.length > 1\n ? assistantAction('Install for all detected', () => install(missingDetected))\n : null,\n ),\n );\n\n const table = h(\n 'table',\n { class: 'assistants' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Assistant'),\n h('th', {}, 'Skill'),\n h('th', {}, 'MCP server'),\n h('th'),\n ),\n ),\n h(\n 'tbody',\n {},\n rows.map((r) => {\n const [badgeClass, skillText] = SKILL_TEXT[r.skill];\n const actions = [];\n if (r.skill === 'missing')\n actions.push(assistantAction('Install', () => install([r.assistant])));\n if (r.skill === 'outdated') {\n actions.push(\n assistantAction('Update', () =>\n api('POST', '/api/assistants/update', {}).then(() =>\n toast(`${r.name}: skill updated.`),\n ),\n ),\n );\n }\n if (r.skill === 'modified') {\n actions.push(\n assistantAction(\n 'Replace',\n () =>\n api('POST', '/api/assistants/update', { force: true }).then(() =>\n toast('Edited skills replaced.'),\n ),\n {\n confirmText: 'Replace your edited copies with the current cloudhop skill?',\n },\n ),\n );\n }\n if (r.skill !== 'missing' || r.mcp === 'registered') {\n actions.push(\n assistantAction(\n 'Remove',\n () => api('POST', '/api/assistants/remove', { tools: [r.assistant], scope }),\n {\n danger: true,\n confirmText: `Remove the cloudhop skill and MCP server from ${r.name}?`,\n },\n ),\n );\n }\n return h(\n 'tr',\n {},\n h(\n 'td',\n { class: 'alias' },\n r.name,\n r.detected ? null : h('small', { class: 'muted-inline' }, ' not detected'),\n ),\n h('td', {}, h('span', { class: `badge ${badgeClass}`, title: r.skillPath }, skillText)),\n h('td', {}, r.skill === 'missing' && r.mcp !== 'registered' ? '' : MCP_TEXT[r.mcp]),\n h(\n 'td',\n { style: 'text-align:right' },\n h('div', { class: 'actions', style: 'justify-content:flex-end' }, actions),\n ),\n );\n }),\n ),\n );\n\n el.replaceChildren(\n head,\n h(\n 'div',\n { class: 'card-body' },\n h(\n 'p',\n { class: 'card-help' },\n 'Teach your AI assistant to use cloudhop: it asks you which alias to work with, runs commands with hop, and confirms before changing anything. Includes the cloudhop MCP server.',\n ),\n table,\n ),\n );\n el.hidden = false;\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'table',\n {},\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n h('tbody', {}, aliases.map(aliasRow)),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n return h(\n 'tr',\n {},\n h('td', { class: 'alias', title: `hop ${alias.name} <command>` }, alias.name),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h(\n 'td',\n { style: 'text-align:right' },\n h(\n 'button',\n {\n class: 'link',\n title: `Remove ${alias.name}`,\n 'aria-label': `Remove ${alias.name}`,\n onclick: (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n },\n '\u2715',\n ),\n ),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n refresh();\n}\n";
|
|
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";
|
|
12602
13446
|
|
|
12603
13447
|
// ../ui/src/assets/index.html
|
|
12604
13448
|
var assets_default = `<!doctype html>
|
|
@@ -12729,9 +13573,29 @@ var assets_default = `<!doctype html>
|
|
|
12729
13573
|
}
|
|
12730
13574
|
/* Fixed columns so every source's table lines up with the others. */
|
|
12731
13575
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
|
12732
|
-
th:nth-child(1) { width:
|
|
12733
|
-
th:nth-child(2) { width:
|
|
12734
|
-
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); }
|
|
12735
13599
|
td { overflow-wrap: anywhere; }
|
|
12736
13600
|
th {
|
|
12737
13601
|
text-align: left;
|
|
@@ -12844,23 +13708,36 @@ var assets_default = `<!doctype html>
|
|
|
12844
13708
|
margin-bottom: 18px;
|
|
12845
13709
|
}
|
|
12846
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); }
|
|
12847
13716
|
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
12848
13717
|
.seg button { border: 0; border-radius: 0; }
|
|
12849
13718
|
.seg button + button { border-left: 1px solid var(--border); }
|
|
12850
13719
|
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
|
|
12851
13720
|
.card-help { color: var(--muted); font-size: 14px; margin: 10px 16px 6px; }
|
|
13721
|
+
.card-body.padded .card-help { margin: 8px 0 10px; }
|
|
12852
13722
|
.muted-inline { color: var(--muted); font-weight: 400; font-size: 12px; }
|
|
12853
13723
|
table.assistants th:nth-child(2) { width: 170px; }
|
|
12854
13724
|
table.assistants th:nth-child(3) { width: 140px; }
|
|
12855
13725
|
table.assistants th:nth-child(4) { width: 230px; }
|
|
12856
13726
|
@media (max-width: 640px) {
|
|
12857
13727
|
.tagline { display: none; }
|
|
12858
|
-
|
|
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; }
|
|
12859
13733
|
td, th { padding: 8px 10px; }
|
|
12860
13734
|
td.alias { white-space: normal; overflow-wrap: anywhere; }
|
|
12861
13735
|
.region input { width: 96px; }
|
|
12862
|
-
th:nth-child(1) { width: auto; }
|
|
12863
|
-
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; }
|
|
12864
13741
|
table.assistants th:nth-child(2) { width: 112px; }
|
|
12865
13742
|
table.assistants th:nth-child(4) { width: 96px; }
|
|
12866
13743
|
table.assistants .actions { flex-direction: column; align-items: flex-end; }
|
|
@@ -12872,7 +13749,12 @@ var assets_default = `<!doctype html>
|
|
|
12872
13749
|
<header>
|
|
12873
13750
|
<div class="bar">
|
|
12874
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>
|
|
12875
|
-
<
|
|
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>
|
|
12876
13758
|
<div class="spacer"></div>
|
|
12877
13759
|
<button type="button" class="primary" id="add-source" hidden>+ Add source</button>
|
|
12878
13760
|
</div>
|
|
@@ -12996,15 +13878,15 @@ async function uiCommand(app, assistants, { flags }) {
|
|
|
12996
13878
|
}
|
|
12997
13879
|
|
|
12998
13880
|
// src/container.ts
|
|
12999
|
-
import { homedir as
|
|
13000
|
-
import { join as
|
|
13881
|
+
import { homedir as homedir4 } from "os";
|
|
13882
|
+
import { join as join8 } from "path";
|
|
13001
13883
|
|
|
13002
13884
|
// ../provider-aws/src/aws-provider.ts
|
|
13003
|
-
import { join as
|
|
13885
|
+
import { join as join7 } from "path";
|
|
13004
13886
|
|
|
13005
13887
|
// ../provider-aws/src/aws-config.ts
|
|
13006
|
-
import { mkdir as
|
|
13007
|
-
import { dirname as
|
|
13888
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
13889
|
+
import { dirname as dirname4 } from "path";
|
|
13008
13890
|
var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand)";
|
|
13009
13891
|
var BLOCK_END = "# <<< cloudhop managed <<<";
|
|
13010
13892
|
function renderBlock(sections) {
|
|
@@ -13051,13 +13933,64 @@ function replaceManagedBlock(content, sections) {
|
|
|
13051
13933
|
async function writeManagedBlock(path2, sections) {
|
|
13052
13934
|
let content = "";
|
|
13053
13935
|
try {
|
|
13054
|
-
content = await
|
|
13936
|
+
content = await readFile5(path2, "utf8");
|
|
13055
13937
|
} catch {
|
|
13056
13938
|
}
|
|
13057
13939
|
const next = replaceManagedBlock(content, sections);
|
|
13058
13940
|
if (next === content) return;
|
|
13059
|
-
await
|
|
13060
|
-
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}`;
|
|
13061
13994
|
}
|
|
13062
13995
|
|
|
13063
13996
|
// ../provider-aws/src/http.ts
|
|
@@ -13071,7 +14004,7 @@ var AwsHttpError = class extends Error {
|
|
|
13071
14004
|
status;
|
|
13072
14005
|
code;
|
|
13073
14006
|
};
|
|
13074
|
-
async function
|
|
14007
|
+
async function parse3(response) {
|
|
13075
14008
|
const text4 = await response.text();
|
|
13076
14009
|
const body = text4 ? JSON.parse(text4) : {};
|
|
13077
14010
|
if (!response.ok) {
|
|
@@ -13091,10 +14024,10 @@ async function postJson(fetchFn, url, body) {
|
|
|
13091
14024
|
headers: { "content-type": "application/json" },
|
|
13092
14025
|
body: JSON.stringify(body)
|
|
13093
14026
|
});
|
|
13094
|
-
return
|
|
14027
|
+
return parse3(response);
|
|
13095
14028
|
}
|
|
13096
14029
|
async function getJson(fetchFn, url, headers) {
|
|
13097
|
-
return
|
|
14030
|
+
return parse3(await fetchFn(url, { headers }));
|
|
13098
14031
|
}
|
|
13099
14032
|
|
|
13100
14033
|
// ../provider-aws/src/sso/portal.ts
|
|
@@ -13278,23 +14211,23 @@ var OidcClient = class {
|
|
|
13278
14211
|
|
|
13279
14212
|
// ../provider-aws/src/sso/token-cache.ts
|
|
13280
14213
|
import { createHash as createHash2 } from "crypto";
|
|
13281
|
-
import { mkdir as
|
|
13282
|
-
import { join as
|
|
14214
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
14215
|
+
import { join as join6 } from "path";
|
|
13283
14216
|
var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
|
|
13284
14217
|
function tokenCachePath(awsDir, sessionName) {
|
|
13285
14218
|
const hash = createHash2("sha1").update(sessionName).digest("hex");
|
|
13286
|
-
return
|
|
14219
|
+
return join6(awsDir, "sso", "cache", `${hash}.json`);
|
|
13287
14220
|
}
|
|
13288
14221
|
async function readToken(awsDir, sessionName) {
|
|
13289
14222
|
try {
|
|
13290
|
-
return JSON.parse(await
|
|
14223
|
+
return JSON.parse(await readFile6(tokenCachePath(awsDir, sessionName), "utf8"));
|
|
13291
14224
|
} catch {
|
|
13292
14225
|
return void 0;
|
|
13293
14226
|
}
|
|
13294
14227
|
}
|
|
13295
14228
|
async function writeToken(awsDir, sessionName, token) {
|
|
13296
|
-
await
|
|
13297
|
-
await
|
|
14229
|
+
await mkdir5(join6(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
|
|
14230
|
+
await writeFile5(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
|
|
13298
14231
|
mode: 384
|
|
13299
14232
|
});
|
|
13300
14233
|
}
|
|
@@ -13433,6 +14366,42 @@ var CONSOLE_LOGIN_KIND = "console-login";
|
|
|
13433
14366
|
var CONSOLE_TARGET = { id: "console", label: "Console sign-in", attributes: {} };
|
|
13434
14367
|
var ssoSessionName = (sourceId) => `cloudhop-${sourceId}`;
|
|
13435
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
|
+
}
|
|
13436
14405
|
var AwsProvider = class {
|
|
13437
14406
|
constructor(options) {
|
|
13438
14407
|
this.options = options;
|
|
@@ -13551,7 +14520,7 @@ var AwsProvider = class {
|
|
|
13551
14520
|
}
|
|
13552
14521
|
});
|
|
13553
14522
|
}
|
|
13554
|
-
await writeManagedBlock(
|
|
14523
|
+
await writeManagedBlock(join7(this.options.awsDir, "config"), sections);
|
|
13555
14524
|
}
|
|
13556
14525
|
async processEnv(resolved, mode) {
|
|
13557
14526
|
const region = resolved.region;
|
|
@@ -13567,6 +14536,25 @@ var AwsProvider = class {
|
|
|
13567
14536
|
const credentials = resolved.source.kind === CONSOLE_LOGIN_KIND ? await this.exportedCredentials(profile) : await this.roleCredentials(resolved);
|
|
13568
14537
|
return { AWS_PROFILE: void 0, ...credentials, ...regionEnv };
|
|
13569
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
|
+
}
|
|
13570
14558
|
isSessionError(output) {
|
|
13571
14559
|
return SESSION_ERROR_PATTERNS.some((pattern) => pattern.test(output));
|
|
13572
14560
|
}
|
|
@@ -13614,9 +14602,10 @@ var AwsProvider = class {
|
|
|
13614
14602
|
};
|
|
13615
14603
|
|
|
13616
14604
|
// src/container.ts
|
|
14605
|
+
var browser = new NodeBrowserOpener();
|
|
13617
14606
|
function createApp(env = process.env) {
|
|
13618
14607
|
const runner = new NodeProcessRunner();
|
|
13619
|
-
const awsDir = env.CLOUDHOP_AWS_DIR ??
|
|
14608
|
+
const awsDir = env.CLOUDHOP_AWS_DIR ?? join8(homedir4(), ".aws");
|
|
13620
14609
|
return new CloudhopApp({
|
|
13621
14610
|
config: new YamlConfigStore(),
|
|
13622
14611
|
providers: [
|
|
@@ -13628,14 +14617,14 @@ function createApp(env = process.env) {
|
|
|
13628
14617
|
// Azure and Google Cloud providers plug in here.
|
|
13629
14618
|
],
|
|
13630
14619
|
runner,
|
|
13631
|
-
browser
|
|
14620
|
+
browser,
|
|
13632
14621
|
installer: new SystemToolInstaller(runner),
|
|
13633
14622
|
notify
|
|
13634
14623
|
});
|
|
13635
14624
|
}
|
|
13636
14625
|
function createAssistants(version, env = process.env) {
|
|
13637
14626
|
return new AssistantsInstaller({
|
|
13638
|
-
places: { home:
|
|
14627
|
+
places: { home: homedir4(), project: process.cwd() },
|
|
13639
14628
|
configDir: defaultConfigDir(env),
|
|
13640
14629
|
runner: new NodeProcessRunner(),
|
|
13641
14630
|
cloudhopVersion: version
|
|
@@ -13643,8 +14632,8 @@ function createAssistants(version, env = process.env) {
|
|
|
13643
14632
|
}
|
|
13644
14633
|
|
|
13645
14634
|
// src/update-check.ts
|
|
13646
|
-
import { mkdir as
|
|
13647
|
-
import { join as
|
|
14635
|
+
import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
14636
|
+
import { join as join9 } from "path";
|
|
13648
14637
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
13649
14638
|
var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
|
|
13650
14639
|
function startUpdateCheck(configDir, current) {
|
|
@@ -13652,11 +14641,11 @@ function startUpdateCheck(configDir, current) {
|
|
|
13652
14641
|
if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
|
|
13653
14642
|
return async () => {
|
|
13654
14643
|
};
|
|
13655
|
-
const file =
|
|
14644
|
+
const file = join9(configDir, "update-check.json");
|
|
13656
14645
|
const work = (async () => {
|
|
13657
14646
|
let cache = { checkedAt: 0 };
|
|
13658
14647
|
try {
|
|
13659
|
-
cache = JSON.parse(await
|
|
14648
|
+
cache = JSON.parse(await readFile7(file, "utf8"));
|
|
13660
14649
|
} catch {
|
|
13661
14650
|
}
|
|
13662
14651
|
if (cache.latest && compareVersions(cache.latest, current) > 0) {
|
|
@@ -13673,19 +14662,27 @@ function startUpdateCheck(configDir, current) {
|
|
|
13673
14662
|
if (!response.ok) return;
|
|
13674
14663
|
const { version } = await response.json();
|
|
13675
14664
|
if (typeof version !== "string") return;
|
|
13676
|
-
await
|
|
13677
|
-
await
|
|
14665
|
+
await mkdir6(configDir, { recursive: true });
|
|
14666
|
+
await writeFile6(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
|
|
13678
14667
|
})().catch(() => {
|
|
13679
14668
|
});
|
|
13680
14669
|
return () => Promise.race([work, new Promise((resolve) => setTimeout(resolve, 300))]);
|
|
13681
14670
|
}
|
|
13682
14671
|
|
|
13683
14672
|
// src/cli.ts
|
|
13684
|
-
var HELP = `cloudhop ${"1.
|
|
14673
|
+
var HELP = `cloudhop ${"1.2.0"}
|
|
13685
14674
|
Hop between cloud accounts, roles and regions with one command.
|
|
13686
14675
|
|
|
13687
14676
|
Usage
|
|
13688
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
|
|
13689
14686
|
cloudhop exec <alias> [--region <r>] -- <cmd...>
|
|
13690
14687
|
Run any tool with the alias' temporary credentials
|
|
13691
14688
|
|
|
@@ -13694,8 +14691,13 @@ Setup
|
|
|
13694
14691
|
cloudhop add Add an SSO portal or a personal account
|
|
13695
14692
|
cloudhop add aws sso --start-url <url> [--region <r>] [--name <id>]
|
|
13696
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)
|
|
13697
14696
|
cloudhop alias [source] Create more aliases from a source
|
|
13698
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)
|
|
13699
14701
|
cloudhop region <alias> [region]
|
|
13700
14702
|
Change the region an alias runs in
|
|
13701
14703
|
cloudhop login [source] Log in again and refresh its accounts
|
|
@@ -13727,23 +14729,47 @@ function takeGlobalFlags(argv) {
|
|
|
13727
14729
|
}
|
|
13728
14730
|
async function main(argv) {
|
|
13729
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
|
+
}
|
|
13730
14748
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
13731
14749
|
console.log(HELP);
|
|
13732
14750
|
return 0;
|
|
13733
14751
|
}
|
|
13734
14752
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
13735
|
-
console.log("1.
|
|
14753
|
+
console.log("1.2.0");
|
|
13736
14754
|
return 0;
|
|
13737
14755
|
}
|
|
13738
14756
|
const app = createApp();
|
|
13739
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
|
+
}
|
|
13740
14766
|
if (command === "mcp") {
|
|
13741
|
-
await serveMcp({ app, version: "1.
|
|
14767
|
+
await serveMcp({ app, version: "1.2.0" });
|
|
13742
14768
|
return 0;
|
|
13743
14769
|
}
|
|
13744
|
-
const assistants = createAssistants("1.
|
|
14770
|
+
const assistants = createAssistants("1.2.0");
|
|
13745
14771
|
await refreshAssistantSkills(assistants, command);
|
|
13746
|
-
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.
|
|
14772
|
+
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.2.0");
|
|
13747
14773
|
try {
|
|
13748
14774
|
return await dispatch(app, assistants, command, args, local);
|
|
13749
14775
|
} finally {
|
|
@@ -13774,6 +14800,13 @@ async function dispatch(app, assistants, command, args, local) {
|
|
|
13774
14800
|
case "status":
|
|
13775
14801
|
await statusCommand(app, local);
|
|
13776
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;
|
|
13777
14810
|
case "region":
|
|
13778
14811
|
await regionCommand(app, local);
|
|
13779
14812
|
return 0;
|
|
@@ -13802,6 +14835,17 @@ async function dispatch(app, assistants, command, args, local) {
|
|
|
13802
14835
|
case "agent":
|
|
13803
14836
|
await agentCommand(assistants, local);
|
|
13804
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;
|
|
13805
14849
|
case "ui":
|
|
13806
14850
|
await uiCommand(app, assistants, parseArgs(local));
|
|
13807
14851
|
return 0;
|