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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2220,6 +2221,7 @@ registerConversationCommands(program, DEFAULT_ACTOR);
// ============================================================================

registerSafetyCommands(program, DEFAULT_ACTOR);
registerPortabilityCommands(program);

// ============================================================================
// onboarding
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/cli/commands/portability.ts
Original file line number Diff line number Diff line change
@@ -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 <snapshotPath>')
.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 <snapshotPath>')
.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 <name>', '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}`]
: []),
],
),
);
}
65 changes: 65 additions & 0 deletions packages/kernel/src/environment.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): NodeJS.ProcessEnv {
return values as NodeJS.ProcessEnv;
}
102 changes: 102 additions & 0 deletions packages/kernel/src/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
export type WorkgraphEnvironmentKind = 'local' | 'cloud';

export interface WorkgraphEnvironmentInfo {
environment: WorkgraphEnvironmentKind;
source: 'explicit' | 'platform' | 'default';
featureFlags: Record<string, boolean>;
}

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<string, boolean> {
const flags: Record<string, boolean> = {};
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;
}
77 changes: 77 additions & 0 deletions packages/kernel/src/export-import.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading