escribano 0.1.4 → 0.2.2

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.
@@ -7,10 +7,12 @@
7
7
  import { execSync } from 'node:child_process';
8
8
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
9
  import { homedir } from 'node:os';
10
- import path from 'node:path';
10
+ import path, { dirname, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { log, step } from '../pipeline/context.js';
12
13
  import { normalizeAppNames } from '../services/app-normalization.js';
13
14
  import { groupTopicBlocksIntoSubjects, saveSubjectsToDatabase, } from '../services/subject-grouping.js';
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
16
  export async function generateArtifactV3(recordingId, repos, intelligence, options) {
15
17
  const format = options.format || 'card';
16
18
  log('info', `[Artifact V3.1] Generating ${format} artifact for recording ${recordingId}...`);
@@ -204,7 +206,7 @@ async function generateLlmArtifact(subjects, groupingResult, format, recording,
204
206
  : format === 'standup'
205
207
  ? 'standup.md'
206
208
  : 'summary-v3.md';
207
- const promptPath = path.join(process.cwd(), 'prompts', promptFileName);
209
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', promptFileName);
208
210
  let promptTemplate;
209
211
  try {
210
212
  promptTemplate = await readFile(promptPath, 'utf-8');
@@ -5,8 +5,10 @@
5
5
  */
6
6
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
7
  import { homedir } from 'node:os';
8
- import path from 'node:path';
8
+ import path, { dirname, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
9
10
  import { log } from '../pipeline/context.js';
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
12
  /**
11
13
  * Generate a work session summary artifact from processed TopicBlocks.
12
14
  *
@@ -81,7 +83,7 @@ export async function generateSummaryV3(recordingId, repos, intelligence, option
81
83
  */
82
84
  async function generateLlmSummary(sections, recording, intelligence) {
83
85
  // Read prompt template
84
- const promptPath = path.join(process.cwd(), 'prompts', 'summary-v3.md');
86
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', 'summary-v3.md');
85
87
  let promptTemplate;
86
88
  try {
87
89
  promptTemplate = await readFile(promptPath, 'utf-8');
@@ -1,9 +1,12 @@
1
1
  import { exec, spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
2
3
  import { mkdir, readFile, rm } from 'node:fs/promises';
3
4
  import os from 'node:os';
4
- import path from 'node:path';
5
+ import path, { dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import { promisify } from 'node:util';
6
8
  const execAsync = promisify(exec);
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
10
  export function createSileroPreprocessor() {
8
11
  let currentProcess = null;
9
12
  return {
@@ -19,19 +22,63 @@ export function createSileroPreprocessor() {
19
22
  catch (error) {
20
23
  throw new Error(`Failed to pre-convert audio for VAD: ${error.message}`);
21
24
  }
22
- const scriptPath = path.join(process.cwd(), 'src', 'scripts', 'audio_preprocessor.py');
25
+ const scriptPath = resolve(__dirname, '..', '..', 'src', 'scripts', 'audio_preprocessor.py');
26
+ if (!existsSync(scriptPath)) {
27
+ throw new Error(`Audio preprocessor script not found at: ${scriptPath}`);
28
+ }
23
29
  const command = `uv run "${scriptPath}" --audio "${inputWavPath}" --output-dir "${tempDir}" --output-json "${manifestPath}"`;
24
30
  try {
25
31
  console.log(`Running Silero VAD on ${inputWavPath}...`);
32
+ if (process.env.ESCRIBANO_VERBOSE === 'true') {
33
+ console.log(` Script path: ${scriptPath}`);
34
+ console.log(` Script exists: ${existsSync(scriptPath)}`);
35
+ console.log(` Command: ${command}`);
36
+ console.log(` Working directory (user): ${process.cwd()}`);
37
+ try {
38
+ const { stdout: uvVersion } = await execAsync('uv --version');
39
+ console.log(` uv version: ${uvVersion.trim()}`);
40
+ }
41
+ catch {
42
+ console.log(` uv version: NOT FOUND`);
43
+ }
44
+ }
26
45
  currentProcess = spawn('sh', ['-c', command]);
46
+ let stderr = '';
47
+ let stdout = '';
48
+ if (currentProcess.stderr) {
49
+ currentProcess.stderr.on('data', (data) => {
50
+ stderr += data.toString();
51
+ });
52
+ }
53
+ if (currentProcess.stdout) {
54
+ currentProcess.stdout.on('data', (data) => {
55
+ stdout += data.toString();
56
+ });
57
+ }
27
58
  await new Promise((resolve, reject) => {
28
59
  currentProcess?.on('close', (code) => {
29
60
  currentProcess = null;
30
61
  if (code === 0) {
62
+ if (process.env.ESCRIBANO_VERBOSE === 'true' && stdout) {
63
+ console.log(` Silero VAD stdout:\n${stdout
64
+ .split('\n')
65
+ .map((l) => ' ' + l)
66
+ .join('\n')}`);
67
+ }
31
68
  resolve();
32
69
  }
33
70
  else {
34
- reject(new Error(`Silero VAD failed with code ${code}`));
71
+ console.error(` Silero VAD stderr:\n${stderr
72
+ .split('\n')
73
+ .map((l) => ' ' + l)
74
+ .join('\n')}`);
75
+ if (stdout) {
76
+ console.error(` Silero VAD stdout:\n${stdout
77
+ .split('\n')
78
+ .map((l) => ' ' + l)
79
+ .join('\n')}`);
80
+ }
81
+ reject(new Error(`Silero VAD failed with code ${code}: ${stderr || stdout || 'No output captured'}`));
35
82
  }
36
83
  });
37
84
  currentProcess?.on('error', (err) => {
@@ -4,12 +4,14 @@
4
4
  * Implements IntelligenceService using Ollama REST API
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { Agent, fetch as undiciFetch } from 'undici';
9
10
  import { z } from 'zod';
10
11
  import { classificationSchema, intelligenceConfigSchema, transcriptMetadataSchema, } from '../0_types.js';
11
12
  // Debug logging controlled by environment variable
12
13
  const DEBUG_OLLAMA = process.env.ESCRIBANO_DEBUG_OLLAMA === 'true';
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
15
  // TODO: put in an util
14
16
  export function debugLog(...args) {
15
17
  if (DEBUG_OLLAMA) {
@@ -193,7 +195,7 @@ async function classifySegmentWithOllama(segment, config, transcript) {
193
195
  return raw;
194
196
  }
195
197
  function loadClassifySegmentPrompt(segment, transcript) {
196
- const promptPath = join(process.cwd(), 'prompts', 'classify-segment.md');
198
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', 'classify-segment.md');
197
199
  let prompt = readFileSync(promptPath, 'utf-8');
198
200
  const timeRangeStr = `[${segment.timeRange[0]}s - ${segment.timeRange[1]}s]`;
199
201
  const ocrContext = segment.contexts.map((c) => `${c.type}: ${c.value}`).join(', ') || 'None';
@@ -208,7 +210,7 @@ function loadClassifySegmentPrompt(segment, transcript) {
208
210
  return prompt;
209
211
  }
210
212
  function loadClassifyPrompt(transcript, visualLogs) {
211
- const promptPath = join(process.cwd(), 'prompts', 'classify.md');
213
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', 'classify.md');
212
214
  let prompt = readFileSync(promptPath, 'utf-8');
213
215
  const segmentsText = transcript.segments
214
216
  .map((seg) => `[seg-${seg.id}] [${seg.start}s - ${seg.end}s] ${seg.text}`)
@@ -241,7 +243,7 @@ function loadClassifyPrompt(transcript, visualLogs) {
241
243
  */
242
244
  function buildVLMSingleImagePrompt() {
243
245
  try {
244
- const promptPath = join(process.cwd(), 'prompts', 'vlm-single.md');
246
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', 'vlm-single.md');
245
247
  return readFileSync(promptPath, 'utf-8');
246
248
  }
247
249
  catch {
@@ -426,7 +428,7 @@ async function extractTopicsWithOllama(observations, config) {
426
428
  return [];
427
429
  let prompt;
428
430
  try {
429
- const promptPath = join(process.cwd(), 'prompts', 'topic-extract.md');
431
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', 'topic-extract.md');
430
432
  const template = readFileSync(promptPath, 'utf-8');
431
433
  prompt = template.replace('{{OBSERVATIONS}}', textSamples.join('\n---\n'));
432
434
  }
@@ -657,7 +659,7 @@ async function extractMetadata(transcript, classification, config, visualLogs) {
657
659
  return raw;
658
660
  }
659
661
  function loadMetadataPrompt(transcript, classification, visualLogs) {
660
- const promptPath = join(process.cwd(), 'prompts', 'extract-metadata.md');
662
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', 'extract-metadata.md');
661
663
  let prompt = readFileSync(promptPath, 'utf-8');
662
664
  const classificationSummary = Object.entries(classification)
663
665
  .filter(([_, score]) => score >= 25)
@@ -698,7 +700,7 @@ async function generateArtifact(artifactType, context, config) {
698
700
  return response;
699
701
  }
700
702
  function loadArtifactPrompt(artifactType, context) {
701
- const promptPath = join(process.cwd(), 'prompts', `${artifactType}.md`);
703
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', `${artifactType}.md`);
702
704
  let prompt = readFileSync(promptPath, 'utf-8');
703
705
  // TODO: Implement robust transcript cleaning (Milestone 4)
704
706
  prompt = prompt.replace('{{TRANSCRIPT_ALL}}', context.transcript.fullText);
@@ -5,12 +5,15 @@
5
5
  * Used for extracting screenshots and detecting scene changes.
6
6
  */
7
7
  import { exec, spawn } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
8
9
  import { mkdir, readdir, readFile, rm } from 'node:fs/promises';
9
10
  import os from 'node:os';
10
- import path from 'node:path';
11
+ import path, { dirname, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
11
13
  import { promisify } from 'node:util';
12
14
  import { debugLog } from './intelligence.ollama.adapter.js';
13
15
  const execAsync = promisify(exec);
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
17
  // Scene detection configuration (with env var overrides)
15
18
  // Lower threshold = more sensitive = more scene changes detected
16
19
  // Examples: 0.3 (sensitive), 0.4 (default), 0.5 (conservative)
@@ -268,15 +271,16 @@ export function createFfmpegVideoService() {
268
271
  * OCR is parallelized across all available CPU cores.
269
272
  */
270
273
  runVisualIndexing: async (framesDir, outputPath) => {
271
- const scriptPath = path.join(process.cwd(), 'src', 'scripts', 'visual_observer_base.py');
274
+ const scriptPath = resolve(__dirname, '..', '..', 'src', 'scripts', 'visual_observer_base.py');
275
+ if (!existsSync(scriptPath)) {
276
+ throw new Error(`Visual observer script not found at: ${scriptPath}`);
277
+ }
272
278
  const frameInterval = Number(process.env.ESCRIBANO_FRAME_INTERVAL) || 2;
273
279
  const workers = os.cpus().length;
274
- // Use uv run to execute the script with its environment
275
- // --workers enables parallel OCR processing
276
280
  const command = `uv run "${scriptPath}" --frames-dir "${framesDir}" --output "${outputPath}" --frame-interval ${frameInterval} --workers ${workers}`;
277
281
  try {
278
282
  await execAsync(command, {
279
- cwd: path.join(process.cwd(), 'src', 'scripts'),
283
+ cwd: dirname(scriptPath),
280
284
  });
281
285
  const content = await readFile(outputPath, 'utf-8');
282
286
  return JSON.parse(content);
package/dist/index.js CHANGED
@@ -7,16 +7,22 @@
7
7
  */
8
8
  import { homedir } from 'node:os';
9
9
  import path from 'node:path';
10
+ import pkg from '../package.json' with { type: 'json' };
10
11
  import { createCapCaptureSource } from './adapters/capture.cap.adapter.js';
11
12
  import { createFilesystemCaptureSource } from './adapters/capture.filesystem.adapter.js';
12
13
  import { cleanupMlxBridge, initializeSystem, processVideo, } from './batch-context.js';
13
14
  import { getDbPath } from './db/index.js';
14
15
  import { checkPrerequisites, hasMissingPrerequisites, printDoctorResults, } from './prerequisites.js';
16
+ import { logEnvironmentVariables } from './utils/env-logger.js';
15
17
  const MODELS_DIR = path.join(homedir(), '.escribano', 'models');
16
18
  const MODEL_FILE = 'ggml-large-v3.bin';
17
19
  const MODEL_PATH = path.join(MODELS_DIR, MODEL_FILE);
18
20
  function main() {
19
21
  const args = parseArgs(process.argv.slice(2));
22
+ if (args.version) {
23
+ console.log(`escribano v${pkg.version}`);
24
+ process.exit(0);
25
+ }
20
26
  if (args.help) {
21
27
  showHelp();
22
28
  process.exit(0);
@@ -53,6 +59,7 @@ function parseArgs(argsArray) {
53
59
  return {
54
60
  force: argsArray.includes('--force'),
55
61
  help: argsArray.includes('--help') || argsArray.includes('-h'),
62
+ version: argsArray.includes('--version') || argsArray.includes('-v'),
56
63
  doctor: argsArray[0] === 'doctor',
57
64
  file: filePath,
58
65
  skipSummary: argsArray.includes('--skip-summary'),
@@ -82,6 +89,7 @@ Usage:
82
89
  npx escribano --include-personal Include personal time in artifact
83
90
  npx escribano --copy Copy artifact to clipboard
84
91
  npx escribano --stdout Print artifact to stdout
92
+ npx escribano --version Show version number
85
93
  npx escribano --help Show this help
86
94
 
87
95
  Examples:
@@ -96,6 +104,8 @@ Output: Markdown summary saved to ~/.escribano/artifacts/
96
104
  }
97
105
  async function run(args) {
98
106
  const { force, file: filePath, skipSummary, micAudio, systemAudio, format, includePersonal, copyToClipboard, printToStdout, } = args;
107
+ // Log environment variables if verbose mode is enabled
108
+ logEnvironmentVariables();
99
109
  // Initialize system (reuses batch-context for consistency)
100
110
  console.log('Initializing database...');
101
111
  const ctx = await initializeSystem();
@@ -5,7 +5,9 @@
5
5
  * This is the foundation for the new artifact architecture.
6
6
  */
7
7
  import { readFileSync } from 'node:fs';
8
- import { join } from 'node:path';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
11
  const PERSONAL_APPS = new Set([
10
12
  'WhatsApp',
11
13
  'Instagram',
@@ -144,7 +146,7 @@ ID: ${b.id}`;
144
146
  : `"${blockIdList[0]}"`;
145
147
  let template;
146
148
  try {
147
- const promptPath = join(process.cwd(), 'prompts', 'subject-grouping.md');
149
+ const promptPath = resolve(__dirname, '..', '..', 'prompts', 'subject-grouping.md');
148
150
  template = readFileSync(promptPath, 'utf-8');
149
151
  }
150
152
  catch {
@@ -0,0 +1,262 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ // Mock fs module
4
+ vi.mock('node:fs', () => ({
5
+ readFileSync: vi.fn(),
6
+ }));
7
+ // Mock process.env
8
+ const originalEnv = { ...process.env };
9
+ beforeEach(() => {
10
+ vi.clearAllMocks();
11
+ // Reset process.env
12
+ process.env = { ...originalEnv };
13
+ });
14
+ // Import after mocking
15
+ const { logEnvironmentVariables } = await import('../../utils/env-logger.js');
16
+ describe('Environment Variable Logger', () => {
17
+ describe('parseEnvExample', () => {
18
+ it('parses simple variable with description', async () => {
19
+ const mockContent = `# Enable verbose logging
20
+ ESCRIBANO_VERBOSE=false`;
21
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
22
+ // Call the function indirectly via logEnvironmentVariables
23
+ process.env.ESCRIBANO_VERBOSE = 'true';
24
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
25
+ logEnvironmentVariables();
26
+ expect(consoleSpy).toHaveBeenCalled();
27
+ const output = consoleSpy.mock.calls
28
+ .map((call) => call.join(' '))
29
+ .join('\n');
30
+ expect(output).toContain('ESCRIBANO_VERBOSE');
31
+ expect(output).toContain('Enable verbose logging');
32
+ consoleSpy.mockRestore();
33
+ });
34
+ it('skips section headers', async () => {
35
+ const mockContent = `# === Frame Extraction ===
36
+ # Output frame width
37
+ ESCRIBANO_FRAME_WIDTH=1024`;
38
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
39
+ process.env.ESCRIBANO_VERBOSE = 'true';
40
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
41
+ logEnvironmentVariables();
42
+ const output = consoleSpy.mock.calls
43
+ .map((call) => call.join(' '))
44
+ .join('\n');
45
+ expect(output).toContain('ESCRIBANO_FRAME_WIDTH');
46
+ expect(output).toContain('Output frame width');
47
+ expect(output).not.toContain('Frame Extraction');
48
+ consoleSpy.mockRestore();
49
+ });
50
+ it('skips commented/deprecated variables', async () => {
51
+ const mockContent = `# Active variable
52
+ ESCRIBANO_VERBOSE=false
53
+ # ESCRIBANO_DEPRECATED=value`;
54
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
55
+ process.env.ESCRIBANO_VERBOSE = 'true';
56
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
57
+ logEnvironmentVariables();
58
+ const output = consoleSpy.mock.calls
59
+ .map((call) => call.join(' '))
60
+ .join('\n');
61
+ expect(output).toContain('ESCRIBANO_VERBOSE');
62
+ expect(output).not.toContain('ESCRIBANO_DEPRECATED');
63
+ consoleSpy.mockRestore();
64
+ });
65
+ it('skips non-ESCRIBANO variables', async () => {
66
+ const mockContent = `ESCRIBANO_VERBOSE=false
67
+ OTHER_VAR=value`;
68
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
69
+ process.env.ESCRIBANO_VERBOSE = 'true';
70
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
71
+ logEnvironmentVariables();
72
+ const output = consoleSpy.mock.calls
73
+ .map((call) => call.join(' '))
74
+ .join('\n');
75
+ expect(output).toContain('ESCRIBANO_VERBOSE');
76
+ expect(output).not.toContain('OTHER_VAR');
77
+ consoleSpy.mockRestore();
78
+ });
79
+ it('handles empty file gracefully', async () => {
80
+ vi.mocked(readFileSync).mockReturnValue('');
81
+ process.env.ESCRIBANO_VERBOSE = 'true';
82
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
83
+ logEnvironmentVariables();
84
+ const output = consoleSpy.mock.calls
85
+ .map((call) => call.join(' '))
86
+ .join('\n');
87
+ expect(output).toContain('Environment Variables');
88
+ consoleSpy.mockRestore();
89
+ });
90
+ it('handles file not found gracefully', async () => {
91
+ vi.mocked(readFileSync).mockImplementation(() => {
92
+ throw new Error('ENOENT');
93
+ });
94
+ process.env.ESCRIBANO_VERBOSE = 'true';
95
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
96
+ logEnvironmentVariables();
97
+ const output = consoleSpy.mock.calls
98
+ .map((call) => call.join(' '))
99
+ .join('\n');
100
+ expect(output).toContain('Could not parse .env.example');
101
+ consoleSpy.mockRestore();
102
+ });
103
+ });
104
+ describe('logEnvironmentVariables', () => {
105
+ it('does not log when ESCRIBANO_VERBOSE is false', () => {
106
+ process.env.ESCRIBANO_VERBOSE = 'false';
107
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
108
+ logEnvironmentVariables();
109
+ expect(consoleSpy).not.toHaveBeenCalled();
110
+ consoleSpy.mockRestore();
111
+ });
112
+ it('does not log when ESCRIBANO_VERBOSE is not set', () => {
113
+ delete process.env.ESCRIBANO_VERBOSE;
114
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
115
+ logEnvironmentVariables();
116
+ expect(consoleSpy).not.toHaveBeenCalled();
117
+ consoleSpy.mockRestore();
118
+ });
119
+ it('logs when ESCRIBANO_VERBOSE is true', () => {
120
+ const mockContent = 'ESCRIBANO_VERBOSE=false';
121
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
122
+ process.env.ESCRIBANO_VERBOSE = 'true';
123
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
124
+ logEnvironmentVariables();
125
+ expect(consoleSpy).toHaveBeenCalled();
126
+ consoleSpy.mockRestore();
127
+ });
128
+ it('marks custom values with [CUSTOM]', () => {
129
+ const mockContent = `# Default batch size
130
+ ESCRIBANO_VLM_BATCH_SIZE=4`;
131
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
132
+ process.env.ESCRIBANO_VERBOSE = 'true';
133
+ process.env.ESCRIBANO_VLM_BATCH_SIZE = '8';
134
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
135
+ logEnvironmentVariables();
136
+ const output = consoleSpy.mock.calls
137
+ .map((call) => call.join(' '))
138
+ .join('\n');
139
+ expect(output).toContain('[CUSTOM]');
140
+ expect(output).toContain('Current: 8');
141
+ expect(output).toContain('Default: 4');
142
+ consoleSpy.mockRestore();
143
+ });
144
+ it('does not mark default values', () => {
145
+ const mockContent = `# Default batch size
146
+ ESCRIBANO_VLM_BATCH_SIZE=4`;
147
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
148
+ process.env.ESCRIBANO_VERBOSE = 'true';
149
+ process.env.ESCRIBANO_VLM_BATCH_SIZE = '4';
150
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
151
+ logEnvironmentVariables();
152
+ const output = consoleSpy.mock.calls
153
+ .map((call) => call.join(' '))
154
+ .join('\n');
155
+ expect(output).not.toContain('[CUSTOM]');
156
+ consoleSpy.mockRestore();
157
+ });
158
+ it('masks secret tokens', () => {
159
+ const mockContent = `ESCRIBANO_OUTLINE_TOKEN=`;
160
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
161
+ process.env.ESCRIBANO_VERBOSE = 'true';
162
+ process.env.ESCRIBANO_OUTLINE_TOKEN = 'secret-api-key-123';
163
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
164
+ logEnvironmentVariables();
165
+ const output = consoleSpy.mock.calls
166
+ .map((call) => call.join(' '))
167
+ .join('\n');
168
+ expect(output).toContain('***');
169
+ expect(output).not.toContain('secret-api-key-123');
170
+ consoleSpy.mockRestore();
171
+ });
172
+ it('does not mask non-secret values', () => {
173
+ const mockContent = `ESCRIBANO_VLM_BATCH_SIZE=4`;
174
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
175
+ process.env.ESCRIBANO_VERBOSE = 'true';
176
+ process.env.ESCRIBANO_VLM_BATCH_SIZE = '8';
177
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
178
+ logEnvironmentVariables();
179
+ const output = consoleSpy.mock.calls
180
+ .map((call) => call.join(' '))
181
+ .join('\n');
182
+ expect(output).toContain('8');
183
+ expect(output).not.toContain('***');
184
+ consoleSpy.mockRestore();
185
+ });
186
+ it('shows "not set" for undefined variables', () => {
187
+ const mockContent = `ESCRIBANO_VLM_BATCH_SIZE=4`;
188
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
189
+ process.env.ESCRIBANO_VERBOSE = 'true';
190
+ delete process.env.ESCRIBANO_VLM_BATCH_SIZE;
191
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
192
+ logEnvironmentVariables();
193
+ const output = consoleSpy.mock.calls
194
+ .map((call) => call.join(' '))
195
+ .join('\n');
196
+ expect(output).toContain('not set');
197
+ consoleSpy.mockRestore();
198
+ });
199
+ it('shows "(empty)" for empty default values', () => {
200
+ const mockContent = `ESCRIBANO_OUTLINE_TOKEN=`;
201
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
202
+ process.env.ESCRIBANO_VERBOSE = 'true';
203
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
204
+ logEnvironmentVariables();
205
+ const output = consoleSpy.mock.calls
206
+ .map((call) => call.join(' '))
207
+ .join('\n');
208
+ expect(output).toContain('(empty)');
209
+ consoleSpy.mockRestore();
210
+ });
211
+ it('sorts variables alphabetically', () => {
212
+ const mockContent = `ESCRIBANO_ZEBRA=1
213
+ ESCRIBANO_ALPHA=2
214
+ ESCRIBANO_MIDDLE=3`;
215
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
216
+ process.env.ESCRIBANO_VERBOSE = 'true';
217
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
218
+ logEnvironmentVariables();
219
+ const output = consoleSpy.mock.calls
220
+ .map((call) => call.join(' '))
221
+ .join('\n');
222
+ const alphaIndex = output.indexOf('ESCRIBANO_ALPHA');
223
+ const middleIndex = output.indexOf('ESCRIBANO_MIDDLE');
224
+ const zebraIndex = output.indexOf('ESCRIBANO_ZEBRA');
225
+ expect(alphaIndex).toBeLessThan(middleIndex);
226
+ expect(middleIndex).toBeLessThan(zebraIndex);
227
+ consoleSpy.mockRestore();
228
+ });
229
+ it('includes multi-line descriptions', () => {
230
+ const mockContent = `# First line of description
231
+ # Second line of description
232
+ ESCRIBANO_VERBOSE=false`;
233
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
234
+ process.env.ESCRIBANO_VERBOSE = 'true';
235
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
236
+ logEnvironmentVariables();
237
+ const output = consoleSpy.mock.calls
238
+ .map((call) => call.join(' '))
239
+ .join('\n');
240
+ expect(output).toContain('First line of description');
241
+ expect(output).toContain('Second line of description');
242
+ consoleSpy.mockRestore();
243
+ });
244
+ });
245
+ describe('text wrapping', () => {
246
+ it('wraps long descriptions to fit width', () => {
247
+ const longDescription = 'This is a very long description that should be wrapped across multiple lines to fit within the specified width limit for better readability in the console output';
248
+ const mockContent = `# ${longDescription}
249
+ ESCRIBANO_VERBOSE=false`;
250
+ vi.mocked(readFileSync).mockReturnValue(mockContent);
251
+ process.env.ESCRIBANO_VERBOSE = 'true';
252
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
253
+ logEnvironmentVariables();
254
+ // The description should appear in the output
255
+ const output = consoleSpy.mock.calls
256
+ .map((call) => call.join(' '))
257
+ .join('\n');
258
+ expect(output).toContain('very long description');
259
+ consoleSpy.mockRestore();
260
+ });
261
+ });
262
+ });