Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 162 additions & 23 deletions packages/scripts/src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { spawn } from 'node:child_process';
import type { EventEmitter } from 'node:events';
import fs from 'node:fs';
import path from 'node:path';
import ora, { type Ora } from 'ora';
Expand Down Expand Up @@ -332,38 +333,176 @@ const createProcessHandlers = (
return { onData, onClose, onError };
};

const BACKEND_START_INACTIVITY_TIMEOUT_MS = 300_000;
const OUTPUT_TAIL_LIMIT = 2_000;
const PROGRESS_LINE_LIMIT = 100;
const PROGRESS_CARRY_LIMIT = 4_000;

export interface MonitoredProcess {
stdout: EventEmitter | null;
stderr: EventEmitter | null;
on(event: 'close', listener: (code: number | null) => void): unknown;
on(event: 'error', listener: (err: Error) => void): unknown;
kill(): unknown;
}

export type OutputStream = 'stdout' | 'stderr';

export interface BackendStartOptions {
inactivityTimeoutMs: number;
onOutput?: (chunk: string, stream: OutputStream) => void;
}

const REDACTED = '[redacted]';

const SENSITIVE_LABEL_PATTERN =
/\b((?:jwt|anon|service[_ ]role|publishable|secret|access|s3)[a-z_ ]*(?:key|secret|token|password)[a-z_ ]*)(\s*[:=]\s*)(\S+)/gi;

const SENSITIVE_TOKEN_PATTERNS: RegExp[] = [
/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g,
/\bsb_[a-z]+_[A-Za-z0-9_-]{8,}/g,
];

const redactSensitiveOutput = (text: string): string => {
const withoutLabelledValues = text.replace(
SENSITIVE_LABEL_PATTERN,
(_match, label: string, separator: string) => `${label}${separator}${REDACTED}`,
);

return SENSITIVE_TOKEN_PATTERNS.reduce(
(redacted, pattern) => redacted.replace(pattern, REDACTED),
withoutLabelledValues,
);
};

const lastNonEmptyLine = (chunk: string): string | undefined =>
chunk
.split(/[\r\n]+/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.pop();

/**
* Builds a reporter that keeps incomplete lines in a carry buffer so redaction
* always runs on whole lines
* @returns Function returning the redacted progress line for a chunk, if any
*/
export const createProgressReporter = (): ((chunk: string) => string | undefined) => {
let carry = '';

return (chunk: string): string | undefined => {
const combined = carry + chunk;
const lastBreak = Math.max(combined.lastIndexOf('\n'), combined.lastIndexOf('\r'));

if (lastBreak === -1) {
carry = combined.slice(0, PROGRESS_CARRY_LIMIT);
return undefined;
}

carry = combined.slice(lastBreak + 1, lastBreak + 1 + PROGRESS_CARRY_LIMIT);
const progress = lastNonEmptyLine(redactSensitiveOutput(combined.slice(0, lastBreak)));
return progress?.slice(0, PROGRESS_LINE_LIMIT);
};
};

export const createStreamProgressReporter = (): ((
chunk: string,
stream: OutputStream,
) => string | undefined) => {
const reporters: Record<OutputStream, (chunk: string) => string | undefined> = {
stdout: createProgressReporter(),
stderr: createProgressReporter(),
};

return (chunk: string, stream: OutputStream): string | undefined => reporters[stream](chunk);
};

export const waitForBackendStart = (
proc: MonitoredProcess,
{ inactivityTimeoutMs, onOutput }: BackendStartOptions,
): Promise<void> =>
new Promise<void>((resolve, reject) => {
let settled = false;
let timeout: NodeJS.Timeout | undefined;

const settle = (finish: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
finish();
};

const armTimeout = () => {
timeout = setTimeout(() => {
settle(() => {
proc.kill();
reject(
new Error(
`Supabase produced no output for ${Math.round(inactivityTimeoutMs / 1000)}s while starting.`,
),
);
});
}, inactivityTimeoutMs);
};

const onData = (stream: OutputStream) => (chunk: Buffer | string) => {
if (settled) return;
clearTimeout(timeout);
armTimeout();
onOutput?.(chunk.toString(), stream);
};

proc.stdout?.on('data', onData('stdout'));
proc.stderr?.on('data', onData('stderr'));

proc.on('close', (code) =>
settle(() => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Supabase start failed with exit code ${code ?? 'unknown'}.`));
}
}),
);

proc.on('error', (err) => settle(() => reject(err)));

armTimeout();
});

const startBackendAndExtractKeys = async (): Promise<BackendKeys> => {
console.log(chalk.yellow('🚀 Starting Supabase backend...'));
const spinner = ora('Waiting for Supabase to initialize...').start();

const startProc = spawn('bun run', ['backend:start'], { cwd: rootDir, shell: true });
const startProc = spawn('bun', ['run', 'backend:start'], {
cwd: rootDir,
stdio: ['ignore', 'pipe', 'pipe'],
});

await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
startProc.kill();
spinner.fail('Timed out waiting for Supabase keys.');
reject(new Error('Supabase start timeout'));
}, 120_000);
let outputTail = '';
const reportProgress = createStreamProgressReporter();

startProc.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) {
resolve();
} else {
spinner.fail('Failed to start Supabase backend.');
reject(new Error('Supabase start failed'));
}
});

startProc.on('error', (err) => {
clearTimeout(timeout);
spinner.fail(`Backend error: ${err.message}`);
reject(err);
try {
await waitForBackendStart(startProc, {
inactivityTimeoutMs: BACKEND_START_INACTIVITY_TIMEOUT_MS,
onOutput: (chunk, stream) => {
outputTail = (outputTail + chunk).slice(-OUTPUT_TAIL_LIMIT);
const progress = reportProgress(chunk, stream);
if (progress) {
spinner.text = `Waiting for Supabase to initialize... ${progress}`;
}
},
});
});
} catch (error) {
spinner.fail((error as Error).message);
const redactedTail = redactSensitiveOutput(outputTail).trim();
if (redactedTail) {
console.error(chalk.gray(redactedTail));
}
throw error;
}

spinner.succeed('Supabase backend started.');

// Now get all keys from status
const keysSpinner = ora('Extracting Supabase keys...').start();
const backendDir = path.join(rootDir, 'apps', 'backend');
Expand Down
46 changes: 45 additions & 1 deletion packages/scripts/test/comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import fs from 'node:fs';
import path from 'node:path';

// Import actual functions to test
import { getDbEnvContent, generateBackendEnvContent, CLIENT_BACKEND_KEYS } from '../src/backend';
import {
getDbEnvContent,
generateBackendEnvContent,
CLIENT_BACKEND_KEYS,
createProgressReporter,
createStreamProgressReporter,
} from '../src/backend';
import { parseEnvContent, buildEnvFileContent, writeEnvFile } from '../src/helpers';

describe('comprehensive functionality tests', () => {
Expand Down Expand Up @@ -397,6 +403,44 @@ Supabase local development setup completed.
});
});

describe('Startup progress redaction', () => {
it('should redact a labeled secret split across chunks', () => {
const reportProgress = createProgressReporter();

expect(reportProgress('SERVICE_ROLE_KEY=')).toBeUndefined();
expect(reportProgress('super-secret-value\n')).toBe('SERVICE_ROLE_KEY=[redacted]');
});

it('should redact a JWT split across chunks', () => {
const reportProgress = createProgressReporter();

expect(reportProgress('starting...\neyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJz')).toBe(
'starting...',
);
expect(reportProgress('dWIiOiIxMjM0NTY3ODkwIn0.signature\n')).toBe('[redacted]');
});

it('should report plain progress lines unchanged', () => {
const reportProgress = createProgressReporter();

expect(reportProgress('Starting containers...\n')).toBe('Starting containers...');
expect(reportProgress('Seeding data')).toBeUndefined();
expect(reportProgress('...done\r')).toBe('Seeding data...done');
});

it('should keep stdout and stderr carry buffers separate', () => {
const reportProgress = createStreamProgressReporter();

expect(reportProgress('SERVICE_ROLE_KEY=', 'stdout')).toBeUndefined();
expect(reportProgress('warning: docker is slow\n', 'stderr')).toBe(
'warning: docker is slow',
);
expect(reportProgress('super-secret-value\n', 'stdout')).toBe(
'SERVICE_ROLE_KEY=[redacted]',
);
});
});

describe('Real function tests with mixed environment variables', () => {
it('should parse mixed environment variables correctly using actual parseEnvContent', () => {
const complexEnvContent = `# App configuration
Expand Down