diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e70178aef..19cf97ed2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,6 +17,7 @@ import { packages } from "./shared.js" import { patchArgvForWrapCommands } from "./argv-patch.js" import { syncEffectSubtree } from "./sync-effect-subtree.js" +import { syncDiff, syncPush, syncShared } from "./sync-shared.js" patchArgvForWrapCommands(process.argv) @@ -751,6 +752,64 @@ NodeRuntime.runMain( ) .pipe(Command.withDescription("Sync the Effect subtree to the version pinned in package.json")) + const SharedLockfileFlag = Flag.file("lockfile").pipe( + Flag.optional, + Flag.withDescription("Path to lockfile (default: .shared.json)") + ) + + const sync = Command + .make( + "sync", + { lockfile: SharedLockfileFlag }, + Effect.fn("effa-cli.sync")(function*({ lockfile }) { + yield* syncShared({ + lockfilePath: Option.getOrElse(lockfile, () => ".shared.json") + }) + }) + ) + .pipe(Command.withDescription("Sync shared docs / e2e helpers / plugins from effect-app/shared per .shared.json")) + + const syncDiffCmd = Command + .make( + "sync-diff", + { lockfile: SharedLockfileFlag }, + Effect.fn("effa-cli.sync-diff")(function*({ lockfile }) { + yield* syncDiff({ + lockfilePath: Option.getOrElse(lockfile, () => ".shared.json") + }) + }) + ) + .pipe(Command.withDescription("Report drift between local synced files and the pinned shared ref")) + + const syncPushCmd = Command + .make( + "sync-push", + { + lockfile: SharedLockfileFlag, + message: Flag.string("message").pipe( + Flag.withAlias("m"), + Flag.optional, + Flag.withDescription("Commit message for the push") + ), + branch: Flag.string("branch").pipe( + Flag.optional, + Flag.withDescription("Branch name in shared repo (default: auto-generated)") + ), + pr: Flag.boolean("pr").pipe( + Flag.withDescription("Open a PR via `gh pr create` after pushing") + ) + }, + Effect.fn("effa-cli.sync-push")(function*({ branch, lockfile, message, pr }) { + yield* syncPush({ + lockfilePath: Option.getOrElse(lockfile, () => ".shared.json"), + message: Option.getOrUndefined(message), + branch: Option.getOrUndefined(branch), + pr + }) + }) + ) + .pipe(Command.withDescription("Push locally-modified synced files to the shared repo on a new branch")) + // configure CLI return yield* Command.run( Command @@ -765,7 +824,10 @@ NodeRuntime.runMain( packagejsonPackages, gist, nuke, - syncEffect + syncEffect, + sync, + syncDiffCmd, + syncPushCmd ])), { version: "v1.0.0" diff --git a/packages/cli/src/sync-shared.ts b/packages/cli/src/sync-shared.ts new file mode 100644 index 000000000..0b4c96070 --- /dev/null +++ b/packages/cli/src/sync-shared.ts @@ -0,0 +1,292 @@ +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" +import * as Path from "effect/Path" +import { RunCommandService } from "./os-command.js" + +/** + * Project-side lockfile shape (`.shared.json`). + * + * `artifacts` maps SOURCE path (inside the shared repo) to DEST path (inside the + * consuming project). `exclude` lists source-relative paths to skip during sync. + */ +export interface SharedLockfile { + readonly repo: string + readonly ref: string + readonly artifacts: Record + readonly exclude?: ReadonlyArray + readonly synced_at?: string +} + +const sanitizeRepoSlug = (repo: string) => repo.replace(/[^A-Za-z0-9_.-]+/g, "_") + +const repoCloneUrl = (repo: string) => { + if (repo.startsWith("http") || repo.startsWith("git@")) return repo + // "github.com/effect-app/shared" → "git@github.com:effect-app/shared.git" + const m = repo.match(/^github\.com\/(.+?)\/(.+?)$/) + if (m) return `git@github.com:${m[1]}/${m[2]}.git` + return repo +} + +const joinPosix = (a: string, b: string) => a.endsWith("/") ? a + b : a + "/" + b + +const readLockfile = Effect.fnUntraced(function*(lockfilePath: string) { + const fs = yield* FileSystem.FileSystem + if (!(yield* fs.exists(lockfilePath))) { + return yield* Effect.fail(new Error(`No ${lockfilePath} found in current directory.`)) + } + const content = yield* fs.readFileString(lockfilePath) + return { content, lockfile: JSON.parse(content) as SharedLockfile } +}) + +const ensureCache = Effect.fnUntraced(function*(lockfile: SharedLockfile) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const { runGetExitCode } = yield* RunCommandService + + const home = process.env["HOME"] ?? process.env["USERPROFILE"] + if (!home) return yield* Effect.fail(new Error("Cannot resolve home directory.")) + + const cacheRoot = path.join(home, ".cache", "effa", "shared") + const cachePath = path.join(cacheRoot, sanitizeRepoSlug(lockfile.repo)) + const cloneUrl = repoCloneUrl(lockfile.repo) + + if (!(yield* fs.exists(cachePath))) { + yield* runGetExitCode(`mkdir -p ${JSON.stringify(cacheRoot)}`) + yield* runGetExitCode(`git clone ${JSON.stringify(cloneUrl)} ${JSON.stringify(cachePath)}`) + } else { + yield* runGetExitCode("git fetch --all --tags --prune", cachePath) + } + + yield* runGetExitCode(`git checkout ${JSON.stringify(lockfile.ref)}`, cachePath) + + return cachePath +}) + +/** + * Walk artifact files. For each file in the artifact map (respecting excludes), + * yields `{ srcRel, srcAbs, destAbs }`. + */ +const walkArtifacts = Effect.fnUntraced(function*( + lockfile: SharedLockfile, + cachePath: string +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const { runGetString } = yield* RunCommandService + + const excludeSet = new Set(lockfile.exclude ?? []) + const results: Array<{ srcRel: string; srcAbs: string; destAbs: string; excluded: boolean }> = [] + + for (const [srcRel, destRel] of Object.entries(lockfile.artifacts)) { + const srcAbs = path.join(cachePath, srcRel) + const destAbs = path.resolve(destRel) + + if (!(yield* fs.exists(srcAbs))) continue + + const stat = yield* fs.stat(srcAbs) + + if (stat.type === "File") { + results.push({ srcRel, srcAbs, destAbs, excluded: excludeSet.has(srcRel) }) + continue + } + + const fileList = yield* runGetString(`find ${JSON.stringify(srcAbs)} -type f`) + for (const fileAbs of fileList.split("\n").filter((l) => l.trim() !== "")) { + const relInArtifact = path.relative(srcAbs, fileAbs) + const srcRelFull = joinPosix(srcRel, relInArtifact) + const destFileAbs = path.join(destAbs, relInArtifact) + results.push({ + srcRel: srcRelFull, + srcAbs: fileAbs, + destAbs: destFileAbs, + excluded: excludeSet.has(srcRelFull) + }) + } + } + + return results +}) + +/** + * Pull artifacts from the shared repo into the consuming project according to + * `.shared.json`. Idempotent — running twice without changes is a no-op. + * + * MVP: overwrites destination files. Caller is expected to inspect `git status` + * after sync to review local changes. Conflict handling lands in a follow-up. + */ +export const syncShared = Effect.fnUntraced(function*(opts: { lockfilePath?: string } = {}) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const { runGetExitCode } = yield* RunCommandService + + const lockfilePath = opts.lockfilePath ?? ".shared.json" + const { content: lockfileContent, lockfile } = yield* readLockfile(lockfilePath) + + yield* Effect.logInfo(`Syncing from ${lockfile.repo} @ ${lockfile.ref}`) + + const cachePath = yield* ensureCache(lockfile) + yield* Effect.logInfo(`Cache: ${cachePath}`) + + const files = yield* walkArtifacts(lockfile, cachePath) + + let copied = 0 + let skipped = 0 + + for (const { srcRel, srcAbs, destAbs, excluded } of files) { + if (excluded) { + yield* Effect.logInfo(` exclude ${srcRel}`) + skipped++ + continue + } + yield* runGetExitCode(`mkdir -p ${JSON.stringify(path.dirname(destAbs))}`) + yield* runGetExitCode(`cp ${JSON.stringify(srcAbs)} ${JSON.stringify(destAbs)}`) + copied++ + } + + const today = new Date().toISOString().slice(0, 10) + const trailingNewline = lockfileContent.endsWith("\n") ? "\n" : "" + const updated = { ...lockfile, synced_at: today } + yield* fs.writeFileString(lockfilePath, JSON.stringify(updated, null, 2) + trailingNewline) + + yield* Effect.logInfo(`Sync complete: ${copied} copied, ${skipped} excluded.`) + yield* Effect.logInfo("Review changes with: git status") +}) + +/** + * Compare project files to cache files. Reports each file as one of: + * M modified locally (project differs from cache) + * A added locally (project has file, cache does not — for tracked artifacts) + * D deleted locally (cache has file, project does not) + * E excluded (skipped by lockfile) + * + * Files that match cache exactly are not listed. + */ +export const syncDiff = Effect.fnUntraced(function*(opts: { lockfilePath?: string } = {}) { + const fs = yield* FileSystem.FileSystem + const { runGetString } = yield* RunCommandService + + const lockfilePath = opts.lockfilePath ?? ".shared.json" + const { lockfile } = yield* readLockfile(lockfilePath) + + yield* Effect.logInfo(`Diffing against ${lockfile.repo} @ ${lockfile.ref}`) + + const cachePath = yield* ensureCache(lockfile) + const files = yield* walkArtifacts(lockfile, cachePath) + + const changes: Array<{ kind: "M" | "D" | "E"; srcRel: string; destPath: string }> = [] + + for (const { srcRel, srcAbs, destAbs, excluded } of files) { + if (excluded) { + changes.push({ kind: "E", srcRel, destPath: destAbs }) + continue + } + + if (!(yield* fs.exists(destAbs))) { + changes.push({ kind: "D", srcRel, destPath: destAbs }) + continue + } + + const srcHash = (yield* runGetString(`sha256sum ${JSON.stringify(srcAbs)}`)).split(" ")[0] + const destHash = (yield* runGetString(`sha256sum ${JSON.stringify(destAbs)}`)).split(" ")[0] + + if (srcHash !== destHash) { + changes.push({ kind: "M", srcRel, destPath: destAbs }) + } + } + + if (changes.length === 0) { + yield* Effect.logInfo("In sync. No diff.") + return + } + + for (const { kind, srcRel, destPath } of changes) { + yield* Effect.logInfo(`${kind} ${srcRel} -> ${destPath}`) + } + yield* Effect.logInfo("") + yield* Effect.logInfo(`Summary: ${changes.filter((c) => c.kind === "M").length} modified, ${ + changes.filter((c) => c.kind === "D").length + } missing in project, ${changes.filter((c) => c.kind === "E").length} excluded.`) +}) + +/** + * Push locally-modified synced files back to the shared repo on a new branch. + * Optionally opens a PR via `gh pr create`. + * + * Workflow: + * 1. Ensure cache fresh at pinned ref. + * 2. Detect modified files via hash compare (project vs cache). + * 3. Create new branch in cache, copy modified files in, commit, push. + * 4. Optionally run `gh pr create` if `--pr` set. + */ +export const syncPush = Effect.fnUntraced(function*(opts: { + lockfilePath?: string | undefined + message?: string | undefined + branch?: string | undefined + pr?: boolean | undefined +} = {}) { + const { runGetExitCode, runGetString } = yield* RunCommandService + + const lockfilePath = opts.lockfilePath ?? ".shared.json" + const { lockfile } = yield* readLockfile(lockfilePath) + + const cachePath = yield* ensureCache(lockfile) + const files = yield* walkArtifacts(lockfile, cachePath) + + const fs = yield* FileSystem.FileSystem + const modified: Array<{ srcRel: string; srcAbs: string; destAbs: string }> = [] + + for (const { srcRel, srcAbs, destAbs, excluded } of files) { + if (excluded) continue + if (!(yield* fs.exists(destAbs))) continue + const srcHash = (yield* runGetString(`sha256sum ${JSON.stringify(srcAbs)}`)).split(" ")[0] + const destHash = (yield* runGetString(`sha256sum ${JSON.stringify(destAbs)}`)).split(" ")[0] + if (srcHash !== destHash) { + modified.push({ srcRel, srcAbs, destAbs }) + } + } + + if (modified.length === 0) { + yield* Effect.logInfo("No local modifications to push.") + return + } + + yield* Effect.logInfo(`Pushing ${modified.length} modified file(s):`) + for (const { srcRel } of modified) { + yield* Effect.logInfo(` M ${srcRel}`) + } + + const branch = opts.branch ?? `sync/from-${sanitizeRepoSlug(process.cwd().split("/").pop() ?? "project")}-${ + new Date().toISOString().slice(0, 10) + }` + + const message = opts.message ?? "sync: propagate local edits" + + // Stash anything in cache (defensive), create branch from pinned ref. + yield* runGetExitCode(`git stash --include-untracked || true`, cachePath) + yield* runGetExitCode(`git checkout -B ${JSON.stringify(branch)} ${JSON.stringify(lockfile.ref)}`, cachePath) + + for (const { srcAbs, destAbs } of modified) { + yield* runGetExitCode(`cp ${JSON.stringify(destAbs)} ${JSON.stringify(srcAbs)}`) + } + + yield* runGetExitCode(`git add -A`, cachePath) + yield* runGetExitCode( + `git -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, + cachePath + ) + yield* runGetExitCode(`git push -u origin ${JSON.stringify(branch)}`, cachePath) + + if (opts.pr) { + yield* runGetExitCode( + `gh pr create --title ${JSON.stringify(message)} --body ${ + JSON.stringify(`Propagated from project at ${process.cwd()}.\n\nFiles:\n${ + modified.map((m) => `- ${m.srcRel}`).join("\n") + }`) + } --head ${JSON.stringify(branch)}`, + cachePath + ) + } + + yield* Effect.logInfo(`Pushed branch ${branch} to shared repo.`) + yield* Effect.logInfo(opts.pr ? "PR opened." : "Open a PR with: gh pr create (from the cache dir)") +})