389 lines
11 KiB
TypeScript
389 lines
11 KiB
TypeScript
import { Command, FileSystem, Path } from '@effect/platform';
|
|
import { BunContext, BunRuntime } from '@effect/platform-bun';
|
|
import { Effect, Logger, LogLevel, Schema as S } from 'effect';
|
|
import { jsonSchemaToEffectSchema } from './lib/script-utils';
|
|
|
|
// Schema for DSC resource list output
|
|
const DscResourceSchema = S.Struct({
|
|
type: S.String,
|
|
kind: S.String,
|
|
version: S.String,
|
|
capabilities: S.Array(S.String),
|
|
path: S.String,
|
|
description: S.NullOr(S.String),
|
|
directory: S.String,
|
|
manifest: S.Struct({
|
|
$schema: S.String,
|
|
type: S.String,
|
|
version: S.String,
|
|
description: S.optional(S.NullOr(S.String)),
|
|
schema: S.optional(
|
|
S.NullOr(
|
|
S.Union(
|
|
S.Struct({
|
|
command: S.Struct({
|
|
executable: S.String,
|
|
args: S.NullOr(S.Array(S.String)),
|
|
}),
|
|
}),
|
|
S.Struct({
|
|
embedded: S.Unknown,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
}),
|
|
});
|
|
|
|
type DscResource = S.Schema.Type<typeof DscResourceSchema>;
|
|
|
|
/**
|
|
* Sanitize a resource type name to be a valid TypeScript identifier and filename
|
|
* e.g., "Microsoft.WinGet/Package" -> "MicrosoftWinGetPackage"
|
|
*/
|
|
const sanitizeTypeName = (resourceType: string): string => {
|
|
return resourceType
|
|
.replace(/[/\\]/g, '') // Remove slashes
|
|
.replace(/\./g, '') // Remove dots
|
|
.replace(/[^a-zA-Z0-9]/g, ''); // Remove any other invalid chars
|
|
};
|
|
|
|
/**
|
|
* Convert resource type to a safe filename
|
|
* e.g., "Microsoft.WinGet/Package" -> "microsoft-winget-package"
|
|
*/
|
|
const toSafeFilename = (resourceType: string): string => {
|
|
return resourceType
|
|
.replace(/[/\\]/g, '-') // Replace slashes with hyphens
|
|
.replace(/\./g, '-') // Replace dots with hyphens
|
|
.toLowerCase();
|
|
};
|
|
|
|
/**
|
|
* Get schema for a DSC resource - either from embedded schema or via `dsc resource schema`
|
|
*/
|
|
const getDscResourceSchema = (resource: DscResource) =>
|
|
Effect.gen(function* () {
|
|
const schema = resource.manifest.schema;
|
|
|
|
if (!schema) {
|
|
yield* Effect.logDebug(`No schema defined for ${resource.type}`);
|
|
return null;
|
|
}
|
|
|
|
// Check if it's an embedded schema
|
|
if ('embedded' in schema) {
|
|
yield* Effect.logDebug(`Using embedded schema for ${resource.type}`);
|
|
return JSON.stringify(schema.embedded);
|
|
}
|
|
|
|
// Otherwise, use dsc resource schema command (more reliable than running executable directly)
|
|
if ('command' in schema) {
|
|
yield* Effect.logDebug(`Getting schema via DSC CLI for ${resource.type}`);
|
|
|
|
const schemaString = yield* Command.make(
|
|
'dsc',
|
|
'resource',
|
|
'schema',
|
|
'--resource',
|
|
resource.type,
|
|
).pipe(Command.string);
|
|
|
|
return schemaString.trim();
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
/**
|
|
* Parse NDJSON output (newline-delimited JSON) from dsc resource list
|
|
*/
|
|
const parseNdjson = (output: string): Array<unknown> =>
|
|
output
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => {
|
|
try {
|
|
return JSON.parse(line);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((obj): obj is unknown => obj !== null);
|
|
|
|
/**
|
|
* Get available DSC resources from the system
|
|
*/
|
|
const getAvailableDscResources = () =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.logDebug('Getting available DSC resources...');
|
|
|
|
const dscrResourcesListOutput = yield* Command.make(
|
|
'dsc',
|
|
'resource',
|
|
'list',
|
|
'-o',
|
|
'json',
|
|
).pipe(Command.string);
|
|
|
|
// Parse NDJSON output
|
|
const rawResources = parseNdjson(dscrResourcesListOutput);
|
|
yield* Effect.log(`Found ${rawResources.length} resources in DSC output`);
|
|
|
|
// Decode each resource with the schema
|
|
const resources: Array<DscResource> = [];
|
|
for (const raw of rawResources) {
|
|
const decoded = S.decodeUnknownOption(DscResourceSchema)(raw);
|
|
if (decoded._tag === 'Some') {
|
|
resources.push(decoded.value);
|
|
} else {
|
|
// Log which resource failed to parse for debugging
|
|
const maybeType =
|
|
typeof raw === 'object' && raw !== null && 'type' in raw
|
|
? (raw as { type: unknown }).type
|
|
: 'unknown';
|
|
yield* Effect.logDebug(`Failed to decode resource: ${maybeType}`);
|
|
}
|
|
}
|
|
|
|
yield* Effect.log(`Successfully decoded ${resources.length} resources`);
|
|
return resources;
|
|
});
|
|
|
|
/**
|
|
* Generate index file that re-exports all types
|
|
*/
|
|
const generateIndexFile = (
|
|
resourceTypes: Array<{ type: string; filename: string; typeName: string }>,
|
|
) => {
|
|
const lines = [
|
|
'// This file is auto-generated. Do not edit manually.',
|
|
'// Re-exports all DSC resource schema types',
|
|
'',
|
|
];
|
|
|
|
for (const { filename, typeName } of resourceTypes) {
|
|
lines.push(`export * as ${typeName} from './${filename}.gen';`);
|
|
}
|
|
|
|
lines.push('');
|
|
return lines.join('\n');
|
|
};
|
|
|
|
/**
|
|
* Generate a types list file for all DSC resource configurations
|
|
*/
|
|
const generateDscConfigTypes = (
|
|
resourceTypes: Array<{ type: string; filename: string; typeName: string }>,
|
|
) => {
|
|
const lines = [
|
|
'// This file is auto-generated. Do not edit manually.',
|
|
"import * as S from 'effect/Schema';",
|
|
'',
|
|
];
|
|
|
|
// Create a resource types literal
|
|
lines.push('/**');
|
|
lines.push(' * All available DSC resource types on this system');
|
|
lines.push(' */');
|
|
lines.push('export const DscResourceTypes = S.Literal(');
|
|
for (const { type } of resourceTypes) {
|
|
lines.push(` "${type}",`);
|
|
}
|
|
lines.push(');');
|
|
lines.push('');
|
|
lines.push(
|
|
'export type DscResourceType = S.Schema.Type<typeof DscResourceTypes>;',
|
|
);
|
|
lines.push('');
|
|
|
|
// Create a map of type to schema import
|
|
lines.push('/**');
|
|
lines.push(' * Map of resource type to generated file');
|
|
lines.push(' */');
|
|
lines.push('export const ResourceTypeToFile = {');
|
|
for (const { type, filename } of resourceTypes) {
|
|
lines.push(` "${type}": "${filename}.gen",`);
|
|
}
|
|
lines.push('} as const;');
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
};
|
|
|
|
/**
|
|
* Generate a resource union file that provides strong typing for the configuration DSL
|
|
*/
|
|
const generateResourceUnion = (
|
|
resourceTypes: Array<{ type: string; filename: string; typeName: string }>,
|
|
) => {
|
|
const lines = [
|
|
'// This file is auto-generated. Do not edit manually.',
|
|
"import * as S from 'effect/Schema';",
|
|
"import * as Resources from './index';",
|
|
'',
|
|
'/**',
|
|
' * A discriminated union of all available DSC resources with their specific properties',
|
|
' */',
|
|
'export const ResourceUnion = S.Union(',
|
|
];
|
|
|
|
for (const { type, typeName } of resourceTypes) {
|
|
lines.push(' S.Struct({');
|
|
lines.push(` type: S.Literal("${type}"),`);
|
|
lines.push(' name: S.String,');
|
|
lines.push(' dependsOn: S.optional(S.Array(S.String)),');
|
|
lines.push(` properties: Resources.${typeName}.${typeName},`);
|
|
lines.push(
|
|
' metadata: S.optional(S.Record({ key: S.String, value: S.Unknown })),',
|
|
);
|
|
lines.push(' }),');
|
|
}
|
|
|
|
lines.push(');');
|
|
lines.push('');
|
|
lines.push(
|
|
'export type ResourceUnion = S.Schema.Type<typeof ResourceUnion>;',
|
|
);
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const genDscResourcesTypes = Effect.gen(function* () {
|
|
yield* Effect.log('Starting DSC resources types generation...');
|
|
|
|
const resources = yield* getAvailableDscResources();
|
|
|
|
// Filter to only resources that have schemas and are not adapters
|
|
// (adapters like PowerShell adapter don't have their own config schema)
|
|
const resourcesWithSchemas = resources.filter(
|
|
(r) => r.manifest.schema && r.kind !== 'adapter',
|
|
);
|
|
|
|
yield* Effect.log(
|
|
`Found ${resourcesWithSchemas.length} resources with schemas (excluding adapters)`,
|
|
);
|
|
|
|
const fs = yield* FileSystem.FileSystem;
|
|
const path = yield* Path.Path;
|
|
|
|
const outputDir = path.join(
|
|
process.cwd(),
|
|
'src',
|
|
'dsc-resource-schema-types',
|
|
);
|
|
|
|
yield* Effect.logDebug(`Output directory: ${outputDir}`);
|
|
|
|
if (yield* fs.exists(outputDir)) {
|
|
yield* Effect.log('Removing existing output directory...');
|
|
yield* fs.remove(outputDir, { recursive: true, force: true });
|
|
}
|
|
|
|
yield* fs.makeDirectory(outputDir, { recursive: true });
|
|
|
|
const generatedTypes: Array<{
|
|
type: string;
|
|
filename: string;
|
|
typeName: string;
|
|
}> = [];
|
|
const errors: Array<{ type: string; error: string }> = [];
|
|
|
|
for (const resource of resourcesWithSchemas) {
|
|
yield* Effect.logDebug(`Processing: ${resource.type}`);
|
|
|
|
const schemaResult = yield* Effect.either(getDscResourceSchema(resource));
|
|
|
|
if (schemaResult._tag === 'Left') {
|
|
errors.push({
|
|
type: resource.type,
|
|
error: String(schemaResult.left),
|
|
});
|
|
yield* Effect.logWarning(
|
|
`Failed to get schema for ${resource.type}: ${schemaResult.left}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const jsonSchema = schemaResult.right;
|
|
if (!jsonSchema) {
|
|
yield* Effect.logDebug(`No schema available for ${resource.type}`);
|
|
continue;
|
|
}
|
|
|
|
const typeName = sanitizeTypeName(resource.type);
|
|
const filename = toSafeFilename(resource.type);
|
|
|
|
const effectSchemaResult = yield* Effect.either(
|
|
jsonSchemaToEffectSchema({
|
|
jsonSchema,
|
|
name: typeName,
|
|
}),
|
|
);
|
|
|
|
if (effectSchemaResult._tag === 'Left') {
|
|
errors.push({
|
|
type: resource.type,
|
|
error: String(effectSchemaResult.left),
|
|
});
|
|
yield* Effect.logWarning(
|
|
`Failed to convert schema for ${resource.type}: ${effectSchemaResult.left}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const effectSchema = effectSchemaResult.right;
|
|
const outputPath = path.join(outputDir, `${filename}.gen.ts`);
|
|
yield* fs.writeFileString(outputPath, effectSchema);
|
|
|
|
generatedTypes.push({
|
|
type: resource.type,
|
|
filename,
|
|
typeName,
|
|
});
|
|
|
|
yield* Effect.log(`Generated: ${filename}.gen.ts (${resource.type})`);
|
|
}
|
|
|
|
// Generate index file
|
|
if (generatedTypes.length > 0) {
|
|
const indexContent = generateIndexFile(generatedTypes);
|
|
const indexPath = path.join(outputDir, 'index.ts');
|
|
yield* fs.writeFileString(indexPath, indexContent);
|
|
yield* Effect.log('Generated: index.ts');
|
|
|
|
// Generate types list file
|
|
const typesListContent = generateDscConfigTypes(generatedTypes);
|
|
const typesListPath = path.join(outputDir, '_resource-types.gen.ts');
|
|
yield* fs.writeFileString(typesListPath, typesListContent);
|
|
yield* Effect.log('Generated: _resource-types.gen.ts');
|
|
|
|
// Generate resource union file
|
|
const unionContent = generateResourceUnion(generatedTypes);
|
|
const unionPath = path.join(outputDir, '_resource-union.gen.ts');
|
|
yield* fs.writeFileString(unionPath, unionContent);
|
|
yield* Effect.log('Generated: _resource-union.gen.ts');
|
|
}
|
|
|
|
yield* Effect.log('');
|
|
yield* Effect.log('=== Generation Summary ===');
|
|
yield* Effect.log(`Successfully generated: ${generatedTypes.length} types`);
|
|
if (errors.length > 0) {
|
|
yield* Effect.log(`Failed: ${errors.length} types`);
|
|
for (const { type, error } of errors) {
|
|
yield* Effect.logDebug(` - ${type}: ${error}`);
|
|
}
|
|
}
|
|
yield* Effect.log('DSC types generation complete!');
|
|
});
|
|
|
|
BunRuntime.runMain(
|
|
genDscResourcesTypes.pipe(
|
|
Effect.provide(BunContext.layer),
|
|
Logger.withMinimumLogLevel(LogLevel.Debug),
|
|
Effect.scoped,
|
|
),
|
|
);
|