revdeck 0.2.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-11-12
10
+
11
+ ### Fixed
12
+
13
+ - `revdeck devcontainer --write` no longer writes absolute host paths into the shared
14
+ `devcontainer.json`: mounts use `${localEnv:HOME}${localEnv:USERPROFILE}` and a stable
15
+ `~/.local/state/revdeck/devcontainer/{run,state}` bridge. The daemon listens on a second RPC socket
16
+ there for containers. Comments and formatting in an existing JSONC file are preserved.
17
+
9
18
  ## [0.2.0] - 2026-11-12
10
19
 
11
20
  ### Added
package/README.md CHANGED
@@ -139,12 +139,17 @@ revdeck devcontainer # print the mounts and environment
139
139
  revdeck devcontainer --write # merge them into .devcontainer/devcontainer.json
140
140
  ```
141
141
 
142
- The command mounts the host runtime directory at `/run/revdeck` (socket) and the state directory at
143
- `/var/lib/revdeck` (read-only `daemon.json`), and sets `REVDECK_RUNTIME_DIR`, `REVDECK_STATE_DIR`,
144
- `REVDECK_DEV_CONTAINER=1` and `REVDECK_UI_HOST`. After rebuilding the container, start the daemon on
145
- the host. Inside the container revdeck never starts a local daemon, so `gh`/`glab` are never invoked
146
- there; if the host socket is missing it fails with a clear `DEVCONTAINER` error. `revdeck doctor` is
147
- aware of this mode. For the UI from the container, start the host daemon with
142
+ The command mounts the stable host bridge `~/.local/state/revdeck/devcontainer/run` at `/run/revdeck`
143
+ (socket) and `~/.local/state/revdeck/devcontainer/state` at `/var/lib/revdeck` (read-only
144
+ `daemon.json`), and sets `REVDECK_RUNTIME_DIR`, `REVDECK_STATE_DIR`, `REVDECK_DEV_CONTAINER=1` and
145
+ `REVDECK_UI_HOST`. The JSON uses `${localEnv:HOME}${localEnv:USERPROFILE}` instead of an absolute
146
+ home path, so the same `.devcontainer/devcontainer.json` works for every developer. Comments and
147
+ foreign keys in an existing JSONC file are preserved.
148
+
149
+ The daemon creates the bridge socket on startup, so on the host run `revdeck daemon restart`, then
150
+ rebuild the container. Inside the container revdeck never starts a local daemon, so `gh`/`glab` are
151
+ never invoked there; if the host socket is missing it fails with a clear `DEVCONTAINER` error.
152
+ `revdeck doctor` is aware of this mode. For the UI from the container, start the host daemon with
148
153
  `revdeck daemon start --host 0.0.0.0`.
149
154
 
150
155
  ## MCP
package/README.ru.md CHANGED
@@ -125,12 +125,18 @@ revdeck devcontainer # показать mounts и окружение
125
125
  revdeck devcontainer --write # добавить их в .devcontainer/devcontainer.json
126
126
  ```
127
127
 
128
- Команда монтирует runtime-каталог хоста в `/run/revdeck` (сокет) и state-каталог в `/var/lib/revdeck`
129
- (только чтение, `daemon.json`) и выставляет `REVDECK_RUNTIME_DIR`, `REVDECK_STATE_DIR`,
130
- `REVDECK_DEV_CONTAINER=1` и `REVDECK_UI_HOST`. После пересборки контейнера запустите демон на хосте.
131
- В контейнере revdeck никогда не запускает свой демон, поэтому `gh`/`glab` там не вызываются; если
132
- сокет хоста недоступен, команда падает с понятной ошибкой `DEVCONTAINER`. `revdeck doctor` знает об
133
- этом режиме. Для UI из контейнера запустите демон на хосте с `revdeck daemon start --host 0.0.0.0`.
128
+ Команда монтирует стабильный мост хоста `~/.local/state/revdeck/devcontainer/run` в `/run/revdeck`
129
+ (сокет) и `~/.local/state/revdeck/devcontainer/state` в `/var/lib/revdeck` (только чтение,
130
+ `daemon.json`) и выставляет `REVDECK_RUNTIME_DIR`, `REVDECK_STATE_DIR`, `REVDECK_DEV_CONTAINER=1` и
131
+ `REVDECK_UI_HOST`. В JSON пишется `${localEnv:HOME}${localEnv:USERPROFILE}`, а не абсолютный домашний
132
+ путь, поэтому один `.devcontainer/devcontainer.json` работает у всех разработчиков. Комментарии и
133
+ чужие ключи в существующем JSONC сохраняются.
134
+
135
+ Демон создаёт сокет моста при старте: на хосте выполните `revdeck daemon restart`, затем пересоберите
136
+ контейнер. В контейнере revdeck никогда не запускает свой демон, поэтому `gh`/`glab` там не
137
+ вызываются; если сокет хоста недоступен, команда падает с понятной ошибкой `DEVCONTAINER`.
138
+ `revdeck doctor` знает об этом режиме. Для UI из контейнера запустите демон на хосте с
139
+ `revdeck daemon start --host 0.0.0.0`.
134
140
 
135
141
  ## MCP
136
142
 
package/dist/cli.js CHANGED
@@ -47,6 +47,19 @@ function socketPath() {
47
47
  function daemonInfoPath() {
48
48
  return path.join(stateDir(), "daemon.json");
49
49
  }
50
+ function devcontainerBridgeDir() {
51
+ if (process.env.REVDECK_DEVCONTAINER_DIR) return path.resolve(process.env.REVDECK_DEVCONTAINER_DIR);
52
+ return path.join(os.homedir(), ".local", "state", "revdeck", "devcontainer");
53
+ }
54
+ function devcontainerRunDir() {
55
+ return path.join(devcontainerBridgeDir(), "run");
56
+ }
57
+ function devcontainerStateDir() {
58
+ return path.join(devcontainerBridgeDir(), "state");
59
+ }
60
+ function devcontainerSocketPath() {
61
+ return path.join(devcontainerRunDir(), "daemon.sock");
62
+ }
50
63
  function logPath() {
51
64
  return path.join(stateDir(), "daemon.log");
52
65
  }
@@ -155,7 +168,7 @@ var VERSION;
155
168
  var init_version = __esm({
156
169
  "src/version.ts"() {
157
170
  "use strict";
158
- VERSION = "0.2.0";
171
+ VERSION = "0.2.1";
159
172
  }
160
173
  });
161
174
 
@@ -1967,22 +1980,26 @@ function isRecord(value) {
1967
1980
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1968
1981
  }
1969
1982
 
1983
+ // src/cli/eject.ts
1984
+ import { applyEdits as applyEdits2, modify as modify2 } from "jsonc-parser";
1985
+
1970
1986
  // src/cli/devcontainer.ts
1971
1987
  init_paths();
1972
1988
  import { existsSync as existsSync6 } from "node:fs";
1973
1989
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
1974
1990
  import path6 from "node:path";
1991
+ import { applyEdits, modify, parse as parseJsonc2 } from "jsonc-parser";
1975
1992
  init_locale();
1976
1993
  init_format();
1977
1994
  var CONTAINER_RUNTIME_DIR = "/run/revdeck";
1978
1995
  var CONTAINER_STATE_DIR = "/var/lib/revdeck";
1979
1996
  var CONTAINER_UI_HOST = "host.docker.internal";
1980
1997
  function devcontainerPatch() {
1981
- const mountSource = { runtime: runtimeDir(), state: stateDir() };
1998
+ const bridge = "${localEnv:HOME}${localEnv:USERPROFILE}/.local/state/revdeck/devcontainer";
1982
1999
  return {
1983
2000
  mounts: [
1984
- { type: "bind", source: mountSource.runtime, target: CONTAINER_RUNTIME_DIR },
1985
- { type: "bind", source: mountSource.state, target: CONTAINER_STATE_DIR, readonly: true }
2001
+ { type: "bind", source: `${bridge}/run`, target: CONTAINER_RUNTIME_DIR },
2002
+ { type: "bind", source: `${bridge}/state`, target: CONTAINER_STATE_DIR, readonly: true }
1986
2003
  ],
1987
2004
  containerEnv: {
1988
2005
  REVDECK_RUNTIME_DIR: CONTAINER_RUNTIME_DIR,
@@ -2019,16 +2036,54 @@ function mergeDevcontainerConfig(existing, patch) {
2019
2036
  result.runArgs = runArgs;
2020
2037
  return result;
2021
2038
  }
2039
+ function applyDevcontainerPatch(text, patch) {
2040
+ let current2 = text;
2041
+ const parsed = parseJsonc2(current2);
2042
+ if (!isRecord(parsed)) throw new Error("devcontainer.json: expected an object");
2043
+ const formatting = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
2044
+ const mounts = Array.isArray(parsed.mounts) ? [...parsed.mounts] : [];
2045
+ for (const mount of patch.mounts) {
2046
+ const index = mounts.findIndex((item) => isRecord(item) && item.target === mount.target);
2047
+ const path10 = ["mounts", index >= 0 ? index : mounts.length];
2048
+ current2 = applyEdits(current2, modify(current2, path10, mount, formatting));
2049
+ if (index >= 0) mounts[index] = mount;
2050
+ else mounts.push(mount);
2051
+ }
2052
+ for (const [key, env] of [
2053
+ ["containerEnv", patch.containerEnv],
2054
+ ["remoteEnv", patch.remoteEnv]
2055
+ ]) {
2056
+ for (const [name, value] of Object.entries(env)) {
2057
+ current2 = applyEdits(current2, modify(current2, [key, name], value, formatting));
2058
+ }
2059
+ }
2060
+ const runArgs = Array.isArray(parsed.runArgs) ? parsed.runArgs.filter((item) => typeof item === "string") : [];
2061
+ for (const arg of patch.runArgs) {
2062
+ if (runArgs.includes(arg)) continue;
2063
+ current2 = applyEdits(current2, modify(current2, ["runArgs", runArgs.length], arg, formatting));
2064
+ runArgs.push(arg);
2065
+ }
2066
+ return current2.endsWith("\n") ? current2 : `${current2}
2067
+ `;
2068
+ }
2022
2069
  async function runDevcontainer(rootInput, options) {
2023
2070
  const root = findRepoRoot(rootInput);
2024
2071
  const patch = devcontainerPatch();
2025
- await ensureDir(runtimeDir());
2026
- await ensureDir(stateDir());
2072
+ await ensureDir(devcontainerRunDir());
2073
+ await ensureDir(devcontainerStateDir());
2027
2074
  const dir = path6.join(root, ".devcontainer");
2028
2075
  const file = path6.join(dir, "devcontainer.json");
2029
- writers.out(tr("\u0425\u043E\u0441\u0442-\u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0438 revdeck:", "revdeck host directories:"));
2030
- writers.out(` ${tr("\u0441\u043E\u043A\u0435\u0442", "socket")}: ${runtimeDir()} -> ${CONTAINER_RUNTIME_DIR}`);
2031
- writers.out(` ${tr("\u0441\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435", "state")}: ${stateDir()} -> ${CONTAINER_STATE_DIR} (${tr("\u0442\u043E\u043B\u044C\u043A\u043E \u0447\u0442\u0435\u043D\u0438\u0435", "read-only")})`);
2076
+ writers.out(tr("\u041C\u043E\u0441\u0442 revdeck \u0434\u043B\u044F \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430:", "revdeck bridge for the container:"));
2077
+ writers.out(` ${tr("\u0441\u043E\u043A\u0435\u0442", "socket")}: ${devcontainerRunDir()} -> ${CONTAINER_RUNTIME_DIR}`);
2078
+ writers.out(` ${tr("\u0441\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435", "state")}: ${devcontainerStateDir()} -> ${CONTAINER_STATE_DIR} (${tr("\u0442\u043E\u043B\u044C\u043A\u043E \u0447\u0442\u0435\u043D\u0438\u0435", "read-only")})`);
2079
+ if (!existsSync6(devcontainerSocketPath())) {
2080
+ writers.err(
2081
+ tr(
2082
+ "! \u0441\u043E\u043A\u0435\u0442 \u043C\u043E\u0441\u0442\u0430 \u0435\u0449\u0451 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D: \u043D\u0430 \u0445\u043E\u0441\u0442\u0435 \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u0435 revdeck daemon restart",
2083
+ "! the bridge socket does not exist yet: run revdeck daemon restart on the host"
2084
+ )
2085
+ );
2086
+ }
2032
2087
  writers.out(tr("\u0412 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0435: \u0434\u0435\u043C\u043E\u043D, gh \u0438 glab \u043E\u0441\u0442\u0430\u044E\u0442\u0441\u044F \u043D\u0430 \u0445\u043E\u0441\u0442\u0435.", "In the container: the daemon, gh and glab stay on the host."));
2033
2088
  if (!options.write) {
2034
2089
  writers.out("");
@@ -2040,12 +2095,11 @@ async function runDevcontainer(rootInput, options) {
2040
2095
  }
2041
2096
  await mkdir3(dir, { recursive: true });
2042
2097
  const existed = existsSync6(file);
2043
- let existing = {};
2098
+ let output;
2044
2099
  if (existed) {
2045
2100
  const text = await readFile4(file, "utf8");
2046
- let parsed;
2047
2101
  try {
2048
- parsed = parseJsonc(text);
2102
+ output = applyDevcontainerPatch(text, patch);
2049
2103
  } catch (error) {
2050
2104
  throw new Error(
2051
2105
  tr(
@@ -2054,22 +2108,18 @@ async function runDevcontainer(rootInput, options) {
2054
2108
  )
2055
2109
  );
2056
2110
  }
2057
- if (!isRecord(parsed)) throw new Error(`${path6.relative(root, file)}: ${tr("\u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F \u043E\u0431\u044A\u0435\u043A\u0442", "expected an object")}`);
2058
- existing = parsed;
2059
2111
  } else {
2060
- existing = { image: "mcr.microsoft.com/devcontainers/base:bookworm" };
2112
+ const existing = { image: "mcr.microsoft.com/devcontainers/base:bookworm" };
2113
+ output = `${JSON.stringify(mergeDevcontainerConfig(existing, patch), null, 2)}
2114
+ `;
2061
2115
  }
2062
- const merged = mergeDevcontainerConfig(existing, patch);
2063
- await writeFile2(file, `${JSON.stringify(merged, null, 2)}
2064
- `);
2116
+ await writeFile2(file, output);
2065
2117
  writers.out(`\u2714 ${path6.relative(root, file)}`);
2066
- if (existed) {
2067
- writers.out(tr(" \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0438 \u0432 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044E\u0449\u0435\u043C JSONC \u043D\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u044F\u044E\u0442\u0441\u044F: \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 diff.", " comments in an existing JSONC are not preserved: review the diff."));
2068
- }
2118
+ if (existed) writers.out(tr(" \u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0438 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u044B.", " comments were preserved."));
2069
2119
  writers.out("");
2070
2120
  writers.out(tr("\u0414\u0430\u043B\u044C\u0448\u0435:", "Next:"));
2071
- writers.out(tr(" 1. \u041F\u0435\u0440\u0435\u0441\u043E\u0431\u0435\u0440\u0438\u0442\u0435/\u043F\u0435\u0440\u0435\u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 devcontainer.", " 1. Rebuild/reopen the devcontainer."));
2072
- writers.out(tr(" 2. \u041D\u0430 \u0445\u043E\u0441\u0442\u0435: revdeck daemon start.", " 2. On the host: revdeck daemon start."));
2121
+ writers.out(tr(" 1. \u041D\u0430 \u0445\u043E\u0441\u0442\u0435: revdeck daemon restart \u2014 \u043E\u043D \u0441\u043E\u0437\u0434\u0430\u0441\u0442 \u0441\u043E\u043A\u0435\u0442 \u043C\u043E\u0441\u0442\u0430.", " 1. On the host: revdeck daemon restart \u2014 it creates the bridge socket."));
2122
+ writers.out(tr(" 2. \u041F\u0435\u0440\u0435\u0441\u043E\u0431\u0435\u0440\u0438\u0442\u0435/\u043F\u0435\u0440\u0435\u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 devcontainer.", " 2. Rebuild/reopen the devcontainer."));
2073
2123
  writers.out(tr(" 3. \u0412 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0435: revdeck doctor \u2014 \u043E\u043D \u043D\u0435 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043A\u0430\u0442\u044C glab/gh \u0432\u043D\u0443\u0442\u0440\u0438.", " 3. In the container: revdeck doctor \u2014 it will not look for glab/gh inside."));
2074
2124
  writers.out(tr(" \u0414\u043B\u044F UI \u0438\u0437 \u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u0430 \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u0435 \u0434\u0435\u043C\u043E\u043D \u043D\u0430 \u0445\u043E\u0441\u0442\u0435 \u0441 --host 0.0.0.0.", " For the UI from the container, start the daemon on the host with --host 0.0.0.0."));
2075
2125
  }
@@ -2147,37 +2197,39 @@ async function runEject(rootInput, options) {
2147
2197
  try {
2148
2198
  const parsed = parseJsonc(text);
2149
2199
  if (isRecord(parsed)) {
2150
- const next = { ...parsed };
2200
+ const formatting = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
2201
+ let current2 = text;
2151
2202
  let changed = false;
2152
- if (Array.isArray(next.mounts)) {
2153
- const mounts = next.mounts.filter((item) => !(isRecord(item) && (item.target === CONTAINER_RUNTIME_DIR || item.target === CONTAINER_STATE_DIR)));
2154
- if (mounts.length !== next.mounts.length) {
2155
- next.mounts = mounts;
2203
+ if (Array.isArray(parsed.mounts)) {
2204
+ const mounts = parsed.mounts.filter((item) => !(isRecord(item) && (item.target === CONTAINER_RUNTIME_DIR || item.target === CONTAINER_STATE_DIR)));
2205
+ if (mounts.length !== parsed.mounts.length) {
2206
+ current2 = applyEdits2(current2, modify2(current2, ["mounts"], mounts, formatting));
2156
2207
  changed = true;
2157
2208
  }
2158
2209
  }
2159
2210
  for (const key of ["containerEnv", "remoteEnv"]) {
2160
- if (!isRecord(next[key])) continue;
2161
- const env = { ...next[key] };
2211
+ if (!isRecord(parsed[key])) continue;
2212
+ const env = { ...parsed[key] };
2162
2213
  for (const name of ["REVDECK_RUNTIME_DIR", "REVDECK_STATE_DIR", "REVDECK_DEV_CONTAINER", "REVDECK_UI_HOST"]) delete env[name];
2163
- if (Object.keys(env).length !== Object.keys(next[key]).length) {
2164
- next[key] = env;
2214
+ if (Object.keys(env).length !== Object.keys(parsed[key]).length) {
2215
+ current2 = applyEdits2(current2, modify2(current2, [key], env, formatting));
2165
2216
  changed = true;
2166
2217
  }
2167
2218
  }
2168
- if (Array.isArray(next.runArgs)) {
2169
- const args = next.runArgs.filter((item) => item !== "--add-host=host.docker.internal:host-gateway");
2170
- if (args.length !== next.runArgs.length) {
2171
- next.runArgs = args;
2219
+ if (Array.isArray(parsed.runArgs)) {
2220
+ const args = parsed.runArgs.filter((item) => item !== "--add-host=host.docker.internal:host-gateway");
2221
+ if (args.length !== parsed.runArgs.length) {
2222
+ current2 = applyEdits2(current2, modify2(current2, ["runArgs"], args, formatting));
2172
2223
  changed = true;
2173
2224
  }
2174
2225
  }
2175
2226
  if (changed) {
2227
+ const output = current2.endsWith("\n") ? current2 : `${current2}
2228
+ `;
2176
2229
  changes.push({
2177
2230
  path: ".devcontainer/devcontainer.json",
2178
2231
  action: tr("\u0443\u0431\u0440\u0430\u0442\u044C \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0435 \u043A \u0434\u0435\u043C\u043E\u043D\u0443 \u0445\u043E\u0441\u0442\u0430", "remove the host daemon connection"),
2179
- remove: () => writeFile3(devcontainer, `${JSON.stringify(next, null, 2)}
2180
- `)
2232
+ remove: () => writeFile3(devcontainer, output)
2181
2233
  });
2182
2234
  }
2183
2235
  }
@@ -2785,6 +2837,15 @@ function openBrowser(url) {
2785
2837
  });
2786
2838
  }
2787
2839
  async function daemon(action, values = {}) {
2840
+ if (isDevContainer() && ["start", "stop", "restart", "install", "uninstall"].includes(action)) {
2841
+ fail(
2842
+ tr(
2843
+ "\u0432 devcontainer \u0434\u0435\u043C\u043E\u043D\u043E\u043C \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442 \u0445\u043E\u0441\u0442: \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u0435 \u043A\u043E\u043C\u0430\u043D\u0434\u0443 \u043D\u0430 \u0445\u043E\u0441\u0442\u0435",
2844
+ "the daemon is managed by the host in a devcontainer: run this command on the host"
2845
+ ),
2846
+ tr("revdeck daemon restart \u043D\u0430 \u0445\u043E\u0441\u0442\u0435", "revdeck daemon restart on the host")
2847
+ );
2848
+ }
2788
2849
  if (action === "start") {
2789
2850
  const { client: client2, started } = await ensureDaemon({
2790
2851
  ...values.host ? { daemonArgs: ["--host", String(values.host)] } : {}