uplink-cli 0.2.1 → 0.2.3

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
@@ -112,9 +112,12 @@ Notes:
112
112
  ## Custom domains
113
113
 
114
114
  Registrar inventory is CLI. Attach/verify is under `host domains`.
115
- The bare `uplink domains` search TUI is **optional** and not bundled with npm — use the JSON commands below.
115
+ The bare `uplink domains` command opens Find a domain. Agents should use JSON:
116
116
 
117
117
  ```bash
118
+ uplink domains search acme --json
119
+ uplink domains check example.com --json
120
+ uplink domains list --json
118
121
  uplink domains providers connect godaddy --token-env GODADDY_PAT --json
119
122
  uplink domains providers connect cloudflare --token-env CF_API_TOKEN --json
120
123
  uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --json
@@ -123,9 +126,6 @@ uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-
123
126
  uplink domains providers list --json
124
127
  uplink domains providers disconnect godaddy --json
125
128
 
126
- uplink domains list --json
127
- uplink domains check example.com --json
128
-
129
129
  echo "$TOKEN" | uplink --token-stdin host domains add --id app_xxx --hostname example.com --json
130
130
  echo "$TOKEN" | uplink --token-stdin host domains verify --id app_xxx --hostname example.com --json
131
131
  echo "$TOKEN" | uplink --token-stdin host domains list --id app_xxx --json
@@ -150,7 +150,7 @@ echo "$TOKEN" | uplink --token-stdin db delete --id db_xxx --yes --json
150
150
  | URL 502 / not connected | Local process on `--port` not running, or client died — re-run `tunnel create` or check `tunnel list` |
151
151
  | Auth errors | Missing/invalid `AGENTCLOUD_TOKEN`; use `--token-stdin` |
152
152
  | `ALIAS_NOT_ENABLED` | Account does not have permanent aliases |
153
- | Domain search TUI missing | Expected on npm — use `domains list` / `check` / `host domains *` |
153
+ | Domain search TUI missing | Not a TTY — use `domains search NAME --json` |
154
154
  | `HOST_APP_LIMIT_REACHED` | Free plan is 1 hosted app — delete one or the account needs hosting granted |
155
155
  | `HOST_STORAGE_LIMIT_REACHED` | Upload exceeds the 100 MB free hosting budget |
156
156
  | `HOST_DOMAIN_NOT_ENABLED` | Custom domains are paid — `*.host.uplink.spot` still works |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 — 2026-08-30
4
+
5
+ New **`uplink upgrade`** (Uplink Pro subscription — $9/mo or `--yearly` $90/yr via Stripe Checkout) and **`uplink billing`** (Stripe billing portal for cancel/card changes). Pro unlocks unlimited hosted apps within 1 GB of storage, always-on for your 5 most-active apps, custom domains, and permanent aliases.
6
+
7
+ Fix global CLI crash on every command (`host list`, `login`, …): `domains.ts` was statically importing Ink, so tsx tried to load `yoga-layout` as CommonJS (`Top-level await is currently not supported with the "cjs" output format`). Interactive Find a domain now runs as a child ESM process, same as the menu.
8
+
9
+ ## 0.2.2 — 2026-08-29
10
+
11
+ Built-in **Find a domain** in the CLI (no Domainking install). Type a label to check common TLDs via DNS/RDAP. `uplink domains` opens it; agents use `uplink domains search acme --json`. Domainking remains a separate app.
12
+
3
13
  ## 0.2.1 — 2026-08-29
4
14
 
5
15
  Ship `tsconfig.json` in the npm tarball so the menu's JSX compiles with `react-jsx`. 0.2.0 global installs crashed with `React is not defined` because tsx had no config and used the classic `React.createElement` transform.
package/README.md CHANGED
@@ -101,7 +101,7 @@ export TUNNEL_DOMAIN=x.uplink.spot
101
101
  - URL not live — ensure something is listening on the port and the client started (`tunnel list` → `connected`)
102
102
  - Auth errors — verify `AGENTCLOUD_TOKEN` or `~/.uplink/credentials`; prefer `--token-stdin` for agents
103
103
  - Relay errors — `TUNNEL_CTRL=tunnel.uplink.spot:7071`
104
- - Domain search TUI not bundled on npm; use `domains list` / `check` / `host domains *`
104
+ - Domain search — `uplink domains` or `uplink domains search acme --json` (public DNS/RDAP)
105
105
 
106
106
  ## Docs
107
107
  - Agents: [AGENTS.md](./AGENTS.md)
package/cli/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { signupCommand } from "./subcommands/signup";
10
10
  import { systemCommand } from "./subcommands/system";
11
11
  import { hostCommand } from "./subcommands/host";
12
12
  import { domainsCommand } from "./subcommands/domains";
13
+ import { upgradeCommand, billingCommand } from "./subcommands/upgrade";
13
14
  import { readFileSync } from "fs";
14
15
  import { join } from "path";
15
16
  import { ensureApiBase, parseTokenEnv } from "./utils/api-base";
@@ -39,6 +40,8 @@ program.addCommand(systemCommand);
39
40
  program.addCommand(menuCommand);
40
41
  program.addCommand(hostCommand);
41
42
  program.addCommand(domainsCommand);
43
+ program.addCommand(upgradeCommand);
44
+ program.addCommand(billingCommand);
42
45
 
43
46
  // Global pre-action hook to apply shared options
44
47
  let cachedTokenStdin: string | null = null;
@@ -39,19 +39,15 @@ export const devCommand = new Command("dev")
39
39
 
40
40
  const clientPath = resolveTunnelClientPath();
41
41
  const ctrlHost = process.env.TUNNEL_CTRL ?? "tunnel.uplink.spot:7071";
42
- const args = [
43
- clientPath,
44
- "--token",
45
- result.token,
46
- "--port",
47
- String(port),
48
- "--ctrl",
49
- ctrlHost,
50
- ];
42
+ // Token goes through the environment, never argv (see tunnel-clients.ts).
43
+ const args = [clientPath, "--port", String(port), "--ctrl", ctrlHost];
51
44
  if (!opts.json) {
52
- console.log(`Starting tunnel client: node ${args.join(" ")}`);
45
+ console.log(`Starting tunnel client on port ${port} via ${ctrlHost}`);
53
46
  }
54
- const child = spawn("node", args, { stdio: "inherit" });
47
+ const child = spawn("node", args, {
48
+ stdio: "inherit",
49
+ env: { ...process.env, TUNNEL_TOKEN: result.token },
50
+ });
55
51
 
56
52
  const shutdown = () => {
57
53
  try {
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
- import { launchDomainking } from "../utils/launchDomainking";
2
+ import { join } from "path";
3
3
  import { handleError, printJson } from "../utils/machine";
4
+ import { runEsmEntry } from "../utils/run-esm";
4
5
  import {
5
6
  adapters,
6
7
  getAdapter,
@@ -18,6 +19,15 @@ import {
18
19
  checkDomainAvailability,
19
20
  formatPublicAvailability,
20
21
  } from "../utils/domain-availability";
22
+ import { searchDomains } from "../utils/domain-search";
23
+
24
+ function runDomainSearchTui(): void {
25
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
26
+ console.log("Domain search needs a terminal. Agents: uplink domains search myapp --json");
27
+ return;
28
+ }
29
+ runEsmEntry(join(__dirname, "../tui/domain-search.mts"));
30
+ }
21
31
 
22
32
  // DreamHost last: it can only confirm ownership, not quote availability.
23
33
  const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"];
@@ -133,12 +143,11 @@ export const domainsCommand = new Command("domains").description(
133
143
 
134
144
  domainsCommand.addHelpText(
135
145
  "after",
136
- "\nWith no subcommand, opens the domain search TUI.\n"
146
+ "\nWith no subcommand, opens Find a domain (type a name; common TLDs are checked via DNS/RDAP).\n"
137
147
  );
138
148
 
139
149
  domainsCommand.action(() => {
140
- const message = launchDomainking();
141
- if (message) console.log(message);
150
+ runDomainSearchTui();
142
151
  });
143
152
 
144
153
  domainsCommand
@@ -213,6 +222,40 @@ domainsCommand
213
222
  }
214
223
  });
215
224
 
225
+ domainsCommand
226
+ .command("search")
227
+ .description("Search a label across common TLDs, or check one exact domain")
228
+ .argument("[name]", "Bare label (acme) or full domain (acme.io)")
229
+ .option("--json", "Output JSON", false)
230
+ .action(async (name: string | undefined, opts: { json?: boolean }) => {
231
+ try {
232
+ if (!name) {
233
+ if (opts.json) throw new Error("Pass a name: uplink domains search acme --json");
234
+ runDomainSearchTui();
235
+ return;
236
+ }
237
+ const results = await searchDomains(name);
238
+ if (opts.json) {
239
+ printJson({
240
+ query: name,
241
+ results: results.map((item) => ({
242
+ domain: item.domain,
243
+ provider: "public",
244
+ status: item.status,
245
+ buyable: null,
246
+ detail: item.detail,
247
+ })),
248
+ });
249
+ return;
250
+ }
251
+ for (const item of results) {
252
+ console.log(`${item.domain.padEnd(28)} ${item.status}`);
253
+ }
254
+ } catch (error) {
255
+ handleError(error, { json: opts.json });
256
+ }
257
+ });
258
+
216
259
  const providers = domainsCommand.command("providers").description("Connect registrar accounts");
217
260
 
218
261
  providers
@@ -233,11 +233,19 @@ const DEFAULT_TARBALL_EXCLUDES = [
233
233
  "npm-debug.log*",
234
234
  "yarn-debug.log*",
235
235
  "yarn-error.log*",
236
- // Environment files (should use --env-file or uplink.host.json)
236
+ // Environment files (should use --env-file or uplink.host.json). Secrets must
237
+ // never be baked into the uploaded artifact/image layers — env is delivered
238
+ // separately via the app config API. The wizard creates .env.production, so it
239
+ // is critical that it is excluded here.
237
240
  ".env",
238
241
  ".env.local",
239
242
  ".env.development",
240
243
  ".env.development.local",
244
+ ".env.production",
245
+ ".env.production.local",
246
+ ".env.test",
247
+ ".env.test.local",
248
+ ".env.*.local",
241
249
  // Misc
242
250
  "*.pid",
243
251
  "*.seed",
@@ -334,6 +342,19 @@ function makeTarball(sourceDir: string): { tarPath: string; sizeBytes: number; s
334
342
  return { tarPath: tmp, sizeBytes: st.size, sha256 };
335
343
  }
336
344
 
345
+ // Only send the account bearer to a URL that shares the resolved API origin.
346
+ // The upload/complete URLs come from the server response; a compromised or
347
+ // spoofed API could otherwise point them at an attacker host and exfiltrate the
348
+ // token. Presigned storage URLs (different origin) must authenticate solely via
349
+ // their signed headers.
350
+ function isApiOriginUrl(target: string): boolean {
351
+ try {
352
+ return new URL(target).origin === new URL(getResolvedApiBase()).origin;
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
357
+
337
358
  async function uploadArtifact(
338
359
  uploadUrl: string,
339
360
  tarPath: string,
@@ -341,14 +362,21 @@ async function uploadArtifact(
341
362
  ): Promise<any> {
342
363
  const hasSignedHeaders = uploadHeaders && Object.keys(uploadHeaders).length > 0;
343
364
  const token = getApiToken();
365
+ const sameOrigin = isApiOriginUrl(uploadUrl);
344
366
  if (!hasSignedHeaders && !token) throw new Error("Missing AGENTCLOUD_TOKEN");
367
+ if (!hasSignedHeaders && token && !sameOrigin) {
368
+ throw new Error(
369
+ `Refusing to send credentials to non-API upload host (${new URL(uploadUrl).host}). ` +
370
+ `Expected signed upload headers from the API.`
371
+ );
372
+ }
345
373
 
346
374
  const headers: Record<string, string> = { ...(uploadHeaders || {}) };
347
375
  if (!headers["Content-Type"]) headers["Content-Type"] = "application/octet-stream";
348
376
  if (!headers["Content-Length"]) {
349
377
  headers["Content-Length"] = String(statSync(tarPath).size);
350
378
  }
351
- if (!hasSignedHeaders && token) headers.Authorization = `Bearer ${token}`;
379
+ if (!hasSignedHeaders && token && sameOrigin) headers.Authorization = `Bearer ${token}`;
352
380
 
353
381
  const res = await fetch(uploadUrl, {
354
382
  method: "PUT",
@@ -369,6 +397,11 @@ async function completeArtifactUpload(completeUrl?: string): Promise<void> {
369
397
  if (!completeUrl) return;
370
398
  const token = getApiToken();
371
399
  if (!token) throw new Error("Missing AGENTCLOUD_TOKEN");
400
+ if (!isApiOriginUrl(completeUrl)) {
401
+ throw new Error(
402
+ `Refusing to send credentials to non-API complete host (${new URL(completeUrl).host}).`
403
+ );
404
+ }
372
405
 
373
406
  const res = await fetch(completeUrl, {
374
407
  method: "POST",
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
- import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
3
+ import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "fs";
4
4
 
5
5
  export type DetectedShell = { shellName: "zsh" | "bash" | ""; configFile: string | null };
6
6
 
@@ -53,11 +53,16 @@ export function upsertShellToken(configFile: string, token: string): { wrote: bo
53
53
  const configContent = existsSync(configFile) ? readFileSync(configFile, "utf-8") : "";
54
54
  const lines = configContent.split("\n");
55
55
 
56
+ // Single-quote the value so shell metacharacters in a token can't be
57
+ // interpreted (escape any embedded single quote the POSIX way).
58
+ const quotedToken = `'${token.replace(/'/g, "'\\''")}'`;
59
+ const exportLine = `export AGENTCLOUD_TOKEN=${quotedToken}`;
60
+
56
61
  let replaced = false;
57
62
  const updatedLines = lines.map((line) => {
58
63
  if (line.match(/^\s*export\s+AGENTCLOUD_TOKEN=/)) {
59
64
  replaced = true;
60
- return `export AGENTCLOUD_TOKEN=${token}`;
65
+ return exportLine;
61
66
  }
62
67
  return line;
63
68
  });
@@ -65,11 +70,17 @@ export function upsertShellToken(configFile: string, token: string): { wrote: bo
65
70
  if (!replaced) {
66
71
  updatedLines.push("");
67
72
  updatedLines.push("# Uplink API Token (added automatically)");
68
- updatedLines.push(`export AGENTCLOUD_TOKEN=${token}`);
73
+ updatedLines.push(exportLine);
69
74
  }
70
75
 
71
- writeFileSync(configFile, updatedLines.join("\n"), { flag: "w", mode: 0o644 });
76
+ // The file holds a secret; write it 0600 so other local users can't read it.
77
+ writeFileSync(configFile, updatedLines.join("\n"), { flag: "w", mode: 0o600 });
78
+ try {
79
+ chmodSync(configFile, 0o600);
80
+ } catch {
81
+ /* best-effort tightening if the file pre-existed with wider mode */
82
+ }
72
83
  const verifyContent = readFileSync(configFile, "utf-8");
73
- return { wrote: true, verifyOk: verifyContent.includes(`export AGENTCLOUD_TOKEN=${token}`) };
84
+ return { wrote: true, verifyOk: verifyContent.includes(exportLine) };
74
85
  }
75
86
 
@@ -1,5 +1,6 @@
1
1
  import { execSync, spawn } from "child_process";
2
- import { existsSync } from "fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
3
4
  import path from "path";
4
5
  import { resolveProjectRoot } from "../../../utils/project-root";
5
6
 
@@ -14,6 +15,48 @@ export function resolveTunnelClientPath(): string {
14
15
  return clientPath;
15
16
  }
16
17
 
18
+ // The tunnel token must not appear on the child's argv (it would be readable via
19
+ // `ps` by any local user, who could then hijack the tunnel). We pass it through
20
+ // the environment instead and keep a 0600 registry mapping pid -> {port, token}
21
+ // so `list`/`stop` can still correlate local clients to their API tunnels.
22
+ function registryDir(): string {
23
+ return path.join(homedir(), ".uplink", "tunnels");
24
+ }
25
+
26
+ function registryPath(pid: number): string {
27
+ return path.join(registryDir(), `${pid}.json`);
28
+ }
29
+
30
+ function writeTunnelRegistry(pid: number, entry: { port: number; token: string }): void {
31
+ try {
32
+ mkdirSync(registryDir(), { recursive: true, mode: 0o700 });
33
+ writeFileSync(registryPath(pid), JSON.stringify(entry), { mode: 0o600 });
34
+ } catch {
35
+ /* registry is best-effort; token stays out of argv regardless */
36
+ }
37
+ }
38
+
39
+ function readTunnelRegistry(pid: number): { port: number; token: string } | null {
40
+ try {
41
+ const raw = readFileSync(registryPath(pid), "utf-8");
42
+ const parsed = JSON.parse(raw);
43
+ if (typeof parsed?.token === "string" && typeof parsed?.port === "number") {
44
+ return { port: parsed.port, token: parsed.token };
45
+ }
46
+ } catch {
47
+ /* missing/corrupt entry */
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function removeTunnelRegistry(pid: number): void {
53
+ try {
54
+ rmSync(registryPath(pid), { force: true });
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+
17
60
  /** Start the local tunnel client in the background (detached). */
18
61
  export function startTunnelClient(opts: {
19
62
  token: string;
@@ -25,17 +68,19 @@ export function startTunnelClient(opts: {
25
68
  const ctrl = opts.ctrl || process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
26
69
  const clientProcess = spawn(
27
70
  "node",
28
- [clientPath, "--token", opts.token, "--port", String(opts.port), "--ctrl", ctrl],
71
+ [clientPath, "--port", String(opts.port), "--ctrl", ctrl],
29
72
  {
30
73
  stdio: "ignore",
31
74
  detached: true,
32
75
  cwd: projectRoot,
76
+ env: { ...process.env, TUNNEL_TOKEN: opts.token },
33
77
  }
34
78
  );
35
79
  clientProcess.unref();
36
80
  if (!clientProcess.pid) {
37
81
  throw new Error("Failed to start tunnel client process");
38
82
  }
83
+ writeTunnelRegistry(clientProcess.pid, { port: opts.port, token: opts.token });
39
84
  return { pid: clientProcess.pid, clientPath };
40
85
  }
41
86
 
@@ -51,20 +96,34 @@ export function findTunnelClients(): TunnelClient[] {
51
96
  .filter((line) => line.includes("scripts/tunnel/client-improved.js"));
52
97
 
53
98
  const clients: TunnelClient[] = [];
99
+ const livePids = new Set<number>();
54
100
 
55
101
  for (const line of lines) {
56
- // Parse process line: PID COMMAND (from ps -o pid=,command=)
102
+ // Parse process line: PID COMMAND (from ps -o pid=,command=). The token is
103
+ // no longer on argv, so we only read pid + port here and recover the token
104
+ // from the 0600 registry written at start time.
57
105
  const pidMatch = line.match(/^\s*(\d+)/);
58
- const tokenMatch = line.match(/--token\s+(\S+)/);
59
106
  const portMatch = line.match(/--port\s+(\d+)/);
107
+ if (!pidMatch || !portMatch) continue;
108
+
109
+ const pid = parseInt(pidMatch[1], 10);
110
+ livePids.add(pid);
111
+ const registry = readTunnelRegistry(pid);
112
+ clients.push({
113
+ pid,
114
+ port: parseInt(portMatch[1], 10),
115
+ token: registry?.token ?? "",
116
+ });
117
+ }
60
118
 
61
- if (pidMatch && tokenMatch && portMatch) {
62
- clients.push({
63
- pid: parseInt(pidMatch[1], 10),
64
- port: parseInt(portMatch[1], 10),
65
- token: tokenMatch[1],
66
- });
119
+ // Garbage-collect registry entries for clients that are no longer running.
120
+ try {
121
+ for (const file of readdirSync(registryDir())) {
122
+ const pid = parseInt(file.replace(/\.json$/, ""), 10);
123
+ if (Number.isFinite(pid) && !livePids.has(pid)) removeTunnelRegistry(pid);
67
124
  }
125
+ } catch {
126
+ /* registry dir may not exist yet */
68
127
  }
69
128
 
70
129
  return clients;
@@ -74,6 +133,7 @@ export function findTunnelClients(): TunnelClient[] {
74
133
  }
75
134
 
76
135
  export function killTunnelClient(pid: number): boolean {
136
+ removeTunnelRegistry(pid);
77
137
  try {
78
138
  process.kill(pid, "SIGTERM");
79
139
  } catch {
@@ -1,34 +1,14 @@
1
- import { launchDomainking, resolveDomainkingEntry } from "../../../utils/launchDomainking";
2
- import { runCliCapture } from "./hosting";
1
+ import { runDomainSearch } from "../../../tui/DomainSearch";
3
2
 
4
3
  type Deps = {
5
4
  promptLine: (question: string) => Promise<string>;
6
5
  restoreRawMode: () => void;
7
6
  };
8
7
 
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
- */
8
+ /** Built-in Find a domain TUI (DNS + RDAP). Domainking remains a separate app. */
14
9
  export function buildFindDomainAction(deps: Deps): () => Promise<string> {
15
10
  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
11
  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
- }
12
+ return runDomainSearch();
33
13
  };
34
14
  }
@@ -187,6 +187,7 @@ export function buildDomainsMenu(deps: Deps): MenuChoice {
187
187
  "CLI (agents):",
188
188
  " uplink domains providers connect godaddy --token-env GODADDY_PAT --json",
189
189
  " uplink domains list --json",
190
+ " uplink domains search acme --json",
190
191
  " uplink domains check example.com --json",
191
192
  " uplink host domains add --id <app> --hostname example.com --json",
192
193
  ].join("\n");
@@ -1,6 +1,7 @@
1
1
  import { clearScreen } from "./io";
2
2
  import { colorBold, colorDim, colorGreen, colorRed } from "./colors";
3
3
  import { DEFAULT_MENU_MESSAGE, type MenuChoice } from "./types";
4
+ import { sanitizeForTerminal } from "../../utils/sanitize";
4
5
 
5
6
  export type RenderArgs = {
6
7
  banner: string;
@@ -100,8 +101,9 @@ export function renderMenu(args: RenderArgs) {
100
101
  console.log(colorDim("Working..."));
101
102
  } else if (message && message !== DEFAULT_MENU_MESSAGE) {
102
103
  console.log();
103
- // Format multi-line messages nicely
104
- const lines = message.split("\n");
104
+ // Format multi-line messages nicely (strip any control/ANSI sequences that
105
+ // may have come from server- or registrar-controlled data).
106
+ const lines = sanitizeForTerminal(message).split("\n");
105
107
  lines.forEach((line) => {
106
108
  // Color success/error indicators
107
109
  const styledLine = line
@@ -1,23 +1,6 @@
1
1
  import { Command } from "commander";
2
- import { spawnSync } from "child_process";
3
2
  import { join } from "path";
4
-
5
- function projectRoot(): string {
6
- return join(__dirname, "../../..");
7
- }
8
-
9
- function resolveTsx(): string {
10
- const root = projectRoot();
11
- try {
12
- return require.resolve("tsx/dist/cli.cjs", { paths: [root] });
13
- } catch {
14
- try {
15
- return require.resolve("tsx/cli", { paths: [root] });
16
- } catch {
17
- return "tsx";
18
- }
19
- }
20
- }
3
+ import { runEsmEntry } from "../utils/run-esm";
21
4
 
22
5
  /**
23
6
  * Ink 6 is ESM-only (yoga-layout uses top-level await). The CLI package is
@@ -30,12 +13,5 @@ export const menuCommand = new Command("menu")
30
13
  console.error("Uplink menu needs an interactive terminal. Use `uplink --help` for commands.");
31
14
  process.exit(1);
32
15
  }
33
- const entry = join(__dirname, "../tui/index.mts");
34
- const result = spawnSync(resolveTsx(), [entry], {
35
- stdio: "inherit",
36
- cwd: projectRoot(),
37
- env: process.env,
38
- });
39
- if (result.error) throw result.error;
40
- process.exit(result.status ?? 0);
16
+ runEsmEntry(join(__dirname, "../tui/index.mts"));
41
17
  });
@@ -0,0 +1,62 @@
1
+ import { Command } from "commander";
2
+ import { spawn } from "child_process";
3
+ import { apiRequest } from "../http";
4
+ import { handleError, printJson } from "../utils/machine";
5
+
6
+ function openInBrowser(url: string): void {
7
+ const [cmd, args] =
8
+ process.platform === "darwin"
9
+ ? ["open", [url]]
10
+ : process.platform === "win32"
11
+ ? ["cmd", ["/c", "start", "", url]]
12
+ : ["xdg-open", [url]];
13
+ try {
14
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
15
+ } catch {
16
+ // Non-fatal: the URL is printed either way.
17
+ }
18
+ }
19
+
20
+ export const upgradeCommand = new Command("upgrade")
21
+ .description("Upgrade to Uplink Pro — unlimited apps in 1 GB, 5 always-on, custom domains, aliases")
22
+ .option("--yearly", "Yearly billing (2 months free)", false)
23
+ .option("--json", "Output JSON (prints the checkout URL, does not open a browser)", false)
24
+ .action(async (opts) => {
25
+ try {
26
+ const interval = opts.yearly ? "year" : "month";
27
+ const result = await apiRequest("POST", "/v1/billing/checkout", { interval });
28
+ if (opts.json) {
29
+ printJson({ url: result.url, interval: result.interval });
30
+ return;
31
+ }
32
+ console.log("");
33
+ console.log("Uplink Pro — complete your upgrade in the browser:");
34
+ console.log("");
35
+ console.log(` ${result.url}`);
36
+ console.log("");
37
+ openInBrowser(result.url);
38
+ } catch (error) {
39
+ handleError(error, { json: opts.json });
40
+ }
41
+ });
42
+
43
+ export const billingCommand = new Command("billing")
44
+ .description("Manage your subscription (opens the Stripe billing portal)")
45
+ .option("--json", "Output JSON (prints the portal URL, does not open a browser)", false)
46
+ .action(async (opts) => {
47
+ try {
48
+ const result = await apiRequest("POST", "/v1/billing/portal");
49
+ if (opts.json) {
50
+ printJson({ url: result.url });
51
+ return;
52
+ }
53
+ console.log("");
54
+ console.log("Manage your subscription here:");
55
+ console.log("");
56
+ console.log(` ${result.url}`);
57
+ console.log("");
58
+ openInBrowser(result.url);
59
+ } catch (error) {
60
+ handleError(error, { json: opts.json });
61
+ }
62
+ });
@@ -3,7 +3,9 @@ import { useState } from "react";
3
3
  import type { MenuChoice } from "../subcommands/menu/types";
4
4
  import { HomeStatus } from "./HomeStatus";
5
5
  import { AppInspector } from "./AppInspector";
6
+ import { Wordmark } from "./brand";
6
7
  import { cleanLabel } from "./format";
8
+ import { sanitizeForTerminal } from "../utils/sanitize";
7
9
 
8
10
  export type TunnelLine = { url: string; port: number };
9
11
 
@@ -139,18 +141,14 @@ export function MenuApp({
139
141
 
140
142
  return (
141
143
  <Box flexDirection="column" paddingX={1} paddingY={1}>
144
+ <Wordmark />
142
145
  {atRoot ? (
143
146
  <HomeStatus status={status} />
144
- ) : (
145
- <Box flexDirection="column">
146
- <Text dimColor>UPLINK</Text>
147
- {crumb ? (
148
- <Box marginTop={1}>
149
- <Text dimColor>{crumb}</Text>
150
- </Box>
151
- ) : null}
147
+ ) : crumb ? (
148
+ <Box marginTop={1}>
149
+ <Text dimColor>{crumb}</Text>
152
150
  </Box>
153
- )}
151
+ ) : null}
154
152
 
155
153
  <Box flexDirection="column" marginTop={atRoot ? 1 : 1}>
156
154
  {current.map((choice, i) => {
@@ -178,7 +176,7 @@ export function MenuApp({
178
176
 
179
177
  {notice ? (
180
178
  <Box flexDirection="column" marginTop={1}>
181
- {notice.split("\n").map((line, i) => (
179
+ {sanitizeForTerminal(notice).split("\n").map((line, i) => (
182
180
  <Text key={i} color={noticeColor(line)} dimColor={!noticeColor(line)}>
183
181
  {line || " "}
184
182
  </Text>
@@ -0,0 +1,115 @@
1
+ import { Box, Text, useApp, useInput, render } from "ink";
2
+ import { Wordmark } from "./brand";
3
+ import TextInput from "ink-text-input";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import type { PublicAvailability } from "../utils/domain-availability";
6
+ import { expandDomainQuery } from "../utils/domain-search";
7
+ import { checkDomainAvailability } from "../utils/domain-availability";
8
+ import { prepareStdinForPrompt } from "../subcommands/menu/io";
9
+
10
+ type Row = PublicAvailability | { domain: string; status: "checking" };
11
+
12
+ const DEBOUNCE_MS = 400;
13
+
14
+ function useLiveChecks(raw: string): Row[] {
15
+ const [rows, setRows] = useState<Row[]>([]);
16
+ const generation = useRef(0);
17
+
18
+ useEffect(() => {
19
+ const gen = ++generation.current;
20
+ const domains = expandDomainQuery(raw);
21
+ if (domains.length === 0) {
22
+ setRows([]);
23
+ return;
24
+ }
25
+ setRows(domains.map((domain) => ({ domain, status: "checking" as const })));
26
+ const timer = setTimeout(() => {
27
+ for (const domain of domains) {
28
+ void checkDomainAvailability(domain).then((result) => {
29
+ if (generation.current !== gen) return;
30
+ setRows((prev) => prev.map((row) => (row.domain === domain ? result : row)));
31
+ });
32
+ }
33
+ }, DEBOUNCE_MS);
34
+ return () => clearTimeout(timer);
35
+ }, [raw]);
36
+
37
+ return rows;
38
+ }
39
+
40
+ function statusColor(status: Row["status"]): string | undefined {
41
+ if (status === "available") return "green";
42
+ if (status === "taken") return undefined;
43
+ if (status === "unknown") return "yellow";
44
+ return undefined;
45
+ }
46
+
47
+ function DomainSearchApp() {
48
+ const { exit } = useApp();
49
+ const [query, setQuery] = useState("");
50
+ const [showTaken, setShowTaken] = useState(false);
51
+ const rows = useLiveChecks(query);
52
+ const pending = rows.some((row) => row.status === "checking");
53
+ const available = rows.filter((row) => row.status === "available");
54
+ const taken = rows.filter((row) => row.status === "taken");
55
+ const rest = rows.filter((row) => row.status !== "taken");
56
+
57
+ useInput((_input, key) => {
58
+ if (key.escape) exit();
59
+ if (key.tab) setShowTaken((prev) => !prev);
60
+ });
61
+
62
+ return (
63
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
64
+ <Wordmark />
65
+ <Box marginTop={1}>
66
+ <Text>Find a domain</Text>
67
+ </Box>
68
+ <Box marginTop={1}>
69
+ <Text dimColor>search › </Text>
70
+ <TextInput value={query} onChange={setQuery} placeholder="acme or acme.io" />
71
+ </Box>
72
+ {rest.length > 0 && (
73
+ <Box flexDirection="column" marginTop={1}>
74
+ {rest.map((row) => (
75
+ <Text key={row.domain} color={statusColor(row.status)} dimColor={row.status === "checking"}>
76
+ {row.status === "checking" ? "·" : row.status === "available" ? "✓" : "?"} {row.domain}
77
+ </Text>
78
+ ))}
79
+ </Box>
80
+ )}
81
+ {taken.length > 0 && (
82
+ <Box flexDirection="column" marginTop={1}>
83
+ {showTaken ? (
84
+ taken.map((row) => (
85
+ <Text key={row.domain} dimColor>
86
+ × {row.domain}
87
+ </Text>
88
+ ))
89
+ ) : (
90
+ <Text dimColor>
91
+ {taken.length} taken · tab to show
92
+ </Text>
93
+ )}
94
+ </Box>
95
+ )}
96
+ <Box marginTop={1}>
97
+ <Text dimColor>
98
+ {rows.length > 0 && !pending ? `${available.length} of ${rows.length} free · ` : ""}
99
+ tab taken · esc back · DNS + RDAP, no registrar required
100
+ </Text>
101
+ </Box>
102
+ </Box>
103
+ );
104
+ }
105
+
106
+ export async function runDomainSearch(): Promise<string> {
107
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
108
+ return "Domain search needs a terminal. Agents: uplink domains search myapp --json";
109
+ }
110
+ const instance = render(<DomainSearchApp />);
111
+ await instance.waitUntilExit();
112
+ instance.unmount();
113
+ prepareStdinForPrompt();
114
+ return "";
115
+ }
@@ -1,6 +1,5 @@
1
1
  import { Box, Text } from "ink";
2
2
  import type { MenuStatus } from "./App";
3
- import { Wordmark } from "./brand";
4
3
  import { formatBytes } from "./format";
5
4
 
6
5
  const LABEL_WIDTH = 12;
@@ -64,7 +63,6 @@ export function HomeStatus({ status }: { status: MenuStatus }) {
64
63
 
65
64
  return (
66
65
  <Box flexDirection="column">
67
- <Wordmark />
68
66
  <Box marginTop={1}>
69
67
  <Text color={status.connected ? "green" : "yellow"}>
70
68
  ● {status.connected ? "connected" : "offline"}
@@ -1,12 +1,15 @@
1
1
  import { Box, Text } from "ink";
2
2
 
3
+ /** Same wordmark as uplink.spot (`pre.wordmark` in index.html). */
3
4
  const WORDMARK = [
4
5
  " _ _ ___ _ ___ _ _ _ __",
5
6
  "| | | | _ \\ | |_ _| \\| | |/ /",
6
- "| |_| | _/ |__ | || .` | ' < ",
7
- " \\___/|_| |____|___|_|\\_|_|\\_\\",
7
+ "| |_| | _/ |__ | || .` | ' <",
8
+ " \\___/|_| |____|___|_|\\__|_|\\_\\",
8
9
  ];
9
10
 
11
+ export const WORDMARK_TEXT = WORDMARK.join("\n");
12
+
10
13
  export function Wordmark() {
11
14
  return (
12
15
  <Box flexDirection="column">
@@ -0,0 +1,10 @@
1
+ import { runDomainSearch } from "./DomainSearch.tsx";
2
+
3
+ runDomainSearch()
4
+ .then((message) => {
5
+ if (message) console.log(message);
6
+ })
7
+ .catch((error) => {
8
+ console.error(error instanceof Error ? error.message : error);
9
+ process.exit(1);
10
+ });
@@ -20,6 +20,7 @@ import {
20
20
  } from "../subcommands/menu/menus";
21
21
  import { buildFindDomainAction } from "../subcommands/menu/menus/domain-check";
22
22
  import { ports, smoke, tunnelClients } from "../subcommands/menu/effects";
23
+ import { WORDMARK_TEXT } from "./brand";
23
24
  import { runInkMenu } from "./runMenu";
24
25
  import { fetchMenuSnapshot } from "./snapshot";
25
26
  import { isEmail, normalizeEmail, persistLogin, requestLoginCode, verifyLoginCode } from "../utils/login-flow";
@@ -38,7 +39,8 @@ async function continueWithEmail(): Promise<string | undefined> {
38
39
  clearScreen();
39
40
  try {
40
41
  process.stdout.write("\n");
41
- process.stdout.write(colorWhite("UPLINK") + colorDim(" Continue with email\n\n"));
42
+ process.stdout.write(colorWhite(WORDMARK_TEXT) + "\n");
43
+ process.stdout.write(colorDim("Continue with email\n\n"));
42
44
  const email = normalizeEmail(await promptLine("Email: "));
43
45
  if (!isEmail(email)) return "Invalid email.";
44
46
 
@@ -200,7 +202,7 @@ export async function startMenuSession(): Promise<void> {
200
202
 
201
203
  if (accountType === "guest") {
202
204
  mainMenu.push({
203
- label: "Check domain availability",
205
+ label: "Find a domain",
204
206
  action: buildFindDomainAction({ promptLine, restoreRawMode }),
205
207
  });
206
208
  mainMenu.push({
@@ -28,6 +28,12 @@ export function normalizeApiBase(input: string | undefined | null): string | nul
28
28
  try {
29
29
  const url = new URL(value);
30
30
  if (url.protocol !== "http:" && url.protocol !== "https:") return null;
31
+ // The bearer token is sent to this origin. Never allow plaintext HTTP for a
32
+ // remote host — only loopback, where there's no on-path attacker.
33
+ const host = url.hostname;
34
+ const isLoopback =
35
+ host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "::1";
36
+ if (url.protocol === "http:" && !isLoopback) return null;
31
37
  return url.origin;
32
38
  } catch {
33
39
  return null;
@@ -0,0 +1,57 @@
1
+ import { checkDomainAvailability, type PublicAvailability } from "./domain-availability";
2
+
3
+ export const DEFAULT_TLDS = [
4
+ "com",
5
+ "net",
6
+ "org",
7
+ "io",
8
+ "co",
9
+ "ai",
10
+ "dev",
11
+ "app",
12
+ "sh",
13
+ "xyz",
14
+ "me",
15
+ "gg",
16
+ "tech",
17
+ "cloud",
18
+ ];
19
+
20
+ const SEARCH_CONCURRENCY = 5;
21
+
22
+ export function normalizeDomainQuery(raw: string): string {
23
+ return raw
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9.-]/g, "");
27
+ }
28
+
29
+ /** Bare label fans out across default TLDs. A dotted query is one exact name. */
30
+ export function expandDomainQuery(raw: string): string[] {
31
+ const query = normalizeDomainQuery(raw);
32
+ if (!query) return [];
33
+ if (query.includes(".")) {
34
+ const [label, ...rest] = query.split(".");
35
+ const tld = rest.join(".");
36
+ return label && tld ? [`${label}.${tld}`] : [];
37
+ }
38
+ return DEFAULT_TLDS.map((tld) => `${query}.${tld}`);
39
+ }
40
+
41
+ export async function searchDomains(raw: string): Promise<PublicAvailability[]> {
42
+ const domains = expandDomainQuery(raw);
43
+ const results = new Map<string, PublicAvailability>();
44
+ let index = 0;
45
+
46
+ async function worker() {
47
+ while (index < domains.length) {
48
+ const domain = domains[index++];
49
+ results.set(domain, await checkDomainAvailability(domain));
50
+ }
51
+ }
52
+
53
+ await Promise.all(
54
+ Array.from({ length: Math.min(SEARCH_CONCURRENCY, domains.length) }, () => worker())
55
+ );
56
+ return domains.map((domain) => results.get(domain)!);
57
+ }
@@ -0,0 +1,38 @@
1
+ import { spawnSync } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ export function cliPackageRoot(): string {
6
+ return join(__dirname, "../../..");
7
+ }
8
+
9
+ export function resolveTsx(): string {
10
+ const root = cliPackageRoot();
11
+ try {
12
+ return require.resolve("tsx/dist/cli.cjs", { paths: [root] });
13
+ } catch {
14
+ try {
15
+ return require.resolve("tsx/cli", { paths: [root] });
16
+ } catch {
17
+ return "tsx";
18
+ }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Ink 6 is ESM-only (yoga-layout uses top-level await). The CLI package is
24
+ * CommonJS, so Ink screens must run as a child ESM process rather than being
25
+ * imported from commander commands.
26
+ */
27
+ export function runEsmEntry(entry: string): never {
28
+ const root = cliPackageRoot();
29
+ const tsconfigPath = join(root, "tsconfig.json");
30
+ const tsxArgs = existsSync(tsconfigPath) ? ["--tsconfig", tsconfigPath, entry] : [entry];
31
+ const result = spawnSync(resolveTsx(), tsxArgs, {
32
+ stdio: "inherit",
33
+ cwd: root,
34
+ env: process.env,
35
+ });
36
+ if (result.error) throw result.error;
37
+ process.exit(result.status ?? 0);
38
+ }
@@ -0,0 +1,9 @@
1
+ // Strip C0/C1 control characters (including ANSI escape sequences) from strings
2
+ // that originate from the server or a registrar before printing them to the
3
+ // terminal. Without this, an attacker-controlled app name, domain, or error body
4
+ // could inject escape sequences to rewrite the terminal, spoof prompts, or hide
5
+ // output. Newlines and tabs are preserved so multi-line messages still render.
6
+ export function sanitizeForTerminal(input: string): string {
7
+ // eslint-disable-next-line no-control-regex
8
+ return String(input).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
9
+ }
package/docs/AGENTS.md CHANGED
@@ -112,9 +112,12 @@ Notes:
112
112
  ## Custom domains
113
113
 
114
114
  Registrar inventory is CLI. Attach/verify is under `host domains`.
115
- The bare `uplink domains` search TUI is **optional** and not bundled with npm — use the JSON commands below.
115
+ The bare `uplink domains` command opens Find a domain. Agents should use JSON:
116
116
 
117
117
  ```bash
118
+ uplink domains search acme --json
119
+ uplink domains check example.com --json
120
+ uplink domains list --json
118
121
  uplink domains providers connect godaddy --token-env GODADDY_PAT --json
119
122
  uplink domains providers connect cloudflare --token-env CF_API_TOKEN --json
120
123
  uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --json
@@ -123,9 +126,6 @@ uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-
123
126
  uplink domains providers list --json
124
127
  uplink domains providers disconnect godaddy --json
125
128
 
126
- uplink domains list --json
127
- uplink domains check example.com --json
128
-
129
129
  echo "$TOKEN" | uplink --token-stdin host domains add --id app_xxx --hostname example.com --json
130
130
  echo "$TOKEN" | uplink --token-stdin host domains verify --id app_xxx --hostname example.com --json
131
131
  echo "$TOKEN" | uplink --token-stdin host domains list --id app_xxx --json
@@ -150,7 +150,7 @@ echo "$TOKEN" | uplink --token-stdin db delete --id db_xxx --yes --json
150
150
  | URL 502 / not connected | Local process on `--port` not running, or client died — re-run `tunnel create` or check `tunnel list` |
151
151
  | Auth errors | Missing/invalid `AGENTCLOUD_TOKEN`; use `--token-stdin` |
152
152
  | `ALIAS_NOT_ENABLED` | Account does not have permanent aliases |
153
- | Domain search TUI missing | Expected on npm — use `domains list` / `check` / `host domains *` |
153
+ | Domain search TUI missing | Not a TTY — use `domains search NAME --json` |
154
154
  | `HOST_APP_LIMIT_REACHED` | Free plan is 1 hosted app — delete one or the account needs hosting granted |
155
155
  | `HOST_STORAGE_LIMIT_REACHED` | Upload exceeds the 100 MB free hosting budget |
156
156
  | `HOST_DOMAIN_NOT_ENABLED` | Custom domains are paid — `*.host.uplink.spot` still works |
@@ -29,7 +29,7 @@ UPLINK
29
29
  ● connected
30
30
 
31
31
  Share → same full Share menu as verified users (no Aliases)
32
- Check domain availability → Domainking TUI if bundled, else inline `domains check` (public DNS/RDAP)
32
+ Check domain availability → built-in Find a domain (DNS/RDAP across common TLDs)
33
33
  Continue with email → preserve guest tunnel; unlock Hosting + Domains
34
34
  About
35
35
  Exit
@@ -76,15 +76,15 @@ CLI equivalents: `uplink host setup|deploy|list|status|logs|delete|analyze|prefl
76
76
  Domains
77
77
  ├── My domains → uplink domains list
78
78
  ├── Connect registrar → providers connect (token via env)
79
- ├── Check availability → domains check
79
+ ├── Find a domain built-in search TUI (also `uplink domains` / `domains search`)
80
80
  ├── Attach to app → host domains add
81
81
  ├── Verify → host domains verify
82
82
  ├── List attached → host domains list
83
83
  ├── Detach → host domains remove
84
- └── Search (optional TUI) → Domainking if DOMAINKING_ENTRY / sibling repo exists
84
+ └── Help
85
85
  ```
86
86
 
87
- Bare `uplink domains` opens the search TUI when available; otherwise it prints agent-friendly command hints.
87
+ Bare `uplink domains` opens Find a domain. Agents use `domains search NAME --json` and `domains check example.com --json`.
88
88
 
89
89
  ---
90
90
 
@@ -106,4 +106,3 @@ CLI: `uplink admin status|tunnels|databases|tokens …`
106
106
  | `AGENTCLOUD_API_BASE` | API host | `https://api.uplink.spot` |
107
107
  | `TUNNEL_CTRL` | Relay | `tunnel.uplink.spot:7071` |
108
108
  | `TUNNEL_DOMAIN` | Tunnel DNS suffix | `x.uplink.spot` |
109
- | `DOMAINKING_ENTRY` | Optional search TUI entry | — |
package/docs/PRODUCT.md CHANGED
@@ -18,7 +18,7 @@ Humans use `uplink` (keyboard menu). Agents use subcommands with `--json` and `-
18
18
 
19
19
  | Kind | How you get it | Can do | Cannot do |
20
20
  |------|----------------|--------|-----------|
21
- | **Guest** | Automatic on `tunnel create` or menu open | 1 tunnel (24h), public `domains check` | Hosting, databases, aliases, custom domains |
21
+ | **Guest** | Automatic on `tunnel create` or menu open | 1 tunnel (24h), public domain search/check | Hosting, databases, aliases, custom domains |
22
22
  | **Verified** | `uplink login --email` then OTP | Guest plus hosting / DBs / registrars | Plan-gated extras (aliases, custom domains) |
23
23
  | **Admin** | Operator token in the control plane | Everything + Usage / System Status / Manage Tokens | — |
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Software for agents — share localhost, host apps, and attach domains from the terminal. JSON-first CLI for Cursor, Claude, Codex, and Windsurf.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -29,9 +29,13 @@ function parseArgs() {
29
29
  return out;
30
30
  }
31
31
 
32
- const { token, port, ctrl, maxSize } = parseArgs();
32
+ const parsed = parseArgs();
33
+ // Prefer the token from the environment so it never appears on argv (which is
34
+ // world-readable via `ps`). The --token flag is kept only for backwards compat.
35
+ const token = process.env.TUNNEL_TOKEN || parsed.token;
36
+ const { port, ctrl, maxSize } = parsed;
33
37
  if (!token || !port || !ctrl) {
34
- console.error("Usage: node scripts/tunnel/client-improved.js --token <token> --port <port> --ctrl <host:port> [--max-size <bytes>]");
38
+ console.error("Usage: TUNNEL_TOKEN=<token> node scripts/tunnel/client-improved.js --port <port> --ctrl <host:port> [--max-size <bytes>]");
35
39
  process.exit(1);
36
40
  }
37
41
 
@@ -1,64 +0,0 @@
1
- import { spawnSync } from "child_process";
2
- import { existsSync } from "fs";
3
- import { homedir } from "os";
4
- import { join } from "path";
5
-
6
- function projectRoot(): string {
7
- return join(__dirname, "../..");
8
- }
9
-
10
- function resolveTsx(): string {
11
- const root = projectRoot();
12
- try {
13
- return require.resolve("tsx/dist/cli.cjs", { paths: [root] });
14
- } catch {
15
- try {
16
- return require.resolve("tsx/cli", { paths: [root] });
17
- } catch {
18
- return "tsx";
19
- }
20
- }
21
- }
22
-
23
- /** Domainking stays its own package; we spawn it, we do not import it. */
24
- export function resolveDomainkingEntry(): string | null {
25
- if (process.env.DOMAINKING_ENTRY && existsSync(process.env.DOMAINKING_ENTRY)) {
26
- return process.env.DOMAINKING_ENTRY;
27
- }
28
- const candidates = [
29
- join(homedir(), "domainking", "src", "index.tsx"),
30
- join(projectRoot(), "..", "domainking", "src", "index.tsx"),
31
- join(projectRoot(), "..", "..", "domainking", "src", "index.tsx"),
32
- ];
33
- return candidates.find((path) => existsSync(path)) ?? null;
34
- }
35
-
36
- export function launchDomainking(): string {
37
- const entry = resolveDomainkingEntry();
38
- if (!entry) {
39
- return [
40
- "Domain search TUI is not bundled with uplink-cli.",
41
- "",
42
- "Agent-friendly commands (no TUI needed):",
43
- " uplink domains list --json",
44
- " uplink domains check example.com --json",
45
- " uplink host domains add --id app_xxx --hostname example.com --json",
46
- " uplink host domains verify --id app_xxx --hostname example.com --json",
47
- "",
48
- "Optional: set DOMAINKING_ENTRY to a Domainking src/index.tsx for the search UI.",
49
- ].join("\n");
50
- }
51
-
52
- const result = spawnSync(resolveTsx(), [entry], {
53
- stdio: "inherit",
54
- cwd: join(entry, "..", ".."),
55
- env: process.env,
56
- });
57
- if (result.error) {
58
- throw result.error;
59
- }
60
- if (result.status && result.status !== 0) {
61
- return "Domain search exited.";
62
- }
63
- return "Back from domain search. Attach a hostname with Domains › Attach to app.";
64
- }