@cardelli/ambit 0.1.3 → 0.1.5

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 (64) hide show
  1. package/esm/deno.d.ts +1 -0
  2. package/esm/deno.js +3 -2
  3. package/esm/deps/jsr.io/@std/cli/1.0.28/_data.d.ts.map +1 -0
  4. package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_run_length.d.ts.map +1 -1
  5. package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/mod.d.ts.map +1 -1
  6. package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/parse_args.d.ts.map +1 -1
  7. package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/parse_args.js +17 -3
  8. package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/prompt_secret.d.ts.map +1 -1
  9. package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/unicode_width.d.ts.map +1 -1
  10. package/esm/deps/jsr.io/@std/collections/{1.1.3 → 1.1.6}/deep_merge.d.ts +2 -2
  11. package/esm/deps/jsr.io/@std/collections/{1.1.3 → 1.1.6}/deep_merge.d.ts.map +1 -1
  12. package/esm/deps/jsr.io/@std/collections/{1.1.3 → 1.1.6}/deep_merge.js +30 -20
  13. package/esm/deps/jsr.io/@std/toml/1.0.11/_parser.js +1 -1
  14. package/esm/deps/jsr.io/@zod/zod/4.3.6/src/v4/core/json-schema-generator.d.ts +1 -1
  15. package/esm/lib/cli.d.ts +0 -1
  16. package/esm/lib/cli.d.ts.map +1 -1
  17. package/esm/lib/cli.js +41 -26
  18. package/esm/lib/command.d.ts +23 -48
  19. package/esm/lib/command.d.ts.map +1 -1
  20. package/esm/lib/command.js +60 -89
  21. package/esm/lib/output.d.ts +1 -1
  22. package/esm/lib/output.d.ts.map +1 -1
  23. package/esm/lib/output.js +5 -9
  24. package/esm/lib/result.d.ts +19 -7
  25. package/esm/lib/result.d.ts.map +1 -1
  26. package/esm/lib/result.js +47 -1
  27. package/esm/main.d.ts.map +1 -1
  28. package/esm/main.js +3 -2
  29. package/esm/src/cli/commands/create.js +111 -97
  30. package/esm/src/cli/commands/deploy.js +61 -35
  31. package/esm/src/cli/commands/destroy.js +224 -29
  32. package/esm/src/cli/commands/doctor.js +2 -2
  33. package/esm/src/cli/commands/list.js +1 -1
  34. package/esm/src/cli/commands/status.js +1 -1
  35. package/esm/src/cli/mod.d.ts.map +1 -1
  36. package/esm/src/cli/mod.js +4 -3
  37. package/esm/src/discovery.d.ts +13 -0
  38. package/esm/src/discovery.d.ts.map +1 -1
  39. package/esm/src/discovery.js +42 -0
  40. package/esm/src/docker/router/start.sh +18 -72
  41. package/esm/src/providers/fly.d.ts +6 -0
  42. package/esm/src/providers/fly.d.ts.map +1 -1
  43. package/esm/src/providers/fly.js +77 -81
  44. package/esm/src/providers/tailscale.d.ts.map +1 -1
  45. package/esm/src/providers/tailscale.js +5 -11
  46. package/esm/src/template.d.ts +8 -7
  47. package/esm/src/template.d.ts.map +1 -1
  48. package/esm/src/template.js +32 -26
  49. package/package.json +7 -1
  50. package/esm/deps/jsr.io/@std/cli/1.0.27/_data.d.ts.map +0 -1
  51. package/esm/deps/jsr.io/@std/collections/1.1.3/_utils.d.ts +0 -6
  52. package/esm/deps/jsr.io/@std/collections/1.1.3/_utils.d.ts.map +0 -1
  53. package/esm/deps/jsr.io/@std/collections/1.1.3/_utils.js +0 -18
  54. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_data.d.ts +0 -0
  55. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_data.js +0 -0
  56. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_run_length.d.ts +0 -0
  57. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/_run_length.js +0 -0
  58. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/mod.d.ts +0 -0
  59. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/mod.js +0 -0
  60. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/parse_args.d.ts +0 -0
  61. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/prompt_secret.d.ts +0 -0
  62. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/prompt_secret.js +0 -0
  63. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/unicode_width.d.ts +0 -0
  64. /package/esm/deps/jsr.io/@std/cli/{1.0.27 → 1.0.28}/unicode_width.js +0 -0
@@ -1,19 +1,47 @@
1
1
  // =============================================================================
2
- // Destroy Command - Tear Down Router and Clean Up
2
+ // Destroy Command - Destroy Networks or Apps
3
3
  // =============================================================================
4
- import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.27/mod.js";
4
+ import * as dntShim from "../../../_dnt.shims.js";
5
+ import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.28/mod.js";
5
6
  import { bold, confirm } from "../../../lib/cli.js";
6
7
  import { createOutput } from "../../../lib/output.js";
7
8
  import { registerCommand } from "../mod.js";
8
9
  import { createFlyProvider } from "../../providers/fly.js";
9
10
  import { createTailscaleProvider } from "../../providers/tailscale.js";
10
11
  import { checkDependencies } from "../../credentials.js";
11
- import { findRouterApp } from "../../discovery.js";
12
+ import { findRouterApp, findWorkloadApp, listWorkloadAppsOnNetwork, } from "../../discovery.js";
12
13
  import { resolveOrg } from "../../resolve.js";
14
+ import { assertNotRouter } from "../../guard.js";
13
15
  // =============================================================================
14
- // Destroy Command
16
+ // Top-Level Help
15
17
  // =============================================================================
16
- const destroy = async (argv) => {
18
+ const showDestroyHelp = () => {
19
+ console.log(`
20
+ ${bold("ambit destroy")} - Destroy Networks or Apps
21
+
22
+ ${bold("USAGE")}
23
+ ambit destroy network <name> [options]
24
+ ambit destroy app <app>.<network> [options]
25
+
26
+ ${bold("SUBCOMMANDS")}
27
+ network Tear down a router, clean up DNS and tailnet device
28
+ app Destroy a workload app on a network
29
+
30
+ ${bold("OPTIONS")}
31
+ --help Show help for a subcommand
32
+
33
+ ${bold("EXAMPLES")}
34
+ ambit destroy network browsers
35
+ ambit destroy app my-app.browsers
36
+ ambit destroy app my-app --network browsers
37
+
38
+ Run 'ambit destroy network --help' or 'ambit destroy app --help' for details.
39
+ `);
40
+ };
41
+ // =============================================================================
42
+ // Destroy Network
43
+ // =============================================================================
44
+ const destroyNetwork = async (argv) => {
17
45
  const args = parseArgs(argv, {
18
46
  string: ["network", "org"],
19
47
  boolean: ["help", "yes", "json"],
@@ -21,57 +49,78 @@ const destroy = async (argv) => {
21
49
  });
22
50
  if (args.help) {
23
51
  console.log(`
24
- ${bold("ambit destroy")} - Tear Down Router
52
+ ${bold("ambit destroy network")} - Tear Down Router
25
53
 
26
54
  ${bold("USAGE")}
27
- ambit destroy --network <name> [--org <org>] [--yes] [--json]
55
+ ambit destroy network <name> [--org <org>] [--yes] [--json]
28
56
 
29
57
  ${bold("OPTIONS")}
30
- --network <name> Network of the router to destroy (required)
31
58
  --org <org> Fly.io organization slug
32
59
  -y, --yes Skip confirmation prompts
33
60
  --json Output as JSON
61
+
62
+ ${bold("EXAMPLES")}
63
+ ambit destroy network browsers
64
+ ambit destroy network browsers --org my-org --yes
34
65
  `);
35
66
  return;
36
67
  }
37
68
  const out = createOutput(args.json);
38
- if (!args.network) {
39
- return out.die("--network Is Required");
69
+ // Accept network as positional or --network flag (backward compat)
70
+ const network = (typeof args._[0] === "string" ? args._[0] : undefined) || args.network;
71
+ if (!network) {
72
+ return out.die("Network name required. Usage: ambit destroy network <name>");
40
73
  }
41
- // =========================================================================
74
+ // ===========================================================================
42
75
  // Prerequisites
43
- // =========================================================================
76
+ // ===========================================================================
44
77
  const { tailscaleKey } = await checkDependencies(out);
45
78
  const fly = createFlyProvider();
46
79
  await fly.ensureAuth({ interactive: !args.json });
47
80
  const tailscale = createTailscaleProvider("-", tailscaleKey);
48
81
  const org = await resolveOrg(fly, args, out);
49
- // =========================================================================
82
+ // ===========================================================================
50
83
  // Discover Router
51
- // =========================================================================
84
+ // ===========================================================================
52
85
  const spinner = out.spinner("Discovering Router");
53
- const app = await findRouterApp(fly, org, args.network);
86
+ const app = await findRouterApp(fly, org, network);
54
87
  if (!app) {
55
88
  spinner.fail("Router Not Found");
56
- return out.die(`No Router Found for Network '${args.network}'`);
89
+ return out.die(`No Router Found for Network '${network}'`);
57
90
  }
58
91
  spinner.success(`Found Router: ${app.appName}`);
59
92
  let tsDevice = null;
60
93
  try {
61
94
  tsDevice = await tailscale.getDeviceByHostname(app.appName);
62
95
  }
63
- catch { /* device may not exist */ }
96
+ catch {
97
+ /* device may not exist */
98
+ }
64
99
  const tag = tsDevice?.tags?.[0] ?? null;
65
- // =========================================================================
100
+ // ===========================================================================
101
+ // Check for Workload Apps on This Network
102
+ // ===========================================================================
103
+ const workloadApps = await listWorkloadAppsOnNetwork(fly, org, network);
104
+ // ===========================================================================
66
105
  // Confirm
67
- // =========================================================================
106
+ // ===========================================================================
68
107
  out.blank()
69
- .header("ambit Destroy")
108
+ .header("ambit Destroy Network")
70
109
  .blank()
71
- .text(` Network: ${app.network}`)
110
+ .text(` Network: ${network}`)
72
111
  .text(` Router App: ${app.appName}`)
73
112
  .text(` Tag: ${tag ?? "unknown"}`)
74
113
  .blank();
114
+ if (workloadApps.length > 0) {
115
+ out.warn(`${workloadApps.length} Workload App(s) Still on Network '${network}':`);
116
+ for (const wa of workloadApps) {
117
+ out.text(` - ${wa.appName}`);
118
+ }
119
+ out.blank();
120
+ out.dim("These apps will lose connectivity when the router is destroyed.");
121
+ out.dim(`Consider destroying them first with: ambit destroy app <name>.${network}`);
122
+ out.blank();
123
+ }
75
124
  if (!args.yes && !args.json) {
76
125
  const confirmed = await confirm("Destroy this router?");
77
126
  if (!confirmed) {
@@ -80,12 +129,12 @@ ${bold("OPTIONS")}
80
129
  }
81
130
  out.blank();
82
131
  }
83
- // =========================================================================
132
+ // ===========================================================================
84
133
  // Tear Down
85
- // =========================================================================
134
+ // ===========================================================================
86
135
  const dnsSpinner = out.spinner("Clearing Split DNS");
87
136
  try {
88
- await tailscale.clearSplitDns(app.network);
137
+ await tailscale.clearSplitDns(network);
89
138
  dnsSpinner.success("Split DNS Cleared");
90
139
  }
91
140
  catch {
@@ -113,10 +162,14 @@ ${bold("OPTIONS")}
113
162
  catch {
114
163
  appSpinner.fail("Could Not Destroy Fly App");
115
164
  }
116
- // =========================================================================
165
+ // ===========================================================================
117
166
  // Done
118
- // =========================================================================
119
- out.done({ destroyed: true, appName: app.appName });
167
+ // ===========================================================================
168
+ out.done({
169
+ destroyed: true,
170
+ appName: app.appName,
171
+ workloadAppsWarned: workloadApps.length,
172
+ });
120
173
  out.ok("Router Destroyed");
121
174
  if (tag) {
122
175
  out.blank()
@@ -135,11 +188,153 @@ ${bold("OPTIONS")}
135
188
  out.print();
136
189
  };
137
190
  // =============================================================================
191
+ // Destroy App
192
+ // =============================================================================
193
+ const destroyApp = async (argv) => {
194
+ const args = parseArgs(argv, {
195
+ string: ["network", "org"],
196
+ boolean: ["help", "yes", "json"],
197
+ alias: { y: "yes" },
198
+ });
199
+ if (args.help) {
200
+ console.log(`
201
+ ${bold("ambit destroy app")} - Destroy a Workload App
202
+
203
+ ${bold("USAGE")}
204
+ ambit destroy app <app>.<network> [--org <org>] [--yes] [--json]
205
+ ambit destroy app <app> --network <name> [--org <org>] [--yes] [--json]
206
+
207
+ ${bold("OPTIONS")}
208
+ --network <name> Target network (if not using dot syntax)
209
+ --org <org> Fly.io organization slug
210
+ -y, --yes Skip confirmation prompts
211
+ --json Output as JSON
212
+
213
+ ${bold("EXAMPLES")}
214
+ ambit destroy app my-app.browsers
215
+ ambit destroy app my-app --network browsers --yes
216
+ `);
217
+ return;
218
+ }
219
+ const out = createOutput(args.json);
220
+ // ===========================================================================
221
+ // Parse App & Network
222
+ // ===========================================================================
223
+ const appArg = args._[0];
224
+ if (!appArg || typeof appArg !== "string") {
225
+ return out.die("Missing app name. Usage: ambit destroy app <app>.<network>");
226
+ }
227
+ let app;
228
+ let network;
229
+ if (appArg.includes(".")) {
230
+ const parts = appArg.split(".");
231
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
232
+ return out.die(`'${appArg}' should have exactly one dot, like my-app.my-network`);
233
+ }
234
+ if (args.network) {
235
+ return out.die(`Network is already part of the name ('${appArg}'), --network is not needed`);
236
+ }
237
+ app = parts[0];
238
+ network = parts[1];
239
+ }
240
+ else {
241
+ app = appArg;
242
+ if (!args.network) {
243
+ return out.die(`Missing network. Use: ambit destroy app ${app}.<network>`);
244
+ }
245
+ network = args.network;
246
+ }
247
+ try {
248
+ assertNotRouter(app);
249
+ }
250
+ catch (e) {
251
+ return out.die(e instanceof Error ? e.message : String(e));
252
+ }
253
+ // ===========================================================================
254
+ // Prerequisites
255
+ // ===========================================================================
256
+ const { tailscaleKey: _tailscaleKey } = await checkDependencies(out);
257
+ const fly = createFlyProvider();
258
+ await fly.ensureAuth({ interactive: !args.json });
259
+ const org = await resolveOrg(fly, args, out);
260
+ // ===========================================================================
261
+ // Discover App
262
+ // ===========================================================================
263
+ const spinner = out.spinner("Discovering App");
264
+ const workloadApp = await findWorkloadApp(fly, org, app, network);
265
+ if (!workloadApp) {
266
+ spinner.fail("App Not Found");
267
+ // Check if app exists on a different network
268
+ const anyApp = await findWorkloadApp(fly, org, app);
269
+ if (anyApp) {
270
+ return out.die(`App '${app}' exists on network '${anyApp.network}', not '${network}'`);
271
+ }
272
+ return out.die(`No app '${app}' found on network '${network}'`);
273
+ }
274
+ spinner.success(`Found App: ${workloadApp.appName} (network: ${workloadApp.network})`);
275
+ // ===========================================================================
276
+ // Confirm
277
+ // ===========================================================================
278
+ out.blank()
279
+ .header("ambit Destroy App")
280
+ .blank()
281
+ .text(` App: ${workloadApp.appName}`)
282
+ .text(` Network: ${workloadApp.network}`)
283
+ .blank();
284
+ if (!args.yes && !args.json) {
285
+ const confirmed = await confirm(`Destroy app '${app}' on network '${network}'?`);
286
+ if (!confirmed) {
287
+ out.text("Cancelled.");
288
+ return;
289
+ }
290
+ out.blank();
291
+ }
292
+ // ===========================================================================
293
+ // Destroy
294
+ // ===========================================================================
295
+ const appSpinner = out.spinner("Destroying Fly App");
296
+ try {
297
+ await fly.deleteApp(workloadApp.appName);
298
+ appSpinner.success("Fly App Destroyed");
299
+ }
300
+ catch {
301
+ appSpinner.fail("Could Not Destroy Fly App");
302
+ }
303
+ // ===========================================================================
304
+ // Done
305
+ // ===========================================================================
306
+ out.done({ destroyed: true, appName: workloadApp.appName, network });
307
+ out.ok("App Destroyed");
308
+ out.blank();
309
+ out.print();
310
+ };
311
+ // =============================================================================
312
+ // Dispatcher
313
+ // =============================================================================
314
+ const destroy = async (argv) => {
315
+ const subcommand = typeof argv[0] === "string" ? argv[0] : undefined;
316
+ if (subcommand === "network") {
317
+ return destroyNetwork(argv.slice(1));
318
+ }
319
+ if (subcommand === "app") {
320
+ return destroyApp(argv.slice(1));
321
+ }
322
+ // Handle --help at the top level
323
+ const args = parseArgs(argv, { boolean: ["help"] });
324
+ if (args.help) {
325
+ showDestroyHelp();
326
+ return;
327
+ }
328
+ // No valid subcommand
329
+ showDestroyHelp();
330
+ dntShim.Deno.exit(1);
331
+ };
332
+ // =============================================================================
138
333
  // Register Command
139
334
  // =============================================================================
140
335
  registerCommand({
141
336
  name: "destroy",
142
- description: "Tear down the router, clean up DNS and tailnet device",
143
- usage: "ambit destroy --network <name> [--org <org>] [--yes] [--json]",
337
+ description: "Destroy a network (router) or a workload app",
338
+ usage: "ambit destroy network|app <name> [options]",
144
339
  run: destroy,
145
340
  });
@@ -1,7 +1,7 @@
1
1
  // =============================================================================
2
2
  // Doctor Command - Verify Environment and Infrastructure Health
3
3
  // =============================================================================
4
- import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.27/mod.js";
4
+ import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.28/mod.js";
5
5
  import { bold } from "../../../lib/cli.js";
6
6
  import { createOutput } from "../../../lib/output.js";
7
7
  import { runCommand } from "../../../lib/command.js";
@@ -67,7 +67,7 @@ ${bold("CHECKS")}
67
67
  report("Tailscale Installed", await isTailscaleInstalled(), "Install from https://tailscale.com/download");
68
68
  const tsStatus = await runCommand(["tailscale", "status", "--json"]);
69
69
  let tsConnected = false;
70
- if (tsStatus.success) {
70
+ if (tsStatus.ok) {
71
71
  try {
72
72
  const parsed = JSON.parse(tsStatus.stdout);
73
73
  tsConnected = parsed.BackendState === "Running";
@@ -1,7 +1,7 @@
1
1
  // =============================================================================
2
2
  // List Command - List All Discovered Routers
3
3
  // =============================================================================
4
- import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.27/mod.js";
4
+ import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.28/mod.js";
5
5
  import { Table } from "../../../deps/jsr.io/@cliffy/table/1.0.0/mod.js";
6
6
  import { bold } from "../../../lib/cli.js";
7
7
  import { createOutput } from "../../../lib/output.js";
@@ -1,7 +1,7 @@
1
1
  // =============================================================================
2
2
  // Status Command - Show Router Status
3
3
  // =============================================================================
4
- import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.27/mod.js";
4
+ import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.28/mod.js";
5
5
  import { Table } from "../../../deps/jsr.io/@cliffy/table/1.0.0/mod.js";
6
6
  import { bold } from "../../../lib/cli.js";
7
7
  import { createOutput } from "../../../lib/output.js";
@@ -1 +1 @@
1
- {"version":3,"file":"mod.d.ts","sourceRoot":"","sources":["../../../src/src/cli/mod.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAQD,eAAO,MAAM,eAAe,GAAI,SAAS,OAAO,KAAG,IAElD,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,KAAG,OAAO,GAAG,SAEnD,CAAC;AAEF,eAAO,MAAM,cAAc,QAAO,OAAO,EAExC,CAAC;AAQF,eAAO,MAAM,QAAQ,QAAO,IA4B3B,CAAC;AAEF,eAAO,MAAM,WAAW,QAAO,IAE9B,CAAC;AAMF,eAAO,MAAM,MAAM,GAAU,MAAM,MAAM,EAAE,KAAG,OAAO,CAAC,IAAI,CAuCzD,CAAC"}
1
+ {"version":3,"file":"mod.d.ts","sourceRoot":"","sources":["../../../src/src/cli/mod.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAQD,eAAO,MAAM,eAAe,GAAI,SAAS,OAAO,KAAG,IAElD,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,KAAG,OAAO,GAAG,SAEnD,CAAC;AAEF,eAAO,MAAM,cAAc,QAAO,OAAO,EAExC,CAAC;AAQF,eAAO,MAAM,QAAQ,QAAO,IA6B3B,CAAC;AAEF,eAAO,MAAM,WAAW,QAAO,IAE9B,CAAC;AAMF,eAAO,MAAM,MAAM,GAAU,MAAM,MAAM,EAAE,KAAG,OAAO,CAAC,IAAI,CAuCzD,CAAC"}
@@ -2,7 +2,7 @@
2
2
  // CLI Framework - Command Parser and Router
3
3
  // =============================================================================
4
4
  import * as dntShim from "../../_dnt.shims.js";
5
- import { parseArgs } from "../../deps/jsr.io/@std/cli/1.0.27/mod.js";
5
+ import { parseArgs } from "../../deps/jsr.io/@std/cli/1.0.28/mod.js";
6
6
  import { bold, dim } from "../../lib/cli.js";
7
7
  import denoConfig from "../../deno.js";
8
8
  // =============================================================================
@@ -34,7 +34,7 @@ ${bold("COMMANDS")}
34
34
  deploy Deploy an app safely on a custom private network
35
35
  list List all discovered routers across networks
36
36
  status Show router status, network, and tailnet info
37
- destroy Tear down the router, clean up DNS and tailnet device
37
+ destroy Destroy a network (router) or a workload app
38
38
  doctor Check that Tailscale and the router are working correctly
39
39
 
40
40
  ${bold("OPTIONS")}
@@ -45,7 +45,8 @@ ${bold("EXAMPLES")}
45
45
  ambit create browsers
46
46
  ambit list
47
47
  ambit status --network browsers
48
- ambit destroy --network browsers
48
+ ambit destroy network browsers
49
+ ambit destroy app my-app.browsers
49
50
  ambit doctor
50
51
 
51
52
  ${dim("Run 'ambit <command> --help' for command-specific help.")}
@@ -6,6 +6,7 @@ export interface RouterApp {
6
6
  appName: string;
7
7
  network: string;
8
8
  org: string;
9
+ routerId: string;
9
10
  }
10
11
  /** Machine state for a router, from the Fly Machines API. */
11
12
  export interface RouterMachineInfo {
@@ -25,6 +26,18 @@ export interface RouterTailscaleInfo {
25
26
  export declare const listRouterApps: (fly: FlyProvider, org: string) => Promise<RouterApp[]>;
26
27
  /** Find the router app for a specific network. */
27
28
  export declare const findRouterApp: (fly: FlyProvider, org: string, network: string) => Promise<RouterApp | null>;
29
+ /** A workload (non-router) app discovered from the Fly REST API. */
30
+ export interface WorkloadApp {
31
+ appName: string;
32
+ network: string;
33
+ org: string;
34
+ }
35
+ /** List all non-router apps on a specific custom network in an org. */
36
+ export declare const listWorkloadAppsOnNetwork: (fly: FlyProvider, org: string, network: string) => Promise<WorkloadApp[]>;
37
+ /** Find a specific workload app by logical name, optionally scoped to a network.
38
+ * When a network is provided, resolves the router-suffixed Fly app name
39
+ * (e.g. "thing" on network "lab" with routerId "abc123" → "thing-abc123"). */
40
+ export declare const findWorkloadApp: (fly: FlyProvider, org: string, appName: string, network?: string) => Promise<WorkloadApp | null>;
28
41
  /** Get machine info for a router app. Returns null if no machines exist. */
29
42
  export declare const getRouterMachineInfo: (fly: FlyProvider, appName: string) => Promise<RouterMachineInfo | null>;
30
43
  /** Get tailscale device info for a router. Returns null if not found. */
@@ -1 +1 @@
1
- {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../src/src/discovery.ts"],"names":[],"mappings":"AAcA,OAAO,sBAAsB,CAAC;AAG9B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAOlE,qDAAqD;AACrD,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,2CAA2C;AAC3C,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAaD,wDAAwD;AACxD,eAAO,MAAM,cAAc,GACzB,KAAK,WAAW,EAChB,KAAK,MAAM,KACV,OAAO,CAAC,SAAS,EAAE,CAcrB,CAAC;AAEF,kDAAkD;AAClD,eAAO,MAAM,aAAa,GACxB,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,SAAS,MAAM,KACd,OAAO,CAAC,SAAS,GAAG,IAAI,CAG1B,CAAC;AAMF,4EAA4E;AAC5E,eAAO,MAAM,oBAAoB,GAC/B,KAAK,WAAW,EAChB,SAAS,MAAM,KACd,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAclC,CAAC;AAMF,yEAAyE;AACzE,eAAO,MAAM,sBAAsB,GACjC,WAAW,iBAAiB,EAC5B,SAAS,MAAM,KACd,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAcpC,CAAC"}
1
+ {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../src/src/discovery.ts"],"names":[],"mappings":"AAcA,OAAO,sBAAsB,CAAC;AAG9B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAOlE,qDAAqD;AACrD,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,2CAA2C;AAC3C,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAaD,wDAAwD;AACxD,eAAO,MAAM,cAAc,GACzB,KAAK,WAAW,EAChB,KAAK,MAAM,KACV,OAAO,CAAC,SAAS,EAAE,CAerB,CAAC;AAEF,kDAAkD;AAClD,eAAO,MAAM,aAAa,GACxB,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,SAAS,MAAM,KACd,OAAO,CAAC,SAAS,GAAG,IAAI,CAG1B,CAAC;AAMF,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,uEAAuE;AACvE,eAAO,MAAM,yBAAyB,GACpC,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,SAAS,MAAM,KACd,OAAO,CAAC,WAAW,EAAE,CAcvB,CAAC;AAEF;;+EAE+E;AAC/E,eAAO,MAAM,eAAe,GAC1B,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,SAAS,MAAM,EACf,UAAU,MAAM,KACf,OAAO,CAAC,WAAW,GAAG,IAAI,CA4B5B,CAAC;AAMF,4EAA4E;AAC5E,eAAO,MAAM,oBAAoB,GAC/B,KAAK,WAAW,EAChB,SAAS,MAAM,KACd,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAclC,CAAC;AAMF,yEAAyE;AACzE,eAAO,MAAM,sBAAsB,GACjC,WAAW,iBAAiB,EAC5B,SAAS,MAAM,KACd,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAcpC,CAAC"}
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // =============================================================================
15
15
  import "../_dnt.polyfills.js";
16
+ import { getRouterSuffix, getWorkloadAppName } from "./providers/fly.js";
16
17
  import { extractSubnet } from "./schemas/config.js";
17
18
  // =============================================================================
18
19
  // Constants
@@ -32,6 +33,7 @@ export const listRouterApps = async (fly, org) => {
32
33
  appName: app.name,
33
34
  network: app.network,
34
35
  org: app.organization?.slug ?? org,
36
+ routerId: getRouterSuffix(app.name, app.network),
35
37
  }));
36
38
  };
37
39
  /** Find the router app for a specific network. */
@@ -39,6 +41,46 @@ export const findRouterApp = async (fly, org, network) => {
39
41
  const apps = await listRouterApps(fly, org);
40
42
  return apps.find((a) => a.network === network) ?? null;
41
43
  };
44
+ /** List all non-router apps on a specific custom network in an org. */
45
+ export const listWorkloadAppsOnNetwork = async (fly, org, network) => {
46
+ const apps = await fly.listAppsWithNetwork(org);
47
+ return apps
48
+ .filter((app) => !app.name.startsWith(ROUTER_APP_PREFIX) &&
49
+ app.network === network)
50
+ .map((app) => ({
51
+ appName: app.name,
52
+ network: app.network,
53
+ org: app.organization?.slug ?? org,
54
+ }));
55
+ };
56
+ /** Find a specific workload app by logical name, optionally scoped to a network.
57
+ * When a network is provided, resolves the router-suffixed Fly app name
58
+ * (e.g. "thing" on network "lab" with routerId "abc123" → "thing-abc123"). */
59
+ export const findWorkloadApp = async (fly, org, appName, network) => {
60
+ const apps = await fly.listAppsWithNetwork(org);
61
+ const workloads = apps
62
+ .filter((app) => !app.name.startsWith(ROUTER_APP_PREFIX) &&
63
+ app.network !== DEFAULT_NETWORK)
64
+ .map((app) => ({
65
+ appName: app.name,
66
+ network: app.network,
67
+ org: app.organization?.slug ?? org,
68
+ }));
69
+ if (network) {
70
+ // Resolve the router's suffix so we can match the suffixed Fly app name
71
+ const router = await findRouterApp(fly, org, network);
72
+ if (router) {
73
+ const suffixedName = getWorkloadAppName(appName, router.routerId);
74
+ const found = workloads.find((a) => a.appName === suffixedName && a.network === network);
75
+ if (found)
76
+ return found;
77
+ }
78
+ // Fallback: try exact match (for pre-suffix apps or direct Fly name)
79
+ return workloads.find((a) => a.appName === appName && a.network === network) ?? null;
80
+ }
81
+ // Without network, try exact match only
82
+ return workloads.find((a) => a.appName === appName) ?? null;
83
+ };
42
84
  // =============================================================================
43
85
  // 2. What's the machine state? (Fly Machines API)
44
86
  // =============================================================================
@@ -5,8 +5,10 @@ set -e
5
5
  # ambit - Self-Configuring Tailscale Subnet Router
6
6
  # =============================================================================
7
7
  # State is persisted to /var/lib/tailscale via Fly volume.
8
- # On first run: creates auth key (with tags), authenticates, approves routes.
8
+ # On first run: authenticates with a pre-minted auth key, advertises routes.
9
9
  # On restart: reuses existing state, no new device created.
10
+ # The router never receives the user's API token — only a single-use,
11
+ # tag-scoped auth key that expires after 5 minutes.
10
12
  # =============================================================================
11
13
 
12
14
  echo "Router: Enabling IP Forwarding"
@@ -33,42 +35,9 @@ if /usr/local/bin/tailscale status --json 2>/dev/null | jq -e '.BackendState ==
33
35
  --hostname="${FLY_APP_NAME:-ambit}" \
34
36
  --advertise-routes="${SUBNET}"
35
37
  else
36
- # First run - need to authenticate
37
- if [ -n "${TAILSCALE_API_TOKEN}" ]; then
38
- echo "Router: Creating Auth Key (First Run)"
39
-
40
- TAGS_JSON="[]"
41
- if [ -n "${TAILSCALE_TAGS}" ]; then
42
- TAGS_JSON=$(echo "${TAILSCALE_TAGS}" | jq -R 'split(",")')
43
- fi
44
-
45
- AUTH_KEY_RESPONSE=$(curl -s -X POST \
46
- -u "${TAILSCALE_API_TOKEN}:" \
47
- -H "Content-Type: application/json" \
48
- -d "$(jq -n \
49
- --argjson tags "${TAGS_JSON}" \
50
- '{
51
- capabilities: { devices: { create: {
52
- reusable: false,
53
- ephemeral: false,
54
- preauthorized: true,
55
- tags: $tags
56
- }}},
57
- expirySeconds: 300
58
- }' | jq 'if .capabilities.devices.create.tags == [] then .capabilities.devices.create |= del(.tags) else . end')" \
59
- "https://api.tailscale.com/api/v2/tailnet/-/keys")
60
-
61
- TAILSCALE_AUTHKEY=$(echo "${AUTH_KEY_RESPONSE}" | jq -r '.key')
62
-
63
- if [ -z "${TAILSCALE_AUTHKEY}" ] || [ "${TAILSCALE_AUTHKEY}" = "null" ]; then
64
- echo "Router: ERROR - Failed to Create Auth Key"
65
- echo "${AUTH_KEY_RESPONSE}"
66
- exit 1
67
- fi
68
-
69
- echo "Router: Auth Key Created"
70
- elif [ -z "${TAILSCALE_AUTHKEY}" ]; then
71
- echo "Router: ERROR - No TAILSCALE_API_TOKEN or TAILSCALE_AUTHKEY Provided"
38
+ # First run - authenticate with pre-minted auth key
39
+ if [ -z "${TAILSCALE_AUTHKEY}" ]; then
40
+ echo "Router: ERROR - No TAILSCALE_AUTHKEY Provided"
72
41
  exit 1
73
42
  fi
74
43
 
@@ -79,40 +48,6 @@ else
79
48
  --advertise-routes="${SUBNET}"
80
49
  fi
81
50
 
82
- echo "Router: Getting Node Key"
83
- NODE_KEY=$(/usr/local/bin/tailscale status --json | jq -r '.Self.PublicKey')
84
- echo "Router: Node Key ${NODE_KEY}"
85
-
86
- # Self-approve routes if we have API access
87
- # This is a fallback — if the user has autoApprovers configured in their
88
- # Tailscale policy file, routes are approved automatically and this block
89
- # is a no-op (routes are already enabled).
90
- if [ -n "${TAILSCALE_API_TOKEN}" ]; then
91
- echo "Router: Finding Device ID"
92
-
93
- DEVICES_RESPONSE=$(curl -s \
94
- -u "${TAILSCALE_API_TOKEN}:" \
95
- "https://api.tailscale.com/api/v2/tailnet/-/devices")
96
-
97
- DEVICE_ID=$(echo "${DEVICES_RESPONSE}" | jq -r ".devices[] | select(.nodeKey == \"${NODE_KEY}\") | .id")
98
-
99
- if [ -n "${DEVICE_ID}" ] && [ "${DEVICE_ID}" != "null" ]; then
100
- echo "Router: Device ID ${DEVICE_ID}"
101
- echo "Router: Approving Subnet Routes"
102
-
103
- curl -s -X POST \
104
- -u "${TAILSCALE_API_TOKEN}:" \
105
- -H "Content-Type: application/json" \
106
- -d "{\"routes\": [\"${SUBNET}\"]}" \
107
- "https://api.tailscale.com/api/v2/device/${DEVICE_ID}/routes" > /dev/null
108
-
109
- echo "Router: Routes Approved"
110
- else
111
- echo "Router: WARNING - Could Not Find Device ID"
112
- echo "Router: Routes May Need Manual Approval"
113
- fi
114
- fi
115
-
116
51
  echo "Router: Fully Configured"
117
52
 
118
53
  # Start SOCKS5 proxy for bidirectional tailnet access
@@ -124,10 +59,21 @@ echo "Router: Starting DNS Proxy"
124
59
 
125
60
  # Generate Corefile for CoreDNS
126
61
  # Rewrites NETWORK_NAME TLD to .flycast before forwarding to Fly DNS.
62
+ # When ROUTER_ID is set, workload app names are suffixed: app.network ->
63
+ # app-ROUTER_ID.flycast. This ties workloads to their router and avoids
64
+ # name collisions across networks.
127
65
  # .flycast resolves to the Flycast address (private_v6) which routes through
128
66
  # Fly Proxy — enabling autostart/autostop and load balancing. The Flycast IP
129
67
  # is within the network's /48 subnet so it's routable through the tailnet.
130
- if [ -n "${NETWORK_NAME}" ]; then
68
+ if [ -n "${NETWORK_NAME}" ] && [ -n "${ROUTER_ID}" ]; then
69
+ echo "Router: DNS Rewrite *.${NETWORK_NAME} -> *-${ROUTER_ID}.flycast"
70
+ cat > /etc/coredns/Corefile <<EOF
71
+ .:53 {
72
+ rewrite name regex (.+)\.${NETWORK_NAME}\. {1}-${ROUTER_ID}.flycast. answer auto
73
+ forward . fdaa::3
74
+ }
75
+ EOF
76
+ elif [ -n "${NETWORK_NAME}" ]; then
131
77
  echo "Router: DNS Rewrite ${NETWORK_NAME} -> flycast"
132
78
  cat > /etc/coredns/Corefile <<EOF
133
79
  .:53 {
@@ -32,6 +32,7 @@ export interface SafeDeployOptions {
32
32
  image?: string;
33
33
  config?: string;
34
34
  region?: string;
35
+ routerId?: string;
35
36
  }
36
37
  export interface FlyProvider {
37
38
  ensureInstalled(): Promise<void>;
@@ -41,6 +42,7 @@ export interface FlyProvider {
41
42
  listOrgs(): Promise<Record<string, string>>;
42
43
  createApp(name: string, org: string, options?: {
43
44
  network?: string;
45
+ routerId?: string;
44
46
  }): Promise<void>;
45
47
  deleteApp(name: string): Promise<void>;
46
48
  listApps(org?: string): Promise<FlyApp[]>;
@@ -67,4 +69,8 @@ export interface FlyProvider {
67
69
  }
68
70
  export declare const createFlyProvider: () => FlyProvider;
69
71
  export declare const getRouterAppName: (network: string, randomSuffix: string) => string;
72
+ /** Extract the routerId suffix from a router app name. */
73
+ export declare const getRouterSuffix: (routerAppName: string, network: string) => string;
74
+ /** Build the physical Fly app name for a workload. */
75
+ export declare const getWorkloadAppName: (name: string, routerId: string) => string;
70
76
  //# sourceMappingURL=fly.d.ts.map