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.
- package/AGENTS.md +17 -2
- package/CHANGELOG.md +19 -0
- package/README.md +23 -7
- package/cli/bin/uplink.js +6 -1
- package/cli/src/index.ts +11 -0
- package/cli/src/registrars/dreamhost.ts +129 -0
- package/cli/src/registrars/godaddy.ts +6 -0
- package/cli/src/registrars/index.ts +2 -0
- package/cli/src/registrars/types.ts +3 -1
- package/cli/src/subcommands/domains.ts +46 -19
- package/cli/src/subcommands/login.ts +85 -0
- package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
- package/cli/src/subcommands/menu/menus/domains.ts +4 -6
- package/cli/src/subcommands/menu/requests.ts +9 -2
- package/cli/src/subcommands/signup.ts +2 -2
- package/cli/src/subcommands/tunnel.ts +2 -0
- package/cli/src/tui/App.tsx +5 -0
- package/cli/src/tui/HomeStatus.tsx +53 -20
- package/cli/src/tui/session.mts +133 -248
- package/cli/src/tui/snapshot.ts +30 -1
- package/cli/src/utils/api-base.ts +11 -0
- package/cli/src/utils/credentials.ts +58 -0
- package/cli/src/utils/domain-availability.ts +56 -0
- package/cli/src/utils/guest-access.ts +38 -0
- package/cli/src/utils/login-flow.ts +57 -0
- package/docs/AGENTS.md +17 -2
- package/docs/HOSTING.md +55 -0
- package/docs/MENU_STRUCTURE.md +10 -6
- package/docs/PRODUCT.md +64 -0
- package/docs/README.md +4 -1
- package/package.json +6 -2
- package/tsconfig.json +17 -0
package/cli/src/tui/snapshot.ts
CHANGED
|
@@ -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
|
-
##
|
|
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
|
package/docs/HOSTING.md
ADDED
|
@@ -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 |
|
package/docs/MENU_STRUCTURE.md
CHANGED
|
@@ -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
|
-
|
|
|
17
|
-
|
|
|
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
|
-
##
|
|
25
|
+
## Guest
|
|
23
26
|
|
|
24
27
|
```
|
|
25
28
|
UPLINK
|
|
26
|
-
●
|
|
29
|
+
● connected
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
```
|
package/docs/PRODUCT.md
ADDED
|
@@ -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
|
-
- **[
|
|
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
|
|
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
|
+
}
|