cloudhop 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/cli.js +1171 -93
- package/package.json +4 -3
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,53 @@ ${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, {
|
|
8035
|
+
service,
|
|
8036
|
+
region,
|
|
8037
|
+
interactive: interactive2 = true
|
|
8038
|
+
} = {}) {
|
|
8039
|
+
const resolved = await this.usableAlias(name, region ? { region } : {});
|
|
8040
|
+
const provider = this.provider(resolved.source.provider);
|
|
8041
|
+
if (!provider.consoleUrl) {
|
|
8042
|
+
throw new CloudhopError(
|
|
8043
|
+
"PROVIDER_NOT_AVAILABLE",
|
|
8044
|
+
`${provider.displayName} has no console sign-in in cloudhop yet.`
|
|
8045
|
+
);
|
|
8046
|
+
}
|
|
8047
|
+
if (service !== void 0 && !/^[a-z0-9-]{1,40}$/.test(service)) {
|
|
8048
|
+
throw new CloudhopError(
|
|
8049
|
+
"INVALID_NAME",
|
|
8050
|
+
`"${service}" doesn't look like a service name.`,
|
|
8051
|
+
"Examples: s3, ec2, lambda, cloudwatch."
|
|
8052
|
+
);
|
|
8053
|
+
}
|
|
8054
|
+
await this.ensureSession(resolved.source.id, { interactive: interactive2 });
|
|
8055
|
+
return provider.consoleUrl(resolved, service);
|
|
8056
|
+
}
|
|
8057
|
+
/**
|
|
8058
|
+
* Opens the web console as the alias in the user's browser. The sign-in URL
|
|
8059
|
+
* stays inside cloudhop: callers only learn how the user got in.
|
|
8060
|
+
*/
|
|
8061
|
+
async openConsole(name, options = {}) {
|
|
8062
|
+
const { url, via } = await this.consoleUrl(name, options);
|
|
8063
|
+
await this.deps.browser.open(url);
|
|
8064
|
+
return via;
|
|
8065
|
+
}
|
|
7933
8066
|
/** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
|
|
7934
8067
|
identityArgs(name) {
|
|
7935
8068
|
return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
|
|
@@ -8220,6 +8353,35 @@ function createTools(app) {
|
|
|
8220
8353
|
};
|
|
8221
8354
|
}
|
|
8222
8355
|
},
|
|
8356
|
+
{
|
|
8357
|
+
name: "open_console",
|
|
8358
|
+
title: "Open the cloud console",
|
|
8359
|
+
description: "Opens the cloud's web console in the user's browser as the alias, already signed in, optionally on one service (AWS: s3, ec2, lambda, cloudwatch...). Returns how the user got in, never the sign-in URL. Use it when the user wants to see something in the console. Never opens a browser to sign in: an expired session returns LOGIN_REQUIRED.",
|
|
8360
|
+
inputSchema: {
|
|
8361
|
+
type: "object",
|
|
8362
|
+
properties: {
|
|
8363
|
+
...aliasInput,
|
|
8364
|
+
service: { type: "string", description: 'Optional service page, e.g. "s3" or "ec2".' }
|
|
8365
|
+
},
|
|
8366
|
+
required: ["alias"],
|
|
8367
|
+
additionalProperties: false
|
|
8368
|
+
},
|
|
8369
|
+
annotations: {
|
|
8370
|
+
readOnlyHint: false,
|
|
8371
|
+
destructiveHint: false,
|
|
8372
|
+
idempotentHint: true,
|
|
8373
|
+
openWorldHint: true
|
|
8374
|
+
},
|
|
8375
|
+
run: async (args) => {
|
|
8376
|
+
const alias = text(args, "alias");
|
|
8377
|
+
const service = typeof args.service === "string" && args.service.trim() ? args.service.trim() : void 0;
|
|
8378
|
+
const via = await app.openConsole(alias, {
|
|
8379
|
+
...service ? { service } : {},
|
|
8380
|
+
interactive: false
|
|
8381
|
+
});
|
|
8382
|
+
return { alias, opened: true, via };
|
|
8383
|
+
}
|
|
8384
|
+
},
|
|
8223
8385
|
{
|
|
8224
8386
|
name: "login",
|
|
8225
8387
|
title: "Sign in to a source",
|
|
@@ -8260,6 +8422,10 @@ function createTools(app) {
|
|
|
8260
8422
|
region: {
|
|
8261
8423
|
type: "string",
|
|
8262
8424
|
description: "Optional region for this call only; defaults to the alias' region."
|
|
8425
|
+
},
|
|
8426
|
+
confirm_production: {
|
|
8427
|
+
type: "boolean",
|
|
8428
|
+
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
8429
|
}
|
|
8264
8430
|
},
|
|
8265
8431
|
required: ["alias", "args"],
|
|
@@ -8288,6 +8454,14 @@ function createTools(app) {
|
|
|
8288
8454
|
);
|
|
8289
8455
|
}
|
|
8290
8456
|
const region = typeof args.region === "string" && args.region.trim() ? args.region.trim() : void 0;
|
|
8457
|
+
if (args.confirm_production !== true && await app.needsProductionConfirmation(alias, cliArgs)) {
|
|
8458
|
+
const status = await app.aliasStatus(alias);
|
|
8459
|
+
throw new CloudhopError(
|
|
8460
|
+
"PROD_CONFIRMATION_REQUIRED",
|
|
8461
|
+
`${alias} is PRODUCTION (${status.label ?? status.target}, ${status.region ?? "default region"}) and this command may change resources.`,
|
|
8462
|
+
"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."
|
|
8463
|
+
);
|
|
8464
|
+
}
|
|
8291
8465
|
const result = await app.captureAsAlias(alias, cliArgs, region ? { region } : {});
|
|
8292
8466
|
return {
|
|
8293
8467
|
alias,
|
|
@@ -8303,7 +8477,8 @@ function createTools(app) {
|
|
|
8303
8477
|
// ../mcp/src/server.ts
|
|
8304
8478
|
var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
|
|
8305
8479
|
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.
|
|
8480
|
+
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.
|
|
8481
|
+
To show the user something in the web console, call open_console (it opens their browser, already signed in).
|
|
8307
8482
|
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
8483
|
var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
8309
8484
|
function serveMcp({
|
|
@@ -8404,7 +8579,17 @@ function serveMcp({
|
|
|
8404
8579
|
}
|
|
8405
8580
|
|
|
8406
8581
|
// src/flags.ts
|
|
8407
|
-
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
8582
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
8583
|
+
"yes",
|
|
8584
|
+
"all",
|
|
8585
|
+
"list",
|
|
8586
|
+
"noOpen",
|
|
8587
|
+
"json",
|
|
8588
|
+
"noMcp",
|
|
8589
|
+
"force",
|
|
8590
|
+
"prod",
|
|
8591
|
+
"off"
|
|
8592
|
+
]);
|
|
8408
8593
|
var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
|
|
8409
8594
|
function parseArgs(argv) {
|
|
8410
8595
|
const positional = [];
|
|
@@ -11362,14 +11547,12 @@ async function chooseAliases(app, source, targets) {
|
|
|
11362
11547
|
);
|
|
11363
11548
|
const validateRegion2 = regionValidator(app, source);
|
|
11364
11549
|
let defaultRegion = source.settings.region ?? "";
|
|
11550
|
+
const suggestions = suggestAliases(targets, source.id, existing);
|
|
11365
11551
|
const aliases = [];
|
|
11366
11552
|
for (const target of selected) {
|
|
11367
11553
|
dist_exports.log.step(target.label);
|
|
11368
11554
|
const suggestion = uniqueName(
|
|
11369
|
-
targets.length === 1 && target.id === "console" ? source.id : suggestAliasName(
|
|
11370
|
-
target.attributes.accountName ?? target.label,
|
|
11371
|
-
target.attributes.roleName ?? ""
|
|
11372
|
-
),
|
|
11555
|
+
targets.length === 1 && target.id === "console" ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
|
|
11373
11556
|
existing
|
|
11374
11557
|
);
|
|
11375
11558
|
const name = answer(
|
|
@@ -11388,13 +11571,20 @@ async function chooseAliases(app, source, targets) {
|
|
|
11388
11571
|
validate: (v) => v?.trim() ? validateRegion2?.(v.trim()) : void 0
|
|
11389
11572
|
})
|
|
11390
11573
|
).trim() || defaultRegion;
|
|
11574
|
+
const prod = answer(
|
|
11575
|
+
await dist_exports.confirm({
|
|
11576
|
+
message: `Is ${name} production? (cloudhop then asks before any change)`,
|
|
11577
|
+
initialValue: looksLikeProduction(name, target.label)
|
|
11578
|
+
})
|
|
11579
|
+
);
|
|
11391
11580
|
defaultRegion = region;
|
|
11392
11581
|
existing.add(name);
|
|
11393
11582
|
aliases.push({
|
|
11394
11583
|
name,
|
|
11395
11584
|
source: source.id,
|
|
11396
11585
|
target: target.id,
|
|
11397
|
-
...region && region !== source.settings.region ? { region } : {}
|
|
11586
|
+
...region && region !== source.settings.region ? { region } : {},
|
|
11587
|
+
...prod ? { prod: true } : {}
|
|
11398
11588
|
});
|
|
11399
11589
|
}
|
|
11400
11590
|
return aliases;
|
|
@@ -11486,14 +11676,20 @@ async function aliasCommand(app, { positional, flags }) {
|
|
|
11486
11676
|
`See the options with \`cloudhop alias ${id} --list\`.`
|
|
11487
11677
|
);
|
|
11488
11678
|
}
|
|
11489
|
-
const
|
|
11490
|
-
|
|
11491
|
-
target.attributes.roleName ?? ""
|
|
11492
|
-
);
|
|
11679
|
+
const taken = new Set((await app.config()).aliases.map((a2) => a2.name));
|
|
11680
|
+
const name = flags.name ?? suggestAliases(targets, id, taken).get(target.id) ?? id;
|
|
11493
11681
|
await app.addAliases([
|
|
11494
|
-
{
|
|
11682
|
+
{
|
|
11683
|
+
name,
|
|
11684
|
+
source: id,
|
|
11685
|
+
target: target.id,
|
|
11686
|
+
...flags.region ? { region: flags.region } : {},
|
|
11687
|
+
...flags.prod ? { prod: true } : {}
|
|
11688
|
+
}
|
|
11495
11689
|
]);
|
|
11496
|
-
console.log(
|
|
11690
|
+
console.log(
|
|
11691
|
+
`Alias "${name}" \u2192 ${target.label}${flags.prod ? " (production)" : ""}. Try: hop ${name}`
|
|
11692
|
+
);
|
|
11497
11693
|
return;
|
|
11498
11694
|
}
|
|
11499
11695
|
if (scripted) {
|
|
@@ -11517,7 +11713,7 @@ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, r
|
|
|
11517
11713
|
import { dirname as dirname2, join as join4 } from "path";
|
|
11518
11714
|
|
|
11519
11715
|
// ../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-
|
|
11716
|
+
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]` (or the\n `open_console` tool) opens it in the user's browser, already signed in.\n Never try to print or fetch its URL.\n- **Always name the alias.** Never run the interactive forms meant for a\n person at a terminal: `hop` or `hop -` without an alias, `cloudhop console`\n without an alias, `cloudhop use` (it only changes an interactive shell),\n `cloudhop setup`, or `cloudhop add`/`alias` without flags. Nobody can answer\n their prompts in your terminal.\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`, `mv`, `region`, `prod`,\n `team import`, `agent`, `login` for another source) unless the user asked\n for it. Never unmark a production alias to get around a confirmation.\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
11717
|
|
|
11522
11718
|
// ../assistants/src/targets.ts
|
|
11523
11719
|
import { join as join3 } from "path";
|
|
@@ -12017,6 +12213,216 @@ async function agentCommand(installer, args) {
|
|
|
12017
12213
|
}
|
|
12018
12214
|
}
|
|
12019
12215
|
|
|
12216
|
+
// src/picker.ts
|
|
12217
|
+
async function pickAlias(app, message = "Which alias?") {
|
|
12218
|
+
if (!interactive()) {
|
|
12219
|
+
throw new CloudhopError(
|
|
12220
|
+
"NOT_INTERACTIVE",
|
|
12221
|
+
"Say which alias to use.",
|
|
12222
|
+
"See them with `cloudhop ls`."
|
|
12223
|
+
);
|
|
12224
|
+
}
|
|
12225
|
+
const { aliases } = await app.overview();
|
|
12226
|
+
if (aliases.length === 0) {
|
|
12227
|
+
throw new CloudhopError(
|
|
12228
|
+
"ALIAS_NOT_FOUND",
|
|
12229
|
+
"No aliases yet.",
|
|
12230
|
+
"Create one with `cloudhop alias <source>`."
|
|
12231
|
+
);
|
|
12232
|
+
}
|
|
12233
|
+
const haystack = new Map(
|
|
12234
|
+
aliases.map((a2) => [
|
|
12235
|
+
a2.name,
|
|
12236
|
+
`${a2.name} ${a2.label ?? a2.target} ${a2.region ?? ""} ${a2.source}`.toLowerCase()
|
|
12237
|
+
])
|
|
12238
|
+
);
|
|
12239
|
+
return answer(
|
|
12240
|
+
await dist_exports.autocomplete({
|
|
12241
|
+
message: `${message} ${paint("dim", "(type to filter)")}`,
|
|
12242
|
+
maxItems: 12,
|
|
12243
|
+
options: aliases.map((a2) => ({
|
|
12244
|
+
value: a2.name,
|
|
12245
|
+
label: a2.prod ? `${a2.name} ${paint("red", "PROD")}` : a2.name,
|
|
12246
|
+
hint: `${a2.label ?? a2.target} \xB7 ${a2.region ?? "no region"}`
|
|
12247
|
+
})),
|
|
12248
|
+
filter: (search, option) => {
|
|
12249
|
+
const text4 = haystack.get(option.value) ?? "";
|
|
12250
|
+
return search.toLowerCase().split(/\s+/).filter(Boolean).every((word) => text4.includes(word));
|
|
12251
|
+
}
|
|
12252
|
+
})
|
|
12253
|
+
);
|
|
12254
|
+
}
|
|
12255
|
+
|
|
12256
|
+
// src/commands/console.ts
|
|
12257
|
+
var HOW = {
|
|
12258
|
+
"signed-in": "signed in",
|
|
12259
|
+
portal: "through the SSO portal (AWS did not accept a direct sign-in)",
|
|
12260
|
+
"sign-in-page": "at the sign-in page (sign in with your console user)"
|
|
12261
|
+
};
|
|
12262
|
+
async function consoleCommand(app, args) {
|
|
12263
|
+
const { positional, flags } = parseArgs(args);
|
|
12264
|
+
const alias = positional[0] ?? await pickAlias(app, "Open the console as");
|
|
12265
|
+
const service = positional[1];
|
|
12266
|
+
const via = await app.openConsole(alias, {
|
|
12267
|
+
...service ? { service } : {},
|
|
12268
|
+
...flags.region ? { region: flags.region } : {}
|
|
12269
|
+
});
|
|
12270
|
+
notify(`Opened the ${service ? `${service} ` : ""}console for ${alias}, ${HOW[via]}.`);
|
|
12271
|
+
}
|
|
12272
|
+
|
|
12273
|
+
// src/commands/each.ts
|
|
12274
|
+
function matcher(selector) {
|
|
12275
|
+
const regex = /^\/(.+)\/([a-z]*)$/.exec(selector);
|
|
12276
|
+
if (regex) {
|
|
12277
|
+
try {
|
|
12278
|
+
const re = new RegExp(regex[1], regex[2]);
|
|
12279
|
+
return (name) => re.test(name);
|
|
12280
|
+
} catch {
|
|
12281
|
+
throw new CloudhopError("INVALID_NAME", `Invalid regular expression: ${selector}`);
|
|
12282
|
+
}
|
|
12283
|
+
}
|
|
12284
|
+
if (/[*?]/.test(selector)) {
|
|
12285
|
+
const escaped = selector.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
12286
|
+
const re = new RegExp(`^${escaped}$`);
|
|
12287
|
+
return (name) => re.test(name);
|
|
12288
|
+
}
|
|
12289
|
+
return (name) => name === selector;
|
|
12290
|
+
}
|
|
12291
|
+
function selectAliases(aliases, selectors, filters) {
|
|
12292
|
+
const patterns = selectors.flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
|
|
12293
|
+
const tests = patterns.map(matcher);
|
|
12294
|
+
for (const [i2, pattern] of patterns.entries()) {
|
|
12295
|
+
const plain = !/[*?]/.test(pattern) && !pattern.startsWith("/");
|
|
12296
|
+
if (plain && !aliases.some((a2) => tests[i2]?.(a2.name))) {
|
|
12297
|
+
throw new CloudhopError(
|
|
12298
|
+
"ALIAS_NOT_FOUND",
|
|
12299
|
+
`Alias "${pattern}" not found.`,
|
|
12300
|
+
"See your aliases with `cloudhop ls`."
|
|
12301
|
+
);
|
|
12302
|
+
}
|
|
12303
|
+
}
|
|
12304
|
+
return aliases.filter(
|
|
12305
|
+
(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)
|
|
12306
|
+
);
|
|
12307
|
+
}
|
|
12308
|
+
async function mapLimit(items, limit, fn) {
|
|
12309
|
+
const results = new Array(items.length);
|
|
12310
|
+
let next = 0;
|
|
12311
|
+
const worker = async () => {
|
|
12312
|
+
while (next < items.length) {
|
|
12313
|
+
const i2 = next++;
|
|
12314
|
+
results[i2] = await fn(items[i2]);
|
|
12315
|
+
}
|
|
12316
|
+
};
|
|
12317
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
12318
|
+
return results;
|
|
12319
|
+
}
|
|
12320
|
+
async function eachCommand(app, args) {
|
|
12321
|
+
const separator = args.indexOf("--");
|
|
12322
|
+
if (separator === -1 || separator === args.length - 1) {
|
|
12323
|
+
throw new CloudhopError(
|
|
12324
|
+
"INVALID_NAME",
|
|
12325
|
+
"Usage: cloudhop each <alias|list|pattern|/regex/...> [--source <s>] [--prod|--no-prod] -- <command>",
|
|
12326
|
+
"Example: cloudhop each 'creci-*' -- s3 ls"
|
|
12327
|
+
);
|
|
12328
|
+
}
|
|
12329
|
+
const { positional, flags } = parseArgs(args.slice(0, separator));
|
|
12330
|
+
const cliArgs = args.slice(separator + 1);
|
|
12331
|
+
if (positional.length === 0 && !flags.source && !flags.prod && !flags.noProd) {
|
|
12332
|
+
throw new CloudhopError(
|
|
12333
|
+
"INVALID_NAME",
|
|
12334
|
+
'Say which aliases: names, a pattern like "globex-*", /regex/, --source or --prod.'
|
|
12335
|
+
);
|
|
12336
|
+
}
|
|
12337
|
+
const prodFilter = flags.prod ? true : flags.noProd ? false : void 0;
|
|
12338
|
+
const { aliases } = await app.overview();
|
|
12339
|
+
const selected = selectAliases(aliases, positional, {
|
|
12340
|
+
...flags.source ? { source: flags.source } : {},
|
|
12341
|
+
...prodFilter !== void 0 ? { prod: prodFilter } : {}
|
|
12342
|
+
});
|
|
12343
|
+
if (selected.length === 0)
|
|
12344
|
+
throw new CloudhopError("ALIAS_NOT_FOUND", "No alias matches that selection.");
|
|
12345
|
+
const command = `aws ${cliArgs.join(" ")}`;
|
|
12346
|
+
const width = Math.max(...selected.map((a2) => a2.name.length)) + 2;
|
|
12347
|
+
const listing = selected.map(
|
|
12348
|
+
(a2) => ` ${a2.name.padEnd(width)}${(a2.region ?? "-").padEnd(12)}${a2.label ?? a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`
|
|
12349
|
+
).join("\n");
|
|
12350
|
+
const risky = [];
|
|
12351
|
+
for (const a2 of selected)
|
|
12352
|
+
if (await app.needsProductionConfirmation(a2.name, cliArgs)) risky.push(a2.name);
|
|
12353
|
+
const tty = interactive();
|
|
12354
|
+
if (!flags.json)
|
|
12355
|
+
process.stderr.write(
|
|
12356
|
+
`${bold(`${selected.length} alias(es)`)} will run ${paint("sky", command)}
|
|
12357
|
+
${listing}
|
|
12358
|
+
`
|
|
12359
|
+
);
|
|
12360
|
+
if (!flags.yes) {
|
|
12361
|
+
if (!tty)
|
|
12362
|
+
throw new CloudhopError(
|
|
12363
|
+
"NOT_INTERACTIVE",
|
|
12364
|
+
"Confirm with --yes to run in several aliases without a terminal."
|
|
12365
|
+
);
|
|
12366
|
+
if (!answer(
|
|
12367
|
+
await dist_exports.confirm({
|
|
12368
|
+
message: `Run it in these ${selected.length} alias(es)?`,
|
|
12369
|
+
initialValue: false
|
|
12370
|
+
})
|
|
12371
|
+
))
|
|
12372
|
+
return 130;
|
|
12373
|
+
}
|
|
12374
|
+
if (risky.length > 0 && process.env.CLOUDHOP_CONFIRM_PROD !== "1") {
|
|
12375
|
+
if (!tty) {
|
|
12376
|
+
throw new CloudhopError(
|
|
12377
|
+
"PROD_CONFIRMATION_REQUIRED",
|
|
12378
|
+
`This may change resources in production: ${risky.join(", ")}.`,
|
|
12379
|
+
"Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
|
|
12380
|
+
);
|
|
12381
|
+
}
|
|
12382
|
+
dist_exports.log.warn(
|
|
12383
|
+
`${paint("red", bold("PRODUCTION"))} ${risky.join(", ")}: this may change resources.`
|
|
12384
|
+
);
|
|
12385
|
+
if (!answer(await dist_exports.confirm({ message: "Also run it in production?", initialValue: false })))
|
|
12386
|
+
return 130;
|
|
12387
|
+
}
|
|
12388
|
+
for (const source of new Set(selected.map((a2) => a2.source))) {
|
|
12389
|
+
await app.ensureSession(source, { interactive: tty });
|
|
12390
|
+
}
|
|
12391
|
+
const parallel = Math.max(1, Math.min(16, Number(flags.parallel) || 4));
|
|
12392
|
+
const outcomes = await mapLimit(selected, parallel, async (a2) => {
|
|
12393
|
+
try {
|
|
12394
|
+
const r2 = await app.captureAsAlias(a2.name, cliArgs);
|
|
12395
|
+
const outcome = { alias: a2.name, exitCode: r2.exitCode, stdout: r2.stdout, stderr: r2.stderr };
|
|
12396
|
+
if (!flags.json) printOutcome(outcome, width);
|
|
12397
|
+
return outcome;
|
|
12398
|
+
} catch (error) {
|
|
12399
|
+
const outcome = { alias: a2.name, exitCode: 1, stdout: "", stderr: error.message };
|
|
12400
|
+
if (!flags.json) printOutcome(outcome, width);
|
|
12401
|
+
return outcome;
|
|
12402
|
+
}
|
|
12403
|
+
});
|
|
12404
|
+
const failed = outcomes.filter((o2) => o2.exitCode !== 0);
|
|
12405
|
+
if (flags.json) {
|
|
12406
|
+
console.log(JSON.stringify({ version: 1, command: cliArgs, results: outcomes }, null, 2));
|
|
12407
|
+
} else {
|
|
12408
|
+
const summary = `${outcomes.length - failed.length} ok${failed.length ? `, ${failed.length} failed: ${failed.map((f2) => f2.alias).join(", ")}` : ""}`;
|
|
12409
|
+
process.stderr.write(`${failed.length ? paint("red", summary) : paint("green", summary)}
|
|
12410
|
+
`);
|
|
12411
|
+
}
|
|
12412
|
+
return failed.length ? 1 : 0;
|
|
12413
|
+
}
|
|
12414
|
+
function printOutcome({ alias, exitCode, stdout: stdout2, stderr }, width) {
|
|
12415
|
+
const tag = paint(exitCode === 0 ? "sky" : "red", `[${alias}]`.padEnd(width + 2));
|
|
12416
|
+
for (const line of stdout2.replace(/\n$/, "").split("\n"))
|
|
12417
|
+
if (line) process.stdout.write(`${tag} ${line}
|
|
12418
|
+
`);
|
|
12419
|
+
for (const line of stderr.replace(/\n$/, "").split("\n"))
|
|
12420
|
+
if (line) process.stderr.write(`${tag} ${paint("dim", line)}
|
|
12421
|
+
`);
|
|
12422
|
+
if (exitCode !== 0) process.stderr.write(`${tag} ${paint("red", `exit ${exitCode}`)}
|
|
12423
|
+
`);
|
|
12424
|
+
}
|
|
12425
|
+
|
|
12020
12426
|
// src/commands/manage.ts
|
|
12021
12427
|
function printReport(report) {
|
|
12022
12428
|
const changes = [
|
|
@@ -12113,8 +12519,9 @@ async function listCommand(app, args = []) {
|
|
|
12113
12519
|
);
|
|
12114
12520
|
for (const { alias, target, region } of own) {
|
|
12115
12521
|
const label = target ? target.label : paint("red", `${alias.target} (no longer available)`);
|
|
12522
|
+
const tag = alias.prod ? ` ${paint("red", "PROD")}` : "";
|
|
12116
12523
|
console.log(
|
|
12117
|
-
` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}`
|
|
12524
|
+
` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}${tag}`
|
|
12118
12525
|
);
|
|
12119
12526
|
}
|
|
12120
12527
|
}
|
|
@@ -12200,16 +12607,16 @@ async function regionCommand(app, args) {
|
|
|
12200
12607
|
})
|
|
12201
12608
|
);
|
|
12202
12609
|
const current = await app.resolveAlias(name);
|
|
12203
|
-
const
|
|
12610
|
+
const validate2 = regionValidator(app, current.source);
|
|
12204
12611
|
const region = regionArg?.trim() ?? (answer(
|
|
12205
12612
|
await dist_exports.text({
|
|
12206
12613
|
message: `Region for ${name}`,
|
|
12207
12614
|
placeholder: current.region,
|
|
12208
12615
|
defaultValue: current.region,
|
|
12209
|
-
validate: (v) => v?.trim() ?
|
|
12616
|
+
validate: (v) => v?.trim() ? validate2?.(v.trim()) : void 0
|
|
12210
12617
|
})
|
|
12211
12618
|
).trim() || current.region);
|
|
12212
|
-
const problem = region ?
|
|
12619
|
+
const problem = region ? validate2?.(region) : "Say which region.";
|
|
12213
12620
|
if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
|
|
12214
12621
|
await app.setAliasRegion(name, region);
|
|
12215
12622
|
console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
|
|
@@ -12234,7 +12641,7 @@ async function statusCommand(app, args) {
|
|
|
12234
12641
|
if (flags.json) return printJson({ alias: status });
|
|
12235
12642
|
const account = status.attributes.accountId ? ` (${status.attributes.accountId})` : "";
|
|
12236
12643
|
console.log(
|
|
12237
|
-
`${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}`
|
|
12644
|
+
`${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}${status.prod ? ` ${paint("red", "PROD")}` : ""}`
|
|
12238
12645
|
);
|
|
12239
12646
|
console.log(` ${STATE_SHORT[status.session]}: ${NEXT_TEXT[status.next]}`);
|
|
12240
12647
|
if (status.next === "browser-sign-in") {
|
|
@@ -12255,10 +12662,61 @@ async function statusCommand(app, args) {
|
|
|
12255
12662
|
);
|
|
12256
12663
|
}
|
|
12257
12664
|
}
|
|
12665
|
+
async function prodCommand(app, args) {
|
|
12666
|
+
const { positional, flags } = parseArgs(args);
|
|
12667
|
+
if (positional.length === 0) {
|
|
12668
|
+
const prod = (await app.overview()).aliases.filter((a2) => a2.prod).map((a2) => a2.name);
|
|
12669
|
+
console.log(
|
|
12670
|
+
prod.length ? `Production aliases: ${prod.join(", ")}` : "No alias is marked as production."
|
|
12671
|
+
);
|
|
12672
|
+
console.log(paint("dim", "Mark one with `cloudhop prod <alias>`, unmark with `--off`."));
|
|
12673
|
+
return;
|
|
12674
|
+
}
|
|
12675
|
+
const on = !flags.off;
|
|
12676
|
+
await app.setProduction(positional, on);
|
|
12677
|
+
for (const name of positional) {
|
|
12678
|
+
console.log(
|
|
12679
|
+
on ? `${paint("red", "PROD")} ${name}: cloudhop asks before any change.` : `${name} is no longer marked as production.`
|
|
12680
|
+
);
|
|
12681
|
+
}
|
|
12682
|
+
}
|
|
12683
|
+
async function renameCommand(app, args) {
|
|
12684
|
+
const [from, to] = args;
|
|
12685
|
+
if (!from || !to)
|
|
12686
|
+
throw new CloudhopError("INVALID_NAME", "Usage: cloudhop mv <alias> <new-name>");
|
|
12687
|
+
await app.renameAlias(from, to);
|
|
12688
|
+
console.log(`${from} \u2192 ${to}`);
|
|
12689
|
+
}
|
|
12258
12690
|
|
|
12259
12691
|
// src/commands/run.ts
|
|
12692
|
+
async function guardProduction(app, alias, args) {
|
|
12693
|
+
const status = await app.aliasStatus(alias).catch(() => void 0);
|
|
12694
|
+
if (!status?.prod) return;
|
|
12695
|
+
const where = `${status.label ?? status.target}${status.region ? `, ${status.region}` : ""}`;
|
|
12696
|
+
if (!await app.needsProductionConfirmation(alias, args)) {
|
|
12697
|
+
if (process.stderr.isTTY) notify(paint("red", `${alias} is production (${where})`));
|
|
12698
|
+
return;
|
|
12699
|
+
}
|
|
12700
|
+
if (process.env.CLOUDHOP_CONFIRM_PROD === "1") return;
|
|
12701
|
+
const what = args ? `aws ${args.join(" ")}` : "this command";
|
|
12702
|
+
if (!interactive()) {
|
|
12703
|
+
throw new CloudhopError(
|
|
12704
|
+
"PROD_CONFIRMATION_REQUIRED",
|
|
12705
|
+
`${alias} is production and ${what} may change resources.`,
|
|
12706
|
+
"Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
|
|
12707
|
+
);
|
|
12708
|
+
}
|
|
12709
|
+
dist_exports.log.warn(
|
|
12710
|
+
`${paint("red", bold("PRODUCTION"))} ${bold(alias)} \u2192 ${where}
|
|
12711
|
+
${paint("dim", "This may change resources:")} ${what}`
|
|
12712
|
+
);
|
|
12713
|
+
const ok = answer(await dist_exports.confirm({ message: `Run it in ${alias}?`, initialValue: false }));
|
|
12714
|
+
if (!ok) throw new CloudhopError("PROD_CONFIRMATION_REQUIRED", "Cancelled: nothing ran.");
|
|
12715
|
+
}
|
|
12260
12716
|
async function proxyCommand(app, alias, args) {
|
|
12261
|
-
|
|
12717
|
+
const cliArgs = args.length > 0 ? args : await app.identityArgs(alias);
|
|
12718
|
+
await guardProduction(app, alias, cliArgs);
|
|
12719
|
+
return app.runAsAlias(alias, cliArgs);
|
|
12262
12720
|
}
|
|
12263
12721
|
async function execCommand(app, args) {
|
|
12264
12722
|
const separator = args.indexOf("--");
|
|
@@ -12272,6 +12730,7 @@ async function execCommand(app, args) {
|
|
|
12272
12730
|
const regionIndex = flags.indexOf("--region");
|
|
12273
12731
|
if (regionIndex !== -1 && flags[regionIndex + 1])
|
|
12274
12732
|
overrides.region = flags[regionIndex + 1];
|
|
12733
|
+
await guardProduction(app, alias);
|
|
12275
12734
|
const [bin, ...rest] = command;
|
|
12276
12735
|
return app.execAsAlias(alias, bin, rest, overrides);
|
|
12277
12736
|
}
|
|
@@ -12371,6 +12830,408 @@ async function doctorCommand(app) {
|
|
|
12371
12830
|
);
|
|
12372
12831
|
}
|
|
12373
12832
|
|
|
12833
|
+
// src/commands/shell.ts
|
|
12834
|
+
import { existsSync as existsSync3 } from "fs";
|
|
12835
|
+
import { appendFile, mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
|
|
12836
|
+
import { homedir as homedir3 } from "os";
|
|
12837
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
12838
|
+
var SHELLS = ["zsh", "bash", "fish", "pwsh"];
|
|
12839
|
+
var ALIAS_COMMANDS = "use status console region mv prod rm exec";
|
|
12840
|
+
var SOURCE_COMMANDS = "alias login sync ls";
|
|
12841
|
+
var SCRIPTS = {
|
|
12842
|
+
zsh: `# cloudhop shell integration (zsh). Load it after compinit: eval "$(cloudhop shell zsh)"
|
|
12843
|
+
cloudhop() {
|
|
12844
|
+
if [ "$1" = "use" ]; then
|
|
12845
|
+
local out; out="$(command cloudhop use --emit zsh "\${@:2}")" || return $?
|
|
12846
|
+
eval "$out"
|
|
12847
|
+
else
|
|
12848
|
+
command cloudhop "$@"
|
|
12849
|
+
fi
|
|
12850
|
+
}
|
|
12851
|
+
_cloudhop_words() { command cloudhop __complete "$1" 2>/dev/null }
|
|
12852
|
+
_hop() { (( CURRENT == 2 )) && { local -a a; a=(\${(f)"$(_cloudhop_words aliases)"}); compadd -a a; } }
|
|
12853
|
+
_cloudhop() {
|
|
12854
|
+
local -a w
|
|
12855
|
+
if (( CURRENT == 2 )); then w=(\${(f)"$(_cloudhop_words commands)"}); compadd -a w; return; fi
|
|
12856
|
+
(( CURRENT == 3 )) || return
|
|
12857
|
+
case " ${ALIAS_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words aliases)"});; esac
|
|
12858
|
+
case " ${SOURCE_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words sources)"});; esac
|
|
12859
|
+
compadd -a w
|
|
12860
|
+
}
|
|
12861
|
+
(( $+functions[compdef] )) && { compdef _hop hop; compdef _cloudhop cloudhop; }
|
|
12862
|
+
# Add $(cloudhop_prompt) to your prompt to see the alias set with \`cloudhop use\`.
|
|
12863
|
+
cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
|
|
12864
|
+
`,
|
|
12865
|
+
bash: `# cloudhop shell integration (bash): eval "$(cloudhop shell bash)"
|
|
12866
|
+
cloudhop() {
|
|
12867
|
+
if [ "$1" = "use" ]; then
|
|
12868
|
+
local out; out="$(command cloudhop use --emit bash "\${@:2}")" || return $?
|
|
12869
|
+
eval "$out"
|
|
12870
|
+
else
|
|
12871
|
+
command cloudhop "$@"
|
|
12872
|
+
fi
|
|
12873
|
+
}
|
|
12874
|
+
_cloudhop_complete() {
|
|
12875
|
+
local cur=\${COMP_WORDS[COMP_CWORD]} cmd=\${COMP_WORDS[0]##*/} kind=""
|
|
12876
|
+
if [ "$cmd" = hop ]; then [ "$COMP_CWORD" -eq 1 ] && kind=aliases
|
|
12877
|
+
elif [ "$COMP_CWORD" -eq 1 ]; then kind=commands
|
|
12878
|
+
elif [ "$COMP_CWORD" -eq 2 ]; then
|
|
12879
|
+
case " ${ALIAS_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=aliases;; esac
|
|
12880
|
+
case " ${SOURCE_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=sources;; esac
|
|
12881
|
+
fi
|
|
12882
|
+
[ -n "$kind" ] && COMPREPLY=($(compgen -W "$(command cloudhop __complete $kind 2>/dev/null)" -- "$cur"))
|
|
12883
|
+
}
|
|
12884
|
+
complete -o default -F _cloudhop_complete hop cloudhop
|
|
12885
|
+
# Add $(cloudhop_prompt) to PS1 to see the alias set with \`cloudhop use\`.
|
|
12886
|
+
cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
|
|
12887
|
+
`,
|
|
12888
|
+
fish: `# cloudhop shell integration (fish): cloudhop shell fish | source
|
|
12889
|
+
function cloudhop
|
|
12890
|
+
if test "$argv[1]" = use
|
|
12891
|
+
set -l out (command cloudhop use --emit fish $argv[2..-1]); or return $status
|
|
12892
|
+
printf '%s\\n' $out | source
|
|
12893
|
+
else
|
|
12894
|
+
command cloudhop $argv
|
|
12895
|
+
end
|
|
12896
|
+
end
|
|
12897
|
+
complete -c hop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete aliases 2>/dev/null)'
|
|
12898
|
+
complete -c cloudhop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete commands 2>/dev/null)'
|
|
12899
|
+
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)'
|
|
12900
|
+
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)'
|
|
12901
|
+
# Call cloudhop_prompt from fish_prompt to see the alias set with \`cloudhop use\`.
|
|
12902
|
+
function cloudhop_prompt
|
|
12903
|
+
test -n "$CLOUDHOP_ALIAS"; and printf '\u2601 %s%s' $CLOUDHOP_ALIAS (test -n "$CLOUDHOP_PROD"; and echo ' PROD')
|
|
12904
|
+
end
|
|
12905
|
+
`,
|
|
12906
|
+
pwsh: `# cloudhop shell integration (PowerShell): cloudhop shell pwsh | Out-String | Invoke-Expression
|
|
12907
|
+
$script:CloudhopExe = (Get-Command cloudhop -CommandType Application, ExternalScript | Select-Object -First 1).Source
|
|
12908
|
+
function cloudhop {
|
|
12909
|
+
if ($args.Count -gt 0 -and $args[0] -eq 'use') {
|
|
12910
|
+
$out = & $script:CloudhopExe use --emit pwsh @($args | Select-Object -Skip 1)
|
|
12911
|
+
if ($LASTEXITCODE -eq 0) { $out | Out-String | Invoke-Expression }
|
|
12912
|
+
} else { & $script:CloudhopExe @args }
|
|
12913
|
+
}
|
|
12914
|
+
Register-ArgumentCompleter -Native -CommandName hop, cloudhop -ScriptBlock {
|
|
12915
|
+
param($word, $ast)
|
|
12916
|
+
$n = $ast.CommandElements.Count - [int]($word -eq '')
|
|
12917
|
+
$kind = if ($ast.CommandElements[0].Value -match 'hop$' -and $ast.CommandElements[0].Value -notmatch 'cloudhop') { if ($n -le 1) { 'aliases' } }
|
|
12918
|
+
elseif ($n -le 1) { 'commands' }
|
|
12919
|
+
elseif ($n -eq 2 -and ' ${ALIAS_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'aliases' }
|
|
12920
|
+
elseif ($n -eq 2 -and ' ${SOURCE_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'sources' }
|
|
12921
|
+
if ($kind) { & $script:CloudhopExe __complete $kind | Where-Object { $_ -like "$word*" } }
|
|
12922
|
+
}
|
|
12923
|
+
function cloudhop_prompt { if ($env:CLOUDHOP_ALIAS) { "\u2601 $env:CLOUDHOP_ALIAS$(if ($env:CLOUDHOP_PROD) { ' PROD' })" } }
|
|
12924
|
+
`
|
|
12925
|
+
};
|
|
12926
|
+
var quote = (shell, value) => shell === "pwsh" ? `'${value.replace(/'/g, "''")}'` : `'${value.replace(/'/g, "'\\''")}'`;
|
|
12927
|
+
function shellExports(shell, env) {
|
|
12928
|
+
return Object.entries(env).map(([key, value]) => {
|
|
12929
|
+
if (value === void 0) {
|
|
12930
|
+
if (shell === "fish") return `set -e ${key}`;
|
|
12931
|
+
if (shell === "pwsh") return `Remove-Item Env:${key} -ErrorAction SilentlyContinue`;
|
|
12932
|
+
return `unset ${key}`;
|
|
12933
|
+
}
|
|
12934
|
+
if (shell === "fish") return `set -gx ${key} ${quote(shell, value)}`;
|
|
12935
|
+
if (shell === "pwsh") return `$env:${key} = ${quote(shell, value)}`;
|
|
12936
|
+
return `export ${key}=${quote(shell, value)}`;
|
|
12937
|
+
}).join("\n");
|
|
12938
|
+
}
|
|
12939
|
+
function detectShell() {
|
|
12940
|
+
if (process.platform === "win32") return "pwsh";
|
|
12941
|
+
const name = (process.env.SHELL ?? "").split("/").pop();
|
|
12942
|
+
return SHELLS.includes(name) ? name : "bash";
|
|
12943
|
+
}
|
|
12944
|
+
function parseShell(value) {
|
|
12945
|
+
if (!value) return detectShell();
|
|
12946
|
+
const shell = value === "powershell" ? "pwsh" : value;
|
|
12947
|
+
if (!SHELLS.includes(shell)) {
|
|
12948
|
+
throw new CloudhopError(
|
|
12949
|
+
"INVALID_NAME",
|
|
12950
|
+
`Unknown shell "${value}".`,
|
|
12951
|
+
`Use one of: ${SHELLS.join(", ")}.`
|
|
12952
|
+
);
|
|
12953
|
+
}
|
|
12954
|
+
return shell;
|
|
12955
|
+
}
|
|
12956
|
+
var RC_FILES = {
|
|
12957
|
+
zsh: (home) => join5(home, ".zshrc"),
|
|
12958
|
+
bash: (home) => join5(home, ".bashrc"),
|
|
12959
|
+
fish: (home) => join5(home, ".config", "fish", "config.fish"),
|
|
12960
|
+
pwsh: (home) => process.platform === "win32" ? join5(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") : join5(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")
|
|
12961
|
+
};
|
|
12962
|
+
var LOAD_LINE = {
|
|
12963
|
+
zsh: 'eval "$(cloudhop shell zsh)"',
|
|
12964
|
+
bash: 'eval "$(cloudhop shell bash)"',
|
|
12965
|
+
fish: "cloudhop shell fish | source",
|
|
12966
|
+
pwsh: "cloudhop shell pwsh | Out-String | Invoke-Expression"
|
|
12967
|
+
};
|
|
12968
|
+
async function shellCommand(args) {
|
|
12969
|
+
const { positional, flags } = parseArgs(args);
|
|
12970
|
+
if (positional[0] !== "install") {
|
|
12971
|
+
process.stdout.write(SCRIPTS[parseShell(positional[0])]);
|
|
12972
|
+
return;
|
|
12973
|
+
}
|
|
12974
|
+
const shell = parseShell(positional[1]);
|
|
12975
|
+
const file = RC_FILES[shell](homedir3());
|
|
12976
|
+
const line = LOAD_LINE[shell];
|
|
12977
|
+
const current = existsSync3(file) ? await readFile3(file, "utf8") : "";
|
|
12978
|
+
if (current.includes(line)) {
|
|
12979
|
+
console.log(`Already set up in ${file.replace(homedir3(), "~")}.`);
|
|
12980
|
+
return;
|
|
12981
|
+
}
|
|
12982
|
+
if (!flags.yes) {
|
|
12983
|
+
if (!interactive())
|
|
12984
|
+
throw new CloudhopError(
|
|
12985
|
+
"NOT_INTERACTIVE",
|
|
12986
|
+
`Add this line to ${file} yourself, or rerun with --yes:
|
|
12987
|
+
${line}`
|
|
12988
|
+
);
|
|
12989
|
+
const ok = answer(
|
|
12990
|
+
await dist_exports.confirm({
|
|
12991
|
+
message: `Add cloudhop to ${file.replace(homedir3(), "~")}?`,
|
|
12992
|
+
initialValue: true
|
|
12993
|
+
})
|
|
12994
|
+
);
|
|
12995
|
+
if (!ok) return;
|
|
12996
|
+
}
|
|
12997
|
+
await mkdir3(dirname3(file), { recursive: true });
|
|
12998
|
+
const note2 = shell === "zsh" ? " # after compinit" : "";
|
|
12999
|
+
await appendFile(
|
|
13000
|
+
file,
|
|
13001
|
+
`${current && !current.endsWith("\n") ? "\n" : ""}
|
|
13002
|
+
# cloudhop: \`cloudhop use\`, tab completion and cloudhop_prompt${note2}
|
|
13003
|
+
${line}
|
|
13004
|
+
`
|
|
13005
|
+
);
|
|
13006
|
+
console.log(
|
|
13007
|
+
`${paint("green", "\u2714")} Added to ${file.replace(homedir3(), "~")}. Open a new terminal to use it.`
|
|
13008
|
+
);
|
|
13009
|
+
}
|
|
13010
|
+
async function useCommand(app, args) {
|
|
13011
|
+
const { positional, flags } = parseArgs(args);
|
|
13012
|
+
const shell = flags.emit ? parseShell(flags.emit) : void 0;
|
|
13013
|
+
if (!shell) {
|
|
13014
|
+
throw new CloudhopError(
|
|
13015
|
+
"NOT_INTERACTIVE",
|
|
13016
|
+
"`cloudhop use` needs the shell integration to change your current shell.",
|
|
13017
|
+
`Set it up once with \`cloudhop shell install\` (or add ${LOAD_LINE[detectShell()]} to your shell's startup file), then open a new terminal.`
|
|
13018
|
+
);
|
|
13019
|
+
}
|
|
13020
|
+
const clear = {
|
|
13021
|
+
AWS_ACCESS_KEY_ID: void 0,
|
|
13022
|
+
AWS_SECRET_ACCESS_KEY: void 0,
|
|
13023
|
+
AWS_SESSION_TOKEN: void 0
|
|
13024
|
+
};
|
|
13025
|
+
if (flags.off) {
|
|
13026
|
+
process.stdout.write(
|
|
13027
|
+
`${shellExports(shell, { ...clear, AWS_PROFILE: void 0, AWS_REGION: void 0, AWS_DEFAULT_REGION: void 0, CLOUDHOP_ALIAS: void 0, CLOUDHOP_PROD: void 0 })}
|
|
13028
|
+
`
|
|
13029
|
+
);
|
|
13030
|
+
notify("No alias in this shell anymore.");
|
|
13031
|
+
return;
|
|
13032
|
+
}
|
|
13033
|
+
const alias = positional[0] ?? await pickAlias(app, "Use which alias in this shell?");
|
|
13034
|
+
const env = await app.envForAlias(alias, "cli");
|
|
13035
|
+
const status = await app.aliasStatus(alias);
|
|
13036
|
+
process.stdout.write(
|
|
13037
|
+
`${shellExports(shell, { ...clear, ...env, CLOUDHOP_ALIAS: alias, CLOUDHOP_PROD: status.prod ? "1" : void 0 })}
|
|
13038
|
+
`
|
|
13039
|
+
);
|
|
13040
|
+
notify(
|
|
13041
|
+
`This shell now uses ${alias} (${status.label ?? status.target}, ${status.region ?? "no region"}).`
|
|
13042
|
+
);
|
|
13043
|
+
if (status.prod) {
|
|
13044
|
+
notify(
|
|
13045
|
+
paint(
|
|
13046
|
+
"red",
|
|
13047
|
+
`${alias} is production. Plain \`aws\` commands are not protected here: use \`hop ${alias}\` to be asked before changes.`
|
|
13048
|
+
)
|
|
13049
|
+
);
|
|
13050
|
+
}
|
|
13051
|
+
}
|
|
13052
|
+
async function completeCommand(app, kind) {
|
|
13053
|
+
if (kind === "commands") {
|
|
13054
|
+
console.log(
|
|
13055
|
+
[...RESERVED_NAMES, "each", "shell", "use"].filter((c3, i2, a2) => a2.indexOf(c3) === i2).sort().join("\n")
|
|
13056
|
+
);
|
|
13057
|
+
return;
|
|
13058
|
+
}
|
|
13059
|
+
const config = await app.config();
|
|
13060
|
+
const words = kind === "sources" ? config.sources.map((s) => s.id) : config.aliases.map((a2) => a2.name);
|
|
13061
|
+
if (words.length) console.log(words.join("\n"));
|
|
13062
|
+
}
|
|
13063
|
+
|
|
13064
|
+
// src/commands/team.ts
|
|
13065
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
13066
|
+
var import_yaml2 = __toESM(require_dist(), 1);
|
|
13067
|
+
var MAX_BYTES = 256 * 1024;
|
|
13068
|
+
var HEADER2 = `# cloudhop team setup: import it with \`cloudhop team import <this file or its https URL>\`.
|
|
13069
|
+
# No secrets: portal URLs, regions, account ids and role names only.
|
|
13070
|
+
`;
|
|
13071
|
+
function validate(app, raw) {
|
|
13072
|
+
const bad = (why) => new CloudhopError("INVALID_NAME", `Not a cloudhop team file: ${why}.`);
|
|
13073
|
+
if (!raw || typeof raw !== "object") throw bad("it is empty");
|
|
13074
|
+
const file = raw;
|
|
13075
|
+
if (file.version !== 1) throw bad('unknown "version" (expected 1)');
|
|
13076
|
+
const sources = file.sources ?? {};
|
|
13077
|
+
const aliases = file.aliases ?? {};
|
|
13078
|
+
for (const [id, source] of Object.entries(sources)) {
|
|
13079
|
+
assertValidName("source", id);
|
|
13080
|
+
if (!PROVIDER_IDS.includes(source?.provider))
|
|
13081
|
+
throw bad(`source "${id}" has an unknown provider`);
|
|
13082
|
+
const kind = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind);
|
|
13083
|
+
if (!kind) throw bad(`source "${id}" has an unknown kind`);
|
|
13084
|
+
for (const field of kind.fields) {
|
|
13085
|
+
const value = source.settings?.[field.key];
|
|
13086
|
+
if (typeof value !== "string" || !value) throw bad(`source "${id}" is missing ${field.key}`);
|
|
13087
|
+
const problem = field.validate?.(value);
|
|
13088
|
+
if (problem) throw bad(`source "${id}": ${problem}`);
|
|
13089
|
+
}
|
|
13090
|
+
}
|
|
13091
|
+
for (const [name, alias] of Object.entries(aliases)) {
|
|
13092
|
+
assertValidName("alias", name);
|
|
13093
|
+
if (!alias || typeof alias.target !== "string" || !sources[alias.source]) {
|
|
13094
|
+
throw bad(`alias "${name}" must name a source of this file and a target`);
|
|
13095
|
+
}
|
|
13096
|
+
}
|
|
13097
|
+
return { version: 1, sources, aliases };
|
|
13098
|
+
}
|
|
13099
|
+
async function load(location) {
|
|
13100
|
+
if (/^http:\/\//i.test(location)) throw new CloudhopError("INVALID_NAME", "Use an https:// URL.");
|
|
13101
|
+
if (/^https:\/\//i.test(location)) {
|
|
13102
|
+
const response = await fetch(location, { signal: AbortSignal.timeout(1e4) });
|
|
13103
|
+
if (!response.ok)
|
|
13104
|
+
throw new CloudhopError("INVALID_NAME", `Could not download it (HTTP ${response.status}).`);
|
|
13105
|
+
const text4 = await response.text();
|
|
13106
|
+
if (text4.length > MAX_BYTES)
|
|
13107
|
+
throw new CloudhopError("INVALID_NAME", "That file is too big for a team setup.");
|
|
13108
|
+
return text4;
|
|
13109
|
+
}
|
|
13110
|
+
return readFile4(location, "utf8");
|
|
13111
|
+
}
|
|
13112
|
+
async function teamCommand(app, args) {
|
|
13113
|
+
const { positional, flags } = parseArgs(args);
|
|
13114
|
+
const [action, location] = positional;
|
|
13115
|
+
if (action === "export") {
|
|
13116
|
+
const config2 = await app.config();
|
|
13117
|
+
const wanted = flags.sources?.split(",").map((s) => s.trim());
|
|
13118
|
+
const sources = config2.sources.filter(
|
|
13119
|
+
(s) => s.kind !== "console-login" && (!wanted || wanted.includes(s.id))
|
|
13120
|
+
);
|
|
13121
|
+
const ids = new Set(sources.map((s) => s.id));
|
|
13122
|
+
const file = {
|
|
13123
|
+
version: 1,
|
|
13124
|
+
sources: Object.fromEntries(sources.map(({ id, ...rest }) => [id, rest])),
|
|
13125
|
+
aliases: Object.fromEntries(
|
|
13126
|
+
config2.aliases.filter((a2) => ids.has(a2.source)).map(({ name, ...rest }) => [name, rest])
|
|
13127
|
+
)
|
|
13128
|
+
};
|
|
13129
|
+
const text4 = HEADER2 + (0, import_yaml2.stringify)(file);
|
|
13130
|
+
if (flags.out) {
|
|
13131
|
+
await writeFile3(flags.out, text4);
|
|
13132
|
+
console.error(
|
|
13133
|
+
`${paint("green", "\u2714")} Wrote ${sources.length} source(s) and ${Object.keys(file.aliases).length} alias(es) to ${flags.out}.`
|
|
13134
|
+
);
|
|
13135
|
+
} else {
|
|
13136
|
+
process.stdout.write(text4);
|
|
13137
|
+
}
|
|
13138
|
+
return;
|
|
13139
|
+
}
|
|
13140
|
+
if (action !== "import" || !location) {
|
|
13141
|
+
throw new CloudhopError(
|
|
13142
|
+
"INVALID_NAME",
|
|
13143
|
+
"Usage: cloudhop team export [--out file] | cloudhop team import <file|https URL>"
|
|
13144
|
+
);
|
|
13145
|
+
}
|
|
13146
|
+
const team = validate(app, (0, import_yaml2.parse)(await load(location)));
|
|
13147
|
+
const config = await app.config();
|
|
13148
|
+
const existingSources = new Map(config.sources.map((s) => [s.id, s]));
|
|
13149
|
+
const sameUrl = (a2, b3) => a2.startUrl !== void 0 && a2.startUrl === b3.startUrl;
|
|
13150
|
+
const newSources = [];
|
|
13151
|
+
const sourceMap = /* @__PURE__ */ new Map();
|
|
13152
|
+
for (const [id, source] of Object.entries(team.sources)) {
|
|
13153
|
+
const match = config.sources.find(
|
|
13154
|
+
(s) => s.provider === source.provider && sameUrl(s.settings, source.settings)
|
|
13155
|
+
);
|
|
13156
|
+
if (match) {
|
|
13157
|
+
sourceMap.set(id, match.id);
|
|
13158
|
+
continue;
|
|
13159
|
+
}
|
|
13160
|
+
if (existingSources.has(id))
|
|
13161
|
+
throw new CloudhopError(
|
|
13162
|
+
"SOURCE_EXISTS",
|
|
13163
|
+
`You already have a different source named "${id}".`,
|
|
13164
|
+
"Rename yours with `cloudhop rm` and add it again, or edit the team file."
|
|
13165
|
+
);
|
|
13166
|
+
newSources.push({ id, ...source });
|
|
13167
|
+
sourceMap.set(id, id);
|
|
13168
|
+
}
|
|
13169
|
+
const takenAliases = new Set(config.aliases.map((a2) => a2.name));
|
|
13170
|
+
const newAliases = [];
|
|
13171
|
+
const skipped = [];
|
|
13172
|
+
for (const [name, alias] of Object.entries(team.aliases)) {
|
|
13173
|
+
if (takenAliases.has(name)) skipped.push(name);
|
|
13174
|
+
else newAliases.push({ name, ...alias, source: sourceMap.get(alias.source) });
|
|
13175
|
+
}
|
|
13176
|
+
if (newSources.length === 0 && newAliases.length === 0) {
|
|
13177
|
+
console.log("Nothing to import: you already have everything in this file.");
|
|
13178
|
+
return;
|
|
13179
|
+
}
|
|
13180
|
+
console.log(bold("This team setup will add:"));
|
|
13181
|
+
for (const s of newSources)
|
|
13182
|
+
console.log(
|
|
13183
|
+
` source ${bold(s.id)} ${paint("sky", s.settings.startUrl ?? s.kind)} ${s.settings.region ?? ""}`
|
|
13184
|
+
);
|
|
13185
|
+
for (const a2 of newAliases)
|
|
13186
|
+
console.log(` alias ${a2.name} ${a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`);
|
|
13187
|
+
if (skipped.length)
|
|
13188
|
+
console.log(paint("dim", ` (skipping aliases you already have: ${skipped.join(", ")})`));
|
|
13189
|
+
if (newSources.length)
|
|
13190
|
+
console.log(
|
|
13191
|
+
paint("yellow", "Check that these portal URLs are your organization's before signing in.")
|
|
13192
|
+
);
|
|
13193
|
+
if (!flags.yes) {
|
|
13194
|
+
if (!interactive())
|
|
13195
|
+
throw new CloudhopError(
|
|
13196
|
+
"NOT_INTERACTIVE",
|
|
13197
|
+
"Review the list above and rerun with --yes to import it."
|
|
13198
|
+
);
|
|
13199
|
+
if (!answer(
|
|
13200
|
+
await dist_exports.confirm({
|
|
13201
|
+
message: "Import it? Each new portal opens your browser to sign in.",
|
|
13202
|
+
initialValue: true
|
|
13203
|
+
})
|
|
13204
|
+
))
|
|
13205
|
+
return;
|
|
13206
|
+
}
|
|
13207
|
+
const providers = new Set((await app.config()).providers);
|
|
13208
|
+
for (const s of newSources) if (!providers.has(s.provider)) await app.enableProvider(s.provider);
|
|
13209
|
+
for (const source of newSources) {
|
|
13210
|
+
const report = await app.connectSource(source);
|
|
13211
|
+
console.log(
|
|
13212
|
+
`${paint("green", "\u2714")} ${source.id}: signed in, ${report.targets.length} account/role(s).`
|
|
13213
|
+
);
|
|
13214
|
+
}
|
|
13215
|
+
const usable = [];
|
|
13216
|
+
const missing = [];
|
|
13217
|
+
for (const alias of newAliases) {
|
|
13218
|
+
const targets = await app.listTargets(alias.source);
|
|
13219
|
+
if (targets.some((t2) => t2.id === alias.target)) usable.push(alias);
|
|
13220
|
+
else missing.push(alias.name);
|
|
13221
|
+
}
|
|
13222
|
+
if (usable.length) await app.addAliases(usable);
|
|
13223
|
+
console.log(
|
|
13224
|
+
`${paint("green", "\u2714")} Created ${usable.length} alias(es)${usable.length ? `: ${usable.map((a2) => a2.name).join(", ")}` : ""}.`
|
|
13225
|
+
);
|
|
13226
|
+
if (missing.length)
|
|
13227
|
+
console.log(
|
|
13228
|
+
paint(
|
|
13229
|
+
"yellow",
|
|
13230
|
+
`Not created, you don't have access to their account/role: ${missing.join(", ")}.`
|
|
13231
|
+
)
|
|
13232
|
+
);
|
|
13233
|
+
}
|
|
13234
|
+
|
|
12374
13235
|
// ../ui/src/api.ts
|
|
12375
13236
|
var text3 = (body, key) => {
|
|
12376
13237
|
const value = body[key];
|
|
@@ -12409,8 +13270,8 @@ function createApi(app, assistants) {
|
|
|
12409
13270
|
return assistants;
|
|
12410
13271
|
};
|
|
12411
13272
|
const regionCheck = (source, region) => {
|
|
12412
|
-
const
|
|
12413
|
-
const problem = region ?
|
|
13273
|
+
const validate2 = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
|
|
13274
|
+
const problem = region ? validate2?.(region) : "Region is required.";
|
|
12414
13275
|
if (problem) throw badRequest(problem);
|
|
12415
13276
|
};
|
|
12416
13277
|
const findSource = async (id) => {
|
|
@@ -12461,7 +13322,8 @@ function createApi(app, assistants) {
|
|
|
12461
13322
|
target: alias.target,
|
|
12462
13323
|
label: target?.label,
|
|
12463
13324
|
missing: !target,
|
|
12464
|
-
region
|
|
13325
|
+
region,
|
|
13326
|
+
prod: alias.prod === true
|
|
12465
13327
|
}))
|
|
12466
13328
|
};
|
|
12467
13329
|
},
|
|
@@ -12556,12 +13418,28 @@ function createApi(app, assistants) {
|
|
|
12556
13418
|
await app.addAliases(aliases);
|
|
12557
13419
|
return { created: aliases.map((a2) => a2.name) };
|
|
12558
13420
|
},
|
|
13421
|
+
/** Changes an alias: any of `region`, `prod` (production mark) and `name` (rename, applied last). */
|
|
12559
13422
|
"PATCH /api/aliases/:name": async ([name], body) => {
|
|
12560
|
-
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
13423
|
+
let current = name;
|
|
13424
|
+
if ("region" in body) {
|
|
13425
|
+
const resolved = await app.resolveAlias(current);
|
|
13426
|
+
const region = text3(body, "region");
|
|
13427
|
+
regionCheck(resolved.source, region);
|
|
13428
|
+
await app.setAliasRegion(current, region);
|
|
13429
|
+
}
|
|
13430
|
+
if (typeof body.prod === "boolean") await app.setProduction([current], body.prod);
|
|
13431
|
+
if ("name" in body) {
|
|
13432
|
+
const to = text3(body, "name");
|
|
13433
|
+
await app.renameAlias(current, to);
|
|
13434
|
+
current = to;
|
|
13435
|
+
}
|
|
13436
|
+
return { name: current };
|
|
13437
|
+
},
|
|
13438
|
+
/** Opens the web console in the user's browser; the sign-in URL never reaches the page. */
|
|
13439
|
+
"POST /api/aliases/:name/console": async ([name], body) => {
|
|
13440
|
+
const service = text3(body, "service");
|
|
13441
|
+
const via = await app.openConsole(name, service ? { service } : {});
|
|
13442
|
+
return { via };
|
|
12565
13443
|
},
|
|
12566
13444
|
"DELETE /api/aliases/:name": async ([name]) => {
|
|
12567
13445
|
await app.removeAlias(name);
|
|
@@ -12598,7 +13476,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
|
|
|
12598
13476
|
import { createServer } from "http";
|
|
12599
13477
|
|
|
12600
13478
|
// ../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";
|
|
13479
|
+
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
13480
|
|
|
12603
13481
|
// ../ui/src/assets/index.html
|
|
12604
13482
|
var assets_default = `<!doctype html>
|
|
@@ -12729,9 +13607,29 @@ var assets_default = `<!doctype html>
|
|
|
12729
13607
|
}
|
|
12730
13608
|
/* Fixed columns so every source's table lines up with the others. */
|
|
12731
13609
|
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(
|
|
13610
|
+
table.aliases th:nth-child(1) { width: 22%; }
|
|
13611
|
+
table.aliases th:nth-child(2) { width: 15%; }
|
|
13612
|
+
table.aliases th:nth-child(3) { width: 180px; }
|
|
13613
|
+
table.aliases th:nth-child(5) { width: 264px; }
|
|
13614
|
+
td.muted { color: var(--muted); font-size: 14px; }
|
|
13615
|
+
.nav { display: flex; gap: 4px; margin-left: 12px; }
|
|
13616
|
+
.nav a { color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 500; padding: 6px 12px; border-radius: 8px; }
|
|
13617
|
+
.nav a:hover { color: var(--text); background: var(--surface-2); }
|
|
13618
|
+
.nav a[aria-current="page"] { color: var(--accent-strong); background: var(--accent-soft); font-weight: 600; }
|
|
13619
|
+
.page-head { display: flex; align-items: flex-end; gap: 16px; margin: 4px 0 16px; flex-wrap: wrap; }
|
|
13620
|
+
.page-head > div:first-child { flex: 1; min-width: 220px; }
|
|
13621
|
+
.page-head h1 { font-size: 22px; margin: 0; letter-spacing: -0.01em; }
|
|
13622
|
+
.page-head p { margin: 4px 0 0; color: var(--muted); font-size: 14px; }
|
|
13623
|
+
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
|
13624
|
+
.toolbar input[type="search"] { flex: 1 1 260px; }
|
|
13625
|
+
.toolbar .count { color: var(--muted); font-size: 13px; }
|
|
13626
|
+
.source-summary { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--muted); }
|
|
13627
|
+
.card-body.padded { padding: 4px 16px 14px; }
|
|
13628
|
+
.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; }
|
|
13629
|
+
.snippet code { flex: 1; overflow-x: auto; white-space: nowrap; }
|
|
13630
|
+
.tool-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 6px 0; }
|
|
13631
|
+
.tool-row .snippet { margin: 0; flex: 1 1 220px; }
|
|
13632
|
+
button.choice { width: 100%; text-align: left; background: var(--surface); }
|
|
12735
13633
|
td { overflow-wrap: anywhere; }
|
|
12736
13634
|
th {
|
|
12737
13635
|
text-align: left;
|
|
@@ -12844,23 +13742,36 @@ var assets_default = `<!doctype html>
|
|
|
12844
13742
|
margin-bottom: 18px;
|
|
12845
13743
|
}
|
|
12846
13744
|
.notice.error { border-left-color: var(--bad); }
|
|
13745
|
+
.row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; white-space: nowrap; }
|
|
13746
|
+
.prod-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
|
13747
|
+
.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; }
|
|
13748
|
+
tr.prod td:first-child { box-shadow: inset 3px 0 0 var(--bad); }
|
|
13749
|
+
button.link.danger-hover:hover { color: var(--bad); }
|
|
12847
13750
|
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
12848
13751
|
.seg button { border: 0; border-radius: 0; }
|
|
12849
13752
|
.seg button + button { border-left: 1px solid var(--border); }
|
|
12850
13753
|
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
|
|
12851
13754
|
.card-help { color: var(--muted); font-size: 14px; margin: 10px 16px 6px; }
|
|
13755
|
+
.card-body.padded .card-help { margin: 8px 0 10px; }
|
|
12852
13756
|
.muted-inline { color: var(--muted); font-weight: 400; font-size: 12px; }
|
|
12853
13757
|
table.assistants th:nth-child(2) { width: 170px; }
|
|
12854
13758
|
table.assistants th:nth-child(3) { width: 140px; }
|
|
12855
13759
|
table.assistants th:nth-child(4) { width: 230px; }
|
|
12856
13760
|
@media (max-width: 640px) {
|
|
12857
13761
|
.tagline { display: none; }
|
|
12858
|
-
|
|
13762
|
+
.nav { margin-left: 4px; overflow-x: auto; }
|
|
13763
|
+
.nav a { padding: 6px 8px; }
|
|
13764
|
+
table.aliases th:nth-child(2), table.aliases td:nth-child(2),
|
|
13765
|
+
table.aliases th:nth-child(4), table.aliases td:nth-child(4),
|
|
13766
|
+
table.assistants th:nth-child(3), table.assistants td:nth-child(3) { display: none; }
|
|
12859
13767
|
td, th { padding: 8px 10px; }
|
|
12860
13768
|
td.alias { white-space: normal; overflow-wrap: anywhere; }
|
|
12861
13769
|
.region input { width: 96px; }
|
|
12862
|
-
th:nth-child(1) { width: auto; }
|
|
12863
|
-
th:nth-child(
|
|
13770
|
+
table.aliases th:nth-child(1) { width: auto; }
|
|
13771
|
+
table.aliases th:nth-child(3) { width: 124px; }
|
|
13772
|
+
table.aliases th:nth-child(5) { width: 132px; }
|
|
13773
|
+
.bar { flex-wrap: wrap; }
|
|
13774
|
+
.row-actions { flex-wrap: wrap; }
|
|
12864
13775
|
table.assistants th:nth-child(2) { width: 112px; }
|
|
12865
13776
|
table.assistants th:nth-child(4) { width: 96px; }
|
|
12866
13777
|
table.assistants .actions { flex-direction: column; align-items: flex-end; }
|
|
@@ -12872,7 +13783,12 @@ var assets_default = `<!doctype html>
|
|
|
12872
13783
|
<header>
|
|
12873
13784
|
<div class="bar">
|
|
12874
13785
|
<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
|
-
<
|
|
13786
|
+
<nav id="nav" class="nav" aria-label="Sections" hidden>
|
|
13787
|
+
<a href="#/aliases" data-page="aliases">Aliases</a>
|
|
13788
|
+
<a href="#/sources" data-page="sources">Sources</a>
|
|
13789
|
+
<a href="#/assistants" data-page="assistants">AI assistants</a>
|
|
13790
|
+
<a href="#/tools" data-page="tools">Tools</a>
|
|
13791
|
+
</nav>
|
|
12876
13792
|
<div class="spacer"></div>
|
|
12877
13793
|
<button type="button" class="primary" id="add-source" hidden>+ Add source</button>
|
|
12878
13794
|
</div>
|
|
@@ -12996,15 +13912,15 @@ async function uiCommand(app, assistants, { flags }) {
|
|
|
12996
13912
|
}
|
|
12997
13913
|
|
|
12998
13914
|
// src/container.ts
|
|
12999
|
-
import { homedir as
|
|
13000
|
-
import { join as
|
|
13915
|
+
import { homedir as homedir4 } from "os";
|
|
13916
|
+
import { join as join8 } from "path";
|
|
13001
13917
|
|
|
13002
13918
|
// ../provider-aws/src/aws-provider.ts
|
|
13003
|
-
import { join as
|
|
13919
|
+
import { join as join7 } from "path";
|
|
13004
13920
|
|
|
13005
13921
|
// ../provider-aws/src/aws-config.ts
|
|
13006
|
-
import { mkdir as
|
|
13007
|
-
import { dirname as
|
|
13922
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
13923
|
+
import { dirname as dirname4 } from "path";
|
|
13008
13924
|
var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand)";
|
|
13009
13925
|
var BLOCK_END = "# <<< cloudhop managed <<<";
|
|
13010
13926
|
function renderBlock(sections) {
|
|
@@ -13051,13 +13967,64 @@ function replaceManagedBlock(content, sections) {
|
|
|
13051
13967
|
async function writeManagedBlock(path2, sections) {
|
|
13052
13968
|
let content = "";
|
|
13053
13969
|
try {
|
|
13054
|
-
content = await
|
|
13970
|
+
content = await readFile5(path2, "utf8");
|
|
13055
13971
|
} catch {
|
|
13056
13972
|
}
|
|
13057
13973
|
const next = replaceManagedBlock(content, sections);
|
|
13058
13974
|
if (next === content) return;
|
|
13059
|
-
await
|
|
13060
|
-
await
|
|
13975
|
+
await mkdir4(dirname4(path2), { recursive: true, mode: 448 });
|
|
13976
|
+
await writeFile4(path2, next, { mode: 384 });
|
|
13977
|
+
}
|
|
13978
|
+
|
|
13979
|
+
// ../provider-aws/src/console.ts
|
|
13980
|
+
var ISSUER = "https://dayepesb.github.io/cloudhop/";
|
|
13981
|
+
function partitionHosts(region) {
|
|
13982
|
+
if (region?.startsWith("cn-")) {
|
|
13983
|
+
return { signin: "https://signin.amazonaws.cn", console: "https://console.amazonaws.cn" };
|
|
13984
|
+
}
|
|
13985
|
+
if (region?.startsWith("us-gov-")) {
|
|
13986
|
+
return {
|
|
13987
|
+
signin: "https://signin.amazonaws-us-gov.com",
|
|
13988
|
+
console: "https://console.amazonaws-us-gov.com"
|
|
13989
|
+
};
|
|
13990
|
+
}
|
|
13991
|
+
return { signin: "https://signin.aws.amazon.com", console: "https://console.aws.amazon.com" };
|
|
13992
|
+
}
|
|
13993
|
+
function consoleDestination(region, service) {
|
|
13994
|
+
const { console: console2 } = partitionHosts(region);
|
|
13995
|
+
const path2 = service ? `${service}/home` : "console/home";
|
|
13996
|
+
return `${console2}/${path2}${region ? `?region=${encodeURIComponent(region)}` : ""}`;
|
|
13997
|
+
}
|
|
13998
|
+
async function federatedConsoleUrl(credentials, destination, region, fetchFn) {
|
|
13999
|
+
const {
|
|
14000
|
+
AWS_ACCESS_KEY_ID: id,
|
|
14001
|
+
AWS_SECRET_ACCESS_KEY: key,
|
|
14002
|
+
AWS_SESSION_TOKEN: token
|
|
14003
|
+
} = credentials;
|
|
14004
|
+
if (!id || !key || !token) return void 0;
|
|
14005
|
+
const { signin } = partitionHosts(region);
|
|
14006
|
+
const response = await fetchFn(`${signin}/federation`, {
|
|
14007
|
+
method: "POST",
|
|
14008
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
14009
|
+
body: new URLSearchParams({
|
|
14010
|
+
Action: "getSigninToken",
|
|
14011
|
+
Session: JSON.stringify({ sessionId: id, sessionKey: key, sessionToken: token })
|
|
14012
|
+
}).toString()
|
|
14013
|
+
}).catch(() => void 0);
|
|
14014
|
+
if (!response?.ok) return void 0;
|
|
14015
|
+
const signinToken = (await response.json().catch(() => ({}))).SigninToken;
|
|
14016
|
+
if (!signinToken) return void 0;
|
|
14017
|
+
const params = new URLSearchParams({
|
|
14018
|
+
Action: "login",
|
|
14019
|
+
Issuer: ISSUER,
|
|
14020
|
+
Destination: destination,
|
|
14021
|
+
SigninToken: signinToken
|
|
14022
|
+
});
|
|
14023
|
+
return `${signin}/federation?${params}`;
|
|
14024
|
+
}
|
|
14025
|
+
function portalShortcutUrl(startUrl, accountId, roleName, destination) {
|
|
14026
|
+
const params = new URLSearchParams({ account_id: accountId, role_name: roleName, destination });
|
|
14027
|
+
return `${startUrl.replace(/\/+$/, "")}/#/console?${params}`;
|
|
13061
14028
|
}
|
|
13062
14029
|
|
|
13063
14030
|
// ../provider-aws/src/http.ts
|
|
@@ -13071,7 +14038,7 @@ var AwsHttpError = class extends Error {
|
|
|
13071
14038
|
status;
|
|
13072
14039
|
code;
|
|
13073
14040
|
};
|
|
13074
|
-
async function
|
|
14041
|
+
async function parse3(response) {
|
|
13075
14042
|
const text4 = await response.text();
|
|
13076
14043
|
const body = text4 ? JSON.parse(text4) : {};
|
|
13077
14044
|
if (!response.ok) {
|
|
@@ -13091,10 +14058,10 @@ async function postJson(fetchFn, url, body) {
|
|
|
13091
14058
|
headers: { "content-type": "application/json" },
|
|
13092
14059
|
body: JSON.stringify(body)
|
|
13093
14060
|
});
|
|
13094
|
-
return
|
|
14061
|
+
return parse3(response);
|
|
13095
14062
|
}
|
|
13096
14063
|
async function getJson(fetchFn, url, headers) {
|
|
13097
|
-
return
|
|
14064
|
+
return parse3(await fetchFn(url, { headers }));
|
|
13098
14065
|
}
|
|
13099
14066
|
|
|
13100
14067
|
// ../provider-aws/src/sso/portal.ts
|
|
@@ -13278,23 +14245,23 @@ var OidcClient = class {
|
|
|
13278
14245
|
|
|
13279
14246
|
// ../provider-aws/src/sso/token-cache.ts
|
|
13280
14247
|
import { createHash as createHash2 } from "crypto";
|
|
13281
|
-
import { mkdir as
|
|
13282
|
-
import { join as
|
|
14248
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
14249
|
+
import { join as join6 } from "path";
|
|
13283
14250
|
var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
|
|
13284
14251
|
function tokenCachePath(awsDir, sessionName) {
|
|
13285
14252
|
const hash = createHash2("sha1").update(sessionName).digest("hex");
|
|
13286
|
-
return
|
|
14253
|
+
return join6(awsDir, "sso", "cache", `${hash}.json`);
|
|
13287
14254
|
}
|
|
13288
14255
|
async function readToken(awsDir, sessionName) {
|
|
13289
14256
|
try {
|
|
13290
|
-
return JSON.parse(await
|
|
14257
|
+
return JSON.parse(await readFile6(tokenCachePath(awsDir, sessionName), "utf8"));
|
|
13291
14258
|
} catch {
|
|
13292
14259
|
return void 0;
|
|
13293
14260
|
}
|
|
13294
14261
|
}
|
|
13295
14262
|
async function writeToken(awsDir, sessionName, token) {
|
|
13296
|
-
await
|
|
13297
|
-
await
|
|
14263
|
+
await mkdir5(join6(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
|
|
14264
|
+
await writeFile5(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
|
|
13298
14265
|
mode: 384
|
|
13299
14266
|
});
|
|
13300
14267
|
}
|
|
@@ -13433,6 +14400,42 @@ var CONSOLE_LOGIN_KIND = "console-login";
|
|
|
13433
14400
|
var CONSOLE_TARGET = { id: "console", label: "Console sign-in", attributes: {} };
|
|
13434
14401
|
var ssoSessionName = (sourceId) => `cloudhop-${sourceId}`;
|
|
13435
14402
|
var consoleProfileName = (sourceId) => `cloudhop-${sourceId}`;
|
|
14403
|
+
var GLOBAL_WITH_VALUE = /* @__PURE__ */ new Set([
|
|
14404
|
+
"--region",
|
|
14405
|
+
"--output",
|
|
14406
|
+
"--profile",
|
|
14407
|
+
"--endpoint-url",
|
|
14408
|
+
"--query",
|
|
14409
|
+
"--color",
|
|
14410
|
+
"--ca-bundle",
|
|
14411
|
+
"--cli-read-timeout",
|
|
14412
|
+
"--cli-connect-timeout",
|
|
14413
|
+
"--cli-binary-format"
|
|
14414
|
+
]);
|
|
14415
|
+
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)(-|$)/;
|
|
14416
|
+
function isAwsReadOnly(args) {
|
|
14417
|
+
if (args.includes("--dryrun") || args.includes("--dry-run") || args.includes("help")) return true;
|
|
14418
|
+
const words = [];
|
|
14419
|
+
for (let i2 = 0; i2 < args.length; i2++) {
|
|
14420
|
+
const arg = args[i2];
|
|
14421
|
+
if (arg.startsWith("--")) {
|
|
14422
|
+
if (GLOBAL_WITH_VALUE.has(arg) && words.length < 2) i2++;
|
|
14423
|
+
continue;
|
|
14424
|
+
}
|
|
14425
|
+
words.push(arg);
|
|
14426
|
+
}
|
|
14427
|
+
const [service, operation] = words;
|
|
14428
|
+
if (!service || !operation) return true;
|
|
14429
|
+
if (service === "sts" || service === "configure") return true;
|
|
14430
|
+
if (service === "s3") {
|
|
14431
|
+
if (operation === "cp" || operation === "sync") {
|
|
14432
|
+
const destination = words[words.length - 1] ?? "";
|
|
14433
|
+
return !destination.startsWith("s3://");
|
|
14434
|
+
}
|
|
14435
|
+
return operation === "ls" || operation === "presign";
|
|
14436
|
+
}
|
|
14437
|
+
return READ_VERBS.test(operation);
|
|
14438
|
+
}
|
|
13436
14439
|
var AwsProvider = class {
|
|
13437
14440
|
constructor(options) {
|
|
13438
14441
|
this.options = options;
|
|
@@ -13551,7 +14554,7 @@ var AwsProvider = class {
|
|
|
13551
14554
|
}
|
|
13552
14555
|
});
|
|
13553
14556
|
}
|
|
13554
|
-
await writeManagedBlock(
|
|
14557
|
+
await writeManagedBlock(join7(this.options.awsDir, "config"), sections);
|
|
13555
14558
|
}
|
|
13556
14559
|
async processEnv(resolved, mode) {
|
|
13557
14560
|
const region = resolved.region;
|
|
@@ -13567,6 +14570,25 @@ var AwsProvider = class {
|
|
|
13567
14570
|
const credentials = resolved.source.kind === CONSOLE_LOGIN_KIND ? await this.exportedCredentials(profile) : await this.roleCredentials(resolved);
|
|
13568
14571
|
return { AWS_PROFILE: void 0, ...credentials, ...regionEnv };
|
|
13569
14572
|
}
|
|
14573
|
+
async consoleUrl(resolved, service) {
|
|
14574
|
+
const destination = consoleDestination(resolved.region, service);
|
|
14575
|
+
const sso = resolved.source.kind !== CONSOLE_LOGIN_KIND;
|
|
14576
|
+
const credentials = sso ? await this.roleCredentials(resolved) : await this.exportedCredentials(consoleProfileName(resolved.source.id)).catch(() => ({}));
|
|
14577
|
+
const url = await federatedConsoleUrl(credentials, destination, resolved.region, this.fetchFn);
|
|
14578
|
+
if (url) return { url, via: "signed-in" };
|
|
14579
|
+
const attrs = resolved.target?.attributes ?? {};
|
|
14580
|
+
const startUrl = resolved.source.settings.startUrl;
|
|
14581
|
+
if (sso && startUrl && attrs.accountId && attrs.roleName) {
|
|
14582
|
+
return {
|
|
14583
|
+
url: portalShortcutUrl(startUrl, attrs.accountId, attrs.roleName, destination),
|
|
14584
|
+
via: "portal"
|
|
14585
|
+
};
|
|
14586
|
+
}
|
|
14587
|
+
return { url: destination, via: "sign-in-page" };
|
|
14588
|
+
}
|
|
14589
|
+
isReadOnly(args) {
|
|
14590
|
+
return isAwsReadOnly(args);
|
|
14591
|
+
}
|
|
13570
14592
|
isSessionError(output) {
|
|
13571
14593
|
return SESSION_ERROR_PATTERNS.some((pattern) => pattern.test(output));
|
|
13572
14594
|
}
|
|
@@ -13614,9 +14636,10 @@ var AwsProvider = class {
|
|
|
13614
14636
|
};
|
|
13615
14637
|
|
|
13616
14638
|
// src/container.ts
|
|
14639
|
+
var browser = new NodeBrowserOpener();
|
|
13617
14640
|
function createApp(env = process.env) {
|
|
13618
14641
|
const runner = new NodeProcessRunner();
|
|
13619
|
-
const awsDir = env.CLOUDHOP_AWS_DIR ??
|
|
14642
|
+
const awsDir = env.CLOUDHOP_AWS_DIR ?? join8(homedir4(), ".aws");
|
|
13620
14643
|
return new CloudhopApp({
|
|
13621
14644
|
config: new YamlConfigStore(),
|
|
13622
14645
|
providers: [
|
|
@@ -13628,14 +14651,14 @@ function createApp(env = process.env) {
|
|
|
13628
14651
|
// Azure and Google Cloud providers plug in here.
|
|
13629
14652
|
],
|
|
13630
14653
|
runner,
|
|
13631
|
-
browser
|
|
14654
|
+
browser,
|
|
13632
14655
|
installer: new SystemToolInstaller(runner),
|
|
13633
14656
|
notify
|
|
13634
14657
|
});
|
|
13635
14658
|
}
|
|
13636
14659
|
function createAssistants(version, env = process.env) {
|
|
13637
14660
|
return new AssistantsInstaller({
|
|
13638
|
-
places: { home:
|
|
14661
|
+
places: { home: homedir4(), project: process.cwd() },
|
|
13639
14662
|
configDir: defaultConfigDir(env),
|
|
13640
14663
|
runner: new NodeProcessRunner(),
|
|
13641
14664
|
cloudhopVersion: version
|
|
@@ -13643,8 +14666,8 @@ function createAssistants(version, env = process.env) {
|
|
|
13643
14666
|
}
|
|
13644
14667
|
|
|
13645
14668
|
// src/update-check.ts
|
|
13646
|
-
import { mkdir as
|
|
13647
|
-
import { join as
|
|
14669
|
+
import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
14670
|
+
import { join as join9 } from "path";
|
|
13648
14671
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
13649
14672
|
var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
|
|
13650
14673
|
function startUpdateCheck(configDir, current) {
|
|
@@ -13652,11 +14675,11 @@ function startUpdateCheck(configDir, current) {
|
|
|
13652
14675
|
if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
|
|
13653
14676
|
return async () => {
|
|
13654
14677
|
};
|
|
13655
|
-
const file =
|
|
14678
|
+
const file = join9(configDir, "update-check.json");
|
|
13656
14679
|
const work = (async () => {
|
|
13657
14680
|
let cache = { checkedAt: 0 };
|
|
13658
14681
|
try {
|
|
13659
|
-
cache = JSON.parse(await
|
|
14682
|
+
cache = JSON.parse(await readFile7(file, "utf8"));
|
|
13660
14683
|
} catch {
|
|
13661
14684
|
}
|
|
13662
14685
|
if (cache.latest && compareVersions(cache.latest, current) > 0) {
|
|
@@ -13673,19 +14696,27 @@ function startUpdateCheck(configDir, current) {
|
|
|
13673
14696
|
if (!response.ok) return;
|
|
13674
14697
|
const { version } = await response.json();
|
|
13675
14698
|
if (typeof version !== "string") return;
|
|
13676
|
-
await
|
|
13677
|
-
await
|
|
14699
|
+
await mkdir6(configDir, { recursive: true });
|
|
14700
|
+
await writeFile6(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
|
|
13678
14701
|
})().catch(() => {
|
|
13679
14702
|
});
|
|
13680
14703
|
return () => Promise.race([work, new Promise((resolve) => setTimeout(resolve, 300))]);
|
|
13681
14704
|
}
|
|
13682
14705
|
|
|
13683
14706
|
// src/cli.ts
|
|
13684
|
-
var HELP = `cloudhop ${"1.1
|
|
14707
|
+
var HELP = `cloudhop ${"1.2.1"}
|
|
13685
14708
|
Hop between cloud accounts, roles and regions with one command.
|
|
13686
14709
|
|
|
13687
14710
|
Usage
|
|
13688
14711
|
hop <alias> [args...] Run the cloud CLI as <alias> (no args: who am I)
|
|
14712
|
+
hop Pick the alias from a list, then type the command
|
|
14713
|
+
hop - [args...] Pick the alias from a list and run [args...]
|
|
14714
|
+
cloudhop console [alias] [service] [--region <r>]
|
|
14715
|
+
Open the web console as <alias>, already signed in
|
|
14716
|
+
cloudhop each <selection...> [--source <s>] [--prod|--no-prod] [--yes] [--json] -- <args...>
|
|
14717
|
+
Run one command in many aliases (names, a,b lists, 'globex-*', /regex/)
|
|
14718
|
+
cloudhop use [alias] | --off Use an alias in this shell (aws, terraform... without hop)
|
|
14719
|
+
cloudhop shell [install] Shell integration: cloudhop use, tab completion, cloudhop_prompt
|
|
13689
14720
|
cloudhop exec <alias> [--region <r>] -- <cmd...>
|
|
13690
14721
|
Run any tool with the alias' temporary credentials
|
|
13691
14722
|
|
|
@@ -13694,8 +14725,13 @@ Setup
|
|
|
13694
14725
|
cloudhop add Add an SSO portal or a personal account
|
|
13695
14726
|
cloudhop add aws sso --start-url <url> [--region <r>] [--name <id>]
|
|
13696
14727
|
cloudhop add aws console-login [--region <r>] [--name <id>]
|
|
14728
|
+
cloudhop team export [--out <file>] | team import <file|https URL>
|
|
14729
|
+
Share portals and aliases with your team (no secrets)
|
|
13697
14730
|
cloudhop alias [source] Create more aliases from a source
|
|
13698
14731
|
cloudhop alias <source> --list | --target <id> [--name <alias>] [--region <r>]
|
|
14732
|
+
cloudhop mv <alias> <new-name> Rename an alias
|
|
14733
|
+
cloudhop prod [alias...] [--off]
|
|
14734
|
+
Mark aliases as production (asks before changes)
|
|
13699
14735
|
cloudhop region <alias> [region]
|
|
13700
14736
|
Change the region an alias runs in
|
|
13701
14737
|
cloudhop login [source] Log in again and refresh its accounts
|
|
@@ -13727,23 +14763,47 @@ function takeGlobalFlags(argv) {
|
|
|
13727
14763
|
}
|
|
13728
14764
|
async function main(argv) {
|
|
13729
14765
|
const [command, ...args] = argv;
|
|
14766
|
+
const invokedAsHop = /^hop(\.js|\.cmd)?$/i.test(basename(process.argv[1] ?? ""));
|
|
14767
|
+
if (!command && invokedAsHop && interactive() || command === "-") {
|
|
14768
|
+
const app2 = createApp();
|
|
14769
|
+
const alias = await pickAlias(app2);
|
|
14770
|
+
let rest = args;
|
|
14771
|
+
if (!command) {
|
|
14772
|
+
const typed = answer(
|
|
14773
|
+
await dist_exports.text({
|
|
14774
|
+
message: `aws command to run as ${alias}`,
|
|
14775
|
+
placeholder: "empty = who am I, e.g. s3 ls"
|
|
14776
|
+
})
|
|
14777
|
+
).trim();
|
|
14778
|
+
rest = typed ? (typed.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^(["'])(.*)\1$/, "$2")) : [];
|
|
14779
|
+
}
|
|
14780
|
+
return proxyCommand(app2, alias, rest);
|
|
14781
|
+
}
|
|
13730
14782
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
13731
14783
|
console.log(HELP);
|
|
13732
14784
|
return 0;
|
|
13733
14785
|
}
|
|
13734
14786
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
13735
|
-
console.log("1.1
|
|
14787
|
+
console.log("1.2.1");
|
|
13736
14788
|
return 0;
|
|
13737
14789
|
}
|
|
13738
14790
|
const app = createApp();
|
|
13739
14791
|
const local = RESERVED_NAMES.has(command) ? takeGlobalFlags(args) : args;
|
|
14792
|
+
if (command === "__complete") {
|
|
14793
|
+
await completeCommand(app, args[0]);
|
|
14794
|
+
return 0;
|
|
14795
|
+
}
|
|
14796
|
+
if (command === "shell") {
|
|
14797
|
+
await shellCommand(local);
|
|
14798
|
+
return 0;
|
|
14799
|
+
}
|
|
13740
14800
|
if (command === "mcp") {
|
|
13741
|
-
await serveMcp({ app, version: "1.1
|
|
14801
|
+
await serveMcp({ app, version: "1.2.1" });
|
|
13742
14802
|
return 0;
|
|
13743
14803
|
}
|
|
13744
|
-
const assistants = createAssistants("1.1
|
|
14804
|
+
const assistants = createAssistants("1.2.1");
|
|
13745
14805
|
await refreshAssistantSkills(assistants, command);
|
|
13746
|
-
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.1
|
|
14806
|
+
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.2.1");
|
|
13747
14807
|
try {
|
|
13748
14808
|
return await dispatch(app, assistants, command, args, local);
|
|
13749
14809
|
} finally {
|
|
@@ -13774,6 +14834,13 @@ async function dispatch(app, assistants, command, args, local) {
|
|
|
13774
14834
|
case "status":
|
|
13775
14835
|
await statusCommand(app, local);
|
|
13776
14836
|
return 0;
|
|
14837
|
+
case "prod":
|
|
14838
|
+
await prodCommand(app, local);
|
|
14839
|
+
return 0;
|
|
14840
|
+
case "mv":
|
|
14841
|
+
case "rename":
|
|
14842
|
+
await renameCommand(app, local);
|
|
14843
|
+
return 0;
|
|
13777
14844
|
case "region":
|
|
13778
14845
|
await regionCommand(app, local);
|
|
13779
14846
|
return 0;
|
|
@@ -13802,6 +14869,17 @@ async function dispatch(app, assistants, command, args, local) {
|
|
|
13802
14869
|
case "agent":
|
|
13803
14870
|
await agentCommand(assistants, local);
|
|
13804
14871
|
return 0;
|
|
14872
|
+
case "team":
|
|
14873
|
+
await teamCommand(app, local);
|
|
14874
|
+
return 0;
|
|
14875
|
+
case "use":
|
|
14876
|
+
await useCommand(app, local);
|
|
14877
|
+
return 0;
|
|
14878
|
+
case "each":
|
|
14879
|
+
return eachCommand(app, args);
|
|
14880
|
+
case "console":
|
|
14881
|
+
await consoleCommand(app, local);
|
|
14882
|
+
return 0;
|
|
13805
14883
|
case "ui":
|
|
13806
14884
|
await uiCommand(app, assistants, parseArgs(local));
|
|
13807
14885
|
return 0;
|