@getmonoceros/workbench 1.9.6 → 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() {
@@ -2726,6 +2729,15 @@ function removeFeatureFromDoc(doc, ref) {
2726
2729
  if (!seq || !isSeq(seq)) return false;
2727
2730
  const idx = seq.items.findIndex((i) => isMap2(i) && i.get("ref") === ref);
2728
2731
  if (idx < 0) return false;
2732
+ if (idx > 0) {
2733
+ const prev = seq.items[idx - 1];
2734
+ if (prev && typeof prev.comment === "string" && prev.comment.length > 0) {
2735
+ const blank = prev.comment.match(/\n[ \t]*\n/);
2736
+ if (blank && blank.index !== void 0) {
2737
+ prev.comment = prev.comment.slice(0, blank.index);
2738
+ }
2739
+ }
2740
+ }
2729
2741
  seq.items.splice(idx, 1);
2730
2742
  pruneEmptySeq(doc, "features");
2731
2743
  return true;
@@ -2737,8 +2749,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
2737
2749
  if (!isMap2(item)) return false;
2738
2750
  const url = item.get("url");
2739
2751
  if (url === urlOrPath) return true;
2740
- const path17 = item.get("path");
2741
- 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;
2742
2754
  return effectivePath === urlOrPath;
2743
2755
  });
2744
2756
  if (idx < 0) return false;
@@ -2788,7 +2800,7 @@ async function runAddRepo(input) {
2788
2800
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
2789
2801
  );
2790
2802
  }
2791
- const path17 = (input.path ?? deriveRepoName(url)).trim();
2803
+ const path18 = (input.path ?? deriveRepoName(url)).trim();
2792
2804
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
2793
2805
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
2794
2806
  if (hasName !== hasEmail) {
@@ -2817,7 +2829,7 @@ async function runAddRepo(input) {
2817
2829
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
2818
2830
  const entry2 = {
2819
2831
  url,
2820
- path: path17,
2832
+ path: path18,
2821
2833
  ...hasName && hasEmail ? {
2822
2834
  gitUser: {
2823
2835
  name: input.gitName.trim(),
@@ -4588,7 +4600,7 @@ async function persistPromptedIdentity(prompted, ymlPath, home, logger) {
4588
4600
  }
4589
4601
 
4590
4602
  // src/version.ts
4591
- var CLI_VERSION = true ? "1.9.6" : "dev";
4603
+ var CLI_VERSION = true ? "1.10.0" : "dev";
4592
4604
 
4593
4605
  // src/commands/_dispatch.ts
4594
4606
  import { consola as consola12 } from "consola";
@@ -4995,6 +5007,7 @@ var ALL_COMMANDS = [
4995
5007
  "remove-repo",
4996
5008
  "remove-port",
4997
5009
  "port",
5010
+ "tunnel",
4998
5011
  "completion"
4999
5012
  ];
5000
5013
  var containerName = (ctx) => listContainerNames(ctx);
@@ -5089,6 +5102,13 @@ var COMMAND_SPECS = {
5089
5102
  flags: { "--default": { type: "boolean" } },
5090
5103
  innerArgs: () => []
5091
5104
  },
5105
+ tunnel: {
5106
+ positionals: [containerName, () => listServiceNames()],
5107
+ flags: {
5108
+ "--local-port": { type: "value" },
5109
+ "--local-address": { type: "value" }
5110
+ }
5111
+ },
5092
5112
  completion: {
5093
5113
  positionals: [() => listShellNames()]
5094
5114
  },
@@ -6719,8 +6739,400 @@ var stopCommand = defineCommand28({
6719
6739
  }
6720
6740
  });
6721
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
+
6722
7134
  // src/main.ts
6723
- var main = defineCommand29({
7135
+ var main = defineCommand30({
6724
7136
  meta: {
6725
7137
  name: "monoceros",
6726
7138
  version: CLI_VERSION,
@@ -6753,6 +7165,7 @@ var main = defineCommand29({
6753
7165
  "remove-repo": removeRepoCommand,
6754
7166
  "remove-port": removePortCommand,
6755
7167
  port: portCommand,
7168
+ tunnel: tunnelCommand,
6756
7169
  completion: completionCommand,
6757
7170
  __complete: __completeCommand
6758
7171
  }