buncargo 3.0.0 → 3.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 (43) hide show
  1. package/dist/cli/bin.js +10 -8
  2. package/dist/cli/index.js +2 -2
  3. package/dist/cli/run-cli.d.ts +10 -2
  4. package/dist/core/quick-tunnel/cloudflared-process.d.ts +10 -0
  5. package/dist/core/quick-tunnel/constants.d.ts +9 -0
  6. package/dist/core/quick-tunnel/index.d.ts +17 -0
  7. package/dist/core/quick-tunnel/install.d.ts +1 -0
  8. package/dist/core/tunnel.d.ts +3 -2
  9. package/dist/environment/index.js +2 -2
  10. package/dist/environment/logging.d.ts +6 -6
  11. package/dist/environment/only-apps.d.ts +10 -0
  12. package/dist/index-3eyrdxw9.js +577 -0
  13. package/dist/index-5aq985p4.js +250 -0
  14. package/dist/index-6cmex7m5.js +72 -0
  15. package/dist/index-6d6x175r.js +572 -0
  16. package/dist/index-7v19es2e.js +666 -0
  17. package/dist/index-9wyhzw0h.js +574 -0
  18. package/dist/index-ag90ry8t.js +576 -0
  19. package/dist/index-byeqyjrz.js +72 -0
  20. package/dist/index-enj4zdma.js +574 -0
  21. package/dist/index-k370bech.js +72 -0
  22. package/dist/index-qa8akv6y.js +666 -0
  23. package/dist/index-vg55rq0y.js +250 -0
  24. package/dist/index-vs81yaks.js +244 -0
  25. package/dist/index-x54nbgs7.js +355 -0
  26. package/dist/index-yz4jfz7z.js +338 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +9 -8
  29. package/dist/loader/index.js +3 -3
  30. package/dist/types/all-types.d.ts +46 -3
  31. package/package.json +147 -145
  32. package/readme.md +16 -0
  33. package/src/cli/run-cli.ts +27 -12
  34. package/src/core/quick-tunnel/cloudflared-process.ts +83 -0
  35. package/src/core/quick-tunnel/constants.ts +31 -0
  36. package/src/core/quick-tunnel/index.ts +96 -0
  37. package/src/core/quick-tunnel/install.ts +160 -0
  38. package/src/core/tunnel.ts +22 -8
  39. package/src/environment/create-dev-environment.ts +123 -13
  40. package/src/environment/logging.ts +34 -20
  41. package/src/environment/only-apps.ts +34 -0
  42. package/src/index.ts +3 -0
  43. package/src/types/all-types.ts +56 -3
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Paths and release metadata for the cloudflared binary.
3
+ * Derived from unjs/untun (MIT), originally forked from node-cloudflared.
4
+ */
5
+
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+
9
+ export const CLOUDFLARED_VERSION =
10
+ process.env.CLOUDFLARED_VERSION || "2023.10.0";
11
+
12
+ export const RELEASE_BASE =
13
+ "https://github.com/cloudflare/cloudflared/releases/";
14
+
15
+ /** Directory for buncargo-managed cloudflared (avoid clashing with untun's node-untun). */
16
+ export const cloudflaredBinPath = path.join(
17
+ tmpdir(),
18
+ "buncargo-cloudflared",
19
+ process.platform === "win32"
20
+ ? `cloudflared.${CLOUDFLARED_VERSION}.exe`
21
+ : `cloudflared.${CLOUDFLARED_VERSION}`,
22
+ );
23
+
24
+ export const cloudflaredNotice = `
25
+ 🔥 Your installation of cloudflared software constitutes a symbol of your signature
26
+ indicating that you accept the terms of the Cloudflare License, Terms and Privacy Policy.
27
+
28
+ ❯ License: \`https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/license/\`
29
+ ❯ Terms: \`https://www.cloudflare.com/terms/\`
30
+ ❯ Privacy Policy: \`https://www.cloudflare.com/privacypolicy/\`
31
+ `;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Cloudflare Quick Tunnel via the cloudflared CLI (same approach as unjs/untun).
3
+ * License / download flow adapted from unjs/untun (MIT).
4
+ */
5
+ import { existsSync } from "node:fs";
6
+ import { createInterface } from "node:readline";
7
+ import { startCloudflaredTunnel } from "./cloudflared-process";
8
+ import { cloudflaredBinPath, cloudflaredNotice } from "./constants";
9
+ import { installCloudflared } from "./install";
10
+
11
+ export interface QuickTunnelOptions {
12
+ url?: string;
13
+ port?: number | string;
14
+ hostname?: string;
15
+ protocol?: "http" | "https";
16
+ verifyTLS?: boolean;
17
+ acceptCloudflareNotice?: boolean;
18
+ }
19
+
20
+ export interface QuickTunnel {
21
+ getURL: () => Promise<string>;
22
+ close: () => Promise<void>;
23
+ }
24
+
25
+ function resolvedLocalUrl(opts: QuickTunnelOptions): string {
26
+ return (
27
+ opts.url ??
28
+ `${opts.protocol || "http"}://${opts.hostname ?? "localhost"}:${opts.port ?? 3000}`
29
+ );
30
+ }
31
+
32
+ function envAcceptsCloudflareNotice(): boolean {
33
+ const v = process.env.BUNCARGO_ACCEPT_CLOUDFLARE_NOTICE;
34
+ const u = process.env.UNTUN_ACCEPT_CLOUDFLARE_NOTICE;
35
+ return v === "1" || v === "true" || u === "1" || u === "true";
36
+ }
37
+
38
+ async function promptInstallCloudflared(): Promise<boolean> {
39
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
40
+ return false;
41
+ }
42
+ return new Promise((resolve) => {
43
+ const rl = createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ });
47
+ rl.question(
48
+ "Do you agree with the above terms and wish to install the binary from GitHub? (y/N) ",
49
+ (answer) => {
50
+ rl.close();
51
+ resolve(/^y(es)?$/i.test(answer.trim()));
52
+ },
53
+ );
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Start a Cloudflare quick tunnel to a local HTTP(S) URL.
59
+ * Returns undefined if the user declines the cloudflared install (when binary is missing).
60
+ */
61
+ export async function startQuickTunnel(
62
+ opts: QuickTunnelOptions,
63
+ ): Promise<QuickTunnel | undefined> {
64
+ const url = resolvedLocalUrl(opts);
65
+
66
+ console.log(`Starting cloudflared tunnel to ${url}`);
67
+
68
+ if (!existsSync(cloudflaredBinPath)) {
69
+ console.log(cloudflaredNotice);
70
+ const canInstall =
71
+ opts.acceptCloudflareNotice ||
72
+ envAcceptsCloudflareNotice() ||
73
+ (await promptInstallCloudflared());
74
+ if (!canInstall) {
75
+ console.error("Skipping tunnel setup.");
76
+ return;
77
+ }
78
+ await installCloudflared();
79
+ }
80
+
81
+ const cfArgs: Record<string, string | number | null> = { "--url": url };
82
+ // Boolean flag: use `null` value so spawn does not pass a stray empty argv (see cloudflared-process).
83
+ if (!opts.verifyTLS) {
84
+ cfArgs["--no-tls-verify"] = null;
85
+ }
86
+ const tunnel = startCloudflaredTunnel(cfArgs);
87
+
88
+ const cleanup = async () => {
89
+ tunnel.stop();
90
+ };
91
+
92
+ return {
93
+ getURL: async () => await tunnel.url,
94
+ close: cleanup,
95
+ };
96
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Download cloudflared from GitHub releases.
3
+ * Derived from unjs/untun (MIT), originally forked from node-cloudflared.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import https from "node:https";
8
+ import path from "node:path";
9
+ import {
10
+ CLOUDFLARED_VERSION,
11
+ cloudflaredBinPath,
12
+ RELEASE_BASE,
13
+ } from "./constants";
14
+
15
+ const LINUX_URL: Partial<Record<NodeJS.Architecture, string>> = {
16
+ arm64: "cloudflared-linux-arm64",
17
+ arm: "cloudflared-linux-arm",
18
+ x64: "cloudflared-linux-amd64",
19
+ ia32: "cloudflared-linux-386",
20
+ };
21
+
22
+ const MACOS_URL: Partial<Record<NodeJS.Architecture, string>> = {
23
+ arm64: "cloudflared-darwin-amd64.tgz",
24
+ x64: "cloudflared-darwin-amd64.tgz",
25
+ };
26
+
27
+ const WINDOWS_URL: Partial<Record<NodeJS.Architecture, string>> = {
28
+ x64: "cloudflared-windows-amd64.exe",
29
+ ia32: "cloudflared-windows-386.exe",
30
+ };
31
+
32
+ function resolveBase(version: string): string {
33
+ if (version === "latest") {
34
+ return `${RELEASE_BASE}latest/download/`;
35
+ }
36
+ return `${RELEASE_BASE}download/${version}/`;
37
+ }
38
+
39
+ export function installCloudflared(
40
+ to: string = cloudflaredBinPath,
41
+ version = CLOUDFLARED_VERSION,
42
+ ): Promise<string> {
43
+ switch (process.platform) {
44
+ case "linux": {
45
+ return installLinux(to, version);
46
+ }
47
+ case "darwin": {
48
+ return installMacos(to, version);
49
+ }
50
+ case "win32": {
51
+ return installWindows(to, version);
52
+ }
53
+ default: {
54
+ throw new Error(`Unsupported platform: ${process.platform}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ async function installLinux(
60
+ to: string,
61
+ version = CLOUDFLARED_VERSION,
62
+ ): Promise<string> {
63
+ const file = LINUX_URL[process.arch];
64
+
65
+ if (file === undefined) {
66
+ throw new Error(`Unsupported architecture: ${process.arch}`);
67
+ }
68
+
69
+ await download(resolveBase(version) + file, to);
70
+ fs.chmodSync(to, 0o755);
71
+ return to;
72
+ }
73
+
74
+ async function installMacos(
75
+ to: string,
76
+ version = CLOUDFLARED_VERSION,
77
+ ): Promise<string> {
78
+ const file = MACOS_URL[process.arch];
79
+
80
+ if (file === undefined) {
81
+ throw new Error(`Unsupported architecture: ${process.arch}`);
82
+ }
83
+
84
+ await download(resolveBase(version) + file, `${to}.tgz`);
85
+ if (process.env.DEBUG) {
86
+ console.log(`Extracting to ${to}`);
87
+ }
88
+ execSync(`tar -xzf ${path.basename(`${to}.tgz`)}`, {
89
+ cwd: path.dirname(to),
90
+ });
91
+ fs.unlinkSync(`${to}.tgz`);
92
+ fs.renameSync(`${path.dirname(to)}/cloudflared`, to);
93
+ return to;
94
+ }
95
+
96
+ async function installWindows(
97
+ to: string,
98
+ version = CLOUDFLARED_VERSION,
99
+ ): Promise<string> {
100
+ const file = WINDOWS_URL[process.arch];
101
+
102
+ if (file === undefined) {
103
+ throw new Error(`Unsupported architecture: ${process.arch}`);
104
+ }
105
+
106
+ await download(resolveBase(version) + file, to);
107
+ return to;
108
+ }
109
+
110
+ function download(url: string, to: string, redirect = 0): Promise<string> {
111
+ if (redirect === 0) {
112
+ if (process.env.DEBUG) {
113
+ console.log(`Downloading ${url} to ${to}`);
114
+ }
115
+ } else if (process.env.DEBUG) {
116
+ console.log(`Redirecting to ${url}`);
117
+ }
118
+
119
+ return new Promise((resolve, reject) => {
120
+ if (!fs.existsSync(path.dirname(to))) {
121
+ fs.mkdirSync(path.dirname(to), { recursive: true });
122
+ }
123
+
124
+ let done = true;
125
+ const file = fs.createWriteStream(to);
126
+ const request = https.get(url, (res) => {
127
+ if (res.statusCode === 302 && res.headers.location !== undefined) {
128
+ const redirection = res.headers.location;
129
+ done = false;
130
+ file.close(() => {
131
+ void download(redirection, to, redirect + 1).then(resolve, reject);
132
+ });
133
+ return;
134
+ }
135
+ res.pipe(file);
136
+ });
137
+
138
+ file.on("finish", () => {
139
+ if (done) {
140
+ file.close(() => {
141
+ resolve(to);
142
+ });
143
+ }
144
+ });
145
+
146
+ request.on("error", (err) => {
147
+ fs.unlink(to, () => {
148
+ reject(err);
149
+ });
150
+ });
151
+
152
+ file.on("error", (err) => {
153
+ fs.unlink(to, () => {
154
+ reject(err);
155
+ });
156
+ });
157
+
158
+ request.end();
159
+ });
160
+ }
@@ -1,5 +1,5 @@
1
- import { startTunnel } from "untun";
2
1
  import type { AppConfig, DevEnvironment, ServiceConfig } from "../types";
2
+ import { startQuickTunnel } from "./quick-tunnel";
3
3
 
4
4
  export interface PublicExposeTarget {
5
5
  kind: "service" | "app";
@@ -15,7 +15,8 @@ export interface PublicTunnel {
15
15
  close: () => Promise<void>;
16
16
  }
17
17
 
18
- interface UntunTunnelLike {
18
+ interface TunnelBackendResult {
19
+ getURL?: () => Promise<string>;
19
20
  url?: string;
20
21
  publicUrl?: string;
21
22
  tunnelUrl?: string;
@@ -33,11 +34,17 @@ function parseExposeNames(exposeValue?: string): Set<string> | null {
33
34
  return new Set(names);
34
35
  }
35
36
 
36
- function asPublicUrl(tunnel: UntunTunnelLike): string | null {
37
+ /** Resolves public origin from tunnel backends (sync fields or untun-style async getURL). */
38
+ async function resolvePublicUrl(
39
+ tunnel: TunnelBackendResult,
40
+ ): Promise<string | null> {
41
+ if (typeof tunnel.getURL === "function") {
42
+ return await tunnel.getURL();
43
+ }
37
44
  return tunnel.url ?? tunnel.publicUrl ?? tunnel.tunnelUrl ?? null;
38
45
  }
39
46
 
40
- function toCloseFn(tunnel: UntunTunnelLike): () => Promise<void> {
47
+ function toCloseFn(tunnel: TunnelBackendResult): () => Promise<void> {
41
48
  const close = tunnel.close ?? tunnel.stop ?? tunnel.destroy;
42
49
  if (!close) return async () => {};
43
50
  return async () => {
@@ -111,10 +118,12 @@ export function resolveExposeTargets<
111
118
  export async function startPublicTunnels(
112
119
  targets: PublicExposeTarget[],
113
120
  options: {
114
- start?: (input: { url: string }) => Promise<UntunTunnelLike>;
121
+ start?: (input: {
122
+ url: string;
123
+ }) => Promise<TunnelBackendResult | undefined>;
115
124
  } = {},
116
125
  ): Promise<PublicTunnel[]> {
117
- const start = options.start ?? ((input) => startTunnel(input));
126
+ const start = options.start ?? ((input) => startQuickTunnel(input));
118
127
  const tunnels: PublicTunnel[] = [];
119
128
 
120
129
  try {
@@ -122,8 +131,13 @@ export async function startPublicTunnels(
122
131
  const localUrl = `http://localhost:${target.port}`;
123
132
  const tunnel = (await start({
124
133
  url: localUrl,
125
- })) as UntunTunnelLike;
126
- const publicUrl = asPublicUrl(tunnel);
134
+ })) as TunnelBackendResult | undefined;
135
+ if (tunnel === undefined) {
136
+ throw new Error(
137
+ `Tunnel for "${target.name}" could not be started (cloudflared missing or install declined)`,
138
+ );
139
+ }
140
+ const publicUrl = await resolvePublicUrl(tunnel);
127
141
  if (!publicUrl) {
128
142
  throw new Error(
129
143
  `Tunnel for "${target.name}" did not provide a public URL`,
@@ -12,6 +12,12 @@ import {
12
12
  startDevServers,
13
13
  stopProcess as stopProcessFn,
14
14
  } from "../core/process";
15
+ import {
16
+ type PublicTunnel,
17
+ resolveExposeTargets,
18
+ startPublicTunnels,
19
+ stopPublicTunnels,
20
+ } from "../core/tunnel";
15
21
  import { isCI as isCIEnv, logExpoApiUrl, logFrontendPort } from "../core/utils";
16
22
  import {
17
23
  spawnWatchdog as spawnWatchdogFn,
@@ -36,15 +42,19 @@ import type {
36
42
  ComputedUrls,
37
43
  DevConfig,
38
44
  DevEnvironment,
45
+ DevEnvironmentTunnelLog,
39
46
  DevServerPids,
40
47
  ExecOptions,
41
48
  HookContext,
49
+ OpenPublicTunnelsOptions,
50
+ OpenPublicTunnelsResult,
42
51
  PrismaRunner,
43
52
  ServiceConfig,
44
53
  StartOptions,
45
54
  StopOptions,
46
55
  } from "../types";
47
56
  import { logEnvironmentInfo } from "./logging";
57
+ import { assertOnlyAppNames, pickApps } from "./only-apps";
48
58
  import { createCheckTableHelper, createSeedCheckContext } from "./seeding";
49
59
 
50
60
  // ═══════════════════════════════════════════════════════════════════════════
@@ -213,13 +223,18 @@ export function createDevEnvironment<
213
223
  startServers: shouldStartServers = true,
214
224
  productionBuild = isCI,
215
225
  skipSeed = false,
226
+ skipEnvironmentLog = false,
227
+ onlyApps,
216
228
  } = startOptions;
217
229
 
230
+ assertOnlyAppNames(Object.keys(apps), onlyApps);
231
+ const appsToStart = pickApps(apps, onlyApps);
232
+
218
233
  const envVars = buildEnvVars(productionBuild);
219
234
  ensureComposeFile();
220
235
 
221
236
  // Log environment info
222
- if (verbose) {
237
+ if (verbose && !skipEnvironmentLog) {
223
238
  logInfo(productionBuild ? "Production Environment" : "Dev Environment");
224
239
  }
225
240
 
@@ -330,7 +345,7 @@ export function createDevEnvironment<
330
345
  }
331
346
 
332
347
  // Start servers if requested
333
- if (shouldStartServers && Object.keys(apps).length > 0) {
348
+ if (shouldStartServers && Object.keys(appsToStart).length > 0) {
334
349
  // Run beforeServers hook
335
350
  if (config.hooks?.beforeServers) {
336
351
  await config.hooks.beforeServers(getHookContext());
@@ -338,11 +353,11 @@ export function createDevEnvironment<
338
353
 
339
354
  // Build if production
340
355
  if (productionBuild) {
341
- buildApps(apps, root, envVars, { verbose });
356
+ buildApps(appsToStart, root, envVars, { verbose });
342
357
  }
343
358
 
344
359
  // Start servers
345
- const pids = await startDevServers(apps, root, envVars, ports, {
360
+ const pids = await startDevServers(appsToStart, root, envVars, ports, {
346
361
  verbose,
347
362
  productionBuild,
348
363
  isCI,
@@ -350,7 +365,7 @@ export function createDevEnvironment<
350
365
 
351
366
  // Wait for servers to be ready
352
367
  if (verbose) console.log("⏳ Waiting for servers to be ready...");
353
- await waitForDevServers(apps, ports, {
368
+ await waitForDevServers(appsToStart, ports, {
354
369
  timeout: isCI ? 120000 : 60000,
355
370
  verbose,
356
371
  productionBuild,
@@ -400,36 +415,129 @@ export function createDevEnvironment<
400
415
  // ─────────────────────────────────────────────────────────────────────────
401
416
 
402
417
  async function startServersOnly(
403
- options: { productionBuild?: boolean; verbose?: boolean } = {},
418
+ options: {
419
+ productionBuild?: boolean;
420
+ verbose?: boolean;
421
+ onlyApps?: string[];
422
+ } = {},
404
423
  ): Promise<DevServerPids> {
405
- const { productionBuild = false, verbose = true } = options;
424
+ const { productionBuild = false, verbose = true, onlyApps } = options;
425
+ assertOnlyAppNames(Object.keys(apps), onlyApps);
426
+ const appsToStart = pickApps(apps, onlyApps);
406
427
  const envVars = buildEnvVars(productionBuild);
407
428
  const isCI = process.env.CI === "true";
408
429
 
430
+ if (Object.keys(appsToStart).length === 0) {
431
+ return {};
432
+ }
433
+
409
434
  // Build if production
410
435
  if (productionBuild) {
411
- buildApps(apps, root, envVars, { verbose });
436
+ buildApps(appsToStart, root, envVars, { verbose });
412
437
  }
413
438
 
414
- return startDevServers(apps, root, envVars, ports, {
439
+ const pids = await startDevServers(appsToStart, root, envVars, ports, {
415
440
  verbose,
416
441
  productionBuild,
417
442
  isCI,
418
443
  });
444
+
445
+ if (verbose) console.log("⏳ Waiting for servers to be ready...");
446
+ await waitForDevServers(appsToStart, ports, {
447
+ timeout: isCI ? 120000 : 60000,
448
+ verbose,
449
+ productionBuild,
450
+ });
451
+
452
+ return pids;
419
453
  }
420
454
 
421
455
  async function waitForServersReady(
422
- options: { timeout?: number; productionBuild?: boolean } = {},
456
+ options: {
457
+ timeout?: number;
458
+ productionBuild?: boolean;
459
+ onlyApps?: string[];
460
+ } = {},
423
461
  ): Promise<void> {
424
- const { timeout = 60000, productionBuild = false } = options;
425
- await waitForDevServers(apps, ports, { timeout, productionBuild });
462
+ const { timeout = 60000, productionBuild = false, onlyApps } = options;
463
+ assertOnlyAppNames(Object.keys(apps), onlyApps);
464
+ const appsToWait = pickApps(apps, onlyApps);
465
+ await waitForDevServers(appsToWait, ports, { timeout, productionBuild });
466
+ }
467
+
468
+ async function openPublicTunnels(
469
+ options: OpenPublicTunnelsOptions = {},
470
+ ): Promise<OpenPublicTunnelsResult<TServices, TApps>> {
471
+ const { names, waitForHealthy } = options;
472
+ const exposeList = names?.length ? names.join(",") : undefined;
473
+
474
+ if (waitForHealthy?.length) {
475
+ assertOnlyAppNames(Object.keys(apps), waitForHealthy);
476
+ const appsWait = pickApps(apps, waitForHealthy);
477
+ const isCI = process.env.CI === "true";
478
+ await waitForDevServers(appsWait, ports, {
479
+ timeout: isCI ? 120000 : 60000,
480
+ verbose: config.options?.verbose ?? true,
481
+ productionBuild: false,
482
+ });
483
+ }
484
+
485
+ const { targets, unknownNames, notEnabledNames } = resolveExposeTargets(
486
+ {
487
+ services,
488
+ apps,
489
+ ports,
490
+ } as DevEnvironment<TServices, TApps>,
491
+ exposeList,
492
+ );
493
+
494
+ if (unknownNames.length > 0) {
495
+ throw new Error(`Unknown expose target(s): ${unknownNames.join(", ")}`);
496
+ }
497
+ if (notEnabledNames.length > 0) {
498
+ throw new Error(
499
+ `Target(s) missing expose: true: ${notEnabledNames.join(", ")}`,
500
+ );
501
+ }
502
+ if (targets.length === 0) {
503
+ throw new Error(
504
+ "No expose targets selected. Add expose: true to services/apps or pass names that have expose: true.",
505
+ );
506
+ }
507
+
508
+ const tunnels = await startPublicTunnels(targets);
509
+ setPublicUrls(
510
+ Object.fromEntries(tunnels.map((t) => [t.name, t.publicUrl])),
511
+ );
512
+
513
+ let closed = false;
514
+ async function close(): Promise<void> {
515
+ if (closed) return;
516
+ closed = true;
517
+ await stopPublicTunnels(tunnels);
518
+ clearPublicUrls();
519
+ }
520
+
521
+ return {
522
+ publicUrls: { ...publicUrls } as ComputedPublicUrls<TServices, TApps>,
523
+ tunnels,
524
+ close,
525
+ };
426
526
  }
427
527
 
428
528
  // ─────────────────────────────────────────────────────────────────────────
429
529
  // Utilities
430
530
  // ─────────────────────────────────────────────────────────────────────────
431
531
 
432
- function logInfo(label = "Docker Dev"): void {
532
+ function logInfo(label = "Docker Dev", tunnels?: PublicTunnel[]): void {
533
+ const tunnelRows: DevEnvironmentTunnelLog[] | undefined = tunnels?.map(
534
+ ({ kind, name, localUrl, publicUrl }) => ({
535
+ kind,
536
+ name,
537
+ localUrl,
538
+ publicUrl,
539
+ }),
540
+ );
433
541
  logEnvironmentInfo({
434
542
  label,
435
543
  projectName,
@@ -440,6 +548,7 @@ export function createDevEnvironment<
440
548
  worktree,
441
549
  portOffset,
442
550
  projectSuffix,
551
+ tunnels: tunnelRows,
443
552
  });
444
553
  }
445
554
 
@@ -539,6 +648,7 @@ export function createDevEnvironment<
539
648
  exec,
540
649
  waitForServer: waitForServerUrl,
541
650
  logInfo,
651
+ openPublicTunnels,
542
652
 
543
653
  // Vibe Kanban Integration
544
654
  getExpoApiUrl,
@@ -14,6 +14,21 @@ function formatDimLabel(label: string, value: string): string {
14
14
  return ` ${pc.dim("•")} ${pc.dim(label.padEnd(10))} ${pc.dim(value)}`;
15
15
  }
16
16
 
17
+ function tunnelFor(
18
+ tunnels:
19
+ | Array<{
20
+ kind: "service" | "app";
21
+ name: string;
22
+ publicUrl: string;
23
+ localUrl: string;
24
+ }>
25
+ | undefined,
26
+ name: string,
27
+ kind: "service" | "app",
28
+ ) {
29
+ return tunnels?.find((t) => t.name === name && t.kind === kind);
30
+ }
31
+
17
32
  export function logEnvironmentInfo(input: {
18
33
  label: string;
19
34
  projectName: string;
@@ -24,6 +39,12 @@ export function logEnvironmentInfo(input: {
24
39
  worktree: boolean;
25
40
  portOffset: number;
26
41
  projectSuffix?: string;
42
+ tunnels?: Array<{
43
+ kind: "service" | "app";
44
+ name: string;
45
+ publicUrl: string;
46
+ localUrl: string;
47
+ }>;
27
48
  }): void {
28
49
  const {
29
50
  label,
@@ -35,6 +56,7 @@ export function logEnvironmentInfo(input: {
35
56
  worktree,
36
57
  portOffset,
37
58
  projectSuffix,
59
+ tunnels,
38
60
  } = input;
39
61
  const serviceNames = Object.keys(services);
40
62
  const appNames = Object.keys(apps);
@@ -50,6 +72,12 @@ export function logEnvironmentInfo(input: {
50
72
  const port = ports[name];
51
73
  const url = `localhost:${port}`;
52
74
  console.log(formatLabel(`${name}:`, formatUrl(`http://${url}`)));
75
+ const t = tunnelFor(tunnels, name, "service");
76
+ if (t) {
77
+ console.log(
78
+ ` ${pc.dim("Public:")} ${formatUrl(t.publicUrl)} ${pc.dim("(tunnel)")}`,
79
+ );
80
+ }
53
81
  }
54
82
  }
55
83
 
@@ -64,6 +92,12 @@ export function logEnvironmentInfo(input: {
64
92
  console.log(` ${pc.green("➜")} ${pc.bold(pc.cyan(name))}`);
65
93
  console.log(` ${pc.dim("Local:")} ${formatUrl(localUrl)}`);
66
94
  console.log(` ${pc.dim("Network:")} ${formatUrl(networkUrl)}`);
95
+ const t = tunnelFor(tunnels, name, "app");
96
+ if (t) {
97
+ console.log(
98
+ ` ${pc.dim("Public:")} ${formatUrl(t.publicUrl)} ${pc.dim("(tunnel)")}`,
99
+ );
100
+ }
67
101
  }
68
102
  }
69
103
 
@@ -79,23 +113,3 @@ export function logEnvironmentInfo(input: {
79
113
  console.log(formatDimLabel("Local IP:", localIp));
80
114
  console.log("");
81
115
  }
82
-
83
- export function logPublicUrls(
84
- tunnels: Array<{
85
- kind: "service" | "app";
86
- name: string;
87
- publicUrl: string;
88
- localUrl: string;
89
- }>,
90
- ): void {
91
- if (tunnels.length === 0) return;
92
-
93
- console.log("");
94
- console.log(` ${pc.dim("─── Public URLs (Quick Tunnel) ───")}`);
95
- for (const tunnel of tunnels) {
96
- const label = `${tunnel.name} (${tunnel.kind})`;
97
- console.log(formatLabel(`${label}:`, formatUrl(tunnel.publicUrl), "🌐"));
98
- console.log(formatDimLabel("Local target:", tunnel.localUrl));
99
- }
100
- console.log("");
101
- }