@minimalcorp/tsunagi 0.1.54 → 0.2.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/.next/standalone/apps/web/.next/BUILD_ID +1 -1
- package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.rsc +16 -16
- package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +16 -16
- package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +9 -9
- package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.rsc +16 -16
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +16 -16
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +9 -9
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0qjafjt._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{_05_5y28._.js → _053072t._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{_0ncpk6b._.js → _07i72iz._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_09eooml._.js +7 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bru-z.._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hnxatt._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0i8ojdw._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0mtw02l._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_12aa-fs._.js +7 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_09nusj_._.js → node_modules_0-g_j5j._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0axuayq._.js → node_modules_04pfam-._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_10v5lct._.js → node_modules_0_1re~9._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0h_hear._.js → node_modules_0_huoqd._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0c8sp_9._.js → node_modules_0a9pqes._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_09e0q95._.js → node_modules_0bfjoue._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0s3mkxs._.js → node_modules_0e.hm5q._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0la1a4x._.js → node_modules_0krwgny._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zv4e8x._.js → node_modules_0l23gkf._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0w8sswp._.js → node_modules_0mpkxmv._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_06gyfuv._.js → node_modules_0oip5a1._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_117mm7f._.js → node_modules_0q_xfyl._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0nji2bx._.js → node_modules_0tod6y8._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0o9t~hh._.js → node_modules_0xp9nhx._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0_876x4._.js → node_modules_0zstt1y._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0mt.8za._.js → node_modules_0~2ubpz._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
- package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/0.qz1zt1c.y.n.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/0ajw1mjy3owa4.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0fe4v5dh3whem.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/{0vxoeg5sfua~3.js → 0gzt~9euv6u2y.js} +2 -2
- package/.next/standalone/apps/web/.next/static/chunks/0jcorj3-aambm.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/{0pavejkn3rkj5.js → 0o6ggm7jrim25.js} +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{0p3_2uc39mlse.js → 0s7g4uwmm49t..js} +2 -2
- package/.next/standalone/apps/web/.next/static/chunks/0wnto9g4i0s3h.css +4 -0
- package/.next/standalone/apps/web/.next/static/chunks/{0jc3jwed_8vre.js → 0~_c.mwboavrv.js} +2 -2
- package/dist/app.d.ts +1 -0
- package/dist/app.js +375 -0
- package/dist/cli.js +152 -290
- package/dist/plugin-lifecycle.d.ts +9 -10
- package/dist/plugin-lifecycle.js +77 -65
- package/dist/server/index.js +18 -3
- package/dist/server/lib/auto-update.d.ts +18 -0
- package/dist/server/lib/auto-update.js +208 -0
- package/dist/server/lib/restart.d.ts +8 -0
- package/dist/server/lib/restart.js +13 -0
- package/dist/server/lib/update-check.js +14 -4
- package/dist/server/pty-manager.d.ts +2 -0
- package/dist/server/pty-manager.js +31 -2
- package/dist/server/routes/version.js +16 -0
- package/dist/single-instance-lock.js +3 -17
- package/dist/update-lifecycle.d.ts +19 -0
- package/dist/update-lifecycle.js +93 -0
- package/dist/versions.d.ts +60 -0
- package/dist/versions.js +95 -0
- package/dist/with-plugin.js +6 -8
- package/npm-shrinkwrap.json +35 -56
- package/package.json +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hqrmyz._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0m.hvms._.js +0 -7
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0mljs_s._.js +0 -7
- package/.next/standalone/apps/web/.next/static/chunks/00b2u96xq7qxl.css +0 -4
- package/.next/standalone/apps/web/.next/static/chunks/08-d-ag2euha9.js +0 -5
- package/.next/standalone/apps/web/.next/static/chunks/0gpkw.1e6kcpn.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0v6gxm~ldhmma.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/13y6z-vus9yvr.js +0 -5
- /package/.next/standalone/apps/web/.next/static/{wiMenzJBT5A0KltHNQWHr → OXWK1FxsZh6lcEQawkL6h}/_buildManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{wiMenzJBT5A0KltHNQWHr → OXWK1FxsZh6lcEQawkL6h}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{wiMenzJBT5A0KltHNQWHr → OXWK1FxsZh6lcEQawkL6h}/_ssgManifest.js +0 -0
package/dist/plugin-lifecycle.js
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
3
4
|
import * as path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
/**
|
|
6
7
|
* Claude Code plugin lifecycle management.
|
|
7
8
|
*
|
|
8
|
-
* Strategy:
|
|
9
|
+
* Strategy: load the plugin per session via `CLAUDE_CODE_PLUGIN_DIRS`.
|
|
9
10
|
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - On shutdown, uninstall is attempted unconditionally and failures are
|
|
17
|
-
* ignored so that cleanup never blocks process exit.
|
|
11
|
+
* - The server sets `CLAUDE_CODE_PLUGIN_DIRS` on every PTY it spawns
|
|
12
|
+
* (apps/server/src/pty-manager.ts), so only Claude sessions started inside
|
|
13
|
+
* tsunagi load the plugin. Nothing is written to the user's Claude Code
|
|
14
|
+
* settings, so there is nothing to clean up on shutdown.
|
|
15
|
+
* - Older tsunagi versions installed the plugin into user scope via a local
|
|
16
|
+
* marketplace. That registration is removed once on startup.
|
|
18
17
|
*/
|
|
19
18
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
20
19
|
// dev (tsx): apps/cli/src/plugin-lifecycle.ts → THIS_DIR = apps/cli/src
|
|
@@ -22,39 +21,59 @@ const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
22
21
|
// prod (compiled): <pkg>/dist/plugin-lifecycle.js → THIS_DIR = <pkg>/dist
|
|
23
22
|
// → ../tsunagi-marketplace = <pkg>/tsunagi-marketplace ✓
|
|
24
23
|
const MARKETPLACE_NAME = 'tsunagi-marketplace';
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return candidate;
|
|
24
|
+
const PLUGIN_NAME = 'tsunagi-plugin';
|
|
25
|
+
const PLUGIN_REF = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
26
|
+
// CLAUDE_CODE_PLUGIN_DIRS に対応した最初のバージョン
|
|
27
|
+
const MIN_CLAUDE_VERSION = [2, 1, 280];
|
|
28
|
+
function getLegacyCleanupMarkerPath() {
|
|
29
|
+
const dataDir = process.env.TSUNAGI_DATA_DIR || path.join(os.homedir(), '.tsunagi');
|
|
30
|
+
return path.join(dataDir, 'state', 'legacy-plugin-removed');
|
|
33
31
|
}
|
|
34
32
|
function debugLog(msg) {
|
|
35
33
|
if (process.env.TSUNAGI_DEBUG) {
|
|
36
34
|
console.log(`[tsunagi:plugin] ${msg}`);
|
|
37
35
|
}
|
|
38
36
|
}
|
|
39
|
-
/**
|
|
40
|
-
function
|
|
37
|
+
/** PTY の CLAUDE_CODE_PLUGIN_DIRS に渡すプラグインのディレクトリ(絶対パス) */
|
|
38
|
+
export function getPluginDir() {
|
|
39
|
+
return path.resolve(THIS_DIR, '..', MARKETPLACE_NAME, 'plugins', PLUGIN_NAME);
|
|
40
|
+
}
|
|
41
|
+
/** `claude --version` の出力(例: "2.1.292 (Claude Code)")から x.y.z を取り出す */
|
|
42
|
+
function getClaudeVersion() {
|
|
41
43
|
try {
|
|
42
|
-
const output = execSync('claude
|
|
43
|
-
|
|
44
|
+
const output = execSync('claude --version', { stdio: 'pipe' }).toString();
|
|
45
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(output);
|
|
46
|
+
return match ? match.slice(1).map(Number) : null;
|
|
44
47
|
}
|
|
45
48
|
catch {
|
|
46
|
-
return
|
|
49
|
+
return null;
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return output.includes(MARKETPLACE_NAME);
|
|
52
|
+
function isAtLeast(version, min) {
|
|
53
|
+
for (let i = 0; i < min.length; i++) {
|
|
54
|
+
if (version[i] !== min[i])
|
|
55
|
+
return version[i] > min[i];
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Exits the process with code 1 if the `claude` CLI is missing or too old to
|
|
61
|
+
* load plugins from `CLAUDE_CODE_PLUGIN_DIRS`.
|
|
62
|
+
*/
|
|
63
|
+
export function assertClaudeSupportsPluginDirs() {
|
|
64
|
+
const minLabel = MIN_CLAUDE_VERSION.join('.');
|
|
65
|
+
const version = getClaudeVersion();
|
|
66
|
+
if (!version) {
|
|
67
|
+
console.error('[tsunagi:plugin] Failed to run `claude --version`.');
|
|
68
|
+
console.error('[tsunagi:plugin] Ensure the `claude` CLI is installed and available on PATH.');
|
|
69
|
+
process.exit(1);
|
|
57
70
|
}
|
|
71
|
+
if (!isAtLeast(version, MIN_CLAUDE_VERSION)) {
|
|
72
|
+
console.error(`[tsunagi:plugin] Claude Code ${version.join('.')} is too old. tsunagi requires ${minLabel} or later.`);
|
|
73
|
+
console.error('[tsunagi:plugin] Run `claude update` and start tsunagi again.');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
debugLog(`Claude Code ${version.join('.')}`);
|
|
58
77
|
}
|
|
59
78
|
/**
|
|
60
79
|
* Run a `claude` CLI command. Returns true on success, false on failure.
|
|
@@ -73,46 +92,39 @@ function runClaude(args) {
|
|
|
73
92
|
return false;
|
|
74
93
|
}
|
|
75
94
|
}
|
|
95
|
+
function claudeOutput(args) {
|
|
96
|
+
try {
|
|
97
|
+
return execSync(`claude ${args}`, { stdio: 'pipe' }).toString();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
76
103
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* Exits the process with code 1 on install failure.
|
|
81
|
-
*
|
|
82
|
-
* @returns `'clean'` if orphaned state was cleaned up before install,
|
|
83
|
-
* `'fresh'` if no prior state existed.
|
|
104
|
+
* Remove the user-scope plugin / marketplace registered by older tsunagi
|
|
105
|
+
* versions. Runs once; a marker file in the state dir records completion.
|
|
106
|
+
* Never throws and never blocks startup on failure.
|
|
84
107
|
*/
|
|
85
|
-
export function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
export function removeLegacyPluginInstall() {
|
|
109
|
+
const markerPath = getLegacyCleanupMarkerPath();
|
|
110
|
+
if (fs.existsSync(markerPath))
|
|
111
|
+
return;
|
|
112
|
+
// 存在確認してから消す(未登録時のエラー出力を避ける)
|
|
113
|
+
let removed = true;
|
|
114
|
+
if (claudeOutput('plugin list').includes(PLUGIN_REF)) {
|
|
115
|
+
removed = runClaude(`plugin uninstall ${PLUGIN_REF}`) && removed;
|
|
91
116
|
}
|
|
92
|
-
if (
|
|
93
|
-
runClaude(`plugin marketplace remove ${MARKETPLACE_NAME}`);
|
|
117
|
+
if (claudeOutput('plugin marketplace list').includes(MARKETPLACE_NAME)) {
|
|
118
|
+
removed = runClaude(`plugin marketplace remove ${MARKETPLACE_NAME}`) && removed;
|
|
94
119
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
process.exit(1);
|
|
120
|
+
if (!removed)
|
|
121
|
+
return; // 次回起動時に再試行する
|
|
122
|
+
try {
|
|
123
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
124
|
+
fs.writeFileSync(markerPath, new Date().toISOString(), 'utf-8');
|
|
125
|
+
debugLog('Legacy plugin registration removed');
|
|
102
126
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
console.error('[tsunagi:plugin] Failed to install Claude Code plugin.');
|
|
106
|
-
runClaude(`plugin marketplace remove ${MARKETPLACE_NAME}`);
|
|
107
|
-
process.exit(1);
|
|
127
|
+
catch {
|
|
128
|
+
// マーカーを書けなくても次回再確認するだけ
|
|
108
129
|
}
|
|
109
|
-
debugLog('Plugin installed');
|
|
110
|
-
return hadOrphan ? 'clean' : 'fresh';
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Best-effort cleanup. Called on process shutdown. Never throws.
|
|
114
|
-
*/
|
|
115
|
-
export function cleanupPluginState() {
|
|
116
|
-
runClaude(`plugin uninstall ${PLUGIN_REF}`);
|
|
117
|
-
runClaude(`plugin marketplace remove ${MARKETPLACE_NAME}`);
|
|
118
130
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -25,6 +25,9 @@ import { stopWhisperServerOnExit } from './lib/whisper-process.js';
|
|
|
25
25
|
import { settingsRoutes } from './routes/settings.js';
|
|
26
26
|
import { versionRoutes } from './routes/version.js';
|
|
27
27
|
import { startUpdateCheck, stopUpdateCheck } from './lib/update-check.js';
|
|
28
|
+
import { stopAutoUpdate } from './lib/auto-update.js';
|
|
29
|
+
import { RESTART_EXIT_CODE, onRestartRequested } from './lib/restart.js';
|
|
30
|
+
import { ptyManager } from './pty-manager.js';
|
|
28
31
|
import { stopSearxngOnExit, syncSearxng } from './lib/searxng.js';
|
|
29
32
|
import { createBasicAuth } from './basic-auth.js';
|
|
30
33
|
// Fastify は単一の公開エンドポイント。Next.js は内部ポートで動かしプロキシする。
|
|
@@ -162,21 +165,33 @@ async function start() {
|
|
|
162
165
|
watchUserSettingsForLocalModel(LOCAL_MODEL_ALIAS);
|
|
163
166
|
// npm に新しいバージョンが公開されたかを定期確認し、Web に通知する
|
|
164
167
|
startUpdateCheck(io);
|
|
165
|
-
|
|
168
|
+
let shuttingDown = false;
|
|
169
|
+
const shutdown = async (signal, exitCode = 0) => {
|
|
170
|
+
if (shuttingDown)
|
|
171
|
+
return;
|
|
172
|
+
shuttingDown = true;
|
|
166
173
|
console.log(`[server] Received ${signal}, shutting down...`);
|
|
174
|
+
stopUpdateCheck();
|
|
175
|
+
stopAutoUpdate();
|
|
176
|
+
// PTY(claude を含む)は SIGHUP 任せにせず、ここで止める
|
|
177
|
+
ptyManager.deleteAllSessions();
|
|
167
178
|
stopWhisperServerOnExit();
|
|
168
179
|
stopSearxngOnExit();
|
|
169
|
-
stopUpdateCheck();
|
|
170
180
|
// 設定で有効なら、ローカルLLMのモデルをメモリから外す(バッテリー・メモリ節約)。
|
|
171
181
|
// 開発時の tsx watch はファイル変更のたびに SIGTERM で再起動するため、その場合は外さない
|
|
172
182
|
if (signal === 'SIGINT' || process.env.NODE_ENV === 'production') {
|
|
173
183
|
await unloadOnShutdown();
|
|
174
184
|
}
|
|
175
185
|
await fastify.close();
|
|
176
|
-
process.exit(
|
|
186
|
+
process.exit(exitCode);
|
|
177
187
|
};
|
|
178
188
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
179
189
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
190
|
+
// 更新の適用(POST /api/version/restart)。apps/cli がこの終了コードを見て新しいバージョンで起動し直す
|
|
191
|
+
onRestartRequested(() => {
|
|
192
|
+
io.emit('version:restarting');
|
|
193
|
+
void shutdown('restart', RESTART_EXIT_CODE);
|
|
194
|
+
});
|
|
180
195
|
// SIGINT/SIGTERM を経由しない異常終了時の最後の砦('exit' は同期処理のみ可能)。
|
|
181
196
|
process.on('exit', () => {
|
|
182
197
|
stopWhisperServerOnExit();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AutoUpdateStatus, RestartBlocker } from '@minimalcorp/tsunagi-shared';
|
|
2
|
+
/** entry が古く、再インストールしてほしいか(apps/cli/src/update-lifecycle.ts が判定する) */
|
|
3
|
+
export declare function isEntryOutdated(): boolean;
|
|
4
|
+
export declare function getAutoUpdateStatus(): AutoUpdateStatus;
|
|
5
|
+
/** 状態が変わったときに呼ぶ(update-check が Web へ通知する) */
|
|
6
|
+
export declare function onAutoUpdateChange(listener: () => void): void;
|
|
7
|
+
/**
|
|
8
|
+
* version を再起動で適用できるよう用意する(update-check が新しいバージョンを見つけるたびに呼ぶ)。
|
|
9
|
+
* インストール中は何もしない。より新しいバージョンは次回の確認で用意する。
|
|
10
|
+
*/
|
|
11
|
+
export declare function prepareUpdate(version: string): void;
|
|
12
|
+
/** サーバー終了時にインストールを止める。途中のディレクトリは次回の install で消す */
|
|
13
|
+
export declare function stopAutoUpdate(): void;
|
|
14
|
+
/**
|
|
15
|
+
* 再起動すると止まってしまう実行中(running / waiting)のタスク。
|
|
16
|
+
* PTY が残っていないタブは、前回終了時の status が残っているだけなので除く。
|
|
17
|
+
*/
|
|
18
|
+
export declare function findRestartBlockers(): Promise<RestartBlocker[]>;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ptyManager } from '../pty-manager.js';
|
|
5
|
+
import { getTsunagiDataDir } from './data-path.js';
|
|
6
|
+
import { prisma } from './db.js';
|
|
7
|
+
/**
|
|
8
|
+
* 新しいバージョンを ~/.tsunagi/versions/<version>/ に `npm install --prefix` で用意し、
|
|
9
|
+
* state.json の next に書く。再起動(requestRestart)で entry が next の版を起動する。
|
|
10
|
+
* ディレクトリ構成と entry との取り決めは apps/cli/src/versions.ts と合わせること。
|
|
11
|
+
*/
|
|
12
|
+
const PACKAGE_NAME = '@minimalcorp/tsunagi';
|
|
13
|
+
const COMPLETE_MARKER = '.complete';
|
|
14
|
+
const STDERR_TAIL_MAX_CHARS = 2000;
|
|
15
|
+
// entry 経由かつ npm でインストールされた tsunagi のときだけ apps/cli が設定する
|
|
16
|
+
const enabled = process.env.TSUNAGI_AUTO_UPDATE === '1' && !!process.env.TSUNAGI_VERSION;
|
|
17
|
+
/** entry が古く、再インストールしてほしいか(apps/cli/src/update-lifecycle.ts が判定する) */
|
|
18
|
+
export function isEntryOutdated() {
|
|
19
|
+
return process.env.TSUNAGI_ENTRY_OUTDATED === '1';
|
|
20
|
+
}
|
|
21
|
+
let status = { state: enabled ? 'idle' : 'disabled', version: null, error: null };
|
|
22
|
+
let installer = null;
|
|
23
|
+
let onChange = null;
|
|
24
|
+
export function getAutoUpdateStatus() {
|
|
25
|
+
return { ...status };
|
|
26
|
+
}
|
|
27
|
+
/** 状態が変わったときに呼ぶ(update-check が Web へ通知する) */
|
|
28
|
+
export function onAutoUpdateChange(listener) {
|
|
29
|
+
onChange = listener;
|
|
30
|
+
}
|
|
31
|
+
function setStatus(next) {
|
|
32
|
+
status = next;
|
|
33
|
+
onChange?.();
|
|
34
|
+
}
|
|
35
|
+
function getVersionDir(version) {
|
|
36
|
+
return path.join(getTsunagiDataDir(), 'versions', version);
|
|
37
|
+
}
|
|
38
|
+
function getPackageRoot(version) {
|
|
39
|
+
return path.join(getVersionDir(version), 'node_modules', ...PACKAGE_NAME.split('/'));
|
|
40
|
+
}
|
|
41
|
+
function getStatePath() {
|
|
42
|
+
return path.join(getTsunagiDataDir(), 'versions', 'state.json');
|
|
43
|
+
}
|
|
44
|
+
function readState() {
|
|
45
|
+
try {
|
|
46
|
+
const state = JSON.parse(fs.readFileSync(getStatePath(), 'utf-8'));
|
|
47
|
+
return state && typeof state === 'object' ? state : {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** 起動に失敗して entry がロールバックしたバージョン */
|
|
54
|
+
function readBrokenVersions() {
|
|
55
|
+
const { broken } = readState();
|
|
56
|
+
return Array.isArray(broken) ? broken.filter((v) => typeof v === 'string') : [];
|
|
57
|
+
}
|
|
58
|
+
/** 次の再起動で起動するバージョンを entry に伝える。他のフィールドは残し、rename で置き換える */
|
|
59
|
+
function setNextVersion(version) {
|
|
60
|
+
const statePath = getStatePath();
|
|
61
|
+
const tmpPath = `${statePath}.${process.pid}.tmp`;
|
|
62
|
+
fs.writeFileSync(tmpPath, JSON.stringify({ ...readState(), next: version }, null, 2), 'utf-8');
|
|
63
|
+
fs.renameSync(tmpPath, statePath);
|
|
64
|
+
}
|
|
65
|
+
/** インストール結果が起動できる形か(entry が起動する dist/app.js があるか) */
|
|
66
|
+
function verifyInstall(version) {
|
|
67
|
+
const root = getPackageRoot(version);
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8'));
|
|
70
|
+
return (pkg.version === version &&
|
|
71
|
+
fs.existsSync(path.join(root, 'dist', 'cli.js')) &&
|
|
72
|
+
fs.existsSync(path.join(root, 'dist', 'app.js')));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 実行中の node と同じ場所の npm を優先する(nvm 等で PATH の npm と食い違わないように) */
|
|
79
|
+
function npmCommand() {
|
|
80
|
+
const candidate = path.join(path.dirname(process.execPath), 'npm');
|
|
81
|
+
return fs.existsSync(candidate) ? candidate : 'npm';
|
|
82
|
+
}
|
|
83
|
+
/** npx 経由で起動した場合の npm_config_* 等が install に影響しないよう取り除く */
|
|
84
|
+
function installEnv() {
|
|
85
|
+
const env = {};
|
|
86
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
87
|
+
if (/^npm_/i.test(key) || key === 'NODE_ENV' || key.startsWith('TSUNAGI_'))
|
|
88
|
+
continue;
|
|
89
|
+
env[key] = value;
|
|
90
|
+
}
|
|
91
|
+
return env;
|
|
92
|
+
}
|
|
93
|
+
function install(version) {
|
|
94
|
+
const dir = getVersionDir(version);
|
|
95
|
+
// 前回途中で止まったインストールが残っていれば消してからやり直す
|
|
96
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
97
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
98
|
+
setStatus({ state: 'installing', version, error: null });
|
|
99
|
+
const child = spawn(npmCommand(), [
|
|
100
|
+
'install',
|
|
101
|
+
'--prefix',
|
|
102
|
+
dir,
|
|
103
|
+
'--no-audit',
|
|
104
|
+
'--no-fund',
|
|
105
|
+
'--no-update-notifier',
|
|
106
|
+
'--loglevel=error',
|
|
107
|
+
...(process.env.TSUNAGI_NPM_REGISTRY ? ['--registry', process.env.TSUNAGI_NPM_REGISTRY] : []),
|
|
108
|
+
`${PACKAGE_NAME}@${version}`,
|
|
109
|
+
], { cwd: dir, env: installEnv(), stdio: ['ignore', 'ignore', 'pipe'] });
|
|
110
|
+
installer = child;
|
|
111
|
+
let stderrTail = '';
|
|
112
|
+
child.stderr?.on('data', (chunk) => {
|
|
113
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_MAX_CHARS);
|
|
114
|
+
});
|
|
115
|
+
let finished = false;
|
|
116
|
+
const finish = (error) => {
|
|
117
|
+
if (finished)
|
|
118
|
+
return;
|
|
119
|
+
finished = true;
|
|
120
|
+
if (installer === child)
|
|
121
|
+
installer = null;
|
|
122
|
+
// stopAutoUpdate で止めた場合は状態を変えない(サーバー終了中)
|
|
123
|
+
if (status.state !== 'installing' || status.version !== version)
|
|
124
|
+
return;
|
|
125
|
+
if (!error && verifyInstall(version)) {
|
|
126
|
+
fs.writeFileSync(path.join(dir, COMPLETE_MARKER), new Date().toISOString(), 'utf-8');
|
|
127
|
+
setNextVersion(version);
|
|
128
|
+
setStatus({ state: 'ready', version, error: null });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
setStatus({
|
|
133
|
+
state: 'error',
|
|
134
|
+
version,
|
|
135
|
+
error: error ?? 'The installed package is incomplete',
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
child.on('error', (err) => finish(`Failed to run npm: ${err.message}`));
|
|
139
|
+
child.on('exit', (code, signal) => {
|
|
140
|
+
if (code === 0)
|
|
141
|
+
finish(null);
|
|
142
|
+
else
|
|
143
|
+
finish(`npm install failed (${signal ?? `code ${code}`})${stderrTail ? `\n${stderrTail}` : ''}`);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* version を再起動で適用できるよう用意する(update-check が新しいバージョンを見つけるたびに呼ぶ)。
|
|
148
|
+
* インストール中は何もしない。より新しいバージョンは次回の確認で用意する。
|
|
149
|
+
*/
|
|
150
|
+
export function prepareUpdate(version) {
|
|
151
|
+
if (!enabled || installer)
|
|
152
|
+
return;
|
|
153
|
+
if (status.state === 'ready' && status.version === version)
|
|
154
|
+
return;
|
|
155
|
+
if (readBrokenVersions().includes(version)) {
|
|
156
|
+
fs.rmSync(getVersionDir(version), { recursive: true, force: true });
|
|
157
|
+
setStatus({
|
|
158
|
+
state: 'error',
|
|
159
|
+
version,
|
|
160
|
+
error: `v${version} failed to start and was rolled back`,
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (fs.existsSync(path.join(getVersionDir(version), COMPLETE_MARKER))) {
|
|
165
|
+
setNextVersion(version);
|
|
166
|
+
setStatus({ state: 'ready', version, error: null });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
install(version);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
setStatus({
|
|
174
|
+
state: 'error',
|
|
175
|
+
version,
|
|
176
|
+
error: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** サーバー終了時にインストールを止める。途中のディレクトリは次回の install で消す */
|
|
181
|
+
export function stopAutoUpdate() {
|
|
182
|
+
if (!installer)
|
|
183
|
+
return;
|
|
184
|
+
try {
|
|
185
|
+
installer.kill('SIGTERM');
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// 既に終了している
|
|
189
|
+
}
|
|
190
|
+
installer = null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 再起動すると止まってしまう実行中(running / waiting)のタスク。
|
|
194
|
+
* PTY が残っていないタブは、前回終了時の status が残っているだけなので除く。
|
|
195
|
+
*/
|
|
196
|
+
export async function findRestartBlockers() {
|
|
197
|
+
const tabs = await prisma.tab.findMany({
|
|
198
|
+
where: { status: { in: ['running', 'waiting'] } },
|
|
199
|
+
include: { task: true },
|
|
200
|
+
});
|
|
201
|
+
const blockers = new Map();
|
|
202
|
+
for (const tab of tabs) {
|
|
203
|
+
if (!ptyManager.getSession(tab.tabId))
|
|
204
|
+
continue;
|
|
205
|
+
blockers.set(tab.taskId, { taskId: tab.taskId, title: tab.task.title });
|
|
206
|
+
}
|
|
207
|
+
return [...blockers.values()];
|
|
208
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 更新を適用するための再起動。Fastify はこの終了コードで終了し、apps/cli の app が残りを止めて
|
|
3
|
+
* 同じコードで終了すると、entry が新しいバージョンで起動し直す(apps/cli/src/versions.ts と合わせる)。
|
|
4
|
+
*/
|
|
5
|
+
export declare const RESTART_EXIT_CODE = 75;
|
|
6
|
+
/** index.ts が終了処理を登録する */
|
|
7
|
+
export declare function onRestartRequested(fn: () => void): void;
|
|
8
|
+
export declare function requestRestart(): void;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 更新を適用するための再起動。Fastify はこの終了コードで終了し、apps/cli の app が残りを止めて
|
|
3
|
+
* 同じコードで終了すると、entry が新しいバージョンで起動し直す(apps/cli/src/versions.ts と合わせる)。
|
|
4
|
+
*/
|
|
5
|
+
export const RESTART_EXIT_CODE = 75;
|
|
6
|
+
let handler = null;
|
|
7
|
+
/** index.ts が終了処理を登録する */
|
|
8
|
+
export function onRestartRequested(fn) {
|
|
9
|
+
handler = fn;
|
|
10
|
+
}
|
|
11
|
+
export function requestRestart() {
|
|
12
|
+
handler?.();
|
|
13
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { getAutoUpdateStatus, isEntryOutdated, onAutoUpdateChange, prepareUpdate, } from './auto-update.js';
|
|
2
|
+
// 公開パッケージの latest dist-tag のみを返す軽量エンドポイント(数KB)。
|
|
3
|
+
// TSUNAGI_NPM_REGISTRY でミラー・検証用 registry に差し替えられる(自動更新の install も同じ registry を使う)
|
|
4
|
+
const REGISTRY_BASE = (process.env.TSUNAGI_NPM_REGISTRY || 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
5
|
+
const REGISTRY_URL = `${REGISTRY_BASE}/@minimalcorp%2ftsunagi/latest`;
|
|
3
6
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
4
7
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
5
8
|
// 実行中のバージョンは apps/cli が自身の package.json から注入する。
|
|
@@ -29,7 +32,10 @@ export function isNewer(latest, current) {
|
|
|
29
32
|
return false;
|
|
30
33
|
}
|
|
31
34
|
export function getUpdateStatus() {
|
|
32
|
-
return { ...status };
|
|
35
|
+
return { ...status, autoUpdate: getAutoUpdateStatus(), entryOutdated: isEntryOutdated() };
|
|
36
|
+
}
|
|
37
|
+
function emitStatus() {
|
|
38
|
+
socket?.emit('version:status', getUpdateStatus());
|
|
33
39
|
}
|
|
34
40
|
/** registry を確認して status を更新する。確認できなければ false(前回の結果を保持) */
|
|
35
41
|
async function check() {
|
|
@@ -46,7 +52,10 @@ async function check() {
|
|
|
46
52
|
status.updateAvailable = isNewer(version, status.current);
|
|
47
53
|
status.checkedAt = new Date().toISOString();
|
|
48
54
|
// checkedAt も表示しているため、変化の有無に関わらず通知する
|
|
49
|
-
|
|
55
|
+
emitStatus();
|
|
56
|
+
// 新しいバージョンは自動でインストールしておき、再起動のタイミングだけユーザーに任せる
|
|
57
|
+
if (status.updateAvailable)
|
|
58
|
+
prepareUpdate(version);
|
|
50
59
|
return true;
|
|
51
60
|
}
|
|
52
61
|
catch {
|
|
@@ -62,6 +71,7 @@ export function startUpdateCheck(io) {
|
|
|
62
71
|
if (!status.current || timer)
|
|
63
72
|
return;
|
|
64
73
|
socket = io;
|
|
74
|
+
onAutoUpdateChange(emitStatus);
|
|
65
75
|
void check();
|
|
66
76
|
timer = setInterval(() => void check(), CHECK_INTERVAL_MS);
|
|
67
77
|
timer.unref();
|
|
@@ -3,8 +3,9 @@ import * as os from 'os';
|
|
|
3
3
|
const SCROLLBACK_MAX_BYTES = 256 * 1024; // 256KB
|
|
4
4
|
const SESSION_GC_INTERVAL_MS = 30 * 60 * 1000; // 30分
|
|
5
5
|
/**
|
|
6
|
-
* apps/cli
|
|
7
|
-
* (TSUNAGI_SERVER_PORT, NODE_ENV, TSUNAGI_NEXT_PORT, TSUNAGI_OUTER_NODE_ENV
|
|
6
|
+
* apps/cli(entry・app)が Fastify サーバー起動時に自分で注入する環境変数
|
|
7
|
+
* (TSUNAGI_SERVER_PORT, NODE_ENV, TSUNAGI_NEXT_PORT, TSUNAGI_OUTER_NODE_ENV, TSUNAGI_PLUGIN_DIR,
|
|
8
|
+
* TSUNAGI_ENTRY_*, TSUNAGI_LAST_GOOD, TSUNAGI_AUTO_UPDATE, TSUNAGI_RESTARTED)。
|
|
8
9
|
* サーバープロセス自身の制御用に後から足された値のため、内部ターミナルには継承しない。
|
|
9
10
|
* 外側 Terminal 由来の環境変数はこのリストと無関係に、これまで通り全て継承する
|
|
10
11
|
* (PORT はここでは扱わない。apps/cli は generic な PORT を上書きせず、専用キー
|
|
@@ -21,7 +22,28 @@ const TSUNAGI_INJECTED_ENV_KEYS = [
|
|
|
21
22
|
'NODE_ENV',
|
|
22
23
|
'TSUNAGI_NEXT_PORT',
|
|
23
24
|
'TSUNAGI_OUTER_NODE_ENV',
|
|
25
|
+
'TSUNAGI_PLUGIN_DIR',
|
|
26
|
+
'TSUNAGI_ENTRY_PROTOCOL',
|
|
27
|
+
'TSUNAGI_ENTRY_PACKAGE_ROOT',
|
|
28
|
+
'TSUNAGI_ENTRY_OUTDATED',
|
|
29
|
+
'TSUNAGI_LAST_GOOD',
|
|
30
|
+
'TSUNAGI_AUTO_UPDATE',
|
|
31
|
+
'TSUNAGI_RESTARTED',
|
|
24
32
|
];
|
|
33
|
+
/**
|
|
34
|
+
* tsunagi の Claude Code プラグイン(hooks・MCP・skill)を、内部ターミナルで起動する claude に
|
|
35
|
+
* セッション単位で読み込ませる。ユーザー設定にはインストールしないため、tsunagi 外の claude には
|
|
36
|
+
* 影響しない。外側 Terminal や DB で CLAUDE_CODE_PLUGIN_DIRS が設定済みなら、その後ろに追加する。
|
|
37
|
+
*/
|
|
38
|
+
function addTsunagiPluginDir(env) {
|
|
39
|
+
const pluginDir = process.env.TSUNAGI_PLUGIN_DIR;
|
|
40
|
+
if (!pluginDir)
|
|
41
|
+
return;
|
|
42
|
+
const dirs = (env.CLAUDE_CODE_PLUGIN_DIRS ?? '').split(':').filter(Boolean);
|
|
43
|
+
if (!dirs.includes(pluginDir))
|
|
44
|
+
dirs.push(pluginDir);
|
|
45
|
+
env.CLAUDE_CODE_PLUGIN_DIRS = dirs.join(':');
|
|
46
|
+
}
|
|
25
47
|
class PtyManager {
|
|
26
48
|
sessions = new Map();
|
|
27
49
|
/** sessionId → 最後に input を送信した socket.id */
|
|
@@ -67,6 +89,7 @@ class PtyManager {
|
|
|
67
89
|
for (const key of unsetKeys) {
|
|
68
90
|
delete spawnEnv[key];
|
|
69
91
|
}
|
|
92
|
+
addTsunagiPluginDir(spawnEnv);
|
|
70
93
|
const ptyProcess = pty.spawn(shell, [], {
|
|
71
94
|
name: 'xterm-256color',
|
|
72
95
|
cols: 80,
|
|
@@ -163,5 +186,11 @@ class PtyManager {
|
|
|
163
186
|
listSessions() {
|
|
164
187
|
return Array.from(this.sessions.keys());
|
|
165
188
|
}
|
|
189
|
+
/** サーバー終了時に全セッションを止める(SIGHUP 任せにせず明示的に kill する) */
|
|
190
|
+
deleteAllSessions() {
|
|
191
|
+
for (const sessionId of Array.from(this.sessions.keys())) {
|
|
192
|
+
this.deleteSession(sessionId);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
166
195
|
}
|
|
167
196
|
export const ptyManager = new PtyManager();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { findRestartBlockers, getAutoUpdateStatus } from '../lib/auto-update.js';
|
|
2
|
+
import { requestRestart } from '../lib/restart.js';
|
|
1
3
|
import { checkForUpdateNow, getUpdateStatus } from '../lib/update-check.js';
|
|
2
4
|
export async function versionRoutes(fastify) {
|
|
3
5
|
// GET /version - 実行中のバージョンと npm の最新バージョン
|
|
@@ -15,4 +17,18 @@ export async function versionRoutes(fastify) {
|
|
|
15
17
|
}
|
|
16
18
|
return reply.status(200).send({ data: getUpdateStatus() });
|
|
17
19
|
});
|
|
20
|
+
// POST /version/restart - インストール済みの新しいバージョンを再起動で適用する。
|
|
21
|
+
// 実行中(running / waiting)のタスクがあれば止まってしまうため 409 で拒否する
|
|
22
|
+
fastify.post('/version/restart', async (_request, reply) => {
|
|
23
|
+
if (getAutoUpdateStatus().state !== 'ready') {
|
|
24
|
+
return reply.status(400).send({ error: 'No update is ready to apply' });
|
|
25
|
+
}
|
|
26
|
+
const blockers = await findRestartBlockers();
|
|
27
|
+
if (blockers.length > 0) {
|
|
28
|
+
return reply.status(409).send({ error: 'Tasks are running', data: { blockers } });
|
|
29
|
+
}
|
|
30
|
+
// 応答を返してから終了処理に入る
|
|
31
|
+
reply.raw.once('finish', requestRestart);
|
|
32
|
+
return reply.status(202).send({ data: getUpdateStatus() });
|
|
33
|
+
});
|
|
18
34
|
}
|
|
@@ -7,7 +7,8 @@ import * as path from 'node:path';
|
|
|
7
7
|
* - Writes current PID to `~/.tsunagi/state/tsunagi.lock` on acquire
|
|
8
8
|
* - On startup, if lock file exists and its PID is alive, exits with error
|
|
9
9
|
* - Stale locks (PID not alive) are removed automatically
|
|
10
|
-
* - Lock is released on
|
|
10
|
+
* - Lock is released on process exit. Signal handling is left to the caller
|
|
11
|
+
* (the entry waits for the app to stop before exiting)
|
|
11
12
|
*/
|
|
12
13
|
function getTsunagiDataDir() {
|
|
13
14
|
return process.env.TSUNAGI_DATA_DIR || path.join(os.homedir(), '.tsunagi');
|
|
@@ -79,20 +80,5 @@ export function acquireSingleInstanceLock() {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
fs.writeFileSync(lockFilePath, String(process.pid), 'utf-8');
|
|
82
|
-
process.on('
|
|
83
|
-
releaseLock();
|
|
84
|
-
process.exit(0);
|
|
85
|
-
});
|
|
86
|
-
process.on('SIGTERM', () => {
|
|
87
|
-
releaseLock();
|
|
88
|
-
process.exit(0);
|
|
89
|
-
});
|
|
90
|
-
process.on('exit', () => {
|
|
91
|
-
releaseLock();
|
|
92
|
-
});
|
|
93
|
-
process.on('uncaughtException', (err) => {
|
|
94
|
-
console.error('[tsunagi] Uncaught exception:', err);
|
|
95
|
-
releaseLock();
|
|
96
|
-
process.exit(1);
|
|
97
|
-
});
|
|
83
|
+
process.on('exit', releaseLock);
|
|
98
84
|
}
|