throughline 0.3.2 → 0.3.4

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * VSCode tasks.json auto-provisioning
3
+ *
4
+ * Stop Hook から毎ターン呼ばれる想定。冪等で低コスト。
5
+ *
6
+ * 狙い: Throughline をグローバル導入したユーザーが VSCode で任意のプロジェクトを
7
+ * 開いたとき、初回のアシスタント応答完了時点で .vscode/tasks.json に
8
+ * "Throughline Monitor" タスクを自動登録し、次回フォルダを開いた瞬間に
9
+ * 専用ターミナルで token-monitor を自動起動させる。
10
+ *
11
+ * 2 段構え:
12
+ * - tasks.json が無い or 純 JSON としてパース可能 → 作成 / 安全にマージ
13
+ * - JSONC フィーチャ (コメント・末尾カンマ) を検出 → 触らず手動案内 1 回のみ
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { join, dirname } from 'node:path';
19
+
20
+ const MONITOR_LABEL = 'Throughline Monitor';
21
+ const JSONC_MARKER_FILENAME = '.throughline-jsonc-noted';
22
+
23
+ /**
24
+ * VSCode 系エディタが動いているかを env から推定する。
25
+ * Cursor / VSCodium / Windsurf でも VSCODE_* は立つ。同じ tasks.json を読むので OK。
26
+ */
27
+ export function detectVsCode(env) {
28
+ return (
29
+ env.TERM_PROGRAM === 'vscode' ||
30
+ Boolean(env.VSCODE_PID) ||
31
+ Boolean(env.VSCODE_IPC_HOOK_CLI)
32
+ );
33
+ }
34
+
35
+ /**
36
+ * 入力テキストに JSONC フィーチャ (コメント・末尾カンマ) が含まれるかを判定する。
37
+ * 文字列リテラル内の偽陽性を避けるため軽量トークナイズする。
38
+ */
39
+ export function detectJsoncFeatures(text) {
40
+ let inString = false;
41
+ let quote = '';
42
+ let prevNonSpace = '';
43
+ for (let i = 0; i < text.length; i++) {
44
+ const c = text[i];
45
+ if (inString) {
46
+ if (c === '\\') {
47
+ i++;
48
+ continue;
49
+ }
50
+ if (c === quote) {
51
+ inString = false;
52
+ }
53
+ continue;
54
+ }
55
+ if (c === '"' || c === "'") {
56
+ inString = true;
57
+ quote = c;
58
+ prevNonSpace = c;
59
+ continue;
60
+ }
61
+ if (c === '/' && text[i + 1] === '/') return true;
62
+ if (c === '/' && text[i + 1] === '*') return true;
63
+ if (c === ']' || c === '}') {
64
+ if (prevNonSpace === ',') return true;
65
+ }
66
+ if (c !== ' ' && c !== '\t' && c !== '\n' && c !== '\r') {
67
+ prevNonSpace = c;
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * テキストから JSON のインデントスタイルを推定する。
75
+ * 最初にネストされた行の先頭空白を見る。検出できなければ 2 スペース。
76
+ */
77
+ export function detectIndent(text) {
78
+ const match = text.match(/\n([ \t]+)\S/);
79
+ if (match) return match[1];
80
+ return ' ';
81
+ }
82
+
83
+ /**
84
+ * tasks 配列に Throughline Monitor 相当のタスクが含まれるか判定する。
85
+ * ユーザーが label をリネームしても command/args に "throughline" + "monitor" が
86
+ * あれば同一タスクとみなす。
87
+ */
88
+ export function hasMonitorTask(obj) {
89
+ const tasks = obj?.tasks;
90
+ if (!Array.isArray(tasks)) return false;
91
+ for (const t of tasks) {
92
+ if (!t || typeof t !== 'object') continue;
93
+ if (t.label === MONITOR_LABEL) return true;
94
+ const invocation = [t.command, ...(Array.isArray(t.args) ? t.args : [])]
95
+ .filter((s) => typeof s === 'string')
96
+ .join(' ')
97
+ .toLowerCase();
98
+ if (invocation.includes('throughline') && invocation.includes('monitor')) {
99
+ return true;
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * 生成する VSCode タスク定義を組み立てる。
107
+ *
108
+ * type: 'process' を選ぶ理由: shell は Windows の .cmd shim や PATH 解決に
109
+ * 依存して失敗しうる。process は command を直接起動するため堅い。
110
+ * command には Node 実行ファイルの絶対パスを入れ、args に throughline.mjs の
111
+ * 絶対パスと 'monitor' サブコマンドを渡す。
112
+ */
113
+ export function buildMonitorTask(throughlineBin) {
114
+ return {
115
+ label: MONITOR_LABEL,
116
+ detail: 'Auto-generated by Throughline. Opens token-monitor in a dedicated terminal when the folder is opened.',
117
+ type: 'process',
118
+ command: process.execPath,
119
+ args: [throughlineBin, 'monitor'],
120
+ isBackground: true,
121
+ presentation: {
122
+ reveal: 'always',
123
+ panel: 'dedicated',
124
+ group: 'throughline',
125
+ close: false,
126
+ echo: false,
127
+ focus: false,
128
+ showReuseMessage: false,
129
+ clear: true,
130
+ },
131
+ runOptions: { runOn: 'folderOpen' },
132
+ problemMatcher: [],
133
+ };
134
+ }
135
+
136
+ /**
137
+ * グローバル install 時の node_modules/throughline/bin/throughline.mjs、
138
+ * もしくはローカル開発時の <repo>/bin/throughline.mjs の絶対パスを返す。
139
+ * 見つからない場合は null。
140
+ */
141
+ export function resolveThroughlineBin() {
142
+ try {
143
+ const path = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
144
+ if (existsSync(path)) return path;
145
+ } catch {
146
+ // fileURLToPath 失敗は想定外だが、その場合は null を返して skip に回す
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function atomicWrite(targetPath, contents) {
152
+ const tmp = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
153
+ writeFileSync(tmp, contents);
154
+ renameSync(tmp, targetPath);
155
+ }
156
+
157
+ function emitJsoncGuidanceOnce(vscodeDir) {
158
+ const markerPath = join(vscodeDir, JSONC_MARKER_FILENAME);
159
+ if (existsSync(markerPath)) return;
160
+ process.stderr.write(
161
+ `[throughline] .vscode/tasks.json contains JSONC features (comments or trailing commas). ` +
162
+ `Auto-edit is unsafe on this file — add the Throughline Monitor task manually. ` +
163
+ `See README for the snippet. This notice prints once per project.\n`,
164
+ );
165
+ try {
166
+ writeFileSync(markerPath, `${new Date().toISOString()}\n`);
167
+ } catch (err) {
168
+ const msg = err instanceof Error ? err.message : 'unknown';
169
+ process.stderr.write(`[throughline] failed to write JSONC marker: ${msg}\n`);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * VSCode の tasks.json に Throughline Monitor タスクを(必要なら)自動登録する。
175
+ *
176
+ * @param {{cwd?: string, env?: NodeJS.ProcessEnv, throughlineBin?: string | null}} opts
177
+ * @returns {{
178
+ * action: 'created' | 'merged' | 'already_present' | 'skipped',
179
+ * reason?: string,
180
+ * path?: string,
181
+ * }}
182
+ */
183
+ export function ensureMonitorTaskFile(opts = {}) {
184
+ const env = opts.env ?? process.env;
185
+ const cwd = opts.cwd;
186
+
187
+ if (env.THROUGHLINE_NO_VSCODE === '1') {
188
+ return { action: 'skipped', reason: 'opt_out' };
189
+ }
190
+ if (!cwd || !existsSync(cwd)) {
191
+ return { action: 'skipped', reason: 'no_cwd' };
192
+ }
193
+ if (!detectVsCode(env)) {
194
+ return { action: 'skipped', reason: 'not_vscode' };
195
+ }
196
+
197
+ const bin = opts.throughlineBin !== undefined
198
+ ? opts.throughlineBin
199
+ : resolveThroughlineBin();
200
+ if (!bin) {
201
+ return { action: 'skipped', reason: 'no_bin' };
202
+ }
203
+
204
+ const vscodeDir = join(cwd, '.vscode');
205
+ const tasksPath = join(vscodeDir, 'tasks.json');
206
+
207
+ if (!existsSync(tasksPath)) {
208
+ if (!existsSync(vscodeDir)) mkdirSync(vscodeDir, { recursive: true });
209
+ const obj = { version: '2.0.0', tasks: [buildMonitorTask(bin)] };
210
+ atomicWrite(tasksPath, JSON.stringify(obj, null, 2) + '\n');
211
+ return { action: 'created', path: tasksPath };
212
+ }
213
+
214
+ const text = readFileSync(tasksPath, 'utf8');
215
+
216
+ if (detectJsoncFeatures(text)) {
217
+ emitJsoncGuidanceOnce(vscodeDir);
218
+ return { action: 'skipped', reason: 'jsonc_unsupported', path: tasksPath };
219
+ }
220
+
221
+ let obj;
222
+ try {
223
+ obj = JSON.parse(text);
224
+ } catch {
225
+ return { action: 'skipped', reason: 'parse_error', path: tasksPath };
226
+ }
227
+
228
+ if (hasMonitorTask(obj)) {
229
+ return { action: 'already_present', path: tasksPath };
230
+ }
231
+
232
+ const indent = detectIndent(text);
233
+ const nextObj = {
234
+ ...obj,
235
+ version: obj.version ?? '2.0.0',
236
+ tasks: [...(Array.isArray(obj.tasks) ? obj.tasks : []), buildMonitorTask(bin)],
237
+ };
238
+ atomicWrite(tasksPath, JSON.stringify(nextObj, null, indent) + '\n');
239
+ return { action: 'merged', path: tasksPath };
240
+ }