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 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
- ## Signup (no auth required)
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
- ## Authenticate (no browser)
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
- uplink signup --json # creates account + token
26
- # or interactive: uplink (Get Started)
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
- # Create tunnel + start local client (URL works immediately)
33
- echo "$AGENTCLOUD_TOKEN" | uplink --token-stdin tunnel create --port 3000 --json
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
- const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap"];
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
- const token = opts.tokenEnv
53
- ? readEnvValue(opts.tokenEnv)
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 (!token) {
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("Ask a connected registrar if a domain is buyable, and at what price")
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 or Namecheap API key")
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 namecheap`);
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 { launchDomainking } from "../../../utils/launchDomainking";
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: async () => {
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",