@cardelli/ambit 0.1.1 → 0.1.2

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.
@@ -7,7 +7,7 @@ import { createOutput } from "../../../lib/output.js";
7
7
  import { registerCommand } from "../mod.js";
8
8
  import { extractSubnet, getRouterTag } from "../../schemas/config.js";
9
9
  import { isPublicTld } from "../../guard.js";
10
- import { createFlyProvider, FlyDeployError, getRouterAppName } from "../../providers/fly.js";
10
+ import { createFlyProvider, FlyDeployError, getRouterAppName, } from "../../providers/fly.js";
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";
@@ -50,10 +50,11 @@ ${bold("EXAMPLES")}
50
50
  return;
51
51
  }
52
52
  const out = createOutput(args.json);
53
- const network = args._[0];
54
- if (!network) {
53
+ const networkArg = args._[0];
54
+ if (!networkArg || typeof networkArg !== "string") {
55
55
  return out.die("Network Name Required. Usage: ambit create <network>");
56
56
  }
57
+ const network = networkArg;
57
58
  if (isPublicTld(network)) {
58
59
  return out.die(`"${network}" Is a Public TLD and Cannot Be Used as a Network Name`);
59
60
  }
@@ -3,6 +3,7 @@
3
3
  // =============================================================================
4
4
  import * as dntShim from "../../../_dnt.shims.js";
5
5
  import { parseArgs } from "../../../deps/jsr.io/@std/cli/1.0.27/mod.js";
6
+ import { join } from "../../../deps/jsr.io/@std/path/1.1.4/mod.js";
6
7
  import { bold, confirm, fileExists } from "../../../lib/cli.js";
7
8
  import { createOutput } from "../../../lib/output.js";
8
9
  import { registerCommand } from "../mod.js";
@@ -11,13 +12,93 @@ import { findRouterApp } from "../../discovery.js";
11
12
  import { resolveOrg } from "../../resolve.js";
12
13
  import { assertNotRouter, auditDeploy, scanFlyToml } from "../../guard.js";
13
14
  // =============================================================================
15
+ // Image Mode
16
+ // =============================================================================
17
+ /**
18
+ * Generate a minimal fly.toml with http_service config for auto start/stop.
19
+ * Written to a temp directory and cleaned up after deploy.
20
+ */
21
+ const generateServiceToml = (port) => `[http_service]\n` +
22
+ ` internal_port = ${port}\n` +
23
+ ` auto_stop_machines = "stop"\n` +
24
+ ` auto_start_machines = true\n` +
25
+ ` min_machines_running = 0\n`;
26
+ /**
27
+ * Parse --main-port value. Returns the port number, or null if "none".
28
+ * Dies on invalid input.
29
+ */
30
+ const parseMainPort = (raw, out) => {
31
+ if (raw === "none")
32
+ return null;
33
+ const port = Number(raw);
34
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
35
+ out.die(`Invalid --main-port: "${raw}". Use a port number (1-65535) or "none".`);
36
+ return "error";
37
+ }
38
+ return port;
39
+ };
40
+ /** Resolve deploy config for image mode (--image). */
41
+ const resolveImageMode = (image, mainPortRaw, out) => {
42
+ const mainPort = parseMainPort(mainPortRaw, out);
43
+ if (mainPort === "error")
44
+ return null;
45
+ const preflight = { scanned: false, warnings: [] };
46
+ if (mainPort !== null) {
47
+ const tempDir = dntShim.Deno.makeTempDirSync();
48
+ const configPath = join(tempDir, "fly.toml");
49
+ dntShim.Deno.writeTextFileSync(configPath, generateServiceToml(mainPort));
50
+ out.ok(`HTTP Service on Port ${mainPort} (auto start/stop)`);
51
+ return { image, configPath, preflight, tempDir };
52
+ }
53
+ out.info("Image Mode — No Service Config");
54
+ return { image, preflight };
55
+ };
56
+ // =============================================================================
57
+ // Config Mode
58
+ // =============================================================================
59
+ /** Resolve deploy config for config mode (default — uses fly.toml). */
60
+ const resolveConfigMode = async (explicitConfig, out) => {
61
+ // Determine config path: explicit --config, or auto-detect ./fly.toml
62
+ let configPath = explicitConfig;
63
+ if (!configPath && await fileExists("./fly.toml")) {
64
+ configPath = "./fly.toml";
65
+ }
66
+ if (!configPath) {
67
+ out.info("No fly.toml Found — Deploying Without Config Scan");
68
+ return { preflight: { scanned: false, warnings: [] } };
69
+ }
70
+ if (!(await fileExists(configPath))) {
71
+ out.die(`Config File Not Found: ${configPath}`);
72
+ return null;
73
+ }
74
+ // Pre-flight scan
75
+ const tomlContent = await dntShim.Deno.readTextFile(configPath);
76
+ const scan = scanFlyToml(tomlContent);
77
+ if (scan.errors.length > 0) {
78
+ for (const err of scan.errors) {
79
+ out.err(err);
80
+ }
81
+ out.die("Pre-flight Check Failed. Fix fly.toml Before Deploying.");
82
+ return null;
83
+ }
84
+ for (const warn of scan.warnings) {
85
+ out.warn(warn);
86
+ }
87
+ out.ok(`Scanned ${configPath}`);
88
+ return {
89
+ configPath,
90
+ preflight: { scanned: scan.scanned, warnings: scan.warnings },
91
+ };
92
+ };
93
+ // =============================================================================
14
94
  // Deploy Command
15
95
  // =============================================================================
16
96
  const deploy = async (argv) => {
17
97
  const args = parseArgs(argv, {
18
- string: ["network", "org", "region", "image", "config"],
98
+ string: ["network", "org", "region", "image", "config", "main-port"],
19
99
  boolean: ["help", "yes", "json"],
20
100
  alias: { y: "yes" },
101
+ default: { "main-port": "80" },
21
102
  });
22
103
  if (args.help) {
23
104
  console.log(`
@@ -35,13 +116,14 @@ ${bold("MODES")}
35
116
  ambit deploy <app> --network <name> --image <img> Docker image, no toml
36
117
 
37
118
  ${bold("OPTIONS")}
38
- --network <name> Custom 6PN network to target (required)
39
- --org <org> Fly.io organization slug
40
- --region <region> Primary deployment region
41
- --image <img> Docker image (mutually exclusive with --config)
42
- --config <path> fly.toml path (mutually exclusive with --image)
43
- -y, --yes Skip confirmation prompts
44
- --json Output as JSON
119
+ --network <name> Custom 6PN network to target (required)
120
+ --org <org> Fly.io organization slug
121
+ --region <region> Primary deployment region
122
+ --image <img> Docker image (mutually exclusive with --config)
123
+ --config <path> fly.toml path (mutually exclusive with --image)
124
+ --main-port <port> Internal port for HTTP service in image mode (default: 80, "none" to skip)
125
+ -y, --yes Skip confirmation prompts
126
+ --json Output as JSON
45
127
 
46
128
  ${bold("SAFETY")}
47
129
  Always deploys with --no-public-ips and --flycast.
@@ -59,10 +141,11 @@ ${bold("EXAMPLES")}
59
141
  // ==========================================================================
60
142
  // Phase 0: Parse & Validate
61
143
  // ==========================================================================
62
- const app = args._[0];
63
- if (!app) {
144
+ const appArg = args._[0];
145
+ if (!appArg || typeof appArg !== "string") {
64
146
  return out.die("App Name Required. Usage: ambit deploy <app> --network <name>");
65
147
  }
148
+ const app = appArg;
66
149
  if (!args.network) {
67
150
  return out.die("--network Is Required");
68
151
  }
@@ -70,7 +153,7 @@ ${bold("EXAMPLES")}
70
153
  assertNotRouter(app);
71
154
  }
72
155
  catch (e) {
73
- return out.die(e.message);
156
+ return out.die(e instanceof Error ? e.message : String(e));
74
157
  }
75
158
  if (args.image && args.config) {
76
159
  return out.die("--image and --config Are Mutually Exclusive");
@@ -128,44 +211,14 @@ ${bold("EXAMPLES")}
128
211
  }
129
212
  out.blank();
130
213
  // ==========================================================================
131
- // Phase 4: Pre-flight TOML Scan
214
+ // Phase 4: Resolve Deploy Mode
132
215
  // ==========================================================================
133
216
  out.header("Step 4: Pre-flight Check").blank();
134
- let preflight = { scanned: false, warnings: [] };
135
- // Determine config path for config mode (not image mode)
136
- let configPath = args.config;
137
- if (!args.image) {
138
- if (!configPath) {
139
- // Auto-detect ./fly.toml
140
- if (await fileExists("./fly.toml")) {
141
- configPath = "./fly.toml";
142
- }
143
- }
144
- if (configPath) {
145
- if (!(await fileExists(configPath))) {
146
- return out.die(`Config File Not Found: ${configPath}`);
147
- }
148
- const tomlContent = await dntShim.Deno.readTextFile(configPath);
149
- const scan = scanFlyToml(tomlContent);
150
- preflight = { scanned: scan.scanned, warnings: scan.warnings };
151
- if (scan.errors.length > 0) {
152
- for (const err of scan.errors) {
153
- out.err(err);
154
- }
155
- return out.die("Pre-flight Check Failed. Fix fly.toml Before Deploying.");
156
- }
157
- for (const warn of scan.warnings) {
158
- out.warn(warn);
159
- }
160
- out.ok(`Scanned ${configPath}`);
161
- }
162
- else {
163
- out.info("No fly.toml Found — Deploying Without Config Scan");
164
- }
165
- }
166
- else {
167
- out.info("Image Mode — Skipping TOML Scan");
168
- }
217
+ const deployConfig = args.image
218
+ ? resolveImageMode(args.image, String(args["main-port"]), out)
219
+ : await resolveConfigMode(args.config, out);
220
+ if (!deployConfig)
221
+ return; // mode resolver already called out.die()
169
222
  out.blank();
170
223
  // ==========================================================================
171
224
  // Phase 5: Deploy with Enforced Flags
@@ -174,8 +227,8 @@ ${bold("EXAMPLES")}
174
227
  out.dim("Deploying with --no-public-ips --flycast ...");
175
228
  try {
176
229
  await fly.deploySafe(app, {
177
- image: args.image,
178
- config: configPath,
230
+ image: deployConfig.image,
231
+ config: deployConfig.configPath,
179
232
  region: args.region,
180
233
  });
181
234
  }
@@ -186,6 +239,14 @@ ${bold("EXAMPLES")}
186
239
  }
187
240
  throw e;
188
241
  }
242
+ finally {
243
+ if (deployConfig.tempDir) {
244
+ try {
245
+ dntShim.Deno.removeSync(deployConfig.tempDir, { recursive: true });
246
+ }
247
+ catch { /* ignore */ }
248
+ }
249
+ }
189
250
  out.ok("Deploy Succeeded");
190
251
  out.blank();
191
252
  // ==========================================================================
@@ -211,33 +272,23 @@ ${bold("EXAMPLES")}
211
272
  // Phase 7: Result
212
273
  // ==========================================================================
213
274
  const hasIssues = audit.public_ips_released > 0 || audit.warnings.length > 0;
275
+ const result = {
276
+ app,
277
+ network,
278
+ created,
279
+ audit: {
280
+ public_ips_released: audit.public_ips_released,
281
+ certs_removed: audit.certs_removed,
282
+ flycast_allocations: audit.flycast_allocations,
283
+ warnings: audit.warnings,
284
+ },
285
+ preflight: deployConfig.preflight,
286
+ };
214
287
  if (hasIssues) {
215
- out.fail("Deploy Completed with Issues", {
216
- app,
217
- network,
218
- created,
219
- audit: {
220
- public_ips_released: audit.public_ips_released,
221
- certs_removed: audit.certs_removed,
222
- flycast_allocations: audit.flycast_allocations,
223
- warnings: audit.warnings,
224
- },
225
- preflight,
226
- });
288
+ out.fail("Deploy Completed with Issues", result);
227
289
  }
228
290
  else {
229
- out.done({
230
- app,
231
- network,
232
- created,
233
- audit: {
234
- public_ips_released: audit.public_ips_released,
235
- certs_removed: audit.certs_removed,
236
- flycast_allocations: audit.flycast_allocations,
237
- warnings: audit.warnings,
238
- },
239
- preflight,
240
- });
291
+ out.done(result);
241
292
  }
242
293
  out.blank()
243
294
  .header("=".repeat(50))
@@ -78,9 +78,7 @@ const showNetworkStatus = async (fly, tailscale, args) => {
78
78
  .text(` Region: ${machine?.region ?? "unknown"}`)
79
79
  .text(` Machine State: ${machine?.state ?? "unknown"}`)
80
80
  .text(` Private IP: ${machine?.privateIp ?? "unknown"}`)
81
- .text(` SOCKS Proxy: ${machine?.privateIp
82
- ? `socks5://[${machine.privateIp}]:1080`
83
- : "unknown"}`);
81
+ .text(` SOCKS Proxy: ${machine?.privateIp ? `socks5://[${machine.privateIp}]:1080` : "unknown"}`);
84
82
  if (machine?.subnet) {
85
83
  out.text(` Subnet: ${machine.subnet}`);
86
84
  }
@@ -65,7 +65,7 @@ export const runCli = async (argv) => {
65
65
  showVersion();
66
66
  return;
67
67
  }
68
- const commandName = args._[0];
68
+ const commandName = typeof args._[0] === "string" ? args._[0] : undefined;
69
69
  if (!commandName || args.help) {
70
70
  if (commandName) {
71
71
  const command = getCommand(commandName);
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/src/schemas/config.ts"],"names":[],"mappings":"AAOA,OAAO,yBAAyB,CAAC;AAKjC,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,MACvB,CAAC;AAEzB,eAAO,MAAM,aAAa,GAAI,WAAW,MAAM,KAAG,MAKjD,CAAC;AAMF,eAAO,MAAM,YAAY,QAAO,MAG/B,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/src/schemas/config.ts"],"names":[],"mappings":"AAOA,OAAO,yBAAyB,CAAC;AAKjC,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,MAAgC,CAAC;AAEhF,eAAO,MAAM,aAAa,GAAI,WAAW,MAAM,KAAG,MAKjD,CAAC;AAMF,eAAO,MAAM,YAAY,QAAO,MAG/B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardelli/ambit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Tailscale subnet router manager for Fly.io custom networks",
5
5
  "license": "MIT",
6
6
  "module": "./esm/src/providers/fly.js",