revdeck 0.1.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/dist/daemon.js CHANGED
@@ -74,6 +74,16 @@ var init_locale = __esm({
74
74
  }
75
75
  });
76
76
 
77
+ // src/core/types.ts
78
+ var LEVELS, REACTIONS;
79
+ var init_types = __esm({
80
+ "src/core/types.ts"() {
81
+ "use strict";
82
+ LEVELS = ["blocker", "major", "minor", "question"];
83
+ REACTIONS = ["thumbsup", "thumbsdown", "smile", "tada", "confused", "heart", "rocket", "eyes"];
84
+ }
85
+ });
86
+
77
87
  // src/core/errors.ts
78
88
  function messageOf(error) {
79
89
  if (error instanceof Error) return error.message;
@@ -289,11 +299,11 @@ function diffStats(files) {
289
299
  function changedPaths(files) {
290
300
  return files.map((file) => file.path);
291
301
  }
292
- function findFile(files, path8) {
293
- return files.find((file) => file.path === path8) ?? files.find((file) => file.oldPath === path8);
302
+ function findFile(files, path9) {
303
+ return files.find((file) => file.path === path9) ?? files.find((file) => file.oldPath === path9);
294
304
  }
295
- function findNewLine(files, path8, line) {
296
- const file = findFile(files, path8);
305
+ function findNewLine(files, path9, line) {
306
+ const file = findFile(files, path9);
297
307
  if (!file) return void 0;
298
308
  for (const hunk of file.hunks) {
299
309
  for (const item of hunk.lines) {
@@ -302,8 +312,8 @@ function findNewLine(files, path8, line) {
302
312
  }
303
313
  return void 0;
304
314
  }
305
- function snippetAround(files, path8, line, radius = 6) {
306
- const file = findFile(files, path8);
315
+ function snippetAround(files, path9, line, radius = 6) {
316
+ const file = findFile(files, path9);
307
317
  if (!file || line == null) return null;
308
318
  const flat = file.hunks.flatMap((hunk) => hunk.lines);
309
319
  const target = flat.findIndex((item) => item.newLine === line);
@@ -316,9 +326,9 @@ function snippetAround(files, path8, line, radius = 6) {
316
326
  target: item.newLine === line
317
327
  }));
318
328
  }
319
- function linePosition(files, path8, line) {
320
- const file = findFile(files, path8);
321
- const found = findNewLine(files, path8, line);
329
+ function linePosition(files, path9, line) {
330
+ const file = findFile(files, path9);
331
+ const found = findNewLine(files, path9, line);
322
332
  if (!file || !found) return null;
323
333
  return { newPath: file.path, newLine: line, oldPath: file.oldPath, oldLine: found.kind === " " ? found.oldLine : null };
324
334
  }
@@ -429,8 +439,8 @@ async function withTempJson(data, fn) {
429
439
  await rm(dir, { recursive: true, force: true });
430
440
  }
431
441
  }
432
- function query(path8, params) {
433
- const url = new URL(path8.startsWith("/") ? path8 : `/${path8}`, "http://local");
442
+ function query(path9, params) {
443
+ const url = new URL(path9.startsWith("/") ? path9 : `/${path9}`, "http://local");
434
444
  for (const [key, value] of Object.entries(params)) {
435
445
  if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
436
446
  }
@@ -459,6 +469,7 @@ var EMPTY_SHA, GitLabProvider;
459
469
  var init_gitlab = __esm({
460
470
  "src/providers/gitlab.ts"() {
461
471
  "use strict";
472
+ init_types();
462
473
  init_diff();
463
474
  init_errors();
464
475
  init_redact();
@@ -475,7 +486,7 @@ var init_gitlab = __esm({
475
486
  ref;
476
487
  options;
477
488
  platform = "gitlab";
478
- capabilities = { reactions: true, resolve: true, nativeDrafts: true };
489
+ capabilities = { reactions: true, resolve: true, nativeDrafts: true, attachments: true };
479
490
  run;
480
491
  me = null;
481
492
  infoCache = null;
@@ -643,6 +654,24 @@ rename to ${newPath}
643
654
  await this.call(["--method", "PUT", this.endpoint(`/discussions/${encodeURIComponent(threadId)}`), "--input", file]);
644
655
  });
645
656
  }
657
+ async reactions(noteId) {
658
+ const base = this.endpoint(`/notes/${encodeURIComponent(noteId)}/award_emoji`);
659
+ const items = await this.paged(base);
660
+ const me = await this.currentUser();
661
+ const grouped = /* @__PURE__ */ new Map();
662
+ for (const item of items) {
663
+ if (!REACTIONS.includes(item.name)) continue;
664
+ const reaction = grouped.get(item.name) ?? { name: item.name, count: 0, users: [], mine: false };
665
+ reaction.count += 1;
666
+ const username = item.user?.username;
667
+ if (username) {
668
+ reaction.users.push(username);
669
+ if (username === me.username) reaction.mine = true;
670
+ }
671
+ grouped.set(item.name, reaction);
672
+ }
673
+ return [...grouped.values()].sort((left, right) => left.name.localeCompare(right.name));
674
+ }
646
675
  async react(noteId, emoji) {
647
676
  const base = this.endpoint(`/notes/${encodeURIComponent(noteId)}/award_emoji`);
648
677
  const existing = await this.json([query(base, { per_page: 100 })]);
@@ -657,6 +686,16 @@ rename to ${newPath}
657
686
  });
658
687
  return { active: true };
659
688
  }
689
+ async upload(input) {
690
+ const endpoint = `projects/${this.projectRef}/uploads`;
691
+ const uploaded = await this.json(["--method", "POST", endpoint, "--form", `file=@${input.path}`]);
692
+ const raw = uploaded.full_path || uploaded.url || "";
693
+ if (!raw) throw new ProviderError(tr("GitLab \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043D\u044B\u0439 \u0444\u0430\u0439\u043B", "GitLab did not return a link to the uploaded file"));
694
+ const absolute = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${this.ref.host}${raw.startsWith("/") ? "" : "/"}${raw}`;
695
+ const label = input.name.replace(/[[\]\r\n]/g, " ").trim() || "attachment";
696
+ const markdown = input.contentType.startsWith("image/") || input.contentType.startsWith("video/") ? `![${label}](${absolute})` : `[${label}](${absolute})`;
697
+ return { name: input.name, url: absolute, markdown };
698
+ }
660
699
  async createThread(input) {
661
700
  const info = await this.info();
662
701
  if (!info.commit.head || info.commit.head === EMPTY_SHA) {
@@ -693,7 +732,7 @@ __export(github_exports, {
693
732
  GitHubProvider: () => GitHubProvider
694
733
  });
695
734
  import { tmpdir as tmpdir3 } from "node:os";
696
- var THREADS_QUERY, REACTIONS, GitHubProvider;
735
+ var THREADS_QUERY, REACTIONS2, CONTENT_TO_NAME, GitHubProvider;
697
736
  var init_github = __esm({
698
737
  "src/providers/github.ts"() {
699
738
  "use strict";
@@ -717,7 +756,7 @@ var init_github = __esm({
717
756
  }
718
757
  }
719
758
  }`;
720
- REACTIONS = {
759
+ REACTIONS2 = {
721
760
  thumbsup: "+1",
722
761
  thumbsdown: "-1",
723
762
  smile: "laugh",
@@ -727,6 +766,7 @@ var init_github = __esm({
727
766
  rocket: "rocket",
728
767
  eyes: "eyes"
729
768
  };
769
+ CONTENT_TO_NAME = Object.fromEntries(Object.entries(REACTIONS2).map(([name, content]) => [content, name]));
730
770
  GitHubProvider = class {
731
771
  constructor(ref, options) {
732
772
  this.ref = ref;
@@ -742,7 +782,7 @@ var init_github = __esm({
742
782
  ref;
743
783
  options;
744
784
  platform = "github";
745
- capabilities = { reactions: true, resolve: true, nativeDrafts: false };
785
+ capabilities = { reactions: true, resolve: true, nativeDrafts: false, attachments: false };
746
786
  run;
747
787
  owner;
748
788
  name;
@@ -945,8 +985,27 @@ rename to ${newPath}
945
985
  const mutation = resolved ? "mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }" : "mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }";
946
986
  await this.graphql(mutation, { id: threadId });
947
987
  }
988
+ async reactions(noteId) {
989
+ const base = `repos/${this.repo}/pulls/comments/${encodeURIComponent(noteId)}/reactions`;
990
+ const items = await this.paged(base);
991
+ const me = await this.currentUser();
992
+ const grouped = /* @__PURE__ */ new Map();
993
+ for (const item of items) {
994
+ const name = CONTENT_TO_NAME[item.content];
995
+ if (!name) continue;
996
+ const reaction = grouped.get(name) ?? { name, count: 0, users: [], mine: false };
997
+ reaction.count += 1;
998
+ const login = item.user?.login;
999
+ if (login) {
1000
+ reaction.users.push(login);
1001
+ if (login === me.username) reaction.mine = true;
1002
+ }
1003
+ grouped.set(name, reaction);
1004
+ }
1005
+ return [...grouped.values()].sort((left, right) => left.name.localeCompare(right.name));
1006
+ }
948
1007
  async react(noteId, emoji) {
949
- const content = REACTIONS[emoji];
1008
+ const content = REACTIONS2[emoji];
950
1009
  if (!content) throw new ProviderError(tr(`GitHub \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0440\u0435\u0430\u043A\u0446\u0438\u044E ${emoji}`, `GitHub does not support reaction ${emoji}`));
951
1010
  const base = `repos/${this.repo}/pulls/comments/${noteId}/reactions`;
952
1011
  const existing = await this.json([query(base, { per_page: 100 })]);
@@ -961,6 +1020,12 @@ rename to ${newPath}
961
1020
  });
962
1021
  return { active: true };
963
1022
  }
1023
+ async upload(_input) {
1024
+ throw new ProviderError(
1025
+ tr("GitHub \u043D\u0435 \u0434\u0430\u0451\u0442 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0433\u043E API \u0434\u043B\u044F \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439", "GitHub has no public API for uploading attachments"),
1026
+ tr("\u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0444\u0430\u0439\u043B \u0432\u0440\u0443\u0447\u043D\u0443\u044E; \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F GitLab", "paste an absolute file URL manually; uploads are supported for GitLab only")
1027
+ );
1028
+ }
964
1029
  async createThread(input) {
965
1030
  if (input.line === null) {
966
1031
  const comment2 = await withTempJson(
@@ -990,9 +1055,9 @@ rename to ${newPath}
990
1055
 
991
1056
  // src/daemon/main.ts
992
1057
  import { existsSync as existsSync4, readFileSync } from "node:fs";
993
- import { appendFile, open as open2, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
1058
+ import { appendFile, open as open2, readFile as readFile3, unlink as unlink4 } from "node:fs/promises";
994
1059
  import net2 from "node:net";
995
- import path7 from "node:path";
1060
+ import path8 from "node:path";
996
1061
  import { fileURLToPath } from "node:url";
997
1062
  import { parseArgs } from "node:util";
998
1063
 
@@ -1034,6 +1099,22 @@ function socketPath() {
1034
1099
  function daemonInfoPath() {
1035
1100
  return path.join(stateDir(), "daemon.json");
1036
1101
  }
1102
+ function devcontainerBridgeDir() {
1103
+ if (process.env.REVDECK_DEVCONTAINER_DIR) return path.resolve(process.env.REVDECK_DEVCONTAINER_DIR);
1104
+ return path.join(os.homedir(), ".local", "state", "revdeck", "devcontainer");
1105
+ }
1106
+ function devcontainerRunDir() {
1107
+ return path.join(devcontainerBridgeDir(), "run");
1108
+ }
1109
+ function devcontainerStateDir() {
1110
+ return path.join(devcontainerBridgeDir(), "state");
1111
+ }
1112
+ function devcontainerSocketPath() {
1113
+ return path.join(devcontainerRunDir(), "daemon.sock");
1114
+ }
1115
+ function devcontainerInfoPath() {
1116
+ return path.join(devcontainerStateDir(), "daemon.json");
1117
+ }
1037
1118
  function logPath() {
1038
1119
  return path.join(stateDir(), "daemon.log");
1039
1120
  }
@@ -1084,7 +1165,7 @@ async function writeFileAtomic(file, data, mode = 384) {
1084
1165
  init_redact();
1085
1166
 
1086
1167
  // src/version.ts
1087
- var VERSION = "0.1.0";
1168
+ var VERSION = "0.2.1";
1088
1169
  var PROTOCOL_VERSION = 1;
1089
1170
 
1090
1171
  // src/daemon/main.ts
@@ -1095,10 +1176,8 @@ import { DatabaseSync } from "node:sqlite";
1095
1176
  import { mkdirSync } from "node:fs";
1096
1177
  import path2 from "node:path";
1097
1178
 
1098
- // src/core/types.ts
1099
- var LEVELS = ["blocker", "major", "minor", "question"];
1100
-
1101
1179
  // src/core/refs.ts
1180
+ init_types();
1102
1181
  function reviewId(ref) {
1103
1182
  return `${ref.platform}:${ref.host}/${ref.project}!${ref.number}`;
1104
1183
  }
@@ -1639,6 +1718,9 @@ init_redact();
1639
1718
  init_errors();
1640
1719
  import { EventEmitter } from "node:events";
1641
1720
  import { createHash as createHash2, randomBytes } from "node:crypto";
1721
+ import { mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
1722
+ import { tmpdir as tmpdir4 } from "node:os";
1723
+ import path6 from "node:path";
1642
1724
 
1643
1725
  // src/core/config.ts
1644
1726
  import { z } from "zod";
@@ -1691,8 +1773,8 @@ function globToRegExp(glob) {
1691
1773
  function escapeRegExp(value) {
1692
1774
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1693
1775
  }
1694
- function classifyFile(path8) {
1695
- const lower = path8.toLowerCase();
1776
+ function classifyFile(path9) {
1777
+ const lower = path9.toLowerCase();
1696
1778
  if (/(^|\/)(node_modules|dist|build|coverage|vendor)\//.test(lower)) return "generated";
1697
1779
  if (/\.(md|mdx|rst|txt|adoc)$/.test(lower) || /(^|\/)(docs?|adr)\//.test(lower)) return "docs";
1698
1780
  if (/(^|\/)(__tests__|tests?|spec|e2e)\//.test(lower) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(lower) || /_test\.go$/.test(lower)) return "test";
@@ -2004,7 +2086,8 @@ init_locale();
2004
2086
  var CONTROL_OR_HIDDEN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u061c\u115f\u1160\u180e\u200b-\u200f\u2028-\u202e\u2060-\u206f\u2800\u3164\ufeff\uffa0]/;
2005
2087
  var QUICK_ACTION = /^[\s\p{Z}\p{Cf}]*\/[a-z_]+(?=[\s\p{Z}\p{Cf}]|$)/imu;
2006
2088
  var MASS_MENTION = /(^|[^\w@./-])@(all|here|everyone)(?![\w-])/i;
2007
- var MD_IMAGE = /!\[[^\]]*\]\s*\(|!\[[^\]]*\]\s*\[/;
2089
+ var MD_IMAGE_REF = /!\[[^\]]*\]\s*\[/;
2090
+ var MD_IMAGE_LINK = /!\[[^\]]*\]\s*\(\s*<?([^)\s>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g;
2008
2091
  var HTML_TAG = /<(?:img|script|iframe|style|video|audio|object|embed|svg|math)\b/i;
2009
2092
  var HTML_COMMENT = /<!--/;
2010
2093
  var MAX_LENGTH = 64 * 1024;
@@ -2017,15 +2100,113 @@ function sanitizeBody(body, platform, options = {}) {
2017
2100
  if (CONTROL_OR_HIDDEN.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u0443\u043F\u0440\u0430\u0432\u043B\u044F\u044E\u0449\u0438\u0435 \u0438\u043B\u0438 \u043D\u0435\u0432\u0438\u0434\u0438\u043C\u044B\u0435 \u0441\u0438\u043C\u0432\u043E\u043B\u044B", "the text contains control or invisible characters"), text: text2 };
2018
2101
  if (HTML_COMMENT.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C HTML-\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439", "the text contains an HTML comment"), text: text2 };
2019
2102
  if (HTML_TAG.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C HTML-\u0442\u0435\u0433", "the text contains an HTML tag"), text: text2 };
2020
- if (MD_IMAGE.test(text2)) return { ok: false, reason: tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430: \u0437\u0430\u043C\u0435\u043D\u0438\u0442\u0435 \u0441\u0441\u044B\u043B\u043A\u043E\u0439", "the text contains an image: replace it with a link"), text: text2 };
2103
+ const imageProblem = imageIssue(text2, options.host);
2104
+ if (imageProblem) return { ok: false, reason: imageProblem, text: text2 };
2021
2105
  if (MASS_MENTION.test(text2)) return { ok: false, reason: tr("\u043C\u0430\u0441\u0441\u043E\u0432\u043E\u0435 \u0443\u043F\u043E\u043C\u0438\u043D\u0430\u043D\u0438\u0435: \u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043A\u043E\u043D\u043A\u0440\u0435\u0442\u043D\u044B\u0445 \u043B\u044E\u0434\u0435\u0439", "mass mention: name specific people"), text: text2 };
2022
2106
  if (checkQuickActions && QUICK_ACTION.test(text2)) {
2023
2107
  return { ok: false, reason: tr("\u0441\u0442\u0440\u043E\u043A\u0430-\u043A\u043E\u043C\u0430\u043D\u0434\u0430 GitLab (/approve, /merge \u2026): \u043F\u0435\u0440\u0435\u0444\u0440\u0430\u0437\u0438\u0440\u0443\u0439\u0442\u0435", "GitLab quick action line (/approve, /merge \u2026): rephrase it"), text: text2 };
2024
2108
  }
2025
2109
  return { ok: true, reason: "", text: text2 };
2026
2110
  }
2111
+ function imageIssue(text2, host) {
2112
+ if (MD_IMAGE_REF.test(text2)) {
2113
+ return tr("\u0432 \u0442\u0435\u043A\u0441\u0442\u0435 \u0435\u0441\u0442\u044C \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430 \u0441\u043E \u0441\u0441\u044B\u043B\u043E\u0447\u043D\u043E\u0439 \u0441\u043D\u043E\u0441\u043A\u043E\u0439: \u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443", "the text contains a reference-style image: use an absolute link");
2114
+ }
2115
+ for (const match of text2.matchAll(MD_IMAGE_LINK)) {
2116
+ const url = match[1];
2117
+ if (!host) {
2118
+ return tr("\u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0438 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B \u0442\u043E\u043B\u044C\u043A\u043E \u0441 \u0445\u043E\u0441\u0442\u0430 \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u044B", "images are allowed only from the platform host");
2119
+ }
2120
+ if (url.startsWith("/uploads/")) continue;
2121
+ try {
2122
+ const parsed = new URL(url);
2123
+ if ((parsed.protocol === "https:" || parsed.protocol === "http:") && parsed.host === host) continue;
2124
+ } catch {
2125
+ }
2126
+ return tr(`\u0432\u043D\u0435\u0448\u043D\u044F\u044F \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0430 ${url}: \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B \u0442\u043E\u043B\u044C\u043A\u043E \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0441 ${host}`, `external image ${url}: only uploads from ${host} are allowed`);
2127
+ }
2128
+ return null;
2129
+ }
2130
+ function normalizeMediaLinks(body, platform, host) {
2131
+ if (platform !== "gitlab" || !host) return body;
2132
+ const base = `https://${host}`;
2133
+ return body.replace(/(!?\[[^\]]*\]\(\s*)(\/?uploads\/[^)\s]+)/g, (_match, prefix, path9) => {
2134
+ return `${prefix}${path9.startsWith("/") ? base : `${base}/`}${path9}`;
2135
+ });
2136
+ }
2137
+
2138
+ // src/core/attachments.ts
2139
+ var MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
2140
+ var MIME_BY_EXTENSION = {
2141
+ ".png": "image/png",
2142
+ ".jpg": "image/jpeg",
2143
+ ".jpeg": "image/jpeg",
2144
+ ".gif": "image/gif",
2145
+ ".webp": "image/webp",
2146
+ ".avif": "image/avif",
2147
+ ".mp4": "video/mp4",
2148
+ ".m4v": "video/mp4",
2149
+ ".webm": "video/webm",
2150
+ ".ogv": "video/ogg",
2151
+ ".ogg": "video/ogg",
2152
+ ".mov": "video/quicktime"
2153
+ };
2154
+ function extensionOf(name) {
2155
+ const index = name.lastIndexOf(".");
2156
+ return index >= 0 ? name.slice(index).toLowerCase() : "";
2157
+ }
2158
+ function attachmentSpec(name, contentType = "") {
2159
+ if (/[/\\]/.test(name)) return null;
2160
+ const base = name.replace(/[\u0000-\u001f\u007f]/g, "").trim();
2161
+ if (!base || base.length > 240) return null;
2162
+ const extension = extensionOf(base);
2163
+ const expected = MIME_BY_EXTENSION[extension];
2164
+ if (!expected) return null;
2165
+ const provided = normalizeContentType(String(contentType));
2166
+ if (provided && provided !== expected) return null;
2167
+ return {
2168
+ name: base,
2169
+ contentType: expected,
2170
+ extension,
2171
+ kind: expected.startsWith("image/") ? "image" : "video"
2172
+ };
2173
+ }
2174
+ var CONTENT_TYPE_ALIASES = {
2175
+ "image/jpg": "image/jpeg",
2176
+ "image/pjpeg": "image/jpeg",
2177
+ "video/x-m4v": "video/mp4",
2178
+ "video/x-ogg": "video/ogg"
2179
+ };
2180
+ function normalizeContentType(value) {
2181
+ const type = value.split(";")[0].trim().toLowerCase();
2182
+ return CONTENT_TYPE_ALIASES[type] ?? type;
2183
+ }
2184
+ function decodeAttachment(data) {
2185
+ const clean = String(data ?? "").replace(/\s+/g, "");
2186
+ if (!clean || clean.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(clean)) {
2187
+ throw new Error("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u043E\u0436\u0438\u0434\u0430\u043B\u0441\u044F base64");
2188
+ }
2189
+ const buffer = Buffer.from(clean, "base64");
2190
+ if (!buffer.length) throw new Error("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043F\u0443\u0441\u0442\u043E\u0435");
2191
+ if (buffer.length > MAX_ATTACHMENT_BYTES) {
2192
+ throw new Error(`\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u0431\u043E\u043B\u044C\u0448\u0435 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} \u041C\u0411`);
2193
+ }
2194
+ return buffer;
2195
+ }
2196
+ function matchesSignature(buffer, contentType) {
2197
+ if (contentType === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
2198
+ if (contentType === "image/jpeg") return buffer.length > 3 && buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255;
2199
+ if (contentType === "image/gif") return buffer.subarray(0, 4).toString("latin1") === "GIF8";
2200
+ if (contentType === "image/webp") return buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP";
2201
+ if (contentType === "image/avif") return buffer.subarray(4, 8).toString("latin1") === "ftyp";
2202
+ if (contentType === "video/webm") return buffer.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]));
2203
+ if (contentType === "video/ogg") return buffer.subarray(0, 4).toString("latin1") === "OggS";
2204
+ if (contentType === "video/mp4" || contentType === "video/quicktime") return buffer.subarray(4, 8).toString("latin1") === "ftyp";
2205
+ return false;
2206
+ }
2027
2207
 
2028
2208
  // src/core/findings.ts
2209
+ init_types();
2029
2210
  import { createHash } from "node:crypto";
2030
2211
  init_locale();
2031
2212
  var LEVEL_ORDER = { blocker: 0, major: 1, minor: 2, question: 3 };
@@ -2159,6 +2340,7 @@ function normalize(value) {
2159
2340
 
2160
2341
  // src/daemon/service.ts
2161
2342
  init_locale();
2343
+ init_types();
2162
2344
 
2163
2345
  // src/core/git.ts
2164
2346
  init_exec();
@@ -2368,6 +2550,14 @@ var ReviewService = class {
2368
2550
  await this.refresh(id, meta.row.root, provider, loaded);
2369
2551
  return this.getReview(id);
2370
2552
  }
2553
+ async providerFor(id) {
2554
+ const stored = this.store.getReview(id);
2555
+ if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
2556
+ return this.providerFactory(
2557
+ { platform: stored.row.platform, host: stored.row.host, project: stored.row.project, number: stored.row.number },
2558
+ stored.row.root
2559
+ );
2560
+ }
2371
2561
  async refresh(id, root, provider, loaded) {
2372
2562
  const errors = [...loaded.warnings];
2373
2563
  const previous = this.store.getReview(id)?.snapshot;
@@ -2581,6 +2771,77 @@ ${errors.join("\n")}`));
2581
2771
  this.emit(id);
2582
2772
  return next;
2583
2773
  }
2774
+ // ---------------------------------------------------------------- reactions
2775
+ /** Реакции для набора комментариев; отсутствующие id пропускаются. */
2776
+ async reactions(id, noteIds) {
2777
+ const review = this.getReview(id);
2778
+ const known = new Set(review.threads.flatMap((thread) => thread.notes.map((note) => note.id)));
2779
+ const requested = [...new Set(noteIds.filter((noteId) => known.has(noteId)))].slice(0, 500);
2780
+ if (!requested.length) return {};
2781
+ const provider = await this.providerFor(id);
2782
+ const result = {};
2783
+ for (const noteId of requested) {
2784
+ try {
2785
+ result[noteId] = await provider.reactions(noteId);
2786
+ } catch {
2787
+ result[noteId] = [];
2788
+ }
2789
+ }
2790
+ return result;
2791
+ }
2792
+ async react(id, noteId, emoji) {
2793
+ if (!REACTIONS.includes(emoji)) {
2794
+ throw new Error(tr(`\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430\u044F \u0440\u0435\u0430\u043A\u0446\u0438\u044F ${emoji}: ${REACTIONS.join(", ")}`, `unknown reaction ${emoji}: ${REACTIONS.join(", ")}`));
2795
+ }
2796
+ const review = this.getReview(id);
2797
+ const note = review.threads.flatMap((thread) => thread.notes).find((item) => item.id === noteId);
2798
+ if (!note) throw new Error(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u044F ${noteId} \u043D\u0435\u0442 \u0432 \u044D\u0442\u043E\u043C \u0440\u0435\u0432\u044C\u044E`, `comment ${noteId} is not in this review`));
2799
+ const provider = await this.providerFor(id);
2800
+ const result = await provider.react(noteId, emoji);
2801
+ let reactions = [];
2802
+ try {
2803
+ reactions = await provider.reactions(noteId);
2804
+ } catch {
2805
+ }
2806
+ this.store.audit(id, "reaction.toggle", { noteId, emoji, active: result.active });
2807
+ return { active: result.active, reactions };
2808
+ }
2809
+ async uploadAttachment(id, input) {
2810
+ const stored = this.store.getReview(id);
2811
+ if (!stored) throw new Error(tr(`\u0440\u0435\u0432\u044C\u044E ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E`, `review ${id} not found`));
2812
+ const spec = attachmentSpec(String(input.name ?? ""), String(input.contentType ?? ""));
2813
+ if (!spec) {
2814
+ throw new Error(
2815
+ tr(
2816
+ "\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV \u0438 MOV",
2817
+ "attachment: PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, OGV and MOV are allowed"
2818
+ )
2819
+ );
2820
+ }
2821
+ const provider = await this.providerFor(id);
2822
+ if (!provider.capabilities.attachments) {
2823
+ throw new Error(
2824
+ tr(
2825
+ "\u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u0430 \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0443 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 \u0438\u0437 revdeck: \u0432\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u0443\u044E \u0441\u0441\u044B\u043B\u043A\u0443 \u0432\u0440\u0443\u0447\u043D\u0443\u044E",
2826
+ "the platform does not support attachments from revdeck: paste an absolute URL manually"
2827
+ )
2828
+ );
2829
+ }
2830
+ const buffer = decodeAttachment(input.data);
2831
+ if (!matchesSignature(buffer, spec.contentType)) {
2832
+ throw new Error(tr("\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435: \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u043C\u043E\u0435 \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 \u0441 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u0435\u043C", "attachment: content does not match the extension"));
2833
+ }
2834
+ const dir = await mkdtemp2(path6.join(tmpdir4(), "revdeck-attachment-"));
2835
+ const file = path6.join(dir, spec.name);
2836
+ try {
2837
+ await writeFile2(file, buffer, { mode: 384, flag: "wx" });
2838
+ const uploaded = await provider.upload({ path: file, name: spec.name, contentType: spec.contentType });
2839
+ this.store.audit(id, "attachment.upload", { name: spec.name, contentType: spec.contentType, bytes: buffer.length, url: uploaded.url });
2840
+ return uploaded;
2841
+ } finally {
2842
+ await rm2(dir, { recursive: true, force: true });
2843
+ }
2844
+ }
2584
2845
  // ---------------------------------------------------------------- notes
2585
2846
  addNote(id, input) {
2586
2847
  const review = this.getReview(id);
@@ -2653,25 +2914,27 @@ ${errors.join("\n")}`));
2653
2914
  warnings.push(tr(`\u0442\u0440\u0435\u0434 ${itemId} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`, `thread ${itemId} not found`));
2654
2915
  continue;
2655
2916
  }
2656
- const body = draft.body.trim();
2657
- const alreadyPosted = body !== "" && draft.posted.trim() === body;
2917
+ const rawBody = draft.body.trim();
2918
+ const body2 = normalizeMediaLinks(rawBody, review.info.ref.platform, review.info.ref.host);
2919
+ const posted = normalizeMediaLinks(draft.posted.trim(), review.info.ref.platform, review.info.ref.host);
2920
+ const alreadyPosted = rawBody !== "" && posted === body2;
2658
2921
  const resolveChanged = draft.resolve !== thread.resolved;
2659
- if (!body && !resolveChanged) {
2922
+ if (!body2 && !resolveChanged) {
2660
2923
  warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: \u043D\u0435\u0447\u0435\u0433\u043E \u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u0442\u044C`, `thread ${thread.id.substring(0, 8)}: nothing to publish`));
2661
2924
  continue;
2662
2925
  }
2663
- if (body && alreadyPosted && !resolveChanged) {
2926
+ if (body2 && alreadyPosted && !resolveChanged) {
2664
2927
  warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: \u043E\u0442\u0432\u0435\u0442 \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D`, `thread ${thread.id.substring(0, 8)}: reply already published`));
2665
2928
  continue;
2666
2929
  }
2667
- if (body) {
2668
- const check2 = sanitizeBody(body, review.info.ref.platform);
2930
+ if (body2) {
2931
+ const check2 = sanitizeBody(body2, review.info.ref.platform, { host: review.info.ref.host });
2669
2932
  if (!check2.ok) {
2670
2933
  warnings.push(tr(`\u0442\u0440\u0435\u0434 ${thread.id.substring(0, 8)}: ${check2.reason}`, `thread ${thread.id.substring(0, 8)}: ${check2.reason}`));
2671
2934
  continue;
2672
2935
  }
2673
2936
  }
2674
- items.push({ kind, id: thread.id, body, resolve: draft.resolve });
2937
+ items.push({ kind, id: thread.id, body: body2, resolve: draft.resolve });
2675
2938
  continue;
2676
2939
  }
2677
2940
  if (kind === "finding") {
@@ -2684,13 +2947,13 @@ ${errors.join("\n")}`));
2684
2947
  warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${finding.id} \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D\u043E`, `finding ${finding.id} is already published`));
2685
2948
  continue;
2686
2949
  }
2687
- const body = finding.comment.trim();
2688
- const check2 = sanitizeBody(body, review.info.ref.platform);
2950
+ const body2 = normalizeMediaLinks(finding.comment.trim(), review.info.ref.platform, review.info.ref.host);
2951
+ const check2 = sanitizeBody(body2, review.info.ref.platform, { host: review.info.ref.host });
2689
2952
  if (!check2.ok) {
2690
2953
  warnings.push(tr(`\u0437\u0430\u043C\u0435\u0447\u0430\u043D\u0438\u0435 ${finding.id}: ${check2.reason}`, `finding ${finding.id}: ${check2.reason}`));
2691
2954
  continue;
2692
2955
  }
2693
- const finalBody = finding.file && finding.line === null ? `\`${finding.file}\`: ${body}` : body;
2956
+ const finalBody = finding.file && finding.line === null ? `\`${finding.file}\`: ${body2}` : body2;
2694
2957
  items.push({
2695
2958
  kind,
2696
2959
  id: finding.id,
@@ -2710,12 +2973,13 @@ ${errors.join("\n")}`));
2710
2973
  warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${note.id} \u0443\u0436\u0435 \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D`, `comment ${note.id} is already published`));
2711
2974
  continue;
2712
2975
  }
2713
- const check = sanitizeBody(note.text, review.info.ref.platform);
2976
+ const body = normalizeMediaLinks(note.text, review.info.ref.platform, review.info.ref.host);
2977
+ const check = sanitizeBody(body, review.info.ref.platform, { host: review.info.ref.host });
2714
2978
  if (!check.ok) {
2715
2979
  warnings.push(tr(`\u043A\u043E\u043C\u043C\u0435\u043D\u0442\u0430\u0440\u0438\u0439 ${note.id}: ${check.reason}`, `comment ${note.id}: ${check.reason}`));
2716
2980
  continue;
2717
2981
  }
2718
- items.push({ kind, id: note.id, body: note.text, file: note.file, line: note.line, side: note.side });
2982
+ items.push({ kind, id: note.id, body, file: note.file, line: note.line, side: note.side });
2719
2983
  }
2720
2984
  return { plan: this.makePlan(id, kind, items), warnings };
2721
2985
  }
@@ -2778,7 +3042,7 @@ ${errors.join("\n")}`));
2778
3042
  if (!thread) throw new Error(tr("\u0442\u0440\u0435\u0434 \u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442", "the thread no longer exists"));
2779
3043
  const body = String(item.body ?? "").trim();
2780
3044
  if (body && draft.posted.trim() !== body) {
2781
- const check = sanitizeBody(body, platform);
3045
+ const check = sanitizeBody(body, platform, { host: review.info.ref.host });
2782
3046
  if (!check.ok) throw new Error(check.reason);
2783
3047
  await provider.reply(thread, body);
2784
3048
  draft.posted = body;
@@ -3045,7 +3309,7 @@ init_errors();
3045
3309
  import http from "node:http";
3046
3310
  import { createReadStream } from "node:fs";
3047
3311
  import { readFile as readFile2, stat } from "node:fs/promises";
3048
- import path6 from "node:path";
3312
+ import path7 from "node:path";
3049
3313
  import { timingSafeEqual } from "node:crypto";
3050
3314
 
3051
3315
  // src/daemon/rpc.ts
@@ -3053,7 +3317,7 @@ import net from "node:net";
3053
3317
  import { chmod, unlink as unlink2 } from "node:fs/promises";
3054
3318
  init_redact();
3055
3319
  init_errors();
3056
- var MAX_LINE = 4 * 1024 * 1024;
3320
+ var MAX_LINE = 40 * 1024 * 1024;
3057
3321
  async function dispatch(service, method, params) {
3058
3322
  switch (method) {
3059
3323
  case "ping":
@@ -3091,6 +3355,23 @@ async function dispatch(service, method, params) {
3091
3355
  const file = service.diffFile(String(params.review ?? ""), String(params.file ?? ""));
3092
3356
  return { file };
3093
3357
  }
3358
+ case "reactions.list":
3359
+ return {
3360
+ reactions: await service.reactions(
3361
+ String(params.review ?? ""),
3362
+ Array.isArray(params.noteIds) ? params.noteIds.map(String) : []
3363
+ )
3364
+ };
3365
+ case "reactions.toggle":
3366
+ return service.react(String(params.review ?? ""), String(params.noteId ?? ""), String(params.emoji ?? ""));
3367
+ case "attachments.upload":
3368
+ return {
3369
+ attachment: await service.uploadAttachment(String(params.review ?? ""), {
3370
+ name: String(params.name ?? ""),
3371
+ contentType: String(params.contentType ?? ""),
3372
+ data: String(params.data ?? "")
3373
+ })
3374
+ };
3094
3375
  case "drafts.put":
3095
3376
  return { draft: service.setDraft(String(params.review ?? ""), String(params.threadId ?? ""), params.patch ?? {}) };
3096
3377
  case "findings.put":
@@ -3223,7 +3504,7 @@ async function respond(service, request) {
3223
3504
  }
3224
3505
 
3225
3506
  // src/daemon/http.ts
3226
- var MAX_BODY = 4 * 1024 * 1024;
3507
+ var MAX_BODY = 40 * 1024 * 1024;
3227
3508
  var MIME = {
3228
3509
  ".html": "text/html; charset=utf-8",
3229
3510
  ".js": "text/javascript; charset=utf-8",
@@ -3317,15 +3598,15 @@ async function startHttpServer(options) {
3317
3598
  res.end('<meta charset="utf-8"><h1>\u041D\u0443\u0436\u043D\u0430 \u0441\u0441\u044B\u043B\u043A\u0430 \u0437\u0430\u043F\u0443\u0441\u043A\u0430</h1><p>\u041E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0438\u0437 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u0430: \u043E\u043D \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 \u043E\u0434\u043D\u043E\u0440\u0430\u0437\u043E\u0432\u044B\u0439 \u0442\u043E\u043A\u0435\u043D.</p>');
3318
3599
  return;
3319
3600
  }
3320
- const html = await readFile2(path6.join(options.uiDir, "index.html"));
3601
+ const html = await readFile2(path7.join(options.uiDir, "index.html"));
3321
3602
  res.writeHead(200, { ...SECURITY_HEADERS, "content-type": MIME[".html"], "cache-control": "no-store" });
3322
3603
  res.end(html);
3323
3604
  return;
3324
3605
  }
3325
3606
  if (req.method === "GET" && (url.pathname.startsWith("/assets/") || url.pathname === "/favicon.svg")) {
3326
3607
  const relative = url.pathname === "/favicon.svg" ? "favicon.svg" : url.pathname.slice("/assets/".length);
3327
- const target = path6.resolve(options.uiDir, url.pathname === "/favicon.svg" ? "" : "assets", relative);
3328
- if (!target.startsWith(path6.resolve(options.uiDir) + path6.sep) || relative.includes("..")) {
3608
+ const target = path7.resolve(options.uiDir, url.pathname === "/favicon.svg" ? "" : "assets", relative);
3609
+ if (!target.startsWith(path7.resolve(options.uiDir) + path7.sep) || relative.includes("..")) {
3329
3610
  reply(res, 404, { ok: false, error: "\u043D\u0435\u0442 \u0444\u0430\u0439\u043B\u0430" });
3330
3611
  return;
3331
3612
  }
@@ -3336,7 +3617,7 @@ async function startHttpServer(options) {
3336
3617
  }
3337
3618
  res.writeHead(200, {
3338
3619
  ...SECURITY_HEADERS,
3339
- "content-type": MIME[path6.extname(target)] ?? "application/octet-stream",
3620
+ "content-type": MIME[path7.extname(target)] ?? "application/octet-stream",
3340
3621
  "cache-control": "public, max-age=31536000, immutable"
3341
3622
  });
3342
3623
  createReadStream(target).pipe(res);
@@ -3414,6 +3695,12 @@ data: ${JSON.stringify(event)}
3414
3695
  reply(res, 200, { ok: true, rules: await service.rules(review) });
3415
3696
  return;
3416
3697
  }
3698
+ if (req.method === "GET" && url.pathname === "/api/reactions") {
3699
+ const review = url.searchParams.get("review") ?? "";
3700
+ const noteIds = (url.searchParams.get("notes") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
3701
+ reply(res, 200, { ok: true, reactions: await service.reactions(review, noteIds) });
3702
+ return;
3703
+ }
3417
3704
  if (req.method === "POST") {
3418
3705
  const body = await readBody(req);
3419
3706
  const review = String(body.review ?? "");
@@ -3436,6 +3723,20 @@ data: ${JSON.stringify(event)}
3436
3723
  reply(res, 200, { ok: true, review: service.getView(updated.id), reviews: service.listReviews() });
3437
3724
  return;
3438
3725
  }
3726
+ case "/api/attachments": {
3727
+ const attachment = await service.uploadAttachment(review, {
3728
+ name: String(body.name ?? ""),
3729
+ contentType: String(body.type ?? ""),
3730
+ data: String(body.data ?? "")
3731
+ });
3732
+ reply(res, 200, { ok: true, attachment });
3733
+ return;
3734
+ }
3735
+ case "/api/reactions": {
3736
+ const result = await service.react(review, String(body.noteId ?? ""), String(body.emoji ?? ""));
3737
+ reply(res, 200, { ok: true, ...result });
3738
+ return;
3739
+ }
3439
3740
  case "/api/drafts": {
3440
3741
  const draft = service.setDraft(review, String(body.threadId ?? ""), body.patch ?? {});
3441
3742
  reply(res, 200, { ok: true, draft });
@@ -3534,12 +3835,35 @@ data: ${JSON.stringify(event)}
3534
3835
  };
3535
3836
  }
3536
3837
 
3838
+ // src/daemon/bridge.ts
3839
+ import { unlink as unlink3 } from "node:fs/promises";
3840
+ async function startDevcontainerBridge(service, info) {
3841
+ await ensureDir(devcontainerRunDir(), 448);
3842
+ await ensureDir(devcontainerStateDir(), 448);
3843
+ const rpc = await startRpcServer(service, devcontainerSocketPath());
3844
+ try {
3845
+ const payload = { ...info, socket: devcontainerSocketPath() };
3846
+ await writeFileAtomic(devcontainerInfoPath(), `${JSON.stringify(payload, null, 2)}
3847
+ `, 384);
3848
+ } catch (error) {
3849
+ await rpc.close();
3850
+ throw error;
3851
+ }
3852
+ return {
3853
+ close: async () => {
3854
+ await rpc.close();
3855
+ await unlink3(devcontainerInfoPath()).catch(() => void 0);
3856
+ }
3857
+ };
3858
+ }
3859
+
3537
3860
  // src/daemon/main.ts
3538
3861
  async function main() {
3539
3862
  setLocale(detectLocale());
3540
3863
  const { values } = parseArgs({
3541
3864
  options: {
3542
3865
  port: { type: "string" },
3866
+ host: { type: "string" },
3543
3867
  "state-dir": { type: "string" },
3544
3868
  "runtime-dir": { type: "string" },
3545
3869
  "ui-dir": { type: "string" },
@@ -3582,12 +3906,13 @@ async function main() {
3582
3906
  });
3583
3907
  const socket = socketPath();
3584
3908
  const rpc = await startRpcServer(service, socket);
3585
- const uiDir = values["ui-dir"] ? path7.resolve(values["ui-dir"]) : path7.join(packageRoot(), "dist", "ui");
3909
+ const uiDir = values["ui-dir"] ? path8.resolve(values["ui-dir"]) : path8.join(packageRoot(), "dist", "ui");
3586
3910
  const http2 = await startHttpServer({
3587
3911
  service,
3588
3912
  token,
3589
3913
  uiDir,
3590
3914
  ...values.port ? { port: Number(values.port) } : {},
3915
+ ...values.host ? { host: String(values.host) } : {},
3591
3916
  version: VERSION
3592
3917
  });
3593
3918
  const info = {
@@ -3602,6 +3927,12 @@ async function main() {
3602
3927
  };
3603
3928
  await writeFileAtomic(daemonInfoPath(), `${JSON.stringify(info, null, 2)}
3604
3929
  `, 384);
3930
+ let bridge = null;
3931
+ try {
3932
+ bridge = await startDevcontainerBridge(service, info);
3933
+ } catch (error) {
3934
+ await log(`devcontainer bridge unavailable: ${redactLine(error instanceof Error ? error.message : error)}`);
3935
+ }
3605
3936
  service.startSync();
3606
3937
  process.stdout.write(
3607
3938
  [
@@ -3619,13 +3950,14 @@ async function main() {
3619
3950
  closing = true;
3620
3951
  await log(`daemon stopping (${signal})`);
3621
3952
  try {
3953
+ await bridge?.close();
3622
3954
  await rpc.close();
3623
3955
  await http2.close();
3624
3956
  } finally {
3625
3957
  service.close();
3626
- await unlink3(daemonInfoPath()).catch(() => void 0);
3958
+ await unlink4(daemonInfoPath()).catch(() => void 0);
3627
3959
  await lock.close().catch(() => void 0);
3628
- await unlink3(path7.join(stateDir(), "daemon.lock")).catch(() => void 0);
3960
+ await unlink4(path8.join(stateDir(), "daemon.lock")).catch(() => void 0);
3629
3961
  process.exit(0);
3630
3962
  }
3631
3963
  };
@@ -3637,9 +3969,9 @@ async function main() {
3637
3969
  }
3638
3970
  }
3639
3971
  function packageRoot() {
3640
- let dir = path7.dirname(fileURLToPath(import.meta.url));
3972
+ let dir = path8.dirname(fileURLToPath(import.meta.url));
3641
3973
  for (; ; ) {
3642
- const candidate = path7.join(dir, "package.json");
3974
+ const candidate = path8.join(dir, "package.json");
3643
3975
  if (existsSync4(candidate)) {
3644
3976
  try {
3645
3977
  const parsed = JSON.parse(readFileSync(candidate, "utf8"));
@@ -3647,7 +3979,7 @@ function packageRoot() {
3647
3979
  } catch {
3648
3980
  }
3649
3981
  }
3650
- const parent = path7.dirname(dir);
3982
+ const parent = path8.dirname(dir);
3651
3983
  if (parent === dir) return process.cwd();
3652
3984
  dir = parent;
3653
3985
  }
@@ -3686,7 +4018,7 @@ async function runningDaemon() {
3686
4018
  return reachable ? info : null;
3687
4019
  }
3688
4020
  async function acquireLock() {
3689
- const file = path7.join(stateDir(), "daemon.lock");
4021
+ const file = path8.join(stateDir(), "daemon.lock");
3690
4022
  for (let attempt = 0; attempt < 2; attempt++) {
3691
4023
  try {
3692
4024
  const handle = await open2(file, "wx", 384);
@@ -3702,7 +4034,7 @@ async function acquireLock() {
3702
4034
  } catch {
3703
4035
  }
3704
4036
  }
3705
- await unlink3(file).catch(() => void 0);
4037
+ await unlink4(file).catch(() => void 0);
3706
4038
  }
3707
4039
  }
3708
4040
  return null;