@cardelli/ambit 0.1.4 → 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.
@@ -11,13 +11,14 @@ import { createFlyProvider, FlyDeployError, getRouterAppName, } from "../../prov
11
11
  import { createTailscaleProvider, enableAcceptRoutes, isAcceptRoutesEnabled, isTailscaleInstalled, waitForDevice, } from "../../providers/tailscale.js";
12
12
  import { getCredentialStore } from "../../credentials.js";
13
13
  import { resolveOrg } from "../../resolve.js";
14
+ import { findRouterApp } from "../../discovery.js";
14
15
  // =============================================================================
15
16
  // Create Command
16
17
  // =============================================================================
17
18
  const create = async (argv) => {
18
19
  const args = parseArgs(argv, {
19
20
  string: ["org", "region", "api-key", "tag"],
20
- boolean: ["help", "yes", "json", "self-approve"],
21
+ boolean: ["help", "yes", "json", "no-auto-approve"],
21
22
  alias: { y: "yes" },
22
23
  });
23
24
  if (args.help) {
@@ -32,9 +33,9 @@ ${bold("OPTIONS")}
32
33
  --region <region> Fly.io region (default: iad)
33
34
  --api-key <key> Tailscale API access token (tskey-api-...)
34
35
  --tag <tag> Tailscale ACL tag for the router (default: tag:ambit-<network>)
35
- --self-approve Approve subnet routes via API (when autoApprovers not configured)
36
+ --no-auto-approve Skip waiting for router and approving routes
36
37
  -y, --yes Skip confirmation prompts
37
- --json Output as JSON
38
+ --json Output as JSON (implies --no-auto-approve)
38
39
 
39
40
  ${bold("DESCRIPTION")}
40
41
  Deploys a Tailscale subnet router onto a Fly.io custom private network.
@@ -45,7 +46,6 @@ ${bold("DESCRIPTION")}
45
46
  ${bold("EXAMPLES")}
46
47
  ambit create browsers
47
48
  ambit create browsers --org my-org --region sea
48
- ambit create browsers --self-approve
49
49
  `);
50
50
  return;
51
51
  }
@@ -59,7 +59,7 @@ ${bold("EXAMPLES")}
59
59
  return out.die(`"${network}" Is a Public TLD and Cannot Be Used as a Network Name`);
60
60
  }
61
61
  const tag = args.tag || getRouterTag(network);
62
- const selfApprove = args["self-approve"] ?? false;
62
+ const shouldApprove = !(args["no-auto-approve"] || args.json);
63
63
  out.blank()
64
64
  .header("=".repeat(50))
65
65
  .header(` ambit Create: ${network}`)
@@ -75,7 +75,13 @@ ${bold("EXAMPLES")}
75
75
  out.ok(`Authenticated as ${email}`);
76
76
  const org = await resolveOrg(fly, args, out);
77
77
  const region = args.region || "iad";
78
- out.ok(`Using Region: ${region}`).blank();
78
+ out.ok(`Using Region: ${region}`);
79
+ const existingRouter = await findRouterApp(fly, org, network);
80
+ if (existingRouter) {
81
+ return out.die(`A Router Already Exists for Network "${network}": ${existingRouter.appName}. ` +
82
+ `Use "ambit destroy ${network}" First, or Choose a Different Network Name.`);
83
+ }
84
+ out.blank();
79
85
  // ==========================================================================
80
86
  // Step 2: Tailscale Configuration
81
87
  // ==========================================================================
@@ -86,8 +92,8 @@ ${bold("EXAMPLES")}
86
92
  if (args.json) {
87
93
  return out.die("--api-key Is Required in JSON Mode");
88
94
  }
89
- out.dim("ambit needs an API access token (not an auth key) to manage your tailnet.")
90
- .dim("Create one at: https://login.tailscale.com/admin/settings/keys")
95
+ out.dim("Ambit Needs an API Access Token (Not an Auth Key) to Manage Your Tailnet.")
96
+ .dim("Create One at: https://login.tailscale.com/admin/settings/keys")
91
97
  .blank();
92
98
  apiKey = await readSecret("API access token (tskey-api-...): ");
93
99
  if (!apiKey) {
@@ -114,10 +120,10 @@ ${bold("EXAMPLES")}
114
120
  if (!hasTagOwner) {
115
121
  tagOwnerSpinner.fail(`Tag ${tag} Not Configured in tagOwners`);
116
122
  out.blank()
117
- .text(` The tag ${tag} does not exist in your Tailscale ACL tagOwners.`)
118
- .text(" Tailscale will reject auth keys for undefined tags.")
123
+ .text(` The Tag ${tag} Does Not Exist in Your Tailscale ACL tagOwners.`)
124
+ .text(" Tailscale Will Reject Auth Keys for Undefined Tags.")
119
125
  .blank()
120
- .text(" Add this tag in your Tailscale ACL settings:")
126
+ .text(" Add This Tag in Your Tailscale ACL Settings:")
121
127
  .dim(" https://login.tailscale.com/admin/acls/visual/tags")
122
128
  .blank()
123
129
  .dim(` "tagOwners": { "${tag}": ["autogroup:admin"] }`)
@@ -125,46 +131,31 @@ ${bold("EXAMPLES")}
125
131
  return out.die(`Add ${tag} to tagOwners Before Creating Router`);
126
132
  }
127
133
  tagOwnerSpinner.success(`Tag ${tag} Configured in tagOwners`);
128
- // ==========================================================================
129
- // Step 2.6: Check autoApprovers
130
- // ==========================================================================
131
- if (!selfApprove) {
132
- const autoApproverSpinner = out.spinner("Checking autoApprovers Configuration");
133
- const hasAutoApprover = await tailscale.isAutoApproverConfigured(tag);
134
- if (!hasAutoApprover) {
135
- autoApproverSpinner.fail("autoApprovers Not Configured");
136
- out.blank()
137
- .text(` The tag ${tag} is not listed in your Tailscale ACL autoApprovers.`)
138
- .text(" Routes advertised by the router will not be auto-approved.")
139
- .blank()
140
- .text(" Either configure autoApprovers in your Tailscale ACL policy:")
141
- .dim(` "autoApprovers": { "routes": { "fdaa:X:XXXX::/48": ["${tag}"] } }`)
142
- .blank()
143
- .text(" Or re-run with --self-approve to approve routes via API:")
144
- .dim(` ambit create ${network} --self-approve`)
145
- .blank();
146
- return out.die("Configure autoApprovers or Use --self-approve");
147
- }
148
- autoApproverSpinner.success("autoApprovers Configured");
149
- }
150
- else {
151
- out.info("Self-Approve Mode: Routes Will Be Approved via API");
152
- }
153
134
  out.blank();
154
135
  // ==========================================================================
155
136
  // Step 3: Deploy Router on Custom 6PN
156
137
  // ==========================================================================
138
+ let hasAutoApprover = false;
157
139
  out.header("Step 3: Deploy Subnet Router").blank();
158
- const routerAppName = getRouterAppName(network, randomId(6));
140
+ const suffix = randomId(8);
141
+ const routerAppName = getRouterAppName(network, suffix);
159
142
  out.info(`Creating Router App: ${routerAppName}`)
160
143
  .info(`Custom Network: ${network}`)
161
144
  .info(`Router Tag: ${tag}`);
162
145
  await fly.createApp(routerAppName, org, { network });
163
146
  out.ok(`Created App: ${routerAppName}`);
147
+ const authKeySpinner = out.spinner("Creating Tag-Scoped Auth Key");
148
+ const authKey = await tailscale.createAuthKey({
149
+ reusable: false,
150
+ ephemeral: false,
151
+ preauthorized: true,
152
+ tags: [tag],
153
+ });
154
+ authKeySpinner.success("Auth Key Created (Single-Use, 5min Expiry)");
164
155
  await fly.setSecrets(routerAppName, {
165
- TAILSCALE_API_TOKEN: apiKey,
156
+ TAILSCALE_AUTHKEY: authKey,
166
157
  NETWORK_NAME: network,
167
- TAILSCALE_TAGS: tag,
158
+ ROUTER_ID: suffix,
168
159
  }, { stage: true });
169
160
  out.ok("Set Router Secrets");
170
161
  const dockerDir = new URL("../../docker/router", globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).url).pathname;
@@ -180,61 +171,79 @@ ${bold("EXAMPLES")}
180
171
  throw e;
181
172
  }
182
173
  out.ok("Router Deployed");
183
- out.blank();
184
- const joinSpinner = out.spinner("Waiting for Router to Join Tailnet");
185
- const device = await waitForDevice(tailscale, routerAppName, 180000);
186
- joinSpinner.success(`Router Joined Tailnet: ${device.addresses[0]}`);
187
- const machines = await fly.listMachines(routerAppName);
188
- const routerMachine = machines.find((m) => m.private_ip);
189
- const subnet = routerMachine?.private_ip
190
- ? extractSubnet(routerMachine.private_ip)
191
- : null;
192
- if (subnet) {
193
- out.ok(`Subnet: ${subnet}`);
194
- }
195
- if (selfApprove && subnet) {
196
- const approveSpinner = out.spinner("Approving Subnet Routes via API");
197
- await tailscale.approveSubnetRoutes(device.id, [subnet]);
198
- approveSpinner.success("Subnet Routes Approved via API");
199
- }
200
- if (device.advertisedRoutes && device.advertisedRoutes.length > 0) {
201
- out.ok(`Routes: ${device.advertisedRoutes.join(", ")}`);
202
- }
203
- const dnsSpinner = out.spinner("Configuring Split DNS");
204
- await tailscale.setSplitDns(network, [device.addresses[0]]);
205
- dnsSpinner.success(`Split DNS Configured: *.${network} -> Router`);
206
174
  // ==========================================================================
207
- // Step 4: Local Client Configuration
175
+ // Step 4: Wait for Router, Approve Routes, Configure DNS
208
176
  // ==========================================================================
209
- out.blank().header("Step 4: Local Client Configuration").blank();
210
- if (await isTailscaleInstalled()) {
211
- if (await isAcceptRoutesEnabled()) {
212
- out.ok("Accept Routes Already Enabled");
177
+ let device = null;
178
+ let routerMachine;
179
+ let subnet = null;
180
+ if (shouldApprove) {
181
+ out.blank();
182
+ const joinSpinner = out.spinner("Waiting for Router to Join Tailnet");
183
+ device = await waitForDevice(tailscale, routerAppName, 180000);
184
+ joinSpinner.success(`Router Joined Tailnet: ${device.addresses[0]}`);
185
+ const machines = await fly.listMachines(routerAppName);
186
+ routerMachine = machines.find((m) => m.private_ip);
187
+ subnet = routerMachine?.private_ip
188
+ ? extractSubnet(routerMachine.private_ip)
189
+ : null;
190
+ if (subnet) {
191
+ out.ok(`Subnet: ${subnet}`);
192
+ hasAutoApprover = await tailscale.isAutoApproverConfigured(tag);
193
+ if (!hasAutoApprover) {
194
+ const approveSpinner = out.spinner("Approving Subnet Routes");
195
+ await tailscale.approveSubnetRoutes(device.id, [subnet]);
196
+ approveSpinner.success("Subnet Routes Approved");
197
+ }
198
+ else {
199
+ out.ok("Routes Auto-Approved via ACL Policy");
200
+ }
213
201
  }
214
- else {
215
- const routeSpinner = out.spinner("Enabling Accept Routes");
216
- if (await enableAcceptRoutes()) {
217
- routeSpinner.success("Accept Routes Enabled");
202
+ if (device.advertisedRoutes && device.advertisedRoutes.length > 0) {
203
+ out.ok(`Routes: ${device.advertisedRoutes.join(", ")}`);
204
+ }
205
+ const dnsSpinner = out.spinner("Configuring Split DNS");
206
+ await tailscale.setSplitDns(network, [device.addresses[0]]);
207
+ dnsSpinner.success(`Split DNS Configured: *.${network} -> Router`);
208
+ // ========================================================================
209
+ // Step 5: Local Client Configuration
210
+ // ========================================================================
211
+ out.blank().header("Step 5: Local Client Configuration").blank();
212
+ if (await isTailscaleInstalled()) {
213
+ if (await isAcceptRoutesEnabled()) {
214
+ out.ok("Accept Routes Already Enabled");
218
215
  }
219
216
  else {
220
- routeSpinner.fail("Could Not Enable Accept Routes");
221
- out.blank()
222
- .dim("Run Manually with Elevated Permissions:")
223
- .dim(" sudo tailscale set --accept-routes");
217
+ const routeSpinner = out.spinner("Enabling Accept Routes");
218
+ if (await enableAcceptRoutes()) {
219
+ routeSpinner.success("Accept Routes Enabled");
220
+ }
221
+ else {
222
+ routeSpinner.fail("Could Not Enable Accept Routes");
223
+ out.blank()
224
+ .dim("Run Manually With Elevated Permissions:")
225
+ .dim(" sudo tailscale set --accept-routes");
226
+ }
224
227
  }
225
228
  }
229
+ else {
230
+ out.warn("Tailscale CLI Not Found")
231
+ .dim(" Ensure Accept-Routes is Enabled on This Device");
232
+ }
226
233
  }
227
234
  else {
228
- out.warn("Tailscale CLI Not Found")
229
- .dim(" Ensure Accept-Routes Is Enabled on This Device");
235
+ out.blank().info("Skipping Route Approval and DNS Configuration (Use ambit doctor to Verify Later)");
230
236
  }
231
237
  // ==========================================================================
232
238
  // Done
233
239
  // ==========================================================================
234
240
  out.done({
235
241
  network,
236
- router: { appName: routerAppName, tailscaleIp: device.addresses[0] },
237
- subnet: subnet || "unknown",
242
+ router: {
243
+ appName: routerAppName,
244
+ tailscaleIp: device?.addresses[0] ?? "pending",
245
+ },
246
+ subnet: subnet || "pending",
238
247
  tag,
239
248
  });
240
249
  out.blank()
@@ -244,43 +253,48 @@ ${bold("EXAMPLES")}
244
253
  .blank()
245
254
  .text(`Any Flycast app on the "${network}" network is reachable as:`)
246
255
  .text(` <app-name>.${network}`)
247
- .blank()
248
- .text(`SOCKS5 proxy available at:`)
249
- .text(` socks5://[${routerMachine?.private_ip ?? "ROUTER_IP"}]:1080`)
250
- .dim("Containers on this network can use it to reach your tailnet.")
251
- .blank()
252
- .dim("Deploy an app to this network:")
256
+ .blank();
257
+ if (routerMachine?.private_ip) {
258
+ out.text("SOCKS5 Proxy Available at:")
259
+ .text(` socks5://[${routerMachine.private_ip}]:1080`)
260
+ .dim("Containers on This Network Can Use It to Reach Your Tailnet.")
261
+ .blank();
262
+ }
263
+ out.dim("Deploy an App to This Network:")
253
264
  .dim(` ambit deploy my-app --network ${network}`)
254
265
  .blank()
255
- .dim("Invite people to your tailnet:")
266
+ .dim("Invite People to Your Tailnet:")
256
267
  .dim(" https://login.tailscale.com/admin/users")
257
- .dim("Control their access:")
268
+ .dim("Control Their Access:")
258
269
  .dim(" https://login.tailscale.com/admin/acls/visual/general-access-rules")
259
270
  .blank();
260
- if (subnet && selfApprove) {
261
- out.header("Recommended Tailscale ACL Policy:")
271
+ if (subnet && !hasAutoApprover) {
272
+ out.header("Recommended: Configure autoApprovers")
262
273
  .blank()
263
- .dim(" Add these to your tailnet policy file at:")
274
+ .dim(" Add to Your Tailnet Policy File at:")
264
275
  .dim(" https://login.tailscale.com/admin/acls/file")
265
276
  .blank()
266
- .text(` "tagOwners": { "${tag}": ["autogroup:admin"] }`)
267
277
  .text(` "autoApprovers": { "routes": { "${subnet}": ["${tag}"] } }`)
268
278
  .blank()
269
- .dim(" To restrict access, add ACL rules:")
270
- .dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${tag}:53"]}`)
271
- .dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${subnet}:*"]}`)
279
+ .dim(" Routes Were Approved via API for This Session.")
280
+ .dim(" autoApprovers Will Auto-Approve on Future Restarts.")
272
281
  .blank();
273
282
  }
274
- else if (subnet) {
283
+ if (subnet) {
275
284
  out.header("Recommended ACL Rules:")
276
285
  .blank()
277
- .dim(" To restrict access, add ACL rules to your policy file:")
286
+ .dim(" To Restrict Access, Add ACL Rules to Your Policy File:")
278
287
  .dim(" https://login.tailscale.com/admin/acls/file")
279
288
  .blank()
280
289
  .dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${tag}:53"]}`)
281
290
  .dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${subnet}:*"]}`)
282
291
  .blank();
283
292
  }
293
+ if (!shouldApprove) {
294
+ out.dim("Route Approval Was Skipped. To Complete Setup:")
295
+ .dim(` ambit doctor --network ${network}`)
296
+ .blank();
297
+ }
284
298
  out.print();
285
299
  };
286
300
  // =============================================================================
@@ -7,7 +7,7 @@ import { join } from "../../../deps/jsr.io/@std/path/1.1.4/mod.js";
7
7
  import { bold, confirm, fileExists } from "../../../lib/cli.js";
8
8
  import { createOutput } from "../../../lib/output.js";
9
9
  import { registerCommand } from "../mod.js";
10
- import { createFlyProvider, FlyDeployError } from "../../providers/fly.js";
10
+ import { createFlyProvider, FlyDeployError, getWorkloadAppName, } from "../../providers/fly.js";
11
11
  import { findRouterApp } from "../../discovery.js";
12
12
  import { resolveOrg } from "../../resolve.js";
13
13
  import { assertNotRouter, auditDeploy, scanFlyToml } from "../../guard.js";
@@ -111,19 +111,20 @@ const resolveTemplateMode = async (templateRaw, out) => {
111
111
  const result = await fetchTemplate(ref);
112
112
  if (!result.ok) {
113
113
  fetchSpinner.fail("Template Fetch Failed");
114
- out.die(result.message);
114
+ out.die(result.error);
115
115
  return null;
116
116
  }
117
+ const { tempDir, templateDir } = result.value;
117
118
  fetchSpinner.success("Template Fetched");
118
119
  // Find and scan the template's fly.toml
119
- const configPath = join(result.templateDir, "fly.toml");
120
+ const configPath = join(templateDir, "fly.toml");
120
121
  let tomlContent;
121
122
  try {
122
123
  tomlContent = await dntShim.Deno.readTextFile(configPath);
123
124
  }
124
125
  catch {
125
126
  try {
126
- dntShim.Deno.removeSync(result.tempDir, { recursive: true });
127
+ dntShim.Deno.removeSync(tempDir, { recursive: true });
127
128
  }
128
129
  catch { /* ignore */ }
129
130
  out.die(`Template '${ref.path === "." ? ref.repo : ref.path}' Has No fly.toml`);
@@ -132,7 +133,7 @@ const resolveTemplateMode = async (templateRaw, out) => {
132
133
  const scan = scanFlyToml(tomlContent);
133
134
  if (scan.errors.length > 0) {
134
135
  try {
135
- dntShim.Deno.removeSync(result.tempDir, { recursive: true });
136
+ dntShim.Deno.removeSync(tempDir, { recursive: true });
136
137
  }
137
138
  catch { /* ignore */ }
138
139
  for (const err of scan.errors) {
@@ -148,7 +149,7 @@ const resolveTemplateMode = async (templateRaw, out) => {
148
149
  return {
149
150
  configPath,
150
151
  preflight: { scanned: scan.scanned, warnings: scan.warnings },
151
- tempDir: result.tempDir,
152
+ tempDir,
152
153
  };
153
154
  };
154
155
  // =============================================================================
@@ -294,27 +295,29 @@ ${bold("EXAMPLES")}
294
295
  `Run 'ambit create ${network}' first.`);
295
296
  }
296
297
  routerSpinner.success(`Router Found: ${router.appName}`);
298
+ const routerId = router.routerId;
299
+ const flyAppName = getWorkloadAppName(app, routerId);
297
300
  out.blank();
298
301
  // ==========================================================================
299
302
  // Phase 3: App Creation (if needed)
300
303
  // ==========================================================================
301
304
  out.header("Step 3: App Setup").blank();
302
305
  let created = false;
303
- const exists = await fly.appExists(app);
306
+ const exists = await fly.appExists(flyAppName);
304
307
  if (exists) {
305
- out.ok(`App '${app}' Exists`);
308
+ out.ok(`App '${flyAppName}' Exists`);
306
309
  }
307
310
  else {
308
- out.info(`App '${app}' Does Not Exist — Will Create on Network '${network}'`);
311
+ out.info(`App '${flyAppName}' Does Not Exist — Will Create on Network '${network}'`);
309
312
  if (!args.yes && !args.json) {
310
- const confirmed = await confirm(`Create app '${app}' on network '${network}'?`);
313
+ const confirmed = await confirm(`Create app '${flyAppName}' on network '${network}'?`);
311
314
  if (!confirmed) {
312
315
  out.text("Cancelled.");
313
316
  return;
314
317
  }
315
318
  }
316
- await fly.createApp(app, org, { network });
317
- out.ok(`Created App '${app}' on Network '${network}'`);
319
+ await fly.createApp(app, org, { network, routerId });
320
+ out.ok(`Created App '${flyAppName}' on Network '${network}'`);
318
321
  created = true;
319
322
  }
320
323
  out.blank();
@@ -342,6 +345,7 @@ ${bold("EXAMPLES")}
342
345
  out.dim("Deploying with --no-public-ips --flycast ...");
343
346
  try {
344
347
  await fly.deploySafe(app, {
348
+ routerId,
345
349
  image: deployConfig.image,
346
350
  config: deployConfig.configPath,
347
351
  region: args.region,
@@ -369,7 +373,7 @@ ${bold("EXAMPLES")}
369
373
  // ==========================================================================
370
374
  out.header("Step 6: Post-flight Audit").blank();
371
375
  const auditSpinner = out.spinner("Auditing Deployment");
372
- const audit = await auditDeploy(fly, app, network);
376
+ const audit = await auditDeploy(fly, flyAppName, network);
373
377
  auditSpinner.success("Audit Complete");
374
378
  if (audit.public_ips_released > 0) {
375
379
  out.warn(`Released ${audit.public_ips_released} Public IP(s)`);
@@ -112,7 +112,7 @@ ${bold("EXAMPLES")}
112
112
  .text(` Tag: ${tag ?? "unknown"}`)
113
113
  .blank();
114
114
  if (workloadApps.length > 0) {
115
- out.warn(`${workloadApps.length} workload app(s) still on network '${network}':`);
115
+ out.warn(`${workloadApps.length} Workload App(s) Still on Network '${network}':`);
116
116
  for (const wa of workloadApps) {
117
117
  out.text(` - ${wa.appName}`);
118
118
  }
@@ -294,7 +294,7 @@ ${bold("EXAMPLES")}
294
294
  // ===========================================================================
295
295
  const appSpinner = out.spinner("Destroying Fly App");
296
296
  try {
297
- await fly.deleteApp(app);
297
+ await fly.deleteApp(workloadApp.appName);
298
298
  appSpinner.success("Fly App Destroyed");
299
299
  }
300
300
  catch {
@@ -303,7 +303,7 @@ ${bold("EXAMPLES")}
303
303
  // ===========================================================================
304
304
  // Done
305
305
  // ===========================================================================
306
- out.done({ destroyed: true, appName: app, network });
306
+ out.done({ destroyed: true, appName: workloadApp.appName, network });
307
307
  out.ok("App Destroyed");
308
308
  out.blank();
309
309
  out.print();
@@ -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";
@@ -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 {
@@ -33,7 +34,9 @@ export interface WorkloadApp {
33
34
  }
34
35
  /** List all non-router apps on a specific custom network in an org. */
35
36
  export declare const listWorkloadAppsOnNetwork: (fly: FlyProvider, org: string, network: string) => Promise<WorkloadApp[]>;
36
- /** Find a specific workload app by name, optionally verifying network. */
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"). */
37
40
  export declare const findWorkloadApp: (fly: FlyProvider, org: string, appName: string, network?: string) => Promise<WorkloadApp | null>;
38
41
  /** Get machine info for a router app. Returns null if no machines exist. */
39
42
  export declare const getRouterMachineInfo: (fly: FlyProvider, appName: string) => Promise<RouterMachineInfo | null>;
@@ -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,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,0EAA0E;AAC1E,eAAO,MAAM,eAAe,GAC1B,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,SAAS,MAAM,EACf,UAAU,MAAM,KACf,OAAO,CAAC,WAAW,GAAG,IAAI,CAmB5B,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. */
@@ -51,7 +53,9 @@ export const listWorkloadAppsOnNetwork = async (fly, org, network) => {
51
53
  org: app.organization?.slug ?? org,
52
54
  }));
53
55
  };
54
- /** Find a specific workload app by name, optionally verifying network. */
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"). */
55
59
  export const findWorkloadApp = async (fly, org, appName, network) => {
56
60
  const apps = await fly.listAppsWithNetwork(org);
57
61
  const workloads = apps
@@ -63,8 +67,18 @@ export const findWorkloadApp = async (fly, org, appName, network) => {
63
67
  org: app.organization?.slug ?? org,
64
68
  }));
65
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)
66
79
  return workloads.find((a) => a.appName === appName && a.network === network) ?? null;
67
80
  }
81
+ // Without network, try exact match only
68
82
  return workloads.find((a) => a.appName === appName) ?? null;
69
83
  };
70
84
  // =============================================================================