ghostdock 0.0.15 → 0.0.17

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.
package/dist/index.js CHANGED
@@ -863,6 +863,14 @@ var GENERATED_TOOL_CATALOG = [
863
863
  description: "Mark the sandbox ephemeral: every stop destroys the machine and the next start boots fresh, while the record, URLs, and settings stay. Omit to use the image label ghostdock.ephemeral, or false when that label is absent. Does not change idle-stop or auto-delete.",
864
864
  type: "boolean"
865
865
  },
866
+ imageBase: {
867
+ description: "How the disk is made for one of Ghostdock's VM images: auto (default) clones the host's image base, a disk with the image already on it, when there is one; off always starts from an empty disk that pulls the image. For tests and comparisons; other images always start empty.",
868
+ type: "string",
869
+ enum: [
870
+ "auto",
871
+ "off"
872
+ ]
873
+ },
866
874
  urls: {
867
875
  description: "Changes to the image’s or snapshot’s URLs, keyed by port. Omit for the image URLs as they are; otherwise you get the image URLs plus yours: an entry on the same port replaces that URL completely (omitted fields take their defaults), and { port, remove: true } drops it; removing a port the image does not declare does nothing. A required port cannot be removed. TCP URLs are always public.",
868
876
  maxItems: 100,
@@ -9176,7 +9184,7 @@ function getCliName(argv = process.argv) {
9176
9184
  return CLI_NAMES.find((name) => name === invoked) ?? "ghostdock";
9177
9185
  }
9178
9186
  // package.json
9179
- var version = "0.0.15";
9187
+ var version = "0.0.17";
9180
9188
  var package_default = {
9181
9189
  name: "ghostdock",
9182
9190
  description: "Ghostdock CLI — device login and agent tool invocation from the command line",
@@ -9284,6 +9292,376 @@ async function clearCredentials(path = getCredentialsPath()) {
9284
9292
  await rm(path, { force: true });
9285
9293
  }
9286
9294
 
9295
+ // ../../shared/lib/private-network-pause.ts
9296
+ function formatPauseLimit(seconds) {
9297
+ const minutes = Math.max(1, Math.round(seconds / 60));
9298
+ if (minutes >= 2 * 1440 && minutes % 1440 === 0)
9299
+ return `${minutes / 1440} days`;
9300
+ const hours = Math.floor(minutes / 60);
9301
+ const rest = minutes % 60;
9302
+ if (hours === 0)
9303
+ return `${rest} min`;
9304
+ return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`;
9305
+ }
9306
+ function devicePauseReasonText(reason, limits) {
9307
+ switch (reason) {
9308
+ case "idle":
9309
+ return `idle ${formatPauseLimit(limits.idleTimeoutSeconds)}`;
9310
+ case "max_session":
9311
+ return `${formatPauseLimit(limits.maxSessionSeconds)} session limit`;
9312
+ case "manual":
9313
+ return "paused in the web app";
9314
+ }
9315
+ }
9316
+
9317
+ // src/output.ts
9318
+ var SYMBOLS = {
9319
+ ready: "⚓",
9320
+ done: "✓",
9321
+ url: "↗",
9322
+ stopped: "◌",
9323
+ failed: "✗",
9324
+ progress: "▂▄▆",
9325
+ item: "•"
9326
+ };
9327
+ var jsonOutput = false;
9328
+ function setJsonOutput(on) {
9329
+ jsonOutput = on;
9330
+ }
9331
+ function isJsonOutput() {
9332
+ return jsonOutput;
9333
+ }
9334
+ function colorEnabled(stream, env = process.env) {
9335
+ if (env.FORCE_COLOR !== undefined)
9336
+ return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
9337
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
9338
+ return false;
9339
+ if (env.TERM === "dumb")
9340
+ return false;
9341
+ return stream.isTTY === true;
9342
+ }
9343
+ function trueColor(env = process.env) {
9344
+ return env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
9345
+ }
9346
+ function accent(text, enabled) {
9347
+ if (!enabled)
9348
+ return text;
9349
+ const open = trueColor() ? "\x1B[38;2;124;255;203m" : "\x1B[38;5;122m";
9350
+ return `${open}${text}\x1B[39m`;
9351
+ }
9352
+ function red(text, enabled) {
9353
+ return enabled ? `\x1B[31m${text}\x1B[39m` : text;
9354
+ }
9355
+ function dim(text, enabled) {
9356
+ return enabled ? `\x1B[2m${text}\x1B[22m` : text;
9357
+ }
9358
+ function eventLine(symbol, message, color = colorEnabled(process.stdout)) {
9359
+ return ` ${accent(SYMBOLS[symbol], color)} ${message}`;
9360
+ }
9361
+ function errorLine(message, color = colorEnabled(process.stderr)) {
9362
+ return red(` ${SYMBOLS.failed} ${message}`, color);
9363
+ }
9364
+ function printNotice(message) {
9365
+ process.stderr.write(`${message}
9366
+ `);
9367
+ }
9368
+ function printError(message) {
9369
+ process.stderr.write(`${errorLine(message)}
9370
+ `);
9371
+ }
9372
+ function printResult(data, human, log = console.log) {
9373
+ if (jsonOutput) {
9374
+ log(JSON.stringify(data, null, 2));
9375
+ return;
9376
+ }
9377
+ for (const line of human())
9378
+ log(line);
9379
+ }
9380
+
9381
+ // src/network/pause.ts
9382
+ var DEVICE_HEADER = "ghostdock-device";
9383
+ var DEVICE_STATE_HEADER = "ghostdock-device-state";
9384
+ function parseDeviceStateHeader(value) {
9385
+ if (!value)
9386
+ return null;
9387
+ const [state, ...params] = value.split(";").map((part) => part.trim());
9388
+ if (state !== "paused")
9389
+ return null;
9390
+ const fields = new Map(params.map((param) => {
9391
+ const [key, ...rest] = param.split("=");
9392
+ return [key?.trim() ?? "", rest.join("=").trim()];
9393
+ }));
9394
+ const reason = fields.get("reason");
9395
+ if (reason !== "idle" && reason !== "max_session" && reason !== "manual")
9396
+ return null;
9397
+ const limit = Number(fields.get("limit"));
9398
+ return { reason, limitSeconds: Number.isInteger(limit) && limit > 0 ? limit : null };
9399
+ }
9400
+ function pausedNotice(paused) {
9401
+ const limit = paused.limitSeconds ?? 0;
9402
+ const why = paused.reason === "manual" || limit > 0 ? ` (${devicePauseReasonText(paused.reason, { idleTimeoutSeconds: limit, maxSessionSeconds: limit })})` : "";
9403
+ return `Network access for this device is paused${why}. Run: ${getCliName()} network connect`;
9404
+ }
9405
+ var noticeShown = false;
9406
+ var noticeSuppressed = false;
9407
+ function notePausedDevice(header, write = (line) => process.stderr.write(`${line}
9408
+ `)) {
9409
+ if (noticeShown || noticeSuppressed)
9410
+ return;
9411
+ const paused = parseDeviceStateHeader(header);
9412
+ if (!paused)
9413
+ return;
9414
+ noticeShown = true;
9415
+ write(`! ${pausedNotice(paused)}`);
9416
+ }
9417
+ function suppressPausedNotice() {
9418
+ noticeSuppressed = true;
9419
+ }
9420
+ function clockTime(iso, now = new Date) {
9421
+ const at = new Date(iso);
9422
+ const time = at.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
9423
+ if (at.toDateString() === now.toDateString())
9424
+ return time;
9425
+ return `${at.toLocaleDateString("en-GB", { weekday: "short" })} ${time}`;
9426
+ }
9427
+ function setBy(slug) {
9428
+ return slug ? `, set by ${slug}` : "";
9429
+ }
9430
+ function pauseHint(state, now = new Date) {
9431
+ const idle = `after ${formatPauseLimit(state.idleTimeoutSeconds)} without traffic${setBy(state.setBy.idle)}`;
9432
+ const sessionEnd = sessionEndsAt(state, now);
9433
+ const session = `${formatPauseLimit(state.maxSessionSeconds)} after connecting${sessionEnd ? ` (at ${clockTime(sessionEnd, now)})` : ""}${setBy(state.setBy.maxSession)}`;
9434
+ return [
9435
+ "ℹ For security, this device's network access pauses automatically:",
9436
+ ` ${idle}, and ${session}.`,
9437
+ ` Resume any time: ${getCliName()} network connect, or Reconnect in the web app.`
9438
+ ];
9439
+ }
9440
+ function sessionEndsAt(state, now) {
9441
+ if (state.status !== "active" || !state.pausesAt)
9442
+ return null;
9443
+ if (state.pauseCause === "max_session")
9444
+ return state.pausesAt;
9445
+ return new Date(now.getTime() + state.maxSessionSeconds * 1000).toISOString();
9446
+ }
9447
+ function bold2(text, enabled = colorEnabled(process.stderr)) {
9448
+ return enabled ? `\x1B[1m${text}\x1B[22m` : text;
9449
+ }
9450
+ function deviceStateText(state, now = new Date) {
9451
+ if (!state)
9452
+ return "Not connected (logged out)";
9453
+ if (state.status === "paused" && state.pauseReason) {
9454
+ const text = state.pauseReason === "manual" ? "Paused by you" : `Paused: ${devicePauseReasonText(state.pauseReason, state)}`;
9455
+ return state.connected ? text : `Not connected (${text.toLowerCase()})`;
9456
+ }
9457
+ const left = state.pausesAt ? new Date(state.pausesAt).getTime() - now.getTime() : 0;
9458
+ const active = `Active, pauses in ${formatRemaining(Math.max(left, 0) / 1000)}`;
9459
+ return state.connected ? active : `Not connected (armed, pauses in ${formatRemaining(Math.max(left, 0) / 1000)})`;
9460
+ }
9461
+ function formatRemaining(seconds) {
9462
+ const minutes = Math.floor(seconds / 60);
9463
+ const days = Math.floor(minutes / 1440);
9464
+ const hours = Math.floor(minutes % 1440 / 60);
9465
+ const rest = minutes % 60;
9466
+ const dayText = days === 1 ? "1 day" : `${days} days`;
9467
+ if (days > 0)
9468
+ return hours ? `${dayText} ${hours} h` : dayText;
9469
+ if (hours > 0)
9470
+ return rest ? `${hours} h ${rest} min` : `${hours} h`;
9471
+ return `${Math.max(rest, 1)} min`;
9472
+ }
9473
+
9474
+ // src/network/tunnel.ts
9475
+ import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
9476
+ import { existsSync } from "node:fs";
9477
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
9478
+ import { dirname as dirname2, join as join2 } from "node:path";
9479
+ var INTERFACE = "gdock";
9480
+ function networkDir() {
9481
+ return join2(getConfigDir(), "network");
9482
+ }
9483
+ function configPath(dir = networkDir()) {
9484
+ return join2(dir, `${INTERFACE}.conf`);
9485
+ }
9486
+ function keyPath(dir) {
9487
+ return join2(dir, "private.key");
9488
+ }
9489
+ function statePath(dir) {
9490
+ return join2(dir, "device.json");
9491
+ }
9492
+ function isProvider(value) {
9493
+ const provider = value;
9494
+ return typeof provider?.id === "string" && typeof provider.name === "string";
9495
+ }
9496
+ var X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
9497
+ function keyPairFromPrivate(privateKey) {
9498
+ const raw = Buffer.from(privateKey, "base64");
9499
+ if (raw.length !== 32)
9500
+ throw new Error("The stored WireGuard key is not 32 bytes.");
9501
+ const key = createPrivateKey({
9502
+ key: Buffer.concat([X25519_PKCS8_PREFIX, raw]),
9503
+ format: "der",
9504
+ type: "pkcs8"
9505
+ });
9506
+ const spki = createPublicKey(key).export({ format: "der", type: "spki" });
9507
+ return { privateKey, publicKey: spki.subarray(spki.length - 32).toString("base64") };
9508
+ }
9509
+ async function loadOrCreateKey(dir = networkDir()) {
9510
+ try {
9511
+ return keyPairFromPrivate((await readFile2(keyPath(dir), "utf8")).trim());
9512
+ } catch (err) {
9513
+ if (err.code !== "ENOENT")
9514
+ throw err;
9515
+ }
9516
+ const { privateKey } = generateKeyPairSync("x25519");
9517
+ const der = privateKey.export({ format: "der", type: "pkcs8" });
9518
+ const raw = der.subarray(der.length - 32).toString("base64");
9519
+ await mkdir2(dir, { recursive: true, mode: 448 });
9520
+ await writeFile2(keyPath(dir), `${raw}
9521
+ `, { mode: 384 });
9522
+ return keyPairFromPrivate(raw);
9523
+ }
9524
+ async function readDeviceState(dir = networkDir()) {
9525
+ try {
9526
+ const parsed = JSON.parse(await readFile2(statePath(dir), "utf8"));
9527
+ if (typeof parsed.deviceId === "string" && typeof parsed.baseUrl === "string") {
9528
+ return {
9529
+ baseUrl: parsed.baseUrl,
9530
+ deviceId: parsed.deviceId,
9531
+ address: typeof parsed.address === "string" ? parsed.address : "",
9532
+ gatewayProvider: isProvider(parsed.gatewayProvider) ? { id: parsed.gatewayProvider.id, name: parsed.gatewayProvider.name } : null,
9533
+ zones: Array.isArray(parsed.zones) ? parsed.zones.filter((zone) => typeof zone === "string" && isDnsZone(zone)) : []
9534
+ };
9535
+ }
9536
+ } catch {}
9537
+ return null;
9538
+ }
9539
+ async function writeDeviceState(state, dir = networkDir()) {
9540
+ await mkdir2(dir, { recursive: true, mode: 448 });
9541
+ await writeFile2(statePath(dir), `${JSON.stringify(state, null, 2)}
9542
+ `, { mode: 384 });
9543
+ }
9544
+ function detectDnsMode(platform = process.platform, exists = existsSync) {
9545
+ if (platform === "darwin")
9546
+ return "macos-resolver";
9547
+ if (platform === "linux" && exists("/run/systemd/resolve"))
9548
+ return "resolved";
9549
+ return "none";
9550
+ }
9551
+ function renderConfig(input) {
9552
+ const { device, network, gateway } = input.registered;
9553
+ const zones = [network.zone, ...network.netZone ? [network.netZone] : []];
9554
+ for (const zone of zones) {
9555
+ if (!isDnsZone(zone))
9556
+ throw new Error(`The server sent a zone that is not a DNS name: ${zone}`);
9557
+ }
9558
+ if (!device.address || !isIPv6Address(device.address)) {
9559
+ throw new Error(`The server sent no IPv6 address for this device: ${device.address}`);
9560
+ }
9561
+ if (!isIPv6Address(network.gatewayAddress)) {
9562
+ throw new Error(`The server sent a gateway that is not an IPv6 address: ${network.gatewayAddress}`);
9563
+ }
9564
+ const ipv4 = device.address4 && network.ingress4 ? { address: device.address4, ingress: network.ingress4 } : null;
9565
+ if (ipv4 && (!isIPv4Address(ipv4.address) || !isIPv4Address(ipv4.ingress))) {
9566
+ throw new Error(`The server sent an IPv4 address that is not one: ${ipv4.address}, ${ipv4.ingress}`);
9567
+ }
9568
+ const lines = [
9569
+ "# Written by `ghostdock network connect`. Run it again rather than editing this file.",
9570
+ "[Interface]",
9571
+ `PrivateKey = ${input.privateKey}`,
9572
+ `Address = ${device.address}/128${ipv4 ? `, ${ipv4.address}/32` : ""}`,
9573
+ `MTU = ${network.mtu}`
9574
+ ];
9575
+ if (input.dns === "macos-resolver") {
9576
+ for (const zone of zones) {
9577
+ const file = `/etc/resolver/${zone}`;
9578
+ lines.push(`PostUp = mkdir -p /etc/resolver && printf 'nameserver ${network.gatewayAddress}\\n' > ${file}`, `PostDown = rm -f ${file}`);
9579
+ }
9580
+ } else if (input.dns === "resolved") {
9581
+ const domains = zones.map((zone) => `'~${zone}'`).join(" ");
9582
+ lines.push(`PostUp = resolvectl dns %i ${network.gatewayAddress} && resolvectl domain %i ${domains}`);
9583
+ }
9584
+ lines.push("", "[Peer]", `PublicKey = ${gateway.publicKey}`, `Endpoint = ${gateway.endpoint}`, `AllowedIPs = ${network.prefix}${ipv4 ? `, ${ipv4.ingress}/32` : ""}`, "PersistentKeepalive = 25", "");
9585
+ return lines.join(`
9586
+ `);
9587
+ }
9588
+ var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
9589
+ function isDnsZone(zone) {
9590
+ const labels = zone.split(".");
9591
+ return zone.length <= 253 && labels.length >= 2 && labels.every((label) => DNS_LABEL.test(label));
9592
+ }
9593
+ function isIPv4Address(text) {
9594
+ return /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/.test(text);
9595
+ }
9596
+ function ipv4RouteOwner(platform, output) {
9597
+ if (platform === "darwin") {
9598
+ if (/^\s*destination: default$/m.test(output))
9599
+ return null;
9600
+ return /^\s*interface: (\S+)$/m.exec(output)?.[1] ?? null;
9601
+ }
9602
+ for (const line of output.split(`
9603
+ `).map((l) => l.trim())) {
9604
+ if (!line || /^(default|broadcast)\b/.test(line))
9605
+ continue;
9606
+ return /\bdev (\S+)/.exec(line)?.[1] ?? "another route";
9607
+ }
9608
+ return null;
9609
+ }
9610
+ function ipv4RouteCommand(platform, address) {
9611
+ return platform === "darwin" ? ["route", ["-n", "get", address]] : ["ip", ["-4", "route", "show", "table", "all", "match", address]];
9612
+ }
9613
+ function isIPv6Address(text) {
9614
+ return /^[0-9a-f:]{2,39}$/.test(text) && text.includes(":");
9615
+ }
9616
+ async function readConfig(path = configPath()) {
9617
+ try {
9618
+ return await readFile2(path, "utf8");
9619
+ } catch {
9620
+ return null;
9621
+ }
9622
+ }
9623
+ function configResolverZones(config) {
9624
+ if (!config)
9625
+ return [];
9626
+ const zones = [];
9627
+ for (const match of config.matchAll(/^PostDown = rm -f \/etc\/resolver\/(\S+)$/gm)) {
9628
+ const zone = match[1] ?? "";
9629
+ if (isDnsZone(zone) && !zones.includes(zone))
9630
+ zones.push(zone);
9631
+ }
9632
+ return zones;
9633
+ }
9634
+ function resolverFile(zone) {
9635
+ return `/etc/resolver/${zone}`;
9636
+ }
9637
+ async function readConfigMtu(path = configPath()) {
9638
+ let contents;
9639
+ try {
9640
+ contents = await readFile2(path, "utf8");
9641
+ } catch {
9642
+ return null;
9643
+ }
9644
+ const match = /^MTU = (\d+)$/m.exec(contents);
9645
+ return match ? Number(match[1]) : null;
9646
+ }
9647
+ async function writeConfig(contents, path = configPath()) {
9648
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
9649
+ await writeFile2(path, contents, { mode: 384 });
9650
+ await chmod2(path, 384);
9651
+ }
9652
+ function tunnelUp(platform = process.platform, exists = existsSync) {
9653
+ if (platform === "darwin")
9654
+ return exists(`/var/run/wireguard/${INTERFACE}.name`);
9655
+ return exists(`/sys/class/net/${INTERFACE}`);
9656
+ }
9657
+ function installHint(platform = process.platform) {
9658
+ if (platform === "darwin")
9659
+ return "brew install wireguard-tools";
9660
+ if (platform === "linux")
9661
+ return "sudo apt install wireguard-tools (or your distribution’s package)";
9662
+ return "Install wireguard-tools. On Windows, run the CLI inside WSL.";
9663
+ }
9664
+
9287
9665
  // src/api-client.ts
9288
9666
  var CLI_USER_AGENT = `ghostdock-cli/${version}`;
9289
9667
 
@@ -9325,6 +9703,7 @@ async function request(path, init = {}) {
9325
9703
  ...init.headers
9326
9704
  }
9327
9705
  });
9706
+ notePausedDevice(res.headers.get(DEVICE_STATE_HEADER));
9328
9707
  if (!res.ok) {
9329
9708
  const payload = await readErrorPayload(res);
9330
9709
  throw new ApiError(res.status, payload.message, payload.data);
@@ -9356,6 +9735,11 @@ function deviceRevoke(body) {
9356
9735
  body: JSON.stringify(body)
9357
9736
  });
9358
9737
  }
9738
+ var deviceIdOnce = null;
9739
+ function currentDeviceId() {
9740
+ deviceIdOnce ??= readDeviceState().then((state) => state && state.baseUrl === getBaseUrl() ? state.deviceId : null).catch(() => null);
9741
+ return deviceIdOnce;
9742
+ }
9359
9743
  var refreshInFlight = null;
9360
9744
  async function refreshStoredCredentials(refreshToken) {
9361
9745
  if (refreshInFlight)
@@ -9378,10 +9762,12 @@ async function authorizedRequest(path, init = {}) {
9378
9762
  if (!credentials) {
9379
9763
  throw new AuthRequiredError;
9380
9764
  }
9765
+ const deviceId = await currentDeviceId();
9381
9766
  const withAuth = (accessToken) => ({
9382
9767
  ...init,
9383
9768
  headers: {
9384
9769
  ...init.headers,
9770
+ ...deviceId ? { [DEVICE_HEADER]: deviceId } : {},
9385
9771
  authorization: `Bearer ${accessToken}`
9386
9772
  }
9387
9773
  });
@@ -9405,75 +9791,11 @@ async function authorizedRequest(path, init = {}) {
9405
9791
  }
9406
9792
  }
9407
9793
 
9408
- // src/output.ts
9409
- var SYMBOLS = {
9410
- ready: "⚓",
9411
- done: "✓",
9412
- url: "↗",
9413
- stopped: "◌",
9414
- failed: "✗",
9415
- progress: "▂▄▆",
9416
- item: "•"
9417
- };
9418
- var jsonOutput = false;
9419
- function setJsonOutput(on) {
9420
- jsonOutput = on;
9421
- }
9422
- function isJsonOutput() {
9423
- return jsonOutput;
9424
- }
9425
- function colorEnabled(stream, env = process.env) {
9426
- if (env.FORCE_COLOR !== undefined)
9427
- return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
9428
- if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
9429
- return false;
9430
- if (env.TERM === "dumb")
9431
- return false;
9432
- return stream.isTTY === true;
9433
- }
9434
- function trueColor(env = process.env) {
9435
- return env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
9436
- }
9437
- function accent(text, enabled) {
9438
- if (!enabled)
9439
- return text;
9440
- const open = trueColor() ? "\x1B[38;2;124;255;203m" : "\x1B[38;5;122m";
9441
- return `${open}${text}\x1B[39m`;
9442
- }
9443
- function red(text, enabled) {
9444
- return enabled ? `\x1B[31m${text}\x1B[39m` : text;
9445
- }
9446
- function dim(text, enabled) {
9447
- return enabled ? `\x1B[2m${text}\x1B[22m` : text;
9448
- }
9449
- function eventLine(symbol, message, color = colorEnabled(process.stdout)) {
9450
- return ` ${accent(SYMBOLS[symbol], color)} ${message}`;
9451
- }
9452
- function errorLine(message, color = colorEnabled(process.stderr)) {
9453
- return red(` ${SYMBOLS.failed} ${message}`, color);
9454
- }
9455
- function printNotice(message) {
9456
- process.stderr.write(`${message}
9457
- `);
9458
- }
9459
- function printError(message) {
9460
- process.stderr.write(`${errorLine(message)}
9461
- `);
9462
- }
9463
- function printResult(data, human, log = console.log) {
9464
- if (jsonOutput) {
9465
- log(JSON.stringify(data, null, 2));
9466
- return;
9467
- }
9468
- for (const line of human())
9469
- log(line);
9470
- }
9471
-
9472
9794
  // src/settings.ts
9473
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
9474
- import { dirname as dirname2, join as join2 } from "node:path";
9795
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
9796
+ import { dirname as dirname3, join as join3 } from "node:path";
9475
9797
  function getSettingsPath() {
9476
- return join2(dirname2(getCredentialsPath()), "settings.json");
9798
+ return join3(dirname3(getCredentialsPath()), "settings.json");
9477
9799
  }
9478
9800
  function isDefaultOrganization(value) {
9479
9801
  if (!value || typeof value !== "object")
@@ -9483,15 +9805,15 @@ function isDefaultOrganization(value) {
9483
9805
  }
9484
9806
  async function readSettings(path = getSettingsPath()) {
9485
9807
  try {
9486
- const parsed = JSON.parse(await readFile2(path, "utf8"));
9808
+ const parsed = JSON.parse(await readFile3(path, "utf8"));
9487
9809
  return isDefaultOrganization(parsed.defaultOrganization) ? { defaultOrganization: parsed.defaultOrganization } : {};
9488
9810
  } catch {
9489
9811
  return {};
9490
9812
  }
9491
9813
  }
9492
9814
  async function writeSettings(settings, path = getSettingsPath()) {
9493
- await mkdir2(dirname2(path), { recursive: true });
9494
- await writeFile2(path, `${JSON.stringify(settings, null, 2)}
9815
+ await mkdir3(dirname3(path), { recursive: true });
9816
+ await writeFile3(path, `${JSON.stringify(settings, null, 2)}
9495
9817
  `, "utf8");
9496
9818
  }
9497
9819
 
@@ -9781,77 +10103,6 @@ var envCommand = defineCommand({
9781
10103
  }
9782
10104
  });
9783
10105
 
9784
- // src/container-logs.ts
9785
- var FOLLOW_INTERVAL_MS = 2000;
9786
- function defaultLogsDeps(follow) {
9787
- let stop = false;
9788
- if (follow) {
9789
- process.once("SIGINT", () => {
9790
- stop = true;
9791
- });
9792
- }
9793
- return {
9794
- request: (input) => authorizedRequest("/api/v1/tools/sandboxes_containers_logs", {
9795
- method: "POST",
9796
- body: JSON.stringify(input)
9797
- }),
9798
- out: (line) => process.stdout.write(`${line}
9799
- `),
9800
- err: (line) => process.stderr.write(`${line}
9801
- `),
9802
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
9803
- stopped: () => stop
9804
- };
9805
- }
9806
- async function runContainerLogs(input, options, deps = defaultLogsDeps(options.follow)) {
9807
- const json = isJsonOutput();
9808
- const color = colorEnabled(process.stdout);
9809
- const print = (line) => {
9810
- if (json) {
9811
- deps.out(JSON.stringify(line));
9812
- return;
9813
- }
9814
- const text = options.timestamps && line.time ? `${dim(line.time, color)} ${line.text}` : line.text;
9815
- (line.stream === "stderr" ? deps.err : deps.out)(text);
9816
- };
9817
- let result = await deps.request(input);
9818
- if (!result.running) {
9819
- deps.err("The sandbox is stopped: it has no logs to show.");
9820
- return 1;
9821
- }
9822
- let last = null;
9823
- for (;; ) {
9824
- for (const line of result.lines) {
9825
- print(line);
9826
- if (line.time)
9827
- last = line.time;
9828
- }
9829
- if (!options.follow) {
9830
- if (result.truncated && json) {
9831
- deps.out(JSON.stringify({ truncated: true }));
9832
- } else if (result.truncated) {
9833
- deps.err(dim("(older lines left out: use --tail for more)", colorEnabled(process.stderr)));
9834
- }
9835
- return 0;
9836
- }
9837
- if (!result.truncated)
9838
- await deps.sleep(FOLLOW_INTERVAL_MS);
9839
- if (deps.stopped())
9840
- return 0;
9841
- const { tail: _tail, ...rest } = input;
9842
- try {
9843
- result = await deps.request(last ? { ...rest, after: last } : input);
9844
- } catch (error) {
9845
- deps.err(red(error instanceof Error ? error.message : String(error), colorEnabled(process.stderr)));
9846
- return 1;
9847
- }
9848
- if (!result.running) {
9849
- deps.err("The sandbox stopped.");
9850
- return 0;
9851
- }
9852
- }
9853
- }
9854
-
9855
10106
  // src/container-picker.ts
9856
10107
  import { createInterface } from "node:readline/promises";
9857
10108
  function notRunningContainerOf(error) {
@@ -9985,6 +10236,130 @@ async function withProgress(message, work, options) {
9985
10236
  }
9986
10237
  }
9987
10238
 
10239
+ // src/container-flow.ts
10240
+ async function withContainerPick(call, send, pickers, notice) {
10241
+ try {
10242
+ return await send();
10243
+ } catch (err) {
10244
+ const question = containerQuestionOf(err);
10245
+ if (!pickers || !question || !call.canPick)
10246
+ throw err;
10247
+ const container = await pickers.choose(question);
10248
+ if (!container)
10249
+ throw err;
10250
+ call.setContainer(container);
10251
+ const result = await send();
10252
+ if (call.sandboxId !== undefined && await pickers.confirmDefault(container)) {
10253
+ try {
10254
+ await authorizedRequest("/api/v1/tools/sandboxes_default_container", {
10255
+ method: "POST",
10256
+ body: JSON.stringify({
10257
+ organization: call.organization,
10258
+ sandboxId: call.sandboxId,
10259
+ container
10260
+ })
10261
+ });
10262
+ notice(eventLine("done", `Default container: ${container}.`));
10263
+ } catch (err) {
10264
+ notice(eventLine("failed", `The default container was not set: ${err instanceof Error ? err.message : String(err)}`));
10265
+ }
10266
+ }
10267
+ return result;
10268
+ }
10269
+ }
10270
+ async function withContainerStart(call, send, start, pickers, notice) {
10271
+ try {
10272
+ return await send();
10273
+ } catch (err) {
10274
+ const container = notRunningContainerOf(err);
10275
+ if (!container || call.sandboxId === undefined)
10276
+ throw err;
10277
+ if (!start && !(pickers && await pickers.confirmStart(container)))
10278
+ throw err;
10279
+ await withProgress("Starting container…", () => authorizedRequest("/api/v1/tools/sandboxes_containers_start", {
10280
+ method: "POST",
10281
+ body: JSON.stringify({
10282
+ organization: call.organization,
10283
+ sandboxId: call.sandboxId,
10284
+ container
10285
+ })
10286
+ }));
10287
+ notice(eventLine("done", `Started ${container}.`));
10288
+ return await send();
10289
+ }
10290
+ }
10291
+
10292
+ // src/container-logs.ts
10293
+ var FOLLOW_INTERVAL_MS = 2000;
10294
+ function defaultLogsDeps(follow) {
10295
+ let stop = false;
10296
+ if (follow) {
10297
+ process.once("SIGINT", () => {
10298
+ stop = true;
10299
+ });
10300
+ }
10301
+ return {
10302
+ request: (input) => authorizedRequest("/api/v1/tools/sandboxes_containers_logs", {
10303
+ method: "POST",
10304
+ body: JSON.stringify(input)
10305
+ }),
10306
+ out: (line) => process.stdout.write(`${line}
10307
+ `),
10308
+ err: (line) => process.stderr.write(`${line}
10309
+ `),
10310
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
10311
+ stopped: () => stop
10312
+ };
10313
+ }
10314
+ async function runContainerLogs(input, options, deps = defaultLogsDeps(options.follow)) {
10315
+ const json = isJsonOutput();
10316
+ const color = colorEnabled(process.stdout);
10317
+ const print = (line) => {
10318
+ if (json) {
10319
+ deps.out(JSON.stringify(line));
10320
+ return;
10321
+ }
10322
+ const text = options.timestamps && line.time ? `${dim(line.time, color)} ${line.text}` : line.text;
10323
+ (line.stream === "stderr" ? deps.err : deps.out)(text);
10324
+ };
10325
+ let result = await deps.request(input);
10326
+ if (!result.running) {
10327
+ deps.err("The sandbox is stopped: it has no logs to show.");
10328
+ return 1;
10329
+ }
10330
+ let last = null;
10331
+ for (;; ) {
10332
+ for (const line of result.lines) {
10333
+ print(line);
10334
+ if (line.time)
10335
+ last = line.time;
10336
+ }
10337
+ if (!options.follow) {
10338
+ if (result.truncated && json) {
10339
+ deps.out(JSON.stringify({ truncated: true }));
10340
+ } else if (result.truncated) {
10341
+ deps.err(dim("(older lines left out: use --tail for more)", colorEnabled(process.stderr)));
10342
+ }
10343
+ return 0;
10344
+ }
10345
+ if (!result.truncated)
10346
+ await deps.sleep(FOLLOW_INTERVAL_MS);
10347
+ if (deps.stopped())
10348
+ return 0;
10349
+ const { tail: _tail, ...rest } = input;
10350
+ try {
10351
+ result = await deps.request(last ? { ...rest, after: last } : input);
10352
+ } catch (error) {
10353
+ deps.err(red(error instanceof Error ? error.message : String(error), colorEnabled(process.stderr)));
10354
+ return 1;
10355
+ }
10356
+ if (!result.running) {
10357
+ deps.err("The sandbox stopped.");
10358
+ return 0;
10359
+ }
10360
+ }
10361
+ }
10362
+
9988
10363
  // src/human.ts
9989
10364
  var ID_KEYS = [
9990
10365
  "sandboxId",
@@ -10104,6 +10479,11 @@ function listLines(items, color) {
10104
10479
  function bootPhase(value) {
10105
10480
  return isItem(value) && typeof value.ms === "number" && Number.isFinite(value.ms) ? { ms: value.ms } : null;
10106
10481
  }
10482
+ var DISK_SOURCE_TEXT = {
10483
+ fresh: "image pulled",
10484
+ "image-base": "image already on disk",
10485
+ "user-snapshot": "from a snapshot"
10486
+ };
10107
10487
  function bootLines(data, color) {
10108
10488
  if (!isItem(data) || !isItem(data.boot))
10109
10489
  return [];
@@ -10115,6 +10495,12 @@ function bootLines(data, color) {
10115
10495
  if (container) {
10116
10496
  lines.push(eventLine("done", `container started in ${formatElapsed(container.ms)}`, color));
10117
10497
  }
10498
+ const provision = bootPhase(data.boot.provision);
10499
+ if (provision) {
10500
+ const disk = typeof data.boot.disk === "string" ? DISK_SOURCE_TEXT[data.boot.disk] : undefined;
10501
+ const how = disk ? ` (${disk})` : "";
10502
+ lines.push(eventLine("done", `ready in ${formatElapsed(provision.ms)}${how}`, color));
10503
+ }
10118
10504
  return lines;
10119
10505
  }
10120
10506
  function renderResult(data, color) {
@@ -10288,10 +10674,10 @@ function buildToolInput(schema, rawArgs) {
10288
10674
 
10289
10675
  // src/commands/invoke.ts
10290
10676
  async function runToolInvoke(tool, rawArgs, log = console.log, error = printError, notice = printNotice, pickers = defaultContainerPickers()) {
10677
+ const properties = tool.inputSchema.properties;
10291
10678
  let input;
10292
10679
  try {
10293
10680
  input = buildToolInput(tool.inputSchema, rawArgs);
10294
- const properties = tool.inputSchema.properties;
10295
10681
  if (properties && "organization" in properties && input.organization === undefined) {
10296
10682
  input.organization = await requireOrganizationRef(undefined, notice);
10297
10683
  }
@@ -10310,24 +10696,33 @@ async function runToolInvoke(tool, rawArgs, log = console.log, error = printErro
10310
10696
  return 1;
10311
10697
  }
10312
10698
  }
10699
+ const call = {
10700
+ organization: typeof input.organization === "string" ? input.organization : undefined,
10701
+ sandboxId: typeof input.sandboxId === "string" ? input.sandboxId : undefined,
10702
+ canPick: !!properties && "container" in properties && !input.container,
10703
+ setContainer: (container) => {
10704
+ input.container = container;
10705
+ }
10706
+ };
10313
10707
  try {
10314
- const send = () => authorizedRequest(`/api/v1/tools/${tool.name}`, {
10708
+ const post = () => authorizedRequest(`/api/v1/tools/${tool.name}`, {
10315
10709
  method: "POST",
10316
10710
  body: JSON.stringify(input)
10317
10711
  });
10318
- const request = () => withContainerStart(input, send, rawArgs.start === true, pickers, notice);
10319
10712
  const waitMessage = PROGRESS_MESSAGES[tool.name];
10320
- if (!waitMessage) {
10321
- const result = await withContainerPick(tool, input, request, pickers, notice);
10322
- printResult(result, () => renderResult(result, colorEnabled(process.stdout)), log);
10323
- stopNote(result, notice);
10324
- return 0;
10325
- }
10326
- const { value: result, elapsed } = await withContainerPick(tool, input, () => withProgress(waitMessage, request), pickers, notice);
10713
+ let elapsed;
10714
+ const send = async () => {
10715
+ if (!waitMessage)
10716
+ return post();
10717
+ const timed = await withProgress(waitMessage, post);
10718
+ elapsed = timed.elapsed;
10719
+ return timed.value;
10720
+ };
10721
+ const result = await withContainerPick(call, () => withContainerStart(call, send, rawArgs.start === true, pickers, notice), pickers, notice);
10327
10722
  printResult(result, () => {
10328
10723
  const color = colorEnabled(process.stdout);
10329
10724
  const lines = renderResult(result, color);
10330
- return bootLines(result, color).length > 0 ? lines : withElapsed(lines, elapsed);
10725
+ return elapsed === undefined || bootLines(result, color).length > 0 ? lines : withElapsed(lines, elapsed);
10331
10726
  }, log);
10332
10727
  stopNote(result, notice);
10333
10728
  return 0;
@@ -10336,59 +10731,6 @@ async function runToolInvoke(tool, rawArgs, log = console.log, error = printErro
10336
10731
  return 1;
10337
10732
  }
10338
10733
  }
10339
- async function withContainerPick(tool, input, send, pickers, notice) {
10340
- try {
10341
- return await send();
10342
- } catch (err) {
10343
- const properties = tool.inputSchema.properties;
10344
- const question = containerQuestionOf(err);
10345
- if (!pickers || !question || !properties || !("container" in properties) || input.container) {
10346
- throw err;
10347
- }
10348
- const container = await pickers.choose(question);
10349
- if (!container)
10350
- throw err;
10351
- input.container = container;
10352
- const result = await send();
10353
- if (typeof input.sandboxId === "string" && await pickers.confirmDefault(container)) {
10354
- try {
10355
- await authorizedRequest("/api/v1/tools/sandboxes_default_container", {
10356
- method: "POST",
10357
- body: JSON.stringify({
10358
- organization: input.organization,
10359
- sandboxId: input.sandboxId,
10360
- container
10361
- })
10362
- });
10363
- notice(eventLine("done", `Default container: ${container}.`));
10364
- } catch (err) {
10365
- notice(eventLine("failed", `The default container was not set: ${err instanceof Error ? err.message : String(err)}`));
10366
- }
10367
- }
10368
- return result;
10369
- }
10370
- }
10371
- async function withContainerStart(input, send, start, pickers, notice) {
10372
- try {
10373
- return await send();
10374
- } catch (err) {
10375
- const container = notRunningContainerOf(err);
10376
- if (!container || typeof input.sandboxId !== "string")
10377
- throw err;
10378
- if (!start && !(pickers && await pickers.confirmStart(container)))
10379
- throw err;
10380
- await authorizedRequest("/api/v1/tools/sandboxes_containers_start", {
10381
- method: "POST",
10382
- body: JSON.stringify({
10383
- organization: input.organization,
10384
- sandboxId: input.sandboxId,
10385
- container
10386
- })
10387
- });
10388
- notice(eventLine("done", `Started ${container}.`));
10389
- return await send();
10390
- }
10391
- }
10392
10734
  function stopNote(result, notice) {
10393
10735
  if (!result || typeof result !== "object")
10394
10736
  return;
@@ -10680,178 +11022,8 @@ var logoutCommand = defineCommand({
10680
11022
 
10681
11023
  // src/commands/network.ts
10682
11024
  import { spawn, spawnSync } from "node:child_process";
11025
+ import { existsSync as existsSync2 } from "node:fs";
10683
11026
  import { hostname as hostname2 } from "node:os";
10684
-
10685
- // src/network/tunnel.ts
10686
- import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
10687
- import { existsSync } from "node:fs";
10688
- import { chmod as chmod2, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
10689
- import { dirname as dirname3, join as join3 } from "node:path";
10690
- var INTERFACE = "gdock";
10691
- function networkDir() {
10692
- return join3(getConfigDir(), "network");
10693
- }
10694
- function configPath(dir = networkDir()) {
10695
- return join3(dir, `${INTERFACE}.conf`);
10696
- }
10697
- function keyPath(dir) {
10698
- return join3(dir, "private.key");
10699
- }
10700
- function statePath(dir) {
10701
- return join3(dir, "device.json");
10702
- }
10703
- function isProvider(value) {
10704
- const provider = value;
10705
- return typeof provider?.id === "string" && typeof provider.name === "string";
10706
- }
10707
- var X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
10708
- function keyPairFromPrivate(privateKey) {
10709
- const raw = Buffer.from(privateKey, "base64");
10710
- if (raw.length !== 32)
10711
- throw new Error("The stored WireGuard key is not 32 bytes.");
10712
- const key = createPrivateKey({
10713
- key: Buffer.concat([X25519_PKCS8_PREFIX, raw]),
10714
- format: "der",
10715
- type: "pkcs8"
10716
- });
10717
- const spki = createPublicKey(key).export({ format: "der", type: "spki" });
10718
- return { privateKey, publicKey: spki.subarray(spki.length - 32).toString("base64") };
10719
- }
10720
- async function loadOrCreateKey(dir = networkDir()) {
10721
- try {
10722
- return keyPairFromPrivate((await readFile3(keyPath(dir), "utf8")).trim());
10723
- } catch (err) {
10724
- if (err.code !== "ENOENT")
10725
- throw err;
10726
- }
10727
- const { privateKey } = generateKeyPairSync("x25519");
10728
- const der = privateKey.export({ format: "der", type: "pkcs8" });
10729
- const raw = der.subarray(der.length - 32).toString("base64");
10730
- await mkdir3(dir, { recursive: true, mode: 448 });
10731
- await writeFile3(keyPath(dir), `${raw}
10732
- `, { mode: 384 });
10733
- return keyPairFromPrivate(raw);
10734
- }
10735
- async function readDeviceState(dir = networkDir()) {
10736
- try {
10737
- const parsed = JSON.parse(await readFile3(statePath(dir), "utf8"));
10738
- if (typeof parsed.deviceId === "string" && typeof parsed.baseUrl === "string") {
10739
- return {
10740
- baseUrl: parsed.baseUrl,
10741
- deviceId: parsed.deviceId,
10742
- address: typeof parsed.address === "string" ? parsed.address : "",
10743
- gatewayProvider: isProvider(parsed.gatewayProvider) ? { id: parsed.gatewayProvider.id, name: parsed.gatewayProvider.name } : null
10744
- };
10745
- }
10746
- } catch {}
10747
- return null;
10748
- }
10749
- async function writeDeviceState(state, dir = networkDir()) {
10750
- await mkdir3(dir, { recursive: true, mode: 448 });
10751
- await writeFile3(statePath(dir), `${JSON.stringify(state, null, 2)}
10752
- `, { mode: 384 });
10753
- }
10754
- function detectDnsMode(platform = process.platform, exists = existsSync) {
10755
- if (platform === "darwin")
10756
- return "macos-resolver";
10757
- if (platform === "linux" && exists("/run/systemd/resolve"))
10758
- return "resolved";
10759
- return "none";
10760
- }
10761
- function renderConfig(input) {
10762
- const { device, network, gateway } = input.registered;
10763
- const zones = [network.zone, ...network.netZone ? [network.netZone] : []];
10764
- for (const zone of zones) {
10765
- if (!isDnsZone(zone))
10766
- throw new Error(`The server sent a zone that is not a DNS name: ${zone}`);
10767
- }
10768
- if (!device.address || !isIPv6Address(device.address)) {
10769
- throw new Error(`The server sent no IPv6 address for this device: ${device.address}`);
10770
- }
10771
- if (!isIPv6Address(network.gatewayAddress)) {
10772
- throw new Error(`The server sent a gateway that is not an IPv6 address: ${network.gatewayAddress}`);
10773
- }
10774
- const ipv4 = device.address4 && network.ingress4 ? { address: device.address4, ingress: network.ingress4 } : null;
10775
- if (ipv4 && (!isIPv4Address(ipv4.address) || !isIPv4Address(ipv4.ingress))) {
10776
- throw new Error(`The server sent an IPv4 address that is not one: ${ipv4.address}, ${ipv4.ingress}`);
10777
- }
10778
- const lines = [
10779
- "# Written by `ghostdock network connect`. Run it again rather than editing this file.",
10780
- "[Interface]",
10781
- `PrivateKey = ${input.privateKey}`,
10782
- `Address = ${device.address}/128${ipv4 ? `, ${ipv4.address}/32` : ""}`,
10783
- `MTU = ${network.mtu}`
10784
- ];
10785
- if (input.dns === "macos-resolver") {
10786
- for (const zone of zones) {
10787
- const file = `/etc/resolver/${zone}`;
10788
- lines.push(`PostUp = mkdir -p /etc/resolver && printf 'nameserver ${network.gatewayAddress}\\n' > ${file}`, `PostDown = rm -f ${file}`);
10789
- }
10790
- } else if (input.dns === "resolved") {
10791
- const domains = zones.map((zone) => `'~${zone}'`).join(" ");
10792
- lines.push(`PostUp = resolvectl dns %i ${network.gatewayAddress} && resolvectl domain %i ${domains}`);
10793
- }
10794
- lines.push("", "[Peer]", `PublicKey = ${gateway.publicKey}`, `Endpoint = ${gateway.endpoint}`, `AllowedIPs = ${network.prefix}${ipv4 ? `, ${ipv4.ingress}/32` : ""}`, "PersistentKeepalive = 25", "");
10795
- return lines.join(`
10796
- `);
10797
- }
10798
- var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10799
- function isDnsZone(zone) {
10800
- const labels = zone.split(".");
10801
- return zone.length <= 253 && labels.length >= 2 && labels.every((label) => DNS_LABEL.test(label));
10802
- }
10803
- function isIPv4Address(text) {
10804
- return /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/.test(text);
10805
- }
10806
- function ipv4RouteOwner(platform, output) {
10807
- if (platform === "darwin") {
10808
- if (/^\s*destination: default$/m.test(output))
10809
- return null;
10810
- return /^\s*interface: (\S+)$/m.exec(output)?.[1] ?? null;
10811
- }
10812
- for (const line of output.split(`
10813
- `).map((l) => l.trim())) {
10814
- if (!line || /^(default|broadcast)\b/.test(line))
10815
- continue;
10816
- return /\bdev (\S+)/.exec(line)?.[1] ?? "another route";
10817
- }
10818
- return null;
10819
- }
10820
- function ipv4RouteCommand(platform, address) {
10821
- return platform === "darwin" ? ["route", ["-n", "get", address]] : ["ip", ["-4", "route", "show", "table", "all", "match", address]];
10822
- }
10823
- function isIPv6Address(text) {
10824
- return /^[0-9a-f:]{2,39}$/.test(text) && text.includes(":");
10825
- }
10826
- async function readConfigMtu(path = configPath()) {
10827
- let contents;
10828
- try {
10829
- contents = await readFile3(path, "utf8");
10830
- } catch {
10831
- return null;
10832
- }
10833
- const match = /^MTU = (\d+)$/m.exec(contents);
10834
- return match ? Number(match[1]) : null;
10835
- }
10836
- async function writeConfig(contents, path = configPath()) {
10837
- await mkdir3(dirname3(path), { recursive: true, mode: 448 });
10838
- await writeFile3(path, contents, { mode: 384 });
10839
- await chmod2(path, 384);
10840
- }
10841
- function tunnelUp(platform = process.platform, exists = existsSync) {
10842
- if (platform === "darwin")
10843
- return exists(`/var/run/wireguard/${INTERFACE}.name`);
10844
- return exists(`/sys/class/net/${INTERFACE}`);
10845
- }
10846
- function installHint(platform = process.platform) {
10847
- if (platform === "darwin")
10848
- return "brew install wireguard-tools";
10849
- if (platform === "linux")
10850
- return "sudo apt install wireguard-tools (or your distribution’s package)";
10851
- return "Install wireguard-tools. On Windows, run the CLI inside WSL.";
10852
- }
10853
-
10854
- // src/commands/network.ts
10855
11027
  function defaultDeps() {
10856
11028
  return {
10857
11029
  request: authorizedRequest,
@@ -10868,6 +11040,7 @@ function defaultDeps() {
10868
11040
  has: (command) => spawnSync("sh", ["-c", `command -v ${command}`]).status === 0,
10869
11041
  dir: networkDir(),
10870
11042
  up: () => tunnelUp(),
11043
+ exists: existsSync2,
10871
11044
  log: console.log,
10872
11045
  error: printError,
10873
11046
  notice: printNotice
@@ -10887,6 +11060,7 @@ async function wgQuick(deps, verb) {
10887
11060
  return deps.run("sudo", args);
10888
11061
  }
10889
11062
  async function runNetworkConnect(args = {}, deps = defaultDeps()) {
11063
+ suppressPausedNotice();
10890
11064
  if (!deps.has("wg-quick")) {
10891
11065
  deps.error(`wg-quick is not installed. ${installHint()}`);
10892
11066
  return 1;
@@ -10920,14 +11094,37 @@ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
10920
11094
  deps.error(message(err));
10921
11095
  return 1;
10922
11096
  }
11097
+ const previousConfig = await readConfig(configPath(deps.dir));
10923
11098
  await writeConfig(config, configPath(deps.dir));
10924
11099
  await writeDeviceState({
10925
11100
  baseUrl: getBaseUrl(),
10926
11101
  deviceId: registered.device.id,
10927
11102
  address: registered.device.address ?? "",
10928
- gatewayProvider: registered.provider
11103
+ gatewayProvider: registered.provider,
11104
+ zones: [
11105
+ registered.network.zone,
11106
+ ...registered.network.netZone ? [registered.network.netZone] : []
11107
+ ]
10929
11108
  }, deps.dir);
10930
11109
  const wasUp = deps.up();
11110
+ if (wasUp && previousConfig === config) {
11111
+ const { device, network, provider, shortNameOrganizationChanged } = registered;
11112
+ printResult({
11113
+ device,
11114
+ network,
11115
+ provider,
11116
+ shortNameOrganizationChanged,
11117
+ interface: INTERFACE,
11118
+ dns,
11119
+ rearmed: true,
11120
+ pause: device.state
11121
+ }, () => [
11122
+ eventLine("done", rearmedLine(device.state)),
11123
+ ...network.netZone && device.shortNameOrganization && shortNameOrganizationChanged ? clearingNotice(network.netCookieSite) : []
11124
+ ], deps.log);
11125
+ printPauseHint(device.state, deps);
11126
+ return 0;
11127
+ }
10931
11128
  if (wasUp) {
10932
11129
  const down = await wgQuick(deps, "down");
10933
11130
  if (down !== 0) {
@@ -10944,7 +11141,16 @@ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
10944
11141
  const { device, network, provider, shortNameOrganizationChanged } = registered;
10945
11142
  const pinned = device.shortNameOrganization;
10946
11143
  const switched = wasUp && previous?.gatewayProvider && previous.gatewayProvider.id !== provider.id ? previous.gatewayProvider : null;
10947
- printResult({ device, network, provider, shortNameOrganizationChanged, interface: INTERFACE, dns }, () => [
11144
+ printResult({
11145
+ device,
11146
+ network,
11147
+ provider,
11148
+ shortNameOrganizationChanged,
11149
+ interface: INTERFACE,
11150
+ dns,
11151
+ rearmed: false,
11152
+ pause: device.state
11153
+ }, () => [
10948
11154
  eventLine("done", `Connected through ${provider.name}. This machine is ${device.address} in the private network.`),
10949
11155
  ...switched ? [
10950
11156
  ` Replaced the connection through ${switched.name}: its sandboxes are out of reach now.`
@@ -10969,8 +11175,22 @@ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
10969
11175
  ] : [],
10970
11176
  ` → ${getCliName()} network ls`
10971
11177
  ], deps.log);
11178
+ printPauseHint(device.state, deps);
10972
11179
  return 0;
10973
11180
  }
11181
+ function rearmedLine(state, now = new Date) {
11182
+ if (!state)
11183
+ return "Re-armed.";
11184
+ const until = clockTime(new Date(now.getTime() + state.maxSessionSeconds * 1000).toISOString(), now);
11185
+ return `Re-armed: network access for this device runs until ${until} (pauses after ${formatPauseLimit(state.idleTimeoutSeconds)} idle).`;
11186
+ }
11187
+ function printPauseHint(state, deps) {
11188
+ if (!state || isJsonOutput())
11189
+ return;
11190
+ const [first = "", ...rest] = pauseHint(state);
11191
+ deps.notice(["", bold2(first), ...rest].join(`
11192
+ `));
11193
+ }
10974
11194
  function ipv4Clashes4(registered, deps) {
10975
11195
  const addresses = [registered.network.ingress4, registered.device.address4].filter((a) => typeof a === "string" && isIPv4Address(a));
10976
11196
  const clashes = [];
@@ -10991,17 +11211,41 @@ function clearingNotice(cookieSite) {
10991
11211
  " Reload or close browser tabs opened on short names before this connect."
10992
11212
  ];
10993
11213
  }
11214
+ async function staleResolverFiles(deps) {
11215
+ if (deps.platform !== "darwin")
11216
+ return [];
11217
+ const state = await readDeviceState(deps.dir);
11218
+ const zones = new Set([
11219
+ ...configResolverZones(await readConfig(configPath(deps.dir))),
11220
+ ...state?.zones ?? []
11221
+ ]);
11222
+ return [...zones].map(resolverFile).filter((file) => deps.exists(file));
11223
+ }
10994
11224
  async function runNetworkDisconnect(deps = defaultDeps()) {
10995
- if (!deps.up()) {
10996
- printResult({ connected: false }, () => [eventLine("item", "Not connected.")], deps.log);
10997
- return 0;
11225
+ const wasUp = deps.up();
11226
+ let failed = false;
11227
+ if (wasUp) {
11228
+ const code = await wgQuick(deps, "down");
11229
+ if (code !== 0) {
11230
+ deps.error(`wg-quick exited ${code}.`);
11231
+ failed = true;
11232
+ }
11233
+ }
11234
+ const stale = await staleResolverFiles(deps);
11235
+ if (stale.length) {
11236
+ const args = ["rm", "-f", ...stale];
11237
+ deps.notice(`Removing split DNS left from an earlier connection. Running: sudo ${args.join(" ")}`);
11238
+ const code = await deps.run("sudo", args);
11239
+ if (code !== 0) {
11240
+ deps.error(`Could not remove ${stale.join(", ")} (rm exited ${code}).`);
11241
+ return 1;
11242
+ }
10998
11243
  }
10999
- const code = await wgQuick(deps, "down");
11000
- if (code !== 0) {
11001
- deps.error(`wg-quick exited ${code}.`);
11244
+ if (failed)
11002
11245
  return 1;
11003
- }
11004
- printResult({ connected: false }, () => [eventLine("done", "Disconnected.")], deps.log);
11246
+ printResult({ connected: false, removedSplitDns: stale }, () => [
11247
+ wasUp ? eventLine("done", "Disconnected.") : eventLine(stale.length ? "done" : "item", stale.length ? "Not connected; removed the leftover split DNS." : "Not connected.")
11248
+ ], deps.log);
11005
11249
  return 0;
11006
11250
  }
11007
11251
  async function readConnection(deps) {
@@ -11039,7 +11283,11 @@ async function readConnection(deps) {
11039
11283
  }
11040
11284
  async function runNetworkStatus(deps = defaultDeps()) {
11041
11285
  const { state, up, device, asked, provider, staleMtu } = await readConnection(deps);
11042
- printResult({ connected: up, device, provider, staleMtu }, () => {
11286
+ const staleSplitDns = up ? [] : await staleResolverFiles(deps);
11287
+ for (const file of staleSplitDns) {
11288
+ deps.notice(`Split DNS from an earlier connection is still set (${file}): names of the private network will hang. Run: ${getCliName()} network disconnect`);
11289
+ }
11290
+ printResult({ connected: up, device, provider, staleMtu, staleSplitDns }, () => {
11043
11291
  if (!state) {
11044
11292
  return [eventLine("item", `Not set up. Run \`${getCliName()} network connect\`.`)];
11045
11293
  }
@@ -11049,6 +11297,7 @@ async function runNetworkStatus(deps = defaultDeps()) {
11049
11297
  ];
11050
11298
  if (device) {
11051
11299
  lines.push(` Device ${device.name}, valid until ${device.expiresAt.slice(0, 10)}.`);
11300
+ lines.push(` ${deviceStateText(device.state)}.`);
11052
11301
  if (device.shortNameOrganization) {
11053
11302
  lines.push(` Short names: ${device.shortNameOrganization.slug}'s sandboxes.`);
11054
11303
  }
@@ -11118,7 +11367,7 @@ async function runNetworkDevices(deps = defaultDeps()) {
11118
11367
  try {
11119
11368
  const { devices } = await deps.request("/api/v1/network/devices");
11120
11369
  const own = (await readDeviceState(deps.dir))?.deviceId;
11121
- printResult({ devices }, () => devices.length === 0 ? [eventLine("item", "No devices.")] : devices.map((d) => ` ${d.id === own ? "*" : " "} ${d.id} ${d.name} ${d.address} until ${d.expiresAt.slice(0, 10)}`), deps.log);
11370
+ printResult({ devices }, () => devices.length === 0 ? [eventLine("item", "No devices.")] : devices.map((d) => ` ${d.id === own ? "*" : " "} ${d.id} ${d.name} ${d.address} until ${d.expiresAt.slice(0, 10)} ${deviceStateText(d.state)}`), deps.log);
11122
11371
  return 0;
11123
11372
  } catch (err) {
11124
11373
  deps.error(message(err));
@@ -11140,7 +11389,7 @@ async function runNetworkDevicesRemove(args, deps = defaultDeps()) {
11140
11389
  var connectCommand = defineCommand({
11141
11390
  meta: {
11142
11391
  name: "connect",
11143
- description: "Put this machine into the private network through one provider: WireGuard and split DNS. The organization (--org, else the default) becomes this machine's short-name organization. Asks for sudo once."
11392
+ description: "Put this machine into the private network through one provider: WireGuard and split DNS. The organization (--org, else the default) becomes this machine's short-name organization. Also re-arms a paused device: with the tunnel up and nothing changed, without sudo."
11144
11393
  },
11145
11394
  args: {
11146
11395
  provider: {
@@ -11155,7 +11404,10 @@ var connectCommand = defineCommand({
11155
11404
  }
11156
11405
  });
11157
11406
  var disconnectCommand = defineCommand({
11158
- meta: { name: "disconnect", description: "Take this machine out of the private network." },
11407
+ meta: {
11408
+ name: "disconnect",
11409
+ description: "Take this machine out of the private network, and remove split DNS an earlier connection left (macOS)."
11410
+ },
11159
11411
  async run() {
11160
11412
  process.exitCode = await runNetworkDisconnect();
11161
11413
  }
@@ -12745,7 +12997,7 @@ async function collectPath3(path) {
12745
12997
  await visit(path);
12746
12998
  return files;
12747
12999
  }
12748
- async function runSandboxUpload(options, log = console.log, error = printError) {
13000
+ async function runSandboxUpload(options, log = console.log, error = printError, notice = printNotice, pickers = defaultContainerPickers()) {
12749
13001
  if (options.paths.length === 0 && !options.zip) {
12750
13002
  error("Provide at least one path or --zip.");
12751
13003
  return 1;
@@ -12761,9 +13013,26 @@ async function runSandboxUpload(options, log = console.log, error = printError)
12761
13013
  if (options.zip) {
12762
13014
  form.append("zip", new Blob([await readFile7(options.zip)]), basename5(options.zip));
12763
13015
  }
12764
- const { value: result } = await withProgress("Uploading…", () => authorizedRequest(`/api/v1/sandboxes/${encodeURIComponent(options.sandboxId)}/files?organization=${encodeURIComponent(organization)}`, { method: "POST", body: form }));
13016
+ let container = options.container;
13017
+ const call = {
13018
+ organization,
13019
+ sandboxId: options.sandboxId,
13020
+ canPick: !container,
13021
+ setContainer: (picked) => {
13022
+ container = picked;
13023
+ }
13024
+ };
13025
+ const send = async () => {
13026
+ const query = new URLSearchParams({ organization });
13027
+ if (container)
13028
+ query.set("container", container);
13029
+ const { value } = await withProgress("Uploading…", () => authorizedRequest(`/api/v1/sandboxes/${encodeURIComponent(options.sandboxId)}/files?${query}`, { method: "POST", body: form }));
13030
+ return value;
13031
+ };
13032
+ const result = await withContainerPick(call, () => withContainerStart(call, send, options.start === true, pickers, notice), pickers, notice);
13033
+ const count = `${result.written.length} file${result.written.length === 1 ? "" : "s"}`;
12765
13034
  printResult(result, () => [
12766
- eventLine("done", `Uploaded ${result.written.length} file${result.written.length === 1 ? "" : "s"} to ${options.sandboxId}.`)
13035
+ eventLine("done", result.container ? `Uploaded ${count} to ${result.container} in ${options.sandboxId}.` : `Uploaded ${count} to ${options.sandboxId}.`)
12767
13036
  ], log);
12768
13037
  return 0;
12769
13038
  } catch (err) {
@@ -12787,6 +13056,14 @@ var sandboxUploadCommand = defineCommand({
12787
13056
  required: true
12788
13057
  },
12789
13058
  org: orgArg,
13059
+ container: {
13060
+ type: "string",
13061
+ description: `Container the files land in, by name, from \`${getCliName()} sandboxes containers list\`. Default: the sandbox's default container, else the one running container.`
13062
+ },
13063
+ start: {
13064
+ type: "boolean",
13065
+ description: "Start the container first when it is not running, without asking."
13066
+ },
12790
13067
  zip: {
12791
13068
  type: "string",
12792
13069
  description: "Local ZIP file to upload."
@@ -12802,7 +13079,9 @@ var sandboxUploadCommand = defineCommand({
12802
13079
  sandboxId: args["sandbox-id"],
12803
13080
  org: args.org,
12804
13081
  paths: args._.slice(1),
12805
- zip: args.zip
13082
+ zip: args.zip,
13083
+ container: args.container,
13084
+ start: args.start
12806
13085
  });
12807
13086
  }
12808
13087
  });