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 +5 -5
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/cli/src/index.ts +3 -0
- package/cli/src/subcommands/dev.ts +7 -11
- package/cli/src/subcommands/domains.ts +47 -4
- package/cli/src/subcommands/host.ts +35 -2
- package/cli/src/subcommands/menu/effects/token-config.ts +16 -5
- package/cli/src/subcommands/menu/effects/tunnel-clients.ts +70 -10
- package/cli/src/subcommands/menu/menus/domain-check.ts +3 -23
- package/cli/src/subcommands/menu/menus/domains.ts +1 -0
- package/cli/src/subcommands/menu/render.ts +4 -2
- package/cli/src/subcommands/menu.ts +2 -26
- package/cli/src/subcommands/upgrade.ts +62 -0
- package/cli/src/tui/App.tsx +8 -10
- package/cli/src/tui/DomainSearch.tsx +115 -0
- package/cli/src/tui/HomeStatus.tsx +0 -2
- package/cli/src/tui/brand.tsx +5 -2
- package/cli/src/tui/domain-search.mts +10 -0
- package/cli/src/tui/session.mts +4 -2
- package/cli/src/utils/api-base.ts +6 -0
- package/cli/src/utils/domain-search.ts +57 -0
- package/cli/src/utils/run-esm.ts +38 -0
- package/cli/src/utils/sanitize.ts +9 -0
- package/docs/AGENTS.md +5 -5
- package/docs/MENU_STRUCTURE.md +4 -5
- package/docs/PRODUCT.md +1 -1
- package/package.json +1 -1
- package/scripts/tunnel/client-improved.js +6 -2
- package/cli/src/utils/launchDomainking.ts +0 -64
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`
|
|
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 |
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
45
|
+
console.log(`Starting tunnel client on port ${port} via ${ctrlHost}`);
|
|
53
46
|
}
|
|
54
|
-
const child = spawn("node", args, {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
73
|
+
updatedLines.push(exportLine);
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
|
|
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(
|
|
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, "--
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/cli/src/tui/App.tsx
CHANGED
|
@@ -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
|
|
146
|
-
<Text dimColor>
|
|
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"}
|
package/cli/src/tui/brand.tsx
CHANGED
|
@@ -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">
|
package/cli/src/tui/session.mts
CHANGED
|
@@ -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(
|
|
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: "
|
|
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`
|
|
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 |
|
|
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/docs/MENU_STRUCTURE.md
CHANGED
|
@@ -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 →
|
|
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
|
-
├──
|
|
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
|
-
└──
|
|
84
|
+
└── Help
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
Bare `uplink domains` opens
|
|
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
|
|
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
|
@@ -29,9 +29,13 @@ function parseArgs() {
|
|
|
29
29
|
return out;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const
|
|
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 --
|
|
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
|
-
}
|