team-chat 0.0.0-stage → 0.3.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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +458 -2
  3. package/agents/rules.md +52 -0
  4. package/agents/runtimes/codex.md +14 -0
  5. package/bin/bridge +3 -0
  6. package/bin/runner +3 -0
  7. package/cli/lib/agents.js +97 -0
  8. package/cli/lib/docs.js +151 -0
  9. package/cli/lib/local.js +41 -0
  10. package/cli/lib/server.js +52 -0
  11. package/cli/lib/util.js +107 -0
  12. package/cli/team-chat.js +383 -0
  13. package/host/bridge/attempts.js +43 -0
  14. package/host/bridge/bridge.js +326 -0
  15. package/host/bridge/config.js +105 -0
  16. package/host/bridge/knowledge-outbox.js +96 -0
  17. package/host/bridge/launcher.js +126 -0
  18. package/host/bridge/main.js +87 -0
  19. package/host/bridge/progress.js +74 -0
  20. package/host/bridge/prompt.js +97 -0
  21. package/host/bridge/run-archive.js +200 -0
  22. package/host/bridge/thread.js +44 -0
  23. package/host/bridge/triage.js +97 -0
  24. package/host/bridge/workdir.js +41 -0
  25. package/host/knowledge/card.js +43 -0
  26. package/host/knowledge/errors.js +50 -0
  27. package/host/knowledge/lesson.js +153 -0
  28. package/host/knowledge/process.js +160 -0
  29. package/host/knowledge/scrub.js +29 -0
  30. package/host/knowledge/shell.js +74 -0
  31. package/host/knowledge/stack.js +229 -0
  32. package/host/knowledge/transcript.js +82 -0
  33. package/host/lib/chat-api.js +120 -0
  34. package/host/lib/log.js +6 -0
  35. package/host/lib/pid-lock.js +65 -0
  36. package/host/lib/shell-words.js +45 -0
  37. package/host/runner/bridge-process.js +82 -0
  38. package/host/runner/config.js +81 -0
  39. package/host/runner/main.js +69 -0
  40. package/host/runner/retention.js +40 -0
  41. package/host/runner/runner.js +161 -0
  42. package/package.json +67 -5
  43. package/server/dist/src/app.js +25 -0
  44. package/server/dist/src/app.module.js +50 -0
  45. package/server/dist/src/attachments/attachment-store.js +111 -0
  46. package/server/dist/src/attachments/attachments.controller.js +67 -0
  47. package/server/dist/src/attachments/mime.js +84 -0
  48. package/server/dist/src/attachments/uploads.interceptor.js +72 -0
  49. package/server/dist/src/auth/participant.guard.js +53 -0
  50. package/server/dist/src/bridges/bridge-restart.service.js +80 -0
  51. package/server/dist/src/bridges/bridges.controller.js +47 -0
  52. package/server/dist/src/chats/chats.controller.js +83 -0
  53. package/server/dist/src/chats/chats.service.js +128 -0
  54. package/server/dist/src/collector/collector.controller.js +71 -0
  55. package/server/dist/src/collector/collector.module.js +23 -0
  56. package/server/dist/src/collector/collector.service.js +111 -0
  57. package/server/dist/src/common/clock.js +20 -0
  58. package/server/dist/src/common/errors.js +37 -0
  59. package/server/dist/src/common/format.js +14 -0
  60. package/server/dist/src/common/http.js +17 -0
  61. package/server/dist/src/common/slug.js +28 -0
  62. package/server/dist/src/common/tokens.js +19 -0
  63. package/server/dist/src/common/validation.js +41 -0
  64. package/server/dist/src/config/config.js +39 -0
  65. package/server/dist/src/conversation.module.js +41 -0
  66. package/server/dist/src/db/database.js +73 -0
  67. package/server/dist/src/db/database.module.js +51 -0
  68. package/server/dist/src/db/schema.js +212 -0
  69. package/server/dist/src/decisions/decisions.controller.js +55 -0
  70. package/server/dist/src/decisions/decisions.service.js +109 -0
  71. package/server/dist/src/documents/documents.controller.js +87 -0
  72. package/server/dist/src/documents/documents.service.js +189 -0
  73. package/server/dist/src/health/health.controller.js +29 -0
  74. package/server/dist/src/knowledge/knowledge.controller.js +83 -0
  75. package/server/dist/src/knowledge/knowledge.module.js +25 -0
  76. package/server/dist/src/knowledge/knowledge.service.js +189 -0
  77. package/server/dist/src/local/docs-sync.service.js +97 -0
  78. package/server/dist/src/local/local.controller.js +197 -0
  79. package/server/dist/src/local/local.guard.js +55 -0
  80. package/server/dist/src/local/local.module.js +30 -0
  81. package/server/dist/src/local/setup.service.js +154 -0
  82. package/server/dist/src/local/transfer.service.js +151 -0
  83. package/server/dist/src/main.js +33 -0
  84. package/server/dist/src/mcp/chat-tools.js +282 -0
  85. package/server/dist/src/mcp/mcp.controller.js +58 -0
  86. package/server/dist/src/mcp/mcp.module.js +24 -0
  87. package/server/dist/src/mentions/mention-state.js +80 -0
  88. package/server/dist/src/mentions/mentions.controller.js +100 -0
  89. package/server/dist/src/mentions/mentions.service.js +203 -0
  90. package/server/dist/src/messages/message.presenter.js +102 -0
  91. package/server/dist/src/messages/messages.service.js +149 -0
  92. package/server/dist/src/messages/thread.controller.js +179 -0
  93. package/server/dist/src/participants/directory.service.js +68 -0
  94. package/server/dist/src/participants/participants.module.js +24 -0
  95. package/server/dist/src/participants/participants.service.js +84 -0
  96. package/server/dist/src/settings/settings.service.js +42 -0
  97. package/server/dist/src/sharing/consent.js +16 -0
  98. package/server/dist/src/sharing/sharing.module.js +22 -0
  99. package/server/dist/src/sharing/sharing.service.js +175 -0
  100. package/server/dist/src/team/runner.service.js +75 -0
  101. package/server/dist/src/team/team.controller.js +178 -0
  102. package/server/dist/src/team/team.module.js +25 -0
  103. package/server/dist/src/team/team.service.js +280 -0
  104. package/server/dist/src/web/template.js +57 -0
  105. package/server/dist/src/web/web-login.service.js +74 -0
  106. package/server/dist/src/web/web.controller.js +203 -0
  107. package/server/dist/src/web/web.module.js +25 -0
  108. package/server/drizzle/0000_init.sql +169 -0
  109. package/server/drizzle/0001_seed_roles.sql +25 -0
  110. package/server/drizzle/0002_knowledge.sql +17 -0
  111. package/server/drizzle/0003_sharing.sql +30 -0
  112. package/server/drizzle/meta/0000_snapshot.json +1224 -0
  113. package/server/drizzle/meta/0001_snapshot.json +1224 -0
  114. package/server/drizzle/meta/0002_snapshot.json +1359 -0
  115. package/server/drizzle/meta/0003_snapshot.json +1545 -0
  116. package/server/drizzle/meta/_journal.json +34 -0
  117. package/server/package.json +1 -0
  118. package/server/views/chat.html +865 -0
  119. package/server/views/documents.html +204 -0
  120. package/server/views/login.html +31 -0
  121. package/server/views/setup.html +60 -0
  122. package/server/views/team.html +369 -0
@@ -0,0 +1,97 @@
1
+ // Агенты этой машины: какие движки есть, конфиг раннера, MCP у Codex, запуск раннера.
2
+ //
3
+ // Сервер называет агенту только движок; команду движка знает только runner.json
4
+ // этой машины. Конфиг пишется один раз — дальше он твой: правь как нужно.
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { run, say, start, warn, which } from './util.js';
10
+ import { PACKAGE_ROOT, restartFile } from './server.js';
11
+
12
+ export const MCP_NAME = 'team-chat';
13
+
14
+ /** Движки, которые есть на машине, и роль по умолчанию для агента на каждом. */
15
+ export function detectRuntimes() {
16
+ const found = {};
17
+ const claude = which('claude');
18
+ if (claude) found.claude = { bin: claude, role: 'developer' };
19
+ const codex = which('codex');
20
+ if (codex) found.codex = { bin: codex, role: 'analyst' };
21
+ return found;
22
+ }
23
+
24
+ /** runner.json — если его нет. Существующий не трогаем: его мог поправить человек. */
25
+ export function writeRunnerConfig(home, runtimes) {
26
+ const file = path.join(home, 'runner.json');
27
+ if (fs.existsSync(file)) return file;
28
+
29
+ const config = { runtimes: {}, trigger_roles: 'any' };
30
+ if (runtimes.claude) {
31
+ config.runtimes.claude = {
32
+ // {mcp_config} — подключение к чату под токеном КОНКРЕТНОГО агента: без него все
33
+ // агенты на claude писали бы под одним именем. Edit/Write — агент сам вносит правки.
34
+ // stream-json — ход работы построчно: мост сохраняет его в запись запуска.
35
+ cmd: `${runtimes.claude.bin} -p --output-format stream-json --verbose `
36
+ + `--allowedTools Bash Read Grep Glob Edit Write mcp__${MCP_NAME} `
37
+ + '--strict-mcp-config --mcp-config {mcp_config}',
38
+ // Урок из запуска пишет дешёвая модель: без инструментов, без MCP, без истории сессий.
39
+ distill: { cmd: `${runtimes.claude.bin} -p --model haiku --tools "" --strict-mcp-config --no-session-persistence` },
40
+ };
41
+ }
42
+ if (runtimes.codex) {
43
+ const workspace = path.join(home, 'workspaces', 'codex');
44
+ fs.mkdirSync(workspace, { recursive: true });
45
+ config.runtimes.codex = {
46
+ // --json — ход работы построчно, для записи запуска.
47
+ cmd: `${runtimes.codex.bin} exec --json --approve-for-me -c sandbox_workspace_write.network_access=true --skip-git-repo-check -`,
48
+ // Рабочий каталог вне репозиториев: писать в код песочница Codex не даст.
49
+ cwd: workspace,
50
+ // Урок из запуска: только чтение, без следа в истории сессий.
51
+ distill: { cmd: `${runtimes.codex.bin} exec --skip-git-repo-check --ephemeral -s read-only -` },
52
+ env: { PATH: `${path.dirname(runtimes.codex.bin)}:${process.env.PATH}` },
53
+ };
54
+ }
55
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
56
+ return file;
57
+ }
58
+
59
+ /**
60
+ * Codex берёт MCP-серверы из своего config.toml, а токен — из переменной окружения,
61
+ * которую ставит мост. Регистрируем сервер чата один раз; адрес сменился — перерегистрируем.
62
+ */
63
+ export function registerCodexMcp(chatUrl) {
64
+ if (!which('codex')) return;
65
+ const url = `${chatUrl}/mcp`;
66
+ const list = run('codex', ['mcp', 'list'], { quiet: true }).stdout;
67
+ const line = list.split('\n').find(l => l.trim().startsWith(MCP_NAME + ' '));
68
+ if (line && line.includes(url)) return;
69
+ if (line) run('codex', ['mcp', 'remove', MCP_NAME], { quiet: true });
70
+ const r = run('codex', ['mcp', 'add', MCP_NAME, '--url', url, '--bearer-token-env-var', 'QBAZAR_CHAT_TOKEN'], { quiet: true });
71
+ if (r.status === 0) say(`→ Codex: MCP-сервер чата ${MCP_NAME} → ${url}`);
72
+ else warn(`не удалось подключить MCP чата к Codex: ${r.stderr.trim() || r.stdout.trim()}`);
73
+ }
74
+
75
+ /** Раннер — Node-процесс из самого пакета; держит мост на каждого агента. */
76
+ export function startRunner(home, { api, token }) {
77
+ const logs = path.join(home, 'logs');
78
+ fs.mkdirSync(path.join(home, 'state'), { recursive: true });
79
+ return start(process.execPath, [path.join(PACKAGE_ROOT, 'bin', 'runner')], {
80
+ cwd: home,
81
+ log: path.join(logs, 'runner.log'),
82
+ env: {
83
+ ...process.env,
84
+ CHAT_API: api,
85
+ CHAT_RUNNER_TOKEN: token,
86
+ CHAT_RUNNER_CONFIG: path.join(home, 'runner.json'),
87
+ CHAT_RUNNER_LOGS: logs,
88
+ CHAT_STATE_DIR: path.join(home, 'state'),
89
+ CHAT_RUNS_DIR: path.join(home, 'runs'),
90
+ CHAT_MCP_NAME: MCP_NAME,
91
+ // Кнопка «перезапустить мосты»: свой сервер кладёт файл в каталог данных. У чужого
92
+ // сервера файла здесь нет — кнопка не про нас.
93
+ CHAT_RESTART_FILE: api.startsWith('http://127.0.0.1') || api.startsWith('http://localhost') ? restartFile(home) : '',
94
+ CHAT_WORKDIR_ROOT: os.homedir(),
95
+ },
96
+ });
97
+ }
@@ -0,0 +1,151 @@
1
+ // Документы ↔ репозитории. Файлы читает и пишет CLI на этой машине; сервер отдаёт
2
+ // подтверждённое и принимает импортированное.
3
+ //
4
+ // Выгрузка замыкает круг: обсуждение живёт в чате, а агент, пишущий код, читает
5
+ // репозиторий. Поэтому наружу уходит ТОЛЬКО статус current, плюс журнал решений и
6
+ // опыт агентов (KNOWLEDGE.md) — в корень каждого репозитория.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { say, warn } from './util.js';
11
+
12
+ /** Файлы, которые никогда не переносятся в чат: их читает машина или они описывают код. */
13
+ const KEEP_IN_REPO = new Set(['CLAUDE.md', 'AGENTS.md', 'README.md', 'CHANGELOG.md', 'DECISIONS.md', 'KNOWLEDGE.md']);
14
+ const OWN_REPO = 'team-chat';
15
+
16
+ /** Записать подтверждённые документы, DECISIONS.md и KNOWLEDGE.md. → сколько файлов записано (или было бы). */
17
+ export function exportDocs({ chat, documents, decisions, knowledge = [] }, root, dryRun) {
18
+ let written = 0;
19
+ if (documents.length === 0) {
20
+ warn('подтверждённых документов нет — выгружать нечего. Статус current ставит владелец.');
21
+ }
22
+ for (const doc of documents) {
23
+ if (!doc.origin) {
24
+ warn(`${doc.slug}: не задан путь в репозитории — пропущен`);
25
+ continue;
26
+ }
27
+ const target = path.join(root, doc.origin);
28
+ say(` ${doc.origin} ← ${doc.slug} (ревизия ${doc.revision})`);
29
+ if (!dryRun) {
30
+ fs.mkdirSync(path.dirname(target), { recursive: true });
31
+ fs.writeFileSync(target, header(chat, doc) + doc.body);
32
+ }
33
+ written++;
34
+ }
35
+
36
+ if (decisions.length) {
37
+ const body = decisionsMarkdown(decisions);
38
+ for (const repo of repos(root)) {
39
+ say(` ${repo}/DECISIONS.md ← решений: ${decisions.length}`);
40
+ if (!dryRun) fs.writeFileSync(path.join(root, repo, 'DECISIONS.md'), body);
41
+ written++;
42
+ }
43
+ }
44
+
45
+ if (knowledge.length) {
46
+ const body = knowledgeMarkdown(chat, knowledge);
47
+ for (const repo of repos(root)) {
48
+ say(` ${repo}/KNOWLEDGE.md ← уроков: ${knowledge.length}`);
49
+ if (!dryRun) fs.writeFileSync(path.join(root, repo, 'KNOWLEDGE.md'), body);
50
+ written++;
51
+ }
52
+ }
53
+ return written;
54
+ }
55
+
56
+ /** Найти переписку людей в репозиториях: корень, docs/ и Seo/. */
57
+ export function findDocs(root) {
58
+ const found = [];
59
+ for (const repo of repos(root)) {
60
+ for (const dir of ['', 'docs', 'Seo']) {
61
+ const full = path.join(root, repo, dir);
62
+ if (!fs.existsSync(full)) continue;
63
+ for (const name of fs.readdirSync(full).filter(n => n.endsWith('.md') && !KEEP_IN_REPO.has(n)).sort()) {
64
+ const origin = path.posix.join(repo, dir, name);
65
+ found.push({
66
+ slug: origin.toLowerCase().replace(/\//g, '--').replace(/\.md$/, '').replace(/[ _]/g, '-'),
67
+ title: name.replace(/\.md$/, ''),
68
+ kind: kindFor(name),
69
+ origin,
70
+ body: fs.readFileSync(path.join(full, name), 'utf8'),
71
+ });
72
+ }
73
+ }
74
+ }
75
+ return found;
76
+ }
77
+
78
+ function repos(root) {
79
+ return fs.readdirSync(root, { withFileTypes: true })
80
+ .filter(d => d.isDirectory() && d.name !== OWN_REPO && !d.name.startsWith('.'))
81
+ .map(d => d.name).sort();
82
+ }
83
+
84
+ /** Тип по имени файла — угадывается, правится потом руками. */
85
+ function kindFor(name) {
86
+ const n = name.toLowerCase();
87
+ if (n.includes('plan') || n.includes('redesign')) return 'plan';
88
+ if (n.includes('audit') || n.includes('review')) return 'audit';
89
+ if (n.includes('handoff') || n.includes('gaps')) return 'handoff';
90
+ if (n.includes('report') || n.includes('issues')) return 'report';
91
+ return 'reference';
92
+ }
93
+
94
+ /** Шапка выгруженного файла: правки вносятся в чате, иначе копия и оригинал разойдутся. */
95
+ function header(chat, doc) {
96
+ return [
97
+ '<!--',
98
+ ` Сгенерировано из командного чата «${chat.name}». Правки вносятся ТАМ, не здесь:`,
99
+ ` документ «${doc.slug}», ревизия ${doc.revision}, статус ${doc.status}.`,
100
+ ' Ручная правка этого файла будет затёрта следующей выгрузкой.',
101
+ '-->',
102
+ '',
103
+ '',
104
+ ].join('\n');
105
+ }
106
+
107
+ function decisionsMarkdown(decisions) {
108
+ const lines = [
109
+ '# Принятые решения',
110
+ '',
111
+ '> Выгружено из командного чата. **Здесь не правят** — решение отменяется',
112
+ '> выпуском нового в чате, и файл перегенерируется.',
113
+ '',
114
+ ];
115
+ for (const d of decisions) {
116
+ lines.push(`## №${d.number}. ${d.title}${d.superseded ? ' ~~(отменено)~~' : ''}`, '');
117
+ if (d.body) lines.push(d.body, '');
118
+ lines.push(`_${d.decided_by}, ${day(d.decided_at)}${d.supersedes ? `, отменяет №${d.supersedes}_` : '_'}`, '');
119
+ }
120
+ return lines.join('\n');
121
+ }
122
+
123
+ /** Опыт агентов — читают и люди, и агенты, работающие с репозиторием без чата. */
124
+ export function knowledgeMarkdown(chat, lessons) {
125
+ const lines = [
126
+ '# Опыт агентов',
127
+ '',
128
+ `> Выгружено из командного чата «${chat.name}»: уроки, которые агенты вынесли из своей работы.`,
129
+ '> **Здесь не правят** — файл перегенерируется. Урок — подсказка: сверяй его с задачей.',
130
+ '',
131
+ ];
132
+ for (const { problem, solution, stack, engine, at } of lessons) {
133
+ lines.push(`## ${solution.rule}`, '');
134
+ const item = (label, value) => value && lines.push(`- **${label}:** ${value}`);
135
+ item('Было', problem && [problem.symptom, problem.area && `(${problem.area})`].filter(Boolean).join(' '));
136
+ item('Причина', problem?.root_cause);
137
+ item('Что сработало', solution.approach?.join(' → '));
138
+ item('Тупики', solution.dead_ends?.join('; '));
139
+ item('Проверено', solution.verified_by);
140
+ item('Когда применять', solution.applies_when);
141
+ item('Стек', stack?.join(', '));
142
+ lines.push('', `_${[engine, at && day(at)].filter(Boolean).join(', ')}_`, '');
143
+ }
144
+ return lines.join('\n');
145
+ }
146
+
147
+ /** 24.10.2026 — по UTC, как время в базе. */
148
+ function day(iso) {
149
+ const date = new Date(iso);
150
+ return `${String(date.getUTCDate()).padStart(2, '0')}.${String(date.getUTCMonth() + 1).padStart(2, '0')}.${date.getUTCFullYear()}`;
151
+ }
@@ -0,0 +1,41 @@
1
+ // Служебные пути запущенного сервера (/api/local/*) — то, что раньше делал `php artisan`.
2
+ // Базу (PGlite) открывает только сервер, поэтому команды CLI идут через него.
3
+
4
+ import { die } from './util.js';
5
+
6
+ export class LocalApi {
7
+ #url;
8
+ #secret;
9
+
10
+ constructor(cfg) {
11
+ this.#url = `http://127.0.0.1:${cfg.port}/api/local`;
12
+ this.#secret = cfg.localSecret;
13
+ }
14
+
15
+ get(path) {
16
+ return this.#request('GET', path);
17
+ }
18
+
19
+ post(path, body = {}) {
20
+ return this.#request('POST', path, body);
21
+ }
22
+
23
+ async #request(method, path, body) {
24
+ let res;
25
+ try {
26
+ res = await fetch(this.#url + path, {
27
+ method,
28
+ headers: { 'X-Local-Secret': this.#secret, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
29
+ body: body ? JSON.stringify(body) : undefined,
30
+ });
31
+ } catch {
32
+ die('чат не запущен на этой машине — сначала team-chat start');
33
+ }
34
+ const data = await res.json().catch(() => null);
35
+ if (!res.ok) {
36
+ const errors = data?.errors ? Object.values(data.errors).flat().join(' ') : null;
37
+ die(errors || data?.message || `сервер ответил ${res.status}`);
38
+ }
39
+ return data;
40
+ }
41
+ }
@@ -0,0 +1,52 @@
1
+ // Сервер чата — Node-процесс из самого пакета (server/dist). Никаких PHP, composer и
2
+ // Docker: всё окружение задаёт CLI, данные живут в каталоге данных.
3
+
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { start } from './util.js';
10
+
11
+ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
12
+ const PKG = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
13
+ export const VERSION = PKG.version;
14
+
15
+ const SERVER_MAIN = path.join(PACKAGE_ROOT, 'server', 'dist', 'src', 'main.js');
16
+
17
+ export function defaultHome() {
18
+ return process.env.TEAM_CHAT_HOME || path.join(os.homedir(), '.team-chat');
19
+ }
20
+
21
+ /** Секрет служебных путей сервера — для команд CLI этой машины. Живёт в config.json (600). */
22
+ export function newSecret() {
23
+ return crypto.randomBytes(32).toString('hex');
24
+ }
25
+
26
+ /** Запрос «перезапустить мосты»: сервер кладёт, раннер этой машины забирает. */
27
+ export function restartFile(home) {
28
+ return path.join(home, 'bridge-restart.request');
29
+ }
30
+
31
+ /** Поднять сервер. Возвращает процесс; вывод — в <данные>/logs/server.log. */
32
+ export function startServer(home, cfg) {
33
+ return start(process.execPath, [SERVER_MAIN], {
34
+ log: path.join(home, 'logs', 'server.log'),
35
+ env: {
36
+ ...process.env,
37
+ CHAT_DATA_DIR: home,
38
+ CHAT_PORT: String(cfg.port),
39
+ CHAT_URL: `http://localhost:${cfg.port}`,
40
+ CHAT_LOCAL_SECRET: cfg.localSecret,
41
+ CHAT_BRIDGE_RESTART_FILE: restartFile(home),
42
+ },
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Данные прежней, PHP-версии пакета (до 0.3): SQLite в каталоге данных. Новый сервер её
48
+ * не читает — перенос делается дампом.
49
+ */
50
+ export function legacyData(home) {
51
+ return fs.existsSync(path.join(home, 'chat.sqlite')) && !fs.existsSync(path.join(home, 'db'));
52
+ }
@@ -0,0 +1,107 @@
1
+ // Мелочи без зависимостей: процессы, файлы, вывод. CLI ставится из git — никаких
2
+ // node_modules, только стандартная библиотека Node.
3
+
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import net from 'node:net';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ export const say = (...parts) => console.log(...parts);
11
+ export const warn = (...parts) => console.error('⚠', ...parts);
12
+
13
+ export function die(message, code = 1) {
14
+ console.error('✗ ' + message);
15
+ process.exit(code);
16
+ }
17
+
18
+ /** Есть ли команда в PATH. */
19
+ export function which(cmd) {
20
+ const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { encoding: 'utf8' });
21
+ return r.status === 0 ? r.stdout.split('\n')[0].trim() : null;
22
+ }
23
+
24
+ /** Запустить и дождаться; вывод — в лог или на экран. Возвращает {status, stdout, stderr}. */
25
+ export function run(cmd, args, { cwd, env, quiet = false, input } = {}) {
26
+ const r = spawnSync(cmd, args, {
27
+ cwd, env, input, encoding: 'utf8',
28
+ stdio: quiet ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'inherit'],
29
+ maxBuffer: 64 * 1024 * 1024,
30
+ });
31
+ if (r.error) return { status: 127, stdout: '', stderr: String(r.error.message) };
32
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
33
+ }
34
+
35
+ /** Долгоживущий процесс со своей группой: остановка гасит и всех его детей. */
36
+ export function start(cmd, args, { cwd, env, log }) {
37
+ fs.mkdirSync(path.dirname(log), { recursive: true });
38
+ const out = fs.openSync(log, 'a');
39
+ const child = spawn(cmd, args, { cwd, env, stdio: ['ignore', out, out], detached: true });
40
+ fs.closeSync(out);
41
+ return child;
42
+ }
43
+
44
+ export function stop(child, signal = 'SIGTERM') {
45
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
46
+ try { process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch { /* уже нет */ } }
47
+ }
48
+
49
+ export function alive(pid) {
50
+ try { process.kill(pid, 0); return true; } catch { return false; }
51
+ }
52
+
53
+ export const sleep = ms => new Promise(r => setTimeout(r, ms));
54
+
55
+ export function readJson(file, fallback = null) {
56
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
57
+ }
58
+
59
+ /** Записать JSON с правами 600: в конфиге токены. */
60
+ export function writeJson(file, data) {
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
63
+ fs.chmodSync(file, 0o600);
64
+ }
65
+
66
+ /** Открыть ссылку в браузере — где это умеется. Не вышло — ссылка всё равно напечатана. */
67
+ export function openBrowser(url) {
68
+ const wsl = process.platform === 'linux' && /microsoft/i.test(os.release());
69
+ const tries = process.platform === 'darwin' ? [['open', [url]]]
70
+ : wsl ? [['wslview', [url]], ['cmd.exe', ['/c', 'start', '', url.replace(/&/g, '^&')]]]
71
+ : [['xdg-open', [url]]];
72
+ for (const [cmd, args] of tries) {
73
+ if (!which(cmd)) continue;
74
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
75
+ child.on('error', () => {});
76
+ child.unref();
77
+ return true;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /** Свободен ли порт на 127.0.0.1. */
83
+ export function portFree(port) {
84
+ return new Promise(resolve => {
85
+ const srv = net.createServer();
86
+ srv.once('error', () => resolve(false));
87
+ srv.once('listening', () => srv.close(() => resolve(true)));
88
+ srv.listen(port, '127.0.0.1');
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Дождаться, пока адрес начнёт отвечать. Если передан процесс сервера — ждём, только
94
+ * пока он жив: иначе ответ чужого сервера на том же порту сошёл бы за наш.
95
+ */
96
+ export async function waitHttp(url, timeoutMs = 60000, child = null) {
97
+ const until = Date.now() + timeoutMs;
98
+ while (Date.now() < until) {
99
+ if (child && (child.exitCode !== null || child.signalCode !== null)) return false;
100
+ try {
101
+ const r = await fetch(url);
102
+ if (r.status < 500) return true;
103
+ } catch { /* ещё не поднялся */ }
104
+ await sleep(500);
105
+ }
106
+ return false;
107
+ }