259 lines · 7.9 KB · TypeScript
RawDownload
#!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import * as Console from "effect/Console";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";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";import { fromYaml } from "@t3tools/shared/schemaYaml"; import { referenceRepos, type ReferenceRepo } from "./lib/reference-repos.ts"; export type ReferenceRepoSyncAction = "add" | "pull"; export interface ReferenceRepoSyncOptions {  readonly rootDir?: string | undefined;  readonly repoId?: string | undefined;  readonly latest?: boolean | undefined;  readonly dryRun?: boolean | undefined;} export interface ReferenceRepoSyncPlan {  readonly repo: ReferenceRepo;  readonly action: ReferenceRepoSyncAction;  readonly ref: string;  readonly args: ReadonlyArray<string>;} export class ReferenceRepoSyncError extends Data.TaggedError("ReferenceRepoSyncError")<{  readonly message: string;  readonly cause?: unknown;}> {} const decodeJsonSource = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown)); const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>  stream.pipe(    Stream.decodeText(),    Stream.runFold(      () => "",      (acc, chunk) => acc + chunk,    ),  ); function readNestedString(input: unknown, keys: ReadonlyArray<string>): string | undefined {  let value = input;  for (const key of keys) {    if (typeof value !== "object" || value === null || !(key in value)) {      return undefined;    }    value = (value as Record<string, unknown>)[key];  }  return typeof value === "string" && value.length > 0 ? value : undefined;} function decodeVersionSource(  sourcePath: string,  content: string,): Effect.Effect<unknown, ReferenceRepoSyncError> {  if (sourcePath.endsWith(".yaml") || sourcePath.endsWith(".yml")) {    return decodeYamlSource(content).pipe(      Effect.mapError(        (cause) =>          new ReferenceRepoSyncError({            message: `Unable to parse version source ${sourcePath}.`,            cause,          }),      ),    );  }   return decodeJsonSource(content).pipe(    Effect.mapError(      (cause) =>        new ReferenceRepoSyncError({          message: `Unable to parse version source ${sourcePath}.`,          cause,        }),    ),  );} function getSelectedRepos(  repoId: string | undefined,): Effect.Effect<ReadonlyArray<ReferenceRepo>, ReferenceRepoSyncError> {  if (!repoId) {    return Effect.succeed(referenceRepos);  }   const repo = referenceRepos.find((candidate) => candidate.id === repoId);  return repo    ? Effect.succeed([repo])    : Effect.fail(        new ReferenceRepoSyncError({          message: `Unknown reference repo '${repoId}'. Expected one of: ${referenceRepos            .map((candidate) => candidate.id)            .join(", ")}.`,        }),      );} export const resolveReferenceRepoRef = Effect.fn("resolveReferenceRepoRef")(function* (  repo: ReferenceRepo,  rootDir: string,  latest: boolean,) {  if (latest) {    return repo.latestRef;  }   const fs = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const versionSourcePath = path.join(rootDir, repo.versionSourcePath);  const versionSourceContent = yield* fs.readFileString(versionSourcePath).pipe(    Effect.mapError(      (cause) =>        new ReferenceRepoSyncError({          message: `Unable to read package version for '${repo.id}' from ${versionSourcePath}.`,          cause,        }),    ),  );  const versionSource = yield* decodeVersionSource(repo.versionSourcePath, versionSourceContent);  const version = readNestedString(versionSource, repo.packageVersionPath);   if (!version) {    return yield* new ReferenceRepoSyncError({      message: `Unable to resolve package version for '${repo.id}' at ${repo.versionSourcePath}:${repo.packageVersionPath.join(        ".",      )}.`,    });  }   return `${repo.versionTagPrefix}${version}`;}); export const planReferenceRepoSync = Effect.fn("planReferenceRepoSync")(function* (  repo: ReferenceRepo,  rootDir: string,  latest: boolean,) {  const fs = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const action: ReferenceRepoSyncAction = (yield* fs.exists(path.join(rootDir, repo.prefix)))    ? "pull"    : "add";  const ref = yield* resolveReferenceRepoRef(repo, rootDir, latest);   return {    repo,    action,    ref,    args: ["subtree", action, `--prefix=${repo.prefix}`, repo.repository, ref, "--squash"],  } satisfies ReferenceRepoSyncPlan;}); const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRepoSyncPlan) {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const child = yield* spawner.spawn(ChildProcess.make("git", plan.args, { cwd: rootDir })).pipe(    Effect.mapError(      (cause) =>        new ReferenceRepoSyncError({          message: `Unable to start git subtree ${plan.action} for '${plan.repo.id}'.`,          cause,        }),    ),  );  const [stdout, stderr, exitCode] = yield* Effect.all(    [      collectStreamAsString(child.stdout),      collectStreamAsString(child.stderr),      child.exitCode.pipe(Effect.map(Number)),    ],    { concurrency: "unbounded" },  ).pipe(    Effect.mapError(      (cause) =>        new ReferenceRepoSyncError({          message: `Unable to run git subtree ${plan.action} for '${plan.repo.id}'.`,          cause,        }),    ),  );   if (exitCode !== 0) {    return yield* new ReferenceRepoSyncError({      message: `git subtree ${plan.action} failed for '${plan.repo.id}' with exit code ${exitCode}.\n${stderr.trim()}`,    });  }   if (stdout.trim().length > 0) {    yield* Console.log(stdout.trim());  }}); export const syncReferenceRepos = Effect.fn("syncReferenceRepos")(function* (  options: ReferenceRepoSyncOptions = {},) {  const path = yield* Path.Path;  const rootDir = path.resolve(options.rootDir ?? process.cwd());  const repos = yield* getSelectedRepos(options.repoId);  const plans: Array<ReferenceRepoSyncPlan> = [];   for (const repo of repos) {    const plan = yield* planReferenceRepoSync(repo, rootDir, options.latest ?? false);    plans.push(plan);    yield* Console.log(`Syncing ${repo.id} from ${plan.ref} with git subtree ${plan.action}.`);    if (!(options.dryRun ?? false)) {      yield* runGit(rootDir, plan).pipe(Effect.scoped);    }  }   return plans;}); export const syncReferenceReposCommand = Command.make(  "sync-reference-repos",  {    repo: Flag.string("repo").pipe(      Flag.withDescription("Sync only the named reference repo. Defaults to all configured repos."),      Flag.optional,    ),    latest: Flag.boolean("latest").pipe(      Flag.withDescription(        "Sync each repo from its latest branch instead of the installed version.",      ),      Flag.withDefault(false),    ),    root: Flag.string("root").pipe(      Flag.withDescription("Workspace root used to resolve versions and subtree prefixes."),      Flag.optional,    ),    dryRun: Flag.boolean("dry-run").pipe(      Flag.withDescription("Print planned subtree operations without running git."),      Flag.withDefault(false),    ),  },  ({ repo, latest, root, dryRun }) =>    syncReferenceRepos({      repoId: Option.getOrUndefined(repo),      rootDir: Option.getOrUndefined(root),      latest,      dryRun,    }),).pipe(Command.withDescription("Sync vendored reference repositories under .repos/.")); if (import.meta.main) {  Command.run(syncReferenceReposCommand, { version: "0.0.0" }).pipe(    Effect.provide(NodeServices.layer),    NodeRuntime.runMain,  );}