@getmonoceros/workbench 1.9.7 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -251,25 +251,25 @@ function detectHelpRequest(argv, main2) {
251
251
  const separatorIdx = argv.indexOf("--");
252
252
  if (helpIdx === -1) return null;
253
253
  if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
254
- const path17 = [];
254
+ const path18 = [];
255
255
  const tokens = argv.slice(
256
256
  0,
257
257
  separatorIdx === -1 ? argv.length : separatorIdx
258
258
  );
259
259
  let cursor = main2;
260
260
  const mainName = (main2.meta ?? {}).name ?? "monoceros";
261
- path17.push(mainName);
261
+ path18.push(mainName);
262
262
  for (const tok of tokens) {
263
263
  if (tok.startsWith("-")) continue;
264
264
  const subs = cursor.subCommands ?? {};
265
265
  if (tok in subs) {
266
266
  cursor = subs[tok];
267
- path17.push(tok);
267
+ path18.push(tok);
268
268
  continue;
269
269
  }
270
270
  break;
271
271
  }
272
- return { path: path17, cmd: cursor };
272
+ return { path: path18, cmd: cursor };
273
273
  }
274
274
  async function maybeRenderHelp(argv, main2) {
275
275
  const hit = detectHelpRequest(argv, main2);
@@ -301,7 +301,7 @@ function getInnerArgs() {
301
301
  }
302
302
 
303
303
  // src/main.ts
304
- import { defineCommand as defineCommand29 } from "citty";
304
+ import { defineCommand as defineCommand30 } from "citty";
305
305
 
306
306
  // src/commands/add-apt-packages.ts
307
307
  import { defineCommand } from "citty";
@@ -1638,7 +1638,8 @@ var SERVICE_CATALOG = {
1638
1638
  // the recommended mount is the parent directory; pre-18 used
1639
1639
  // /var/lib/postgresql/data directly. See
1640
1640
  // https://github.com/docker-library/postgres/pull/1259.
1641
- dataMount: "/var/lib/postgresql"
1641
+ dataMount: "/var/lib/postgresql",
1642
+ defaultPort: 5432
1642
1643
  },
1643
1644
  mysql: {
1644
1645
  id: "mysql",
@@ -1647,12 +1648,14 @@ var SERVICE_CATALOG = {
1647
1648
  MYSQL_ROOT_PASSWORD: "monoceros",
1648
1649
  MYSQL_DATABASE: "monoceros"
1649
1650
  },
1650
- dataMount: "/var/lib/mysql"
1651
+ dataMount: "/var/lib/mysql",
1652
+ defaultPort: 3306
1651
1653
  },
1652
1654
  redis: {
1653
1655
  id: "redis",
1654
1656
  image: "redis:8",
1655
- dataMount: "/data"
1657
+ dataMount: "/data",
1658
+ defaultPort: 6379
1656
1659
  }
1657
1660
  };
1658
1661
  function knownLanguages() {
@@ -2746,8 +2749,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
2746
2749
  if (!isMap2(item)) return false;
2747
2750
  const url = item.get("url");
2748
2751
  if (url === urlOrPath) return true;
2749
- const path17 = item.get("path");
2750
- const effectivePath = typeof path17 === "string" ? path17 : typeof url === "string" ? deriveRepoName(url) : void 0;
2752
+ const path18 = item.get("path");
2753
+ const effectivePath = typeof path18 === "string" ? path18 : typeof url === "string" ? deriveRepoName(url) : void 0;
2751
2754
  return effectivePath === urlOrPath;
2752
2755
  });
2753
2756
  if (idx < 0) return false;
@@ -2797,7 +2800,7 @@ async function runAddRepo(input) {
2797
2800
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
2798
2801
  );
2799
2802
  }
2800
- const path17 = (input.path ?? deriveRepoName(url)).trim();
2803
+ const path18 = (input.path ?? deriveRepoName(url)).trim();
2801
2804
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
2802
2805
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
2803
2806
  if (hasName !== hasEmail) {
@@ -2826,7 +2829,7 @@ async function runAddRepo(input) {
2826
2829
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
2827
2830
  const entry2 = {
2828
2831
  url,
2829
- path: path17,
2832
+ path: path18,
2830
2833
  ...hasName && hasEmail ? {
2831
2834
  gitUser: {
2832
2835
  name: input.gitName.trim(),
@@ -4597,7 +4600,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4597
4600
  }
4598
4601
 
4599
4602
  // src/version.ts
4600
- var CLI_VERSION = true ? "1.9.7" : "dev";
4603
+ var CLI_VERSION = true ? "1.10.0" : "dev";
4601
4604
 
4602
4605
  // src/commands/_dispatch.ts
4603
4606
  import { consola as consola12 } from "consola";
@@ -5004,6 +5007,7 @@ var ALL_COMMANDS = [
5004
5007
  "remove-repo",
5005
5008
  "remove-port",
5006
5009
  "port",
5010
+ "tunnel",
5007
5011
  "completion"
5008
5012
  ];
5009
5013
  var containerName = (ctx) => listContainerNames(ctx);
@@ -5098,6 +5102,13 @@ var COMMAND_SPECS = {
5098
5102
  flags: { "--default": { type: "boolean" } },
5099
5103
  innerArgs: () => []
5100
5104
  },
5105
+ tunnel: {
5106
+ positionals: [containerName, () => listServiceNames()],
5107
+ flags: {
5108
+ "--local-port": { type: "value" },
5109
+ "--local-address": { type: "value" }
5110
+ }
5111
+ },
5101
5112
  completion: {
5102
5113
  positionals: [() => listShellNames()]
5103
5114
  },
@@ -6728,8 +6739,400 @@ var stopCommand = defineCommand28({
6728
6739
  }
6729
6740
  });
6730
6741
 
6742
+ // src/commands/tunnel.ts
6743
+ import { defineCommand as defineCommand29 } from "citty";
6744
+ import { consola as consola33 } from "consola";
6745
+
6746
+ // src/tunnel/run.ts
6747
+ import { spawn as spawn9 } from "child_process";
6748
+ import { consola as consola32 } from "consola";
6749
+
6750
+ // src/tunnel/resolve.ts
6751
+ import { existsSync as existsSync12 } from "fs";
6752
+ import path17 from "path";
6753
+ async function resolveTunnelTarget(opts) {
6754
+ const ymlPath = containerConfigPath(opts.name, opts.monocerosHome);
6755
+ if (!existsSync12(ymlPath)) {
6756
+ throw new Error(
6757
+ `No yml profile for '${opts.name}' at ${ymlPath}. Run \`monoceros init ${opts.name}\` first.`
6758
+ );
6759
+ }
6760
+ const parsed = await readConfig(ymlPath);
6761
+ const config = parsed.config;
6762
+ const containerRoot = containerDir(opts.name, opts.monocerosHome);
6763
+ if (!existsSync12(containerRoot)) {
6764
+ throw new Error(
6765
+ `Container '${opts.name}' is not materialised at ${containerRoot}. Run \`monoceros apply ${opts.name}\` first.`
6766
+ );
6767
+ }
6768
+ const composePath = path17.join(containerRoot, ".devcontainer", "compose.yaml");
6769
+ const isCompose = existsSync12(composePath);
6770
+ const parsedTarget = parseTargetArg(opts.target, config);
6771
+ const docker = opts.docker ?? defaultDockerExec;
6772
+ if (isCompose) {
6773
+ return resolveCompose2({
6774
+ name: opts.name,
6775
+ containerRoot,
6776
+ parsedTarget
6777
+ });
6778
+ }
6779
+ return resolveImageMode({
6780
+ name: opts.name,
6781
+ containerRoot,
6782
+ parsedTarget,
6783
+ config,
6784
+ docker
6785
+ });
6786
+ }
6787
+ function parseTargetArg(raw, config) {
6788
+ const asNumber = Number(raw);
6789
+ if (Number.isInteger(asNumber) && asNumber > 0 && asNumber < 65536) {
6790
+ return { kind: "port", port: asNumber };
6791
+ }
6792
+ const entry2 = SERVICE_CATALOG[raw];
6793
+ if (!entry2) {
6794
+ const candidates = knownServices().join(", ");
6795
+ throw new Error(
6796
+ `Unknown service '${raw}'. Known services: ${candidates}. Or pass a port number (e.g. \`monoceros tunnel <name> 8080\`).`
6797
+ );
6798
+ }
6799
+ if (!config.services.includes(raw)) {
6800
+ throw new Error(
6801
+ `Service '${raw}' is not declared in this container's yml. Add it with \`monoceros add-service ${config.services.length === 0 ? "<name>" : "\u2026"} ${raw}\` and re-apply.`
6802
+ );
6803
+ }
6804
+ return { kind: "service", service: raw, port: entry2.defaultPort };
6805
+ }
6806
+ function resolveCompose2(args) {
6807
+ const network = `${composeProjectName(args.containerRoot)}_default`;
6808
+ if (args.parsedTarget.kind === "service") {
6809
+ return {
6810
+ network,
6811
+ targetHost: args.parsedTarget.service,
6812
+ internalPort: args.parsedTarget.port,
6813
+ display: `${args.name}/${args.parsedTarget.service}`
6814
+ };
6815
+ }
6816
+ return {
6817
+ network,
6818
+ targetHost: "workspace",
6819
+ internalPort: args.parsedTarget.port,
6820
+ display: `${args.name}:${args.parsedTarget.port}`
6821
+ };
6822
+ }
6823
+ async function resolveImageMode(args) {
6824
+ if (args.parsedTarget.kind === "service") {
6825
+ throw new Error(
6826
+ `Service '${args.parsedTarget.service}' is declared in the yml but '${args.name}' is image-mode (no compose.yaml). Services need compose mode \u2014 re-apply with at least one \`services:\` entry to get a compose setup.`
6827
+ );
6828
+ }
6829
+ const ports = args.config.routing?.ports ?? [];
6830
+ if (ports.length > 0) {
6831
+ return {
6832
+ network: PROXY_NETWORK_NAME,
6833
+ targetHost: args.name,
6834
+ internalPort: args.parsedTarget.port,
6835
+ display: `${args.name}:${args.parsedTarget.port}`
6836
+ };
6837
+ }
6838
+ const { network, ip } = await lookupContainerNetwork({
6839
+ containerRoot: args.containerRoot,
6840
+ docker: args.docker
6841
+ });
6842
+ return {
6843
+ network,
6844
+ targetHost: ip,
6845
+ internalPort: args.parsedTarget.port,
6846
+ display: `${args.name}:${args.parsedTarget.port}`
6847
+ };
6848
+ }
6849
+ async function lookupContainerNetwork(args) {
6850
+ const psResult = await args.docker([
6851
+ "ps",
6852
+ "-q",
6853
+ "--filter",
6854
+ `label=devcontainer.local_folder=${args.containerRoot}`
6855
+ ]);
6856
+ if (psResult.exitCode !== 0) {
6857
+ throw new Error(
6858
+ `docker ps failed: ${psResult.stderr.trim() || `exit ${psResult.exitCode}`}`
6859
+ );
6860
+ }
6861
+ const containerId = psResult.stdout.trim().split("\n")[0]?.trim();
6862
+ if (!containerId) {
6863
+ throw new Error(
6864
+ `No running container for '${args.containerRoot}'. Start it with \`monoceros start <name>\` (or open a shell with \`monoceros shell <name>\`) and retry.`
6865
+ );
6866
+ }
6867
+ const inspect = await args.docker([
6868
+ "inspect",
6869
+ "--format",
6870
+ "{{json .NetworkSettings.Networks}}",
6871
+ containerId
6872
+ ]);
6873
+ if (inspect.exitCode !== 0) {
6874
+ throw new Error(
6875
+ `docker inspect failed: ${inspect.stderr.trim() || `exit ${inspect.exitCode}`}`
6876
+ );
6877
+ }
6878
+ let networks = null;
6879
+ try {
6880
+ networks = JSON.parse(inspect.stdout);
6881
+ } catch {
6882
+ throw new Error(
6883
+ `Unexpected docker inspect output: ${inspect.stdout.slice(0, 200)}`
6884
+ );
6885
+ }
6886
+ if (!networks) {
6887
+ throw new Error(
6888
+ `Container ${containerId} reports no networks. Restart it and retry.`
6889
+ );
6890
+ }
6891
+ for (const [name, settings] of Object.entries(networks)) {
6892
+ if (settings.IPAddress && settings.IPAddress.length > 0) {
6893
+ return { network: name, ip: settings.IPAddress };
6894
+ }
6895
+ }
6896
+ throw new Error(
6897
+ `Container ${containerId} has no network with a reachable IP. Restart it and retry.`
6898
+ );
6899
+ }
6900
+
6901
+ // src/tunnel/port-check.ts
6902
+ import { Socket as Socket2 } from "net";
6903
+ var CONNECT_TIMEOUT_MS2 = 750;
6904
+ var realPortProbe2 = (port, address) => {
6905
+ const probeHost = address === "0.0.0.0" ? "127.0.0.1" : address;
6906
+ return new Promise((resolve) => {
6907
+ const socket = new Socket2();
6908
+ let settled = false;
6909
+ const settle = (result) => {
6910
+ if (settled) return;
6911
+ settled = true;
6912
+ socket.destroy();
6913
+ resolve(result);
6914
+ };
6915
+ socket.setTimeout(CONNECT_TIMEOUT_MS2);
6916
+ socket.once("connect", () => {
6917
+ settle({
6918
+ ok: false,
6919
+ code: "EADDRINUSE",
6920
+ message: `another process is listening on ${port}`
6921
+ });
6922
+ });
6923
+ socket.once("timeout", () => {
6924
+ settle({ ok: true });
6925
+ });
6926
+ socket.once("error", (err) => {
6927
+ const code = err.code ?? "UNKNOWN";
6928
+ if (code === "ECONNREFUSED") {
6929
+ settle({ ok: true });
6930
+ } else {
6931
+ settle({ ok: false, code, message: err.message });
6932
+ }
6933
+ });
6934
+ socket.connect(port, probeHost);
6935
+ });
6936
+ };
6937
+ async function preflightLocalPort(opts) {
6938
+ const probe = opts.probe ?? realPortProbe2;
6939
+ const result = await probe(opts.port, opts.address);
6940
+ if (result.ok) return;
6941
+ throw new Error(formatLocalPortHeldError(opts.port, opts.address, result));
6942
+ }
6943
+ function formatLocalPortHeldError(port, address, result) {
6944
+ const lines = [];
6945
+ if (result.code === "EADDRINUSE") {
6946
+ lines.push(`Local port ${port} on ${address} is already in use.`);
6947
+ lines.push("");
6948
+ lines.push("Identify the holder, then either stop it or pick a different");
6949
+ lines.push("port for the tunnel:");
6950
+ lines.push("");
6951
+ lines.push(` sudo lsof -iTCP:${port} -sTCP:LISTEN -n -P`);
6952
+ lines.push(` # or: sudo ss -tlnp | grep ":${port}\\b"`);
6953
+ lines.push("");
6954
+ lines.push("Re-run with an explicit local port:");
6955
+ lines.push(` monoceros tunnel \u2026 --local-port=${port + 1}`);
6956
+ } else {
6957
+ lines.push(
6958
+ `Cannot probe local port ${port} on ${address}: ${result.message}`
6959
+ );
6960
+ lines.push("");
6961
+ lines.push(
6962
+ "Most likely the host network stack (firewall, namespace) is interfering."
6963
+ );
6964
+ lines.push("Try a different local port via `--local-port=<n>`.");
6965
+ }
6966
+ return lines.join("\n");
6967
+ }
6968
+
6969
+ // src/tunnel/run.ts
6970
+ var SOCAT_IMAGE = "alpine/socat:1.8.0.3";
6971
+ var defaultDockerSpawn = (args) => {
6972
+ const child = spawn9("docker", args, {
6973
+ stdio: "inherit"
6974
+ });
6975
+ const exited = new Promise((resolve, reject) => {
6976
+ child.on("error", reject);
6977
+ child.on("exit", (code, signal) => {
6978
+ if (typeof code === "number") resolve(code);
6979
+ else if (signal) resolve(128 + signalNumber(signal));
6980
+ else resolve(0);
6981
+ });
6982
+ });
6983
+ return {
6984
+ exited,
6985
+ kill: (signal) => {
6986
+ try {
6987
+ child.kill(signal);
6988
+ } catch {
6989
+ }
6990
+ }
6991
+ };
6992
+ };
6993
+ function signalNumber(signal) {
6994
+ switch (signal) {
6995
+ case "SIGINT":
6996
+ return 2;
6997
+ case "SIGTERM":
6998
+ return 15;
6999
+ default:
7000
+ return 1;
7001
+ }
7002
+ }
7003
+ var installSigintDefault = (handler) => {
7004
+ process.on("SIGINT", handler);
7005
+ return () => process.off("SIGINT", handler);
7006
+ };
7007
+ var DEFAULT_LOCAL_ADDRESS = "127.0.0.1";
7008
+ async function runTunnel(opts) {
7009
+ const log = opts.logger ?? {
7010
+ info: (m) => consola32.info(m),
7011
+ warn: (m) => consola32.warn(m)
7012
+ };
7013
+ const resolve = opts.resolve ?? resolveTunnelTarget;
7014
+ const resolveArgs2 = {
7015
+ name: opts.name,
7016
+ target: opts.target,
7017
+ ...opts.monocerosHome !== void 0 ? { monocerosHome: opts.monocerosHome } : {}
7018
+ };
7019
+ const resolved = await resolve(resolveArgs2);
7020
+ const localPort = opts.localPort ?? resolved.internalPort;
7021
+ const localAddress = opts.localAddress ?? DEFAULT_LOCAL_ADDRESS;
7022
+ validateLocalAddress(localAddress);
7023
+ const preflight = opts.preflight ?? preflightLocalPort;
7024
+ await preflight({ port: localPort, address: localAddress });
7025
+ const dockerSpawn = opts.dockerSpawn ?? defaultDockerSpawn;
7026
+ const installSignalHandler = opts.installSignalHandler ?? installSigintDefault;
7027
+ const dockerArgs = buildDockerArgs({
7028
+ localAddress,
7029
+ localPort,
7030
+ internalPort: resolved.internalPort,
7031
+ network: resolved.network,
7032
+ targetHost: resolved.targetHost
7033
+ });
7034
+ log.info(
7035
+ `Tunnel: ${localAddress}:${localPort} \u2192 ${resolved.display}:${resolved.internalPort} (Ctrl+C to stop)`
7036
+ );
7037
+ const handle = dockerSpawn(dockerArgs);
7038
+ const uninstall = installSignalHandler(() => {
7039
+ });
7040
+ try {
7041
+ const exitCode = await handle.exited;
7042
+ if (exitCode === 130) return 0;
7043
+ return exitCode;
7044
+ } finally {
7045
+ uninstall();
7046
+ }
7047
+ }
7048
+ function buildDockerArgs(input) {
7049
+ return [
7050
+ "run",
7051
+ "--rm",
7052
+ "-i",
7053
+ `--network=${input.network}`,
7054
+ "-p",
7055
+ `${input.localAddress}:${input.localPort}:${input.internalPort}`,
7056
+ SOCAT_IMAGE,
7057
+ `TCP-LISTEN:${input.internalPort},fork,reuseaddr`,
7058
+ `TCP:${input.targetHost}:${input.internalPort}`
7059
+ ];
7060
+ }
7061
+ var IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
7062
+ function validateLocalAddress(addr) {
7063
+ if (addr === "localhost") return;
7064
+ if (IPV4_RE.test(addr)) {
7065
+ for (const part of addr.split(".")) {
7066
+ const n = Number(part);
7067
+ if (n < 0 || n > 255) {
7068
+ throw new Error(
7069
+ `Invalid --local-address '${addr}': each dotted-quad octet must be 0-255.`
7070
+ );
7071
+ }
7072
+ }
7073
+ return;
7074
+ }
7075
+ throw new Error(
7076
+ `Invalid --local-address '${addr}'. Use 127.0.0.1 (default), 0.0.0.0, or a specific IPv4 address.`
7077
+ );
7078
+ }
7079
+
7080
+ // src/commands/tunnel.ts
7081
+ var tunnelCommand = defineCommand29({
7082
+ meta: {
7083
+ name: "tunnel",
7084
+ group: "discovery",
7085
+ description: "Open a TCP tunnel from the host to a service or port inside the container. Foreground process \u2014 Ctrl+C closes the tunnel. Pass a service name (e.g. `postgres`, `mysql`, `redis`) from the container yml or a bare in-container port number. See ADR 0009."
7086
+ },
7087
+ args: {
7088
+ name: {
7089
+ type: "positional",
7090
+ description: "Container name (yml in $MONOCEROS_HOME/container-configs/).",
7091
+ required: true
7092
+ },
7093
+ target: {
7094
+ type: "positional",
7095
+ description: "Service name from the container yml (e.g. `postgres`) or an in-container port number (e.g. `8080`).",
7096
+ required: true
7097
+ },
7098
+ "local-port": {
7099
+ type: "string",
7100
+ description: "Host port the tunnel listens on. Default: same as the internal port (e.g. postgres \u2192 5432). Pass a different value when the default is busy."
7101
+ },
7102
+ "local-address": {
7103
+ type: "string",
7104
+ description: "Host interface the tunnel binds to. Default: 127.0.0.1 (loopback only \u2014 same machine). Pass 0.0.0.0 to expose on all interfaces (LAN, other devices on the same network)."
7105
+ }
7106
+ },
7107
+ async run({ args }) {
7108
+ try {
7109
+ const localPort = parseLocalPort(args["local-port"]);
7110
+ const exitCode = await runTunnel({
7111
+ name: args.name,
7112
+ target: args.target,
7113
+ ...localPort !== void 0 ? { localPort } : {},
7114
+ ...args["local-address"] ? { localAddress: args["local-address"] } : {}
7115
+ });
7116
+ process.exit(exitCode);
7117
+ } catch (err) {
7118
+ consola33.error(err instanceof Error ? err.message : String(err));
7119
+ process.exit(1);
7120
+ }
7121
+ }
7122
+ });
7123
+ function parseLocalPort(raw) {
7124
+ if (raw === void 0) return void 0;
7125
+ const n = Number(raw);
7126
+ if (!Number.isInteger(n) || n <= 0 || n >= 65536) {
7127
+ throw new Error(
7128
+ `Invalid --local-port '${raw}': must be an integer between 1 and 65535.`
7129
+ );
7130
+ }
7131
+ return n;
7132
+ }
7133
+
6731
7134
  // src/main.ts
6732
- var main = defineCommand29({
7135
+ var main = defineCommand30({
6733
7136
  meta: {
6734
7137
  name: "monoceros",
6735
7138
  version: CLI_VERSION,
@@ -6762,6 +7165,7 @@ var main = defineCommand29({
6762
7165
  "remove-repo": removeRepoCommand,
6763
7166
  "remove-port": removePortCommand,
6764
7167
  port: portCommand,
7168
+ tunnel: tunnelCommand,
6765
7169
  completion: completionCommand,
6766
7170
  __complete: __completeCommand
6767
7171
  }