cloudhop 1.1.0 → 1.2.1

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