uplink-cli 0.1.39 → 0.2.1

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.
@@ -5,10 +5,14 @@ import { getResolvedApiBase, getResolvedApiToken } from "../utils/api-base";
5
5
  import type { MenuStatus } from "./App";
6
6
 
7
7
  const SNAPSHOT_TIMEOUT_MS = 2000;
8
+ /** Hard max for a single upload (paid). Free accounts are tighter via /v1/me. */
8
9
  const ARTIFACT_CAP_BYTES = 500_000_000;
9
10
 
10
11
  export { ARTIFACT_CAP_BYTES };
11
12
 
13
+ const FREE_STORAGE = 100_000_000;
14
+ const FREE_APP_LIMIT = 1;
15
+
12
16
  type JsonObject = Record<string, unknown>;
13
17
 
14
18
  function localTunnels(): MenuStatus["tunnels"] {
@@ -76,19 +80,44 @@ async function fetchHealth(): Promise<{ connected: boolean; latencyMs: number |
76
80
  return { connected: true, latencyMs: Date.now() - started };
77
81
  }
78
82
 
83
+ function parseHosting(me: JsonObject | null): {
84
+ storageUsedBytes: number;
85
+ storageLimitBytes: number;
86
+ appLimit: number;
87
+ alwaysOn: boolean;
88
+ idleMinutes: number | null;
89
+ } {
90
+ const hosting = asObject(me?.hosting);
91
+ const storage = asObject(hosting?.storageBytes);
92
+ const apps = asObject(hosting?.apps);
93
+ const used = Number(storage?.used);
94
+ const limit = Number(storage?.limit);
95
+ const appLimit = Number(apps?.limit);
96
+ return {
97
+ storageUsedBytes: Number.isFinite(used) ? used : 0,
98
+ storageLimitBytes: Number.isFinite(limit) ? limit : FREE_STORAGE,
99
+ appLimit: Number.isFinite(appLimit) ? appLimit : FREE_APP_LIMIT,
100
+ alwaysOn: hosting?.alwaysOn === true,
101
+ idleMinutes: typeof hosting?.idleMinutes === "number" ? hosting.idleMinutes : 30,
102
+ };
103
+ }
104
+
79
105
  export async function fetchMenuSnapshot(): Promise<MenuStatus> {
80
106
  const tunnels = localTunnels();
81
- const [healthStatus, apps, providers] = await Promise.all([
107
+ const [healthStatus, apps, providers, meBody] = await Promise.all([
82
108
  fetchHealth(),
83
109
  fetchApps(),
84
110
  Promise.resolve(connectedProviders()),
111
+ apiGet("/v1/me"),
85
112
  ]);
113
+ const hosting = parseHosting(asObject(meBody));
86
114
  return {
87
115
  connected: healthStatus.connected,
88
116
  latencyMs: healthStatus.latencyMs,
89
117
  tunnels,
90
118
  apps,
91
119
  providers,
120
+ ...hosting,
92
121
  };
93
122
  }
94
123
 
@@ -2,6 +2,7 @@ import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import { createInterface } from "readline";
5
+ import { readStoredCredentials } from "./credentials";
5
6
 
6
7
  export const DEFAULT_API_BASE = "https://api.uplink.spot";
7
8
 
@@ -75,6 +76,8 @@ export function getResolvedApiBase(): string {
75
76
  if (envBase) return envBase;
76
77
  const parsedToken = parseTokenEnv(process.env.AGENTCLOUD_TOKEN);
77
78
  if (parsedToken.apiBase) return parsedToken.apiBase;
79
+ const storedBase = normalizeApiBase(readStoredCredentials()?.apiBase);
80
+ if (storedBase) return storedBase;
78
81
  const configBase = readApiBaseConfig();
79
82
  if (configBase) return configBase;
80
83
  return DEFAULT_API_BASE;
@@ -83,6 +86,8 @@ export function getResolvedApiBase(): string {
83
86
  export function getResolvedApiToken(apiBase: string): string | undefined {
84
87
  const parsedToken = parseTokenEnv(process.env.AGENTCLOUD_TOKEN);
85
88
  if (parsedToken.token) return parsedToken.token;
89
+ const stored = readStoredCredentials();
90
+ if (stored?.token) return stored.token;
86
91
  if (isLocalApiBase(apiBase)) {
87
92
  return process.env.AGENTCLOUD_TOKEN_DEV || undefined;
88
93
  }
@@ -128,6 +133,12 @@ export async function ensureApiBase(options: { interactive: boolean }): Promise<
128
133
  return parsedToken.apiBase;
129
134
  }
130
135
 
136
+ const storedBase = normalizeApiBase(readStoredCredentials()?.apiBase);
137
+ if (storedBase) {
138
+ process.env.AGENTCLOUD_API_BASE = storedBase;
139
+ return storedBase;
140
+ }
141
+
131
142
  const configBase = readApiBaseConfig();
132
143
  if (configBase) {
133
144
  process.env.AGENTCLOUD_API_BASE = configBase;
@@ -0,0 +1,58 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ export type StoredCredentials = {
6
+ token: string;
7
+ userId?: string;
8
+ email?: string;
9
+ accountType?: "guest" | "verified";
10
+ apiBase?: string;
11
+ updatedAt?: string;
12
+ };
13
+
14
+ function credentialsDir(): string {
15
+ return join(homedir(), ".uplink");
16
+ }
17
+
18
+ export function credentialsPath(): string {
19
+ return join(credentialsDir(), "credentials");
20
+ }
21
+
22
+ export function readStoredCredentials(): StoredCredentials | null {
23
+ try {
24
+ const path = credentialsPath();
25
+ if (!existsSync(path)) return null;
26
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as StoredCredentials;
27
+ if (!parsed?.token || typeof parsed.token !== "string") return null;
28
+ return parsed;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function writeStoredCredentials(creds: StoredCredentials): string {
35
+ const dir = credentialsDir();
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ try {
38
+ chmodSync(dir, 0o700);
39
+ } catch {
40
+ /* best effort */
41
+ }
42
+ const path = credentialsPath();
43
+ const body: StoredCredentials = {
44
+ token: creds.token,
45
+ userId: creds.userId,
46
+ email: creds.email,
47
+ accountType: creds.accountType,
48
+ apiBase: creds.apiBase,
49
+ updatedAt: new Date().toISOString(),
50
+ };
51
+ writeFileSync(path, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 });
52
+ try {
53
+ chmodSync(path, 0o600);
54
+ } catch {
55
+ /* best effort */
56
+ }
57
+ return path;
58
+ }
@@ -0,0 +1,56 @@
1
+ import { promises as dns } from "dns";
2
+ import fetch from "node-fetch";
3
+
4
+ export type PublicAvailability = {
5
+ domain: string;
6
+ status: "available" | "taken" | "unknown";
7
+ source: "dns" | "rdap";
8
+ detail: string;
9
+ };
10
+
11
+ const RDAP_TIMEOUT_MS = 8000;
12
+
13
+ /**
14
+ * Registrar-free availability check: DNS nameservers first (fast, definitive
15
+ * for registered domains), then RDAP for the authoritative registration record.
16
+ * Price and purchase still require a connected registrar.
17
+ */
18
+ export async function checkDomainAvailability(domain: string): Promise<PublicAvailability> {
19
+ try {
20
+ const nameservers = await dns.resolveNs(domain);
21
+ if (nameservers.length > 0) {
22
+ return { domain, status: "taken", source: "dns", detail: "domain has nameservers" };
23
+ }
24
+ } catch {
25
+ // NXDOMAIN or no NS records — RDAP decides.
26
+ }
27
+
28
+ let lastFailure = "RDAP unreachable";
29
+ for (let attempt = 0; attempt < 2; attempt++) {
30
+ try {
31
+ const response = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, {
32
+ headers: { accept: "application/rdap+json" },
33
+ timeout: RDAP_TIMEOUT_MS,
34
+ });
35
+ if (response.status === 404) {
36
+ return { domain, status: "available", source: "rdap", detail: "no registration record" };
37
+ }
38
+ if (response.ok) {
39
+ return { domain, status: "taken", source: "rdap", detail: "registration record exists" };
40
+ }
41
+ lastFailure = `RDAP returned ${response.status}`;
42
+ } catch (error) {
43
+ lastFailure = error instanceof Error ? error.message : String(error);
44
+ }
45
+ }
46
+ return { domain, status: "unknown", source: "rdap", detail: lastFailure };
47
+ }
48
+
49
+ export function formatPublicAvailability(result: PublicAvailability): string {
50
+ const lines = [`${result.domain} ${result.status} (${result.detail})`];
51
+ if (result.status === "available") {
52
+ lines.push(" Availability is from public DNS/RDAP. For price and purchase, connect a registrar:");
53
+ lines.push(" uplink domains providers connect godaddy --token-env GODADDY_PAT");
54
+ }
55
+ return lines.join("\n");
56
+ }
@@ -0,0 +1,38 @@
1
+ import fetch from "node-fetch";
2
+ import { getResolvedApiBase, getResolvedApiToken } from "./api-base";
3
+ import { readStoredCredentials, writeStoredCredentials } from "./credentials";
4
+
5
+ type GuestSignup = {
6
+ token: string;
7
+ userId: string;
8
+ accountType?: "guest";
9
+ };
10
+
11
+ export async function ensureGuestAccess(options: { force?: boolean } = {}): Promise<void> {
12
+ const apiBase = getResolvedApiBase();
13
+ // force: mint a fresh guest token even when one resolves (e.g. it was rejected by the API).
14
+ if (!options.force && getResolvedApiToken(apiBase)) return;
15
+
16
+ const response = await fetch(`${apiBase}/v1/signup`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ label: "Automatic guest access" }),
20
+ });
21
+ const json = (await response.json().catch(() => ({}))) as GuestSignup & {
22
+ error?: { code?: string; message?: string };
23
+ };
24
+ if (!response.ok || !json.token) {
25
+ const code = json.error?.code || "GUEST_ACCESS_FAILED";
26
+ const message = json.error?.message || response.statusText;
27
+ throw new Error(`${code}: ${message}`);
28
+ }
29
+
30
+ writeStoredCredentials({
31
+ ...(readStoredCredentials() ?? {}),
32
+ token: json.token,
33
+ userId: json.userId,
34
+ accountType: "guest",
35
+ apiBase,
36
+ });
37
+ process.env.AGENTCLOUD_TOKEN = json.token;
38
+ }
@@ -0,0 +1,57 @@
1
+ import { unauthenticatedRequest } from "../subcommands/menu/requests";
2
+ import { getResolvedApiBase } from "./api-base";
3
+ import { writeStoredCredentials } from "./credentials";
4
+
5
+ export type LoginToken = {
6
+ id: string;
7
+ token: string;
8
+ tokenPrefix: string;
9
+ role: string;
10
+ userId: string;
11
+ label: string;
12
+ createdAt: string;
13
+ expiresAt: string | null;
14
+ message?: string;
15
+ };
16
+
17
+ export function normalizeEmail(raw: string): string {
18
+ return raw.trim().toLowerCase();
19
+ }
20
+
21
+ export function isEmail(raw: string): boolean {
22
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(raw));
23
+ }
24
+
25
+ export async function requestLoginCode(email: string): Promise<{ ok: boolean; message?: string }> {
26
+ return unauthenticatedRequest(
27
+ "POST",
28
+ "/v1/auth/otp/request",
29
+ { email: normalizeEmail(email) },
30
+ { includeCurrentToken: true }
31
+ );
32
+ }
33
+
34
+ export async function verifyLoginCode(email: string, code: string): Promise<LoginToken> {
35
+ return unauthenticatedRequest(
36
+ "POST",
37
+ "/v1/auth/otp/verify",
38
+ {
39
+ email: normalizeEmail(email),
40
+ code: code.trim(),
41
+ },
42
+ { includeCurrentToken: true }
43
+ );
44
+ }
45
+
46
+ export function persistLogin(result: { token: string; userId: string }, email?: string): string {
47
+ const apiBase = getResolvedApiBase();
48
+ const path = writeStoredCredentials({
49
+ token: result.token,
50
+ userId: result.userId,
51
+ email,
52
+ accountType: email ? "verified" : "guest",
53
+ apiBase,
54
+ });
55
+ process.env.AGENTCLOUD_TOKEN = result.token;
56
+ return path;
57
+ }
package/docs/AGENTS.md CHANGED
@@ -7,13 +7,15 @@ Package name: `uplink-cli` · Binary: `uplink`
7
7
 
8
8
  ## Auth
9
9
 
10
+ - `uplink tunnel create` automatically creates guest access when no token exists. Guest access includes **1 active tunnel**, expiring after **24 hours**.
10
11
  - Use `AGENTCLOUD_TOKEN` (bearer). Prefer stdin over argv:
11
12
  ```bash
12
13
  echo "$TOKEN" | uplink --token-stdin …
13
14
  ```
15
+ - Humans: `uplink login --email you@example.com` then `--code 123456`. This upgrades current guest access, preserves its tunnel, and unlocks persistent features. Credentials are saved to `~/.uplink/credentials` (chmod 600).
14
16
  - API base: `--api-base https://api.uplink.spot` or `AGENTCLOUD_API_BASE`.
15
17
 
16
- ## Signup (no auth required)
18
+ ## Explicit guest token (optional)
17
19
 
18
20
  ```bash
19
21
  uplink signup --json
@@ -26,6 +28,8 @@ Save `token` from the JSON — it is shown only once. Then:
26
28
  export AGENTCLOUD_TOKEN='…'
27
29
  ```
28
30
 
31
+ Explicit signup creates guest access. A later email login upgrades that guest account when the current token is available.
32
+
29
33
  ## Machine-mode contract
30
34
 
31
35
  | Rule | Detail |
@@ -37,8 +41,12 @@ export AGENTCLOUD_TOKEN='…'
37
41
  | Exit `20` | network |
38
42
  | Exit `30` | server / unknown |
39
43
 
44
+ Guest accounts can share one local port and use public domain search. Hosting, databases, aliases, and custom domains require a verified email account. Gate error: `ACCOUNT_VERIFICATION_REQUIRED`.
45
+
40
46
  Premium aliases may return `ALIAS_NOT_ENABLED` / `ALIAS_LIMIT_REACHED`.
41
47
 
48
+ Free hosting (new accounts): **1 app**, **100 MB** of live artifacts, **no custom domains**, app **sleeps after 30 minutes idle**. Errors: `HOST_APP_LIMIT_REACHED`, `HOST_STORAGE_LIMIT_REACHED`, `HOST_DOMAIN_NOT_ENABLED`. Check quota: the `hosting` object on `GET /v1/me`.
49
+
42
50
  ## Tunnels (share localhost)
43
51
 
44
52
  `tunnel create` **creates the API record and starts the local client** so the public URL works. Use `--api-only` only if you will start the client yourself.
@@ -110,6 +118,7 @@ The bare `uplink domains` search TUI is **optional** and not bundled with npm
110
118
  uplink domains providers connect godaddy --token-env GODADDY_PAT --json
111
119
  uplink domains providers connect cloudflare --token-env CF_API_TOKEN --json
112
120
  uplink domains providers connect hostinger --token-env HOSTINGER_API_TOKEN --json
121
+ uplink domains providers connect dreamhost --token-env DREAMHOST_API_KEY --json
113
122
  uplink domains providers connect namecheap --token-env NAMECHEAP_API_KEY --user-env NAMECHEAP_API_USER --json
114
123
  uplink domains providers list --json
115
124
  uplink domains providers disconnect godaddy --json
@@ -123,7 +132,7 @@ echo "$TOKEN" | uplink --token-stdin host domains list --id app_xxx --json
123
132
  echo "$TOKEN" | uplink --token-stdin host domains remove --id app_xxx --hostname example.com --json
124
133
  ```
125
134
 
126
- Do not treat RDAP “available” as buyable unless `domains check` says `buyable: true`. Purchase is not wired yet.
135
+ `domains check` works without a registrar: it falls back to public DNS/RDAP and returns `provider: "public"` with no price. Do not treat RDAP “available” as buyable unless `domains check` says `buyable: true`. Purchase is not wired yet.
127
136
 
128
137
  ## Databases (optional)
129
138
 
@@ -142,6 +151,10 @@ echo "$TOKEN" | uplink --token-stdin db delete --id db_xxx --yes --json
142
151
  | Auth errors | Missing/invalid `AGENTCLOUD_TOKEN`; use `--token-stdin` |
143
152
  | `ALIAS_NOT_ENABLED` | Account does not have permanent aliases |
144
153
  | Domain search TUI missing | Expected on npm — use `domains list` / `check` / `host domains *` |
154
+ | `HOST_APP_LIMIT_REACHED` | Free plan is 1 hosted app — delete one or the account needs hosting granted |
155
+ | `HOST_STORAGE_LIMIT_REACHED` | Upload exceeds the 100 MB free hosting budget |
156
+ | `HOST_DOMAIN_NOT_ENABLED` | Custom domains are paid — `*.host.uplink.spot` still works |
157
+ | First request after idle is slow | Free apps sleep after 30 minutes; the router wakes them |
145
158
  | Hosting stuck `queued` | Edge builder/runner issue — check `host status` / `host logs` |
146
159
 
147
160
  ## Interactive menu
@@ -157,5 +170,7 @@ Agents should prefer the non-interactive commands above.
157
170
  ## More
158
171
 
159
172
  - Menu map: `docs/MENU_STRUCTURE.md`
173
+ - Hosting: `docs/HOSTING.md`
174
+ - Product: `docs/PRODUCT.md`
160
175
  - Website: https://uplink.spot
161
176
  - npm: https://www.npmjs.com/package/uplink-cli
@@ -0,0 +1,55 @@
1
+ # Hosting
2
+
3
+ Verified accounts can deploy apps to `*.host.uplink.spot`. Guests cannot — the API returns `ACCOUNT_VERIFICATION_REQUIRED`.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ echo "$TOKEN" | uplink --token-stdin host setup --path . --name myapp --yes --json
9
+ echo "$TOKEN" | uplink --token-stdin host deploy --path . --name myapp --wait --json
10
+ echo "$TOKEN" | uplink --token-stdin host list --json
11
+ echo "$TOKEN" | uplink --token-stdin host status --id app_xxx --json
12
+ echo "$TOKEN" | uplink --token-stdin host logs --id app_xxx --json
13
+ echo "$TOKEN" | uplink --token-stdin host delete --id app_xxx --yes --json
14
+ ```
15
+
16
+ Public URL is `https://<app_id>.host.uplink.spot` (the app **id**, not the name). Custom hostnames attach with `host domains add` / `verify`.
17
+
18
+ ## What gets deployed
19
+
20
+ - Next.js (App Router): `output: "standalone"` in `next.config`.
21
+ - Vite / CRA: static `dist` or `build`.
22
+ - Anything else: a `Dockerfile` in the project root.
23
+
24
+ Use a `.uplinkignore` so `node_modules`, `.next`, `dist`, logs, and local databases are not uploaded.
25
+
26
+ ## Free-plan quotas
27
+
28
+ From `GET /v1/me` → `hosting`:
29
+
30
+ | Limit | Typical free account |
31
+ |-------|----------------------|
32
+ | Apps | 1 |
33
+ | Live artifacts | 100 MB |
34
+ | Custom domains | off (`HOST_DOMAIN_NOT_ENABLED`) |
35
+ | Idle sleep | 30 minutes with no traffic |
36
+
37
+ The platform URL still works when custom domains are gated.
38
+
39
+ ## Sleep and wake
40
+
41
+ After idle timeout the runner stops the container and keeps the image. Status is `sleeping`, not deleted.
42
+
43
+ The next HTTPS request to the app URL (or a verified custom domain) tells the router to wake it. Expect ~1–4 seconds on first hit, then normal latency.
44
+
45
+ `host list` still shows sleeping apps. `host status` is where you see `running` vs `sleeping`.
46
+
47
+ ## Errors
48
+
49
+ | Code | Meaning |
50
+ |------|---------|
51
+ | `ACCOUNT_VERIFICATION_REQUIRED` | Guest token — run `uplink login` |
52
+ | `HOST_APP_LIMIT_REACHED` | Delete an app or raise the plan |
53
+ | `HOST_STORAGE_LIMIT_REACHED` | Artifact too large |
54
+ | `HOST_DOMAIN_NOT_ENABLED` | Custom domain not on this plan |
55
+ | `NOT_READY` | Logs/status before a deployment is running |
@@ -13,20 +13,24 @@ The menu adapts by auth and role. Arrow keys + Enter (no numeric entry).
13
13
 
14
14
  | State | Condition | Main options |
15
15
  |-------|-----------|--------------|
16
- | Unauthenticated | No / invalid `AGENTCLOUD_TOKEN` | Get Started, Find a domain, About, Exit |
17
- | User | Valid token | Share, Hosting, Domains, About, Exit |
16
+ | Guest | No / invalid token → guest access is created silently on menu open | Share, Check domain availability, Continue with email, About, Exit |
17
+ | Verified user | Email-verified token | Share, Hosting, Domains, About, Exit |
18
18
  | Admin | `role: admin` | Same as user + Usage, System Status, Manage Tokens |
19
+ | Offline | API unreachable | Connection details, About, Exit |
20
+
21
+ There is no separate "unauthenticated" menu: opening the menu without a usable token mints a guest token (1 active tunnel, 24-hour expiry) and shows the same Share menu everyone gets — including port scanning and tunnel management.
19
22
 
20
23
  ---
21
24
 
22
- ## Unauthenticated
25
+ ## Guest
23
26
 
24
27
  ```
25
28
  UPLINK
26
- offline
29
+ connected
27
30
 
28
- Get Started signup; optional save AGENTCLOUD_TOKEN to shell rc
29
- Find a domain domain search TUI if Domainking is available
31
+ Share same full Share menu as verified users (no Aliases)
32
+ Check domain availability Domainking TUI if bundled, else inline `domains check` (public DNS/RDAP)
33
+ Continue with email → preserve guest tunnel; unlock Hosting + Domains
30
34
  About
31
35
  Exit
32
36
  ```
@@ -0,0 +1,64 @@
1
+ # Uplink
2
+
3
+ **Software for agents** — share localhost, host apps, and attach domains from the terminal. No dashboard required.
4
+
5
+ Website: [uplink.spot](https://uplink.spot) · CLI: [`uplink-cli`](https://www.npmjs.com/package/uplink-cli) · Agents: [AGENTS.md](../AGENTS.md)
6
+
7
+ ## What it is
8
+
9
+ Three surfaces, one CLI (`uplink`):
10
+
11
+ 1. **Share** — expose `localhost:<port>` as HTTPS (`https://<token>.x.uplink.spot`).
12
+ 2. **Hosting** — deploy a project to `https://<app_id>.host.uplink.spot` (email-verified accounts).
13
+ 3. **Domains** — check availability, list registrar inventory, attach a hostname to a hosted app.
14
+
15
+ Humans use `uplink` (keyboard menu). Agents use subcommands with `--json` and `--token-stdin`.
16
+
17
+ ## Accounts
18
+
19
+ | Kind | How you get it | Can do | Cannot do |
20
+ |------|----------------|--------|-----------|
21
+ | **Guest** | Automatic on `tunnel create` or menu open | 1 tunnel (24h), public `domains check` | Hosting, databases, aliases, custom domains |
22
+ | **Verified** | `uplink login --email` then OTP | Guest plus hosting / DBs / registrars | Plan-gated extras (aliases, custom domains) |
23
+ | **Admin** | Operator token in the control plane | Everything + Usage / System Status / Manage Tokens | — |
24
+
25
+ Guest → verified **merges**: the existing tunnel stays; credentials land in `~/.uplink/credentials` (mode 600). Gate error for locked features: `ACCOUNT_VERIFICATION_REQUIRED`.
26
+
27
+ ## URLs
28
+
29
+ | Kind | Format |
30
+ |------|--------|
31
+ | Tunnel | `https://<token>.x.uplink.spot` |
32
+ | Alias (plan) | `https://<alias>.uplink.spot` |
33
+ | Hosted app | `https://<app_id>.host.uplink.spot` |
34
+ | Custom domain | hostname attached + verified on an app |
35
+
36
+ Reserved alias labels include `www`, `api`, `x`, `host`, `docs`, `status`.
37
+
38
+ ## Architecture (high level)
39
+
40
+ ```
41
+ localhost --tunnel client--> relay (*.x.uplink.spot)
42
+ control plane API (api.uplink.spot)
43
+ hosted app --container------> runner + router (*.host.uplink.spot)
44
+ Caddy terminates TLS (Cloudflare DNS-01 wildcards).
45
+ ```
46
+
47
+ The CLI repo is public. The control plane, builder, runner, router, and relay live in a private runtime repo.
48
+
49
+ ## Hosting behavior
50
+
51
+ Free apps **sleep after 30 minutes idle**. A request wakes them (first hit is slower). See [HOSTING.md](./HOSTING.md).
52
+
53
+ ## vs a typical tunnel SaaS
54
+
55
+ | | Uplink | Typical tunnel dashboard |
56
+ |--|--------|--------------------------|
57
+ | Signup to share localhost | Guest token, no email | Account + browser |
58
+ | Agent install | `npx uplink-cli` + `--json` | Scraping a UI |
59
+ | Hosting | Same CLI | Separate product |
60
+ | Custom domains | Attach to a hosted app | Often a paid add-on |
61
+
62
+ ## License
63
+
64
+ CLI: MIT. Runtime: private.
package/docs/README.md CHANGED
@@ -11,11 +11,14 @@ uplink --help # commands
11
11
  ## Documentation
12
12
 
13
13
  - **[AGENTS.md](../AGENTS.md)** (also mirrored here) — programmatic CLI for AI agents
14
+ - **[PRODUCT.md](./PRODUCT.md)** — what Uplink is, account types, URLs
15
+ - **[HOSTING.md](./HOSTING.md)** — deploy, quotas, sleep/wake
14
16
  - **[MENU_STRUCTURE.md](./MENU_STRUCTURE.md)** — interactive menu reference
15
- - **[OPEN_SOURCE_CLI.md](./OPEN_SOURCE_CLI.md)** — public CLI vs private backend scope
17
+ - **[CHANGELOG.md](../CHANGELOG.md)** — releases
16
18
 
17
19
  ## Links
18
20
 
19
21
  - Website: [uplink.spot](https://uplink.spot)
20
22
  - npm: [uplink-cli](https://www.npmjs.com/package/uplink-cli)
23
+ - Source: [github.com/firstprinciplecode/uplink](https://github.com/firstprinciplecode/uplink)
21
24
  - API: `https://api.uplink.spot`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplink-cli",
3
- "version": "0.1.39",
3
+ "version": "0.2.1",
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",
@@ -42,7 +42,11 @@
42
42
  "AGENTS.md",
43
43
  "docs/README.md",
44
44
  "docs/AGENTS.md",
45
- "docs/MENU_STRUCTURE.md"
45
+ "docs/MENU_STRUCTURE.md",
46
+ "docs/HOSTING.md",
47
+ "docs/PRODUCT.md",
48
+ "CHANGELOG.md",
49
+ "tsconfig.json"
46
50
  ],
47
51
  "scripts": {
48
52
  "dev:cli": "tsx cli/src/index.ts",
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
+ }