cloudhop 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/dist/cli.js +1137 -93
  3. package/package.json +5 -4
package/dist/cli.js CHANGED
@@ -1511,7 +1511,7 @@ var require_stringify = __commonJS({
1511
1511
  props.push(doc.directives.tagString(tag));
1512
1512
  return props.join(" ");
1513
1513
  }
1514
- function stringify2(item, ctx, onComment, onChompKeep) {
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 = stringify2;
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 stringify2 = require_stringify();
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 = stringify2.stringify(key, ctx, () => keyCommentDone = true, () => chompKeep = true);
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 = stringify2.stringify(value, ctx, () => valueCommentDone = true, () => chompKeep = true);
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 stringify2 = require_stringify();
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 = stringify2.createStringifyContext(ctx.doc, {});
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 stringify2 = require_stringify();
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 stringify3 = flow ? stringifyFlowCollection : stringifyBlockCollection;
1876
- return stringify3(collection, ctx, options);
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 = stringify2.stringify(item, itemCtx, () => comment2 = null, () => chompKeep = true);
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 = stringify2.stringify(item, itemCtx, () => comment = null);
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 stringify2 = require_stringify();
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 = stringify2.createStringifyContext(doc, options);
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 = stringify2.stringify(doc.contents, ctx, () => contentComment = null, onChompKeep);
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(stringify2.stringify(doc.contents, ctx));
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 stringify2 = (cst) => "type" in cst ? stringifyToken(cst) : stringifyItem(cst);
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 = stringify2;
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 quote = this.charAt(0);
6050
- let end = this.buffer.indexOf(quote, this.pos + 1);
6051
- if (quote === "'") {
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 parse3(src, reviver, options) {
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 stringify2(value, replacer, options) {
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 = parse3;
7315
+ exports.parse = parse4;
7316
7316
  exports.parseAllDocuments = parseAllDocuments;
7317
7317
  exports.parseDocument = parseDocument;
7318
- exports.stringify = stringify2;
7318
+ exports.stringify = stringify3;
7319
7319
  }
7320
7320
  });
7321
7321
 
@@ -7427,6 +7427,9 @@ var require_src = __commonJS({
7427
7427
  }
7428
7428
  });
7429
7429
 
7430
+ // src/cli.ts
7431
+ import { basename } from "path";
7432
+
7430
7433
  // ../adapters-node/src/node-browser-opener.ts
7431
7434
  import { spawn } from "child_process";
7432
7435
  var NodeBrowserOpener = class {
@@ -7602,6 +7605,14 @@ var RESERVED_NAMES = /* @__PURE__ */ new Set([
7602
7605
  "agent",
7603
7606
  "alias",
7604
7607
  "login",
7608
+ "team",
7609
+ "shell",
7610
+ "use",
7611
+ "each",
7612
+ "console",
7613
+ "prod",
7614
+ "rename",
7615
+ "mv",
7605
7616
  "mcp",
7606
7617
  "region",
7607
7618
  "setup",
@@ -7639,6 +7650,56 @@ function suggestAliasName(...parts) {
7639
7650
  const slug = parts.join("-").toLowerCase().replace(/access$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
7640
7651
  return slug || "alias";
7641
7652
  }
7653
+ var slugTokens = (text4) => text4.toLowerCase().replace(/access$/i, "").split(/[^a-z0-9]+/).filter(Boolean);
7654
+ var ROLE_WORDS = {
7655
+ administrator: "admin",
7656
+ administratoraccess: "admin",
7657
+ readonly: "ro",
7658
+ readonlyaccess: "ro",
7659
+ developer: "dev",
7660
+ poweruser: "power",
7661
+ poweruseraccess: "power",
7662
+ viewonly: "view"
7663
+ };
7664
+ var ROLE_NOISE = /* @__PURE__ */ new Set(["aws", "access", "role", "ps", "iam"]);
7665
+ var PRODUCTION = /(^|[^a-z])(prod|prd|production|live)([^a-z]|$)/i;
7666
+ var looksLikeProduction = (...texts) => texts.some((text4) => PRODUCTION.test(text4));
7667
+ function suggestAliases(targets, sourceId, taken = /* @__PURE__ */ new Set()) {
7668
+ const sourceWords = new Set(
7669
+ slugTokens(sourceId).filter((w) => w.length > 2 && !/^(test|dev|main)$/.test(w))
7670
+ );
7671
+ const accountOf = (t2) => {
7672
+ let words = slugTokens(t2.attributes.accountName ?? t2.label.split(" / ")[0] ?? t2.id);
7673
+ if (words.length > 1 && sourceWords.has(words[0])) words = words.slice(1);
7674
+ while (words.length > 1 && /^\d{1,2}$/.test(words[words.length - 1]))
7675
+ words = words.slice(0, -1);
7676
+ return words;
7677
+ };
7678
+ const roleOf = (t2, accountWords) => {
7679
+ const raw = t2.attributes.roleName ?? t2.label.split(" / ")[1] ?? "";
7680
+ const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/^aws/, "").replace(/access$/, "");
7681
+ if (ROLE_WORDS[compact]) return [ROLE_WORDS[compact]];
7682
+ const skip = /* @__PURE__ */ new Set([...accountWords, ...sourceWords, ...ROLE_NOISE]);
7683
+ const words = slugTokens(raw).filter((w) => !skip.has(w)).map((w) => ROLE_WORDS[w] ?? w);
7684
+ return words.length ? words : slugTokens(raw).slice(-1);
7685
+ };
7686
+ const accounts = targets.map(accountOf);
7687
+ const count = /* @__PURE__ */ new Map();
7688
+ for (const words of accounts) count.set(words.join("-"), (count.get(words.join("-")) ?? 0) + 1);
7689
+ const used = new Set(taken);
7690
+ const result = /* @__PURE__ */ new Map();
7691
+ targets.forEach((target, i2) => {
7692
+ const accountWords = accounts[i2];
7693
+ const base = accountWords.join("-");
7694
+ const words = (count.get(base) ?? 0) > 1 ? [...accountWords, ...roleOf(target, accountWords)] : accountWords;
7695
+ const stem = suggestAliasName(words.join("-"));
7696
+ let name = stem;
7697
+ for (let n3 = 2; used.has(name) || RESERVED_NAMES.has(name); n3++) name = `${stem}-${n3}`;
7698
+ used.add(name);
7699
+ result.set(target.id, name);
7700
+ });
7701
+ return result;
7702
+ }
7642
7703
 
7643
7704
  // ../core/src/domain/version.ts
7644
7705
  function compareVersions(a2, b3) {
@@ -7868,6 +7929,7 @@ var CloudhopApp = class {
7868
7929
  available: Boolean(resolved.target),
7869
7930
  region: resolved.region,
7870
7931
  attributes: resolved.target?.attributes ?? {},
7932
+ prod: alias.prod === true,
7871
7933
  session: source.session,
7872
7934
  next: source.next
7873
7935
  };
@@ -7888,6 +7950,30 @@ var CloudhopApp = class {
7888
7950
  }
7889
7951
  return status;
7890
7952
  }
7953
+ /** Renames an alias and its profile, keeping its account, role, region and production mark. */
7954
+ async renameAlias(from, to) {
7955
+ assertValidName("alias", to);
7956
+ const config = await this.deps.config.load();
7957
+ const alias = this.findAlias(config, from);
7958
+ if (from === to) return;
7959
+ if (config.aliases.some((a2) => a2.name === to)) {
7960
+ throw new CloudhopError("ALIAS_EXISTS", `Alias "${to}" already exists.`);
7961
+ }
7962
+ alias.name = to;
7963
+ const source = this.findSource(config, alias.source);
7964
+ await this.syncProfiles(source.provider, config);
7965
+ await this.deps.config.save(config);
7966
+ }
7967
+ /** Marks aliases as production (or not). Production aliases ask before changing anything. */
7968
+ async setProduction(names, prod) {
7969
+ const config = await this.deps.config.load();
7970
+ for (const name of names) {
7971
+ const alias = this.findAlias(config, name);
7972
+ if (prod) alias.prod = true;
7973
+ else delete alias.prod;
7974
+ }
7975
+ await this.deps.config.save(config);
7976
+ }
7891
7977
  /** Changes an alias' region; matching the source's region clears the override. */
7892
7978
  async setAliasRegion(name, region) {
7893
7979
  const config = await this.deps.config.load();
@@ -7930,6 +8016,49 @@ ${result.stderr}`)) {
7930
8016
  }
7931
8017
  return result;
7932
8018
  }
8019
+ /**
8020
+ * Whether running these CLI arguments (or, with `args` undefined, any
8021
+ * command through `cloudhop exec`) as this alias needs a person's OK: true
8022
+ * for production aliases unless the command surely only reads.
8023
+ */
8024
+ async needsProductionConfirmation(name, args) {
8025
+ const resolved = await this.resolveAlias(name);
8026
+ if (!resolved.alias.prod) return false;
8027
+ if (!args) return true;
8028
+ return !this.provider(resolved.source.provider).isReadOnly(args);
8029
+ }
8030
+ /**
8031
+ * A URL that opens the web console as the alias, signing in first if the
8032
+ * session expired. The URL is a short-lived secret: open it, never print it.
8033
+ */
8034
+ async consoleUrl(name, { service, region } = {}) {
8035
+ const resolved = await this.usableAlias(name, region ? { region } : {});
8036
+ const provider = this.provider(resolved.source.provider);
8037
+ if (!provider.consoleUrl) {
8038
+ throw new CloudhopError(
8039
+ "PROVIDER_NOT_AVAILABLE",
8040
+ `${provider.displayName} has no console sign-in in cloudhop yet.`
8041
+ );
8042
+ }
8043
+ if (service !== void 0 && !/^[a-z0-9-]{1,40}$/.test(service)) {
8044
+ throw new CloudhopError(
8045
+ "INVALID_NAME",
8046
+ `"${service}" doesn't look like a service name.`,
8047
+ "Examples: s3, ec2, lambda, cloudwatch."
8048
+ );
8049
+ }
8050
+ await this.ensureSession(resolved.source.id);
8051
+ return provider.consoleUrl(resolved, service);
8052
+ }
8053
+ /**
8054
+ * Opens the web console as the alias in the user's browser. The sign-in URL
8055
+ * stays inside cloudhop: callers only learn how the user got in.
8056
+ */
8057
+ async openConsole(name, options = {}) {
8058
+ const { url, via } = await this.consoleUrl(name, options);
8059
+ await this.deps.browser.open(url);
8060
+ return via;
8061
+ }
7933
8062
  /** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
7934
8063
  identityArgs(name) {
7935
8064
  return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
@@ -8260,6 +8389,10 @@ function createTools(app) {
8260
8389
  region: {
8261
8390
  type: "string",
8262
8391
  description: "Optional region for this call only; defaults to the alias' region."
8392
+ },
8393
+ confirm_production: {
8394
+ type: "boolean",
8395
+ description: "Only for production aliases (prod: true) and commands that may change resources: set it to true after the user explicitly approved this exact command."
8263
8396
  }
8264
8397
  },
8265
8398
  required: ["alias", "args"],
@@ -8288,6 +8421,14 @@ function createTools(app) {
8288
8421
  );
8289
8422
  }
8290
8423
  const region = typeof args.region === "string" && args.region.trim() ? args.region.trim() : void 0;
8424
+ if (args.confirm_production !== true && await app.needsProductionConfirmation(alias, cliArgs)) {
8425
+ const status = await app.aliasStatus(alias);
8426
+ throw new CloudhopError(
8427
+ "PROD_CONFIRMATION_REQUIRED",
8428
+ `${alias} is PRODUCTION (${status.label ?? status.target}, ${status.region ?? "default region"}) and this command may change resources.`,
8429
+ "Show the user the alias, account, region and the exact command, and ask for explicit approval. Only if they approve, call run_cloud_cli again with confirm_production: true."
8430
+ );
8431
+ }
8291
8432
  const result = await app.captureAsAlias(alias, cliArgs, region ? { region } : {});
8292
8433
  return {
8293
8434
  alias,
@@ -8303,7 +8444,7 @@ function createTools(app) {
8303
8444
  // ../mcp/src/server.ts
8304
8445
  var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
8305
8446
  Before the first cloud operation in a conversation: call list_aliases, show the user the aliases (name, account/role, region) and ask which one to use. Never guess the alias.
8306
- Use run_cloud_cli for cloud CLI commands, never --profile. Read-only commands may run once the alias is chosen; before anything that creates, changes or deletes resources, show the alias, account, region and exact command, and wait for the user's explicit OK. Be extra careful with production aliases.
8447
+ Use run_cloud_cli for cloud CLI commands, never --profile. Read-only commands may run once the alias is chosen; before anything that creates, changes or deletes resources, show the alias, account, region and exact command, and wait for the user's explicit OK. Aliases with prod: true are production: run_cloud_cli refuses changes there until the user approves and you pass confirm_production: true.
8307
8448
  If a tool returns LOGIN_REQUIRED or alias_status says browser-sign-in: tell the user their browser will open, then call login (it waits up to 5 minutes for them to approve). Never print or ask for credentials.`;
8308
8449
  var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
8309
8450
  function serveMcp({
@@ -8404,7 +8545,17 @@ function serveMcp({
8404
8545
  }
8405
8546
 
8406
8547
  // src/flags.ts
8407
- var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["yes", "all", "list", "noOpen", "json", "noMcp", "force"]);
8548
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
8549
+ "yes",
8550
+ "all",
8551
+ "list",
8552
+ "noOpen",
8553
+ "json",
8554
+ "noMcp",
8555
+ "force",
8556
+ "prod",
8557
+ "off"
8558
+ ]);
8408
8559
  var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
8409
8560
  function parseArgs(argv) {
8410
8561
  const positional = [];
@@ -11362,14 +11513,12 @@ async function chooseAliases(app, source, targets) {
11362
11513
  );
11363
11514
  const validateRegion2 = regionValidator(app, source);
11364
11515
  let defaultRegion = source.settings.region ?? "";
11516
+ const suggestions = suggestAliases(targets, source.id, existing);
11365
11517
  const aliases = [];
11366
11518
  for (const target of selected) {
11367
11519
  dist_exports.log.step(target.label);
11368
11520
  const suggestion = uniqueName(
11369
- targets.length === 1 && target.id === "console" ? source.id : suggestAliasName(
11370
- target.attributes.accountName ?? target.label,
11371
- target.attributes.roleName ?? ""
11372
- ),
11521
+ targets.length === 1 && target.id === "console" ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
11373
11522
  existing
11374
11523
  );
11375
11524
  const name = answer(
@@ -11388,13 +11537,20 @@ async function chooseAliases(app, source, targets) {
11388
11537
  validate: (v) => v?.trim() ? validateRegion2?.(v.trim()) : void 0
11389
11538
  })
11390
11539
  ).trim() || defaultRegion;
11540
+ const prod = answer(
11541
+ await dist_exports.confirm({
11542
+ message: `Is ${name} production? (cloudhop then asks before any change)`,
11543
+ initialValue: looksLikeProduction(name, target.label)
11544
+ })
11545
+ );
11391
11546
  defaultRegion = region;
11392
11547
  existing.add(name);
11393
11548
  aliases.push({
11394
11549
  name,
11395
11550
  source: source.id,
11396
11551
  target: target.id,
11397
- ...region && region !== source.settings.region ? { region } : {}
11552
+ ...region && region !== source.settings.region ? { region } : {},
11553
+ ...prod ? { prod: true } : {}
11398
11554
  });
11399
11555
  }
11400
11556
  return aliases;
@@ -11486,14 +11642,20 @@ async function aliasCommand(app, { positional, flags }) {
11486
11642
  `See the options with \`cloudhop alias ${id} --list\`.`
11487
11643
  );
11488
11644
  }
11489
- const name = flags.name ?? suggestAliasName(
11490
- target.attributes.accountName ?? target.label,
11491
- target.attributes.roleName ?? ""
11492
- );
11645
+ const taken = new Set((await app.config()).aliases.map((a2) => a2.name));
11646
+ const name = flags.name ?? suggestAliases(targets, id, taken).get(target.id) ?? id;
11493
11647
  await app.addAliases([
11494
- { name, source: id, target: target.id, ...flags.region ? { region: flags.region } : {} }
11648
+ {
11649
+ name,
11650
+ source: id,
11651
+ target: target.id,
11652
+ ...flags.region ? { region: flags.region } : {},
11653
+ ...flags.prod ? { prod: true } : {}
11654
+ }
11495
11655
  ]);
11496
- console.log(`Alias "${name}" \u2192 ${target.label}. Try: hop ${name}`);
11656
+ console.log(
11657
+ `Alias "${name}" \u2192 ${target.label}${flags.prod ? " (production)" : ""}. Try: hop ${name}`
11658
+ );
11497
11659
  return;
11498
11660
  }
11499
11661
  if (scripted) {
@@ -11517,7 +11679,7 @@ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, r
11517
11679
  import { dirname as dirname2, join as join4 } from "path";
11518
11680
 
11519
11681
  // ../assistants/src/assets/skill.md
11520
- var skill_default = "---\nname: cloudhop\ndescription: Run AWS CLI commands and other cloud tools as a specific account, role and region through cloudhop aliases (the `hop` and `cloudhop` commands). Use whenever the user asks to do anything in their AWS accounts, inspect or change cloud resources, run aws/terraform/cdk against an account, list their cloud accounts or aliases, or mentions hop or cloudhop.\n---\n\n# Working with cloud accounts through cloudhop\n\nThe user manages their cloud access with **cloudhop**. Each **alias** (for\nexample `acme-dev`) points to one account, one role and one region. cloudhop\nsigns the user in through their browser only when a session has expired. There\nare no access keys: never ask for, create, print or store credentials.\n\n## 1. Pick the alias with the user, every time\n\nBefore the first cloud operation in a conversation:\n\n1. List the aliases. Use the `list_aliases` tool if the cloudhop MCP server is\n connected; otherwise run `cloudhop ls --json`.\n2. Show the user the options (alias, account / role, region) and **ask which\n one to use**. Don't guess, even if one name looks right, and don't pick\n one just because there is only one: confirm it.\n3. Use that alias for the rest of the conversation. Ask again if the user\n moves to another environment, account or task.\n\n## 2. Check the session before running anything\n\nRun `cloudhop status <alias> --json` (or the `alias_status` tool) and read\n`next`:\n\n| `next` | Meaning | What to do |\n| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ready` | Session valid | Go ahead |\n| `silent-renewal` | Expired but renewable without the user | Go ahead; cloudhop renews it |\n| `browser-sign-in` | The user must sign in | Tell the user their browser will open and they need to approve, then run `cloudhop login <source>` (or the `login` tool) and wait up to 5 minutes |\n| `unknown` | Managed by the cloud CLI (personal accounts) | Go ahead; if it fails, run `cloudhop login <source>` |\n\nDon't start several cloud commands in parallel while a sign-in is pending.\n\n## 3. Run commands through the alias\n\n- **AWS CLI**: `hop <alias> <aws arguments>`, e.g. `hop acme-dev s3 ls`.\n It is exactly `aws <arguments>` with the alias' account, role and region.\n Never add `--profile`. Use `--region <r>` only to override the region once.\n With the MCP server, use `run_cloud_cli` with `args` (no program name).\n- **Who am I**: `hop <alias>` with no arguments (or the `whoami` tool).\n- **Other tools** (Terraform, CDK, SDK scripts):\n `cloudhop exec <alias> -- <command>`, e.g.\n `cloudhop exec acme-dev -- terraform plan`.\n- Prefer `--output json` and `--query` to keep output small.\n- Don't export `AWS_PROFILE`, `AWS_ACCESS_KEY_ID` or similar variables, don't\n run commands that print credentials (`env`, `printenv`, `export-credentials`)\n and never edit `~/.aws/config`, `~/.aws/credentials` or cloudhop's own files.\n\n## 4. Confirm before changing anything\n\n- Read-only commands (`list`, `describe`, `get`, `s3 ls`, `logs tail`) can run\n once the user has chosen the alias.\n- Before any command that **creates, changes, deletes or costs money**, show\n the alias, account, region and the exact command, and wait for the user's\n explicit \"yes\". One confirmation covers one command, not a series.\n- Treat aliases or accounts that look like production (`prod`, `prd`,\n `production`, `live`) with extra care: confirm every change, and suggest a\n dry run (`--dryrun`, `terraform plan`) first.\n- Don't change cloudhop itself (`cloudhop add`, `rm`, `region`, `login` for\n another source) unless the user asked for it.\n\n## 5. When something goes wrong\n\n| You see | Do this |\n| --------------------------------------------------- | ------------------------------------------------------------------------------ |\n| \"Opening your browser to log in\" | Tell the user to approve in the browser; the command continues by itself |\n| The command was cut off while waiting for a sign-in | Run `cloudhop login <source>` with a timeout of at least 5 minutes, then retry |\n| `LOGIN_REQUIRED` | Same as `browser-sign-in` above |\n| No browser on this machine (remote, container) | Run `cloudhop login <source> --device-code` and show the user the URL and code |\n| `Alias \"x\" not found` | Run `cloudhop ls` and ask the user again |\n| AWS CLI missing or too old | Suggest `cloudhop doctor`; don't install anything without asking |\n| `AccessDenied` | The role lacks permission: tell the user; don't try other aliases on your own |\n\nMore: https://dayepesb.github.io/cloudhop/guides/hop-like-aws/\n";
11682
+ var skill_default = "---\nname: cloudhop\ndescription: Run AWS CLI commands and other cloud tools as a specific account, role and region through cloudhop aliases (the `hop` and `cloudhop` commands). Use whenever the user asks to do anything in their AWS accounts, inspect or change cloud resources, run aws/terraform/cdk against an account, list their cloud accounts or aliases, or mentions hop or cloudhop.\n---\n\n# Working with cloud accounts through cloudhop\n\nThe user manages their cloud access with **cloudhop**. Each **alias** (for\nexample `acme-dev`) points to one account, one role and one region. cloudhop\nsigns the user in through their browser only when a session has expired. There\nare no access keys: never ask for, create, print or store credentials.\n\n## 1. Pick the alias with the user, every time\n\nBefore the first cloud operation in a conversation:\n\n1. List the aliases. Use the `list_aliases` tool if the cloudhop MCP server is\n connected; otherwise run `cloudhop ls --json`.\n2. Show the user the options (alias, account / role, region) and **ask which\n one to use**. Don't guess, even if one name looks right, and don't pick\n one just because there is only one: confirm it.\n3. Use that alias for the rest of the conversation. Ask again if the user\n moves to another environment, account or task.\n\n## 2. Check the session before running anything\n\nRun `cloudhop status <alias> --json` (or the `alias_status` tool) and read\n`next`:\n\n| `next` | Meaning | What to do |\n| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ready` | Session valid | Go ahead |\n| `silent-renewal` | Expired but renewable without the user | Go ahead; cloudhop renews it |\n| `browser-sign-in` | The user must sign in | Tell the user their browser will open and they need to approve, then run `cloudhop login <source>` (or the `login` tool) and wait up to 5 minutes |\n| `unknown` | Managed by the cloud CLI (personal accounts) | Go ahead; if it fails, run `cloudhop login <source>` |\n\nDon't start several cloud commands in parallel while a sign-in is pending.\n\n## 3. Run commands through the alias\n\n- **AWS CLI**: `hop <alias> <aws arguments>`, e.g. `hop acme-dev s3 ls`.\n It is exactly `aws <arguments>` with the alias' account, role and region.\n Never add `--profile`. Use `--region <r>` only to override the region once.\n With the MCP server, use `run_cloud_cli` with `args` (no program name).\n- **Who am I**: `hop <alias>` with no arguments (or the `whoami` tool).\n- **The same command in several aliases** (after the user chose them):\n `cloudhop each a,b,c --yes -- <aws arguments>`; add `--json` to parse the\n results.\n- **The AWS web console**: `cloudhop console <alias> [service]` opens it in\n the user's browser, already signed in. Never try to print or fetch its URL.\n- **Other tools** (Terraform, CDK, SDK scripts):\n `cloudhop exec <alias> -- <command>`, e.g.\n `cloudhop exec acme-dev -- terraform plan`.\n- Prefer `--output json` and `--query` to keep output small.\n- Don't export `AWS_PROFILE`, `AWS_ACCESS_KEY_ID` or similar variables, don't\n run commands that print credentials (`env`, `printenv`, `export-credentials`)\n and never edit `~/.aws/config`, `~/.aws/credentials` or cloudhop's own files.\n\n## 4. Confirm before changing anything\n\n- Read-only commands (`list`, `describe`, `get`, `s3 ls`, `logs tail`) can run\n once the user has chosen the alias.\n- Before any command that **creates, changes, deletes or costs money**, show\n the alias, account, region and the exact command, and wait for the user's\n explicit \"yes\". One confirmation covers one command, not a series.\n- **Production aliases** have `\"prod\": true` in `cloudhop ls --json` (and\n `PROD` in `cloudhop ls`). Confirm every change there and suggest a dry run\n (`--dryrun`, `terraform plan`) first. cloudhop enforces it: without a\n terminal, `hop`/`cloudhop exec` refuse changes on production with\n `PROD_CONFIRMATION_REQUIRED`. After the user explicitly approves the exact\n command, rerun it with `CLOUDHOP_CONFIRM_PROD=1` in that command's\n environment (with MCP: `confirm_production: true`). Never set it on your own.\n- Treat aliases or accounts that merely look like production (`prd`, `live`)\n the same way, even if they are not marked.\n- Don't change cloudhop itself (`cloudhop add`, `rm`, `region`, `login` for\n another source) unless the user asked for it.\n\n## 5. When something goes wrong\n\n| You see | Do this |\n| --------------------------------------------------- | ------------------------------------------------------------------------------ |\n| \"Opening your browser to log in\" | Tell the user to approve in the browser; the command continues by itself |\n| The command was cut off while waiting for a sign-in | Run `cloudhop login <source>` with a timeout of at least 5 minutes, then retry |\n| `LOGIN_REQUIRED` | Same as `browser-sign-in` above |\n| No browser on this machine (remote, container) | Run `cloudhop login <source> --device-code` and show the user the URL and code |\n| `Alias \"x\" not found` | Run `cloudhop ls` and ask the user again |\n| AWS CLI missing or too old | Suggest `cloudhop doctor`; don't install anything without asking |\n| `AccessDenied` | The role lacks permission: tell the user; don't try other aliases on your own |\n\nMore: https://dayepesb.github.io/cloudhop/guides/hop-like-aws/\n";
11521
11683
 
11522
11684
  // ../assistants/src/targets.ts
11523
11685
  import { join as join3 } from "path";
@@ -12017,6 +12179,216 @@ async function agentCommand(installer, args) {
12017
12179
  }
12018
12180
  }
12019
12181
 
12182
+ // src/picker.ts
12183
+ async function pickAlias(app, message = "Which alias?") {
12184
+ if (!interactive()) {
12185
+ throw new CloudhopError(
12186
+ "NOT_INTERACTIVE",
12187
+ "Say which alias to use.",
12188
+ "See them with `cloudhop ls`."
12189
+ );
12190
+ }
12191
+ const { aliases } = await app.overview();
12192
+ if (aliases.length === 0) {
12193
+ throw new CloudhopError(
12194
+ "ALIAS_NOT_FOUND",
12195
+ "No aliases yet.",
12196
+ "Create one with `cloudhop alias <source>`."
12197
+ );
12198
+ }
12199
+ const haystack = new Map(
12200
+ aliases.map((a2) => [
12201
+ a2.name,
12202
+ `${a2.name} ${a2.label ?? a2.target} ${a2.region ?? ""} ${a2.source}`.toLowerCase()
12203
+ ])
12204
+ );
12205
+ return answer(
12206
+ await dist_exports.autocomplete({
12207
+ message: `${message} ${paint("dim", "(type to filter)")}`,
12208
+ maxItems: 12,
12209
+ options: aliases.map((a2) => ({
12210
+ value: a2.name,
12211
+ label: a2.prod ? `${a2.name} ${paint("red", "PROD")}` : a2.name,
12212
+ hint: `${a2.label ?? a2.target} \xB7 ${a2.region ?? "no region"}`
12213
+ })),
12214
+ filter: (search, option) => {
12215
+ const text4 = haystack.get(option.value) ?? "";
12216
+ return search.toLowerCase().split(/\s+/).filter(Boolean).every((word) => text4.includes(word));
12217
+ }
12218
+ })
12219
+ );
12220
+ }
12221
+
12222
+ // src/commands/console.ts
12223
+ var HOW = {
12224
+ "signed-in": "signed in",
12225
+ portal: "through the SSO portal (AWS did not accept a direct sign-in)",
12226
+ "sign-in-page": "at the sign-in page (sign in with your console user)"
12227
+ };
12228
+ async function consoleCommand(app, args) {
12229
+ const { positional, flags } = parseArgs(args);
12230
+ const alias = positional[0] ?? await pickAlias(app, "Open the console as");
12231
+ const service = positional[1];
12232
+ const via = await app.openConsole(alias, {
12233
+ ...service ? { service } : {},
12234
+ ...flags.region ? { region: flags.region } : {}
12235
+ });
12236
+ notify(`Opened the ${service ? `${service} ` : ""}console for ${alias}, ${HOW[via]}.`);
12237
+ }
12238
+
12239
+ // src/commands/each.ts
12240
+ function matcher(selector) {
12241
+ const regex = /^\/(.+)\/([a-z]*)$/.exec(selector);
12242
+ if (regex) {
12243
+ try {
12244
+ const re = new RegExp(regex[1], regex[2]);
12245
+ return (name) => re.test(name);
12246
+ } catch {
12247
+ throw new CloudhopError("INVALID_NAME", `Invalid regular expression: ${selector}`);
12248
+ }
12249
+ }
12250
+ if (/[*?]/.test(selector)) {
12251
+ const escaped = selector.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
12252
+ const re = new RegExp(`^${escaped}$`);
12253
+ return (name) => re.test(name);
12254
+ }
12255
+ return (name) => name === selector;
12256
+ }
12257
+ function selectAliases(aliases, selectors, filters) {
12258
+ const patterns = selectors.flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
12259
+ const tests = patterns.map(matcher);
12260
+ for (const [i2, pattern] of patterns.entries()) {
12261
+ const plain = !/[*?]/.test(pattern) && !pattern.startsWith("/");
12262
+ if (plain && !aliases.some((a2) => tests[i2]?.(a2.name))) {
12263
+ throw new CloudhopError(
12264
+ "ALIAS_NOT_FOUND",
12265
+ `Alias "${pattern}" not found.`,
12266
+ "See your aliases with `cloudhop ls`."
12267
+ );
12268
+ }
12269
+ }
12270
+ return aliases.filter(
12271
+ (a2) => (tests.length === 0 || tests.some((t2) => t2(a2.name))) && (filters.source === void 0 || a2.source === filters.source) && (filters.prod === void 0 || a2.prod === filters.prod)
12272
+ );
12273
+ }
12274
+ async function mapLimit(items, limit, fn) {
12275
+ const results = new Array(items.length);
12276
+ let next = 0;
12277
+ const worker = async () => {
12278
+ while (next < items.length) {
12279
+ const i2 = next++;
12280
+ results[i2] = await fn(items[i2]);
12281
+ }
12282
+ };
12283
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
12284
+ return results;
12285
+ }
12286
+ async function eachCommand(app, args) {
12287
+ const separator = args.indexOf("--");
12288
+ if (separator === -1 || separator === args.length - 1) {
12289
+ throw new CloudhopError(
12290
+ "INVALID_NAME",
12291
+ "Usage: cloudhop each <alias|list|pattern|/regex/...> [--source <s>] [--prod|--no-prod] -- <command>",
12292
+ "Example: cloudhop each 'creci-*' -- s3 ls"
12293
+ );
12294
+ }
12295
+ const { positional, flags } = parseArgs(args.slice(0, separator));
12296
+ const cliArgs = args.slice(separator + 1);
12297
+ if (positional.length === 0 && !flags.source && !flags.prod && !flags.noProd) {
12298
+ throw new CloudhopError(
12299
+ "INVALID_NAME",
12300
+ 'Say which aliases: names, a pattern like "globex-*", /regex/, --source or --prod.'
12301
+ );
12302
+ }
12303
+ const prodFilter = flags.prod ? true : flags.noProd ? false : void 0;
12304
+ const { aliases } = await app.overview();
12305
+ const selected = selectAliases(aliases, positional, {
12306
+ ...flags.source ? { source: flags.source } : {},
12307
+ ...prodFilter !== void 0 ? { prod: prodFilter } : {}
12308
+ });
12309
+ if (selected.length === 0)
12310
+ throw new CloudhopError("ALIAS_NOT_FOUND", "No alias matches that selection.");
12311
+ const command = `aws ${cliArgs.join(" ")}`;
12312
+ const width = Math.max(...selected.map((a2) => a2.name.length)) + 2;
12313
+ const listing = selected.map(
12314
+ (a2) => ` ${a2.name.padEnd(width)}${(a2.region ?? "-").padEnd(12)}${a2.label ?? a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`
12315
+ ).join("\n");
12316
+ const risky = [];
12317
+ for (const a2 of selected)
12318
+ if (await app.needsProductionConfirmation(a2.name, cliArgs)) risky.push(a2.name);
12319
+ const tty = interactive();
12320
+ if (!flags.json)
12321
+ process.stderr.write(
12322
+ `${bold(`${selected.length} alias(es)`)} will run ${paint("sky", command)}
12323
+ ${listing}
12324
+ `
12325
+ );
12326
+ if (!flags.yes) {
12327
+ if (!tty)
12328
+ throw new CloudhopError(
12329
+ "NOT_INTERACTIVE",
12330
+ "Confirm with --yes to run in several aliases without a terminal."
12331
+ );
12332
+ if (!answer(
12333
+ await dist_exports.confirm({
12334
+ message: `Run it in these ${selected.length} alias(es)?`,
12335
+ initialValue: false
12336
+ })
12337
+ ))
12338
+ return 130;
12339
+ }
12340
+ if (risky.length > 0 && process.env.CLOUDHOP_CONFIRM_PROD !== "1") {
12341
+ if (!tty) {
12342
+ throw new CloudhopError(
12343
+ "PROD_CONFIRMATION_REQUIRED",
12344
+ `This may change resources in production: ${risky.join(", ")}.`,
12345
+ "Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
12346
+ );
12347
+ }
12348
+ dist_exports.log.warn(
12349
+ `${paint("red", bold("PRODUCTION"))} ${risky.join(", ")}: this may change resources.`
12350
+ );
12351
+ if (!answer(await dist_exports.confirm({ message: "Also run it in production?", initialValue: false })))
12352
+ return 130;
12353
+ }
12354
+ for (const source of new Set(selected.map((a2) => a2.source))) {
12355
+ await app.ensureSession(source, { interactive: tty });
12356
+ }
12357
+ const parallel = Math.max(1, Math.min(16, Number(flags.parallel) || 4));
12358
+ const outcomes = await mapLimit(selected, parallel, async (a2) => {
12359
+ try {
12360
+ const r2 = await app.captureAsAlias(a2.name, cliArgs);
12361
+ const outcome = { alias: a2.name, exitCode: r2.exitCode, stdout: r2.stdout, stderr: r2.stderr };
12362
+ if (!flags.json) printOutcome(outcome, width);
12363
+ return outcome;
12364
+ } catch (error) {
12365
+ const outcome = { alias: a2.name, exitCode: 1, stdout: "", stderr: error.message };
12366
+ if (!flags.json) printOutcome(outcome, width);
12367
+ return outcome;
12368
+ }
12369
+ });
12370
+ const failed = outcomes.filter((o2) => o2.exitCode !== 0);
12371
+ if (flags.json) {
12372
+ console.log(JSON.stringify({ version: 1, command: cliArgs, results: outcomes }, null, 2));
12373
+ } else {
12374
+ const summary = `${outcomes.length - failed.length} ok${failed.length ? `, ${failed.length} failed: ${failed.map((f2) => f2.alias).join(", ")}` : ""}`;
12375
+ process.stderr.write(`${failed.length ? paint("red", summary) : paint("green", summary)}
12376
+ `);
12377
+ }
12378
+ return failed.length ? 1 : 0;
12379
+ }
12380
+ function printOutcome({ alias, exitCode, stdout: stdout2, stderr }, width) {
12381
+ const tag = paint(exitCode === 0 ? "sky" : "red", `[${alias}]`.padEnd(width + 2));
12382
+ for (const line of stdout2.replace(/\n$/, "").split("\n"))
12383
+ if (line) process.stdout.write(`${tag} ${line}
12384
+ `);
12385
+ for (const line of stderr.replace(/\n$/, "").split("\n"))
12386
+ if (line) process.stderr.write(`${tag} ${paint("dim", line)}
12387
+ `);
12388
+ if (exitCode !== 0) process.stderr.write(`${tag} ${paint("red", `exit ${exitCode}`)}
12389
+ `);
12390
+ }
12391
+
12020
12392
  // src/commands/manage.ts
12021
12393
  function printReport(report) {
12022
12394
  const changes = [
@@ -12113,8 +12485,9 @@ async function listCommand(app, args = []) {
12113
12485
  );
12114
12486
  for (const { alias, target, region } of own) {
12115
12487
  const label = target ? target.label : paint("red", `${alias.target} (no longer available)`);
12488
+ const tag = alias.prod ? ` ${paint("red", "PROD")}` : "";
12116
12489
  console.log(
12117
- ` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}`
12490
+ ` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}${tag}`
12118
12491
  );
12119
12492
  }
12120
12493
  }
@@ -12200,16 +12573,16 @@ async function regionCommand(app, args) {
12200
12573
  })
12201
12574
  );
12202
12575
  const current = await app.resolveAlias(name);
12203
- const validate = regionValidator(app, current.source);
12576
+ const validate2 = regionValidator(app, current.source);
12204
12577
  const region = regionArg?.trim() ?? (answer(
12205
12578
  await dist_exports.text({
12206
12579
  message: `Region for ${name}`,
12207
12580
  placeholder: current.region,
12208
12581
  defaultValue: current.region,
12209
- validate: (v) => v?.trim() ? validate?.(v.trim()) : void 0
12582
+ validate: (v) => v?.trim() ? validate2?.(v.trim()) : void 0
12210
12583
  })
12211
12584
  ).trim() || current.region);
12212
- const problem = region ? validate?.(region) : "Say which region.";
12585
+ const problem = region ? validate2?.(region) : "Say which region.";
12213
12586
  if (!region || problem) throw new CloudhopError("INVALID_NAME", problem ?? "Invalid region.");
12214
12587
  await app.setAliasRegion(name, region);
12215
12588
  console.log(`${name}: ${current.region ?? "(none)"} \u2192 ${region}`);
@@ -12234,7 +12607,7 @@ async function statusCommand(app, args) {
12234
12607
  if (flags.json) return printJson({ alias: status });
12235
12608
  const account = status.attributes.accountId ? ` (${status.attributes.accountId})` : "";
12236
12609
  console.log(
12237
- `${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}`
12610
+ `${bold(paint("sky", status.name))} \u2192 ${status.label ?? status.target}${account}, ${status.region ?? "no region"}${status.prod ? ` ${paint("red", "PROD")}` : ""}`
12238
12611
  );
12239
12612
  console.log(` ${STATE_SHORT[status.session]}: ${NEXT_TEXT[status.next]}`);
12240
12613
  if (status.next === "browser-sign-in") {
@@ -12255,10 +12628,61 @@ async function statusCommand(app, args) {
12255
12628
  );
12256
12629
  }
12257
12630
  }
12631
+ async function prodCommand(app, args) {
12632
+ const { positional, flags } = parseArgs(args);
12633
+ if (positional.length === 0) {
12634
+ const prod = (await app.overview()).aliases.filter((a2) => a2.prod).map((a2) => a2.name);
12635
+ console.log(
12636
+ prod.length ? `Production aliases: ${prod.join(", ")}` : "No alias is marked as production."
12637
+ );
12638
+ console.log(paint("dim", "Mark one with `cloudhop prod <alias>`, unmark with `--off`."));
12639
+ return;
12640
+ }
12641
+ const on = !flags.off;
12642
+ await app.setProduction(positional, on);
12643
+ for (const name of positional) {
12644
+ console.log(
12645
+ on ? `${paint("red", "PROD")} ${name}: cloudhop asks before any change.` : `${name} is no longer marked as production.`
12646
+ );
12647
+ }
12648
+ }
12649
+ async function renameCommand(app, args) {
12650
+ const [from, to] = args;
12651
+ if (!from || !to)
12652
+ throw new CloudhopError("INVALID_NAME", "Usage: cloudhop mv <alias> <new-name>");
12653
+ await app.renameAlias(from, to);
12654
+ console.log(`${from} \u2192 ${to}`);
12655
+ }
12258
12656
 
12259
12657
  // src/commands/run.ts
12658
+ async function guardProduction(app, alias, args) {
12659
+ const status = await app.aliasStatus(alias).catch(() => void 0);
12660
+ if (!status?.prod) return;
12661
+ const where = `${status.label ?? status.target}${status.region ? `, ${status.region}` : ""}`;
12662
+ if (!await app.needsProductionConfirmation(alias, args)) {
12663
+ if (process.stderr.isTTY) notify(paint("red", `${alias} is production (${where})`));
12664
+ return;
12665
+ }
12666
+ if (process.env.CLOUDHOP_CONFIRM_PROD === "1") return;
12667
+ const what = args ? `aws ${args.join(" ")}` : "this command";
12668
+ if (!interactive()) {
12669
+ throw new CloudhopError(
12670
+ "PROD_CONFIRMATION_REQUIRED",
12671
+ `${alias} is production and ${what} may change resources.`,
12672
+ "Run it in a terminal to confirm, or set CLOUDHOP_CONFIRM_PROD=1 once a person has approved it."
12673
+ );
12674
+ }
12675
+ dist_exports.log.warn(
12676
+ `${paint("red", bold("PRODUCTION"))} ${bold(alias)} \u2192 ${where}
12677
+ ${paint("dim", "This may change resources:")} ${what}`
12678
+ );
12679
+ const ok = answer(await dist_exports.confirm({ message: `Run it in ${alias}?`, initialValue: false }));
12680
+ if (!ok) throw new CloudhopError("PROD_CONFIRMATION_REQUIRED", "Cancelled: nothing ran.");
12681
+ }
12260
12682
  async function proxyCommand(app, alias, args) {
12261
- return app.runAsAlias(alias, args.length > 0 ? args : await app.identityArgs(alias));
12683
+ const cliArgs = args.length > 0 ? args : await app.identityArgs(alias);
12684
+ await guardProduction(app, alias, cliArgs);
12685
+ return app.runAsAlias(alias, cliArgs);
12262
12686
  }
12263
12687
  async function execCommand(app, args) {
12264
12688
  const separator = args.indexOf("--");
@@ -12272,6 +12696,7 @@ async function execCommand(app, args) {
12272
12696
  const regionIndex = flags.indexOf("--region");
12273
12697
  if (regionIndex !== -1 && flags[regionIndex + 1])
12274
12698
  overrides.region = flags[regionIndex + 1];
12699
+ await guardProduction(app, alias);
12275
12700
  const [bin, ...rest] = command;
12276
12701
  return app.execAsAlias(alias, bin, rest, overrides);
12277
12702
  }
@@ -12371,6 +12796,408 @@ async function doctorCommand(app) {
12371
12796
  );
12372
12797
  }
12373
12798
 
12799
+ // src/commands/shell.ts
12800
+ import { existsSync as existsSync3 } from "fs";
12801
+ import { appendFile, mkdir as mkdir3, readFile as readFile3 } from "fs/promises";
12802
+ import { homedir as homedir3 } from "os";
12803
+ import { dirname as dirname3, join as join5 } from "path";
12804
+ var SHELLS = ["zsh", "bash", "fish", "pwsh"];
12805
+ var ALIAS_COMMANDS = "use status console region mv prod rm exec";
12806
+ var SOURCE_COMMANDS = "alias login sync ls";
12807
+ var SCRIPTS = {
12808
+ zsh: `# cloudhop shell integration (zsh). Load it after compinit: eval "$(cloudhop shell zsh)"
12809
+ cloudhop() {
12810
+ if [ "$1" = "use" ]; then
12811
+ local out; out="$(command cloudhop use --emit zsh "\${@:2}")" || return $?
12812
+ eval "$out"
12813
+ else
12814
+ command cloudhop "$@"
12815
+ fi
12816
+ }
12817
+ _cloudhop_words() { command cloudhop __complete "$1" 2>/dev/null }
12818
+ _hop() { (( CURRENT == 2 )) && { local -a a; a=(\${(f)"$(_cloudhop_words aliases)"}); compadd -a a; } }
12819
+ _cloudhop() {
12820
+ local -a w
12821
+ if (( CURRENT == 2 )); then w=(\${(f)"$(_cloudhop_words commands)"}); compadd -a w; return; fi
12822
+ (( CURRENT == 3 )) || return
12823
+ case " ${ALIAS_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words aliases)"});; esac
12824
+ case " ${SOURCE_COMMANDS} " in *" $words[2] "*) w=(\${(f)"$(_cloudhop_words sources)"});; esac
12825
+ compadd -a w
12826
+ }
12827
+ (( $+functions[compdef] )) && { compdef _hop hop; compdef _cloudhop cloudhop; }
12828
+ # Add $(cloudhop_prompt) to your prompt to see the alias set with \`cloudhop use\`.
12829
+ cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
12830
+ `,
12831
+ bash: `# cloudhop shell integration (bash): eval "$(cloudhop shell bash)"
12832
+ cloudhop() {
12833
+ if [ "$1" = "use" ]; then
12834
+ local out; out="$(command cloudhop use --emit bash "\${@:2}")" || return $?
12835
+ eval "$out"
12836
+ else
12837
+ command cloudhop "$@"
12838
+ fi
12839
+ }
12840
+ _cloudhop_complete() {
12841
+ local cur=\${COMP_WORDS[COMP_CWORD]} cmd=\${COMP_WORDS[0]##*/} kind=""
12842
+ if [ "$cmd" = hop ]; then [ "$COMP_CWORD" -eq 1 ] && kind=aliases
12843
+ elif [ "$COMP_CWORD" -eq 1 ]; then kind=commands
12844
+ elif [ "$COMP_CWORD" -eq 2 ]; then
12845
+ case " ${ALIAS_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=aliases;; esac
12846
+ case " ${SOURCE_COMMANDS} " in *" \${COMP_WORDS[1]} "*) kind=sources;; esac
12847
+ fi
12848
+ [ -n "$kind" ] && COMPREPLY=($(compgen -W "$(command cloudhop __complete $kind 2>/dev/null)" -- "$cur"))
12849
+ }
12850
+ complete -o default -F _cloudhop_complete hop cloudhop
12851
+ # Add $(cloudhop_prompt) to PS1 to see the alias set with \`cloudhop use\`.
12852
+ cloudhop_prompt() { [ -n "$CLOUDHOP_ALIAS" ] && printf '\u2601 %s%s' "$CLOUDHOP_ALIAS" "\${CLOUDHOP_PROD:+ PROD}"; }
12853
+ `,
12854
+ fish: `# cloudhop shell integration (fish): cloudhop shell fish | source
12855
+ function cloudhop
12856
+ if test "$argv[1]" = use
12857
+ set -l out (command cloudhop use --emit fish $argv[2..-1]); or return $status
12858
+ printf '%s\\n' $out | source
12859
+ else
12860
+ command cloudhop $argv
12861
+ end
12862
+ end
12863
+ complete -c hop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete aliases 2>/dev/null)'
12864
+ complete -c cloudhop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete commands 2>/dev/null)'
12865
+ complete -c cloudhop -f -n '__fish_seen_subcommand_from ${ALIAS_COMMANDS}; and test (count (commandline -opc)) -eq 2' -a '(command cloudhop __complete aliases 2>/dev/null)'
12866
+ complete -c cloudhop -f -n '__fish_seen_subcommand_from ${SOURCE_COMMANDS}; and test (count (commandline -opc)) -eq 2' -a '(command cloudhop __complete sources 2>/dev/null)'
12867
+ # Call cloudhop_prompt from fish_prompt to see the alias set with \`cloudhop use\`.
12868
+ function cloudhop_prompt
12869
+ test -n "$CLOUDHOP_ALIAS"; and printf '\u2601 %s%s' $CLOUDHOP_ALIAS (test -n "$CLOUDHOP_PROD"; and echo ' PROD')
12870
+ end
12871
+ `,
12872
+ pwsh: `# cloudhop shell integration (PowerShell): cloudhop shell pwsh | Out-String | Invoke-Expression
12873
+ $script:CloudhopExe = (Get-Command cloudhop -CommandType Application, ExternalScript | Select-Object -First 1).Source
12874
+ function cloudhop {
12875
+ if ($args.Count -gt 0 -and $args[0] -eq 'use') {
12876
+ $out = & $script:CloudhopExe use --emit pwsh @($args | Select-Object -Skip 1)
12877
+ if ($LASTEXITCODE -eq 0) { $out | Out-String | Invoke-Expression }
12878
+ } else { & $script:CloudhopExe @args }
12879
+ }
12880
+ Register-ArgumentCompleter -Native -CommandName hop, cloudhop -ScriptBlock {
12881
+ param($word, $ast)
12882
+ $n = $ast.CommandElements.Count - [int]($word -eq '')
12883
+ $kind = if ($ast.CommandElements[0].Value -match 'hop$' -and $ast.CommandElements[0].Value -notmatch 'cloudhop') { if ($n -le 1) { 'aliases' } }
12884
+ elseif ($n -le 1) { 'commands' }
12885
+ elseif ($n -eq 2 -and ' ${ALIAS_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'aliases' }
12886
+ elseif ($n -eq 2 -and ' ${SOURCE_COMMANDS} ' -match " $($ast.CommandElements[1].Value) ") { 'sources' }
12887
+ if ($kind) { & $script:CloudhopExe __complete $kind | Where-Object { $_ -like "$word*" } }
12888
+ }
12889
+ function cloudhop_prompt { if ($env:CLOUDHOP_ALIAS) { "\u2601 $env:CLOUDHOP_ALIAS$(if ($env:CLOUDHOP_PROD) { ' PROD' })" } }
12890
+ `
12891
+ };
12892
+ var quote = (shell, value) => shell === "pwsh" ? `'${value.replace(/'/g, "''")}'` : `'${value.replace(/'/g, "'\\''")}'`;
12893
+ function shellExports(shell, env) {
12894
+ return Object.entries(env).map(([key, value]) => {
12895
+ if (value === void 0) {
12896
+ if (shell === "fish") return `set -e ${key}`;
12897
+ if (shell === "pwsh") return `Remove-Item Env:${key} -ErrorAction SilentlyContinue`;
12898
+ return `unset ${key}`;
12899
+ }
12900
+ if (shell === "fish") return `set -gx ${key} ${quote(shell, value)}`;
12901
+ if (shell === "pwsh") return `$env:${key} = ${quote(shell, value)}`;
12902
+ return `export ${key}=${quote(shell, value)}`;
12903
+ }).join("\n");
12904
+ }
12905
+ function detectShell() {
12906
+ if (process.platform === "win32") return "pwsh";
12907
+ const name = (process.env.SHELL ?? "").split("/").pop();
12908
+ return SHELLS.includes(name) ? name : "bash";
12909
+ }
12910
+ function parseShell(value) {
12911
+ if (!value) return detectShell();
12912
+ const shell = value === "powershell" ? "pwsh" : value;
12913
+ if (!SHELLS.includes(shell)) {
12914
+ throw new CloudhopError(
12915
+ "INVALID_NAME",
12916
+ `Unknown shell "${value}".`,
12917
+ `Use one of: ${SHELLS.join(", ")}.`
12918
+ );
12919
+ }
12920
+ return shell;
12921
+ }
12922
+ var RC_FILES = {
12923
+ zsh: (home) => join5(home, ".zshrc"),
12924
+ bash: (home) => join5(home, ".bashrc"),
12925
+ fish: (home) => join5(home, ".config", "fish", "config.fish"),
12926
+ pwsh: (home) => process.platform === "win32" ? join5(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") : join5(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")
12927
+ };
12928
+ var LOAD_LINE = {
12929
+ zsh: 'eval "$(cloudhop shell zsh)"',
12930
+ bash: 'eval "$(cloudhop shell bash)"',
12931
+ fish: "cloudhop shell fish | source",
12932
+ pwsh: "cloudhop shell pwsh | Out-String | Invoke-Expression"
12933
+ };
12934
+ async function shellCommand(args) {
12935
+ const { positional, flags } = parseArgs(args);
12936
+ if (positional[0] !== "install") {
12937
+ process.stdout.write(SCRIPTS[parseShell(positional[0])]);
12938
+ return;
12939
+ }
12940
+ const shell = parseShell(positional[1]);
12941
+ const file = RC_FILES[shell](homedir3());
12942
+ const line = LOAD_LINE[shell];
12943
+ const current = existsSync3(file) ? await readFile3(file, "utf8") : "";
12944
+ if (current.includes(line)) {
12945
+ console.log(`Already set up in ${file.replace(homedir3(), "~")}.`);
12946
+ return;
12947
+ }
12948
+ if (!flags.yes) {
12949
+ if (!interactive())
12950
+ throw new CloudhopError(
12951
+ "NOT_INTERACTIVE",
12952
+ `Add this line to ${file} yourself, or rerun with --yes:
12953
+ ${line}`
12954
+ );
12955
+ const ok = answer(
12956
+ await dist_exports.confirm({
12957
+ message: `Add cloudhop to ${file.replace(homedir3(), "~")}?`,
12958
+ initialValue: true
12959
+ })
12960
+ );
12961
+ if (!ok) return;
12962
+ }
12963
+ await mkdir3(dirname3(file), { recursive: true });
12964
+ const note2 = shell === "zsh" ? " # after compinit" : "";
12965
+ await appendFile(
12966
+ file,
12967
+ `${current && !current.endsWith("\n") ? "\n" : ""}
12968
+ # cloudhop: \`cloudhop use\`, tab completion and cloudhop_prompt${note2}
12969
+ ${line}
12970
+ `
12971
+ );
12972
+ console.log(
12973
+ `${paint("green", "\u2714")} Added to ${file.replace(homedir3(), "~")}. Open a new terminal to use it.`
12974
+ );
12975
+ }
12976
+ async function useCommand(app, args) {
12977
+ const { positional, flags } = parseArgs(args);
12978
+ const shell = flags.emit ? parseShell(flags.emit) : void 0;
12979
+ if (!shell) {
12980
+ throw new CloudhopError(
12981
+ "NOT_INTERACTIVE",
12982
+ "`cloudhop use` needs the shell integration to change your current shell.",
12983
+ `Set it up once with \`cloudhop shell install\` (or add ${LOAD_LINE[detectShell()]} to your shell's startup file), then open a new terminal.`
12984
+ );
12985
+ }
12986
+ const clear = {
12987
+ AWS_ACCESS_KEY_ID: void 0,
12988
+ AWS_SECRET_ACCESS_KEY: void 0,
12989
+ AWS_SESSION_TOKEN: void 0
12990
+ };
12991
+ if (flags.off) {
12992
+ process.stdout.write(
12993
+ `${shellExports(shell, { ...clear, AWS_PROFILE: void 0, AWS_REGION: void 0, AWS_DEFAULT_REGION: void 0, CLOUDHOP_ALIAS: void 0, CLOUDHOP_PROD: void 0 })}
12994
+ `
12995
+ );
12996
+ notify("No alias in this shell anymore.");
12997
+ return;
12998
+ }
12999
+ const alias = positional[0] ?? await pickAlias(app, "Use which alias in this shell?");
13000
+ const env = await app.envForAlias(alias, "cli");
13001
+ const status = await app.aliasStatus(alias);
13002
+ process.stdout.write(
13003
+ `${shellExports(shell, { ...clear, ...env, CLOUDHOP_ALIAS: alias, CLOUDHOP_PROD: status.prod ? "1" : void 0 })}
13004
+ `
13005
+ );
13006
+ notify(
13007
+ `This shell now uses ${alias} (${status.label ?? status.target}, ${status.region ?? "no region"}).`
13008
+ );
13009
+ if (status.prod) {
13010
+ notify(
13011
+ paint(
13012
+ "red",
13013
+ `${alias} is production. Plain \`aws\` commands are not protected here: use \`hop ${alias}\` to be asked before changes.`
13014
+ )
13015
+ );
13016
+ }
13017
+ }
13018
+ async function completeCommand(app, kind) {
13019
+ if (kind === "commands") {
13020
+ console.log(
13021
+ [...RESERVED_NAMES, "each", "shell", "use"].filter((c3, i2, a2) => a2.indexOf(c3) === i2).sort().join("\n")
13022
+ );
13023
+ return;
13024
+ }
13025
+ const config = await app.config();
13026
+ const words = kind === "sources" ? config.sources.map((s) => s.id) : config.aliases.map((a2) => a2.name);
13027
+ if (words.length) console.log(words.join("\n"));
13028
+ }
13029
+
13030
+ // src/commands/team.ts
13031
+ import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
13032
+ var import_yaml2 = __toESM(require_dist(), 1);
13033
+ var MAX_BYTES = 256 * 1024;
13034
+ var HEADER2 = `# cloudhop team setup: import it with \`cloudhop team import <this file or its https URL>\`.
13035
+ # No secrets: portal URLs, regions, account ids and role names only.
13036
+ `;
13037
+ function validate(app, raw) {
13038
+ const bad = (why) => new CloudhopError("INVALID_NAME", `Not a cloudhop team file: ${why}.`);
13039
+ if (!raw || typeof raw !== "object") throw bad("it is empty");
13040
+ const file = raw;
13041
+ if (file.version !== 1) throw bad('unknown "version" (expected 1)');
13042
+ const sources = file.sources ?? {};
13043
+ const aliases = file.aliases ?? {};
13044
+ for (const [id, source] of Object.entries(sources)) {
13045
+ assertValidName("source", id);
13046
+ if (!PROVIDER_IDS.includes(source?.provider))
13047
+ throw bad(`source "${id}" has an unknown provider`);
13048
+ const kind = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind);
13049
+ if (!kind) throw bad(`source "${id}" has an unknown kind`);
13050
+ for (const field of kind.fields) {
13051
+ const value = source.settings?.[field.key];
13052
+ if (typeof value !== "string" || !value) throw bad(`source "${id}" is missing ${field.key}`);
13053
+ const problem = field.validate?.(value);
13054
+ if (problem) throw bad(`source "${id}": ${problem}`);
13055
+ }
13056
+ }
13057
+ for (const [name, alias] of Object.entries(aliases)) {
13058
+ assertValidName("alias", name);
13059
+ if (!alias || typeof alias.target !== "string" || !sources[alias.source]) {
13060
+ throw bad(`alias "${name}" must name a source of this file and a target`);
13061
+ }
13062
+ }
13063
+ return { version: 1, sources, aliases };
13064
+ }
13065
+ async function load(location) {
13066
+ if (/^http:\/\//i.test(location)) throw new CloudhopError("INVALID_NAME", "Use an https:// URL.");
13067
+ if (/^https:\/\//i.test(location)) {
13068
+ const response = await fetch(location, { signal: AbortSignal.timeout(1e4) });
13069
+ if (!response.ok)
13070
+ throw new CloudhopError("INVALID_NAME", `Could not download it (HTTP ${response.status}).`);
13071
+ const text4 = await response.text();
13072
+ if (text4.length > MAX_BYTES)
13073
+ throw new CloudhopError("INVALID_NAME", "That file is too big for a team setup.");
13074
+ return text4;
13075
+ }
13076
+ return readFile4(location, "utf8");
13077
+ }
13078
+ async function teamCommand(app, args) {
13079
+ const { positional, flags } = parseArgs(args);
13080
+ const [action, location] = positional;
13081
+ if (action === "export") {
13082
+ const config2 = await app.config();
13083
+ const wanted = flags.sources?.split(",").map((s) => s.trim());
13084
+ const sources = config2.sources.filter(
13085
+ (s) => s.kind !== "console-login" && (!wanted || wanted.includes(s.id))
13086
+ );
13087
+ const ids = new Set(sources.map((s) => s.id));
13088
+ const file = {
13089
+ version: 1,
13090
+ sources: Object.fromEntries(sources.map(({ id, ...rest }) => [id, rest])),
13091
+ aliases: Object.fromEntries(
13092
+ config2.aliases.filter((a2) => ids.has(a2.source)).map(({ name, ...rest }) => [name, rest])
13093
+ )
13094
+ };
13095
+ const text4 = HEADER2 + (0, import_yaml2.stringify)(file);
13096
+ if (flags.out) {
13097
+ await writeFile3(flags.out, text4);
13098
+ console.error(
13099
+ `${paint("green", "\u2714")} Wrote ${sources.length} source(s) and ${Object.keys(file.aliases).length} alias(es) to ${flags.out}.`
13100
+ );
13101
+ } else {
13102
+ process.stdout.write(text4);
13103
+ }
13104
+ return;
13105
+ }
13106
+ if (action !== "import" || !location) {
13107
+ throw new CloudhopError(
13108
+ "INVALID_NAME",
13109
+ "Usage: cloudhop team export [--out file] | cloudhop team import <file|https URL>"
13110
+ );
13111
+ }
13112
+ const team = validate(app, (0, import_yaml2.parse)(await load(location)));
13113
+ const config = await app.config();
13114
+ const existingSources = new Map(config.sources.map((s) => [s.id, s]));
13115
+ const sameUrl = (a2, b3) => a2.startUrl !== void 0 && a2.startUrl === b3.startUrl;
13116
+ const newSources = [];
13117
+ const sourceMap = /* @__PURE__ */ new Map();
13118
+ for (const [id, source] of Object.entries(team.sources)) {
13119
+ const match = config.sources.find(
13120
+ (s) => s.provider === source.provider && sameUrl(s.settings, source.settings)
13121
+ );
13122
+ if (match) {
13123
+ sourceMap.set(id, match.id);
13124
+ continue;
13125
+ }
13126
+ if (existingSources.has(id))
13127
+ throw new CloudhopError(
13128
+ "SOURCE_EXISTS",
13129
+ `You already have a different source named "${id}".`,
13130
+ "Rename yours with `cloudhop rm` and add it again, or edit the team file."
13131
+ );
13132
+ newSources.push({ id, ...source });
13133
+ sourceMap.set(id, id);
13134
+ }
13135
+ const takenAliases = new Set(config.aliases.map((a2) => a2.name));
13136
+ const newAliases = [];
13137
+ const skipped = [];
13138
+ for (const [name, alias] of Object.entries(team.aliases)) {
13139
+ if (takenAliases.has(name)) skipped.push(name);
13140
+ else newAliases.push({ name, ...alias, source: sourceMap.get(alias.source) });
13141
+ }
13142
+ if (newSources.length === 0 && newAliases.length === 0) {
13143
+ console.log("Nothing to import: you already have everything in this file.");
13144
+ return;
13145
+ }
13146
+ console.log(bold("This team setup will add:"));
13147
+ for (const s of newSources)
13148
+ console.log(
13149
+ ` source ${bold(s.id)} ${paint("sky", s.settings.startUrl ?? s.kind)} ${s.settings.region ?? ""}`
13150
+ );
13151
+ for (const a2 of newAliases)
13152
+ console.log(` alias ${a2.name} ${a2.target}${a2.prod ? ` ${paint("red", "PROD")}` : ""}`);
13153
+ if (skipped.length)
13154
+ console.log(paint("dim", ` (skipping aliases you already have: ${skipped.join(", ")})`));
13155
+ if (newSources.length)
13156
+ console.log(
13157
+ paint("yellow", "Check that these portal URLs are your organization's before signing in.")
13158
+ );
13159
+ if (!flags.yes) {
13160
+ if (!interactive())
13161
+ throw new CloudhopError(
13162
+ "NOT_INTERACTIVE",
13163
+ "Review the list above and rerun with --yes to import it."
13164
+ );
13165
+ if (!answer(
13166
+ await dist_exports.confirm({
13167
+ message: "Import it? Each new portal opens your browser to sign in.",
13168
+ initialValue: true
13169
+ })
13170
+ ))
13171
+ return;
13172
+ }
13173
+ const providers = new Set((await app.config()).providers);
13174
+ for (const s of newSources) if (!providers.has(s.provider)) await app.enableProvider(s.provider);
13175
+ for (const source of newSources) {
13176
+ const report = await app.connectSource(source);
13177
+ console.log(
13178
+ `${paint("green", "\u2714")} ${source.id}: signed in, ${report.targets.length} account/role(s).`
13179
+ );
13180
+ }
13181
+ const usable = [];
13182
+ const missing = [];
13183
+ for (const alias of newAliases) {
13184
+ const targets = await app.listTargets(alias.source);
13185
+ if (targets.some((t2) => t2.id === alias.target)) usable.push(alias);
13186
+ else missing.push(alias.name);
13187
+ }
13188
+ if (usable.length) await app.addAliases(usable);
13189
+ console.log(
13190
+ `${paint("green", "\u2714")} Created ${usable.length} alias(es)${usable.length ? `: ${usable.map((a2) => a2.name).join(", ")}` : ""}.`
13191
+ );
13192
+ if (missing.length)
13193
+ console.log(
13194
+ paint(
13195
+ "yellow",
13196
+ `Not created, you don't have access to their account/role: ${missing.join(", ")}.`
13197
+ )
13198
+ );
13199
+ }
13200
+
12374
13201
  // ../ui/src/api.ts
12375
13202
  var text3 = (body, key) => {
12376
13203
  const value = body[key];
@@ -12409,8 +13236,8 @@ function createApi(app, assistants) {
12409
13236
  return assistants;
12410
13237
  };
12411
13238
  const regionCheck = (source, region) => {
12412
- const validate = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
12413
- const problem = region ? validate?.(region) : "Region is required.";
13239
+ const validate2 = app.provider(source.provider).sourceKinds.find((k) => k.kind === source.kind)?.fields.find((f2) => f2.key === "region")?.validate;
13240
+ const problem = region ? validate2?.(region) : "Region is required.";
12414
13241
  if (problem) throw badRequest(problem);
12415
13242
  };
12416
13243
  const findSource = async (id) => {
@@ -12461,7 +13288,8 @@ function createApi(app, assistants) {
12461
13288
  target: alias.target,
12462
13289
  label: target?.label,
12463
13290
  missing: !target,
12464
- region
13291
+ region,
13292
+ prod: alias.prod === true
12465
13293
  }))
12466
13294
  };
12467
13295
  },
@@ -12556,12 +13384,28 @@ function createApi(app, assistants) {
12556
13384
  await app.addAliases(aliases);
12557
13385
  return { created: aliases.map((a2) => a2.name) };
12558
13386
  },
13387
+ /** Changes an alias: any of `region`, `prod` (production mark) and `name` (rename, applied last). */
12559
13388
  "PATCH /api/aliases/:name": async ([name], body) => {
12560
- const resolved = await app.resolveAlias(name);
12561
- const region = text3(body, "region");
12562
- regionCheck(resolved.source, region);
12563
- await app.setAliasRegion(name, region);
12564
- return { name, region };
13389
+ let current = name;
13390
+ if ("region" in body) {
13391
+ const resolved = await app.resolveAlias(current);
13392
+ const region = text3(body, "region");
13393
+ regionCheck(resolved.source, region);
13394
+ await app.setAliasRegion(current, region);
13395
+ }
13396
+ if (typeof body.prod === "boolean") await app.setProduction([current], body.prod);
13397
+ if ("name" in body) {
13398
+ const to = text3(body, "name");
13399
+ await app.renameAlias(current, to);
13400
+ current = to;
13401
+ }
13402
+ return { name: current };
13403
+ },
13404
+ /** Opens the web console in the user's browser; the sign-in URL never reaches the page. */
13405
+ "POST /api/aliases/:name/console": async ([name], body) => {
13406
+ const service = text3(body, "service");
13407
+ const via = await app.openConsole(name, service ? { service } : {});
13408
+ return { via };
12565
13409
  },
12566
13410
  "DELETE /api/aliases/:name": async ([name]) => {
12567
13411
  await app.removeAlias(name);
@@ -12598,7 +13442,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
12598
13442
  import { createServer } from "http";
12599
13443
 
12600
13444
  // ../ui/src/assets/app.js
12601
- var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// A fresh link opened in this same tab only changes the fragment: reload to pick up its token.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) location.reload();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/sources(\\/[^/]+\\/(login|sync|targets))?$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n if (state.sources.length === 0) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n ),\n assistantsCard(),\n );\n loadAssistants();\n return;\n }\n $main.replaceChildren(...state.sources.map(sourceCard), assistantsCard());\n loadAssistants();\n}\n\n// ---------- AI assistants ----------\n\nlet assistantScope = 'user';\ntry {\n assistantScope =\n localStorage.getItem('cloudhop-assistant-scope') === 'project' ? 'project' : 'user';\n} catch {}\nlet assistantsData;\n\nconst SKILL_TEXT = {\n current: ['valid', 'Up to date'],\n outdated: ['refreshable', 'Outdated'],\n modified: ['refreshable', 'Edited by you'],\n missing: ['', 'Not installed'],\n};\nconst MCP_TEXT = { registered: 'On', missing: 'Off', unknown: 'Check in the app' };\n\nfunction assistantsCard() {\n return h('section', { class: 'card', id: 'assistants', hidden: true });\n}\n\nasync function loadAssistants() {\n try {\n assistantsData = await api('GET', '/api/assistants');\n renderAssistants();\n } catch {\n document.getElementById('assistants')?.remove();\n }\n}\n\nfunction setAssistantScope(scope) {\n assistantScope = scope;\n try {\n localStorage.setItem('cloudhop-assistant-scope', scope);\n } catch {}\n renderAssistants();\n}\n\nfunction assistantAction(label, run, { danger = false, confirmText } = {}) {\n return h(\n 'button',\n {\n class: danger ? 'danger' : undefined,\n onclick: (e) => {\n if (confirmText && !confirm(confirmText)) return;\n busy(e.currentTarget, async () => {\n try {\n await run();\n } catch (error) {\n fail(error);\n }\n await loadAssistants();\n });\n },\n },\n label,\n );\n}\n\nfunction reportInstall(results) {\n for (const r of results) {\n if (r.mcp === 'manual') {\n toast(`${r.name}: skill installed. Add the MCP server by hand.`, { hint: r.mcpManual });\n } else {\n toast(`${r.name} now knows cloudhop. Start a new chat so it picks it up.`);\n }\n }\n}\n\nfunction renderAssistants() {\n const el = document.getElementById('assistants');\n if (!el || !assistantsData) return;\n const rows = assistantScope === 'user' ? assistantsData.user : assistantsData.projectScope;\n const scope = assistantScope;\n const install = (tools) =>\n api('POST', '/api/assistants', { tools, scope, mcp: true }).then((r) =>\n reportInstall(r.results),\n );\n const missingDetected = rows\n .filter((r) => r.detected && r.skill === 'missing')\n .map((r) => r.assistant);\n\n const seg = h(\n 'div',\n { class: 'seg', role: 'group', 'aria-label': 'Where' },\n h(\n 'button',\n { 'aria-pressed': String(scope === 'user'), onclick: () => setAssistantScope('user') },\n 'For me',\n ),\n h(\n 'button',\n { 'aria-pressed': String(scope === 'project'), onclick: () => setAssistantScope('project') },\n 'This project',\n ),\n );\n\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, 'AI assistants'),\n h(\n 'span',\n { class: 'meta' },\n scope === 'user'\n ? 'Installed in your home folder, for every project'\n : assistantsData.project,\n ),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n seg,\n missingDetected.length > 1\n ? assistantAction('Install for all detected', () => install(missingDetected))\n : null,\n ),\n );\n\n const table = h(\n 'table',\n { class: 'assistants' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Assistant'),\n h('th', {}, 'Skill'),\n h('th', {}, 'MCP server'),\n h('th'),\n ),\n ),\n h(\n 'tbody',\n {},\n rows.map((r) => {\n const [badgeClass, skillText] = SKILL_TEXT[r.skill];\n const actions = [];\n if (r.skill === 'missing')\n actions.push(assistantAction('Install', () => install([r.assistant])));\n if (r.skill === 'outdated') {\n actions.push(\n assistantAction('Update', () =>\n api('POST', '/api/assistants/update', {}).then(() =>\n toast(`${r.name}: skill updated.`),\n ),\n ),\n );\n }\n if (r.skill === 'modified') {\n actions.push(\n assistantAction(\n 'Replace',\n () =>\n api('POST', '/api/assistants/update', { force: true }).then(() =>\n toast('Edited skills replaced.'),\n ),\n {\n confirmText: 'Replace your edited copies with the current cloudhop skill?',\n },\n ),\n );\n }\n if (r.skill !== 'missing' || r.mcp === 'registered') {\n actions.push(\n assistantAction(\n 'Remove',\n () => api('POST', '/api/assistants/remove', { tools: [r.assistant], scope }),\n {\n danger: true,\n confirmText: `Remove the cloudhop skill and MCP server from ${r.name}?`,\n },\n ),\n );\n }\n return h(\n 'tr',\n {},\n h(\n 'td',\n { class: 'alias' },\n r.name,\n r.detected ? null : h('small', { class: 'muted-inline' }, ' not detected'),\n ),\n h('td', {}, h('span', { class: `badge ${badgeClass}`, title: r.skillPath }, skillText)),\n h('td', {}, r.skill === 'missing' && r.mcp !== 'registered' ? '' : MCP_TEXT[r.mcp]),\n h(\n 'td',\n { style: 'text-align:right' },\n h('div', { class: 'actions', style: 'justify-content:flex-end' }, actions),\n ),\n );\n }),\n ),\n );\n\n el.replaceChildren(\n head,\n h(\n 'div',\n { class: 'card-body' },\n h(\n 'p',\n { class: 'card-help' },\n 'Teach your AI assistant to use cloudhop: it asks you which alias to work with, runs commands with hop, and confirms before changing anything. Includes the cloudhop MCP server.',\n ),\n table,\n ),\n );\n el.hidden = false;\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'table',\n {},\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n h('tbody', {}, aliases.map(aliasRow)),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n return h(\n 'tr',\n {},\n h('td', { class: 'alias', title: `hop ${alias.name} <command>` }, alias.name),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h(\n 'td',\n { style: 'text-align:right' },\n h(\n 'button',\n {\n class: 'link',\n title: `Remove ${alias.name}`,\n 'aria-label': `Remove ${alias.name}`,\n onclick: (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n },\n '\u2715',\n ),\n ),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n refresh();\n}\n";
13445
+ var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// ---------- pages ----------\n\nconst PAGES = {\n aliases: 'Aliases',\n sources: 'Sources',\n assistants: 'AI assistants',\n tools: 'Tools',\n};\nlet route = 'aliases';\nconst aliasFilter = { text: '', source: '', prodOnly: false };\n\n/** `#/aliases?source=acme` \u2192 route \"aliases\", filtered by that source. */\nfunction readRoute() {\n const match = /^#\\/([a-z]+)(?:\\?(.*))?$/.exec(location.hash);\n route = match && PAGES[match[1]] ? match[1] : 'aliases';\n const query = new URLSearchParams(match?.[2] ?? '');\n if (query.has('source')) aliasFilter.source = query.get('source');\n}\n\nfunction go(page, query) {\n location.hash = `#/${page}${query ? `?${new URLSearchParams(query)}` : ''}`;\n}\n\n// A fresh link opened in this same tab carries a new token: reload to pick it up.\n// Any other fragment is a page change.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) {\n location.reload();\n return;\n }\n readRoute();\n if (state) render();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nconst $nav = document.getElementById('nav');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/(sources(\\/[^/]+\\/(login|sync|targets))?|aliases\\/[^/]+\\/console)$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n $nav.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n for (const link of $nav.querySelectorAll('a')) {\n if (link.dataset.page === route) link.setAttribute('aria-current', 'page');\n else link.removeAttribute('aria-current');\n }\n document.title = `${PAGES[route]} \xB7 cloudhop`;\n const pages = {\n aliases: aliasesPage,\n sources: sourcesPage,\n assistants: assistantsPage,\n tools: toolsPage,\n };\n $main.replaceChildren(...pages[route]());\n if (route === 'assistants') loadAssistants();\n if (route === 'tools') loadTools();\n}\n\nfunction pageHead(title, subtitle, ...actions) {\n return h(\n 'div',\n { class: 'page-head' },\n h('div', {}, h('h1', {}, title), subtitle ? h('p', {}, subtitle) : null),\n h('div', { class: 'actions' }, actions),\n );\n}\n\nfunction firstSourceHero() {\n return h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n );\n}\n\n// ---------- Aliases page ----------\n\nfunction aliasesPage() {\n if (state.sources.length === 0) return [firstSourceHero()];\n if (aliasFilter.source && !state.sources.some((s) => s.id === aliasFilter.source))\n aliasFilter.source = '';\n\n const addAliases = () => {\n const source =\n state.sources.find((s) => s.id === aliasFilter.source) ??\n (state.sources.length === 1 ? state.sources[0] : undefined);\n if (source) openAliasPicker(source);\n else openSourceChooser();\n };\n const head = pageHead(\n 'Aliases',\n 'Each alias is one account, role and region. Run it with hop <alias>.',\n h('button', { onclick: addAliases }, '+ Aliases'),\n );\n if (state.aliases.length === 0) {\n return [\n head,\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'No aliases yet'),\n h('p', {}, 'Pick the accounts and roles you use and give each one a short name.'),\n h('button', { class: 'primary', onclick: addAliases }, 'Pick accounts'),\n ),\n ];\n }\n\n const rows = h('tbody', { id: 'alias-rows' });\n const count = h('span', { class: 'count' });\n const fill = () => {\n const words = aliasFilter.text.toLowerCase().split(/\\s+/).filter(Boolean);\n const visible = state.aliases.filter((a) => {\n if (aliasFilter.source && a.source !== aliasFilter.source) return false;\n if (aliasFilter.prodOnly && !a.prod) return false;\n const text = `${a.name} ${a.label ?? a.target} ${a.region ?? ''} ${a.source}`.toLowerCase();\n return words.every((w) => text.includes(w));\n });\n rows.replaceChildren(\n ...(visible.length\n ? visible.map(aliasRow)\n : [\n h(\n 'tr',\n {},\n h('td', { colspan: '5', class: 'empty' }, 'No alias matches these filters.'),\n ),\n ]),\n );\n count.textContent = `${visible.length} of ${state.aliases.length}`;\n };\n\n const search = h('input', {\n type: 'search',\n placeholder: 'Filter: name, account, role or region',\n value: aliasFilter.text,\n 'aria-label': 'Filter aliases',\n oninput: (e) => {\n aliasFilter.text = e.currentTarget.value;\n fill();\n },\n });\n const sourceSelect = h(\n 'select',\n {\n 'aria-label': 'Source',\n onchange: (e) => {\n aliasFilter.source = e.currentTarget.value;\n fill();\n },\n },\n h('option', { value: '' }, 'All sources'),\n state.sources.map((s) =>\n h('option', { value: s.id, selected: s.id === aliasFilter.source }, s.id),\n ),\n );\n const prodOnly = h(\n 'label',\n { class: 'prod-toggle' },\n h('input', {\n type: 'checkbox',\n checked: aliasFilter.prodOnly,\n onchange: (e) => {\n aliasFilter.prodOnly = e.currentTarget.checked;\n fill();\n },\n }),\n 'Production only',\n );\n\n const table = h(\n 'table',\n { class: 'aliases' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Source'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n rows,\n );\n fill();\n return [\n head,\n h(\n 'div',\n { class: 'toolbar' },\n search,\n sourceSelect,\n prodOnly,\n h('div', { class: 'spacer' }),\n count,\n ),\n h('section', { class: 'card' }, table),\n ];\n}\n\n/** When several sources exist, ask which one to pick accounts from. */\nfunction openSourceChooser() {\n showDialog(\n 'Add aliases from',\n h(\n 'div',\n { class: 'choices' },\n state.sources.map((source) =>\n h(\n 'button',\n { class: 'choice', onclick: () => openAliasPicker(source) },\n h(\n 'span',\n {},\n h('strong', {}, source.id),\n h('small', {}, source.settings.startUrl ?? source.kindLabel),\n ),\n ),\n ),\n ),\n );\n}\n\n// ---------- Sources page ----------\n\nfunction sourcesPage() {\n const head = pageHead('Sources', 'The SSO portals and personal accounts you sign in to.');\n if (state.sources.length === 0) return [head, firstSourceHero()];\n return [head, ...state.sources.map(sourceCard)];\n}\n\n// ---------- AI assistants page ----------\n\nfunction assistantsPage() {\n return [assistantsCard()];\n}\n\n// ---------- Tools page ----------\n\nfunction snippet(command) {\n return h(\n 'div',\n { class: 'snippet' },\n h('code', {}, command),\n h(\n 'button',\n {\n class: 'link',\n title: 'Copy',\n 'aria-label': `Copy ${command}`,\n onclick: () =>\n navigator.clipboard\n ?.writeText(command)\n .then(() => toast('Copied.'))\n .catch(() =>\n toast('Copy it by hand: the browser blocked the clipboard.', { error: true }),\n ),\n },\n 'Copy',\n ),\n );\n}\n\nfunction toolCard(title, text, ...commands) {\n return h(\n 'section',\n { class: 'card' },\n h('div', { class: 'card-head' }, h('h2', {}, title)),\n h(\n 'div',\n { class: 'card-body padded' },\n h('p', { class: 'card-help' }, text),\n commands.map(snippet),\n ),\n );\n}\n\nfunction toolsPage() {\n const sample = state.aliases[0]?.name ?? 'acme-dev';\n return [\n pageHead('Tools', 'More ways to use cloudhop from your terminal.'),\n h(\n 'section',\n { class: 'card', id: 'tools-cli' },\n h('div', { class: 'card-head' }, h('h2', {}, 'Cloud CLIs')),\n h('div', { class: 'card-body padded' }, h('p', { class: 'card-help' }, 'Checking\u2026')),\n ),\n toolCard(\n 'Shell integration',\n 'Pick aliases from a list, tab-complete them, and use one in your shell so plain aws and Terraform work without hop.',\n 'cloudhop shell install',\n `cloudhop use ${sample}`,\n 'hop',\n ),\n toolCard(\n 'Run in many accounts',\n 'Run one command in many aliases at once: names, lists, wildcards or /regex/. It asks before running.',\n `cloudhop each '${sample.split('-')[0]}-*' -- s3 ls`,\n ),\n toolCard(\n 'Share with your team',\n 'Export your portals and aliases (no secrets) and let teammates import them in one command.',\n 'cloudhop team export --out team.yaml',\n 'cloudhop team import team.yaml',\n ),\n ];\n}\n\nasync function loadTools() {\n const card = document.querySelector('#tools-cli .card-body');\n if (!card) return;\n try {\n const entries = await api('GET', '/api/doctor');\n card.replaceChildren(\n ...entries.map((e) =>\n h(\n 'div',\n { class: 'tool-row' },\n h('span', { class: `badge ${e.installed && !e.outdated ? 'valid' : 'expired'}` }, e.cli),\n h(\n 'span',\n {},\n !e.installed\n ? 'Not installed.'\n : e.outdated\n ? `${e.version} is older than ${e.minVersion}.`\n : `Version ${e.version}.`,\n ),\n e.installed && !e.outdated ? null : snippet('cloudhop doctor'),\n ),\n ),\n );\n } catch (error) {\n card.replaceChildren(h('p', { class: 'card-help' }, error.message));\n }\n}\n\n// ---------- AI assistants ----------\n\nlet assistantScope = 'user';\ntry {\n assistantScope =\n localStorage.getItem('cloudhop-assistant-scope') === 'project' ? 'project' : 'user';\n} catch {}\nlet assistantsData;\n\nconst SKILL_TEXT = {\n current: ['valid', 'Up to date'],\n outdated: ['refreshable', 'Outdated'],\n modified: ['refreshable', 'Edited by you'],\n missing: ['', 'Not installed'],\n};\nconst MCP_TEXT = { registered: 'On', missing: 'Off', unknown: 'Check in the app' };\n\nfunction assistantsCard() {\n return h('section', { class: 'card', id: 'assistants', hidden: true });\n}\n\nasync function loadAssistants() {\n try {\n assistantsData = await api('GET', '/api/assistants');\n renderAssistants();\n } catch {\n document.getElementById('assistants')?.remove();\n }\n}\n\nfunction setAssistantScope(scope) {\n assistantScope = scope;\n try {\n localStorage.setItem('cloudhop-assistant-scope', scope);\n } catch {}\n renderAssistants();\n}\n\nfunction assistantAction(label, run, { danger = false, confirmText } = {}) {\n return h(\n 'button',\n {\n class: danger ? 'danger' : undefined,\n onclick: (e) => {\n if (confirmText && !confirm(confirmText)) return;\n busy(e.currentTarget, async () => {\n try {\n await run();\n } catch (error) {\n fail(error);\n }\n await loadAssistants();\n });\n },\n },\n label,\n );\n}\n\nfunction reportInstall(results) {\n for (const r of results) {\n if (r.mcp === 'manual') {\n toast(`${r.name}: skill installed. Add the MCP server by hand.`, { hint: r.mcpManual });\n } else {\n toast(`${r.name} now knows cloudhop. Start a new chat so it picks it up.`);\n }\n }\n}\n\nfunction renderAssistants() {\n const el = document.getElementById('assistants');\n if (!el || !assistantsData) return;\n const rows = assistantScope === 'user' ? assistantsData.user : assistantsData.projectScope;\n const scope = assistantScope;\n const install = (tools) =>\n api('POST', '/api/assistants', { tools, scope, mcp: true }).then((r) =>\n reportInstall(r.results),\n );\n const missingDetected = rows\n .filter((r) => r.detected && r.skill === 'missing')\n .map((r) => r.assistant);\n\n const seg = h(\n 'div',\n { class: 'seg', role: 'group', 'aria-label': 'Where' },\n h(\n 'button',\n { 'aria-pressed': String(scope === 'user'), onclick: () => setAssistantScope('user') },\n 'For me',\n ),\n h(\n 'button',\n { 'aria-pressed': String(scope === 'project'), onclick: () => setAssistantScope('project') },\n 'This project',\n ),\n );\n\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, 'AI assistants'),\n h(\n 'span',\n { class: 'meta' },\n scope === 'user'\n ? 'Installed in your home folder, for every project'\n : assistantsData.project,\n ),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n seg,\n missingDetected.length > 1\n ? assistantAction('Install for all detected', () => install(missingDetected))\n : null,\n ),\n );\n\n const table = h(\n 'table',\n { class: 'assistants' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Assistant'),\n h('th', {}, 'Skill'),\n h('th', {}, 'MCP server'),\n h('th'),\n ),\n ),\n h(\n 'tbody',\n {},\n rows.map((r) => {\n const [badgeClass, skillText] = SKILL_TEXT[r.skill];\n const actions = [];\n if (r.skill === 'missing')\n actions.push(assistantAction('Install', () => install([r.assistant])));\n if (r.skill === 'outdated') {\n actions.push(\n assistantAction('Update', () =>\n api('POST', '/api/assistants/update', {}).then(() =>\n toast(`${r.name}: skill updated.`),\n ),\n ),\n );\n }\n if (r.skill === 'modified') {\n actions.push(\n assistantAction(\n 'Replace',\n () =>\n api('POST', '/api/assistants/update', { force: true }).then(() =>\n toast('Edited skills replaced.'),\n ),\n {\n confirmText: 'Replace your edited copies with the current cloudhop skill?',\n },\n ),\n );\n }\n if (r.skill !== 'missing' || r.mcp === 'registered') {\n actions.push(\n assistantAction(\n 'Remove',\n () => api('POST', '/api/assistants/remove', { tools: [r.assistant], scope }),\n {\n danger: true,\n confirmText: `Remove the cloudhop skill and MCP server from ${r.name}?`,\n },\n ),\n );\n }\n return h(\n 'tr',\n {},\n h(\n 'td',\n { class: 'alias' },\n r.name,\n r.detected ? null : h('small', { class: 'muted-inline' }, ' not detected'),\n ),\n h('td', {}, h('span', { class: `badge ${badgeClass}`, title: r.skillPath }, skillText)),\n h('td', {}, r.skill === 'missing' && r.mcp !== 'registered' ? '' : MCP_TEXT[r.mcp]),\n h(\n 'td',\n { style: 'text-align:right' },\n h('div', { class: 'actions', style: 'justify-content:flex-end' }, actions),\n ),\n );\n }),\n ),\n );\n\n el.replaceChildren(\n head,\n h(\n 'div',\n { class: 'card-body' },\n h(\n 'p',\n { class: 'card-help' },\n 'Teach your AI assistant to use cloudhop: it asks you which alias to work with, runs commands with hop, and confirms before changing anything. Includes the cloudhop MCP server.',\n ),\n table,\n ),\n );\n el.hidden = false;\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const prodCount = aliases.filter((a) => a.prod).length;\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'div',\n { class: 'source-summary' },\n h(\n 'span',\n {},\n `${aliases.length} alias(es)`,\n prodCount ? h('span', { class: 'prod-tag' }, `${prodCount} PROD`) : null,\n ),\n h(\n 'button',\n { class: 'link', onclick: () => go('aliases', { source: source.id }) },\n 'View aliases \u2192',\n ),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n const patch = (body, message) =>\n api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, body)\n .then(() => message && toast(message))\n .catch(fail)\n .then(refresh);\n const iconButton = (label, title, onclick, extra = {}) =>\n h('button', { class: 'link', title, 'aria-label': title, onclick, ...extra }, label);\n\n const actions = h(\n 'div',\n { class: 'row-actions' },\n h(\n 'button',\n {\n title: `Open the AWS console as ${alias.name}, already signed in`,\n disabled: alias.missing,\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const result = await api(\n 'POST',\n `/api/aliases/${encodeURIComponent(alias.name)}/console`,\n {},\n ).catch(fail);\n if (result) {\n toast(\n result.via === 'signed-in'\n ? `Opened the console for ${alias.name}.`\n : result.via === 'portal'\n ? `Opened ${alias.name} through the SSO portal.`\n : `Opened the console sign-in page for ${alias.name}.`,\n );\n }\n }),\n },\n 'Console',\n ),\n h(\n 'label',\n { class: 'prod-toggle', title: 'Production: hop and AI assistants ask before any change' },\n h('input', {\n type: 'checkbox',\n checked: alias.prod,\n 'aria-label': `${alias.name} is production`,\n onchange: (e) =>\n patch(\n { prod: e.currentTarget.checked },\n e.currentTarget.checked\n ? `${alias.name} is now production.`\n : `${alias.name} is no longer production.`,\n ),\n }),\n 'Prod',\n ),\n iconButton('\u270E', `Rename ${alias.name}`, () => {\n const to = prompt(`New name for \"${alias.name}\"`, alias.name)?.trim();\n if (to && to !== alias.name) patch({ name: to }, `${alias.name} is now ${to}.`);\n }),\n iconButton(\n '\u2715',\n `Remove ${alias.name}`,\n (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n { class: 'link danger-hover' },\n ),\n );\n\n return h(\n 'tr',\n { class: alias.prod ? 'prod' : undefined },\n h(\n 'td',\n { class: 'alias', title: `hop ${alias.name} <command>` },\n alias.name,\n alias.prod ? h('span', { class: 'prod-tag' }, 'PROD') : null,\n ),\n h('td', { class: 'muted' }, alias.source),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h('td', { style: 'text-align:right' }, actions),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n readRoute();\n refresh();\n}\n";
12602
13446
 
12603
13447
  // ../ui/src/assets/index.html
12604
13448
  var assets_default = `<!doctype html>
@@ -12729,9 +13573,29 @@ var assets_default = `<!doctype html>
12729
13573
  }
12730
13574
  /* Fixed columns so every source's table lines up with the others. */
12731
13575
  table { width: 100%; border-collapse: collapse; table-layout: fixed; }
12732
- th:nth-child(1) { width: 26%; }
12733
- th:nth-child(2) { width: 190px; }
12734
- th:nth-child(4) { width: 56px; }
13576
+ table.aliases th:nth-child(1) { width: 22%; }
13577
+ table.aliases th:nth-child(2) { width: 15%; }
13578
+ table.aliases th:nth-child(3) { width: 180px; }
13579
+ table.aliases th:nth-child(5) { width: 264px; }
13580
+ td.muted { color: var(--muted); font-size: 14px; }
13581
+ .nav { display: flex; gap: 4px; margin-left: 12px; }
13582
+ .nav a { color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 500; padding: 6px 12px; border-radius: 8px; }
13583
+ .nav a:hover { color: var(--text); background: var(--surface-2); }
13584
+ .nav a[aria-current="page"] { color: var(--accent-strong); background: var(--accent-soft); font-weight: 600; }
13585
+ .page-head { display: flex; align-items: flex-end; gap: 16px; margin: 4px 0 16px; flex-wrap: wrap; }
13586
+ .page-head > div:first-child { flex: 1; min-width: 220px; }
13587
+ .page-head h1 { font-size: 22px; margin: 0; letter-spacing: -0.01em; }
13588
+ .page-head p { margin: 4px 0 0; color: var(--muted); font-size: 14px; }
13589
+ .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
13590
+ .toolbar input[type="search"] { flex: 1 1 260px; }
13591
+ .toolbar .count { color: var(--muted); font-size: 13px; }
13592
+ .source-summary { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--muted); }
13593
+ .card-body.padded { padding: 4px 16px 14px; }
13594
+ .snippet { display: flex; align-items: center; gap: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 6px 6px 6px 12px; margin-bottom: 8px; }
13595
+ .snippet code { flex: 1; overflow-x: auto; white-space: nowrap; }
13596
+ .tool-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 6px 0; }
13597
+ .tool-row .snippet { margin: 0; flex: 1 1 220px; }
13598
+ button.choice { width: 100%; text-align: left; background: var(--surface); }
12735
13599
  td { overflow-wrap: anywhere; }
12736
13600
  th {
12737
13601
  text-align: left;
@@ -12844,23 +13708,36 @@ var assets_default = `<!doctype html>
12844
13708
  margin-bottom: 18px;
12845
13709
  }
12846
13710
  .notice.error { border-left-color: var(--bad); }
13711
+ .row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; white-space: nowrap; }
13712
+ .prod-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); cursor: pointer; }
13713
+ .prod-tag { display: inline-block; white-space: nowrap; margin-left: 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; color: var(--bad); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; vertical-align: 1px; }
13714
+ tr.prod td:first-child { box-shadow: inset 3px 0 0 var(--bad); }
13715
+ button.link.danger-hover:hover { color: var(--bad); }
12847
13716
  .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
12848
13717
  .seg button { border: 0; border-radius: 0; }
12849
13718
  .seg button + button { border-left: 1px solid var(--border); }
12850
13719
  .seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
12851
13720
  .card-help { color: var(--muted); font-size: 14px; margin: 10px 16px 6px; }
13721
+ .card-body.padded .card-help { margin: 8px 0 10px; }
12852
13722
  .muted-inline { color: var(--muted); font-weight: 400; font-size: 12px; }
12853
13723
  table.assistants th:nth-child(2) { width: 170px; }
12854
13724
  table.assistants th:nth-child(3) { width: 140px; }
12855
13725
  table.assistants th:nth-child(4) { width: 230px; }
12856
13726
  @media (max-width: 640px) {
12857
13727
  .tagline { display: none; }
12858
- th:nth-child(3), td:nth-child(3) { display: none; }
13728
+ .nav { margin-left: 4px; overflow-x: auto; }
13729
+ .nav a { padding: 6px 8px; }
13730
+ table.aliases th:nth-child(2), table.aliases td:nth-child(2),
13731
+ table.aliases th:nth-child(4), table.aliases td:nth-child(4),
13732
+ table.assistants th:nth-child(3), table.assistants td:nth-child(3) { display: none; }
12859
13733
  td, th { padding: 8px 10px; }
12860
13734
  td.alias { white-space: normal; overflow-wrap: anywhere; }
12861
13735
  .region input { width: 96px; }
12862
- th:nth-child(1) { width: auto; }
12863
- th:nth-child(2) { width: 124px; }
13736
+ table.aliases th:nth-child(1) { width: auto; }
13737
+ table.aliases th:nth-child(3) { width: 124px; }
13738
+ table.aliases th:nth-child(5) { width: 132px; }
13739
+ .bar { flex-wrap: wrap; }
13740
+ .row-actions { flex-wrap: wrap; }
12864
13741
  table.assistants th:nth-child(2) { width: 112px; }
12865
13742
  table.assistants th:nth-child(4) { width: 96px; }
12866
13743
  table.assistants .actions { flex-direction: column; align-items: flex-end; }
@@ -12872,7 +13749,12 @@ var assets_default = `<!doctype html>
12872
13749
  <header>
12873
13750
  <div class="bar">
12874
13751
  <div class="logo"><img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%233BB8F5%22/%3E%3Cstop offset=%221%22 stop-color=%22%231450A8%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" alt="" width="28" height="28" /><span>cloud<span>hop</span></span></div>
12875
- <div class="tagline">Hop between cloud accounts, roles and regions</div>
13752
+ <nav id="nav" class="nav" aria-label="Sections" hidden>
13753
+ <a href="#/aliases" data-page="aliases">Aliases</a>
13754
+ <a href="#/sources" data-page="sources">Sources</a>
13755
+ <a href="#/assistants" data-page="assistants">AI assistants</a>
13756
+ <a href="#/tools" data-page="tools">Tools</a>
13757
+ </nav>
12876
13758
  <div class="spacer"></div>
12877
13759
  <button type="button" class="primary" id="add-source" hidden>+ Add source</button>
12878
13760
  </div>
@@ -12996,15 +13878,15 @@ async function uiCommand(app, assistants, { flags }) {
12996
13878
  }
12997
13879
 
12998
13880
  // src/container.ts
12999
- import { homedir as homedir3 } from "os";
13000
- import { join as join7 } from "path";
13881
+ import { homedir as homedir4 } from "os";
13882
+ import { join as join8 } from "path";
13001
13883
 
13002
13884
  // ../provider-aws/src/aws-provider.ts
13003
- import { join as join6 } from "path";
13885
+ import { join as join7 } from "path";
13004
13886
 
13005
13887
  // ../provider-aws/src/aws-config.ts
13006
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
13007
- import { dirname as dirname3 } from "path";
13888
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
13889
+ import { dirname as dirname4 } from "path";
13008
13890
  var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand)";
13009
13891
  var BLOCK_END = "# <<< cloudhop managed <<<";
13010
13892
  function renderBlock(sections) {
@@ -13051,13 +13933,64 @@ function replaceManagedBlock(content, sections) {
13051
13933
  async function writeManagedBlock(path2, sections) {
13052
13934
  let content = "";
13053
13935
  try {
13054
- content = await readFile3(path2, "utf8");
13936
+ content = await readFile5(path2, "utf8");
13055
13937
  } catch {
13056
13938
  }
13057
13939
  const next = replaceManagedBlock(content, sections);
13058
13940
  if (next === content) return;
13059
- await mkdir3(dirname3(path2), { recursive: true, mode: 448 });
13060
- await writeFile3(path2, next, { mode: 384 });
13941
+ await mkdir4(dirname4(path2), { recursive: true, mode: 448 });
13942
+ await writeFile4(path2, next, { mode: 384 });
13943
+ }
13944
+
13945
+ // ../provider-aws/src/console.ts
13946
+ var ISSUER = "https://dayepesb.github.io/cloudhop/";
13947
+ function partitionHosts(region) {
13948
+ if (region?.startsWith("cn-")) {
13949
+ return { signin: "https://signin.amazonaws.cn", console: "https://console.amazonaws.cn" };
13950
+ }
13951
+ if (region?.startsWith("us-gov-")) {
13952
+ return {
13953
+ signin: "https://signin.amazonaws-us-gov.com",
13954
+ console: "https://console.amazonaws-us-gov.com"
13955
+ };
13956
+ }
13957
+ return { signin: "https://signin.aws.amazon.com", console: "https://console.aws.amazon.com" };
13958
+ }
13959
+ function consoleDestination(region, service) {
13960
+ const { console: console2 } = partitionHosts(region);
13961
+ const path2 = service ? `${service}/home` : "console/home";
13962
+ return `${console2}/${path2}${region ? `?region=${encodeURIComponent(region)}` : ""}`;
13963
+ }
13964
+ async function federatedConsoleUrl(credentials, destination, region, fetchFn) {
13965
+ const {
13966
+ AWS_ACCESS_KEY_ID: id,
13967
+ AWS_SECRET_ACCESS_KEY: key,
13968
+ AWS_SESSION_TOKEN: token
13969
+ } = credentials;
13970
+ if (!id || !key || !token) return void 0;
13971
+ const { signin } = partitionHosts(region);
13972
+ const response = await fetchFn(`${signin}/federation`, {
13973
+ method: "POST",
13974
+ headers: { "content-type": "application/x-www-form-urlencoded" },
13975
+ body: new URLSearchParams({
13976
+ Action: "getSigninToken",
13977
+ Session: JSON.stringify({ sessionId: id, sessionKey: key, sessionToken: token })
13978
+ }).toString()
13979
+ }).catch(() => void 0);
13980
+ if (!response?.ok) return void 0;
13981
+ const signinToken = (await response.json().catch(() => ({}))).SigninToken;
13982
+ if (!signinToken) return void 0;
13983
+ const params = new URLSearchParams({
13984
+ Action: "login",
13985
+ Issuer: ISSUER,
13986
+ Destination: destination,
13987
+ SigninToken: signinToken
13988
+ });
13989
+ return `${signin}/federation?${params}`;
13990
+ }
13991
+ function portalShortcutUrl(startUrl, accountId, roleName, destination) {
13992
+ const params = new URLSearchParams({ account_id: accountId, role_name: roleName, destination });
13993
+ return `${startUrl.replace(/\/+$/, "")}/#/console?${params}`;
13061
13994
  }
13062
13995
 
13063
13996
  // ../provider-aws/src/http.ts
@@ -13071,7 +14004,7 @@ var AwsHttpError = class extends Error {
13071
14004
  status;
13072
14005
  code;
13073
14006
  };
13074
- async function parse2(response) {
14007
+ async function parse3(response) {
13075
14008
  const text4 = await response.text();
13076
14009
  const body = text4 ? JSON.parse(text4) : {};
13077
14010
  if (!response.ok) {
@@ -13091,10 +14024,10 @@ async function postJson(fetchFn, url, body) {
13091
14024
  headers: { "content-type": "application/json" },
13092
14025
  body: JSON.stringify(body)
13093
14026
  });
13094
- return parse2(response);
14027
+ return parse3(response);
13095
14028
  }
13096
14029
  async function getJson(fetchFn, url, headers) {
13097
- return parse2(await fetchFn(url, { headers }));
14030
+ return parse3(await fetchFn(url, { headers }));
13098
14031
  }
13099
14032
 
13100
14033
  // ../provider-aws/src/sso/portal.ts
@@ -13278,23 +14211,23 @@ var OidcClient = class {
13278
14211
 
13279
14212
  // ../provider-aws/src/sso/token-cache.ts
13280
14213
  import { createHash as createHash2 } from "crypto";
13281
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
13282
- import { join as join5 } from "path";
14214
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
14215
+ import { join as join6 } from "path";
13283
14216
  var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
13284
14217
  function tokenCachePath(awsDir, sessionName) {
13285
14218
  const hash = createHash2("sha1").update(sessionName).digest("hex");
13286
- return join5(awsDir, "sso", "cache", `${hash}.json`);
14219
+ return join6(awsDir, "sso", "cache", `${hash}.json`);
13287
14220
  }
13288
14221
  async function readToken(awsDir, sessionName) {
13289
14222
  try {
13290
- return JSON.parse(await readFile4(tokenCachePath(awsDir, sessionName), "utf8"));
14223
+ return JSON.parse(await readFile6(tokenCachePath(awsDir, sessionName), "utf8"));
13291
14224
  } catch {
13292
14225
  return void 0;
13293
14226
  }
13294
14227
  }
13295
14228
  async function writeToken(awsDir, sessionName, token) {
13296
- await mkdir4(join5(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
13297
- await writeFile4(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
14229
+ await mkdir5(join6(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
14230
+ await writeFile5(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
13298
14231
  mode: 384
13299
14232
  });
13300
14233
  }
@@ -13433,6 +14366,42 @@ var CONSOLE_LOGIN_KIND = "console-login";
13433
14366
  var CONSOLE_TARGET = { id: "console", label: "Console sign-in", attributes: {} };
13434
14367
  var ssoSessionName = (sourceId) => `cloudhop-${sourceId}`;
13435
14368
  var consoleProfileName = (sourceId) => `cloudhop-${sourceId}`;
14369
+ var GLOBAL_WITH_VALUE = /* @__PURE__ */ new Set([
14370
+ "--region",
14371
+ "--output",
14372
+ "--profile",
14373
+ "--endpoint-url",
14374
+ "--query",
14375
+ "--color",
14376
+ "--ca-bundle",
14377
+ "--cli-read-timeout",
14378
+ "--cli-connect-timeout",
14379
+ "--cli-binary-format"
14380
+ ]);
14381
+ var READ_VERBS = /^(describe|list|get|head|lookup|search|scan|query|select|filter|tail|batch-get|check|preview|simulate|validate|estimate|test|wait|help|download|ls|presign)(-|$)/;
14382
+ function isAwsReadOnly(args) {
14383
+ if (args.includes("--dryrun") || args.includes("--dry-run") || args.includes("help")) return true;
14384
+ const words = [];
14385
+ for (let i2 = 0; i2 < args.length; i2++) {
14386
+ const arg = args[i2];
14387
+ if (arg.startsWith("--")) {
14388
+ if (GLOBAL_WITH_VALUE.has(arg) && words.length < 2) i2++;
14389
+ continue;
14390
+ }
14391
+ words.push(arg);
14392
+ }
14393
+ const [service, operation] = words;
14394
+ if (!service || !operation) return true;
14395
+ if (service === "sts" || service === "configure") return true;
14396
+ if (service === "s3") {
14397
+ if (operation === "cp" || operation === "sync") {
14398
+ const destination = words[words.length - 1] ?? "";
14399
+ return !destination.startsWith("s3://");
14400
+ }
14401
+ return operation === "ls" || operation === "presign";
14402
+ }
14403
+ return READ_VERBS.test(operation);
14404
+ }
13436
14405
  var AwsProvider = class {
13437
14406
  constructor(options) {
13438
14407
  this.options = options;
@@ -13551,7 +14520,7 @@ var AwsProvider = class {
13551
14520
  }
13552
14521
  });
13553
14522
  }
13554
- await writeManagedBlock(join6(this.options.awsDir, "config"), sections);
14523
+ await writeManagedBlock(join7(this.options.awsDir, "config"), sections);
13555
14524
  }
13556
14525
  async processEnv(resolved, mode) {
13557
14526
  const region = resolved.region;
@@ -13567,6 +14536,25 @@ var AwsProvider = class {
13567
14536
  const credentials = resolved.source.kind === CONSOLE_LOGIN_KIND ? await this.exportedCredentials(profile) : await this.roleCredentials(resolved);
13568
14537
  return { AWS_PROFILE: void 0, ...credentials, ...regionEnv };
13569
14538
  }
14539
+ async consoleUrl(resolved, service) {
14540
+ const destination = consoleDestination(resolved.region, service);
14541
+ const sso = resolved.source.kind !== CONSOLE_LOGIN_KIND;
14542
+ const credentials = sso ? await this.roleCredentials(resolved) : await this.exportedCredentials(consoleProfileName(resolved.source.id)).catch(() => ({}));
14543
+ const url = await federatedConsoleUrl(credentials, destination, resolved.region, this.fetchFn);
14544
+ if (url) return { url, via: "signed-in" };
14545
+ const attrs = resolved.target?.attributes ?? {};
14546
+ const startUrl = resolved.source.settings.startUrl;
14547
+ if (sso && startUrl && attrs.accountId && attrs.roleName) {
14548
+ return {
14549
+ url: portalShortcutUrl(startUrl, attrs.accountId, attrs.roleName, destination),
14550
+ via: "portal"
14551
+ };
14552
+ }
14553
+ return { url: destination, via: "sign-in-page" };
14554
+ }
14555
+ isReadOnly(args) {
14556
+ return isAwsReadOnly(args);
14557
+ }
13570
14558
  isSessionError(output) {
13571
14559
  return SESSION_ERROR_PATTERNS.some((pattern) => pattern.test(output));
13572
14560
  }
@@ -13614,9 +14602,10 @@ var AwsProvider = class {
13614
14602
  };
13615
14603
 
13616
14604
  // src/container.ts
14605
+ var browser = new NodeBrowserOpener();
13617
14606
  function createApp(env = process.env) {
13618
14607
  const runner = new NodeProcessRunner();
13619
- const awsDir = env.CLOUDHOP_AWS_DIR ?? join7(homedir3(), ".aws");
14608
+ const awsDir = env.CLOUDHOP_AWS_DIR ?? join8(homedir4(), ".aws");
13620
14609
  return new CloudhopApp({
13621
14610
  config: new YamlConfigStore(),
13622
14611
  providers: [
@@ -13628,14 +14617,14 @@ function createApp(env = process.env) {
13628
14617
  // Azure and Google Cloud providers plug in here.
13629
14618
  ],
13630
14619
  runner,
13631
- browser: new NodeBrowserOpener(),
14620
+ browser,
13632
14621
  installer: new SystemToolInstaller(runner),
13633
14622
  notify
13634
14623
  });
13635
14624
  }
13636
14625
  function createAssistants(version, env = process.env) {
13637
14626
  return new AssistantsInstaller({
13638
- places: { home: homedir3(), project: process.cwd() },
14627
+ places: { home: homedir4(), project: process.cwd() },
13639
14628
  configDir: defaultConfigDir(env),
13640
14629
  runner: new NodeProcessRunner(),
13641
14630
  cloudhopVersion: version
@@ -13643,8 +14632,8 @@ function createAssistants(version, env = process.env) {
13643
14632
  }
13644
14633
 
13645
14634
  // src/update-check.ts
13646
- import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
13647
- import { join as join8 } from "path";
14635
+ import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
14636
+ import { join as join9 } from "path";
13648
14637
  var DAY_MS = 24 * 60 * 60 * 1e3;
13649
14638
  var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
13650
14639
  function startUpdateCheck(configDir, current) {
@@ -13652,11 +14641,11 @@ function startUpdateCheck(configDir, current) {
13652
14641
  if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
13653
14642
  return async () => {
13654
14643
  };
13655
- const file = join8(configDir, "update-check.json");
14644
+ const file = join9(configDir, "update-check.json");
13656
14645
  const work = (async () => {
13657
14646
  let cache = { checkedAt: 0 };
13658
14647
  try {
13659
- cache = JSON.parse(await readFile5(file, "utf8"));
14648
+ cache = JSON.parse(await readFile7(file, "utf8"));
13660
14649
  } catch {
13661
14650
  }
13662
14651
  if (cache.latest && compareVersions(cache.latest, current) > 0) {
@@ -13673,19 +14662,27 @@ function startUpdateCheck(configDir, current) {
13673
14662
  if (!response.ok) return;
13674
14663
  const { version } = await response.json();
13675
14664
  if (typeof version !== "string") return;
13676
- await mkdir5(configDir, { recursive: true });
13677
- await writeFile5(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
14665
+ await mkdir6(configDir, { recursive: true });
14666
+ await writeFile6(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
13678
14667
  })().catch(() => {
13679
14668
  });
13680
14669
  return () => Promise.race([work, new Promise((resolve) => setTimeout(resolve, 300))]);
13681
14670
  }
13682
14671
 
13683
14672
  // src/cli.ts
13684
- var HELP = `cloudhop ${"1.1.0"}
14673
+ var HELP = `cloudhop ${"1.2.0"}
13685
14674
  Hop between cloud accounts, roles and regions with one command.
13686
14675
 
13687
14676
  Usage
13688
14677
  hop <alias> [args...] Run the cloud CLI as <alias> (no args: who am I)
14678
+ hop Pick the alias from a list, then type the command
14679
+ hop - [args...] Pick the alias from a list and run [args...]
14680
+ cloudhop console [alias] [service] [--region <r>]
14681
+ Open the web console as <alias>, already signed in
14682
+ cloudhop each <selection...> [--source <s>] [--prod|--no-prod] [--yes] [--json] -- <args...>
14683
+ Run one command in many aliases (names, a,b lists, 'globex-*', /regex/)
14684
+ cloudhop use [alias] | --off Use an alias in this shell (aws, terraform... without hop)
14685
+ cloudhop shell [install] Shell integration: cloudhop use, tab completion, cloudhop_prompt
13689
14686
  cloudhop exec <alias> [--region <r>] -- <cmd...>
13690
14687
  Run any tool with the alias' temporary credentials
13691
14688
 
@@ -13694,8 +14691,13 @@ Setup
13694
14691
  cloudhop add Add an SSO portal or a personal account
13695
14692
  cloudhop add aws sso --start-url <url> [--region <r>] [--name <id>]
13696
14693
  cloudhop add aws console-login [--region <r>] [--name <id>]
14694
+ cloudhop team export [--out <file>] | team import <file|https URL>
14695
+ Share portals and aliases with your team (no secrets)
13697
14696
  cloudhop alias [source] Create more aliases from a source
13698
14697
  cloudhop alias <source> --list | --target <id> [--name <alias>] [--region <r>]
14698
+ cloudhop mv <alias> <new-name> Rename an alias
14699
+ cloudhop prod [alias...] [--off]
14700
+ Mark aliases as production (asks before changes)
13699
14701
  cloudhop region <alias> [region]
13700
14702
  Change the region an alias runs in
13701
14703
  cloudhop login [source] Log in again and refresh its accounts
@@ -13727,23 +14729,47 @@ function takeGlobalFlags(argv) {
13727
14729
  }
13728
14730
  async function main(argv) {
13729
14731
  const [command, ...args] = argv;
14732
+ const invokedAsHop = /^hop(\.js|\.cmd)?$/i.test(basename(process.argv[1] ?? ""));
14733
+ if (!command && invokedAsHop && interactive() || command === "-") {
14734
+ const app2 = createApp();
14735
+ const alias = await pickAlias(app2);
14736
+ let rest = args;
14737
+ if (!command) {
14738
+ const typed = answer(
14739
+ await dist_exports.text({
14740
+ message: `aws command to run as ${alias}`,
14741
+ placeholder: "empty = who am I, e.g. s3 ls"
14742
+ })
14743
+ ).trim();
14744
+ rest = typed ? (typed.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^(["'])(.*)\1$/, "$2")) : [];
14745
+ }
14746
+ return proxyCommand(app2, alias, rest);
14747
+ }
13730
14748
  if (!command || command === "help" || command === "--help" || command === "-h") {
13731
14749
  console.log(HELP);
13732
14750
  return 0;
13733
14751
  }
13734
14752
  if (command === "--version" || command === "-v" || command === "version") {
13735
- console.log("1.1.0");
14753
+ console.log("1.2.0");
13736
14754
  return 0;
13737
14755
  }
13738
14756
  const app = createApp();
13739
14757
  const local = RESERVED_NAMES.has(command) ? takeGlobalFlags(args) : args;
14758
+ if (command === "__complete") {
14759
+ await completeCommand(app, args[0]);
14760
+ return 0;
14761
+ }
14762
+ if (command === "shell") {
14763
+ await shellCommand(local);
14764
+ return 0;
14765
+ }
13740
14766
  if (command === "mcp") {
13741
- await serveMcp({ app, version: "1.1.0" });
14767
+ await serveMcp({ app, version: "1.2.0" });
13742
14768
  return 0;
13743
14769
  }
13744
- const assistants = createAssistants("1.1.0");
14770
+ const assistants = createAssistants("1.2.0");
13745
14771
  await refreshAssistantSkills(assistants, command);
13746
- const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.1.0");
14772
+ const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.2.0");
13747
14773
  try {
13748
14774
  return await dispatch(app, assistants, command, args, local);
13749
14775
  } finally {
@@ -13774,6 +14800,13 @@ async function dispatch(app, assistants, command, args, local) {
13774
14800
  case "status":
13775
14801
  await statusCommand(app, local);
13776
14802
  return 0;
14803
+ case "prod":
14804
+ await prodCommand(app, local);
14805
+ return 0;
14806
+ case "mv":
14807
+ case "rename":
14808
+ await renameCommand(app, local);
14809
+ return 0;
13777
14810
  case "region":
13778
14811
  await regionCommand(app, local);
13779
14812
  return 0;
@@ -13802,6 +14835,17 @@ async function dispatch(app, assistants, command, args, local) {
13802
14835
  case "agent":
13803
14836
  await agentCommand(assistants, local);
13804
14837
  return 0;
14838
+ case "team":
14839
+ await teamCommand(app, local);
14840
+ return 0;
14841
+ case "use":
14842
+ await useCommand(app, local);
14843
+ return 0;
14844
+ case "each":
14845
+ return eachCommand(app, args);
14846
+ case "console":
14847
+ await consoleCommand(app, local);
14848
+ return 0;
13805
14849
  case "ui":
13806
14850
  await uiCommand(app, assistants, parseArgs(local));
13807
14851
  return 0;