From a785484b349ed9256c58bd473e2f110cc5846dc1 Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 4 Mar 2026 23:19:40 +0900 Subject: [PATCH 01/12] feat: profiles --- source/commands/browser/start.tsx | 11 +++ source/utils/browser/lifecycle.ts | 75 +++++++++++++++---- source/utils/browser/lifecycle/api-client.ts | 8 ++ .../browser/lifecycle/bootstrap-flags.ts | 23 ++++++ .../utils/browser/lifecycle/profile-store.ts | 57 ++++++++++++++ source/utils/browser/lifecycle/types.ts | 4 + 6 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 source/utils/browser/lifecycle/profile-store.ts diff --git a/source/commands/browser/start.tsx b/source/commands/browser/start.tsx index a5a9347..2b3b346 100644 --- a/source/commands/browser/start.tsx +++ b/source/commands/browser/start.tsx @@ -91,6 +91,15 @@ export const options = zod.object({ }), ) .optional(), + profile: zod + .string() + .describe( + option({ + description: + 'Profile directory to persist browser state across sessions', + }), + ) + .optional(), }); type Props = { @@ -111,6 +120,7 @@ export default function Start({options}: Props) { headless: options.sessionHeadless, region: options.sessionRegion, solveCaptcha: options.sessionSolveCaptcha, + profileDir: options.profile, }); console.log(`id: ${session.id}`); @@ -149,6 +159,7 @@ export default function Start({options}: Props) { }, [ options.apiUrl, options.local, + options.profile, options.proxy, options.sessionHeadless, options.sessionRegion, diff --git a/source/utils/browser/lifecycle.ts b/source/utils/browser/lifecycle.ts index ae1f509..22e36f0 100644 --- a/source/utils/browser/lifecycle.ts +++ b/source/utils/browser/lifecycle.ts @@ -9,6 +9,10 @@ import { solveSessionCaptchaFromApi, } from './lifecycle/api-client.js'; import {parseBrowserPassthroughBootstrapFlags} from './lifecycle/bootstrap-flags.js'; +import { + readSteelProfile, + writeSteelProfile, +} from './lifecycle/profile-store.js'; import { buildDeadSessionMessage, getSessionId, @@ -159,19 +163,56 @@ export async function startBrowserSession( continue; } - const createdSession = await createSessionFromApi( - mode, - { - stealth: options.stealth, - proxyUrl: options.proxyUrl, - timeoutMs: options.timeoutMs, - headless: options.headless, - region: options.region, - solveCaptcha: options.solveCaptcha, - }, - environment, - apiUrl, - ); + let resolvedProfileId: string | undefined; + + if (options.profileDir) { + const stored = await readSteelProfile(options.profileDir); + resolvedProfileId = stored?.profileId; + } + + let createdSession: UnknownRecord; + try { + createdSession = await createSessionFromApi( + mode, + { + stealth: options.stealth, + proxyUrl: options.proxyUrl, + timeoutMs: options.timeoutMs, + headless: options.headless, + region: options.region, + solveCaptcha: options.solveCaptcha, + profileId: resolvedProfileId, + persistProfile: Boolean(options.profileDir), + }, + environment, + apiUrl, + ); + } catch (error) { + if ( + options.profileDir && + resolvedProfileId && + isNotFoundApiError(error) + ) { + console.error(`Warning: Profile not found. Creating a new profile.`); + createdSession = await createSessionFromApi( + mode, + { + stealth: options.stealth, + proxyUrl: options.proxyUrl, + timeoutMs: options.timeoutMs, + headless: options.headless, + region: options.region, + solveCaptcha: options.solveCaptcha, + persistProfile: true, + }, + environment, + apiUrl, + ); + } else { + throw error; + } + } + const createdSessionId = getSessionId(createdSession); if (!createdSessionId) { @@ -199,6 +240,13 @@ export async function startBrowserSession( }); if (claimedCreatedSession) { + if (options.profileDir) { + const returnedProfileId = createdSession['profileId']; + if (typeof returnedProfileId === 'string') { + await writeSteelProfile(options.profileDir, returnedProfileId); + } + } + return toSessionSummary(createdSession, mode, sessionName, environment); } @@ -643,6 +691,7 @@ export async function bootstrapBrowserPassthroughArgv( solveCaptcha: parsed.options.solveCaptcha || undefined, deadSessionBehavior: 'error', environment, + profileDir: parsed.options.profileDir || undefined, }); if (!session.connectUrl) { diff --git a/source/utils/browser/lifecycle/api-client.ts b/source/utils/browser/lifecycle/api-client.ts index 06ef04c..c434397 100644 --- a/source/utils/browser/lifecycle/api-client.ts +++ b/source/utils/browser/lifecycle/api-client.ts @@ -354,6 +354,14 @@ export async function createSessionFromApi( payload['solveCaptcha'] = true; } + if (options.profileId) { + payload['profileId'] = options.profileId; + } + + if (options.persistProfile) { + payload['persistProfile'] = true; + } + const responseData = await requestApi( mode, environment, diff --git a/source/utils/browser/lifecycle/bootstrap-flags.ts b/source/utils/browser/lifecycle/bootstrap-flags.ts index 2e3119f..b778093 100644 --- a/source/utils/browser/lifecycle/bootstrap-flags.ts +++ b/source/utils/browser/lifecycle/bootstrap-flags.ts @@ -21,6 +21,7 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { solveCaptcha: false, autoConnect: false, cdpTarget: null, + profileDir: process.env['STEEL_PROFILE']?.trim() || null, }; const passthroughArgv: string[] = []; @@ -201,6 +202,28 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { ); } + if (argument === '--profile' || argument.startsWith('--profile=')) { + const value = + argument === '--profile' + ? browserArgv[index + 1] + : argument.slice('--profile='.length); + + if (!value) { + throw new BrowserAdapterError( + 'INVALID_BROWSER_ARGS', + 'Missing value for --profile.', + ); + } + + options.profileDir = value.trim(); + + if (argument === '--profile') { + index++; + } + + continue; + } + if (argument === '--cdp' || argument.startsWith('--cdp=')) { const value = argument === '--cdp' diff --git a/source/utils/browser/lifecycle/profile-store.ts b/source/utils/browser/lifecycle/profile-store.ts new file mode 100644 index 0000000..7a7024e --- /dev/null +++ b/source/utils/browser/lifecycle/profile-store.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +type SteelProfileData = { + profileId: string; + updatedAt: string; +}; + +function expandProfileDir(dir: string): string { + if (dir.startsWith('~')) { + return path.join(os.homedir(), dir.slice(1)); + } + + return dir; +} + +export async function readSteelProfile( + dir: string, +): Promise<{profileId: string} | null> { + const expandedDir = expandProfileDir(dir); + const filePath = path.join(expandedDir, '.steel.json'); + + try { + const contents = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(contents) as unknown; + + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as Record)['profileId'] === 'string' + ) { + return {profileId: (parsed as SteelProfileData).profileId}; + } + + return null; + } catch { + return null; + } +} + +export async function writeSteelProfile( + dir: string, + profileId: string, +): Promise { + const expandedDir = expandProfileDir(dir); + + await fs.mkdir(expandedDir, {recursive: true}); + + const filePath = path.join(expandedDir, '.steel.json'); + const data: SteelProfileData = { + profileId, + updatedAt: new Date().toISOString(), + }; + + await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} diff --git a/source/utils/browser/lifecycle/types.ts b/source/utils/browser/lifecycle/types.ts index dc11c64..bafc26a 100644 --- a/source/utils/browser/lifecycle/types.ts +++ b/source/utils/browser/lifecycle/types.ts @@ -21,6 +21,8 @@ export type StartSessionRequestOptions = { headless?: boolean; region?: string; solveCaptcha?: boolean; + profileId?: string; + persistProfile?: boolean; }; export type SolveCaptchaRequestOptions = { @@ -41,6 +43,7 @@ export type ParsedBootstrapOptions = { solveCaptcha: boolean; autoConnect: boolean; cdpTarget: string | null; + profileDir: string | null; }; export type BrowserSessionSummary = { @@ -66,6 +69,7 @@ export type StartBrowserSessionOptions = { solveCaptcha?: boolean; deadSessionBehavior?: DeadSessionBehavior; environment?: NodeJS.ProcessEnv; + profileDir?: string; }; export type StopBrowserSessionOptions = { From 0812359b6eaf438b27b8c3740a0a3eab33c13bee Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 5 Mar 2026 00:05:18 +0900 Subject: [PATCH 02/12] test: add profiles test --- source/steel.tsx | 2 - tests/unit/browser-lifecycle.test.ts | 282 +++++++++++++++++++++++ tests/unit/browser-profile-store.test.ts | 169 ++++++++++++++ 3 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 tests/unit/browser-profile-store.test.ts diff --git a/source/steel.tsx b/source/steel.tsx index ef2961b..1e054db 100644 --- a/source/steel.tsx +++ b/source/steel.tsx @@ -8,8 +8,6 @@ import { checkAndUpdate, getCurrentVersion, setGlobalUpdateInfo, - subscribeToUpdateState, - type UpdateState, } from './utils/update.js'; import { filterSteelGlobalFlags, diff --git a/tests/unit/browser-lifecycle.test.ts b/tests/unit/browser-lifecycle.test.ts index 7f245d3..8b6ef23 100644 --- a/tests/unit/browser-lifecycle.test.ts +++ b/tests/unit/browser-lifecycle.test.ts @@ -103,6 +103,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: false, autoConnect: false, cdpTarget: null, + profileDir: null, }); expect(parsed.passthroughArgv).toEqual([ 'open', @@ -208,6 +209,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: false, autoConnect: false, cdpTarget: null, + profileDir: null, }); expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); } finally { @@ -245,6 +247,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: true, autoConnect: false, cdpTarget: null, + profileDir: null, }); expect(parsed.passthroughArgv).toEqual([ 'open', @@ -283,6 +286,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: true, autoConnect: false, cdpTarget: null, + profileDir: null, }); expect(parsed.passthroughArgv).toEqual([ 'open', @@ -1365,3 +1369,281 @@ describe('browser lifecycle session contract', () => { } }); }); + +describe('browser lifecycle --profile flag parsing', () => { + test('parses --profile flag into profileDir', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + '--profile', + '/tmp/my-profile', + ]); + + expect(parsed.options.profileDir).toBe('/tmp/my-profile'); + expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('parses --profile= (equals form) into profileDir', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + '--profile=/tmp/my-profile', + ]); + + expect(parsed.options.profileDir).toBe('/tmp/my-profile'); + expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('throws when --profile has no value', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + + expect(() => + lifecycle.parseBrowserPassthroughBootstrapFlags(['open', '--profile']), + ).toThrow('Missing value for --profile.'); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('profileDir defaults to null when STEEL_PROFILE is not set', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + delete process.env['STEEL_PROFILE']; + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + ]); + + expect(parsed.options.profileDir).toBeNull(); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); +}); + +describe('browser lifecycle session profile contract', () => { + test('creates new session with persistProfile when no .steel.json exists', async () => { + const configDirectory = createTempConfigDirectory(); + const profileDir = createTempConfigDirectory(); + + try { + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-new-profile', + status: 'live', + profileId: 'returned-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const session = await lifecycle.startBrowserSession({ + profileDir, + environment: {STEEL_API_KEY: 'env-api-key'}, + }); + + expect(session.id).toBe('session-new-profile'); + + // Should pass persistProfile: true but no profileId + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body['persistProfile']).toBe(true); + expect(body['profileId']).toBeUndefined(); + + // Should write the returned profileId to .steel.json + const steelJson = path.join(profileDir, '.steel.json'); + expect(fs.existsSync(steelJson)).toBe(true); + const written = JSON.parse(fs.readFileSync(steelJson, 'utf-8')) as { + profileId: string; + }; + expect(written.profileId).toBe('returned-profile-uuid'); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + fs.rmSync(profileDir, {recursive: true, force: true}); + } + }); + + test('reuses stored profileId from .steel.json on subsequent start', async () => { + const configDirectory = createTempConfigDirectory(); + const profileDir = createTempConfigDirectory(); + + try { + // Pre-seed .steel.json with an existing profileId + fs.writeFileSync( + path.join(profileDir, '.steel.json'), + JSON.stringify({ + profileId: 'existing-profile-uuid', + updatedAt: '2026-01-01T00:00:00.000Z', + }), + 'utf-8', + ); + + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-with-profile', + status: 'live', + profileId: 'existing-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const session = await lifecycle.startBrowserSession({ + profileDir, + environment: {STEEL_API_KEY: 'env-api-key'}, + }); + + expect(session.id).toBe('session-with-profile'); + + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body['profileId']).toBe('existing-profile-uuid'); + expect(body['persistProfile']).toBe(true); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + fs.rmSync(profileDir, {recursive: true, force: true}); + } + }); + + test('recovers from expired profileId: warns and retries without profileId', async () => { + const configDirectory = createTempConfigDirectory(); + const profileDir = createTempConfigDirectory(); + const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + fs.writeFileSync( + path.join(profileDir, '.steel.json'), + JSON.stringify({ + profileId: 'expired-profile-uuid', + updatedAt: '2026-01-01T00:00:00.000Z', + }), + 'utf-8', + ); + + // First call: 404 for expired profileId + fetchMock + .mockResolvedValueOnce( + createJsonResponse(404, { + message: "Profile with ID 'expired-profile-uuid' not found", + }), + ) + // Second call: success with new profileId + .mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-recovered', + status: 'live', + profileId: 'new-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const session = await lifecycle.startBrowserSession({ + profileDir, + environment: {STEEL_API_KEY: 'env-api-key'}, + }); + + expect(session.id).toBe('session-recovered'); + + // Warning should have been printed + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Profile not found'), + ); + + // Second call should not include the expired profileId + expect(fetchMock).toHaveBeenCalledTimes(2); + const retryBody = JSON.parse( + fetchMock.mock.calls[1]?.[1]?.body as string, + ) as Record; + expect(retryBody['profileId']).toBeUndefined(); + expect(retryBody['persistProfile']).toBe(true); + + // New profileId should be written to .steel.json + const written = JSON.parse( + fs.readFileSync(path.join(profileDir, '.steel.json'), 'utf-8'), + ) as {profileId: string}; + expect(written.profileId).toBe('new-profile-uuid'); + } finally { + warnSpy.mockRestore(); + fs.rmSync(configDirectory, {recursive: true, force: true}); + fs.rmSync(profileDir, {recursive: true, force: true}); + } + }); + + test('does not write .steel.json when API does not return profileId', async () => { + const configDirectory = createTempConfigDirectory(); + const profileDir = createTempConfigDirectory(); + + try { + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-no-profile-returned', + status: 'live', + // no profileId field + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + await lifecycle.startBrowserSession({ + profileDir, + environment: {STEEL_API_KEY: 'env-api-key'}, + }); + + expect(fs.existsSync(path.join(profileDir, '.steel.json'))).toBe(false); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + fs.rmSync(profileDir, {recursive: true, force: true}); + } + }); + + test('bootstrap passthrough passes profileDir from --profile flag', async () => { + const configDirectory = createTempConfigDirectory(); + const profileDir = createTempConfigDirectory(); + + try { + fetchMock.mockResolvedValueOnce( + createJsonResponse(201, { + id: 'session-bootstrap-profile', + status: 'live', + profileId: 'bootstrap-profile-uuid', + }), + ); + + const lifecycle = await loadBrowserLifecycle(configDirectory); + const result = await lifecycle.bootstrapBrowserPassthroughArgv( + ['open', 'https://steel.dev', '--profile', profileDir], + {STEEL_API_KEY: 'env-api-key'}, + ); + + expect(result.argv[0]).toBe('open'); + expect(result.argv).toContain('--cdp'); + + const body = JSON.parse( + fetchMock.mock.calls[0]?.[1]?.body as string, + ) as Record; + expect(body['persistProfile']).toBe(true); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + fs.rmSync(profileDir, {recursive: true, force: true}); + } + }); +}); diff --git a/tests/unit/browser-profile-store.test.ts b/tests/unit/browser-profile-store.test.ts new file mode 100644 index 0000000..1e3a136 --- /dev/null +++ b/tests/unit/browser-profile-store.test.ts @@ -0,0 +1,169 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {describe, test, expect} from 'vitest'; +import { + readSteelProfile, + writeSteelProfile, +} from '../../source/utils/browser/lifecycle/profile-store'; + +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'steel-profile-store-test-')); +} + +describe('profile-store', () => { + describe('readSteelProfile', () => { + test('returns null when .steel.json does not exist', async () => { + const dir = createTempDir(); + + try { + const result = await readSteelProfile(dir); + expect(result).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('returns profileId from existing .steel.json', async () => { + const dir = createTempDir(); + + try { + fs.writeFileSync( + path.join(dir, '.steel.json'), + JSON.stringify({ + profileId: 'uuid-abc-123', + updatedAt: '2026-03-04T00:00:00.000Z', + }), + 'utf-8', + ); + + const result = await readSteelProfile(dir); + expect(result).toEqual({profileId: 'uuid-abc-123'}); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('returns null when .steel.json contains invalid JSON', async () => { + const dir = createTempDir(); + + try { + fs.writeFileSync(path.join(dir, '.steel.json'), 'not-json', 'utf-8'); + + const result = await readSteelProfile(dir); + expect(result).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('returns null when .steel.json has no profileId field', async () => { + const dir = createTempDir(); + + try { + fs.writeFileSync( + path.join(dir, '.steel.json'), + JSON.stringify({updatedAt: '2026-03-04T00:00:00.000Z'}), + 'utf-8', + ); + + const result = await readSteelProfile(dir); + expect(result).toBeNull(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('expands ~ to home directory', async () => { + const homeDir = os.homedir(); + const subDirName = `steel-profile-tilde-test-${Date.now()}`; + const fullDir = path.join(homeDir, subDirName); + + try { + fs.mkdirSync(fullDir, {recursive: true}); + fs.writeFileSync( + path.join(fullDir, '.steel.json'), + JSON.stringify({profileId: 'tilde-profile-id'}), + 'utf-8', + ); + + const result = await readSteelProfile(`~/${subDirName}`); + expect(result).toEqual({profileId: 'tilde-profile-id'}); + } finally { + fs.rmSync(fullDir, {recursive: true, force: true}); + } + }); + }); + + describe('writeSteelProfile', () => { + test('creates .steel.json with profileId and updatedAt', async () => { + const dir = createTempDir(); + + try { + await writeSteelProfile(dir, 'new-profile-uuid'); + + const filePath = path.join(dir, '.steel.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const contents = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + profileId: string; + updatedAt: string; + }; + expect(contents.profileId).toBe('new-profile-uuid'); + expect(typeof contents.updatedAt).toBe('string'); + expect(() => new Date(contents.updatedAt)).not.toThrow(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('creates directory recursively if it does not exist', async () => { + const baseDir = createTempDir(); + const nestedDir = path.join(baseDir, 'deep', 'nested', 'dir'); + + try { + await writeSteelProfile(nestedDir, 'nested-profile-uuid'); + + expect(fs.existsSync(path.join(nestedDir, '.steel.json'))).toBe(true); + } finally { + fs.rmSync(baseDir, {recursive: true, force: true}); + } + }); + + test('overwrites existing .steel.json', async () => { + const dir = createTempDir(); + + try { + await writeSteelProfile(dir, 'first-profile-uuid'); + await writeSteelProfile(dir, 'second-profile-uuid'); + + const contents = JSON.parse( + fs.readFileSync(path.join(dir, '.steel.json'), 'utf-8'), + ) as {profileId: string}; + expect(contents.profileId).toBe('second-profile-uuid'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + test('expands ~ to home directory when writing', async () => { + const homeDir = os.homedir(); + const subDirName = `steel-profile-write-tilde-test-${Date.now()}`; + const fullDir = path.join(homeDir, subDirName); + + try { + await writeSteelProfile(`~/${subDirName}`, 'tilde-write-uuid'); + + const filePath = path.join(fullDir, '.steel.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const contents = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + profileId: string; + }; + expect(contents.profileId).toBe('tilde-write-uuid'); + } finally { + fs.rmSync(fullDir, {recursive: true, force: true}); + } + }); + }); +}); From 68869e23b0b3dd13509f5b85317fc7b70061be7a Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 5 Mar 2026 00:17:07 +0900 Subject: [PATCH 03/12] feat: name based profiles --- source/commands/browser/start.tsx | 4 +- source/utils/browser/lifecycle.ts | 18 +- .../browser/lifecycle/bootstrap-flags.ts | 11 +- .../utils/browser/lifecycle/profile-store.ts | 45 ++- source/utils/browser/lifecycle/types.ts | 4 +- tests/unit/browser-lifecycle.test.ts | 133 ++++--- tests/unit/browser-profile-store.test.ts | 340 ++++++++++-------- 7 files changed, 318 insertions(+), 237 deletions(-) diff --git a/source/commands/browser/start.tsx b/source/commands/browser/start.tsx index 2b3b346..29eef1d 100644 --- a/source/commands/browser/start.tsx +++ b/source/commands/browser/start.tsx @@ -96,7 +96,7 @@ export const options = zod.object({ .describe( option({ description: - 'Profile directory to persist browser state across sessions', + 'Named profile to persist browser state across sessions (stored in ~/.config/steel/profiles/)', }), ) .optional(), @@ -120,7 +120,7 @@ export default function Start({options}: Props) { headless: options.sessionHeadless, region: options.sessionRegion, solveCaptcha: options.sessionSolveCaptcha, - profileDir: options.profile, + profileName: options.profile, }); console.log(`id: ${session.id}`); diff --git a/source/utils/browser/lifecycle.ts b/source/utils/browser/lifecycle.ts index 22e36f0..95a1153 100644 --- a/source/utils/browser/lifecycle.ts +++ b/source/utils/browser/lifecycle.ts @@ -165,8 +165,8 @@ export async function startBrowserSession( let resolvedProfileId: string | undefined; - if (options.profileDir) { - const stored = await readSteelProfile(options.profileDir); + if (options.profileName) { + const stored = await readSteelProfile(options.profileName, environment); resolvedProfileId = stored?.profileId; } @@ -182,14 +182,14 @@ export async function startBrowserSession( region: options.region, solveCaptcha: options.solveCaptcha, profileId: resolvedProfileId, - persistProfile: Boolean(options.profileDir), + persistProfile: Boolean(options.profileName), }, environment, apiUrl, ); } catch (error) { if ( - options.profileDir && + options.profileName && resolvedProfileId && isNotFoundApiError(error) ) { @@ -240,10 +240,14 @@ export async function startBrowserSession( }); if (claimedCreatedSession) { - if (options.profileDir) { + if (options.profileName) { const returnedProfileId = createdSession['profileId']; if (typeof returnedProfileId === 'string') { - await writeSteelProfile(options.profileDir, returnedProfileId); + await writeSteelProfile( + options.profileName, + returnedProfileId, + environment, + ); } } @@ -691,7 +695,7 @@ export async function bootstrapBrowserPassthroughArgv( solveCaptcha: parsed.options.solveCaptcha || undefined, deadSessionBehavior: 'error', environment, - profileDir: parsed.options.profileDir || undefined, + profileName: parsed.options.profileName || undefined, }); if (!session.connectUrl) { diff --git a/source/utils/browser/lifecycle/bootstrap-flags.ts b/source/utils/browser/lifecycle/bootstrap-flags.ts index b778093..6dab2d8 100644 --- a/source/utils/browser/lifecycle/bootstrap-flags.ts +++ b/source/utils/browser/lifecycle/bootstrap-flags.ts @@ -3,6 +3,7 @@ import { parsePositiveIntegerFlagValue, resolveExplicitApiUrl, } from './api-client.js'; +import {validateProfileName} from './profile-store.js'; import type {ParsedBootstrapOptions} from './types.js'; export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { @@ -21,7 +22,7 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { solveCaptcha: false, autoConnect: false, cdpTarget: null, - profileDir: process.env['STEEL_PROFILE']?.trim() || null, + profileName: process.env['STEEL_PROFILE']?.trim() || null, }; const passthroughArgv: string[] = []; @@ -215,7 +216,13 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { ); } - options.profileDir = value.trim(); + const name = value.trim(); + const validationError = validateProfileName(name); + if (validationError) { + throw new BrowserAdapterError('INVALID_BROWSER_ARGS', validationError); + } + + options.profileName = name; if (argument === '--profile') { index++; diff --git a/source/utils/browser/lifecycle/profile-store.ts b/source/utils/browser/lifecycle/profile-store.ts index 7a7024e..430af73 100644 --- a/source/utils/browser/lifecycle/profile-store.ts +++ b/source/utils/browser/lifecycle/profile-store.ts @@ -4,22 +4,36 @@ import * as path from 'node:path'; type SteelProfileData = { profileId: string; - updatedAt: string; }; -function expandProfileDir(dir: string): string { - if (dir.startsWith('~')) { - return path.join(os.homedir(), dir.slice(1)); +function getProfilesDirectory(environment: NodeJS.ProcessEnv): string { + const configDir = + environment.STEEL_CONFIG_DIR?.trim() || + path.join(os.homedir(), '.config', 'steel'); + return path.join(configDir, 'profiles'); +} + +function getProfilePath(name: string, environment: NodeJS.ProcessEnv): string { + return path.join(getProfilesDirectory(environment), `${name}.json`); +} + +export function validateProfileName(name: string): string | null { + if (!name.trim()) { + return 'Profile name cannot be empty.'; } - return dir; + if (name.includes('/') || name.includes('\\')) { + return `Invalid profile name "${name}". Use a name like "myapp", not a path.`; + } + + return null; } export async function readSteelProfile( - dir: string, + name: string, + environment: NodeJS.ProcessEnv, ): Promise<{profileId: string} | null> { - const expandedDir = expandProfileDir(dir); - const filePath = path.join(expandedDir, '.steel.json'); + const filePath = getProfilePath(name, environment); try { const contents = await fs.readFile(filePath, 'utf-8'); @@ -40,18 +54,15 @@ export async function readSteelProfile( } export async function writeSteelProfile( - dir: string, + name: string, profileId: string, + environment: NodeJS.ProcessEnv, ): Promise { - const expandedDir = expandProfileDir(dir); - - await fs.mkdir(expandedDir, {recursive: true}); + const profilesDir = getProfilesDirectory(environment); + await fs.mkdir(profilesDir, {recursive: true}); - const filePath = path.join(expandedDir, '.steel.json'); - const data: SteelProfileData = { - profileId, - updatedAt: new Date().toISOString(), - }; + const filePath = getProfilePath(name, environment); + const data: SteelProfileData = {profileId}; await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); } diff --git a/source/utils/browser/lifecycle/types.ts b/source/utils/browser/lifecycle/types.ts index bafc26a..42a4365 100644 --- a/source/utils/browser/lifecycle/types.ts +++ b/source/utils/browser/lifecycle/types.ts @@ -43,7 +43,7 @@ export type ParsedBootstrapOptions = { solveCaptcha: boolean; autoConnect: boolean; cdpTarget: string | null; - profileDir: string | null; + profileName: string | null; }; export type BrowserSessionSummary = { @@ -69,7 +69,7 @@ export type StartBrowserSessionOptions = { solveCaptcha?: boolean; deadSessionBehavior?: DeadSessionBehavior; environment?: NodeJS.ProcessEnv; - profileDir?: string; + profileName?: string; }; export type StopBrowserSessionOptions = { diff --git a/tests/unit/browser-lifecycle.test.ts b/tests/unit/browser-lifecycle.test.ts index 8b6ef23..4e8a152 100644 --- a/tests/unit/browser-lifecycle.test.ts +++ b/tests/unit/browser-lifecycle.test.ts @@ -103,7 +103,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: false, autoConnect: false, cdpTarget: null, - profileDir: null, + profileName: null, }); expect(parsed.passthroughArgv).toEqual([ 'open', @@ -209,7 +209,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: false, autoConnect: false, cdpTarget: null, - profileDir: null, + profileName: null, }); expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); } finally { @@ -247,7 +247,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: true, autoConnect: false, cdpTarget: null, - profileDir: null, + profileName: null, }); expect(parsed.passthroughArgv).toEqual([ 'open', @@ -286,7 +286,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { solveCaptcha: true, autoConnect: false, cdpTarget: null, - profileDir: null, + profileName: null, }); expect(parsed.passthroughArgv).toEqual([ 'open', @@ -1371,7 +1371,7 @@ describe('browser lifecycle session contract', () => { }); describe('browser lifecycle --profile flag parsing', () => { - test('parses --profile flag into profileDir', async () => { + test('parses --profile flag into profileName', async () => { const configDirectory = createTempConfigDirectory(); try { @@ -1380,17 +1380,17 @@ describe('browser lifecycle --profile flag parsing', () => { 'open', 'https://steel.dev', '--profile', - '/tmp/my-profile', + 'myapp', ]); - expect(parsed.options.profileDir).toBe('/tmp/my-profile'); + expect(parsed.options.profileName).toBe('myapp'); expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); } }); - test('parses --profile= (equals form) into profileDir', async () => { + test('parses --profile= (equals form) into profileName', async () => { const configDirectory = createTempConfigDirectory(); try { @@ -1398,10 +1398,10 @@ describe('browser lifecycle --profile flag parsing', () => { const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ 'open', 'https://steel.dev', - '--profile=/tmp/my-profile', + '--profile=myapp', ]); - expect(parsed.options.profileDir).toBe('/tmp/my-profile'); + expect(parsed.options.profileName).toBe('myapp'); expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); @@ -1422,7 +1422,25 @@ describe('browser lifecycle --profile flag parsing', () => { } }); - test('profileDir defaults to null when STEEL_PROFILE is not set', async () => { + test('throws when --profile value contains a path separator', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + + expect(() => + lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + '--profile', + '/tmp/my-profile', + ]), + ).toThrow('Invalid profile name'); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('profileName defaults to null when STEEL_PROFILE is not set', async () => { const configDirectory = createTempConfigDirectory(); try { @@ -1433,7 +1451,7 @@ describe('browser lifecycle --profile flag parsing', () => { 'https://steel.dev', ]); - expect(parsed.options.profileDir).toBeNull(); + expect(parsed.options.profileName).toBeNull(); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); } @@ -1441,9 +1459,8 @@ describe('browser lifecycle --profile flag parsing', () => { }); describe('browser lifecycle session profile contract', () => { - test('creates new session with persistProfile when no .steel.json exists', async () => { + test('creates new session with persistProfile when no profile file exists', async () => { const configDirectory = createTempConfigDirectory(); - const profileDir = createTempConfigDirectory(); try { fetchMock.mockResolvedValueOnce( @@ -1456,8 +1473,11 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); const session = await lifecycle.startBrowserSession({ - profileDir, - environment: {STEEL_API_KEY: 'env-api-key'}, + profileName: 'myapp', + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, }); expect(session.id).toBe('session-new-profile'); @@ -1470,31 +1490,28 @@ describe('browser lifecycle session profile contract', () => { expect(body['persistProfile']).toBe(true); expect(body['profileId']).toBeUndefined(); - // Should write the returned profileId to .steel.json - const steelJson = path.join(profileDir, '.steel.json'); - expect(fs.existsSync(steelJson)).toBe(true); - const written = JSON.parse(fs.readFileSync(steelJson, 'utf-8')) as { + // Should write the returned profileId to profiles/myapp.json + const profileFile = path.join(configDirectory, 'profiles', 'myapp.json'); + expect(fs.existsSync(profileFile)).toBe(true); + const written = JSON.parse(fs.readFileSync(profileFile, 'utf-8')) as { profileId: string; }; expect(written.profileId).toBe('returned-profile-uuid'); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); - fs.rmSync(profileDir, {recursive: true, force: true}); } }); - test('reuses stored profileId from .steel.json on subsequent start', async () => { + test('reuses stored profileId from profile file on subsequent start', async () => { const configDirectory = createTempConfigDirectory(); - const profileDir = createTempConfigDirectory(); try { - // Pre-seed .steel.json with an existing profileId + // Pre-seed profiles/myapp.json with an existing profileId + const profilesDir = path.join(configDirectory, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); fs.writeFileSync( - path.join(profileDir, '.steel.json'), - JSON.stringify({ - profileId: 'existing-profile-uuid', - updatedAt: '2026-01-01T00:00:00.000Z', - }), + path.join(profilesDir, 'myapp.json'), + JSON.stringify({profileId: 'existing-profile-uuid'}), 'utf-8', ); @@ -1508,8 +1525,11 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); const session = await lifecycle.startBrowserSession({ - profileDir, - environment: {STEEL_API_KEY: 'env-api-key'}, + profileName: 'myapp', + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, }); expect(session.id).toBe('session-with-profile'); @@ -1521,22 +1541,19 @@ describe('browser lifecycle session profile contract', () => { expect(body['persistProfile']).toBe(true); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); - fs.rmSync(profileDir, {recursive: true, force: true}); } }); test('recovers from expired profileId: warns and retries without profileId', async () => { const configDirectory = createTempConfigDirectory(); - const profileDir = createTempConfigDirectory(); const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { + const profilesDir = path.join(configDirectory, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); fs.writeFileSync( - path.join(profileDir, '.steel.json'), - JSON.stringify({ - profileId: 'expired-profile-uuid', - updatedAt: '2026-01-01T00:00:00.000Z', - }), + path.join(profilesDir, 'myapp.json'), + JSON.stringify({profileId: 'expired-profile-uuid'}), 'utf-8', ); @@ -1558,18 +1575,19 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); const session = await lifecycle.startBrowserSession({ - profileDir, - environment: {STEEL_API_KEY: 'env-api-key'}, + profileName: 'myapp', + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, }); expect(session.id).toBe('session-recovered'); - // Warning should have been printed expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Profile not found'), ); - // Second call should not include the expired profileId expect(fetchMock).toHaveBeenCalledTimes(2); const retryBody = JSON.parse( fetchMock.mock.calls[1]?.[1]?.body as string, @@ -1577,21 +1595,22 @@ describe('browser lifecycle session profile contract', () => { expect(retryBody['profileId']).toBeUndefined(); expect(retryBody['persistProfile']).toBe(true); - // New profileId should be written to .steel.json + // New profileId should be written to profiles/myapp.json const written = JSON.parse( - fs.readFileSync(path.join(profileDir, '.steel.json'), 'utf-8'), + fs.readFileSync( + path.join(configDirectory, 'profiles', 'myapp.json'), + 'utf-8', + ), ) as {profileId: string}; expect(written.profileId).toBe('new-profile-uuid'); } finally { warnSpy.mockRestore(); fs.rmSync(configDirectory, {recursive: true, force: true}); - fs.rmSync(profileDir, {recursive: true, force: true}); } }); - test('does not write .steel.json when API does not return profileId', async () => { + test('does not write profile file when API does not return profileId', async () => { const configDirectory = createTempConfigDirectory(); - const profileDir = createTempConfigDirectory(); try { fetchMock.mockResolvedValueOnce( @@ -1604,20 +1623,23 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); await lifecycle.startBrowserSession({ - profileDir, - environment: {STEEL_API_KEY: 'env-api-key'}, + profileName: 'myapp', + environment: { + STEEL_API_KEY: 'env-api-key', + STEEL_CONFIG_DIR: configDirectory, + }, }); - expect(fs.existsSync(path.join(profileDir, '.steel.json'))).toBe(false); + expect( + fs.existsSync(path.join(configDirectory, 'profiles', 'myapp.json')), + ).toBe(false); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); - fs.rmSync(profileDir, {recursive: true, force: true}); } }); - test('bootstrap passthrough passes profileDir from --profile flag', async () => { + test('bootstrap passthrough passes profileName from --profile flag', async () => { const configDirectory = createTempConfigDirectory(); - const profileDir = createTempConfigDirectory(); try { fetchMock.mockResolvedValueOnce( @@ -1630,8 +1652,8 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); const result = await lifecycle.bootstrapBrowserPassthroughArgv( - ['open', 'https://steel.dev', '--profile', profileDir], - {STEEL_API_KEY: 'env-api-key'}, + ['open', 'https://steel.dev', '--profile', 'myapp'], + {STEEL_API_KEY: 'env-api-key', STEEL_CONFIG_DIR: configDirectory}, ); expect(result.argv[0]).toBe('open'); @@ -1643,7 +1665,6 @@ describe('browser lifecycle session profile contract', () => { expect(body['persistProfile']).toBe(true); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); - fs.rmSync(profileDir, {recursive: true, force: true}); } }); }); diff --git a/tests/unit/browser-profile-store.test.ts b/tests/unit/browser-profile-store.test.ts index 1e3a136..30143e1 100644 --- a/tests/unit/browser-profile-store.test.ts +++ b/tests/unit/browser-profile-store.test.ts @@ -5,165 +5,203 @@ import {describe, test, expect} from 'vitest'; import { readSteelProfile, writeSteelProfile, + validateProfileName, } from '../../source/utils/browser/lifecycle/profile-store'; -function createTempDir(): string { +function createTempConfigDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'steel-profile-store-test-')); } -describe('profile-store', () => { - describe('readSteelProfile', () => { - test('returns null when .steel.json does not exist', async () => { - const dir = createTempDir(); - - try { - const result = await readSteelProfile(dir); - expect(result).toBeNull(); - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } - }); - - test('returns profileId from existing .steel.json', async () => { - const dir = createTempDir(); - - try { - fs.writeFileSync( - path.join(dir, '.steel.json'), - JSON.stringify({ - profileId: 'uuid-abc-123', - updatedAt: '2026-03-04T00:00:00.000Z', - }), - 'utf-8', - ); - - const result = await readSteelProfile(dir); - expect(result).toEqual({profileId: 'uuid-abc-123'}); - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } - }); - - test('returns null when .steel.json contains invalid JSON', async () => { - const dir = createTempDir(); - - try { - fs.writeFileSync(path.join(dir, '.steel.json'), 'not-json', 'utf-8'); - - const result = await readSteelProfile(dir); - expect(result).toBeNull(); - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } - }); - - test('returns null when .steel.json has no profileId field', async () => { - const dir = createTempDir(); - - try { - fs.writeFileSync( - path.join(dir, '.steel.json'), - JSON.stringify({updatedAt: '2026-03-04T00:00:00.000Z'}), +describe('validateProfileName', () => { + test('accepts simple names', () => { + expect(validateProfileName('myapp')).toBeNull(); + expect(validateProfileName('my-app')).toBeNull(); + expect(validateProfileName('my_app')).toBeNull(); + expect(validateProfileName('MyApp123')).toBeNull(); + }); + + test('rejects empty or whitespace-only names', () => { + expect(validateProfileName('')).not.toBeNull(); + expect(validateProfileName(' ')).not.toBeNull(); + }); + + test('rejects names containing path separators', () => { + expect(validateProfileName('my/app')).not.toBeNull(); + expect(validateProfileName('my\\app')).not.toBeNull(); + expect(validateProfileName('../etc/passwd')).not.toBeNull(); + }); +}); + +describe('readSteelProfile', () => { + test('returns null when profile file does not exist', async () => { + const configDir = createTempConfigDir(); + + try { + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBeNull(); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('reads profileId from existing profile file', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + JSON.stringify({profileId: 'uuid-abc-123'}), + 'utf-8', + ); + + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toEqual({profileId: 'uuid-abc-123'}); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns null when profile file contains invalid JSON', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + 'not-json', + 'utf-8', + ); + + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBeNull(); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns null when profile file has no profileId field', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'myapp.json'), + JSON.stringify({something: 'else'}), + 'utf-8', + ); + + const result = await readSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBeNull(); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('uses default config dir (~/.config/steel) when STEEL_CONFIG_DIR is not set', async () => { + // Just verify it doesn't throw and returns null for a non-existent profile + const result = await readSteelProfile( + `nonexistent-profile-${Date.now()}`, + {}, + ); + expect(result).toBeNull(); + }); +}); + +describe('writeSteelProfile', () => { + test('creates profiles directory and writes profile file', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'new-profile-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + + const filePath = path.join(configDir, 'profiles', 'myapp.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const contents = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + profileId: string; + }; + expect(contents.profileId).toBe('new-profile-uuid'); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('does not write updatedAt (no timestamp noise in file)', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'some-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + + const contents = JSON.parse( + fs.readFileSync( + path.join(configDir, 'profiles', 'myapp.json'), 'utf-8', - ); - - const result = await readSteelProfile(dir); - expect(result).toBeNull(); - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } - }); - - test('expands ~ to home directory', async () => { - const homeDir = os.homedir(); - const subDirName = `steel-profile-tilde-test-${Date.now()}`; - const fullDir = path.join(homeDir, subDirName); - - try { - fs.mkdirSync(fullDir, {recursive: true}); - fs.writeFileSync( - path.join(fullDir, '.steel.json'), - JSON.stringify({profileId: 'tilde-profile-id'}), + ), + ) as Record; + expect(Object.keys(contents)).toEqual(['profileId']); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('overwrites existing profile', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'first-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + await writeSteelProfile('myapp', 'second-uuid', { + STEEL_CONFIG_DIR: configDir, + }); + + const contents = JSON.parse( + fs.readFileSync( + path.join(configDir, 'profiles', 'myapp.json'), 'utf-8', - ); - - const result = await readSteelProfile(`~/${subDirName}`); - expect(result).toEqual({profileId: 'tilde-profile-id'}); - } finally { - fs.rmSync(fullDir, {recursive: true, force: true}); - } - }); + ), + ) as {profileId: string}; + expect(contents.profileId).toBe('second-uuid'); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } }); - describe('writeSteelProfile', () => { - test('creates .steel.json with profileId and updatedAt', async () => { - const dir = createTempDir(); - - try { - await writeSteelProfile(dir, 'new-profile-uuid'); - - const filePath = path.join(dir, '.steel.json'); - expect(fs.existsSync(filePath)).toBe(true); - - const contents = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { - profileId: string; - updatedAt: string; - }; - expect(contents.profileId).toBe('new-profile-uuid'); - expect(typeof contents.updatedAt).toBe('string'); - expect(() => new Date(contents.updatedAt)).not.toThrow(); - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } - }); - - test('creates directory recursively if it does not exist', async () => { - const baseDir = createTempDir(); - const nestedDir = path.join(baseDir, 'deep', 'nested', 'dir'); - - try { - await writeSteelProfile(nestedDir, 'nested-profile-uuid'); - - expect(fs.existsSync(path.join(nestedDir, '.steel.json'))).toBe(true); - } finally { - fs.rmSync(baseDir, {recursive: true, force: true}); - } - }); - - test('overwrites existing .steel.json', async () => { - const dir = createTempDir(); - - try { - await writeSteelProfile(dir, 'first-profile-uuid'); - await writeSteelProfile(dir, 'second-profile-uuid'); - - const contents = JSON.parse( - fs.readFileSync(path.join(dir, '.steel.json'), 'utf-8'), - ) as {profileId: string}; - expect(contents.profileId).toBe('second-profile-uuid'); - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } - }); - - test('expands ~ to home directory when writing', async () => { - const homeDir = os.homedir(); - const subDirName = `steel-profile-write-tilde-test-${Date.now()}`; - const fullDir = path.join(homeDir, subDirName); - - try { - await writeSteelProfile(`~/${subDirName}`, 'tilde-write-uuid'); - - const filePath = path.join(fullDir, '.steel.json'); - expect(fs.existsSync(filePath)).toBe(true); - - const contents = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { - profileId: string; - }; - expect(contents.profileId).toBe('tilde-write-uuid'); - } finally { - fs.rmSync(fullDir, {recursive: true, force: true}); - } - }); + test('different profile names are stored in separate files', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('app-a', 'uuid-a', {STEEL_CONFIG_DIR: configDir}); + await writeSteelProfile('app-b', 'uuid-b', {STEEL_CONFIG_DIR: configDir}); + + const profilesDir = path.join(configDir, 'profiles'); + const a = JSON.parse( + fs.readFileSync(path.join(profilesDir, 'app-a.json'), 'utf-8'), + ) as {profileId: string}; + const b = JSON.parse( + fs.readFileSync(path.join(profilesDir, 'app-b.json'), 'utf-8'), + ) as {profileId: string}; + + expect(a.profileId).toBe('uuid-a'); + expect(b.profileId).toBe('uuid-b'); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } }); }); From 59ae86be86d90907ad489f01a1029c44becd1bcc Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 5 Mar 2026 00:27:56 +0900 Subject: [PATCH 04/12] feat: profiles command --- source/commands/profile/delete.tsx | 42 ++++ source/commands/profile/index.tsx | 1 + source/commands/profile/list.tsx | 55 ++++++ .../utils/browser/lifecycle/profile-store.ts | 56 ++++++ tests/unit/browser-profile-store.test.ts | 181 ++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 source/commands/profile/delete.tsx create mode 100644 source/commands/profile/index.tsx create mode 100644 source/commands/profile/list.tsx diff --git a/source/commands/profile/delete.tsx b/source/commands/profile/delete.tsx new file mode 100644 index 0000000..3e8f80e --- /dev/null +++ b/source/commands/profile/delete.tsx @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import {useEffect} from 'react'; +import zod from 'zod'; +import {option} from 'pastel'; +import {deleteSteelProfile} from '../../utils/browser/lifecycle/profile-store.js'; + +export const description = + 'Delete a saved Steel browser profile (local file only)'; + +export const options = zod.object({ + name: zod.string().describe( + option({ + description: 'Name of the profile to delete', + }), + ), +}); + +type Props = { + options: zod.infer; +}; + +export default function Delete({options}: Props) { + useEffect(() => { + async function run() { + const deleted = await deleteSteelProfile(options.name, process.env); + + if (!deleted) { + console.error(`Profile "${options.name}" not found.`); + process.exit(1); + return; + } + + console.log( + `Deleted profile "${options.name}". Note: Browser state on Steel servers is not affected.`, + ); + process.exit(0); + } + + run(); + }, [options.name]); +} diff --git a/source/commands/profile/index.tsx b/source/commands/profile/index.tsx new file mode 100644 index 0000000..bbb3c99 --- /dev/null +++ b/source/commands/profile/index.tsx @@ -0,0 +1 @@ +export const description = 'Manage named Steel browser profiles'; diff --git a/source/commands/profile/list.tsx b/source/commands/profile/list.tsx new file mode 100644 index 0000000..21a9b41 --- /dev/null +++ b/source/commands/profile/list.tsx @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import {useEffect} from 'react'; +import zod from 'zod'; +import {option} from 'pastel'; +import {listSteelProfiles} from '../../utils/browser/lifecycle/profile-store.js'; + +export const description = 'List all saved Steel browser profiles'; + +export const options = zod.object({ + json: zod + .boolean() + .describe( + option({ + description: 'Output profiles as JSON', + }), + ) + .optional(), +}); + +type Props = { + options: zod.infer; +}; + +export default function List({options}: Props) { + useEffect(() => { + async function run() { + const profiles = await listSteelProfiles(process.env); + + if (options.json) { + console.log(JSON.stringify(profiles, null, 2)); + process.exit(0); + return; + } + + if (profiles.length === 0) { + console.log( + 'No profiles found. Use --profile with steel browser start to create one.', + ); + process.exit(0); + return; + } + + const nameWidth = Math.max(4, ...profiles.map(p => p.name.length)); + console.log(`${'NAME'.padEnd(nameWidth)} PROFILE_ID`); + for (const profile of profiles) { + console.log(`${profile.name.padEnd(nameWidth)} ${profile.profileId}`); + } + + process.exit(0); + } + + run(); + }, [options.json]); +} diff --git a/source/utils/browser/lifecycle/profile-store.ts b/source/utils/browser/lifecycle/profile-store.ts index 430af73..537bede 100644 --- a/source/utils/browser/lifecycle/profile-store.ts +++ b/source/utils/browser/lifecycle/profile-store.ts @@ -66,3 +66,59 @@ export async function writeSteelProfile( await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); } + +export async function listSteelProfiles( + environment: NodeJS.ProcessEnv, +): Promise> { + const profilesDir = getProfilesDirectory(environment); + + let entries: string[]; + try { + entries = await fs.readdir(profilesDir); + } catch { + return []; + } + + const profiles: Array<{name: string; profileId: string}> = []; + + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + + const name = entry.slice(0, -5); + const filePath = path.join(profilesDir, entry); + + try { + const contents = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(contents) as unknown; + + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as Record)['profileId'] === 'string' + ) { + profiles.push({ + name, + profileId: (parsed as SteelProfileData).profileId, + }); + } + } catch { + // Skip corrupt files + } + } + + return profiles; +} + +export async function deleteSteelProfile( + name: string, + environment: NodeJS.ProcessEnv, +): Promise { + const filePath = getProfilePath(name, environment); + + try { + await fs.unlink(filePath); + return true; + } catch { + return false; + } +} diff --git a/tests/unit/browser-profile-store.test.ts b/tests/unit/browser-profile-store.test.ts index 30143e1..ffdb706 100644 --- a/tests/unit/browser-profile-store.test.ts +++ b/tests/unit/browser-profile-store.test.ts @@ -6,6 +6,8 @@ import { readSteelProfile, writeSteelProfile, validateProfileName, + listSteelProfiles, + deleteSteelProfile, } from '../../source/utils/browser/lifecycle/profile-store'; function createTempConfigDir(): string { @@ -205,3 +207,182 @@ describe('writeSteelProfile', () => { } }); }); + +describe('listSteelProfiles', () => { + test('returns empty array when profiles directory does not exist', async () => { + const configDir = createTempConfigDir(); + + try { + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns empty array when profiles directory is empty', async () => { + const configDir = createTempConfigDir(); + + try { + fs.mkdirSync(path.join(configDir, 'profiles'), {recursive: true}); + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('lists all valid profiles', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('app-a', 'uuid-a', {STEEL_CONFIG_DIR: configDir}); + await writeSteelProfile('app-b', 'uuid-b', {STEEL_CONFIG_DIR: configDir}); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + const sorted = result.sort((a, b) => a.name.localeCompare(b.name)); + + expect(sorted).toEqual([ + {name: 'app-a', profileId: 'uuid-a'}, + {name: 'app-b', profileId: 'uuid-b'}, + ]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('skips corrupt (invalid JSON) files', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'corrupt.json'), + 'not-json', + 'utf-8', + ); + await writeSteelProfile('good', 'uuid-good', { + STEEL_CONFIG_DIR: configDir, + }); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([{name: 'good', profileId: 'uuid-good'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('skips files missing profileId field', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'no-id.json'), + JSON.stringify({something: 'else'}), + 'utf-8', + ); + await writeSteelProfile('valid', 'uuid-valid', { + STEEL_CONFIG_DIR: configDir, + }); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([{name: 'valid', profileId: 'uuid-valid'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('ignores non-.json files', async () => { + const configDir = createTempConfigDir(); + + try { + const profilesDir = path.join(configDir, 'profiles'); + fs.mkdirSync(profilesDir, {recursive: true}); + fs.writeFileSync( + path.join(profilesDir, 'readme.txt'), + 'ignore me', + 'utf-8', + ); + await writeSteelProfile('myapp', 'uuid-myapp', { + STEEL_CONFIG_DIR: configDir, + }); + + const result = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(result).toEqual([{name: 'myapp', profileId: 'uuid-myapp'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); +}); + +describe('deleteSteelProfile', () => { + test('returns false when profile does not exist', async () => { + const configDir = createTempConfigDir(); + + try { + const result = await deleteSteelProfile('nonexistent', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBe(false); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('deletes the profile file and returns true', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'uuid-123', { + STEEL_CONFIG_DIR: configDir, + }); + const filePath = path.join(configDir, 'profiles', 'myapp.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const result = await deleteSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBe(true); + expect(fs.existsSync(filePath)).toBe(false); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('does not affect other profiles when deleting one', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('app-a', 'uuid-a', {STEEL_CONFIG_DIR: configDir}); + await writeSteelProfile('app-b', 'uuid-b', {STEEL_CONFIG_DIR: configDir}); + + await deleteSteelProfile('app-a', {STEEL_CONFIG_DIR: configDir}); + + const remaining = await listSteelProfiles({STEEL_CONFIG_DIR: configDir}); + expect(remaining).toEqual([{name: 'app-b', profileId: 'uuid-b'}]); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); + + test('returns false on second delete of same profile', async () => { + const configDir = createTempConfigDir(); + + try { + await writeSteelProfile('myapp', 'uuid-123', { + STEEL_CONFIG_DIR: configDir, + }); + await deleteSteelProfile('myapp', {STEEL_CONFIG_DIR: configDir}); + + const result = await deleteSteelProfile('myapp', { + STEEL_CONFIG_DIR: configDir, + }); + expect(result).toBe(false); + } finally { + fs.rmSync(configDir, {recursive: true, force: true}); + } + }); +}); From a0991825f19f2d321817ab3d1c5396ba2e44dd5e Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 5 Mar 2026 13:03:20 +0900 Subject: [PATCH 05/12] feat: local to cloud --- package-lock.json | 353 +++++++++++++++++++++++- package.json | 3 + source/commands/browser/start.tsx | 11 + source/commands/profile/import.tsx | 247 +++++++++++++++++ source/utils/browser/lifecycle.ts | 8 +- source/utils/browser/lifecycle/types.ts | 1 + source/utils/browser/profile-porter.ts | 326 ++++++++++++++++++++++ 7 files changed, 945 insertions(+), 4 deletions(-) create mode 100644 source/commands/profile/import.tsx create mode 100644 source/utils/browser/profile-porter.ts diff --git a/package-lock.json b/package-lock.json index 592cb2a..fcc6e99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,18 @@ { "name": "@steel-dev/cli", - "version": "0.1.8", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@steel-dev/cli", - "version": "0.1.8", + "version": "0.2.0", "license": "MIT", "dependencies": { "@babel/parser": "^7.27.2", + "@types/better-sqlite3": "^7.6.13", + "better-sqlite3": "^12.6.2", + "fflate": "^0.8.2", "figures": "^6.1.0", "ink": "^5.2.1", "ink-big-text": "^2.0.0", @@ -1758,6 +1761,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3189,6 +3201,78 @@ "node": ">=10.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/blacklist": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/blacklist/-/blacklist-1.1.4.tgz", @@ -3509,6 +3593,12 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -4018,6 +4108,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4028,6 +4133,15 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4177,6 +4291,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -5505,6 +5628,15 @@ "node": ">=0.8.x" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -5650,6 +5782,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -5678,6 +5816,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5794,6 +5938,12 @@ "node": ">=0.4.x" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6002,6 +6152,12 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -6443,6 +6599,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ink": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", @@ -8578,6 +8740,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -8597,7 +8771,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8618,6 +8791,12 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/modern-tar": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz", @@ -8663,6 +8842,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", @@ -8695,6 +8880,18 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-simctl": { "version": "7.7.5", "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-7.7.5.tgz", @@ -9346,6 +9543,75 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9501,6 +9767,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -10241,6 +10531,51 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slice-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", @@ -11098,6 +11433,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 3d90d8c..5131a9f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ ], "dependencies": { "@babel/parser": "^7.27.2", + "@types/better-sqlite3": "^7.6.13", + "better-sqlite3": "^12.6.2", + "fflate": "^0.8.2", "figures": "^6.1.0", "ink": "^5.2.1", "ink-big-text": "^2.0.0", diff --git a/source/commands/browser/start.tsx b/source/commands/browser/start.tsx index 29eef1d..88bb53e 100644 --- a/source/commands/browser/start.tsx +++ b/source/commands/browser/start.tsx @@ -100,6 +100,15 @@ export const options = zod.object({ }), ) .optional(), + updateProfile: zod + .boolean() + .describe( + option({ + description: + 'Save session state back to the profile when the session ends (default: false — profile is loaded read-only)', + }), + ) + .optional(), }); type Props = { @@ -121,6 +130,7 @@ export default function Start({options}: Props) { region: options.sessionRegion, solveCaptcha: options.sessionSolveCaptcha, profileName: options.profile, + updateProfile: options.updateProfile, }); console.log(`id: ${session.id}`); @@ -167,5 +177,6 @@ export default function Start({options}: Props) { options.sessionTimeout, options.session, options.stealth, + options.updateProfile, ]); } diff --git a/source/commands/profile/import.tsx b/source/commands/profile/import.tsx new file mode 100644 index 0000000..ae6498f --- /dev/null +++ b/source/commands/profile/import.tsx @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +import * as React from 'react'; +import {Box, Text} from 'ink'; +import Spinner from 'ink-spinner'; +import SelectInput from 'ink-select-input'; +import zod from 'zod'; +import {option} from 'pastel'; +import { + findChromeProfiles, + isChromeRunning, + packageChromeProfile, + uploadProfileToSteel, + type ChromeProfile, +} from '../../utils/browser/profile-porter.js'; +import {writeSteelProfile} from '../../utils/browser/lifecycle/profile-store.js'; +import {resolveBrowserAuth} from '../../utils/browser/auth.js'; +import {DEFAULT_API_PATH} from '../../utils/browser/lifecycle/constants.js'; + +export const description = + 'Import a local Chrome profile into Steel (macOS only)'; + +export const options = zod.object({ + name: zod.string().describe( + option({ + description: 'Steel profile name to save as', + }), + ), + from: zod + .string() + .describe( + option({ + description: + 'Chrome profile to import from (e.g. "Default", "Profile 1")', + }), + ) + .optional(), +}); + +type Props = { + options: zod.infer; +}; + +type Phase = + | {tag: 'checking'} + | {tag: 'selecting'; profiles: ChromeProfile[]; chromeRunning: boolean} + | {tag: 'importing'; chromeProfile: ChromeProfile; step: string} + | {tag: 'done'; profileId: string; cookiesReencrypted: number; zipMb: string} + | {tag: 'error'; message: string}; + +export default function Import({options}: Props) { + const [phase, setPhase] = React.useState({tag: 'checking'}); + + React.useEffect(() => { + if (process.platform !== 'darwin') { + setPhase({ + tag: 'error', + message: '`steel profile import` is currently macOS only.', + }); + return; + } + + const auth = resolveBrowserAuth(process.env); + if (!auth.apiKey) { + setPhase({ + tag: 'error', + message: 'No API key found. Run `steel login` or set STEEL_API_KEY.', + }); + return; + } + + const profiles = findChromeProfiles(); + if (profiles.length === 0) { + setPhase({ + tag: 'error', + message: 'No Chrome profiles found.', + }); + return; + } + + if (options.from) { + const match = profiles.find(p => p.dirName === options.from); + if (!match) { + setPhase({ + tag: 'error', + message: `Chrome profile "${options.from}" not found. Available: ${profiles.map(p => p.dirName).join(', ')}`, + }); + return; + } + + runImport(match, auth.apiKey!); + return; + } + + // No --from: show picker + setPhase({ + tag: 'selecting', + profiles, + chromeRunning: isChromeRunning(), + }); + }, []); + + function runImport(chromeProfile: ChromeProfile, apiKey: string) { + setPhase({tag: 'importing', chromeProfile, step: 'Starting...'}); + + (async () => { + let zipBuffer: Buffer; + let cookiesReencrypted: number; + let zipMb: string; + + try { + ({zipBuffer, cookiesReencrypted} = packageChromeProfile( + chromeProfile.dirName, + msg => { + setPhase({ + tag: 'importing', + chromeProfile, + step: msg, + }); + }, + )); + zipMb = (zipBuffer.length / 1024 / 1024).toFixed(1); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + setPhase({ + tag: 'importing', + chromeProfile, + step: 'Uploading to Steel...', + }); + + let profileId: string; + try { + profileId = await uploadProfileToSteel( + zipBuffer, + apiKey, + DEFAULT_API_PATH, + ); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + await writeSteelProfile(options.name, profileId, process.env); + + setPhase({tag: 'done', profileId, cookiesReencrypted, zipMb}); + process.exit(0); + })(); + } + + function handleSelect(item: {value: string}) { + if (phase.tag !== 'selecting') return; + const profile = phase.profiles.find(p => p.dirName === item.value)!; + const auth = resolveBrowserAuth(process.env); + runImport(profile, auth.apiKey!); + } + + if (phase.tag === 'checking') { + return ( + + + + + Checking... + + ); + } + + if (phase.tag === 'selecting') { + const items = phase.profiles.map(p => ({ + label: `${p.displayName} ${p.dirName !== p.displayName ? `(${p.dirName})` : ''}`, + value: p.dirName, + })); + + return ( + + {phase.chromeRunning && ( + + + ⚠ Chrome is running. Close it for best results (cookie file may + be locked). + + + )} + Select Chrome profile to import: + + + ); + } + + if (phase.tag === 'importing') { + return ( + + + + + + {' '} + {phase.chromeProfile.displayName} → {options.name} + {' '} + {phase.step} + + + ); + } + + if (phase.tag === 'done') { + return ( + + + + + Imported as {options.name} + + + + id: {phase.profileId} + + cookies: {phase.cookiesReencrypted} re-encrypted · {phase.zipMb} MB + + + + steel browser start --profile {options.name} + + Add --update-profile to save session changes back to the profile + + + + ); + } + + // error + return ( + + + {(phase as {tag: 'error'; message: string}).message} + + ); +} diff --git a/source/utils/browser/lifecycle.ts b/source/utils/browser/lifecycle.ts index 95a1153..9d7adf1 100644 --- a/source/utils/browser/lifecycle.ts +++ b/source/utils/browser/lifecycle.ts @@ -170,6 +170,12 @@ export async function startBrowserSession( resolvedProfileId = stored?.profileId; } + const persistProfile = options.profileName + ? resolvedProfileId + ? Boolean(options.updateProfile) + : true + : false; + let createdSession: UnknownRecord; try { createdSession = await createSessionFromApi( @@ -182,7 +188,7 @@ export async function startBrowserSession( region: options.region, solveCaptcha: options.solveCaptcha, profileId: resolvedProfileId, - persistProfile: Boolean(options.profileName), + persistProfile, }, environment, apiUrl, diff --git a/source/utils/browser/lifecycle/types.ts b/source/utils/browser/lifecycle/types.ts index 42a4365..642a874 100644 --- a/source/utils/browser/lifecycle/types.ts +++ b/source/utils/browser/lifecycle/types.ts @@ -70,6 +70,7 @@ export type StartBrowserSessionOptions = { deadSessionBehavior?: DeadSessionBehavior; environment?: NodeJS.ProcessEnv; profileName?: string; + updateProfile?: boolean; }; export type StopBrowserSessionOptions = { diff --git a/source/utils/browser/profile-porter.ts b/source/utils/browser/profile-porter.ts new file mode 100644 index 0000000..66db728 --- /dev/null +++ b/source/utils/browser/profile-porter.ts @@ -0,0 +1,326 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {execSync} from 'node:child_process'; +import {zipSync} from 'fflate'; +import Database from 'better-sqlite3'; + +export type SyncProfileOptions = { + name: string; + chromeProfile?: string; +}; + +export type SyncProfileResult = { + profileId: string; + cookiesReencrypted: number; + zipBytes: number; +}; + +const CHROME_BASE_DIR = path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Google', + 'Chrome', +); + +const IV = Buffer.alloc(16, 0x20); // 16 space bytes + +const INCLUDE_ENTRIES = [ + 'Cookies', + 'Local Storage', + 'IndexedDB', + 'Preferences', + 'Bookmarks', + 'Favicons', + 'History', + 'Web Data', +]; + +const SKIP_NAMES = new Set([ + 'LOCK', + 'SingletonLock', + 'SingletonCookie', + 'SingletonSocket', +]); + +const SKIP_EXTS = new Set(['.log', '.pma']); + +function getKeychainPassphrase(): string { + return execSync( + 'security find-generic-password -w -s "Chrome Safe Storage"', + { + encoding: 'utf-8', + }, + ).trim(); +} + +function deriveKey(passphrase: string, iterations: number): Buffer { + return crypto.pbkdf2Sync(passphrase, 'saltysalt', iterations, 16, 'sha1'); +} + +function decryptCookie( + encryptedValue: Buffer, + key: Buffer, + hostKey: string, + metaVersion: number, +): string | null { + const prefix = encryptedValue.slice(0, 3).toString('ascii'); + if (prefix !== 'v10') return null; + + const decipher = crypto.createDecipheriv('aes-128-cbc', key, IV); + decipher.setAutoPadding(true); + + let plaintext: Buffer; + try { + plaintext = Buffer.concat([ + decipher.update(encryptedValue.slice(3)), + decipher.final(), + ]); + } catch { + return null; + } + + if (metaVersion >= 24 && plaintext.length >= 32) { + const expectedHash = crypto.createHash('sha256').update(hostKey).digest(); + if (expectedHash.equals(plaintext.slice(0, 32))) { + return plaintext.slice(32).toString('utf-8'); + } + } + + return plaintext.toString('utf-8'); +} + +function encryptCookie( + value: string, + key: Buffer, + hostKey: string, + metaVersion: number, +): Buffer { + let plaintext = Buffer.from(value, 'utf-8'); + + if (metaVersion >= 24) { + const domainHash = crypto.createHash('sha256').update(hostKey).digest(); + plaintext = Buffer.concat([domainHash, plaintext]); + } + + const cipher = crypto.createCipheriv('aes-128-cbc', key, IV); + cipher.setAutoPadding(true); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([Buffer.from('v10'), encrypted]); +} + +function makeReencryptedCookiesBuffer( + originalPath: string, + macosKey: Buffer, + peanutsKey: Buffer, +): {buffer: Buffer; converted: number} { + const tmpPath = originalPath + '.steel_tmp'; + fs.copyFileSync(originalPath, tmpPath); + + const db = new Database(tmpPath); + const metaVersion = Number( + ( + db.prepare("SELECT value FROM meta WHERE key='version'").get() as + | {value: string} + | undefined + )?.value ?? 0, + ); + + const rows = db + .prepare( + 'SELECT rowid, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 3', + ) + .all() as Array<{rowid: number; host_key: string; encrypted_value: Buffer}>; + + const update = db.prepare( + 'UPDATE cookies SET encrypted_value = ? WHERE rowid = ?', + ); + + let converted = 0; + + db.transaction(() => { + for (const row of rows) { + const plaintext = decryptCookie( + row.encrypted_value, + macosKey, + row.host_key, + metaVersion, + ); + if (plaintext === null) continue; + + const reencrypted = encryptCookie( + plaintext, + peanutsKey, + row.host_key, + metaVersion, + ); + update.run(reencrypted, row.rowid); + converted++; + } + })(); + + db.close(); + + const buffer = fs.readFileSync(tmpPath); + fs.unlinkSync(tmpPath); + return {buffer, converted}; +} + +function collectFiles( + dirPath: string, + baseDir: string, + files: Record = {}, +): Record { + if (!fs.existsSync(dirPath)) return files; + + for (const entry of fs.readdirSync(dirPath, {withFileTypes: true})) { + if (SKIP_NAMES.has(entry.name)) continue; + if (SKIP_EXTS.has(path.extname(entry.name))) continue; + + const fullPath = path.join(dirPath, entry.name); + const relPath = path.relative(baseDir, fullPath); + + if (entry.isDirectory()) { + collectFiles(fullPath, baseDir, files); + } else if (entry.isFile()) { + files[relPath] = new Uint8Array(fs.readFileSync(fullPath)); + } + } + + return files; +} + +export type ChromeProfile = { + dirName: string; + displayName: string; +}; + +export function findChromeProfiles(): ChromeProfile[] { + if (!fs.existsSync(CHROME_BASE_DIR)) return []; + return fs + .readdirSync(CHROME_BASE_DIR, {withFileTypes: true}) + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(name => fs.existsSync(path.join(CHROME_BASE_DIR, name, 'Cookies'))) + .map(dirName => ({ + dirName, + displayName: getChromeProfileDisplayName(dirName), + })); +} + +function getChromeProfileDisplayName(dirName: string): string { + try { + const prefsPath = path.join(CHROME_BASE_DIR, dirName, 'Preferences'); + const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8')) as { + profile?: {name?: string}; + account_info?: Array<{full_name?: string}>; + }; + const name = prefs?.profile?.name; + if (name && name !== dirName) return name; + const fullName = prefs?.account_info?.[0]?.full_name; + if (fullName) return fullName; + } catch { + console.error(`Error getting Chrome profile display name for ${dirName}`); + } + + return dirName; +} + +export function isChromeRunning(): boolean { + try { + execSync('pgrep -x "Google Chrome"', {stdio: 'ignore'}); + return true; + } catch { + return false; + } +} + +export type PackageResult = { + zipBuffer: Buffer; + cookiesReencrypted: number; +}; + +export function packageChromeProfile( + chromeProfile: string, + onProgress?: (msg: string) => void, +): PackageResult { + const profileDir = path.join(CHROME_BASE_DIR, chromeProfile); + + if (!fs.existsSync(path.join(profileDir, 'Cookies'))) { + throw new Error( + `Chrome profile "${chromeProfile}" not found at ${profileDir}`, + ); + } + + onProgress?.('Reading Keychain passphrase...'); + const passphrase = getKeychainPassphrase(); + const macosKey = deriveKey(passphrase, 1003); + const peanutsKey = deriveKey('peanuts', 1); + + const files: Record = {}; + let cookiesReencrypted = 0; + + for (const entry of INCLUDE_ENTRIES) { + const fullPath = path.join(profileDir, entry); + if (!fs.existsSync(fullPath)) continue; + + const stat = fs.statSync(fullPath); + + if (stat.isFile()) { + if (entry === 'Cookies') { + onProgress?.('Re-encrypting Cookies...'); + const {buffer, converted} = makeReencryptedCookiesBuffer( + fullPath, + macosKey, + peanutsKey, + ); + files[`Default/${entry}`] = new Uint8Array(buffer); + cookiesReencrypted = converted; + } else { + files[`Default/${entry}`] = new Uint8Array(fs.readFileSync(fullPath)); + } + } else if (stat.isDirectory()) { + onProgress?.(`Collecting ${entry}/...`); + collectFiles(fullPath, profileDir, files); + } + } + + onProgress?.('Zipping...'); + const zipped = zipSync(files, {level: 6}); + return {zipBuffer: Buffer.from(zipped), cookiesReencrypted}; +} + +export async function uploadProfileToSteel( + zipBuffer: Buffer, + apiKey: string, + apiBase: string, +): Promise { + const form = new FormData(); + form.append( + 'userDataDir', + new Blob([new Uint8Array(zipBuffer)], {type: 'application/zip'}), + 'userDataDir.zip', + ); + + const res = await fetch(`${apiBase}/profiles`, { + method: 'POST', + headers: {'Steel-Api-Key': apiKey}, + body: form, + }); + + const body = (await res.json()) as {id?: string; message?: string}; + + if (!res.ok) { + throw new Error( + `Profile upload failed (${res.status}): ${body.message ?? JSON.stringify(body)}`, + ); + } + + if (!body.id) { + throw new Error('Profile upload response missing id'); + } + + return body.id; +} From f1a0e8f84d61dd52a4f8e36c94c516c755b65af6 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 5 Mar 2026 20:13:08 +0900 Subject: [PATCH 06/12] feat: profile sync --- source/commands/profile/import.tsx | 7 +- source/commands/profile/sync.tsx | 203 ++++++++++++++++++ .../utils/browser/lifecycle/profile-store.ts | 12 +- source/utils/browser/profile-porter.ts | 27 +++ 4 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 source/commands/profile/sync.tsx diff --git a/source/commands/profile/import.tsx b/source/commands/profile/import.tsx index ae6498f..8f134f8 100644 --- a/source/commands/profile/import.tsx +++ b/source/commands/profile/import.tsx @@ -149,7 +149,12 @@ export default function Import({options}: Props) { return; } - await writeSteelProfile(options.name, profileId, process.env); + await writeSteelProfile( + options.name, + profileId, + process.env, + chromeProfile.dirName, + ); setPhase({tag: 'done', profileId, cookiesReencrypted, zipMb}); process.exit(0); diff --git a/source/commands/profile/sync.tsx b/source/commands/profile/sync.tsx new file mode 100644 index 0000000..079141e --- /dev/null +++ b/source/commands/profile/sync.tsx @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import * as React from 'react'; +import {Box, Text} from 'ink'; +import Spinner from 'ink-spinner'; +import zod from 'zod'; +import {option} from 'pastel'; +import { + findChromeProfiles, + isChromeRunning, + packageChromeProfile, + updateProfileOnSteel, + type ChromeProfile, +} from '../../utils/browser/profile-porter.js'; +import {readSteelProfile} from '../../utils/browser/lifecycle/profile-store.js'; +import {resolveBrowserAuth} from '../../utils/browser/auth.js'; +import {DEFAULT_API_PATH} from '../../utils/browser/lifecycle/constants.js'; + +export const description = + 'Sync a local Chrome profile to an existing Steel profile'; + +export const options = zod.object({ + name: zod.string().describe( + option({ + description: 'Steel profile name to sync', + }), + ), + from: zod + .string() + .describe( + option({ + description: 'Chrome profile to sync from (overrides stored source)', + }), + ) + .optional(), +}); + +type Props = { + options: zod.infer; +}; + +type Phase = + | {tag: 'syncing'; chromeProfile: ChromeProfile; step: string} + | {tag: 'done'; cookiesReencrypted: number; zipMb: string} + | {tag: 'error'; message: string}; + +export default function Sync({options}: Props) { + const [phase, setPhase] = React.useState(null); + + React.useEffect(() => { + (async () => { + if (process.platform !== 'darwin') { + setPhase({ + tag: 'error', + message: '`steel profile sync` is currently macOS only.', + }); + return; + } + + const auth = resolveBrowserAuth(process.env); + if (!auth.apiKey) { + setPhase({ + tag: 'error', + message: 'No API key found. Run `steel login` or set STEEL_API_KEY.', + }); + return; + } + + const stored = await readSteelProfile(options.name, process.env); + if (!stored) { + setPhase({ + tag: 'error', + message: `Profile "${options.name}" not found. Run \`steel profile import --name ${options.name}\` first.`, + }); + return; + } + + const chromeProfileDirName = options.from ?? stored.chromeProfile; + if (!chromeProfileDirName) { + setPhase({ + tag: 'error', + message: `No source Chrome profile stored for "${options.name}". Specify one with --from.`, + }); + return; + } + + const allProfiles = findChromeProfiles(); + const chromeProfile = allProfiles.find( + p => p.dirName === chromeProfileDirName, + ); + if (!chromeProfile) { + setPhase({ + tag: 'error', + message: `Chrome profile "${chromeProfileDirName}" not found. Available: ${allProfiles.map(p => p.dirName).join(', ')}`, + }); + return; + } + + if (isChromeRunning()) { + console.error( + 'Warning: Chrome is running. Close it for best results (cookie file may be locked).', + ); + } + + setPhase({tag: 'syncing', chromeProfile, step: 'Starting...'}); + + let zipBuffer: Buffer; + let cookiesReencrypted: number; + + try { + ({zipBuffer, cookiesReencrypted} = packageChromeProfile( + chromeProfile.dirName, + msg => { + setPhase({tag: 'syncing', chromeProfile, step: msg}); + }, + )); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + setPhase({ + tag: 'syncing', + chromeProfile, + step: 'Uploading to Steel...', + }); + + try { + await updateProfileOnSteel( + stored.profileId, + zipBuffer, + auth.apiKey, + DEFAULT_API_PATH, + ); + } catch (error) { + setPhase({ + tag: 'error', + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + const zipMb = (zipBuffer.length / 1024 / 1024).toFixed(1); + setPhase({tag: 'done', cookiesReencrypted, zipMb}); + process.exit(0); + })(); + }, []); + + if (phase === null) { + return ( + + + + + Checking... + + ); + } + + if (phase.tag === 'syncing') { + return ( + + + + + + {' '} + {phase.chromeProfile.displayName} → {options.name} + {' '} + {phase.step} + + + ); + } + + if (phase.tag === 'done') { + return ( + + + + + Synced {options.name} + + + + + {phase.cookiesReencrypted} cookies re-encrypted · {phase.zipMb} MB + + + + ); + } + + return ( + + + {phase.message} + + ); +} diff --git a/source/utils/browser/lifecycle/profile-store.ts b/source/utils/browser/lifecycle/profile-store.ts index 537bede..e3a1c8a 100644 --- a/source/utils/browser/lifecycle/profile-store.ts +++ b/source/utils/browser/lifecycle/profile-store.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; type SteelProfileData = { profileId: string; + chromeProfile?: string; }; function getProfilesDirectory(environment: NodeJS.ProcessEnv): string { @@ -32,7 +33,7 @@ export function validateProfileName(name: string): string | null { export async function readSteelProfile( name: string, environment: NodeJS.ProcessEnv, -): Promise<{profileId: string} | null> { +): Promise<{profileId: string; chromeProfile?: string} | null> { const filePath = getProfilePath(name, environment); try { @@ -44,7 +45,11 @@ export async function readSteelProfile( typeof parsed === 'object' && typeof (parsed as Record)['profileId'] === 'string' ) { - return {profileId: (parsed as SteelProfileData).profileId}; + const data = parsed as SteelProfileData; + return { + profileId: data.profileId, + chromeProfile: data.chromeProfile, + }; } return null; @@ -57,12 +62,13 @@ export async function writeSteelProfile( name: string, profileId: string, environment: NodeJS.ProcessEnv, + chromeProfile?: string, ): Promise { const profilesDir = getProfilesDirectory(environment); await fs.mkdir(profilesDir, {recursive: true}); const filePath = getProfilePath(name, environment); - const data: SteelProfileData = {profileId}; + const data: SteelProfileData = {profileId, chromeProfile}; await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); } diff --git a/source/utils/browser/profile-porter.ts b/source/utils/browser/profile-porter.ts index 66db728..5ac5ce6 100644 --- a/source/utils/browser/profile-porter.ts +++ b/source/utils/browser/profile-porter.ts @@ -292,6 +292,33 @@ export function packageChromeProfile( return {zipBuffer: Buffer.from(zipped), cookiesReencrypted}; } +export async function updateProfileOnSteel( + profileId: string, + zipBuffer: Buffer, + apiKey: string, + apiBase: string, +): Promise { + const form = new FormData(); + form.append( + 'userDataDir', + new Blob([new Uint8Array(zipBuffer)], {type: 'application/zip'}), + 'userDataDir.zip', + ); + + const res = await fetch(`${apiBase}/profiles/${profileId}`, { + method: 'PATCH', + headers: {'Steel-Api-Key': apiKey}, + body: form, + }); + + if (!res.ok) { + const body = (await res.json()) as {message?: string}; + throw new Error( + `Profile update failed (${res.status}): ${body.message ?? JSON.stringify(body)}`, + ); + } +} + export async function uploadProfileToSteel( zipBuffer: Buffer, apiKey: string, From fc2a838f58a81dd11e972bd12347fc1624f5441b Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 5 Mar 2026 20:45:13 +0900 Subject: [PATCH 07/12] feat: better ux --- source/commands/profile/delete.tsx | 12 +++++++++++- source/commands/profile/import.tsx | 11 ++++++++++- source/commands/profile/sync.tsx | 21 ++++++++++++++++++++- source/utils/browser/lifecycle.ts | 8 +++----- source/utils/browser/profile-porter.ts | 5 ++++- tests/unit/browser-lifecycle.test.ts | 5 ++++- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/source/commands/profile/delete.tsx b/source/commands/profile/delete.tsx index 3e8f80e..45a4886 100644 --- a/source/commands/profile/delete.tsx +++ b/source/commands/profile/delete.tsx @@ -3,7 +3,10 @@ import {useEffect} from 'react'; import zod from 'zod'; import {option} from 'pastel'; -import {deleteSteelProfile} from '../../utils/browser/lifecycle/profile-store.js'; +import { + deleteSteelProfile, + validateProfileName, +} from '../../utils/browser/lifecycle/profile-store.js'; export const description = 'Delete a saved Steel browser profile (local file only)'; @@ -23,6 +26,13 @@ type Props = { export default function Delete({options}: Props) { useEffect(() => { async function run() { + const nameError = validateProfileName(options.name); + if (nameError) { + console.error(nameError); + process.exit(1); + return; + } + const deleted = await deleteSteelProfile(options.name, process.env); if (!deleted) { diff --git a/source/commands/profile/import.tsx b/source/commands/profile/import.tsx index 8f134f8..f27e357 100644 --- a/source/commands/profile/import.tsx +++ b/source/commands/profile/import.tsx @@ -13,7 +13,10 @@ import { uploadProfileToSteel, type ChromeProfile, } from '../../utils/browser/profile-porter.js'; -import {writeSteelProfile} from '../../utils/browser/lifecycle/profile-store.js'; +import { + validateProfileName, + writeSteelProfile, +} from '../../utils/browser/lifecycle/profile-store.js'; import {resolveBrowserAuth} from '../../utils/browser/auth.js'; import {DEFAULT_API_PATH} from '../../utils/browser/lifecycle/constants.js'; @@ -52,6 +55,12 @@ export default function Import({options}: Props) { const [phase, setPhase] = React.useState({tag: 'checking'}); React.useEffect(() => { + const nameError = validateProfileName(options.name); + if (nameError) { + setPhase({tag: 'error', message: nameError}); + return; + } + if (process.platform !== 'darwin') { setPhase({ tag: 'error', diff --git a/source/commands/profile/sync.tsx b/source/commands/profile/sync.tsx index 079141e..09a46cc 100644 --- a/source/commands/profile/sync.tsx +++ b/source/commands/profile/sync.tsx @@ -12,7 +12,11 @@ import { updateProfileOnSteel, type ChromeProfile, } from '../../utils/browser/profile-porter.js'; -import {readSteelProfile} from '../../utils/browser/lifecycle/profile-store.js'; +import { + readSteelProfile, + validateProfileName, + writeSteelProfile, +} from '../../utils/browser/lifecycle/profile-store.js'; import {resolveBrowserAuth} from '../../utils/browser/auth.js'; import {DEFAULT_API_PATH} from '../../utils/browser/lifecycle/constants.js'; @@ -49,6 +53,12 @@ export default function Sync({options}: Props) { React.useEffect(() => { (async () => { + const nameError = validateProfileName(options.name); + if (nameError) { + setPhase({tag: 'error', message: nameError}); + return; + } + if (process.platform !== 'darwin') { setPhase({ tag: 'error', @@ -143,6 +153,15 @@ export default function Sync({options}: Props) { return; } + if (options.from && options.from !== stored.chromeProfile) { + await writeSteelProfile( + options.name, + stored.profileId, + process.env, + options.from, + ); + } + const zipMb = (zipBuffer.length / 1024 / 1024).toFixed(1); setPhase({tag: 'done', cookiesReencrypted, zipMb}); process.exit(0); diff --git a/source/utils/browser/lifecycle.ts b/source/utils/browser/lifecycle.ts index 56ec104..1c15e00 100644 --- a/source/utils/browser/lifecycle.ts +++ b/source/utils/browser/lifecycle.ts @@ -170,11 +170,9 @@ export async function startBrowserSession( resolvedProfileId = stored?.profileId; } - const persistProfile = options.profileName - ? resolvedProfileId - ? Boolean(options.updateProfile) - : true - : false; + const persistProfile = Boolean( + options.profileName && options.updateProfile, + ); let createdSession: UnknownRecord; try { diff --git a/source/utils/browser/profile-porter.ts b/source/utils/browser/profile-porter.ts index 5ac5ce6..f15493c 100644 --- a/source/utils/browser/profile-porter.ts +++ b/source/utils/browser/profile-porter.ts @@ -283,7 +283,10 @@ export function packageChromeProfile( } } else if (stat.isDirectory()) { onProgress?.(`Collecting ${entry}/...`); - collectFiles(fullPath, profileDir, files); + const dirFiles = collectFiles(fullPath, fullPath, {}); + for (const [relPath, data] of Object.entries(dirFiles)) { + files[`Default/${entry}/${relPath}`] = data; + } } } diff --git a/tests/unit/browser-lifecycle.test.ts b/tests/unit/browser-lifecycle.test.ts index 815aaa9..5fdbb9c 100644 --- a/tests/unit/browser-lifecycle.test.ts +++ b/tests/unit/browser-lifecycle.test.ts @@ -1482,6 +1482,7 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); const session = await lifecycle.startBrowserSession({ profileName: 'myapp', + updateProfile: true, environment: { STEEL_API_KEY: 'env-api-key', STEEL_CONFIG_DIR: configDirectory, @@ -1534,6 +1535,7 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); const session = await lifecycle.startBrowserSession({ profileName: 'myapp', + updateProfile: true, environment: { STEEL_API_KEY: 'env-api-key', STEEL_CONFIG_DIR: configDirectory, @@ -1670,7 +1672,8 @@ describe('browser lifecycle session profile contract', () => { const body = JSON.parse( fetchMock.mock.calls[0]?.[1]?.body as string, ) as Record; - expect(body['persistProfile']).toBe(true); + // --profile without --update-profile should not persist + expect(body['persistProfile']).toBeFalsy(); } finally { fs.rmSync(configDirectory, {recursive: true, force: true}); } From a603d7e9d66de5fa426d89b670da6ede2359c00f Mon Sep 17 00:00:00 2001 From: junhsss Date: Fri, 6 Mar 2026 02:25:36 +0900 Subject: [PATCH 08/12] feat: skills --- .../references/steel-browser-lifecycle.md | 13 +++++++++++++ source/utils/browser/lifecycle.ts | 3 +++ 2 files changed, 16 insertions(+) diff --git a/skills/steel-browser/references/steel-browser-lifecycle.md b/skills/steel-browser/references/steel-browser-lifecycle.md index 72f74cb..1cf34f3 100644 --- a/skills/steel-browser/references/steel-browser-lifecycle.md +++ b/skills/steel-browser/references/steel-browser-lifecycle.md @@ -49,6 +49,8 @@ Main flags: - `--session-solve-captcha` - `--namespace ` — credential namespace for the session - `--credentials` — enable credential injection for the session +- `--profile ` — load a named browser profile into the session +- `--update-profile` — save session state back to the profile on end Parse these output fields: @@ -137,6 +139,17 @@ Main flags: - `--local` - `--api-url ` +## Profile persistence + +```bash +steel browser start --session "$SESSION" --profile myapp --update-profile +``` + +- `--profile `: Load a previously imported browser profile (cookies, storage, etc.) into the session. +- `--update-profile`: Save session state back to the profile when the session ends. + +Profiles are created outside agent workflows via `steel profile import`. Agents should only consume them with the flags above. + ## Passthrough bootstrap behavior For inherited commands, Steel may inject resolved `--cdp` automatically. diff --git a/source/utils/browser/lifecycle.ts b/source/utils/browser/lifecycle.ts index 1c15e00..3067891 100644 --- a/source/utils/browser/lifecycle.ts +++ b/source/utils/browser/lifecycle.ts @@ -164,10 +164,12 @@ export async function startBrowserSession( } let resolvedProfileId: string | undefined; + let storedChromeProfile: string | undefined; if (options.profileName) { const stored = await readSteelProfile(options.profileName, environment); resolvedProfileId = stored?.profileId; + storedChromeProfile = stored?.chromeProfile; } const persistProfile = Boolean( @@ -255,6 +257,7 @@ export async function startBrowserSession( options.profileName, returnedProfileId, environment, + storedChromeProfile, ); } } From ebfdc663382a5a4a4ba73b0e8dbe769135911532 Mon Sep 17 00:00:00 2001 From: junhsss Date: Fri, 6 Mar 2026 02:29:41 +0900 Subject: [PATCH 09/12] docs: profiles --- README.md | 1 + docs/README.md | 1 + docs/references/steel-browser.md | 31 ++++++++++++++++++++++++++++++- docs/references/steel-cli.md | 9 +++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5174ce7..b908506 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Read more: | Quickstart | `forge`, `run` | | Browser lifecycle | `browser start`, `browser stop`, `browser sessions`, `browser live` | | Browser passthrough | `steel browser ` | +| Browser profiles | `profile import`, `profile sync`, `profile list`, `profile delete` | | API tools | `scrape`, `screenshot`, `pdf` | | Local runtime | `dev install`, `dev start`, `dev stop` | | Account and utility | `login`, `logout`, `config`, `settings`, `cache`, `docs`, `support`, `star`, `update` | diff --git a/docs/README.md b/docs/README.md index c0a6372..3fd3617 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ This folder contains both generated command docs and hand-maintained migration/r - `migration-agent-browser.md`: migration guide from `agent-browser` to `steel browser`. - `upstream-sync.md`: maintainer guide for vendored runtime updates. - `references/`: stable quick-reference docs and synced upstream command catalogs. + - Profile commands and persistence are documented in `references/steel-browser.md` and `references/steel-cli.md`. ## References Subfolder diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index db4ec7e..56c869e 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -62,6 +62,8 @@ Main flags: - `--session-solve-captcha` - `--namespace ` - `--credentials` +- `--profile ` +- `--update-profile` Flag semantics: @@ -78,7 +80,12 @@ Flag semantics: stored under this namespace will be available for injection. - `--credentials` enables credential injection for the session. Sends `credentials: {}` in the session creation payload. -- `--stealth`, `--proxy`, `--namespace`, and `--credentials` are create-time flags. +- `--profile ` loads a previously imported browser profile (cookies, local + storage, etc.) into the session. +- `--update-profile` saves session state back to the profile when the session ends. + Requires `--profile`. +- `--stealth`, `--proxy`, `--namespace`, `--credentials`, `--profile`, and + `--update-profile` are create-time flags. If `--session ` attaches to an existing live session, these values are not re-applied. @@ -161,6 +168,28 @@ API mapping: - Request body: optional `pageId`, `url`, `taskId` - Response: `success` + optional `message` +## Profile Persistence + +Profiles let sessions start with pre-existing browser state (cookies, local storage, IndexedDB, etc.) imported from a local Chrome installation. + +Setup (macOS only, run by user): + +```bash +steel profile import --name myapp # import Chrome profile to Steel +steel profile sync --name myapp # re-sync local changes to Steel +steel profile list # list saved profiles +steel profile delete --name myapp # remove local metadata +``` + +Usage in sessions: + +```bash +steel browser start --session job --profile myapp # load profile +steel browser start --session job --profile myapp --update-profile # load + save back +``` + +Profile metadata is stored at `~/.config/steel/profiles/.json`. + ## Passthrough Bootstrap Rules For inherited commands, Steel bootstrap injects a resolved `--cdp` endpoint unless explicit attach flags are present. diff --git a/docs/references/steel-cli.md b/docs/references/steel-cli.md index cd90ebc..922270f 100644 --- a/docs/references/steel-cli.md +++ b/docs/references/steel-cli.md @@ -19,6 +19,13 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer - `steel browser live`: print the active session live-view URL. - `steel browser `: pass through to vendored `agent-browser` runtime. +### Profile Commands + +- `steel profile import`: import a local Chrome profile into Steel (macOS only). +- `steel profile sync`: sync a local Chrome profile to an existing Steel profile (macOS only). +- `steel profile list`: list all saved Steel browser profiles. +- `steel profile delete`: delete a saved Steel profile (local metadata only). + ### Credentials Commands - `steel credentials create`: store a new credential for a given origin. @@ -67,6 +74,7 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer - Config directory: `~/.config/steel` - Main config: `~/.config/steel/config.json` - Browser session state: `~/.config/steel/browser-session-state.json` +- Profile metadata: `~/.config/steel/profiles/.json` ## Environment Variables (Common) @@ -75,6 +83,7 @@ For generated flags and argument schemas, use [../cli-reference.md](../cli-refer - `STEEL_BROWSER_API_URL`: canonical self-hosted local endpoint override. - `STEEL_LOCAL_API_URL`: backward-compatible self-hosted alias. - `STEEL_CONFIG_DIR`: override config directory root. +- `STEEL_PROFILE`: default profile name for browser sessions. ## Key References From 6f8e131474b0c8817555079f434469695fef80e6 Mon Sep 17 00:00:00 2001 From: junhsss Date: Sat, 14 Mar 2026 01:13:07 +0900 Subject: [PATCH 10/12] fix: profile passthrough and validation gaps --- source/utils/browser/lifecycle.ts | 9 +- .../browser/lifecycle/bootstrap-flags.ts | 6 + .../utils/browser/lifecycle/profile-store.ts | 4 +- source/utils/browser/lifecycle/types.ts | 1 + source/utils/browser/profile-porter.ts | 107 +++++++++++------- tests/unit/browser-lifecycle.test.ts | 43 +++++++ tests/unit/browser-profile-store.test.ts | 10 ++ 7 files changed, 133 insertions(+), 47 deletions(-) diff --git a/source/utils/browser/lifecycle.ts b/source/utils/browser/lifecycle.ts index 3067891..c15cff1 100644 --- a/source/utils/browser/lifecycle.ts +++ b/source/utils/browser/lifecycle.ts @@ -11,6 +11,7 @@ import { import {parseBrowserPassthroughBootstrapFlags} from './lifecycle/bootstrap-flags.js'; import { readSteelProfile, + validateProfileName, writeSteelProfile, } from './lifecycle/profile-store.js'; import { @@ -167,6 +168,11 @@ export async function startBrowserSession( let storedChromeProfile: string | undefined; if (options.profileName) { + const nameError = validateProfileName(options.profileName); + if (nameError) { + throw new BrowserAdapterError('INVALID_BROWSER_ARGS', nameError); + } + const stored = await readSteelProfile(options.profileName, environment); resolvedProfileId = stored?.profileId; storedChromeProfile = stored?.chromeProfile; @@ -211,7 +217,7 @@ export async function startBrowserSession( headless: options.headless, region: options.region, solveCaptcha: options.solveCaptcha, - persistProfile: true, + persistProfile, namespace: options.namespace, credentials: options.credentials, }, @@ -709,6 +715,7 @@ export async function bootstrapBrowserPassthroughArgv( deadSessionBehavior: 'error', environment, profileName: parsed.options.profileName || undefined, + updateProfile: parsed.options.updateProfile || undefined, }); if (!session.connectUrl) { diff --git a/source/utils/browser/lifecycle/bootstrap-flags.ts b/source/utils/browser/lifecycle/bootstrap-flags.ts index 73fd03b..1942936 100644 --- a/source/utils/browser/lifecycle/bootstrap-flags.ts +++ b/source/utils/browser/lifecycle/bootstrap-flags.ts @@ -23,6 +23,7 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { autoConnect: false, cdpTarget: null, profileName: process.env['STEEL_PROFILE']?.trim() || null, + updateProfile: false, namespace: null, credentials: false, }; @@ -233,6 +234,11 @@ export function parseBrowserPassthroughBootstrapFlags(browserArgv: string[]): { continue; } + if (argument === '--update-profile') { + options.updateProfile = true; + continue; + } + if (argument === '--namespace' || argument.startsWith('--namespace=')) { const value = argument === '--namespace' diff --git a/source/utils/browser/lifecycle/profile-store.ts b/source/utils/browser/lifecycle/profile-store.ts index e3a1c8a..609d4f8 100644 --- a/source/utils/browser/lifecycle/profile-store.ts +++ b/source/utils/browser/lifecycle/profile-store.ts @@ -23,8 +23,8 @@ export function validateProfileName(name: string): string | null { return 'Profile name cannot be empty.'; } - if (name.includes('/') || name.includes('\\')) { - return `Invalid profile name "${name}". Use a name like "myapp", not a path.`; + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) { + return `Invalid profile name "${name}". Use alphanumeric characters, hyphens, or underscores (must start with alphanumeric).`; } return null; diff --git a/source/utils/browser/lifecycle/types.ts b/source/utils/browser/lifecycle/types.ts index fb61491..a152f90 100644 --- a/source/utils/browser/lifecycle/types.ts +++ b/source/utils/browser/lifecycle/types.ts @@ -46,6 +46,7 @@ export type ParsedBootstrapOptions = { autoConnect: boolean; cdpTarget: string | null; profileName: string | null; + updateProfile: boolean; namespace: string | null; credentials: boolean; }; diff --git a/source/utils/browser/profile-porter.ts b/source/utils/browser/profile-porter.ts index f15493c..ef24f72 100644 --- a/source/utils/browser/profile-porter.ts +++ b/source/utils/browser/profile-porter.ts @@ -116,56 +116,75 @@ function makeReencryptedCookiesBuffer( macosKey: Buffer, peanutsKey: Buffer, ): {buffer: Buffer; converted: number} { - const tmpPath = originalPath + '.steel_tmp'; - fs.copyFileSync(originalPath, tmpPath); - - const db = new Database(tmpPath); - const metaVersion = Number( - ( - db.prepare("SELECT value FROM meta WHERE key='version'").get() as - | {value: string} - | undefined - )?.value ?? 0, + const tmpPath = path.join( + os.tmpdir(), + `steel-cookies-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, ); + fs.copyFileSync(originalPath, tmpPath); - const rows = db - .prepare( - 'SELECT rowid, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 3', - ) - .all() as Array<{rowid: number; host_key: string; encrypted_value: Buffer}>; - - const update = db.prepare( - 'UPDATE cookies SET encrypted_value = ? WHERE rowid = ?', - ); - - let converted = 0; - - db.transaction(() => { - for (const row of rows) { - const plaintext = decryptCookie( - row.encrypted_value, - macosKey, - row.host_key, - metaVersion, + try { + const db = new Database(tmpPath); + try { + const metaVersion = Number( + ( + db.prepare("SELECT value FROM meta WHERE key='version'").get() as + | {value: string} + | undefined + )?.value ?? 0, ); - if (plaintext === null) continue; - const reencrypted = encryptCookie( - plaintext, - peanutsKey, - row.host_key, - metaVersion, + const rows = db + .prepare( + 'SELECT rowid, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 3', + ) + .all() as Array<{ + rowid: number; + host_key: string; + encrypted_value: Buffer; + }>; + + const update = db.prepare( + 'UPDATE cookies SET encrypted_value = ? WHERE rowid = ?', ); - update.run(reencrypted, row.rowid); - converted++; - } - })(); - - db.close(); - const buffer = fs.readFileSync(tmpPath); - fs.unlinkSync(tmpPath); - return {buffer, converted}; + let converted = 0; + + db.transaction(() => { + for (const row of rows) { + const plaintext = decryptCookie( + row.encrypted_value, + macosKey, + row.host_key, + metaVersion, + ); + if (plaintext === null) continue; + + const reencrypted = encryptCookie( + plaintext, + peanutsKey, + row.host_key, + metaVersion, + ); + update.run(reencrypted, row.rowid); + converted++; + } + })(); + + db.close(); + + const buffer = fs.readFileSync(tmpPath); + return {buffer, converted}; + } catch (error) { + db.close(); + throw error; + } + } finally { + try { + fs.unlinkSync(tmpPath); + } catch { + // best-effort cleanup + } + } } function collectFiles( diff --git a/tests/unit/browser-lifecycle.test.ts b/tests/unit/browser-lifecycle.test.ts index 5fdbb9c..eca25bd 100644 --- a/tests/unit/browser-lifecycle.test.ts +++ b/tests/unit/browser-lifecycle.test.ts @@ -104,6 +104,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { autoConnect: false, cdpTarget: null, profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -212,6 +213,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { autoConnect: false, cdpTarget: null, profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -252,6 +254,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { autoConnect: false, cdpTarget: null, profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -293,6 +296,7 @@ describe('browser lifecycle passthrough bootstrap parsing', () => { autoConnect: false, cdpTarget: null, profileName: null, + updateProfile: false, namespace: null, credentials: false, }); @@ -1448,6 +1452,44 @@ describe('browser lifecycle --profile flag parsing', () => { } }); + test('parses --update-profile flag', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + 'https://steel.dev', + '--profile', + 'myapp', + '--update-profile', + ]); + + expect(parsed.options.profileName).toBe('myapp'); + expect(parsed.options.updateProfile).toBe(true); + expect(parsed.passthroughArgv).toEqual(['open', 'https://steel.dev']); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + + test('updateProfile defaults to false', async () => { + const configDirectory = createTempConfigDirectory(); + + try { + const lifecycle = await loadBrowserLifecycle(configDirectory); + const parsed = lifecycle.parseBrowserPassthroughBootstrapFlags([ + 'open', + '--profile', + 'myapp', + ]); + + expect(parsed.options.updateProfile).toBe(false); + } finally { + fs.rmSync(configDirectory, {recursive: true, force: true}); + } + }); + test('profileName defaults to null when STEEL_PROFILE is not set', async () => { const configDirectory = createTempConfigDirectory(); @@ -1586,6 +1628,7 @@ describe('browser lifecycle session profile contract', () => { const lifecycle = await loadBrowserLifecycle(configDirectory); const session = await lifecycle.startBrowserSession({ profileName: 'myapp', + updateProfile: true, environment: { STEEL_API_KEY: 'env-api-key', STEEL_CONFIG_DIR: configDirectory, diff --git a/tests/unit/browser-profile-store.test.ts b/tests/unit/browser-profile-store.test.ts index ffdb706..1c60dbf 100644 --- a/tests/unit/browser-profile-store.test.ts +++ b/tests/unit/browser-profile-store.test.ts @@ -32,6 +32,16 @@ describe('validateProfileName', () => { expect(validateProfileName('my\\app')).not.toBeNull(); expect(validateProfileName('../etc/passwd')).not.toBeNull(); }); + + test('rejects path traversal names', () => { + expect(validateProfileName('.')).not.toBeNull(); + expect(validateProfileName('..')).not.toBeNull(); + expect(validateProfileName('.hidden')).not.toBeNull(); + }); + + test('rejects names with dots', () => { + expect(validateProfileName('my.app')).not.toBeNull(); + }); }); describe('readSteelProfile', () => { From 34d733949365813e041f9f9606f29e61bd360e5f Mon Sep 17 00:00:00 2001 From: junhsss Date: Sat, 14 Mar 2026 02:07:47 +0900 Subject: [PATCH 11/12] chore: use node 18 compatible better-sqlite3 --- package-lock.json | 15 ++++++--------- package.json | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index fcc6e99..52cc5df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@steel-dev/cli", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@steel-dev/cli", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@babel/parser": "^7.27.2", "@types/better-sqlite3": "^7.6.13", - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^11.10.0", "fflate": "^0.8.2", "figures": "^6.1.0", "ink": "^5.2.1", @@ -3202,17 +3202,14 @@ } }, "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, "node_modules/bindings": { diff --git a/package.json b/package.json index 6e564f4..73f24fd 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dependencies": { "@babel/parser": "^7.27.2", "@types/better-sqlite3": "^7.6.13", - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^11.10.0", "fflate": "^0.8.2", "figures": "^6.1.0", "ink": "^5.2.1", From 55f5b6d024a0d989bf002cbd738cb897ddfa2838 Mon Sep 17 00:00:00 2001 From: junhsss Date: Sat, 14 Mar 2026 02:34:53 +0900 Subject: [PATCH 12/12] feat: keychain access warning --- source/commands/profile/import.tsx | 17 ++++++++++++++- source/commands/profile/sync.tsx | 17 ++++++++++++++- source/utils/browser/profile-porter.ts | 29 +++++++++++++++++++++----- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/source/commands/profile/import.tsx b/source/commands/profile/import.tsx index f27e357..269624a 100644 --- a/source/commands/profile/import.tsx +++ b/source/commands/profile/import.tsx @@ -47,6 +47,7 @@ type Props = { type Phase = | {tag: 'checking'} | {tag: 'selecting'; profiles: ChromeProfile[]; chromeRunning: boolean} + | {tag: 'keychain'} | {tag: 'importing'; chromeProfile: ChromeProfile; step: string} | {tag: 'done'; profileId: string; cookiesReencrypted: number; zipMb: string} | {tag: 'error'; message: string}; @@ -118,7 +119,7 @@ export default function Import({options}: Props) { let zipMb: string; try { - ({zipBuffer, cookiesReencrypted} = packageChromeProfile( + ({zipBuffer, cookiesReencrypted} = await packageChromeProfile( chromeProfile.dirName, msg => { setPhase({ @@ -127,6 +128,9 @@ export default function Import({options}: Props) { step: msg, }); }, + () => { + setPhase({tag: 'keychain'}); + }, )); zipMb = (zipBuffer.length / 1024 / 1024).toFixed(1); } catch (error) { @@ -210,6 +214,17 @@ export default function Import({options}: Props) { ); } + if (phase.tag === 'keychain') { + return ( + + + macOS will ask for your password to read Chrome's cookie encryption + key from Keychain. + + + ); + } + if (phase.tag === 'importing') { return ( diff --git a/source/commands/profile/sync.tsx b/source/commands/profile/sync.tsx index 09a46cc..6c314c1 100644 --- a/source/commands/profile/sync.tsx +++ b/source/commands/profile/sync.tsx @@ -44,6 +44,7 @@ type Props = { }; type Phase = + | {tag: 'keychain'} | {tag: 'syncing'; chromeProfile: ChromeProfile; step: string} | {tag: 'done'; cookiesReencrypted: number; zipMb: string} | {tag: 'error'; message: string}; @@ -118,11 +119,14 @@ export default function Sync({options}: Props) { let cookiesReencrypted: number; try { - ({zipBuffer, cookiesReencrypted} = packageChromeProfile( + ({zipBuffer, cookiesReencrypted} = await packageChromeProfile( chromeProfile.dirName, msg => { setPhase({tag: 'syncing', chromeProfile, step: msg}); }, + () => { + setPhase({tag: 'keychain'}); + }, )); } catch (error) { setPhase({ @@ -179,6 +183,17 @@ export default function Sync({options}: Props) { ); } + if (phase.tag === 'keychain') { + return ( + + + macOS will ask for your password to read Chrome's cookie encryption + key from Keychain. + + + ); + } + if (phase.tag === 'syncing') { return ( diff --git a/source/utils/browser/profile-porter.ts b/source/utils/browser/profile-porter.ts index ef24f72..7ac8998 100644 --- a/source/utils/browser/profile-porter.ts +++ b/source/utils/browser/profile-porter.ts @@ -2,7 +2,7 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import {execSync} from 'node:child_process'; +import {exec, execSync} from 'node:child_process'; import {zipSync} from 'fflate'; import Database from 'better-sqlite3'; @@ -56,6 +56,22 @@ function getKeychainPassphrase(): string { ).trim(); } +function getKeychainPassphraseAsync(): Promise { + return new Promise((resolve, reject) => { + exec( + 'security find-generic-password -w -s "Chrome Safe Storage"', + {encoding: 'utf-8'}, + (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout.trim()); + } + }, + ); + }); +} + function deriveKey(passphrase: string, iterations: number): Buffer { return crypto.pbkdf2Sync(passphrase, 'saltysalt', iterations, 16, 'sha1'); } @@ -261,10 +277,11 @@ export type PackageResult = { cookiesReencrypted: number; }; -export function packageChromeProfile( +export async function packageChromeProfile( chromeProfile: string, onProgress?: (msg: string) => void, -): PackageResult { + onKeychainPrompt?: () => void, +): Promise { const profileDir = path.join(CHROME_BASE_DIR, chromeProfile); if (!fs.existsSync(path.join(profileDir, 'Cookies'))) { @@ -273,8 +290,10 @@ export function packageChromeProfile( ); } - onProgress?.('Reading Keychain passphrase...'); - const passphrase = getKeychainPassphrase(); + onKeychainPrompt?.(); + // Let Ink render the message before the OS Keychain dialog appears + await new Promise(resolve => setTimeout(resolve, 100)); + const passphrase = await getKeychainPassphraseAsync(); const macosKey = deriveKey(passphrase, 1003); const peanutsKey = deriveKey('peanuts', 1);