@effect-app/cli 1.26.4 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/gist.ts CHANGED
@@ -17,6 +17,7 @@ import { RunCommandService } from "./os-command.js"
17
17
  export class GistEntry extends Schema.Class<GistEntry>("GistEntry")({
18
18
  description: Schema.String,
19
19
  public: Schema.Boolean,
20
+ company: Schema.String,
20
21
  files: Schema.Array(Schema.String)
21
22
  }) {}
22
23
 
@@ -96,16 +97,26 @@ export class GistEntryDecoded extends GistEntry.transformOrFail<GistEntryDecoded
96
97
  }) {}
97
98
 
98
99
  export class GistYAML extends Schema.Class<GistYAML>("GistYAML")({
99
- gists: Schema.Record({
100
- key: Schema.String,
101
- value: GistEntryDecoded
102
- }),
100
+ gists: Schema
101
+ .Record({
102
+ key: Schema.String,
103
+ value: GistEntryDecoded
104
+ })
105
+ .pipe(Schema.optionalWith({
106
+ default: () => ({}),
107
+ nullable: true,
108
+ exact: true
109
+ })),
103
110
  settings: Schema.Struct({
104
111
  token_env: Schema.String,
105
112
  base_directory: Schema.String
106
113
  })
107
114
  }) {}
108
115
 
116
+ /**
117
+ * Cache entry representing a gist mapping.
118
+ * Each entry contains the gist's human-readable name and GitHub ID.
119
+ */
109
120
  export class GistCacheEntry extends Schema.Class<GistCacheEntry>("GistCacheEntry")({
110
121
  name: Schema.String,
111
122
  id: Schema.String
@@ -115,14 +126,16 @@ export const GistCacheEntries = Schema.Array(GistCacheEntry)
115
126
  export interface GistCacheEntries extends Schema.Schema.Type<typeof GistCacheEntries> {}
116
127
 
117
128
  /**
118
- * Gist cache mapping YAML configuration names to GitHub gist IDs.
129
+ * Gist cache mapping YAML configuration names to GitHub gist IDs with company awareness.
119
130
  *
120
131
  * Since GitHub gists don't have user-defined names, we maintain a cache
121
132
  * that maps the human-readable names from our YAML config to actual gist IDs.
133
+ * Each cache entry is associated with a company, enabling multi-tenant operations.
122
134
  * This allows us to:
123
135
  * - Update existing gists instead of creating duplicates
124
136
  * - Clean up obsolete entries when gists are removed from config
125
137
  * - Persist the name->ID mapping across CLI runs
138
+ * - Isolate gist operations by company context
126
139
  *
127
140
  * The cache itself is stored as a secret GitHub gist for persistence.
128
141
  */
@@ -130,9 +143,12 @@ export class GistCache {
130
143
  entries: GistCacheEntries
131
144
  gist_id: string
132
145
 
133
- constructor({ entries, gist_id }: { entries: GistCacheEntries; gist_id: string }) {
146
+ company: string
147
+
148
+ constructor({ company, entries, gist_id }: { entries: GistCacheEntries; gist_id: string; company: string }) {
134
149
  this.entries = entries
135
150
  this.gist_id = gist_id
151
+ this.company = company
136
152
  }
137
153
  }
138
154
 
@@ -141,11 +157,16 @@ export class GistCache {
141
157
  // Errors
142
158
  //
143
159
  class GistCacheNotFound extends Data.TaggedError("GistCacheNotFound")<{
144
- readonly reason: string
160
+ readonly message: string
161
+ }> {}
162
+
163
+ class GistCacheOfCompanyNotFound extends Data.TaggedError("GistCacheOfCompanyNotFound")<{
164
+ readonly message: string
165
+ readonly cache_gist_id: string
145
166
  }> {}
146
167
 
147
168
  class GistYAMLError extends Data.TaggedError("GistYAMLError")<{
148
- readonly reason: string
169
+ readonly message: string
149
170
  }> {}
150
171
 
151
172
  //
@@ -153,6 +174,7 @@ class GistYAMLError extends Data.TaggedError("GistYAMLError")<{
153
174
  // Services
154
175
  //
155
176
 
177
+ // @effect-diagnostics-next-line missingEffectServiceDependency:off
156
178
  class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
157
179
  dependencies: [RunCommandService.Default],
158
180
  effect: Effect.gen(function*() {
@@ -184,9 +206,15 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
184
206
  return gist_id && gist_id.length > 0 ? Option.some(gist_id) : Option.none()
185
207
  }
186
208
 
187
- const loadGistCache = Effect
209
+ const loadGistCache: (
210
+ company: string,
211
+ rec?: { recCache?: boolean; recCacheCompany?: boolean }
212
+ ) => Effect.Effect<GistCache, GistCacheOfCompanyNotFound, never> = Effect
188
213
  .fn("effa-cli.gist.loadGistCache")(
189
- function*() {
214
+ function*(
215
+ company: string,
216
+ { recCache = false, recCacheCompany = false } = { recCache: false, recCacheCompany: false }
217
+ ) {
190
218
  // search for existing cache gist
191
219
  const output = yield* runGetStringSuppressed(`gh gist list --filter "${CACHE_GIST_DESCRIPTION}"`)
192
220
  .pipe(Effect.orElse(() => Effect.succeed("")))
@@ -196,59 +224,81 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
196
224
  // extract first gist ID (should be our cache gist)
197
225
  const firstLine = lines[0]
198
226
  if (!firstLine) {
199
- return yield* new GistCacheNotFound({ reason: "Empty gist list output" })
227
+ return yield* new GistCacheNotFound({ message: "Empty gist list output" })
200
228
  }
201
229
 
202
230
  const parts = firstLine.split(/\s+/)
203
231
  const gist_id = parts[0]?.trim()
204
232
 
205
233
  if (!gist_id) {
206
- return yield* new GistCacheNotFound({ reason: "No gist ID found in output" })
234
+ if (recCache) {
235
+ return yield* Effect.dieMessage("Failed to create or locate cache gist after creation attempt")
236
+ }
237
+ return yield* new GistCacheNotFound({ message: "No gist ID found in output" })
207
238
  } else {
208
239
  yield* Effect.logInfo(`Found existing cache gist with ID ${gist_id}`)
209
240
  }
210
241
 
211
- // read cache gist content
212
- const cacheContent = yield* runGetStringSuppressed(`gh gist view ${gist_id}`)
213
- .pipe(Effect.orElse(() => Effect.succeed("")))
214
-
215
- const entries = yield* pipe(
216
- cacheContent.split(CACHE_GIST_DESCRIPTION)[1]?.trim(),
217
- pipe(Schema.parseJson(GistCacheEntries), Schema.decodeUnknown),
218
- Effect.orElse(() => new GistCacheNotFound({ reason: "Failed to parse cache JSON" }))
242
+ // read company-specific cache file
243
+ const filesInCache = yield* runGetStringSuppressed(`gh gist view ${gist_id} --files`).pipe(
244
+ Effect.map((files) =>
245
+ files
246
+ .trim()
247
+ .split("\n")
248
+ .map((f) => f.trim())
249
+ )
219
250
  )
220
251
 
221
- return { entries, gist_id }
222
- },
223
- Effect.catchTag("GistCacheNotFound", () =>
224
- Effect.gen(function*() {
225
- // cache doesn't exist, create it
226
- yield* Effect.logInfo("Cache gist not found, creating new cache...")
227
-
228
- const cacheJson = yield* pipe(
229
- [],
230
- pipe(Schema.parseJson(GistCacheEntries), Schema.encodeUnknown),
231
- // cannot recover from parse errors in any case, better to die here instead of cluttering the signature
252
+ if (!filesInCache.includes(`${company}.json`)) {
253
+ if (recCacheCompany) {
254
+ return yield* Effect.dieMessage(
255
+ `Failed to create or locate cache entry for company ${company} after creation attempt`
256
+ )
257
+ }
258
+ return yield* new GistCacheOfCompanyNotFound({
259
+ message: `Cache gist not found of company ${company}`,
260
+ cache_gist_id: gist_id
261
+ })
262
+ } else {
263
+ const cacheContent = yield* runGetStringSuppressed(`gh gist view ${gist_id} -f "${company}.json"`)
264
+
265
+ const entries = yield* pipe(
266
+ cacheContent,
267
+ pipe(Schema.parseJson(GistCacheEntries), Schema.decodeUnknown),
232
268
  Effect.orDie
233
269
  )
234
270
 
235
- const gistUrl = yield* runGetStringSuppressed(
236
- `echo '${cacheJson}' | gh gist create --desc="${CACHE_GIST_DESCRIPTION}" -`
237
- )
271
+ return new GistCache({ entries, gist_id, company })
272
+ }
273
+ },
274
+ (_, company) =>
275
+ _.pipe(
276
+ Effect.catchTag("GistCacheNotFound", () =>
277
+ Effect.gen(function*() {
278
+ yield* Effect.logInfo("Cache gist not found, creating new cache...")
279
+
280
+ yield* runGetStringSuppressed(
281
+ `echo "do_not_delete" | gh gist create --desc="${CACHE_GIST_DESCRIPTION}" -f effa-gist.cache -`
282
+ )
238
283
 
239
- const gist_id = yield* pipe(
240
- gistUrl,
241
- extractGistIdFromUrl,
242
- Option.match({
243
- onNone: () => Effect.dieMessage(`Could not extract cache's gist ID from URL: ${gistUrl}`),
244
- onSome: (id) =>
245
- Effect.succeed(id).pipe(Effect.tap(Effect.logInfo(`Created new cache gist with ID ${id}`)))
246
- })
247
- )
284
+ // retry loading the cache after creating it
285
+ return yield* loadGistCache(company, { recCache: true })
286
+ }))
287
+ ),
288
+ (_, company) =>
289
+ _.pipe(
290
+ Effect.catchTag("GistCacheOfCompanyNotFound", (e) =>
291
+ Effect.gen(function*() {
292
+ yield* Effect.logInfo(`Cache for company ${company} not found, creating company-specific cache file...`)
293
+
294
+ yield* runGetStringSuppressed(
295
+ `echo "[]" | gh gist edit ${e.cache_gist_id} -a ${company}.json -`
296
+ )
248
297
 
249
- return { entries: [], gist_id }
250
- })),
251
- Effect.map(({ entries, gist_id }) => new GistCache({ entries, gist_id }))
298
+ // retry loading the cache after creating it
299
+ return yield* loadGistCache(company, { recCacheCompany: true })
300
+ }))
301
+ )
252
302
  )
253
303
 
254
304
  const saveGistCache = Effect.fn("effa-cli.gist.saveGistCache")(
@@ -260,17 +310,22 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
260
310
  Effect.orDie
261
311
  )
262
312
 
263
- yield* runGetExitCodeSuppressed(`echo '${cacheJson}' | gh gist edit ${cache.gist_id} -`)
313
+ yield* runGetExitCodeSuppressed(
314
+ `echo '${cacheJson}' | gh gist edit ${cache.gist_id} -f ${cache.company}.json -`
315
+ )
264
316
  }
265
317
  )
266
318
 
267
319
  const createGistWithFiles = Effect.fn("GHGistService.createGistWithFiles")(
268
- function*({ cache, description, files, gist_name, is_public }: {
320
+ function*({ description, env, files, gist_name, is_public }: {
269
321
  gist_name: string
270
322
  description: string
271
- files: string[]
323
+ files: {
324
+ path: string
325
+ name: string
326
+ }[]
272
327
  is_public: boolean
273
- cache: GistCache
328
+ env: string
274
329
  }) {
275
330
  yield* Effect.logInfo(`Creating gist ${gist_name} with ${files.length} file(s)`)
276
331
 
@@ -280,7 +335,7 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
280
335
  "create",
281
336
  `--desc="${description}"`,
282
337
  is_public ? "--public" : "",
283
- ...files.map((filePath) => `"${filePath}"`)
338
+ ...files.map((file) => `"${file.path}"`)
284
339
  ]
285
340
  .filter((x) => !!x)
286
341
  .join(" ")
@@ -289,7 +344,7 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
289
344
  const gistUrl = yield* runGetStringSuppressed(ghCommand)
290
345
 
291
346
  // extract ID from URL
292
- return yield* pipe(
347
+ const gistNameId = yield* pipe(
293
348
  gistUrl,
294
349
  extractGistIdFromUrl,
295
350
  Option.match({
@@ -297,57 +352,94 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
297
352
  onSome: (id) =>
298
353
  Effect
299
354
  .succeed(
300
- new GistCache({
301
- gist_id: cache.gist_id,
302
- entries: [...cache.entries, { name: gist_name, id }]
303
- })
355
+ { name: gist_name, id }
304
356
  )
305
357
  .pipe(Effect.tap(Effect.logInfo(`Created gist with ID ${id}`)))
306
358
  })
307
359
  )
360
+
361
+ // rename all files to include environment prefix for multi-environment support
362
+ for (const file of files) {
363
+ const originalName = file.name
364
+ const name_with_env = `${env}.${originalName}`
365
+ const ghRenameCommand = [
366
+ "gh",
367
+ "gist",
368
+ "rename",
369
+ gistNameId.id,
370
+ originalName,
371
+ name_with_env
372
+ ]
373
+ .join(" ")
374
+
375
+ yield* Effect.logInfo(`Renaming file ${originalName} to ${name_with_env} in gist ${gist_name}`)
376
+ yield* runGetStringSuppressed(ghRenameCommand)
377
+ }
378
+
379
+ return gistNameId
308
380
  }
309
381
  )
310
382
 
311
383
  const getGistFileNames = Effect.fn("getGistFileNames")(
312
- function*({ gist_id, gist_name }: {
384
+ function*({ env, gist_id, gist_name }: {
313
385
  gist_id: string
314
386
  gist_name: string
387
+ env: string
315
388
  }) {
316
389
  yield* Effect.logInfo(`Retrieving file names from gist ${gist_name} with ID ${gist_id}`)
317
390
  const output = yield* runGetStringSuppressed(`gh gist view ${gist_id} --files`)
318
- return output
319
- .trim()
320
- .split("\n")
321
- .filter((line: string) => line.trim())
391
+
392
+ // filter file names by environment prefix and remove the prefix
393
+ // files in gists are prefixed with "env." to support multiple environments
394
+ return Array.filterMap(
395
+ output
396
+ .trim()
397
+ .split("\n"),
398
+ (fn) => {
399
+ const fnTrimmed = fn.trim()
400
+ if (!fnTrimmed.startsWith(env + ".")) {
401
+ return Option.none()
402
+ }
403
+ return Option.some(
404
+ fnTrimmed.substring(env.length + 1) // remove env prefix and dot
405
+ )
406
+ }
407
+ )
322
408
  }
323
409
  )
324
410
 
325
411
  const removeFileFromGist = Effect.fn("removeFileFromGist")(
326
- function*({ file_name, gist_id, gist_name }: {
412
+ function*({ env, file_name, gist_id, gist_name }: {
327
413
  gist_id: string
328
414
  gist_name: string
329
415
  file_name: string
416
+ env: string
330
417
  }) {
331
- yield* Effect.logInfo(`Removing file ${file_name} from gist ${gist_name}`)
332
- return yield* runGetExitCodeSuppressed(`gh gist edit ${gist_id} --remove "${file_name}"`)
418
+ const name_with_env = `${env}.${file_name}`
419
+ yield* Effect.logInfo(`Removing file ${name_with_env} from gist ${gist_name}`)
420
+ return yield* runGetExitCodeSuppressed(`gh gist edit ${gist_id} --remove "${name_with_env}"`)
333
421
  }
334
422
  )
335
423
 
336
424
  const updateFileOfGist = Effect.fn("updateFileOfGist")(
337
- function*({ file_name, file_path, gist_id, gist_name }: {
425
+ function*({ env, file_name, file_path, gist_id, gist_name }: {
338
426
  gist_id: string
339
427
  gist_name: string
340
428
  file_name: string
341
429
  file_path: string
430
+ env: string
342
431
  }) {
343
- yield* Effect.logInfo(`Updating file ${file_name} located at ${file_path} of gist ${gist_name}`)
432
+ const name_with_env = `${env}.${file_name}`
433
+ yield* Effect.logInfo(`Updating file ${name_with_env} located at ${file_path} of gist ${gist_name}`)
434
+
435
+ // it seems this does not require renaming the file
344
436
  const editCommand = [
345
437
  "gh",
346
438
  "gist",
347
439
  "edit",
348
440
  gist_id,
349
441
  "-f",
350
- file_name,
442
+ name_with_env,
351
443
  `"${file_path}"`
352
444
  ]
353
445
  .join(" ")
@@ -357,23 +449,47 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
357
449
  )
358
450
 
359
451
  const addFileToGist = Effect.fn("addFileToGist")(
360
- function*({ file_path, gist_id, gist_name }: {
452
+ function*({ env, file, gist_id, gist_name }: {
361
453
  gist_id: string
362
454
  gist_name: string
363
- file_path: string
455
+ file: {
456
+ path: string
457
+ name: string
458
+ }
459
+ env: string
364
460
  }) {
365
- yield* Effect.logInfo(`Adding file ${file_path} to gist ${gist_name}`)
461
+ yield* Effect.logInfo(`Adding file ${file.path} to gist ${gist_name}`)
366
462
  const editCommand = [
367
463
  "gh",
368
464
  "gist",
369
465
  "edit",
370
466
  gist_id,
371
467
  "-a",
372
- `"${file_path}"`
468
+ `"${file.path}"`
373
469
  ]
374
470
  .join(" ")
375
471
 
376
- return yield* runGetExitCodeSuppressed(editCommand)
472
+ yield* runGetExitCodeSuppressed(editCommand)
473
+
474
+ const renameCommand = [
475
+ "gh",
476
+ "gist",
477
+ "rename",
478
+ gist_id,
479
+ file.name,
480
+ `${env}.${file.name}`
481
+ ]
482
+ .join(" ")
483
+
484
+ yield* Effect.logInfo(`Renaming file ${file.name} to ${env}.${file.name} in gist ${gist_name}`)
485
+ return yield* runGetExitCodeSuppressed(renameCommand)
486
+ }
487
+ )
488
+
489
+ const deleteGist = Effect.fn("deleteGist")(
490
+ function*({ gist_id, gist_name }: { gist_id: string; gist_name: string }) {
491
+ yield* Effect.logInfo(`Deleting gist ${gist_name} with ID ${gist_id}`)
492
+ return yield* runGetExitCodeSuppressed(`gh gist delete ${gist_id}`)
377
493
  }
378
494
  )
379
495
 
@@ -419,55 +535,72 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
419
535
  saveGistCache,
420
536
 
421
537
  /**
422
- * Creates a new GitHub gist with the specified files and updates the local cache.
423
- * Generates a GitHub CLI command to create the gist and extracts the resulting gist ID.
538
+ * Creates a new GitHub gist with the specified files and renames them with environment prefixes.
539
+ * Generates a GitHub CLI command to create the gist, extracts the resulting gist ID,
540
+ * and renames all files with environment prefixes for multi-environment support.
424
541
  *
425
- * @param cache - The current GistCache instance
426
- * @param name - The human-readable name for this gist (used in cache mapping)
542
+ * @param gist_name - The human-readable name for this gist (used in cache mapping)
427
543
  * @param description - The description for the GitHub gist
428
- * @param files - Array of file paths to include in the gist
544
+ * @param files - Array of file objects with path and name properties to include in the gist
429
545
  * @param is_public - Whether the gist should be public or private
430
- * @returns An Effect that yields an updated GistCache with the new gist entry
546
+ * @param env - Environment prefix to prepend to file names (e.g., "local-dev", "prod")
547
+ * @returns An Effect that yields a gist entry object with name and id properties
431
548
  */
432
549
  createGistWithFiles,
433
550
 
434
551
  /**
435
- * Retrieves file names from a GitHub gist.
436
- * Fetches the list of files contained in the specified gist.
552
+ * Retrieves file names from a GitHub gist, filtered by environment prefix.
553
+ * Fetches the list of files contained in the specified gist and returns only
554
+ * those that match the current environment, with the environment prefix removed.
437
555
  *
438
556
  * @param gist_id - The GitHub gist ID to retrieve file names from
439
557
  * @param gist_name - The human-readable name of the gist (for logging purposes)
440
- * @returns An Effect that yields an array of file names
558
+ * @param env - Environment prefix to filter files by (e.g., "local-dev", "prod")
559
+ * @returns An Effect that yields an array of file names with environment prefix removed
441
560
  */
442
561
  getGistFileNames,
443
562
 
444
563
  /**
445
564
  * Removes a file from a specified GitHub gist.
565
+ * The file name is automatically prefixed with the environment when removing.
446
566
  * @param gist_id - The ID of the gist to modify
447
567
  * @param gist_name - The human-readable name of the gist (for logging purposes)
448
- * @param file_name - The name of the file to remove from the gist
568
+ * @param file_name - The base name of the file to remove (without environment prefix)
569
+ * @param env - Environment prefix that was used when the file was added
449
570
  * @returns An Effect that succeeds when the file is removed
450
571
  */
451
572
  removeFileFromGist,
452
573
 
453
574
  /**
454
575
  * Updates a file in a specified GitHub gist.
576
+ * The file name is automatically prefixed with the environment when updating.
455
577
  * @param gist_id - The ID of the gist to modify
456
578
  * @param gist_name - The human-readable name of the gist (for logging purposes)
457
- * @param file_name - The name of the file to remove from the gist
579
+ * @param file_name - The base name of the file to update (without environment prefix)
458
580
  * @param file_path - The local path of the file to update in the gist
581
+ * @param env - Environment prefix that was used when the file was added
459
582
  * @returns An Effect that succeeds when the file is updated
460
583
  */
461
584
  updateFileOfGist,
462
585
 
463
586
  /**
464
587
  * Adds a new file to a specified GitHub gist.
588
+ * The file is automatically renamed with an environment prefix for multi-environment support.
465
589
  * @param gist_id - The ID of the gist to modify
466
590
  * @param gist_name - The human-readable name of the gist (for logging purposes)
467
- * @param file_path - The local path of the file to add to the gist
468
- * @returns An Effect that succeeds when the file is added
591
+ * @param file - The file object containing path and name properties
592
+ * @param env - Environment prefix to prepend to the file name
593
+ * @returns An Effect that succeeds when the file is added and renamed
594
+ */
595
+ addFileToGist,
596
+
597
+ /**
598
+ * Deletes a specified GitHub gist by its ID.
599
+ * @param gist_id - The ID of the gist to delete
600
+ * @param gist_name - The human-readable name of the gist (for logging purposes)
601
+ * @returns An Effect that succeeds when the gist is deleted
469
602
  */
470
- addFileToGist
603
+ deleteGist
471
604
  }
472
605
  })
473
606
  }) {}
@@ -485,6 +618,14 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
485
618
 
486
619
  return {
487
620
  handler: Effect.fn("effa-cli.gist.GistHandler")(function*({ YAMLPath }: { YAMLPath: string }) {
621
+ // load company and environment from environment variables
622
+ const CONFIG = yield* Effect.all({
623
+ company: Config.string("COMPANY"),
624
+ env: Config.string("ENV").pipe(Config.withDefault("local-dev"))
625
+ })
626
+
627
+ yield* Effect.logInfo(`Company: ${CONFIG.company}, ENV: ${CONFIG.env}`)
628
+
488
629
  yield* Effect.logInfo(`Reading configuration from ${YAMLPath}`)
489
630
 
490
631
  const configExists = yield* fs.exists(YAMLPath)
@@ -492,14 +633,14 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
492
633
  return yield* Effect.fail(new Error(`Configuration file not found: ${YAMLPath}`))
493
634
  }
494
635
 
495
- const config = yield* pipe(
636
+ const configFromYaml = yield* pipe(
496
637
  YAMLPath,
497
638
  fs.readFileString,
498
639
  Effect.andThen((content) =>
499
640
  Effect.try({
500
641
  try: () => yaml.load(content),
501
642
  catch(error) {
502
- return new GistYAMLError({ reason: `Failed to parse YAML: ${(error as Error).message}` })
643
+ return new GistYAMLError({ message: `Failed to parse YAML: ${(error as Error).message}` })
503
644
  }
504
645
  })
505
646
  ),
@@ -507,17 +648,24 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
507
648
  )
508
649
 
509
650
  // load GitHub token securely from environment variable
510
- const redactedToken = yield* Config.redacted(config.settings.token_env)
651
+ const redactedToken = yield* Config.redacted(configFromYaml.settings.token_env)
511
652
 
512
- yield* Effect.logInfo(`Using GitHub token from environment variable: ${config.settings.token_env}`)
653
+ yield* Effect.logInfo(`Using GitHub token from environment variable: ${configFromYaml.settings.token_env}`)
513
654
  yield* Effect.logInfo(`Token loaded: ${redactedToken}`) // this will show <redacted> in logs
514
655
 
515
656
  yield* GH.login(Redacted.value(redactedToken))
516
657
 
517
- const cache = yield* SynchronizedRef.make<GistCache>(yield* GH.loadGistCache())
658
+ const cache = yield* SynchronizedRef.make<GistCache>(yield* GH.loadGistCache(CONFIG.company))
659
+
660
+ // filter YAML gists by company to ensure isolation between different organizations
661
+ // this prevents cross-company gist operations and maintains data separation
662
+ const thisCompanyGistsFromYaml = Object
663
+ .entries(configFromYaml.gists)
664
+ .filter(([, v]) => v.company === CONFIG.company)
518
665
 
519
- // handle each gist entry in the configuration
520
- for (const [name, gistConfig] of Object.entries(config.gists)) {
666
+ for (
667
+ const [name, gistConfig] of thisCompanyGistsFromYaml
668
+ ) {
521
669
  const { description, files_with_name, public: is_public } = gistConfig
522
670
 
523
671
  yield* Effect.logInfo(`Processing gist ${name}`)
@@ -526,7 +674,7 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
526
674
  .all(
527
675
  files_with_name.map((f) =>
528
676
  Effect.gen(function*() {
529
- const fullPath = path.join(config.settings.base_directory, f.path)
677
+ const fullPath = path.join(configFromYaml.settings.base_directory, f.path)
530
678
  const fileExists = yield* fs.exists(fullPath)
531
679
 
532
680
  if (!fileExists) {
@@ -557,7 +705,8 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
557
705
  const gistFileNames = new Set(
558
706
  yield* GH.getGistFileNames({
559
707
  gist_id: gistFromCache.id,
560
- gist_name: gistFromCache.name
708
+ gist_name: gistFromCache.name,
709
+ env: CONFIG.env
561
710
  })
562
711
  )
563
712
 
@@ -569,7 +718,8 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
569
718
  yield* GH.removeFileFromGist({
570
719
  gist_id: gistFromCache.id,
571
720
  gist_name: gistFromCache.name,
572
- file_name: gf
721
+ file_name: gf,
722
+ env: CONFIG.env
573
723
  })
574
724
  }
575
725
  }
@@ -581,60 +731,84 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
581
731
  gist_id: gistFromCache.id,
582
732
  gist_name: gistFromCache.name,
583
733
  file_name: f.name,
584
- file_path: f.path
734
+ file_path: f.path,
735
+ env: CONFIG.env
585
736
  })
586
737
  } else {
587
738
  yield* GH.addFileToGist({
588
739
  gist_id: gistFromCache.id,
589
740
  gist_name: gistFromCache.name,
590
- file_path: f.path
741
+ file: f,
742
+ env: CONFIG.env
591
743
  })
592
744
  }
593
745
  }
594
746
  } else {
595
747
  if (filesOnDiskWithFullPath.length !== 0) {
596
- yield* SynchronizedRef.getAndUpdateEffect(cache, (cache) => {
597
- return GH.createGistWithFiles({
598
- gist_name: name,
599
- description,
600
- is_public,
601
- cache,
602
- files: filesOnDiskWithFullPath.map((f) => f.path)
748
+ yield* SynchronizedRef.getAndUpdateEffect(
749
+ cache,
750
+ Effect.fnUntraced(function*(cache) {
751
+ return new GistCache({
752
+ gist_id: cache.gist_id,
753
+ entries: [
754
+ ...cache.entries,
755
+ {
756
+ ...(yield* GH.createGistWithFiles({
757
+ gist_name: name,
758
+ description,
759
+ is_public,
760
+
761
+ files: filesOnDiskWithFullPath,
762
+ env: CONFIG.env
763
+ }))
764
+ }
765
+ ],
766
+ company: cache.company
767
+ })
603
768
  })
604
- })
769
+ )
605
770
  } else {
606
771
  yield* Effect.logWarning(`No valid files found for gist ${name}, skipping creation...`)
607
772
  }
608
773
  }
774
+ }
609
775
 
610
- // here the local cache has been updated, but not yet saved to GitHub
611
- // we still want to remove gists from cache that are no longer in the configuration
612
-
613
- const configGistNames = new Set(Object.entries(config.gists).map(([name]) => name))
614
-
615
- const newCache = yield* SynchronizedRef.updateAndGetEffect(
616
- cache,
617
- Effect.fnUntraced(function*(cache) {
618
- const newEntries = [...cache.entries]
776
+ // cache cleanup: remove gists that are no longer in YAML configuration
777
+ // only affects entries for the current company to maintain isolation
778
+ const configGistNames = new Set(
779
+ thisCompanyGistsFromYaml
780
+ .map(([name]) => name)
781
+ )
619
782
 
620
- for (let i = newEntries.length - 1; i >= 0; i--) {
621
- const cacheEntry = newEntries[i]
622
- if (cacheEntry && !configGistNames.has(cacheEntry.name)) {
623
- yield* Effect.logInfo(
624
- `Obsolete gist ${cacheEntry.name} with ID ${cacheEntry.id}) will be removed from cache`
625
- )
626
- newEntries.splice(i, 1)
627
- }
783
+ const newCache = yield* SynchronizedRef.updateAndGetEffect(
784
+ cache,
785
+ Effect.fnUntraced(function*(cache) {
786
+ const newEntries = [...cache.entries]
787
+
788
+ // remove obsolete cache entries for current company only
789
+ // this ensures gists from other companies remain untouched
790
+ for (let i = newEntries.length - 1; i >= 0; i--) {
791
+ const cacheEntry = newEntries[i]
792
+ if (cacheEntry && !configGistNames.has(cacheEntry.name)) {
793
+ // delete the actual gist from GitHub
794
+ yield* GH.deleteGist({
795
+ gist_id: cacheEntry.id,
796
+ gist_name: cacheEntry.name
797
+ })
798
+ yield* Effect.logInfo(
799
+ `Obsolete gist ${cacheEntry.name} of company ${CONFIG.company} with ID ${cacheEntry.id}) will be removed from cache`
800
+ )
801
+ newEntries.splice(i, 1)
628
802
  }
803
+ }
629
804
 
630
- return { ...cache, entries: newEntries }
631
- })
632
- )
805
+ return { ...cache, entries: newEntries }
806
+ })
807
+ )
633
808
 
634
- yield* GH.saveGistCache(newCache)
809
+ yield* GH.saveGistCache(newCache)
635
810
 
636
- yield* Effect.logInfo("Gist operations completed")
637
- }
811
+ yield* Effect.logInfo("Gist operations completed")
638
812
  })
639
813
  }
640
814
  })