switchman-dev 0.1.5 → 0.1.7

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.
@@ -1,6 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
- import { join } from 'path';
3
+ import { dirname, join, posix } from 'path';
4
4
 
5
5
  const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
6
6
 
@@ -111,21 +111,22 @@ function parseFileObjects(repoPath, filePath) {
111
111
  }));
112
112
  }
113
113
 
114
- function trackedFiles(repoPath) {
114
+ export function listTrackedFiles(repoPath, { sourceOnly = false } = {}) {
115
115
  try {
116
116
  const output = execSync('git ls-files', {
117
117
  cwd: repoPath,
118
118
  encoding: 'utf8',
119
119
  stdio: ['pipe', 'pipe', 'pipe'],
120
120
  }).trim();
121
- return output.split('\n').filter(Boolean);
121
+ const files = output.split('\n').filter(Boolean);
122
+ return sourceOnly ? files.filter(isSourceLikePath) : files;
122
123
  } catch {
123
124
  return [];
124
125
  }
125
126
  }
126
127
 
127
128
  export function buildSemanticIndexForPath(repoPath, filePaths = null) {
128
- const files = filePaths || trackedFiles(repoPath);
129
+ const files = filePaths || listTrackedFiles(repoPath);
129
130
  const objects = files
130
131
  .filter(isSourceLikePath)
131
132
  .flatMap((filePath) => parseFileObjects(repoPath, filePath))
@@ -142,6 +143,67 @@ export function buildSemanticIndexForPath(repoPath, filePaths = null) {
142
143
  };
143
144
  }
144
145
 
146
+ function extractImportSpecifiers(content) {
147
+ const specifiers = new Set();
148
+ const patterns = [
149
+ /import\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
150
+ /export\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
151
+ /require\(\s*['"]([^'"]+)['"]\s*\)/g,
152
+ /import\(\s*['"]([^'"]+)['"]\s*\)/g,
153
+ ];
154
+ for (const pattern of patterns) {
155
+ for (const match of content.matchAll(pattern)) {
156
+ if (match[1]) specifiers.add(match[1]);
157
+ }
158
+ }
159
+ return [...specifiers];
160
+ }
161
+
162
+ function resolveImportTarget(filePath, specifier, trackedSourceFiles) {
163
+ if (!specifier || !specifier.startsWith('.')) return null;
164
+ const fromDir = dirname(filePath);
165
+ const rawTarget = posix.normalize(posix.join(fromDir === '.' ? '' : fromDir, specifier));
166
+ const candidates = [];
167
+ if ([...SOURCE_EXTENSIONS].some((ext) => rawTarget.endsWith(ext))) {
168
+ candidates.push(rawTarget);
169
+ } else {
170
+ for (const ext of SOURCE_EXTENSIONS) {
171
+ candidates.push(`${rawTarget}${ext}`);
172
+ candidates.push(posix.join(rawTarget, `index${ext}`));
173
+ }
174
+ }
175
+ return candidates.find((candidate) => trackedSourceFiles.has(candidate)) || null;
176
+ }
177
+
178
+ export function buildModuleDependencyIndexForPath(repoPath, { filePaths = null } = {}) {
179
+ const files = (filePaths || listTrackedFiles(repoPath, { sourceOnly: true })).filter(isSourceLikePath);
180
+ const trackedSourceFiles = new Set(files);
181
+ const dependencies = [];
182
+
183
+ for (const filePath of files) {
184
+ const absolutePath = join(repoPath, filePath);
185
+ if (!existsSync(absolutePath)) continue;
186
+ const content = readFileSync(absolutePath, 'utf8');
187
+ for (const specifier of extractImportSpecifiers(content)) {
188
+ const resolvedPath = resolveImportTarget(filePath, specifier, trackedSourceFiles);
189
+ if (!resolvedPath) continue;
190
+ dependencies.push({
191
+ file_path: filePath,
192
+ imported_path: resolvedPath,
193
+ import_specifier: specifier,
194
+ area: areaForPath(filePath),
195
+ subsystem_tags: classifySubsystems(filePath),
196
+ });
197
+ }
198
+ }
199
+
200
+ return {
201
+ generated_at: new Date().toISOString(),
202
+ dependency_count: dependencies.length,
203
+ dependencies,
204
+ };
205
+ }
206
+
145
207
  export function detectSemanticConflicts(semanticIndexes = []) {
146
208
  const conflicts = [];
147
209
 
@@ -227,7 +289,7 @@ function normalizeObjectRow(row) {
227
289
  }
228
290
 
229
291
  export function importCodeObjectsToStore(db, repoRoot, { filePaths = null } = {}) {
230
- const files = filePaths || trackedFiles(repoRoot);
292
+ const files = filePaths || listTrackedFiles(repoRoot);
231
293
  const objects = files
232
294
  .filter(isSourceLikePath)
233
295
  .flatMap((filePath) => parseFileObjects(repoRoot, filePath));
@@ -309,3 +371,7 @@ export function materializeCodeObjects(db, repoRoot, { outputRoot = repoRoot } =
309
371
  files: files.sort(),
310
372
  };
311
373
  }
374
+
375
+ export function classifySubsystemsForPath(filePath) {
376
+ return classifySubsystems(filePath);
377
+ }
@@ -0,0 +1,210 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { dirname, join } from 'path';
4
+ import { randomUUID } from 'crypto';
5
+ import readline from 'readline/promises';
6
+
7
+ export const DEFAULT_TELEMETRY_HOST = 'https://us.i.posthog.com';
8
+
9
+ function normalizeBoolean(value) {
10
+ if (typeof value === 'boolean') return value;
11
+ if (typeof value !== 'string') return null;
12
+ const lowered = value.trim().toLowerCase();
13
+ if (['1', 'true', 'yes', 'y', 'on'].includes(lowered)) return true;
14
+ if (['0', 'false', 'no', 'n', 'off'].includes(lowered)) return false;
15
+ return null;
16
+ }
17
+
18
+ export function getTelemetryConfigPath(homeDir = homedir()) {
19
+ return join(homeDir, '.switchman', 'config.json');
20
+ }
21
+
22
+ export function getTelemetryRuntimeConfig(env = process.env) {
23
+ return {
24
+ apiKey: env.SWITCHMAN_TELEMETRY_API_KEY || env.POSTHOG_API_KEY || null,
25
+ host: env.SWITCHMAN_TELEMETRY_HOST || env.POSTHOG_HOST || DEFAULT_TELEMETRY_HOST,
26
+ disabled: normalizeBoolean(env.SWITCHMAN_TELEMETRY_DISABLED) === true,
27
+ };
28
+ }
29
+
30
+ export function loadTelemetryConfig(homeDir = homedir()) {
31
+ const configPath = getTelemetryConfigPath(homeDir);
32
+ if (!existsSync(configPath)) {
33
+ return {
34
+ telemetry_enabled: null,
35
+ telemetry_install_id: null,
36
+ telemetry_prompted_at: null,
37
+ };
38
+ }
39
+
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
42
+ return {
43
+ telemetry_enabled: typeof parsed?.telemetry_enabled === 'boolean' ? parsed.telemetry_enabled : null,
44
+ telemetry_install_id: typeof parsed?.telemetry_install_id === 'string' ? parsed.telemetry_install_id : null,
45
+ telemetry_prompted_at: typeof parsed?.telemetry_prompted_at === 'string' ? parsed.telemetry_prompted_at : null,
46
+ };
47
+ } catch {
48
+ return {
49
+ telemetry_enabled: null,
50
+ telemetry_install_id: null,
51
+ telemetry_prompted_at: null,
52
+ };
53
+ }
54
+ }
55
+
56
+ export function writeTelemetryConfig(homeDir = homedir(), config = {}) {
57
+ const configPath = getTelemetryConfigPath(homeDir);
58
+ mkdirSync(dirname(configPath), { recursive: true });
59
+ const normalized = {
60
+ telemetry_enabled: typeof config.telemetry_enabled === 'boolean' ? config.telemetry_enabled : null,
61
+ telemetry_install_id: typeof config.telemetry_install_id === 'string' ? config.telemetry_install_id : (config.telemetry_enabled ? randomUUID() : null),
62
+ telemetry_prompted_at: typeof config.telemetry_prompted_at === 'string' ? config.telemetry_prompted_at : null,
63
+ };
64
+ writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`);
65
+ return { path: configPath, config: normalized };
66
+ }
67
+
68
+ export function enableTelemetry(homeDir = homedir()) {
69
+ const current = loadTelemetryConfig(homeDir);
70
+ return writeTelemetryConfig(homeDir, {
71
+ ...current,
72
+ telemetry_enabled: true,
73
+ telemetry_install_id: current.telemetry_install_id || randomUUID(),
74
+ telemetry_prompted_at: new Date().toISOString(),
75
+ });
76
+ }
77
+
78
+ export function disableTelemetry(homeDir = homedir()) {
79
+ const current = loadTelemetryConfig(homeDir);
80
+ return writeTelemetryConfig(homeDir, {
81
+ ...current,
82
+ telemetry_enabled: false,
83
+ telemetry_install_id: current.telemetry_install_id || randomUUID(),
84
+ telemetry_prompted_at: new Date().toISOString(),
85
+ });
86
+ }
87
+
88
+ export async function maybePromptForTelemetry({ homeDir = homedir(), stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
89
+ const runtime = getTelemetryRuntimeConfig(env);
90
+ if (!runtime.apiKey || runtime.disabled) {
91
+ return { prompted: false, enabled: false, available: Boolean(runtime.apiKey) && !runtime.disabled };
92
+ }
93
+
94
+ const current = loadTelemetryConfig(homeDir);
95
+ if (typeof current.telemetry_enabled === 'boolean') {
96
+ return { prompted: false, enabled: current.telemetry_enabled, available: true };
97
+ }
98
+
99
+ if (!stdin?.isTTY || !stdout?.isTTY) {
100
+ return { prompted: false, enabled: false, available: true };
101
+ }
102
+
103
+ const rl = readline.createInterface({ input: stdin, output: stdout });
104
+ try {
105
+ stdout.write('\nHelp improve Switchman?\n');
106
+ stdout.write('If you opt in, Switchman will send anonymous usage events like setup success,\n');
107
+ stdout.write('verify-setup pass, status --watch, queue usage, and gate outcomes.\n');
108
+ stdout.write('No code, prompts, file contents, repo names, or secrets are collected.\n\n');
109
+ const answer = await rl.question('Enable telemetry? [y/N] ');
110
+ const enabled = ['y', 'yes'].includes(String(answer || '').trim().toLowerCase());
111
+ if (enabled) {
112
+ enableTelemetry(homeDir);
113
+ } else {
114
+ disableTelemetry(homeDir);
115
+ }
116
+ return { prompted: true, enabled, available: true };
117
+ } finally {
118
+ rl.close();
119
+ }
120
+ }
121
+
122
+ export async function captureTelemetryEvent(event, properties = {}, {
123
+ homeDir = homedir(),
124
+ env = process.env,
125
+ timeoutMs = 1500,
126
+ } = {}) {
127
+ const result = await sendTelemetryEvent(event, properties, {
128
+ homeDir,
129
+ env,
130
+ timeoutMs,
131
+ });
132
+ return result.ok;
133
+ }
134
+
135
+ export async function sendTelemetryEvent(event, properties = {}, {
136
+ homeDir = homedir(),
137
+ env = process.env,
138
+ timeoutMs = 1500,
139
+ } = {}) {
140
+ const runtime = getTelemetryRuntimeConfig(env);
141
+ if (!runtime.apiKey) {
142
+ return {
143
+ ok: false,
144
+ reason: 'not_configured',
145
+ status: null,
146
+ destination: runtime.host,
147
+ };
148
+ }
149
+ if (runtime.disabled) {
150
+ return {
151
+ ok: false,
152
+ reason: 'disabled_by_env',
153
+ status: null,
154
+ destination: runtime.host,
155
+ };
156
+ }
157
+ if (typeof fetch !== 'function') {
158
+ return {
159
+ ok: false,
160
+ reason: 'fetch_unavailable',
161
+ status: null,
162
+ destination: runtime.host,
163
+ };
164
+ }
165
+
166
+ const config = loadTelemetryConfig(homeDir);
167
+ if (config.telemetry_enabled !== true || !config.telemetry_install_id) {
168
+ return {
169
+ ok: false,
170
+ reason: 'not_enabled',
171
+ status: null,
172
+ destination: runtime.host,
173
+ };
174
+ }
175
+
176
+ try {
177
+ const controller = new AbortController();
178
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
179
+ const response = await fetch(`${runtime.host.replace(/\/$/, '')}/capture/`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({
183
+ api_key: runtime.apiKey,
184
+ event,
185
+ distinct_id: config.telemetry_install_id,
186
+ properties: {
187
+ source: 'switchman-cli',
188
+ ...properties,
189
+ },
190
+ timestamp: new Date().toISOString(),
191
+ }),
192
+ signal: controller.signal,
193
+ });
194
+ clearTimeout(timer);
195
+ return {
196
+ ok: response.ok,
197
+ reason: response.ok ? null : 'http_error',
198
+ status: response.status,
199
+ destination: runtime.host,
200
+ };
201
+ } catch (err) {
202
+ return {
203
+ ok: false,
204
+ reason: err?.name === 'AbortError' ? 'timeout' : 'network_error',
205
+ status: null,
206
+ destination: runtime.host,
207
+ error: String(err?.message || err),
208
+ };
209
+ }
210
+ }