cloudhop 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli.js +414 -74
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -7452,6 +7452,7 @@ var CloudhopError = class extends Error {
|
|
|
7452
7452
|
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
7453
7453
|
"add",
|
|
7454
7454
|
"allow",
|
|
7455
|
+
"credentials",
|
|
7455
7456
|
"deny",
|
|
7456
7457
|
"agent",
|
|
7457
7458
|
"alias",
|
|
@@ -7571,6 +7572,11 @@ function compareVersions(a2, b3) {
|
|
|
7571
7572
|
}
|
|
7572
7573
|
|
|
7573
7574
|
// ../core/src/application/cloudhop-app.ts
|
|
7575
|
+
var localNotProd = (name) => new CloudhopError(
|
|
7576
|
+
"NOT_SUPPORTED",
|
|
7577
|
+
`${name} is a local emulator: it can't be production.`,
|
|
7578
|
+
"Production protection is for aliases that reach the cloud."
|
|
7579
|
+
);
|
|
7574
7580
|
var loginRequired = (sourceId) => new CloudhopError(
|
|
7575
7581
|
"LOGIN_REQUIRED",
|
|
7576
7582
|
`The session of "${sourceId}" expired and needs a browser sign-in.`,
|
|
@@ -7580,6 +7586,7 @@ var NEXT_BY_STATE = {
|
|
|
7580
7586
|
valid: "ready",
|
|
7581
7587
|
refreshable: "silent-renewal",
|
|
7582
7588
|
expired: "browser-sign-in",
|
|
7589
|
+
offline: "start-emulator",
|
|
7583
7590
|
unknown: "unknown"
|
|
7584
7591
|
};
|
|
7585
7592
|
var CloudhopApp = class {
|
|
@@ -7673,7 +7680,9 @@ var CloudhopApp = class {
|
|
|
7673
7680
|
const config = await this.deps.config.load();
|
|
7674
7681
|
const source = this.findSource(config, sourceId);
|
|
7675
7682
|
const provider = this.provider(source.provider);
|
|
7676
|
-
this.
|
|
7683
|
+
if (!this.isLocal(source)) {
|
|
7684
|
+
this.deps.notify(`Logging in to ${source.id} (${provider.displayName})\u2026`);
|
|
7685
|
+
}
|
|
7677
7686
|
await provider.login(source, {
|
|
7678
7687
|
openBrowser: (url) => this.deps.browser.open(url),
|
|
7679
7688
|
notify: this.deps.notify
|
|
@@ -7692,6 +7701,13 @@ var CloudhopApp = class {
|
|
|
7692
7701
|
const provider = this.provider(source.provider);
|
|
7693
7702
|
const state = await provider.sessionState(source);
|
|
7694
7703
|
if (state === "valid" || state === "unknown") return "none";
|
|
7704
|
+
if (state === "offline") {
|
|
7705
|
+
throw new CloudhopError(
|
|
7706
|
+
"EMULATOR_NOT_RUNNING",
|
|
7707
|
+
`The emulator of ${source.id} isn't running.`,
|
|
7708
|
+
provider.offlineHint?.(source)
|
|
7709
|
+
);
|
|
7710
|
+
}
|
|
7695
7711
|
if (state === "refreshable" && await provider.refreshSession(source)) return "refreshed";
|
|
7696
7712
|
if (!interactive2) throw loginRequired(sourceId);
|
|
7697
7713
|
await this.login(sourceId);
|
|
@@ -7740,6 +7756,7 @@ var CloudhopApp = class {
|
|
|
7740
7756
|
}
|
|
7741
7757
|
names.add(alias.name);
|
|
7742
7758
|
touchedProviders.add(source.provider);
|
|
7759
|
+
if (alias.prod && this.isLocal(source)) throw localNotProd(alias.name);
|
|
7743
7760
|
config.aliases.push(alias);
|
|
7744
7761
|
}
|
|
7745
7762
|
for (const id of touchedProviders) await this.syncProfiles(id, config);
|
|
@@ -7788,6 +7805,8 @@ var CloudhopApp = class {
|
|
|
7788
7805
|
region: resolved.region,
|
|
7789
7806
|
attributes: resolved.target?.attributes ?? {},
|
|
7790
7807
|
prod: alias.prod === true,
|
|
7808
|
+
local: this.isLocal(resolved.source),
|
|
7809
|
+
default: alias.default === true,
|
|
7791
7810
|
session: source.session,
|
|
7792
7811
|
next: source.next
|
|
7793
7812
|
};
|
|
@@ -7827,11 +7846,44 @@ var CloudhopApp = class {
|
|
|
7827
7846
|
const config = await this.deps.config.load();
|
|
7828
7847
|
for (const name of names) {
|
|
7829
7848
|
const alias = this.findAlias(config, name);
|
|
7849
|
+
if (prod && this.isLocal(this.findSource(config, alias.source))) throw localNotProd(name);
|
|
7830
7850
|
if (prod) alias.prod = true;
|
|
7831
7851
|
else delete alias.prod;
|
|
7832
7852
|
}
|
|
7833
7853
|
await this.deps.config.save(config);
|
|
7834
7854
|
}
|
|
7855
|
+
/**
|
|
7856
|
+
* Makes an alias the one new terminals of its cloud start with, replacing
|
|
7857
|
+
* that cloud's previous default. Returns the cloud.
|
|
7858
|
+
*/
|
|
7859
|
+
async setDefaultAlias(name) {
|
|
7860
|
+
const config = await this.deps.config.load();
|
|
7861
|
+
const alias = this.findAlias(config, name);
|
|
7862
|
+
const provider = this.findSource(config, alias.source).provider;
|
|
7863
|
+
for (const other of config.aliases) {
|
|
7864
|
+
if (this.findSource(config, other.source).provider === provider) delete other.default;
|
|
7865
|
+
}
|
|
7866
|
+
alias.default = true;
|
|
7867
|
+
await this.deps.config.save(config);
|
|
7868
|
+
return provider;
|
|
7869
|
+
}
|
|
7870
|
+
/** Clears the default alias of a cloud, or of every cloud. Returns the aliases it cleared. */
|
|
7871
|
+
async clearDefaultAlias(provider) {
|
|
7872
|
+
const config = await this.deps.config.load();
|
|
7873
|
+
const cleared = config.aliases.filter(
|
|
7874
|
+
(a2) => a2.default && (!provider || this.findSource(config, a2.source).provider === provider)
|
|
7875
|
+
);
|
|
7876
|
+
for (const alias of cleared) delete alias.default;
|
|
7877
|
+
if (cleared.length) await this.deps.config.save(config);
|
|
7878
|
+
return cleared.map((a2) => a2.name);
|
|
7879
|
+
}
|
|
7880
|
+
/** The alias new terminals start with, per cloud. */
|
|
7881
|
+
async defaultAliases() {
|
|
7882
|
+
const config = await this.deps.config.load();
|
|
7883
|
+
return Object.fromEntries(
|
|
7884
|
+
config.aliases.filter((a2) => a2.default).map((a2) => [this.findSource(config, a2.source).provider, a2.name])
|
|
7885
|
+
);
|
|
7886
|
+
}
|
|
7835
7887
|
/** Changes an alias' region; matching the source's region clears the override. */
|
|
7836
7888
|
async setAliasRegion(name, region) {
|
|
7837
7889
|
const config = await this.deps.config.load();
|
|
@@ -7896,6 +7948,13 @@ ${result.stderr}`)) {
|
|
|
7896
7948
|
} = {}) {
|
|
7897
7949
|
const resolved = await this.usableAlias(name, region ? { region } : {});
|
|
7898
7950
|
const provider = this.provider(resolved.source.provider);
|
|
7951
|
+
if (this.isLocal(resolved.source)) {
|
|
7952
|
+
throw new CloudhopError(
|
|
7953
|
+
"NOT_SUPPORTED",
|
|
7954
|
+
`${name} is a local emulator: it has no web console.`,
|
|
7955
|
+
`Look at its resources with \`hop ${name} <command>\`.`
|
|
7956
|
+
);
|
|
7957
|
+
}
|
|
7899
7958
|
if (!provider.consoleUrl) {
|
|
7900
7959
|
throw new CloudhopError(
|
|
7901
7960
|
"PROVIDER_NOT_AVAILABLE",
|
|
@@ -7921,6 +7980,23 @@ ${result.stderr}`)) {
|
|
|
7921
7980
|
await this.deps.browser.open(url);
|
|
7922
7981
|
return via;
|
|
7923
7982
|
}
|
|
7983
|
+
/**
|
|
7984
|
+
* The keys of a local emulator alias, for the `credential_process` of its
|
|
7985
|
+
* profile. Never for cloud aliases: their credentials stay with the CLI.
|
|
7986
|
+
*/
|
|
7987
|
+
async localCredentials(name) {
|
|
7988
|
+
const resolved = await this.resolveAlias(name);
|
|
7989
|
+
const provider = this.provider(resolved.source.provider);
|
|
7990
|
+
if (!this.isLocal(resolved.source) || !provider.localCredentials) {
|
|
7991
|
+
throw new CloudhopError("NOT_SUPPORTED", `${name} is not a local emulator alias.`);
|
|
7992
|
+
}
|
|
7993
|
+
return provider.localCredentials(resolved);
|
|
7994
|
+
}
|
|
7995
|
+
/** Whether a source is an emulator on this machine. */
|
|
7996
|
+
isLocal(source) {
|
|
7997
|
+
const provider = this.deps.providers.find((p3) => p3.id === source.provider);
|
|
7998
|
+
return provider?.sourceKinds.find((k) => k.kind === source.kind)?.local === true;
|
|
7999
|
+
}
|
|
7924
8000
|
/** The command that shows who an alias is, e.g. `aws sts get-caller-identity`. */
|
|
7925
8001
|
identityArgs(name) {
|
|
7926
8002
|
return this.resolveAlias(name).then((r2) => this.provider(r2.source.provider).identityArgs);
|
|
@@ -8443,7 +8519,7 @@ function createTools(app, context) {
|
|
|
8443
8519
|
{
|
|
8444
8520
|
name: "list_aliases",
|
|
8445
8521
|
title: "List cloudhop aliases",
|
|
8446
|
-
description: "Lists every cloudhop source (SSO portal or
|
|
8522
|
+
description: "Lists every cloudhop source (SSO portal, personal account or local emulator) with its session state, and every alias: name, account/role label, region and whether a sign-in is needed. Also hints: `folder` (the aliases this project pins in its .cloudhoprc, and whether the user trusted it) and `inUse` (aliases the user switched their terminal to); `local: true` marks an alias of an emulator on this machine; `default: true` marks the alias new terminals of the user start with. Call this first, then ask the user which alias to work with, offering the hinted one first. Never contains secrets.",
|
|
8447
8523
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
8448
8524
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
8449
8525
|
run: async () => ({ ...await app.overview(), ...await context?.() })
|
|
@@ -8451,7 +8527,7 @@ function createTools(app, context) {
|
|
|
8451
8527
|
{
|
|
8452
8528
|
name: "alias_status",
|
|
8453
8529
|
title: "Check an alias",
|
|
8454
|
-
description: 'Shows which account, role and region an alias points to and whether it can run now. `next` is "ready", "silent-renewal" (runs without asking), "browser-sign-in" (call login first, or ask the user to run `cloudhop login <source>`) or "unknown".',
|
|
8530
|
+
description: 'Shows which account, role and region an alias points to and whether it can run now. `next` is "ready", "silent-renewal" (runs without asking), "browser-sign-in" (call login first, or ask the user to run `cloudhop login <source>`), "start-emulator" (a local emulator that is not running: ask the user to start it) or "unknown".',
|
|
8455
8531
|
inputSchema: {
|
|
8456
8532
|
type: "object",
|
|
8457
8533
|
properties: aliasInput,
|
|
@@ -8606,10 +8682,11 @@ function createTools(app, context) {
|
|
|
8606
8682
|
|
|
8607
8683
|
// ../mcp/src/server.ts
|
|
8608
8684
|
var MCP_INSTRUCTIONS = `cloudhop gives each cloud account + role + region a short alias and signs the user in only when a session expired.
|
|
8609
|
-
Before the first cloud operation in a conversation: call list_aliases, show the user the aliases (name, account/role, region) and ask which one to use. Never guess the alias. If list_aliases has folder.aliases (the project's .cloudhoprc) or
|
|
8685
|
+
Before the first cloud operation in a conversation: call list_aliases, show the user the aliases (name, account/role, region) and ask which one to use. Never guess the alias. If list_aliases has folder.aliases (the project's .cloudhoprc), inUse or an alias with default: true, offer that alias first, but still ask.
|
|
8610
8686
|
Use run_cloud_cli for cloud CLI commands, never --profile. Read-only commands may run once the alias is chosen; before anything that creates, changes or deletes resources, show the alias, account, region and exact command, and wait for the user's explicit OK. Aliases with prod: true are production: run_cloud_cli refuses changes there until the user approves and you pass confirm_production: true.
|
|
8611
8687
|
To show the user something in the web console, call open_console (it opens their browser, already signed in).
|
|
8612
|
-
If a tool returns LOGIN_REQUIRED or alias_status says browser-sign-in: tell the user their browser will open, then call login (it waits up to 5 minutes for them to approve). Never print or ask for credentials
|
|
8688
|
+
If a tool returns LOGIN_REQUIRED or alias_status says browser-sign-in: tell the user their browser will open, then call login (it waits up to 5 minutes for them to approve). Never print or ask for credentials.
|
|
8689
|
+
Aliases with local: true point to an emulator on the user's machine (AWS Emulator, LocalStack), never to the cloud: account 000000000000, no web console, never production. If a tool returns EMULATOR_NOT_RUNNING or alias_status says start-emulator, tell the user to start their emulator (the hint says how); don't call login.`;
|
|
8613
8690
|
var SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
8614
8691
|
function serveMcp({
|
|
8615
8692
|
app,
|
|
@@ -8721,7 +8798,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
|
8721
8798
|
"prod",
|
|
8722
8799
|
"off",
|
|
8723
8800
|
"save",
|
|
8724
|
-
"leave"
|
|
8801
|
+
"leave",
|
|
8802
|
+
"default",
|
|
8803
|
+
"noDefault"
|
|
8725
8804
|
]);
|
|
8726
8805
|
var camel = (name) => name.replace(/-([a-z])/g, (_2, c3) => c3.toUpperCase());
|
|
8727
8806
|
function parseArgs(argv) {
|
|
@@ -11612,7 +11691,8 @@ var validator = (fn) => (value) => {
|
|
|
11612
11691
|
// src/commands/add.ts
|
|
11613
11692
|
function suggestSourceId(settings2, kind) {
|
|
11614
11693
|
const host = settings2.startUrl?.match(/^https:\/\/([^./]+)\./)?.[1];
|
|
11615
|
-
|
|
11694
|
+
const fallback = kind === "console-login" ? "personal" : kind === "aws-emulator" ? "local" : kind === "localstack" ? "localstack" : "main";
|
|
11695
|
+
return suggestAliasName(host ?? fallback);
|
|
11616
11696
|
}
|
|
11617
11697
|
async function pickProvider(app) {
|
|
11618
11698
|
const { providers } = await app.config();
|
|
@@ -11681,11 +11761,12 @@ async function chooseAliases(app, source, targets) {
|
|
|
11681
11761
|
const validateRegion2 = regionValidator(app, source);
|
|
11682
11762
|
let defaultRegion = source.settings.region ?? "";
|
|
11683
11763
|
const suggestions = suggestAliases(targets, source.id, existing);
|
|
11764
|
+
const local = app.isLocal(source);
|
|
11684
11765
|
const aliases = [];
|
|
11685
11766
|
for (const target of selected) {
|
|
11686
11767
|
dist_exports.log.step(target.label);
|
|
11687
11768
|
const suggestion = uniqueName(
|
|
11688
|
-
targets.length === 1 && target.id === "console" ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
|
|
11769
|
+
targets.length === 1 && (target.id === "console" || local) ? source.id : suggestions.get(target.id) ?? suggestAliasName(target.label),
|
|
11689
11770
|
existing
|
|
11690
11771
|
);
|
|
11691
11772
|
const name = answer(
|
|
@@ -11704,7 +11785,7 @@ async function chooseAliases(app, source, targets) {
|
|
|
11704
11785
|
validate: (v) => v?.trim() ? validateRegion2?.(v.trim()) : void 0
|
|
11705
11786
|
})
|
|
11706
11787
|
).trim() || defaultRegion;
|
|
11707
|
-
const prod = answer(
|
|
11788
|
+
const prod = !local && answer(
|
|
11708
11789
|
await dist_exports.confirm({
|
|
11709
11790
|
message: `Is ${name} production? (cloudhop then asks before any change)`,
|
|
11710
11791
|
initialValue: looksLikeProduction(name, target.label)
|
|
@@ -11765,6 +11846,14 @@ async function addCommand(app, args) {
|
|
|
11765
11846
|
if (args.positional.length > 0) {
|
|
11766
11847
|
const source2 = sourceFromFlags(app, args);
|
|
11767
11848
|
const report2 = await app.connectSource(source2);
|
|
11849
|
+
const target = report2.targets[0];
|
|
11850
|
+
if (app.isLocal(source2) && target) {
|
|
11851
|
+
const taken = new Set((await app.config()).aliases.map((a2) => a2.name));
|
|
11852
|
+
const name = uniqueName(args.flags.alias ?? source2.id, taken);
|
|
11853
|
+
await app.addAliases([{ name, source: source2.id, target: target.id }]);
|
|
11854
|
+
console.log(`Emulator "${source2.id}" added as alias "${name}". Try: hop ${name} s3 ls`);
|
|
11855
|
+
return;
|
|
11856
|
+
}
|
|
11768
11857
|
console.log(`Source "${source2.id}" added. ${report2.targets.length} account/role(s) available:`);
|
|
11769
11858
|
printTargets(source2.id, report2.targets);
|
|
11770
11859
|
return;
|
|
@@ -11774,12 +11863,13 @@ async function addCommand(app, args) {
|
|
|
11774
11863
|
const provider = await pickProvider(app);
|
|
11775
11864
|
const source = await askSource(app, provider);
|
|
11776
11865
|
const report = await app.connectSource(source);
|
|
11777
|
-
dist_exports.log.success(
|
|
11866
|
+
if (app.isLocal(source)) dist_exports.log.success("Emulator added. Now give it an alias.");
|
|
11867
|
+
else dist_exports.log.success(`Logged in. Found ${report.targets.length} account/role combination(s).`);
|
|
11778
11868
|
const aliases = await chooseAliases(app, source, report.targets);
|
|
11779
11869
|
await app.addAliases(aliases);
|
|
11780
11870
|
const first = aliases[0]?.name;
|
|
11781
11871
|
dist_exports.outro(
|
|
11782
|
-
first ? `Ready! Try: hop ${first} sts get-caller-identity` : `Source "${source.id}" saved. Create aliases later with \`cloudhop alias ${source.id}\`.`
|
|
11872
|
+
first ? app.isLocal(source) ? `Ready! Try: hop ${first} s3 ls (or \`cloudhop use ${first}\`, then plain aws)` : `Ready! Try: hop ${first} sts get-caller-identity` : `Source "${source.id}" saved. Create aliases later with \`cloudhop alias ${source.id}\`.`
|
|
11783
11873
|
);
|
|
11784
11874
|
}
|
|
11785
11875
|
async function aliasCommand(app, { positional, flags }) {
|
|
@@ -11797,7 +11887,7 @@ async function aliasCommand(app, { positional, flags }) {
|
|
|
11797
11887
|
);
|
|
11798
11888
|
const source = sources.find((s) => s.id === id);
|
|
11799
11889
|
if (!source) throw new CloudhopError("SOURCE_NOT_FOUND", `Source "${id}" not found.`);
|
|
11800
|
-
await app.ensureSession(id);
|
|
11890
|
+
if (!app.isLocal(source)) await app.ensureSession(id);
|
|
11801
11891
|
let targets = await app.listTargets(id);
|
|
11802
11892
|
if (targets.length === 0) targets = (await app.syncSource(id)).targets;
|
|
11803
11893
|
if (flags.target) {
|
|
@@ -11846,7 +11936,7 @@ import { mkdir as mkdir3, readFile as readFile3, rename as rename2, rm as rm2, r
|
|
|
11846
11936
|
import { dirname as dirname3, join as join5 } from "path";
|
|
11847
11937
|
|
|
11848
11938
|
// ../assistants/src/assets/skill.md
|
|
11849
|
-
var skill_default = "---\nname: cloudhop\ndescription: Run AWS CLI commands and other cloud tools as a specific account, role and region through cloudhop aliases (the `hop` and `cloudhop` commands). Use whenever the user asks to do anything in their AWS accounts, inspect or change cloud resources, run aws/terraform/cdk against an account, list their cloud accounts or aliases, or mentions hop or cloudhop.\n---\n\n# Working with cloud accounts through cloudhop\n\nThe user manages their cloud access with **cloudhop**. Each **alias** (for\nexample `acme-dev`) points to one account, one role and one region. cloudhop\nsigns the user in through their browser only when a session has expired. There\nare no access keys: never ask for, create, print or store credentials.\n\n## How the user works (to guide them)\n\nWhen the user asks how to set up or use cloudhop themselves, describe this\npath; these are commands for **them** to run in their own terminal:\n\n1. **Once**: `npm install --global cloudhop`, then `cloudhop add` (signs in\n to an SSO portal or personal account and names each account/role as an\n alias), then `cloudhop shell` (adds one line to their shell's startup\n file and, with Starship, the alias segment to `starship.toml`; they open\n a new terminal afterwards). `cloudhop setup` does all of it in one\n guided run. Works in zsh, bash (Git Bash too), fish and PowerShell;\n in Windows `cmd.exe`, `cloudhop use` opens a new `cmd` with the alias in\n its prompt.\n2. **Every day**: `cloudhop use <alias>` switches that terminal to an\n account; after that they type plain `aws ...`, Terraform\n or SDK commands. `cloudhop use <other>` switches accounts and\n `cloudhop use --off` goes back. Expired sessions sign in by themselves,\n and on production aliases `aws` asks before changes. Their prompt shows\n the alias in use, in red for production, and `cloudhop status` tells\n them which alias their terminal uses.\n3. **Per project**: `cloudhop use <alias> --save` writes a `.cloudhoprc` in\n the folder, so `cd` into it switches the terminal; teammates run\n `cloudhop allow` once after cloning (and again after the file changes);\n `cloudhop deny` withdraws it. A file does nothing until it is trusted.\n4. **One-offs**: `hop <alias> <aws args>` runs one command in another\n account without switching; `cloudhop each` runs it in many;\n `cloudhop console <alias> [service]` opens the web console signed in.\n\nWhy `cloudhop shell` is needed: a program can't change the environment of\nthe terminal that started it, only the shell can.\nWithout it, `cloudhop use` opens a new shell instead; `hop` always works.\n\nSwitches, for the user: `CLOUDHOP_WRAP=0` keeps `aws` as the plain AWS\nCLI, `CLOUDHOP_AUTO_USE=0` ignores `.cloudhoprc` files, `CLOUDHOP_PROMPT=0`\nhides the prompt label. `cloudhop shell starship` prints the Starship\nsegment for people who place it by hand.\n\nWhen their terminal doesn't do what they expect:\n\n| They say | Suggest |\n| --- | --- |\n| \"Which account am I in?\" | `cloudhop status` (first line) |\n| `cloudhop use` opened a new shell / says it needs the integration | `cloudhop shell`, then a new terminal |\n| No label in the prompt | Run `cloudhop shell` again (it adds what's missing), then a new terminal |\n| `cd` into the project doesn't switch | `cloudhop allow` in that folder; check the alias name exists (`cloudhop ls`) |\n| `aws` runs in the wrong account | `cloudhop status`, then `cloudhop use <alias>` or `cloudhop use --off` |\n\nThat is the user's way. **You** work differently, below: you always name the\nalias with `hop <alias>`, because your shell doesn't have their integration.\n\n## 1. Pick the alias with the user, every time\n\nBefore the first cloud operation in a conversation:\n\n1. List the aliases. Use the `list_aliases` tool if the cloudhop MCP server is\n connected; otherwise run `cloudhop ls --json`.\n2. Show the user the options (alias, account / role, region) and **ask which\n one to use**. Don't guess, even if one name looks right, and don't pick\n one just because there is only one: confirm it.\n - The listing may carry hints: `folder.aliases` is what this project pins\n in its `.cloudhoprc` (`folder.trusted` says whether the user approved\n it), and `inUse` is what the user switched their terminal to with\n `cloudhop use` (`cloudhop status --json` has `inUse` too). Offer that\n alias first (\"This project uses\n `license-qa`, use it?\"), but still wait for the answer.\n3. Use that alias for the rest of the conversation. Ask again if the user\n moves to another environment, account or task.\n\n## 2. Check the session before running anything\n\nRun `cloudhop status <alias> --json` (or the `alias_status` tool) and read\n`next`:\n\n| `next` | Meaning | What to do |\n| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ready` | Session valid | Go ahead |\n| `silent-renewal` | Expired but renewable without the user | Go ahead; cloudhop renews it |\n| `browser-sign-in` | The user must sign in | Tell the user their browser will open and they need to approve, then run `cloudhop login <source>` (or the `login` tool) and wait up to 5 minutes |\n| `unknown` | Managed by the cloud CLI (personal accounts) | Go ahead; if it fails, run `cloudhop login <source>` |\n\nDon't start several cloud commands in parallel while a sign-in is pending.\n\n## 3. Run commands through the alias\n\n- **AWS CLI**: `hop <alias> <aws arguments>`, e.g. `hop acme-dev s3 ls`.\n It is exactly `aws <arguments>` with the alias' account, role and region.\n Never add `--profile`. Use `--region <r>` only to override the region once.\n With the MCP server, use `run_cloud_cli` with `args` (no program name).\n- **Who am I**: `hop <alias>` with no arguments (or the `whoami` tool).\n- **The same command in several aliases** (after the user chose them):\n `cloudhop each a,b,c --yes -- <aws arguments>`; add `--json` to parse the\n results.\n- **The AWS web console**: `cloudhop console <alias> [service]` (or the\n `open_console` tool) opens it in the user's browser, already signed in.\n Never try to print or fetch its URL.\n- **Always name the alias**, even when the project has a `.cloudhoprc` or\n the user's terminal uses an alias: your shell doesn't have the user's\n cloudhop integration, so a plain `aws` may run as anything. Use\n `hop <alias> ...`, never bare `aws`.\n- Never run the interactive forms meant for a person at a terminal: `hop` or\n `hop -` without an alias, `cloudhop console` without an alias,\n `cloudhop use` (it changes or opens an interactive shell), `cloudhop\n allow`/`deny` (trusting a folder is the user's decision), `cloudhop setup`,\n or `cloudhop add`/`alias` without flags. Nobody can answer their prompts in\n your terminal.\n- **Other tools** (Terraform, CDK, SDK scripts):\n `cloudhop exec <alias> -- <command>`, e.g.\n `cloudhop exec acme-dev -- terraform plan`.\n- Prefer `--output json` and `--query` to keep output small.\n- Don't export `AWS_PROFILE`, `AWS_ACCESS_KEY_ID` or similar variables, don't\n run commands that print credentials (`env`, `printenv`, `export-credentials`)\n and never edit `~/.aws/config`, `~/.aws/credentials` or cloudhop's own files.\n\n## 4. Confirm before changing anything\n\n- Read-only commands (`list`, `describe`, `get`, `s3 ls`, `logs tail`) can run\n once the user has chosen the alias.\n- Before any command that **creates, changes, deletes or costs money**, show\n the alias, account, region and the exact command, and wait for the user's\n explicit \"yes\". One confirmation covers one command, not a series.\n- **Production aliases** have `\"prod\": true` in `cloudhop ls --json` (and\n `PROD` in `cloudhop ls`). Confirm every change there and suggest a dry run\n (`--dryrun`, `terraform plan`) first. cloudhop enforces it: without a\n terminal, `hop`/`cloudhop exec` refuse changes on production with\n `PROD_CONFIRMATION_REQUIRED`. After the user explicitly approves the exact\n command, rerun it with `CLOUDHOP_CONFIRM_PROD=1` in that command's\n environment (with MCP: `confirm_production: true`). Never set it on your own.\n- Treat aliases or accounts that merely look like production (`prd`, `live`)\n the same way, even if they are not marked.\n- Don't change cloudhop itself (`cloudhop add`, `rm`, `mv`, `region`, `prod`,\n `team import`, `agent`, `login` for another source) or write a\n `.cloudhoprc` unless the user asked for it. Never unmark a production\n alias to get around a confirmation.\n\n## 5. When something goes wrong\n\n| You see | Do this |\n| --------------------------------------------------- | ------------------------------------------------------------------------------ |\n| \"Opening your browser to log in\" | Tell the user to approve in the browser; the command continues by itself |\n| The command was cut off while waiting for a sign-in | Run `cloudhop login <source>` with a timeout of at least 5 minutes, then retry |\n| `LOGIN_REQUIRED` | Same as `browser-sign-in` above |\n| No browser on this machine (remote, container) | Run `cloudhop login <source> --device-code` and show the user the URL and code |\n| `Alias \"x\" not found` | Run `cloudhop ls` and ask the user again |\n| AWS CLI missing or too old | Suggest `cloudhop doctor`; don't install anything without asking |\n| `AccessDenied` | The role lacks permission: tell the user; don't try other aliases on your own |\n\nMore: https://dayepesb.github.io/cloudhop/guides/hop-like-aws/\n";
|
|
11939
|
+
var skill_default = "---\nname: cloudhop\ndescription: Run AWS CLI commands and other cloud tools as a specific account, role and region through cloudhop aliases (the `hop` and `cloudhop` commands). Use whenever the user asks to do anything in their AWS accounts, inspect or change cloud resources, run aws/terraform/cdk against an account, list their cloud accounts or aliases, or mentions hop or cloudhop.\n---\n\n# Working with cloud accounts through cloudhop\n\nThe user manages their cloud access with **cloudhop**. Each **alias** (for\nexample `acme-dev`) points to one account, one role and one region. cloudhop\nsigns the user in through their browser only when a session has expired. There\nare no access keys: never ask for, create, print or store credentials.\n\n## How the user works (to guide them)\n\nWhen the user asks how to set up or use cloudhop themselves, describe this\npath; these are commands for **them** to run in their own terminal:\n\n1. **Once**: `npm install --global cloudhop`, then `cloudhop add` (signs in\n to an SSO portal or personal account and names each account/role as an\n alias), then `cloudhop shell` (adds one line to their shell's startup\n file and, with Starship, the alias segment to `starship.toml`; they open\n a new terminal afterwards). `cloudhop setup` does all of it in one\n guided run. Works in zsh, bash (Git Bash too), fish and PowerShell;\n in Windows `cmd.exe`, `cloudhop use` opens a new `cmd` with the alias in\n its prompt.\n2. **Every day**: `cloudhop use <alias>` switches that terminal to an\n account; after that they type plain `aws ...`, Terraform\n or SDK commands. `cloudhop use <other>` switches accounts and\n `cloudhop use --off` goes back. Expired sessions sign in by themselves,\n and on production aliases `aws` asks before changes. Their prompt shows\n the alias in use, in red for production, and `cloudhop status` tells\n them which alias their terminal uses. `cloudhop use` is per terminal: a\n new terminal starts with no alias unless they choose a default with\n `cloudhop use <alias> --default` (or the **Default** box in\n `cloudhop ui`); `cloudhop use --no-default` stops it. A production\n default needs `--yes`. A `cloudhop use` typed in a terminal wins over\n the folder's `.cloudhoprc`, which wins over the default.\n3. **Per project**: `cloudhop use <alias> --save` writes a `.cloudhoprc` in\n the folder, so `cd` into it switches the terminal; teammates run\n `cloudhop allow` once after cloning (and again after the file changes);\n `cloudhop deny` withdraws it. A file does nothing until it is trusted.\n4. **One-offs**: `hop <alias> <aws args>` runs one command in another\n account without switching; `cloudhop each` runs it in many;\n `cloudhop console <alias> [service]` opens the web console signed in.\n5. **Local emulators**: `cloudhop add` \u2192 \"AWS Emulator (local)\" or\n \"LocalStack or another local emulator\" (or\n `cloudhop add aws aws-emulator --name local`) gives the emulator an\n alias. Then `cloudhop use local` + plain `aws`, `hop local ...`, a\n `.cloudhoprc` with `aws: local` for tests, or\n `cloudhop exec local -- npm test`. It never reaches AWS. The user starts\n the emulator themselves; cloudhop says so when it isn't running.\n\nWhy `cloudhop shell` is needed: a program can't change the environment of\nthe terminal that started it, only the shell can.\nWithout it, `cloudhop use` opens a new shell instead; `hop` always works.\n\nSwitches, for the user: `CLOUDHOP_WRAP=0` keeps `aws` as the plain AWS\nCLI, `CLOUDHOP_AUTO_USE=0` ignores `.cloudhoprc` files, `CLOUDHOP_PROMPT=0`\nhides the prompt label. `cloudhop shell starship` prints the Starship\nsegment for people who place it by hand.\n\nWhen their terminal doesn't do what they expect:\n\n| They say | Suggest |\n| --- | --- |\n| \"Which account am I in?\" | `cloudhop status` (first line) |\n| `cloudhop use` opened a new shell / says it needs the integration | `cloudhop shell`, then a new terminal |\n| No label in the prompt | Run `cloudhop shell` again (it adds what's missing), then a new terminal |\n| `cd` into the project doesn't switch | `cloudhop allow` in that folder; check the alias name exists (`cloudhop ls`) |\n| `aws` runs in the wrong account | `cloudhop status`, then `cloudhop use <alias>` or `cloudhop use --off` |\n| A new terminal forgot the `cloudhop use` | It's per terminal by design: `cloudhop use <alias> --default` makes every new terminal start with it |\n\nThat is the user's way. **You** work differently, below: you always name the\nalias with `hop <alias>`, because your shell doesn't have their integration.\n\n## 1. Pick the alias with the user, every time\n\nBefore the first cloud operation in a conversation:\n\n1. List the aliases. Use the `list_aliases` tool if the cloudhop MCP server is\n connected; otherwise run `cloudhop ls --json`.\n2. Show the user the options (alias, account / role, region) and **ask which\n one to use**. Don't guess, even if one name looks right, and don't pick\n one just because there is only one: confirm it.\n - The listing may carry hints: `folder.aliases` is what this project pins\n in its `.cloudhoprc` (`folder.trusted` says whether the user approved\n it), and `inUse` is what the user switched their terminal to with\n `cloudhop use` (`cloudhop status --json` has `inUse` too); an alias\n with `default: true` is the one their new terminals start with. Offer that\n alias first (\"This project uses\n `license-qa`, use it?\"), but still wait for the answer.\n3. Use that alias for the rest of the conversation. Ask again if the user\n moves to another environment, account or task.\n\n**Local emulators.** An alias with `local: true` (in `cloudhop ls --json`,\n`list_aliases` or `status`) points to AWS Emulator or LocalStack on the\nuser's machine: account `000000000000`, never the cloud. Use it like any\nalias (`hop local s3 ls`). It has no web console and is never production,\nso changes there don't need the production confirmation, but still say\nwhat you will create or delete. Never run a command meant for the\nemulator with a cloud alias, or the other way round: if the user said\n\"local\" or \"the emulator\", the alias must have `local: true`.\n\n## 2. Check the session before running anything\n\nRun `cloudhop status <alias> --json` (or the `alias_status` tool) and read\n`next`:\n\n| `next` | Meaning | What to do |\n| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ready` | Session valid | Go ahead |\n| `silent-renewal` | Expired but renewable without the user | Go ahead; cloudhop renews it |\n| `browser-sign-in` | The user must sign in | Tell the user their browser will open and they need to approve, then run `cloudhop login <source>` (or the `login` tool) and wait up to 5 minutes |\n| `start-emulator` | A local emulator that isn't running | Tell the user to start it (the error's hint says how); never call `login` for it |\n| `unknown` | Managed by the cloud CLI (personal accounts) | Go ahead; if it fails, run `cloudhop login <source>` |\n\nDon't start several cloud commands in parallel while a sign-in is pending.\n\n## 3. Run commands through the alias\n\n- **AWS CLI**: `hop <alias> <aws arguments>`, e.g. `hop acme-dev s3 ls`.\n It is exactly `aws <arguments>` with the alias' account, role and region.\n Never add `--profile`. Use `--region <r>` only to override the region once.\n With the MCP server, use `run_cloud_cli` with `args` (no program name).\n- **Who am I**: `hop <alias>` with no arguments (or the `whoami` tool).\n- **The same command in several aliases** (after the user chose them):\n `cloudhop each a,b,c --yes -- <aws arguments>`; add `--json` to parse the\n results.\n- **The AWS web console**: `cloudhop console <alias> [service]` (or the\n `open_console` tool) opens it in the user's browser, already signed in.\n Never try to print or fetch its URL.\n- **Always name the alias**, even when the project has a `.cloudhoprc` or\n the user's terminal uses an alias: your shell doesn't have the user's\n cloudhop integration, so a plain `aws` may run as anything. Use\n `hop <alias> ...`, never bare `aws`.\n- Never run the interactive forms meant for a person at a terminal: `hop` or\n `hop -` without an alias, `cloudhop console` without an alias,\n `cloudhop use` (it changes or opens an interactive shell), `cloudhop\n allow`/`deny` (trusting a folder is the user's decision), `cloudhop setup`,\n or `cloudhop add`/`alias` without flags. Nobody can answer their prompts in\n your terminal.\n- **Other tools** (Terraform, CDK, SDK scripts):\n `cloudhop exec <alias> -- <command>`, e.g.\n `cloudhop exec acme-dev -- terraform plan`.\n- Prefer `--output json` and `--query` to keep output small.\n- Don't export `AWS_PROFILE`, `AWS_ACCESS_KEY_ID` or similar variables, don't\n run commands that print credentials (`env`, `printenv`, `export-credentials`)\n and never edit `~/.aws/config`, `~/.aws/credentials` or cloudhop's own files.\n\n## 4. Confirm before changing anything\n\n- Read-only commands (`list`, `describe`, `get`, `s3 ls`, `logs tail`) can run\n once the user has chosen the alias.\n- Before any command that **creates, changes, deletes or costs money**, show\n the alias, account, region and the exact command, and wait for the user's\n explicit \"yes\". One confirmation covers one command, not a series.\n- **Production aliases** have `\"prod\": true` in `cloudhop ls --json` (and\n `PROD` in `cloudhop ls`). Confirm every change there and suggest a dry run\n (`--dryrun`, `terraform plan`) first. cloudhop enforces it: without a\n terminal, `hop`/`cloudhop exec` refuse changes on production with\n `PROD_CONFIRMATION_REQUIRED`. After the user explicitly approves the exact\n command, rerun it with `CLOUDHOP_CONFIRM_PROD=1` in that command's\n environment (with MCP: `confirm_production: true`). Never set it on your own.\n- Treat aliases or accounts that merely look like production (`prd`, `live`)\n the same way, even if they are not marked.\n- Don't change cloudhop itself (`cloudhop add`, `rm`, `mv`, `region`, `prod`,\n `team import`, `agent`, `login` for another source) or write a\n `.cloudhoprc` or change their default alias (`use --default`,\n `--no-default`) unless the user asked for it. Never unmark a production\n alias to get around a confirmation.\n\n## 5. When something goes wrong\n\n| You see | Do this |\n| --------------------------------------------------- | ------------------------------------------------------------------------------ |\n| \"Opening your browser to log in\" | Tell the user to approve in the browser; the command continues by itself |\n| The command was cut off while waiting for a sign-in | Run `cloudhop login <source>` with a timeout of at least 5 minutes, then retry |\n| `LOGIN_REQUIRED` | Same as `browser-sign-in` above |\n| No browser on this machine (remote, container) | Run `cloudhop login <source> --device-code` and show the user the URL and code |\n| `Alias \"x\" not found` | Run `cloudhop ls` and ask the user again |\n| AWS CLI missing or too old | Suggest `cloudhop doctor`; don't install anything without asking |\n| `AccessDenied` | The role lacks permission: tell the user; don't try other aliases on your own |\n| `EMULATOR_NOT_RUNNING` | Same as `start-emulator` above |\n| `NotImplemented` (HTTP 501) on a `local` alias | The emulator doesn't cover that operation yet: tell the user, it isn't a cloudhop problem |\n\nMore: https://dayepesb.github.io/cloudhop/guides/hop-like-aws/\n";
|
|
11850
11940
|
|
|
11851
11941
|
// ../assistants/src/targets.ts
|
|
11852
11942
|
import { join as join4 } from "path";
|
|
@@ -12658,7 +12748,7 @@ var ALIAS_COMMANDS = "use status console region mv prod rm exec";
|
|
|
12658
12748
|
var SOURCE_COMMANDS = "alias login sync ls";
|
|
12659
12749
|
var aliasVar = (provider) => `CLOUDHOP_${provider.toUpperCase()}_ALIAS`;
|
|
12660
12750
|
var prodVar = (provider) => `CLOUDHOP_${provider.toUpperCase()}_PROD`;
|
|
12661
|
-
function posixScript(shell, clis) {
|
|
12751
|
+
function posixScript(shell, clis, start) {
|
|
12662
12752
|
const wrappers = clis.map(
|
|
12663
12753
|
({ command, provider }) => `${command}() {
|
|
12664
12754
|
case " $* " in *" --profile"*) command ${command} "$@"; return $?;; esac
|
|
@@ -12744,12 +12834,12 @@ _cloudhop_cd() {
|
|
|
12744
12834
|
_cloudhop_rc_seen=$rc
|
|
12745
12835
|
local out
|
|
12746
12836
|
if [ -n "$rc" ]; then out="$(command cloudhop use --emit ${shell} --from "$rc")"
|
|
12747
|
-
elif [ -n "$CLOUDHOP_RC" ]; then out="$(command cloudhop use --emit ${shell} --leave)"
|
|
12837
|
+
elif [ -n "$CLOUDHOP_RC$_cloudhop_start" ]; then out="$(command cloudhop use --emit ${shell} --leave)"
|
|
12748
12838
|
else return 0; fi
|
|
12749
12839
|
eval "$out"
|
|
12750
12840
|
}
|
|
12751
12841
|
${hook}
|
|
12752
|
-
_cloudhop_cd
|
|
12842
|
+
${start ? "# New terminals start with your default alias (`cloudhop use <alias> --default`).\n_cloudhop_start=1 _cloudhop_rc_seen=-\n_cloudhop_cd\nunset _cloudhop_start" : "_cloudhop_cd"}
|
|
12753
12843
|
${wrappers}
|
|
12754
12844
|
${completion}
|
|
12755
12845
|
# The aliases in use, e.g. "aws:acme-dev". Shown in your prompt by itself
|
|
@@ -12762,7 +12852,7 @@ ${prompt}
|
|
|
12762
12852
|
${promptHook}
|
|
12763
12853
|
`;
|
|
12764
12854
|
}
|
|
12765
|
-
function fishScript(clis) {
|
|
12855
|
+
function fishScript(clis, start) {
|
|
12766
12856
|
const wrappers = clis.map(
|
|
12767
12857
|
({ command, provider }) => `function ${command} --wraps ${command}
|
|
12768
12858
|
if test -n "$${aliasVar(provider)}" -a "$CLOUDHOP_WRAP" != 0; and not string match -q -- '--profile*' $argv
|
|
@@ -12806,11 +12896,11 @@ function _cloudhop_cd --on-variable PWD
|
|
|
12806
12896
|
set -g _cloudhop_rc_seen $rc
|
|
12807
12897
|
if test -n "$rc"
|
|
12808
12898
|
command cloudhop use --emit fish --from $rc | source
|
|
12809
|
-
else if test -n "$CLOUDHOP_RC"
|
|
12899
|
+
else if test -n "$CLOUDHOP_RC$_cloudhop_start"
|
|
12810
12900
|
command cloudhop use --emit fish --leave | source
|
|
12811
12901
|
end
|
|
12812
12902
|
end
|
|
12813
|
-
_cloudhop_cd
|
|
12903
|
+
${start ? "# New terminals start with your default alias (`cloudhop use <alias> --default`).\nset -g _cloudhop_start 1\nset -g _cloudhop_rc_seen -\n_cloudhop_cd\nset -e _cloudhop_start" : "_cloudhop_cd"}
|
|
12814
12904
|
${wrappers}
|
|
12815
12905
|
complete -c hop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete aliases 2>/dev/null)'
|
|
12816
12906
|
complete -c cloudhop -f -n 'test (count (commandline -opc)) -eq 1' -a '(command cloudhop __complete commands 2>/dev/null)'
|
|
@@ -12834,7 +12924,7 @@ if not functions -q fish_right_prompt
|
|
|
12834
12924
|
end
|
|
12835
12925
|
`;
|
|
12836
12926
|
}
|
|
12837
|
-
function pwshScript(clis) {
|
|
12927
|
+
function pwshScript(clis, start) {
|
|
12838
12928
|
const wrappers = clis.map(
|
|
12839
12929
|
({
|
|
12840
12930
|
command,
|
|
@@ -12874,10 +12964,11 @@ function _CloudhopCd {
|
|
|
12874
12964
|
}
|
|
12875
12965
|
if ($rc -eq $script:CloudhopRcSeen) { return }
|
|
12876
12966
|
$script:CloudhopRcSeen = $rc
|
|
12877
|
-
$out = if ($rc) { & $script:CloudhopExe use --emit pwsh --from $rc } elseif ($env:CLOUDHOP_RC) { & $script:CloudhopExe use --emit pwsh --leave }
|
|
12967
|
+
$out = if ($rc) { & $script:CloudhopExe use --emit pwsh --from $rc } elseif ($env:CLOUDHOP_RC -or $script:CloudhopStart) { & $script:CloudhopExe use --emit pwsh --leave }
|
|
12968
|
+
$script:CloudhopStart = $false
|
|
12878
12969
|
if ($out) { $out | Out-String | Invoke-Expression }
|
|
12879
12970
|
}
|
|
12880
|
-
$script:CloudhopPrompt = $function:prompt
|
|
12971
|
+
${start ? "# New terminals start with your default alias (`cloudhop use <alias> --default`).\n$script:CloudhopStart = $true\n" : ""}$script:CloudhopPrompt = $function:prompt
|
|
12881
12972
|
function prompt {
|
|
12882
12973
|
_CloudhopCd
|
|
12883
12974
|
$out = & $script:CloudhopPrompt
|
|
@@ -12909,10 +13000,10 @@ ${prompt}
|
|
|
12909
13000
|
}
|
|
12910
13001
|
`;
|
|
12911
13002
|
}
|
|
12912
|
-
function shellScript(shell, clis) {
|
|
12913
|
-
if (shell === "fish") return fishScript(clis);
|
|
12914
|
-
if (shell === "pwsh") return pwshScript(clis);
|
|
12915
|
-
return posixScript(shell, clis);
|
|
13003
|
+
function shellScript(shell, clis, start = false) {
|
|
13004
|
+
if (shell === "fish") return fishScript(clis, start);
|
|
13005
|
+
if (shell === "pwsh") return pwshScript(clis, start);
|
|
13006
|
+
return posixScript(shell, clis, start);
|
|
12916
13007
|
}
|
|
12917
13008
|
function starshipSnippet() {
|
|
12918
13009
|
return `# cloudhop: the alias each cloud uses in this shell (\`cloudhop use\`, .cloudhoprc).
|
|
@@ -12981,7 +13072,7 @@ function shellIntegrationInstalled() {
|
|
|
12981
13072
|
return hasCloudhopStarship(starship);
|
|
12982
13073
|
}
|
|
12983
13074
|
var starshipFile = () => process.env.STARSHIP_CONFIG || join6(homedir3(), ".config", "starship.toml");
|
|
12984
|
-
async function shellCommand(args, clis) {
|
|
13075
|
+
async function shellCommand(args, clis, start = false) {
|
|
12985
13076
|
const { positional, flags } = parseArgs(args);
|
|
12986
13077
|
if (positional[0] === "starship") {
|
|
12987
13078
|
process.stdout.write(starshipSnippet());
|
|
@@ -12989,7 +13080,7 @@ async function shellCommand(args, clis) {
|
|
|
12989
13080
|
}
|
|
12990
13081
|
const setUp = positional[0] === "install" || !positional[0] && process.stdout.isTTY;
|
|
12991
13082
|
if (!setUp) {
|
|
12992
|
-
process.stdout.write(shellScript(parseShell(positional[0]), clis));
|
|
13083
|
+
process.stdout.write(shellScript(parseShell(positional[0]), clis, start));
|
|
12993
13084
|
return;
|
|
12994
13085
|
}
|
|
12995
13086
|
const shell = parseShell(positional[1]);
|
|
@@ -13157,17 +13248,19 @@ async function useCommand(app, trusted, args) {
|
|
|
13157
13248
|
`);
|
|
13158
13249
|
return 0;
|
|
13159
13250
|
}
|
|
13251
|
+
if (flags.noDefault) {
|
|
13252
|
+
const which = positional[0];
|
|
13253
|
+
const cleared = await app.clearDefaultAlias(which ? cloudArg(which) : void 0);
|
|
13254
|
+
if (shell) process.stdout.write("\n");
|
|
13255
|
+
notify(
|
|
13256
|
+
cleared.length ? `New terminals no longer start with ${cleared.join(", ")}. This one keeps what it uses.` : "There was no default alias."
|
|
13257
|
+
);
|
|
13258
|
+
return 0;
|
|
13259
|
+
}
|
|
13160
13260
|
if (flags.off) {
|
|
13161
13261
|
if (!shell) throw noIntegration("Nothing to turn off: this shell has no cloudhop integration.");
|
|
13162
13262
|
const which = positional[0];
|
|
13163
|
-
|
|
13164
|
-
throw new CloudhopError(
|
|
13165
|
-
"INVALID_NAME",
|
|
13166
|
-
`Unknown cloud "${which}".`,
|
|
13167
|
-
`Use one of: ${PROVIDER_IDS.join(", ")}, or nothing to turn them all off.`
|
|
13168
|
-
);
|
|
13169
|
-
}
|
|
13170
|
-
const providers = which ? [which] : app.availableProviders().map((provider) => provider.id);
|
|
13263
|
+
const providers = which ? [cloudArg(which)] : app.availableProviders().map((provider) => provider.id);
|
|
13171
13264
|
const env2 = {};
|
|
13172
13265
|
for (const provider of providers) Object.assign(env2, deactivate(app, provider));
|
|
13173
13266
|
const remaining = folderProviders().filter((p3) => !providers.includes(p3));
|
|
@@ -13182,6 +13275,16 @@ async function useCommand(app, trusted, args) {
|
|
|
13182
13275
|
}
|
|
13183
13276
|
const alias = positional[0] ?? await pickAlias(app, "Use which alias in this shell?");
|
|
13184
13277
|
const activation = await activate(app, alias, true);
|
|
13278
|
+
if (flags.default) {
|
|
13279
|
+
if (activation.prod && !flags.yes) {
|
|
13280
|
+
throw new CloudhopError(
|
|
13281
|
+
"PROD_CONFIRMATION_REQUIRED",
|
|
13282
|
+
`${alias} is production: every new terminal would start in it.`,
|
|
13283
|
+
`If that's what you want, run \`cloudhop use ${alias} --default --yes\`.`
|
|
13284
|
+
);
|
|
13285
|
+
}
|
|
13286
|
+
await app.setDefaultAlias(alias);
|
|
13287
|
+
}
|
|
13185
13288
|
let folder;
|
|
13186
13289
|
if (flags.save) folder = await saveFolderFile(trusted, activation.provider, alias);
|
|
13187
13290
|
if (!shell) {
|
|
@@ -13201,14 +13304,23 @@ async function useCommand(app, trusted, args) {
|
|
|
13201
13304
|
process.stdout.write(`${shellExports(shell, env)}
|
|
13202
13305
|
`);
|
|
13203
13306
|
notify(`This shell now uses ${activation.summary}.`);
|
|
13307
|
+
if (flags.default) notify(`New terminals start with ${alias}.`);
|
|
13204
13308
|
if (folder) notify(`Saved in ${tilde2(folder)}: this folder switches to ${alias} on cd.`);
|
|
13205
13309
|
warnProduction(activation);
|
|
13206
13310
|
return 0;
|
|
13207
13311
|
}
|
|
13208
13312
|
var folderMarks = (file, providers) => ({
|
|
13209
13313
|
CLOUDHOP_RC: providers.length && file ? file : void 0,
|
|
13210
|
-
CLOUDHOP_RC_PROVIDERS: providers.length
|
|
13314
|
+
CLOUDHOP_RC_PROVIDERS: providers.length ? providers.join(",") : void 0
|
|
13211
13315
|
});
|
|
13316
|
+
function cloudArg(which) {
|
|
13317
|
+
if (PROVIDER_IDS.includes(which)) return which;
|
|
13318
|
+
throw new CloudhopError(
|
|
13319
|
+
"INVALID_NAME",
|
|
13320
|
+
`Unknown cloud "${which}".`,
|
|
13321
|
+
`Use one of: ${PROVIDER_IDS.join(", ")}, or nothing for all of them.`
|
|
13322
|
+
);
|
|
13323
|
+
}
|
|
13212
13324
|
var noIntegration = (message) => new CloudhopError(
|
|
13213
13325
|
"NOT_INTERACTIVE",
|
|
13214
13326
|
message,
|
|
@@ -13240,21 +13352,44 @@ async function folderEnv(app, trusted, file) {
|
|
|
13240
13352
|
}
|
|
13241
13353
|
}
|
|
13242
13354
|
}
|
|
13243
|
-
const
|
|
13355
|
+
const fromFolder = activations.map((a2) => a2.provider);
|
|
13356
|
+
const defaults = [];
|
|
13357
|
+
for (const [provider, alias] of Object.entries(await defaultAliases(app))) {
|
|
13358
|
+
if (fromFolder.includes(provider)) continue;
|
|
13359
|
+
const picked = process.env[activeVars(provider).alias];
|
|
13360
|
+
if (picked && !previous.includes(provider)) continue;
|
|
13361
|
+
try {
|
|
13362
|
+
defaults.push(await activate(app, alias, false));
|
|
13363
|
+
} catch (error) {
|
|
13364
|
+
notify(`Default alias: ${error.message}`);
|
|
13365
|
+
}
|
|
13366
|
+
}
|
|
13367
|
+
const applied = [...fromFolder, ...defaults.map((a2) => a2.provider)];
|
|
13244
13368
|
const env = {};
|
|
13245
13369
|
const left = previous.filter((p3) => !applied.includes(p3));
|
|
13246
13370
|
for (const provider of left) Object.assign(env, deactivate(app, provider));
|
|
13247
|
-
for (const activation of activations) Object.assign(env, activation.env);
|
|
13371
|
+
for (const activation of [...activations, ...defaults]) Object.assign(env, activation.env);
|
|
13248
13372
|
Object.assign(env, folderMarks(path2, applied));
|
|
13373
|
+
const back = defaults.filter((a2) => previous.includes(a2.provider));
|
|
13249
13374
|
if (activations.length) {
|
|
13250
13375
|
notify(`Using ${activations.map((a2) => a2.summary).join(" and ")}, from ${FOLDER_FILE}.`);
|
|
13251
|
-
|
|
13252
|
-
|
|
13253
|
-
|
|
13254
|
-
|
|
13376
|
+
} else if (back.length) {
|
|
13377
|
+
notify(`Back to your default ${back.map((a2) => a2.summary).join(" and ")}.`);
|
|
13378
|
+
}
|
|
13379
|
+
const gone = left.map((p3) => process.env[activeVars(p3).alias]).filter(Boolean);
|
|
13380
|
+
if (!activations.length && !back.length && gone.length) {
|
|
13381
|
+
notify(`Left the folder of ${gone.join(", ")}: no alias in this shell.`);
|
|
13255
13382
|
}
|
|
13383
|
+
for (const activation of [...activations, ...defaults]) warnProduction(activation);
|
|
13256
13384
|
return env;
|
|
13257
13385
|
}
|
|
13386
|
+
async function defaultAliases(app) {
|
|
13387
|
+
try {
|
|
13388
|
+
return await app.defaultAliases();
|
|
13389
|
+
} catch {
|
|
13390
|
+
return {};
|
|
13391
|
+
}
|
|
13392
|
+
}
|
|
13258
13393
|
async function saveFolderFile(trusted, provider, alias) {
|
|
13259
13394
|
const file = join7(process.cwd(), FOLDER_FILE);
|
|
13260
13395
|
const content = await readFolderFile(file);
|
|
@@ -13379,8 +13514,10 @@ var SESSION_LABEL = {
|
|
|
13379
13514
|
valid: paint("green", "\u25CF session valid"),
|
|
13380
13515
|
refreshable: paint("yellow", "\u25D0 renews automatically on next use"),
|
|
13381
13516
|
expired: paint("red", "\u25CB expired, opens the browser on next use"),
|
|
13517
|
+
offline: paint("yellow", "\u25CB emulator not running"),
|
|
13382
13518
|
unknown: paint("dim", "\xB7 session managed by the cloud CLI")
|
|
13383
13519
|
};
|
|
13520
|
+
var RUNNING = paint("green", "\u25CF emulator running");
|
|
13384
13521
|
var JSON_VERSION = 1;
|
|
13385
13522
|
var printJson = (value) => console.log(JSON.stringify({ version: JSON_VERSION, ...value }, null, 2));
|
|
13386
13523
|
async function listCommand(app, args = [], context) {
|
|
@@ -13423,7 +13560,7 @@ async function listCommand(app, args = [], context) {
|
|
|
13423
13560
|
console.log(
|
|
13424
13561
|
`${bold(paint("sky", source.id))} ${paint("dim", `${provider.displayName} \xB7 ${where}`)}`
|
|
13425
13562
|
);
|
|
13426
|
-
console.log(` ${SESSION_LABEL[state]}`);
|
|
13563
|
+
console.log(` ${state === "valid" && app.isLocal(source) ? RUNNING : SESSION_LABEL[state]}`);
|
|
13427
13564
|
const own = aliases.filter((a2) => a2.alias.source === source.id);
|
|
13428
13565
|
if (own.length === 0) {
|
|
13429
13566
|
console.log(paint("dim", ` no aliases yet: \`cloudhop alias ${source.id}\``));
|
|
@@ -13434,7 +13571,7 @@ async function listCommand(app, args = [], context) {
|
|
|
13434
13571
|
);
|
|
13435
13572
|
for (const { alias, target, region } of own) {
|
|
13436
13573
|
const label = target ? target.label : paint("red", `${alias.target} (no longer available)`);
|
|
13437
|
-
const tag = alias.prod ? ` ${paint("red", "PROD")}` : ""
|
|
13574
|
+
const tag = `${alias.prod ? ` ${paint("red", "PROD")}` : ""}${alias.default ? ` ${paint("dim", "default")}` : ""}`;
|
|
13438
13575
|
console.log(
|
|
13439
13576
|
` ${alias.name.padEnd(nameWidth)}${(region ?? "-").padEnd(regionWidth)}${label}${tag}`
|
|
13440
13577
|
);
|
|
@@ -13540,12 +13677,14 @@ var STATE_SHORT = {
|
|
|
13540
13677
|
valid: paint("green", "\u25CF session valid"),
|
|
13541
13678
|
refreshable: paint("yellow", "\u25D0 session expired, renewable"),
|
|
13542
13679
|
expired: paint("red", "\u25CB session expired"),
|
|
13680
|
+
offline: paint("yellow", "\u25CB emulator not running"),
|
|
13543
13681
|
unknown: paint("dim", "\xB7 session")
|
|
13544
13682
|
};
|
|
13545
13683
|
var NEXT_TEXT = {
|
|
13546
13684
|
ready: "ready to run",
|
|
13547
13685
|
"silent-renewal": "the next command renews the session silently",
|
|
13548
13686
|
"browser-sign-in": "the next command opens the browser to sign in",
|
|
13687
|
+
"start-emulator": "start the emulator first",
|
|
13549
13688
|
unknown: "the cloud CLI manages this session"
|
|
13550
13689
|
};
|
|
13551
13690
|
async function statusCommand(app, args) {
|
|
@@ -13567,7 +13706,10 @@ async function statusCommand(app, args) {
|
|
|
13567
13706
|
}
|
|
13568
13707
|
const overview = await app.overview();
|
|
13569
13708
|
const inUse = activeAliases();
|
|
13570
|
-
|
|
13709
|
+
const defaults = Object.fromEntries(
|
|
13710
|
+
overview.aliases.filter((a2) => a2.default).map((a2) => [a2.provider, a2.name])
|
|
13711
|
+
);
|
|
13712
|
+
if (flags.json) return printJson({ inUse, defaults, sources: overview.sources });
|
|
13571
13713
|
const using = Object.entries(inUse).map(([cloud, alias]) => {
|
|
13572
13714
|
const status = overview.aliases.find((a2) => a2.name === alias);
|
|
13573
13715
|
const region = status?.region ? ` (${status.region})` : "";
|
|
@@ -13576,6 +13718,8 @@ async function statusCommand(app, args) {
|
|
|
13576
13718
|
console.log(
|
|
13577
13719
|
using.length ? `This terminal uses ${using.join(", ")}.` : paint("dim", "This terminal uses no alias (`cloudhop use <alias>` to pick one).")
|
|
13578
13720
|
);
|
|
13721
|
+
const starts = Object.entries(defaults).map(([cloud, alias]) => `${cloud} ${alias}`);
|
|
13722
|
+
if (starts.length) console.log(paint("dim", `New terminals start with ${starts.join(", ")}.`));
|
|
13579
13723
|
if (overview.sources.length === 0) {
|
|
13580
13724
|
console.log("Nothing here yet. Run `cloudhop setup` to get started.");
|
|
13581
13725
|
return;
|
|
@@ -14003,6 +14147,7 @@ function createApi(app, assistants) {
|
|
|
14003
14147
|
kind: source.kind,
|
|
14004
14148
|
kindLabel: provider.sourceKinds.find((k) => k.kind === source.kind)?.label ?? source.kind,
|
|
14005
14149
|
settings: source.settings,
|
|
14150
|
+
local: app.isLocal(source),
|
|
14006
14151
|
session: await provider.sessionState(source)
|
|
14007
14152
|
};
|
|
14008
14153
|
})
|
|
@@ -14026,14 +14171,16 @@ function createApi(app, assistants) {
|
|
|
14026
14171
|
}))
|
|
14027
14172
|
})),
|
|
14028
14173
|
sources,
|
|
14029
|
-
aliases: aliases.map(({ alias, target, region }) => ({
|
|
14174
|
+
aliases: aliases.map(({ alias, source, target, region }) => ({
|
|
14030
14175
|
name: alias.name,
|
|
14031
14176
|
source: alias.source,
|
|
14032
14177
|
target: alias.target,
|
|
14033
14178
|
label: target?.label,
|
|
14034
14179
|
missing: !target,
|
|
14035
14180
|
region,
|
|
14036
|
-
prod: alias.prod === true
|
|
14181
|
+
prod: alias.prod === true,
|
|
14182
|
+
default: alias.default === true,
|
|
14183
|
+
local: app.isLocal(source)
|
|
14037
14184
|
}))
|
|
14038
14185
|
};
|
|
14039
14186
|
},
|
|
@@ -14098,14 +14245,14 @@ function createApi(app, assistants) {
|
|
|
14098
14245
|
},
|
|
14099
14246
|
"POST /api/sources/:id/login": async ([id]) => reportView(await app.login(id)),
|
|
14100
14247
|
"POST /api/sources/:id/sync": async ([id]) => {
|
|
14101
|
-
await app.ensureSession(id);
|
|
14248
|
+
if (!app.isLocal(await findSource(id))) await app.ensureSession(id);
|
|
14102
14249
|
return reportView(await app.syncSource(id));
|
|
14103
14250
|
},
|
|
14104
14251
|
"GET /api/sources/:id/targets": async ([id]) => {
|
|
14105
14252
|
await findSource(id);
|
|
14106
14253
|
let targets = await app.listTargets(id);
|
|
14107
14254
|
if (targets.length === 0) {
|
|
14108
|
-
await app.ensureSession(id);
|
|
14255
|
+
if (!app.isLocal(await findSource(id))) await app.ensureSession(id);
|
|
14109
14256
|
targets = (await app.syncSource(id)).targets;
|
|
14110
14257
|
}
|
|
14111
14258
|
return { targets: targets.map(targetView) };
|
|
@@ -14128,7 +14275,10 @@ function createApi(app, assistants) {
|
|
|
14128
14275
|
await app.addAliases(aliases);
|
|
14129
14276
|
return { created: aliases.map((a2) => a2.name) };
|
|
14130
14277
|
},
|
|
14131
|
-
/**
|
|
14278
|
+
/**
|
|
14279
|
+
* Changes an alias: any of `region`, `prod` (production mark), `default`
|
|
14280
|
+
* (new terminals start with it) and `name` (rename, applied last).
|
|
14281
|
+
*/
|
|
14132
14282
|
"PATCH /api/aliases/:name": async ([name], body) => {
|
|
14133
14283
|
let current = name;
|
|
14134
14284
|
if ("region" in body) {
|
|
@@ -14138,6 +14288,11 @@ function createApi(app, assistants) {
|
|
|
14138
14288
|
await app.setAliasRegion(current, region);
|
|
14139
14289
|
}
|
|
14140
14290
|
if (typeof body.prod === "boolean") await app.setProduction([current], body.prod);
|
|
14291
|
+
if (body.default === true) await app.setDefaultAlias(current);
|
|
14292
|
+
if (body.default === false) {
|
|
14293
|
+
const resolved = await app.resolveAlias(current);
|
|
14294
|
+
if (resolved.alias.default) await app.clearDefaultAlias(resolved.source.provider);
|
|
14295
|
+
}
|
|
14141
14296
|
if ("name" in body) {
|
|
14142
14297
|
const to = text3(body, "name");
|
|
14143
14298
|
await app.renameAlias(current, to);
|
|
@@ -14186,7 +14341,7 @@ import { randomBytes, timingSafeEqual } from "crypto";
|
|
|
14186
14341
|
import { createServer } from "http";
|
|
14187
14342
|
|
|
14188
14343
|
// ../ui/src/assets/app.js
|
|
14189
|
-
var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// ---------- pages ----------\n\nconst PAGES = {\n aliases: 'Aliases',\n sources: 'Sources',\n assistants: 'AI assistants',\n tools: 'Tools',\n};\nlet route = 'aliases';\nconst aliasFilter = { text: '', source: '', prodOnly: false };\n\n/** `#/aliases?source=acme` \u2192 route \"aliases\", filtered by that source. */\nfunction readRoute() {\n const match = /^#\\/([a-z]+)(?:\\?(.*))?$/.exec(location.hash);\n // Own keys only: \"#/constructor\" must not reach Object.prototype.\n route = match && Object.hasOwn(PAGES, match[1]) ? match[1] : 'aliases';\n const query = new URLSearchParams(match?.[2] ?? '');\n if (query.has('source')) aliasFilter.source = query.get('source');\n}\n\nfunction go(page, query) {\n location.hash = `#/${page}${query ? `?${new URLSearchParams(query)}` : ''}`;\n}\n\n// A fresh link opened in this same tab carries a new token: reload to pick it up.\n// Any other fragment is a page change.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) {\n location.reload();\n return;\n }\n readRoute();\n if (state) render();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nconst $nav = document.getElementById('nav');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/(sources(\\/[^/]+\\/(login|sync|targets))?|aliases\\/[^/]+\\/console)$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n $nav.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n for (const link of $nav.querySelectorAll('a')) {\n if (link.dataset.page === route) link.setAttribute('aria-current', 'page');\n else link.removeAttribute('aria-current');\n }\n document.title = `${PAGES[route]} \xB7 cloudhop`;\n const pages = new Map([\n ['aliases', aliasesPage],\n ['sources', sourcesPage],\n ['assistants', assistantsPage],\n ['tools', toolsPage],\n ]);\n const page = pages.get(route) ?? aliasesPage;\n $main.replaceChildren(...page());\n if (route === 'assistants') loadAssistants();\n if (route === 'tools') loadTools();\n}\n\nfunction pageHead(title, subtitle, ...actions) {\n return h(\n 'div',\n { class: 'page-head' },\n h('div', {}, h('h1', {}, title), subtitle ? h('p', {}, subtitle) : null),\n h('div', { class: 'actions' }, actions),\n );\n}\n\nfunction firstSourceHero() {\n return h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n );\n}\n\n// ---------- Aliases page ----------\n\nfunction aliasesPage() {\n if (state.sources.length === 0) return [firstSourceHero()];\n if (aliasFilter.source && !state.sources.some((s) => s.id === aliasFilter.source))\n aliasFilter.source = '';\n\n const addAliases = () => {\n const source =\n state.sources.find((s) => s.id === aliasFilter.source) ??\n (state.sources.length === 1 ? state.sources[0] : undefined);\n if (source) openAliasPicker(source);\n else openSourceChooser();\n };\n const head = pageHead(\n 'Aliases',\n 'Each alias is one account, role and region. Run it with hop <alias>.',\n h('button', { onclick: addAliases }, '+ Aliases'),\n );\n if (state.aliases.length === 0) {\n return [\n head,\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'No aliases yet'),\n h('p', {}, 'Pick the accounts and roles you use and give each one a short name.'),\n h('button', { class: 'primary', onclick: addAliases }, 'Pick accounts'),\n ),\n ];\n }\n\n const rows = h('tbody', { id: 'alias-rows' });\n const count = h('span', { class: 'count' });\n const fill = () => {\n const words = aliasFilter.text.toLowerCase().split(/\\s+/).filter(Boolean);\n const visible = state.aliases.filter((a) => {\n if (aliasFilter.source && a.source !== aliasFilter.source) return false;\n if (aliasFilter.prodOnly && !a.prod) return false;\n const text = `${a.name} ${a.label ?? a.target} ${a.region ?? ''} ${a.source}`.toLowerCase();\n return words.every((w) => text.includes(w));\n });\n rows.replaceChildren(\n ...(visible.length\n ? visible.map(aliasRow)\n : [\n h(\n 'tr',\n {},\n h('td', { colspan: '5', class: 'empty' }, 'No alias matches these filters.'),\n ),\n ]),\n );\n count.textContent = `${visible.length} of ${state.aliases.length}`;\n };\n\n const search = h('input', {\n type: 'search',\n placeholder: 'Filter: name, account, role or region',\n value: aliasFilter.text,\n 'aria-label': 'Filter aliases',\n oninput: (e) => {\n aliasFilter.text = e.currentTarget.value;\n fill();\n },\n });\n const sourceSelect = h(\n 'select',\n {\n 'aria-label': 'Source',\n onchange: (e) => {\n aliasFilter.source = e.currentTarget.value;\n fill();\n },\n },\n h('option', { value: '' }, 'All sources'),\n state.sources.map((s) =>\n h('option', { value: s.id, selected: s.id === aliasFilter.source }, s.id),\n ),\n );\n const prodOnly = h(\n 'label',\n { class: 'prod-toggle' },\n h('input', {\n type: 'checkbox',\n checked: aliasFilter.prodOnly,\n onchange: (e) => {\n aliasFilter.prodOnly = e.currentTarget.checked;\n fill();\n },\n }),\n 'Production only',\n );\n\n const table = h(\n 'table',\n { class: 'aliases' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Source'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n rows,\n );\n fill();\n return [\n head,\n h(\n 'div',\n { class: 'toolbar' },\n search,\n sourceSelect,\n prodOnly,\n h('div', { class: 'spacer' }),\n count,\n ),\n h('section', { class: 'card' }, table),\n ];\n}\n\n/** When several sources exist, ask which one to pick accounts from. */\nfunction openSourceChooser() {\n showDialog(\n 'Add aliases from',\n h(\n 'div',\n { class: 'choices' },\n state.sources.map((source) =>\n h(\n 'button',\n { class: 'choice', onclick: () => openAliasPicker(source) },\n h(\n 'span',\n {},\n h('strong', {}, source.id),\n h('small', {}, source.settings.startUrl ?? source.kindLabel),\n ),\n ),\n ),\n ),\n );\n}\n\n// ---------- Sources page ----------\n\nfunction sourcesPage() {\n const head = pageHead('Sources', 'The SSO portals and personal accounts you sign in to.');\n if (state.sources.length === 0) return [head, firstSourceHero()];\n return [head, ...state.sources.map(sourceCard)];\n}\n\n// ---------- AI assistants page ----------\n\nfunction assistantsPage() {\n return [assistantsCard()];\n}\n\n// ---------- Tools page ----------\n\nfunction snippet(command) {\n return h(\n 'div',\n { class: 'snippet' },\n h('code', {}, command),\n h(\n 'button',\n {\n class: 'link',\n title: 'Copy',\n 'aria-label': `Copy ${command}`,\n onclick: () =>\n navigator.clipboard\n ?.writeText(command)\n .then(() => toast('Copied.'))\n .catch(() =>\n toast('Copy it by hand: the browser blocked the clipboard.', { error: true }),\n ),\n },\n 'Copy',\n ),\n );\n}\n\nfunction toolCard(title, text, ...commands) {\n return h(\n 'section',\n { class: 'card' },\n h('div', { class: 'card-head' }, h('h2', {}, title)),\n h(\n 'div',\n { class: 'card-body padded' },\n h('p', { class: 'card-help' }, text),\n commands.map(snippet),\n ),\n );\n}\n\nfunction toolsPage() {\n const sample = state.aliases[0]?.name ?? 'acme-dev';\n return [\n pageHead('Tools', 'More ways to use cloudhop from your terminal.'),\n h(\n 'section',\n { class: 'card', id: 'tools-cli' },\n h('div', { class: 'card-head' }, h('h2', {}, 'Cloud CLIs')),\n h('div', { class: 'card-body padded' }, h('p', { class: 'card-help' }, 'Checking\u2026')),\n ),\n toolCard(\n 'Shell integration',\n 'Pick aliases from a list, tab-complete them, and use one in your shell so plain aws and Terraform work without hop.',\n 'cloudhop shell install',\n `cloudhop use ${sample}`,\n 'hop',\n ),\n toolCard(\n 'Run in many accounts',\n 'Run one command in many aliases at once: names, lists, wildcards or /regex/. It asks before running.',\n `cloudhop each '${sample.split('-')[0]}-*' -- s3 ls`,\n ),\n toolCard(\n 'Share with your team',\n 'Export your portals and aliases (no secrets) and let teammates import them in one command.',\n 'cloudhop team export --out team.yaml',\n 'cloudhop team import team.yaml',\n ),\n ];\n}\n\nasync function loadTools() {\n const card = document.querySelector('#tools-cli .card-body');\n if (!card) return;\n try {\n const entries = await api('GET', '/api/doctor');\n card.replaceChildren(\n ...entries.map((e) =>\n h(\n 'div',\n { class: 'tool-row' },\n h('span', { class: `badge ${e.installed && !e.outdated ? 'valid' : 'expired'}` }, e.cli),\n h(\n 'span',\n {},\n !e.installed\n ? 'Not installed.'\n : e.outdated\n ? `${e.version} is older than ${e.minVersion}.`\n : `Version ${e.version}.`,\n ),\n e.installed && !e.outdated ? null : snippet('cloudhop doctor'),\n ),\n ),\n );\n } catch (error) {\n card.replaceChildren(h('p', { class: 'card-help' }, error.message));\n }\n}\n\n// ---------- AI assistants ----------\n\nlet assistantScope = 'user';\ntry {\n assistantScope =\n localStorage.getItem('cloudhop-assistant-scope') === 'project' ? 'project' : 'user';\n} catch {}\nlet assistantsData;\n\nconst SKILL_TEXT = {\n current: ['valid', 'Up to date'],\n outdated: ['refreshable', 'Outdated'],\n modified: ['refreshable', 'Edited by you'],\n missing: ['', 'Not installed'],\n};\nconst MCP_TEXT = { registered: 'On', missing: 'Off', unknown: 'Check in the app' };\n\nfunction assistantsCard() {\n return h('section', { class: 'card', id: 'assistants', hidden: true });\n}\n\nasync function loadAssistants() {\n try {\n assistantsData = await api('GET', '/api/assistants');\n renderAssistants();\n } catch {\n document.getElementById('assistants')?.remove();\n }\n}\n\nfunction setAssistantScope(scope) {\n assistantScope = scope;\n try {\n localStorage.setItem('cloudhop-assistant-scope', scope);\n } catch {}\n renderAssistants();\n}\n\nfunction assistantAction(label, run, { danger = false, confirmText } = {}) {\n return h(\n 'button',\n {\n class: danger ? 'danger' : undefined,\n onclick: (e) => {\n if (confirmText && !confirm(confirmText)) return;\n busy(e.currentTarget, async () => {\n try {\n await run();\n } catch (error) {\n fail(error);\n }\n await loadAssistants();\n });\n },\n },\n label,\n );\n}\n\nfunction reportInstall(results) {\n for (const r of results) {\n if (r.mcp === 'manual') {\n toast(`${r.name}: skill installed. Add the MCP server by hand.`, { hint: r.mcpManual });\n } else {\n toast(`${r.name} now knows cloudhop. Start a new chat so it picks it up.`);\n }\n }\n}\n\nfunction renderAssistants() {\n const el = document.getElementById('assistants');\n if (!el || !assistantsData) return;\n const rows = assistantScope === 'user' ? assistantsData.user : assistantsData.projectScope;\n const scope = assistantScope;\n const install = (tools) =>\n api('POST', '/api/assistants', { tools, scope, mcp: true }).then((r) =>\n reportInstall(r.results),\n );\n const missingDetected = rows\n .filter((r) => r.detected && r.skill === 'missing')\n .map((r) => r.assistant);\n\n const seg = h(\n 'div',\n { class: 'seg', role: 'group', 'aria-label': 'Where' },\n h(\n 'button',\n { 'aria-pressed': String(scope === 'user'), onclick: () => setAssistantScope('user') },\n 'For me',\n ),\n h(\n 'button',\n { 'aria-pressed': String(scope === 'project'), onclick: () => setAssistantScope('project') },\n 'This project',\n ),\n );\n\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, 'AI assistants'),\n h(\n 'span',\n { class: 'meta' },\n scope === 'user'\n ? 'Installed in your home folder, for every project'\n : assistantsData.project,\n ),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n seg,\n missingDetected.length > 1\n ? assistantAction('Install for all detected', () => install(missingDetected))\n : null,\n ),\n );\n\n const table = h(\n 'table',\n { class: 'assistants' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Assistant'),\n h('th', {}, 'Skill'),\n h('th', {}, 'MCP server'),\n h('th'),\n ),\n ),\n h(\n 'tbody',\n {},\n rows.map((r) => {\n const [badgeClass, skillText] = SKILL_TEXT[r.skill];\n const actions = [];\n if (r.skill === 'missing')\n actions.push(assistantAction('Install', () => install([r.assistant])));\n if (r.skill === 'outdated') {\n actions.push(\n assistantAction('Update', () =>\n api('POST', '/api/assistants/update', {}).then(() =>\n toast(`${r.name}: skill updated.`),\n ),\n ),\n );\n }\n if (r.skill === 'modified') {\n actions.push(\n assistantAction(\n 'Replace',\n () =>\n api('POST', '/api/assistants/update', { force: true }).then(() =>\n toast('Edited skills replaced.'),\n ),\n {\n confirmText: 'Replace your edited copies with the current cloudhop skill?',\n },\n ),\n );\n }\n if (r.skill !== 'missing' || r.mcp === 'registered') {\n actions.push(\n assistantAction(\n 'Remove',\n () => api('POST', '/api/assistants/remove', { tools: [r.assistant], scope }),\n {\n danger: true,\n confirmText: `Remove the cloudhop skill and MCP server from ${r.name}?`,\n },\n ),\n );\n }\n return h(\n 'tr',\n {},\n h(\n 'td',\n { class: 'alias' },\n r.name,\n r.detected ? null : h('small', { class: 'muted-inline' }, ' not detected'),\n ),\n h('td', {}, h('span', { class: `badge ${badgeClass}`, title: r.skillPath }, skillText)),\n h('td', {}, r.skill === 'missing' && r.mcp !== 'registered' ? '' : MCP_TEXT[r.mcp]),\n h(\n 'td',\n { style: 'text-align:right' },\n h('div', { class: 'actions', style: 'justify-content:flex-end' }, actions),\n ),\n );\n }),\n ),\n );\n\n el.replaceChildren(\n head,\n h(\n 'div',\n { class: 'card-body' },\n h(\n 'p',\n { class: 'card-help' },\n 'Teach your AI assistant to use cloudhop: it asks you which alias to work with, runs commands with hop, and confirms before changing anything. Includes the cloudhop MCP server.',\n ),\n table,\n ),\n );\n el.hidden = false;\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where = source.settings.startUrl ?? source.kindLabel;\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, SESSION_TEXT[source.session] ?? source.session),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const prodCount = aliases.filter((a) => a.prod).length;\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'div',\n { class: 'source-summary' },\n h(\n 'span',\n {},\n `${aliases.length} alias(es)`,\n prodCount ? h('span', { class: 'prod-tag' }, `${prodCount} PROD`) : null,\n ),\n h(\n 'button',\n { class: 'link', onclick: () => go('aliases', { source: source.id }) },\n 'View aliases \u2192',\n ),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n const patch = (body, message) =>\n api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, body)\n .then(() => message && toast(message))\n .catch(fail)\n .then(refresh);\n const iconButton = (label, title, onclick, extra = {}) =>\n h('button', { class: 'link', title, 'aria-label': title, onclick, ...extra }, label);\n\n const actions = h(\n 'div',\n { class: 'row-actions' },\n h(\n 'button',\n {\n title: `Open the AWS console as ${alias.name}, already signed in`,\n disabled: alias.missing,\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const result = await api(\n 'POST',\n `/api/aliases/${encodeURIComponent(alias.name)}/console`,\n {},\n ).catch(fail);\n if (result) {\n toast(\n result.via === 'signed-in'\n ? `Opened the console for ${alias.name}.`\n : result.via === 'portal'\n ? `Opened ${alias.name} through the SSO portal.`\n : `Opened the console sign-in page for ${alias.name}.`,\n );\n }\n }),\n },\n 'Console',\n ),\n h(\n 'label',\n { class: 'prod-toggle', title: 'Production: hop and AI assistants ask before any change' },\n h('input', {\n type: 'checkbox',\n checked: alias.prod,\n 'aria-label': `${alias.name} is production`,\n onchange: (e) =>\n patch(\n { prod: e.currentTarget.checked },\n e.currentTarget.checked\n ? `${alias.name} is now production.`\n : `${alias.name} is no longer production.`,\n ),\n }),\n 'Prod',\n ),\n iconButton('\u270E', `Rename ${alias.name}`, () => {\n const to = prompt(`New name for \"${alias.name}\"`, alias.name)?.trim();\n if (to && to !== alias.name) patch({ name: to }, `${alias.name} is now ${to}.`);\n }),\n iconButton(\n '\u2715',\n `Remove ${alias.name}`,\n (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n { class: 'link danger-hover' },\n ),\n );\n\n return h(\n 'tr',\n { class: alias.prod ? 'prod' : undefined },\n h(\n 'td',\n { class: 'alias', title: `hop ${alias.name} <command>` },\n alias.name,\n alias.prod ? h('span', { class: 'prod-tag' }, 'PROD') : null,\n ),\n h('td', { class: 'muted' }, alias.source),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h('td', { style: 'text-align:right' }, actions),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n readRoute();\n refresh();\n}\n";
|
|
14344
|
+
var app_default = "// cloudhop local UI. Plain DOM, no build step: served as-is by `cloudhop ui`.\n\nconst TOKEN_KEY = 'cloudhop-token';\nconst SESSION_TEXT = {\n valid: 'Session valid',\n refreshable: 'Renews on next use',\n expired: 'Expired: sign in on next use',\n offline: 'Emulator not running',\n unknown: 'Managed by the cloud CLI',\n};\n\n// The token arrives in the URL fragment once; keep it for this tab and clean the address bar.\nconst fromHash = new URLSearchParams(location.hash.slice(1)).get('token');\nif (fromHash) {\n try {\n sessionStorage.setItem(TOKEN_KEY, fromHash);\n } catch {}\n history.replaceState(null, '', location.pathname);\n}\nlet token = fromHash;\ntry {\n token = token ?? sessionStorage.getItem(TOKEN_KEY);\n} catch {}\n\n// ---------- pages ----------\n\nconst PAGES = {\n aliases: 'Aliases',\n sources: 'Sources',\n assistants: 'AI assistants',\n tools: 'Tools',\n};\nlet route = 'aliases';\nconst aliasFilter = { text: '', source: '', prodOnly: false };\n\n/** `#/aliases?source=acme` \u2192 route \"aliases\", filtered by that source. */\nfunction readRoute() {\n const match = /^#\\/([a-z]+)(?:\\?(.*))?$/.exec(location.hash);\n // Own keys only: \"#/constructor\" must not reach Object.prototype.\n route = match && Object.hasOwn(PAGES, match[1]) ? match[1] : 'aliases';\n const query = new URLSearchParams(match?.[2] ?? '');\n if (query.has('source')) aliasFilter.source = query.get('source');\n}\n\nfunction go(page, query) {\n location.hash = `#/${page}${query ? `?${new URLSearchParams(query)}` : ''}`;\n}\n\n// A fresh link opened in this same tab carries a new token: reload to pick it up.\n// Any other fragment is a page change.\naddEventListener('hashchange', () => {\n if (new URLSearchParams(location.hash.slice(1)).has('token')) {\n location.reload();\n return;\n }\n readRoute();\n if (state) render();\n});\n\nconst $main = document.getElementById('main');\nconst $dialog = document.getElementById('dialog');\nconst $toasts = document.getElementById('toasts');\nconst $addSource = document.getElementById('add-source');\nconst $nav = document.getElementById('nav');\nlet state;\n\n/** Tiny element builder: h('button', { class: 'primary', onclick }, 'Save'). */\nfunction h(tag, attrs = {}, ...children) {\n const el = document.createElement(tag);\n for (const [key, value] of Object.entries(attrs)) {\n if (value === undefined || value === null || value === false) continue;\n if (key.startsWith('on')) el.addEventListener(key.slice(2), value);\n else if (key === 'class') el.className = value;\n else if (value === true) el.setAttribute(key, '');\n else el.setAttribute(key, value);\n }\n for (const child of children.flat()) {\n if (child === undefined || child === null || child === false) continue;\n el.append(child instanceof Node ? child : document.createTextNode(String(child)));\n }\n return el;\n}\n\nclass ApiError extends Error {\n constructor(message, hint) {\n super(message);\n this.hint = hint;\n }\n}\n\n// Calls that may sign in wait for the person in the browser; everything else should answer quickly.\nconst MAY_SIGN_IN = /^\\/api\\/(sources(\\/[^/]+\\/(login|sync|targets))?|aliases\\/[^/]+\\/console)$/;\nconst QUICK_TIMEOUT_MS = 20_000;\n\nasync function api(method, path, body) {\n const waitsForPerson = MAY_SIGN_IN.test(path);\n let response;\n try {\n response = await fetch(path, {\n method,\n headers: {\n 'x-cloudhop-token': token ?? '',\n ...(body ? { 'content-type': 'application/json' } : {}),\n },\n body: body ? JSON.stringify(body) : undefined,\n signal: waitsForPerson ? undefined : AbortSignal.timeout(QUICK_TIMEOUT_MS),\n });\n } catch (error) {\n if (error.name === 'TimeoutError') {\n throw new ApiError(\n 'cloudhop ui is not responding.',\n 'Is it paused in your terminal (Ctrl-Z)? Run `fg` there, or stop it and run `cloudhop ui` again.',\n );\n }\n throw new ApiError(\n 'cloudhop ui is not running.',\n 'Run `cloudhop ui` again and open the new link it prints.',\n );\n }\n const data = await response.json().catch(() => ({}));\n if (!response.ok)\n throw new ApiError(data.error ?? `Request failed (${response.status}).`, data.hint);\n return data;\n}\n\nfunction toast(message, { error = false, hint } = {}) {\n const el = h(\n 'div',\n { class: `toast${error ? ' error' : ''}` },\n message,\n hint && h('small', {}, hint),\n );\n $toasts.append(el);\n setTimeout(() => el.remove(), error ? 9000 : 4000);\n}\n\nconst fail = (error) => toast(error.message, { error: true, hint: error.hint });\n\n/** Disables a button while its action runs. */\nasync function busy(button, action) {\n button.disabled = true;\n try {\n return await action();\n } finally {\n button.disabled = false;\n }\n}\n\nasync function refresh() {\n try {\n state = await api('GET', '/api/state');\n render();\n checkDoctor();\n } catch (error) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Cannot reach cloudhop'),\n h('p', {}, error.message, error.hint ? ` ${error.hint}` : ''),\n ),\n );\n }\n}\n\nasync function checkDoctor() {\n try {\n const entries = await api('GET', '/api/doctor');\n const problems = entries.filter((e) => !e.installed || e.outdated);\n document.getElementById('doctor')?.remove();\n if (problems.length === 0) return;\n $main.prepend(\n h(\n 'div',\n { class: 'notice', id: 'doctor' },\n ...problems.map((e) =>\n h(\n 'div',\n {},\n e.installed\n ? `${e.cli} ${e.version} is older than ${e.minVersion}. `\n : `${e.cli} is not installed, so aliases cannot run commands yet. `,\n 'Run ',\n h('code', {}, 'cloudhop doctor'),\n ' in a terminal to install it.',\n ),\n ),\n ),\n );\n } catch {}\n}\n\n// ---------- main view ----------\n\nfunction render() {\n const enabled = state.enabledProviders;\n $addSource.hidden = enabled.length === 0;\n $nav.hidden = enabled.length === 0;\n if (enabled.length === 0) return renderChooseClouds();\n for (const link of $nav.querySelectorAll('a')) {\n if (link.dataset.page === route) link.setAttribute('aria-current', 'page');\n else link.removeAttribute('aria-current');\n }\n document.title = `${PAGES[route]} \xB7 cloudhop`;\n const pages = new Map([\n ['aliases', aliasesPage],\n ['sources', sourcesPage],\n ['assistants', assistantsPage],\n ['tools', toolsPage],\n ]);\n const page = pages.get(route) ?? aliasesPage;\n $main.replaceChildren(...page());\n if (route === 'assistants') loadAssistants();\n if (route === 'tools') loadTools();\n}\n\nfunction pageHead(title, subtitle, ...actions) {\n return h(\n 'div',\n { class: 'page-head' },\n h('div', {}, h('h1', {}, title), subtitle ? h('p', {}, subtitle) : null),\n h('div', { class: 'actions' }, actions),\n );\n}\n\nfunction firstSourceHero() {\n return h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Add your first account'),\n h(\n 'p',\n {},\n 'Add a company SSO portal or a personal account. cloudhop signs you in, finds every account and role you can use, and lets you give each one a short alias.',\n ),\n h('button', { class: 'primary', onclick: () => openAddSource() }, '+ Add source'),\n );\n}\n\n// ---------- Aliases page ----------\n\nfunction aliasesPage() {\n if (state.sources.length === 0) return [firstSourceHero()];\n if (aliasFilter.source && !state.sources.some((s) => s.id === aliasFilter.source))\n aliasFilter.source = '';\n\n const addAliases = () => {\n const source =\n state.sources.find((s) => s.id === aliasFilter.source) ??\n (state.sources.length === 1 ? state.sources[0] : undefined);\n if (source) openAliasPicker(source);\n else openSourceChooser();\n };\n const head = pageHead(\n 'Aliases',\n 'Each alias is one account, role and region. Run it with hop <alias>.',\n h('button', { onclick: addAliases }, '+ Aliases'),\n );\n if (state.aliases.length === 0) {\n return [\n head,\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'No aliases yet'),\n h('p', {}, 'Pick the accounts and roles you use and give each one a short name.'),\n h('button', { class: 'primary', onclick: addAliases }, 'Pick accounts'),\n ),\n ];\n }\n\n const rows = h('tbody', { id: 'alias-rows' });\n const count = h('span', { class: 'count' });\n const fill = () => {\n const words = aliasFilter.text.toLowerCase().split(/\\s+/).filter(Boolean);\n const visible = state.aliases.filter((a) => {\n if (aliasFilter.source && a.source !== aliasFilter.source) return false;\n if (aliasFilter.prodOnly && !a.prod) return false;\n const text = `${a.name} ${a.label ?? a.target} ${a.region ?? ''} ${a.source}`.toLowerCase();\n return words.every((w) => text.includes(w));\n });\n rows.replaceChildren(\n ...(visible.length\n ? visible.map(aliasRow)\n : [\n h(\n 'tr',\n {},\n h('td', { colspan: '5', class: 'empty' }, 'No alias matches these filters.'),\n ),\n ]),\n );\n count.textContent = `${visible.length} of ${state.aliases.length}`;\n };\n\n const search = h('input', {\n type: 'search',\n placeholder: 'Filter: name, account, role or region',\n value: aliasFilter.text,\n 'aria-label': 'Filter aliases',\n oninput: (e) => {\n aliasFilter.text = e.currentTarget.value;\n fill();\n },\n });\n const sourceSelect = h(\n 'select',\n {\n 'aria-label': 'Source',\n onchange: (e) => {\n aliasFilter.source = e.currentTarget.value;\n fill();\n },\n },\n h('option', { value: '' }, 'All sources'),\n state.sources.map((s) =>\n h('option', { value: s.id, selected: s.id === aliasFilter.source }, s.id),\n ),\n );\n const prodOnly = h(\n 'label',\n { class: 'prod-toggle' },\n h('input', {\n type: 'checkbox',\n checked: aliasFilter.prodOnly,\n onchange: (e) => {\n aliasFilter.prodOnly = e.currentTarget.checked;\n fill();\n },\n }),\n 'Production only',\n );\n\n const table = h(\n 'table',\n { class: 'aliases' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Alias'),\n h('th', {}, 'Source'),\n h('th', {}, 'Region'),\n h('th', {}, 'Account / role'),\n h('th'),\n ),\n ),\n rows,\n );\n fill();\n return [\n head,\n h(\n 'div',\n { class: 'toolbar' },\n search,\n sourceSelect,\n prodOnly,\n h('div', { class: 'spacer' }),\n count,\n ),\n h('section', { class: 'card' }, table),\n ];\n}\n\n/** When several sources exist, ask which one to pick accounts from. */\nfunction openSourceChooser() {\n showDialog(\n 'Add aliases from',\n h(\n 'div',\n { class: 'choices' },\n state.sources.map((source) =>\n h(\n 'button',\n { class: 'choice', onclick: () => openAliasPicker(source) },\n h(\n 'span',\n {},\n h('strong', {}, source.id),\n h('small', {}, source.settings.startUrl ?? source.kindLabel),\n ),\n ),\n ),\n ),\n );\n}\n\n// ---------- Sources page ----------\n\nfunction sourcesPage() {\n const head = pageHead(\n 'Sources',\n 'The SSO portals and personal accounts you sign in to, and your local emulators.',\n );\n if (state.sources.length === 0) return [head, firstSourceHero()];\n return [head, ...state.sources.map(sourceCard)];\n}\n\n// ---------- AI assistants page ----------\n\nfunction assistantsPage() {\n return [assistantsCard()];\n}\n\n// ---------- Tools page ----------\n\nfunction snippet(command) {\n return h(\n 'div',\n { class: 'snippet' },\n h('code', {}, command),\n h(\n 'button',\n {\n class: 'link',\n title: 'Copy',\n 'aria-label': `Copy ${command}`,\n onclick: () =>\n navigator.clipboard\n ?.writeText(command)\n .then(() => toast('Copied.'))\n .catch(() =>\n toast('Copy it by hand: the browser blocked the clipboard.', { error: true }),\n ),\n },\n 'Copy',\n ),\n );\n}\n\nfunction toolCard(title, text, ...commands) {\n return h(\n 'section',\n { class: 'card' },\n h('div', { class: 'card-head' }, h('h2', {}, title)),\n h(\n 'div',\n { class: 'card-body padded' },\n h('p', { class: 'card-help' }, text),\n commands.map(snippet),\n ),\n );\n}\n\nfunction toolsPage() {\n const sample = state.aliases[0]?.name ?? 'acme-dev';\n return [\n pageHead('Tools', 'More ways to use cloudhop from your terminal.'),\n h(\n 'section',\n { class: 'card', id: 'tools-cli' },\n h('div', { class: 'card-head' }, h('h2', {}, 'Cloud CLIs')),\n h('div', { class: 'card-body padded' }, h('p', { class: 'card-help' }, 'Checking\u2026')),\n ),\n toolCard(\n 'Shell integration',\n 'Pick aliases from a list, tab-complete them, and use one in your shell so plain aws and Terraform work without hop.',\n 'cloudhop shell install',\n `cloudhop use ${sample}`,\n 'hop',\n ),\n toolCard(\n 'Run in many accounts',\n 'Run one command in many aliases at once: names, lists, wildcards or /regex/. It asks before running.',\n `cloudhop each '${sample.split('-')[0]}-*' -- s3 ls`,\n ),\n toolCard(\n 'Share with your team',\n 'Export your portals and aliases (no secrets) and let teammates import them in one command.',\n 'cloudhop team export --out team.yaml',\n 'cloudhop team import team.yaml',\n ),\n ];\n}\n\nasync function loadTools() {\n const card = document.querySelector('#tools-cli .card-body');\n if (!card) return;\n try {\n const entries = await api('GET', '/api/doctor');\n card.replaceChildren(\n ...entries.map((e) =>\n h(\n 'div',\n { class: 'tool-row' },\n h('span', { class: `badge ${e.installed && !e.outdated ? 'valid' : 'expired'}` }, e.cli),\n h(\n 'span',\n {},\n !e.installed\n ? 'Not installed.'\n : e.outdated\n ? `${e.version} is older than ${e.minVersion}.`\n : `Version ${e.version}.`,\n ),\n e.installed && !e.outdated ? null : snippet('cloudhop doctor'),\n ),\n ),\n );\n } catch (error) {\n card.replaceChildren(h('p', { class: 'card-help' }, error.message));\n }\n}\n\n// ---------- AI assistants ----------\n\nlet assistantScope = 'user';\ntry {\n assistantScope =\n localStorage.getItem('cloudhop-assistant-scope') === 'project' ? 'project' : 'user';\n} catch {}\nlet assistantsData;\n\nconst SKILL_TEXT = {\n current: ['valid', 'Up to date'],\n outdated: ['refreshable', 'Outdated'],\n modified: ['refreshable', 'Edited by you'],\n missing: ['', 'Not installed'],\n};\nconst MCP_TEXT = { registered: 'On', missing: 'Off', unknown: 'Check in the app' };\n\nfunction assistantsCard() {\n return h('section', { class: 'card', id: 'assistants', hidden: true });\n}\n\nasync function loadAssistants() {\n try {\n assistantsData = await api('GET', '/api/assistants');\n renderAssistants();\n } catch {\n document.getElementById('assistants')?.remove();\n }\n}\n\nfunction setAssistantScope(scope) {\n assistantScope = scope;\n try {\n localStorage.setItem('cloudhop-assistant-scope', scope);\n } catch {}\n renderAssistants();\n}\n\nfunction assistantAction(label, run, { danger = false, confirmText } = {}) {\n return h(\n 'button',\n {\n class: danger ? 'danger' : undefined,\n onclick: (e) => {\n if (confirmText && !confirm(confirmText)) return;\n busy(e.currentTarget, async () => {\n try {\n await run();\n } catch (error) {\n fail(error);\n }\n await loadAssistants();\n });\n },\n },\n label,\n );\n}\n\nfunction reportInstall(results) {\n for (const r of results) {\n if (r.mcp === 'manual') {\n toast(`${r.name}: skill installed. Add the MCP server by hand.`, { hint: r.mcpManual });\n } else {\n toast(`${r.name} now knows cloudhop. Start a new chat so it picks it up.`);\n }\n }\n}\n\nfunction renderAssistants() {\n const el = document.getElementById('assistants');\n if (!el || !assistantsData) return;\n const rows = assistantScope === 'user' ? assistantsData.user : assistantsData.projectScope;\n const scope = assistantScope;\n const install = (tools) =>\n api('POST', '/api/assistants', { tools, scope, mcp: true }).then((r) =>\n reportInstall(r.results),\n );\n const missingDetected = rows\n .filter((r) => r.detected && r.skill === 'missing')\n .map((r) => r.assistant);\n\n const seg = h(\n 'div',\n { class: 'seg', role: 'group', 'aria-label': 'Where' },\n h(\n 'button',\n { 'aria-pressed': String(scope === 'user'), onclick: () => setAssistantScope('user') },\n 'For me',\n ),\n h(\n 'button',\n { 'aria-pressed': String(scope === 'project'), onclick: () => setAssistantScope('project') },\n 'This project',\n ),\n );\n\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, 'AI assistants'),\n h(\n 'span',\n { class: 'meta' },\n scope === 'user'\n ? 'Installed in your home folder, for every project'\n : assistantsData.project,\n ),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n seg,\n missingDetected.length > 1\n ? assistantAction('Install for all detected', () => install(missingDetected))\n : null,\n ),\n );\n\n const table = h(\n 'table',\n { class: 'assistants' },\n h(\n 'thead',\n {},\n h(\n 'tr',\n {},\n h('th', {}, 'Assistant'),\n h('th', {}, 'Skill'),\n h('th', {}, 'MCP server'),\n h('th'),\n ),\n ),\n h(\n 'tbody',\n {},\n rows.map((r) => {\n const [badgeClass, skillText] = SKILL_TEXT[r.skill];\n const actions = [];\n if (r.skill === 'missing')\n actions.push(assistantAction('Install', () => install([r.assistant])));\n if (r.skill === 'outdated') {\n actions.push(\n assistantAction('Update', () =>\n api('POST', '/api/assistants/update', {}).then(() =>\n toast(`${r.name}: skill updated.`),\n ),\n ),\n );\n }\n if (r.skill === 'modified') {\n actions.push(\n assistantAction(\n 'Replace',\n () =>\n api('POST', '/api/assistants/update', { force: true }).then(() =>\n toast('Edited skills replaced.'),\n ),\n {\n confirmText: 'Replace your edited copies with the current cloudhop skill?',\n },\n ),\n );\n }\n if (r.skill !== 'missing' || r.mcp === 'registered') {\n actions.push(\n assistantAction(\n 'Remove',\n () => api('POST', '/api/assistants/remove', { tools: [r.assistant], scope }),\n {\n danger: true,\n confirmText: `Remove the cloudhop skill and MCP server from ${r.name}?`,\n },\n ),\n );\n }\n return h(\n 'tr',\n {},\n h(\n 'td',\n { class: 'alias' },\n r.name,\n r.detected ? null : h('small', { class: 'muted-inline' }, ' not detected'),\n ),\n h('td', {}, h('span', { class: `badge ${badgeClass}`, title: r.skillPath }, skillText)),\n h('td', {}, r.skill === 'missing' && r.mcp !== 'registered' ? '' : MCP_TEXT[r.mcp]),\n h(\n 'td',\n { style: 'text-align:right' },\n h('div', { class: 'actions', style: 'justify-content:flex-end' }, actions),\n ),\n );\n }),\n ),\n );\n\n el.replaceChildren(\n head,\n h(\n 'div',\n { class: 'card-body' },\n h(\n 'p',\n { class: 'card-help' },\n 'Teach your AI assistant to use cloudhop: it asks you which alias to work with, runs commands with hop, and confirms before changing anything. Includes the cloudhop MCP server.',\n ),\n table,\n ),\n );\n el.hidden = false;\n}\n\nfunction renderChooseClouds() {\n const boxes = state.providers.map((p) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', { type: 'checkbox', value: p.id, checked: true }),\n h('span', {}, p.name),\n ),\n );\n const save = h(\n 'button',\n {\n class: 'primary',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const ids = boxes\n .map((b) => b.querySelector('input'))\n .filter((i) => i.checked)\n .map((i) => i.value);\n await api('PUT', '/api/providers', { enabled: ids }).catch(fail);\n await refresh();\n }),\n },\n 'Continue',\n );\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Which clouds do you use?'),\n h('p', {}, 'Azure and Google Cloud are coming soon.'),\n h(\n 'div',\n { class: 'choices', style: 'max-width:320px;margin:0 auto 16px;text-align:left' },\n boxes,\n ),\n save,\n ),\n );\n}\n\nfunction sourceCard(source) {\n const aliases = state.aliases.filter((a) => a.source === source.id);\n const where =\n source.settings.startUrl ??\n source.settings.endpoint ??\n (source.settings.port ? `https://127.0.0.1:${source.settings.port}` : source.kindLabel);\n const sessionText =\n source.local && source.session === 'valid'\n ? 'Emulator running'\n : (SESSION_TEXT[source.session] ?? source.session);\n const head = h(\n 'div',\n { class: 'card-head' },\n h('h2', {}, source.id),\n h('span', { class: `badge ${source.session}` }, sessionText),\n h('span', { class: 'meta' }, `${source.providerName} \xB7 ${where}`),\n h('div', { class: 'spacer' }),\n h(\n 'div',\n { class: 'actions' },\n h('button', { onclick: () => openAliasPicker(source) }, '+ Aliases'),\n // An emulator has nothing to sign in to or refresh.\n source.local\n ? null\n : h(\n 'button',\n {\n title: 'Sign in again and refresh its accounts',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n toast('Opening the browser to sign in\u2026');\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/login`,\n ).catch(fail);\n if (report)\n toast(`${source.id}: signed in, ${report.targets.length} account/role(s).`);\n await refresh();\n }),\n },\n 'Sign in',\n ),\n source.local\n ? null\n : h(\n 'button',\n {\n title: 'Refresh the accounts and roles you have access to',\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const report = await api(\n 'POST',\n `/api/sources/${encodeURIComponent(source.id)}/sync`,\n ).catch(fail);\n if (report) toast(syncMessage(source.id, report));\n await refresh();\n }),\n },\n 'Sync',\n ),\n h(\n 'button',\n {\n class: 'danger',\n onclick: (e) => {\n const count = aliases.length;\n const message = `Remove \"${source.id}\"${count ? ` and its ${count} alias(es)` : ''}?`;\n if (!confirm(message)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/sources/${encodeURIComponent(source.id)}`).catch(fail);\n await refresh();\n });\n },\n },\n 'Remove',\n ),\n ),\n );\n\n const prodCount = aliases.filter((a) => a.prod).length;\n const body =\n aliases.length === 0\n ? h(\n 'div',\n { class: 'empty' },\n 'No aliases yet. ',\n h(\n 'button',\n { class: 'primary', onclick: () => openAliasPicker(source) },\n 'Pick accounts',\n ),\n )\n : h(\n 'div',\n { class: 'source-summary' },\n h(\n 'span',\n {},\n `${aliases.length} alias(es)`,\n prodCount ? h('span', { class: 'prod-tag' }, `${prodCount} PROD`) : null,\n ),\n h(\n 'button',\n { class: 'link', onclick: () => go('aliases', { source: source.id }) },\n 'View aliases \u2192',\n ),\n );\n return h('section', { class: 'card' }, head, h('div', { class: 'card-body' }, body));\n}\n\nfunction syncMessage(id, report) {\n const parts = [`${report.targets.length} available`];\n if (report.added) parts.push(`${report.added} new`);\n if (report.removed) parts.push(`${report.removed} removed`);\n const orphaned = report.orphanedAliases.length\n ? ` No longer available: ${report.orphanedAliases.join(', ')}.`\n : '';\n return `${id}: ${parts.join(', ')}.${orphaned}`;\n}\n\nfunction aliasRow(alias) {\n const input = h('input', {\n value: alias.region ?? '',\n 'aria-label': `Region for ${alias.name}`,\n class: 'mono',\n spellcheck: 'false',\n });\n const save = h('button', { hidden: true }, 'Save');\n const commit = () =>\n busy(save, async () => {\n const region = input.value.trim();\n const done = await api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, {\n region,\n }).catch(fail);\n if (done) toast(`${alias.name} now runs in ${region}.`);\n await refresh();\n });\n input.addEventListener('input', () => {\n save.hidden = input.value.trim() === (alias.region ?? '');\n });\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !save.hidden) commit();\n if (e.key === 'Escape') {\n input.value = alias.region ?? '';\n save.hidden = true;\n }\n });\n save.addEventListener('click', commit);\n\n const patch = (body, message) =>\n api('PATCH', `/api/aliases/${encodeURIComponent(alias.name)}`, body)\n .then(() => message && toast(message))\n .catch(fail)\n .then(refresh);\n const iconButton = (label, title, onclick, extra = {}) =>\n h('button', { class: 'link', title, 'aria-label': title, onclick, ...extra }, label);\n\n const actions = h(\n 'div',\n { class: 'row-actions' },\n h(\n 'button',\n {\n title: alias.local\n ? 'A local emulator has no web console'\n : `Open the AWS console as ${alias.name}, already signed in`,\n disabled: alias.missing || alias.local,\n onclick: (e) =>\n busy(e.currentTarget, async () => {\n const result = await api(\n 'POST',\n `/api/aliases/${encodeURIComponent(alias.name)}/console`,\n {},\n ).catch(fail);\n if (result) {\n toast(\n result.via === 'signed-in'\n ? `Opened the console for ${alias.name}.`\n : result.via === 'portal'\n ? `Opened ${alias.name} through the SSO portal.`\n : `Opened the console sign-in page for ${alias.name}.`,\n );\n }\n }),\n },\n 'Console',\n ),\n h(\n 'label',\n {\n class: 'prod-toggle',\n title: alias.local\n ? 'A local emulator never reaches the cloud: nothing to protect'\n : 'Production: hop and AI assistants ask before any change',\n },\n h('input', {\n type: 'checkbox',\n checked: alias.prod,\n disabled: alias.local,\n 'aria-label': `${alias.name} is production`,\n onchange: (e) =>\n patch(\n { prod: e.currentTarget.checked },\n e.currentTarget.checked\n ? `${alias.name} is now production.`\n : `${alias.name} is no longer production.`,\n ),\n }),\n 'Prod',\n ),\n h(\n 'label',\n {\n class: 'prod-toggle',\n title: 'New terminals start with this alias (only one per cloud)',\n },\n h('input', {\n type: 'checkbox',\n checked: alias.default,\n 'aria-label': `New terminals start with ${alias.name}`,\n onchange: (e) => {\n const on = e.currentTarget.checked;\n if (\n on &&\n alias.prod &&\n !confirm(`${alias.name} is production. Start every new terminal in it?`)\n ) {\n e.currentTarget.checked = false;\n return;\n }\n patch(\n { default: on },\n on\n ? `New terminals start with ${alias.name}. Open a new one to see it.`\n : `New terminals no longer start with ${alias.name}.`,\n );\n },\n }),\n 'Default',\n ),\n iconButton('\u270E', `Rename ${alias.name}`, () => {\n const to = prompt(`New name for \"${alias.name}\"`, alias.name)?.trim();\n if (to && to !== alias.name) patch({ name: to }, `${alias.name} is now ${to}.`);\n }),\n iconButton(\n '\u2715',\n `Remove ${alias.name}`,\n (e) => {\n if (!confirm(`Remove alias \"${alias.name}\"?`)) return;\n busy(e.currentTarget, async () => {\n await api('DELETE', `/api/aliases/${encodeURIComponent(alias.name)}`).catch(fail);\n await refresh();\n });\n },\n { class: 'link danger-hover' },\n ),\n );\n\n return h(\n 'tr',\n { class: alias.prod ? 'prod' : undefined },\n h(\n 'td',\n { class: 'alias', title: `hop ${alias.name} <command>` },\n alias.name,\n alias.prod ? h('span', { class: 'prod-tag' }, 'PROD') : null,\n alias.local\n ? h('span', { class: 'default-tag', title: 'Runs against a local emulator' }, 'LOCAL')\n : null,\n alias.default\n ? h(\n 'span',\n { class: 'default-tag', title: 'New terminals start with this alias' },\n 'DEFAULT',\n )\n : null,\n ),\n h('td', { class: 'muted' }, alias.source),\n h('td', {}, h('div', { class: 'region' }, input, save)),\n h(\n 'td',\n {},\n alias.missing\n ? h('span', { class: 'missing' }, `${alias.target} (no longer available)`)\n : alias.label,\n ),\n h('td', { style: 'text-align:right' }, actions),\n );\n}\n\n// ---------- dialogs ----------\n\nfunction showDialog(title, body, footer) {\n $dialog.replaceChildren(\n h(\n 'div',\n { class: 'dlg-head' },\n h('h2', {}, title),\n h('button', { class: 'link', 'aria-label': 'Close', onclick: () => $dialog.close() }, '\u2715'),\n ),\n h('div', { class: 'dlg-body' }, body),\n footer ? h('div', { class: 'dlg-foot' }, footer) : null,\n );\n if (!$dialog.open) $dialog.showModal();\n}\n\nfunction suggestSourceId(settings, kind) {\n const host = /^https:\\/\\/([^./]+)\\./.exec(settings.startUrl ?? '')?.[1];\n return slug(host ?? (kind === 'console-login' ? 'personal' : 'main'));\n}\n\nconst slug = (text) =>\n text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 63) || 'main';\n\n/** `previous` refills the form after a failed sign-in: { provider, kind, id, settings, error }. */\nfunction openAddSource(previous) {\n const providers = state.providers.filter((p) => state.enabledProviders.includes(p.id));\n let provider = providers.find((p) => p.id === previous?.provider) ?? providers[0];\n let kind = provider.kinds.find((k) => k.kind === previous?.kind) ?? provider.kinds[0];\n\n const form = h('form', { onsubmit: (e) => e.preventDefault() });\n const nameInput = h('input', {\n id: 'f-name',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n });\n let nameTouched = Boolean(previous);\n nameInput.addEventListener('input', () => {\n nameTouched = true;\n });\n\n const fieldInputs = {};\n const fieldsBox = h('div');\n const nameLabel = h('label', { for: 'f-name' });\n\n function updateSuggestion() {\n if (nameTouched) return;\n const settings = Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n );\n const taken = new Set(state.sources.map((s) => s.id));\n let id = suggestSourceId(settings, kind.kind);\n for (let i = 2; taken.has(id); i++) id = `${suggestSourceId(settings, kind.kind)}-${i}`;\n nameInput.value = id;\n }\n\n function renderFields() {\n for (const key of Object.keys(fieldInputs)) delete fieldInputs[key];\n fieldsBox.replaceChildren(\n ...kind.fields.map((f) => {\n const input = h('input', {\n id: `f-${f.key}`,\n placeholder: f.placeholder ?? f.defaultValue ?? '',\n value: previous?.settings?.[f.key] ?? f.defaultValue ?? '',\n required: true,\n class: 'mono',\n spellcheck: 'false',\n oninput: updateSuggestion,\n });\n fieldInputs[f.key] = input;\n return h('div', { class: 'field' }, h('label', { for: input.id }, f.label), input);\n }),\n );\n nameLabel.textContent = kind.nameLabel ?? 'Short name for this source';\n updateSuggestion();\n }\n\n const providerSelect =\n providers.length > 1\n ? h(\n 'div',\n { class: 'field' },\n h('label', { for: 'f-provider' }, 'Cloud'),\n h(\n 'select',\n {\n id: 'f-provider',\n onchange: (e) => {\n provider = providers.find((p) => p.id === e.target.value);\n kind = provider.kinds[0];\n renderKinds();\n },\n },\n providers.map((p) => h('option', { value: p.id, selected: p === provider }, p.name)),\n ),\n )\n : null;\n\n const kindsBox = h('div', { class: 'choices' });\n function renderKinds() {\n kindsBox.replaceChildren(\n ...provider.kinds.map((k) =>\n h(\n 'label',\n { class: 'choice' },\n h('input', {\n type: 'radio',\n name: 'kind',\n value: k.kind,\n checked: k === kind,\n onchange: () => {\n kind = k;\n renderFields();\n },\n }),\n h('span', {}, h('strong', {}, k.label), k.hint ? h('small', {}, k.hint) : null),\n ),\n ),\n );\n renderFields();\n }\n\n form.append(\n previous?.error\n ? h(\n 'div',\n { class: 'notice error' },\n h('strong', {}, 'Could not sign in, so nothing was saved. '),\n previous.error.message,\n previous.error.hint ? h('div', { class: 'help' }, previous.error.hint) : null,\n )\n : '',\n providerSelect ?? '',\n h('div', { class: 'field' }, h('label', {}, 'What do you want to add?')),\n kindsBox,\n fieldsBox,\n h(\n 'div',\n { class: 'field' },\n nameLabel,\n nameInput,\n h(\n 'span',\n { class: 'help' },\n 'Lowercase letters, numbers and dashes. You will see it in ',\n h('code', {}, 'cloudhop ls'),\n '.',\n ),\n ),\n );\n renderKinds();\n if (previous) nameInput.value = previous.id;\n\n const submit = h('button', { class: 'primary', type: 'submit' }, 'Add and sign in');\n form.addEventListener('submit', async () => {\n if (!form.reportValidity()) return;\n const body = {\n provider: provider.id,\n kind: kind.kind,\n id: nameInput.value.trim(),\n settings: Object.fromEntries(\n Object.entries(fieldInputs).map(([k, i]) => [k, i.value.trim()]),\n ),\n };\n showDialog(\n 'Sign in',\n h(\n 'div',\n { class: 'waiting' },\n h('div', { class: 'spinner' }),\n h('strong', {}, 'Finish signing in in the browser tab that just opened.'),\n h('p', { class: 'help' }, 'This page continues on its own once you approve the request.'),\n ),\n );\n try {\n const report = await api('POST', '/api/sources', body);\n await refresh();\n const source = state.sources.find((s) => s.id === body.id);\n toast(`Signed in to ${body.id}. ${report.targets.length} account/role(s) found.`);\n openAliasPicker(source, report.targets);\n } catch (error) {\n // Nothing was saved: reopen the form with what was typed so it can be fixed.\n await refresh();\n openAddSource({ ...body, error });\n }\n });\n\n showDialog('Add source', form, [\n h('button', { type: 'button', onclick: () => $dialog.close() }, 'Cancel'),\n submit,\n ]);\n submit.setAttribute('form', 'add-form');\n form.id = 'add-form';\n}\n\nasync function openAliasPicker(source, preloaded) {\n let targets = preloaded;\n if (!targets) {\n showDialog(\n `Aliases for ${source.id}`,\n h('div', { class: 'waiting' }, h('div', { class: 'spinner' }), 'Loading accounts\u2026'),\n );\n try {\n targets = (await api('GET', `/api/sources/${encodeURIComponent(source.id)}/targets`)).targets;\n } catch (error) {\n $dialog.close();\n return fail(error);\n }\n }\n\n const used = new Map(\n state.aliases.filter((a) => a.source === source.id).map((a) => [a.target, a.name]),\n );\n const taken = new Set(state.aliases.map((a) => a.name));\n const defaultRegion = source.settings.region ?? '';\n const filter = h('input', {\n type: 'search',\n placeholder: `Filter ${targets.length} account/role(s)\u2026`,\n style: 'width:100%;margin-bottom:12px',\n });\n\n const rows = targets.map((t) => {\n const existing = used.get(t.id);\n let name = t.suggestedAlias;\n for (let i = 2; taken.has(name); i++) name = `${t.suggestedAlias}-${i}`;\n const check = h('input', {\n type: 'checkbox',\n disabled: Boolean(existing),\n 'aria-label': t.label,\n });\n const aliasInput = h('input', {\n value: name,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Alias for ${t.label}`,\n placeholder: 'alias',\n });\n const regionInput = h('input', {\n value: defaultRegion,\n class: 'mono',\n spellcheck: 'false',\n 'aria-label': `Region for ${t.label}`,\n placeholder: 'region',\n });\n // Only the checkbox and its text sit in the label, so clicking the inputs never toggles it.\n const el = h(\n 'div',\n { class: 'target' },\n h(\n 'label',\n { class: 'pick' },\n check,\n h(\n 'span',\n {},\n t.label,\n existing ? h('small', { class: 'already' }, `Already: ${existing}`) : null,\n ),\n ),\n h('div', { class: 'inputs' }, aliasInput, regionInput),\n );\n return { target: t, el, check, aliasInput, regionInput };\n });\n\n filter.addEventListener('input', () => {\n const q = filter.value.trim().toLowerCase();\n for (const row of rows) row.el.hidden = q !== '' && !row.target.label.toLowerCase().includes(q);\n });\n\n const save = h('button', { class: 'primary' }, 'Create aliases');\n save.addEventListener('click', () =>\n busy(save, async () => {\n const aliases = rows\n .filter((r) => r.check.checked)\n .map((r) => ({\n source: source.id,\n target: r.target.id,\n name: r.aliasInput.value.trim(),\n region: r.regionInput.value.trim(),\n }));\n if (aliases.length === 0) return toast('Pick at least one account/role.', { error: true });\n try {\n const result = await api('POST', '/api/aliases', { aliases });\n $dialog.close();\n toast(`Created ${result.created.join(', ')}. Try: hop ${result.created[0]}`);\n await refresh();\n } catch (error) {\n fail(error);\n }\n }),\n );\n\n const body =\n targets.length === 0\n ? h('p', { class: 'empty' }, 'This source has no accounts or roles assigned to you.')\n : h(\n 'div',\n {},\n filter,\n h(\n 'div',\n { class: 'targets' },\n rows.map((r) => r.el),\n ),\n );\n showDialog(`Aliases for ${source.id}`, body, [\n h('span', { class: 'note' }, 'Check an account/role, then name its alias and region.'),\n h('button', { onclick: () => $dialog.close() }, 'Cancel'),\n targets.length ? save : null,\n ]);\n}\n\n$addSource.addEventListener('click', () => openAddSource());\n\nif (!token) {\n $main.replaceChildren(\n h(\n 'div',\n { class: 'hero' },\n h('h2', {}, 'Open the link from your terminal'),\n h('p', {}, 'Run cloudhop ui and open the link it prints. It includes a one-time access key.'),\n ),\n );\n} else {\n readRoute();\n refresh();\n}\n";
|
|
14190
14345
|
|
|
14191
14346
|
// ../ui/src/assets/index.html
|
|
14192
14347
|
var assets_default = `<!doctype html>
|
|
@@ -14368,7 +14523,7 @@ var assets_default = `<!doctype html>
|
|
|
14368
14523
|
}
|
|
14369
14524
|
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
|
14370
14525
|
.badge.valid { color: var(--ok); }
|
|
14371
|
-
.badge.refreshable { color: var(--warn); }
|
|
14526
|
+
.badge.refreshable, .badge.offline { color: var(--warn); }
|
|
14372
14527
|
.badge.expired { color: var(--bad); }
|
|
14373
14528
|
.empty { padding: 14px 16px; color: var(--muted); }
|
|
14374
14529
|
.missing { color: var(--bad); }
|
|
@@ -14455,6 +14610,7 @@ var assets_default = `<!doctype html>
|
|
|
14455
14610
|
.row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; white-space: nowrap; }
|
|
14456
14611
|
.prod-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
|
14457
14612
|
.prod-tag { display: inline-block; white-space: nowrap; margin-left: 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; color: var(--bad); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; vertical-align: 1px; }
|
|
14613
|
+
.default-tag { display: inline-block; white-space: nowrap; margin-left: 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; color: var(--accent-strong); border: 1px solid currentColor; border-radius: 4px; padding: 0 4px; vertical-align: 1px; }
|
|
14458
14614
|
tr.prod td:first-child { box-shadow: inset 3px 0 0 var(--bad); }
|
|
14459
14615
|
button.link.danger-hover:hover { color: var(--bad); }
|
|
14460
14616
|
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
@@ -14623,10 +14779,10 @@ async function uiCommand(app, assistants, { flags }) {
|
|
|
14623
14779
|
|
|
14624
14780
|
// src/container.ts
|
|
14625
14781
|
import { homedir as homedir5 } from "os";
|
|
14626
|
-
import { join as
|
|
14782
|
+
import { join as join11 } from "path";
|
|
14627
14783
|
|
|
14628
14784
|
// ../provider-aws/src/aws-provider.ts
|
|
14629
|
-
import { join as
|
|
14785
|
+
import { join as join10 } from "path";
|
|
14630
14786
|
|
|
14631
14787
|
// ../provider-aws/src/aws-config.ts
|
|
14632
14788
|
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
|
|
@@ -14635,7 +14791,13 @@ var BLOCK_START = "# >>> cloudhop managed >>> (edit with `cloudhop`, not by hand
|
|
|
14635
14791
|
var BLOCK_END = "# <<< cloudhop managed <<<";
|
|
14636
14792
|
function renderBlock(sections) {
|
|
14637
14793
|
const body = sections.map(
|
|
14638
|
-
(s) => [
|
|
14794
|
+
(s) => [
|
|
14795
|
+
`[${s.header}]`,
|
|
14796
|
+
...Object.entries(s.values).map(
|
|
14797
|
+
// A value that starts on a new line is a nested section, like `s3 =` + ` addressing_style = path`.
|
|
14798
|
+
([k, v]) => `${k} =${v.startsWith("\n") ? "" : " "}${v}`
|
|
14799
|
+
)
|
|
14800
|
+
].join("\n")
|
|
14639
14801
|
).join("\n\n");
|
|
14640
14802
|
return `${BLOCK_START}
|
|
14641
14803
|
${body}${body ? "\n" : ""}${BLOCK_END}
|
|
@@ -14739,6 +14901,73 @@ function portalShortcutUrl(startUrl, accountId, roleName, destination) {
|
|
|
14739
14901
|
return `${base}/#/console?${params}`;
|
|
14740
14902
|
}
|
|
14741
14903
|
|
|
14904
|
+
// ../provider-aws/src/emulator.ts
|
|
14905
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
14906
|
+
import { connect } from "net";
|
|
14907
|
+
import { join as join8 } from "path";
|
|
14908
|
+
var AWS_EMULATOR_KIND = "aws-emulator";
|
|
14909
|
+
var LOCALSTACK_KIND = "localstack";
|
|
14910
|
+
var LOCAL_KINDS = [AWS_EMULATOR_KIND, LOCALSTACK_KIND];
|
|
14911
|
+
var LOCAL_ACCOUNT = "000000000000";
|
|
14912
|
+
var DEFAULT_EMULATOR_PORT = "9297";
|
|
14913
|
+
var DEFAULT_LOCALSTACK_ENDPOINT = "http://localhost:4566";
|
|
14914
|
+
var FAKE_KEYS = { accessKeyId: "test", secretAccessKey: "test" };
|
|
14915
|
+
var validatePort = (value) => /^\d{1,5}$/.test(value) && Number(value) > 0 && Number(value) < 65536 ? void 0 : "Use a port number like 9297.";
|
|
14916
|
+
var validateEndpoint = (value) => {
|
|
14917
|
+
if (!/^https?:\/\/[^\s/?#]+\/?$/.test(value)) return "Use a URL like http://localhost:4566.";
|
|
14918
|
+
if (/amazonaws\.com|\.aws(:|\/|$)/i.test(value)) return "Use your emulator, not an AWS endpoint.";
|
|
14919
|
+
return void 0;
|
|
14920
|
+
};
|
|
14921
|
+
function emulatorEndpoint(source) {
|
|
14922
|
+
if (source.kind === AWS_EMULATOR_KIND) {
|
|
14923
|
+
const port2 = Number(source.settings.port ?? DEFAULT_EMULATOR_PORT);
|
|
14924
|
+
return { url: `https://127.0.0.1:${port2}`, host: "127.0.0.1", port: port2 };
|
|
14925
|
+
}
|
|
14926
|
+
const url = new URL(source.settings.endpoint ?? DEFAULT_LOCALSTACK_ENDPOINT);
|
|
14927
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
14928
|
+
return { url: url.origin, host: url.hostname, port };
|
|
14929
|
+
}
|
|
14930
|
+
var emulatorDir = (awsDir, source) => join8(awsDir, "emulator", source.settings.port ?? DEFAULT_EMULATOR_PORT);
|
|
14931
|
+
function localTarget(source) {
|
|
14932
|
+
const what = source.kind === AWS_EMULATOR_KIND ? `AWS Emulator on port ${emulatorEndpoint(source).port}` : `Emulator at ${emulatorEndpoint(source).url}`;
|
|
14933
|
+
return {
|
|
14934
|
+
id: LOCAL_ACCOUNT,
|
|
14935
|
+
label: what,
|
|
14936
|
+
attributes: { accountId: LOCAL_ACCOUNT, accountName: source.id }
|
|
14937
|
+
};
|
|
14938
|
+
}
|
|
14939
|
+
function offlineHint(source) {
|
|
14940
|
+
if (source.kind === AWS_EMULATOR_KIND) {
|
|
14941
|
+
const { port } = emulatorEndpoint(source);
|
|
14942
|
+
return `Start AWS Emulator on port ${port} (\`aws-emulator -p ${port}\` on Linux, its Docker container on macOS and Windows), then try again.`;
|
|
14943
|
+
}
|
|
14944
|
+
return `Start it at ${emulatorEndpoint(source).url} (for LocalStack, \`localstack start\`), then try again.`;
|
|
14945
|
+
}
|
|
14946
|
+
function isListening(host, port, timeoutMs = 800) {
|
|
14947
|
+
return new Promise((resolve3) => {
|
|
14948
|
+
const socket = connect({ host, port });
|
|
14949
|
+
const done = (up) => {
|
|
14950
|
+
socket.destroy();
|
|
14951
|
+
resolve3(up);
|
|
14952
|
+
};
|
|
14953
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
14954
|
+
socket.once("connect", () => done(true));
|
|
14955
|
+
socket.once("error", () => done(false));
|
|
14956
|
+
});
|
|
14957
|
+
}
|
|
14958
|
+
async function emulatorCredentials(awsDir, source) {
|
|
14959
|
+
if (source.kind !== AWS_EMULATOR_KIND) return FAKE_KEYS;
|
|
14960
|
+
const text4 = await readFile7(
|
|
14961
|
+
join8(emulatorDir(awsDir, source), "emulator_credentials"),
|
|
14962
|
+
"utf8"
|
|
14963
|
+
).catch(() => void 0);
|
|
14964
|
+
if (text4 === void 0) return FAKE_KEYS;
|
|
14965
|
+
const value = (key2) => text4.match(new RegExp(`^\\s*${key2}\\s*=\\s*(\\S+)`, "m"))?.[1];
|
|
14966
|
+
const accessKeyId = value("aws_access_key_id");
|
|
14967
|
+
const secretAccessKey = value("aws_secret_access_key");
|
|
14968
|
+
return accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : FAKE_KEYS;
|
|
14969
|
+
}
|
|
14970
|
+
|
|
14742
14971
|
// ../provider-aws/src/http.ts
|
|
14743
14972
|
var AwsHttpError = class extends Error {
|
|
14744
14973
|
constructor(status, code, message) {
|
|
@@ -14957,22 +15186,22 @@ var OidcClient = class {
|
|
|
14957
15186
|
|
|
14958
15187
|
// ../provider-aws/src/sso/token-cache.ts
|
|
14959
15188
|
import { createHash as createHash3 } from "crypto";
|
|
14960
|
-
import { mkdir as mkdir6, readFile as
|
|
14961
|
-
import { join as
|
|
15189
|
+
import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile8 } from "fs/promises";
|
|
15190
|
+
import { join as join9 } from "path";
|
|
14962
15191
|
var EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
|
|
14963
15192
|
function tokenCachePath(awsDir, sessionName) {
|
|
14964
15193
|
const hash = createHash3("sha1").update(sessionName).digest("hex");
|
|
14965
|
-
return
|
|
15194
|
+
return join9(awsDir, "sso", "cache", `${hash}.json`);
|
|
14966
15195
|
}
|
|
14967
15196
|
async function readToken(awsDir, sessionName) {
|
|
14968
15197
|
try {
|
|
14969
|
-
return JSON.parse(await
|
|
15198
|
+
return JSON.parse(await readFile8(tokenCachePath(awsDir, sessionName), "utf8"));
|
|
14970
15199
|
} catch {
|
|
14971
15200
|
return void 0;
|
|
14972
15201
|
}
|
|
14973
15202
|
}
|
|
14974
15203
|
async function writeToken(awsDir, sessionName, token) {
|
|
14975
|
-
await mkdir6(
|
|
15204
|
+
await mkdir6(join9(awsDir, "sso", "cache"), { recursive: true, mode: 448 });
|
|
14976
15205
|
await writeFile8(tokenCachePath(awsDir, sessionName), JSON.stringify(token, null, 2), {
|
|
14977
15206
|
mode: 384
|
|
14978
15207
|
});
|
|
@@ -15152,6 +15381,7 @@ var AwsProvider = class {
|
|
|
15152
15381
|
constructor(options) {
|
|
15153
15382
|
this.options = options;
|
|
15154
15383
|
this.fetchFn = options.fetchFn ?? fetch;
|
|
15384
|
+
this.probe = options.probe ?? ((host, port) => isListening(host, port));
|
|
15155
15385
|
}
|
|
15156
15386
|
options;
|
|
15157
15387
|
id = "aws";
|
|
@@ -15188,15 +15418,47 @@ var AwsProvider = class {
|
|
|
15188
15418
|
validate: validateRegion
|
|
15189
15419
|
}
|
|
15190
15420
|
]
|
|
15421
|
+
},
|
|
15422
|
+
{
|
|
15423
|
+
kind: AWS_EMULATOR_KIND,
|
|
15424
|
+
label: "AWS Emulator (local)",
|
|
15425
|
+
hint: "AWS service APIs on this machine. No AWS account involved.",
|
|
15426
|
+
nameLabel: "Short name for this emulator",
|
|
15427
|
+
local: true,
|
|
15428
|
+
fields: [
|
|
15429
|
+
{ key: "port", label: "Port", defaultValue: DEFAULT_EMULATOR_PORT, validate: validatePort },
|
|
15430
|
+
{ key: "region", label: "Region", defaultValue: "us-east-1", validate: validateRegion }
|
|
15431
|
+
]
|
|
15432
|
+
},
|
|
15433
|
+
{
|
|
15434
|
+
kind: LOCALSTACK_KIND,
|
|
15435
|
+
label: "LocalStack or another local emulator",
|
|
15436
|
+
hint: "Any emulator with an AWS endpoint, signed with test/test.",
|
|
15437
|
+
nameLabel: "Short name for this emulator",
|
|
15438
|
+
local: true,
|
|
15439
|
+
fields: [
|
|
15440
|
+
{
|
|
15441
|
+
key: "endpoint",
|
|
15442
|
+
label: "Endpoint",
|
|
15443
|
+
defaultValue: DEFAULT_LOCALSTACK_ENDPOINT,
|
|
15444
|
+
validate: validateEndpoint
|
|
15445
|
+
},
|
|
15446
|
+
{ key: "region", label: "Region", defaultValue: "us-east-1", validate: validateRegion }
|
|
15447
|
+
]
|
|
15191
15448
|
}
|
|
15192
15449
|
];
|
|
15193
15450
|
fetchFn;
|
|
15451
|
+
probe;
|
|
15194
15452
|
async sessionState(source) {
|
|
15195
15453
|
if (source.kind === CONSOLE_LOGIN_KIND) return "unknown";
|
|
15454
|
+
if (LOCAL_KINDS.includes(source.kind)) {
|
|
15455
|
+
const { host, port } = emulatorEndpoint(source);
|
|
15456
|
+
return await this.probe(host, port) ? "valid" : "offline";
|
|
15457
|
+
}
|
|
15196
15458
|
return tokenState(await readToken(this.options.awsDir, ssoSessionName(source.id)));
|
|
15197
15459
|
}
|
|
15198
15460
|
async refreshSession(source) {
|
|
15199
|
-
if (source.kind === CONSOLE_LOGIN_KIND) return false;
|
|
15461
|
+
if (source.kind === CONSOLE_LOGIN_KIND || LOCAL_KINDS.includes(source.kind)) return false;
|
|
15200
15462
|
const name = ssoSessionName(source.id);
|
|
15201
15463
|
const token = await readToken(this.options.awsDir, name);
|
|
15202
15464
|
const refreshed = token && await refreshSsoToken(token, this.fetchFn);
|
|
@@ -15205,6 +15467,7 @@ var AwsProvider = class {
|
|
|
15205
15467
|
return true;
|
|
15206
15468
|
}
|
|
15207
15469
|
async login(source, interaction) {
|
|
15470
|
+
if (LOCAL_KINDS.includes(source.kind)) return;
|
|
15208
15471
|
if (source.kind === CONSOLE_LOGIN_KIND) {
|
|
15209
15472
|
const args = ["login", "--profile", consoleProfileName(source.id)];
|
|
15210
15473
|
if (source.settings.region) args.push("--region", source.settings.region);
|
|
@@ -15224,6 +15487,7 @@ var AwsProvider = class {
|
|
|
15224
15487
|
}
|
|
15225
15488
|
async discoverTargets(source) {
|
|
15226
15489
|
if (source.kind === CONSOLE_LOGIN_KIND) return [CONSOLE_TARGET];
|
|
15490
|
+
if (LOCAL_KINDS.includes(source.kind)) return [localTarget(source)];
|
|
15227
15491
|
const portal = await this.portal(source);
|
|
15228
15492
|
const accounts = await portal.listAccounts();
|
|
15229
15493
|
const targets = [];
|
|
@@ -15256,6 +15520,13 @@ var AwsProvider = class {
|
|
|
15256
15520
|
});
|
|
15257
15521
|
}
|
|
15258
15522
|
for (const { alias, source, target, region } of aliases) {
|
|
15523
|
+
if (LOCAL_KINDS.includes(source.kind)) {
|
|
15524
|
+
sections.push({
|
|
15525
|
+
header: `profile ${alias.name}`,
|
|
15526
|
+
values: this.localProfile(alias.name, source, region)
|
|
15527
|
+
});
|
|
15528
|
+
continue;
|
|
15529
|
+
}
|
|
15259
15530
|
if (source.kind !== SSO_KIND || !target) continue;
|
|
15260
15531
|
sections.push({
|
|
15261
15532
|
header: `profile ${alias.name}`,
|
|
@@ -15267,7 +15538,7 @@ var AwsProvider = class {
|
|
|
15267
15538
|
}
|
|
15268
15539
|
});
|
|
15269
15540
|
}
|
|
15270
|
-
await writeManagedBlock(
|
|
15541
|
+
await writeManagedBlock(join10(this.options.awsDir, "config"), sections);
|
|
15271
15542
|
}
|
|
15272
15543
|
async processEnv(resolved, mode) {
|
|
15273
15544
|
const region = resolved.region;
|
|
@@ -15280,6 +15551,9 @@ var AwsProvider = class {
|
|
|
15280
15551
|
...regionEnv
|
|
15281
15552
|
};
|
|
15282
15553
|
}
|
|
15554
|
+
if (LOCAL_KINDS.includes(resolved.source.kind)) {
|
|
15555
|
+
return { AWS_PROFILE: void 0, ...await this.localEnv(resolved), ...regionEnv };
|
|
15556
|
+
}
|
|
15283
15557
|
const credentials = resolved.source.kind === CONSOLE_LOGIN_KIND ? await this.exportedCredentials(profile) : await this.roleCredentials(resolved);
|
|
15284
15558
|
return { AWS_PROFILE: void 0, ...credentials, ...regionEnv };
|
|
15285
15559
|
}
|
|
@@ -15305,6 +15579,51 @@ var AwsProvider = class {
|
|
|
15305
15579
|
isSessionError(output) {
|
|
15306
15580
|
return SESSION_ERROR_PATTERNS.some((pattern) => pattern.test(output));
|
|
15307
15581
|
}
|
|
15582
|
+
offlineHint(source) {
|
|
15583
|
+
return offlineHint(source);
|
|
15584
|
+
}
|
|
15585
|
+
localCredentials(resolved) {
|
|
15586
|
+
return emulatorCredentials(this.options.awsDir, resolved.source);
|
|
15587
|
+
}
|
|
15588
|
+
/**
|
|
15589
|
+
* The profile of an emulator alias: its endpoint, the certificate AWS
|
|
15590
|
+
* Emulator writes on every start (same path, new content) and keys read
|
|
15591
|
+
* fresh by `cloudhop credentials` on each call, since they change too.
|
|
15592
|
+
*/
|
|
15593
|
+
localProfile(name, source, region) {
|
|
15594
|
+
const values = {
|
|
15595
|
+
...region ? { region } : {},
|
|
15596
|
+
endpoint_url: emulatorEndpoint(source).url
|
|
15597
|
+
};
|
|
15598
|
+
if (source.kind === AWS_EMULATOR_KIND) {
|
|
15599
|
+
values.ca_bundle = join10(emulatorDir(this.options.awsDir, source), "ca.pem");
|
|
15600
|
+
values.credential_process = `cloudhop credentials ${name}`;
|
|
15601
|
+
} else {
|
|
15602
|
+
values.aws_access_key_id = "test";
|
|
15603
|
+
values.aws_secret_access_key = "test";
|
|
15604
|
+
values.s3 = "\n addressing_style = path";
|
|
15605
|
+
}
|
|
15606
|
+
return values;
|
|
15607
|
+
}
|
|
15608
|
+
/** For `cloudhop exec`: tools that read only variables, not profiles. */
|
|
15609
|
+
async localEnv(resolved) {
|
|
15610
|
+
const { source } = resolved;
|
|
15611
|
+
const keys = await emulatorCredentials(this.options.awsDir, source);
|
|
15612
|
+
const env = {
|
|
15613
|
+
AWS_ACCESS_KEY_ID: keys.accessKeyId,
|
|
15614
|
+
AWS_SECRET_ACCESS_KEY: keys.secretAccessKey,
|
|
15615
|
+
AWS_SESSION_TOKEN: void 0,
|
|
15616
|
+
AWS_ENDPOINT_URL: emulatorEndpoint(source).url,
|
|
15617
|
+
// Never fall back to the credentials of a machine in the cloud.
|
|
15618
|
+
AWS_EC2_METADATA_DISABLED: "true"
|
|
15619
|
+
};
|
|
15620
|
+
if (source.kind === AWS_EMULATOR_KIND) {
|
|
15621
|
+
const ca = join10(emulatorDir(this.options.awsDir, source), "ca.pem");
|
|
15622
|
+
env.AWS_CA_BUNDLE = ca;
|
|
15623
|
+
if (!process.env.NODE_EXTRA_CA_CERTS) env.NODE_EXTRA_CA_CERTS = ca;
|
|
15624
|
+
}
|
|
15625
|
+
return env;
|
|
15626
|
+
}
|
|
15308
15627
|
async roleCredentials(resolved) {
|
|
15309
15628
|
const portal = await this.portal(resolved.source);
|
|
15310
15629
|
const attrs = resolved.target?.attributes ?? {};
|
|
@@ -15352,7 +15671,7 @@ var AwsProvider = class {
|
|
|
15352
15671
|
var browser = new NodeBrowserOpener();
|
|
15353
15672
|
function createApp(env = process.env) {
|
|
15354
15673
|
const runner = new NodeProcessRunner();
|
|
15355
|
-
const awsDir = env.CLOUDHOP_AWS_DIR ??
|
|
15674
|
+
const awsDir = env.CLOUDHOP_AWS_DIR ?? join11(homedir5(), ".aws");
|
|
15356
15675
|
return new CloudhopApp({
|
|
15357
15676
|
config: new YamlConfigStore(),
|
|
15358
15677
|
providers: [
|
|
@@ -15382,8 +15701,8 @@ function createTrustedFolders(env = process.env) {
|
|
|
15382
15701
|
}
|
|
15383
15702
|
|
|
15384
15703
|
// src/update-check.ts
|
|
15385
|
-
import { mkdir as mkdir7, readFile as
|
|
15386
|
-
import { join as
|
|
15704
|
+
import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile9 } from "fs/promises";
|
|
15705
|
+
import { join as join12 } from "path";
|
|
15387
15706
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
15388
15707
|
var REGISTRY_URL = "https://registry.npmjs.org/cloudhop/latest";
|
|
15389
15708
|
var VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.]{1,40})?$/;
|
|
@@ -15393,11 +15712,11 @@ function startUpdateCheck(configDir, current) {
|
|
|
15393
15712
|
if (env.CLOUDHOP_NO_UPDATE_CHECK === "1" || env.CI || !process.stderr.isTTY)
|
|
15394
15713
|
return async () => {
|
|
15395
15714
|
};
|
|
15396
|
-
const file =
|
|
15715
|
+
const file = join12(configDir, "update-check.json");
|
|
15397
15716
|
const work = (async () => {
|
|
15398
15717
|
let cache = { checkedAt: 0 };
|
|
15399
15718
|
try {
|
|
15400
|
-
cache = JSON.parse(await
|
|
15719
|
+
cache = JSON.parse(await readFile9(file, "utf8"));
|
|
15401
15720
|
} catch {
|
|
15402
15721
|
}
|
|
15403
15722
|
const cached = validVersion(cache.latest);
|
|
@@ -15423,7 +15742,7 @@ function startUpdateCheck(configDir, current) {
|
|
|
15423
15742
|
}
|
|
15424
15743
|
|
|
15425
15744
|
// src/cli.ts
|
|
15426
|
-
var HELP = `cloudhop ${"1.
|
|
15745
|
+
var HELP = `cloudhop ${"1.4.0"}
|
|
15427
15746
|
Hop between cloud accounts, roles and regions with one command.
|
|
15428
15747
|
|
|
15429
15748
|
Quick start
|
|
@@ -15442,6 +15761,7 @@ Usage
|
|
|
15442
15761
|
cloudhop use [alias] [--save] Use an alias in this terminal: then plain aws, terraform... run as it
|
|
15443
15762
|
(--save: remember it for this folder in .cloudhoprc)
|
|
15444
15763
|
cloudhop use --off [cloud] Stop using the alias of every cloud, or just one (aws)
|
|
15764
|
+
cloudhop use <alias> --default Also start every new terminal with it (--no-default: stop)
|
|
15445
15765
|
cloudhop allow | deny [folder] Trust (or stop trusting) a folder's .cloudhoprc
|
|
15446
15766
|
cloudhop shell Set up your terminal once: cloudhop use, .cloudhoprc on cd, completion
|
|
15447
15767
|
cloudhop shell starship Prompt segments for Starship (alias in use, PROD in red)
|
|
@@ -15453,6 +15773,9 @@ Setup
|
|
|
15453
15773
|
cloudhop add Add an SSO portal or a personal account
|
|
15454
15774
|
cloudhop add aws sso --start-url <url> [--region <r>] [--name <id>]
|
|
15455
15775
|
cloudhop add aws console-login [--region <r>] [--name <id>]
|
|
15776
|
+
cloudhop add aws aws-emulator [--port 9297] [--region <r>] [--name <id>]
|
|
15777
|
+
cloudhop add aws localstack [--endpoint http://localhost:4566] [--region <r>] [--name <id>]
|
|
15778
|
+
A local emulator: an alias like any other, never reaching AWS
|
|
15456
15779
|
cloudhop team export [--out <file>] | team import <file|https URL>
|
|
15457
15780
|
Share portals and aliases with your team (no secrets)
|
|
15458
15781
|
cloudhop alias [source] Create more aliases from a source
|
|
@@ -15512,7 +15835,7 @@ async function main(argv) {
|
|
|
15512
15835
|
return 0;
|
|
15513
15836
|
}
|
|
15514
15837
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
15515
|
-
console.log("1.
|
|
15838
|
+
console.log("1.4.0");
|
|
15516
15839
|
return 0;
|
|
15517
15840
|
}
|
|
15518
15841
|
const app = createApp();
|
|
@@ -15523,23 +15846,40 @@ async function main(argv) {
|
|
|
15523
15846
|
}
|
|
15524
15847
|
if (command === "shell") {
|
|
15525
15848
|
const clis = app.availableProviders().map((p3) => ({ command: p3.cli.command, provider: p3.id }));
|
|
15526
|
-
await
|
|
15849
|
+
const start = await app.defaultAliases().then(
|
|
15850
|
+
(d) => Object.keys(d).length > 0,
|
|
15851
|
+
() => false
|
|
15852
|
+
);
|
|
15853
|
+
await shellCommand(local, clis, start);
|
|
15527
15854
|
return 0;
|
|
15528
15855
|
}
|
|
15529
15856
|
if (command === "use" && local.some((a2) => a2 === "--emit" || a2.startsWith("--emit="))) {
|
|
15530
15857
|
return useCommand(app, createTrustedFolders(), local);
|
|
15531
15858
|
}
|
|
15859
|
+
if (command === "credentials") {
|
|
15860
|
+
const name = local[0];
|
|
15861
|
+
if (!name) throw new CloudhopError("INVALID_NAME", "Usage: cloudhop credentials <alias>");
|
|
15862
|
+
const keys = await app.localCredentials(name);
|
|
15863
|
+
const json = {
|
|
15864
|
+
Version: 1,
|
|
15865
|
+
AccessKeyId: keys.accessKeyId,
|
|
15866
|
+
SecretAccessKey: keys.secretAccessKey
|
|
15867
|
+
};
|
|
15868
|
+
process.stdout.write(`${JSON.stringify(json)}
|
|
15869
|
+
`);
|
|
15870
|
+
return 0;
|
|
15871
|
+
}
|
|
15532
15872
|
if (command === "mcp") {
|
|
15533
15873
|
await serveMcp({
|
|
15534
15874
|
app,
|
|
15535
|
-
version: "1.
|
|
15875
|
+
version: "1.4.0",
|
|
15536
15876
|
context: () => shellContext(createTrustedFolders())
|
|
15537
15877
|
});
|
|
15538
15878
|
return 0;
|
|
15539
15879
|
}
|
|
15540
|
-
const assistants = createAssistants("1.
|
|
15880
|
+
const assistants = createAssistants("1.4.0");
|
|
15541
15881
|
await refreshAssistantSkills(assistants, command);
|
|
15542
|
-
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.
|
|
15882
|
+
const finishUpdateCheck = startUpdateCheck(defaultConfigDir(), "1.4.0");
|
|
15543
15883
|
try {
|
|
15544
15884
|
return await dispatch(app, assistants, command, args, local);
|
|
15545
15885
|
} finally {
|