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.
- package/README.md +95 -49
- package/dist/cli.js +295 -10
- 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="
|
|
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
|
-
|
|
9
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/cloudhop)
|
|
14
16
|
[](https://github.com/dayepesb/cloudhop/actions/workflows/pipeline.yml)
|
|
15
|
-
[](https://github.com/dayepesb/cloudhop/blob/main/LICENSE)
|
|
18
|
+
[](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
|
|
24
|
-
cloudhop setup
|
|
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
|
-
|
|
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
|
-
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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/)
|
|
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
|
-
##
|
|
105
|
+
## Roadmap
|
|
52
106
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
## License
|
|
74
121
|
|
|
75
|
-
|
|
76
|
-
[
|
|
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
|
|
10667
|
-
var
|
|
10668
|
-
var
|
|
10669
|
-
var
|
|
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
|
-
]
|
|
10686
|
+
];
|
|
10687
|
+
var HOP_COLUMN = 30;
|
|
10678
10688
|
function printBanner() {
|
|
10679
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
16
|
+
"terraform",
|
|
17
|
+
"cloud",
|
|
18
|
+
"devops"
|
|
15
19
|
],
|
|
16
20
|
"license": "Apache-2.0",
|
|
17
21
|
"author": "dayepesb",
|
|
18
|
-
"homepage": "https://github.
|
|
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",
|