@cardelli/ambit 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +38 -22
  2. package/esm/cli/commands/create/index.js +53 -17
  3. package/esm/cli/commands/create/machine.d.ts +2 -1
  4. package/esm/cli/commands/create/machine.d.ts.map +1 -1
  5. package/esm/cli/commands/create/machine.js +70 -29
  6. package/esm/cli/commands/deploy/index.js +2 -4
  7. package/esm/cli/commands/deploy/machine.d.ts.map +1 -1
  8. package/esm/cli/commands/destroy/app.d.ts.map +1 -1
  9. package/esm/cli/commands/destroy/index.js +2 -0
  10. package/esm/cli/commands/destroy/network.d.ts.map +1 -1
  11. package/esm/cli/commands/destroy/network.js +66 -5
  12. package/esm/cli/commands/doctor.js +13 -4
  13. package/esm/cli/commands/list.js +1 -1
  14. package/esm/cli/commands/share.d.ts +2 -0
  15. package/esm/cli/commands/share.d.ts.map +1 -0
  16. package/esm/cli/commands/share.js +250 -0
  17. package/esm/cli/commands/status.js +4 -1
  18. package/esm/cli/mod.d.ts.map +1 -1
  19. package/esm/cli/mod.js +2 -0
  20. package/esm/deno.js +1 -1
  21. package/esm/lib/command.d.ts.map +1 -1
  22. package/esm/lib/command.js +5 -7
  23. package/esm/main.d.ts +1 -0
  24. package/esm/main.d.ts.map +1 -1
  25. package/esm/main.js +2 -0
  26. package/esm/providers/fly.d.ts.map +1 -1
  27. package/esm/providers/fly.js +14 -3
  28. package/esm/providers/tailscale.d.ts +7 -0
  29. package/esm/providers/tailscale.d.ts.map +1 -1
  30. package/esm/providers/tailscale.js +23 -1
  31. package/esm/router/start.sh +7 -4
  32. package/esm/util/constants.d.ts +0 -1
  33. package/esm/util/constants.d.ts.map +1 -1
  34. package/esm/util/constants.js +0 -1
  35. package/esm/util/credentials.d.ts.map +1 -1
  36. package/esm/util/credentials.js +1 -1
  37. package/esm/util/discovery.d.ts.map +1 -1
  38. package/esm/util/discovery.js +1 -1
  39. package/esm/util/tailscale-local.d.ts +41 -0
  40. package/esm/util/tailscale-local.d.ts.map +1 -1
  41. package/esm/util/tailscale-local.js +146 -0
  42. package/esm/util/template.d.ts.map +1 -1
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -67,17 +67,38 @@ Open `http://my-crazy-site.lab`. It works for you and nobody else.
67
67
 
68
68
  ### `ambit create <network>`
69
69
 
70
- This is the first command you run, it sets up your private network: a named slice of the cloud that only your devices can reach. Under the hood it handles Fly.io and Tailscale authentication, deploys the router, sets up DNS, and configures your local machine to accept the new routes.
71
-
72
- | Flag | Description |
73
- | ---------------------- | ---------------------------------------------------------- |
74
- | `--org <org>` | Fly.io organization slug |
75
- | `--region <region>` | Fly.io region (default: `iad`) |
76
- | `--api-key <key>` | Tailscale API access token (tskey-api-...) |
77
- | `--tag <tag>` | Tailscale ACL tag for the router (default: `tag:ambit-<network>`) |
78
- | `--no-auto-approve` | Skip waiting for router and approving routes |
79
- | `-y`, `--yes` | Skip confirmation prompts |
80
- | `--json` | Machine-readable JSON output (implies `--no-auto-approve`) |
70
+ This is the first command you run, it sets up your private network: a named slice of the cloud that only your devices can reach. Under the hood it handles Fly.io and Tailscale authentication, deploys the router, sets up DNS, configures your local machine to accept the new routes, and automatically adds the router's tag to your Tailscale ACL policy.
71
+
72
+ | Flag | Description |
73
+ | ------------------- | --------------------------------------------------------------------------- |
74
+ | `--org <org>` | Fly.io organization slug |
75
+ | `--region <region>` | Fly.io region (default: `iad`) |
76
+ | `--api-key <key>` | Tailscale API access token (tskey-api-...) |
77
+ | `--tag <tag>` | Tailscale ACL tag for the router (default: `tag:ambit-<network>`) |
78
+ | `--manual` | Skip automatic Tailscale ACL configuration (tagOwners + autoApprovers) |
79
+ | `--no-auto-approve` | Skip waiting for router and approving routes |
80
+ | `-y`, `--yes` | Skip confirmation prompts |
81
+ | `--json` | Machine-readable JSON output (implies `--no-auto-approve`) |
82
+
83
+ ### `ambit share <network> <member> [<member>...]`
84
+
85
+ Grants one or more members access to a network by adding two ACL rules per member: one for DNS (so they can resolve `*.<network>` names) and one for the subnet (so they can reach apps). All members are validated before any changes are made. The command is idempotent — safe to re-run.
86
+
87
+ Each member must be one of:
88
+ - `group:<name>` — a Tailscale group
89
+ - `tag:<name>` — a device tag
90
+ - `autogroup:<name>` — a built-in Tailscale group (e.g. `autogroup:member`)
91
+ - A valid email address — a specific Tailscale user
92
+
93
+ ```bash
94
+ npx @cardelli/ambit share browsers group:team
95
+ npx @cardelli/ambit share browsers group:team alice@example.com group:contractors
96
+ ```
97
+
98
+ | Flag | Description |
99
+ | ------------- | ------------------------ |
100
+ | `--org <org>` | Fly.io organization slug |
101
+ | `--json` | Machine-readable JSON |
81
102
 
82
103
  ### `ambit deploy <app>.<network>`
83
104
 
@@ -173,11 +194,12 @@ Lists all discovered routers across networks in a table showing the network name
173
194
 
174
195
  ### `ambit destroy network <name>`
175
196
 
176
- Tears down a network: destroys the router, cleans up DNS, and removes the Tailscale device. If there are workload apps still on the network, ambit warns you before proceeding. Reminds you to clean up any ACL entries you added.
197
+ Tears down a network: destroys the router, cleans up DNS, removes the Tailscale device, and automatically removes the router's tag from your Tailscale ACL policy (tagOwners and autoApprovers). If there are workload apps still on the network, ambit warns you before proceeding.
177
198
 
178
199
  | Flag | Description |
179
200
  | ----------------- | ------------------------------ |
180
201
  | `--org <org>` | Fly.io organization slug |
202
+ | `--manual` | Skip automatic Tailscale ACL cleanup (tagOwners + autoApprovers) |
181
203
  | `-y`, `--yes` | Skip confirmation prompts |
182
204
  | `--json` | Machine-readable JSON output |
183
205
 
@@ -218,20 +240,12 @@ Checks app health: verifies the app is deployed and running, then checks the rou
218
240
 
219
241
  ## Access Control
220
242
 
221
- Ambit doesn't touch your Tailscale ACL policy. After creating a router, it prints the exact policy entries you need so you can control who on your tailnet can reach which networks. By default, if you haven't restricted anything, all your devices can reach everything.
243
+ By default, `ambit create` automatically adds the router's tag to your Tailscale ACL policy (`tagOwners` and `autoApprovers`). `ambit destroy network` automatically removes them. Pass `--manual` to either command to skip this and manage the policy yourself useful if your API token lacks ACL write permission (`policy_file` scope).
222
244
 
223
- If you want to lock it down, two rules do the job — one for DNS queries and one for data traffic:
245
+ If you want to lock down which users can reach which networks, two rules do the job — one for DNS queries and one for data traffic:
224
246
 
225
247
  ```jsonc
226
248
  {
227
- "tagOwners": {
228
- "tag:ambit-infra": ["autogroup:admin"]
229
- },
230
- "autoApprovers": {
231
- "routes": {
232
- "fdaa:X:XXXX::/48": ["tag:ambit-infra"]
233
- }
234
- },
235
249
  "acls": [
236
250
  {
237
251
  "action": "accept",
@@ -243,6 +257,8 @@ If you want to lock it down, two rules do the job — one for DNS queries and on
243
257
  }
244
258
  ```
245
259
 
260
+ These `acls` entries are never touched automatically — ambit only manages `tagOwners` and `autoApprovers`.
261
+
246
262
  ## Multiple Networks
247
263
 
248
264
  You can create as many networks as you want, and each one gets its own TLD on your tailnet. The SOCKS proxy on each router means containers on one network can reach services on another by going through the tailnet, so a browser on your `browsers` network can connect to a database on your `infra` network.
@@ -9,12 +9,12 @@ import { runMachine } from "../../../lib/machine.js";
9
9
  import { registerCommand } from "../../mod.js";
10
10
  import { getRouterTag } from "../../../util/naming.js";
11
11
  import { isPublicTld } from "../../../util/guard.js";
12
- import { createFlyProvider, } from "../../../providers/fly.js";
12
+ import { createFlyProvider } from "../../../providers/fly.js";
13
13
  import { createTailscaleProvider, } from "../../../providers/tailscale.js";
14
14
  import { getCredentialStore } from "../../../util/credentials.js";
15
- import { FLY_PRIVATE_SUBNET, TAILSCALE_API_KEY_PREFIX, } from "../../../util/constants.js";
15
+ import { TAILSCALE_API_KEY_PREFIX } from "../../../util/constants.js";
16
16
  import { resolveOrg } from "../../../util/resolve.js";
17
- import { isAutoApproverConfigured, isTagOwnerConfigured } from "../../../util/tailscale-local.js";
17
+ import { assertAdditivePatch, isAutoApproverConfigured, isTagOwnerConfigured, patchTagOwner, } from "../../../util/tailscale-local.js";
18
18
  import { createTransition, hydrateCreate, reportSkipped, } from "./machine.js";
19
19
  // =============================================================================
20
20
  // Stage 1: Fly.io Configuration
@@ -34,6 +34,13 @@ const stageFlyConfig = async (out, opts) => {
34
34
  // =============================================================================
35
35
  // Stage 2: Tailscale Configuration
36
36
  // =============================================================================
37
+ const handleAclSetFailure = (out, result, action) => {
38
+ if (result.status === 403) {
39
+ out.err(`${action}: Permission Denied (HTTP 403)`);
40
+ return out.die("Your API Token Lacks ACL Write Permission. Re-run with --manual to Skip ACL Changes");
41
+ }
42
+ return out.die(`${action}: ${result.error ?? `HTTP ${result.status}`}`);
43
+ };
37
44
  const stageTailscaleConfig = async (out, opts) => {
38
45
  out.header("Step 2: Tailscale Configuration").blank();
39
46
  const credentials = getCredentialStore();
@@ -63,9 +70,26 @@ const stageTailscaleConfig = async (out, opts) => {
63
70
  validateSpinner.success("API Access Token Validated");
64
71
  await credentials.setTailscaleApiKey(apiKey);
65
72
  const tagOwnerSpinner = out.spinner(`Checking tagOwners for ${opts.tag}`);
66
- const policy = await tailscale.acl.getPolicy();
73
+ let policy = await tailscale.acl.getPolicy();
67
74
  const hasTagOwner = isTagOwnerConfigured(policy, opts.tag);
68
- if (!hasTagOwner) {
75
+ if (!hasTagOwner && !opts.manual && policy) {
76
+ tagOwnerSpinner.stop();
77
+ const beforeTagOwner = policy;
78
+ policy = patchTagOwner(policy, opts.tag);
79
+ assertAdditivePatch(beforeTagOwner, policy);
80
+ const validateTagOwner = await tailscale.acl.validatePolicy(policy);
81
+ if (!validateTagOwner.ok) {
82
+ return handleAclSetFailure(out, validateTagOwner, `Validating tagOwners patch for ${opts.tag}`);
83
+ }
84
+ const patchSpinner = out.spinner(`Adding ${opts.tag} to tagOwners`);
85
+ const result = await tailscale.acl.setPolicy(policy);
86
+ if (!result.ok) {
87
+ patchSpinner.fail(`Adding ${opts.tag} to tagOwners`);
88
+ return handleAclSetFailure(out, result, `Adding ${opts.tag} to tagOwners`);
89
+ }
90
+ patchSpinner.success(`Added ${opts.tag} to tagOwners`);
91
+ }
92
+ else if (!hasTagOwner) {
69
93
  tagOwnerSpinner.fail(`${opts.tag} Not Set Up Yet`);
70
94
  out.blank()
71
95
  .text(` You need to grant yourself permission to create the "${opts.network}"`)
@@ -80,22 +104,24 @@ const stageTailscaleConfig = async (out, opts) => {
80
104
  .blank();
81
105
  return out.die(`Set Up ${opts.tag} in Tailscale, Then Try Again`);
82
106
  }
83
- tagOwnerSpinner.success(`${opts.tag} Found in Tailscale ACL`);
84
- if (opts.json) {
107
+ else {
108
+ tagOwnerSpinner.success(`${opts.tag} Found in Tailscale ACL`);
109
+ }
110
+ if (opts.manual && opts.json) {
85
111
  const approverSpinner = out.spinner(`Checking autoApprovers for ${opts.tag}`);
86
112
  const hasApprover = isAutoApproverConfigured(policy, opts.tag);
87
113
  if (!hasApprover) {
88
114
  approverSpinner.fail(`Auto-approve Not Configured for ${opts.tag}`);
89
115
  out.blank()
90
- .text(" In JSON mode, ambit can't interactively approve the router's")
91
- .text(` network connections. You can set this up from the Tailscale dashboard:`)
116
+ .text(" In --manual --json mode, ambit can't interactively approve the")
117
+ .text(" router's subnet routes. Set up autoApprovers first:")
92
118
  .link(" https://login.tailscale.com/admin/acls/visual/auto-approvers")
93
- .dim(` Route: ${FLY_PRIVATE_SUBNET} Owner: ${opts.tag}`)
119
+ .dim(` Route: <subnet>/48 Owner: ${opts.tag}`)
94
120
  .blank()
95
- .dim(" Or you can do it manually with this JSON config:")
96
- .dim(` "autoApprovers": { "routes": { "${FLY_PRIVATE_SUBNET}": ["${opts.tag}"] } }`)
121
+ .dim(" Or in the ACL file:")
122
+ .dim(` "autoApprovers": { "routes": { "<subnet>/48": ["${opts.tag}"] } }`)
97
123
  .blank();
98
- return out.die(`Set Up Auto-approve for ${opts.tag} to Use --json`);
124
+ return out.die(`Set Up Auto-approve for ${opts.tag} to Use --manual --json`);
99
125
  }
100
126
  approverSpinner.success(`Auto-approve Configured for ${opts.tag}`);
101
127
  }
@@ -194,7 +220,8 @@ const stageSummary = async (out, fly, tailscale, ctx, opts) => {
194
220
  .blank()
195
221
  .dim(" You can do this from the Tailscale dashboard:")
196
222
  .link(" https://login.tailscale.com/admin/acls/visual/general-access-rules")
197
- .dim(` Source: group:YOUR_GROUP Destination: ${opts.tag}:*`)
223
+ .dim(` Source: group:YOUR_GROUP Destination: ${opts.tag}:53`)
224
+ .dim(` Source: group:YOUR_GROUP Destination: ${ctx.subnet}:*`)
198
225
  .blank()
199
226
  .dim(" Or you can do it manually with this JSON config:")
200
227
  .dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${opts.tag}:53"]}`)
@@ -214,7 +241,7 @@ const stageSummary = async (out, fly, tailscale, ctx, opts) => {
214
241
  const create = async (argv) => {
215
242
  const opts = {
216
243
  string: ["org", "region", "api-key", "tag"],
217
- boolean: ["help", "yes", "json", "no-auto-approve"],
244
+ boolean: ["help", "yes", "json", "no-auto-approve", "manual"],
218
245
  alias: { y: "yes" },
219
246
  };
220
247
  const args = parseArgs(argv, opts);
@@ -231,7 +258,8 @@ ${bold("OPTIONS")}
231
258
  --region <region> Fly.io region (default: iad)
232
259
  --api-key <key> Tailscale API access token (tskey-api-...)
233
260
  --tag <tag> Tailscale ACL tag for the router (default: tag:ambit-<network>)
234
- --no-auto-approve Skip waiting for router and approving routes
261
+ --manual Skip automatic Tailscale ACL configuration (tagOwners + autoApprovers)
262
+ --no-auto-approve Skip waiting for router and approving routes
235
263
  -y, --yes Skip confirmation prompts
236
264
  --json Output as JSON (implies --no-auto-approve)
237
265
 
@@ -241,9 +269,14 @@ ${bold("DESCRIPTION")}
241
269
 
242
270
  my-app.${args._[0] || "<network>"} resolves to my-app.flycast
243
271
 
272
+ By default, ambit auto-configures your Tailscale ACL policy (tagOwners
273
+ and autoApprovers). Use --manual if your API token lacks ACL write
274
+ permission or you prefer to manage the policy yourself.
275
+
244
276
  ${bold("EXAMPLES")}
245
277
  ambit create browsers
246
278
  ambit create browsers --org my-org --region sea
279
+ ambit create browsers --manual
247
280
  `);
248
281
  return;
249
282
  }
@@ -257,7 +290,8 @@ ${bold("EXAMPLES")}
257
290
  return out.die(`"${network}" Is a Public TLD and Cannot Be Used as a Network Name`);
258
291
  }
259
292
  const tag = args.tag || getRouterTag(network);
260
- const shouldApprove = !(args["no-auto-approve"] || args.json);
293
+ const manual = !!args.manual;
294
+ const shouldApprove = !manual || !(args["no-auto-approve"] || args.json);
261
295
  out.blank()
262
296
  .header("=".repeat(50))
263
297
  .header(` ambit Create: ${network}`)
@@ -270,6 +304,7 @@ ${bold("EXAMPLES")}
270
304
  });
271
305
  const tailscale = await stageTailscaleConfig(out, {
272
306
  json: args.json,
307
+ manual,
273
308
  apiKey: args["api-key"],
274
309
  tag,
275
310
  network,
@@ -280,6 +315,7 @@ ${bold("EXAMPLES")}
280
315
  region,
281
316
  tag,
282
317
  shouldApprove,
318
+ manual,
283
319
  });
284
320
  };
285
321
  // =============================================================================
@@ -3,7 +3,7 @@ import { Result } from "../../../lib/result.js";
3
3
  import { type FlyProvider } from "../../../providers/fly.js";
4
4
  import type { TailscaleProvider } from "../../../providers/tailscale.js";
5
5
  import type { TailscaleDevice } from "../../../schemas/tailscale.js";
6
- export type CreatePhase = "create_app" | "deploy_router" | "await_device" | "approve_routes" | "configure_dns" | "accept_routes" | "complete";
6
+ export type CreatePhase = "create_app" | "deploy_router" | "approve_routes" | "configure_dns" | "accept_routes" | "complete";
7
7
  export type CreateResult = {
8
8
  network: string;
9
9
  router: {
@@ -22,6 +22,7 @@ export interface CreateCtx {
22
22
  region: string;
23
23
  tag: string;
24
24
  shouldApprove: boolean;
25
+ manual: boolean;
25
26
  appName: string;
26
27
  routerId: string;
27
28
  device?: TailscaleDevice;
@@ -1 +1 @@
1
- {"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/create/machine.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAQhD,OAAO,EAEL,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AASnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAOrE,MAAM,MAAM,WAAW,GACnB,YAAY,GACZ,eAAe,GACf,cAAc,GACd,gBAAgB,GAChB,eAAe,GACf,eAAe,GACf,UAAU,CAAC;AAMf,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACxD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,SAAS,EAAE,iBAAiB,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,EAAE,OAAO,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD,eAAO,MAAM,aAAa,GACxB,KAAK,MAAM,CAAC,YAAY,CAAC,EACzB,YAAY,WAAW,SAMxB,CAAC;AAMF,eAAO,MAAM,aAAa,GACxB,KAAK,SAAS,KACb,OAAO,CAAC,WAAW,CA4BrB,CAAC;AAMF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,WAAW,EAClB,KAAK,SAAS,KACb,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CAgH7B,CAAC"}
1
+ {"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/create/machine.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAQhD,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAW7E,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAOrE,MAAM,MAAM,WAAW,GACnB,YAAY,GACZ,eAAe,GACf,gBAAgB,GAChB,eAAe,GACf,eAAe,GACf,UAAU,CAAC;AAMf,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACxD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,SAAS,EAAE,iBAAiB,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAcD,eAAO,MAAM,aAAa,GACxB,KAAK,MAAM,CAAC,YAAY,CAAC,EACzB,YAAY,WAAW,SAMxB,CAAC;AAMF,eAAO,MAAM,aAAa,GACxB,KAAK,SAAS,KACb,OAAO,CAAC,WAAW,CA4BrB,CAAC;AAMF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,WAAW,EAClB,KAAK,SAAS,KACb,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CA6K7B,CAAC"}
@@ -5,9 +5,9 @@ import { randomId } from "../../../lib/cli.js";
5
5
  import { Result } from "../../../lib/result.js";
6
6
  import { extractSubnet } from "../../../util/fly-transforms.js";
7
7
  import { ROUTER_DOCKER_DIR, SECRET_NETWORK_NAME, SECRET_ROUTER_ID, SECRET_TAILSCALE_AUTHKEY, } from "../../../util/constants.js";
8
- import { FlyDeployError, } from "../../../providers/fly.js";
8
+ import { FlyDeployError } from "../../../providers/fly.js";
9
9
  import { getRouterAppName } from "../../../util/naming.js";
10
- import { enableAcceptRoutes, isAcceptRoutesEnabled, isAutoApproverConfigured, isTailscaleInstalled, waitForDevice, } from "../../../util/tailscale-local.js";
10
+ import { assertAdditivePatch, enableAcceptRoutes, isAcceptRoutesEnabled, isAutoApproverConfigured, isTailscaleInstalled, patchAutoApprover, waitForDevice, } from "../../../util/tailscale-local.js";
11
11
  import { findRouterApp, getRouterMachineInfo } from "../../../util/discovery.js";
12
12
  // =============================================================================
13
13
  // Phase Labels
@@ -15,7 +15,6 @@ import { findRouterApp, getRouterMachineInfo } from "../../../util/discovery.js"
15
15
  const CREATE_PHASES = [
16
16
  { phase: "create_app", label: "Fly App Created" },
17
17
  { phase: "deploy_router", label: "Router Deployed" },
18
- { phase: "await_device", label: "Router in Tailnet" },
19
18
  { phase: "approve_routes", label: "Routes Approved" },
20
19
  { phase: "configure_dns", label: "Split DNS Configured" },
21
20
  { phase: "accept_routes", label: "Accept Routes Enabled" },
@@ -44,7 +43,7 @@ export const hydrateCreate = async (ctx) => {
44
43
  return "complete";
45
44
  const device = await ctx.tailscale.devices.getByHostname(router.appName);
46
45
  if (!device)
47
- return "await_device";
46
+ return "approve_routes";
48
47
  ctx.device = device;
49
48
  const routes = await ctx.tailscale.routes.get(device.id);
50
49
  if (!routes || routes.unapproved.length > 0)
@@ -70,15 +69,7 @@ export const createTransition = async (phase, ctx) => {
70
69
  return Result.ok("deploy_router");
71
70
  }
72
71
  case "deploy_router": {
73
- const authKey = await ctx.tailscale.auth.createKey({
74
- reusable: false,
75
- ephemeral: false,
76
- preauthorized: true,
77
- tags: [ctx.tag],
78
- });
79
- ctx.out.ok("Auth Key Created");
80
- await ctx.out.spin("Setting Secrets", () => ctx.fly.secrets.set(ctx.appName, {
81
- [SECRET_TAILSCALE_AUTHKEY]: authKey,
72
+ await ctx.out.spin("Staging Secrets", () => ctx.fly.secrets.set(ctx.appName, {
82
73
  [SECRET_NETWORK_NAME]: ctx.network,
83
74
  [SECRET_ROUTER_ID]: ctx.routerId,
84
75
  }, { stage: true }));
@@ -102,34 +93,84 @@ export const createTransition = async (phase, ctx) => {
102
93
  const m = machines.find((m) => m.private_ip);
103
94
  if (m?.private_ip)
104
95
  ctx.subnet = extractSubnet(m.private_ip);
105
- if (!ctx.shouldApprove)
106
- return Result.ok("complete");
107
- return Result.ok("await_device");
96
+ return Result.ok("approve_routes");
108
97
  }
109
- case "await_device": {
110
- ctx.device = await waitForDevice(ctx.tailscale, ctx.appName, 180000);
111
- ctx.out.ok(`Router Joined Tailnet: ${ctx.device.addresses[0]}`);
98
+ case "approve_routes": {
112
99
  if (!ctx.subnet) {
113
100
  const machines = await ctx.fly.machines.list(ctx.appName);
114
101
  const m = machines.find((m) => m.private_ip);
115
102
  if (m?.private_ip)
116
103
  ctx.subnet = extractSubnet(m.private_ip);
117
104
  }
118
- return Result.ok("approve_routes");
119
- }
120
- case "approve_routes": {
121
- if (!ctx.device || !ctx.subnet) {
122
- return Result.err("Missing Device or Subnet");
123
- }
124
- const policy = await ctx.tailscale.acl.getPolicy();
105
+ if (!ctx.subnet)
106
+ return Result.err("Missing Subnet");
107
+ let policy = await ctx.tailscale.acl.getPolicy();
125
108
  const hasAutoApprover = isAutoApproverConfigured(policy, ctx.tag);
126
- if (hasAutoApprover) {
127
- ctx.out.ok("Routes Auto-Approved via ACL Policy");
109
+ let approverReady = hasAutoApprover;
110
+ if (!hasAutoApprover && !ctx.manual && policy) {
111
+ const before = policy;
112
+ policy = patchAutoApprover(policy, ctx.tag, ctx.subnet);
113
+ assertAdditivePatch(before, policy);
114
+ const vr = await ctx.tailscale.acl.validatePolicy(policy);
115
+ if (!vr.ok) {
116
+ ctx.out.warn(`Could Not Validate autoApprover Patch: ${vr.error ?? `HTTP ${vr.status}`}`);
117
+ }
118
+ else {
119
+ const sr = await ctx.tailscale.acl.setPolicy(policy);
120
+ if (sr.ok) {
121
+ ctx.out.ok(`Added autoApprover for ${ctx.tag} → ${ctx.subnet}`);
122
+ approverReady = true;
123
+ }
124
+ else if (sr.status === 403) {
125
+ ctx.out.warn("API Token Lacks ACL Write Permission — Will Approve Routes Manually");
126
+ }
127
+ else {
128
+ ctx.out.warn(`Could Not Set autoApprover: ${sr.error ?? `HTTP ${sr.status}`}`);
129
+ }
130
+ }
128
131
  }
129
- else {
132
+ // If the device isn't in the tailnet yet, the router hasn't
133
+ // authenticated. Mint an auth key and deliver it — the non-staged
134
+ // secrets set triggers a Fly restart. The router boots with the key,
135
+ // authenticates, and advertises routes. With autoApprover in place,
136
+ // routes are auto-approved immediately.
137
+ if (!ctx.device) {
138
+ const existing = await ctx.tailscale.devices.getByHostname(ctx.appName);
139
+ if (existing) {
140
+ ctx.device = existing;
141
+ }
142
+ else {
143
+ const authKey = await ctx.tailscale.auth.createKey({
144
+ reusable: false,
145
+ ephemeral: false,
146
+ preauthorized: true,
147
+ tags: [ctx.tag],
148
+ });
149
+ ctx.out.ok("Auth Key Created");
150
+ const keySpinner = ctx.out.spinner("Delivering Auth Key (restarting router)");
151
+ await ctx.fly.secrets.set(ctx.appName, {
152
+ [SECRET_TAILSCALE_AUTHKEY]: authKey,
153
+ });
154
+ keySpinner.success("Auth Key Delivered");
155
+ if (!ctx.shouldApprove) {
156
+ ctx.out.dim(" Skipping Route Approval (--no-auto-approve)");
157
+ return Result.ok("complete");
158
+ }
159
+ ctx.device = await waitForDevice(ctx.tailscale, ctx.appName, 180000);
160
+ ctx.out.ok(`Router Joined Tailnet: ${ctx.device.addresses[0]}`);
161
+ }
162
+ }
163
+ const routes = await ctx.tailscale.routes.get(ctx.device.id);
164
+ if (routes && routes.unapproved.length > 0) {
165
+ if (approverReady) {
166
+ ctx.out.warn("Routes Not Auto-Approved Despite autoApprover — Approving Manually");
167
+ }
130
168
  await ctx.tailscale.routes.approve(ctx.device.id, [ctx.subnet]);
131
169
  ctx.out.ok("Subnet Routes Approved");
132
170
  }
171
+ else {
172
+ ctx.out.ok("Routes Auto-Approved via ACL Policy");
173
+ }
133
174
  return Result.ok("configure_dns");
134
175
  }
135
176
  case "configure_dns": {
@@ -7,7 +7,7 @@ import { checkArgs } from "../../../lib/args.js";
7
7
  import { createOutput } from "../../../lib/output.js";
8
8
  import { runMachine } from "../../../lib/machine.js";
9
9
  import { registerCommand } from "../../mod.js";
10
- import { createFlyProvider, } from "../../../providers/fly.js";
10
+ import { createFlyProvider } from "../../../providers/fly.js";
11
11
  import { getWorkloadAppName } from "../../../util/naming.js";
12
12
  import { findRouterApp, getRouterMachineInfo } from "../../../util/discovery.js";
13
13
  import { resolveOrg } from "../../../util/resolve.js";
@@ -126,9 +126,7 @@ const stageSummary = (out, ctx, deployConfig) => {
126
126
  }
127
127
  out.blank()
128
128
  .header("=".repeat(50))
129
- .header(hasIssues
130
- ? " Deploy Completed (with Warnings)"
131
- : " Deploy Completed!")
129
+ .header(hasIssues ? " Deploy Completed (with Warnings)" : " Deploy Completed!")
132
130
  .header("=".repeat(50))
133
131
  .blank()
134
132
  .text(`App '${ctx.app}' Is Reachable from Your Tailnet as:`)
@@ -1 +1 @@
1
- {"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/deploy/machine.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAChD,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACvB,MAAM,2BAA2B,CAAC;AAEnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAM/C,MAAM,MAAM,WAAW,GACnB,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,OAAO,GACP,UAAU,CAAC;AAMf,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE;QACL,mBAAmB,EAAE,MAAM,CAAC;QAC5B,aAAa,EAAE,MAAM,CAAC;QACtB,mBAAmB,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACjE,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,SAAS,EAAE;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;CACH,CAAC;AAEF,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,OAAO,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,YAAY,CAAC;IAC3B,aAAa,EAAE,iBAAiB,CAAC;IACjC,KAAK,CAAC,EAAE;QACN,mBAAmB,EAAE,MAAM,CAAC;QAC5B,aAAa,EAAE,MAAM,CAAC;QACtB,mBAAmB,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACjE,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;CACH;AAaD,eAAO,MAAM,mBAAmB,GAC9B,KAAK,MAAM,CAAC,YAAY,CAAC,EACzB,YAAY,WAAW,SAMxB,CAAC;AAMF,eAAO,MAAM,aAAa,GACxB,KAAK,SAAS,KACb,OAAO,CAAC,WAAW,CAMrB,CAAC;AAMF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,WAAW,EAClB,KAAK,SAAS,KACb,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CA2G7B,CAAC"}
1
+ {"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/deploy/machine.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAChD,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACvB,MAAM,2BAA2B,CAAC;AAEnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAM/C,MAAM,MAAM,WAAW,GACnB,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,OAAO,GACP,UAAU,CAAC;AAMf,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE;QACL,mBAAmB,EAAE,MAAM,CAAC;QAC5B,aAAa,EAAE,MAAM,CAAC;QACtB,mBAAmB,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACjE,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,SAAS,EAAE;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;CACH,CAAC;AAEF,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,OAAO,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,YAAY,CAAC;IAC3B,aAAa,EAAE,iBAAiB,CAAC;IACjC,KAAK,CAAC,EAAE;QACN,mBAAmB,EAAE,MAAM,CAAC;QAC5B,aAAa,EAAE,MAAM,CAAC;QACtB,mBAAmB,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACjE,QAAQ,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;CACH;AAaD,eAAO,MAAM,mBAAmB,GAC9B,KAAK,MAAM,CAAC,YAAY,CAAC,EACzB,YAAY,WAAW,SAMxB,CAAC;AAMF,eAAO,MAAM,aAAa,GACxB,KAAK,SAAS,KACb,OAAO,CAAC,WAAW,CAMrB,CAAC;AAMF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,WAAW,EAClB,KAAK,SAAS,KACb,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CA6G7B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/destroy/app.ts"],"names":[],"mappings":"AAiLA,eAAO,MAAM,UAAU,GAAU,MAAM,MAAM,EAAE,KAAG,OAAO,CAAC,IAAI,CAoF7D,CAAC"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/destroy/app.ts"],"names":[],"mappings":"AAkLA,eAAO,MAAM,UAAU,GAAU,MAAM,MAAM,EAAE,KAAG,OAAO,CAAC,IAAI,CAoF7D,CAAC"}
@@ -23,10 +23,12 @@ ${bold("SUBCOMMANDS")}
23
23
  app Destroy a workload app on a network
24
24
 
25
25
  ${bold("OPTIONS")}
26
+ --manual Skip automatic Tailscale ACL cleanup (network subcommand)
26
27
  --help Show help for a subcommand
27
28
 
28
29
  ${bold("EXAMPLES")}
29
30
  ambit destroy network browsers
31
+ ambit destroy network browsers --yes
30
32
  ambit destroy app my-app.browsers
31
33
  ambit destroy app my-app --network browsers
32
34
 
@@ -1 +1 @@
1
- {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/destroy/network.ts"],"names":[],"mappings":"AAwQA,eAAO,MAAM,cAAc,GAAU,MAAM,MAAM,EAAE,KAAG,OAAO,CAAC,IAAI,CAkDjE,CAAC"}
1
+ {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/destroy/network.ts"],"names":[],"mappings":"AAuVA,eAAO,MAAM,cAAc,GAAU,MAAM,MAAM,EAAE,KAAG,OAAO,CAAC,IAAI,CA0DjE,CAAC"}
@@ -7,7 +7,9 @@ import { checkArgs } from "../../../lib/args.js";
7
7
  import { createOutput } from "../../../lib/output.js";
8
8
  import { Result } from "../../../lib/result.js";
9
9
  import { runMachine } from "../../../lib/machine.js";
10
- import { findRouterApp, listWorkloadAppsOnNetwork, } from "../../../util/discovery.js";
10
+ import { findRouterApp, listWorkloadAppsOnNetwork } from "../../../util/discovery.js";
11
+ import { getRouterTag } from "../../../util/naming.js";
12
+ import { isAutoApproverConfigured, isTagOwnerConfigured, unpatchAutoApprover, unpatchTagOwner, } from "../../../util/tailscale-local.js";
11
13
  import { initSession } from "../../../util/session.js";
12
14
  // =============================================================================
13
15
  // Phase Labels
@@ -17,6 +19,7 @@ const DESTROY_NETWORK_PHASES = [
17
19
  { phase: "clear_dns", label: "Split DNS Cleared" },
18
20
  { phase: "remove_device", label: "Tailscale Device Removed" },
19
21
  { phase: "delete_app", label: "Fly App Destroyed" },
22
+ { phase: "clean_acl", label: "ACL Policy Cleaned" },
20
23
  ];
21
24
  const reportSkipped = (out, startPhase) => {
22
25
  for (const { phase, label } of DESTROY_NETWORK_PHASES) {
@@ -107,6 +110,48 @@ const destroyNetworkTransition = async (phase, ctx) => {
107
110
  else {
108
111
  ctx.out.skip("Fly App Already Destroyed");
109
112
  }
113
+ return Result.ok(ctx.manual ? "complete" : "clean_acl");
114
+ }
115
+ case "clean_acl": {
116
+ const tag = ctx.tag || getRouterTag(ctx.network);
117
+ let policy = await ctx.tailscale.acl.getPolicy();
118
+ if (!policy) {
119
+ ctx.out.skip("Could Not Read ACL Policy");
120
+ return Result.ok("complete");
121
+ }
122
+ let changed = false;
123
+ if (isTagOwnerConfigured(policy, tag)) {
124
+ policy = unpatchTagOwner(policy, tag);
125
+ changed = true;
126
+ }
127
+ if (isAutoApproverConfigured(policy, tag)) {
128
+ policy = unpatchAutoApprover(policy, tag);
129
+ changed = true;
130
+ }
131
+ if (changed) {
132
+ const validateResult = await ctx.tailscale.acl
133
+ .validatePolicy(policy);
134
+ if (!validateResult.ok) {
135
+ ctx.out.warn(`ACL Policy Validation Failed — Skipping Cleanup: ${validateResult.error ?? `HTTP ${validateResult.status}`}`);
136
+ return Result.ok("complete");
137
+ }
138
+ const spinner = ctx.out.spinner(`Removing ${tag} from ACL policy`);
139
+ const result = await ctx.tailscale.acl.setPolicy(policy);
140
+ if (!result.ok) {
141
+ spinner.fail(`Removing ${tag} from ACL policy`);
142
+ if (result.status === 403) {
143
+ ctx.out.warn("Your API Token Lacks ACL Write Permission. Re-run with --manual to Skip ACL Changes");
144
+ }
145
+ else {
146
+ ctx.out.warn(`Failed to Update ACL Policy: ${result.error ?? `HTTP ${result.status}`}`);
147
+ }
148
+ return Result.ok("complete");
149
+ }
150
+ spinner.success(`Removed ${tag} from ACL policy`);
151
+ }
152
+ else {
153
+ ctx.out.skip(`No ACL Entries Found for ${tag}`);
154
+ }
110
155
  return Result.ok("complete");
111
156
  }
112
157
  default:
@@ -147,7 +192,14 @@ const stageSummary = (out, ctx) => {
147
192
  workloadAppsWarned: 0,
148
193
  });
149
194
  out.ok("Router Destroyed");
150
- if (ctx.tag) {
195
+ const tag = ctx.tag || getRouterTag(ctx.network);
196
+ if (!ctx.manual) {
197
+ out.blank()
198
+ .dim("If You Added ACL Rules Referencing This Router, Remember to Remove:")
199
+ .dim(` acls: rules referencing ${tag}`)
200
+ .blank();
201
+ }
202
+ else if (ctx.tag) {
151
203
  out.blank()
152
204
  .dim("If You Added ACL Policy Entries for This Router, Remember to Remove:")
153
205
  .dim(` tagOwners: ${ctx.tag}`)
@@ -169,7 +221,7 @@ const stageSummary = (out, ctx) => {
169
221
  export const destroyNetwork = async (argv) => {
170
222
  const opts = {
171
223
  string: ["network", "org"],
172
- boolean: ["help", "yes", "json"],
224
+ boolean: ["help", "yes", "json", "manual"],
173
225
  alias: { y: "yes" },
174
226
  };
175
227
  const args = parseArgs(argv, opts);
@@ -183,17 +235,25 @@ ${bold("USAGE")}
183
235
 
184
236
  ${bold("OPTIONS")}
185
237
  --org <org> Fly.io organization slug
238
+ --manual Skip automatic Tailscale ACL cleanup (tagOwners + autoApprovers)
186
239
  -y, --yes Skip confirmation prompts
187
240
  --json Output as JSON
188
241
 
242
+ ${bold("DESCRIPTION")}
243
+ By default, ambit removes the router's tag from your Tailscale ACL
244
+ policy (tagOwners and autoApprovers). Use --manual if your API token
245
+ lacks ACL write permission or you prefer to manage the policy yourself.
246
+
189
247
  ${bold("EXAMPLES")}
190
248
  ambit destroy network browsers
191
- ambit destroy network browsers --org my-org --yes
249
+ ambit destroy network browsers --yes
250
+ ambit destroy network browsers --manual --org my-org
192
251
  `);
193
252
  return;
194
253
  }
195
254
  const out = createOutput(args.json);
196
- const network = (typeof args._[0] === "string" ? args._[0] : undefined) || args.network;
255
+ const network = (typeof args._[0] === "string" ? args._[0] : undefined) ||
256
+ args.network;
197
257
  if (!network) {
198
258
  return out.die("Network Name Required. Usage: ambit destroy network <name>");
199
259
  }
@@ -206,5 +266,6 @@ ${bold("EXAMPLES")}
206
266
  org,
207
267
  yes: args.yes,
208
268
  json: args.json,
269
+ manual: !!args.manual,
209
270
  });
210
271
  };