@cardelli/ambit 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +38 -22
  2. package/esm/cli/commands/create/index.js +71 -20
  3. package/esm/cli/commands/create/machine.d.ts.map +1 -1
  4. package/esm/cli/commands/create/machine.js +4 -2
  5. package/esm/cli/commands/deploy/index.js +2 -4
  6. package/esm/cli/commands/deploy/machine.d.ts.map +1 -1
  7. package/esm/cli/commands/deploy/machine.js +4 -2
  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/util/credentials.d.ts.map +1 -1
  32. package/esm/util/credentials.js +1 -1
  33. package/esm/util/discovery.d.ts.map +1 -1
  34. package/esm/util/discovery.js +1 -1
  35. package/esm/util/tailscale-local.d.ts +41 -0
  36. package/esm/util/tailscale-local.d.ts.map +1 -1
  37. package/esm/util/tailscale-local.js +146 -0
  38. package/esm/util/template.d.ts.map +1 -1
  39. 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
15
  import { FLY_PRIVATE_SUBNET, 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, patchAutoApprover, 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}"`)
@@ -75,28 +99,48 @@ const stageTailscaleConfig = async (out, opts) => {
75
99
  .text(` you're a part of in the Tailscale dashboard:`)
76
100
  .link(" https://login.tailscale.com/admin/acls/visual/tags")
77
101
  .blank()
78
- .dim(" Or you can do it manually in the JSON editor:")
79
- .link(" https://login.tailscale.com/admin/acls/file")
102
+ .dim(" Or you can do it manually with this JSON config:")
80
103
  .dim(` "tagOwners": { "${opts.tag}": ["autogroup:admin"] }`)
81
104
  .blank();
82
105
  return out.die(`Set Up ${opts.tag} in Tailscale, Then Try Again`);
83
106
  }
84
- tagOwnerSpinner.success(`${opts.tag} Found in Tailscale ACL`);
85
- if (opts.json) {
107
+ else {
108
+ tagOwnerSpinner.success(`${opts.tag} Found in Tailscale ACL`);
109
+ }
110
+ if (!opts.manual) {
111
+ const hasApprover = isAutoApproverConfigured(policy, opts.tag);
112
+ if (!hasApprover && policy) {
113
+ const beforeApprover = policy;
114
+ policy = patchAutoApprover(policy, opts.tag, FLY_PRIVATE_SUBNET);
115
+ assertAdditivePatch(beforeApprover, policy);
116
+ const validateApprover = await tailscale.acl.validatePolicy(policy);
117
+ if (!validateApprover.ok) {
118
+ return handleAclSetFailure(out, validateApprover, `Validating autoApprover patch for ${opts.tag}`);
119
+ }
120
+ const approverSpinner = out.spinner(`Adding autoApprover for ${opts.tag}`);
121
+ const result = await tailscale.acl.setPolicy(policy);
122
+ if (!result.ok) {
123
+ approverSpinner.fail(`Adding autoApprover for ${opts.tag}`);
124
+ return handleAclSetFailure(out, result, `Adding autoApprover for ${opts.tag}`);
125
+ }
126
+ approverSpinner.success(`Added autoApprover for ${opts.tag}`);
127
+ }
128
+ }
129
+ else if (opts.json) {
86
130
  const approverSpinner = out.spinner(`Checking autoApprovers for ${opts.tag}`);
87
131
  const hasApprover = isAutoApproverConfigured(policy, opts.tag);
88
132
  if (!hasApprover) {
89
133
  approverSpinner.fail(`Auto-approve Not Configured for ${opts.tag}`);
90
134
  out.blank()
91
- .text(" In JSON mode, ambit can't interactively approve subnet routes.")
92
- .text(` Add an autoApprovers rule so Tailscale automatically trusts`)
93
- .text(` routes advertised by ${opts.tag}:`)
94
- .blank()
95
- .dim(" https://login.tailscale.com/admin/acls/file")
135
+ .text(" In JSON mode, ambit can't interactively approve the router's")
136
+ .text(` network connections. You can set this up from the Tailscale dashboard:`)
137
+ .link(" https://login.tailscale.com/admin/acls/visual/auto-approvers")
138
+ .dim(` Route: ${FLY_PRIVATE_SUBNET} Owner: ${opts.tag}`)
96
139
  .blank()
140
+ .dim(" Or you can do it manually with this JSON config:")
97
141
  .dim(` "autoApprovers": { "routes": { "${FLY_PRIVATE_SUBNET}": ["${opts.tag}"] } }`)
98
142
  .blank();
99
- return out.die(`Add autoApprovers for ${opts.tag} to Use --json`);
143
+ return out.die(`Set Up Auto-approve for ${opts.tag} to Use --json`);
100
144
  }
101
145
  approverSpinner.success(`Auto-approve Configured for ${opts.tag}`);
102
146
  }
@@ -184,7 +228,6 @@ const stageSummary = async (out, fly, tailscale, ctx, opts) => {
184
228
  .dim(` Route: ${ctx.subnet} Owner: ${opts.tag}`)
185
229
  .blank()
186
230
  .dim(" Or you can do it manually with this JSON config:")
187
- .link(" https://login.tailscale.com/admin/acls/file")
188
231
  .dim(` "autoApprovers": { "routes": { "${ctx.subnet}": ["${opts.tag}"] } }`);
189
232
  if (opts.shouldApprove) {
190
233
  out.blank().dim(" Traffic Was Allowed via API for This Session.");
@@ -196,10 +239,10 @@ const stageSummary = async (out, fly, tailscale, ctx, opts) => {
196
239
  .blank()
197
240
  .dim(" You can do this from the Tailscale dashboard:")
198
241
  .link(" https://login.tailscale.com/admin/acls/visual/general-access-rules")
199
- .dim(` Source: group:YOUR_GROUP Destination: ${opts.tag}:*`)
242
+ .dim(` Source: group:YOUR_GROUP Destination: ${opts.tag}:53`)
243
+ .dim(` Source: group:YOUR_GROUP Destination: ${ctx.subnet}:*`)
200
244
  .blank()
201
245
  .dim(" Or you can do it manually with this JSON config:")
202
- .link(" https://login.tailscale.com/admin/acls/file")
203
246
  .dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${opts.tag}:53"]}`)
204
247
  .dim(` {"action": "accept", "src": ["group:YOUR_GROUP"], "dst": ["${ctx.subnet}:*"]}`)
205
248
  .blank();
@@ -217,7 +260,7 @@ const stageSummary = async (out, fly, tailscale, ctx, opts) => {
217
260
  const create = async (argv) => {
218
261
  const opts = {
219
262
  string: ["org", "region", "api-key", "tag"],
220
- boolean: ["help", "yes", "json", "no-auto-approve"],
263
+ boolean: ["help", "yes", "json", "no-auto-approve", "manual"],
221
264
  alias: { y: "yes" },
222
265
  };
223
266
  const args = parseArgs(argv, opts);
@@ -234,7 +277,8 @@ ${bold("OPTIONS")}
234
277
  --region <region> Fly.io region (default: iad)
235
278
  --api-key <key> Tailscale API access token (tskey-api-...)
236
279
  --tag <tag> Tailscale ACL tag for the router (default: tag:ambit-<network>)
237
- --no-auto-approve Skip waiting for router and approving routes
280
+ --manual Skip automatic Tailscale ACL configuration (tagOwners + autoApprovers)
281
+ --no-auto-approve Skip waiting for router and approving routes
238
282
  -y, --yes Skip confirmation prompts
239
283
  --json Output as JSON (implies --no-auto-approve)
240
284
 
@@ -244,9 +288,14 @@ ${bold("DESCRIPTION")}
244
288
 
245
289
  my-app.${args._[0] || "<network>"} resolves to my-app.flycast
246
290
 
291
+ By default, ambit auto-configures your Tailscale ACL policy (tagOwners
292
+ and autoApprovers). Use --manual if your API token lacks ACL write
293
+ permission or you prefer to manage the policy yourself.
294
+
247
295
  ${bold("EXAMPLES")}
248
296
  ambit create browsers
249
297
  ambit create browsers --org my-org --region sea
298
+ ambit create browsers --manual
250
299
  `);
251
300
  return;
252
301
  }
@@ -260,7 +309,8 @@ ${bold("EXAMPLES")}
260
309
  return out.die(`"${network}" Is a Public TLD and Cannot Be Used as a Network Name`);
261
310
  }
262
311
  const tag = args.tag || getRouterTag(network);
263
- const shouldApprove = !(args["no-auto-approve"] || args.json);
312
+ const manual = !!args.manual;
313
+ const shouldApprove = !manual || !(args["no-auto-approve"] || args.json);
264
314
  out.blank()
265
315
  .header("=".repeat(50))
266
316
  .header(` ambit Create: ${network}`)
@@ -273,6 +323,7 @@ ${bold("EXAMPLES")}
273
323
  });
274
324
  const tailscale = await stageTailscaleConfig(out, {
275
325
  json: args.json,
326
+ manual,
276
327
  apiKey: args["api-key"],
277
328
  tag,
278
329
  network,
@@ -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,CA8G7B,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;AAS7E,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,CAoH7B,CAAC"}
@@ -5,7 +5,7 @@ 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
10
  import { enableAcceptRoutes, isAcceptRoutesEnabled, isAutoApproverConfigured, isTailscaleInstalled, waitForDevice, } from "../../../util/tailscale-local.js";
11
11
  import { findRouterApp, getRouterMachineInfo } from "../../../util/discovery.js";
@@ -83,19 +83,21 @@ export const createTransition = async (phase, ctx) => {
83
83
  [SECRET_ROUTER_ID]: ctx.routerId,
84
84
  }, { stage: true }));
85
85
  const dockerDir = ROUTER_DOCKER_DIR;
86
+ const deploySpinner = ctx.out.spinner("Deploying Router to Fly.io");
86
87
  try {
87
88
  await ctx.fly.deploy.router(ctx.appName, dockerDir, {
88
89
  region: ctx.region,
89
90
  });
90
91
  }
91
92
  catch (e) {
93
+ deploySpinner.fail("Router Deploy Failed");
92
94
  if (e instanceof FlyDeployError) {
93
95
  ctx.out.dim(` ${e.detail}`);
94
96
  return Result.err(e.message);
95
97
  }
96
98
  throw e;
97
99
  }
98
- ctx.out.ok("Router Deployed");
100
+ deploySpinner.success("Router Deployed");
99
101
  const machines = await ctx.fly.machines.list(ctx.appName);
100
102
  const m = machines.find((m) => m.private_ip);
101
103
  if (m?.private_ip)
@@ -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,CAyG7B,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"}
@@ -67,11 +67,13 @@ export const deployTransition = async (phase, ctx) => {
67
67
  return Result.ok("deploy");
68
68
  }
69
69
  case "deploy": {
70
- ctx.out.blank().dim("Deploying with --no-public-ips --flycast ...");
70
+ ctx.out.blank();
71
+ const deploySpinner = ctx.out.spinner("Deploying to Fly.io");
71
72
  try {
72
73
  await ctx.fly.deploy.app(ctx.app, ctx.deployOptions);
73
74
  }
74
75
  catch (e) {
76
+ deploySpinner.fail("Deploy Failed");
75
77
  if (e instanceof FlyDeployError) {
76
78
  ctx.out.dim(` ${e.detail}`);
77
79
  return Result.err(e.message);
@@ -88,7 +90,7 @@ export const deployTransition = async (phase, ctx) => {
88
90
  }
89
91
  }
90
92
  }
91
- ctx.out.ok("Deploy Succeeded");
93
+ deploySpinner.success("Deploy Succeeded");
92
94
  return Result.ok("audit");
93
95
  }
94
96
  case "audit": {
@@ -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
  };
@@ -130,7 +130,9 @@ const stageSummary = (out, results) => {
130
130
  });
131
131
  }
132
132
  out.blank();
133
- out.text(issues === 0 ? "All Checks Passed." : `${issues} Issue${issues > 1 ? "s" : ""} Found.`);
133
+ out.text(issues === 0
134
+ ? "All Checks Passed."
135
+ : `${issues} Issue${issues > 1 ? "s" : ""} Found.`);
134
136
  out.blank();
135
137
  out.print();
136
138
  };
@@ -138,7 +140,10 @@ const stageSummary = (out, results) => {
138
140
  // Doctor Network
139
141
  // =============================================================================
140
142
  const doctorNetwork = async (argv) => {
141
- const opts = { string: ["network", "org"], boolean: ["help", "json"] };
143
+ const opts = {
144
+ string: ["network", "org"],
145
+ boolean: ["help", "json"],
146
+ };
142
147
  const args = parseArgs(argv, opts);
143
148
  checkArgs(args, opts, "ambit doctor network");
144
149
  if (args.help) {
@@ -168,7 +173,8 @@ ${bold("CHECKS")}
168
173
  out.blank().header("ambit Doctor: Network").blank();
169
174
  const results = [];
170
175
  const report = makeReporter(results, out);
171
- const network = (typeof args._[0] === "string" ? args._[0] : undefined) || args.network;
176
+ const network = (typeof args._[0] === "string" ? args._[0] : undefined) ||
177
+ args.network;
172
178
  const { fly, tailscale, org } = await initSession(out, {
173
179
  json: args.json,
174
180
  org: args.org,
@@ -181,7 +187,10 @@ ${bold("CHECKS")}
181
187
  // Doctor App
182
188
  // =============================================================================
183
189
  const doctorApp = async (argv) => {
184
- const opts = { string: ["network", "org"], boolean: ["help", "json"] };
190
+ const opts = {
191
+ string: ["network", "org"],
192
+ boolean: ["help", "json"],
193
+ };
185
194
  const args = parseArgs(argv, opts);
186
195
  checkArgs(args, opts, "ambit doctor app");
187
196
  if (args.help) {
@@ -7,7 +7,7 @@ import { bold } from "../../lib/cli.js";
7
7
  import { checkArgs } from "../../lib/args.js";
8
8
  import { createOutput } from "../../lib/output.js";
9
9
  import { registerCommand } from "../mod.js";
10
- import { discoverRouters, } from "../../util/discovery.js";
10
+ import { discoverRouters } from "../../util/discovery.js";
11
11
  import { initSession } from "../../util/session.js";
12
12
  // =============================================================================
13
13
  // Stage: Render
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=share.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/share.ts"],"names":[],"mappings":""}