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.
Files changed (67) hide show
  1. package/AGENTS.md +177 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +72 -52
  5. package/cli/src/index.ts +16 -3
  6. package/cli/src/registrars/cloudflare.ts +148 -0
  7. package/cli/src/registrars/dreamhost.ts +129 -0
  8. package/cli/src/registrars/godaddy.ts +105 -0
  9. package/cli/src/registrars/hostinger.ts +106 -0
  10. package/cli/src/registrars/http.ts +18 -0
  11. package/cli/src/registrars/index.ts +32 -0
  12. package/cli/src/registrars/namecheap.ts +163 -0
  13. package/cli/src/registrars/secret.ts +66 -0
  14. package/cli/src/registrars/store.ts +55 -0
  15. package/cli/src/registrars/types.ts +42 -0
  16. package/cli/src/subcommands/admin.ts +17 -30
  17. package/cli/src/subcommands/db.ts +63 -57
  18. package/cli/src/subcommands/dev.ts +23 -25
  19. package/cli/src/subcommands/domains.ts +295 -0
  20. package/cli/src/subcommands/host-domains.ts +148 -0
  21. package/cli/src/subcommands/host.ts +3 -0
  22. package/cli/src/subcommands/login.ts +85 -0
  23. package/cli/src/subcommands/menu/colors.ts +1 -1
  24. package/cli/src/subcommands/menu/effects/tunnel-clients.ts +87 -14
  25. package/cli/src/subcommands/menu/inline-tree-select.ts +6 -5
  26. package/cli/src/subcommands/menu/io.ts +27 -5
  27. package/cli/src/subcommands/menu/menus/domain-check.ts +34 -0
  28. package/cli/src/subcommands/menu/menus/domains.ts +197 -0
  29. package/cli/src/subcommands/menu/menus/hosting.ts +14 -46
  30. package/cli/src/subcommands/menu/menus/index.ts +1 -0
  31. package/cli/src/subcommands/menu/menus/tunnels.ts +25 -67
  32. package/cli/src/subcommands/menu/render.ts +2 -2
  33. package/cli/src/subcommands/menu/requests.ts +9 -2
  34. package/cli/src/subcommands/menu/tests.ts +1 -1
  35. package/cli/src/subcommands/menu/tunnels.ts +10 -99
  36. package/cli/src/subcommands/menu/types.ts +8 -0
  37. package/cli/src/subcommands/menu.ts +32 -524
  38. package/cli/src/subcommands/signup.ts +2 -2
  39. package/cli/src/subcommands/system.ts +58 -36
  40. package/cli/src/subcommands/tunnel.ts +126 -33
  41. package/cli/src/templates/index.ts +3 -3
  42. package/cli/src/tui/App.tsx +202 -0
  43. package/cli/src/tui/AppInspector.tsx +114 -0
  44. package/cli/src/tui/HomeStatus.tsx +92 -0
  45. package/cli/src/tui/brand.tsx +20 -0
  46. package/cli/src/tui/format.ts +22 -0
  47. package/cli/src/tui/index.mts +6 -0
  48. package/cli/src/tui/liveTree.ts +40 -0
  49. package/cli/src/tui/package.json +3 -0
  50. package/cli/src/tui/runMenu.tsx +57 -0
  51. package/cli/src/tui/session.mts +267 -0
  52. package/cli/src/tui/snapshot.ts +175 -0
  53. package/cli/src/utils/api-base.ts +11 -0
  54. package/cli/src/utils/credentials.ts +58 -0
  55. package/cli/src/utils/domain-availability.ts +56 -0
  56. package/cli/src/utils/guest-access.ts +38 -0
  57. package/cli/src/utils/launchDomainking.ts +64 -0
  58. package/cli/src/utils/login-flow.ts +57 -0
  59. package/docs/AGENTS.md +130 -148
  60. package/docs/HOSTING.md +55 -0
  61. package/docs/MENU_STRUCTURE.md +60 -288
  62. package/docs/PRODUCT.md +64 -0
  63. package/docs/README.md +11 -7
  64. package/package.json +22 -36
  65. package/scripts/tunnel/client-improved.js +127 -38
  66. package/scripts/tunnel/client.js +118 -0
  67. 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
+ });
@@ -67,5 +67,5 @@ export function colorSoftGray(text: string) {
67
67
  }
68
68
 
69
69
  export function colorAccent(text: string) {
70
- return `${c.brightBlue}${text}${c.reset}`;
70
+ return `${c.bold}${c.brightWhite}${text}${c.reset}`;
71
71
  }
@@ -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
- execSync(`kill -TERM ${pid}`, { stdio: "ignore" });
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
- // Start tunnel client in background
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));