uplink-cli 0.1.39 → 0.2.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/AGENTS.md +19 -3
- package/CHANGELOG.md +15 -0
- package/README.md +29 -8
- package/cli/src/index.ts +11 -0
- package/cli/src/registrars/dreamhost.ts +129 -0
- package/cli/src/registrars/godaddy.ts +6 -0
- package/cli/src/registrars/index.ts +2 -0
- package/cli/src/registrars/types.ts +3 -1
- package/cli/src/subcommands/domains.ts +46 -19
- package/cli/src/subcommands/login.ts +85 -0
- package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
- package/cli/src/subcommands/menu/menus/domains.ts +4 -6
- package/cli/src/subcommands/menu/requests.ts +9 -2
- package/cli/src/subcommands/signup.ts +2 -2
- package/cli/src/subcommands/tunnel.ts +2 -0
- package/cli/src/tui/App.tsx +5 -0
- package/cli/src/tui/HomeStatus.tsx +53 -20
- package/cli/src/tui/session.mts +133 -248
- package/cli/src/tui/snapshot.ts +30 -1
- package/cli/src/utils/api-base.ts +11 -0
- package/cli/src/utils/credentials.ts +58 -0
- package/cli/src/utils/domain-availability.ts +56 -0
- package/cli/src/utils/guest-access.ts +38 -0
- package/cli/src/utils/login-flow.ts +57 -0
- package/docs/AGENTS.md +19 -3
- package/docs/HOSTING.md +55 -0
- package/docs/MENU_STRUCTURE.md +10 -6
- package/docs/PRODUCT.md +64 -0
- package/docs/README.md +7 -3
- package/package.json +5 -2
package/AGENTS.md
CHANGED
|
@@ -3,17 +3,20 @@
|
|
|
3
3
|
For agents (Cursor, Claude Code, Codex, Windsurf, and similar) to use Uplink **non-interactively**.
|
|
4
4
|
|
|
5
5
|
Install: `npm install -g uplink-cli` or `npx uplink-cli …`
|
|
6
|
+
Until **0.2.0** is on npm: `npm install -g github:firstprinciplecode/uplink#v0.2.0`
|
|
6
7
|
Package name: `uplink-cli` · Binary: `uplink`
|
|
7
8
|
|
|
8
9
|
## Auth
|
|
9
10
|
|
|
11
|
+
- `uplink tunnel create` automatically creates guest access when no token exists. Guest access includes **1 active tunnel**, expiring after **24 hours**.
|
|
10
12
|
- Use `AGENTCLOUD_TOKEN` (bearer). Prefer stdin over argv:
|
|
11
13
|
```bash
|
|
12
14
|
echo "$TOKEN" | uplink --token-stdin …
|
|
13
15
|
```
|
|
16
|
+
- Humans: `uplink login --email you@example.com` then `--code 123456`. This upgrades current guest access, preserves its tunnel, and unlocks persistent features. Credentials are saved to `~/.uplink/credentials` (chmod 600).
|
|
14
17
|
- API base: `--api-base https://api.uplink.spot` or `AGENTCLOUD_API_BASE`.
|
|
15
18
|
|
|
16
|
-
##
|
|
19
|
+
## Explicit guest token (optional)
|
|
17
20
|
|
|
18
21
|
```bash
|
|
19
22
|
uplink signup --json
|
|
@@ -26,6 +29,8 @@ Save `token` from the JSON — it is shown only once. Then:
|
|
|
26
29
|
export AGENTCLOUD_TOKEN='…'
|
|
27
30
|
```
|
|
28
31
|
|
|
32
|
+
Explicit signup creates guest access. A later email login upgrades that guest account when the current token is available.
|
|
33
|
+
|
|
29
34
|
## Machine-mode contract
|
|
30
35
|
|
|
31
36
|
| Rule | Detail |
|
|
@@ -37,8 +42,12 @@ export AGENTCLOUD_TOKEN='…'
|
|
|
37
42
|
| Exit `20` | network |
|
|
38
43
|
| Exit `30` | server / unknown |
|
|
39
44
|
|
|
45
|
+
Guest accounts can share one local port and use public domain search. Hosting, databases, aliases, and custom domains require a verified email account. Gate error: `ACCOUNT_VERIFICATION_REQUIRED`.
|
|
46
|
+
|
|
40
47
|
Premium aliases may return `ALIAS_NOT_ENABLED` / `ALIAS_LIMIT_REACHED`.
|
|
41
48
|
|
|
49
|
+
Free hosting (new accounts): **1 app**, **100 MB** of live artifacts, **no custom domains**, app **sleeps after 30 minutes idle**. Errors: `HOST_APP_LIMIT_REACHED`, `HOST_STORAGE_LIMIT_REACHED`, `HOST_DOMAIN_NOT_ENABLED`. Check quota: the `hosting` object on `GET /v1/me`.
|
|
50
|
+
|
|
42
51
|
## Tunnels (share localhost)
|
|
43
52
|
|
|
44
53
|
`tunnel create` **creates the API record and starts the local client** so the public URL works. Use `--api-only` only if you will start the client yourself.
|
|
@@ -110,6 +119,7 @@ The bare `uplink domains` search TUI is **optional** and not bundled with npm
|
|
|
110
119
|
uplink domains providers connect godaddy --token-env GODADDY_PAT --json
|
|
111
120
|
uplink domains providers connect cloudflare --token-env CF_API_TOKEN --json
|
|
112
121
|
uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --json
|
|
122
|
+
uplink domains providers connect dreamhost --token-env DREAMHOST_API_KEY --json
|
|
113
123
|
uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER --json
|
|
114
124
|
uplink domains providers list --json
|
|
115
125
|
uplink domains providers disconnect godaddy --json
|
|
@@ -123,7 +133,7 @@ echo "$TOKEN" | uplink --token-stdin host domains list --id app_xxx --json
|
|
|
123
133
|
echo "$TOKEN" | uplink --token-stdin host domains remove --id app_xxx --hostname example.com --json
|
|
124
134
|
```
|
|
125
135
|
|
|
126
|
-
Do not treat RDAP “available” as buyable unless `domains check` says `buyable: true`. Purchase is not wired yet.
|
|
136
|
+
`domains check` works without a registrar: it falls back to public DNS/RDAP and returns `provider: "public"` with no price. Do not treat RDAP “available” as buyable unless `domains check` says `buyable: true`. Purchase is not wired yet.
|
|
127
137
|
|
|
128
138
|
## Databases (optional)
|
|
129
139
|
|
|
@@ -142,6 +152,10 @@ echo "$TOKEN" | uplink --token-stdin db delete --id db_xxx --yes --json
|
|
|
142
152
|
| Auth errors | Missing/invalid `AGENTCLOUD_TOKEN`; use `--token-stdin` |
|
|
143
153
|
| `ALIAS_NOT_ENABLED` | Account does not have permanent aliases |
|
|
144
154
|
| Domain search TUI missing | Expected on npm — use `domains list` / `check` / `host domains *` |
|
|
155
|
+
| `HOST_APP_LIMIT_REACHED` | Free plan is 1 hosted app — delete one or the account needs hosting granted |
|
|
156
|
+
| `HOST_STORAGE_LIMIT_REACHED` | Upload exceeds the 100 MB free hosting budget |
|
|
157
|
+
| `HOST_DOMAIN_NOT_ENABLED` | Custom domains are paid — `*.host.uplink.spot` still works |
|
|
158
|
+
| First request after idle is slow | Free apps sleep after 30 minutes; the router wakes them |
|
|
145
159
|
| Hosting stuck `queued` | Edge builder/runner issue — check `host status` / `host logs` |
|
|
146
160
|
|
|
147
161
|
## Interactive menu
|
|
@@ -157,5 +171,7 @@ Agents should prefer the non-interactive commands above.
|
|
|
157
171
|
## More
|
|
158
172
|
|
|
159
173
|
- Menu map: `docs/MENU_STRUCTURE.md`
|
|
174
|
+
- Hosting: `docs/HOSTING.md`
|
|
175
|
+
- Product: `docs/PRODUCT.md`
|
|
160
176
|
- Website: https://uplink.spot
|
|
161
|
-
- npm: https://www.npmjs.com/package/uplink-cli
|
|
177
|
+
- npm: https://www.npmjs.com/package/uplink-cli (0.2.0 pending; use the GitHub tag until then)
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-08-28
|
|
4
|
+
|
|
5
|
+
Tagged on GitHub. npm publish is pending org auth (`firstprinciplellc`). Until then install from the tag (see README).
|
|
6
|
+
|
|
7
|
+
- **Guest access** — `tunnel create` and the interactive menu mint a guest token when none exists (1 active tunnel, 24-hour expiry). Stored in `~/.uplink/credentials`.
|
|
8
|
+
- **Email login** — `uplink login --email` / `--code` upgrades that guest account, keeps the tunnel, and unlocks hosting, databases, and registrar inventory.
|
|
9
|
+
- **Public domain check** — `uplink domains check` works with no registrar (DNS + RDAP). Connect a registrar for price.
|
|
10
|
+
- **DreamHost** — inventory adapter; DNS-scoped keys and multiple `--token-env` names are supported.
|
|
11
|
+
- **GoDaddy** — stop falling back to the v1 API on rate-limit / auth errors.
|
|
12
|
+
|
|
13
|
+
## 0.1.39 — 2026-08-27
|
|
14
|
+
|
|
15
|
+
Current version on npm. Agent-ready CLI: `--json`, `--token-stdin`, tunnels, hosting, domains.
|
package/README.md
CHANGED
|
@@ -14,23 +14,40 @@
|
|
|
14
14
|
Learn more at [uplink.spot](https://uplink.spot)
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
|
+
|
|
18
|
+
**npm (current published: 0.1.39)** — guest login and DreamHost land in **0.2.0**, tagged on GitHub, npm publish waiting on org auth.
|
|
19
|
+
|
|
17
20
|
```bash
|
|
21
|
+
# Until 0.2.0 is on npm:
|
|
22
|
+
npm install -g github:firstprinciplecode/uplink#v0.2.0
|
|
23
|
+
|
|
24
|
+
# After publish:
|
|
18
25
|
npm install -g uplink-cli
|
|
19
|
-
# or
|
|
20
26
|
npx uplink-cli --help
|
|
21
27
|
```
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
Changelog: [CHANGELOG.md](./CHANGELOG.md). Product overview: [docs/PRODUCT.md](./docs/PRODUCT.md). Hosting: [docs/HOSTING.md](./docs/HOSTING.md).
|
|
30
|
+
|
|
31
|
+
## Start without signup
|
|
24
32
|
```bash
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export AGENTCLOUD_TOKEN=your-token-here # save securely
|
|
33
|
+
# Guest token is created and saved automatically
|
|
34
|
+
uplink tunnel create --port 3000 --json
|
|
28
35
|
```
|
|
29
36
|
|
|
37
|
+
Guest access includes one active tunnel for 24 hours and public domain search.
|
|
38
|
+
|
|
39
|
+
## Unlock persistent features
|
|
40
|
+
```bash
|
|
41
|
+
uplink login --email you@example.com
|
|
42
|
+
uplink login --email you@example.com --code 123456 --json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Email verification preserves the guest tunnel and unlocks hosting, databases, and registrar features. Aliases and custom domains still depend on the account plan.
|
|
46
|
+
|
|
30
47
|
## Quick start (agents)
|
|
31
48
|
```bash
|
|
32
|
-
#
|
|
33
|
-
|
|
49
|
+
# Creates guest access automatically when needed
|
|
50
|
+
uplink tunnel create --port 3000 --json
|
|
34
51
|
|
|
35
52
|
# List / stop
|
|
36
53
|
echo "$AGENTCLOUD_TOKEN" | uplink --token-stdin tunnel list --json
|
|
@@ -69,6 +86,7 @@ echo "$AGENTCLOUD_TOKEN" | uplink --token-stdin \
|
|
|
69
86
|
|
|
70
87
|
## Key commands
|
|
71
88
|
- `uplink menu` — interactive UI
|
|
89
|
+
- `uplink login --email <email> [--code <code>] [--json]`
|
|
72
90
|
- `uplink signup --json`
|
|
73
91
|
- `uplink tunnel create --port <p> [--alias <a>] [--json]` — starts client
|
|
74
92
|
- `uplink tunnel list|stats|stop|alias-set|alias-delete --json`
|
|
@@ -86,13 +104,16 @@ export TUNNEL_DOMAIN=x.uplink.spot
|
|
|
86
104
|
|
|
87
105
|
## Troubleshooting
|
|
88
106
|
- URL not live — ensure something is listening on the port and the client started (`tunnel list` → `connected`)
|
|
89
|
-
- Auth errors — verify `AGENTCLOUD_TOKEN`; prefer `--token-stdin`
|
|
107
|
+
- Auth errors — verify `AGENTCLOUD_TOKEN` or `~/.uplink/credentials`; prefer `--token-stdin` for agents
|
|
90
108
|
- Relay errors — `TUNNEL_CTRL=tunnel.uplink.spot:7071`
|
|
91
109
|
- Domain search TUI — not bundled on npm; use `domains list` / `check` / `host domains *`
|
|
92
110
|
|
|
93
111
|
## Docs
|
|
94
112
|
- Agents: [AGENTS.md](./AGENTS.md)
|
|
113
|
+
- Product: [docs/PRODUCT.md](./docs/PRODUCT.md)
|
|
114
|
+
- Hosting: [docs/HOSTING.md](./docs/HOSTING.md)
|
|
95
115
|
- Menu: [docs/MENU_STRUCTURE.md](./docs/MENU_STRUCTURE.md)
|
|
116
|
+
- Changelog: [CHANGELOG.md](./CHANGELOG.md)
|
|
96
117
|
|
|
97
118
|
## License
|
|
98
119
|
MIT — see [LICENSE](./LICENSE)
|
package/cli/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { devCommand } from "./subcommands/dev";
|
|
|
5
5
|
import { adminCommand } from "./subcommands/admin";
|
|
6
6
|
import { menuCommand } from "./subcommands/menu";
|
|
7
7
|
import { tunnelCommand } from "./subcommands/tunnel";
|
|
8
|
+
import { loginCommand } from "./subcommands/login";
|
|
8
9
|
import { signupCommand } from "./subcommands/signup";
|
|
9
10
|
import { systemCommand } from "./subcommands/system";
|
|
10
11
|
import { hostCommand } from "./subcommands/host";
|
|
@@ -12,6 +13,7 @@ import { domainsCommand } from "./subcommands/domains";
|
|
|
12
13
|
import { readFileSync } from "fs";
|
|
13
14
|
import { join } from "path";
|
|
14
15
|
import { ensureApiBase, parseTokenEnv } from "./utils/api-base";
|
|
16
|
+
import { readStoredCredentials } from "./utils/credentials";
|
|
15
17
|
import { handleError } from "./utils/machine";
|
|
16
18
|
|
|
17
19
|
// Get version from package.json (CommonJS build: __dirname available)
|
|
@@ -32,6 +34,7 @@ program.addCommand(devCommand);
|
|
|
32
34
|
program.addCommand(adminCommand);
|
|
33
35
|
program.addCommand(tunnelCommand);
|
|
34
36
|
program.addCommand(signupCommand);
|
|
37
|
+
program.addCommand(loginCommand);
|
|
35
38
|
program.addCommand(systemCommand);
|
|
36
39
|
program.addCommand(menuCommand);
|
|
37
40
|
program.addCommand(hostCommand);
|
|
@@ -71,6 +74,14 @@ program.hook("preAction", async (thisCommand) => {
|
|
|
71
74
|
if (!process.env.AGENTCLOUD_API_BASE && parsed.apiBase) {
|
|
72
75
|
process.env.AGENTCLOUD_API_BASE = parsed.apiBase;
|
|
73
76
|
}
|
|
77
|
+
} else {
|
|
78
|
+
const stored = readStoredCredentials();
|
|
79
|
+
if (stored?.token && !process.env.AGENTCLOUD_TOKEN) {
|
|
80
|
+
process.env.AGENTCLOUD_TOKEN = stored.token;
|
|
81
|
+
if (!process.env.AGENTCLOUD_API_BASE && stored.apiBase) {
|
|
82
|
+
process.env.AGENTCLOUD_API_BASE = stored.apiBase;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { InventoryDomain, RegistrarAdapter, RegistrarCredentials } from "./types";
|
|
2
|
+
|
|
3
|
+
const BASE = "https://api.dreamhost.com/";
|
|
4
|
+
const NO_ACCESS = "this_key_cannot_access_this_cmd";
|
|
5
|
+
|
|
6
|
+
type DreamhostEnvelope = {
|
|
7
|
+
result?: string;
|
|
8
|
+
data?: unknown;
|
|
9
|
+
reason?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function keysOf(creds: RegistrarCredentials): string[] {
|
|
13
|
+
const keys = [creds.apiKey || creds.token, ...(creds.extraTokens || [])];
|
|
14
|
+
return [...new Set(keys.filter((key): key is string => Boolean(key)))];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function dreamhostCmd(key: string, cmd: string): Promise<unknown> {
|
|
18
|
+
const url = `${BASE}?key=${encodeURIComponent(key)}&cmd=${encodeURIComponent(cmd)}&format=json`;
|
|
19
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
let body: DreamhostEnvelope;
|
|
22
|
+
try {
|
|
23
|
+
body = text ? (JSON.parse(text) as DreamhostEnvelope) : {};
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`DreamHost returned a non-JSON response (HTTP ${res.status})`);
|
|
26
|
+
}
|
|
27
|
+
if (!res.ok || body.result !== "success") {
|
|
28
|
+
const detail = body.reason || (typeof body.data === "string" ? body.data : "") || `HTTP ${res.status}`;
|
|
29
|
+
throw new Error(`DreamHost ${cmd} failed: ${detail}`);
|
|
30
|
+
}
|
|
31
|
+
return body.data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rowsOf(data: unknown): Array<Record<string, unknown>> {
|
|
35
|
+
if (!Array.isArray(data)) return [];
|
|
36
|
+
return data.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function domainOf(row: Record<string, unknown>): string | undefined {
|
|
40
|
+
const value = row.domain ?? row.name ?? row.domain_name;
|
|
41
|
+
return typeof value === "string" && value.includes(".") ? value.toLowerCase() : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function expiryOf(row: Record<string, unknown>): string | undefined {
|
|
45
|
+
const value = row.expires ?? row.expiry ?? row.expiration_date;
|
|
46
|
+
return typeof value === "string" && value ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* DreamHost API keys are scoped per command. Panel keys with domain access can
|
|
51
|
+
* list registrations; DNS-only keys can still reveal the account's domains via
|
|
52
|
+
* dns-list_records zones, so each lookup falls through that chain.
|
|
53
|
+
*/
|
|
54
|
+
async function listForKey(key: string): Promise<InventoryDomain[]> {
|
|
55
|
+
const out: InventoryDomain[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const push = (domain: string | undefined, expiresAt?: string) => {
|
|
58
|
+
if (!domain || seen.has(domain)) return;
|
|
59
|
+
seen.add(domain);
|
|
60
|
+
out.push({ domain, provider: "dreamhost", status: "owned", expiresAt });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let lastError: Error | null = null;
|
|
64
|
+
for (const cmd of ["domain-list_registrations", "domain-list_domains"]) {
|
|
65
|
+
try {
|
|
66
|
+
for (const row of rowsOf(await dreamhostCmd(key, cmd))) {
|
|
67
|
+
push(domainOf(row), expiryOf(row));
|
|
68
|
+
}
|
|
69
|
+
if (out.length > 0) return out;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
72
|
+
if (!lastError.message.includes(NO_ACCESS)) throw lastError;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
for (const row of rowsOf(await dreamhostCmd(key, "dns-list_records"))) {
|
|
78
|
+
const zone = row.zone;
|
|
79
|
+
if (typeof zone === "string" && zone.includes(".")) push(zone.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
84
|
+
throw lastError && err.message.includes(NO_ACCESS) ? lastError : err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const dreamhostAdapter: RegistrarAdapter = {
|
|
89
|
+
id: "dreamhost",
|
|
90
|
+
label: "DreamHost",
|
|
91
|
+
connectHelp:
|
|
92
|
+
"Panel API key(s); DNS-scoped keys work too. --token-env DREAMHOST_API_KEY (comma-separate env names for multiple accounts)",
|
|
93
|
+
async verify(creds) {
|
|
94
|
+
const keys = keysOf(creds);
|
|
95
|
+
if (keys.length === 0) throw new Error("DreamHost API key is missing");
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
await listForKey(key);
|
|
98
|
+
}
|
|
99
|
+
return creds;
|
|
100
|
+
},
|
|
101
|
+
async listDomains(creds) {
|
|
102
|
+
const out: InventoryDomain[] = [];
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const errors: string[] = [];
|
|
105
|
+
for (const key of keysOf(creds)) {
|
|
106
|
+
try {
|
|
107
|
+
for (const item of await listForKey(key)) {
|
|
108
|
+
if (seen.has(item.domain)) continue;
|
|
109
|
+
seen.add(item.domain);
|
|
110
|
+
out.push(item);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (out.length === 0 && errors.length > 0) throw new Error(errors.join("; "));
|
|
117
|
+
return out.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
118
|
+
},
|
|
119
|
+
async check(creds, domain) {
|
|
120
|
+
// DreamHost has no availability API; it can only confirm ownership.
|
|
121
|
+
// Throw for unowned domains so `domains check` falls through to a
|
|
122
|
+
// provider that can quote (or the public DNS/RDAP fallback).
|
|
123
|
+
const owned = await this.listDomains(creds);
|
|
124
|
+
if (owned.some((item) => item.domain === domain.toLowerCase())) {
|
|
125
|
+
return { domain, provider: "dreamhost", status: "owned", buyable: false };
|
|
126
|
+
}
|
|
127
|
+
throw new Error("DreamHost cannot check availability for domains outside the account");
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -80,6 +80,12 @@ export const godaddyAdapter: RegistrarAdapter = {
|
|
|
80
80
|
return { domain, provider: "godaddy", status: "taken", buyable: false };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Fall back only when this API version is unavailable. Retrying a 401,
|
|
84
|
+
// 429, or 5xx against v1 wastes quota and can hide the real failure.
|
|
85
|
+
if (v3.status !== 404 && v3.status !== 405) {
|
|
86
|
+
throw httpError(v3, await v3.text());
|
|
87
|
+
}
|
|
88
|
+
|
|
83
89
|
const v1 = await godaddyFetch(
|
|
84
90
|
creds,
|
|
85
91
|
`/v1/domains/available?domain=${encodeURIComponent(domain)}`
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cloudflareAdapter } from "./cloudflare";
|
|
2
|
+
import { dreamhostAdapter } from "./dreamhost";
|
|
2
3
|
import { godaddyAdapter } from "./godaddy";
|
|
3
4
|
import { hostingerAdapter } from "./hostinger";
|
|
4
5
|
import { namecheapAdapter } from "./namecheap";
|
|
@@ -10,6 +11,7 @@ export const adapters: RegistrarAdapter[] = [
|
|
|
10
11
|
cloudflareAdapter,
|
|
11
12
|
hostingerAdapter,
|
|
12
13
|
namecheapAdapter,
|
|
14
|
+
dreamhostAdapter,
|
|
13
15
|
];
|
|
14
16
|
|
|
15
17
|
const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PROVIDER_IDS = ["godaddy", "cloudflare", "hostinger", "namecheap"] as const;
|
|
1
|
+
export const PROVIDER_IDS = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"] as const;
|
|
2
2
|
export type ProviderId = (typeof PROVIDER_IDS)[number];
|
|
3
3
|
|
|
4
4
|
export function isProviderId(value: string): value is ProviderId {
|
|
@@ -10,6 +10,8 @@ export type RegistrarCredentials = {
|
|
|
10
10
|
apiUser?: string;
|
|
11
11
|
apiKey?: string;
|
|
12
12
|
accountId?: string;
|
|
13
|
+
/** Additional API keys for providers with multiple accounts (e.g. DreamHost). */
|
|
14
|
+
extraTokens?: string[];
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export type InventoryDomain = {
|
|
@@ -14,8 +14,13 @@ import {
|
|
|
14
14
|
type RegistrarCredentials,
|
|
15
15
|
} from "../registrars";
|
|
16
16
|
import { canPrompt, promptSecret, readEnvValue } from "../registrars/secret";
|
|
17
|
+
import {
|
|
18
|
+
checkDomainAvailability,
|
|
19
|
+
formatPublicAvailability,
|
|
20
|
+
} from "../utils/domain-availability";
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
// DreamHost last: it can only confirm ownership, not quote availability.
|
|
23
|
+
const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"];
|
|
19
24
|
|
|
20
25
|
function parseProvider(raw?: string): ProviderId | undefined {
|
|
21
26
|
if (!raw) return undefined;
|
|
@@ -49,15 +54,22 @@ async function credentialsFromFlags(
|
|
|
49
54
|
return { apiKey, apiUser };
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
// --token-env accepts a comma-separated list of env names for providers
|
|
58
|
+
// with multiple accounts (e.g. dreamhost).
|
|
59
|
+
const envNames = (opts.tokenEnv || "")
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((name) => name.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
const tokens = envNames.length
|
|
64
|
+
? envNames.map((name) => readEnvValue(name))
|
|
54
65
|
: interactive
|
|
55
|
-
? await promptSecret(`${provider} API token: `)
|
|
56
|
-
:
|
|
57
|
-
if (!
|
|
66
|
+
? [await promptSecret(`${provider} API token: `)].filter(Boolean)
|
|
67
|
+
: [];
|
|
68
|
+
if (tokens.length === 0 || !tokens[0]) {
|
|
58
69
|
throw new Error(`${provider} needs --token-env <VAR> (do not pass the secret on the command line)`);
|
|
59
70
|
}
|
|
60
|
-
const creds: RegistrarCredentials = { token };
|
|
71
|
+
const creds: RegistrarCredentials = { token: tokens[0] };
|
|
72
|
+
if (tokens.length > 1) creds.extraTokens = tokens.slice(1);
|
|
61
73
|
if (opts.accountEnv) creds.accountId = readEnvValue(opts.accountEnv);
|
|
62
74
|
return creds;
|
|
63
75
|
}
|
|
@@ -88,12 +100,6 @@ async function listInventory(providerFilter?: ProviderId): Promise<InventoryDoma
|
|
|
88
100
|
|
|
89
101
|
async function quoteDomain(domain: string, providerFilter?: ProviderId): Promise<DomainQuote> {
|
|
90
102
|
const store = readRegistrarStore();
|
|
91
|
-
const inventory = await listInventory(providerFilter).catch(() => [] as InventoryDomain[]);
|
|
92
|
-
const owned = inventory.find((item) => item.domain === domain);
|
|
93
|
-
if (owned) {
|
|
94
|
-
return { domain, provider: owned.provider, status: "owned", buyable: false };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
103
|
const ids = providerFilter
|
|
98
104
|
? [providerFilter]
|
|
99
105
|
: CHECK_ORDER.filter((id) => store[id]);
|
|
@@ -138,7 +144,7 @@ domainsCommand.action(() => {
|
|
|
138
144
|
domainsCommand
|
|
139
145
|
.command("list")
|
|
140
146
|
.description("List domains owned at connected registrars")
|
|
141
|
-
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap)")
|
|
147
|
+
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost)")
|
|
142
148
|
.option("--json", "Output JSON", false)
|
|
143
149
|
.action(async (opts) => {
|
|
144
150
|
try {
|
|
@@ -163,7 +169,7 @@ domainsCommand
|
|
|
163
169
|
|
|
164
170
|
domainsCommand
|
|
165
171
|
.command("check")
|
|
166
|
-
.description("
|
|
172
|
+
.description("Check domain availability (public DNS/RDAP; connected registrars add price)")
|
|
167
173
|
.argument("<domain>", "Domain name (e.g. example.com)")
|
|
168
174
|
.option("--provider <id>", "Which registrar to ask")
|
|
169
175
|
.option("--json", "Output JSON", false)
|
|
@@ -172,6 +178,27 @@ domainsCommand
|
|
|
172
178
|
const domain = String(domainArg).trim().toLowerCase();
|
|
173
179
|
if (!domain.includes(".")) throw new Error("Pass a full domain like example.com");
|
|
174
180
|
const provider = parseProvider(opts.provider);
|
|
181
|
+
|
|
182
|
+
// No registrar connected: fall back to public DNS/RDAP availability.
|
|
183
|
+
const store = readRegistrarStore();
|
|
184
|
+
const hasRegistrar = provider ? Boolean(store[provider]) : CHECK_ORDER.some((id) => store[id]);
|
|
185
|
+
if (!hasRegistrar) {
|
|
186
|
+
const result = await checkDomainAvailability(domain);
|
|
187
|
+
if (opts.json) {
|
|
188
|
+
printJson({
|
|
189
|
+
domain: result.domain,
|
|
190
|
+
provider: "public",
|
|
191
|
+
status: result.status,
|
|
192
|
+
buyable: null,
|
|
193
|
+
detail: result.detail,
|
|
194
|
+
note: "Connect a registrar for price and purchase info.",
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
console.log(formatPublicAvailability(result));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
175
202
|
const quote = await quoteDomain(domain, provider);
|
|
176
203
|
if (opts.json) {
|
|
177
204
|
printJson(quote);
|
|
@@ -217,8 +244,8 @@ providers
|
|
|
217
244
|
providers
|
|
218
245
|
.command("connect")
|
|
219
246
|
.description("Save a registrar credential after a live check")
|
|
220
|
-
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap")
|
|
221
|
-
.option("--token-env <name>", "Env var holding the API token
|
|
247
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
|
|
248
|
+
.option("--token-env <name>", "Env var holding the API token (comma-separate names for multiple keys)")
|
|
222
249
|
.option("--user-env <name>", "Env var holding the Namecheap API user")
|
|
223
250
|
.option("--account-env <name>", "Env var holding a Cloudflare account id (optional)")
|
|
224
251
|
.option("--json", "Output JSON", false)
|
|
@@ -226,7 +253,7 @@ providers
|
|
|
226
253
|
try {
|
|
227
254
|
const provider = String(providerArg).toLowerCase();
|
|
228
255
|
if (!isProviderId(provider)) {
|
|
229
|
-
throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, or
|
|
256
|
+
throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, namecheap, or dreamhost`);
|
|
230
257
|
}
|
|
231
258
|
const adapter = getAdapter(provider);
|
|
232
259
|
const creds = await credentialsFromFlags(provider, opts);
|
|
@@ -250,7 +277,7 @@ providers
|
|
|
250
277
|
providers
|
|
251
278
|
.command("disconnect")
|
|
252
279
|
.description("Remove a saved registrar credential")
|
|
253
|
-
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap")
|
|
280
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
|
|
254
281
|
.option("--json", "Output JSON", false)
|
|
255
282
|
.action((providerArg: string, opts) => {
|
|
256
283
|
try {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createInterface } from "readline";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
import { formatTokenForEnv, getResolvedApiBase } from "../utils/api-base";
|
|
5
|
+
import { isEmail, normalizeEmail, persistLogin, requestLoginCode, verifyLoginCode } from "../utils/login-flow";
|
|
6
|
+
|
|
7
|
+
function prompt(question: string): Promise<string> {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
rl.question(question, (answer) => {
|
|
11
|
+
rl.close();
|
|
12
|
+
resolve(answer);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const loginCommand = new Command("login")
|
|
18
|
+
.description("Continue with email to unlock persistent features")
|
|
19
|
+
.option("--email <email>", "Email to send the login code to")
|
|
20
|
+
.option("--code <code>", "6-digit code from email (completes login)")
|
|
21
|
+
.option("--no-save", "Do not write ~/.uplink/credentials")
|
|
22
|
+
.option("--json", "Output JSON", false)
|
|
23
|
+
.action(async (opts) => {
|
|
24
|
+
try {
|
|
25
|
+
const json = Boolean(opts.json);
|
|
26
|
+
let email = opts.email ? normalizeEmail(opts.email) : "";
|
|
27
|
+
if (!email) {
|
|
28
|
+
if (json) {
|
|
29
|
+
console.error("Provide --email. To finish login, also pass --code.");
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
email = normalizeEmail(await prompt("Email: "));
|
|
33
|
+
}
|
|
34
|
+
if (!isEmail(email)) {
|
|
35
|
+
console.error("Invalid email.");
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const codeOpt = opts.code ? String(opts.code).trim() : "";
|
|
40
|
+
|
|
41
|
+
if (!codeOpt) {
|
|
42
|
+
await requestLoginCode(email);
|
|
43
|
+
if (json) {
|
|
44
|
+
printJson({ ok: true, email, message: "OTP sent. Run again with --email and --code." });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
console.log(`Code sent to ${email}.`);
|
|
48
|
+
opts.code = (await prompt("Code: ")).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const code = String(opts.code || "").trim();
|
|
52
|
+
if (!/^\d{6}$/.test(code)) {
|
|
53
|
+
console.error("Code must be 6 digits.");
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = await verifyLoginCode(email, code);
|
|
58
|
+
const apiBase = getResolvedApiBase();
|
|
59
|
+
const save = opts.save !== false;
|
|
60
|
+
let savedTo: string | null = null;
|
|
61
|
+
if (save) {
|
|
62
|
+
savedTo = persistLogin(result, email);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (json) {
|
|
66
|
+
printJson({ ...result, email, savedTo });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tokenExport = formatTokenForEnv(result.token, apiBase);
|
|
71
|
+
console.log("Logged in.");
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(` Email: ${email}`);
|
|
74
|
+
console.log(` User ID: ${result.userId}`);
|
|
75
|
+
console.log(` Token: ${result.token}`);
|
|
76
|
+
if (savedTo) console.log(` Saved: ${savedTo}`);
|
|
77
|
+
console.log("");
|
|
78
|
+
if (!savedTo) {
|
|
79
|
+
console.log("To use this token:");
|
|
80
|
+
console.log(` export AGENTCLOUD_TOKEN="${tokenExport}"`);
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
handleError(error, { json: opts.json });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { launchDomainking, resolveDomainkingEntry } from "../../../utils/launchDomainking";
|
|
2
|
+
import { runCliCapture } from "./hosting";
|
|
3
|
+
|
|
4
|
+
type Deps = {
|
|
5
|
+
promptLine: (question: string) => Promise<string>;
|
|
6
|
+
restoreRawMode: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared "find a domain" action: the Domainking search TUI when it is
|
|
11
|
+
* available, otherwise an inline prompt against `uplink domains check`
|
|
12
|
+
* (which works without a registrar via public DNS/RDAP).
|
|
13
|
+
*/
|
|
14
|
+
export function buildFindDomainAction(deps: Deps): () => Promise<string> {
|
|
15
|
+
return async () => {
|
|
16
|
+
if (resolveDomainkingEntry()) {
|
|
17
|
+
deps.restoreRawMode();
|
|
18
|
+
return launchDomainking();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const domain = (await deps.promptLine("Domain to check (e.g. example.com, or back): "))
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
deps.restoreRawMode();
|
|
25
|
+
if (!domain || domain === "back") return "";
|
|
26
|
+
if (!domain.includes(".")) return "Pass a full domain like example.com";
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
return runCliCapture(["domains", "check", domain]) || `${domain}: no result.`;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return error instanceof Error ? error.message : String(error);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SelectOption } from "../inline-tree-select";
|
|
2
2
|
import type { MenuChoice } from "../types";
|
|
3
|
-
import {
|
|
3
|
+
import { buildFindDomainAction } from "./domain-check";
|
|
4
4
|
import { parseHostedApps, runCli, runCliCapture } from "./hosting";
|
|
5
5
|
|
|
6
6
|
type Deps = {
|
|
@@ -18,6 +18,7 @@ const PROVIDER_OPTIONS: SelectOption[] = [
|
|
|
18
18
|
{ label: "Cloudflare", value: "cloudflare" },
|
|
19
19
|
{ label: "Hostinger", value: "hostinger" },
|
|
20
20
|
{ label: "Namecheap", value: "namecheap" },
|
|
21
|
+
{ label: "DreamHost", value: "dreamhost" },
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
async function pickHostedApp(
|
|
@@ -114,10 +115,7 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
|
|
|
114
115
|
},
|
|
115
116
|
{
|
|
116
117
|
label: "Find a domain",
|
|
117
|
-
action:
|
|
118
|
-
restoreRawMode();
|
|
119
|
-
return launchDomainking();
|
|
120
|
-
},
|
|
118
|
+
action: buildFindDomainAction(deps),
|
|
121
119
|
},
|
|
122
120
|
{
|
|
123
121
|
label: "Attach to app",
|
|
@@ -180,7 +178,7 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
|
|
|
180
178
|
return [
|
|
181
179
|
"Uplink lists domains you already own at connected registrars, then attaches them to hosted apps.",
|
|
182
180
|
"",
|
|
183
|
-
" My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap",
|
|
181
|
+
" My domains — inventory from GoDaddy / Cloudflare / Hostinger / Namecheap / DreamHost",
|
|
184
182
|
" Connect — save a registrar token (same as the CLI)",
|
|
185
183
|
" Find — search names that are not yours yet",
|
|
186
184
|
" Attach — bind a hostname to a hosted app",
|