829 lines · 26.9 KB · TypeScript
RawDownload
#!/usr/bin/env node import { spawnSync } from "node:child_process";import { existsSync } from "node:fs";import { join } from "node:path"; 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 { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import { Config, Data, Effect, FileSystem, Layer, Logger, Option, Path, Schema } from "effect";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 ProductionMacIconSource = Effect.zipWith(  RepoRoot,  Effect.service(Path.Path),  (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),);const ProductionLinuxIconSource = Effect.zipWith(  RepoRoot,  Effect.service(Path.Path),  (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionLinuxIconPng),);const ProductionWindowsIconSource = Effect.zipWith(  RepoRoot,  Effect.service(Path.Path),  (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionWindowsIconIco),);const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); 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<string>;} 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;}> {} function resolveGitCommitHash(repoRoot: string): string {  const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], {    cwd: repoRoot,    encoding: "utf8",  });  if (result.status !== 0) {    return "unknown";  }  const hash = result.stdout.trim();  if (!/^[0-9a-f]{7,40}$/i.test(hash)) {    return "unknown";  }  return hash.toLowerCase();} function resolvePythonForNodeGyp(): string | undefined {  const configured = process.env.npm_config_python ?? process.env.PYTHON;  if (configured && existsSync(configured)) {    return configured;  }   if (process.platform === "win32") {    const localAppData = process.env.LOCALAPPDATA;    if (localAppData) {      for (const version of ["Python313", "Python312", "Python311", "Python310"]) {        const candidate = join(localAppData, "Programs", "Python", version, "python.exe");        if (existsSync(candidate)) {          return candidate;        }      }    }  }   const probe = spawnSync("python", ["-c", "import sys;print(sys.executable)"], {    encoding: "utf8",  });  if (probe.status !== 0) {    return undefined;  }   const executable = probe.stdout.trim();  if (!executable || !existsSync(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: string | 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;  };} 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 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("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 = mergeOptions(    input.mockUpdateServerPort,    env.mockUpdateServerPort,    undefined,  );   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, verbose: boolean) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const iconSource = yield* ProductionMacIconSource;    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 stageLinuxIcons(stageResourcesDir: string) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const iconSource = yield* ProductionLinuxIconSource;    if (!(yield* fs.exists(iconSource))) {      return yield* new BuildScriptError({        message: `Production icon source is missing at ${iconSource}`,      });    }     const iconPath = path.join(stageResourcesDir, "icon.png");    yield* fs.copyFile(iconSource, iconPath);  });} function stageWindowsIcons(stageResourcesDir: string) {  return Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const iconSource = yield* ProductionWindowsIconSource;    if (!(yield* fs.exists(iconSource))) {      return yield* new BuildScriptError({        message: `Production Windows icon source is missing at ${iconSource}`,      });    }     const iconPath = path.join(stageResourcesDir, "icon.ico");    yield* fs.copyFile(iconSource, 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, unknown> | undefined,  catalog: Record<string, unknown>,): Record<string, unknown> {  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");} function resolveGitHubPublishConfig():  | {      readonly provider: "github";      readonly owner: string;      readonly repo: string;      readonly releaseType: "release";    }  | undefined {  const rawRepo =    process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() ||    process.env.GITHUB_REPOSITORY?.trim() ||    "";  if (!rawRepo) return undefined;   const [owner, repo, ...rest] = rawRepo.split("/");  if (!owner || !repo || rest.length > 0) return undefined;   return {    provider: "github",    owner,    repo,    releaseType: "release",  };} const createBuildConfig = Effect.fn("createBuildConfig")(function* (  platform: typeof BuildPlatform.Type,  target: string,  productName: string,  signed: boolean,  mockUpdates: boolean,  mockUpdateServerPort: string | undefined,) {  const buildConfig: Record<string, unknown> = {    appId: "com.t3tools.t3code",    productName,    artifactName: "T3-Code-${version}-${arch}.${ext}",    directories: {      buildResources: "apps/desktop/resources",    },  };  const publishConfig = resolveGitHubPublishConfig();  if (publishConfig) {    buildConfig.publish = [publishConfig];  } else if (mockUpdates) {    buildConfig.publish = [      {        provider: "generic",        url: `http://localhost:${mockUpdateServerPort ?? 3000}`,      },    ];  }   if (platform === "mac") {    buildConfig.mac = {      target: target === "dmg" ? [target, "zip"] : [target],      icon: "icon.icns",      category: "public.app-category.developer-tools",    };  }

Lines 1–500 of 829.