@minimalcorp/tsunagi 0.1.55 → 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 (164) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
  3. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +14 -14
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  20. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  22. package/.next/standalone/apps/web/.next/server/app/index.rsc +16 -16
  23. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  24. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +16 -16
  25. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +9 -9
  27. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  29. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  33. package/.next/standalone/apps/web/.next/server/app/settings.rsc +17 -16
  34. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +17 -16
  35. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  36. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +9 -9
  37. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +3 -2
  38. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +5 -4
  39. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  40. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  41. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__00qk65u._.js +3 -0
  43. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__02o_btn._.js +2 -2
  44. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0qjafjt._.js +1 -1
  45. package/.next/standalone/apps/web/.next/server/chunks/ssr/_03vftq7._.js +7 -0
  46. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0835zmv._.js +14 -0
  47. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
  48. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g79jqo._.js +20 -0
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0kflgfj._.js +20 -0
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0w2dm3r._.js +3 -0
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/_10gk93.._.js +3 -0
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_05_5y28._.js → _139_l.u._.js} +2 -2
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/_13w5ytx._.js +7 -0
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +2 -2
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +4 -21
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0c8sp_9._.js → node_modules_04pfam-._.js} +1 -1
  57. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zv4e8x._.js → node_modules_09d2oqj._.js} +1 -1
  58. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0h_hear._.js → node_modules_0_huoqd._.js} +1 -1
  59. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_0a57phw._.js +3 -0
  60. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0axuayq._.js → node_modules_0ak_3rk._.js} +1 -1
  61. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0la1a4x._.js → node_modules_0cv326~._.js} +1 -1
  62. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0_876x4._.js → node_modules_0h.vgyn._.js} +1 -1
  63. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0w8sswp._.js → node_modules_0krwgny._.js} +1 -1
  64. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_09e0q95._.js → node_modules_0l23gkf._.js} +1 -1
  65. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0o9t~hh._.js → node_modules_0npujym._.js} +1 -1
  66. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_06gyfuv._.js → node_modules_0oip5a1._.js} +1 -1
  67. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_10v5lct._.js → node_modules_0q_5qo~._.js} +1 -1
  68. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_117mm7f._.js → node_modules_0q_xfyl._.js} +1 -1
  69. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0s3mkxs._.js → node_modules_0sn0lqb._.js} +1 -1
  70. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0nji2bx._.js → node_modules_0tod6y8._.js} +1 -1
  71. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0mt.8za._.js → node_modules_0xp9nhx._.js} +1 -1
  72. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_09nusj_._.js → node_modules_11-z65a._.js} +1 -1
  73. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  74. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  75. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  76. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  77. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  78. package/.next/standalone/apps/web/.next/static/chunks/0.gcmzq82~36i.js +1 -0
  79. package/.next/standalone/apps/web/.next/static/chunks/06gfh91..uydi.js +5 -0
  80. package/.next/standalone/apps/web/.next/static/chunks/08e.dbdz18nd2.js +18 -0
  81. package/.next/standalone/apps/web/.next/static/chunks/0b90ys7wq-p.4.css +4 -0
  82. package/.next/standalone/apps/web/.next/static/chunks/0crn03y2tj0lu.js +6 -0
  83. package/.next/standalone/apps/web/.next/static/chunks/0l83q95s~tih4.js +5 -0
  84. package/.next/standalone/apps/web/.next/static/chunks/0lvl3ec93cab_.js +7 -0
  85. package/.next/standalone/apps/web/.next/static/chunks/0lxvbkhh7amha.js +1 -0
  86. package/.next/standalone/apps/web/.next/static/chunks/0mqupo29wme26.js +7 -0
  87. package/.next/standalone/apps/web/.next/static/chunks/0rn1kcmsfzjmz.js +12 -0
  88. package/.next/standalone/apps/web/.next/static/chunks/0whl1y16cvn5q.js +1 -0
  89. package/.next/standalone/apps/web/.next/static/chunks/0z60ewb40i2ce.js +1 -0
  90. package/.next/standalone/apps/web/.next/static/chunks/0~qk-51yuw4mm.js +18 -0
  91. package/.next/standalone/apps/web/.next/static/chunks/{0v3hfoggxwsov.js → 149sia28cskw9.js} +1 -1
  92. package/.next/standalone/apps/web/.next/static/chunks/17~93fal1fw_d.js +5 -0
  93. package/dist/app.d.ts +1 -0
  94. package/dist/app.js +375 -0
  95. package/dist/cli.js +152 -290
  96. package/dist/plugin-lifecycle.d.ts +9 -10
  97. package/dist/plugin-lifecycle.js +77 -65
  98. package/dist/prisma/migrations/20261007000000_add_claude_profiles/migration.sql +10 -0
  99. package/dist/prisma/schema.prisma +9 -0
  100. package/dist/server/generated/prisma/browser.d.ts +5 -0
  101. package/dist/server/generated/prisma/client.d.ts +5 -0
  102. package/dist/server/generated/prisma/internal/class.d.ts +11 -0
  103. package/dist/server/generated/prisma/internal/class.js +4 -4
  104. package/dist/server/generated/prisma/internal/prismaNamespace.d.ts +83 -1
  105. package/dist/server/generated/prisma/internal/prismaNamespace.js +6 -0
  106. package/dist/server/generated/prisma/internal/prismaNamespaceBrowser.d.ts +7 -0
  107. package/dist/server/generated/prisma/internal/prismaNamespaceBrowser.js +6 -0
  108. package/dist/server/generated/prisma/models/ClaudeProfile.d.ts +939 -0
  109. package/dist/server/generated/prisma/models/ClaudeProfile.js +1 -0
  110. package/dist/server/generated/prisma/models.d.ts +1 -0
  111. package/dist/server/index.js +20 -3
  112. package/dist/server/lib/auto-update.d.ts +18 -0
  113. package/dist/server/lib/auto-update.js +208 -0
  114. package/dist/server/lib/claude-config-guard.d.ts +7 -5
  115. package/dist/server/lib/claude-config-guard.js +31 -12
  116. package/dist/server/lib/claude-profiles.d.ts +49 -0
  117. package/dist/server/lib/claude-profiles.js +168 -0
  118. package/dist/server/lib/pty-env-sync.d.ts +12 -0
  119. package/dist/server/lib/pty-env-sync.js +139 -0
  120. package/dist/server/lib/pty-launch.d.ts +36 -0
  121. package/dist/server/lib/pty-launch.js +141 -0
  122. package/dist/server/lib/repositories/environment.js +8 -34
  123. package/dist/server/lib/restart.d.ts +8 -0
  124. package/dist/server/lib/restart.js +13 -0
  125. package/dist/server/lib/update-check.js +14 -4
  126. package/dist/server/pty-manager.d.ts +25 -1
  127. package/dist/server/pty-manager.js +38 -3
  128. package/dist/server/routes/claude-profiles.d.ts +2 -0
  129. package/dist/server/routes/claude-profiles.js +138 -0
  130. package/dist/server/routes/env.js +25 -1
  131. package/dist/server/routes/onboarding.js +15 -6
  132. package/dist/server/routes/terminal.js +34 -120
  133. package/dist/server/routes/version.js +16 -0
  134. package/dist/single-instance-lock.js +3 -17
  135. package/dist/update-lifecycle.d.ts +19 -0
  136. package/dist/update-lifecycle.js +93 -0
  137. package/dist/versions.d.ts +60 -0
  138. package/dist/versions.js +95 -0
  139. package/dist/with-plugin.js +6 -8
  140. package/npm-shrinkwrap.json +8 -8
  141. package/package.json +1 -1
  142. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__03~.ax1._.js +0 -3
  143. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bru-z.._.js +0 -3
  144. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hqrmyz._.js +0 -3
  145. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0i8ojdw._.js +0 -14
  146. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0m.hvms._.js +0 -7
  147. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0mljs_s._.js +0 -7
  148. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0ncpk6b._.js +0 -3
  149. package/.next/standalone/apps/web/.next/server/chunks/ssr/_109pmo2._.js +0 -3
  150. package/.next/standalone/apps/web/.next/static/chunks/01mfp5lo0gy.q.js +0 -23
  151. package/.next/standalone/apps/web/.next/static/chunks/08-d-ag2euha9.js +0 -5
  152. package/.next/standalone/apps/web/.next/static/chunks/0_by0zpp68~r7.js +0 -1
  153. package/.next/standalone/apps/web/.next/static/chunks/0c.dwezn5qjvs.css +0 -4
  154. package/.next/standalone/apps/web/.next/static/chunks/0gpkw.1e6kcpn.js +0 -1
  155. package/.next/standalone/apps/web/.next/static/chunks/0jc3jwed_8vre.js +0 -7
  156. package/.next/standalone/apps/web/.next/static/chunks/0p3_2uc39mlse.js +0 -5
  157. package/.next/standalone/apps/web/.next/static/chunks/0v6gxm~ldhmma.js +0 -1
  158. package/.next/standalone/apps/web/.next/static/chunks/0vxoeg5sfua~3.js +0 -12
  159. package/.next/standalone/apps/web/.next/static/chunks/0wzu~340lh7o~.js +0 -7
  160. package/.next/standalone/apps/web/.next/static/chunks/0xa_n_zp.qw-b.js +0 -1
  161. package/.next/standalone/apps/web/.next/static/chunks/13y6z-vus9yvr.js +0 -5
  162. /package/.next/standalone/apps/web/.next/static/{EOnxRuFun6cfXk-y9KPq5 → jgWo5G4-W0asjpQwCqJWQ}/_buildManifest.js +0 -0
  163. /package/.next/standalone/apps/web/.next/static/{EOnxRuFun6cfXk-y9KPq5 → jgWo5G4-W0asjpQwCqJWQ}/_clientMiddlewareManifest.js +0 -0
  164. /package/.next/standalone/apps/web/.next/static/{EOnxRuFun6cfXk-y9KPq5 → jgWo5G4-W0asjpQwCqJWQ}/_ssgManifest.js +0 -0
@@ -0,0 +1,138 @@
1
+ import * as os from 'os';
2
+ import { ptyManager } from '../pty-manager.js';
3
+ import { prisma } from '../lib/db.js';
4
+ import { deleteEnv, getEnv, setEnv } from '../lib/repositories/environment.js';
5
+ import { CLAUDE_PROFILE_ENV_KEY, DEFAULT_CLAUDE_PROFILE, buildClaudeLoginCommand, claudeLoginPtyEnv, claudeProfileExists, createClaudeProfile, deleteClaudeProfile, getClaudeAuthStatus, listClaudeProfiles, } from '../lib/claude-profiles.js';
6
+ import { notifyEnvChanged } from '../lib/pty-env-sync.js';
7
+ /** ログイン用 PTY の sessionId の接頭辞(タブの PTY と区別する) */
8
+ const LOGIN_SESSION_PREFIX = 'claude-login-';
9
+ function validateScope(scope, owner, repo) {
10
+ if (scope !== 'global' && scope !== 'owner' && scope !== 'repo')
11
+ return 'Invalid scope';
12
+ if (scope === 'owner' && !owner)
13
+ return 'owner is required for scope=owner';
14
+ if (scope === 'repo' && (!owner || !repo))
15
+ return 'owner and repo are required for scope=repo';
16
+ return null;
17
+ }
18
+ async function getAssignment(scope, owner, repo) {
19
+ const row = await prisma.environmentVariable.findFirst({
20
+ where: {
21
+ key: CLAUDE_PROFILE_ENV_KEY,
22
+ scope,
23
+ ...(scope !== 'global' && { owner }),
24
+ ...(scope === 'repo' && { repo }),
25
+ },
26
+ });
27
+ const merged = await getEnv(scope, owner, repo);
28
+ const assignment = {
29
+ assigned: row?.value ?? null,
30
+ effective: merged[CLAUDE_PROFILE_ENV_KEY] ?? DEFAULT_CLAUDE_PROFILE,
31
+ };
32
+ return assignment;
33
+ }
34
+ function syncPtyEnv(fastify) {
35
+ notifyEnvChanged().catch((err) => fastify.log.warn(err, 'Failed to sync PTY env'));
36
+ }
37
+ export async function claudeProfilesRoutes(fastify) {
38
+ // GET /claude-profiles - システム既定 + 登録済みプロファイルとログイン状態
39
+ fastify.get('/claude-profiles', async (_request, reply) => {
40
+ const profiles = await listClaudeProfiles();
41
+ const data = await Promise.all([
42
+ getClaudeAuthStatus(DEFAULT_CLAUDE_PROFILE).then((status) => ({
43
+ slug: DEFAULT_CLAUDE_PROFILE,
44
+ name: 'System default',
45
+ // CLAUDE_CONFIG_DIR を付けない(普段ターミナルで claude を使うときと同じ設定)
46
+ configDir: null,
47
+ status,
48
+ })),
49
+ ...profiles.map(async (profile) => ({
50
+ ...profile,
51
+ status: await getClaudeAuthStatus(profile.slug),
52
+ })),
53
+ ]);
54
+ return reply.send({ data: { profiles: data } });
55
+ });
56
+ // GET /claude-profiles/:slug/status - ログイン状態(ログイン用ダイアログで完了を確認する)
57
+ fastify.get('/claude-profiles/:slug/status', async (request, reply) => {
58
+ const { slug } = request.params;
59
+ if (!(await claudeProfileExists(slug))) {
60
+ return reply.status(404).send({ error: 'Profile not found' });
61
+ }
62
+ return reply.send({ data: { status: await getClaudeAuthStatus(slug) } });
63
+ });
64
+ // POST /claude-profiles - プロファイル作成
65
+ fastify.post('/claude-profiles', async (request, reply) => {
66
+ try {
67
+ const profile = await createClaudeProfile(request.body?.name ?? '');
68
+ return reply.status(201).send({ data: { profile } });
69
+ }
70
+ catch (err) {
71
+ return reply.status(400).send({ error: err instanceof Error ? err.message : String(err) });
72
+ }
73
+ });
74
+ // DELETE /claude-profiles/:slug - プロファイル削除(logout してディレクトリごと消す)
75
+ fastify.delete('/claude-profiles/:slug', async (request, reply) => {
76
+ const { slug } = request.params;
77
+ if (slug === DEFAULT_CLAUDE_PROFILE || !(await claudeProfileExists(slug))) {
78
+ return reply.status(404).send({ error: 'Profile not found' });
79
+ }
80
+ await deleteClaudeProfile(slug);
81
+ syncPtyEnv(fastify);
82
+ return reply.status(204).send();
83
+ });
84
+ // POST /claude-profiles/:slug/login - ログイン用 PTY を起動し `claude auth login` を実行する
85
+ fastify.post('/claude-profiles/:slug/login', async (request, reply) => {
86
+ const { slug } = request.params;
87
+ if (!(await claudeProfileExists(slug))) {
88
+ return reply.status(404).send({ error: 'Profile not found' });
89
+ }
90
+ // 同じプロファイルのログイン用 PTY が残っていれば閉じる
91
+ const prefix = `${LOGIN_SESSION_PREFIX}${slug}-`;
92
+ for (const id of ptyManager.listSessions()) {
93
+ if (id.startsWith(prefix))
94
+ ptyManager.deleteSession(id);
95
+ }
96
+ const sessionId = `${prefix}${crypto.randomUUID()}`;
97
+ const { env, unsetKeys } = claudeLoginPtyEnv(slug);
98
+ const session = ptyManager.createSession(sessionId, os.homedir(), env, unsetKeys);
99
+ // シェルの初期化(プロンプト表示)を待つため少し遅延させて書き込む
100
+ setTimeout(() => session.pty.write(`${buildClaudeLoginCommand()}\n`), 300);
101
+ return reply.status(201).send({ data: { sessionId } });
102
+ });
103
+ // DELETE /claude-profiles/login/:sessionId - ログイン用 PTY を閉じる
104
+ fastify.delete('/claude-profiles/login/:sessionId', async (request, reply) => {
105
+ const { sessionId } = request.params;
106
+ if (!sessionId.startsWith(LOGIN_SESSION_PREFIX)) {
107
+ return reply.status(400).send({ error: 'Not a login session' });
108
+ }
109
+ ptyManager.deleteSession(sessionId);
110
+ return reply.status(204).send();
111
+ });
112
+ // GET /claude-profiles/assignment - スコープのプロファイル割り当て
113
+ fastify.get('/claude-profiles/assignment', async (request, reply) => {
114
+ const { scope, owner, repo } = request.query;
115
+ const error = validateScope(scope, owner, repo);
116
+ if (error)
117
+ return reply.status(400).send({ error });
118
+ return reply.send({ data: { assignment: await getAssignment(scope, owner, repo) } });
119
+ });
120
+ // PUT /claude-profiles/assignment - スコープのプロファイル割り当てを変更する
121
+ fastify.put('/claude-profiles/assignment', async (request, reply) => {
122
+ const { scope, owner, repo, slug } = request.body ?? {};
123
+ const error = validateScope(scope, owner, repo);
124
+ if (error)
125
+ return reply.status(400).send({ error });
126
+ if (slug !== null && !(await claudeProfileExists(slug))) {
127
+ return reply.status(400).send({ error: 'Profile not found' });
128
+ }
129
+ if (slug === null) {
130
+ await deleteEnv(CLAUDE_PROFILE_ENV_KEY, scope, owner, repo);
131
+ }
132
+ else {
133
+ await setEnv(CLAUDE_PROFILE_ENV_KEY, slug, scope, owner, repo);
134
+ }
135
+ syncPtyEnv(fastify);
136
+ return reply.send({ data: { assignment: await getAssignment(scope, owner, repo) } });
137
+ });
138
+ }
@@ -1,4 +1,11 @@
1
1
  import * as envRepo from '../lib/repositories/environment.js';
2
+ import { RESERVED_ENV_KEYS } from '../lib/claude-profiles.js';
3
+ import { notifyEnvChanged } from '../lib/pty-env-sync.js';
4
+ const RESERVED_KEY_ERROR = 'Claude の認証は Settings の Claude Profile で設定してください';
5
+ /** 変更後、起動中のターミナルに反映する(失敗しても API 自体は成功扱い) */
6
+ function syncPtyEnv(fastify) {
7
+ notifyEnvChanged().catch((err) => fastify.log.warn(err, 'Failed to sync PTY env'));
8
+ }
2
9
  export async function envRoutes(fastify) {
3
10
  // GET /env
4
11
  fastify.get('/env', async (request, reply) => {
@@ -30,6 +37,9 @@ export async function envRoutes(fastify) {
30
37
  if (!key || !value || !scope) {
31
38
  return reply.status(400).send({ error: 'Missing required fields: key, value, scope' });
32
39
  }
40
+ if (RESERVED_ENV_KEYS.includes(key)) {
41
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
42
+ }
33
43
  if (scope === 'owner' && !owner) {
34
44
  return reply.status(400).send({ error: 'Missing required field: owner for scope=owner' });
35
45
  }
@@ -39,6 +49,7 @@ export async function envRoutes(fastify) {
39
49
  });
40
50
  }
41
51
  await envRepo.setEnv(key, value, scope, owner, repo);
52
+ syncPtyEnv(fastify);
42
53
  return reply.status(201).send({ data: { success: true } });
43
54
  }
44
55
  catch (error) {
@@ -53,6 +64,9 @@ export async function envRoutes(fastify) {
53
64
  if (!key || !value || !scope) {
54
65
  return reply.status(400).send({ error: 'Missing required fields: key, value, scope' });
55
66
  }
67
+ if (RESERVED_ENV_KEYS.includes(key)) {
68
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
69
+ }
56
70
  if (scope === 'owner' && !owner) {
57
71
  return reply.status(400).send({ error: 'Missing required field: owner for scope=owner' });
58
72
  }
@@ -62,6 +76,7 @@ export async function envRoutes(fastify) {
62
76
  });
63
77
  }
64
78
  await envRepo.setEnv(key, value, scope, owner, repo);
79
+ syncPtyEnv(fastify);
65
80
  return reply.status(200).send({ data: { success: true } });
66
81
  }
67
82
  catch (error) {
@@ -80,6 +95,9 @@ export async function envRoutes(fastify) {
80
95
  error: 'Missing required query parameters: key, scope',
81
96
  });
82
97
  }
98
+ if (RESERVED_ENV_KEYS.includes(key)) {
99
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
100
+ }
83
101
  if (scope === 'owner' && !owner) {
84
102
  return reply.status(400).send({
85
103
  error: 'Missing required query parameter: owner for scope=owner',
@@ -94,6 +112,7 @@ export async function envRoutes(fastify) {
94
112
  if (!success) {
95
113
  return reply.status(404).send({ error: 'Environment variable not found' });
96
114
  }
115
+ syncPtyEnv(fastify);
97
116
  return reply.status(200).send({ data: { success: true } });
98
117
  }
99
118
  catch (error) {
@@ -116,7 +135,8 @@ export async function envRoutes(fastify) {
116
135
  error: 'Missing required query parameters: owner, repo for scope=repo',
117
136
  });
118
137
  }
119
- const envVars = await envRepo.getAllEnv(scope, owner || undefined, repo || undefined);
138
+ // 予約キー(Claude プロファイルの割り当て)は専用 UI で扱うため一覧に含めない
139
+ const envVars = (await envRepo.getAllEnv(scope, owner || undefined, repo || undefined)).filter((env) => !RESERVED_ENV_KEYS.includes(env.key));
120
140
  return reply.status(200).send({ data: { envVars } });
121
141
  }
122
142
  catch (error) {
@@ -131,7 +151,11 @@ export async function envRoutes(fastify) {
131
151
  if (!key || !scope || typeof enabled !== 'boolean') {
132
152
  return reply.status(400).send({ error: 'key, scope, and enabled are required' });
133
153
  }
154
+ if (RESERVED_ENV_KEYS.includes(key)) {
155
+ return reply.status(400).send({ error: RESERVED_KEY_ERROR });
156
+ }
134
157
  await envRepo.toggleEnv(key, scope, enabled, owner, repo);
158
+ syncPtyEnv(fastify);
135
159
  return reply.status(200).send({ data: { success: true } });
136
160
  }
137
161
  catch (error) {
@@ -1,15 +1,24 @@
1
- import { getEnv } from '../lib/repositories/environment.js';
2
1
  import { isLocalLlmReady } from '../lib/local-llm.js';
2
+ import { DEFAULT_CLAUDE_PROFILE, getClaudeAuthStatus, listClaudeProfiles, } from '../lib/claude-profiles.js';
3
+ /** システム既定かいずれかの Claude プロファイルがログイン済みか */
4
+ async function isClaudeLoggedIn() {
5
+ if ((await getClaudeAuthStatus(DEFAULT_CLAUDE_PROFILE)).loggedIn)
6
+ return true;
7
+ const profiles = await listClaudeProfiles();
8
+ const statuses = await Promise.all(profiles.map((p) => getClaudeAuthStatus(p.slug)));
9
+ return statuses.some((status) => status.loggedIn);
10
+ }
3
11
  export async function onboardingRoutes(fastify) {
4
12
  // GET /onboarding/status
5
13
  fastify.get('/onboarding/status', async (_request, reply) => {
6
14
  try {
7
- const globalEnv = await getEnv('global');
8
- const hasGlobalToken = Boolean(globalEnv.ANTHROPIC_API_KEY || globalEnv.CLAUDE_CODE_OAUTH_TOKEN);
9
- // 実験的機能のローカルLLM(Ollama / LM Studio)を設定済みなら、Anthropic のトークンがなくても claude を起動できる
10
- const localLlmReady = await isLocalLlmReady();
15
+ const [claudeLoggedIn, localLlmReady] = await Promise.all([
16
+ isClaudeLoggedIn(),
17
+ // 実験的機能のローカルLLM(Ollama / LM Studio)を設定済みなら、Claude にログインしていなくても claude を起動できる
18
+ isLocalLlmReady(),
19
+ ]);
11
20
  return reply.status(200).send({
12
- data: { completed: hasGlobalToken || localLlmReady, hasGlobalToken, localLlmReady },
21
+ data: { completed: claudeLoggedIn || localLlmReady, claudeLoggedIn, localLlmReady },
13
22
  });
14
23
  }
15
24
  catch (error) {
@@ -1,23 +1,9 @@
1
- import * as fs from 'fs/promises';
2
- import * as os from 'os';
3
- import * as path from 'path';
4
- import { fileURLToPath } from 'url';
5
1
  import { ptyManager } from '../pty-manager.js';
6
2
  import { prisma } from '../lib/db.js';
7
- import { getWorktreePath } from '../lib/worktree-manager.js';
8
- import { getEnv } from '../lib/repositories/environment.js';
9
- import { ensureClaudeOnboardingCompleted, removeLocalModelFromUserSettings, } from '../lib/claude-config-guard.js';
10
- import { LOCAL_LLM_UNSET_ENV_KEYS, LOCAL_MODEL_ALIAS, buildLocalLlmEnv, } from '../lib/local-llm-env.js';
11
- import { ensureActiveLoaded, getLocalLlmSettings } from '../lib/local-llm.js';
12
- import { LOCAL_LLM_PROXY_URL, buildClaudeCommand, isLocalLlmMode } from '../lib/claude-command.js';
13
- // このファイル基準で解決する(cwd 非依存):
14
- // dev: apps/server/src/routes → apps/server/scripts
15
- // build: apps/server/dist/routes → apps/server/scripts
16
- // npm bundle: <pkg>/dist/server/routes → <pkg>/dist/scripts
17
- const TSUNAGI_EDITOR_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../scripts/monaco-editor.sh');
18
- // Fastify(API) の公開ポート。index.ts と同じ既定値・同じ環境変数(TSUNAGI_SERVER_PORT)を見る。
19
- // monaco-editor.sh など PTY 内のプロセスが API を叩く際の同一ホスト向けベース URL に使う。
20
- const SERVER_PORT = Number(process.env.TSUNAGI_SERVER_PORT) || 2791;
3
+ import { ensureActiveLoaded } from '../lib/local-llm.js';
4
+ import { buildClaudeCommand, isLocalLlmMode } from '../lib/claude-command.js';
5
+ import { PtyLaunchError, prepareClaudeConfigForSession, resolvePtyLaunch, startPtySession, } from '../lib/pty-launch.js';
6
+ import { getEnvPending, initPtyEnvSync, respawnSessionNow } from '../lib/pty-env-sync.js';
21
7
  /**
22
8
  * 再接続時の画面復元用 scrollback から「端末への問い合わせ(クエリ)」シーケンスを除去する。
23
9
  *
@@ -41,24 +27,9 @@ function stripTerminalQueries(s) {
41
27
  .replace(/\x1b\[[?>]?[0-9;]*n/g, '') // DSR: ESC[?6n(主犯) / ESC[6n / ESC[5n など
42
28
  .replace(/\x1b\[[?>=]?[0-9;]*c/g, ''); // DA: ESC[c / ESC[0c / ESC[>c / ESC[=c
43
29
  }
44
- /**
45
- * ローカルLLMタブで注入する環境変数を返す。Claude Code は tsunagi の中継口に接続し、
46
- * 中継口が使用中のモデルへ転送する。最初の発言を待たせないよう、読み込みはここで始めておく(完了は待たない)
47
- */
48
- async function prepareLocalLlm() {
49
- const { active, extraEnv } = await getLocalLlmSettings();
50
- if (!active) {
51
- throw new Error('ローカルLLMのモデルが未設定です。Settings で使用するモデルを選んでください');
52
- }
53
- ensureActiveLoaded().catch(() => undefined);
54
- return buildLocalLlmEnv({
55
- proxyUrl: LOCAL_LLM_PROXY_URL,
56
- contextTokens: active.contextTokens,
57
- extraEnv,
58
- });
59
- }
60
30
  export async function terminalRoutes(fastify) {
61
31
  const io = fastify.io;
32
+ initPtyEnvSync(io);
62
33
  // socket.io イベントハンドラ
63
34
  io.on('connection', (socket) => {
64
35
  let boundSessionId = null;
@@ -97,6 +68,8 @@ export async function terminalRoutes(fastify) {
97
68
  socket.removeAllListeners('resize');
98
69
  socket.removeAllListeners('input');
99
70
  socket.join(room);
71
+ // 環境変数の変更が未反映ならクライアントに伝える(反映待ちバッジ)
72
+ socket.emit('env-pending', { sessionId, pending: getEnvPending(sessionId) });
100
73
  // 接続確立 → GCタイマーをキャンセル
101
74
  ptyManager.cancelGc(sessionId);
102
75
  // この socket を PTY の所有者(active socket)にする。input の書き込みは所有者の
@@ -133,6 +106,10 @@ export async function terminalRoutes(fastify) {
133
106
  dataHandlerDispose = () => dataHandler.dispose();
134
107
  // PTYプロセス終了 → room全体に通知(全接続クライアントに終了を伝える)+ セッション削除
135
108
  const exitHandler = ptyProcess.onExit(({ exitCode }) => {
109
+ // 環境変数の反映のための作り直し(pty-env-sync)。新しい PTY が同じ sessionId で
110
+ // 既に動いているため、終了扱い・セッション削除はしない
111
+ if (session.respawning)
112
+ return;
136
113
  io.to(room).emit('exit', { exitCode });
137
114
  // Ctrl+C等でClaudeが強制終了した場合にclaudeStatusをidleにリセット
138
115
  // todos は残す(Claude 側の Task はセッションに永続化され、--resume 後も続きから使われる)
@@ -171,6 +148,7 @@ export async function terminalRoutes(fastify) {
171
148
  return;
172
149
  if (ptyManager.getActiveSocket(sessionId) !== socket.id)
173
150
  return;
151
+ session.lastInputAt = Date.now();
174
152
  ptyProcess.write(data);
175
153
  });
176
154
  });
@@ -204,8 +182,7 @@ export async function terminalRoutes(fastify) {
204
182
  });
205
183
  // POST /terminal/sessions - セッション作成(または既存セッション再利用)
206
184
  fastify.post('/terminal/sessions', async (request, reply) => {
207
- const { cwd, env, sessionId: requestedSessionId, claude } = request.body ?? {};
208
- let { command } = request.body ?? {};
185
+ const { cwd, env, sessionId: requestedSessionId, claude, command } = request.body ?? {};
209
186
  const sessionId = requestedSessionId ?? crypto.randomUUID();
210
187
  // 既存セッションが生きていれば再利用
211
188
  const existing = ptyManager.getSession(sessionId);
@@ -213,95 +190,22 @@ export async function terminalRoutes(fastify) {
213
190
  fastify.log.info({ sessionId }, 'Reusing existing PTY session');
214
191
  return reply.status(200).send({ sessionId, reused: true });
215
192
  }
216
- // sessionId(=tab_id) から Task を解決する。cwd / env の決定に使う。
217
- // Taskに紐づかないタブは Tab レコードが存在しないため null になる。
218
- const tab = await prisma.tab
219
- .findUnique({ where: { tabId: sessionId }, include: { task: true } })
220
- .catch((err) => {
221
- fastify.log.warn({ err, sessionId }, 'Failed to load tab');
222
- return null;
223
- });
224
- // cwd は Task があればサーバー側で worktree パスを導出する(クライアントの state に依存しない)。
225
- // クライアント指定の cwd は Task に紐づかないタブのフォールバックとしてのみ使う。
226
- const defaultDir = path.join(os.homedir(), '.tsunagi', 'workspaces');
227
- let workingDir = tab
228
- ? getWorktreePath(tab.task.owner, tab.task.repo, tab.task.branch)
229
- : (cwd ?? defaultDir);
230
- // cwd が存在するか確認。Task のタブは worktree 必須(無ければ起動しない)、
231
- // それ以外はデフォルトにフォールバック
193
+ const launchRequest = { cwd, env };
232
194
  try {
233
- await fs.access(workingDir);
234
- }
235
- catch {
236
- if (tab) {
237
- fastify.log.warn({ sessionId, workingDir }, 'worktree does not exist');
238
- return reply.status(409).send({ error: `Worktree not found at ${workingDir}` });
239
- }
240
- fastify.log.warn({ sessionId, workingDir }, 'cwd does not exist, falling back to default');
241
- workingDir = defaultDir;
242
- }
243
- // デフォルトディレクトリが存在しない場合も作成する
244
- await fs.mkdir(workingDir, { recursive: true });
245
- try {
246
- // repo スコープまで階層マージした環境変数を取得する(global → owner → repo、後勝ちで上書き)。
247
- // Taskに紐づかないタブは global のみになる。
248
- let dbEnv = {};
249
- const tabMode = tab?.mode;
250
- try {
251
- dbEnv = tab ? await getEnv('repo', tab.task.owner, tab.task.repo) : await getEnv('global');
252
- fastify.log.info({ count: Object.keys(dbEnv).length, owner: tab?.task.owner, repo: tab?.task.repo }, 'Loaded env vars');
253
- }
254
- catch (err) {
255
- fastify.log.warn({ err }, 'Failed to load env vars');
256
- }
257
- // ローカルLLMタブ: Anthropic の代わりに tsunagi の中継口(→ Ollama / LM Studio)で claude を動かす。
258
- // DB の環境変数より後に適用し、Anthropic の認証トークンは環境変数ごと取り除く。
259
- let providerEnv = {};
260
- let unsetKeys = [];
261
- if (isLocalLlmMode(tabMode)) {
262
- try {
263
- providerEnv = await prepareLocalLlm();
264
- }
265
- catch (err) {
266
- return reply
267
- .status(400)
268
- .send({ error: err instanceof Error ? err.message : String(err) });
269
- }
270
- unsetKeys = LOCAL_LLM_UNSET_ENV_KEYS;
271
- }
272
- if (claude) {
273
- command = await buildClaudeCommand(sessionId, (tabMode ?? 'claude'));
274
- }
275
- // 優先順位: リクエストで渡されたenv > ローカルLLMのprovider env > DB env(global/owner/repo) > tsunagi独自のEDITOR設定
276
- // tsunagi-editor.sh をデフォルトにすることで Ctrl+G が Monaco Modal を開く。
277
- // DB / リクエストで EDITOR が明示設定されている場合はそちらが優先される。
278
- const tsunagiDefaultEnv = {
279
- EDITOR: TSUNAGI_EDITOR_PATH,
280
- TSUNAGI_SESSION_ID: sessionId,
281
- // monaco-editor.sh が API(Fastify) を叩くためのベース URL。サーバーと同一
282
- // ホストなので localhost。単一ポート化で API は SERVER_PORT(既定 2791)。
283
- TSUNAGI_API_BASE: `http://localhost:${SERVER_PORT}`,
284
- };
285
- const mergedEnv = { ...tsunagiDefaultEnv, ...dbEnv, ...providerEnv, ...env };
286
- const session = ptyManager.createSession(sessionId, workingDir, mergedEnv, unsetKeys);
287
- // コマンドが指定されていればPTY起動後にシェルへ書き込む
288
- if (command) {
289
- const cmd = command.endsWith('\n') ? command : command + '\n';
290
- // claude logout で ~/.claude.json の hasCompletedOnboarding がリセットされて
291
- // いると、次回 claude 起動時にオンボーディングウィザードが表示され --resume/
292
- // --session-id を前提にした自動起動フローが止まるため、起動前に補正する。
293
- await ensureClaudeOnboardingCompleted();
294
- // 監視の取りこぼしに備え、起動前にも中継口の名前が既定モデルに残っていないか補正する
295
- await removeLocalModelFromUserSettings(LOCAL_MODEL_ALIAS);
296
- // シェルの初期化(プロンプト表示)を待つため少し遅延させて書き込む
297
- setTimeout(() => {
298
- session.pty.write(cmd);
299
- }, 300);
300
- }
195
+ const launch = await resolvePtyLaunch(sessionId, launchRequest);
196
+ await startPtySession(sessionId, launch, {
197
+ request: launchRequest,
198
+ launchClaude: Boolean(claude),
199
+ command,
200
+ });
301
201
  return reply.status(201).send({ sessionId, reused: false });
302
202
  }
303
203
  catch (err) {
304
204
  const message = err instanceof Error ? err.message : String(err);
205
+ if (err instanceof PtyLaunchError) {
206
+ fastify.log.warn({ sessionId, err: message }, 'Failed to launch PTY');
207
+ return reply.status(err.statusCode).send({ error: message });
208
+ }
305
209
  return reply.status(500).send({ error: message });
306
210
  }
307
211
  });
@@ -326,9 +230,19 @@ export async function terminalRoutes(fastify) {
326
230
  catch (err) {
327
231
  return reply.status(400).send({ error: err instanceof Error ? err.message : String(err) });
328
232
  }
233
+ // 環境変数の反映で PTY を作り直したときも claude を起動し直す
234
+ session.options.launchClaude = true;
235
+ await prepareClaudeConfigForSession(session);
329
236
  session.pty.write(`${command}\n`);
330
237
  return reply.status(204).send();
331
238
  });
239
+ // POST /terminal/sessions/:sessionId/respawn - 環境変数の変更を今すぐ反映する(PTY を作り直す)
240
+ fastify.post('/terminal/sessions/:sessionId/respawn', async (request, reply) => {
241
+ const ok = await respawnSessionNow(request.params.sessionId);
242
+ if (!ok)
243
+ return reply.status(404).send({ error: 'Session not found' });
244
+ return reply.status(204).send();
245
+ });
332
246
  // DELETE /terminal/sessions/:sessionId - セッション明示削除(PTY kill)
333
247
  fastify.delete('/terminal/sessions/:sessionId', async (request, reply) => {
334
248
  const { sessionId } = request.params;
@@ -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 SIGINT / SIGTERM / exit
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('SIGINT', () => {
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
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * entry が古く、再インストールで更新してほしいか。
3
+ * 古い entry でも取り決めの範囲内で自動更新は続ける(versions.ts 参照)。
4
+ */
5
+ export declare function isEntryOutdated(): boolean;
6
+ /** entry が npm でインストールされたパッケージから起動しており、自動更新を使うか */
7
+ export declare function isAutoUpdateEnabled(): boolean;
8
+ /**
9
+ * 前回正常に起動した版と違う版(初めて起動する版)なら、マイグレーション前に DB を退避する。
10
+ * 起動に失敗すると entry がこの退避を戻して前の版で起動し直す。
11
+ */
12
+ export declare function snapshotDbBeforeFirstStart(version: string): void;
13
+ /** 前の版が使っていたポートが空くまで待つ(上限付き) */
14
+ export declare function waitForPortsFree(ports: number[], timeoutMs?: number): Promise<void>;
15
+ /**
16
+ * 起動完了後、実行中より古いバージョンを消す(インストール済み・途中とも)。
17
+ * 実行中より新しいものはインストール中・再起動待ちの可能性があるため残す。
18
+ */
19
+ export declare function removeVersionsOlderThan(version: string): void;
@@ -0,0 +1,93 @@
1
+ import * as fs from 'node:fs';
2
+ import * as net from 'node:net';
3
+ import * as path from 'node:path';
4
+ import { ENTRY_PROTOCOL, compareVersions, getTsunagiDataDir, getVersionsDir, parseVersion, updateEntryState, } from './versions.js';
5
+ /**
6
+ * App-side part of the auto-update: everything the entry (cli.ts) leaves to
7
+ * the app, so that it can change with each version.
8
+ */
9
+ /** 初めて起動するバージョンが書き換える可能性のあるファイル(データディレクトリからの相対パス) */
10
+ const DB_FILES = ['state/tsunagi.db', 'state/tsunagi.db-wal', 'state/tsunagi.db-shm'];
11
+ const SNAPSHOT_PREFIX = 'pre-update-';
12
+ const SNAPSHOT_RETENTION = 3;
13
+ /** entry から起動されたか。dev 起動(with-plugin.ts)や直接実行では null */
14
+ function getEntryProtocol() {
15
+ const value = Number(process.env.TSUNAGI_ENTRY_PROTOCOL);
16
+ return Number.isInteger(value) && value > 0 ? value : null;
17
+ }
18
+ /**
19
+ * entry が古く、再インストールで更新してほしいか。
20
+ * 古い entry でも取り決めの範囲内で自動更新は続ける(versions.ts 参照)。
21
+ */
22
+ export function isEntryOutdated() {
23
+ const protocol = getEntryProtocol();
24
+ return protocol !== null && protocol < ENTRY_PROTOCOL;
25
+ }
26
+ /** entry が npm でインストールされたパッケージから起動しており、自動更新を使うか */
27
+ export function isAutoUpdateEnabled() {
28
+ return getEntryProtocol() !== null && process.env.TSUNAGI_AUTO_UPDATE === '1';
29
+ }
30
+ /**
31
+ * 前回正常に起動した版と違う版(初めて起動する版)なら、マイグレーション前に DB を退避する。
32
+ * 起動に失敗すると entry がこの退避を戻して前の版で起動し直す。
33
+ */
34
+ export function snapshotDbBeforeFirstStart(version) {
35
+ const lastGood = process.env.TSUNAGI_LAST_GOOD;
36
+ if (!lastGood || lastGood === version || getEntryProtocol() === null)
37
+ return;
38
+ const dataDir = getTsunagiDataDir();
39
+ const backupsDir = path.join(dataDir, 'backups');
40
+ const dir = path.join(backupsDir, `${SNAPSHOT_PREFIX}${Date.now()}-v${version}`);
41
+ const files = {};
42
+ for (const file of DB_FILES) {
43
+ const src = path.join(dataDir, file);
44
+ files[file] = fs.existsSync(src);
45
+ if (!files[file])
46
+ continue;
47
+ fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
48
+ fs.copyFileSync(src, path.join(dir, file));
49
+ }
50
+ updateEntryState((state) => ({ ...state, snapshot: { version, dir, files } }));
51
+ const snapshots = fs
52
+ .readdirSync(backupsDir)
53
+ .filter((name) => name.startsWith(SNAPSHOT_PREFIX))
54
+ .sort();
55
+ while (snapshots.length > SNAPSHOT_RETENTION) {
56
+ const oldest = snapshots.shift();
57
+ if (oldest)
58
+ fs.rmSync(path.join(backupsDir, oldest), { recursive: true, force: true });
59
+ }
60
+ }
61
+ /** 前の版が使っていたポートが空くまで待つ(上限付き) */
62
+ export async function waitForPortsFree(ports, timeoutMs = 10_000) {
63
+ const isFree = (port) => new Promise((resolve) => {
64
+ const server = net.createServer();
65
+ server.once('error', () => resolve(false));
66
+ server.listen(port, '0.0.0.0', () => server.close(() => resolve(true)));
67
+ });
68
+ const deadline = Date.now() + timeoutMs;
69
+ while (Date.now() < deadline) {
70
+ const results = await Promise.all(ports.map(isFree));
71
+ if (results.every(Boolean))
72
+ return;
73
+ await new Promise((resolve) => setTimeout(resolve, 200));
74
+ }
75
+ }
76
+ /**
77
+ * 起動完了後、実行中より古いバージョンを消す(インストール済み・途中とも)。
78
+ * 実行中より新しいものはインストール中・再起動待ちの可能性があるため残す。
79
+ */
80
+ export function removeVersionsOlderThan(version) {
81
+ let entries;
82
+ try {
83
+ entries = fs.readdirSync(getVersionsDir());
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ for (const name of entries) {
89
+ if (!parseVersion(name) || compareVersions(name, version) >= 0)
90
+ continue;
91
+ fs.rmSync(path.join(getVersionsDir(), name), { recursive: true, force: true });
92
+ }
93
+ }