throughline 0.3.21 → 0.3.23

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.
@@ -15,7 +15,7 @@
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';
@@ -81,22 +81,58 @@ export function detectIndent(text) {
81
81
  }
82
82
 
83
83
  /**
84
- * tasks 配列に Throughline Monitor 相当のタスクが含まれるか判定する。
84
+ * tasks 配列内で Throughline Monitor 相当のタスクの index を返す。
85
85
  * ユーザーが label をリネームしても command/args に "throughline" + "monitor" が
86
- * あれば同一タスクとみなす。
86
+ * あれば同一タスクとみなす。見つからない場合は -1。
87
87
  */
88
- export function hasMonitorTask(obj) {
88
+ export function findMonitorTaskIndex(obj) {
89
89
  const tasks = obj?.tasks;
90
- if (!Array.isArray(tasks)) return false;
91
- for (const t of tasks) {
90
+ if (!Array.isArray(tasks)) return -1;
91
+ for (let i = 0; i < tasks.length; i++) {
92
+ const t = tasks[i];
92
93
  if (!t || typeof t !== 'object') continue;
93
- if (t.label === MONITOR_LABEL) return true;
94
+ if (t.label === MONITOR_LABEL) return i;
94
95
  const invocation = [t.command, ...(Array.isArray(t.args) ? t.args : [])]
95
96
  .filter((s) => typeof s === 'string')
96
97
  .join(' ')
97
98
  .toLowerCase();
98
99
  if (invocation.includes('throughline') && invocation.includes('monitor')) {
99
- return true;
100
+ return i;
101
+ }
102
+ }
103
+ return -1;
104
+ }
105
+
106
+ export function hasMonitorTask(obj) {
107
+ return findMonitorTaskIndex(obj) >= 0;
108
+ }
109
+
110
+ /**
111
+ * 既存の Monitor タスクが「別環境(別 OS / 別 install 場所)の絶対パスを参照していて
112
+ * 現在の環境では起動できない」状態かを判定する。
113
+ *
114
+ * 判定基準:
115
+ * - command が絶対パスかつ existsSync(command) === false → 壊れている
116
+ * - args の中に絶対パスの .mjs / .js / .cjs があり、それが存在しない → 壊れている
117
+ *
118
+ * 相対パス("node" / "throughline" 等の PATH 解決前提)は判定対象外。
119
+ * ユーザーが手動で PATH 解決型に書き換えたケースを誤って上書きしないため。
120
+ */
121
+ export function isMonitorTaskBroken(task) {
122
+ if (!task || typeof task !== 'object') return false;
123
+ if (typeof task.command === 'string' && isAbsolute(task.command) && !existsSync(task.command)) {
124
+ return true;
125
+ }
126
+ if (Array.isArray(task.args)) {
127
+ for (const a of task.args) {
128
+ if (
129
+ typeof a === 'string' &&
130
+ isAbsolute(a) &&
131
+ /\.(mjs|js|cjs)$/.test(a) &&
132
+ !existsSync(a)
133
+ ) {
134
+ return true;
135
+ }
100
136
  }
101
137
  }
102
138
  return false;
@@ -173,18 +209,33 @@ function atomicWrite(targetPath, contents) {
173
209
  * セットアップ済みのため沈黙する(冪等性)。
174
210
  */
175
211
  export function buildSetupNotice(action) {
176
- if (action !== 'created' && action !== 'merged') return null;
177
- return (
178
- '<system-reminder>\n' +
179
- 'Throughline: このプロジェクトに token-monitor の自動起動設定を追加しました (.vscode/tasks.json)。\n' +
180
- '今すぐ monitor を起動するには、VSCode のコマンドパレット (Ctrl+Shift+P) から\n' +
181
- ' Developer: Reload Window\n' +
182
- 'を実行してください。次回以降はフォルダを開いた瞬間に自動起動します。\n' +
183
- '\n' +
184
- 'このお知らせは今このプロジェクトで初回セットアップが完了したためだけに 1 度だけ流れています。\n' +
185
- '必ずユーザー(あなたの対話相手)に日本語でそのまま伝えてください。\n' +
186
- '</system-reminder>\n'
187
- );
212
+ if (action === 'created' || action === 'merged') {
213
+ return (
214
+ '<system-reminder>\n' +
215
+ 'Throughline: このプロジェクトに token-monitor の自動起動設定を追加しました (.vscode/tasks.json)。\n' +
216
+ '今すぐ monitor を起動するには、VSCode のコマンドパレット (Ctrl+Shift+P) から\n' +
217
+ ' Developer: Reload Window\n' +
218
+ 'を実行してください。次回以降はフォルダを開いた瞬間に自動起動します。\n' +
219
+ '\n' +
220
+ 'このお知らせは今このプロジェクトで初回セットアップが完了したためだけに 1 度だけ流れています。\n' +
221
+ '必ずユーザー(あなたの対話相手)に日本語でそのまま伝えてください。\n' +
222
+ '</system-reminder>\n'
223
+ );
224
+ }
225
+ if (action === 'repaired') {
226
+ return (
227
+ '<system-reminder>\n' +
228
+ 'Throughline: .vscode/tasks.json に登録されていた token-monitor タスクが\n' +
229
+ '別環境(別 OS / 別 install 場所)の絶対パスを参照していたため、現環境向けに自動修復しました。\n' +
230
+ '反映するには VSCode のコマンドパレット (Ctrl+Shift+P) から\n' +
231
+ ' Developer: Reload Window\n' +
232
+ 'を実行してください。\n' +
233
+ '\n' +
234
+ '必ずユーザー(あなたの対話相手)に日本語でそのまま伝えてください。\n' +
235
+ '</system-reminder>\n'
236
+ );
237
+ }
238
+ return null;
188
239
  }
189
240
 
190
241
  function emitSetupNotice(action) {
@@ -211,9 +262,13 @@ function emitJsoncGuidanceOnce(vscodeDir) {
211
262
  /**
212
263
  * VSCode の tasks.json に Throughline Monitor タスクを(必要なら)自動登録する。
213
264
  *
265
+ * 既存タスクが別環境の絶対パスを参照していて現環境で起動できない場合は
266
+ * command/args だけを差し替えて修復する(label / presentation 等のユーザー
267
+ * カスタマイズは保持)。
268
+ *
214
269
  * @param {{cwd?: string, env?: NodeJS.ProcessEnv, throughlineBin?: string | null}} opts
215
270
  * @returns {{
216
- * action: 'created' | 'merged' | 'already_present' | 'skipped',
271
+ * action: 'created' | 'merged' | 'repaired' | 'already_present' | 'skipped',
217
272
  * reason?: string,
218
273
  * path?: string,
219
274
  * }}
@@ -264,7 +319,25 @@ export function ensureMonitorTaskFile(opts = {}) {
264
319
  return { action: 'skipped', reason: 'parse_error', path: tasksPath };
265
320
  }
266
321
 
267
- if (hasMonitorTask(obj)) {
322
+ const existingIdx = findMonitorTaskIndex(obj);
323
+ if (existingIdx >= 0) {
324
+ const existing = obj.tasks[existingIdx];
325
+ if (isMonitorTaskBroken(existing)) {
326
+ const fresh = buildMonitorTask(bin);
327
+ const repaired = {
328
+ ...existing,
329
+ type: fresh.type,
330
+ command: fresh.command,
331
+ args: fresh.args,
332
+ };
333
+ const indent = detectIndent(text);
334
+ const nextTasks = [...obj.tasks];
335
+ nextTasks[existingIdx] = repaired;
336
+ const nextObj = { ...obj, version: obj.version ?? '2.0.0', tasks: nextTasks };
337
+ atomicWrite(tasksPath, JSON.stringify(nextObj, null, indent) + '\n');
338
+ emitSetupNotice('repaired');
339
+ return { action: 'repaired', path: tasksPath };
340
+ }
268
341
  return { action: 'already_present', path: tasksPath };
269
342
  }
270
343
 
@@ -9,12 +9,16 @@ import {
9
9
  detectJsoncFeatures,
10
10
  detectIndent,
11
11
  hasMonitorTask,
12
+ findMonitorTaskIndex,
13
+ isMonitorTaskBroken,
12
14
  buildMonitorTask,
13
15
  buildSetupNotice,
14
16
  } from './vscode-task.mjs';
15
17
 
16
18
  const VSCODE_ENV = { TERM_PROGRAM: 'vscode' };
17
- const FAKE_BIN = '/fake/abs/path/bin/throughline.mjs';
19
+ // 実在する絶対パスを使う。`isMonitorTaskBroken` が「絶対パス + 非存在」で broken 判定するので、
20
+ // 架空パスを使うと意図せず repaired ブランチに落ちてしまう。
21
+ const FAKE_BIN = process.execPath;
18
22
 
19
23
  function mkTmpCwd() {
20
24
  const dir = mkdtempSync(join(tmpdir(), 'throughline-vscode-'));
@@ -137,6 +141,71 @@ test('hasMonitorTask: handles missing tasks array', () => {
137
141
  assert.equal(hasMonitorTask({ tasks: null }), false);
138
142
  });
139
143
 
144
+ // --- findMonitorTaskIndex ---
145
+
146
+ test('findMonitorTaskIndex: returns index when label matches', () => {
147
+ assert.equal(
148
+ findMonitorTaskIndex({ tasks: [{ label: 'Build' }, { label: 'Throughline Monitor' }] }),
149
+ 1,
150
+ );
151
+ });
152
+
153
+ test('findMonitorTaskIndex: returns -1 when no match', () => {
154
+ assert.equal(findMonitorTaskIndex({ tasks: [{ label: 'Build' }] }), -1);
155
+ assert.equal(findMonitorTaskIndex({}), -1);
156
+ });
157
+
158
+ // --- isMonitorTaskBroken ---
159
+
160
+ test('isMonitorTaskBroken: false when command is an existing absolute path', () => {
161
+ assert.equal(
162
+ isMonitorTaskBroken({ command: process.execPath, args: ['monitor'] }),
163
+ false,
164
+ );
165
+ });
166
+
167
+ test('isMonitorTaskBroken: true when command is a non-existent absolute path', () => {
168
+ assert.equal(
169
+ isMonitorTaskBroken({ command: '/definitely/does/not/exist/node', args: ['monitor'] }),
170
+ true,
171
+ );
172
+ });
173
+
174
+ test('isMonitorTaskBroken: false when command is a relative name (PATH-resolved)', () => {
175
+ // ユーザーが手動で "node" / "throughline" に書き換えたケースは誤上書きしない
176
+ assert.equal(
177
+ isMonitorTaskBroken({ command: 'node', args: ['/x/throughline.mjs', 'monitor'] }),
178
+ true, // args 側の絶対パスが壊れているので true
179
+ );
180
+ assert.equal(
181
+ isMonitorTaskBroken({ command: 'throughline', args: ['monitor'] }),
182
+ false,
183
+ );
184
+ });
185
+
186
+ test('isMonitorTaskBroken: true when args contains non-existent absolute .mjs path', () => {
187
+ assert.equal(
188
+ isMonitorTaskBroken({
189
+ command: process.execPath,
190
+ args: ['/no/such/file/throughline.mjs', 'monitor'],
191
+ }),
192
+ true,
193
+ );
194
+ });
195
+
196
+ test('isMonitorTaskBroken: false when args has only relative strings', () => {
197
+ assert.equal(
198
+ isMonitorTaskBroken({ command: process.execPath, args: ['monitor'] }),
199
+ false,
200
+ );
201
+ });
202
+
203
+ test('isMonitorTaskBroken: handles malformed task safely', () => {
204
+ assert.equal(isMonitorTaskBroken(null), false);
205
+ assert.equal(isMonitorTaskBroken({}), false);
206
+ assert.equal(isMonitorTaskBroken({ command: 42 }), false);
207
+ });
208
+
140
209
  // --- buildMonitorTask ---
141
210
 
142
211
  test('buildMonitorTask: uses type=shell with provided bin as args[0] for PTY allocation', () => {
@@ -367,7 +436,8 @@ test('ensureMonitorTaskFile: already_present when command references throughline
367
436
  label: 'My Custom Monitor',
368
437
  type: 'process',
369
438
  command: '/usr/bin/node',
370
- args: ['/path/to/bin/throughline.mjs', 'monitor'],
439
+ // 相対パスにして broken 判定を避ける(このテストは「label renamed でも検出できるか」だけが論点)
440
+ args: ['./throughline.mjs', 'monitor'],
371
441
  },
372
442
  ],
373
443
  };
@@ -411,6 +481,160 @@ test('ensureMonitorTaskFile: second call is idempotent (already_present after cr
411
481
  }
412
482
  });
413
483
 
484
+ // --- ensureMonitorTaskFile: cross-environment repair (地雷 4) ---
485
+
486
+ test('ensureMonitorTaskFile: repaired when existing task points to non-existent absolute paths', () => {
487
+ const { dir, cleanup } = mkTmpCwd();
488
+ try {
489
+ mkdirSync(join(dir, '.vscode'));
490
+ // 別 OS で生成されたタスク: command と args の絶対パスが現環境には存在しない
491
+ const stale = {
492
+ version: '2.0.0',
493
+ tasks: [
494
+ {
495
+ label: 'Throughline Monitor',
496
+ type: 'shell',
497
+ command: '/old/env/node',
498
+ args: ['/old/env/throughline.mjs', 'monitor'],
499
+ presentation: { panel: 'dedicated', group: 'throughline' },
500
+ isBackground: true,
501
+ },
502
+ ],
503
+ };
504
+ const tasksPath = join(dir, '.vscode', 'tasks.json');
505
+ writeFileSync(tasksPath, JSON.stringify(stale, null, 2));
506
+
507
+ const result = ensureMonitorTaskFile({
508
+ cwd: dir,
509
+ env: VSCODE_ENV,
510
+ throughlineBin: FAKE_BIN,
511
+ });
512
+ assert.equal(result.action, 'repaired');
513
+
514
+ const obj = JSON.parse(readFileSync(tasksPath, 'utf8'));
515
+ assert.equal(obj.tasks.length, 1);
516
+ const task = obj.tasks[0];
517
+ // command と args は現環境向けに差し替わる
518
+ assert.equal(task.command, process.execPath);
519
+ assert.deepEqual(task.args, [FAKE_BIN, 'monitor']);
520
+ // ユーザーカスタマイズ (presentation 等) は保持される
521
+ assert.equal(task.label, 'Throughline Monitor');
522
+ assert.deepEqual(task.presentation, { panel: 'dedicated', group: 'throughline' });
523
+ assert.equal(task.isBackground, true);
524
+ } finally {
525
+ cleanup();
526
+ }
527
+ });
528
+
529
+ test('ensureMonitorTaskFile: repaired preserves other tasks in the file', () => {
530
+ const { dir, cleanup } = mkTmpCwd();
531
+ try {
532
+ mkdirSync(join(dir, '.vscode'));
533
+ const stale = {
534
+ version: '2.0.0',
535
+ tasks: [
536
+ { label: 'Build', type: 'shell', command: 'make' },
537
+ {
538
+ label: 'Throughline Monitor',
539
+ type: 'shell',
540
+ command: '/old/env/node',
541
+ args: ['/old/env/throughline.mjs', 'monitor'],
542
+ },
543
+ { label: 'Test', type: 'shell', command: 'npm test' },
544
+ ],
545
+ };
546
+ writeFileSync(join(dir, '.vscode', 'tasks.json'), JSON.stringify(stale, null, 2));
547
+
548
+ const result = ensureMonitorTaskFile({
549
+ cwd: dir,
550
+ env: VSCODE_ENV,
551
+ throughlineBin: FAKE_BIN,
552
+ });
553
+ assert.equal(result.action, 'repaired');
554
+
555
+ const obj = JSON.parse(readFileSync(join(dir, '.vscode', 'tasks.json'), 'utf8'));
556
+ assert.equal(obj.tasks.length, 3);
557
+ assert.equal(obj.tasks[0].label, 'Build');
558
+ assert.equal(obj.tasks[1].label, 'Throughline Monitor');
559
+ assert.equal(obj.tasks[1].command, process.execPath);
560
+ assert.equal(obj.tasks[2].label, 'Test');
561
+ } finally {
562
+ cleanup();
563
+ }
564
+ });
565
+
566
+ test('ensureMonitorTaskFile: already_present (not repaired) when task points to existing paths', () => {
567
+ const { dir, cleanup } = mkTmpCwd();
568
+ try {
569
+ mkdirSync(join(dir, '.vscode'));
570
+ // command が現環境に存在するなら修復しない (process.execPath は必ず存在する)
571
+ const valid = {
572
+ version: '2.0.0',
573
+ tasks: [
574
+ {
575
+ label: 'Throughline Monitor',
576
+ type: 'shell',
577
+ command: process.execPath,
578
+ args: ['monitor'],
579
+ },
580
+ ],
581
+ };
582
+ const tasksPath = join(dir, '.vscode', 'tasks.json');
583
+ writeFileSync(tasksPath, JSON.stringify(valid, null, 2));
584
+ const beforeMtime = statSync(tasksPath).mtimeMs;
585
+
586
+ const result = ensureMonitorTaskFile({
587
+ cwd: dir,
588
+ env: VSCODE_ENV,
589
+ throughlineBin: FAKE_BIN,
590
+ });
591
+ assert.equal(result.action, 'already_present');
592
+
593
+ const afterMtime = statSync(tasksPath).mtimeMs;
594
+ assert.equal(beforeMtime, afterMtime);
595
+ } finally {
596
+ cleanup();
597
+ }
598
+ });
599
+
600
+ test('ensureMonitorTaskFile: repaired emits notice on stdout', () => {
601
+ const { dir, cleanup } = mkTmpCwd();
602
+ const captured = [];
603
+ const origWrite = process.stdout.write.bind(process.stdout);
604
+ process.stdout.write = (chunk) => {
605
+ captured.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
606
+ return true;
607
+ };
608
+ try {
609
+ mkdirSync(join(dir, '.vscode'));
610
+ const stale = {
611
+ version: '2.0.0',
612
+ tasks: [
613
+ {
614
+ label: 'Throughline Monitor',
615
+ command: '/old/env/node',
616
+ args: ['/old/env/throughline.mjs', 'monitor'],
617
+ },
618
+ ],
619
+ };
620
+ writeFileSync(join(dir, '.vscode', 'tasks.json'), JSON.stringify(stale, null, 2));
621
+
622
+ const result = ensureMonitorTaskFile({
623
+ cwd: dir,
624
+ env: VSCODE_ENV,
625
+ throughlineBin: FAKE_BIN,
626
+ });
627
+ assert.equal(result.action, 'repaired');
628
+ } finally {
629
+ process.stdout.write = origWrite;
630
+ cleanup();
631
+ }
632
+ const joined = captured.join('');
633
+ assert.ok(joined.includes('<system-reminder>'), 'repaired should emit notice');
634
+ assert.ok(joined.includes('自動修復'));
635
+ assert.ok(joined.includes('Reload Window'));
636
+ });
637
+
414
638
  // --- ensureMonitorTaskFile: JSONC ---
415
639
 
416
640
  test('ensureMonitorTaskFile: jsonc_unsupported for file with line comments', () => {
@@ -536,6 +760,13 @@ test('buildSetupNotice: returns notice text for merged', () => {
536
760
  assert.ok(text.includes('Reload Window'));
537
761
  });
538
762
 
763
+ test('buildSetupNotice: returns notice text for repaired', () => {
764
+ const text = buildSetupNotice('repaired');
765
+ assert.ok(text && text.includes('<system-reminder>'));
766
+ assert.ok(text.includes('自動修復'));
767
+ assert.ok(text.includes('Reload Window'));
768
+ });
769
+
539
770
  test('buildSetupNotice: returns null for already_present (silent idempotency)', () => {
540
771
  assert.equal(buildSetupNotice('already_present'), null);
541
772
  });