uplink-cli 0.1.38 → 0.2.0
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 +177 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +72 -52
- package/cli/src/index.ts +16 -3
- package/cli/src/registrars/cloudflare.ts +148 -0
- package/cli/src/registrars/dreamhost.ts +129 -0
- package/cli/src/registrars/godaddy.ts +105 -0
- package/cli/src/registrars/hostinger.ts +106 -0
- package/cli/src/registrars/http.ts +18 -0
- package/cli/src/registrars/index.ts +32 -0
- package/cli/src/registrars/namecheap.ts +163 -0
- package/cli/src/registrars/secret.ts +66 -0
- package/cli/src/registrars/store.ts +55 -0
- package/cli/src/registrars/types.ts +42 -0
- package/cli/src/subcommands/admin.ts +17 -30
- package/cli/src/subcommands/db.ts +63 -57
- package/cli/src/subcommands/dev.ts +23 -25
- package/cli/src/subcommands/domains.ts +295 -0
- package/cli/src/subcommands/host-domains.ts +148 -0
- package/cli/src/subcommands/host.ts +3 -0
- package/cli/src/subcommands/login.ts +85 -0
- package/cli/src/subcommands/menu/colors.ts +1 -1
- package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
- package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
- package/cli/src/subcommands/menu/io.ts +27 -5
- package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
- package/cli/src/subcommands/menu/menus/domains.ts +197 -0
- package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
- package/cli/src/subcommands/menu/menus/index.ts +1 -0
- package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
- package/cli/src/subcommands/menu/render.ts +2 -2
- package/cli/src/subcommands/menu/requests.ts +9 -2
- package/cli/src/subcommands/menu/tests.ts +1 -1
- package/cli/src/subcommands/menu/tunnels.ts +10 -99
- package/cli/src/subcommands/menu/types.ts +8 -0
- package/cli/src/subcommands/menu.ts +32 -524
- package/cli/src/subcommands/signup.ts +2 -2
- package/cli/src/subcommands/system.ts +58 -36
- package/cli/src/subcommands/tunnel.ts +126 -33
- package/cli/src/templates/index.ts +3 -3
- package/cli/src/tui/App.tsx +202 -0
- package/cli/src/tui/AppInspector.tsx +114 -0
- package/cli/src/tui/HomeStatus.tsx +92 -0
- package/cli/src/tui/brand.tsx +20 -0
- package/cli/src/tui/format.ts +22 -0
- package/cli/src/tui/index.mts +6 -0
- package/cli/src/tui/liveTree.ts +40 -0
- package/cli/src/tui/package.json +3 -0
- package/cli/src/tui/runMenu.tsx +57 -0
- package/cli/src/tui/session.mts +267 -0
- package/cli/src/tui/snapshot.ts +175 -0
- 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/launchDomainking.ts +64 -0
- package/cli/src/utils/login-flow.ts +57 -0
- package/docs/AGENTS.md +130 -148
- package/docs/HOSTING.md +55 -0
- package/docs/MENU_STRUCTURE.md +60 -288
- package/docs/PRODUCT.md +64 -0
- package/docs/README.md +11 -7
- package/package.json +22 -36
- package/scripts/tunnel/client-improved.js +127 -38
- package/scripts/tunnel/client.js +118 -0
- package/assets/cli-screenshot.png +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { launchDomainking } from "../utils/launchDomainking";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
import {
|
|
5
|
+
adapters,
|
|
6
|
+
getAdapter,
|
|
7
|
+
isProviderId,
|
|
8
|
+
readRegistrarStore,
|
|
9
|
+
removeProvider,
|
|
10
|
+
saveProvider,
|
|
11
|
+
type DomainQuote,
|
|
12
|
+
type InventoryDomain,
|
|
13
|
+
type ProviderId,
|
|
14
|
+
type RegistrarCredentials,
|
|
15
|
+
} from "../registrars";
|
|
16
|
+
import { canPrompt, promptSecret, readEnvValue } from "../registrars/secret";
|
|
17
|
+
import {
|
|
18
|
+
checkDomainAvailability,
|
|
19
|
+
formatPublicAvailability,
|
|
20
|
+
} from "../utils/domain-availability";
|
|
21
|
+
|
|
22
|
+
// DreamHost last: it can only confirm ownership, not quote availability.
|
|
23
|
+
const CHECK_ORDER: ProviderId[] = ["godaddy", "cloudflare", "hostinger", "namecheap", "dreamhost"];
|
|
24
|
+
|
|
25
|
+
function parseProvider(raw?: string): ProviderId | undefined {
|
|
26
|
+
if (!raw) return undefined;
|
|
27
|
+
const id = raw.toLowerCase();
|
|
28
|
+
if (!isProviderId(id)) throw new Error(`Unknown provider: ${raw}`);
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function credentialsFromFlags(
|
|
33
|
+
provider: ProviderId,
|
|
34
|
+
opts: { tokenEnv?: string; userEnv?: string; accountEnv?: string; json?: boolean }
|
|
35
|
+
): Promise<RegistrarCredentials> {
|
|
36
|
+
const interactive = canPrompt() && !opts.json;
|
|
37
|
+
|
|
38
|
+
if (provider === "namecheap") {
|
|
39
|
+
const apiKey = opts.tokenEnv
|
|
40
|
+
? readEnvValue(opts.tokenEnv)
|
|
41
|
+
: interactive
|
|
42
|
+
? await promptSecret("Namecheap API key: ")
|
|
43
|
+
: "";
|
|
44
|
+
const apiUser = opts.userEnv
|
|
45
|
+
? readEnvValue(opts.userEnv)
|
|
46
|
+
: interactive
|
|
47
|
+
? await promptSecret("Namecheap API user: ")
|
|
48
|
+
: "";
|
|
49
|
+
if (!apiKey || !apiUser) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Namecheap needs --token-env NAMECHEAP_API_KEY and --user-env NAMECHEAP_API_USER"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return { apiKey, apiUser };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --token-env accepts a comma-separated list of env names for providers
|
|
58
|
+
// with multiple accounts (e.g. dreamhost).
|
|
59
|
+
const envNames = (opts.tokenEnv || "")
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((name) => name.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
const tokens = envNames.length
|
|
64
|
+
? envNames.map((name) => readEnvValue(name))
|
|
65
|
+
: interactive
|
|
66
|
+
? [await promptSecret(`${provider} API token: `)].filter(Boolean)
|
|
67
|
+
: [];
|
|
68
|
+
if (tokens.length === 0 || !tokens[0]) {
|
|
69
|
+
throw new Error(`${provider} needs --token-env <VAR> (do not pass the secret on the command line)`);
|
|
70
|
+
}
|
|
71
|
+
const creds: RegistrarCredentials = { token: tokens[0] };
|
|
72
|
+
if (tokens.length > 1) creds.extraTokens = tokens.slice(1);
|
|
73
|
+
if (opts.accountEnv) creds.accountId = readEnvValue(opts.accountEnv);
|
|
74
|
+
return creds;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function listInventory(providerFilter?: ProviderId): Promise<InventoryDomain[]> {
|
|
78
|
+
const store = readRegistrarStore();
|
|
79
|
+
const ids = providerFilter ? [providerFilter] : CHECK_ORDER.filter((id) => store[id]);
|
|
80
|
+
const domains: InventoryDomain[] = [];
|
|
81
|
+
const errors: string[] = [];
|
|
82
|
+
for (const id of ids) {
|
|
83
|
+
const creds = store[id];
|
|
84
|
+
if (!creds) {
|
|
85
|
+
if (providerFilter) throw new Error(`${id} is not connected`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const listed = await getAdapter(id).listDomains(creds);
|
|
90
|
+
domains.push(...listed);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
errors.push(`${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (domains.length === 0 && errors.length > 0) {
|
|
96
|
+
throw new Error(errors.join("; "));
|
|
97
|
+
}
|
|
98
|
+
return domains.sort((a, b) => a.domain.localeCompare(b.domain));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function quoteDomain(domain: string, providerFilter?: ProviderId): Promise<DomainQuote> {
|
|
102
|
+
const store = readRegistrarStore();
|
|
103
|
+
const ids = providerFilter
|
|
104
|
+
? [providerFilter]
|
|
105
|
+
: CHECK_ORDER.filter((id) => store[id]);
|
|
106
|
+
if (ids.length === 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"No registrar connected. Run: uplink domains providers connect godaddy --token-env GODADDY_PAT --json"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let lastError: string | undefined;
|
|
113
|
+
for (const id of ids) {
|
|
114
|
+
const creds = store[id];
|
|
115
|
+
if (!creds) throw new Error(`${id} is not connected`);
|
|
116
|
+
try {
|
|
117
|
+
return await getAdapter(id).check(creds, domain);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
domain,
|
|
124
|
+
provider: ids[0],
|
|
125
|
+
status: "unknown",
|
|
126
|
+
error: lastError || "check failed",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const domainsCommand = new Command("domains").description(
|
|
131
|
+
"Registrar inventory, availability, and search. Attach with `uplink host domains`."
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
domainsCommand.addHelpText(
|
|
135
|
+
"after",
|
|
136
|
+
"\nWith no subcommand, opens the domain search TUI.\n"
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
domainsCommand.action(() => {
|
|
140
|
+
const message = launchDomainking();
|
|
141
|
+
if (message) console.log(message);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
domainsCommand
|
|
145
|
+
.command("list")
|
|
146
|
+
.description("List domains owned at connected registrars")
|
|
147
|
+
.option("--provider <id>", "Only this provider (godaddy|cloudflare|hostinger|namecheap|dreamhost)")
|
|
148
|
+
.option("--json", "Output JSON", false)
|
|
149
|
+
.action(async (opts) => {
|
|
150
|
+
try {
|
|
151
|
+
const provider = parseProvider(opts.provider);
|
|
152
|
+
const domains = await listInventory(provider);
|
|
153
|
+
if (opts.json) {
|
|
154
|
+
printJson({ domains, count: domains.length });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (domains.length === 0) {
|
|
158
|
+
console.log("No domains. Connect a registrar: uplink domains providers connect godaddy");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const item of domains) {
|
|
162
|
+
const expiry = item.expiresAt ? ` expires ${item.expiresAt}` : "";
|
|
163
|
+
console.log(`- ${item.domain} (${item.provider}${expiry})`);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
handleError(error, { json: opts.json });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
domainsCommand
|
|
171
|
+
.command("check")
|
|
172
|
+
.description("Check domain availability (public DNS/RDAP; connected registrars add price)")
|
|
173
|
+
.argument("<domain>", "Domain name (e.g. example.com)")
|
|
174
|
+
.option("--provider <id>", "Which registrar to ask")
|
|
175
|
+
.option("--json", "Output JSON", false)
|
|
176
|
+
.action(async (domainArg: string, opts) => {
|
|
177
|
+
try {
|
|
178
|
+
const domain = String(domainArg).trim().toLowerCase();
|
|
179
|
+
if (!domain.includes(".")) throw new Error("Pass a full domain like example.com");
|
|
180
|
+
const provider = parseProvider(opts.provider);
|
|
181
|
+
|
|
182
|
+
// No registrar connected: fall back to public DNS/RDAP availability.
|
|
183
|
+
const store = readRegistrarStore();
|
|
184
|
+
const hasRegistrar = provider ? Boolean(store[provider]) : CHECK_ORDER.some((id) => store[id]);
|
|
185
|
+
if (!hasRegistrar) {
|
|
186
|
+
const result = await checkDomainAvailability(domain);
|
|
187
|
+
if (opts.json) {
|
|
188
|
+
printJson({
|
|
189
|
+
domain: result.domain,
|
|
190
|
+
provider: "public",
|
|
191
|
+
status: result.status,
|
|
192
|
+
buyable: null,
|
|
193
|
+
detail: result.detail,
|
|
194
|
+
note: "Connect a registrar for price and purchase info.",
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
console.log(formatPublicAvailability(result));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const quote = await quoteDomain(domain, provider);
|
|
203
|
+
if (opts.json) {
|
|
204
|
+
printJson(quote);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const price = quote.priceUsd != null ? ` $${quote.priceUsd.toFixed(2)}/yr` : "";
|
|
208
|
+
const premium = quote.premium ? " premium" : "";
|
|
209
|
+
console.log(`${quote.domain} ${quote.status}${price}${premium} [${quote.provider}]`);
|
|
210
|
+
if (quote.error) console.log(` ${quote.error}`);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
handleError(error, { json: opts.json });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const providers = domainsCommand.command("providers").description("Connect registrar accounts");
|
|
217
|
+
|
|
218
|
+
providers
|
|
219
|
+
.command("list")
|
|
220
|
+
.description("Show which registrars are connected (never prints secrets)")
|
|
221
|
+
.option("--json", "Output JSON", false)
|
|
222
|
+
.action((opts) => {
|
|
223
|
+
try {
|
|
224
|
+
const store = readRegistrarStore();
|
|
225
|
+
const items = adapters.map((adapter) => ({
|
|
226
|
+
id: adapter.id,
|
|
227
|
+
label: adapter.label,
|
|
228
|
+
connected: Boolean(store[adapter.id]),
|
|
229
|
+
help: adapter.connectHelp,
|
|
230
|
+
}));
|
|
231
|
+
if (opts.json) {
|
|
232
|
+
printJson({ providers: items });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const item of items) {
|
|
236
|
+
console.log(`- ${item.id} ${item.connected ? "connected" : "not connected"}`);
|
|
237
|
+
if (!item.connected) console.log(` ${item.help}`);
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
handleError(error, { json: opts.json });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
providers
|
|
245
|
+
.command("connect")
|
|
246
|
+
.description("Save a registrar credential after a live check")
|
|
247
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
|
|
248
|
+
.option("--token-env <name>", "Env var holding the API token (comma-separate names for multiple keys)")
|
|
249
|
+
.option("--user-env <name>", "Env var holding the Namecheap API user")
|
|
250
|
+
.option("--account-env <name>", "Env var holding a Cloudflare account id (optional)")
|
|
251
|
+
.option("--json", "Output JSON", false)
|
|
252
|
+
.action(async (providerArg: string, opts) => {
|
|
253
|
+
try {
|
|
254
|
+
const provider = String(providerArg).toLowerCase();
|
|
255
|
+
if (!isProviderId(provider)) {
|
|
256
|
+
throw new Error(`Unknown provider: ${providerArg}. Use godaddy, cloudflare, hostinger, namecheap, or dreamhost`);
|
|
257
|
+
}
|
|
258
|
+
const adapter = getAdapter(provider);
|
|
259
|
+
const creds = await credentialsFromFlags(provider, opts);
|
|
260
|
+
const verified = await adapter.verify(creds);
|
|
261
|
+
saveProvider(provider, verified);
|
|
262
|
+
const listed = await adapter.listDomains(verified).catch(() => [] as InventoryDomain[]);
|
|
263
|
+
if (opts.json) {
|
|
264
|
+
printJson({
|
|
265
|
+
provider,
|
|
266
|
+
connected: true,
|
|
267
|
+
domains: listed.length,
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
console.log(`Connected ${adapter.label} (${listed.length} domain${listed.length === 1 ? "" : "s"})`);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
handleError(error, { json: opts.json });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
providers
|
|
278
|
+
.command("disconnect")
|
|
279
|
+
.description("Remove a saved registrar credential")
|
|
280
|
+
.argument("<provider>", "godaddy | cloudflare | hostinger | namecheap | dreamhost")
|
|
281
|
+
.option("--json", "Output JSON", false)
|
|
282
|
+
.action((providerArg: string, opts) => {
|
|
283
|
+
try {
|
|
284
|
+
const provider = String(providerArg).toLowerCase();
|
|
285
|
+
if (!isProviderId(provider)) throw new Error(`Unknown provider: ${providerArg}`);
|
|
286
|
+
const removed = removeProvider(provider);
|
|
287
|
+
if (opts.json) {
|
|
288
|
+
printJson({ provider, connected: false, removed });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
console.log(removed ? `Disconnected ${provider}` : `${provider} was not connected`);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
handleError(error, { json: opts.json });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { apiRequest } from "../http";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
|
|
5
|
+
type AppDomain = {
|
|
6
|
+
id: string;
|
|
7
|
+
hostname: string;
|
|
8
|
+
verified: boolean;
|
|
9
|
+
verifiedAt: string | null;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
dns?: { type: string; host: string; target: string };
|
|
12
|
+
};
|
|
13
|
+
type AppDomainList = { domains: AppDomain[] };
|
|
14
|
+
|
|
15
|
+
async function findDomainByHostname(appId: string, hostname: string): Promise<AppDomain | null> {
|
|
16
|
+
const result = (await apiRequest("GET", `/v1/apps/${appId}/domains`)) as AppDomainList;
|
|
17
|
+
const clean = hostname.trim().toLowerCase();
|
|
18
|
+
return result.domains?.find((d) => d.hostname === clean) ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printDomain(domain: AppDomain): void {
|
|
22
|
+
const status = domain.verified ? "verified" : "pending verification";
|
|
23
|
+
console.log(`- ${domain.hostname} (${status})`);
|
|
24
|
+
if (domain.dns) {
|
|
25
|
+
console.log(` DNS needed: ${domain.dns.type} ${domain.dns.host} -> ${domain.dns.target}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The verify endpoint responds 409 with a reason while DNS doesn't point at the edge. */
|
|
30
|
+
function extractVerifyFailure(error: unknown): { reason?: string; dns?: AppDomain["dns"] } | null {
|
|
31
|
+
if (!(error instanceof Error)) return null;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(error.message) as { reason?: string; dns?: AppDomain["dns"] };
|
|
34
|
+
return parsed && (parsed.reason || parsed.dns) ? parsed : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const domainsCommand = new Command("domains").description(
|
|
41
|
+
"Manage custom domains for hosted apps"
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
domainsCommand
|
|
45
|
+
.command("list")
|
|
46
|
+
.description("List custom domains attached to an app")
|
|
47
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
48
|
+
.option("--json", "Output JSON", false)
|
|
49
|
+
.action(async (opts) => {
|
|
50
|
+
try {
|
|
51
|
+
const result = (await apiRequest("GET", `/v1/apps/${opts.id}/domains`)) as AppDomainList;
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
printJson(result);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!result.domains || result.domains.length === 0) {
|
|
57
|
+
console.log("No custom domains attached.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
console.log("Custom domains:");
|
|
61
|
+
for (const domain of result.domains) printDomain(domain);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
handleError(error, { json: opts.json });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
domainsCommand
|
|
68
|
+
.command("add")
|
|
69
|
+
.description("Attach a custom domain to an app")
|
|
70
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
71
|
+
.requiredOption("--hostname <hostname>", "Domain to attach (e.g. example.com)")
|
|
72
|
+
.option("--json", "Output JSON", false)
|
|
73
|
+
.action(async (opts) => {
|
|
74
|
+
try {
|
|
75
|
+
const domain = (await apiRequest("POST", `/v1/apps/${opts.id}/domains`, {
|
|
76
|
+
hostname: opts.hostname,
|
|
77
|
+
})) as AppDomain;
|
|
78
|
+
if (opts.json) {
|
|
79
|
+
printJson(domain);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
console.log(`Attached ${domain.hostname} (pending verification)`);
|
|
83
|
+
if (domain.dns) {
|
|
84
|
+
console.log(` 1. At your registrar: ${domain.dns.type} ${domain.dns.host} -> ${domain.dns.target}`);
|
|
85
|
+
}
|
|
86
|
+
console.log(` 2. Then run: uplink host domains verify --id ${opts.id} --hostname ${domain.hostname}`);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
handleError(error, { json: opts.json });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
domainsCommand
|
|
93
|
+
.command("verify")
|
|
94
|
+
.description("Verify a domain's DNS points at the hosting edge (enables routing + TLS)")
|
|
95
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
96
|
+
.requiredOption("--hostname <hostname>", "Domain to verify")
|
|
97
|
+
.option("--json", "Output JSON", false)
|
|
98
|
+
.action(async (opts) => {
|
|
99
|
+
try {
|
|
100
|
+
const domain = await findDomainByHostname(opts.id, opts.hostname);
|
|
101
|
+
if (!domain) throw new Error(`Domain ${opts.hostname} is not attached to app ${opts.id}`);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const verified = (await apiRequest(
|
|
105
|
+
"POST",
|
|
106
|
+
`/v1/apps/${opts.id}/domains/${domain.id}/verify`
|
|
107
|
+
)) as AppDomain;
|
|
108
|
+
if (opts.json) printJson(verified);
|
|
109
|
+
else {
|
|
110
|
+
console.log(`Verified ${verified.hostname}`);
|
|
111
|
+
console.log(` Live at https://${verified.hostname} (cert issues on first request)`);
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const failure = extractVerifyFailure(error);
|
|
115
|
+
if (!failure) throw error;
|
|
116
|
+
if (opts.json) {
|
|
117
|
+
printJson({ hostname: domain.hostname, verified: false, ...failure });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
console.log(`Not verified yet: ${failure.reason || "DNS does not point at the edge"}`);
|
|
121
|
+
if (failure.dns) {
|
|
122
|
+
console.log(` DNS needed: ${failure.dns.type} ${failure.dns.host} -> ${failure.dns.target}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(" DNS changes can take a few minutes to propagate; try again shortly.");
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
handleError(error, { json: opts.json });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
domainsCommand
|
|
133
|
+
.command("remove")
|
|
134
|
+
.description("Detach a custom domain from an app")
|
|
135
|
+
.requiredOption("--id <id>", "App id (app_...)")
|
|
136
|
+
.requiredOption("--hostname <hostname>", "Domain to detach")
|
|
137
|
+
.option("--json", "Output JSON", false)
|
|
138
|
+
.action(async (opts) => {
|
|
139
|
+
try {
|
|
140
|
+
const domain = await findDomainByHostname(opts.id, opts.hostname);
|
|
141
|
+
if (!domain) throw new Error(`Domain ${opts.hostname} is not attached to app ${opts.id}`);
|
|
142
|
+
const result = await apiRequest("DELETE", `/v1/apps/${opts.id}/domains/${domain.id}`);
|
|
143
|
+
if (opts.json) printJson(result);
|
|
144
|
+
else console.log(`Detached ${opts.hostname}`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
handleError(error, { json: opts.json });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
@@ -13,6 +13,7 @@ import os from "os";
|
|
|
13
13
|
import fetch from "node-fetch";
|
|
14
14
|
import { spawnSync } from "child_process";
|
|
15
15
|
import { getResolvedApiBase, getResolvedApiToken } from "../utils/api-base";
|
|
16
|
+
import { domainsCommand } from "./host-domains";
|
|
16
17
|
|
|
17
18
|
type App = { id: string; name: string; url: string; createdAt?: string; updatedAt?: string };
|
|
18
19
|
type AppList = { apps: App[]; count: number };
|
|
@@ -591,6 +592,8 @@ function writeUplinkIgnore(dir: string, entries: string[]): void {
|
|
|
591
592
|
|
|
592
593
|
export const hostCommand = new Command("host").description("Host persistent web services (Dockerfile required)");
|
|
593
594
|
|
|
595
|
+
hostCommand.addCommand(domainsCommand);
|
|
596
|
+
|
|
594
597
|
async function resolveSqliteConfig(
|
|
595
598
|
analysis: AnalysisResult,
|
|
596
599
|
opts: { yes: boolean }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createInterface } from "readline";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
import { formatTokenForEnv, getResolvedApiBase } from "../utils/api-base";
|
|
5
|
+
import { isEmail, normalizeEmail, persistLogin, requestLoginCode, verifyLoginCode } from "../utils/login-flow";
|
|
6
|
+
|
|
7
|
+
function prompt(question: string): Promise<string> {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
rl.question(question, (answer) => {
|
|
11
|
+
rl.close();
|
|
12
|
+
resolve(answer);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const loginCommand = new Command("login")
|
|
18
|
+
.description("Continue with email to unlock persistent features")
|
|
19
|
+
.option("--email <email>", "Email to send the login code to")
|
|
20
|
+
.option("--code <code>", "6-digit code from email (completes login)")
|
|
21
|
+
.option("--no-save", "Do not write ~/.uplink/credentials")
|
|
22
|
+
.option("--json", "Output JSON", false)
|
|
23
|
+
.action(async (opts) => {
|
|
24
|
+
try {
|
|
25
|
+
const json = Boolean(opts.json);
|
|
26
|
+
let email = opts.email ? normalizeEmail(opts.email) : "";
|
|
27
|
+
if (!email) {
|
|
28
|
+
if (json) {
|
|
29
|
+
console.error("Provide --email. To finish login, also pass --code.");
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
email = normalizeEmail(await prompt("Email: "));
|
|
33
|
+
}
|
|
34
|
+
if (!isEmail(email)) {
|
|
35
|
+
console.error("Invalid email.");
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const codeOpt = opts.code ? String(opts.code).trim() : "";
|
|
40
|
+
|
|
41
|
+
if (!codeOpt) {
|
|
42
|
+
await requestLoginCode(email);
|
|
43
|
+
if (json) {
|
|
44
|
+
printJson({ ok: true, email, message: "OTP sent. Run again with --email and --code." });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
console.log(`Code sent to ${email}.`);
|
|
48
|
+
opts.code = (await prompt("Code: ")).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const code = String(opts.code || "").trim();
|
|
52
|
+
if (!/^\d{6}$/.test(code)) {
|
|
53
|
+
console.error("Code must be 6 digits.");
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = await verifyLoginCode(email, code);
|
|
58
|
+
const apiBase = getResolvedApiBase();
|
|
59
|
+
const save = opts.save !== false;
|
|
60
|
+
let savedTo: string | null = null;
|
|
61
|
+
if (save) {
|
|
62
|
+
savedTo = persistLogin(result, email);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (json) {
|
|
66
|
+
printJson({ ...result, email, savedTo });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tokenExport = formatTokenForEnv(result.token, apiBase);
|
|
71
|
+
console.log("Logged in.");
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(` Email: ${email}`);
|
|
74
|
+
console.log(` User ID: ${result.userId}`);
|
|
75
|
+
console.log(` Token: ${result.token}`);
|
|
76
|
+
if (savedTo) console.log(` Saved: ${savedTo}`);
|
|
77
|
+
console.log("");
|
|
78
|
+
if (!savedTo) {
|
|
79
|
+
console.log("To use this token:");
|
|
80
|
+
console.log(` export AGENTCLOUD_TOKEN="${tokenExport}"`);
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
handleError(error, { json: opts.json });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
import { execSync, spawn } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
2
4
|
import { resolveProjectRoot } from "../../../utils/project-root";
|
|
3
5
|
|
|
4
6
|
export type TunnelClient = { pid: number; port: number; token: string };
|
|
5
7
|
|
|
8
|
+
export function resolveTunnelClientPath(): string {
|
|
9
|
+
const projectRoot = resolveProjectRoot(__dirname);
|
|
10
|
+
const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
|
|
11
|
+
if (!existsSync(clientPath)) {
|
|
12
|
+
throw new Error(`Tunnel client not found at ${clientPath}`);
|
|
13
|
+
}
|
|
14
|
+
return clientPath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Start the local tunnel client in the background (detached). */
|
|
18
|
+
export function startTunnelClient(opts: {
|
|
19
|
+
token: string;
|
|
20
|
+
port: number;
|
|
21
|
+
ctrl?: string;
|
|
22
|
+
}): { pid: number; clientPath: string } {
|
|
23
|
+
const projectRoot = resolveProjectRoot(__dirname);
|
|
24
|
+
const clientPath = resolveTunnelClientPath();
|
|
25
|
+
const ctrl = opts.ctrl || process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
|
|
26
|
+
const clientProcess = spawn(
|
|
27
|
+
"node",
|
|
28
|
+
[clientPath, "--token", opts.token, "--port", String(opts.port), "--ctrl", ctrl],
|
|
29
|
+
{
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
detached: true,
|
|
32
|
+
cwd: projectRoot,
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
clientProcess.unref();
|
|
36
|
+
if (!clientProcess.pid) {
|
|
37
|
+
throw new Error("Failed to start tunnel client process");
|
|
38
|
+
}
|
|
39
|
+
return { pid: clientProcess.pid, clientPath };
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
export function findTunnelClients(): TunnelClient[] {
|
|
7
43
|
try {
|
|
8
44
|
// Find processes running client-improved.js (current user, match script path to avoid false positives)
|
|
@@ -39,11 +75,23 @@ export function findTunnelClients(): TunnelClient[] {
|
|
|
39
75
|
|
|
40
76
|
export function killTunnelClient(pid: number): boolean {
|
|
41
77
|
try {
|
|
42
|
-
|
|
43
|
-
return true;
|
|
78
|
+
process.kill(pid, "SIGTERM");
|
|
44
79
|
} catch {
|
|
45
80
|
return false;
|
|
46
81
|
}
|
|
82
|
+
try {
|
|
83
|
+
execSync(`kill -0 ${pid} && sleep 0.4 && kill -KILL ${pid} || true`, {
|
|
84
|
+
stdio: "ignore",
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
/* process already gone */
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 0);
|
|
91
|
+
return false;
|
|
92
|
+
} catch {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
47
95
|
}
|
|
48
96
|
|
|
49
97
|
export function killAllTunnelClients(clients: TunnelClient[]): number {
|
|
@@ -54,8 +102,44 @@ export function killAllTunnelClients(clients: TunnelClient[]): number {
|
|
|
54
102
|
return killed;
|
|
55
103
|
}
|
|
56
104
|
|
|
105
|
+
type ApiTunnel = { id?: string; token?: string; connected?: boolean };
|
|
106
|
+
|
|
57
107
|
type ApiRequest = (method: string, path: string, body?: unknown) => Promise<any>;
|
|
58
108
|
|
|
109
|
+
export async function stopTunnelClients(
|
|
110
|
+
apiRequest: ApiRequest,
|
|
111
|
+
clients: TunnelClient[],
|
|
112
|
+
opts: { connectedGhosts?: boolean } = {}
|
|
113
|
+
): Promise<{ killed: number; deleted: number }> {
|
|
114
|
+
const tokens = new Set(clients.map((c) => c.token));
|
|
115
|
+
let deleted = 0;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const result = await apiRequest("GET", "/v1/tunnels");
|
|
119
|
+
const tunnels = (result.tunnels || []) as ApiTunnel[];
|
|
120
|
+
for (const tunnel of tunnels) {
|
|
121
|
+
if (!tunnel.id) continue;
|
|
122
|
+
const matched = Boolean(tunnel.token && tokens.has(tunnel.token));
|
|
123
|
+
const ghost = Boolean(opts.connectedGhosts && tunnel.connected);
|
|
124
|
+
if (!matched && !ghost) continue;
|
|
125
|
+
try {
|
|
126
|
+
await apiRequest("DELETE", `/v1/tunnels/${tunnel.id}`);
|
|
127
|
+
deleted++;
|
|
128
|
+
} catch {
|
|
129
|
+
/* keep stopping the rest */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
/* still kill local processes */
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let killed = 0;
|
|
137
|
+
for (const c of clients) {
|
|
138
|
+
if (killTunnelClient(c.pid)) killed++;
|
|
139
|
+
}
|
|
140
|
+
return { killed, deleted };
|
|
141
|
+
}
|
|
142
|
+
|
|
59
143
|
/**
|
|
60
144
|
* Create a tunnel via API and start the local client in background.
|
|
61
145
|
* NOTE: Maintains existing behavior including the brief post-spawn delay.
|
|
@@ -79,19 +163,8 @@ export async function createAndStartTunnel(apiRequest: ApiRequest, port: number)
|
|
|
79
163
|
const url = result.url || "(no url)";
|
|
80
164
|
const token = result.token || "(no token)";
|
|
81
165
|
const alias = result.alias || null;
|
|
82
|
-
const ctrl = process.env.TUNNEL_CTRL || "tunnel.uplink.spot:7071";
|
|
83
166
|
|
|
84
|
-
|
|
85
|
-
// (CommonJS build: __dirname available)
|
|
86
|
-
const path = require("path");
|
|
87
|
-
const projectRoot = resolveProjectRoot(__dirname);
|
|
88
|
-
const clientPath = path.join(projectRoot, "scripts/tunnel/client-improved.js");
|
|
89
|
-
const clientProcess = spawn("node", [clientPath, "--token", token, "--port", String(port), "--ctrl", ctrl], {
|
|
90
|
-
stdio: "ignore",
|
|
91
|
-
detached: true,
|
|
92
|
-
cwd: projectRoot,
|
|
93
|
-
});
|
|
94
|
-
clientProcess.unref();
|
|
167
|
+
startTunnelClient({ token, port });
|
|
95
168
|
|
|
96
169
|
// Wait a moment for client to connect
|
|
97
170
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|