cloudhop 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +278 -38
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -74,6 +74,7 @@ Without it, `cloudhop use` opens a new shell, and everything below still works.
74
74
  | Give any tool temporary credentials | `cloudhop exec acme-prod -- terraform plan` |
75
75
  | See your portals and aliases | `cloudhop ls` |
76
76
  | Add another portal or personal account | `cloudhop add` |
77
+ | Give AWS Emulator or LocalStack an alias | `cloudhop add aws aws-emulator --name local` |
77
78
  | Create more aliases from a portal | `cloudhop alias acme` |
78
79
  | Change an alias' region | `cloudhop region acme-prod us-east-2` |
79
80
  | Remove an alias or a portal | `cloudhop rm acme-dev` |
@@ -143,6 +144,7 @@ English and [Spanish](https://dayepesb.github.io/cloudhop/es/):
143
144
  [getting started](https://dayepesb.github.io/cloudhop/getting-started/),
144
145
  [company SSO portals](https://dayepesb.github.io/cloudhop/guides/aws-sso/),
145
146
  [personal accounts](https://dayepesb.github.io/cloudhop/guides/personal-accounts/),
147
+ [local emulators](https://dayepesb.github.io/cloudhop/guides/local-emulators/),
146
148
  [other tools](https://dayepesb.github.io/cloudhop/guides/other-tools/) and
147
149
  [troubleshooting](https://dayepesb.github.io/cloudhop/reference/troubleshooting/).
148
150
 
package/dist/cli.js CHANGED
@@ -7452,6 +7452,7 @@ var CloudhopError = class extends Error {
7452
7452
  var RESERVED_NAMES = /* @__PURE__ */ new Set([
7453
7453
  "add",
7454
7454
  "allow",
7455
+ "credentials",
7455
7456
  "deny",
7456
7457
  "agent",
7457
7458
  "alias",
@@ -7571,6 +7572,11 @@ function compareVersions(a2, b3) {
7571
7572
  }
7572
7573
 
7573
7574
  // ../core/src/application/cloudhop-app.ts
7575
+ var localNotProd = (name) => new CloudhopError(
7576
+ "NOT_SUPPORTED",
7577
+ `${name} is a local emulator: it can't be production.`,
7578
+ "Production protection is for aliases that reach the cloud."
7579
+ );
7574
7580
  var loginRequired = (sourceId) => new CloudhopError(
7575
7581
  "LOGIN_REQUIRED",
7576
7582
  `The session of "${sourceId}" expired and needs a browser sign-in.`,
@@ -7580,6 +7586,7 @@ var NEXT_BY_STATE = {
7580
7586
  valid: "ready",
7581
7587
  refreshable: "silent-renewal",
7582
7588
  expired: "browser-sign-in",
7589
+ offline: "start-emulator",
7583
7590
  unknown: "unknown"
7584
7591
  };
7585
7592
  var CloudhopApp = class {
@@ -7673,7 +7680,9 @@ var CloudhopApp = class {
7673
7680
  const config = await this.deps.config.load();
7674
7681
  const source = this.findSource(config, sourceId);
7675
7682
  const provider = this.provider(source.provider);
7676
- this.deps.notify(`Logging in to ${source.id} (${provider.displayName})\u2026`);
7683
+ if (!this.isLocal(source)) {
7684
+ this.deps.notify(`Logging in to ${source.id} (${provider.displayName})\u2026`);
7685
+ }
7677
7686
  await provider.login(source, {
7678
7687
  openBrowser: (url) => this.deps.browser.open(url),
7679
7688
  notify: this.deps.notify
@@ -7692,6 +7701,13 @@ var CloudhopApp = class {
7692
7701
  const provider = this.provider(source.provider);
7693
7702
  const state = await provider.sessionState(source);
7694
7703
  if (state === "valid" || state === "unknown") return "none";
7704
+ if (state === "offline") {
7705
+ throw new CloudhopError(
7706
+ "EMULATOR_NOT_RUNNING",
7707
+ `The emulator of ${source.id} isn't running.`,
7708
+ provider.offlineHint?.(source)
7709
+ );
7710
+ }
7695
7711
  if (state === "refreshable" && await provider.refreshSession(source)) return "refreshed";
7696
7712
  if (!interactive2) throw loginRequired(sourceId);
7697
7713
  await this.login(sourceId);
@@ -7740,6 +7756,7 @@ var CloudhopApp = class {
7740
7756
  }
7741
7757
  names.add(alias.name);
7742
7758
  touchedProviders.add(source.provider);
7759
+ if (alias.prod && this.isLocal(source)) throw localNotProd(alias.name);
7743
7760
  config.aliases.push(alias);
7744
7761
  }
7745
7762
  for (const id of touchedProviders) await this.syncProfiles(id, config);
@@ -7788,6 +7805,7 @@ var CloudhopApp = class {
7788
7805
  region: resolved.region,
7789
7806
  attributes: resolved.target?.attributes ?? {},
7790
7807
  prod: alias.prod === true,
7808
+ local: this.isLocal(resolved.source),
7791
7809
  default: alias.default === true,
7792
7810
  session: source.session,
7793
7811
  next: source.next
@@ -7828,6 +7846,7 @@ var CloudhopApp = class {
7828
7846
  const config = await this.deps.config.load();
7829
7847
  for (const name of names) {
7830
7848
  const alias = this.findAlias(config, name);
7849
+ if (prod && this.isLocal(this.findSource(config, alias.source))) throw localNotProd(name);
7831
7850
  if (prod) alias.prod = true;
7832
7851
  else delete alias.prod;
7833
7852
  }
@@ -7929,6 +7948,13 @@ ${result.stderr}`)) {
7929
7948
  } = {}) {
7930
7949
  const resolved = await this.usableAlias(name, region ? { region } : {});
7931
7950
  const provider = this.provider(resolved.source.provider);
7951
+ if (this.isLocal(resolved.source)) {
7952
+ throw new CloudhopError(
7953
+ "NOT_SUPPORTED",
7954
+ `${name} is a local emulator: it has no web console.`,
7955
+ `Look at its resources with \`hop ${name} <command>\`.`
7956
+ );
7957
+ }
7932
7958
  if (!provider.consoleUrl) {
7933
7959
  throw new CloudhopError(
7934
7960
  "PROVIDER_NOT_AVAILABLE",
@@ -7954,6 +7980,23 @@ ${result.stderr}`)) {
7954
7980
  await this.deps.browser.open(url);
7955
7981
  return via;
7956
7982
  }
7983
+ /**
7984
+ * The keys of a local emulator alias, for the `credential_process` of its
7985
+ * profile. Never for cloud aliases: their credentials stay with the CLI.
7986
+ */
7987
+ async localCredentials(name) {
7988
+ const resolved = await this.resolveAlias(name);
7989
+ const provider = this.provider(resolved.source.provider);
7990
+ if (!this.isLocal(resolved.source) || !provider.localCredentials) {
7991
+ throw new CloudhopError("NOT_SUPPORTED", `${name} is not a local emulator alias.`);
7992
+ }
7993
+ return provider.localCredentials(resolved);
7994
+ }
7995
+ /** Whether a source is an emulator on this machine. */
7996
+ isLocal(source) {
7997
+ const provider = this.deps.providers.find((p3) => p3.id === source.provider);
7998
+ return provider?.sourceKinds.find((k) => k.kind === source.kind)?.local === true;
7999
+ }
7957
8000
  /** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
7958
8001
  identityArgs(name) {
7959
8002
  return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
@@ -8476,7 +8519,7 @@ function createTools(app, context) {
8476
8519
  {
8477
8520
  name: "list_aliases",
8478
8521
  title: "List cloudhop aliases",
8479
- description: "Lists every cloudhop source (SSO portal or personal account) with its session state, and every alias: name, account/role label, region and whether a sign-in is needed. Also hints: `folder` (the aliases this project pins in its .cloudhoprc, and whether the user trusted it) and `inUse` (aliases the user switched their terminal to); `default: true` marks the alias new terminals of the user start with. Call this first, then ask the user which alias to work with, offering the hinted one first. Never contains secrets.",
8522
+ description: "Lists every cloudhop source (SSO portal, personal account or local emulator) with its session state, and every alias: name, account/role label, region and whether a sign-in is needed. Also hints: `folder` (the aliases this project pins in its .cloudhoprc, and whether the user trusted it) and `inUse` (aliases the user switched their terminal to); `local: true` marks an alias of an emulator on this machine; `default: true` marks the alias new terminals of the user start with. Call this first, then ask the user which alias to work with, offering the hinted one first. Never contains secrets.",
8480
8523
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
8481
8524
  annotations: { readOnlyHint: true, openWorldHint: false },
8482
8525
  run: async () => ({ ...await app.overview(), ...await context?.() })
@@ -8484,7 +8527,7 @@ function createTools(app, context) {
8484
8527
  {
8485
8528
  name: "alias_status",
8486
8529
  title: "Check an alias",
8487
- description: 'Shows which account, role and region an alias points to and whether it can run now. `next` is "ready", "silent-renewal" (runs without asking), "browser-sign-in" (call login first, or ask the user to run `cloudhop login <source>`) or "unknown".',
8530
+ description: 'Shows which account, role and region an alias points to and whether it can run now. `next` is "ready", "silent-renewal" (runs without asking), "browser-sign-in" (call login first, or ask the user to run `cloudhop login <source>`), "start-emulator" (a local emulator that is not running: ask the user to start it) or "unknown".',
8488
8531
  inputSchema: {
8489
8532
  type: "object",
8490
8533
  properties: aliasInput,
@@ -8642,7 +8685,8 @@ var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a shor
8642
8685
  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. If list_aliases has folder.aliases (the project's .cloudhoprc), inUse or an alias with default: true, offer that alias first, but still ask.
8643
8686
  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.
8644
8687
  To show the user something in the web console, call open_console (it opens their browser, already signed in).
8645
- 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.`;
8688
+ 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.
8689
+ Aliases with local: true point to an emulator on the user's machine (AWS Emulator, LocalStack), never to the cloud: account 000000000000, no web console, never production. If a tool returns EMULATOR_NOT_RUNNING or alias_status says start-emulator, tell the user to start their emulator (the hint says how); don't call login.`;
8646
8690
  var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
8647
8691
  function serveMcp({
8648
8692
  app,
@@ -11647,7 +11691,8 @@ var validator = (fn) => (value) => {
11647
11691
  // src/commands/add.ts
11648
11692
  function suggestSourceId(settings2, kind) {
11649
11693
  const host = settings2.startUrl?.match(/^https:\/\/([^./]+)\./)?.[1];
11650
- return suggestAliasName(host ?? (kind === "console-login" ? "personal" : "main"));
11694
+ const fallback = kind === "console-login" ? "personal" : kind === "aws-emulator" ? "local" : kind === "localstack" ? "localstack" : "main";
11695
+ return suggestAliasName(host ?? fallback);
11651
11696
  }
11652
11697
  async function pickProvider(app) {
11653
11698
  const { providers } = await app.config();
@@ -11716,11 +11761,12 @@ async function chooseAliases(app, source, targets) {
11716
11761
  const validateRegion2 = regionValidator(app, source);
11717
11762
  let defaultRegion = source.settings.region ?? "";
11718
11763
  const suggestions = suggestAliases(targets, source.id, existing);
11764
+ const local = app.isLocal(source);
11719
11765
  const aliases = [];
11720
11766
  for (const target of selected) {
11721
11767
  dist_exports.log.step(target.label);
11722
11768
  const suggestion = uniqueName(
11723
- targets.length === 1 && target.id === "console" ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
11769
+ targets.length === 1 && (target.id === "console" || local) ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
11724
11770
  existing
11725
11771
  );
11726
11772
  const name = answer(
@@ -11739,7 +11785,7 @@ async function chooseAliases(app, source, targets) {
11739
11785
  validate: (v) => v?.trim() ? validateRegion2?.(v.trim()) : void 0
11740
11786
  })
11741
11787
  ).trim() || defaultRegion;
11742
- const prod = answer(
11788
+ const prod = !local && answer(
11743
11789
  await dist_exports.confirm({
11744
11790
  message: `Is ${name} production? (cloudhop then asks before any change)`,
11745
11791
  initialValue: looksLikeProduction(name, target.label)
@@ -11800,6 +11846,14 @@ async function addCommand(app, args) {
11800
11846
  if (args.positional.length > 0) {
11801
11847
  const source2 = sourceFromFlags(app, args);
11802
11848
  const report2 = await app.connectSource(source2);
11849
+ const target = report2.targets[0];
11850
+ if (app.isLocal(source2) && target) {
11851
+ const taken = new Set((await app.config()).aliases.map((a2) => a2.name));
11852
+ const name = uniqueName(args.flags.alias ?? source2.id, taken);
11853
+ await app.addAliases([{ name, source: source2.id, target: target.id }]);
11854
+ console.log(`Emulator "${source2.id}" added as alias "${name}". Try: hop ${name} s3 ls`);
11855
+ return;
11856
+ }
11803
11857
  console.log(`Source "${source2.id}" added. ${report2.targets.length} account/role(s) available:`);
11804
11858
  printTargets(source2.id, report2.targets);
11805
11859
  return;
@@ -11809,12 +11863,13 @@ async function addCommand(app, args) {
11809
11863
  const provider = await pickProvider(app);
11810
11864
  const source = await askSource(app, provider);
11811
11865
  const report = await app.connectSource(source);
11812
- dist_exports.log.success(`Logged in. Found ${report.targets.length} account/role combination(s).`);
11866
+ if (app.isLocal(source)) dist_exports.log.success("Emulator added. Now give it an alias.");
11867
+ else dist_exports.log.success(`Logged in. Found ${report.targets.length} account/role combination(s).`);
11813
11868
  const aliases = await chooseAliases(app, source, report.targets);
11814
11869
  await app.addAliases(aliases);
11815
11870
  const first = aliases[0]?.name;
11816
11871
  dist_exports.outro(
11817
- first ? `Ready! Try: hop ${first} sts get-caller-identity` : `Source "${source.id}" saved. Create aliases later with \`cloudhop alias ${source.id}\`.`
11872
+ first ? app.isLocal(source) ? `Ready! Try: hop ${first} s3 ls (or \`cloudhop use ${first}\`, then plain aws)` : `Ready! Try: hop ${first} sts get-caller-identity` : `Source "${source.id}" saved. Create aliases later with \`cloudhop alias ${source.id}\`.`
11818
11873
  );
11819
11874
  }
11820
11875
  async function aliasCommand(app, { positional, flags }) {
@@ -11832,7 +11887,7 @@ async function aliasCommand(app, { positional, flags }) {
11832
11887
  );
11833
11888
  const source = sources.find((s) => s.id === id);
11834
11889
  if (!source) throw new CloudhopError("SOURCE_NOT_FOUND", `Source "${id}" not found.`);
11835
- await app.ensureSession(id);
11890
+ if (!app.isLocal(source)) await app.ensureSession(id);
11836
11891
  let targets = await app.listTargets(id);
11837
11892
  if (targets.length === 0) targets = (await app.syncSource(id)).targets;
11838
11893
  if (flags.target) {
@@ -11881,7 +11936,7 @@ import { mkdir as mkdir3, readFile as readFile3, rename as rename2, rm as rm2, r
11881
11936
  import { dirname as dirname3, join as join5 } from "path";
11882
11937
 
11883
11938
  // ../assistants/src/assets/skill.md
11884
- 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## How the user works (to guide them)\n\nWhen the user asks how to set up or use cloudhop themselves, describe this\npath; these are commands for **them** to run in their own terminal:\n\n1. **Once**: `npm install --global cloudhop`, then `cloudhop add` (signs in\n to an SSO portal or personal account and names each account/role as an\n alias), then `cloudhop shell` (adds one line to their shell's startup\n file and, with Starship, the alias segment to `starship.toml`; they open\n a new terminal afterwards). `cloudhop setup` does all of it in one\n guided run. Works in zsh, bash (Git Bash too), fish and PowerShell;\n in Windows `cmd.exe`, `cloudhop use` opens a new `cmd` with the alias in\n its prompt.\n2. **Every day**: `cloudhop use <alias>` switches that terminal to an\n account; after that they type plain `aws ...`, Terraform\n or SDK commands. `cloudhop use <other>` switches accounts and\n `cloudhop use --off` goes back. Expired sessions sign in by themselves,\n and on production aliases `aws` asks before changes. Their prompt shows\n the alias in use, in red for production, and `cloudhop status` tells\n them which alias their terminal uses. `cloudhop use` is per terminal: a\n new terminal starts with no alias unless they choose a default with\n `cloudhop use <alias> --default` (or the **Default** box in\n `cloudhop ui`); `cloudhop use --no-default` stops it. A production\n default needs `--yes`. A `cloudhop use` typed in a terminal wins over\n the folder's `.cloudhoprc`, which wins over the default.\n3. **Per project**: `cloudhop use <alias> --save` writes a `.cloudhoprc` in\n the folder, so `cd` into it switches the terminal; teammates run\n `cloudhop allow` once after cloning (and again after the file changes);\n `cloudhop deny` withdraws it. A file does nothing until it is trusted.\n4. **One-offs**: `hop <alias> <aws args>` runs one command in another\n account without switching; `cloudhop each` runs it in many;\n `cloudhop console <alias> [service]` opens the web console signed in.\n\nWhy `cloudhop shell` is needed: a program can't change the environment of\nthe terminal that started it, only the shell can.\nWithout it, `cloudhop use` opens a new shell instead; `hop` always works.\n\nSwitches, for the user: `CLOUDHOP_WRAP=0` keeps `aws` as the plain AWS\nCLI, `CLOUDHOP_AUTO_USE=0` ignores `.cloudhoprc` files, `CLOUDHOP_PROMPT=0`\nhides the prompt label. `cloudhop shell starship` prints the Starship\nsegment for people who place it by hand.\n\nWhen their terminal doesn't do what they expect:\n\n| They say | Suggest |\n| --- | --- |\n| \"Which account am I in?\" | `cloudhop status` (first line) |\n| `cloudhop use` opened a new shell / says it needs the integration | `cloudhop shell`, then a new terminal |\n| No label in the prompt | Run `cloudhop shell` again (it adds what's missing), then a new terminal |\n| `cd` into the project doesn't switch | `cloudhop allow` in that folder; check the alias name exists (`cloudhop ls`) |\n| `aws` runs in the wrong account | `cloudhop status`, then `cloudhop use <alias>` or `cloudhop use --off` |\n| A new terminal forgot the `cloudhop use` | It's per terminal by design: `cloudhop use <alias> --default` makes every new terminal start with it |\n\nThat is the user's way. **You** work differently, below: you always name the\nalias with `hop <alias>`, because your shell doesn't have their integration.\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.\n - The listing may carry hints: `folder.aliases` is what this project pins\n in its `.cloudhoprc` (`folder.trusted` says whether the user approved\n it), and `inUse` is what the user switched their terminal to with\n `cloudhop use` (`cloudhop status --json` has `inUse` too); an alias\n with `default: true` is the one their new terminals start with. Offer that\n alias first (\"This project uses\n `license-qa`, use it?\"), but still wait for the answer.\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**, even when the project has a `.cloudhoprc` or\n the user's terminal uses an alias: your shell doesn't have the user's\n cloudhop integration, so a plain `aws` may run as anything. Use\n `hop <alias> ...`, never bare `aws`.\n- Never run the interactive forms meant for a person at a terminal: `hop` or\n `hop -` without an alias, `cloudhop console` without an alias,\n `cloudhop use` (it changes or opens an interactive shell), `cloudhop\n allow`/`deny` (trusting a folder is the user's decision), `cloudhop setup`,\n or `cloudhop add`/`alias` without flags. Nobody can answer their prompts in\n 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) or write a\n `.cloudhoprc` or change their default alias (`use --default`,\n `--no-default`) unless the user asked for it. Never unmark a production\n 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";
11939
+ 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## How the user works (to guide them)\n\nWhen the user asks how to set up or use cloudhop themselves, describe this\npath; these are commands for **them** to run in their own terminal:\n\n1. **Once**: `npm install --global cloudhop`, then `cloudhop add` (signs in\n to an SSO portal or personal account and names each account/role as an\n alias), then `cloudhop shell` (adds one line to their shell's startup\n file and, with Starship, the alias segment to `starship.toml`; they open\n a new terminal afterwards). `cloudhop setup` does all of it in one\n guided run. Works in zsh, bash (Git Bash too), fish and PowerShell;\n in Windows `cmd.exe`, `cloudhop use` opens a new `cmd` with the alias in\n its prompt.\n2. **Every day**: `cloudhop use <alias>` switches that terminal to an\n account; after that they type plain `aws ...`, Terraform\n or SDK commands. `cloudhop use <other>` switches accounts and\n `cloudhop use --off` goes back. Expired sessions sign in by themselves,\n and on production aliases `aws` asks before changes. Their prompt shows\n the alias in use, in red for production, and `cloudhop status` tells\n them which alias their terminal uses. `cloudhop use` is per terminal: a\n new terminal starts with no alias unless they choose a default with\n `cloudhop use <alias> --default` (or the **Default** box in\n `cloudhop ui`); `cloudhop use --no-default` stops it. A production\n default needs `--yes`. A `cloudhop use` typed in a terminal wins over\n the folder's `.cloudhoprc`, which wins over the default.\n3. **Per project**: `cloudhop use <alias> --save` writes a `.cloudhoprc` in\n the folder, so `cd` into it switches the terminal; teammates run\n `cloudhop allow` once after cloning (and again after the file changes);\n `cloudhop deny` withdraws it. A file does nothing until it is trusted.\n4. **One-offs**: `hop <alias> <aws args>` runs one command in another\n account without switching; `cloudhop each` runs it in many;\n `cloudhop console <alias> [service]` opens the web console signed in.\n5. **Local emulators**: `cloudhop add` \u2192 \"AWS Emulator (local)\" or\n \"LocalStack or another local emulator\" (or\n `cloudhop add aws aws-emulator --name local`) gives the emulator an\n alias. Then `cloudhop use local` + plain `aws`, `hop local ...`, a\n `.cloudhoprc` with `aws: local` for tests, or\n `cloudhop exec local -- npm test`. It never reaches AWS. The user starts\n the emulator themselves; cloudhop says so when it isn't running.\n\nWhy `cloudhop shell` is needed: a program can't change the environment of\nthe terminal that started it, only the shell can.\nWithout it, `cloudhop use` opens a new shell instead; `hop` always works.\n\nSwitches, for the user: `CLOUDHOP_WRAP=0` keeps `aws` as the plain AWS\nCLI, `CLOUDHOP_AUTO_USE=0` ignores `.cloudhoprc` files, `CLOUDHOP_PROMPT=0`\nhides the prompt label. `cloudhop shell starship` prints the Starship\nsegment for people who place it by hand.\n\nWhen their terminal doesn't do what they expect:\n\n| They say | Suggest |\n| --- | --- |\n| \"Which account am I in?\" | `cloudhop status` (first line) |\n| `cloudhop use` opened a new shell / says it needs the integration | `cloudhop shell`, then a new terminal |\n| No label in the prompt | Run `cloudhop shell` again (it adds what's missing), then a new terminal |\n| `cd` into the project doesn't switch | `cloudhop allow` in that folder; check the alias name exists (`cloudhop ls`) |\n| `aws` runs in the wrong account | `cloudhop status`, then `cloudhop use <alias>` or `cloudhop use --off` |\n| A new terminal forgot the `cloudhop use` | It's per terminal by design: `cloudhop use <alias> --default` makes every new terminal start with it |\n\nThat is the user's way. **You** work differently, below: you always name the\nalias with `hop <alias>`, because your shell doesn't have their integration.\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.\n - The listing may carry hints: `folder.aliases` is what this project pins\n in its `.cloudhoprc` (`folder.trusted` says whether the user approved\n it), and `inUse` is what the user switched their terminal to with\n `cloudhop use` (`cloudhop status --json` has `inUse` too); an alias\n with `default: true` is the one their new terminals start with. Offer that\n alias first (\"This project uses\n `license-qa`, use it?\"), but still wait for the answer.\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**Local emulators.** An alias with `local: true` (in `cloudhop ls --json`,\n`list_aliases` or `status`) points to AWS Emulator or LocalStack on the\nuser's machine: account `000000000000`, never the cloud. Use it like any\nalias (`hop local s3 ls`). It has no web console and is never production,\nso changes there don't need the production confirmation, but still say\nwhat you will create or delete. Never run a command meant for the\nemulator with a cloud alias, or the other way round: if the user said\n\"local\" or \"the emulator\", the alias must have `local: true`.\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| `start-emulator` | A local emulator that isn't running | Tell the user to start it (the error's hint says how); never call `login` for it |\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**, even when the project has a `.cloudhoprc` or\n the user's terminal uses an alias: your shell doesn't have the user's\n cloudhop integration, so a plain `aws` may run as anything. Use\n `hop <alias> ...`, never bare `aws`.\n- Never run the interactive forms meant for a person at a terminal: `hop` or\n `hop -` without an alias, `cloudhop console` without an alias,\n `cloudhop use` (it changes or opens an interactive shell), `cloudhop\n allow`/`deny` (trusting a folder is the user's decision), `cloudhop setup`,\n or `cloudhop add`/`alias` without flags. Nobody can answer their prompts in\n 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) or write a\n `.cloudhoprc` or change their default alias (`use --default`,\n `--no-default`) unless the user asked for it. Never unmark a production\n 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| `EMULATOR_NOT_RUNNING` | Same as `start-emulator` above |\n| `NotImplemented` (HTTP 501) on a `local` alias | The emulator doesn't cover that operation yet: tell the user, it isn't a cloudhop problem |\n\nMore: https://dayepesb.github.io/cloudhop/guides/hop-like-aws/\n";
11885
11940
 
11886
11941
  // ../assistants/src/targets.ts
11887
11942
  import { join as join4 } from "path";
@@ -13459,8 +13514,10 @@ var SESSION_LABEL = {
13459
13514
  valid: paint("green", "\u25CF session valid"),
13460
13515
  refreshable: paint("yellow", "\u25D0 renews automatically on next use"),
13461
13516
  expired: paint("red", "\u25CB expired, opens the browser on next use"),
13517
+ offline: paint("yellow", "\u25CB emulator not running"),
13462
13518
  unknown: paint("dim", "\xB7 session managed by the cloud CLI")
13463
13519
  };
13520
+ var RUNNING = paint("green", "\u25CF emulator running");
13464
13521
  var JSON_VERSION = 1;
13465
13522
  var printJson = (value) => console.log(JSON.stringify({ version: JSON_VERSION, ...value }, null, 2));
13466
13523
  async function listCommand(app, args = [], context) {
@@ -13503,7 +13560,7 @@ async function listCommand(app, args = [], context) {
13503
13560
  console.log(
13504
13561
  `${bold(paint("sky", source.id))} ${paint("dim", `${provider.displayName} \xB7 ${where}`)}`
13505
13562
  );
13506
- console.log(` ${SESSION_LABEL[state]}`);
13563
+ console.log(` ${state === "valid" && app.isLocal(source) ? RUNNING : SESSION_LABEL[state]}`);
13507
13564
  const own = aliases.filter((a2) => a2.alias.source === source.id);
13508
13565
  if (own.length === 0) {
13509
13566
  console.log(paint("dim", ` no aliases yet: \`cloudhop alias ${source.id}\``));
@@ -13620,12 +13677,14 @@ var STATE_SHORT = {
13620
13677
  valid: paint("green", "\u25CF session valid"),
13621
13678
  refreshable: paint("yellow", "\u25D0 session expired, renewable"),
13622
13679
  expired: paint("red", "\u25CB session expired"),
13680
+ offline: paint("yellow", "\u25CB emulator not running"),
13623
13681
  unknown: paint("dim", "\xB7 session")
13624
13682
  };
13625
13683
  var NEXT_TEXT = {
13626
13684
  ready: "ready to run",
13627
13685
  "silent-renewal": "the next command renews the session silently",
13628
13686
  "browser-sign-in": "the next command opens the browser to sign in",
13687
+ "start-emulator": "start the emulator first",
13629
13688
  unknown: "the cloud CLI manages this session"
13630
13689
  };
13631
13690
  async function statusCommand(app, args) {
@@ -14088,6 +14147,7 @@ function createApi(app, assistants) {
14088
14147
  kind: source.kind,
14089
14148
  kindLabel: provider.sourceKinds.find((k) => k.kind === source.kind)?.label ?? source.kind,
14090
14149
  settings: source.settings,
14150
+ local: app.isLocal(source),
14091
14151
  session: await provider.sessionState(source)
14092
14152
  };
14093
14153
  })
@@ -14111,7 +14171,7 @@ function createApi(app, assistants) {
14111
14171
  }))
14112
14172
  })),
14113
14173
  sources,
14114
- aliases: aliases.map(({ alias, target, region }) => ({
14174
+ aliases: aliases.map(({ alias, source, target, region }) => ({
14115
14175
  name: alias.name,
14116
14176
  source: alias.source,
14117
14177
  target: alias.target,
@@ -14119,7 +14179,8 @@ function createApi(app, assistants) {
14119
14179
  missing: !target,
14120
14180
  region,
14121
14181
  prod: alias.prod === true,
14122
- default: alias.default === true
14182
+ default: alias.default === true,
14183
+ local: app.isLocal(source)
14123
14184
  }))
14124
14185
  };
14125
14186
  },
@@ -14184,14 +14245,14 @@ function createApi(app, assistants) {
14184
14245
  },
14185
14246
  "POST /api/sources/:id/login": async ([id]) => reportView(await app.login(id)),
14186
14247
  "POST /api/sources/:id/sync": async ([id]) => {
14187
- await app.ensureSession(id);
14248
+ if (!app.isLocal(await findSource(id))) await app.ensureSession(id);
14188
14249
  return reportView(await app.syncSource(id));
14189
14250
  },
14190
14251
  "GET /api/sources/:id/targets": async ([id]) => {
14191
14252
  await findSource(id);
14192
14253
  let targets = await app.listTargets(id);
14193
14254
  if (targets.length === 0) {
14194
- await app.ensureSession(id);
14255
+ if (!app.isLocal(await findSource(id))) await app.ensureSession(id);
14195
14256
  targets = (await app.syncSource(id)).targets;
14196
14257
  }
14197
14258
  return { targets: targets.map(targetView) };
@@ -14280,7 +14341,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
14280
14341
  import { createServer } from "http";
14281
14342
 
14282
14343
  // ../ui/src/assets/app.js
14283
- 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 // Own keys only: \"#/constructor\" must not reach Object.prototype.\n route = match && Object.hasOwn(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 = new Map([\n ['aliases', aliasesPage],\n ['sources', sourcesPage],\n ['assistants', assistantsPage],\n ['tools', toolsPage],\n ]);\n const page = pages.get(route) ?? aliasesPage;\n $main.replaceChildren(...page());\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 h(\n 'label',\n {\n class: 'prod-toggle',\n title: 'New terminals start with this alias (only one per cloud)',\n },\n h('input', {\n type: 'checkbox',\n checked: alias.default,\n 'aria-label': `New terminals start with ${alias.name}`,\n onchange: (e) => {\n const on = e.currentTarget.checked;\n if (\n on &&\n alias.prod &&\n !confirm(`${alias.name} is production. Start every new terminal in it?`)\n ) {\n e.currentTarget.checked = false;\n return;\n }\n patch(\n { default: on },\n on\n ? `New terminals start with ${alias.name}. Open a new one to see it.`\n : `New terminals no longer start with ${alias.name}.`,\n );\n },\n }),\n 'Default',\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 alias.default\n ? h(\n 'span',\n { class: 'default-tag', title: 'New terminals start with this alias' },\n 'DEFAULT',\n )\n : 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";
14344
+ 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 offline: 'Emulator not running',\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 // Own keys only: \"#/constructor\" must not reach Object.prototype.\n route = match && Object.hasOwn(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 = new Map([\n ['aliases', aliasesPage],\n ['sources', sourcesPage],\n ['assistants', assistantsPage],\n ['tools', toolsPage],\n ]);\n const page = pages.get(route) ?? aliasesPage;\n $main.replaceChildren(...page());\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(\n 'Sources',\n 'The SSO portals and personal accounts you sign in to, and your local emulators.',\n );\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 =\n source.settings.startUrl ??\n source.settings.endpoint ??\n (source.settings.port ? `https://127.0.0.1:${source.settings.port}` : source.kindLabel);\n const sessionText =\n source.local && source.session === 'valid'\n ? 'Emulator running'\n : (SESSION_TEXT[source.session] ?? source.session);\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, sessionText),\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 // An emulator has nothing to sign in to or refresh.\n source.local\n ? null\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 source.local\n ? null\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: alias.local\n ? 'A local emulator has no web console'\n : `Open the AWS console as ${alias.name}, already signed in`,\n disabled: alias.missing || alias.local,\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 {\n class: 'prod-toggle',\n title: alias.local\n ? 'A local emulator never reaches the cloud: nothing to protect'\n : 'Production: hop and AI assistants ask before any change',\n },\n h('input', {\n type: 'checkbox',\n checked: alias.prod,\n disabled: alias.local,\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 h(\n 'label',\n {\n class: 'prod-toggle',\n title: 'New terminals start with this alias (only one per cloud)',\n },\n h('input', {\n type: 'checkbox',\n checked: alias.default,\n 'aria-label': `New terminals start with ${alias.name}`,\n onchange: (e) => {\n const on = e.currentTarget.checked;\n if (\n on &&\n alias.prod &&\n !confirm(`${alias.name} is production. Start every new terminal in it?`)\n ) {\n e.currentTarget.checked = false;\n return;\n }\n patch(\n { default: on },\n on\n ? `New terminals start with ${alias.name}. Open a new one to see it.`\n : `New terminals no longer start with ${alias.name}.`,\n );\n },\n }),\n 'Default',\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 alias.local\n ? h('span', { class: 'default-tag', title: 'Runs against a local emulator' }, 'LOCAL')\n : null,\n alias.default\n ? h(\n 'span',\n { class: 'default-tag', title: 'New terminals start with this alias' },\n 'DEFAULT',\n )\n : 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";
14284
14345
 
14285
14346
  // ../ui/src/assets/index.html
14286
14347
  var assets_default = `<!doctype html>
@@ -14462,7 +14523,7 @@ var assets_default = `<!doctype html>
14462
14523
  }
14463
14524
  .badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
14464
14525
  .badge.valid { color: var(--ok); }
14465
- .badge.refreshable { color: var(--warn); }
14526
+ .badge.refreshable, .badge.offline { color: var(--warn); }
14466
14527
  .badge.expired { color: var(--bad); }
14467
14528
  .empty { padding: 14px 16px; color: var(--muted); }
14468
14529
  .missing { color: var(--bad); }
@@ -14718,10 +14779,10 @@ async function uiCommand(app, assistants, { flags }) {
14718
14779
 
14719
14780
  // src/container.ts
14720
14781
  import { homedir as homedir5 } from "os";
14721
- import { join as join10 } from "path";
14782
+ import { join as join11 } from "path";
14722
14783
 
14723
14784
  // ../provider-aws/src/aws-provider.ts
14724
- import { join as join9 } from "path";
14785
+ import { join as join10 } from "path";
14725
14786
 
14726
14787
  // ../provider-aws/src/aws-config.ts
14727
14788
  import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
@@ -14730,7 +14791,13 @@ var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand
14730
14791
  var BLOCK_END = "# <<< cloudhop managed <<<";
14731
14792
  function renderBlock(sections) {
14732
14793
  const body = sections.map(
14733
- (s) => [`[${s.header}]`, ...Object.entries(s.values).map(([k, v]) => `${k} = ${v}`)].join("\n")
14794
+ (s) => [
14795
+ `[${s.header}]`,
14796
+ ...Object.entries(s.values).map(
14797
+ // A value that starts on a new line is a nested section, like `s3 =` + ` addressing_style = path`.
14798
+ ([k, v]) => `${k} =${v.startsWith("\n") ? "" : " "}${v}`
14799
+ )
14800
+ ].join("\n")
14734
14801
  ).join("\n\n");
14735
14802
  return `${BLOCK_START}
14736
14803
  ${body}${body ? "\n" : ""}${BLOCK_END}
@@ -14834,6 +14901,73 @@ function portalShortcutUrl(startUrl, accountId, roleName, destination) {
14834
14901
  return `${base}/#/console?${params}`;
14835
14902
  }
14836
14903
 
14904
+ // ../provider-aws/src/emulator.ts
14905
+ import { readFile as readFile7 } from "fs/promises";
14906
+ import { connect } from "net";
14907
+ import { join as join8 } from "path";
14908
+ var AWS_EMULATOR_KIND = "aws-emulator";
14909
+ var LOCALSTACK_KIND = "localstack";
14910
+ var LOCAL_KINDS = [AWS_EMULATOR_KIND, LOCALSTACK_KIND];
14911
+ var LOCAL_ACCOUNT = "000000000000";
14912
+ var DEFAULT_EMULATOR_PORT = "9297";
14913
+ var DEFAULT_LOCALSTACK_ENDPOINT = "http://localhost:4566";
14914
+ var FAKE_KEYS = { accessKeyId: "test", secretAccessKey: "test" };
14915
+ var validatePort = (value) => /^\d{1,5}$/.test(value) && Number(value) > 0 && Number(value) < 65536 ? void 0 : "Use a port number like 9297.";
14916
+ var validateEndpoint = (value) => {
14917
+ if (!/^https?:\/\/[^\s/?#]+\/?$/.test(value)) return "Use a URL like http://localhost:4566.";
14918
+ if (/amazonaws\.com|\.aws(:|\/|$)/i.test(value)) return "Use your emulator, not an AWS endpoint.";
14919
+ return void 0;
14920
+ };
14921
+ function emulatorEndpoint(source) {
14922
+ if (source.kind === AWS_EMULATOR_KIND) {
14923
+ const port2 = Number(source.settings.port ?? DEFAULT_EMULATOR_PORT);
14924
+ return { url: `https://127.0.0.1:${port2}`, host: "127.0.0.1", port: port2 };
14925
+ }
14926
+ const url = new URL(source.settings.endpoint ?? DEFAULT_LOCALSTACK_ENDPOINT);
14927
+ const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
14928
+ return { url: url.origin, host: url.hostname, port };
14929
+ }
14930
+ var emulatorDir = (awsDir, source) => join8(awsDir, "emulator", source.settings.port ?? DEFAULT_EMULATOR_PORT);
14931
+ function localTarget(source) {
14932
+ const what = source.kind === AWS_EMULATOR_KIND ? `AWS Emulator on port ${emulatorEndpoint(source).port}` : `Emulator at ${emulatorEndpoint(source).url}`;
14933
+ return {
14934
+ id: LOCAL_ACCOUNT,
14935
+ label: what,
14936
+ attributes: { accountId: LOCAL_ACCOUNT, accountName: source.id }
14937
+ };
14938
+ }
14939
+ function offlineHint(source) {
14940
+ if (source.kind === AWS_EMULATOR_KIND) {
14941
+ const { port } = emulatorEndpoint(source);
14942
+ return `Start AWS Emulator on port ${port} (\`aws-emulator -p ${port}\` on Linux, its Docker container on macOS and Windows), then try again.`;
14943
+ }
14944
+ return `Start it at ${emulatorEndpoint(source).url} (for LocalStack, \`localstack start\`), then try again.`;
14945
+ }
14946
+ function isListening(host, port, timeoutMs = 800) {
14947
+ return new Promise((resolve3) => {
14948
+ const socket = connect({ host, port });
14949
+ const done = (up) => {
14950
+ socket.destroy();
14951
+ resolve3(up);
14952
+ };
14953
+ socket.setTimeout(timeoutMs, () => done(false));
14954
+ socket.once("connect", () => done(true));
14955
+ socket.once("error", () => done(false));
14956
+ });
14957
+ }
14958
+ async function emulatorCredentials(awsDir, source) {
14959
+ if (source.kind !== AWS_EMULATOR_KIND) return FAKE_KEYS;
14960
+ const text4 = await readFile7(
14961
+ join8(emulatorDir(awsDir, source), "emulator_credentials"),
14962
+ "utf8"
14963
+ ).catch(() => void 0);
14964
+ if (text4 === void 0) return FAKE_KEYS;
14965
+ const value = (key2) => text4.match(new RegExp(`^\\s*${key2}\\s*=\\s*(\\S+)`, "m"))?.[1];
14966
+ const accessKeyId = value("aws_access_key_id");
14967
+ const secretAccessKey = value("aws_secret_access_key");
14968
+ return accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : FAKE_KEYS;
14969
+ }
14970
+
14837
14971
  // ../provider-aws/src/http.ts
14838
14972
  var AwsHttpError = class extends Error {
14839
14973
  constructor(status, code, message) {
@@ -15052,22 +15186,22 @@ var OidcClient = class {
15052
15186
 
15053
15187
  // ../provider-aws/src/sso/token-cache.ts
15054
15188
  import { createHash as createHash3 } from "crypto";
15055
- import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile8 } from "fs/promises";
15056
- import { join as join8 } from "path";
15189
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile8 } from "fs/promises";
15190
+ import { join as join9 } from "path";
15057
15191
  var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
15058
15192
  function tokenCachePath(awsDir, sessionName) {
15059
15193
  const hash = createHash3("sha1").update(sessionName).digest("hex");
15060
- return join8(awsDir, "sso", "cache", `${hash}.json`);
15194
+ return join9(awsDir, "sso", "cache", `${hash}.json`);
15061
15195
  }
15062
15196
  async function readToken(awsDir, sessionName) {
15063
15197
  try {
15064
- return JSON.parse(await readFile7(tokenCachePath(awsDir, sessionName), "utf8"));
15198
+ return JSON.parse(await readFile8(tokenCachePath(awsDir, sessionName), "utf8"));
15065
15199
  } catch {
15066
15200
  return void 0;
15067
15201
  }
15068
15202
  }
15069
15203
  async function writeToken(awsDir, sessionName, token) {
15070
- await mkdir6(join8(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
15204
+ await mkdir6(join9(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
15071
15205
  await writeFile8(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
15072
15206
  mode: 384
15073
15207
  });
@@ -15247,6 +15381,7 @@ var AwsProvider = class {
15247
15381
  constructor(options) {
15248
15382
  this.options = options;
15249
15383
  this.fetchFn = options.fetchFn ?? fetch;
15384
+ this.probe = options.probe ?? ((host, port) => isListening(host, port));
15250
15385
  }
15251
15386
  options;
15252
15387
  id = "aws";
@@ -15283,15 +15418,47 @@ var AwsProvider = class {
15283
15418
  validate: validateRegion
15284
15419
  }
15285
15420
  ]
15421
+ },
15422
+ {
15423
+ kind: AWS_EMULATOR_KIND,
15424
+ label: "AWS Emulator (local)",
15425
+ hint: "AWS service APIs on this machine. No AWS account involved.",
15426
+ nameLabel: "Short name for this emulator",
15427
+ local: true,
15428
+ fields: [
15429
+ { key: "port", label: "Port", defaultValue: DEFAULT_EMULATOR_PORT, validate: validatePort },
15430
+ { key: "region", label: "Region", defaultValue: "us-east-1", validate: validateRegion }
15431
+ ]
15432
+ },
15433
+ {
15434
+ kind: LOCALSTACK_KIND,
15435
+ label: "LocalStack or another local emulator",
15436
+ hint: "Any emulator with an AWS endpoint, signed with test/test.",
15437
+ nameLabel: "Short name for this emulator",
15438
+ local: true,
15439
+ fields: [
15440
+ {
15441
+ key: "endpoint",
15442
+ label: "Endpoint",
15443
+ defaultValue: DEFAULT_LOCALSTACK_ENDPOINT,
15444
+ validate: validateEndpoint
15445
+ },
15446
+ { key: "region", label: "Region", defaultValue: "us-east-1", validate: validateRegion }
15447
+ ]
15286
15448
  }
15287
15449
  ];
15288
15450
  fetchFn;
15451
+ probe;
15289
15452
  async sessionState(source) {
15290
15453
  if (source.kind === CONSOLE_LOGIN_KIND) return "unknown";
15454
+ if (LOCAL_KINDS.includes(source.kind)) {
15455
+ const { host, port } = emulatorEndpoint(source);
15456
+ return await this.probe(host, port) ? "valid" : "offline";
15457
+ }
15291
15458
  return tokenState(await readToken(this.options.awsDir, ssoSessionName(source.id)));
15292
15459
  }
15293
15460
  async refreshSession(source) {
15294
- if (source.kind === CONSOLE_LOGIN_KIND) return false;
15461
+ if (source.kind === CONSOLE_LOGIN_KIND || LOCAL_KINDS.includes(source.kind)) return false;
15295
15462
  const name = ssoSessionName(source.id);
15296
15463
  const token = await readToken(this.options.awsDir, name);
15297
15464
  const refreshed = token && await refreshSsoToken(token, this.fetchFn);
@@ -15300,6 +15467,7 @@ var AwsProvider = class {
15300
15467
  return true;
15301
15468
  }
15302
15469
  async login(source, interaction) {
15470
+ if (LOCAL_KINDS.includes(source.kind)) return;
15303
15471
  if (source.kind === CONSOLE_LOGIN_KIND) {
15304
15472
  const args = ["login", "--profile", consoleProfileName(source.id)];
15305
15473
  if (source.settings.region) args.push("--region", source.settings.region);
@@ -15319,6 +15487,7 @@ var AwsProvider = class {
15319
15487
  }
15320
15488
  async discoverTargets(source) {
15321
15489
  if (source.kind === CONSOLE_LOGIN_KIND) return [CONSOLE_TARGET];
15490
+ if (LOCAL_KINDS.includes(source.kind)) return [localTarget(source)];
15322
15491
  const portal = await this.portal(source);
15323
15492
  const accounts = await portal.listAccounts();
15324
15493
  const targets = [];
@@ -15351,6 +15520,13 @@ var AwsProvider = class {
15351
15520
  });
15352
15521
  }
15353
15522
  for (const { alias, source, target, region } of aliases) {
15523
+ if (LOCAL_KINDS.includes(source.kind)) {
15524
+ sections.push({
15525
+ header: `profile ${alias.name}`,
15526
+ values: this.localProfile(alias.name, source, region)
15527
+ });
15528
+ continue;
15529
+ }
15354
15530
  if (source.kind !== SSO_KIND || !target) continue;
15355
15531
  sections.push({
15356
15532
  header: `profile ${alias.name}`,
@@ -15362,7 +15538,7 @@ var AwsProvider = class {
15362
15538
  }
15363
15539
  });
15364
15540
  }
15365
- await writeManagedBlock(join9(this.options.awsDir, "config"), sections);
15541
+ await writeManagedBlock(join10(this.options.awsDir, "config"), sections);
15366
15542
  }
15367
15543
  async processEnv(resolved, mode) {
15368
15544
  const region = resolved.region;
@@ -15375,6 +15551,9 @@ var AwsProvider = class {
15375
15551
  ...regionEnv
15376
15552
  };
15377
15553
  }
15554
+ if (LOCAL_KINDS.includes(resolved.source.kind)) {
15555
+ return { AWS_PROFILE: void 0, ...await this.localEnv(resolved), ...regionEnv };
15556
+ }
15378
15557
  const credentials = resolved.source.kind === CONSOLE_LOGIN_KIND ? await this.exportedCredentials(profile) : await this.roleCredentials(resolved);
15379
15558
  return { AWS_PROFILE: void 0, ...credentials, ...regionEnv };
15380
15559
  }
@@ -15400,6 +15579,51 @@ var AwsProvider = class {
15400
15579
  isSessionError(output) {
15401
15580
  return SESSION_ERROR_PATTERNS.some((pattern) => pattern.test(output));
15402
15581
  }
15582
+ offlineHint(source) {
15583
+ return offlineHint(source);
15584
+ }
15585
+ localCredentials(resolved) {
15586
+ return emulatorCredentials(this.options.awsDir, resolved.source);
15587
+ }
15588
+ /**
15589
+ * The profile of an emulator alias: its endpoint, the certificate AWS
15590
+ * Emulator writes on every start (same path, new content) and keys read
15591
+ * fresh by `cloudhop credentials` on each call, since they change too.
15592
+ */
15593
+ localProfile(name, source, region) {
15594
+ const values = {
15595
+ ...region ? { region } : {},
15596
+ endpoint_url: emulatorEndpoint(source).url
15597
+ };
15598
+ if (source.kind === AWS_EMULATOR_KIND) {
15599
+ values.ca_bundle = join10(emulatorDir(this.options.awsDir, source), "ca.pem");
15600
+ values.credential_process = `cloudhop credentials ${name}`;
15601
+ } else {
15602
+ values.aws_access_key_id = "test";
15603
+ values.aws_secret_access_key = "test";
15604
+ values.s3 = "\n addressing_style = path";
15605
+ }
15606
+ return values;
15607
+ }
15608
+ /** For `cloudhop exec`: tools that read only variables, not profiles. */
15609
+ async localEnv(resolved) {
15610
+ const { source } = resolved;
15611
+ const keys = await emulatorCredentials(this.options.awsDir, source);
15612
+ const env = {
15613
+ AWS_ACCESS_KEY_ID: keys.accessKeyId,
15614
+ AWS_SECRET_ACCESS_KEY: keys.secretAccessKey,
15615
+ AWS_SESSION_TOKEN: void 0,
15616
+ AWS_ENDPOINT_URL: emulatorEndpoint(source).url,
15617
+ // Never fall back to the credentials of a machine in the cloud.
15618
+ AWS_EC2_METADATA_DISABLED: "true"
15619
+ };
15620
+ if (source.kind === AWS_EMULATOR_KIND) {
15621
+ const ca = join10(emulatorDir(this.options.awsDir, source), "ca.pem");
15622
+ env.AWS_CA_BUNDLE = ca;
15623
+ if (!process.env.NODE_EXTRA_CA_CERTS) env.NODE_EXTRA_CA_CERTS = ca;
15624
+ }
15625
+ return env;
15626
+ }
15403
15627
  async roleCredentials(resolved) {
15404
15628
  const portal = await this.portal(resolved.source);
15405
15629
  const attrs = resolved.target?.attributes ?? {};
@@ -15447,7 +15671,7 @@ var AwsProvider = class {
15447
15671
  var browser = new NodeBrowserOpener();
15448
15672
  function createApp(env = process.env) {
15449
15673
  const runner = new NodeProcessRunner();
15450
- const awsDir = env.CLOUDHOP_AWS_DIR ?? join10(homedir5(), ".aws");
15674
+ const awsDir = env.CLOUDHOP_AWS_DIR ?? join11(homedir5(), ".aws");
15451
15675
  return new CloudhopApp({
15452
15676
  config: new YamlConfigStore(),
15453
15677
  providers: [
@@ -15477,8 +15701,8 @@ function createTrustedFolders(env = process.env) {
15477
15701
  }
15478
15702
 
15479
15703
  // src/update-check.ts
15480
- import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile9 } from "fs/promises";
15481
- import { join as join11 } from "path";
15704
+ import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile9 } from "fs/promises";
15705
+ import { join as join12 } from "path";
15482
15706
  var DAY_MS = 24 * 60 * 60 * 1e3;
15483
15707
  var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
15484
15708
  var VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.]{1,40})?$/;
@@ -15488,11 +15712,11 @@ function startUpdateCheck(configDir, current) {
15488
15712
  if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
15489
15713
  return async () => {
15490
15714
  };
15491
- const file = join11(configDir, "update-check.json");
15715
+ const file = join12(configDir, "update-check.json");
15492
15716
  const work = (async () => {
15493
15717
  let cache = { checkedAt: 0 };
15494
15718
  try {
15495
- cache = JSON.parse(await readFile8(file, "utf8"));
15719
+ cache = JSON.parse(await readFile9(file, "utf8"));
15496
15720
  } catch {
15497
15721
  }
15498
15722
  const cached = validVersion(cache.latest);
@@ -15518,7 +15742,7 @@ function startUpdateCheck(configDir, current) {
15518
15742
  }
15519
15743
 
15520
15744
  // src/cli.ts
15521
- var HELP = `cloudhop ${"1.3.1"}
15745
+ var HELP = `cloudhop ${"1.4.0"}
15522
15746
  Hop between cloud accounts, roles and regions with one command.
15523
15747
 
15524
15748
  Quick start
@@ -15549,6 +15773,9 @@ Setup
15549
15773
  cloudhop add Add an SSO portal or a personal account
15550
15774
  cloudhop add aws sso --start-url <url> [--region <r>] [--name <id>]
15551
15775
  cloudhop add aws console-login [--region <r>] [--name <id>]
15776
+ cloudhop add aws aws-emulator [--port 9297] [--region <r>] [--name <id>]
15777
+ cloudhop add aws localstack [--endpoint http://localhost:4566] [--region <r>] [--name <id>]
15778
+ A local emulator: an alias like any other, never reaching AWS
15552
15779
  cloudhop team export [--out <file>] | team import <file|https URL>
15553
15780
  Share portals and aliases with your team (no secrets)
15554
15781
  cloudhop alias [source] Create more aliases from a source
@@ -15608,7 +15835,7 @@ async function main(argv) {
15608
15835
  return 0;
15609
15836
  }
15610
15837
  if (command === "--version" || command === "-v" || command === "version") {
15611
- console.log("1.3.1");
15838
+ console.log("1.4.0");
15612
15839
  return 0;
15613
15840
  }
15614
15841
  const app = createApp();
@@ -15629,17 +15856,30 @@ async function main(argv) {
15629
15856
  if (command === "use" && local.some((a2) => a2 === "--emit" || a2.startsWith("--emit="))) {
15630
15857
  return useCommand(app, createTrustedFolders(), local);
15631
15858
  }
15859
+ if (command === "credentials") {
15860
+ const name = local[0];
15861
+ if (!name) throw new CloudhopError("INVALID_NAME", "Usage: cloudhop credentials <alias>");
15862
+ const keys = await app.localCredentials(name);
15863
+ const json = {
15864
+ Version: 1,
15865
+ AccessKeyId: keys.accessKeyId,
15866
+ SecretAccessKey: keys.secretAccessKey
15867
+ };
15868
+ process.stdout.write(`${JSON.stringify(json)}
15869
+ `);
15870
+ return 0;
15871
+ }
15632
15872
  if (command === "mcp") {
15633
15873
  await serveMcp({
15634
15874
  app,
15635
- version: "1.3.1",
15875
+ version: "1.4.0",
15636
15876
  context: () => shellContext(createTrustedFolders())
15637
15877
  });
15638
15878
  return 0;
15639
15879
  }
15640
- const assistants = createAssistants("1.3.1");
15880
+ const assistants = createAssistants("1.4.0");
15641
15881
  await refreshAssistantSkills(assistants, command);
15642
- const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.3.1");
15882
+ const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.4.0");
15643
15883
  try {
15644
15884
  return await dispatch(app, assistants, command, args, local);
15645
15885
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudhop",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Every cloud account, one short alias away. Sign in to AWS SSO portals and personal accounts once, hop between accounts, roles and regions with one command. No access keys.",
5
5
  "keywords": [
6
6
  "cli",
@@ -43,12 +43,12 @@
43
43
  "devDependencies": {
44
44
  "@clack/prompts": "^1.8.1",
45
45
  "yaml": "^2.9.1",
46
+ "@cloudhop/adapters-node": "0.0.0",
46
47
  "@cloudhop/assistants": "0.0.0",
47
48
  "@cloudhop/core": "0.0.0",
48
49
  "@cloudhop/mcp": "0.0.0",
49
50
  "@cloudhop/provider-aws": "0.0.0",
50
- "@cloudhop/ui": "0.0.0",
51
- "@cloudhop/adapters-node": "0.0.0"
51
+ "@cloudhop/ui": "0.0.0"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "tsup",