Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-cli-gist-cache-id.md
Original file line number Diff line number Diff line change
@@ -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.
140 changes: 34 additions & 106 deletions packages/cli/src/gist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}> {}
Expand All @@ -182,9 +173,8 @@ class GistYAMLError extends Data.TaggedError("GistYAMLError")<{
// Services
//

class GHGistService extends Context.Service<GHGistService>()("GHGistService", {
export class GHGistService extends Context.Service<GHGistService>()("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
Expand Down Expand Up @@ -214,101 +204,36 @@ class GHGistService extends Context.Service<GHGistService>()("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<GistCache, GistCacheOfCompanyNotFound> = 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) {
Expand Down Expand Up @@ -518,10 +443,10 @@ class GHGistService extends Context.Service<GHGistService>()("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,

Expand Down Expand Up @@ -623,7 +548,8 @@ export class GistHandler extends Context.Service<GistHandler>()("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}`)
Expand Down Expand Up @@ -657,7 +583,9 @@ export class GistHandler extends Context.Service<GistHandler>()("GistHandler", {

yield* GH.login(Redacted.value(redactedToken))

const cache = yield* SynchronizedRef.make<GistCache>(yield* GH.loadGistCache(CONFIG.company))
const cache = yield* SynchronizedRef.make<GistCache>(
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
Expand Down
92 changes: 92 additions & 0 deletions packages/cli/test/gist-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = []
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<string> = []
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 -"
])
})
})
13 changes: 8 additions & 5 deletions wiki/effect‐app‐cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,18 @@ 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")

**Example Usage:**

```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:**
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
Loading