diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2b7aa61..18702c3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,6 +8,7 @@ import { registerConversationCommands } from './cli/commands/conversation.js'; import { registerDispatchCommands } from './cli/commands/dispatch.js'; import { registerMcpCommands } from './cli/commands/mcp.js'; import { registerSafetyCommands } from './cli/commands/safety.js'; +import { registerPortabilityCommands } from './cli/commands/portability.js'; import { registerTriggerCommands } from './cli/commands/trigger.js'; import { addWorkspaceOption, @@ -2220,6 +2221,7 @@ registerConversationCommands(program, DEFAULT_ACTOR); // ============================================================================ registerSafetyCommands(program, DEFAULT_ACTOR); +registerPortabilityCommands(program); // ============================================================================ // onboarding diff --git a/packages/cli/src/cli/commands/portability.ts b/packages/cli/src/cli/commands/portability.ts new file mode 100644 index 0000000..cc4fbc7 --- /dev/null +++ b/packages/cli/src/cli/commands/portability.ts @@ -0,0 +1,86 @@ +import { Command } from 'commander'; +import * as workgraph from '@versatly/workgraph-kernel'; +import { + addWorkspaceOption, + resolveWorkspacePath, + runCommand, +} from '../core.js'; + +export function registerPortabilityCommands(program: Command): void { + addWorkspaceOption( + program + .command('export ') + .description('Export current workspace as tar.gz snapshot') + .option('--json', 'Emit structured JSON output'), + ).action((snapshotPath, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return workgraph.exportImport.exportWorkspaceSnapshot(workspacePath, snapshotPath); + }, + (result) => [ + `Exported workspace snapshot: ${result.snapshotPath}`, + `Workspace: ${result.workspacePath}`, + `Bytes: ${result.bytes}`, + ], + ), + ); + + addWorkspaceOption( + program + .command('import ') + .description('Import a tar.gz snapshot into a workspace') + .option('--overwrite', 'Replace existing workspace contents') + .option('--json', 'Emit structured JSON output'), + ).action((snapshotPath, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return workgraph.exportImport.importWorkspaceSnapshot(snapshotPath, workspacePath, { + overwrite: !!opts.overwrite, + }); + }, + (result) => [ + `Imported workspace snapshot: ${result.snapshotPath}`, + `Workspace: ${result.workspacePath}`, + `Files imported: ${result.filesImported}`, + ], + ), + ); + + program + .command('env') + .description('Show runtime environment and feature flags') + .option('--flag ', 'Resolve one feature flag by name') + .option('--json', 'Emit structured JSON output') + .action((opts) => + runCommand( + opts, + () => { + const info = workgraph.environment.getEnvironmentInfo(); + const selectedFlag = opts.flag + ? { + name: String(opts.flag), + enabled: workgraph.environment.isFeatureEnabled(String(opts.flag)), + } + : undefined; + return { + ...info, + selectedFlag, + }; + }, + (result) => [ + `Environment: ${result.environment} (${result.source})`, + `Feature flags: ${Object.keys(result.featureFlags).length}`, + ...Object.entries(result.featureFlags) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, enabled]) => `- ${name}=${enabled}`), + ...(result.selectedFlag + ? [`Selected flag: ${result.selectedFlag.name}=${result.selectedFlag.enabled}`] + : []), + ], + ), + ); +} diff --git a/packages/kernel/src/environment.test.ts b/packages/kernel/src/environment.test.ts new file mode 100644 index 0000000..40ea0d3 --- /dev/null +++ b/packages/kernel/src/environment.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + detectEnvironment, + getEnvironmentInfo, + isFeatureEnabled, + listFeatureFlags, +} from './environment.js'; + +describe('environment detection', () => { + it('prefers WORKGRAPH_ENV when set to cloud/local', () => { + expect(detectEnvironment(asEnv({ WORKGRAPH_ENV: 'cloud' }))).toBe('cloud'); + expect(detectEnvironment(asEnv({ WORKGRAPH_ENV: 'local', VERCEL: '1' }))).toBe('local'); + }); + + it('falls back to cloud when known cloud signals are present', () => { + expect(detectEnvironment(asEnv({ VERCEL: '1' }))).toBe('cloud'); + expect(detectEnvironment(asEnv({ K_SERVICE: 'workgraph-api' }))).toBe('cloud'); + }); + + it('defaults to local when no signals are present', () => { + expect(detectEnvironment(asEnv({}))).toBe('local'); + }); + + it('returns environment metadata including source and feature flags', () => { + const info = getEnvironmentInfo(asEnv({ + WORKGRAPH_ENV: 'cloud', + WORKGRAPH_FEATURE_PORTABILITY: 'true', + WORKGRAPH_FEATURE_FAST_IMPORT: '0', + })); + + expect(info.environment).toBe('cloud'); + expect(info.source).toBe('explicit'); + expect(info.featureFlags).toEqual({ + portability: true, + 'fast-import': false, + }); + }); +}); + +describe('feature flags', () => { + it('lists and normalizes WORKGRAPH_FEATURE_* flags', () => { + expect(listFeatureFlags(asEnv({ + WORKGRAPH_FEATURE_LOCAL_EXPORT: 'yes', + WORKGRAPH_FEATURE_CLOUD_IMPORT: 'off', + WORKGRAPH_FEATURE_EMPTY: '', + }))).toEqual({ + 'local-export': true, + 'cloud-import': false, + empty: false, + }); + }); + + it('resolves one feature flag with defaults', () => { + const env = asEnv({ + WORKGRAPH_FEATURE_LOCAL_EXPORT: 'true', + }); + expect(isFeatureEnabled('local-export', env)).toBe(true); + expect(isFeatureEnabled('cloud-import', env)).toBe(false); + expect(isFeatureEnabled('unknown-flag', env, true)).toBe(true); + }); +}); + +function asEnv(values: Record): NodeJS.ProcessEnv { + return values as NodeJS.ProcessEnv; +} diff --git a/packages/kernel/src/environment.ts b/packages/kernel/src/environment.ts new file mode 100644 index 0000000..8092b89 --- /dev/null +++ b/packages/kernel/src/environment.ts @@ -0,0 +1,102 @@ +export type WorkgraphEnvironmentKind = 'local' | 'cloud'; + +export interface WorkgraphEnvironmentInfo { + environment: WorkgraphEnvironmentKind; + source: 'explicit' | 'platform' | 'default'; + featureFlags: Record; +} + +const CLOUD_PLATFORM_SIGNAL_KEYS = [ + 'VERCEL', + 'K_SERVICE', + 'AWS_EXECUTION_ENV', + 'RAILWAY_ENVIRONMENT', + 'RENDER', + 'FLY_APP_NAME', +] as const; + +export function detectEnvironment(env: NodeJS.ProcessEnv = process.env): WorkgraphEnvironmentKind { + const explicit = normalizeEnvironment(env.WORKGRAPH_ENV); + if (explicit) return explicit; + if (hasCloudPlatformSignals(env)) return 'cloud'; + return 'local'; +} + +export function getEnvironmentInfo(env: NodeJS.ProcessEnv = process.env): WorkgraphEnvironmentInfo { + const explicit = normalizeEnvironment(env.WORKGRAPH_ENV); + if (explicit) { + return { + environment: explicit, + source: 'explicit', + featureFlags: listFeatureFlags(env), + }; + } + + if (hasCloudPlatformSignals(env)) { + return { + environment: 'cloud', + source: 'platform', + featureFlags: listFeatureFlags(env), + }; + } + + return { + environment: 'local', + source: 'default', + featureFlags: listFeatureFlags(env), + }; +} + +export function listFeatureFlags(env: NodeJS.ProcessEnv = process.env): Record { + const flags: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (!key.startsWith('WORKGRAPH_FEATURE_')) continue; + const featureName = normalizeFeatureFlagName(key.slice('WORKGRAPH_FEATURE_'.length)); + if (!featureName) continue; + flags[featureName] = parseBooleanFlag(value, false); + } + return flags; +} + +export function isFeatureEnabled( + featureName: string, + env: NodeJS.ProcessEnv = process.env, + defaultValue = false, +): boolean { + const normalizedFeatureName = normalizeFeatureFlagName(featureName); + if (!normalizedFeatureName) return defaultValue; + const envKey = `WORKGRAPH_FEATURE_${normalizedFeatureName.toUpperCase().replace(/-/g, '_')}`; + return parseBooleanFlag(env[envKey], defaultValue); +} + +function normalizeEnvironment(raw: string | undefined): WorkgraphEnvironmentKind | undefined { + if (!raw) return undefined; + const normalized = raw.trim().toLowerCase(); + if (normalized === 'local' || normalized === 'cloud') return normalized; + return undefined; +} + +function normalizeFeatureFlagName(raw: string): string { + return raw + .trim() + .toLowerCase() + .replaceAll('_', '-'); +} + +function hasCloudPlatformSignals(env: NodeJS.ProcessEnv): boolean { + return CLOUD_PLATFORM_SIGNAL_KEYS.some((key) => readNonEmptyString(env[key]) !== undefined); +} + +function parseBooleanFlag(raw: string | undefined, defaultValue: boolean): boolean { + const normalized = readNonEmptyString(raw)?.toLowerCase(); + if (!normalized) return defaultValue; + if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true; + if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false; + return defaultValue; +} + +function readNonEmptyString(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/kernel/src/export-import.test.ts b/packages/kernel/src/export-import.test.ts new file mode 100644 index 0000000..202fc4f --- /dev/null +++ b/packages/kernel/src/export-import.test.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initWorkspace } from './workspace.js'; +import { exportWorkspaceSnapshot, importWorkspaceSnapshot } from './export-import.js'; + +let tempRoot: string; + +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-export-import-')); +}); + +afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +const portabilityTest = hasTarCommand() ? it : it.skip; + +describe('workspace export/import', () => { + portabilityTest('exports a workspace to tar.gz and imports into a new path', () => { + const sourceWorkspacePath = path.join(tempRoot, 'source-workspace'); + initWorkspace(sourceWorkspacePath); + fs.mkdirSync(path.join(sourceWorkspacePath, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(sourceWorkspacePath, 'docs', 'note.md'), '# Snapshot smoke test\n', 'utf-8'); + + const snapshotPath = path.join(tempRoot, 'snapshots', 'workspace.tar.gz'); + const exportResult = exportWorkspaceSnapshot(sourceWorkspacePath, snapshotPath); + expect(fs.existsSync(snapshotPath)).toBe(true); + expect(exportResult.bytes).toBeGreaterThan(0); + + const importedWorkspacePath = path.join(tempRoot, 'imported-workspace'); + const importResult = importWorkspaceSnapshot(snapshotPath, importedWorkspacePath); + expect(importResult.filesImported).toBeGreaterThan(0); + expect(fs.existsSync(path.join(importedWorkspacePath, '.workgraph.json'))).toBe(true); + expect(fs.readFileSync(path.join(importedWorkspacePath, 'docs', 'note.md'), 'utf-8')).toContain('Snapshot'); + }); + + portabilityTest('rejects importing into non-empty workspace unless overwrite is enabled', () => { + const sourceWorkspacePath = path.join(tempRoot, 'source-workspace'); + initWorkspace(sourceWorkspacePath); + const snapshotPath = path.join(tempRoot, 'snapshots', 'workspace.tar.gz'); + exportWorkspaceSnapshot(sourceWorkspacePath, snapshotPath); + + const existingWorkspacePath = path.join(tempRoot, 'existing-workspace'); + initWorkspace(existingWorkspacePath); + + expect(() => importWorkspaceSnapshot(snapshotPath, existingWorkspacePath)).toThrow( + /already contains files/i, + ); + }); + + portabilityTest('supports overwriting an existing workspace on import', () => { + const sourceWorkspacePath = path.join(tempRoot, 'source-workspace'); + initWorkspace(sourceWorkspacePath); + fs.mkdirSync(path.join(sourceWorkspacePath, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(sourceWorkspacePath, 'docs', 'overwrite.md'), 'from source\n', 'utf-8'); + + const snapshotPath = path.join(tempRoot, 'snapshots', 'workspace.tar.gz'); + exportWorkspaceSnapshot(sourceWorkspacePath, snapshotPath); + + const existingWorkspacePath = path.join(tempRoot, 'existing-workspace'); + initWorkspace(existingWorkspacePath); + fs.mkdirSync(path.join(existingWorkspacePath, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(existingWorkspacePath, 'docs', 'old.md'), 'stale\n', 'utf-8'); + + importWorkspaceSnapshot(snapshotPath, existingWorkspacePath, { overwrite: true }); + expect(fs.existsSync(path.join(existingWorkspacePath, 'docs', 'overwrite.md'))).toBe(true); + }); +}); + +function hasTarCommand(): boolean { + const result = spawnSync('tar', ['--version'], { encoding: 'utf-8' }); + if (result.error) return false; + return result.status === 0; +} diff --git a/packages/kernel/src/export-import.ts b/packages/kernel/src/export-import.ts new file mode 100644 index 0000000..d5fff80 --- /dev/null +++ b/packages/kernel/src/export-import.ts @@ -0,0 +1,168 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { LocalStorageAdapter, type StorageAdapter } from './storage-adapter.js'; + +export interface ExportWorkspaceSnapshotOptions { + storageAdapter?: StorageAdapter; +} + +export interface ExportWorkspaceSnapshotResult { + workspacePath: string; + snapshotPath: string; + bytes: number; + createdAt: string; + adapterKind: 'local'; +} + +export interface ImportWorkspaceSnapshotOptions { + overwrite?: boolean; + storageAdapter?: StorageAdapter; +} + +export interface ImportWorkspaceSnapshotResult { + workspacePath: string; + snapshotPath: string; + filesImported: number; + importedAt: string; + adapterKind: 'local'; +} + +export function exportWorkspaceSnapshot( + workspacePath: string, + snapshotPath: string, + options: ExportWorkspaceSnapshotOptions = {}, +): ExportWorkspaceSnapshotResult { + const adapter = options.storageAdapter ?? new LocalStorageAdapter(); + assertLocalAdapter(adapter, 'export'); + + const absoluteWorkspacePath = adapter.resolve(workspacePath); + if (!adapter.exists(absoluteWorkspacePath)) { + throw new Error(`Workspace path does not exist: ${absoluteWorkspacePath}`); + } + if (!adapter.stat(absoluteWorkspacePath).isDirectory()) { + throw new Error(`Workspace path must be a directory: ${absoluteWorkspacePath}`); + } + + const absoluteSnapshotPath = adapter.resolve(snapshotPath); + adapter.mkdir(path.dirname(absoluteSnapshotPath), { recursive: true }); + + // Use tar so snapshots remain standard tar.gz archives across environments. + runTarCommand([ + '-czf', + absoluteSnapshotPath, + '-C', + absoluteWorkspacePath, + '.', + ]); + + const snapshotStats = adapter.stat(absoluteSnapshotPath); + return { + workspacePath: absoluteWorkspacePath, + snapshotPath: absoluteSnapshotPath, + bytes: snapshotStats.size, + createdAt: new Date().toISOString(), + adapterKind: 'local', + }; +} + +export function importWorkspaceSnapshot( + snapshotPath: string, + workspacePath: string, + options: ImportWorkspaceSnapshotOptions = {}, +): ImportWorkspaceSnapshotResult { + const adapter = options.storageAdapter ?? new LocalStorageAdapter(); + assertLocalAdapter(adapter, 'import'); + + const absoluteSnapshotPath = adapter.resolve(snapshotPath); + if (!adapter.exists(absoluteSnapshotPath)) { + throw new Error(`Snapshot file does not exist: ${absoluteSnapshotPath}`); + } + if (!adapter.stat(absoluteSnapshotPath).isFile()) { + throw new Error(`Snapshot path must be a file: ${absoluteSnapshotPath}`); + } + + const absoluteWorkspacePath = adapter.resolve(workspacePath); + const overwrite = options.overwrite === true; + if (adapter.exists(absoluteWorkspacePath) && !overwrite && !isDirectoryEmpty(adapter, absoluteWorkspacePath)) { + throw new Error( + `Workspace path already contains files. Use overwrite to replace existing content: ${absoluteWorkspacePath}`, + ); + } + + const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'workgraph-import-')); + try { + runTarCommand([ + '-xzf', + absoluteSnapshotPath, + '-C', + extractionRoot, + ]); + + if (overwrite && adapter.exists(absoluteWorkspacePath)) { + adapter.rm(absoluteWorkspacePath, { recursive: true, force: true }); + } + adapter.mkdir(absoluteWorkspacePath, { recursive: true }); + + const entries = fs.readdirSync(extractionRoot); + for (const entry of entries) { + const sourceEntryPath = path.join(extractionRoot, entry); + const destinationEntryPath = path.join(absoluteWorkspacePath, entry); + fs.cpSync(sourceEntryPath, destinationEntryPath, { + recursive: true, + force: overwrite, + errorOnExist: !overwrite, + }); + } + + return { + workspacePath: absoluteWorkspacePath, + snapshotPath: absoluteSnapshotPath, + filesImported: countFilesRecursively(extractionRoot), + importedAt: new Date().toISOString(), + adapterKind: 'local', + }; + } finally { + fs.rmSync(extractionRoot, { recursive: true, force: true }); + } +} + +function assertLocalAdapter(adapter: StorageAdapter, operation: 'export' | 'import'): void { + if (adapter.kind !== 'local') { + throw new Error(`Cloud storage adapter is not yet supported for workspace ${operation}.`); + } +} + +function isDirectoryEmpty(adapter: StorageAdapter, targetPath: string): boolean { + if (!adapter.exists(targetPath)) return true; + if (!adapter.stat(targetPath).isDirectory()) return false; + return adapter.readdir(targetPath).length === 0; +} + +function runTarCommand(args: string[]): void { + const result = spawnSync('tar', args, { + encoding: 'utf-8', + }); + if (!result.error && result.status === 0) return; + + if (result.error && (result.error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('Failed to execute tar command. Ensure tar is installed and available on PATH.'); + } + + const details = (result.stderr || result.stdout || '').trim(); + throw new Error(`tar command failed: ${details || `exit status ${String(result.status)}`}`); +} + +function countFilesRecursively(rootPath: string): number { + if (!fs.existsSync(rootPath)) return 0; + const stats = fs.statSync(rootPath); + if (stats.isFile()) return 1; + if (!stats.isDirectory()) return 0; + + let total = 0; + for (const entry of fs.readdirSync(rootPath)) { + total += countFilesRecursively(path.join(rootPath, entry)); + } + return total; +} diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 7a5d60b..b7e8fdb 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -66,3 +66,6 @@ export * as wikiIndex from './graph/wiki-index.js'; export * as graphHygiene from './graph/hygiene.js'; export * as schemaValidation from './validation/schema.js'; export * from './errors.js'; +export * as storageAdapter from './storage-adapter.js'; +export * as environment from './environment.js'; +export * as exportImport from './export-import.js'; diff --git a/packages/kernel/src/storage-adapter.test.ts b/packages/kernel/src/storage-adapter.test.ts new file mode 100644 index 0000000..0021ef9 --- /dev/null +++ b/packages/kernel/src/storage-adapter.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LocalStorageAdapter } from './storage-adapter.js'; + +let tempRoot: string; + +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-storage-adapter-')); +}); + +afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('LocalStorageAdapter', () => { + it('resolves relative paths against an optional rootPath', () => { + const adapter = new LocalStorageAdapter({ rootPath: tempRoot }); + const resolved = adapter.resolve('nested/file.txt'); + + expect(resolved).toBe(path.join(tempRoot, 'nested/file.txt')); + }); + + it('supports mkdir/write/read/stat/exists operations', () => { + const adapter = new LocalStorageAdapter({ rootPath: tempRoot }); + adapter.mkdir('docs', { recursive: true }); + adapter.writeFile('docs/readme.md', '# Storage Adapter\n'); + + expect(adapter.exists('docs/readme.md')).toBe(true); + expect(adapter.readFile('docs/readme.md')).toContain('Storage Adapter'); + expect(adapter.stat('docs').isDirectory()).toBe(true); + expect(adapter.stat('docs/readme.md').isFile()).toBe(true); + expect(adapter.readdir('docs')).toContain('readme.md'); + }); + + it('supports cp and rm operations', () => { + const adapter = new LocalStorageAdapter({ rootPath: tempRoot }); + adapter.mkdir('source', { recursive: true }); + adapter.writeFile('source/file.md', 'content\n'); + adapter.cp('source', 'target', { recursive: true }); + + expect(adapter.exists('target/file.md')).toBe(true); + adapter.rm('target', { recursive: true, force: true }); + expect(adapter.exists('target')).toBe(false); + }); +}); diff --git a/packages/kernel/src/storage-adapter.ts b/packages/kernel/src/storage-adapter.ts new file mode 100644 index 0000000..a02a659 --- /dev/null +++ b/packages/kernel/src/storage-adapter.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type StorageAdapterKind = 'local' | 'cloud'; + +export interface StorageAdapter { + readonly kind: StorageAdapterKind; + resolve(targetPath: string): string; + exists(targetPath: string): boolean; + readFile(targetPath: string, encoding?: BufferEncoding): string; + writeFile(targetPath: string, data: string | Uint8Array): void; + mkdir(targetPath: string, options?: { recursive?: boolean }): void; + readdir(targetPath: string): string[]; + rm(targetPath: string, options?: { recursive?: boolean; force?: boolean }): void; + cp( + sourcePath: string, + destinationPath: string, + options?: { recursive?: boolean; force?: boolean; errorOnExist?: boolean }, + ): void; + stat(targetPath: string): fs.Stats; +} + +export interface LocalStorageAdapterOptions { + rootPath?: string; +} + +export class LocalStorageAdapter implements StorageAdapter { + readonly kind = 'local' as const; + private readonly rootPath?: string; + + constructor(options: LocalStorageAdapterOptions = {}) { + this.rootPath = options.rootPath ? path.resolve(options.rootPath) : undefined; + } + + resolve(targetPath: string): string { + if (path.isAbsolute(targetPath)) return targetPath; + if (!this.rootPath) return path.resolve(targetPath); + return path.resolve(this.rootPath, targetPath); + } + + exists(targetPath: string): boolean { + return fs.existsSync(this.resolve(targetPath)); + } + + readFile(targetPath: string, encoding: BufferEncoding = 'utf-8'): string { + return fs.readFileSync(this.resolve(targetPath), encoding); + } + + writeFile(targetPath: string, data: string | Uint8Array): void { + fs.writeFileSync(this.resolve(targetPath), data); + } + + mkdir(targetPath: string, options: { recursive?: boolean } = {}): void { + fs.mkdirSync(this.resolve(targetPath), { recursive: options.recursive === true }); + } + + readdir(targetPath: string): string[] { + return fs.readdirSync(this.resolve(targetPath)); + } + + rm(targetPath: string, options: { recursive?: boolean; force?: boolean } = {}): void { + fs.rmSync(this.resolve(targetPath), { + recursive: options.recursive === true, + force: options.force === true, + }); + } + + cp( + sourcePath: string, + destinationPath: string, + options: { recursive?: boolean; force?: boolean; errorOnExist?: boolean } = {}, + ): void { + fs.cpSync(this.resolve(sourcePath), this.resolve(destinationPath), { + recursive: options.recursive === true, + force: options.force ?? true, + errorOnExist: options.errorOnExist === true, + }); + } + + stat(targetPath: string): fs.Stats { + return fs.statSync(this.resolve(targetPath)); + } +} + +// Stub contract for future cloud-backed implementations. +export interface CloudStorageAdapter extends StorageAdapter { + readonly kind: 'cloud'; + readonly provider: string; + readonly bucketOrNamespace: string; + toObjectUri(targetPath: string): string; +} diff --git a/tests/integration/portability-cli.test.ts b/tests/integration/portability-cli.test.ts new file mode 100644 index 0000000..d5442a2 --- /dev/null +++ b/tests/integration/portability-cli.test.ts @@ -0,0 +1,84 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { ensureCliBuiltForTests } from '../helpers/cli-build.js'; + +interface CliEnvelope { + ok: boolean; + data?: unknown; + error?: string; +} + +function runCli(args: string[]): CliEnvelope { + ensureCliBuiltForTests(); + const result = spawnSync('node', [path.resolve('bin/workgraph.js'), ...args], { + encoding: 'utf-8', + }); + const output = (result.stdout || result.stderr || '').trim(); + try { + return JSON.parse(output) as CliEnvelope; + } catch { + throw new Error(`CLI output was not valid JSON for args [${args.join(' ')}]: ${output}`); + } +} + +describe('portability CLI commands', () => { + beforeAll(() => { + ensureCliBuiltForTests(); + }); + + it('supports env/export/import commands end-to-end', () => { + const sourceWorkspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-portability-source-')); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-portability-cli-')); + const importedWorkspacePath = path.join(tempRoot, 'imported-workspace'); + const snapshotPath = path.join(tempRoot, 'workspace.tar.gz'); + + try { + const init = runCli(['init', sourceWorkspacePath, '--json']); + expect(init.ok).toBe(true); + + const env = runCli(['env', '--json']); + expect(env.ok).toBe(true); + expect((env.data as { environment: string }).environment.length).toBeGreaterThan(0); + + const exportResult = runCli([ + 'export', + snapshotPath, + '-w', + sourceWorkspacePath, + '--json', + ]); + expect(exportResult.ok).toBe(true); + expect(fs.existsSync(snapshotPath)).toBe(true); + + const importResult = runCli([ + 'import', + snapshotPath, + '-w', + importedWorkspacePath, + '--json', + ]); + expect(importResult.ok).toBe(true); + expect(fs.existsSync(path.join(importedWorkspacePath, '.workgraph.json'))).toBe(true); + + const listThreads = runCli(['thread', 'list', '-w', importedWorkspacePath, '--json']); + expect(listThreads.ok).toBe(true); + } finally { + fs.rmSync(sourceWorkspacePath, { recursive: true, force: true }); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('exposes portability commands in help output', () => { + const help = spawnSync('node', [path.resolve('bin/workgraph.js'), '--help'], { + encoding: 'utf-8', + }); + + expect(help.status).toBe(0); + expect(help.stdout).toContain('export'); + expect(help.stdout).toContain('import'); + expect(help.stdout).toContain('env'); + }); +});