955 lines · 30.8 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 { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";import { getDefaultBuildArch } from "./lib/build-target-arch.ts";import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import * as Config from "effect/Config";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Logger from "effect/Logger";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { Command, Flag } from "effect/unstable/cli";import { ChildProcess, ChildProcessSpawner } 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 encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); interface DesktopBuildIconAssets {  readonly macIconPng: string;  readonly linuxIconPng: string;  readonly windowsIconIco: string;} 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>;  readonly mockUpdates: Option.Option<boolean>;  readonly mockUpdateServerPort: Option.Option<number>;} 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";  }   return getDefaultBuildArch(platform, process.arch, process.env, config);} class BuildScriptError extends Data.TaggedError("BuildScriptError")<{  readonly message: string;  readonly cause?: unknown;}> {} const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>  stream.pipe(    Stream.decodeText(),    Stream.runFold(      () => "",      (acc, chunk) => acc + chunk,    ),  ); const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* (  command: ChildProcess.Command,) {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const child = yield* spawner.spawn(command);   const [stdout, stderr, exitCode] = yield* Effect.all(    [      collectStreamAsString(child.stdout),      collectStreamAsString(child.stderr),      child.exitCode.pipe(Effect.map(Number)),    ],    { concurrency: "unbounded" },  );   return { stdout, stderr, exitCode } as const;}); const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) {  const result = yield* spawnAndCollectOutput(    ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], {      cwd: repoRoot,    }),  ).pipe(    Effect.catch(() =>      Effect.succeed({        stdout: "",        stderr: "",        exitCode: 1,      }),    ),  );   if (result.exitCode !== 0) {    return "unknown";  }  const hash = result.stdout.trim();  if (!/^[0-9a-f]{7,40}$/i.test(hash)) {    return "unknown";  }  return hash.toLowerCase();}); const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () {  const fs = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const configured = process.env.npm_config_python ?? process.env.PYTHON;  if (configured && (yield* fs.exists(configured))) {    return configured;  }   if (process.platform === "win32") {    const localAppData = process.env.LOCALAPPDATA;    if (localAppData) {      for (const version of ["Python313", "Python312", "Python311", "Python310"]) {        const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe");        if (yield* fs.exists(candidate)) {          return candidate;        }      }    }  }   const probe = yield* spawnAndCollectOutput(    ChildProcess.make("python", ["-c", "import sys;print(sys.executable)"]),  ).pipe(    Effect.catch(() =>      Effect.succeed({        stdout: "",        stderr: "",        exitCode: 1,      }),    ),  );   if (probe.exitCode !== 0) {    return undefined;  }   const executable = probe.stdout.trim();  if (!executable || !(yield* fs.exists(executable))) {    return undefined;  }   return executable;}); 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;  readonly mockUpdates: boolean;  readonly mockUpdateServerPort: number | undefined;} interface StagePackageJson {  readonly name: string;  readonly version: string;  readonly buildVersion: string;  readonly t3codeCommitHash: 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;  };  readonly overrides: Record<string, unknown>;} 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)),  mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)),  mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),}); const MockUpdateServerPortSchema = Schema.NumberFromString.check(  Schema.isInt(),  Schema.isBetween({ minimum: 1, maximum: 65535 }),);const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema); const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>  Option.getOrElse(flag, () => envValue);const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>  Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); export const resolveMockUpdateServerPort = Effect.fn("resolveMockUpdateServerPort")(function* (  mockUpdateServerPort: string | undefined,) {  const port = mockUpdateServerPort?.trim();  if (!port) {    return undefined;  }   return yield* decodeMockUpdateServerPort(port);}); export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(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 releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates)    ? "release-mock"    : "release";  const outputDir = path.resolve(    repoRoot,    mergeOptions(input.outputDir, env.outputDir, releaseDir),  );   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);   const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);  const mockUpdateServerPort =    Option.getOrUndefined(input.mockUpdateServerPort) ??    (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe(      Effect.mapError(        (cause) =>          new BuildScriptError({            message: "Invalid mock update server port.",            cause,          }),      ),    ));   return {    platform,    target,    arch,    version,    outputDir,    skipBuild,    keepStage,    signed,    verbose,    mockUpdates,    mockUpdateServerPort,  } satisfies ResolvedBuildOptions;}); const commandOutputOptions = (verbose: boolean) =>  ({    stdout: verbose ? "inherit" : "ignore",    stderr: "inherit",  }) as const; const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Command) {  const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const child = yield* commandSpawner.spawn(command);  const exitCode = yield* child.exitCode;   if (exitCode !== 0) {    return yield* new BuildScriptError({      message: `Command exited with non-zero exit code (${exitCode})`,    });  }}); 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, sourcePng: string, verbose: boolean) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    if (!(yield* fs.exists(sourcePng))) {      return yield* new BuildScriptError({        message: `Desktop macOS icon source is missing at ${sourcePng}`,      });    }     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 ${sourcePng} --out ${iconPngPath}`,    );     yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose);  });} function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    if (!(yield* fs.exists(sourcePng))) {      return yield* new BuildScriptError({        message: `Desktop Linux icon source is missing at ${sourcePng}`,      });    }     const iconPath = path.join(stageResourcesDir, "icon.png");    yield* fs.copyFile(sourcePng, iconPath);  });} function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    if (!(yield* fs.exists(sourceIco))) {      return yield* new BuildScriptError({        message: `Desktop Windows icon source is missing at ${sourceIco}`,      });    }     const iconPath = path.join(stageResourcesDir, "icon.ico");    yield* fs.copyFile(sourceIco, iconPath);  });} 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])      .filter((value): value is string => value !== undefined);    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 resolveDesktopRuntimeDependencies(  dependencies: Record<string, string> | undefined,  catalog: Record<string, string>,): Record<string, string> {  if (!dependencies || Object.keys(dependencies).length === 0) {    return {};  }   const runtimeDependencies = Object.fromEntries(    Object.entries(dependencies).filter(([dependencyName]) => dependencyName !== "electron"),  );   return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");}

Lines 1–500 of 955.