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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/cli/src/commands/publish-manifest.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
createLocalRegistryBackend,
publishManifest,
rollbackManifest,
} from '@federation-wizards/core';
import { stringify as stringifyYaml } from 'yaml';

function createWorkspace(): string {
const dir = mkdtempSync(join(tmpdir(), 'fwiz-cli-publish-'));

const config = {
version: '1',
workspace: { type: 'plain' },
hosts: [{ name: 'shell', port: 4200 }],
remotes: [{ name: 'checkout', port: 4201 }],
shared: {
react: {
singleton: true,
requiredVersion: '^19.0.0',
eager: false,
},
},
registry: {
type: 'http',
baseUrl: 'https://cdn.example.com',
prefix: 'mf',
},
};

writeWorkspaceConfig(dir, config);
return dir;
}

function writeWorkspaceConfig(dir: string, config: unknown): void {
writeFileSync(
join(dir, 'fwiz.config.yaml'),
`${stringifyYaml(config)}\n`,
'utf8',
);
}

describe('fwiz publish-manifest command', () => {
it('publishes and rolls back a remote through core APIs', async () => {
const dir = createWorkspace();
const backend = createLocalRegistryBackend(join(dir, '.registry'));

const publishResult = await publishManifest(
{
cwd: dir,
version: '1.0.0',
remote: 'checkout',
},
backend,
);

expect(publishResult.remotes[0]?.name).toBe('checkout');

const rollbackResult = await rollbackManifest(
{
cwd: dir,
remote: 'checkout',
version: '1.0.0',
dryRun: true,
},
backend,
);

expect(rollbackResult.dryRun).toBe(true);
expect(rollbackResult.previousVersion).toBe('1.0.0');

const manifest = readFileSync(
join(dir, '.registry/mf/checkout/1.0.0/mf-manifest.json'),
'utf8',
);

expect(manifest).toContain('"name": "checkout"');
});
});
110 changes: 110 additions & 0 deletions apps/cli/src/commands/publish-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { Command } from 'commander';

import {
PublishManifestError,
publishManifest,
rollbackManifest,
} from '@federation-wizards/core';

export function registerPublishManifestCommand(program: Command): void {
program
.command('publish-manifest')
.description(
'Generate versioned mf-manifest.json files and publish them to a central registry',
)
.summary('Upload remote manifests and update remotes-registry.json')
.addHelpText(
'after',
`
Examples:
$ fwiz publish-manifest
$ fwiz publish-manifest --version 1.2.0
$ fwiz publish-manifest --remote checkout
$ fwiz publish-manifest --dry-run
$ fwiz publish-manifest --rollback --remote checkout
$ fwiz publish-manifest --rollback --remote checkout --version 1.1.0

Requires a registry section in fwiz.config.yaml with either an HTTP PUT
endpoint or S3-compatible storage settings.
`,
)
.option(
'--cwd <path>',
'Workspace directory (defaults to current working directory)',
process.cwd(),
)
.option('--dry-run', 'Generate manifests without uploading or updating registry')
.option('--version <version>', 'Manifest version to publish or roll back to')
.option('--remote <name>', 'Publish or roll back a single remote')
.option('--rollback', 'Roll back the selected remote to a previous version')
.action(
async (options: {
cwd: string;
dryRun?: boolean;
version?: string;
remote?: string;
rollback?: boolean;
}) => {
try {
if (options.rollback) {
if (!options.remote) {
console.error('--remote is required when using --rollback.');
process.exitCode = 1;
return;
}

const result = await rollbackManifest({
cwd: options.cwd,
remote: options.remote,
version: options.version,
dryRun: options.dryRun,
});

if (result.dryRun) {
console.log(
`[dry-run] Would roll back ${result.remote} from ${result.currentVersion} to ${result.previousVersion}.`,
);
return;
}

console.log(
`Rolled back ${result.remote} from ${result.currentVersion} to ${result.previousVersion}.`,
);
console.log(`Registry: ${result.registryUrl}`);
return;
}

const result = await publishManifest({
cwd: options.cwd,
version: options.version,
remote: options.remote,
dryRun: options.dryRun,
});

if (result.dryRun) {
console.log(
`[dry-run] Would publish version ${result.version} for ${result.remotes.length} remote(s).`,
);
} else {
console.log(
`Published version ${result.version} for ${result.remotes.length} remote(s).`,
);
}

for (const remote of result.remotes) {
console.log(` - ${remote.name}: ${remote.manifestUrl}`);
}

console.log(`Registry: ${result.registryUrl}`);
} catch (error) {
const message =
error instanceof PublishManifestError ||
error instanceof Error
? error.message
: String(error);
console.error(message);
process.exitCode = 1;
}
},
);
}
2 changes: 2 additions & 0 deletions apps/cli/src/program.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from 'commander';

import { registerInitCommand } from './commands/init.js';
import { registerPublishManifestCommand } from './commands/publish-manifest.js';

export function createProgram(): Command {
const program = new Command();
Expand All @@ -11,6 +12,7 @@ export function createProgram(): Command {
.version('0.0.1');

registerInitCommand(program);
registerPublishManifestCommand(program);

return program;
}
1 change: 1 addition & 0 deletions libs/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
}
},
"dependencies": {
"@aws-sdk/client-s3": "^3.848.0",
"joi": "^18.2.1",
"tslib": "^2.3.0",
"yaml": "^2.9.0"
Expand Down
2 changes: 2 additions & 0 deletions libs/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export * from './lib/config/init.js';
export * from './lib/config/load.js';
export * from './lib/workspace/detect.js';
export * from './lib/workspace/patch.js';
export * from './lib/manifest/generate.js';
export * from './lib/registry/index.js';
41 changes: 41 additions & 0 deletions libs/core/src/lib/config/schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';

import { createDefaultConfig } from './defaults.js';
import { validateFwizConfig } from './schema.js';

describe('validateFwizConfig registry section', () => {
it('accepts an HTTP registry configuration', () => {
const config = {
...createDefaultConfig({ type: 'plain', appProjects: [] }),
registry: {
type: 'http',
baseUrl: 'https://cdn.example.com/mf',
prefix: 'assets',
},
};

const result = validateFwizConfig(config);

expect(result.valid).toBe(true);
if (result.valid) {
expect(result.value.registry?.type).toBe('http');
}
});

it('requires bucket for S3 registry configuration', () => {
const config = {
...createDefaultConfig({ type: 'plain', appProjects: [] }),
registry: {
type: 's3',
baseUrl: 'https://cdn.example.com',
},
};

const result = validateFwizConfig(config);

expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.warnings.join(' ')).toMatch(/bucket/i);
}
});
});
30 changes: 30 additions & 0 deletions libs/core/src/lib/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@ const remoteSchema = Joi.object({
port: Joi.number().integer().min(1).max(65535).required(),
});

const registrySchema = Joi.object({
type: Joi.string().valid('s3', 'http').required(),
baseUrl: Joi.string().uri().required(),
prefix: Joi.string().optional(),
remotesRegistryKey: Joi.string().optional(),
uploadBaseUrl: Joi.string().uri().optional(),
headers: Joi.object().pattern(Joi.string(), Joi.string()).optional(),
bucket: Joi.string().when('type', {
is: 's3',
then: Joi.required(),
otherwise: Joi.forbidden(),
}),
region: Joi.string().when('type', {
is: 's3',
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
endpoint: Joi.string().uri().when('type', {
is: 's3',
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
forcePathStyle: Joi.boolean().when('type', {
is: 's3',
then: Joi.optional(),
otherwise: Joi.forbidden(),
}),
});

export const fwizConfigSchema = Joi.object({
version: Joi.string().required(),
workspace: Joi.object({
Expand All @@ -28,6 +57,7 @@ export const fwizConfigSchema = Joi.object({
hosts: Joi.array().items(hostSchema).min(1).required(),
remotes: Joi.array().items(remoteSchema).required(),
shared: Joi.object().pattern(Joi.string(), sharedDependencySchema).required(),
registry: registrySchema.optional(),
});

export function validateFwizConfig(
Expand Down
16 changes: 16 additions & 0 deletions libs/core/src/lib/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ export interface RemoteConfig {
port: number;
}

export type RegistryBackendType = 's3' | 'http';

export interface RegistryConfig {
type: RegistryBackendType;
baseUrl: string;
prefix?: string;
remotesRegistryKey?: string;
uploadBaseUrl?: string;
headers?: Record<string, string>;
bucket?: string;
region?: string;
endpoint?: string;
forcePathStyle?: boolean;
}

export interface FwizConfig {
version: string;
workspace: {
Expand All @@ -26,6 +41,7 @@ export interface FwizConfig {
hosts: HostConfig[];
remotes: RemoteConfig[];
shared: Record<string, SharedDependencyConfig>;
registry?: RegistryConfig;
}

export interface WorkspaceInfo {
Expand Down
35 changes: 35 additions & 0 deletions libs/core/src/lib/manifest/generate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';

import { generateMfManifest } from './generate.js';

describe('generateMfManifest', () => {
it('generates a versioned mf-manifest.json shape for a remote', () => {
const manifest = generateMfManifest({
remote: { name: 'checkout', project: 'checkout', port: 4201 },
version: '1.2.0',
shared: {
react: {
singleton: true,
requiredVersion: '^19.0.0',
eager: false,
},
},
registry: {
type: 'http',
baseUrl: 'https://cdn.example.com/assets',
prefix: 'mf',
},
buildId: 'test-build-id',
});

expect(manifest.id).toBe('test-build-id');
expect(manifest.name).toBe('checkout');
expect(manifest.metaData.buildInfo.buildVersion).toBe('1.2.0');
expect(manifest.metaData.publicPath).toBe(
'https://cdn.example.com/assets/mf/checkout/1.2.0/',
);
expect(manifest.shared).toHaveLength(1);
expect(manifest.exposes[0]?.name).toBe('./Module');
expect(manifest.remotes).toEqual([]);
});
});
Loading
Loading