@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,7 +1,7 @@
1
1
  // =============================================================================
2
2
  // Create Command - Create Tailscale Subnet Router on Fly.io Custom Network
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, randomId, readSecret } from "../../../lib/cli.js";
6
6
  import { createOutput } from "../../../lib/output.js";
7
7
  import { registerCommand } from "../mod.js";
@@ -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
  // =============================================================================
@@ -2,12 +2,12 @@
2
2
  // Deploy Command - Safely Deploy a Workload App on a Custom Private Network
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 { 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";
@@ -99,38 +99,41 @@ const resolveTemplateMode = async (templateRaw, out) => {
99
99
  const ref = parseTemplateRef(templateRaw);
100
100
  if (!ref) {
101
101
  out.die(`Invalid Template Reference: "${templateRaw}". ` +
102
- `Format: owner/repo/path[@ref]`);
102
+ `Format: owner/repo[/path][@ref]`);
103
103
  return null;
104
104
  }
105
- const label = `${ref.owner}/${ref.repo}/${ref.path}` +
105
+ const label = (ref.path === "."
106
+ ? `${ref.owner}/${ref.repo}`
107
+ : `${ref.owner}/${ref.repo}/${ref.path}`) +
106
108
  (ref.ref ? `@${ref.ref}` : "");
107
109
  out.info(`Template: ${label}`);
108
110
  const fetchSpinner = out.spinner("Fetching Template from GitHub");
109
111
  const result = await fetchTemplate(ref);
110
112
  if (!result.ok) {
111
113
  fetchSpinner.fail("Template Fetch Failed");
112
- out.die(result.message);
114
+ out.die(result.error);
113
115
  return null;
114
116
  }
117
+ const { tempDir, templateDir } = result.value;
115
118
  fetchSpinner.success("Template Fetched");
116
119
  // Find and scan the template's fly.toml
117
- const configPath = join(result.templateDir, "fly.toml");
120
+ const configPath = join(templateDir, "fly.toml");
118
121
  let tomlContent;
119
122
  try {
120
123
  tomlContent = await dntShim.Deno.readTextFile(configPath);
121
124
  }
122
125
  catch {
123
126
  try {
124
- dntShim.Deno.removeSync(result.tempDir, { recursive: true });
127
+ dntShim.Deno.removeSync(tempDir, { recursive: true });
125
128
  }
126
129
  catch { /* ignore */ }
127
- out.die(`Template '${ref.path}' Has No fly.toml`);
130
+ out.die(`Template '${ref.path === "." ? ref.repo : ref.path}' Has No fly.toml`);
128
131
  return null;
129
132
  }
130
133
  const scan = scanFlyToml(tomlContent);
131
134
  if (scan.errors.length > 0) {
132
135
  try {
133
- dntShim.Deno.removeSync(result.tempDir, { recursive: true });
136
+ dntShim.Deno.removeSync(tempDir, { recursive: true });
134
137
  }
135
138
  catch { /* ignore */ }
136
139
  for (const err of scan.errors) {
@@ -142,11 +145,11 @@ const resolveTemplateMode = async (templateRaw, out) => {
142
145
  for (const warn of scan.warnings) {
143
146
  out.warn(warn);
144
147
  }
145
- out.ok(`Scanned ${ref.path}/fly.toml`);
148
+ out.ok(`Scanned ${ref.path === "." ? "" : ref.path + "/"}fly.toml`);
146
149
  return {
147
150
  configPath,
148
151
  preflight: { scanned: scan.scanned, warnings: scan.warnings },
149
- tempDir: result.tempDir,
152
+ tempDir,
150
153
  };
151
154
  };
152
155
  // =============================================================================
@@ -172,21 +175,24 @@ const deploy = async (argv) => {
172
175
  ${bold("ambit deploy")} - Deploy an App Safely on a Custom Private Network
173
176
 
174
177
  ${bold("USAGE")}
178
+ ambit deploy <app>.<network> [options]
175
179
  ambit deploy <app> --network <name> [options]
176
180
 
181
+ The network can be specified as part of the name (app.network) or with --network.
182
+
177
183
  ${bold("MODES")}
178
184
  Config mode (default):
179
- ambit deploy <app> --network <name> Uses ./fly.toml
180
- ambit deploy <app> --network <name> --config path Explicit fly.toml
185
+ ambit deploy my-app.lab Uses ./fly.toml
186
+ ambit deploy my-app.lab --config path Explicit fly.toml
181
187
 
182
188
  Image mode:
183
- ambit deploy <app> --network <name> --image <img> Docker image, no toml
189
+ ambit deploy my-app.lab --image <img> Docker image, no toml
184
190
 
185
191
  Template mode:
186
- ambit deploy <app> --network <name> --template <ref> GitHub template
192
+ ambit deploy my-app.lab --template <ref> GitHub template
187
193
 
188
194
  ${bold("OPTIONS")}
189
- --network <name> Custom 6PN network to target (required)
195
+ --network <name> Target network
190
196
  --org <org> Fly.io organization slug
191
197
  --region <region> Primary deployment region
192
198
  -y, --yes Skip confirmation prompts
@@ -200,10 +206,11 @@ ${bold("IMAGE MODE")}
200
206
  --main-port <port> Internal port for HTTP service (default: 80, "none" to skip)
201
207
 
202
208
  ${bold("TEMPLATE MODE")}
203
- --template <ref> GitHub template as owner/repo/path[@ref]
209
+ --template <ref> GitHub template as owner/repo[/path][@ref]
204
210
 
205
211
  Reference format:
206
- owner/repo/path Fetch from the default branch
212
+ owner/repo Fetch repo root from the default branch
213
+ owner/repo/path Fetch subdirectory from the default branch
207
214
  owner/repo/path@tag Fetch a tagged release
208
215
  owner/repo/path@branch Fetch a specific branch
209
216
  owner/repo/path@commit Fetch a specific commit
@@ -214,11 +221,12 @@ ${bold("SAFETY")}
214
221
  Pre-flight scan rejects fly.toml with force_https or TLS on 443.
215
222
 
216
223
  ${bold("EXAMPLES")}
217
- ambit deploy my-app --network browsers
218
- ambit deploy my-app --network browsers --image registry/img:latest
219
- ambit deploy my-app --network browsers --config ./fly.toml --region sea
220
- ambit deploy my-browser --network lab --template ToxicPine/ambit-templates/cdp
221
- ambit deploy my-browser --network lab --template ToxicPine/ambit-templates/cdp@v1.0
224
+ ambit deploy my-app.lab
225
+ ambit deploy my-app.lab --image registry/img:latest
226
+ ambit deploy my-app.lab --config ./fly.toml --region sea
227
+ ambit deploy my-claw.lab --template ToxicPine/ambit-openclaw
228
+ ambit deploy my-browser.lab --template ToxicPine/ambit-templates/chromatic
229
+ ambit deploy my-browser --network lab --template ToxicPine/ambit-templates/chromatic@v1.0
222
230
  `);
223
231
  return;
224
232
  }
@@ -228,11 +236,27 @@ ${bold("EXAMPLES")}
228
236
  // ==========================================================================
229
237
  const appArg = args._[0];
230
238
  if (!appArg || typeof appArg !== "string") {
231
- return out.die("App Name Required. Usage: ambit deploy <app> --network <name>");
239
+ return out.die("Missing app name. Usage: ambit deploy <app>.<network>");
240
+ }
241
+ let app;
242
+ let network;
243
+ if (appArg.includes(".")) {
244
+ const parts = appArg.split(".");
245
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
246
+ return out.die(`'${appArg}' should have exactly one dot, like my-app.my-network`);
247
+ }
248
+ if (args.network) {
249
+ return out.die(`Network is already part of the name ('${appArg}'), --network is not needed`);
250
+ }
251
+ app = parts[0];
252
+ network = parts[1];
232
253
  }
233
- const app = appArg;
234
- if (!args.network) {
235
- return out.die("--network Is Required");
254
+ else {
255
+ app = appArg;
256
+ if (!args.network) {
257
+ return out.die(`Missing network. Use: ambit deploy ${app}.<network>`);
258
+ }
259
+ network = args.network;
236
260
  }
237
261
  try {
238
262
  assertNotRouter(app);
@@ -244,7 +268,6 @@ ${bold("EXAMPLES")}
244
268
  if (modeFlags.length > 1) {
245
269
  return out.die("--image, --config, and --template Are Mutually Exclusive");
246
270
  }
247
- const network = args.network;
248
271
  out.blank()
249
272
  .header("=".repeat(50))
250
273
  .header(` ambit Deploy: ${app}`)
@@ -272,27 +295,29 @@ ${bold("EXAMPLES")}
272
295
  `Run 'ambit create ${network}' first.`);
273
296
  }
274
297
  routerSpinner.success(`Router Found: ${router.appName}`);
298
+ const routerId = router.routerId;
299
+ const flyAppName = getWorkloadAppName(app, routerId);
275
300
  out.blank();
276
301
  // ==========================================================================
277
302
  // Phase 3: App Creation (if needed)
278
303
  // ==========================================================================
279
304
  out.header("Step 3: App Setup").blank();
280
305
  let created = false;
281
- const exists = await fly.appExists(app);
306
+ const exists = await fly.appExists(flyAppName);
282
307
  if (exists) {
283
- out.ok(`App '${app}' Exists`);
308
+ out.ok(`App '${flyAppName}' Exists`);
284
309
  }
285
310
  else {
286
- 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}'`);
287
312
  if (!args.yes && !args.json) {
288
- const confirmed = await confirm(`Create app '${app}' on network '${network}'?`);
313
+ const confirmed = await confirm(`Create app '${flyAppName}' on network '${network}'?`);
289
314
  if (!confirmed) {
290
315
  out.text("Cancelled.");
291
316
  return;
292
317
  }
293
318
  }
294
- await fly.createApp(app, org, { network });
295
- 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}'`);
296
321
  created = true;
297
322
  }
298
323
  out.blank();
@@ -320,6 +345,7 @@ ${bold("EXAMPLES")}
320
345
  out.dim("Deploying with --no-public-ips --flycast ...");
321
346
  try {
322
347
  await fly.deploySafe(app, {
348
+ routerId,
323
349
  image: deployConfig.image,
324
350
  config: deployConfig.configPath,
325
351
  region: args.region,
@@ -347,7 +373,7 @@ ${bold("EXAMPLES")}
347
373
  // ==========================================================================
348
374
  out.header("Step 6: Post-flight Audit").blank();
349
375
  const auditSpinner = out.spinner("Auditing Deployment");
350
- const audit = await auditDeploy(fly, app, network);
376
+ const audit = await auditDeploy(fly, flyAppName, network);
351
377
  auditSpinner.success("Audit Complete");
352
378
  if (audit.public_ips_released > 0) {
353
379
  out.warn(`Released ${audit.public_ips_released} Public IP(s)`);