#!/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; } 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 = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), ); function readNestedString(input: unknown, keys: ReadonlyArray): 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)[key]; } return typeof value === "string" && value.length > 0 ? value : undefined; } function decodeVersionSource( sourcePath: string, content: string, ): Effect.Effect { 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, 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 = []; 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, ); }