diff --git a/.changeset/fix-cli-gist-cache-id.md b/.changeset/fix-cli-gist-cache-id.md new file mode 100644 index 0000000000..7ff26aabf5 --- /dev/null +++ b/.changeset/fix-cli-gist-cache-id.md @@ -0,0 +1,5 @@ +--- +"@effect-app/cli": patch +--- + +Require an explicit `EFFA_GIST_CACHE_ID`, remove cache Gist discovery and automatic creation, and initialize missing company pointer files once. diff --git a/packages/cli/src/gist.ts b/packages/cli/src/gist.ts index 77f6183f79..c57108d262 100644 --- a/packages/cli/src/gist.ts +++ b/packages/cli/src/gist.ts @@ -164,15 +164,6 @@ export class GistCache { // // Errors // -class GistCacheNotFound extends Data.TaggedError("GistCacheNotFound")<{ - readonly message: string -}> {} - -class GistCacheOfCompanyNotFound extends Data.TaggedError("GistCacheOfCompanyNotFound")<{ - readonly message: string - readonly cache_gist_id: string -}> {} - class GistYAMLError extends Data.TaggedError("GistYAMLError")<{ readonly message: string }> {} @@ -182,9 +173,8 @@ class GistYAMLError extends Data.TaggedError("GistYAMLError")<{ // Services // -class GHGistService extends Context.Service()("GHGistService", { +export class GHGistService extends Context.Service()("GHGistService", { make: Effect.gen(function*() { - const CACHE_GIST_DESCRIPTION = "GIST_CACHE_DO_NOT_EDIT_effa_cli_internal" const { runGetExitCode, runGetString } = yield* RunCommandService // the client cannot recover from PlatformErrors, so we convert failures into defects to clean up the signatures @@ -214,101 +204,36 @@ class GHGistService extends Context.Service()("GHGistService", { return gist_id && gist_id.length > 0 ? Option.some(gist_id) : Option.none() } - const loadGistCache: ( - company: string, - rec?: { recCache?: boolean; recCacheCompany?: boolean } - ) => Effect.Effect = Effect - .fn("effa-cli.gist.loadGistCache")( - function*( - company: string, - { recCache = false, recCacheCompany = false } = { recCache: false, recCacheCompany: false } - ) { - // search for existing cache gist - const output = yield* runGetStringSuppressed(`gh gist list --filter "${CACHE_GIST_DESCRIPTION}"`) - - const firstLine = output.trim().split("\n").find((line: string) => line.trim()) - // extract first gist ID (should be our cache gist) - if (!firstLine) { - return yield* new GistCacheNotFound({ message: "Empty gist list output" }) - } - - const parts = firstLine.split(/\s+/) - const gist_id = parts[0]?.trim() - - if (!gist_id) { - if (recCache) { - return yield* Effect.die("Failed to create or locate cache gist after creation attempt") - } - return yield* new GistCacheNotFound({ message: "No gist ID found in output" }) - } else { - yield* Effect.logInfo(`Found existing cache gist with ID ${gist_id}`) - } - - // read company-specific cache file - const filesInCache = yield* runGetStringSuppressed(`gh gist view ${gist_id} --files`).pipe( - Effect.map((files) => - files - .trim() - .split("\n") - .map((f) => f.trim()) - ) + const loadGistCache = Effect.fn("effa-cli.gist.loadGistCache")( + function*(cacheGistId: string, company: string) { + const filesInCache = yield* runGetStringSuppressed(`gh gist view ${cacheGistId} --files`).pipe( + Effect.map((files) => + files + .trim() + .split("\n") + .map((f) => f.trim()) ) + ) - if (!filesInCache.includes(`${company}.json`)) { - if (recCacheCompany) { - return yield* Effect.die( - `Failed to create or locate cache entry for company ${company} after creation attempt` - ) - } - return yield* new GistCacheOfCompanyNotFound({ - message: `Cache gist not found of company ${company}`, - cache_gist_id: gist_id - }) - } else { - const cacheContent = yield* runGetStringSuppressed(`gh gist view ${gist_id} -f "${company}.json"`) - - const entries = yield* pipe( - cacheContent, - Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(GistCacheEntries))), - Effect.orDie - ) + if (!filesInCache.includes(`${company}.json`)) { + yield* Effect.logInfo(`Cache for company ${company} not found, creating company-specific cache file...`) + yield* runGetStringSuppressed( + `echo "[]" | gh gist edit ${cacheGistId} -a ${company}.json -` + ) + return new GistCache({ entries: [], gist_id: cacheGistId, company }) + } - return new GistCache({ entries, gist_id, company }) - } - }, - (_, company) => - _.pipe( - Effect.catchTag( - "GistCacheNotFound", - Effect.fnUntraced(function*() { - yield* Effect.logInfo("Cache gist not found, creating new cache...") - - yield* runGetStringSuppressed( - `echo "do_not_delete" | gh gist create --desc="${CACHE_GIST_DESCRIPTION}" -f effa-gist.cache -` - ) + const cacheContent = yield* runGetStringSuppressed(`gh gist view ${cacheGistId} -f "${company}.json"`) - // retry loading the cache after creating it - return yield* loadGistCache(company, { recCache: true }) - }) - ) - ), - (_, company) => - _.pipe( - Effect.catchTag( - "GistCacheOfCompanyNotFound", - Effect.fnUntraced(function*(e) { - yield* Effect.logInfo(`Cache for company ${company} not found, creating company-specific cache file...`) - - yield* runGetStringSuppressed( - `echo "[]" | gh gist edit ${e.cache_gist_id} -a ${company}.json -` - ) + const entries = yield* pipe( + cacheContent, + Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(GistCacheEntries))), + Effect.orDie + ) - // retry loading the cache after creating it - return yield* loadGistCache(company, { recCacheCompany: true }) - }) - ) - ) - ) + return new GistCache({ entries, gist_id: cacheGistId, company }) + } + ) const saveGistCache = Effect.fn("effa-cli.gist.saveGistCache")( function*(cache: GistCache) { @@ -518,10 +443,10 @@ class GHGistService extends Context.Service()("GHGistService", { login, /** - * Loads the gist cache from GitHub, containing mappings of YAML configuration names to gist IDs. - * If no cache exists, creates a new empty cache gist. + * Loads company mappings from the configured cache Gist. + * A missing company file is initialized once with an empty mapping. * - * @returns An Effect that yields a GistCache containing the loaded cache entries and cache gist ID + * @returns An Effect that yields the loaded company cache */ loadGistCache, @@ -623,7 +548,8 @@ export class GistHandler extends Context.Service()("GistHandler", { // load company and environment from environment variables const CONFIG = yield* Config.all({ company: Config.string("COMPANY"), - env: Config.string("ENV").pipe(Config.withDefault("local-dev")) + env: Config.string("ENV").pipe(Config.withDefault("local-dev")), + gistCacheId: Config.nonEmptyString("EFFA_GIST_CACHE_ID") }) yield* Effect.logInfo(`Company: ${CONFIG.company}, ENV: ${CONFIG.env}`) @@ -657,7 +583,9 @@ export class GistHandler extends Context.Service()("GistHandler", { yield* GH.login(Redacted.value(redactedToken)) - const cache = yield* SynchronizedRef.make(yield* GH.loadGistCache(CONFIG.company)) + const cache = yield* SynchronizedRef.make( + yield* GH.loadGistCache(CONFIG.gistCacheId, CONFIG.company) + ) // filter YAML gists by company to ensure isolation between different organizations // this prevents cross-company gist operations and maintains data separation diff --git a/packages/cli/test/gist-cache.test.ts b/packages/cli/test/gist-cache.test.ts new file mode 100644 index 0000000000..902e714249 --- /dev/null +++ b/packages/cli/test/gist-cache.test.ts @@ -0,0 +1,92 @@ +import { Effect, Layer } from "effect" +import { describe, expect, it } from "vitest" +import { GHGistService } from "../src/gist.js" +import { RunCommandService } from "../src/os-command.js" + +describe("GHGistService.loadGistCache", () => { + it("loads the company cache from the configured Gist ID without discovery", async () => { + const commands: Array = [] + const runCommand = Layer.succeed(RunCommandService)({ + runGetExitCode: (command) => { + commands.push(command) + return Effect.die(`Unexpected command: ${command}`) + }, + runGetString: (command) => { + commands.push(command) + + switch (command) { + case "gh gist view configured-cache-id --files": + return Effect.succeed("effa-gist.cache\nmako.json\n") + case "gh gist view configured-cache-id -f \"mako.json\"": + return Effect.succeed("[{\"name\":\"mako\",\"id\":\"content-gist-id\"}]") + default: + return Effect.die(`Unexpected command: ${command}`) + } + } + }) + + const cache = await Effect.runPromise( + Effect + .gen(function*() { + const gh = yield* GHGistService + return yield* gh.loadGistCache("configured-cache-id", "mako") + }) + .pipe( + Effect.provide(GHGistService.DefaultWithoutDependencies), + Effect.provide(runCommand) + ) + ) + + expect(cache.gist_id).toBe("configured-cache-id") + expect(cache.company).toBe("mako") + expect(cache.entries.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: "content-gist-id", name: "mako" } + ]) + expect(commands).toEqual([ + "gh gist view configured-cache-id --files", + "gh gist view configured-cache-id -f \"mako.json\"" + ]) + }) + + it("creates a missing company cache file once without retrying", async () => { + const commands: Array = [] + const runCommand = Layer.succeed(RunCommandService)({ + runGetExitCode: (command) => { + commands.push(command) + return Effect.die(`Unexpected command: ${command}`) + }, + runGetString: (command) => { + commands.push(command) + + switch (command) { + case "gh gist view configured-cache-id --files": + return Effect.succeed("effa-gist.cache\n") + case "echo \"[]\" | gh gist edit configured-cache-id -a mako.json -": + return Effect.succeed("") + default: + return Effect.die(`Unexpected command: ${command}`) + } + } + }) + + const cache = await Effect.runPromise( + Effect + .gen(function*() { + const gh = yield* GHGistService + return yield* gh.loadGistCache("configured-cache-id", "mako") + }) + .pipe( + Effect.provide(GHGistService.DefaultWithoutDependencies), + Effect.provide(runCommand) + ) + ) + + expect(cache.gist_id).toBe("configured-cache-id") + expect(cache.company).toBe("mako") + expect(cache.entries).toEqual([]) + expect(commands).toEqual([ + "gh gist view configured-cache-id --files", + "echo \"[]\" | gh gist edit configured-cache-id -a mako.json -" + ]) + }) +}) diff --git "a/wiki/effect\342\200\220app\342\200\220cli.md" "b/wiki/effect\342\200\220app\342\200\220cli.md" index d07729c4b2..41c6ad0474 100644 --- "a/wiki/effect\342\200\220app\342\200\220cli.md" +++ "b/wiki/effect\342\200\220app\342\200\220cli.md" @@ -203,6 +203,7 @@ pnpm effa gist [options] **Environment Variables:** - `GIST_GITHUB_TOKEN`: GitHub Personal Access Token with gist permissions +- `EFFA_GIST_CACHE_ID`: ID of the pre-created secret Gist used for pointer storage - `COMPANY`: Company identifier for multi-tenant gist management - `ENV`: Environment name (defaults to "local-dev") @@ -210,10 +211,10 @@ pnpm effa gist [options] ```bash # Using default config file (gists.yaml) -COMPANY=acme GIST_GITHUB_TOKEN=ghp_xxx pnpm effa gist +COMPANY=acme EFFA_GIST_CACHE_ID=abc123 GIST_GITHUB_TOKEN=ghp_xxx pnpm effa gist # Using custom config file with specific environment -COMPANY=acme ENV=production GIST_GITHUB_TOKEN=ghp_xxx pnpm effa gist --config my-gists.yaml +COMPANY=acme ENV=production EFFA_GIST_CACHE_ID=abc123 GIST_GITHUB_TOKEN=ghp_xxx pnpm effa gist --config my-gists.yaml ``` **YAML Configuration Format:** @@ -271,9 +272,10 @@ gists: - Handles file name collisions (GitHub gists have flat structure) 5. **GitHub Integration**: - Supports both public and private gists - - Persistent cache stored as a secret GitHub gist with company-specific files (e.g., `company1.json`, `company2.json`) - - Automatic gist deletion when removed from configuration - - Robust error handling for cache creation and company-specific cache files + - Uses the secret cache Gist configured by `EFFA_GIST_CACHE_ID` + - Never discovers or automatically creates the cache Gist + - Automatically initializes a missing company file, such as `company1.json`, inside the configured cache Gist + - Automatically deletes target gists removed from configuration **Example File Structure in Gists:** When `ENV=production`, files are automatically renamed with environment prefixes: @@ -288,6 +290,7 @@ This allows multiple environments to coexist in the same gist without conflicts. - GitHub CLI (`gh`) installed and configured - GitHub Personal Access Token with gist scope +- A pre-created secret cache Gist, configured through `EFFA_GIST_CACHE_ID` - YAML configuration file with proper structure - `COMPANY` environment variable set for multi-tenant operations (each company gets its own cache file)