cloudhop 0.1.1 → 1.0.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 +95 -49
  2. package/dist/cli.js +295 -10
  3. package/package.json +10 -6
package/README.md CHANGED
@@ -1,77 +1,123 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/dayepesb/cloudhop/main/assets/logo.svg" width="96" height="96" alt="cloudhop logo">
2
+ <img src="https://raw.githubusercontent.com/dayepesb/cloudhop/main/assets/logo.svg" width="88" height="88" alt="cloudhop logo">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">cloudhop</h1>
6
6
 
7
7
  <p align="center">
8
- Hop between cloud accounts, roles and regions with one command.<br>
9
- Automatic login, no access keys.
8
+ <strong>Every cloud account, one short alias away.</strong><br>
9
+ Sign in once per portal, give each account and role a short alias, and let
10
+ cloudhop log you in again only when a session has expired. No access keys.
10
11
  </p>
11
12
 
12
13
  <p align="center">
13
14
 
15
+ [![npm](https://img.shields.io/npm/v/cloudhop?color=1767c2)](https://www.npmjs.com/package/cloudhop)
14
16
  [![Pipeline](https://github.com/dayepesb/cloudhop/actions/workflows/pipeline.yml/badge.svg)](https://github.com/dayepesb/cloudhop/actions/workflows/pipeline.yml)
15
- [![npm](https://img.shields.io/npm/v/cloudhop)](https://www.npmjs.com/package/cloudhop)
16
- [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
17
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-1767c2)](https://github.com/dayepesb/cloudhop/blob/main/LICENSE)
18
+ [![Docs](https://img.shields.io/badge/docs-dayepesb.github.io%2Fcloudhop-ff7a59)](https://dayepesb.github.io/cloudhop/)
17
19
 
18
20
  </p>
19
21
 
20
- > 🚧 Early preview. AWS works today; Azure and Google Cloud are planned.
21
-
22
22
  ```bash
23
- npm i -g cloudhop
24
- cloudhop setup # add an SSO portal or personal account
25
-
26
- hop acme-prod s3 ls # `aws s3 ls` with the right account, role and region
27
- hop acme-prod # who am I?
28
- cloudhop exec acme-prod -- terraform plan # any tool, with temporary credentials
29
- cloudhop ui # or manage everything from a local web page
23
+ npm install --global cloudhop
24
+ cloudhop setup # add your SSO portal, sign in, pick accounts
25
+ hop acme-prod s3 ls # runs `aws s3 ls` as that account, role and region
30
26
  ```
31
27
 
32
- ## Why
28
+ ## Why cloudhop
29
+
30
+ Working with several AWS SSO portals and personal accounts usually means
31
+ copying access keys, editing `~/.aws/config` by hand and running
32
+ `aws sso login` again and again. cloudhop does that part for you:
33
+
34
+ - **One sign-in per portal.** Add an IAM Identity Center portal once; cloudhop
35
+ finds every account and role you can use and turns the ones you pick into
36
+ aliases.
37
+ - **Logs in only when needed.** Nothing runs in the background. When you use
38
+ an alias with an expired session, cloudhop renews it silently or opens the
39
+ browser, refreshes that portal's accounts, then runs your command.
40
+ - **No access keys, ever.** Only temporary credentials: IAM Identity Center
41
+ for companies, `aws login` for personal accounts.
42
+ - **Works with your tools.** Aliases are real profiles in `~/.aws/config`, so
43
+ Terraform, the AWS SDKs and your IDE see them too. `cloudhop exec` covers
44
+ everything else.
45
+ - **Terminal or browser.** `cloudhop ui` opens a local page to add portals,
46
+ pick accounts and change regions.
47
+
48
+ ## Everyday use
49
+
50
+ | You want to | Run |
51
+ | --- | --- |
52
+ | Run any `aws` command as an alias | `hop acme-prod ec2 describe-instances` |
53
+ | Check who you are | `hop acme-prod` |
54
+ | Use another region once | `hop acme-prod s3 ls --region eu-west-1` |
55
+ | Give any tool temporary credentials | `cloudhop exec acme-prod -- terraform plan` |
56
+ | See your portals and aliases | `cloudhop ls` |
57
+ | Add another portal or personal account | `cloudhop add` |
58
+ | Create more aliases from a portal | `cloudhop alias acme` |
59
+ | Change an alias' region | `cloudhop region acme-prod us-east-2` |
60
+ | Remove an alias or a portal | `cloudhop rm acme-dev` |
61
+ | Do all of it from the browser | `cloudhop ui` |
62
+
63
+ Every setup command also works with flags, for scripts and dotfiles. See the
64
+ [command reference](https://dayepesb.github.io/cloudhop/reference/commands/).
65
+
66
+ ## The local web UI
67
+
68
+ `cloudhop ui` serves a page on your own computer (and only there) to manage
69
+ portals, aliases and regions.
70
+
71
+ <p align="center">
72
+ <img src="https://raw.githubusercontent.com/dayepesb/cloudhop/main/assets/ui-screenshot.png" alt="cloudhop ui listing two portals with their aliases, regions and session state" width="840">
73
+ </p>
74
+
75
+ ## Requirements
76
+
77
+ - Node.js 20 or newer.
78
+ - AWS CLI v2 to run commands through aliases (`cloudhop setup` offers to
79
+ install it). Personal accounts need AWS CLI 2.32.0 or newer.
80
+ - macOS, Linux or Windows.
33
81
 
34
- Working with several AWS SSO portals plus personal accounts means copying
35
- access keys, editing `~/.aws/config` by hand and logging in again and again.
36
- cloudhop is a thin proxy over the AWS CLI that:
82
+ ## Security
37
83
 
38
- - Adds SSO portals and personal accounts interactively.
39
- - Discovers every account and role you have access to and creates aliases.
40
- - Logs you in automatically, only when a session has expired, and refreshes
41
- every account of that portal at the same time.
42
- - Never stores long-lived secrets: it uses AWS IAM Identity Center and
43
- `aws login` temporary credentials.
44
- - Writes standard profiles to `~/.aws/config`, so Terraform, SDKs and IDEs
45
- see the same aliases.
84
+ - No long-lived secrets: sessions live in the AWS CLI's own token cache, and
85
+ `~/.aws/credentials` is never written.
86
+ - cloudhop only edits its own block in `~/.aws/config` and never overwrites
87
+ profiles you wrote by hand.
88
+ - The web UI listens on `127.0.0.1` only and needs a one-time key per run.
89
+ - Releases are published by GitHub Actions after a maintainer's approval,
90
+ with npm provenance. Verify with `npm audit signatures`.
91
+
92
+ Found a vulnerability? Please
93
+ [report it privately](https://github.com/dayepesb/cloudhop/security/advisories/new).
46
94
 
47
95
  ## Documentation
48
96
 
49
- [dayepesb.github.io/cloudhop](https://dayepesb.github.io/cloudhop/), in English and Spanish.
97
+ **[dayepesb.github.io/cloudhop](https://dayepesb.github.io/cloudhop/)**, in
98
+ English and [Spanish](https://dayepesb.github.io/cloudhop/es/):
99
+ [getting started](https://dayepesb.github.io/cloudhop/getting-started/),
100
+ [company SSO portals](https://dayepesb.github.io/cloudhop/guides/aws-sso/),
101
+ [personal accounts](https://dayepesb.github.io/cloudhop/guides/personal-accounts/),
102
+ [other tools](https://dayepesb.github.io/cloudhop/guides/other-tools/) and
103
+ [troubleshooting](https://dayepesb.github.io/cloudhop/reference/troubleshooting/).
50
104
 
51
- ## Contributing
105
+ ## Roadmap
52
106
 
53
- ```bash
54
- pnpm install
55
- pnpm test # unit tests
56
- pnpm build && pnpm test:e2e # CLI end-to-end tests (sandboxed)
57
- pnpm exec playwright install chromium && pnpm test:web # web UI tests
58
- pnpm audit # dependency vulnerabilities
59
- pnpm typecheck
60
- pnpm lint
61
- pnpm build # bundles packages/cli/dist/cli.js
62
- node packages/cli/dist/cli.js --help
63
- pnpm docs:dev # documentation site
64
- ```
107
+ AWS is fully supported. **Azure** and **Google Cloud** are next, as providers
108
+ in the same package. The work is broken down into
109
+ [issues on the roadmap](https://github.com/dayepesb/cloudhop/issues?q=is%3Aopen+label%3Aroadmap);
110
+ pick one up!
65
111
 
66
- Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing the code; it also
67
- explains the pipeline and how releases are approved. Use
68
- `CLOUDHOP_HOME` and `CLOUDHOP_AWS_DIR` to try things without touching your
69
- real configuration.
112
+ ## Contributing
70
113
 
71
- ## License
114
+ Contributions are welcome. Start with
115
+ [CONTRIBUTING.md](https://github.com/dayepesb/cloudhop/blob/main/CONTRIBUTING.md)
116
+ and [ARCHITECTURE.md](https://github.com/dayepesb/cloudhop/blob/main/ARCHITECTURE.md),
117
+ and follow the
118
+ [Code of Conduct](https://github.com/dayepesb/cloudhop/blob/main/CODE_OF_CONDUCT.md).
72
119
 
73
- [Apache License 2.0](LICENSE). See [NOTICE](NOTICE).
120
+ ## License
74
121
 
75
- Contributions are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) and the
76
- [Code of Conduct](CODE_OF_CONDUCT.md). Report security problems privately as
77
- described in [SECURITY.md](SECURITY.md).
122
+ [Apache License 2.0](https://github.com/dayepesb/cloudhop/blob/main/LICENSE).
123
+ See [NOTICE](https://github.com/dayepesb/cloudhop/blob/main/NOTICE).
package/dist/cli.js CHANGED
@@ -10663,10 +10663,19 @@ function answer(value) {
10663
10663
  if (isCancel(value)) throw new PromptCancelled();
10664
10664
  return value;
10665
10665
  }
10666
- var dim = (text3) => process.stderr.isTTY ? `\x1B[2m${text3}\x1B[22m` : text3;
10667
- var bold = (text3) => process.stdout.isTTY ? `\x1B[1m${text3}\x1B[22m` : text3;
10668
- var ANSI = { dim: [2, 22], green: [32, 39], yellow: [33, 39], red: [31, 39], cyan: [36, 39] };
10669
- var paint = (style, text3) => process.stdout.isTTY ? `\x1B[${ANSI[style][0]}m${text3}\x1B[${ANSI[style][1]}m` : text3;
10666
+ var colorful = (stream2) => Boolean(stream2.isTTY) && !process.env.NO_COLOR;
10667
+ var truecolor = /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
10668
+ var dim = (text3) => colorful(process.stderr) ? `\x1B[2m${text3}\x1B[22m` : text3;
10669
+ var bold = (text3) => colorful(process.stdout) ? `\x1B[1m${text3}\x1B[22m` : text3;
10670
+ var STYLES = {
10671
+ dim: ["2", "22"],
10672
+ green: ["32", "39"],
10673
+ yellow: ["33", "39"],
10674
+ red: ["31", "39"],
10675
+ sky: [truecolor ? "38;2;59;184;245" : "38;5;75", "39"],
10676
+ coral: [truecolor ? "38;2;255;122;89" : "38;5;209", "39"]
10677
+ };
10678
+ var paint = (style, text3) => colorful(process.stdout) ? `\x1B[${STYLES[style][0]}m${text3}\x1B[${STYLES[style][1]}m` : text3;
10670
10679
  var BANNER = [
10671
10680
  " _ _ _",
10672
10681
  " ___ | | ___ _ _ __| || |__ ___ _ __",
@@ -10674,9 +10683,12 @@ var BANNER = [
10674
10683
  "| (__ | || (_) || |_| || (_| || | | || (_) || |_) |",
10675
10684
  " \\___||_| \\___/ \\__,_| \\__,_||_| |_| \\___/ | .__/",
10676
10685
  " |_|"
10677
- ].join("\n");
10686
+ ];
10687
+ var HOP_COLUMN = 30;
10678
10688
  function printBanner() {
10679
- console.log(paint("cyan", BANNER));
10689
+ for (const line of BANNER) {
10690
+ console.log(paint("sky", line.slice(0, HOP_COLUMN)) + paint("coral", line.slice(HOP_COLUMN)));
10691
+ }
10680
10692
  }
10681
10693
  function notify(message) {
10682
10694
  process.stderr.write(`${dim("cloudhop \u203A")} ${message}
@@ -10975,7 +10987,9 @@ async function listCommand(app, sourceId) {
10975
10987
  const state = await provider.sessionState(source);
10976
10988
  const where = source.settings.startUrl ?? source.kind;
10977
10989
  if (index > 0) console.log("");
10978
- console.log(`${bold(source.id)} ${paint("dim", `${provider.displayName} \xB7 ${where}`)}`);
10990
+ console.log(
10991
+ `${bold(paint("sky", source.id))} ${paint("dim", `${provider.displayName} \xB7 ${where}`)}`
10992
+ );
10979
10993
  console.log(` ${SESSION_LABEL[state]}`);
10980
10994
  const own = aliases.filter((a2) => a2.alias.source === source.id);
10981
10995
  if (own.length === 0) {
@@ -11620,7 +11634,278 @@ import { createServer } from "http";
11620
11634
  var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// A fresh link opened in this same tab only changes the fragment: reload to pick up its token.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) location.reload();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/sources(\\/[^/]+\\/(login|sync|targets))?$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n if (state.sources.length === 0) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n ),\n );\n return;\n }\n $main.replaceChildren(...state.sources.map(sourceCard));\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'table',\n {},\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n h('tbody', {}, aliases.map(aliasRow)),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n return h(\n 'tr',\n {},\n h('td', { class: 'alias', title: `hop ${alias.name} <command>` }, alias.name),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h(\n 'td',\n { style: 'text-align:right' },\n h(\n 'button',\n {\n class: 'link',\n title: `Remove ${alias.name}`,\n 'aria-label': `Remove ${alias.name}`,\n onclick: (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n },\n '\u2715',\n ),\n ),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n refresh();\n}\n";
11621
11635
 
11622
11636
  // ../ui/src/assets/index.html
11623
- var assets_default = '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>cloudhop</title>\n <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%2319c3d1%22/%3E%3Cstop offset=%221%22 stop-color=%22%230a6a82%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" />\n <style>\n :root {\n --bg: #f6f7f9;\n --surface: #ffffff;\n --surface-2: #f1f3f6;\n --border: #e2e6eb;\n --text: #18202b;\n --muted: #5d6877;\n --accent: #0b8fa0;\n --accent-strong: #08707e;\n --accent-soft: #e0f4f6;\n --ok: #1f8a4c;\n --warn: #a86400;\n --bad: #c23934;\n --radius: 10px;\n --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);\n color-scheme: light;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --bg: #0f141a;\n --surface: #161d25;\n --surface-2: #1d2630;\n --border: #2a3542;\n --text: #e6ebf1;\n --muted: #93a1b1;\n --accent: #35c2d3;\n --accent-strong: #5fd3e1;\n --accent-soft: #123a41;\n --ok: #4cc27f;\n --warn: #e0a341;\n --bad: #f06b66;\n --shadow: none;\n color-scheme: dark;\n }\n }\n * { box-sizing: border-box; }\n /* biome-ignore lint/complexity/noImportantStyles: the hidden attribute must beat component display rules */\n [hidden] { display: none !important; }\n body {\n margin: 0;\n background: var(--bg);\n color: var(--text);\n font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;\n }\n code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }\n header {\n background: var(--surface);\n border-bottom: 1px solid var(--border);\n }\n .bar {\n max-width: 1040px;\n margin: 0 auto;\n padding: 14px 20px;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .logo { font-weight: 700; font-size: 20px; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }\n .logo span span { color: var(--accent); }\n .tagline { color: var(--muted); font-size: 14px; }\n .spacer { flex: 1; }\n main { max-width: 1040px; margin: 0 auto; padding: 24px 20px 64px; }\n h2 { font-size: 15px; margin: 0; }\n .card {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n margin-bottom: 18px;\n }\n .card-head {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 10px 14px;\n padding: 14px 16px;\n border-bottom: 1px solid var(--border);\n }\n .card-head .meta { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }\n .card-body { padding: 4px 0; }\n .actions { display: flex; gap: 8px; flex-wrap: wrap; }\n button {\n font: inherit;\n font-size: 14px;\n border-radius: 8px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text);\n padding: 6px 12px;\n cursor: pointer;\n }\n button:hover { background: var(--surface-2); }\n button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }\n button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }\n button.primary:hover { background: var(--accent-strong); }\n button.danger { color: var(--bad); }\n button.link { border: none; background: none; color: var(--muted); padding: 4px 6px; }\n button.link:hover { color: var(--bad); }\n button:disabled { opacity: 0.55; cursor: progress; }\n input, select {\n font: inherit;\n font-size: 14px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 6px 10px;\n min-width: 0;\n }\n table { width: 100%; border-collapse: collapse; }\n th {\n text-align: left;\n font-size: 12px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--muted);\n font-weight: 600;\n padding: 8px 16px;\n }\n td { padding: 8px 16px; border-top: 1px solid var(--border); vertical-align: middle; }\n td.alias { font-weight: 600; white-space: nowrap; }\n .region { display: flex; gap: 6px; align-items: center; }\n .region input { width: 140px; }\n .badge {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 12px;\n font-weight: 600;\n padding: 2px 10px;\n border-radius: 999px;\n background: var(--surface-2);\n color: var(--muted);\n white-space: nowrap;\n }\n .badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }\n .badge.valid { color: var(--ok); }\n .badge.refreshable { color: var(--warn); }\n .badge.expired { color: var(--bad); }\n .empty { padding: 14px 16px; color: var(--muted); }\n .missing { color: var(--bad); }\n .hero {\n text-align: center;\n padding: 48px 20px;\n }\n .hero p { color: var(--muted); max-width: 560px; margin: 8px auto 20px; }\n dialog {\n border: 1px solid var(--border);\n border-radius: 14px;\n padding: 0;\n width: min(720px, calc(100vw - 32px));\n max-height: calc(100vh - 48px);\n background: var(--surface);\n color: var(--text);\n box-shadow: 0 20px 60px rgb(0 0 0 / 30%);\n }\n dialog::backdrop { background: rgb(10 15 20 / 45%); }\n .dlg-head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }\n .dlg-head h2 { font-size: 17px; flex: 1; }\n .dlg-body { padding: 18px 20px; overflow: auto; max-height: calc(100vh - 200px); overflow-wrap: anywhere; }\n .dlg-foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; align-items: center; }\n .dlg-foot .note { flex: 1; color: var(--muted); font-size: 13px; }\n .field { display: grid; gap: 4px; margin-bottom: 14px; }\n .field label { font-weight: 600; font-size: 14px; }\n .field .help { color: var(--muted); font-size: 13px; }\n .choices { display: grid; gap: 8px; margin-bottom: 16px; }\n .choice {\n display: flex;\n gap: 10px;\n align-items: flex-start;\n border: 1px solid var(--border);\n border-radius: 10px;\n padding: 10px 12px;\n cursor: pointer;\n }\n .choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }\n .choice small { display: block; color: var(--muted); }\n .targets { display: grid; gap: 6px; }\n .target {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 6px 10px;\n align-items: center;\n border: 1px solid var(--border);\n border-radius: 10px;\n padding: 8px 12px;\n }\n .target:has(input[type="checkbox"]:checked) { border-color: var(--accent); }\n .target .pick { display: contents; cursor: pointer; }\n .target .already { display: block; color: var(--muted); font-size: 13px; }\n .target .inputs { grid-column: 2; display: none; gap: 8px; flex-wrap: wrap; }\n .target:has(input[type="checkbox"]:checked) .inputs { display: flex; }\n .target .inputs input { flex: 1 1 160px; }\n .waiting { text-align: center; padding: 28px 8px; }\n .spinner {\n width: 28px; height: 28px; margin: 0 auto 14px;\n border: 3px solid var(--border); border-top-color: var(--accent);\n border-radius: 50%; animation: spin 0.9s linear infinite;\n }\n @keyframes spin { to { transform: rotate(360deg); } }\n #toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 10; }\n .toast {\n background: var(--text);\n color: var(--bg);\n padding: 10px 14px;\n border-radius: 10px;\n max-width: 420px;\n box-shadow: 0 6px 20px rgb(0 0 0 / 25%);\n font-size: 14px;\n }\n .toast.error { background: var(--bad); color: #fff; }\n .toast small { display: block; opacity: 0.85; }\n .notice {\n border: 1px solid var(--border);\n border-left: 4px solid var(--warn);\n background: var(--surface);\n border-radius: var(--radius);\n padding: 10px 14px;\n margin-bottom: 18px;\n }\n .notice.error { border-left-color: var(--bad); }\n @media (max-width: 640px) {\n .tagline { display: none; }\n th:nth-child(3), td:nth-child(3) { display: none; }\n td, th { padding: 8px 10px; }\n td.alias { white-space: normal; overflow-wrap: anywhere; }\n .region input { width: 96px; }\n }\n </style>\n </head>\n <body>\n <header>\n <div class="bar">\n <div class="logo"><img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%2319c3d1%22/%3E%3Cstop offset=%221%22 stop-color=%22%230a6a82%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" alt="" width="28" height="28" /><span>cloud<span>hop</span></span></div>\n <div class="tagline">Hop between cloud accounts, roles and regions</div>\n <div class="spacer"></div>\n <button type="button" class="primary" id="add-source" hidden>+ Add source</button>\n </div>\n </header>\n <main id="main" aria-live="polite">\n <div class="waiting"><div class="spinner"></div>Loading\u2026</div>\n </main>\n <dialog id="dialog"></dialog>\n <div id="toasts" role="status"></div>\n <script type="module" src="/app.js"></script>\n </body>\n</html>\n';
11637
+ var assets_default = `<!doctype html>
11638
+ <html lang="en">
11639
+ <head>
11640
+ <meta charset="utf-8" />
11641
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11642
+ <title>cloudhop</title>
11643
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%233BB8F5%22/%3E%3Cstop offset=%221%22 stop-color=%22%231450A8%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" />
11644
+ <style>
11645
+ /* cloudhop palette "sky and coral": sky/blue for accents and focus, coral for the main action. */
11646
+ :root {
11647
+ --bg: #f6f8fc;
11648
+ --surface: #ffffff;
11649
+ --surface-2: #eef2f9;
11650
+ --border: #dde3ef;
11651
+ --text: #0f1730;
11652
+ --muted: #55617d;
11653
+ --accent: #1767c2;
11654
+ --accent-strong: #0f4e99;
11655
+ --accent-soft: #dcebfb;
11656
+ --cta: #c2410c;
11657
+ --cta-strong: #9a3412;
11658
+ --on-cta: #ffffff;
11659
+ --ok: #1a7f52;
11660
+ --warn: #8a5a00;
11661
+ --bad: #be123c;
11662
+ --on-bad: #ffffff;
11663
+ --radius: 10px;
11664
+ --shadow: 0 1px 2px rgb(15 23 48 / 6%), 0 1px 3px rgb(15 23 48 / 8%);
11665
+ color-scheme: light;
11666
+ }
11667
+ @media (prefers-color-scheme: dark) {
11668
+ :root {
11669
+ --bg: #0b1124;
11670
+ --surface: #141c33;
11671
+ --surface-2: #1b2540;
11672
+ --border: #2a3555;
11673
+ --text: #e8ecf7;
11674
+ --muted: #9aa6c3;
11675
+ --accent: #3bb8f5;
11676
+ --accent-strong: #7fd0fa;
11677
+ --accent-soft: #12304f;
11678
+ --cta: #ff7a59;
11679
+ --cta-strong: #ff9478;
11680
+ --on-cta: #2a0e05;
11681
+ --ok: #4cd19a;
11682
+ --warn: #f5b84b;
11683
+ --bad: #fb7185;
11684
+ --on-bad: #2b0509;
11685
+ --shadow: none;
11686
+ color-scheme: dark;
11687
+ }
11688
+ }
11689
+ * { box-sizing: border-box; }
11690
+ /* biome-ignore lint/complexity/noImportantStyles: the hidden attribute must beat component display rules */
11691
+ [hidden] { display: none !important; }
11692
+ body {
11693
+ margin: 0;
11694
+ background: var(--bg);
11695
+ color: var(--text);
11696
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
11697
+ }
11698
+ code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
11699
+ header {
11700
+ background: var(--surface);
11701
+ border-bottom: 1px solid var(--border);
11702
+ }
11703
+ .bar {
11704
+ max-width: 1040px;
11705
+ margin: 0 auto;
11706
+ padding: 14px 20px;
11707
+ display: flex;
11708
+ align-items: center;
11709
+ gap: 16px;
11710
+ }
11711
+ .logo { font-weight: 700; font-size: 20px; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }
11712
+ .logo span span { color: var(--cta); }
11713
+ .tagline { color: var(--muted); font-size: 14px; }
11714
+ .spacer { flex: 1; }
11715
+ main { max-width: 1040px; margin: 0 auto; padding: 24px 20px 64px; }
11716
+ h2 { font-size: 15px; margin: 0; }
11717
+ .card {
11718
+ background: var(--surface);
11719
+ border: 1px solid var(--border);
11720
+ border-radius: var(--radius);
11721
+ box-shadow: var(--shadow);
11722
+ margin-bottom: 18px;
11723
+ }
11724
+ .card-head {
11725
+ display: flex;
11726
+ flex-wrap: wrap;
11727
+ align-items: center;
11728
+ gap: 10px 14px;
11729
+ padding: 14px 16px;
11730
+ border-bottom: 1px solid var(--border);
11731
+ }
11732
+ .card-head .meta { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
11733
+ .card-body { padding: 4px 0; }
11734
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; }
11735
+ button {
11736
+ font: inherit;
11737
+ font-size: 14px;
11738
+ border-radius: 8px;
11739
+ border: 1px solid var(--border);
11740
+ background: var(--surface);
11741
+ color: var(--text);
11742
+ padding: 6px 12px;
11743
+ cursor: pointer;
11744
+ }
11745
+ button:hover { background: var(--surface-2); }
11746
+ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
11747
+ button.primary { background: var(--cta); border-color: var(--cta); color: var(--on-cta); font-weight: 600; }
11748
+ button.primary:hover { background: var(--cta-strong); border-color: var(--cta-strong); }
11749
+ button.danger { color: var(--bad); }
11750
+ button.link { border: none; background: none; color: var(--muted); padding: 4px 6px; }
11751
+ button.link:hover { color: var(--bad); }
11752
+ button:disabled { opacity: 0.55; cursor: progress; }
11753
+ input, select {
11754
+ font: inherit;
11755
+ font-size: 14px;
11756
+ color: var(--text);
11757
+ background: var(--surface);
11758
+ border: 1px solid var(--border);
11759
+ border-radius: 8px;
11760
+ padding: 6px 10px;
11761
+ min-width: 0;
11762
+ }
11763
+ /* Fixed columns so every source's table lines up with the others. */
11764
+ table { width: 100%; border-collapse: collapse; table-layout: fixed; }
11765
+ th:nth-child(1) { width: 26%; }
11766
+ th:nth-child(2) { width: 190px; }
11767
+ th:nth-child(4) { width: 56px; }
11768
+ td { overflow-wrap: anywhere; }
11769
+ th {
11770
+ text-align: left;
11771
+ font-size: 12px;
11772
+ text-transform: uppercase;
11773
+ letter-spacing: 0.04em;
11774
+ color: var(--muted);
11775
+ font-weight: 600;
11776
+ padding: 8px 16px;
11777
+ }
11778
+ td { padding: 8px 16px; border-top: 1px solid var(--border); vertical-align: middle; }
11779
+ td.alias { font-weight: 600; }
11780
+ .region { display: flex; gap: 6px; align-items: center; }
11781
+ .region input { width: 140px; }
11782
+ .badge {
11783
+ display: inline-flex;
11784
+ align-items: center;
11785
+ gap: 6px;
11786
+ font-size: 12px;
11787
+ font-weight: 600;
11788
+ padding: 2px 10px;
11789
+ border-radius: 999px;
11790
+ background: var(--surface-2);
11791
+ color: var(--muted);
11792
+ white-space: nowrap;
11793
+ }
11794
+ .badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
11795
+ .badge.valid { color: var(--ok); }
11796
+ .badge.refreshable { color: var(--warn); }
11797
+ .badge.expired { color: var(--bad); }
11798
+ .empty { padding: 14px 16px; color: var(--muted); }
11799
+ .missing { color: var(--bad); }
11800
+ .hero {
11801
+ text-align: center;
11802
+ padding: 48px 20px;
11803
+ }
11804
+ .hero p { color: var(--muted); max-width: 560px; margin: 8px auto 20px; }
11805
+ dialog {
11806
+ border: 1px solid var(--border);
11807
+ border-radius: 14px;
11808
+ padding: 0;
11809
+ width: min(720px, calc(100vw - 32px));
11810
+ max-height: calc(100vh - 48px);
11811
+ background: var(--surface);
11812
+ color: var(--text);
11813
+ box-shadow: 0 20px 60px rgb(0 0 0 / 30%);
11814
+ }
11815
+ dialog::backdrop { background: rgb(10 15 20 / 45%); }
11816
+ .dlg-head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }
11817
+ .dlg-head h2 { font-size: 17px; flex: 1; }
11818
+ .dlg-body { padding: 18px 20px; overflow: auto; max-height: calc(100vh - 200px); overflow-wrap: anywhere; }
11819
+ .dlg-foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
11820
+ .dlg-foot .note { flex: 1; color: var(--muted); font-size: 13px; }
11821
+ .field { display: grid; gap: 4px; margin-bottom: 14px; }
11822
+ .field label { font-weight: 600; font-size: 14px; }
11823
+ .field .help { color: var(--muted); font-size: 13px; }
11824
+ .choices { display: grid; gap: 8px; margin-bottom: 16px; }
11825
+ .choice {
11826
+ display: flex;
11827
+ gap: 10px;
11828
+ align-items: flex-start;
11829
+ border: 1px solid var(--border);
11830
+ border-radius: 10px;
11831
+ padding: 10px 12px;
11832
+ cursor: pointer;
11833
+ }
11834
+ .choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
11835
+ .choice small { display: block; color: var(--muted); }
11836
+ .targets { display: grid; gap: 6px; }
11837
+ .target {
11838
+ display: grid;
11839
+ grid-template-columns: auto 1fr;
11840
+ gap: 6px 10px;
11841
+ align-items: center;
11842
+ border: 1px solid var(--border);
11843
+ border-radius: 10px;
11844
+ padding: 8px 12px;
11845
+ }
11846
+ .target:has(input[type="checkbox"]:checked) { border-color: var(--accent); }
11847
+ .target .pick { display: contents; cursor: pointer; }
11848
+ .target .already { display: block; color: var(--muted); font-size: 13px; }
11849
+ .target .inputs { grid-column: 2; display: none; gap: 8px; flex-wrap: wrap; }
11850
+ .target:has(input[type="checkbox"]:checked) .inputs { display: flex; }
11851
+ .target .inputs input { flex: 1 1 160px; }
11852
+ .waiting { text-align: center; padding: 28px 8px; }
11853
+ .spinner {
11854
+ width: 28px; height: 28px; margin: 0 auto 14px;
11855
+ border: 3px solid var(--border); border-top-color: var(--accent);
11856
+ border-radius: 50%; animation: spin 0.9s linear infinite;
11857
+ }
11858
+ @keyframes spin { to { transform: rotate(360deg); } }
11859
+ #toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 10; }
11860
+ .toast {
11861
+ background: var(--text);
11862
+ color: var(--bg);
11863
+ padding: 10px 14px;
11864
+ border-radius: 10px;
11865
+ max-width: 420px;
11866
+ box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
11867
+ font-size: 14px;
11868
+ }
11869
+ .toast.error { background: var(--bad); color: var(--on-bad); }
11870
+ .toast small { display: block; opacity: 0.85; }
11871
+ .notice {
11872
+ border: 1px solid var(--border);
11873
+ border-left: 4px solid var(--warn);
11874
+ background: var(--surface);
11875
+ border-radius: var(--radius);
11876
+ padding: 10px 14px;
11877
+ margin-bottom: 18px;
11878
+ }
11879
+ .notice.error { border-left-color: var(--bad); }
11880
+ @media (max-width: 640px) {
11881
+ .tagline { display: none; }
11882
+ th:nth-child(3), td:nth-child(3) { display: none; }
11883
+ td, th { padding: 8px 10px; }
11884
+ td.alias { white-space: normal; overflow-wrap: anywhere; }
11885
+ .region input { width: 96px; }
11886
+ th:nth-child(1) { width: auto; }
11887
+ th:nth-child(2) { width: 124px; }
11888
+ }
11889
+ </style>
11890
+ </head>
11891
+ <body>
11892
+ <header>
11893
+ <div class="bar">
11894
+ <div class="logo"><img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22 role=%22img%22 aria-label=%22cloudhop%22%3E%3Cdefs%3E%3ClinearGradient id=%22ch-bg%22 x1=%220%22 y1=%220%22 x2=%221%22 y2=%221%22%3E%3Cstop offset=%220%22 stop-color=%22%233BB8F5%22/%3E%3Cstop offset=%221%22 stop-color=%22%231450A8%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width=%2264%22 height=%2264%22 rx=%2215%22 fill=%22url(%23ch-bg)%22/%3E%3Cpath d=%22M8.5 47.5a5.5 5.5 0 0 1 5-5.5 6.8 6.8 0 0 1 13 1.6 4.4 4.4 0 0 1-.9 8.7H13.4a4.9 4.9 0 0 1-4.9-4.8z%22 fill=%22%23fff%22 fill-opacity=%22.55%22/%3E%3Cpath d=%22M31 44.6a7.6 7.6 0 0 1 7-7.6 9.6 9.6 0 0 1 18.2 2.6 6.3 6.3 0 0 1-1.3 12.5H38.3A7.4 7.4 0 0 1 31 44.6z%22 fill=%22%23fff%22/%3E%3Cpath d=%22M18 37.5C19 21 33 13.5 40.6 23%22 fill=%22none%22 stroke=%22%23fff%22 stroke-width=%223.6%22 stroke-linecap=%22round%22 stroke-dasharray=%220.01 6.3%22/%3E%3Cpath d=%22M46 30.2l-7.4-2.1 5.4-4.3z%22 fill=%22%23fff%22 stroke=%22%23fff%22 stroke-width=%222.4%22 stroke-linejoin=%22round%22/%3E%3C/svg%3E" alt="" width="28" height="28" /><span>cloud<span>hop</span></span></div>
11895
+ <div class="tagline">Hop between cloud accounts, roles and regions</div>
11896
+ <div class="spacer"></div>
11897
+ <button type="button" class="primary" id="add-source" hidden>+ Add source</button>
11898
+ </div>
11899
+ </header>
11900
+ <main id="main" aria-live="polite">
11901
+ <div class="waiting"><div class="spinner"></div>Loading\u2026</div>
11902
+ </main>
11903
+ <dialog id="dialog"></dialog>
11904
+ <div id="toasts" role="status"></div>
11905
+ <script type="module" src="/app.js"></script>
11906
+ </body>
11907
+ </html>
11908
+ `;
11624
11909
 
11625
11910
  // ../ui/src/server.ts
11626
11911
  var MAX_BODY = 64 * 1024;
@@ -12365,7 +12650,7 @@ function createApp(env = process.env) {
12365
12650
  }
12366
12651
 
12367
12652
  // src/cli.ts
12368
- var HELP = `cloudhop ${"0.1.1"}
12653
+ var HELP = `cloudhop ${"1.0.0"}
12369
12654
  Hop between cloud accounts, roles and regions with one command.
12370
12655
 
12371
12656
  Usage
@@ -12411,7 +12696,7 @@ async function main(argv) {
12411
12696
  return 0;
12412
12697
  }
12413
12698
  if (command === "--version" || command === "-v" || command === "version") {
12414
- console.log("0.1.1");
12699
+ console.log("1.0.0");
12415
12700
  return 0;
12416
12701
  }
12417
12702
  const app = createApp();
package/package.json CHANGED
@@ -1,21 +1,25 @@
1
1
  {
2
2
  "name": "cloudhop",
3
- "version": "0.1.1",
4
- "description": "Hop between cloud accounts, roles and regions with one command. Automatic login, no copied credentials.",
3
+ "version": "1.0.0",
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",
7
7
  "aws",
8
+ "aws-cli",
9
+ "aws-sso",
8
10
  "sso",
9
11
  "iam-identity-center",
10
12
  "aws-login",
11
13
  "multi-account",
12
14
  "profiles",
13
15
  "credentials",
14
- "cloud"
16
+ "terraform",
17
+ "cloud",
18
+ "devops"
15
19
  ],
16
20
  "license": "Apache-2.0",
17
21
  "author": "dayepesb",
18
- "homepage": "https://github.com/dayepesb/cloudhop#readme",
22
+ "homepage": "https://dayepesb.github.io/cloudhop/",
19
23
  "repository": {
20
24
  "type": "git",
21
25
  "url": "git+https://github.com/dayepesb/cloudhop.git",
@@ -38,10 +42,10 @@
38
42
  },
39
43
  "devDependencies": {
40
44
  "@clack/prompts": "^1.8.1",
45
+ "@cloudhop/core": "0.0.0",
41
46
  "@cloudhop/adapters-node": "0.0.0",
42
47
  "@cloudhop/provider-aws": "0.0.0",
43
- "@cloudhop/ui": "0.0.0",
44
- "@cloudhop/core": "0.0.0"
48
+ "@cloudhop/ui": "0.0.0"
45
49
  },
46
50
  "scripts": {
47
51
  "build": "tsup",