throughline 0.3.22 → 0.3.24
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/README.ja.md +255 -0
- package/README.md +138 -16
- package/bin/throughline.mjs +0 -0
- package/package.json +1 -1
- package/src/cli/install.mjs +53 -1
- package/src/cli/install.test.mjs +43 -1
- package/src/turn-processor.mjs +304 -304
- package/src/vscode-task.mjs +160 -22
- package/src/vscode-task.test.mjs +403 -2
package/src/vscode-task.mjs
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { join, dirname } from 'node:path';
|
|
18
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
19
19
|
|
|
20
20
|
const MONITOR_LABEL = 'Throughline Monitor';
|
|
21
21
|
const JSONC_MARKER_FILENAME = '.throughline-jsonc-noted';
|
|
22
|
+
const GITIGNORE_MARKER_FILENAME = '.throughline-gitignore-noted';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* VSCode 系エディタが動いているかを env から推定する。
|
|
@@ -81,22 +82,58 @@ export function detectIndent(text) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
|
-
* tasks
|
|
85
|
+
* tasks 配列内で Throughline Monitor 相当のタスクの index を返す。
|
|
85
86
|
* ユーザーが label をリネームしても command/args に "throughline" + "monitor" が
|
|
86
|
-
*
|
|
87
|
+
* あれば同一タスクとみなす。見つからない場合は -1。
|
|
87
88
|
*/
|
|
88
|
-
export function
|
|
89
|
+
export function findMonitorTaskIndex(obj) {
|
|
89
90
|
const tasks = obj?.tasks;
|
|
90
|
-
if (!Array.isArray(tasks)) return
|
|
91
|
-
for (
|
|
91
|
+
if (!Array.isArray(tasks)) return -1;
|
|
92
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
93
|
+
const t = tasks[i];
|
|
92
94
|
if (!t || typeof t !== 'object') continue;
|
|
93
|
-
if (t.label === MONITOR_LABEL) return
|
|
95
|
+
if (t.label === MONITOR_LABEL) return i;
|
|
94
96
|
const invocation = [t.command, ...(Array.isArray(t.args) ? t.args : [])]
|
|
95
97
|
.filter((s) => typeof s === 'string')
|
|
96
98
|
.join(' ')
|
|
97
99
|
.toLowerCase();
|
|
98
100
|
if (invocation.includes('throughline') && invocation.includes('monitor')) {
|
|
99
|
-
return
|
|
101
|
+
return i;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function hasMonitorTask(obj) {
|
|
108
|
+
return findMonitorTaskIndex(obj) >= 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 既存の Monitor タスクが「別環境(別 OS / 別 install 場所)の絶対パスを参照していて
|
|
113
|
+
* 現在の環境では起動できない」状態かを判定する。
|
|
114
|
+
*
|
|
115
|
+
* 判定基準:
|
|
116
|
+
* - command が絶対パスかつ existsSync(command) === false → 壊れている
|
|
117
|
+
* - args の中に絶対パスの .mjs / .js / .cjs があり、それが存在しない → 壊れている
|
|
118
|
+
*
|
|
119
|
+
* 相対パス("node" / "throughline" 等の PATH 解決前提)は判定対象外。
|
|
120
|
+
* ユーザーが手動で PATH 解決型に書き換えたケースを誤って上書きしないため。
|
|
121
|
+
*/
|
|
122
|
+
export function isMonitorTaskBroken(task) {
|
|
123
|
+
if (!task || typeof task !== 'object') return false;
|
|
124
|
+
if (typeof task.command === 'string' && isAbsolute(task.command) && !existsSync(task.command)) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (Array.isArray(task.args)) {
|
|
128
|
+
for (const a of task.args) {
|
|
129
|
+
if (
|
|
130
|
+
typeof a === 'string' &&
|
|
131
|
+
isAbsolute(a) &&
|
|
132
|
+
/\.(mjs|js|cjs)$/.test(a) &&
|
|
133
|
+
!existsSync(a)
|
|
134
|
+
) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
100
137
|
}
|
|
101
138
|
}
|
|
102
139
|
return false;
|
|
@@ -173,18 +210,33 @@ function atomicWrite(targetPath, contents) {
|
|
|
173
210
|
* セットアップ済みのため沈黙する(冪等性)。
|
|
174
211
|
*/
|
|
175
212
|
export function buildSetupNotice(action) {
|
|
176
|
-
if (action
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
213
|
+
if (action === 'created' || action === 'merged') {
|
|
214
|
+
return (
|
|
215
|
+
'<system-reminder>\n' +
|
|
216
|
+
'Throughline: このプロジェクトに token-monitor の自動起動設定を追加しました (.vscode/tasks.json)。\n' +
|
|
217
|
+
'今すぐ monitor を起動するには、VSCode のコマンドパレット (Ctrl+Shift+P) から\n' +
|
|
218
|
+
' Developer: Reload Window\n' +
|
|
219
|
+
'を実行してください。次回以降はフォルダを開いた瞬間に自動起動します。\n' +
|
|
220
|
+
'\n' +
|
|
221
|
+
'このお知らせは今このプロジェクトで初回セットアップが完了したためだけに 1 度だけ流れています。\n' +
|
|
222
|
+
'必ずユーザー(あなたの対話相手)に日本語でそのまま伝えてください。\n' +
|
|
223
|
+
'</system-reminder>\n'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (action === 'repaired') {
|
|
227
|
+
return (
|
|
228
|
+
'<system-reminder>\n' +
|
|
229
|
+
'Throughline: .vscode/tasks.json に登録されていた token-monitor タスクが\n' +
|
|
230
|
+
'別環境(別 OS / 別 install 場所)の絶対パスを参照していたため、現環境向けに自動修復しました。\n' +
|
|
231
|
+
'反映するには VSCode のコマンドパレット (Ctrl+Shift+P) から\n' +
|
|
232
|
+
' Developer: Reload Window\n' +
|
|
233
|
+
'を実行してください。\n' +
|
|
234
|
+
'\n' +
|
|
235
|
+
'必ずユーザー(あなたの対話相手)に日本語でそのまま伝えてください。\n' +
|
|
236
|
+
'</system-reminder>\n'
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
188
240
|
}
|
|
189
241
|
|
|
190
242
|
function emitSetupNotice(action) {
|
|
@@ -192,6 +244,67 @@ function emitSetupNotice(action) {
|
|
|
192
244
|
if (text) process.stdout.write(text);
|
|
193
245
|
}
|
|
194
246
|
|
|
247
|
+
/**
|
|
248
|
+
* `.vscode/tasks.json` が gitignore 推奨対象か判定する。
|
|
249
|
+
*
|
|
250
|
+
* 推奨条件:
|
|
251
|
+
* - cwd が git リポジトリ (`.git/` がある) かつ
|
|
252
|
+
* - `.gitignore` に `.vscode/tasks.json` 相当のエントリが無い
|
|
253
|
+
*
|
|
254
|
+
* 単独プロジェクト (.git なし) は判定対象外 (false を返す)。共有しないので
|
|
255
|
+
* gitignore の必要性そのものが無いため。
|
|
256
|
+
*
|
|
257
|
+
* @param {string} cwd
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
export function shouldRecommendGitignore(cwd) {
|
|
261
|
+
const gitDir = join(cwd, '.git');
|
|
262
|
+
if (!existsSync(gitDir)) return false;
|
|
263
|
+
|
|
264
|
+
const gitignorePath = join(cwd, '.gitignore');
|
|
265
|
+
if (!existsSync(gitignorePath)) return true;
|
|
266
|
+
|
|
267
|
+
const content = readFileSync(gitignorePath, 'utf8');
|
|
268
|
+
const lines = content.split(/\r?\n/);
|
|
269
|
+
for (const raw of lines) {
|
|
270
|
+
const line = raw.trim();
|
|
271
|
+
if (!line || line.startsWith('#')) continue;
|
|
272
|
+
// 否定 (!) は除外しない: "!.vscode/tasks.json" は明示的に追跡したい意図なので推奨を出すべき
|
|
273
|
+
if (line.startsWith('!')) continue;
|
|
274
|
+
// .vscode 全体除外 / tasks.json 単独除外 / .vscode/* どれかを含むか
|
|
275
|
+
if (line === '.vscode' || line === '.vscode/' || line === '/.vscode' || line === '/.vscode/') return false;
|
|
276
|
+
if (line === '.vscode/*' || line === '.vscode/**') return false;
|
|
277
|
+
if (line === '.vscode/tasks.json' || line === '/.vscode/tasks.json') return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function emitGitignoreRecommendationOnce(vscodeDir) {
|
|
283
|
+
const markerPath = join(vscodeDir, GITIGNORE_MARKER_FILENAME);
|
|
284
|
+
if (existsSync(markerPath)) return;
|
|
285
|
+
process.stdout.write(
|
|
286
|
+
'<system-reminder>\n' +
|
|
287
|
+
'Throughline ヒント: .vscode/tasks.json には現環境の絶対パス (node 実行ファイル / throughline.mjs)\n' +
|
|
288
|
+
'が書き込まれます。共有リポジトリで commit すると別 OS / 別 install 場所のメンバーで起動失敗の\n' +
|
|
289
|
+
'原因になるため、.gitignore に以下のいずれかを追加することを推奨します:\n' +
|
|
290
|
+
'\n' +
|
|
291
|
+
' .vscode/tasks.json # tasks.json だけ除外 (settings.json 等は共有可)\n' +
|
|
292
|
+
' .vscode/ # .vscode 配下を全部除外\n' +
|
|
293
|
+
'\n' +
|
|
294
|
+
'なお Throughline v0.3.23+ は別環境の絶対パスが焼き込まれた tasks.json を\n' +
|
|
295
|
+
'自動修復するため、commit してしまっても致命的ではありません。あくまで推奨です。\n' +
|
|
296
|
+
'\n' +
|
|
297
|
+
'この通知はプロジェクトごとに 1 度だけ流れます。必ずユーザー(あなたの対話相手)に日本語でそのまま伝えてください。\n' +
|
|
298
|
+
'</system-reminder>\n'
|
|
299
|
+
);
|
|
300
|
+
try {
|
|
301
|
+
writeFileSync(markerPath, `${new Date().toISOString()}\n`);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
304
|
+
process.stderr.write(`[throughline] failed to write gitignore marker: ${msg}\n`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
195
308
|
function emitJsoncGuidanceOnce(vscodeDir) {
|
|
196
309
|
const markerPath = join(vscodeDir, JSONC_MARKER_FILENAME);
|
|
197
310
|
if (existsSync(markerPath)) return;
|
|
@@ -211,9 +324,13 @@ function emitJsoncGuidanceOnce(vscodeDir) {
|
|
|
211
324
|
/**
|
|
212
325
|
* VSCode の tasks.json に Throughline Monitor タスクを(必要なら)自動登録する。
|
|
213
326
|
*
|
|
327
|
+
* 既存タスクが別環境の絶対パスを参照していて現環境で起動できない場合は
|
|
328
|
+
* command/args だけを差し替えて修復する(label / presentation 等のユーザー
|
|
329
|
+
* カスタマイズは保持)。
|
|
330
|
+
*
|
|
214
331
|
* @param {{cwd?: string, env?: NodeJS.ProcessEnv, throughlineBin?: string | null}} opts
|
|
215
332
|
* @returns {{
|
|
216
|
-
* action: 'created' | 'merged' | 'already_present' | 'skipped',
|
|
333
|
+
* action: 'created' | 'merged' | 'repaired' | 'already_present' | 'skipped',
|
|
217
334
|
* reason?: string,
|
|
218
335
|
* path?: string,
|
|
219
336
|
* }}
|
|
@@ -247,6 +364,7 @@ export function ensureMonitorTaskFile(opts = {}) {
|
|
|
247
364
|
const obj = { version: '2.0.0', tasks: [buildMonitorTask(bin)] };
|
|
248
365
|
atomicWrite(tasksPath, JSON.stringify(obj, null, 2) + '\n');
|
|
249
366
|
emitSetupNotice('created');
|
|
367
|
+
if (shouldRecommendGitignore(cwd)) emitGitignoreRecommendationOnce(vscodeDir);
|
|
250
368
|
return { action: 'created', path: tasksPath };
|
|
251
369
|
}
|
|
252
370
|
|
|
@@ -264,7 +382,26 @@ export function ensureMonitorTaskFile(opts = {}) {
|
|
|
264
382
|
return { action: 'skipped', reason: 'parse_error', path: tasksPath };
|
|
265
383
|
}
|
|
266
384
|
|
|
267
|
-
|
|
385
|
+
const existingIdx = findMonitorTaskIndex(obj);
|
|
386
|
+
if (existingIdx >= 0) {
|
|
387
|
+
const existing = obj.tasks[existingIdx];
|
|
388
|
+
if (isMonitorTaskBroken(existing)) {
|
|
389
|
+
const fresh = buildMonitorTask(bin);
|
|
390
|
+
const repaired = {
|
|
391
|
+
...existing,
|
|
392
|
+
type: fresh.type,
|
|
393
|
+
command: fresh.command,
|
|
394
|
+
args: fresh.args,
|
|
395
|
+
};
|
|
396
|
+
const indent = detectIndent(text);
|
|
397
|
+
const nextTasks = [...obj.tasks];
|
|
398
|
+
nextTasks[existingIdx] = repaired;
|
|
399
|
+
const nextObj = { ...obj, version: obj.version ?? '2.0.0', tasks: nextTasks };
|
|
400
|
+
atomicWrite(tasksPath, JSON.stringify(nextObj, null, indent) + '\n');
|
|
401
|
+
emitSetupNotice('repaired');
|
|
402
|
+
if (shouldRecommendGitignore(cwd)) emitGitignoreRecommendationOnce(vscodeDir);
|
|
403
|
+
return { action: 'repaired', path: tasksPath };
|
|
404
|
+
}
|
|
268
405
|
return { action: 'already_present', path: tasksPath };
|
|
269
406
|
}
|
|
270
407
|
|
|
@@ -276,5 +413,6 @@ export function ensureMonitorTaskFile(opts = {}) {
|
|
|
276
413
|
};
|
|
277
414
|
atomicWrite(tasksPath, JSON.stringify(nextObj, null, indent) + '\n');
|
|
278
415
|
emitSetupNotice('merged');
|
|
416
|
+
if (shouldRecommendGitignore(cwd)) emitGitignoreRecommendationOnce(vscodeDir);
|
|
279
417
|
return { action: 'merged', path: tasksPath };
|
|
280
418
|
}
|
package/src/vscode-task.test.mjs
CHANGED
|
@@ -9,12 +9,17 @@ import {
|
|
|
9
9
|
detectJsoncFeatures,
|
|
10
10
|
detectIndent,
|
|
11
11
|
hasMonitorTask,
|
|
12
|
+
findMonitorTaskIndex,
|
|
13
|
+
isMonitorTaskBroken,
|
|
12
14
|
buildMonitorTask,
|
|
13
15
|
buildSetupNotice,
|
|
16
|
+
shouldRecommendGitignore,
|
|
14
17
|
} from './vscode-task.mjs';
|
|
15
18
|
|
|
16
19
|
const VSCODE_ENV = { TERM_PROGRAM: 'vscode' };
|
|
17
|
-
|
|
20
|
+
// 実在する絶対パスを使う。`isMonitorTaskBroken` が「絶対パス + 非存在」で broken 判定するので、
|
|
21
|
+
// 架空パスを使うと意図せず repaired ブランチに落ちてしまう。
|
|
22
|
+
const FAKE_BIN = process.execPath;
|
|
18
23
|
|
|
19
24
|
function mkTmpCwd() {
|
|
20
25
|
const dir = mkdtempSync(join(tmpdir(), 'throughline-vscode-'));
|
|
@@ -137,6 +142,71 @@ test('hasMonitorTask: handles missing tasks array', () => {
|
|
|
137
142
|
assert.equal(hasMonitorTask({ tasks: null }), false);
|
|
138
143
|
});
|
|
139
144
|
|
|
145
|
+
// --- findMonitorTaskIndex ---
|
|
146
|
+
|
|
147
|
+
test('findMonitorTaskIndex: returns index when label matches', () => {
|
|
148
|
+
assert.equal(
|
|
149
|
+
findMonitorTaskIndex({ tasks: [{ label: 'Build' }, { label: 'Throughline Monitor' }] }),
|
|
150
|
+
1,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('findMonitorTaskIndex: returns -1 when no match', () => {
|
|
155
|
+
assert.equal(findMonitorTaskIndex({ tasks: [{ label: 'Build' }] }), -1);
|
|
156
|
+
assert.equal(findMonitorTaskIndex({}), -1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- isMonitorTaskBroken ---
|
|
160
|
+
|
|
161
|
+
test('isMonitorTaskBroken: false when command is an existing absolute path', () => {
|
|
162
|
+
assert.equal(
|
|
163
|
+
isMonitorTaskBroken({ command: process.execPath, args: ['monitor'] }),
|
|
164
|
+
false,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('isMonitorTaskBroken: true when command is a non-existent absolute path', () => {
|
|
169
|
+
assert.equal(
|
|
170
|
+
isMonitorTaskBroken({ command: '/definitely/does/not/exist/node', args: ['monitor'] }),
|
|
171
|
+
true,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('isMonitorTaskBroken: false when command is a relative name (PATH-resolved)', () => {
|
|
176
|
+
// ユーザーが手動で "node" / "throughline" に書き換えたケースは誤上書きしない
|
|
177
|
+
assert.equal(
|
|
178
|
+
isMonitorTaskBroken({ command: 'node', args: ['/x/throughline.mjs', 'monitor'] }),
|
|
179
|
+
true, // args 側の絶対パスが壊れているので true
|
|
180
|
+
);
|
|
181
|
+
assert.equal(
|
|
182
|
+
isMonitorTaskBroken({ command: 'throughline', args: ['monitor'] }),
|
|
183
|
+
false,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('isMonitorTaskBroken: true when args contains non-existent absolute .mjs path', () => {
|
|
188
|
+
assert.equal(
|
|
189
|
+
isMonitorTaskBroken({
|
|
190
|
+
command: process.execPath,
|
|
191
|
+
args: ['/no/such/file/throughline.mjs', 'monitor'],
|
|
192
|
+
}),
|
|
193
|
+
true,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('isMonitorTaskBroken: false when args has only relative strings', () => {
|
|
198
|
+
assert.equal(
|
|
199
|
+
isMonitorTaskBroken({ command: process.execPath, args: ['monitor'] }),
|
|
200
|
+
false,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('isMonitorTaskBroken: handles malformed task safely', () => {
|
|
205
|
+
assert.equal(isMonitorTaskBroken(null), false);
|
|
206
|
+
assert.equal(isMonitorTaskBroken({}), false);
|
|
207
|
+
assert.equal(isMonitorTaskBroken({ command: 42 }), false);
|
|
208
|
+
});
|
|
209
|
+
|
|
140
210
|
// --- buildMonitorTask ---
|
|
141
211
|
|
|
142
212
|
test('buildMonitorTask: uses type=shell with provided bin as args[0] for PTY allocation', () => {
|
|
@@ -367,7 +437,8 @@ test('ensureMonitorTaskFile: already_present when command references throughline
|
|
|
367
437
|
label: 'My Custom Monitor',
|
|
368
438
|
type: 'process',
|
|
369
439
|
command: '/usr/bin/node',
|
|
370
|
-
|
|
440
|
+
// 相対パスにして broken 判定を避ける(このテストは「label renamed でも検出できるか」だけが論点)
|
|
441
|
+
args: ['./throughline.mjs', 'monitor'],
|
|
371
442
|
},
|
|
372
443
|
],
|
|
373
444
|
};
|
|
@@ -411,6 +482,329 @@ test('ensureMonitorTaskFile: second call is idempotent (already_present after cr
|
|
|
411
482
|
}
|
|
412
483
|
});
|
|
413
484
|
|
|
485
|
+
// --- shouldRecommendGitignore ---
|
|
486
|
+
|
|
487
|
+
test('shouldRecommendGitignore: false when not a git repo (.git missing)', () => {
|
|
488
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
489
|
+
try {
|
|
490
|
+
assert.equal(shouldRecommendGitignore(dir), false);
|
|
491
|
+
} finally {
|
|
492
|
+
cleanup();
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('shouldRecommendGitignore: true when git repo has no .gitignore', () => {
|
|
497
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
498
|
+
try {
|
|
499
|
+
mkdirSync(join(dir, '.git'));
|
|
500
|
+
assert.equal(shouldRecommendGitignore(dir), true);
|
|
501
|
+
} finally {
|
|
502
|
+
cleanup();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('shouldRecommendGitignore: true when .gitignore does not list .vscode/tasks.json', () => {
|
|
507
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
508
|
+
try {
|
|
509
|
+
mkdirSync(join(dir, '.git'));
|
|
510
|
+
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n*.log\n');
|
|
511
|
+
assert.equal(shouldRecommendGitignore(dir), true);
|
|
512
|
+
} finally {
|
|
513
|
+
cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('shouldRecommendGitignore: false when .gitignore has .vscode/tasks.json', () => {
|
|
518
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
519
|
+
try {
|
|
520
|
+
mkdirSync(join(dir, '.git'));
|
|
521
|
+
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.vscode/tasks.json\n');
|
|
522
|
+
assert.equal(shouldRecommendGitignore(dir), false);
|
|
523
|
+
} finally {
|
|
524
|
+
cleanup();
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('shouldRecommendGitignore: false when .gitignore has .vscode/ (whole dir)', () => {
|
|
529
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
530
|
+
try {
|
|
531
|
+
mkdirSync(join(dir, '.git'));
|
|
532
|
+
writeFileSync(join(dir, '.gitignore'), '.vscode/\n');
|
|
533
|
+
assert.equal(shouldRecommendGitignore(dir), false);
|
|
534
|
+
} finally {
|
|
535
|
+
cleanup();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('shouldRecommendGitignore: false when .gitignore has .vscode (no slash)', () => {
|
|
540
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
541
|
+
try {
|
|
542
|
+
mkdirSync(join(dir, '.git'));
|
|
543
|
+
writeFileSync(join(dir, '.gitignore'), '.vscode\n');
|
|
544
|
+
assert.equal(shouldRecommendGitignore(dir), false);
|
|
545
|
+
} finally {
|
|
546
|
+
cleanup();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('shouldRecommendGitignore: ignores comments and negation lines', () => {
|
|
551
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
552
|
+
try {
|
|
553
|
+
mkdirSync(join(dir, '.git'));
|
|
554
|
+
// 否定パターンは「除外しない」意図なので、推奨は引き続き出す
|
|
555
|
+
writeFileSync(join(dir, '.gitignore'), '# comment\n!.vscode/tasks.json\n');
|
|
556
|
+
assert.equal(shouldRecommendGitignore(dir), true);
|
|
557
|
+
} finally {
|
|
558
|
+
cleanup();
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// --- ensureMonitorTaskFile: gitignore recommendation notice ---
|
|
563
|
+
|
|
564
|
+
test('ensureMonitorTaskFile: created emits gitignore recommendation when .git exists and no .gitignore entry', () => {
|
|
565
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
566
|
+
const captured = [];
|
|
567
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
568
|
+
process.stdout.write = (chunk) => {
|
|
569
|
+
captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
570
|
+
return true;
|
|
571
|
+
};
|
|
572
|
+
try {
|
|
573
|
+
mkdirSync(join(dir, '.git'));
|
|
574
|
+
const result = ensureMonitorTaskFile({
|
|
575
|
+
cwd: dir,
|
|
576
|
+
env: VSCODE_ENV,
|
|
577
|
+
throughlineBin: FAKE_BIN,
|
|
578
|
+
});
|
|
579
|
+
assert.equal(result.action, 'created');
|
|
580
|
+
} finally {
|
|
581
|
+
process.stdout.write = origWrite;
|
|
582
|
+
cleanup();
|
|
583
|
+
}
|
|
584
|
+
const joined = captured.join('');
|
|
585
|
+
assert.ok(joined.includes('.gitignore'), 'should emit gitignore recommendation');
|
|
586
|
+
assert.ok(joined.includes('Reload Window'), 'should still emit setup notice');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('ensureMonitorTaskFile: created does NOT emit gitignore recommendation when not a git repo', () => {
|
|
590
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
591
|
+
const captured = [];
|
|
592
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
593
|
+
process.stdout.write = (chunk) => {
|
|
594
|
+
captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
595
|
+
return true;
|
|
596
|
+
};
|
|
597
|
+
try {
|
|
598
|
+
const result = ensureMonitorTaskFile({
|
|
599
|
+
cwd: dir,
|
|
600
|
+
env: VSCODE_ENV,
|
|
601
|
+
throughlineBin: FAKE_BIN,
|
|
602
|
+
});
|
|
603
|
+
assert.equal(result.action, 'created');
|
|
604
|
+
} finally {
|
|
605
|
+
process.stdout.write = origWrite;
|
|
606
|
+
cleanup();
|
|
607
|
+
}
|
|
608
|
+
const joined = captured.join('');
|
|
609
|
+
assert.ok(!joined.includes('gitignore'), 'should not mention gitignore for non-git dirs');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('ensureMonitorTaskFile: gitignore recommendation is emitted only once per project', () => {
|
|
613
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
614
|
+
try {
|
|
615
|
+
mkdirSync(join(dir, '.git'));
|
|
616
|
+
|
|
617
|
+
const captured = [];
|
|
618
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
619
|
+
process.stdout.write = (chunk) => {
|
|
620
|
+
captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
621
|
+
return true;
|
|
622
|
+
};
|
|
623
|
+
try {
|
|
624
|
+
// 1 回目: created → gitignore 推奨が出る
|
|
625
|
+
const r1 = ensureMonitorTaskFile({
|
|
626
|
+
cwd: dir,
|
|
627
|
+
env: VSCODE_ENV,
|
|
628
|
+
throughlineBin: FAKE_BIN,
|
|
629
|
+
});
|
|
630
|
+
assert.equal(r1.action, 'created');
|
|
631
|
+
const firstCount = captured.filter((s) => s.includes('gitignore')).length;
|
|
632
|
+
assert.equal(firstCount, 1);
|
|
633
|
+
|
|
634
|
+
// tasks.json を一度消して再 created 状況を作る
|
|
635
|
+
// (実運用では already_present になるので現実的ではないが、marker の効きを見る)
|
|
636
|
+
const tasksPath = join(dir, '.vscode', 'tasks.json');
|
|
637
|
+
rmSync(tasksPath);
|
|
638
|
+
const r2 = ensureMonitorTaskFile({
|
|
639
|
+
cwd: dir,
|
|
640
|
+
env: VSCODE_ENV,
|
|
641
|
+
throughlineBin: FAKE_BIN,
|
|
642
|
+
});
|
|
643
|
+
assert.equal(r2.action, 'created');
|
|
644
|
+
const secondCount = captured.filter((s) => s.includes('gitignore')).length;
|
|
645
|
+
assert.equal(secondCount, 1, 'marker file should suppress 2nd recommendation');
|
|
646
|
+
} finally {
|
|
647
|
+
process.stdout.write = origWrite;
|
|
648
|
+
}
|
|
649
|
+
} finally {
|
|
650
|
+
cleanup();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// --- ensureMonitorTaskFile: cross-environment repair (地雷 4) ---
|
|
655
|
+
|
|
656
|
+
test('ensureMonitorTaskFile: repaired when existing task points to non-existent absolute paths', () => {
|
|
657
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
658
|
+
try {
|
|
659
|
+
mkdirSync(join(dir, '.vscode'));
|
|
660
|
+
// 別 OS で生成されたタスク: command と args の絶対パスが現環境には存在しない
|
|
661
|
+
const stale = {
|
|
662
|
+
version: '2.0.0',
|
|
663
|
+
tasks: [
|
|
664
|
+
{
|
|
665
|
+
label: 'Throughline Monitor',
|
|
666
|
+
type: 'shell',
|
|
667
|
+
command: '/old/env/node',
|
|
668
|
+
args: ['/old/env/throughline.mjs', 'monitor'],
|
|
669
|
+
presentation: { panel: 'dedicated', group: 'throughline' },
|
|
670
|
+
isBackground: true,
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
};
|
|
674
|
+
const tasksPath = join(dir, '.vscode', 'tasks.json');
|
|
675
|
+
writeFileSync(tasksPath, JSON.stringify(stale, null, 2));
|
|
676
|
+
|
|
677
|
+
const result = ensureMonitorTaskFile({
|
|
678
|
+
cwd: dir,
|
|
679
|
+
env: VSCODE_ENV,
|
|
680
|
+
throughlineBin: FAKE_BIN,
|
|
681
|
+
});
|
|
682
|
+
assert.equal(result.action, 'repaired');
|
|
683
|
+
|
|
684
|
+
const obj = JSON.parse(readFileSync(tasksPath, 'utf8'));
|
|
685
|
+
assert.equal(obj.tasks.length, 1);
|
|
686
|
+
const task = obj.tasks[0];
|
|
687
|
+
// command と args は現環境向けに差し替わる
|
|
688
|
+
assert.equal(task.command, process.execPath);
|
|
689
|
+
assert.deepEqual(task.args, [FAKE_BIN, 'monitor']);
|
|
690
|
+
// ユーザーカスタマイズ (presentation 等) は保持される
|
|
691
|
+
assert.equal(task.label, 'Throughline Monitor');
|
|
692
|
+
assert.deepEqual(task.presentation, { panel: 'dedicated', group: 'throughline' });
|
|
693
|
+
assert.equal(task.isBackground, true);
|
|
694
|
+
} finally {
|
|
695
|
+
cleanup();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test('ensureMonitorTaskFile: repaired preserves other tasks in the file', () => {
|
|
700
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
701
|
+
try {
|
|
702
|
+
mkdirSync(join(dir, '.vscode'));
|
|
703
|
+
const stale = {
|
|
704
|
+
version: '2.0.0',
|
|
705
|
+
tasks: [
|
|
706
|
+
{ label: 'Build', type: 'shell', command: 'make' },
|
|
707
|
+
{
|
|
708
|
+
label: 'Throughline Monitor',
|
|
709
|
+
type: 'shell',
|
|
710
|
+
command: '/old/env/node',
|
|
711
|
+
args: ['/old/env/throughline.mjs', 'monitor'],
|
|
712
|
+
},
|
|
713
|
+
{ label: 'Test', type: 'shell', command: 'npm test' },
|
|
714
|
+
],
|
|
715
|
+
};
|
|
716
|
+
writeFileSync(join(dir, '.vscode', 'tasks.json'), JSON.stringify(stale, null, 2));
|
|
717
|
+
|
|
718
|
+
const result = ensureMonitorTaskFile({
|
|
719
|
+
cwd: dir,
|
|
720
|
+
env: VSCODE_ENV,
|
|
721
|
+
throughlineBin: FAKE_BIN,
|
|
722
|
+
});
|
|
723
|
+
assert.equal(result.action, 'repaired');
|
|
724
|
+
|
|
725
|
+
const obj = JSON.parse(readFileSync(join(dir, '.vscode', 'tasks.json'), 'utf8'));
|
|
726
|
+
assert.equal(obj.tasks.length, 3);
|
|
727
|
+
assert.equal(obj.tasks[0].label, 'Build');
|
|
728
|
+
assert.equal(obj.tasks[1].label, 'Throughline Monitor');
|
|
729
|
+
assert.equal(obj.tasks[1].command, process.execPath);
|
|
730
|
+
assert.equal(obj.tasks[2].label, 'Test');
|
|
731
|
+
} finally {
|
|
732
|
+
cleanup();
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test('ensureMonitorTaskFile: already_present (not repaired) when task points to existing paths', () => {
|
|
737
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
738
|
+
try {
|
|
739
|
+
mkdirSync(join(dir, '.vscode'));
|
|
740
|
+
// command が現環境に存在するなら修復しない (process.execPath は必ず存在する)
|
|
741
|
+
const valid = {
|
|
742
|
+
version: '2.0.0',
|
|
743
|
+
tasks: [
|
|
744
|
+
{
|
|
745
|
+
label: 'Throughline Monitor',
|
|
746
|
+
type: 'shell',
|
|
747
|
+
command: process.execPath,
|
|
748
|
+
args: ['monitor'],
|
|
749
|
+
},
|
|
750
|
+
],
|
|
751
|
+
};
|
|
752
|
+
const tasksPath = join(dir, '.vscode', 'tasks.json');
|
|
753
|
+
writeFileSync(tasksPath, JSON.stringify(valid, null, 2));
|
|
754
|
+
const beforeMtime = statSync(tasksPath).mtimeMs;
|
|
755
|
+
|
|
756
|
+
const result = ensureMonitorTaskFile({
|
|
757
|
+
cwd: dir,
|
|
758
|
+
env: VSCODE_ENV,
|
|
759
|
+
throughlineBin: FAKE_BIN,
|
|
760
|
+
});
|
|
761
|
+
assert.equal(result.action, 'already_present');
|
|
762
|
+
|
|
763
|
+
const afterMtime = statSync(tasksPath).mtimeMs;
|
|
764
|
+
assert.equal(beforeMtime, afterMtime);
|
|
765
|
+
} finally {
|
|
766
|
+
cleanup();
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test('ensureMonitorTaskFile: repaired emits notice on stdout', () => {
|
|
771
|
+
const { dir, cleanup } = mkTmpCwd();
|
|
772
|
+
const captured = [];
|
|
773
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
774
|
+
process.stdout.write = (chunk) => {
|
|
775
|
+
captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
776
|
+
return true;
|
|
777
|
+
};
|
|
778
|
+
try {
|
|
779
|
+
mkdirSync(join(dir, '.vscode'));
|
|
780
|
+
const stale = {
|
|
781
|
+
version: '2.0.0',
|
|
782
|
+
tasks: [
|
|
783
|
+
{
|
|
784
|
+
label: 'Throughline Monitor',
|
|
785
|
+
command: '/old/env/node',
|
|
786
|
+
args: ['/old/env/throughline.mjs', 'monitor'],
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
};
|
|
790
|
+
writeFileSync(join(dir, '.vscode', 'tasks.json'), JSON.stringify(stale, null, 2));
|
|
791
|
+
|
|
792
|
+
const result = ensureMonitorTaskFile({
|
|
793
|
+
cwd: dir,
|
|
794
|
+
env: VSCODE_ENV,
|
|
795
|
+
throughlineBin: FAKE_BIN,
|
|
796
|
+
});
|
|
797
|
+
assert.equal(result.action, 'repaired');
|
|
798
|
+
} finally {
|
|
799
|
+
process.stdout.write = origWrite;
|
|
800
|
+
cleanup();
|
|
801
|
+
}
|
|
802
|
+
const joined = captured.join('');
|
|
803
|
+
assert.ok(joined.includes('<system-reminder>'), 'repaired should emit notice');
|
|
804
|
+
assert.ok(joined.includes('自動修復'));
|
|
805
|
+
assert.ok(joined.includes('Reload Window'));
|
|
806
|
+
});
|
|
807
|
+
|
|
414
808
|
// --- ensureMonitorTaskFile: JSONC ---
|
|
415
809
|
|
|
416
810
|
test('ensureMonitorTaskFile: jsonc_unsupported for file with line comments', () => {
|
|
@@ -536,6 +930,13 @@ test('buildSetupNotice: returns notice text for merged', () => {
|
|
|
536
930
|
assert.ok(text.includes('Reload Window'));
|
|
537
931
|
});
|
|
538
932
|
|
|
933
|
+
test('buildSetupNotice: returns notice text for repaired', () => {
|
|
934
|
+
const text = buildSetupNotice('repaired');
|
|
935
|
+
assert.ok(text && text.includes('<system-reminder>'));
|
|
936
|
+
assert.ok(text.includes('自動修復'));
|
|
937
|
+
assert.ok(text.includes('Reload Window'));
|
|
938
|
+
});
|
|
939
|
+
|
|
539
940
|
test('buildSetupNotice: returns null for already_present (silent idempotency)', () => {
|
|
540
941
|
assert.equal(buildSetupNotice('already_present'), null);
|
|
541
942
|
});
|