1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Console from "effect/Console";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import { Command, Flag } from "effect/cli";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14import { fromYaml } from "@t3tools/shared/schemaYaml";
15
16import { referenceRepos, type ReferenceRepo } from "./lib/reference-repos.ts";
17
18export type ReferenceRepoSyncAction = "add" | "pull";
19
20export interface ReferenceRepoSyncOptions {
21 readonly rootDir?: string | undefined;
22 readonly repoId?: string | undefined;
23 readonly latest?: boolean | undefined;
24 readonly dryRun?: boolean | undefined;
25}
26
27export interface ReferenceRepoSyncPlan {
28 readonly repo: ReferenceRepo;
29 readonly action: ReferenceRepoSyncAction;
30 readonly ref: string;
31 readonly args: ReadonlyArray<string>;
32}
33
34export class ReferenceRepoSelectionError extends Schema.TaggedError<ReferenceRepoSelectionError>()(
35 "ReferenceRepoSelectionError",
36 {
37 repoId: Schema.String,
38 expectedRepoIds: Schema.Array(Schema.String),
39 },
40) {
41 override get message(): string {
42 return `Unknown reference repo "${this.repoId}". Expected one of: ${this.expectedRepoIds.join(", ")}.`;
43 }
44}
45
46export class ReferenceRepoVersionSourceError extends Schema.TaggedError<ReferenceRepoVersionSourceError>()(
47 "ReferenceRepoVersionSourceError",
48 {
49 operation: Schema.Literals(["read", "parse"]),
50 repoId: Schema.String,
51 sourcePath: Schema.String,
52 cause: Schema.Defect(),
53 },
54) {
55 override get message(): string {
56 return `Reference repo "${this.repoId}" version source operation "${this.operation}" failed for ${this.sourcePath}.`;
57 }
58}
59
60export class ReferenceRepoVersionResolutionError extends Schema.TaggedError<ReferenceRepoVersionResolutionError>()(
61 "ReferenceRepoVersionResolutionError",
62 {
63 repoId: Schema.String,
64 sourcePath: Schema.String,
65 packageVersionPath: Schema.Array(Schema.String),
66 },
67) {
68 override get message(): string {
69 return `No version was found for reference repo "${this.repoId}" at ${this.sourcePath}:${this.packageVersionPath.join(".")}.`;
70 }
71}
72
73export class ReferenceRepoGitSubtreeError extends Schema.TaggedError<ReferenceRepoGitSubtreeError>()(
74 "ReferenceRepoGitSubtreeError",
75 {
76 operation: Schema.Literals(["spawn", "communicate", "exit"]),
77 repoId: Schema.String,
78 action: Schema.Literals(["add", "pull"]),
79 repository: Schema.String,
80 ref: Schema.String,
81 rootDir: Schema.String,
82 argumentCount: Schema.Number,
83 exitCode: Schema.optional(Schema.Number),
84 stdoutLength: Schema.optional(Schema.Number),
85 stderrLength: Schema.optional(Schema.Number),
86 cause: Schema.optional(Schema.Defect()),
87 },
88) {
89 override get message(): string {
90 return `Git subtree ${this.action} for reference repo "${this.repoId}" failed during "${this.operation}".`;
91 }
92}
93
94export type ReferenceRepoSyncError =
95 | ReferenceRepoSelectionError
96 | ReferenceRepoVersionSourceError
97 | ReferenceRepoVersionResolutionError
98 | ReferenceRepoGitSubtreeError;
99
100const decodeJsonSource = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
101const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));
102
103const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
104 stream.pipe(
105 Stream.decodeText(),
106 Stream.runFold(
107 () => "",
108 (acc, chunk) => acc + chunk,
109 ),
110 );
111
112function readNestedString(input: unknown, keys: ReadonlyArray<string>): string | undefined {
113 let value = input;
114 for (const key of keys) {
115 if (typeof value !== "object" || value === null || !(key in value)) {
116 return undefined;
117 }
118 value = (value as Record<string, unknown>)[key];
119 }
120 return typeof value === "string" && value.length > 0 ? value : undefined;
121}
122
123function decodeVersionSource(
124 repo: ReferenceRepo,
125 sourcePath: string,
126 content: string,
127): Effect.Effect<unknown, ReferenceRepoSyncError> {
128 const decode =
129 repo.versionSourcePath.endsWith(".yaml") || repo.versionSourcePath.endsWith(".yml")
130 ? decodeYamlSource
131 : decodeJsonSource;
132 return decode(content).pipe(
133 Effect.mapError(
134 (cause) =>
135 new ReferenceRepoVersionSourceError({
136 operation: "parse",
137 repoId: repo.id,
138 sourcePath,
139 cause,
140 }),
141 ),
142 );
143}
144
145function getSelectedRepos(
146 repoId: string | undefined,
147): Effect.Effect<ReadonlyArray<ReferenceRepo>, ReferenceRepoSyncError> {
148 if (!repoId) {
149 return Effect.succeed(referenceRepos);
150 }
151
152 const repo = referenceRepos.find((candidate) => candidate.id === repoId);
153 return repo
154 ? Effect.succeed([repo])
155 : Effect.fail(
156 new ReferenceRepoSelectionError({
157 repoId,
158 expectedRepoIds: referenceRepos.map((candidate) => candidate.id),
159 }),
160 );
161}
162
163export const resolveReferenceRepoRef = Effect.fn("resolveReferenceRepoRef")(function* (
164 repo: ReferenceRepo,
165 rootDir: string,
166 latest: boolean,
167) {
168 if (latest) {
169 return repo.latestRef;
170 }
171
172 const fs = yield* FileSystem.FileSystem;
173 const path = yield* Path.Path;
174 const versionSourcePath = path.join(rootDir, repo.versionSourcePath);
175 const versionSourceContent = yield* fs.readFileString(versionSourcePath).pipe(
176 Effect.mapError(
177 (cause) =>
178 new ReferenceRepoVersionSourceError({
179 operation: "read",
180 repoId: repo.id,
181 sourcePath: versionSourcePath,
182 cause,
183 }),
184 ),
185 );
186 const versionSource = yield* decodeVersionSource(repo, versionSourcePath, versionSourceContent);
187 const version = readNestedString(versionSource, repo.packageVersionPath);
188
189 if (!version) {
190 return yield* new ReferenceRepoVersionResolutionError({
191 repoId: repo.id,
192 sourcePath: versionSourcePath,
193 packageVersionPath: repo.packageVersionPath,
194 });
195 }
196
197 return `${repo.versionTagPrefix}${version}`;
198});
199
200export const planReferenceRepoSync = Effect.fn("planReferenceRepoSync")(function* (
201 repo: ReferenceRepo,
202 rootDir: string,
203 latest: boolean,
204) {
205 const fs = yield* FileSystem.FileSystem;
206 const path = yield* Path.Path;
207 const action: ReferenceRepoSyncAction = (yield* fs.exists(path.join(rootDir, repo.prefix)))
208 ? "pull"
209 : "add";
210 const ref = yield* resolveReferenceRepoRef(repo, rootDir, latest);
211
212 return {
213 repo,
214 action,
215 ref,
216 args: ["subtree", action, `--prefix=${repo.prefix}`, repo.repository, ref, "--squash"],
217 } satisfies ReferenceRepoSyncPlan;
218});
219
220const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRepoSyncPlan) {
221 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
222 const errorContext = {
223 repoId: plan.repo.id,
224 action: plan.action,
225 repository: plan.repo.repository,
226 ref: plan.ref,
227 rootDir,
228 argumentCount: plan.args.length,
229 } as const;
230 const child = yield* spawner.spawn(ChildProcess.make("git", plan.args, { cwd: rootDir })).pipe(
231 Effect.mapError(
232 (cause) =>
233 new ReferenceRepoGitSubtreeError({
234 ...errorContext,
235 operation: "spawn",
236 cause,
237 }),
238 ),
239 );
240 const [stdout, stderr, exitCode] = yield* Effect.all(
241 [
242 collectStreamAsString(child.stdout),
243 collectStreamAsString(child.stderr),
244 child.exitCode.pipe(Effect.map(Number)),
245 ],
246 { concurrency: "unbounded" },
247 ).pipe(
248 Effect.mapError(
249 (cause) =>
250 new ReferenceRepoGitSubtreeError({
251 ...errorContext,
252 operation: "communicate",
253 cause,
254 }),
255 ),
256 );
257
258 if (exitCode !== 0) {
259 return yield* new ReferenceRepoGitSubtreeError({
260 ...errorContext,
261 operation: "exit",
262 exitCode,
263 stdoutLength: stdout.length,
264 stderrLength: stderr.length,
265 });
266 }
267
268 if (stdout.trim().length > 0) {
269 yield* Console.log(stdout.trim());
270 }
271});
272
273export const syncReferenceRepos = Effect.fn("syncReferenceRepos")(function* (
274 options: ReferenceRepoSyncOptions = {},
275) {
276 const path = yield* Path.Path;
277 const rootDir = path.resolve(options.rootDir ?? process.cwd());
278 const repos = yield* getSelectedRepos(options.repoId);
279 const plans: Array<ReferenceRepoSyncPlan> = [];
280
281 for (const repo of repos) {
282 const plan = yield* planReferenceRepoSync(repo, rootDir, options.latest ?? false);
283 plans.push(plan);
284 yield* Console.log(`Syncing ${repo.id} from ${plan.ref} with git subtree ${plan.action}.`);
285 if (!(options.dryRun ?? false)) {
286 yield* runGit(rootDir, plan).pipe(Effect.scoped);
287 }
288 }
289
290 return plans;
291});
292
293export const syncReferenceReposCommand = Command.make(
294 "sync-reference-repos",
295 {
296 repo: Flag.String("repo").pipe(
297 Flag.withDescription("Sync only the named reference repo. Defaults to all configured repos."),
298 Flag.optional,
299 ),
300 latest: Flag.Boolean("latest").pipe(
301 Flag.withDescription(
302 "Sync each repo from its latest branch instead of the installed version.",
303 ),
304 Flag.withDefault(false),
305 ),
306 root: Flag.String("root").pipe(
307 Flag.withDescription("Workspace root used to resolve versions and subtree prefixes."),
308 Flag.optional,
309 ),
310 dryRun: Flag.Boolean("dry-run").pipe(
311 Flag.withDescription("Print planned subtree operations without running git."),
312 Flag.withDefault(false),
313 ),
314 },
315 ({ repo, latest, root, dryRun }) =>
316 syncReferenceRepos({
317 repoId: Option.getOrUndefined(repo),
318 rootDir: Option.getOrUndefined(root),
319 latest,
320 dryRun,
321 }),
322).pipe(Command.withDescription("Sync vendored reference repositories under .repos/."));
323
324if (import.meta.main) {
325 Command.run(syncReferenceReposCommand, { version: "0.0.0" }).pipe(
326 Effect.provide(NodeServices.layer),
327 NodeRuntime.runMain,
328 );
329}