ghostdock 0.0.17 → 0.0.18

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
@@ -9183,8 +9183,25 @@ function getCliName(argv = process.argv) {
9183
9183
  const invoked = basename(entry);
9184
9184
  return CLI_NAMES.find((name) => name === invoked) ?? "ghostdock";
9185
9185
  }
9186
+
9187
+ // src/commands/docker.ts
9188
+ import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
9189
+
9190
+ // ../../shared/lib/private-network.ts
9191
+ var ALL_BITS = (1n << 128n) - 1n;
9192
+
9193
+ // ../../shared/lib/docker-access.ts
9194
+ var DOCKER_CERTIFICATE_RENEW_DAYS = 10;
9195
+ var DOCKER_CLIENT_URI_PREFIX = {
9196
+ user: "urn:ghostdock:user:",
9197
+ device: "urn:ghostdock:device:",
9198
+ organization: "urn:ghostdock:org:"
9199
+ };
9200
+ function dockerContextName(orgSlug, sandboxName) {
9201
+ return `ghostdock-${orgSlug}-${sandboxName}`;
9202
+ }
9186
9203
  // package.json
9187
- var version = "0.0.17";
9204
+ var version = "0.0.18";
9188
9205
  var package_default = {
9189
9206
  name: "ghostdock",
9190
9207
  description: "Ghostdock CLI — device login and agent tool invocation from the command line",
@@ -9452,11 +9469,15 @@ function deviceStateText(state, now = new Date) {
9452
9469
  return "Not connected (logged out)";
9453
9470
  if (state.status === "paused" && state.pauseReason) {
9454
9471
  const text = state.pauseReason === "manual" ? "Paused by you" : `Paused: ${devicePauseReasonText(state.pauseReason, state)}`;
9455
- return state.connected ? text : `Not connected (${text.toLowerCase()})`;
9472
+ if (state.connected)
9473
+ return text;
9474
+ return state.connecting ? `${text}, connecting` : `Not connected (${text.toLowerCase()})`;
9456
9475
  }
9457
9476
  const left = state.pausesAt ? new Date(state.pausesAt).getTime() - now.getTime() : 0;
9458
9477
  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)})`;
9478
+ if (state.connected)
9479
+ return active;
9480
+ return state.connecting ? `${active}, connecting` : `Not connected (armed, pauses in ${formatRemaining(Math.max(left, 0) / 1000)})`;
9460
9481
  }
9461
9482
  function formatRemaining(seconds) {
9462
9483
  const minutes = Math.floor(seconds / 60);
@@ -9791,11 +9812,388 @@ async function authorizedRequest(path, init = {}) {
9791
9812
  }
9792
9813
  }
9793
9814
 
9794
- // src/settings.ts
9795
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
9815
+ // src/docker/certificates.ts
9816
+ import { spawnSync } from "node:child_process";
9817
+
9818
+ // src/docker/context.ts
9819
+ function contextDescription(sandbox, orgSlug) {
9820
+ return `Ghostdock sandbox ${sandbox} (${orgSlug}; needs ${getCliName()} network connect)`;
9821
+ }
9822
+ function csvField(field) {
9823
+ return /[",\r\n]/.test(field) ? `"${field.replaceAll('"', '""')}"` : field;
9824
+ }
9825
+ function contextArgs(verb, spec) {
9826
+ const endpoint = [
9827
+ `host=${spec.host}`,
9828
+ `ca=${spec.files.ca}`,
9829
+ `cert=${spec.files.cert}`,
9830
+ `key=${spec.files.key}`
9831
+ ].map(csvField).join(",");
9832
+ return ["context", verb, spec.name, "--description", spec.description, "--docker", endpoint];
9833
+ }
9834
+ function contextHost(exec, name) {
9835
+ const result = exec("docker", [
9836
+ "context",
9837
+ "inspect",
9838
+ "--format",
9839
+ "{{.Endpoints.docker.Host}}",
9840
+ name
9841
+ ]);
9842
+ return result.status === 0 ? result.stdout.trim() : null;
9843
+ }
9844
+ function dockerFailure(args, result) {
9845
+ const said = result.stderr.trim() || result.stdout.trim();
9846
+ return new Error(`docker ${args.slice(0, 3).join(" ")} failed (exit ${result.status})${said ? `: ${said}` : "."}`);
9847
+ }
9848
+ function writeContext(exec, spec) {
9849
+ const created = contextHost(exec, spec.name) === null;
9850
+ const args = contextArgs(created ? "create" : "update", spec);
9851
+ const result = exec("docker", args);
9852
+ if (result.status !== 0)
9853
+ throw dockerFailure(args, result);
9854
+ return { created };
9855
+ }
9856
+ function useContext(exec, name) {
9857
+ const args = ["context", "use", name];
9858
+ const result = exec("docker", args);
9859
+ if (result.status !== 0)
9860
+ throw dockerFailure(args, result);
9861
+ }
9862
+
9863
+ // src/docker/csr.ts
9864
+ import { createPublicKey as createPublicKey2, sign } from "node:crypto";
9865
+ var TAG = {
9866
+ integer: 2,
9867
+ bitString: 3,
9868
+ utf8String: 12,
9869
+ sequence: 48,
9870
+ set: 49,
9871
+ context0: 160
9872
+ };
9873
+ var OID_COMMON_NAME = Buffer.from("0603550403", "hex");
9874
+ var OID_ECDSA_WITH_SHA256 = Buffer.from("06082a8648ce3d040302", "hex");
9875
+ function der(tag, content) {
9876
+ const length = content.length;
9877
+ if (length < 128)
9878
+ return Buffer.concat([Buffer.from([tag, length]), content]);
9879
+ const bytes = [];
9880
+ for (let rest = length;rest > 0; rest >>= 8)
9881
+ bytes.unshift(rest & 255);
9882
+ return Buffer.concat([Buffer.from([tag, 128 | bytes.length, ...bytes]), content]);
9883
+ }
9884
+ function csrSubject(deviceId) {
9885
+ return `ghostdock device ${deviceId}`;
9886
+ }
9887
+ function buildCsr(privateKey, deviceId) {
9888
+ const spki = createPublicKey2(privateKey).export({ type: "spki", format: "der" });
9889
+ const commonName = der(TAG.sequence, Buffer.concat([OID_COMMON_NAME, der(TAG.utf8String, Buffer.from(csrSubject(deviceId)))]));
9890
+ const subject = der(TAG.sequence, der(TAG.set, commonName));
9891
+ const info = der(TAG.sequence, Buffer.concat([
9892
+ der(TAG.integer, Buffer.from([0])),
9893
+ subject,
9894
+ spki,
9895
+ der(TAG.context0, Buffer.alloc(0))
9896
+ ]));
9897
+ const signature = sign("sha256", info, { key: privateKey, dsaEncoding: "der" });
9898
+ return der(TAG.sequence, Buffer.concat([
9899
+ info,
9900
+ der(TAG.sequence, OID_ECDSA_WITH_SHA256),
9901
+ der(TAG.bitString, Buffer.concat([Buffer.from([0]), signature]))
9902
+ ]));
9903
+ }
9904
+ function buildCsrPem(privateKey, deviceId) {
9905
+ const base64 = buildCsr(privateKey, deviceId).toString("base64");
9906
+ const lines = base64.match(/.{1,64}/g) ?? [];
9907
+ return `-----BEGIN CERTIFICATE REQUEST-----
9908
+ ${lines.join(`
9909
+ `)}
9910
+ -----END CERTIFICATE REQUEST-----
9911
+ `;
9912
+ }
9913
+
9914
+ // src/docker/store.ts
9915
+ import {
9916
+ createPrivateKey as createPrivateKey2,
9917
+ generateKeyPairSync as generateKeyPairSync2,
9918
+ randomBytes,
9919
+ X509Certificate
9920
+ } from "node:crypto";
9921
+ import { chmod as chmod3, link, mkdir as mkdir3, readdir, readFile as readFile3, rename, unlink, writeFile as writeFile3 } from "node:fs/promises";
9796
9922
  import { dirname as dirname3, join as join3 } from "node:path";
9923
+ function dockerDirFor(networkDir) {
9924
+ return join3(dirname3(networkDir), "docker");
9925
+ }
9926
+ function isOrganizationId(id) {
9927
+ return /^[A-Za-z0-9_-]{1,128}$/.test(id);
9928
+ }
9929
+ function organizationFiles(dockerDir, organizationId) {
9930
+ if (!isOrganizationId(organizationId)) {
9931
+ throw new Error(`Not an organization id: ${JSON.stringify(organizationId)}`);
9932
+ }
9933
+ const dir = join3(dockerDir, organizationId);
9934
+ return {
9935
+ dir,
9936
+ key: join3(dir, "key.pem"),
9937
+ cert: join3(dir, "cert.pem"),
9938
+ ca: join3(dir, "ca.pem"),
9939
+ meta: join3(dir, "certificate.json")
9940
+ };
9941
+ }
9942
+ async function listOrganizationIds(dockerDir) {
9943
+ try {
9944
+ const entries = await readdir(dockerDir, { withFileTypes: true });
9945
+ return entries.filter((entry) => entry.isDirectory() && isOrganizationId(entry.name)).map((entry) => entry.name).sort();
9946
+ } catch (err) {
9947
+ if (err.code === "ENOENT")
9948
+ return [];
9949
+ throw err;
9950
+ }
9951
+ }
9952
+ function tempPath(path) {
9953
+ return `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
9954
+ }
9955
+ async function writeFileAtomic(path, contents, mode) {
9956
+ await mkdir3(dirname3(path), { recursive: true, mode: 448 });
9957
+ const temp = tempPath(path);
9958
+ try {
9959
+ await writeFile3(temp, contents, { mode });
9960
+ await chmod3(temp, mode);
9961
+ await rename(temp, path);
9962
+ } catch (err) {
9963
+ await unlink(temp).catch(() => {});
9964
+ throw err;
9965
+ }
9966
+ }
9967
+ function isP256(key) {
9968
+ return key.asymmetricKeyType === "ec" && key.asymmetricKeyDetails?.namedCurve === "prime256v1";
9969
+ }
9970
+ async function loadOrCreateDockerKey(files) {
9971
+ const existing = await readDockerKey(files);
9972
+ if (existing)
9973
+ return existing;
9974
+ const { privateKey } = generateKeyPairSync2("ec", { namedCurve: "prime256v1" });
9975
+ const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
9976
+ await mkdir3(files.dir, { recursive: true, mode: 448 });
9977
+ const temp = tempPath(files.key);
9978
+ try {
9979
+ await writeFile3(temp, pem, { mode: 384 });
9980
+ await chmod3(temp, 384);
9981
+ await link(temp, files.key);
9982
+ } catch (err) {
9983
+ if (err.code !== "EEXIST")
9984
+ throw err;
9985
+ const created = await readDockerKey(files);
9986
+ if (created)
9987
+ return created;
9988
+ throw err;
9989
+ } finally {
9990
+ await unlink(temp).catch(() => {});
9991
+ }
9992
+ return privateKey;
9993
+ }
9994
+ async function readDockerKey(files) {
9995
+ let pem;
9996
+ try {
9997
+ pem = await readFile3(files.key, "utf8");
9998
+ } catch (err) {
9999
+ if (err.code === "ENOENT")
10000
+ return null;
10001
+ throw err;
10002
+ }
10003
+ const key = createPrivateKey2(pem);
10004
+ if (!isP256(key))
10005
+ throw new Error(`${files.key} is not an ECDSA P-256 key.`);
10006
+ return key;
10007
+ }
10008
+ function uriSan(certificate, prefix) {
10009
+ for (const name of (certificate.subjectAltName ?? "").split(/,\s*/)) {
10010
+ if (name.startsWith(`URI:${prefix}`))
10011
+ return name.slice(`URI:${prefix}`.length);
10012
+ }
10013
+ return null;
10014
+ }
10015
+ function parseClientCertificate(pem, key) {
10016
+ const certificate = new X509Certificate(pem);
10017
+ if (!certificate.checkPrivateKey(key)) {
10018
+ throw new Error("The certificate is not for this machine's Docker key.");
10019
+ }
10020
+ return {
10021
+ pem,
10022
+ serial: certificate.serialNumber.toLowerCase(),
10023
+ notAfter: new Date(certificate.validTo),
10024
+ deviceId: uriSan(certificate, DOCKER_CLIENT_URI_PREFIX.device),
10025
+ organizationId: uriSan(certificate, DOCKER_CLIENT_URI_PREFIX.organization)
10026
+ };
10027
+ }
10028
+ async function readClientCertificate(files, key) {
10029
+ try {
10030
+ const [pem] = await Promise.all([readFile3(files.cert, "utf8"), readFile3(files.ca, "utf8")]);
10031
+ return parseClientCertificate(pem, key);
10032
+ } catch {
10033
+ return null;
10034
+ }
10035
+ }
10036
+ var DAY_MS = 24 * 60 * 60 * 1000;
10037
+ function certificateDue(certificate, deviceId, now) {
10038
+ return certificate.deviceId !== deviceId || certificate.notAfter.getTime() - now.getTime() < DOCKER_CERTIFICATE_RENEW_DAYS * DAY_MS;
10039
+ }
10040
+ async function readCertificateMeta(files) {
10041
+ try {
10042
+ const parsed = JSON.parse(await readFile3(files.meta, "utf8"));
10043
+ if (typeof parsed.baseUrl === "string" && typeof parsed.organization?.id === "string" && typeof parsed.organization.slug === "string") {
10044
+ return {
10045
+ baseUrl: parsed.baseUrl,
10046
+ organization: { id: parsed.organization.id, slug: parsed.organization.slug },
10047
+ serial: typeof parsed.serial === "string" ? parsed.serial : "",
10048
+ notAfter: typeof parsed.notAfter === "string" ? parsed.notAfter : ""
10049
+ };
10050
+ }
10051
+ } catch {}
10052
+ return null;
10053
+ }
10054
+ async function writeIssuedCertificate(files, issued) {
10055
+ await writeFileAtomic(files.ca, issued.caPem, 420);
10056
+ await writeFileAtomic(files.cert, issued.certificatePem, 420);
10057
+ await writeFileAtomic(files.meta, `${JSON.stringify(issued.meta, null, 2)}
10058
+ `, 420);
10059
+ }
10060
+ function contextsPath(dockerDir) {
10061
+ return join3(dockerDir, "contexts.json");
10062
+ }
10063
+ async function readContexts(dockerDir) {
10064
+ try {
10065
+ const parsed = JSON.parse(await readFile3(contextsPath(dockerDir), "utf8"));
10066
+ return (parsed.contexts ?? []).filter((c) => typeof c?.name === "string" && typeof c.organizationId === "string" && typeof c.sandbox === "string");
10067
+ } catch {
10068
+ return [];
10069
+ }
10070
+ }
10071
+ async function writeContexts(dockerDir, contexts) {
10072
+ const unique = [...new Map(contexts.map((c) => [c.name, c])).values()].sort((a, b) => a.name.localeCompare(b.name));
10073
+ await writeFileAtomic(contextsPath(dockerDir), `${JSON.stringify({ contexts: unique }, null, 2)}
10074
+ `, 420);
10075
+ }
10076
+ async function recordContext(dockerDir, context) {
10077
+ const others = (await readContexts(dockerDir)).filter((c) => c.name !== context.name);
10078
+ await writeContexts(dockerDir, [...others, context]);
10079
+ }
10080
+
10081
+ // src/docker/certificates.ts
10082
+ function execDocker(command, args) {
10083
+ const result = spawnSync(command, args, { encoding: "utf8" });
10084
+ return {
10085
+ status: result.status ?? 1,
10086
+ stdout: result.stdout ?? "",
10087
+ stderr: result.stderr ?? result.error?.message ?? ""
10088
+ };
10089
+ }
10090
+ async function issueCertificate(input, request) {
10091
+ const body = {
10092
+ organization: input.organizationId,
10093
+ deviceId: input.deviceId,
10094
+ csrPem: buildCsrPem(input.key, input.deviceId)
10095
+ };
10096
+ const issued = await request("/api/v1/docker/certificates", {
10097
+ method: "POST",
10098
+ body: JSON.stringify(body)
10099
+ });
10100
+ const certificate = parseClientCertificate(issued.certificatePem, input.key);
10101
+ if (certificate.deviceId !== input.deviceId || issued.organization.id !== input.organizationId || certificate.organizationId !== null && certificate.organizationId !== input.organizationId) {
10102
+ throw new Error("The app sent a certificate for another device or organization: not stored.");
10103
+ }
10104
+ const meta = {
10105
+ baseUrl: input.baseUrl,
10106
+ organization: issued.organization,
10107
+ serial: certificate.serial,
10108
+ notAfter: certificate.notAfter.toISOString()
10109
+ };
10110
+ await writeIssuedCertificate(input.files, {
10111
+ certificatePem: issued.certificatePem,
10112
+ caPem: issued.caPem,
10113
+ meta
10114
+ });
10115
+ return { certificate, meta };
10116
+ }
10117
+ async function ensureCertificate(input, request) {
10118
+ const key = await loadOrCreateDockerKey(input.files);
10119
+ const stored = await readClientCertificate(input.files, key);
10120
+ if (stored && !certificateDue(stored, input.deviceId, input.now)) {
10121
+ return { certificate: stored, issued: false };
10122
+ }
10123
+ const { certificate } = await issueCertificate({ ...input, key }, request);
10124
+ return { certificate, issued: true };
10125
+ }
10126
+ function message(err) {
10127
+ return err instanceof Error ? err.message : String(err);
10128
+ }
10129
+ function day(date) {
10130
+ return date.toISOString().slice(0, 10);
10131
+ }
10132
+ async function renewDockerCertificates(deps, options = {}) {
10133
+ const exec = deps.exec ?? execDocker;
10134
+ const dockerDir = deps.dockerDir ?? dockerDirFor(deps.dir);
10135
+ const now = (deps.now ?? (() => new Date))();
10136
+ let organizationIds;
10137
+ let state;
10138
+ try {
10139
+ organizationIds = (await listOrganizationIds(dockerDir)).filter((id) => id !== options.except);
10140
+ state = organizationIds.length > 0 ? await readDeviceState(deps.dir) : null;
10141
+ } catch (err) {
10142
+ deps.notice(`Could not check the Docker certificates: ${message(err)}`);
10143
+ return;
10144
+ }
10145
+ if (!state || !deps.has("docker"))
10146
+ return;
10147
+ const deviceId = state.deviceId;
10148
+ let contexts = await readContexts(dockerDir);
10149
+ let contextsChanged = false;
10150
+ for (const organizationId of organizationIds) {
10151
+ const files = organizationFiles(dockerDir, organizationId);
10152
+ const meta = await readCertificateMeta(files);
10153
+ const label = meta?.organization.slug ?? organizationId;
10154
+ try {
10155
+ if (meta && meta.baseUrl !== state.baseUrl)
10156
+ continue;
10157
+ const key = await readDockerKey(files);
10158
+ const stored = key ? await readClientCertificate(files, key) : null;
10159
+ if (!key || !stored || !certificateDue(stored, deviceId, now))
10160
+ continue;
10161
+ const { certificate } = await issueCertificate({ files, key, organizationId, deviceId, baseUrl: state.baseUrl }, deps.request);
10162
+ const updated = [];
10163
+ for (const context of contexts.filter((c) => c.organizationId === organizationId)) {
10164
+ const host = contextHost(exec, context.name);
10165
+ if (host === null) {
10166
+ contexts = contexts.filter((c) => c.name !== context.name);
10167
+ contextsChanged = true;
10168
+ continue;
10169
+ }
10170
+ writeContext(exec, {
10171
+ name: context.name,
10172
+ description: contextDescription(context.sandbox, label),
10173
+ host,
10174
+ files
10175
+ });
10176
+ updated.push(context.name);
10177
+ }
10178
+ deps.notice(`Renewed the Docker certificate for ${label}, valid until ${day(certificate.notAfter)}${updated.length > 0 ? `; updated ${updated.join(", ")}` : ""}.`);
10179
+ } catch (err) {
10180
+ deps.notice(`Could not renew the Docker certificate for ${label} (${message(err)}). Run \`${getCliName()} docker context <sandbox> --org ${label}\` to try again.`);
10181
+ }
10182
+ }
10183
+ if (contextsChanged) {
10184
+ try {
10185
+ await writeContexts(dockerDir, contexts);
10186
+ } catch (err) {
10187
+ deps.notice(`Could not update ${dockerDir}/contexts.json: ${message(err)}`);
10188
+ }
10189
+ }
10190
+ }
10191
+
10192
+ // src/settings.ts
10193
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises";
10194
+ import { dirname as dirname4, join as join4 } from "node:path";
9797
10195
  function getSettingsPath() {
9798
- return join3(dirname3(getCredentialsPath()), "settings.json");
10196
+ return join4(dirname4(getCredentialsPath()), "settings.json");
9799
10197
  }
9800
10198
  function isDefaultOrganization(value) {
9801
10199
  if (!value || typeof value !== "object")
@@ -9805,15 +10203,15 @@ function isDefaultOrganization(value) {
9805
10203
  }
9806
10204
  async function readSettings(path = getSettingsPath()) {
9807
10205
  try {
9808
- const parsed = JSON.parse(await readFile3(path, "utf8"));
10206
+ const parsed = JSON.parse(await readFile4(path, "utf8"));
9809
10207
  return isDefaultOrganization(parsed.defaultOrganization) ? { defaultOrganization: parsed.defaultOrganization } : {};
9810
10208
  } catch {
9811
10209
  return {};
9812
10210
  }
9813
10211
  }
9814
10212
  async function writeSettings(settings, path = getSettingsPath()) {
9815
- await mkdir3(dirname3(path), { recursive: true });
9816
- await writeFile3(path, `${JSON.stringify(settings, null, 2)}
10213
+ await mkdir4(dirname4(path), { recursive: true });
10214
+ await writeFile4(path, `${JSON.stringify(settings, null, 2)}
9817
10215
  `, "utf8");
9818
10216
  }
9819
10217
 
@@ -9878,1588 +10276,1769 @@ async function resolveOrganizationId(flag) {
9878
10276
  return (await findMyOrganization(await requireOrganizationRef(flag))).id;
9879
10277
  }
9880
10278
 
9881
- // src/commands/env.ts
9882
- function reportError(err, error) {
9883
- if (err instanceof AuthRequiredError || err instanceof Error) {
9884
- error(err.message);
9885
- } else {
9886
- error(String(err));
9887
- }
9888
- return 1;
10279
+ // src/commands/network.ts
10280
+ import { spawn, spawnSync as spawnSync2 } from "node:child_process";
10281
+ import { existsSync as existsSync2 } from "node:fs";
10282
+ import { hostname } from "node:os";
10283
+ function defaultDeps() {
10284
+ return {
10285
+ request: authorizedRequest,
10286
+ run: (command, args) => new Promise((resolve, reject) => {
10287
+ const child = spawn(command, args, { stdio: "inherit" });
10288
+ child.on("error", reject);
10289
+ child.on("close", (code) => resolve(code ?? 1));
10290
+ }),
10291
+ output: (command, args) => {
10292
+ const result = spawnSync2(command, args, { encoding: "utf8" });
10293
+ return result.status === 0 ? result.stdout : "";
10294
+ },
10295
+ platform: process.platform,
10296
+ has: (command) => spawnSync2("sh", ["-c", `command -v ${command}`]).status === 0,
10297
+ dir: networkDir(),
10298
+ up: () => tunnelUp(),
10299
+ exists: existsSync2,
10300
+ log: console.log,
10301
+ error: printError,
10302
+ notice: printNotice
10303
+ };
9889
10304
  }
9890
- function invokeTool(tool, input) {
9891
- return authorizedRequest(`/api/v1/tools/${tool}`, {
9892
- method: "POST",
9893
- body: JSON.stringify(input)
9894
- });
10305
+ function message2(err) {
10306
+ return err instanceof Error ? err.message : String(err);
9895
10307
  }
9896
- function displayValue(entry) {
9897
- if (entry.valueOmittedReason === "secret")
9898
- return "<secret>";
9899
- if (entry.valueOmittedReason === "too_large") {
9900
- return `<${entry.valueLength} chars — read with \`${getCliName()} env get\`>`;
9901
- }
9902
- return entry.value ?? "";
10308
+ function connectCommandFor(provider) {
10309
+ const ref = provider.connectRef;
10310
+ const arg = /^[A-Za-z0-9._-]+$/.test(ref) ? ref : JSON.stringify(ref);
10311
+ return `${getCliName()} network connect ${arg}`;
9903
10312
  }
9904
- function formatTable(entries) {
9905
- const rows = entries.map((entry) => [
9906
- entry.name,
9907
- entry.kind,
9908
- displayValue(entry),
9909
- entry.comment ?? ""
9910
- ]);
9911
- const header = ["NAME", "KIND", "VALUE", "COMMENT"];
9912
- const widths = header.map((_, column) => Math.max(header[column]?.length ?? 0, ...rows.map((row) => row[column]?.length ?? 0)));
9913
- const line = (cells) => cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
9914
- return [line(header), ...rows.map(line)].join(`
9915
- `);
10313
+ async function wgQuick(deps, verb) {
10314
+ const args = ["wg-quick", verb, configPath(deps.dir)];
10315
+ deps.notice(`Running: sudo ${args.join(" ")}`);
10316
+ return deps.run("sudo", args);
9916
10317
  }
9917
- async function readStdin() {
9918
- const chunks = [];
9919
- for await (const chunk of process.stdin) {
9920
- chunks.push(Buffer.from(chunk));
10318
+ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
10319
+ suppressPausedNotice();
10320
+ if (!deps.has("wg-quick")) {
10321
+ deps.error(`wg-quick is not installed. ${installHint()}`);
10322
+ return 1;
9921
10323
  }
9922
- return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
9923
- }
9924
- async function runEnvList(args, log = console.log, error = printError) {
10324
+ let registered;
10325
+ let privateKey;
10326
+ const previous = await readDeviceState(deps.dir);
9925
10327
  try {
9926
- const result = await invokeTool("env_list", {
9927
- organization: args.organization
10328
+ const organization = await requireOrganizationRef(args.org, deps.notice);
10329
+ const provider = args.provider?.trim();
10330
+ const key = await loadOrCreateKey(deps.dir);
10331
+ privateKey = key.privateKey;
10332
+ registered = await deps.request("/api/v1/network/devices", {
10333
+ method: "POST",
10334
+ body: JSON.stringify({
10335
+ publicKey: key.publicKey,
10336
+ name: hostname().slice(0, 64) || "device",
10337
+ organization,
10338
+ ...provider ? { provider } : {}
10339
+ })
9928
10340
  });
9929
- if (args.json) {
9930
- log(JSON.stringify(result.entries, null, 2));
9931
- return 0;
9932
- }
9933
- if (result.entries.length === 0) {
9934
- log(" No variables or secrets.");
9935
- return 0;
9936
- }
9937
- log(formatTable(result.entries));
9938
- return 0;
9939
10341
  } catch (err) {
9940
- return reportError(err, error);
10342
+ deps.error(message2(err));
10343
+ return 1;
9941
10344
  }
9942
- }
9943
- async function runEnvGet(args, log = console.log, error = printError) {
10345
+ const dns = detectDnsMode();
10346
+ let config;
9944
10347
  try {
9945
- const result = await invokeTool("env_get", {
9946
- organization: args.organization,
9947
- name: args.name
9948
- });
9949
- log(args.json ? JSON.stringify(result.entry, null, 2) : result.entry.value ?? "");
9950
- return 0;
10348
+ config = renderConfig({ privateKey, registered, dns });
9951
10349
  } catch (err) {
9952
- return reportError(err, error);
9953
- }
9954
- }
9955
- function resolveKind(args) {
9956
- if (args.kind !== undefined && args.kind !== "variable" && args.kind !== "secret") {
9957
- return { error: `--kind must be "variable" or "secret", got "${args.kind}".` };
9958
- }
9959
- const fromKind = args.kind;
9960
- if (args.secret && fromKind === "variable") {
9961
- return { error: "Pass either --secret or --kind variable, not both." };
9962
- }
9963
- return { kind: fromKind ?? (args.secret ? "secret" : undefined) };
9964
- }
9965
- async function runEnvSet(args, log = console.log, error = printError, stdin = readStdin) {
9966
- if (args.value !== undefined && args.valueStdin) {
9967
- error("Pass either --value or --value-stdin, not both.");
9968
- return 1;
9969
- }
9970
- if (args.value === undefined && !args.valueStdin) {
9971
- error("Provide --value, or --value-stdin to read the value from stdin.");
9972
- return 1;
9973
- }
9974
- const resolved = resolveKind(args);
9975
- if ("error" in resolved) {
9976
- error(resolved.error);
10350
+ deps.error(message2(err));
9977
10351
  return 1;
9978
10352
  }
9979
- const value = args.valueStdin ? await stdin() : args.value ?? "";
9980
- try {
9981
- const result = await invokeTool("env_set", {
9982
- organization: args.organization,
9983
- name: args.name,
9984
- value,
9985
- ...resolved.kind ? { kind: resolved.kind } : {},
9986
- ...args.comment ? { comment: args.comment } : {}
9987
- });
9988
- if (args.json) {
9989
- log(JSON.stringify(result, null, 2));
9990
- return 0;
9991
- }
9992
- log(eventLine("done", `${result.created ? "Created" : "Updated"} ${result.entry.kind} ${result.entry.name}.`));
9993
- if (result.previousKind === "secret") {
9994
- log(eventLine("item", `Warning: ${result.entry.name} was a secret and is now a variable — its value is readable by everyone in the organization.`));
9995
- } else if (result.previousKind === "variable") {
9996
- log(eventLine("item", `${result.entry.name} was a variable and is now a secret; its value is no longer shown.`));
9997
- }
10353
+ const previousConfig = await readConfig(configPath(deps.dir));
10354
+ await writeConfig(config, configPath(deps.dir));
10355
+ await writeDeviceState({
10356
+ baseUrl: getBaseUrl(),
10357
+ deviceId: registered.device.id,
10358
+ address: registered.device.address ?? "",
10359
+ gatewayProvider: registered.provider,
10360
+ zones: [
10361
+ registered.network.zone,
10362
+ ...registered.network.netZone ? [registered.network.netZone] : []
10363
+ ]
10364
+ }, deps.dir);
10365
+ const wasUp = deps.up();
10366
+ if (wasUp && previousConfig === config) {
10367
+ const { device, network, provider, shortNameOrganizationChanged } = registered;
10368
+ printResult({
10369
+ device,
10370
+ network,
10371
+ provider,
10372
+ shortNameOrganizationChanged,
10373
+ interface: INTERFACE,
10374
+ dns,
10375
+ rearmed: true,
10376
+ pause: device.state
10377
+ }, () => [
10378
+ eventLine("done", rearmedLine(device.state)),
10379
+ ...network.netZone && device.shortNameOrganization && shortNameOrganizationChanged ? clearingNotice(network.netCookieSite) : []
10380
+ ], deps.log);
10381
+ printPauseHint(device.state, deps);
10382
+ await renewDockerCertificates(deps);
9998
10383
  return 0;
9999
- } catch (err) {
10000
- return reportError(err, error);
10001
10384
  }
10002
- }
10003
- async function runEnvDelete(args, log = console.log, error = printError) {
10004
- try {
10005
- await invokeTool("env_delete", {
10006
- organization: args.organization,
10007
- name: args.name
10008
- });
10009
- log(eventLine("done", `Deleted ${args.name}.`));
10010
- return 0;
10011
- } catch (err) {
10012
- return reportError(err, error);
10385
+ if (wasUp) {
10386
+ const down = await wgQuick(deps, "down");
10387
+ if (down !== 0) {
10388
+ deps.error(`Could not take the old tunnel down (wg-quick exited ${down}).`);
10389
+ return 1;
10390
+ }
10013
10391
  }
10014
- }
10015
- var nameArg = {
10016
- type: "positional",
10017
- description: "Environment variable name.",
10018
- required: true
10019
- };
10020
- var envCommand = defineCommand({
10021
- meta: {
10022
- name: "env",
10023
- description: "Manage organization environment variables and secrets."
10024
- },
10025
- subCommands: {
10026
- list: defineCommand({
10027
- meta: { name: "list", description: "List variables and secrets." },
10028
- args: {
10029
- org: orgArg
10030
- },
10031
- async run({ args }) {
10032
- process.exitCode = await withOrganization(args.org, (organization) => runEnvList({
10033
- organization,
10034
- json: isJsonOutput()
10035
- }));
10036
- }
10037
- }),
10038
- get: defineCommand({
10039
- meta: {
10040
- name: "get",
10041
- description: "Print one variable value in full (secrets cannot be read)."
10042
- },
10043
- args: {
10044
- name: nameArg,
10045
- org: orgArg
10046
- },
10047
- async run({ args }) {
10048
- process.exitCode = await withOrganization(args.org, (organization) => runEnvGet({
10049
- organization,
10050
- name: args.name,
10051
- json: isJsonOutput()
10052
- }));
10053
- }
10054
- }),
10055
- set: defineCommand({
10056
- meta: { name: "set", description: "Create or replace a variable or secret." },
10057
- args: {
10058
- name: nameArg,
10059
- org: orgArg,
10060
- value: { type: "string", description: "Value to store." },
10061
- "value-stdin": {
10062
- type: "boolean",
10063
- description: "Read the value from stdin — keeps secrets out of shell history.",
10064
- default: false
10065
- },
10066
- secret: {
10067
- type: "boolean",
10068
- description: "Shorthand for --kind secret. Required when creating a secret.",
10069
- default: false
10070
- },
10071
- kind: {
10072
- type: "string",
10073
- description: "variable or secret. Required when creating; on an update, omit to keep the existing kind or pass the other to convert."
10074
- },
10075
- comment: { type: "string", description: "Note about what this value is for." }
10076
- },
10077
- async run({ args }) {
10078
- process.exitCode = await withOrganization(args.org, (organization) => runEnvSet({
10079
- organization,
10080
- name: args.name,
10081
- value: args.value,
10082
- valueStdin: args["value-stdin"],
10083
- secret: args.secret,
10084
- kind: args.kind,
10085
- comment: args.comment,
10086
- json: isJsonOutput()
10087
- }));
10088
- }
10089
- }),
10090
- delete: defineCommand({
10091
- meta: { name: "delete", description: "Delete a variable or secret." },
10092
- args: {
10093
- name: nameArg,
10094
- org: orgArg
10095
- },
10096
- async run({ args }) {
10097
- process.exitCode = await withOrganization(args.org, (organization) => runEnvDelete({
10098
- organization,
10099
- name: args.name
10100
- }));
10101
- }
10102
- })
10392
+ const ipv4Clashes = ipv4Clashes4(registered, deps);
10393
+ const code = await wgQuick(deps, "up");
10394
+ if (code !== 0) {
10395
+ deps.error(`wg-quick exited ${code}. The config is in ${configPath(deps.dir)}.`);
10396
+ return 1;
10103
10397
  }
10104
- });
10105
-
10106
- // src/container-picker.ts
10107
- import { createInterface } from "node:readline/promises";
10108
- function notRunningContainerOf(error) {
10109
- if (!(error instanceof ApiError) || !error.data || typeof error.data !== "object")
10110
- return null;
10111
- const { code, params } = error.data;
10112
- if (code !== "container_not_running")
10113
- return null;
10114
- const container = params && typeof params === "object" ? params : {};
10115
- return typeof container.container === "string" && container.container ? container.container : null;
10398
+ const { device, network, provider, shortNameOrganizationChanged } = registered;
10399
+ const pinned = device.shortNameOrganization;
10400
+ const switched = wasUp && previous?.gatewayProvider && previous.gatewayProvider.id !== provider.id ? previous.gatewayProvider : null;
10401
+ printResult({
10402
+ device,
10403
+ network,
10404
+ provider,
10405
+ shortNameOrganizationChanged,
10406
+ interface: INTERFACE,
10407
+ dns,
10408
+ rearmed: false,
10409
+ pause: device.state
10410
+ }, () => [
10411
+ eventLine("done", `Connected through ${provider.name}. This machine is ${device.address} in the private network.`),
10412
+ ...switched ? [
10413
+ ` Replaced the connection through ${switched.name}: its sandboxes are out of reach now.`
10414
+ ] : [],
10415
+ ` Reachable: the sandboxes on ${provider.name}. One provider at a time.`,
10416
+ ` Names: <sandbox>.<org>.${network.zone}`,
10417
+ ...network.netZone ? [` In a browser: https://<sandbox>--<org>.${network.netZone}:<port>`] : [],
10418
+ ...network.netZone && pinned ? [
10419
+ ` Short names: https://<sandbox>.${network.netZone}:<port> for ${pinned.slug}'s sandboxes, on this machine.`,
10420
+ ` Another organization: ${getCliName()} network connect --org <org>.`
10421
+ ] : [],
10422
+ ...network.netZone && pinned && shortNameOrganizationChanged ? clearingNotice(network.netCookieSite) : [],
10423
+ ...network.ingress4 && !device.address4 ? [
10424
+ " No IPv4 address was free on this provider's host, so browser names work over IPv6 only:",
10425
+ " Chrome on a network without IPv6 cannot open them on this machine. Firefox and Safari",
10426
+ " can. Run connect again later: an address frees up when another device goes."
10427
+ ] : [],
10428
+ ...ipv4Clashes.map(({ address, owner }) => ` ${address} is already routed through ${owner} on this machine: browsers may not reach the private network's names over IPv4 while it is. Ask your Ghostdock admin about the IPv4 block.`),
10429
+ ...dns === "none" ? [
10430
+ " No split DNS on this machine (no systemd-resolved): use the addresses from",
10431
+ ` \`${getCliName()} network ls\`.`
10432
+ ] : [],
10433
+ ` → ${getCliName()} network ls`
10434
+ ], deps.log);
10435
+ printPauseHint(device.state, deps);
10436
+ await renewDockerCertificates(deps);
10437
+ return 0;
10116
10438
  }
10117
- function containerQuestionOf(error) {
10118
- if (!(error instanceof ApiError) || !error.data || typeof error.data !== "object")
10119
- return null;
10120
- const { code, params } = error.data;
10121
- if (code !== "container_choice_required" && code !== "container_default_missing")
10122
- return null;
10123
- const record = params && typeof params === "object" ? params : {};
10124
- const running = typeof record.containers === "string" ? record.containers.split(",").map((name) => name.trim()).filter(Boolean) : [];
10125
- if (running.length === 0)
10126
- return null;
10127
- return {
10128
- running,
10129
- ...code === "container_default_missing" && typeof record.container === "string" ? { missingDefault: record.container } : {}
10130
- };
10439
+ function rearmedLine(state, now = new Date) {
10440
+ if (!state)
10441
+ return "Re-armed.";
10442
+ const until = clockTime(new Date(now.getTime() + state.maxSessionSeconds * 1000).toISOString(), now);
10443
+ return `Re-armed: network access for this device runs until ${until} (pauses after ${formatPauseLimit(state.idleTimeoutSeconds)} idle).`;
10131
10444
  }
10132
- var say = (line) => process.stderr.write(`${line}
10133
- `);
10134
- var chooseContainerOnTerminal = async ({ running, missingDefault }) => {
10135
- if (missingDefault) {
10136
- say(`
10137
- The default container ${missingDefault} is gone.`);
10445
+ function printPauseHint(state, deps) {
10446
+ if (!state || isJsonOutput())
10447
+ return;
10448
+ const [first = "", ...rest] = pauseHint(state);
10449
+ deps.notice(["", bold2(first), ...rest].join(`
10450
+ `));
10451
+ }
10452
+ function ipv4Clashes4(registered, deps) {
10453
+ const addresses = [registered.network.ingress4, registered.device.address4].filter((a) => typeof a === "string" && isIPv4Address(a));
10454
+ const clashes = [];
10455
+ for (const address of addresses) {
10456
+ const [command, args] = ipv4RouteCommand(deps.platform, address);
10457
+ const owner = ipv4RouteOwner(deps.platform, deps.output(command, args));
10458
+ if (owner)
10459
+ clashes.push({ address, owner });
10138
10460
  }
10139
- say(missingDefault ? "Which container?" : `
10140
- Several containers are running. Which one?`);
10141
- running.forEach((name, index) => {
10142
- say(` ${index + 1}. ${name}`);
10143
- });
10144
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
10145
- try {
10146
- const answer = (await prompt.question("Number (Enter to cancel): ")).trim();
10147
- const index = Number.parseInt(answer, 10) - 1;
10148
- return Number.isInteger(index) && index >= 0 && index < running.length ? running[index] ?? null : null;
10149
- } finally {
10150
- prompt.close();
10461
+ return clashes;
10462
+ }
10463
+ function clearingNotice(cookieSite) {
10464
+ const site = cookieSite?.site ?? "the net domain";
10465
+ return [
10466
+ " Short names meant another organization's sandboxes before. The first short-name site each",
10467
+ ` browser opens now clears its data once: the site's storage, and every cookie of ${site}.`,
10468
+ ` That signs the browser out of every site under ${site}${cookieSite?.includesApp ? ", the Ghostdock app included" : ""}.`,
10469
+ " Reload or close browser tabs opened on short names before this connect."
10470
+ ];
10471
+ }
10472
+ async function staleResolverFiles(deps) {
10473
+ if (deps.platform !== "darwin")
10474
+ return [];
10475
+ const state = await readDeviceState(deps.dir);
10476
+ const zones = new Set([
10477
+ ...configResolverZones(await readConfig(configPath(deps.dir))),
10478
+ ...state?.zones ?? []
10479
+ ]);
10480
+ return [...zones].map(resolverFile).filter((file) => deps.exists(file));
10481
+ }
10482
+ async function runNetworkDisconnect(deps = defaultDeps()) {
10483
+ const wasUp = deps.up();
10484
+ let failed = false;
10485
+ if (wasUp) {
10486
+ const code = await wgQuick(deps, "down");
10487
+ if (code !== 0) {
10488
+ deps.error(`wg-quick exited ${code}.`);
10489
+ failed = true;
10490
+ }
10151
10491
  }
10152
- };
10153
- var confirmDefaultOnTerminal = async (container) => {
10154
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
10155
- try {
10156
- const answer = (await prompt.question(`Make ${container} the default? [y/N] `)).trim();
10157
- return /^y(es)?$/i.test(answer);
10158
- } finally {
10159
- prompt.close();
10492
+ const stale = await staleResolverFiles(deps);
10493
+ if (stale.length) {
10494
+ const args = ["rm", "-f", ...stale];
10495
+ deps.notice(`Removing split DNS left from an earlier connection. Running: sudo ${args.join(" ")}`);
10496
+ const code = await deps.run("sudo", args);
10497
+ if (code !== 0) {
10498
+ deps.error(`Could not remove ${stale.join(", ")} (rm exited ${code}).`);
10499
+ return 1;
10500
+ }
10160
10501
  }
10161
- };
10162
- var confirmStartOnTerminal = async (container) => {
10163
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
10502
+ if (failed)
10503
+ return 1;
10504
+ printResult({ connected: false, removedSplitDns: stale }, () => [
10505
+ wasUp ? eventLine("done", "Disconnected.") : eventLine(stale.length ? "done" : "item", stale.length ? "Not connected; removed the leftover split DNS." : "Not connected.")
10506
+ ], deps.log);
10507
+ return 0;
10508
+ }
10509
+ async function readConnection(deps) {
10510
+ const state = await readDeviceState(deps.dir);
10511
+ const up = deps.up();
10512
+ if (!state)
10513
+ return { state, up, device: null, asked: true, provider: null, staleMtu: null };
10164
10514
  try {
10165
- const answer = (await prompt.question(`Container ${container} is not running. Start it? [Y/n] `)).trim();
10166
- return answer === "" || /^y(es)?$/i.test(answer);
10167
- } finally {
10168
- prompt.close();
10515
+ const { devices, mtu } = await deps.request("/api/v1/network/devices");
10516
+ const device = devices.find((d) => d.id === state.deviceId) ?? null;
10517
+ const config = await readConfigMtu(configPath(deps.dir));
10518
+ const staleMtu = config !== null && config !== mtu ? { config, current: mtu } : null;
10519
+ if (staleMtu) {
10520
+ deps.notice(`This machine's tunnel has MTU ${staleMtu.config}, the network now uses ${staleMtu.current}. ` + `Run \`${getCliName()} network connect\` again: until then, large packets other than TCP ` + "can be lost on the way.");
10521
+ }
10522
+ return {
10523
+ state,
10524
+ up,
10525
+ device,
10526
+ asked: true,
10527
+ provider: up ? device?.gatewayProvider ?? null : null,
10528
+ staleMtu
10529
+ };
10530
+ } catch (err) {
10531
+ deps.notice(`Could not ask the app about this device: ${message2(err)}`);
10532
+ return {
10533
+ state,
10534
+ up,
10535
+ device: null,
10536
+ asked: false,
10537
+ provider: up ? state.gatewayProvider : null,
10538
+ staleMtu: null
10539
+ };
10169
10540
  }
10170
- };
10171
- function defaultContainerPickers() {
10172
- return process.stdin.isTTY && process.stderr.isTTY ? {
10173
- choose: chooseContainerOnTerminal,
10174
- confirmDefault: confirmDefaultOnTerminal,
10175
- confirmStart: confirmStartOnTerminal
10176
- } : undefined;
10177
10541
  }
10178
-
10179
- // src/progress.ts
10180
- var PROGRESS_FRAMES = ["▂▄▆", "▄▆█", "▆█▆", "█▆▄", "▆▄▂", "▄▂▄"];
10181
- var FRAME_MS = 120;
10182
- function formatElapsed(ms) {
10183
- return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
10542
+ async function runNetworkStatus(deps = defaultDeps()) {
10543
+ const { state, up, device, asked, provider, staleMtu } = await readConnection(deps);
10544
+ const staleSplitDns = up ? [] : await staleResolverFiles(deps);
10545
+ for (const file of staleSplitDns) {
10546
+ deps.notice(`Split DNS from an earlier connection is still set (${file}): names of the private network will hang. Run: ${getCliName()} network disconnect`);
10547
+ }
10548
+ printResult({ connected: up, device, provider, staleMtu, staleSplitDns }, () => {
10549
+ if (!state) {
10550
+ return [eventLine("item", `Not set up. Run \`${getCliName()} network connect\`.`)];
10551
+ }
10552
+ const through = provider ? ` through ${provider.name}` : "";
10553
+ const lines = [
10554
+ eventLine("item", `${up ? `Connected${through}` : "Not connected"}: ${state.address}`)
10555
+ ];
10556
+ if (device) {
10557
+ lines.push(` Device ${device.name}, valid until ${device.expiresAt.slice(0, 10)}.`);
10558
+ lines.push(` ${deviceStateText(device.state)}.`);
10559
+ if (device.shortNameOrganization) {
10560
+ lines.push(` Short names: ${device.shortNameOrganization.slug}'s sandboxes.`);
10561
+ }
10562
+ if (!device.gatewayProvider) {
10563
+ lines.push(` Its provider is gone, so no gateway serves it. Run \`${getCliName()} network connect\`.`);
10564
+ }
10565
+ } else if (asked) {
10566
+ lines.push(` The app no longer knows this device (removed, expired or logged out). Run \`${getCliName()} network connect\`.`);
10567
+ }
10568
+ if (provider)
10569
+ lines.push(` Reachable: the sandboxes on ${provider.name}.`);
10570
+ return lines;
10571
+ }, deps.log);
10572
+ return 0;
10184
10573
  }
10185
- function startProgress(message, options = {}) {
10186
- const stream = options.stream ?? process.stderr;
10187
- const now = options.now ?? Date.now;
10188
- const started = now();
10189
- const elapsed = () => formatElapsed(now() - started);
10190
- if (isJsonOutput())
10191
- return { stop: elapsed };
10192
- const animate = options.animate ?? stream.isTTY === true;
10193
- if (!animate) {
10194
- stream.write(` ${SYMBOLS.progress} ${message}
10195
- `);
10196
- return { stop: elapsed };
10574
+ function unreachableNote(sandbox, connected) {
10575
+ if (!sandbox.provider.privateNetwork) {
10576
+ return `not reachable: ${sandbox.provider.name} has no private network yet`;
10197
10577
  }
10198
- const color = colorEnabled(stream);
10199
- let frame = 0;
10200
- const draw = () => {
10201
- const bars = accent(PROGRESS_FRAMES[frame % PROGRESS_FRAMES.length] ?? SYMBOLS.progress, color);
10202
- stream.write(`\r\x1B[2K ${bars} ${message} ${dim(elapsed(), color)}`);
10203
- frame += 1;
10204
- };
10205
- const restore = () => stream.write("\r\x1B[2K\x1B[?25h");
10206
- const onInterrupt = () => {
10207
- restore();
10208
- process.exit(130);
10209
- };
10210
- stream.write("\x1B[?25l");
10211
- draw();
10212
- const timer = setInterval(draw, FRAME_MS);
10213
- timer.unref?.();
10214
- process.once("SIGINT", onInterrupt);
10215
- let stopped = false;
10216
- return {
10217
- stop() {
10218
- if (!stopped) {
10219
- stopped = true;
10220
- clearInterval(timer);
10221
- process.off("SIGINT", onInterrupt);
10222
- restore();
10578
+ if (connected?.id === sandbox.provider.id)
10579
+ return null;
10580
+ return `not reachable: on ${sandbox.provider.name} → ${connectCommandFor(sandbox.provider)}`;
10581
+ }
10582
+ async function runNetworkLs(args, deps = defaultDeps()) {
10583
+ try {
10584
+ const organization = await requireOrganizationRef(args.org, deps.notice);
10585
+ const connection = await readConnection(deps);
10586
+ const deviceId = connection.device?.id;
10587
+ const listed = await deps.request(`/api/v1/network/organizations/${encodeURIComponent(organization)}/sandboxes${deviceId ? `?device=${encodeURIComponent(deviceId)}` : ""}`);
10588
+ const connected = connection.provider;
10589
+ const sandboxes = listed.sandboxes.map((sandbox) => ({
10590
+ ...sandbox,
10591
+ reachable: unreachableNote(sandbox, connected) === null
10592
+ }));
10593
+ printResult({ ...listed, connectedProvider: connected, sandboxes }, () => {
10594
+ if (listed.sandboxes.length === 0) {
10595
+ return [eventLine("item", `No sandboxes in ${listed.organization.slug}.`)];
10223
10596
  }
10224
- return elapsed();
10225
- }
10226
- };
10227
- }
10228
- async function withProgress(message, work, options) {
10229
- const progress = startProgress(message, options);
10230
- try {
10231
- const value = await work();
10232
- return { value, elapsed: progress.stop() };
10597
+ const width = Math.max(...listed.sandboxes.map((s) => s.name.length));
10598
+ const canonicalWidth = Math.max(...listed.sandboxes.map((s) => s.canonicalName.length));
10599
+ const httpsWidth = Math.max(...listed.sandboxes.map((s) => (s.httpsName?.length ?? 0) + 8));
10600
+ const shortWidth = Math.max(...listed.sandboxes.map((s) => (s.shortName?.length ?? 0) + 8));
10601
+ const lines = [
10602
+ connected ? eventLine("item", `Connected through ${connected.name}.`) : eventLine("item", `Not connected: nothing is reachable. Run \`${getCliName()} network connect [provider]\`.`)
10603
+ ];
10604
+ for (const s of listed.sandboxes) {
10605
+ const note = unreachableNote(s, connected);
10606
+ lines.push([
10607
+ ` ${s.name.padEnd(width)}`,
10608
+ (s.address ?? "-").padEnd(28),
10609
+ s.canonicalName.padEnd(canonicalWidth),
10610
+ ...s.httpsName ? [`https://${s.httpsName}`.padEnd(httpsWidth)] : [],
10611
+ ...s.shortName ? [`https://${s.shortName}`.padEnd(shortWidth)] : [],
10612
+ s.status,
10613
+ ...note ? [`(${note})`] : []
10614
+ ].join(" "));
10615
+ }
10616
+ return lines;
10617
+ }, deps.log);
10618
+ return 0;
10233
10619
  } catch (err) {
10234
- progress.stop();
10235
- throw err;
10620
+ deps.error(message2(err));
10621
+ return 1;
10236
10622
  }
10237
10623
  }
10238
-
10239
- // src/container-flow.ts
10240
- async function withContainerPick(call, send, pickers, notice) {
10624
+ async function runNetworkDevices(deps = defaultDeps()) {
10241
10625
  try {
10242
- return await send();
10626
+ const { devices } = await deps.request("/api/v1/network/devices");
10627
+ const own = (await readDeviceState(deps.dir))?.deviceId;
10628
+ 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);
10629
+ return 0;
10243
10630
  } 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;
10631
+ deps.error(message2(err));
10632
+ return 1;
10268
10633
  }
10269
10634
  }
10270
- async function withContainerStart(call, send, start, pickers, notice) {
10635
+ async function runNetworkDevicesRemove(args, deps = defaultDeps()) {
10271
10636
  try {
10272
- return await send();
10637
+ await deps.request(`/api/v1/network/devices/${encodeURIComponent(args.id)}`, {
10638
+ method: "DELETE"
10639
+ });
10640
+ printResult({ removed: args.id }, () => [eventLine("done", `Removed device ${args.id}.`)], deps.log);
10641
+ return 0;
10273
10642
  } 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();
10643
+ deps.error(message2(err));
10644
+ return 1;
10289
10645
  }
10290
10646
  }
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
- });
10647
+ var connectCommand = defineCommand({
10648
+ meta: {
10649
+ name: "connect",
10650
+ 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."
10651
+ },
10652
+ args: {
10653
+ provider: {
10654
+ type: "positional",
10655
+ required: false,
10656
+ description: "Sandbox provider name or id. Omit for the organization's default provider (else the platform default). Only its sandboxes are reachable; connecting through another replaces the connection."
10657
+ },
10658
+ org: orgArg
10659
+ },
10660
+ async run({ args }) {
10661
+ process.exitCode = await runNetworkConnect({ provider: args.provider, org: args.org });
10300
10662
  }
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;
10663
+ });
10664
+ var disconnectCommand = defineCommand({
10665
+ meta: {
10666
+ name: "disconnect",
10667
+ description: "Take this machine out of the private network, and remove split DNS an earlier connection left (macOS)."
10668
+ },
10669
+ async run() {
10670
+ process.exitCode = await runNetworkDisconnect();
10329
10671
  }
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
- }
10672
+ });
10673
+ var statusCommand = defineCommand({
10674
+ meta: {
10675
+ name: "status",
10676
+ description: "Whether this machine is connected, through which provider, and as which device."
10677
+ },
10678
+ async run() {
10679
+ process.exitCode = await runNetworkStatus();
10360
10680
  }
10361
- }
10362
-
10363
- // src/human.ts
10364
- var ID_KEYS = [
10365
- "sandboxId",
10366
- "siteId",
10367
- "projectId",
10368
- "deploymentId",
10369
- "imageId",
10370
- "snapshotId",
10371
- "urlId",
10372
- "mcpId",
10373
- "commandId",
10374
- "sessionId",
10375
- "sandboxProviderId",
10376
- "id"
10377
- ];
10378
- var LABEL_KEYS = ["displayName", "name", "title", "slug", "label", "path", "uri", "ref"];
10379
- var STATUS_KEYS = ["status", "outcome", "state"];
10380
- var URL_KEYS = ["url", "liveUrl", "publicUrl", "sshCommand"];
10381
- function isItem(value) {
10382
- return value !== null && typeof value === "object" && !Array.isArray(value);
10383
- }
10384
- function isScalar(value) {
10385
- return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
10386
- }
10387
- function firstKey(item, keys) {
10388
- return keys.find((key) => {
10389
- const value = item[key];
10390
- return typeof value === "string" && value.trim() !== "";
10391
- });
10392
- }
10393
- function firstString(item, keys) {
10394
- const key = firstKey(item, keys);
10395
- return key ? item[key] : undefined;
10396
- }
10397
- function symbolForStatus(status) {
10398
- const s = (status ?? "").toLowerCase();
10399
- if (/(fail|error|denied)/.test(s))
10400
- return "failed";
10401
- if (/^(running|active|ready|deployed|published|online|verified|ok)$/.test(s))
10402
- return "ready";
10403
- if (/^(stopped|deleted|unpublished|vanished|archived|revoked|closed|killed|exited|dead|paused)$/.test(s)) {
10404
- return "stopped";
10681
+ });
10682
+ var lsCommand = defineCommand({
10683
+ meta: {
10684
+ name: "ls",
10685
+ description: "Sandboxes of an organization with their private addresses and names, and which are reachable through the current connection."
10686
+ },
10687
+ args: { org: orgArg },
10688
+ async run({ args }) {
10689
+ process.exitCode = await runNetworkLs({ org: args.org });
10690
+ }
10691
+ });
10692
+ var devicesRemoveCommand = defineCommand({
10693
+ meta: { name: "remove", description: "Remove one of your devices from the private network." },
10694
+ args: {
10695
+ id: { type: "positional", description: "Device id, from `network devices`.", required: true }
10696
+ },
10697
+ async run({ args }) {
10698
+ process.exitCode = await runNetworkDevicesRemove({ id: args.id });
10699
+ }
10700
+ });
10701
+ var devicesCommand = defineCommand({
10702
+ meta: { name: "devices", description: "Your devices in the private network." },
10703
+ subCommands: { remove: devicesRemoveCommand },
10704
+ async run({ rawArgs }) {
10705
+ if (rawArgs[0] === "remove")
10706
+ return;
10707
+ process.exitCode = await runNetworkDevices();
10405
10708
  }
10406
- if (/(pending|starting|spawning|stopping|building|progress|awaiting|queued|creating|restarting|removing)/.test(s)) {
10407
- return "progress";
10709
+ });
10710
+ var networkCommand = defineCommand({
10711
+ meta: {
10712
+ name: "network",
10713
+ description: "The private network: reach your sandboxes by address and name from this machine."
10714
+ },
10715
+ subCommands: {
10716
+ connect: connectCommand,
10717
+ disconnect: disconnectCommand,
10718
+ status: statusCommand,
10719
+ ls: lsCommand,
10720
+ devices: devicesCommand
10408
10721
  }
10409
- return "item";
10722
+ });
10723
+
10724
+ // src/commands/docker.ts
10725
+ function defaultDeps2() {
10726
+ const dir = networkDir();
10727
+ return {
10728
+ request: authorizedRequest,
10729
+ run: (command, args) => new Promise((resolve, reject) => {
10730
+ const child = spawn2(command, args, { stdio: "inherit" });
10731
+ child.on("error", reject);
10732
+ child.on("close", (code) => resolve(code ?? 1));
10733
+ }),
10734
+ output: (command, args) => {
10735
+ const result = spawnSync3(command, args, { encoding: "utf8" });
10736
+ return result.status === 0 ? result.stdout : "";
10737
+ },
10738
+ platform: process.platform,
10739
+ has: (command) => spawnSync3("sh", ["-c", `command -v ${command}`]).status === 0,
10740
+ dir,
10741
+ up: () => tunnelUp(),
10742
+ log: console.log,
10743
+ error: printError,
10744
+ notice: printNotice,
10745
+ exec: execDocker,
10746
+ dockerDir: dockerDirFor(dir),
10747
+ now: () => new Date,
10748
+ env: process.env
10749
+ };
10410
10750
  }
10411
- function isSandbox(item) {
10412
- return typeof item.sandboxId === "string" && typeof item.name === "string" && !("id" in item);
10751
+ function message3(err) {
10752
+ return err instanceof Error ? err.message : String(err);
10413
10753
  }
10414
- function lineKeys(item) {
10415
- if (isSandbox(item)) {
10416
- return { id: "name", label: firstKey(item, ["displayName"]) };
10754
+ var DNS_LABEL2 = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10755
+ function noDockerHostReason(sandbox, orgSlug) {
10756
+ if (!sandbox.provider.privateNetwork) {
10757
+ return `${sandbox.name} runs on ${sandbox.provider.name}, which has no private network yet: no Docker context reaches it.`;
10417
10758
  }
10418
- const idKeys = "id" in item ? ID_KEYS.filter((key) => key !== "sandboxId") : ID_KEYS;
10419
- return { id: firstKey(item, idKeys), label: firstKey(item, LABEL_KEYS) };
10420
- }
10421
- function shortId(id) {
10422
- return /^[0-9a-f]{64}$/.test(id) ? id.slice(0, 12) : id;
10423
- }
10424
- function itemLine(item, color) {
10425
- const keys = lineKeys(item);
10426
- const id = keys.id ? item[keys.id] : undefined;
10427
- const label = keys.label ? item[keys.label] : undefined;
10428
- const status = firstString(item, STATUS_KEYS);
10429
- const url = firstString(item, URL_KEYS);
10430
- const parts = [label ?? id ?? "(unnamed)"];
10431
- if (label && id && id !== label)
10432
- parts.push(dim(shortId(id), color));
10433
- if (status)
10434
- parts.push(status);
10435
- if (url)
10436
- parts.push(`${SYMBOLS.url} ${url}`);
10437
- return eventLine(symbolForStatus(status), parts.join(" "), color);
10759
+ if (!sandbox.address) {
10760
+ return `${sandbox.name} has no address in the private network yet (${sandbox.provider.name} has no host on it).`;
10761
+ }
10762
+ const notLabel = [sandbox.name, orgSlug].find((name) => !DNS_LABEL2.test(name));
10763
+ if (notLabel) {
10764
+ return `${sandbox.name} has no Docker engine over the network: "${notLabel}" is not a DNS label (lower-case letters, digits and hyphens), so its Docker engine stays closed.`;
10765
+ }
10766
+ return `${sandbox.name} has no Docker engine over the network.`;
10438
10767
  }
10439
- function scalarText(value) {
10440
- if (value === null)
10441
- return "—";
10442
- if (isScalar(value))
10443
- return String(value);
10444
- if (Array.isArray(value) && value.every(isScalar))
10445
- return value.join(", ");
10446
- if (Array.isArray(value))
10447
- return `${value.length} item${value.length === 1 ? "" : "s"}`;
10448
- return "…";
10768
+ function sandboxWarnings(sandbox, gateway, up) {
10769
+ const warnings = [];
10770
+ if (gateway?.id !== sandbox.provider.id) {
10771
+ warnings.push(`${sandbox.name} runs on ${sandbox.provider.name}, and this machine connects through ${gateway?.name ?? "no provider"}: Docker reaches it only after \`${connectCommandFor(sandbox.provider)}\`.`);
10772
+ } else if (!up) {
10773
+ warnings.push(`This machine is not connected to the private network: run \`${connectCommandFor(sandbox.provider)}\` before using the context.`);
10774
+ }
10775
+ if (sandbox.dockerDoor === "none") {
10776
+ warnings.push(`${sandbox.name}: this sandbox's base image is too old for Docker over the network; restart it to update.`);
10777
+ }
10778
+ return warnings;
10449
10779
  }
10450
- function fieldLines(item, color, skip) {
10451
- const lines = [];
10452
- for (const [key, value] of Object.entries(item)) {
10453
- if (skip.has(key) || value === undefined)
10454
- continue;
10455
- if (Array.isArray(value) && value.some(isItem)) {
10456
- lines.push(` ${dim(`${key}:`, color)}`);
10457
- for (const entry of value) {
10458
- lines.push(` ${isItem(entry) ? itemLine(entry, color) : eventLine("item", scalarText(entry), color)}`);
10459
- }
10460
- continue;
10780
+ var NO_DEVICE = () => `Run ${getCliName()} network connect first: the Docker context uses this machine's device.`;
10781
+ async function runDockerContext(args, deps = defaultDeps2()) {
10782
+ const state = await readDeviceStateFor(deps);
10783
+ if (!state) {
10784
+ deps.error(NO_DEVICE());
10785
+ return 1;
10786
+ }
10787
+ if (!deps.has("docker")) {
10788
+ deps.error("docker is not installed: the context is for Docker's CLI. Install Docker (Docker Desktop, OrbStack, or the docker CLI alone), then run this again.");
10789
+ return 1;
10790
+ }
10791
+ const use = args.use !== false;
10792
+ try {
10793
+ const organization = await requireOrganizationRef(args.org, deps.notice);
10794
+ const connection = await readConnection(deps);
10795
+ if (connection.asked && !connection.device) {
10796
+ deps.error(`The app no longer knows this machine's device (removed, expired or logged out). ${NO_DEVICE()}`);
10797
+ return 1;
10461
10798
  }
10462
- if (isItem(value)) {
10463
- for (const [sub, subValue] of Object.entries(value)) {
10464
- if (subValue === undefined || isItem(subValue))
10465
- continue;
10466
- lines.push(` ${dim(`${key}.${sub}:`, color)} ${scalarText(subValue)}`);
10799
+ const listed = await deps.request(`/api/v1/network/organizations/${encodeURIComponent(organization)}/sandboxes?device=${encodeURIComponent(state.deviceId)}`);
10800
+ const slug = listed.organization.slug;
10801
+ const wanted = args.sandbox.trim().toLowerCase();
10802
+ const sandbox = listed.sandboxes.find((s) => s.name.toLowerCase() === wanted);
10803
+ if (!sandbox) {
10804
+ deps.error(`No sandbox ${args.sandbox} in ${slug}. Run \`${getCliName()} network ls --org ${slug}\` for its sandboxes.`);
10805
+ return 1;
10806
+ }
10807
+ if (!sandbox.dockerHost) {
10808
+ deps.error(noDockerHostReason(sandbox, slug));
10809
+ return 1;
10810
+ }
10811
+ const gateway = connection.device?.gatewayProvider ?? connection.state?.gatewayProvider ?? null;
10812
+ for (const warning of sandboxWarnings(sandbox, gateway, connection.up))
10813
+ deps.notice(warning);
10814
+ const organizationId = listed.organization.id;
10815
+ if (!isOrganizationId(organizationId)) {
10816
+ throw new Error(`The app sent an organization id that is not one: ${organizationId}`);
10817
+ }
10818
+ const files = organizationFiles(deps.dockerDir, organizationId);
10819
+ const { certificate, issued } = await ensureCertificate({ files, organizationId, deviceId: state.deviceId, baseUrl: state.baseUrl, now: deps.now() }, deps.request);
10820
+ const name = dockerContextName(slug, sandbox.name);
10821
+ const { created } = writeContext(deps.exec, {
10822
+ name,
10823
+ description: contextDescription(sandbox.name, slug),
10824
+ host: sandbox.dockerHost,
10825
+ files
10826
+ });
10827
+ await recordContext(deps.dockerDir, { name, organizationId, sandbox: sandbox.name });
10828
+ if (use) {
10829
+ useContext(deps.exec, name);
10830
+ for (const variable of ["DOCKER_HOST", "DOCKER_CONTEXT"]) {
10831
+ if (deps.env[variable]) {
10832
+ deps.notice(`${variable} is set in this shell and wins over \`docker context use\`: unset it, or pass \`docker --context ${name}\`.`);
10833
+ }
10467
10834
  }
10468
- continue;
10469
10835
  }
10470
- lines.push(` ${dim(`${key}:`, color)} ${scalarText(value)}`);
10836
+ printResult({
10837
+ context: { name, host: sandbox.dockerHost, created, inUse: use },
10838
+ sandbox: {
10839
+ name: sandbox.name,
10840
+ canonicalName: sandbox.canonicalName,
10841
+ provider: sandbox.provider,
10842
+ dockerDoor: sandbox.dockerDoor
10843
+ },
10844
+ organization: listed.organization,
10845
+ certificate: {
10846
+ serial: certificate.serial,
10847
+ notAfter: certificate.notAfter.toISOString(),
10848
+ issued,
10849
+ files: { ca: files.ca, cert: files.cert, key: files.key }
10850
+ }
10851
+ }, () => [
10852
+ eventLine("done", `Docker context ${name}${use ? " (in use)" : ""}`),
10853
+ ` ${sandbox.dockerHost}`,
10854
+ ` Certificate for this device, valid until ${day(certificate.notAfter)}.`,
10855
+ ` → ${use ? "docker ps" : `docker --context ${name} ps`}`
10856
+ ], deps.log);
10857
+ await renewDockerCertificates(deps, { except: organizationId });
10858
+ return 0;
10859
+ } catch (err) {
10860
+ deps.error(message3(err));
10861
+ return 1;
10471
10862
  }
10472
- return lines;
10473
- }
10474
- function listLines(items, color) {
10475
- if (items.length === 0)
10476
- return [` ${dim("(none)", color)}`];
10477
- return items.map((item) => isItem(item) ? itemLine(item, color) : eventLine("item", scalarText(item), color));
10478
10863
  }
10479
- function bootPhase(value) {
10480
- return isItem(value) && typeof value.ms === "number" && Number.isFinite(value.ms) ? { ms: value.ms } : null;
10864
+ async function readDeviceStateFor(deps) {
10865
+ const state = await readDeviceState(deps.dir);
10866
+ return state && state.baseUrl === getBaseUrl() ? state : null;
10481
10867
  }
10482
- var DISK_SOURCE_TEXT = {
10483
- fresh: "image pulled",
10484
- "image-base": "image already on disk",
10485
- "user-snapshot": "from a snapshot"
10486
- };
10487
- function bootLines(data, color) {
10488
- if (!isItem(data) || !isItem(data.boot))
10489
- return [];
10490
- const sandbox = bootPhase(data.boot.sandbox);
10491
- if (!sandbox)
10492
- return [];
10493
- const lines = [eventLine("ready", `sandbox started in ${formatElapsed(sandbox.ms)}`, color)];
10494
- const container = bootPhase(data.boot.container);
10495
- if (container) {
10496
- lines.push(eventLine("done", `container started in ${formatElapsed(container.ms)}`, color));
10868
+ var contextCommand = defineCommand({
10869
+ meta: {
10870
+ name: "context",
10871
+ description: `Point Docker at a sandbox's Docker engine over the private network: plain \`docker\` and \`docker compose\` then run there. Creates (or updates) the Docker context ghostdock-<org>-<sandbox> for tcp://<sandbox>.<org>.ghostdock.internal:2376 with a client certificate of this machine's device, and makes it the current context. Needs \`${getCliName()} network connect\` first, and Docker's CLI. The certificate lives 30 days; \`network connect\` and this command renew it with 10 days left. An expired certificate fails Docker's TLS handshake with Docker's own error: run this command again. Docker's \`no such host\` on a ghostdock.internal address means this machine is not connected: run \`${getCliName()} network connect\`. After an organization rename the old name stops resolving: run it again to update the host.`
10872
+ },
10873
+ args: {
10874
+ sandbox: {
10875
+ type: "positional",
10876
+ required: true,
10877
+ description: "Sandbox name, from `network ls`."
10878
+ },
10879
+ org: orgArg,
10880
+ use: {
10881
+ type: "boolean",
10882
+ default: true,
10883
+ description: "Make it the current Docker context (`docker context use`). `--no-use` only creates or updates it.",
10884
+ negativeDescription: "Only create or update the context; then pass `docker --context <name>`."
10885
+ }
10886
+ },
10887
+ async run({ args }) {
10888
+ process.exitCode = await runDockerContext({
10889
+ sandbox: args.sandbox,
10890
+ org: args.org,
10891
+ use: args.use
10892
+ });
10497
10893
  }
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));
10894
+ });
10895
+ var dockerCommand = defineCommand({
10896
+ meta: {
10897
+ name: "docker",
10898
+ description: "Docker over the private network: a sandbox's Docker engine from this machine."
10899
+ },
10900
+ subCommands: { context: contextCommand }
10901
+ });
10902
+
10903
+ // src/commands/env.ts
10904
+ function reportError(err, error) {
10905
+ if (err instanceof AuthRequiredError || err instanceof Error) {
10906
+ error(err.message);
10907
+ } else {
10908
+ error(String(err));
10503
10909
  }
10504
- return lines;
10910
+ return 1;
10505
10911
  }
10506
- function renderResult(data, color) {
10507
- if (data === null || data === undefined)
10508
- return [eventLine("done", "Done.", color)];
10509
- if (Array.isArray(data))
10510
- return listLines(data, color);
10511
- if (!isItem(data))
10512
- return [eventLine("item", scalarText(data), color)];
10513
- const entries = Object.entries(data).filter(([, value]) => value !== undefined);
10514
- if (entries.length === 0 || entries.length === 1 && data.ok === true) {
10515
- return [eventLine("done", "Done.", color)];
10516
- }
10517
- const lists = entries.filter(([, value]) => Array.isArray(value) && (value.length === 0 || value.some(isItem)));
10518
- if (lists.length === 1 && !firstString(data, ID_KEYS)) {
10519
- const [listKey, list] = lists[0];
10520
- const rest = fieldLines(data, color, new Set([listKey]));
10521
- return [...rest, ...listLines(list, color)];
10522
- }
10523
- const { id: idKey, label: labelKey } = lineKeys(data);
10524
- const used = [idKey, labelKey, firstKey(data, STATUS_KEYS), firstKey(data, URL_KEYS)].filter((key) => key !== undefined);
10525
- const head = used.length > 0 ? [itemLine(data, color)] : [];
10526
- const boot = bootLines(data, color);
10527
- if ("boot" in data && (boot.length > 0 || data.boot === null))
10528
- used.push("boot");
10529
- return [...head, ...boot, ...fieldLines(data, color, new Set(used))];
10912
+ function invokeTool(tool, input) {
10913
+ return authorizedRequest(`/api/v1/tools/${tool}`, {
10914
+ method: "POST",
10915
+ body: JSON.stringify(input)
10916
+ });
10530
10917
  }
10531
-
10532
- // src/cli-help-text.ts
10533
- var CLI_HELP_DESCRIPTION_MAX_LENGTH = 80;
10534
- function cliHelpDescription(description, maxLength = CLI_HELP_DESCRIPTION_MAX_LENGTH) {
10535
- const oneLine = description.replace(/\s+/g, " ").trim();
10536
- if (oneLine.length <= maxLength) {
10537
- return oneLine;
10918
+ function displayValue(entry) {
10919
+ if (entry.valueOmittedReason === "secret")
10920
+ return "<secret>";
10921
+ if (entry.valueOmittedReason === "too_large") {
10922
+ return `<${entry.valueLength} chars — read with \`${getCliName()} env get\`>`;
10538
10923
  }
10539
- const budget = Math.max(1, maxLength - 1);
10540
- const slice = oneLine.slice(0, budget);
10541
- const breakAt = slice.lastIndexOf(" ");
10542
- const cut = breakAt >= Math.floor(budget * 0.6) ? slice.slice(0, breakAt) : slice;
10543
- return `${cut.trimEnd()}…`;
10924
+ return entry.value ?? "";
10544
10925
  }
10545
-
10546
- // src/schema-flags.ts
10547
- function toFlagName(propertyName) {
10548
- if (propertyName === "organization")
10549
- return "org";
10550
- return propertyName.replace(/([KMGTP])iB(?![a-z])/g, "$1ib").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
10926
+ function formatTable(entries) {
10927
+ const rows = entries.map((entry) => [
10928
+ entry.name,
10929
+ entry.kind,
10930
+ displayValue(entry),
10931
+ entry.comment ?? ""
10932
+ ]);
10933
+ const header = ["NAME", "KIND", "VALUE", "COMMENT"];
10934
+ const widths = header.map((_, column) => Math.max(header[column]?.length ?? 0, ...rows.map((row) => row[column]?.length ?? 0)));
10935
+ const line = (cells) => cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
10936
+ return [line(header), ...rows.map(line)].join(`
10937
+ `);
10551
10938
  }
10552
- function isJsonSchemaProperty(value) {
10553
- return value !== null && typeof value === "object" && !Array.isArray(value);
10939
+ async function readStdin() {
10940
+ const chunks = [];
10941
+ for await (const chunk of process.stdin) {
10942
+ chunks.push(Buffer.from(chunk));
10943
+ }
10944
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
10554
10945
  }
10555
- function schemaVariants(prop) {
10556
- const variants = [];
10557
- for (const key of ["anyOf", "oneOf", "allOf"]) {
10558
- const group = prop[key];
10559
- if (!Array.isArray(group))
10560
- continue;
10561
- for (const item of group) {
10562
- if (isJsonSchemaProperty(item))
10563
- variants.push(item);
10946
+ async function runEnvList(args, log = console.log, error = printError) {
10947
+ try {
10948
+ const result = await invokeTool("env_list", {
10949
+ organization: args.organization
10950
+ });
10951
+ if (args.json) {
10952
+ log(JSON.stringify(result.entries, null, 2));
10953
+ return 0;
10954
+ }
10955
+ if (result.entries.length === 0) {
10956
+ log(" No variables or secrets.");
10957
+ return 0;
10564
10958
  }
10959
+ log(formatTable(result.entries));
10960
+ return 0;
10961
+ } catch (err) {
10962
+ return reportError(err, error);
10565
10963
  }
10566
- return variants;
10567
10964
  }
10568
- function primaryJsonType(prop) {
10569
- if (prop.type !== undefined) {
10570
- if (Array.isArray(prop.type)) {
10571
- return prop.type.find((t) => t !== "null");
10572
- }
10573
- return prop.type === "null" ? undefined : prop.type;
10965
+ async function runEnvGet(args, log = console.log, error = printError) {
10966
+ try {
10967
+ const result = await invokeTool("env_get", {
10968
+ organization: args.organization,
10969
+ name: args.name
10970
+ });
10971
+ log(args.json ? JSON.stringify(result.entry, null, 2) : result.entry.value ?? "");
10972
+ return 0;
10973
+ } catch (err) {
10974
+ return reportError(err, error);
10574
10975
  }
10575
- const types = new Set;
10576
- for (const variant of schemaVariants(prop)) {
10577
- const type = primaryJsonType(variant);
10578
- if (type)
10579
- types.add(type);
10976
+ }
10977
+ function resolveKind(args) {
10978
+ if (args.kind !== undefined && args.kind !== "variable" && args.kind !== "secret") {
10979
+ return { error: `--kind must be "variable" or "secret", got "${args.kind}".` };
10580
10980
  }
10581
- if (types.has("object"))
10582
- return "object";
10583
- if (types.has("array"))
10584
- return "array";
10585
- if (types.size === 1) {
10586
- const [only] = types;
10587
- return only;
10981
+ const fromKind = args.kind;
10982
+ if (args.secret && fromKind === "variable") {
10983
+ return { error: "Pass either --secret or --kind variable, not both." };
10588
10984
  }
10589
- return;
10590
- }
10591
- function allowsNull(prop) {
10592
- if (prop.type === "null")
10593
- return true;
10594
- if (Array.isArray(prop.type) && prop.type.includes("null"))
10595
- return true;
10596
- return schemaVariants(prop).some(allowsNull);
10985
+ return { kind: fromKind ?? (args.secret ? "secret" : undefined) };
10597
10986
  }
10598
- function buildToolArgs(schema, describe = cliHelpDescription, positionals = []) {
10599
- const properties = schema.properties ?? {};
10600
- const required = new Set(schema.required ?? []);
10601
- const args = {};
10602
- for (const name of positionals) {
10603
- const prop = properties[name];
10604
- if (!prop)
10605
- continue;
10606
- const jsonType = primaryJsonType(prop);
10607
- args[toFlagName(name)] = {
10608
- type: "positional",
10609
- description: describe(prop.description ?? name),
10610
- required: true,
10611
- valueHint: jsonType
10612
- };
10987
+ async function runEnvSet(args, log = console.log, error = printError, stdin = readStdin) {
10988
+ if (args.value !== undefined && args.valueStdin) {
10989
+ error("Pass either --value or --value-stdin, not both.");
10990
+ return 1;
10613
10991
  }
10614
- for (const [name, prop] of Object.entries(properties)) {
10615
- if (positionals.includes(name))
10616
- continue;
10617
- const jsonType = primaryJsonType(prop);
10618
- const isBoolean = jsonType === "boolean";
10619
- const isJsonValue = jsonType === "object" || jsonType === "array";
10620
- const typeNote = isJsonValue ? ` (JSON ${jsonType})` : "";
10621
- const isOrganization = name === "organization";
10622
- const isRequired = required.has(name) && prop.default === undefined && !isOrganization;
10623
- const defaultNote = isOrganization ? " Defaults to GHOSTDOCK_ORG, then `organizations use`." : "";
10624
- args[toFlagName(name)] = isBoolean ? {
10625
- type: "boolean",
10626
- description: describe(`${prop.description ?? name}${typeNote}${defaultNote}`),
10627
- required: isRequired
10628
- } : {
10629
- type: "string",
10630
- description: describe(`${prop.description ?? name}${typeNote}${defaultNote}`),
10631
- required: isRequired,
10632
- valueHint: jsonType
10633
- };
10992
+ if (args.value === undefined && !args.valueStdin) {
10993
+ error("Provide --value, or --value-stdin to read the value from stdin.");
10994
+ return 1;
10634
10995
  }
10635
- return args;
10636
- }
10637
- function coerceFlagValue(raw, prop, flagName) {
10638
- if (raw === "null" && allowsNull(prop))
10639
- return null;
10640
- const jsonType = primaryJsonType(prop);
10641
- if (jsonType === "boolean") {
10642
- return typeof raw === "boolean" ? raw : raw === "true";
10996
+ const resolved = resolveKind(args);
10997
+ if ("error" in resolved) {
10998
+ error(resolved.error);
10999
+ return 1;
10643
11000
  }
10644
- if (jsonType === "number" || jsonType === "integer") {
10645
- const num = Number(raw);
10646
- if (Number.isNaN(num)) {
10647
- throw new Error(`Invalid number for --${flagName}: ${String(raw)}`);
11001
+ const value = args.valueStdin ? await stdin() : args.value ?? "";
11002
+ try {
11003
+ const result = await invokeTool("env_set", {
11004
+ organization: args.organization,
11005
+ name: args.name,
11006
+ value,
11007
+ ...resolved.kind ? { kind: resolved.kind } : {},
11008
+ ...args.comment ? { comment: args.comment } : {}
11009
+ });
11010
+ if (args.json) {
11011
+ log(JSON.stringify(result, null, 2));
11012
+ return 0;
10648
11013
  }
10649
- return num;
10650
- }
10651
- if (jsonType === "object" || jsonType === "array") {
10652
- if (typeof raw !== "string")
10653
- return raw;
10654
- try {
10655
- return JSON.parse(raw);
10656
- } catch {
10657
- throw new Error(`Invalid JSON for --${flagName}: ${String(raw)}`);
11014
+ log(eventLine("done", `${result.created ? "Created" : "Updated"} ${result.entry.kind} ${result.entry.name}.`));
11015
+ if (result.previousKind === "secret") {
11016
+ log(eventLine("item", `Warning: ${result.entry.name} was a secret and is now a variable — its value is readable by everyone in the organization.`));
11017
+ } else if (result.previousKind === "variable") {
11018
+ log(eventLine("item", `${result.entry.name} was a variable and is now a secret; its value is no longer shown.`));
10658
11019
  }
11020
+ return 0;
11021
+ } catch (err) {
11022
+ return reportError(err, error);
10659
11023
  }
10660
- return raw;
10661
11024
  }
10662
- function buildToolInput(schema, rawArgs) {
10663
- const properties = schema.properties ?? {};
10664
- const input = {};
10665
- for (const [name, prop] of Object.entries(properties)) {
10666
- const flagName = toFlagName(name);
10667
- const raw = rawArgs[flagName] ?? rawArgs[name];
10668
- if (raw === undefined)
10669
- continue;
10670
- input[name] = coerceFlagValue(raw, prop, flagName);
11025
+ async function runEnvDelete(args, log = console.log, error = printError) {
11026
+ try {
11027
+ await invokeTool("env_delete", {
11028
+ organization: args.organization,
11029
+ name: args.name
11030
+ });
11031
+ log(eventLine("done", `Deleted ${args.name}.`));
11032
+ return 0;
11033
+ } catch (err) {
11034
+ return reportError(err, error);
11035
+ }
11036
+ }
11037
+ var nameArg = {
11038
+ type: "positional",
11039
+ description: "Environment variable name.",
11040
+ required: true
11041
+ };
11042
+ var envCommand = defineCommand({
11043
+ meta: {
11044
+ name: "env",
11045
+ description: "Manage organization environment variables and secrets."
11046
+ },
11047
+ subCommands: {
11048
+ list: defineCommand({
11049
+ meta: { name: "list", description: "List variables and secrets." },
11050
+ args: {
11051
+ org: orgArg
11052
+ },
11053
+ async run({ args }) {
11054
+ process.exitCode = await withOrganization(args.org, (organization) => runEnvList({
11055
+ organization,
11056
+ json: isJsonOutput()
11057
+ }));
11058
+ }
11059
+ }),
11060
+ get: defineCommand({
11061
+ meta: {
11062
+ name: "get",
11063
+ description: "Print one variable value in full (secrets cannot be read)."
11064
+ },
11065
+ args: {
11066
+ name: nameArg,
11067
+ org: orgArg
11068
+ },
11069
+ async run({ args }) {
11070
+ process.exitCode = await withOrganization(args.org, (organization) => runEnvGet({
11071
+ organization,
11072
+ name: args.name,
11073
+ json: isJsonOutput()
11074
+ }));
11075
+ }
11076
+ }),
11077
+ set: defineCommand({
11078
+ meta: { name: "set", description: "Create or replace a variable or secret." },
11079
+ args: {
11080
+ name: nameArg,
11081
+ org: orgArg,
11082
+ value: { type: "string", description: "Value to store." },
11083
+ "value-stdin": {
11084
+ type: "boolean",
11085
+ description: "Read the value from stdin — keeps secrets out of shell history.",
11086
+ default: false
11087
+ },
11088
+ secret: {
11089
+ type: "boolean",
11090
+ description: "Shorthand for --kind secret. Required when creating a secret.",
11091
+ default: false
11092
+ },
11093
+ kind: {
11094
+ type: "string",
11095
+ description: "variable or secret. Required when creating; on an update, omit to keep the existing kind or pass the other to convert."
11096
+ },
11097
+ comment: { type: "string", description: "Note about what this value is for." }
11098
+ },
11099
+ async run({ args }) {
11100
+ process.exitCode = await withOrganization(args.org, (organization) => runEnvSet({
11101
+ organization,
11102
+ name: args.name,
11103
+ value: args.value,
11104
+ valueStdin: args["value-stdin"],
11105
+ secret: args.secret,
11106
+ kind: args.kind,
11107
+ comment: args.comment,
11108
+ json: isJsonOutput()
11109
+ }));
11110
+ }
11111
+ }),
11112
+ delete: defineCommand({
11113
+ meta: { name: "delete", description: "Delete a variable or secret." },
11114
+ args: {
11115
+ name: nameArg,
11116
+ org: orgArg
11117
+ },
11118
+ async run({ args }) {
11119
+ process.exitCode = await withOrganization(args.org, (organization) => runEnvDelete({
11120
+ organization,
11121
+ name: args.name
11122
+ }));
11123
+ }
11124
+ })
10671
11125
  }
10672
- return input;
10673
- }
11126
+ });
10674
11127
 
10675
- // src/commands/invoke.ts
10676
- async function runToolInvoke(tool, rawArgs, log = console.log, error = printError, notice = printNotice, pickers = defaultContainerPickers()) {
10677
- const properties = tool.inputSchema.properties;
10678
- let input;
10679
- try {
10680
- input = buildToolInput(tool.inputSchema, rawArgs);
10681
- if (properties && "organization" in properties && input.organization === undefined) {
10682
- input.organization = await requireOrganizationRef(undefined, notice);
10683
- }
10684
- } catch (err) {
10685
- error(err instanceof Error ? err.message : String(err));
10686
- return 1;
10687
- }
10688
- if (tool.name === "sandboxes_containers_logs") {
10689
- try {
10690
- return await runContainerLogs(input, {
10691
- follow: rawArgs.follow === true,
10692
- timestamps: rawArgs.timestamps === true
10693
- });
10694
- } catch (err) {
10695
- error(err instanceof Error ? err.message : `${tool.name} failed.`);
10696
- return 1;
10697
- }
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
- }
11128
+ // src/container-picker.ts
11129
+ import { createInterface } from "node:readline/promises";
11130
+ function notRunningContainerOf(error) {
11131
+ if (!(error instanceof ApiError) || !error.data || typeof error.data !== "object")
11132
+ return null;
11133
+ const { code, params } = error.data;
11134
+ if (code !== "container_not_running")
11135
+ return null;
11136
+ const container = params && typeof params === "object" ? params : {};
11137
+ return typeof container.container === "string" && container.container ? container.container : null;
11138
+ }
11139
+ function containerQuestionOf(error) {
11140
+ if (!(error instanceof ApiError) || !error.data || typeof error.data !== "object")
11141
+ return null;
11142
+ const { code, params } = error.data;
11143
+ if (code !== "container_choice_required" && code !== "container_default_missing")
11144
+ return null;
11145
+ const record = params && typeof params === "object" ? params : {};
11146
+ const running = typeof record.containers === "string" ? record.containers.split(",").map((name) => name.trim()).filter(Boolean) : [];
11147
+ if (running.length === 0)
11148
+ return null;
11149
+ return {
11150
+ running,
11151
+ ...code === "container_default_missing" && typeof record.container === "string" ? { missingDefault: record.container } : {}
10706
11152
  };
11153
+ }
11154
+ var say = (line) => process.stderr.write(`${line}
11155
+ `);
11156
+ var chooseContainerOnTerminal = async ({ running, missingDefault }) => {
11157
+ if (missingDefault) {
11158
+ say(`
11159
+ The default container ${missingDefault} is gone.`);
11160
+ }
11161
+ say(missingDefault ? "Which container?" : `
11162
+ Several containers are running. Which one?`);
11163
+ running.forEach((name, index) => {
11164
+ say(` ${index + 1}. ${name}`);
11165
+ });
11166
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
10707
11167
  try {
10708
- const post = () => authorizedRequest(`/api/v1/tools/${tool.name}`, {
10709
- method: "POST",
10710
- body: JSON.stringify(input)
10711
- });
10712
- const waitMessage = PROGRESS_MESSAGES[tool.name];
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);
10722
- printResult(result, () => {
10723
- const color = colorEnabled(process.stdout);
10724
- const lines = renderResult(result, color);
10725
- return elapsed === undefined || bootLines(result, color).length > 0 ? lines : withElapsed(lines, elapsed);
10726
- }, log);
10727
- stopNote(result, notice);
10728
- return 0;
10729
- } catch (err) {
10730
- error(err instanceof Error ? err.message : `${tool.name} failed.`);
10731
- return 1;
11168
+ const answer = (await prompt.question("Number (Enter to cancel): ")).trim();
11169
+ const index = Number.parseInt(answer, 10) - 1;
11170
+ return Number.isInteger(index) && index >= 0 && index < running.length ? running[index] ?? null : null;
11171
+ } finally {
11172
+ prompt.close();
10732
11173
  }
10733
- }
10734
- function stopNote(result, notice) {
10735
- if (!result || typeof result !== "object")
10736
- return;
10737
- const { action, note, container } = result;
10738
- if (action !== "stop" || typeof container !== "string")
10739
- return;
10740
- if (note === "default") {
10741
- notice(`${container} is the sandbox's default container: SSH, commands and files ask which container until it runs again.`);
10742
- } else if (note === "last_running") {
10743
- notice("No container is running now: SSH, commands and files have none to land in until one runs.");
10744
- } else if (note === "sandbox_stopped") {
10745
- notice(`The sandbox is stopped, so ${container} is too: nothing was done.`);
11174
+ };
11175
+ var confirmDefaultOnTerminal = async (container) => {
11176
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
11177
+ try {
11178
+ const answer = (await prompt.question(`Make ${container} the default? [y/N] `)).trim();
11179
+ return /^y(es)?$/i.test(answer);
11180
+ } finally {
11181
+ prompt.close();
10746
11182
  }
10747
- }
10748
- var PROGRESS_MESSAGES = {
10749
- sandboxes_create: "Creating sandbox…",
10750
- sandboxes_wake: "Waking sandbox…",
10751
- sandboxes_stop: "Stopping sandbox…",
10752
- sandboxes_delete: "Deleting sandbox…",
10753
- sandboxes_resize: "Resizing sandbox…",
10754
- sandboxes_resize_disk: "Resizing disk…",
10755
- sandboxes_commands_run: "Running command…",
10756
- sandboxes_containers_start: "Starting container…",
10757
- sandboxes_containers_stop: "Stopping container…",
10758
- sandboxes_containers_restart: "Restarting container…",
10759
- snapshots_create: "Saving snapshot…",
10760
- projects_redeploy: "Redeploying…",
10761
- providers_verify_incus: "Checking the Incus host…"
10762
11183
  };
10763
- function withElapsed(lines, elapsed) {
10764
- const color = colorEnabled(process.stdout);
10765
- const [first, ...rest] = lines;
10766
- if (first === undefined || first.startsWith(" ")) {
10767
- return [eventLine("done", `Done in ${elapsed}.`, color), ...lines];
11184
+ var confirmStartOnTerminal = async (container) => {
11185
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
11186
+ try {
11187
+ const answer = (await prompt.question(`Container ${container} is not running. Start it? [Y/n] `)).trim();
11188
+ return answer === "" || /^y(es)?$/i.test(answer);
11189
+ } finally {
11190
+ prompt.close();
10768
11191
  }
10769
- return [`${first} ${dim(`in ${elapsed}`, color)}`, ...rest];
11192
+ };
11193
+ function defaultContainerPickers() {
11194
+ return process.stdin.isTTY && process.stderr.isTTY ? {
11195
+ choose: chooseContainerOnTerminal,
11196
+ confirmDefault: confirmDefaultOnTerminal,
11197
+ confirmStart: confirmStartOnTerminal
11198
+ } : undefined;
10770
11199
  }
10771
- function cliText(catalog, skills = BUILTIN_SKILL_NAMES) {
10772
- const paths = new Map(catalog.filter((tool) => tool.cli).map((tool) => [tool.name, tool.cli]));
10773
- const names = [...paths.keys()].sort((a, b) => b.length - a.length);
10774
- const pattern = names.length > 0 ? new RegExp(`\\b(${names.join("|")})\\b`, "g") : null;
10775
- return (text) => {
10776
- const oneLine = text.replace(/\s+/g, " ").trim();
10777
- const commands = pattern ? oneLine.replace(pattern, (name) => paths.get(name)?.join(" ") ?? name) : oneLine;
10778
- return commands.replace(/\bthe ([a-z][a-z0-9-]*) skill\b/g, (phrase, skill) => skills.includes(skill) ? `the ${skill} skill (\`${getCliName()} skills get ${skill}\`)` : phrase);
10779
- };
11200
+
11201
+ // src/progress.ts
11202
+ var PROGRESS_FRAMES = ["▂▄▆", "▄▆█", "▆█▆", "█▆▄", "▆▄▂", "▄▂▄"];
11203
+ var FRAME_MS = 120;
11204
+ function formatElapsed(ms) {
11205
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
10780
11206
  }
10781
- function cliOnlyArgs(tool) {
10782
- const schema = tool.inputSchema;
10783
- const args = {};
10784
- if (schema.properties && "container" in schema.properties && !schema.required?.includes("container")) {
10785
- args.start = {
10786
- type: "boolean",
10787
- description: "Start the container first when it is not running, without asking."
10788
- };
10789
- }
10790
- if (tool.name === "sandboxes_containers_logs") {
10791
- args.follow = {
10792
- type: "boolean",
10793
- alias: "f",
10794
- description: "Keep printing new lines until Ctrl-C."
10795
- };
10796
- args.timestamps = { type: "boolean", description: "Put each line's time in front of it." };
11207
+ function startProgress(message, options = {}) {
11208
+ const stream = options.stream ?? process.stderr;
11209
+ const now = options.now ?? Date.now;
11210
+ const started = now();
11211
+ const elapsed = () => formatElapsed(now() - started);
11212
+ if (isJsonOutput())
11213
+ return { stop: elapsed };
11214
+ const animate = options.animate ?? stream.isTTY === true;
11215
+ if (!animate) {
11216
+ stream.write(` ${SYMBOLS.progress} ${message}
11217
+ `);
11218
+ return { stop: elapsed };
10797
11219
  }
10798
- return args;
10799
- }
10800
- var generatedToolCommands = new WeakSet;
10801
- function createToolCommands(catalog) {
10802
- const commands = {};
10803
- const toCliText = cliText(catalog);
10804
- for (const tool of catalog) {
10805
- const command = defineCommand({
10806
- meta: {
10807
- name: tool.cli?.at(-1) ?? tool.name,
10808
- description: toCliText(tool.description)
10809
- },
10810
- args: { ...buildToolArgs(tool.inputSchema, toCliText, tool.cliArgs), ...cliOnlyArgs(tool) },
10811
- async run({ args }) {
10812
- process.exitCode = await runToolInvoke(tool, args);
11220
+ const color = colorEnabled(stream);
11221
+ let frame = 0;
11222
+ const draw = () => {
11223
+ const bars = accent(PROGRESS_FRAMES[frame % PROGRESS_FRAMES.length] ?? SYMBOLS.progress, color);
11224
+ stream.write(`\r\x1B[2K ${bars} ${message} ${dim(elapsed(), color)}`);
11225
+ frame += 1;
11226
+ };
11227
+ const restore = () => stream.write("\r\x1B[2K\x1B[?25h");
11228
+ const onInterrupt = () => {
11229
+ restore();
11230
+ process.exit(130);
11231
+ };
11232
+ stream.write("\x1B[?25l");
11233
+ draw();
11234
+ const timer = setInterval(draw, FRAME_MS);
11235
+ timer.unref?.();
11236
+ process.once("SIGINT", onInterrupt);
11237
+ let stopped = false;
11238
+ return {
11239
+ stop() {
11240
+ if (!stopped) {
11241
+ stopped = true;
11242
+ clearInterval(timer);
11243
+ process.off("SIGINT", onInterrupt);
11244
+ restore();
10813
11245
  }
10814
- });
10815
- generatedToolCommands.add(command);
10816
- commands[tool.name] = command;
10817
- }
10818
- return commands;
10819
- }
10820
-
10821
- // src/commands/login.ts
10822
- import { hostname } from "node:os";
10823
-
10824
- // src/commands/login-organization.ts
10825
- import { createInterface as createInterface2 } from "node:readline/promises";
10826
- function label(organization) {
10827
- return `${organization.name} (${organization.slug ?? organization.id})`;
11246
+ return elapsed();
11247
+ }
11248
+ };
10828
11249
  }
10829
- var chooseOrganizationOnTerminal = async (organizations) => {
10830
- console.log(`
10831
- Which organization should commands use by default?`);
10832
- organizations.forEach((organization, index) => {
10833
- console.log(` ${index + 1}. ${label(organization)}`);
10834
- });
10835
- const prompt = createInterface2({ input: process.stdin, output: process.stdout });
11250
+ async function withProgress(message, work, options) {
11251
+ const progress = startProgress(message, options);
10836
11252
  try {
10837
- const answer = (await prompt.question(`Number (Enter to skip): `)).trim();
10838
- const index = Number.parseInt(answer, 10) - 1;
10839
- return Number.isInteger(index) && index >= 0 && index < organizations.length ? index : null;
10840
- } finally {
10841
- prompt.close();
11253
+ const value = await work();
11254
+ return { value, elapsed: progress.stop() };
11255
+ } catch (err) {
11256
+ progress.stop();
11257
+ throw err;
10842
11258
  }
10843
- };
10844
- function defaultChooser() {
10845
- return process.stdin.isTTY && process.stdout.isTTY ? chooseOrganizationOnTerminal : undefined;
10846
11259
  }
10847
- async function chooseDefaultOrganizationAfterLogin(org, log, choose = defaultChooser()) {
10848
- const cli = getCliName();
10849
- let organizations;
11260
+
11261
+ // src/container-flow.ts
11262
+ async function withContainerPick(call, send, pickers, notice) {
10850
11263
  try {
10851
- ({ organizations } = await authorizedRequest("/api/v1/tools/organizations_list", { method: "POST", body: "{}" }));
10852
- } catch {
10853
- organizations = undefined;
10854
- }
10855
- if (!Array.isArray(organizations)) {
10856
- log(eventLine("item", `Pick a default organization with \`${cli} organizations use <org>\`.`));
10857
- return org ? 1 : 0;
10858
- }
10859
- const settings = await readSettings();
10860
- const save = async (organization, why) => {
10861
- settings.defaultOrganization = { id: organization.id, slug: organization.slug };
10862
- await writeSettings(settings);
10863
- log(eventLine("done", `Default organization: ${label(organization)}${why}.`));
10864
- };
10865
- const ref = org?.trim();
10866
- if (ref) {
10867
- const match = organizations.find((organization) => organization.id === ref) ?? organizations.find((organization) => organization.slug === ref);
10868
- if (!match) {
10869
- log(eventLine("failed", `You are not a member of an organization "${ref}"; no default set.`));
10870
- return 1;
11264
+ return await send();
11265
+ } catch (err) {
11266
+ const question = containerQuestionOf(err);
11267
+ if (!pickers || !question || !call.canPick)
11268
+ throw err;
11269
+ const container = await pickers.choose(question);
11270
+ if (!container)
11271
+ throw err;
11272
+ call.setContainer(container);
11273
+ const result = await send();
11274
+ if (call.sandboxId !== undefined && await pickers.confirmDefault(container)) {
11275
+ try {
11276
+ await authorizedRequest("/api/v1/tools/sandboxes_default_container", {
11277
+ method: "POST",
11278
+ body: JSON.stringify({
11279
+ organization: call.organization,
11280
+ sandboxId: call.sandboxId,
11281
+ container
11282
+ })
11283
+ });
11284
+ notice(eventLine("done", `Default container: ${container}.`));
11285
+ } catch (err) {
11286
+ notice(eventLine("failed", `The default container was not set: ${err instanceof Error ? err.message : String(err)}`));
11287
+ }
10871
11288
  }
10872
- await save(match, "");
10873
- return 0;
10874
- }
10875
- const current = organizations.find((organization) => organization.id === settings.defaultOrganization?.id);
10876
- if (current) {
10877
- log(eventLine("item", `Default organization: ${label(current)} (unchanged).`));
10878
- return 0;
10879
- }
10880
- if (settings.defaultOrganization) {
10881
- delete settings.defaultOrganization;
10882
- await writeSettings(settings);
10883
- }
10884
- const [only, ...others] = organizations;
10885
- if (!only) {
10886
- log(eventLine("item", "You do not belong to an organization yet."));
10887
- return 0;
10888
- }
10889
- if (others.length === 0) {
10890
- await save(only, " (your only organization)");
10891
- return 0;
10892
- }
10893
- const index = choose ? await choose(organizations) : null;
10894
- const chosen = index === null ? undefined : organizations[index];
10895
- if (chosen) {
10896
- await save(chosen, "");
10897
- return 0;
11289
+ return result;
10898
11290
  }
10899
- log(eventLine("item", `You belong to ${organizations.length} organizations: ${organizations.map((organization) => organization.slug ?? organization.id).join(", ")}. Pick a default with \`${cli} organizations use <org>\`, or pass --org.`));
10900
- return 0;
10901
11291
  }
10902
-
10903
- // src/commands/login.ts
10904
- function defaultClientName() {
11292
+ async function withContainerStart(call, send, start, pickers, notice) {
10905
11293
  try {
10906
- const host = hostname().trim();
10907
- if (host)
10908
- return host.slice(0, 64);
10909
- } catch {}
10910
- return "ghostdock-cli";
11294
+ return await send();
11295
+ } catch (err) {
11296
+ const container = notRunningContainerOf(err);
11297
+ if (!container || call.sandboxId === undefined)
11298
+ throw err;
11299
+ if (!start && !(pickers && await pickers.confirmStart(container)))
11300
+ throw err;
11301
+ await withProgress("Starting container…", () => authorizedRequest("/api/v1/tools/sandboxes_containers_start", {
11302
+ method: "POST",
11303
+ body: JSON.stringify({
11304
+ organization: call.organization,
11305
+ sandboxId: call.sandboxId,
11306
+ container
11307
+ })
11308
+ }));
11309
+ notice(eventLine("done", `Started ${container}.`));
11310
+ return await send();
11311
+ }
10911
11312
  }
10912
- var DEFAULT_TIMEOUT_SECONDS = 300;
10913
- function toCredentials(result) {
11313
+
11314
+ // src/container-logs.ts
11315
+ var FOLLOW_INTERVAL_MS = 2000;
11316
+ function defaultLogsDeps(follow) {
11317
+ let stop = false;
11318
+ if (follow) {
11319
+ process.once("SIGINT", () => {
11320
+ stop = true;
11321
+ });
11322
+ }
10914
11323
  return {
10915
- accessToken: result.accessToken,
10916
- refreshToken: result.refreshToken,
10917
- accessExpiresAt: new Date(Date.now() + result.expiresIn * 1000).toISOString()
11324
+ request: (input) => authorizedRequest("/api/v1/tools/sandboxes_containers_logs", {
11325
+ method: "POST",
11326
+ body: JSON.stringify(input)
11327
+ }),
11328
+ out: (line) => process.stdout.write(`${line}
11329
+ `),
11330
+ err: (line) => process.stderr.write(`${line}
11331
+ `),
11332
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
11333
+ stopped: () => stop
10918
11334
  };
10919
11335
  }
10920
- function sleep(ms) {
10921
- return new Promise((resolve) => setTimeout(resolve, ms));
10922
- }
10923
- async function runLogin(args, log = console.log, choose = defaultChooser(), error = printError) {
10924
- const loggedIn = async () => {
10925
- log(eventLine("ready", "Logged in."));
10926
- return chooseDefaultOrganizationAfterLogin(args.org, log, choose);
10927
- };
10928
- if (args.code) {
10929
- const result = await deviceToken({ pasteCode: args.code });
10930
- if (result.status === "approved") {
10931
- await writeCredentials(toCredentials(result));
10932
- return loggedIn();
11336
+ async function runContainerLogs(input, options, deps = defaultLogsDeps(options.follow)) {
11337
+ const json = isJsonOutput();
11338
+ const color = colorEnabled(process.stdout);
11339
+ const print = (line) => {
11340
+ if (json) {
11341
+ deps.out(JSON.stringify(line));
11342
+ return;
10933
11343
  }
10934
- error("That code has not been approved yet. Approve it in the browser, then run this again.");
11344
+ const text = options.timestamps && line.time ? `${dim(line.time, color)} ${line.text}` : line.text;
11345
+ (line.stream === "stderr" ? deps.err : deps.out)(text);
11346
+ };
11347
+ let result = await deps.request(input);
11348
+ if (!result.running) {
11349
+ deps.err("The sandbox is stopped: it has no logs to show.");
10935
11350
  return 1;
10936
11351
  }
10937
- const start = await deviceStart({ client_name: defaultClientName() });
10938
- log(eventLine("url", `Open this URL to sign in: ${start.verificationUrl}`));
10939
- log(` Pass --wait to finish here once you approve, or approve on another device and run \`${getCliName()} login --code <paste-code>\` with the code shown there.`);
10940
- if (!args.wait) {
10941
- return 0;
10942
- }
10943
- const timeoutSeconds = Number.parseInt(args.timeout ?? "", 10) || DEFAULT_TIMEOUT_SECONDS;
10944
- const deadline = Date.now() + timeoutSeconds * 1000;
10945
- const intervalMs = start.interval * 1000;
10946
- const progress = startProgress("Waiting for approval in the browser…");
10947
- while (Date.now() < deadline) {
10948
- await sleep(intervalMs);
10949
- try {
10950
- const result = await deviceToken({ deviceCode: start.deviceCode });
10951
- if (result.status === "approved") {
10952
- progress.stop();
10953
- await writeCredentials(toCredentials(result));
10954
- return loggedIn();
10955
- }
10956
- } catch (err) {
10957
- progress.stop();
10958
- if (err instanceof ApiError && err.status === 400) {
10959
- error(`Login failed: ${err.message}`);
10960
- return 1;
11352
+ let last = null;
11353
+ for (;; ) {
11354
+ for (const line of result.lines) {
11355
+ print(line);
11356
+ if (line.time)
11357
+ last = line.time;
11358
+ }
11359
+ if (!options.follow) {
11360
+ if (result.truncated && json) {
11361
+ deps.out(JSON.stringify({ truncated: true }));
11362
+ } else if (result.truncated) {
11363
+ deps.err(dim("(older lines left out: use --tail for more)", colorEnabled(process.stderr)));
10961
11364
  }
10962
- throw err;
11365
+ return 0;
10963
11366
  }
10964
- }
10965
- progress.stop();
10966
- error(`Timed out waiting for approval. Run \`${getCliName()} login\` again.`);
10967
- return 1;
10968
- }
10969
- var loginCommand = defineCommand({
10970
- meta: {
10971
- name: "login",
10972
- description: "Sign in and store a device access token for this machine (~/.config/ghostdock/credentials.json)."
10973
- },
10974
- args: {
10975
- wait: {
10976
- type: "boolean",
10977
- description: "Poll until the login is approved in the browser (default timeout 5 minutes).",
10978
- default: false
10979
- },
10980
- code: {
10981
- type: "string",
10982
- description: "Redeem the one-time paste code shown in the browser instead of polling."
10983
- },
10984
- timeout: {
10985
- type: "string",
10986
- description: "Max seconds to poll when using --wait (default 300)."
10987
- },
10988
- org: {
10989
- type: "string",
10990
- description: "Set this organization (id or slug) as the default once logged in."
11367
+ if (!result.truncated)
11368
+ await deps.sleep(FOLLOW_INTERVAL_MS);
11369
+ if (deps.stopped())
11370
+ return 0;
11371
+ const { tail: _tail, ...rest } = input;
11372
+ try {
11373
+ result = await deps.request(last ? { ...rest, after: last } : input);
11374
+ } catch (error) {
11375
+ deps.err(red(error instanceof Error ? error.message : String(error), colorEnabled(process.stderr)));
11376
+ return 1;
11377
+ }
11378
+ if (!result.running) {
11379
+ deps.err("The sandbox stopped.");
11380
+ return 0;
10991
11381
  }
10992
- },
10993
- async run({ args }) {
10994
- process.exitCode = await runLogin(args);
10995
11382
  }
10996
- });
11383
+ }
10997
11384
 
10998
- // src/commands/logout.ts
10999
- async function runLogout(log = console.log) {
11000
- const credentials = await readCredentials();
11001
- if (credentials) {
11002
- try {
11003
- await deviceRevoke({
11004
- accessToken: credentials.accessToken,
11005
- refreshToken: credentials.refreshToken
11006
- });
11007
- } catch {}
11385
+ // src/human.ts
11386
+ var ID_KEYS = [
11387
+ "sandboxId",
11388
+ "siteId",
11389
+ "projectId",
11390
+ "deploymentId",
11391
+ "imageId",
11392
+ "snapshotId",
11393
+ "urlId",
11394
+ "mcpId",
11395
+ "commandId",
11396
+ "sessionId",
11397
+ "sandboxProviderId",
11398
+ "id"
11399
+ ];
11400
+ var LABEL_KEYS = ["displayName", "name", "title", "slug", "label", "path", "uri", "ref"];
11401
+ var STATUS_KEYS = ["status", "outcome", "state"];
11402
+ var URL_KEYS = ["url", "liveUrl", "publicUrl", "sshCommand"];
11403
+ function isItem(value) {
11404
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11405
+ }
11406
+ function isScalar(value) {
11407
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
11408
+ }
11409
+ function firstKey(item, keys) {
11410
+ return keys.find((key) => {
11411
+ const value = item[key];
11412
+ return typeof value === "string" && value.trim() !== "";
11413
+ });
11414
+ }
11415
+ function firstString(item, keys) {
11416
+ const key = firstKey(item, keys);
11417
+ return key ? item[key] : undefined;
11418
+ }
11419
+ function symbolForStatus(status) {
11420
+ const s = (status ?? "").toLowerCase();
11421
+ if (/(fail|error|denied)/.test(s))
11422
+ return "failed";
11423
+ if (/^(running|active|ready|deployed|published|online|verified|ok)$/.test(s))
11424
+ return "ready";
11425
+ if (/^(stopped|deleted|unpublished|vanished|archived|revoked|closed|killed|exited|dead|paused)$/.test(s)) {
11426
+ return "stopped";
11008
11427
  }
11009
- await clearCredentials();
11010
- log(eventLine("stopped", "Logged out."));
11011
- return 0;
11428
+ if (/(pending|starting|spawning|stopping|building|progress|awaiting|queued|creating|restarting|removing)/.test(s)) {
11429
+ return "progress";
11430
+ }
11431
+ return "item";
11012
11432
  }
11013
- var logoutCommand = defineCommand({
11014
- meta: {
11015
- name: "logout",
11016
- description: "Revoke the device session and remove stored credentials."
11017
- },
11018
- async run() {
11019
- process.exitCode = await runLogout();
11433
+ function isSandbox(item) {
11434
+ return typeof item.sandboxId === "string" && typeof item.name === "string" && !("id" in item);
11435
+ }
11436
+ function lineKeys(item) {
11437
+ if (isSandbox(item)) {
11438
+ return { id: "name", label: firstKey(item, ["displayName"]) };
11020
11439
  }
11021
- });
11022
-
11023
- // src/commands/network.ts
11024
- import { spawn, spawnSync } from "node:child_process";
11025
- import { existsSync as existsSync2 } from "node:fs";
11026
- import { hostname as hostname2 } from "node:os";
11027
- function defaultDeps() {
11028
- return {
11029
- request: authorizedRequest,
11030
- run: (command, args) => new Promise((resolve, reject) => {
11031
- const child = spawn(command, args, { stdio: "inherit" });
11032
- child.on("error", reject);
11033
- child.on("close", (code) => resolve(code ?? 1));
11034
- }),
11035
- output: (command, args) => {
11036
- const result = spawnSync(command, args, { encoding: "utf8" });
11037
- return result.status === 0 ? result.stdout : "";
11038
- },
11039
- platform: process.platform,
11040
- has: (command) => spawnSync("sh", ["-c", `command -v ${command}`]).status === 0,
11041
- dir: networkDir(),
11042
- up: () => tunnelUp(),
11043
- exists: existsSync2,
11044
- log: console.log,
11045
- error: printError,
11046
- notice: printNotice
11047
- };
11440
+ const idKeys = "id" in item ? ID_KEYS.filter((key) => key !== "sandboxId") : ID_KEYS;
11441
+ return { id: firstKey(item, idKeys), label: firstKey(item, LABEL_KEYS) };
11048
11442
  }
11049
- function message(err) {
11050
- return err instanceof Error ? err.message : String(err);
11443
+ function shortId(id) {
11444
+ return /^[0-9a-f]{64}$/.test(id) ? id.slice(0, 12) : id;
11051
11445
  }
11052
- function connectCommandFor(provider) {
11053
- const ref = provider.connectRef;
11054
- const arg = /^[A-Za-z0-9._-]+$/.test(ref) ? ref : JSON.stringify(ref);
11055
- return `${getCliName()} network connect ${arg}`;
11446
+ function itemLine(item, color) {
11447
+ const keys = lineKeys(item);
11448
+ const id = keys.id ? item[keys.id] : undefined;
11449
+ const label = keys.label ? item[keys.label] : undefined;
11450
+ const status = firstString(item, STATUS_KEYS);
11451
+ const url = firstString(item, URL_KEYS);
11452
+ const parts = [label ?? id ?? "(unnamed)"];
11453
+ if (label && id && id !== label)
11454
+ parts.push(dim(shortId(id), color));
11455
+ if (status)
11456
+ parts.push(status);
11457
+ if (url)
11458
+ parts.push(`${SYMBOLS.url} ${url}`);
11459
+ return eventLine(symbolForStatus(status), parts.join(" "), color);
11056
11460
  }
11057
- async function wgQuick(deps, verb) {
11058
- const args = ["wg-quick", verb, configPath(deps.dir)];
11059
- deps.notice(`Running: sudo ${args.join(" ")}`);
11060
- return deps.run("sudo", args);
11461
+ function scalarText(value) {
11462
+ if (value === null)
11463
+ return "—";
11464
+ if (isScalar(value))
11465
+ return String(value);
11466
+ if (Array.isArray(value) && value.every(isScalar))
11467
+ return value.join(", ");
11468
+ if (Array.isArray(value))
11469
+ return `${value.length} item${value.length === 1 ? "" : "s"}`;
11470
+ return "…";
11061
11471
  }
11062
- async function runNetworkConnect(args = {}, deps = defaultDeps()) {
11063
- suppressPausedNotice();
11064
- if (!deps.has("wg-quick")) {
11065
- deps.error(`wg-quick is not installed. ${installHint()}`);
11066
- return 1;
11472
+ function fieldLines(item, color, skip) {
11473
+ const lines = [];
11474
+ for (const [key, value] of Object.entries(item)) {
11475
+ if (skip.has(key) || value === undefined)
11476
+ continue;
11477
+ if (Array.isArray(value) && value.some(isItem)) {
11478
+ lines.push(` ${dim(`${key}:`, color)}`);
11479
+ for (const entry of value) {
11480
+ lines.push(` ${isItem(entry) ? itemLine(entry, color) : eventLine("item", scalarText(entry), color)}`);
11481
+ }
11482
+ continue;
11483
+ }
11484
+ if (isItem(value)) {
11485
+ for (const [sub, subValue] of Object.entries(value)) {
11486
+ if (subValue === undefined || isItem(subValue))
11487
+ continue;
11488
+ lines.push(` ${dim(`${key}.${sub}:`, color)} ${scalarText(subValue)}`);
11489
+ }
11490
+ continue;
11491
+ }
11492
+ lines.push(` ${dim(`${key}:`, color)} ${scalarText(value)}`);
11067
11493
  }
11068
- let registered;
11069
- let privateKey;
11070
- const previous = await readDeviceState(deps.dir);
11071
- try {
11072
- const organization = await requireOrganizationRef(args.org, deps.notice);
11073
- const provider = args.provider?.trim();
11074
- const key = await loadOrCreateKey(deps.dir);
11075
- privateKey = key.privateKey;
11076
- registered = await deps.request("/api/v1/network/devices", {
11077
- method: "POST",
11078
- body: JSON.stringify({
11079
- publicKey: key.publicKey,
11080
- name: hostname2().slice(0, 64) || "device",
11081
- organization,
11082
- ...provider ? { provider } : {}
11083
- })
11084
- });
11085
- } catch (err) {
11086
- deps.error(message(err));
11087
- return 1;
11494
+ return lines;
11495
+ }
11496
+ function listLines(items, color) {
11497
+ if (items.length === 0)
11498
+ return [` ${dim("(none)", color)}`];
11499
+ return items.map((item) => isItem(item) ? itemLine(item, color) : eventLine("item", scalarText(item), color));
11500
+ }
11501
+ function bootPhase(value) {
11502
+ return isItem(value) && typeof value.ms === "number" && Number.isFinite(value.ms) ? { ms: value.ms } : null;
11503
+ }
11504
+ var DISK_SOURCE_TEXT = {
11505
+ fresh: "image pulled",
11506
+ "image-base": "image already on disk",
11507
+ "user-snapshot": "from a snapshot"
11508
+ };
11509
+ function bootLines(data, color) {
11510
+ if (!isItem(data) || !isItem(data.boot))
11511
+ return [];
11512
+ const sandbox = bootPhase(data.boot.sandbox);
11513
+ if (!sandbox)
11514
+ return [];
11515
+ const lines = [eventLine("ready", `sandbox started in ${formatElapsed(sandbox.ms)}`, color)];
11516
+ const container = bootPhase(data.boot.container);
11517
+ if (container) {
11518
+ lines.push(eventLine("done", `container started in ${formatElapsed(container.ms)}`, color));
11088
11519
  }
11089
- const dns = detectDnsMode();
11090
- let config;
11091
- try {
11092
- config = renderConfig({ privateKey, registered, dns });
11093
- } catch (err) {
11094
- deps.error(message(err));
11095
- return 1;
11520
+ const provision = bootPhase(data.boot.provision);
11521
+ if (provision) {
11522
+ const disk = typeof data.boot.disk === "string" ? DISK_SOURCE_TEXT[data.boot.disk] : undefined;
11523
+ const how = disk ? ` (${disk})` : "";
11524
+ lines.push(eventLine("done", `ready in ${formatElapsed(provision.ms)}${how}`, color));
11525
+ }
11526
+ return lines;
11527
+ }
11528
+ function renderResult(data, color) {
11529
+ if (data === null || data === undefined)
11530
+ return [eventLine("done", "Done.", color)];
11531
+ if (Array.isArray(data))
11532
+ return listLines(data, color);
11533
+ if (!isItem(data))
11534
+ return [eventLine("item", scalarText(data), color)];
11535
+ const entries = Object.entries(data).filter(([, value]) => value !== undefined);
11536
+ if (entries.length === 0 || entries.length === 1 && data.ok === true) {
11537
+ return [eventLine("done", "Done.", color)];
11538
+ }
11539
+ const lists = entries.filter(([, value]) => Array.isArray(value) && (value.length === 0 || value.some(isItem)));
11540
+ if (lists.length === 1 && !firstString(data, ID_KEYS)) {
11541
+ const [listKey, list] = lists[0];
11542
+ const rest = fieldLines(data, color, new Set([listKey]));
11543
+ return [...rest, ...listLines(list, color)];
11544
+ }
11545
+ const { id: idKey, label: labelKey } = lineKeys(data);
11546
+ const used = [idKey, labelKey, firstKey(data, STATUS_KEYS), firstKey(data, URL_KEYS)].filter((key) => key !== undefined);
11547
+ const head = used.length > 0 ? [itemLine(data, color)] : [];
11548
+ const boot = bootLines(data, color);
11549
+ if ("boot" in data && (boot.length > 0 || data.boot === null))
11550
+ used.push("boot");
11551
+ return [...head, ...boot, ...fieldLines(data, color, new Set(used))];
11552
+ }
11553
+
11554
+ // src/cli-help-text.ts
11555
+ var CLI_HELP_DESCRIPTION_MAX_LENGTH = 80;
11556
+ function cliHelpDescription(description, maxLength = CLI_HELP_DESCRIPTION_MAX_LENGTH) {
11557
+ const oneLine = description.replace(/\s+/g, " ").trim();
11558
+ if (oneLine.length <= maxLength) {
11559
+ return oneLine;
11096
11560
  }
11097
- const previousConfig = await readConfig(configPath(deps.dir));
11098
- await writeConfig(config, configPath(deps.dir));
11099
- await writeDeviceState({
11100
- baseUrl: getBaseUrl(),
11101
- deviceId: registered.device.id,
11102
- address: registered.device.address ?? "",
11103
- gatewayProvider: registered.provider,
11104
- zones: [
11105
- registered.network.zone,
11106
- ...registered.network.netZone ? [registered.network.netZone] : []
11107
- ]
11108
- }, deps.dir);
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;
11561
+ const budget = Math.max(1, maxLength - 1);
11562
+ const slice = oneLine.slice(0, budget);
11563
+ const breakAt = slice.lastIndexOf(" ");
11564
+ const cut = breakAt >= Math.floor(budget * 0.6) ? slice.slice(0, breakAt) : slice;
11565
+ return `${cut.trimEnd()}…`;
11566
+ }
11567
+
11568
+ // src/schema-flags.ts
11569
+ function toFlagName(propertyName) {
11570
+ if (propertyName === "organization")
11571
+ return "org";
11572
+ return propertyName.replace(/([KMGTP])iB(?![a-z])/g, "$1ib").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
11573
+ }
11574
+ function isJsonSchemaProperty(value) {
11575
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11576
+ }
11577
+ function schemaVariants(prop) {
11578
+ const variants = [];
11579
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
11580
+ const group = prop[key];
11581
+ if (!Array.isArray(group))
11582
+ continue;
11583
+ for (const item of group) {
11584
+ if (isJsonSchemaProperty(item))
11585
+ variants.push(item);
11586
+ }
11127
11587
  }
11128
- if (wasUp) {
11129
- const down = await wgQuick(deps, "down");
11130
- if (down !== 0) {
11131
- deps.error(`Could not take the old tunnel down (wg-quick exited ${down}).`);
11132
- return 1;
11588
+ return variants;
11589
+ }
11590
+ function primaryJsonType(prop) {
11591
+ if (prop.type !== undefined) {
11592
+ if (Array.isArray(prop.type)) {
11593
+ return prop.type.find((t) => t !== "null");
11133
11594
  }
11595
+ return prop.type === "null" ? undefined : prop.type;
11134
11596
  }
11135
- const ipv4Clashes = ipv4Clashes4(registered, deps);
11136
- const code = await wgQuick(deps, "up");
11137
- if (code !== 0) {
11138
- deps.error(`wg-quick exited ${code}. The config is in ${configPath(deps.dir)}.`);
11139
- return 1;
11597
+ const types = new Set;
11598
+ for (const variant of schemaVariants(prop)) {
11599
+ const type = primaryJsonType(variant);
11600
+ if (type)
11601
+ types.add(type);
11140
11602
  }
11141
- const { device, network, provider, shortNameOrganizationChanged } = registered;
11142
- const pinned = device.shortNameOrganization;
11143
- const switched = wasUp && previous?.gatewayProvider && previous.gatewayProvider.id !== provider.id ? previous.gatewayProvider : null;
11144
- printResult({
11145
- device,
11146
- network,
11147
- provider,
11148
- shortNameOrganizationChanged,
11149
- interface: INTERFACE,
11150
- dns,
11151
- rearmed: false,
11152
- pause: device.state
11153
- }, () => [
11154
- eventLine("done", `Connected through ${provider.name}. This machine is ${device.address} in the private network.`),
11155
- ...switched ? [
11156
- ` Replaced the connection through ${switched.name}: its sandboxes are out of reach now.`
11157
- ] : [],
11158
- ` Reachable: the sandboxes on ${provider.name}. One provider at a time.`,
11159
- ` Names: <sandbox>.<org>.${network.zone}`,
11160
- ...network.netZone ? [` In a browser: https://<sandbox>--<org>.${network.netZone}:<port>`] : [],
11161
- ...network.netZone && pinned ? [
11162
- ` Short names: https://<sandbox>.${network.netZone}:<port> for ${pinned.slug}'s sandboxes, on this machine.`,
11163
- ` Another organization: ${getCliName()} network connect --org <org>.`
11164
- ] : [],
11165
- ...network.netZone && pinned && shortNameOrganizationChanged ? clearingNotice(network.netCookieSite) : [],
11166
- ...network.ingress4 && !device.address4 ? [
11167
- " No IPv4 address was free on this provider's host, so browser names work over IPv6 only:",
11168
- " Chrome on a network without IPv6 cannot open them on this machine. Firefox and Safari",
11169
- " can. Run connect again later: an address frees up when another device goes."
11170
- ] : [],
11171
- ...ipv4Clashes.map(({ address, owner }) => ` ${address} is already routed through ${owner} on this machine: browsers may not reach the private network's names over IPv4 while it is. Ask your Ghostdock admin about the IPv4 block.`),
11172
- ...dns === "none" ? [
11173
- " No split DNS on this machine (no systemd-resolved): use the addresses from",
11174
- ` \`${getCliName()} network ls\`.`
11175
- ] : [],
11176
- ` → ${getCliName()} network ls`
11177
- ], deps.log);
11178
- printPauseHint(device.state, deps);
11179
- return 0;
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).`;
11603
+ if (types.has("object"))
11604
+ return "object";
11605
+ if (types.has("array"))
11606
+ return "array";
11607
+ if (types.size === 1) {
11608
+ const [only] = types;
11609
+ return only;
11610
+ }
11611
+ return;
11186
11612
  }
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
- `));
11613
+ function allowsNull(prop) {
11614
+ if (prop.type === "null")
11615
+ return true;
11616
+ if (Array.isArray(prop.type) && prop.type.includes("null"))
11617
+ return true;
11618
+ return schemaVariants(prop).some(allowsNull);
11193
11619
  }
11194
- function ipv4Clashes4(registered, deps) {
11195
- const addresses = [registered.network.ingress4, registered.device.address4].filter((a) => typeof a === "string" && isIPv4Address(a));
11196
- const clashes = [];
11197
- for (const address of addresses) {
11198
- const [command, args] = ipv4RouteCommand(deps.platform, address);
11199
- const owner = ipv4RouteOwner(deps.platform, deps.output(command, args));
11200
- if (owner)
11201
- clashes.push({ address, owner });
11620
+ function buildToolArgs(schema, describe = cliHelpDescription, positionals = []) {
11621
+ const properties = schema.properties ?? {};
11622
+ const required = new Set(schema.required ?? []);
11623
+ const args = {};
11624
+ for (const name of positionals) {
11625
+ const prop = properties[name];
11626
+ if (!prop)
11627
+ continue;
11628
+ const jsonType = primaryJsonType(prop);
11629
+ args[toFlagName(name)] = {
11630
+ type: "positional",
11631
+ description: describe(prop.description ?? name),
11632
+ required: true,
11633
+ valueHint: jsonType
11634
+ };
11202
11635
  }
11203
- return clashes;
11636
+ for (const [name, prop] of Object.entries(properties)) {
11637
+ if (positionals.includes(name))
11638
+ continue;
11639
+ const jsonType = primaryJsonType(prop);
11640
+ const isBoolean = jsonType === "boolean";
11641
+ const isJsonValue = jsonType === "object" || jsonType === "array";
11642
+ const typeNote = isJsonValue ? ` (JSON ${jsonType})` : "";
11643
+ const isOrganization = name === "organization";
11644
+ const isRequired = required.has(name) && prop.default === undefined && !isOrganization;
11645
+ const defaultNote = isOrganization ? " Defaults to GHOSTDOCK_ORG, then `organizations use`." : "";
11646
+ args[toFlagName(name)] = isBoolean ? {
11647
+ type: "boolean",
11648
+ description: describe(`${prop.description ?? name}${typeNote}${defaultNote}`),
11649
+ required: isRequired
11650
+ } : {
11651
+ type: "string",
11652
+ description: describe(`${prop.description ?? name}${typeNote}${defaultNote}`),
11653
+ required: isRequired,
11654
+ valueHint: jsonType
11655
+ };
11656
+ }
11657
+ return args;
11204
11658
  }
11205
- function clearingNotice(cookieSite) {
11206
- const site = cookieSite?.site ?? "the net domain";
11207
- return [
11208
- " Short names meant another organization's sandboxes before. The first short-name site each",
11209
- ` browser opens now clears its data once: the site's storage, and every cookie of ${site}.`,
11210
- ` That signs the browser out of every site under ${site}${cookieSite?.includesApp ? ", the Ghostdock app included" : ""}.`,
11211
- " Reload or close browser tabs opened on short names before this connect."
11212
- ];
11659
+ function coerceFlagValue(raw, prop, flagName) {
11660
+ if (raw === "null" && allowsNull(prop))
11661
+ return null;
11662
+ const jsonType = primaryJsonType(prop);
11663
+ if (jsonType === "boolean") {
11664
+ return typeof raw === "boolean" ? raw : raw === "true";
11665
+ }
11666
+ if (jsonType === "number" || jsonType === "integer") {
11667
+ const num = Number(raw);
11668
+ if (Number.isNaN(num)) {
11669
+ throw new Error(`Invalid number for --${flagName}: ${String(raw)}`);
11670
+ }
11671
+ return num;
11672
+ }
11673
+ if (jsonType === "object" || jsonType === "array") {
11674
+ if (typeof raw !== "string")
11675
+ return raw;
11676
+ try {
11677
+ return JSON.parse(raw);
11678
+ } catch {
11679
+ throw new Error(`Invalid JSON for --${flagName}: ${String(raw)}`);
11680
+ }
11681
+ }
11682
+ return raw;
11213
11683
  }
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));
11684
+ function buildToolInput(schema, rawArgs) {
11685
+ const properties = schema.properties ?? {};
11686
+ const input = {};
11687
+ for (const [name, prop] of Object.entries(properties)) {
11688
+ const flagName = toFlagName(name);
11689
+ const raw = rawArgs[flagName] ?? rawArgs[name];
11690
+ if (raw === undefined)
11691
+ continue;
11692
+ input[name] = coerceFlagValue(raw, prop, flagName);
11693
+ }
11694
+ return input;
11223
11695
  }
11224
- async function runNetworkDisconnect(deps = defaultDeps()) {
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;
11696
+
11697
+ // src/commands/invoke.ts
11698
+ async function runToolInvoke(tool, rawArgs, log = console.log, error = printError, notice = printNotice, pickers = defaultContainerPickers()) {
11699
+ const properties = tool.inputSchema.properties;
11700
+ let input;
11701
+ try {
11702
+ input = buildToolInput(tool.inputSchema, rawArgs);
11703
+ if (properties && "organization" in properties && input.organization === undefined) {
11704
+ input.organization = await requireOrganizationRef(undefined, notice);
11232
11705
  }
11706
+ } catch (err) {
11707
+ error(err instanceof Error ? err.message : String(err));
11708
+ return 1;
11233
11709
  }
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}).`);
11710
+ if (tool.name === "sandboxes_containers_logs") {
11711
+ try {
11712
+ return await runContainerLogs(input, {
11713
+ follow: rawArgs.follow === true,
11714
+ timestamps: rawArgs.timestamps === true
11715
+ });
11716
+ } catch (err) {
11717
+ error(err instanceof Error ? err.message : `${tool.name} failed.`);
11241
11718
  return 1;
11242
11719
  }
11243
11720
  }
11244
- if (failed)
11245
- return 1;
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);
11249
- return 0;
11250
- }
11251
- async function readConnection(deps) {
11252
- const state = await readDeviceState(deps.dir);
11253
- const up = deps.up();
11254
- if (!state)
11255
- return { state, up, device: null, asked: true, provider: null, staleMtu: null };
11256
- try {
11257
- const { devices, mtu } = await deps.request("/api/v1/network/devices");
11258
- const device = devices.find((d) => d.id === state.deviceId) ?? null;
11259
- const config = await readConfigMtu(configPath(deps.dir));
11260
- const staleMtu = config !== null && config !== mtu ? { config, current: mtu } : null;
11261
- if (staleMtu) {
11262
- deps.notice(`This machine's tunnel has MTU ${staleMtu.config}, the network now uses ${staleMtu.current}. ` + `Run \`${getCliName()} network connect\` again: until then, large packets other than TCP ` + "can be lost on the way.");
11721
+ const call = {
11722
+ organization: typeof input.organization === "string" ? input.organization : undefined,
11723
+ sandboxId: typeof input.sandboxId === "string" ? input.sandboxId : undefined,
11724
+ canPick: !!properties && "container" in properties && !input.container,
11725
+ setContainer: (container) => {
11726
+ input.container = container;
11263
11727
  }
11264
- return {
11265
- state,
11266
- up,
11267
- device,
11268
- asked: true,
11269
- provider: up ? device?.gatewayProvider ?? null : null,
11270
- staleMtu
11728
+ };
11729
+ try {
11730
+ const post = () => authorizedRequest(`/api/v1/tools/${tool.name}`, {
11731
+ method: "POST",
11732
+ body: JSON.stringify(input)
11733
+ });
11734
+ const waitMessage = PROGRESS_MESSAGES[tool.name];
11735
+ let elapsed;
11736
+ const send = async () => {
11737
+ if (!waitMessage)
11738
+ return post();
11739
+ const timed = await withProgress(waitMessage, post);
11740
+ elapsed = timed.elapsed;
11741
+ return timed.value;
11271
11742
  };
11743
+ const result = await withContainerPick(call, () => withContainerStart(call, send, rawArgs.start === true, pickers, notice), pickers, notice);
11744
+ printResult(result, () => {
11745
+ const color = colorEnabled(process.stdout);
11746
+ const lines = renderResult(result, color);
11747
+ return elapsed === undefined || bootLines(result, color).length > 0 ? lines : withElapsed(lines, elapsed);
11748
+ }, log);
11749
+ stopNote(result, notice);
11750
+ return 0;
11272
11751
  } catch (err) {
11273
- deps.notice(`Could not ask the app about this device: ${message(err)}`);
11274
- return {
11275
- state,
11276
- up,
11277
- device: null,
11278
- asked: false,
11279
- provider: up ? state.gatewayProvider : null,
11280
- staleMtu: null
11281
- };
11752
+ error(err instanceof Error ? err.message : `${tool.name} failed.`);
11753
+ return 1;
11282
11754
  }
11283
11755
  }
11284
- async function runNetworkStatus(deps = defaultDeps()) {
11285
- const { state, up, device, asked, provider, staleMtu } = await readConnection(deps);
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`);
11756
+ function stopNote(result, notice) {
11757
+ if (!result || typeof result !== "object")
11758
+ return;
11759
+ const { action, note, container } = result;
11760
+ if (action !== "stop" || typeof container !== "string")
11761
+ return;
11762
+ if (note === "default") {
11763
+ notice(`${container} is the sandbox's default container: SSH, commands and files ask which container until it runs again.`);
11764
+ } else if (note === "last_running") {
11765
+ notice("No container is running now: SSH, commands and files have none to land in until one runs.");
11766
+ } else if (note === "sandbox_stopped") {
11767
+ notice(`The sandbox is stopped, so ${container} is too: nothing was done.`);
11289
11768
  }
11290
- printResult({ connected: up, device, provider, staleMtu, staleSplitDns }, () => {
11291
- if (!state) {
11292
- return [eventLine("item", `Not set up. Run \`${getCliName()} network connect\`.`)];
11293
- }
11294
- const through = provider ? ` through ${provider.name}` : "";
11295
- const lines = [
11296
- eventLine("item", `${up ? `Connected${through}` : "Not connected"}: ${state.address}`)
11297
- ];
11298
- if (device) {
11299
- lines.push(` Device ${device.name}, valid until ${device.expiresAt.slice(0, 10)}.`);
11300
- lines.push(` ${deviceStateText(device.state)}.`);
11301
- if (device.shortNameOrganization) {
11302
- lines.push(` Short names: ${device.shortNameOrganization.slug}'s sandboxes.`);
11303
- }
11304
- if (!device.gatewayProvider) {
11305
- lines.push(` Its provider is gone, so no gateway serves it. Run \`${getCliName()} network connect\`.`);
11306
- }
11307
- } else if (asked) {
11308
- lines.push(` The app no longer knows this device (removed, expired or logged out). Run \`${getCliName()} network connect\`.`);
11309
- }
11310
- if (provider)
11311
- lines.push(` Reachable: the sandboxes on ${provider.name}.`);
11312
- return lines;
11313
- }, deps.log);
11314
- return 0;
11315
11769
  }
11316
- function unreachableNote(sandbox, connected) {
11317
- if (!sandbox.provider.privateNetwork) {
11318
- return `not reachable: ${sandbox.provider.name} has no private network yet`;
11770
+ var PROGRESS_MESSAGES = {
11771
+ sandboxes_create: "Creating sandbox…",
11772
+ sandboxes_wake: "Waking sandbox…",
11773
+ sandboxes_stop: "Stopping sandbox…",
11774
+ sandboxes_delete: "Deleting sandbox…",
11775
+ sandboxes_resize: "Resizing sandbox…",
11776
+ sandboxes_resize_disk: "Resizing disk…",
11777
+ sandboxes_commands_run: "Running command…",
11778
+ sandboxes_containers_start: "Starting container…",
11779
+ sandboxes_containers_stop: "Stopping container…",
11780
+ sandboxes_containers_restart: "Restarting container…",
11781
+ snapshots_create: "Saving snapshot…",
11782
+ projects_redeploy: "Redeploying…",
11783
+ providers_verify_incus: "Checking the Incus host…"
11784
+ };
11785
+ function withElapsed(lines, elapsed) {
11786
+ const color = colorEnabled(process.stdout);
11787
+ const [first, ...rest] = lines;
11788
+ if (first === undefined || first.startsWith(" ")) {
11789
+ return [eventLine("done", `Done in ${elapsed}.`, color), ...lines];
11319
11790
  }
11320
- if (connected?.id === sandbox.provider.id)
11321
- return null;
11322
- return `not reachable: on ${sandbox.provider.name} → ${connectCommandFor(sandbox.provider)}`;
11791
+ return [`${first} ${dim(`in ${elapsed}`, color)}`, ...rest];
11323
11792
  }
11324
- async function runNetworkLs(args, deps = defaultDeps()) {
11325
- try {
11326
- const organization = await requireOrganizationRef(args.org, deps.notice);
11327
- const connection = await readConnection(deps);
11328
- const deviceId = connection.device?.id;
11329
- const listed = await deps.request(`/api/v1/network/organizations/${encodeURIComponent(organization)}/sandboxes${deviceId ? `?device=${encodeURIComponent(deviceId)}` : ""}`);
11330
- const connected = connection.provider;
11331
- const sandboxes = listed.sandboxes.map((sandbox) => ({
11332
- ...sandbox,
11333
- reachable: unreachableNote(sandbox, connected) === null
11334
- }));
11335
- printResult({ ...listed, connectedProvider: connected, sandboxes }, () => {
11336
- if (listed.sandboxes.length === 0) {
11337
- return [eventLine("item", `No sandboxes in ${listed.organization.slug}.`)];
11338
- }
11339
- const width = Math.max(...listed.sandboxes.map((s) => s.name.length));
11340
- const canonicalWidth = Math.max(...listed.sandboxes.map((s) => s.canonicalName.length));
11341
- const httpsWidth = Math.max(...listed.sandboxes.map((s) => (s.httpsName?.length ?? 0) + 8));
11342
- const shortWidth = Math.max(...listed.sandboxes.map((s) => (s.shortName?.length ?? 0) + 8));
11343
- const lines = [
11344
- connected ? eventLine("item", `Connected through ${connected.name}.`) : eventLine("item", `Not connected: nothing is reachable. Run \`${getCliName()} network connect [provider]\`.`)
11345
- ];
11346
- for (const s of listed.sandboxes) {
11347
- const note = unreachableNote(s, connected);
11348
- lines.push([
11349
- ` ${s.name.padEnd(width)}`,
11350
- (s.address ?? "-").padEnd(28),
11351
- s.canonicalName.padEnd(canonicalWidth),
11352
- ...s.httpsName ? [`https://${s.httpsName}`.padEnd(httpsWidth)] : [],
11353
- ...s.shortName ? [`https://${s.shortName}`.padEnd(shortWidth)] : [],
11354
- s.status,
11355
- ...note ? [`(${note})`] : []
11356
- ].join(" "));
11793
+ function cliText(catalog, skills = BUILTIN_SKILL_NAMES) {
11794
+ const paths = new Map(catalog.filter((tool) => tool.cli).map((tool) => [tool.name, tool.cli]));
11795
+ const names = [...paths.keys()].sort((a, b) => b.length - a.length);
11796
+ const pattern = names.length > 0 ? new RegExp(`\\b(${names.join("|")})\\b`, "g") : null;
11797
+ return (text) => {
11798
+ const oneLine = text.replace(/\s+/g, " ").trim();
11799
+ const commands = pattern ? oneLine.replace(pattern, (name) => paths.get(name)?.join(" ") ?? name) : oneLine;
11800
+ return commands.replace(/\bthe ([a-z][a-z0-9-]*) skill\b/g, (phrase, skill) => skills.includes(skill) ? `the ${skill} skill (\`${getCliName()} skills get ${skill}\`)` : phrase);
11801
+ };
11802
+ }
11803
+ function cliOnlyArgs(tool) {
11804
+ const schema = tool.inputSchema;
11805
+ const args = {};
11806
+ if (schema.properties && "container" in schema.properties && !schema.required?.includes("container")) {
11807
+ args.start = {
11808
+ type: "boolean",
11809
+ description: "Start the container first when it is not running, without asking."
11810
+ };
11811
+ }
11812
+ if (tool.name === "sandboxes_containers_logs") {
11813
+ args.follow = {
11814
+ type: "boolean",
11815
+ alias: "f",
11816
+ description: "Keep printing new lines until Ctrl-C."
11817
+ };
11818
+ args.timestamps = { type: "boolean", description: "Put each line's time in front of it." };
11819
+ }
11820
+ return args;
11821
+ }
11822
+ var generatedToolCommands = new WeakSet;
11823
+ function createToolCommands(catalog) {
11824
+ const commands = {};
11825
+ const toCliText = cliText(catalog);
11826
+ for (const tool of catalog) {
11827
+ const command = defineCommand({
11828
+ meta: {
11829
+ name: tool.cli?.at(-1) ?? tool.name,
11830
+ description: toCliText(tool.description)
11831
+ },
11832
+ args: { ...buildToolArgs(tool.inputSchema, toCliText, tool.cliArgs), ...cliOnlyArgs(tool) },
11833
+ async run({ args }) {
11834
+ process.exitCode = await runToolInvoke(tool, args);
11357
11835
  }
11358
- return lines;
11359
- }, deps.log);
11360
- return 0;
11361
- } catch (err) {
11362
- deps.error(message(err));
11363
- return 1;
11836
+ });
11837
+ generatedToolCommands.add(command);
11838
+ commands[tool.name] = command;
11839
+ }
11840
+ return commands;
11841
+ }
11842
+
11843
+ // src/commands/login.ts
11844
+ import { hostname as hostname2 } from "node:os";
11845
+
11846
+ // src/commands/login-organization.ts
11847
+ import { createInterface as createInterface2 } from "node:readline/promises";
11848
+ function label(organization) {
11849
+ return `${organization.name} (${organization.slug ?? organization.id})`;
11850
+ }
11851
+ var chooseOrganizationOnTerminal = async (organizations) => {
11852
+ console.log(`
11853
+ Which organization should commands use by default?`);
11854
+ organizations.forEach((organization, index) => {
11855
+ console.log(` ${index + 1}. ${label(organization)}`);
11856
+ });
11857
+ const prompt = createInterface2({ input: process.stdin, output: process.stdout });
11858
+ try {
11859
+ const answer = (await prompt.question(`Number (Enter to skip): `)).trim();
11860
+ const index = Number.parseInt(answer, 10) - 1;
11861
+ return Number.isInteger(index) && index >= 0 && index < organizations.length ? index : null;
11862
+ } finally {
11863
+ prompt.close();
11364
11864
  }
11865
+ };
11866
+ function defaultChooser() {
11867
+ return process.stdin.isTTY && process.stdout.isTTY ? chooseOrganizationOnTerminal : undefined;
11365
11868
  }
11366
- async function runNetworkDevices(deps = defaultDeps()) {
11869
+ async function chooseDefaultOrganizationAfterLogin(org, log, choose = defaultChooser()) {
11870
+ const cli = getCliName();
11871
+ let organizations;
11367
11872
  try {
11368
- const { devices } = await deps.request("/api/v1/network/devices");
11369
- const own = (await readDeviceState(deps.dir))?.deviceId;
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);
11873
+ ({ organizations } = await authorizedRequest("/api/v1/tools/organizations_list", { method: "POST", body: "{}" }));
11874
+ } catch {
11875
+ organizations = undefined;
11876
+ }
11877
+ if (!Array.isArray(organizations)) {
11878
+ log(eventLine("item", `Pick a default organization with \`${cli} organizations use <org>\`.`));
11879
+ return org ? 1 : 0;
11880
+ }
11881
+ const settings = await readSettings();
11882
+ const save = async (organization, why) => {
11883
+ settings.defaultOrganization = { id: organization.id, slug: organization.slug };
11884
+ await writeSettings(settings);
11885
+ log(eventLine("done", `Default organization: ${label(organization)}${why}.`));
11886
+ };
11887
+ const ref = org?.trim();
11888
+ if (ref) {
11889
+ const match = organizations.find((organization) => organization.id === ref) ?? organizations.find((organization) => organization.slug === ref);
11890
+ if (!match) {
11891
+ log(eventLine("failed", `You are not a member of an organization "${ref}"; no default set.`));
11892
+ return 1;
11893
+ }
11894
+ await save(match, "");
11895
+ return 0;
11896
+ }
11897
+ const current = organizations.find((organization) => organization.id === settings.defaultOrganization?.id);
11898
+ if (current) {
11899
+ log(eventLine("item", `Default organization: ${label(current)} (unchanged).`));
11900
+ return 0;
11901
+ }
11902
+ if (settings.defaultOrganization) {
11903
+ delete settings.defaultOrganization;
11904
+ await writeSettings(settings);
11905
+ }
11906
+ const [only, ...others] = organizations;
11907
+ if (!only) {
11908
+ log(eventLine("item", "You do not belong to an organization yet."));
11909
+ return 0;
11910
+ }
11911
+ if (others.length === 0) {
11912
+ await save(only, " (your only organization)");
11913
+ return 0;
11914
+ }
11915
+ const index = choose ? await choose(organizations) : null;
11916
+ const chosen = index === null ? undefined : organizations[index];
11917
+ if (chosen) {
11918
+ await save(chosen, "");
11371
11919
  return 0;
11372
- } catch (err) {
11373
- deps.error(message(err));
11374
- return 1;
11375
11920
  }
11921
+ log(eventLine("item", `You belong to ${organizations.length} organizations: ${organizations.map((organization) => organization.slug ?? organization.id).join(", ")}. Pick a default with \`${cli} organizations use <org>\`, or pass --org.`));
11922
+ return 0;
11376
11923
  }
11377
- async function runNetworkDevicesRemove(args, deps = defaultDeps()) {
11924
+
11925
+ // src/commands/login.ts
11926
+ function defaultClientName() {
11378
11927
  try {
11379
- await deps.request(`/api/v1/network/devices/${encodeURIComponent(args.id)}`, {
11380
- method: "DELETE"
11381
- });
11382
- printResult({ removed: args.id }, () => [eventLine("done", `Removed device ${args.id}.`)], deps.log);
11383
- return 0;
11384
- } catch (err) {
11385
- deps.error(message(err));
11928
+ const host = hostname2().trim();
11929
+ if (host)
11930
+ return host.slice(0, 64);
11931
+ } catch {}
11932
+ return "ghostdock-cli";
11933
+ }
11934
+ var DEFAULT_TIMEOUT_SECONDS = 300;
11935
+ function toCredentials(result) {
11936
+ return {
11937
+ accessToken: result.accessToken,
11938
+ refreshToken: result.refreshToken,
11939
+ accessExpiresAt: new Date(Date.now() + result.expiresIn * 1000).toISOString()
11940
+ };
11941
+ }
11942
+ function sleep(ms) {
11943
+ return new Promise((resolve) => setTimeout(resolve, ms));
11944
+ }
11945
+ async function runLogin(args, log = console.log, choose = defaultChooser(), error = printError) {
11946
+ const loggedIn = async () => {
11947
+ log(eventLine("ready", "Logged in."));
11948
+ return chooseDefaultOrganizationAfterLogin(args.org, log, choose);
11949
+ };
11950
+ if (args.code) {
11951
+ const result = await deviceToken({ pasteCode: args.code });
11952
+ if (result.status === "approved") {
11953
+ await writeCredentials(toCredentials(result));
11954
+ return loggedIn();
11955
+ }
11956
+ error("That code has not been approved yet. Approve it in the browser, then run this again.");
11386
11957
  return 1;
11387
11958
  }
11959
+ const start = await deviceStart({ client_name: defaultClientName() });
11960
+ log(eventLine("url", `Open this URL to sign in: ${start.verificationUrl}`));
11961
+ log(` Pass --wait to finish here once you approve, or approve on another device and run \`${getCliName()} login --code <paste-code>\` with the code shown there.`);
11962
+ if (!args.wait) {
11963
+ return 0;
11964
+ }
11965
+ const timeoutSeconds = Number.parseInt(args.timeout ?? "", 10) || DEFAULT_TIMEOUT_SECONDS;
11966
+ const deadline = Date.now() + timeoutSeconds * 1000;
11967
+ const intervalMs = start.interval * 1000;
11968
+ const progress = startProgress("Waiting for approval in the browser…");
11969
+ while (Date.now() < deadline) {
11970
+ await sleep(intervalMs);
11971
+ try {
11972
+ const result = await deviceToken({ deviceCode: start.deviceCode });
11973
+ if (result.status === "approved") {
11974
+ progress.stop();
11975
+ await writeCredentials(toCredentials(result));
11976
+ return loggedIn();
11977
+ }
11978
+ } catch (err) {
11979
+ progress.stop();
11980
+ if (err instanceof ApiError && err.status === 400) {
11981
+ error(`Login failed: ${err.message}`);
11982
+ return 1;
11983
+ }
11984
+ throw err;
11985
+ }
11986
+ }
11987
+ progress.stop();
11988
+ error(`Timed out waiting for approval. Run \`${getCliName()} login\` again.`);
11989
+ return 1;
11388
11990
  }
11389
- var connectCommand = defineCommand({
11991
+ var loginCommand = defineCommand({
11390
11992
  meta: {
11391
- name: "connect",
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."
11993
+ name: "login",
11994
+ description: "Sign in and store a device access token for this machine (~/.config/ghostdock/credentials.json)."
11393
11995
  },
11394
11996
  args: {
11395
- provider: {
11396
- type: "positional",
11397
- required: false,
11398
- description: "Sandbox provider name or id. Omit for the organization's default provider (else the platform default). Only its sandboxes are reachable; connecting through another replaces the connection."
11997
+ wait: {
11998
+ type: "boolean",
11999
+ description: "Poll until the login is approved in the browser (default timeout 5 minutes).",
12000
+ default: false
11399
12001
  },
11400
- org: orgArg
12002
+ code: {
12003
+ type: "string",
12004
+ description: "Redeem the one-time paste code shown in the browser instead of polling."
12005
+ },
12006
+ timeout: {
12007
+ type: "string",
12008
+ description: "Max seconds to poll when using --wait (default 300)."
12009
+ },
12010
+ org: {
12011
+ type: "string",
12012
+ description: "Set this organization (id or slug) as the default once logged in."
12013
+ }
11401
12014
  },
11402
12015
  async run({ args }) {
11403
- process.exitCode = await runNetworkConnect({ provider: args.provider, org: args.org });
12016
+ process.exitCode = await runLogin(args);
11404
12017
  }
11405
12018
  });
11406
- var disconnectCommand = defineCommand({
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
- },
11411
- async run() {
11412
- process.exitCode = await runNetworkDisconnect();
12019
+
12020
+ // src/commands/logout.ts
12021
+ async function runLogout(log = console.log) {
12022
+ const credentials = await readCredentials();
12023
+ if (credentials) {
12024
+ try {
12025
+ await deviceRevoke({
12026
+ accessToken: credentials.accessToken,
12027
+ refreshToken: credentials.refreshToken
12028
+ });
12029
+ } catch {}
11413
12030
  }
11414
- });
11415
- var statusCommand = defineCommand({
12031
+ await clearCredentials();
12032
+ log(eventLine("stopped", "Logged out."));
12033
+ return 0;
12034
+ }
12035
+ var logoutCommand = defineCommand({
11416
12036
  meta: {
11417
- name: "status",
11418
- description: "Whether this machine is connected, through which provider, and as which device."
12037
+ name: "logout",
12038
+ description: "Revoke the device session and remove stored credentials."
11419
12039
  },
11420
12040
  async run() {
11421
- process.exitCode = await runNetworkStatus();
11422
- }
11423
- });
11424
- var lsCommand = defineCommand({
11425
- meta: {
11426
- name: "ls",
11427
- description: "Sandboxes of an organization with their private addresses and names, and which are reachable through the current connection."
11428
- },
11429
- args: { org: orgArg },
11430
- async run({ args }) {
11431
- process.exitCode = await runNetworkLs({ org: args.org });
11432
- }
11433
- });
11434
- var devicesRemoveCommand = defineCommand({
11435
- meta: { name: "remove", description: "Remove one of your devices from the private network." },
11436
- args: {
11437
- id: { type: "positional", description: "Device id, from `network devices`.", required: true }
11438
- },
11439
- async run({ args }) {
11440
- process.exitCode = await runNetworkDevicesRemove({ id: args.id });
11441
- }
11442
- });
11443
- var devicesCommand = defineCommand({
11444
- meta: { name: "devices", description: "Your devices in the private network." },
11445
- subCommands: { remove: devicesRemoveCommand },
11446
- async run({ rawArgs }) {
11447
- if (rawArgs[0] === "remove")
11448
- return;
11449
- process.exitCode = await runNetworkDevices();
11450
- }
11451
- });
11452
- var networkCommand = defineCommand({
11453
- meta: {
11454
- name: "network",
11455
- description: "The private network: reach your sandboxes by address and name from this machine."
11456
- },
11457
- subCommands: {
11458
- connect: connectCommand,
11459
- disconnect: disconnectCommand,
11460
- status: statusCommand,
11461
- ls: lsCommand,
11462
- devices: devicesCommand
12041
+ process.exitCode = await runLogout();
11463
12042
  }
11464
12043
  });
11465
12044
 
@@ -11538,8 +12117,8 @@ var organizationsCurrentCommand = defineCommand({
11538
12117
  });
11539
12118
 
11540
12119
  // src/commands/publish-auto.ts
11541
- import { readdir, readFile as readFile4, stat } from "node:fs/promises";
11542
- import { basename as basename2, join as join4, relative, sep } from "node:path";
12120
+ import { readdir as readdir2, readFile as readFile5, stat } from "node:fs/promises";
12121
+ import { basename as basename2, join as join5, relative, sep } from "node:path";
11543
12122
  function publishAutoLines(result) {
11544
12123
  const [summaryLine, ...rest] = formatPublishAutoResult(result).split(`
11545
12124
  `);
@@ -11616,23 +12195,23 @@ function formatAttemptedSpec(spec) {
11616
12195
  async function collectPath(path) {
11617
12196
  const pathStats = await stat(path);
11618
12197
  if (pathStats.isFile()) {
11619
- return [{ path: basename2(path), data: await readFile4(path) }];
12198
+ return [{ path: basename2(path), data: await readFile5(path) }];
11620
12199
  }
11621
12200
  if (!pathStats.isDirectory()) {
11622
12201
  throw new Error(`Publish path must be a file or directory: ${path}`);
11623
12202
  }
11624
12203
  const files = [];
11625
12204
  async function visit(currentPath) {
11626
- const entries = await readdir(currentPath, { withFileTypes: true });
12205
+ const entries = await readdir2(currentPath, { withFileTypes: true });
11627
12206
  entries.sort((a, b) => a.name.localeCompare(b.name));
11628
12207
  for (const entry of entries) {
11629
- const entryPath = join4(currentPath, entry.name);
12208
+ const entryPath = join5(currentPath, entry.name);
11630
12209
  if (entry.isDirectory()) {
11631
12210
  await visit(entryPath);
11632
12211
  } else if (entry.isFile()) {
11633
12212
  files.push({
11634
12213
  path: relative(path, entryPath).split(sep).join("/"),
11635
- data: await readFile4(entryPath)
12214
+ data: await readFile5(entryPath)
11636
12215
  });
11637
12216
  }
11638
12217
  }
@@ -11675,7 +12254,7 @@ async function runPublishAuto(options, log = console.log, error = printError) {
11675
12254
  }
11676
12255
  }
11677
12256
  if (options.zip) {
11678
- form.append("zip", new Blob([await readFile4(options.zip)]), basename2(options.zip));
12257
+ form.append("zip", new Blob([await readFile5(options.zip)]), basename2(options.zip));
11679
12258
  }
11680
12259
  const { value: result } = await withProgress("Planning and deploying… (this can take a few minutes)", () => authorizedRequest(`/api/v1/organizations/${encodeURIComponent(options.organizationId)}/publish-auto`, { method: "POST", body: form }));
11681
12260
  printResult(result, () => publishAutoLines(result), log);
@@ -11798,28 +12377,28 @@ var publishAutoCommand = defineCommand({
11798
12377
  });
11799
12378
 
11800
12379
  // src/commands/publish-static.ts
11801
- import { readdir as readdir2, readFile as readFile5, stat as stat2 } from "node:fs/promises";
11802
- import { basename as basename3, join as join5, relative as relative2, sep as sep2 } from "node:path";
12380
+ import { readdir as readdir3, readFile as readFile6, stat as stat2 } from "node:fs/promises";
12381
+ import { basename as basename3, join as join6, relative as relative2, sep as sep2 } from "node:path";
11803
12382
  async function collectPath2(path) {
11804
12383
  const pathStats = await stat2(path);
11805
12384
  if (pathStats.isFile()) {
11806
- return [{ path: basename3(path), data: await readFile5(path) }];
12385
+ return [{ path: basename3(path), data: await readFile6(path) }];
11807
12386
  }
11808
12387
  if (!pathStats.isDirectory()) {
11809
12388
  throw new Error(`Publish path must be a file or directory: ${path}`);
11810
12389
  }
11811
12390
  const files = [];
11812
12391
  async function visit(currentPath) {
11813
- const entries = await readdir2(currentPath, { withFileTypes: true });
12392
+ const entries = await readdir3(currentPath, { withFileTypes: true });
11814
12393
  entries.sort((a, b) => a.name.localeCompare(b.name));
11815
12394
  for (const entry of entries) {
11816
- const entryPath = join5(currentPath, entry.name);
12395
+ const entryPath = join6(currentPath, entry.name);
11817
12396
  if (entry.isDirectory()) {
11818
12397
  await visit(entryPath);
11819
12398
  } else if (entry.isFile()) {
11820
12399
  files.push({
11821
12400
  path: relative2(path, entryPath).split(sep2).join("/"),
11822
- data: await readFile5(entryPath)
12401
+ data: await readFile6(entryPath)
11823
12402
  });
11824
12403
  }
11825
12404
  }
@@ -11842,7 +12421,7 @@ async function runPublishStatic(options, log = console.log, error = printError)
11842
12421
  }
11843
12422
  }
11844
12423
  if (options.zip) {
11845
- form.append("zip", new Blob([await readFile5(options.zip)]), basename3(options.zip));
12424
+ form.append("zip", new Blob([await readFile6(options.zip)]), basename3(options.zip));
11846
12425
  }
11847
12426
  const { value: result } = await withProgress("Publishing…", () => authorizedRequest(`/api/v1/organizations/${encodeURIComponent(options.organizationId)}/static-sites`, { method: "POST", body: form }));
11848
12427
  printResult(result, () => [eventLine("url", result.url), eventLine("done", `Published site ${result.siteId}.`)], log);
@@ -11896,7 +12475,7 @@ var publishStaticCommand = defineCommand({
11896
12475
  });
11897
12476
 
11898
12477
  // src/commands/registry.ts
11899
- import { spawn as spawn2 } from "node:child_process";
12478
+ import { spawn as spawn3 } from "node:child_process";
11900
12479
  function reportError2(err, error) {
11901
12480
  if (err instanceof AuthRequiredError || err instanceof Error) {
11902
12481
  error(err.message);
@@ -11913,7 +12492,7 @@ function defaultInvoke(input) {
11913
12492
  }
11914
12493
  function dockerLogin(input) {
11915
12494
  return new Promise((resolve, reject) => {
11916
- const child = spawn2("docker", ["login", input.host, "-u", input.username, "--password-stdin"], {
12495
+ const child = spawn3("docker", ["login", input.host, "-u", input.username, "--password-stdin"], {
11917
12496
  stdio: ["pipe", "inherit", "pipe"]
11918
12497
  });
11919
12498
  let stderr = "";
@@ -12011,8 +12590,8 @@ var registryCommand = defineCommand({
12011
12590
  });
12012
12591
 
12013
12592
  // src/commands/sandbox-create.ts
12014
- import { readdir as readdir3, readFile as readFile6, stat as stat3 } from "node:fs/promises";
12015
- import { basename as basename4, join as join6, relative as relative3, sep as sep3 } from "node:path";
12593
+ import { readdir as readdir4, readFile as readFile7, stat as stat3 } from "node:fs/promises";
12594
+ import { basename as basename4, join as join7, relative as relative3, sep as sep3 } from "node:path";
12016
12595
 
12017
12596
  // ../../node_modules/fflate/esm/index.mjs
12018
12597
  import { createRequire } from "module";
@@ -12665,16 +13244,16 @@ async function collectDirectory(path) {
12665
13244
  }
12666
13245
  const files = [];
12667
13246
  async function visit(currentPath) {
12668
- const entries = await readdir3(currentPath, { withFileTypes: true });
13247
+ const entries = await readdir4(currentPath, { withFileTypes: true });
12669
13248
  entries.sort((a, b) => a.name.localeCompare(b.name));
12670
13249
  for (const entry of entries) {
12671
- const entryPath = join6(currentPath, entry.name);
13250
+ const entryPath = join7(currentPath, entry.name);
12672
13251
  if (entry.isDirectory()) {
12673
13252
  await visit(entryPath);
12674
13253
  } else if (entry.isFile()) {
12675
13254
  files.push({
12676
13255
  path: relative3(path, entryPath).split(sep3).join("/"),
12677
- data: await readFile6(entryPath)
13256
+ data: await readFile7(entryPath)
12678
13257
  });
12679
13258
  }
12680
13259
  }
@@ -12707,7 +13286,7 @@ async function runSandboxCreate(options, log = console.log, error = printError)
12707
13286
  return 1;
12708
13287
  }
12709
13288
  try {
12710
- const zipBytes = options.zip ? new Uint8Array(await readFile6(options.zip)) : zipDirectory(await collectDirectory(options.paths[0] ?? ""));
13289
+ const zipBytes = options.zip ? new Uint8Array(await readFile7(options.zip)) : zipDirectory(await collectDirectory(options.paths[0] ?? ""));
12711
13290
  const form = new FormData;
12712
13291
  form.append("kind", options.kind);
12713
13292
  if (options.displayName)
@@ -12968,28 +13547,28 @@ var toolsCommand = defineCommand({
12968
13547
  });
12969
13548
 
12970
13549
  // src/commands/upload-files.ts
12971
- import { readdir as readdir4, readFile as readFile7, stat as stat4 } from "node:fs/promises";
12972
- import { basename as basename5, join as join7, relative as relative4, sep as sep4 } from "node:path";
13550
+ import { readdir as readdir5, readFile as readFile8, stat as stat4 } from "node:fs/promises";
13551
+ import { basename as basename5, join as join8, relative as relative4, sep as sep4 } from "node:path";
12973
13552
  async function collectPath3(path) {
12974
13553
  const pathStats = await stat4(path);
12975
13554
  if (pathStats.isFile()) {
12976
- return [{ path: basename5(path), data: await readFile7(path) }];
13555
+ return [{ path: basename5(path), data: await readFile8(path) }];
12977
13556
  }
12978
13557
  if (!pathStats.isDirectory()) {
12979
13558
  throw new Error(`Upload path must be a file or directory: ${path}`);
12980
13559
  }
12981
13560
  const files = [];
12982
13561
  async function visit(currentPath) {
12983
- const entries = await readdir4(currentPath, { withFileTypes: true });
13562
+ const entries = await readdir5(currentPath, { withFileTypes: true });
12984
13563
  entries.sort((a, b) => a.name.localeCompare(b.name));
12985
13564
  for (const entry of entries) {
12986
- const entryPath = join7(currentPath, entry.name);
13565
+ const entryPath = join8(currentPath, entry.name);
12987
13566
  if (entry.isDirectory()) {
12988
13567
  await visit(entryPath);
12989
13568
  } else if (entry.isFile()) {
12990
13569
  files.push({
12991
13570
  path: relative4(path, entryPath).split(sep4).join("/"),
12992
- data: await readFile7(entryPath)
13571
+ data: await readFile8(entryPath)
12993
13572
  });
12994
13573
  }
12995
13574
  }
@@ -13011,7 +13590,7 @@ async function runSandboxUpload(options, log = console.log, error = printError,
13011
13590
  }
13012
13591
  }
13013
13592
  if (options.zip) {
13014
- form.append("zip", new Blob([await readFile7(options.zip)]), basename5(options.zip));
13593
+ form.append("zip", new Blob([await readFile8(options.zip)]), basename5(options.zip));
13015
13594
  }
13016
13595
  let container = options.container;
13017
13596
  const call = {
@@ -13153,7 +13732,8 @@ var HANDWRITTEN_COMMANDS = [
13153
13732
  [["sandboxes", "files", "upload"], sandboxUploadCommand],
13154
13733
  [["sites", "publish"], publishStaticCommand],
13155
13734
  [["projects", "publish"], publishAutoCommand],
13156
- [["network"], networkCommand]
13735
+ [["network"], networkCommand],
13736
+ [["docker"], dockerCommand]
13157
13737
  ];
13158
13738
  var HANDWRITTEN_PATHS = new Set(HANDWRITTEN_COMMANDS.map(([path]) => path.join(" ")));
13159
13739
  function createMainCommand(catalog = OFFLINE_TOOL_CATALOG) {