uplink-cli 0.2.0 → 0.2.2
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 +6 -7
- package/CHANGELOG.md +9 -1
- package/README.md +3 -8
- package/cli/bin/uplink.js +6 -1
- package/cli/src/subcommands/dev.ts +7 -11
- package/cli/src/subcommands/domains.ts +40 -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/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/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/sanitize.ts +9 -0
- package/docs/AGENTS.md +6 -7
- package/docs/MENU_STRUCTURE.md +4 -5
- package/docs/PRODUCT.md +1 -1
- package/docs/README.md +2 -3
- package/package.json +3 -2
- package/scripts/tunnel/client-improved.js +6 -2
- package/tsconfig.json +17 -0
- package/cli/src/utils/launchDomainking.ts +0 -64
package/AGENTS.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
For agents (Cursor, Claude Code, Codex, Windsurf, and similar) to use Uplink **non-interactively**.
|
|
4
4
|
|
|
5
5
|
Install: `npm install -g uplink-cli` or `npx uplink-cli …`
|
|
6
|
-
Until **0.2.0** is on npm: `npm install -g github:firstprinciplecode/uplink#v0.2.0`
|
|
7
6
|
Package name: `uplink-cli` · Binary: `uplink`
|
|
8
7
|
|
|
9
8
|
## Auth
|
|
@@ -113,9 +112,12 @@ Notes:
|
|
|
113
112
|
## Custom domains
|
|
114
113
|
|
|
115
114
|
Registrar inventory is CLI. Attach/verify is under `host domains`.
|
|
116
|
-
The bare `uplink domains`
|
|
115
|
+
The bare `uplink domains` command opens Find a domain. Agents should use JSON:
|
|
117
116
|
|
|
118
117
|
```bash
|
|
118
|
+
uplink domains search acme --json
|
|
119
|
+
uplink domains check example.com --json
|
|
120
|
+
uplink domains list --json
|
|
119
121
|
uplink domains providers connect godaddy --token-env GODADDY_PAT --json
|
|
120
122
|
uplink domains providers connect cloudflare --token-env CF_API_TOKEN --json
|
|
121
123
|
uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --json
|
|
@@ -124,9 +126,6 @@ uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-
|
|
|
124
126
|
uplink domains providers list --json
|
|
125
127
|
uplink domains providers disconnect godaddy --json
|
|
126
128
|
|
|
127
|
-
uplink domains list --json
|
|
128
|
-
uplink domains check example.com --json
|
|
129
|
-
|
|
130
129
|
echo "$TOKEN" | uplink --token-stdin host domains add --id app_xxx --hostname example.com --json
|
|
131
130
|
echo "$TOKEN" | uplink --token-stdin host domains verify --id app_xxx --hostname example.com --json
|
|
132
131
|
echo "$TOKEN" | uplink --token-stdin host domains list --id app_xxx --json
|
|
@@ -151,7 +150,7 @@ echo "$TOKEN" | uplink --token-stdin db delete --id db_xxx --yes --json
|
|
|
151
150
|
| URL 502 / not connected | Local process on `--port` not running, or client died — re-run `tunnel create` or check `tunnel list` |
|
|
152
151
|
| Auth errors | Missing/invalid `AGENTCLOUD_TOKEN`; use `--token-stdin` |
|
|
153
152
|
| `ALIAS_NOT_ENABLED` | Account does not have permanent aliases |
|
|
154
|
-
| Domain search TUI missing |
|
|
153
|
+
| Domain search TUI missing | Not a TTY — use `domains search NAME --json` |
|
|
155
154
|
| `HOST_APP_LIMIT_REACHED` | Free plan is 1 hosted app — delete one or the account needs hosting granted |
|
|
156
155
|
| `HOST_STORAGE_LIMIT_REACHED` | Upload exceeds the 100 MB free hosting budget |
|
|
157
156
|
| `HOST_DOMAIN_NOT_ENABLED` | Custom domains are paid — `*.host.uplink.spot` still works |
|
|
@@ -174,4 +173,4 @@ Agents should prefer the non-interactive commands above.
|
|
|
174
173
|
- Hosting: `docs/HOSTING.md`
|
|
175
174
|
- Product: `docs/PRODUCT.md`
|
|
176
175
|
- Website: https://uplink.spot
|
|
177
|
-
- npm: https://www.npmjs.com/package/uplink-cli
|
|
176
|
+
- npm: https://www.npmjs.com/package/uplink-cli
|
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 — 2026-08-29
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## 0.2.1 — 2026-08-29
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
3
11
|
## 0.2.0 — 2026-08-28
|
|
4
12
|
|
|
5
|
-
|
|
13
|
+
Published on npm as `uplink-cli@0.2.0`.
|
|
6
14
|
|
|
7
15
|
- **Guest access** — `tunnel create` and the interactive menu mint a guest token when none exists (1 active tunnel, 24-hour expiry). Stored in `~/.uplink/credentials`.
|
|
8
16
|
- **Email login** — `uplink login --email` / `--code` upgrades that guest account, keeps the tunnel, and unlocks hosting, databases, and registrar inventory.
|
package/README.md
CHANGED
|
@@ -15,18 +15,13 @@ Learn more at [uplink.spot](https://uplink.spot)
|
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
18
|
-
**npm (current published: 0.1.39)** — guest login and DreamHost land in **0.2.0**, tagged on GitHub, npm publish waiting on org auth.
|
|
19
|
-
|
|
20
18
|
```bash
|
|
21
|
-
# Until 0.2.0 is on npm:
|
|
22
|
-
npm install -g github:firstprinciplecode/uplink#v0.2.0
|
|
23
|
-
|
|
24
|
-
# After publish:
|
|
25
19
|
npm install -g uplink-cli
|
|
20
|
+
# or
|
|
26
21
|
npx uplink-cli --help
|
|
27
22
|
```
|
|
28
23
|
|
|
29
|
-
Changelog: [CHANGELOG.md](./CHANGELOG.md). Product
|
|
24
|
+
`latest` on npm is **0.2.0** (guest login, email OTP, public `domains check`, DreamHost). Changelog: [CHANGELOG.md](./CHANGELOG.md). Product: [docs/PRODUCT.md](./docs/PRODUCT.md). Hosting: [docs/HOSTING.md](./docs/HOSTING.md).
|
|
30
25
|
|
|
31
26
|
## Start without signup
|
|
32
27
|
```bash
|
|
@@ -106,7 +101,7 @@ export TUNNEL_DOMAIN=x.uplink.spot
|
|
|
106
101
|
- URL not live — ensure something is listening on the port and the client started (`tunnel list` → `connected`)
|
|
107
102
|
- Auth errors — verify `AGENTCLOUD_TOKEN` or `~/.uplink/credentials`; prefer `--token-stdin` for agents
|
|
108
103
|
- Relay errors — `TUNNEL_CTRL=tunnel.uplink.spot:7071`
|
|
109
|
-
- Domain search
|
|
104
|
+
- Domain search — `uplink domains` or `uplink domains search acme --json` (public DNS/RDAP)
|
|
110
105
|
|
|
111
106
|
## Docs
|
|
112
107
|
- Agents: [AGENTS.md](./AGENTS.md)
|
package/cli/bin/uplink.js
CHANGED
|
@@ -38,7 +38,12 @@ try {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// Run the CLI via tsx (no extra node wrapper so PATH/global tsx works)
|
|
41
|
-
const
|
|
41
|
+
const tsconfigPath = path.join(projectRoot, "tsconfig.json");
|
|
42
|
+
const tsxArgs = fs.existsSync(tsconfigPath)
|
|
43
|
+
? ["--tsconfig", tsconfigPath, cliPath, ...args]
|
|
44
|
+
: [cliPath, ...args];
|
|
45
|
+
|
|
46
|
+
const child = spawn(tsxPath, tsxArgs, {
|
|
42
47
|
stdio: "inherit",
|
|
43
48
|
cwd: projectRoot,
|
|
44
49
|
env: { ...process.env, UPLINK_CWD: invokeCwd, UPLINK_BIN: invokeBin },
|
|
@@ -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,5 +1,4 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { launchDomainking } from "../utils/launchDomainking";
|
|
3
2
|
import { handleError, printJson } from "../utils/machine";
|
|
4
3
|
import {
|
|
5
4
|
adapters,
|
|
@@ -18,6 +17,8 @@ import {
|
|
|
18
17
|
checkDomainAvailability,
|
|
19
18
|
formatPublicAvailability,
|
|
20
19
|
} from "../utils/domain-availability";
|
|
20
|
+
import { searchDomains } from "../utils/domain-search";
|
|
21
|
+
import { runDomainSearch } from "../tui/DomainSearch";
|
|
21
22
|
|
|
22
23
|
// DreamHost last: it can only confirm ownership, not quote availability.
|
|
23
24
|
const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"];
|
|
@@ -133,11 +134,11 @@ export const domainsCommand = new Command("domains").description(
|
|
|
133
134
|
|
|
134
135
|
domainsCommand.addHelpText(
|
|
135
136
|
"after",
|
|
136
|
-
"\nWith no subcommand, opens
|
|
137
|
+
"\nWith no subcommand, opens Find a domain (type a name; common TLDs are checked via DNS/RDAP).\n"
|
|
137
138
|
);
|
|
138
139
|
|
|
139
|
-
domainsCommand.action(() => {
|
|
140
|
-
const message =
|
|
140
|
+
domainsCommand.action(async () => {
|
|
141
|
+
const message = await runDomainSearch();
|
|
141
142
|
if (message) console.log(message);
|
|
142
143
|
});
|
|
143
144
|
|
|
@@ -213,6 +214,41 @@ domainsCommand
|
|
|
213
214
|
}
|
|
214
215
|
});
|
|
215
216
|
|
|
217
|
+
domainsCommand
|
|
218
|
+
.command("search")
|
|
219
|
+
.description("Search a label across common TLDs, or check one exact domain")
|
|
220
|
+
.argument("[name]", "Bare label (acme) or full domain (acme.io)")
|
|
221
|
+
.option("--json", "Output JSON", false)
|
|
222
|
+
.action(async (name: string | undefined, opts: { json?: boolean }) => {
|
|
223
|
+
try {
|
|
224
|
+
if (!name) {
|
|
225
|
+
if (opts.json) throw new Error("Pass a name: uplink domains search acme --json");
|
|
226
|
+
const message = await runDomainSearch();
|
|
227
|
+
if (message) console.log(message);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const results = await searchDomains(name);
|
|
231
|
+
if (opts.json) {
|
|
232
|
+
printJson({
|
|
233
|
+
query: name,
|
|
234
|
+
results: results.map((item) => ({
|
|
235
|
+
domain: item.domain,
|
|
236
|
+
provider: "public",
|
|
237
|
+
status: item.status,
|
|
238
|
+
buyable: null,
|
|
239
|
+
detail: item.detail,
|
|
240
|
+
})),
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const item of results) {
|
|
245
|
+
console.log(`${item.domain.padEnd(28)} ${item.status}`);
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
handleError(error, { json: opts.json });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
216
252
|
const providers = domainsCommand.command("providers").description("Connect registrar accounts");
|
|
217
253
|
|
|
218
254
|
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
|
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,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
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
For agents (Cursor, Claude Code, Codex, Windsurf, and similar) to use Uplink **non-interactively**.
|
|
4
4
|
|
|
5
5
|
Install: `npm install -g uplink-cli` or `npx uplink-cli …`
|
|
6
|
-
Until **0.2.0** is on npm: `npm install -g github:firstprinciplecode/uplink#v0.2.0`
|
|
7
6
|
Package name: `uplink-cli` · Binary: `uplink`
|
|
8
7
|
|
|
9
8
|
## Auth
|
|
@@ -113,9 +112,12 @@ Notes:
|
|
|
113
112
|
## Custom domains
|
|
114
113
|
|
|
115
114
|
Registrar inventory is CLI. Attach/verify is under `host domains`.
|
|
116
|
-
The bare `uplink domains`
|
|
115
|
+
The bare `uplink domains` command opens Find a domain. Agents should use JSON:
|
|
117
116
|
|
|
118
117
|
```bash
|
|
118
|
+
uplink domains search acme --json
|
|
119
|
+
uplink domains check example.com --json
|
|
120
|
+
uplink domains list --json
|
|
119
121
|
uplink domains providers connect godaddy --token-env GODADDY_PAT --json
|
|
120
122
|
uplink domains providers connect cloudflare --token-env CF_API_TOKEN --json
|
|
121
123
|
uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --json
|
|
@@ -124,9 +126,6 @@ uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-
|
|
|
124
126
|
uplink domains providers list --json
|
|
125
127
|
uplink domains providers disconnect godaddy --json
|
|
126
128
|
|
|
127
|
-
uplink domains list --json
|
|
128
|
-
uplink domains check example.com --json
|
|
129
|
-
|
|
130
129
|
echo "$TOKEN" | uplink --token-stdin host domains add --id app_xxx --hostname example.com --json
|
|
131
130
|
echo "$TOKEN" | uplink --token-stdin host domains verify --id app_xxx --hostname example.com --json
|
|
132
131
|
echo "$TOKEN" | uplink --token-stdin host domains list --id app_xxx --json
|
|
@@ -151,7 +150,7 @@ echo "$TOKEN" | uplink --token-stdin db delete --id db_xxx --yes --json
|
|
|
151
150
|
| URL 502 / not connected | Local process on `--port` not running, or client died — re-run `tunnel create` or check `tunnel list` |
|
|
152
151
|
| Auth errors | Missing/invalid `AGENTCLOUD_TOKEN`; use `--token-stdin` |
|
|
153
152
|
| `ALIAS_NOT_ENABLED` | Account does not have permanent aliases |
|
|
154
|
-
| Domain search TUI missing |
|
|
153
|
+
| Domain search TUI missing | Not a TTY — use `domains search NAME --json` |
|
|
155
154
|
| `HOST_APP_LIMIT_REACHED` | Free plan is 1 hosted app — delete one or the account needs hosting granted |
|
|
156
155
|
| `HOST_STORAGE_LIMIT_REACHED` | Upload exceeds the 100 MB free hosting budget |
|
|
157
156
|
| `HOST_DOMAIN_NOT_ENABLED` | Custom domains are paid — `*.host.uplink.spot` still works |
|
|
@@ -174,4 +173,4 @@ Agents should prefer the non-interactive commands above.
|
|
|
174
173
|
- Hosting: `docs/HOSTING.md`
|
|
175
174
|
- Product: `docs/PRODUCT.md`
|
|
176
175
|
- Website: https://uplink.spot
|
|
177
|
-
- npm: https://www.npmjs.com/package/uplink-cli
|
|
176
|
+
- npm: https://www.npmjs.com/package/uplink-cli
|
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/docs/README.md
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
## Quick Start
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
|
|
7
|
-
npm install -g github:firstprinciplecode/uplink#v0.2.0
|
|
6
|
+
npm install -g uplink-cli
|
|
8
7
|
uplink menu # interactive
|
|
9
8
|
uplink --help # commands
|
|
10
9
|
```
|
|
@@ -20,6 +19,6 @@ uplink --help # commands
|
|
|
20
19
|
## Links
|
|
21
20
|
|
|
22
21
|
- Website: [uplink.spot](https://uplink.spot)
|
|
23
|
-
- npm: [uplink-cli](https://www.npmjs.com/package/uplink-cli)
|
|
22
|
+
- npm: [uplink-cli](https://www.npmjs.com/package/uplink-cli)
|
|
24
23
|
- Source: [github.com/firstprinciplecode/uplink](https://github.com/firstprinciplecode/uplink)
|
|
25
24
|
- API: `https://api.uplink.spot`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uplink-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"docs/MENU_STRUCTURE.md",
|
|
46
46
|
"docs/HOSTING.md",
|
|
47
47
|
"docs/PRODUCT.md",
|
|
48
|
-
"CHANGELOG.md"
|
|
48
|
+
"CHANGELOG.md",
|
|
49
|
+
"tsconfig.json"
|
|
49
50
|
],
|
|
50
51
|
"scripts": {
|
|
51
52
|
"dev:cli": "tsx cli/src/index.ts",
|
|
@@ -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
|
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"esModuleInterop": true,
|
|
6
|
+
"resolveJsonModule": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"types": ["node", "react"],
|
|
11
|
+
"typeRoots": ["./node_modules/@types"]
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"cli/src/**/*",
|
|
15
|
+
"scripts/**/*"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -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
|
-
}
|