Julius Marmingefix?c9da3b78 months agoHistory
633 lines · 20.7 KB · TypeScript
RawDownload
#!/usr/bin/env node import rootPackageJson from "../package.json" with { type: "json" };import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect";import { Command, Flag } from "effect/unstable/cli";import { ChildProcess } from "effect/unstable/process"; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); const RepoRoot = Effect.service(Path.Path).pipe(  Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),);const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) =>  path.join(repoRoot, "assets/macos-icon-1024.png"),); interface PlatformConfig {  readonly cliFlag: "--mac" | "--linux" | "--win";  readonly defaultTarget: string;  readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;} const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {  mac: {    cliFlag: "--mac",    defaultTarget: "dmg",    archChoices: ["arm64", "x64", "universal"],  },  linux: {    cliFlag: "--linux",    defaultTarget: "AppImage",    archChoices: ["x64", "arm64"],  },  win: {    cliFlag: "--win",    defaultTarget: "nsis",    archChoices: ["x64", "arm64"],  },}; interface BuildCliInput {  readonly platform: Option.Option<typeof BuildPlatform.Type>;  readonly target: Option.Option<string>;  readonly arch: Option.Option<typeof BuildArch.Type>;  readonly buildVersion: Option.Option<string>;  readonly outputDir: Option.Option<string>;  readonly skipBuild: Option.Option<boolean>;  readonly keepStage: Option.Option<boolean>;  readonly signed: Option.Option<boolean>;  readonly verbose: Option.Option<boolean>;} function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {  if (hostPlatform === "darwin") return "mac";  if (hostPlatform === "linux") return "linux";  if (hostPlatform === "win32") return "win";  return undefined;} function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type {  const config = PLATFORM_CONFIG[platform];  if (!config) {    return "x64";  }   if (process.arch === "arm64" && config.archChoices.includes("arm64")) {    return "arm64";  }  if (process.arch === "x64" && config.archChoices.includes("x64")) {    return "x64";  }   return config.archChoices[0] ?? "x64";} class BuildScriptError extends Data.TaggedError("BuildScriptError")<{  readonly message: string;  readonly cause?: unknown;}> {} interface ResolvedBuildOptions {  readonly platform: typeof BuildPlatform.Type;  readonly target: string;  readonly arch: typeof BuildArch.Type;  readonly version: string | undefined;  readonly outputDir: string;  readonly skipBuild: boolean;  readonly keepStage: boolean;  readonly signed: boolean;  readonly verbose: boolean;} interface StagePackageJson {  readonly name: string;  readonly version: string;  readonly private: true;  readonly description: string;  readonly author: string;  readonly main: string;  readonly build: Record<string, unknown>;  readonly dependencies: Record<string, unknown>;  readonly devDependencies: {    readonly electron: string;  };} const AzureTrustedSigningOptionsConfig = Config.all({  publisherName: Config.string("AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"),  endpoint: Config.string("AZURE_TRUSTED_SIGNING_ENDPOINT"),  certificateProfileName: Config.string("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"),  codeSigningAccountName: Config.string("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"),  fileDigest: Config.string("AZURE_TRUSTED_SIGNING_FILE_DIGEST").pipe(    Config.withDefault(() => "SHA256"),  ),  timestampDigest: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST").pipe(    Config.withDefault(() => "SHA256"),  ),  timestampRfc3161: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161").pipe(    Config.withDefault(() => "http://timestamp.acs.microsoft.com"),  ),}); const BuildEnvConfig = Config.all({  platform: Config.schema(BuildPlatform, "T3CODE_DESKTOP_PLATFORM").pipe(Config.option),  target: Config.string("T3CODE_DESKTOP_TARGET").pipe(Config.option),  arch: Config.schema(BuildArch, "T3CODE_DESKTOP_ARCH").pipe(Config.option),  version: Config.string("T3CODE_DESKTOP_VERSION").pipe(Config.option),  outputDir: Config.string("T3CODE_DESKTOP_OUTPUT_DIR").pipe(Config.option),  skipBuild: Config.boolean("T3CODE_DESKTOP_SKIP_BUILD").pipe(Config.withDefault(() => false)),  keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(() => false)),  signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(() => false)),  verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(() => false)),}); const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>  Option.getOrElse(Option.filter(flag, Boolean), () => envValue);const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>  Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); const resolveBuildOptions = Effect.fn(function* (input: BuildCliInput) {  const path = yield* Path.Path;  const repoRoot = yield* RepoRoot;  const env = yield* BuildEnvConfig.asEffect();   const platform = mergeOptions(    input.platform,    env.platform,    detectHostBuildPlatform(process.platform),  );   if (!platform) {    return yield* new BuildScriptError({      message: `Unsupported host platform '${process.platform}'.`,    });  }   const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget);  const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform));  const version = mergeOptions(input.buildVersion, env.version, undefined);  const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release"));   const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild);  const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage);  const signed = resolveBooleanFlag(input.signed, env.signed);  const verbose = resolveBooleanFlag(input.verbose, env.verbose);   return {    platform,    target,    arch,    version,    outputDir,    skipBuild,    keepStage,    signed,    verbose,  } satisfies ResolvedBuildOptions;}); const commandOutputOptions = (verbose: boolean) =>  ({    stdout: verbose ? "inherit" : "ignore",    stderr: "inherit",  }) as const; const runCommand = Effect.fn(function* (command: ChildProcess.Command) {  const exitCode = yield* ChildProcess.exitCode(command);   if (exitCode !== 0) {    return yield* new BuildScriptError({      message: "Command exited with non-zero exit code",    });  }}); function generateMacIconSet(  sourcePng: string,  targetIcns: string,  tmpRoot: string,  path: Path.Path,  verbose: boolean,) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const iconsetDir = path.join(tmpRoot, "icon.iconset");    yield* fs.makeDirectory(iconsetDir, { recursive: true });     const iconSizes = [16, 32, 128, 256, 512] as const;    for (const size of iconSizes) {      yield* runCommand(        ChildProcess.make({          ...commandOutputOptions(verbose),        })`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`,      );       const retinaSize = size * 2;      yield* runCommand(        ChildProcess.make({          ...commandOutputOptions(verbose),        })`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`,      );    }     yield* runCommand(      ChildProcess.make({        ...commandOutputOptions(verbose),      })`iconutil -c icns ${iconsetDir} -o ${targetIcns}`,    );  });} function stageMacIcons(stageResourcesDir: string, verbose: boolean) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const iconSource = yield* IconSource;    if (!(yield* fs.exists(iconSource))) {      return yield* new BuildScriptError({        message: `Production icon source is missing at ${iconSource}`,      });    }     const tmpRoot = yield* fs.makeTempDirectoryScoped({      prefix: "t3code-icon-build-",    });     const iconPngPath = path.join(stageResourcesDir, "icon.png");    const iconIcnsPath = path.join(stageResourcesDir, "icon.icns");     yield* runCommand(      ChildProcess.make({        ...commandOutputOptions(verbose),      })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`,    );     yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose);  });} function validateBundledClientAssets(clientDir: string) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const indexPath = path.join(clientDir, "index.html");    const indexHtml = yield* fs.readFileString(indexPath);    const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map(      (match) => match[1],    );    const missing: string[] = [];     for (const ref of refs) {      const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? "";      if (!normalizedRef) continue;      if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue;      if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue;       const ext = path.extname(normalizedRef);      if (!ext) continue;       const relativePath = normalizedRef.replace(/^\/+/, "");      const assetPath = path.join(clientDir, relativePath);      if (!(yield* fs.exists(assetPath))) {        missing.push(normalizedRef);      }    }     if (missing.length > 0) {      const preview = missing.slice(0, 6).join(", ");      const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : "";      return yield* new BuildScriptError({        message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`,      });    }  });} function resolveCatalogDependencies(  dependencies: Record<string, unknown>,  catalog: Record<string, unknown>,  dependencySourceLabel: string,): Record<string, unknown> {  return Object.fromEntries(    Object.entries(dependencies).map(([dependencyName, spec]) => {      if (typeof spec !== "string" || !spec.startsWith("catalog:")) {        return [dependencyName, spec];      }       const catalogKey = spec.slice("catalog:".length).trim();      const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName;      const resolvedSpec = catalog[lookupKey];      if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) {        throw new BuildScriptError({          message: `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. Expected key '${lookupKey}' in root workspace catalog.`,        });      }       return [dependencyName, resolvedSpec];    }),  );} const createBuildConfig = Effect.fn(function* (  platform: typeof BuildPlatform.Type,  target: string,  productName: string,  signed: boolean,) {  const buildConfig: Record<string, unknown> = {    appId: "com.t3tools.t3code",    productName,    artifactName: "T3-Code-${version}-${arch}.${ext}",    directories: {      buildResources: "apps/desktop/resources",    },  };   if (platform === "mac") {    buildConfig.mac = {      target: [target],      icon: "icon.icns",      category: "public.app-category.developer-tools",    };  }   if (platform === "linux") {    buildConfig.linux = {      target: [target],      icon: "icon.png",      category: "Development",    };  }   if (platform === "win") {    const winConfig: Record<string, unknown> = {      target: [target],      icon: "icon.ico",    };    if (signed) {      winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;    }    buildConfig.win = winConfig;  }   return buildConfig;}); const assertPlatformBuildResources = Effect.fn(function* (  platform: typeof BuildPlatform.Type,  stageResourcesDir: string,  verbose: boolean,) {  const fs = yield* FileSystem.FileSystem;  const path = yield* Path.Path;   if (platform === "mac") {    yield* stageMacIcons(stageResourcesDir, verbose);    return;  }   if (platform === "linux") {    const iconPath = path.join(stageResourcesDir, "icon.png");    if (!(yield* fs.exists(iconPath))) {      return yield* new BuildScriptError({        message: `Missing Linux icon at ${iconPath}`,      });    }    return;  }   if (platform === "win") {    const iconPath = path.join(stageResourcesDir, "icon.ico");    if (!(yield* fs.exists(iconPath))) {      return yield* new BuildScriptError({        message: `Missing Windows icon at ${iconPath}`,      });    }  }}); const buildDesktopArtifact = Effect.fn(function* (options: ResolvedBuildOptions) {  const repoRoot = yield* RepoRoot;  const path = yield* Path.Path;  const fs = yield* FileSystem.FileSystem;   const platformConfig = PLATFORM_CONFIG[options.platform];  if (!platformConfig) {    return yield* new BuildScriptError({      message: `Unsupported platform '${options.platform}'.`,    });  }   const electronVersion = desktopPackageJson.dependencies.electron;   const dependencies = serverPackageJson.dependencies;  if (!dependencies || Object.keys(dependencies).length === 0) {    return yield* new BuildScriptError({      message: "Could not resolve production dependencies from apps/server/package.json.",    });  }   const resolvedDependencies = yield* Effect.try({    try: () =>      resolveCatalogDependencies(dependencies, rootPackageJson.workspaces.catalog, "apps/server"),    catch: (cause) =>      new BuildScriptError({        message: "Could not resolve production dependencies from apps/server/package.json.",        cause,      }),  });   const appVersion = options.version ?? serverPackageJson.version;  const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;  const stageRoot = yield* mkdir({    prefix: `t3code-desktop-${options.platform}-stage-`,  });   const stageAppDir = path.join(stageRoot, "app");  const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources");  const distDirs = {    desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"),    desktopResources: path.join(repoRoot, "apps/desktop/resources"),    serverDist: path.join(repoRoot, "apps/server/dist"),  };  const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html");   if (!options.skipBuild) {    yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts...");    yield* runCommand(      ChildProcess.make({        cwd: repoRoot,        ...commandOutputOptions(options.verbose),      })`bun run build:desktop`,    );  }   for (const [label, dir] of Object.entries(distDirs)) {    if (!(yield* fs.exists(dir))) {      return yield* new BuildScriptError({        message: `Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`,      });    }  }   if (!(yield* fs.exists(bundledClientEntry))) {    return yield* new BuildScriptError({      message: `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`,    });  }   yield* validateBundledClientAssets(path.dirname(bundledClientEntry));   yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true });  yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true });   yield* Effect.log("[desktop-artifact] Staging release app...");  yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));  yield* fs.copy(distDirs.desktopResources, stageResourcesDir);  yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));   yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose);   const stagePackageJson: StagePackageJson = {    name: "t3-code-desktop",    version: appVersion,    private: true,    description: "T3 Code desktop build",    author: "T3 Tools",    main: "apps/desktop/dist-electron/main.js",    build: yield* createBuildConfig(      options.platform,      options.target,      desktopPackageJson.productName ?? "T3 Code",      options.signed,    ),

Lines 1–500 of 633.