@effect-app/cli 1.25.0 → 1.26.1

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/gist.d.ts +231 -0
  3. package/dist/gist.d.ts.map +1 -0
  4. package/dist/gist.js +447 -0
  5. package/dist/index.js +38 -65
  6. package/dist/os-command.d.ts +18 -0
  7. package/dist/os-command.d.ts.map +1 -0
  8. package/dist/os-command.js +47 -0
  9. package/package.json +11 -17
  10. package/src/gist.ts +615 -0
  11. package/src/index.ts +56 -73
  12. package/src/os-command.ts +63 -0
  13. package/test.gists.yaml +20 -0
  14. package/dist/link.d.ts +0 -2
  15. package/dist/link.d.ts.map +0 -1
  16. package/dist/link.js +0 -17
  17. package/dist/old.d.ts +0 -2
  18. package/dist/old.d.ts.map +0 -1
  19. package/dist/old.js +0 -246
  20. package/dist/sync.d.ts +0 -2
  21. package/dist/sync.d.ts.map +0 -1
  22. package/dist/sync.js +0 -16
  23. package/dist/unlink.d.ts +0 -2
  24. package/dist/unlink.d.ts.map +0 -1
  25. package/dist/unlink.js +0 -13
  26. package/src/link.ts +0 -20
  27. package/src/old.ts +0 -283
  28. package/src/sync.ts +0 -17
  29. package/src/unlink.ts +0 -14
  30. package/vitest.config.ts.timestamp-1709838404819-f2fb28517168c.mjs +0 -33
  31. package/vitest.config.ts.timestamp-1709838418683-9c399c96f9d78.mjs +0 -33
  32. package/vitest.config.ts.timestamp-1709838649058-0e8f9431c893d.mjs +0 -33
  33. package/vitest.config.ts.timestamp-1711724061889-4985ba59def8.mjs +0 -37
  34. package/vitest.config.ts.timestamp-1711743471019-3c5e0c6ca2188.mjs +0 -37
  35. package/vitest.config.ts.timestamp-1711743489536-5ca18d3f67759.mjs +0 -37
  36. package/vitest.config.ts.timestamp-1711743593444-e40a8dcd4fc31.mjs +0 -37
  37. package/vitest.config.ts.timestamp-1711744615239-6a156fd39b9c9.mjs +0 -37
package/src/gist.ts ADDED
@@ -0,0 +1,615 @@
1
+ /* eslint-disable no-constant-binary-expression */
2
+ /* eslint-disable no-empty-pattern */
3
+ // import necessary modules from the libraries
4
+ import { FileSystem, Path } from "@effect/platform"
5
+
6
+ import { Array, Config, Data, Effect, Option, ParseResult, pipe, Schema, SynchronizedRef } from "effect"
7
+ import * as yaml from "js-yaml"
8
+ import path from "path"
9
+ import { RunCommandService } from "./os-command.js"
10
+
11
+ //
12
+ //
13
+ // Schemas
14
+ //
15
+
16
+ export class GistEntry extends Schema.Class<GistEntry>("GistEntry")({
17
+ description: Schema.String,
18
+ public: Schema.Boolean,
19
+ files: Schema.Array(Schema.String)
20
+ }) {}
21
+
22
+ /**
23
+ * Extended gist entry that validates file_name uniqueness and extracts base filenames.
24
+ *
25
+ * GitHub Gists have a flat file structure and do not support directories/folders.
26
+ * All files within a gist must exist in the same namespace, meaning that files
27
+ * with identical names will collide, even if they originate from different local
28
+ * directories. When multiple files share the same basename, GitHub will either:
29
+ * - Reject the gist creation
30
+ * - Silently overwrite files (last one wins)
31
+ * - Display unpredictable behavior
32
+ *
33
+ * This validation prevents such collisions by detecting when multiple file paths
34
+ * would result in the same file_name when flattened to the gist structure.
35
+ *
36
+ * @example
37
+ * // These paths would collide in a gist
38
+ * ["src/config.json", "dist/config.json"] // Both become "config.json"
39
+ *
40
+ * @see {@link https://docs.github.com/articles/creating-gists | GitHub Gist Documentation}
41
+ * @see {@link https://github.com/orgs/community/discussions/29584 | Community Discussion on Gist Folder Support}
42
+ */
43
+ export class GistEntryDecoded extends GistEntry.transformOrFail<GistEntryDecoded>("GistEntryDecoded")({
44
+ files_with_name: Schema.Array(Schema.Struct({
45
+ path: Schema.String,
46
+ name: Schema.String
47
+ }))
48
+ }, {
49
+ decode: Effect.fnUntraced(function*(entry, _, ast) {
50
+ const files_with_name = entry.files.map((file) => ({
51
+ path: file,
52
+ name: path.basename(file) // <-- I'm using Node's path module here so that this schema works without requirements on Effect's Path module
53
+ }))
54
+
55
+ // check for duplicate file names
56
+ const nameMap = new Map<string, string[]>()
57
+ for (const { name, path: filePath } of files_with_name) {
58
+ if (!nameMap.has(name)) {
59
+ nameMap.set(name, [])
60
+ }
61
+ nameMap.get(name)!.push(filePath)
62
+ }
63
+
64
+ // find duplicates and collect all collisions
65
+ const collisions: ParseResult.ParseIssue[] = []
66
+ for (const [fileName, paths] of nameMap.entries()) {
67
+ if (paths.length > 1) {
68
+ collisions.push(
69
+ new ParseResult.Type(
70
+ ast,
71
+ paths,
72
+ `Duplicate file name detected: "${fileName}". Colliding paths: ${paths.join(", ")}`
73
+ )
74
+ )
75
+ }
76
+ }
77
+
78
+ // if there are any collisions, fail with all of them
79
+ if (Array.isNonEmptyArray(collisions)) {
80
+ return yield* Effect.fail(
81
+ new ParseResult.Composite(
82
+ ast,
83
+ entry.files,
84
+ collisions
85
+ )
86
+ )
87
+ }
88
+
89
+ return yield* Effect.succeed({
90
+ ...entry,
91
+ files_with_name
92
+ })
93
+ }),
94
+ encode: (({ files_with_name, ...entry }) => ParseResult.succeed(entry))
95
+ }) {}
96
+
97
+ export class GistYAML extends Schema.Class<GistYAML>("GistYAML")({
98
+ gists: Schema.Record({
99
+ key: Schema.String,
100
+ value: GistEntryDecoded
101
+ }),
102
+ settings: Schema.Struct({
103
+ token_env: Schema.String,
104
+ base_directory: Schema.String
105
+ })
106
+ }) {}
107
+
108
+ export class GistCacheEntry extends Schema.Class<GistCacheEntry>("GistCacheEntry")({
109
+ name: Schema.String,
110
+ id: Schema.String
111
+ }) {}
112
+
113
+ export const GistCacheEntries = Schema.Array(GistCacheEntry)
114
+ export interface GistCacheEntries extends Schema.Schema.Type<typeof GistCacheEntries> {}
115
+
116
+ /**
117
+ * Gist cache mapping YAML configuration names to GitHub gist IDs.
118
+ *
119
+ * Since GitHub gists don't have user-defined names, we maintain a cache
120
+ * that maps the human-readable names from our YAML config to actual gist IDs.
121
+ * This allows us to:
122
+ * - Update existing gists instead of creating duplicates
123
+ * - Clean up obsolete entries when gists are removed from config
124
+ * - Persist the name->ID mapping across CLI runs
125
+ *
126
+ * The cache itself is stored as a secret GitHub gist for persistence.
127
+ */
128
+ export class GistCache {
129
+ entries: GistCacheEntries
130
+ gist_id: string
131
+
132
+ constructor({ entries, gist_id }: { entries: GistCacheEntries; gist_id: string }) {
133
+ this.entries = entries
134
+ this.gist_id = gist_id
135
+ }
136
+ }
137
+
138
+ //
139
+ //
140
+ // Errors
141
+ //
142
+ class GistCacheNotFound extends Data.TaggedError("GistCacheNotFound")<{
143
+ readonly reason: string
144
+ }> {}
145
+
146
+ class GistYAMLError extends Data.TaggedError("GistYAMLError")<{
147
+ readonly reason: string
148
+ }> {}
149
+
150
+ //
151
+ //
152
+ // Services
153
+ //
154
+
155
+ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
156
+ dependencies: [RunCommandService.Default],
157
+ effect: Effect.gen(function*() {
158
+ const CACHE_GIST_DESCRIPTION = "GIST_CACHE_DO_NOT_EDIT_effa_cli_internal"
159
+ const { runGetExitCode, runGetString } = yield* RunCommandService
160
+
161
+ // the client cannot recover from PlatformErrors, so we convert failures into defects to clean up the signatures
162
+ const runGetExitCodeSuppressed = (...args: Parameters<typeof runGetExitCode>) => {
163
+ return runGetExitCode(...args).pipe(
164
+ Effect.catchAll((e) => Effect.dieMessage(`Command failed: ${args.join(" ")}\nError: ${e.message}`)),
165
+ Effect.asVoid
166
+ )
167
+ }
168
+
169
+ // the client cannot recover from PlatformErrors, so we convert failures into defects to clean up the signatures
170
+ const runGetStringSuppressed = (...args: Parameters<typeof runGetString>) => {
171
+ return runGetString(...args).pipe(
172
+ Effect.catchAll((e) => Effect.dieMessage(`Command failed: ${args.join(" ")}\nError: ${e.message}`))
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Extracts the Gist ID from a given GitHub Gist URL: https://gist.github.com/user/ID
178
+ * @param url - The full URL of the GitHub Gist.
179
+ * @returns An Option containing the Gist ID if extraction is successful, otherwise None.
180
+ */
181
+ function extractGistIdFromUrl(url: string) {
182
+ const gist_id = url.trim().split("/").pop()
183
+ return gist_id && gist_id.length > 0 ? Option.some(gist_id) : Option.none()
184
+ }
185
+
186
+ const loadGistCache = Effect
187
+ .fn("effa-cli.gist.loadGistCache")(
188
+ function*() {
189
+ // search for existing cache gist
190
+ const output = yield* runGetStringSuppressed(`gh gist list --filter "${CACHE_GIST_DESCRIPTION}"`)
191
+ .pipe(Effect.orElse(() => Effect.succeed("")))
192
+
193
+ const lines = output.trim().split("\n").filter((line: string) => line.trim())
194
+
195
+ // extract first gist ID (should be our cache gist)
196
+ const firstLine = lines[0]
197
+ if (!firstLine) {
198
+ return yield* new GistCacheNotFound({ reason: "Empty gist list output" })
199
+ }
200
+
201
+ const parts = firstLine.split(/\s+/)
202
+ const gist_id = parts[0]?.trim()
203
+
204
+ if (!gist_id) {
205
+ return yield* new GistCacheNotFound({ reason: "No gist ID found in output" })
206
+ } else {
207
+ yield* Effect.logInfo(`Found existing cache gist with ID ${gist_id}`)
208
+ }
209
+
210
+ // read cache gist content
211
+ const cacheContent = yield* runGetStringSuppressed(`gh gist view ${gist_id}`)
212
+ .pipe(Effect.orElse(() => Effect.succeed("")))
213
+
214
+ const entries = yield* pipe(
215
+ cacheContent.split(CACHE_GIST_DESCRIPTION)[1]?.trim(),
216
+ pipe(Schema.parseJson(GistCacheEntries), Schema.decodeUnknown),
217
+ Effect.orElse(() => new GistCacheNotFound({ reason: "Failed to parse cache JSON" }))
218
+ )
219
+
220
+ return { entries, gist_id }
221
+ },
222
+ Effect.catchTag("GistCacheNotFound", () =>
223
+ Effect.gen(function*() {
224
+ // cache doesn't exist, create it
225
+ yield* Effect.logInfo("Cache gist not found, creating new cache...")
226
+
227
+ const cacheJson = yield* pipe(
228
+ [],
229
+ pipe(Schema.parseJson(GistCacheEntries), Schema.encodeUnknown),
230
+ // cannot recover from parse errors in any case, better to die here instead of cluttering the signature
231
+ Effect.orDie
232
+ )
233
+
234
+ const gistUrl = yield* runGetStringSuppressed(
235
+ `echo '${cacheJson}' | gh gist create --desc="${CACHE_GIST_DESCRIPTION}" -`
236
+ )
237
+
238
+ const gist_id = yield* pipe(
239
+ gistUrl,
240
+ extractGistIdFromUrl,
241
+ Option.match({
242
+ onNone: () => Effect.dieMessage(`Could not extract cache's gist ID from URL: ${gistUrl}`),
243
+ onSome: (id) =>
244
+ Effect.succeed(id).pipe(Effect.tap(Effect.logInfo(`Created new cache gist with ID ${id}`)))
245
+ })
246
+ )
247
+
248
+ return { entries: [], gist_id }
249
+ })),
250
+ Effect.map(({ entries, gist_id }) => new GistCache({ entries, gist_id }))
251
+ )
252
+
253
+ const saveGistCache = Effect.fn("effa-cli.gist.saveGistCache")(
254
+ function*(cache: GistCache) {
255
+ const cacheJson = yield* pipe(
256
+ cache.entries,
257
+ pipe(Schema.parseJson(GistCacheEntries), Schema.encodeUnknown),
258
+ // cannot recover from parse errors in any case, better to die here instead of cluttering the signature
259
+ Effect.orDie
260
+ )
261
+
262
+ yield* runGetExitCodeSuppressed(`echo '${cacheJson}' | gh gist edit ${cache.gist_id} -`)
263
+ }
264
+ )
265
+
266
+ const createGistWithFiles = Effect.fn("GHGistService.createGistWithFiles")(
267
+ function*({ cache, description, files, gist_name, is_public }: {
268
+ gist_name: string
269
+ description: string
270
+ files: string[]
271
+ is_public: boolean
272
+ cache: GistCache
273
+ }) {
274
+ yield* Effect.logInfo(`Creating gist ${gist_name} with ${files.length} file(s)`)
275
+
276
+ const ghCommand = [
277
+ "gh",
278
+ "gist",
279
+ "create",
280
+ `--desc="${description}"`,
281
+ is_public ? "--public" : "",
282
+ ...files.map((filePath) => `"${filePath}"`)
283
+ ]
284
+ .filter((x) => !!x)
285
+ .join(" ")
286
+
287
+ // create and capture the created gist URL
288
+ const gistUrl = yield* runGetStringSuppressed(ghCommand)
289
+
290
+ // extract ID from URL
291
+ return yield* pipe(
292
+ gistUrl,
293
+ extractGistIdFromUrl,
294
+ Option.match({
295
+ onNone: () => Effect.dieMessage(`Failed to extract gist ID from URL: ${gistUrl}`),
296
+ onSome: (id) =>
297
+ Effect
298
+ .succeed(
299
+ new GistCache({
300
+ gist_id: cache.gist_id,
301
+ entries: [...cache.entries, { name: gist_name, id }]
302
+ })
303
+ )
304
+ .pipe(Effect.tap(Effect.logInfo(`Created gist with ID ${id}`)))
305
+ })
306
+ )
307
+ }
308
+ )
309
+
310
+ const getGistFileNames = Effect.fn("getGistFileNames")(
311
+ function*({ gist_id, gist_name }: {
312
+ gist_id: string
313
+ gist_name: string
314
+ }) {
315
+ yield* Effect.logInfo(`Retrieving file names from gist ${gist_name} with ID ${gist_id}`)
316
+ const output = yield* runGetStringSuppressed(`gh gist view ${gist_id} --files`)
317
+ return output
318
+ .trim()
319
+ .split("\n")
320
+ .filter((line: string) => line.trim())
321
+ }
322
+ )
323
+
324
+ const removeFileFromGist = Effect.fn("removeFileFromGist")(
325
+ function*({ file_name, gist_id, gist_name }: {
326
+ gist_id: string
327
+ gist_name: string
328
+ file_name: string
329
+ }) {
330
+ yield* Effect.logInfo(`Removing file ${file_name} from gist ${gist_name}`)
331
+ return yield* runGetExitCodeSuppressed(`gh gist edit ${gist_id} --remove "${file_name}"`)
332
+ }
333
+ )
334
+
335
+ const updateFileOfGist = Effect.fn("updateFileOfGist")(
336
+ function*({ file_name, file_path, gist_id, gist_name }: {
337
+ gist_id: string
338
+ gist_name: string
339
+ file_name: string
340
+ file_path: string
341
+ }) {
342
+ yield* Effect.logInfo(`Updating file ${file_name} located at ${file_path} of gist ${gist_name}`)
343
+ const editCommand = [
344
+ "gh",
345
+ "gist",
346
+ "edit",
347
+ gist_id,
348
+ "-f",
349
+ file_name,
350
+ `"${file_path}"`
351
+ ]
352
+ .join(" ")
353
+
354
+ return yield* runGetExitCodeSuppressed(editCommand)
355
+ }
356
+ )
357
+
358
+ const addFileToGist = Effect.fn("addFileToGist")(
359
+ function*({ file_path, gist_id, gist_name }: {
360
+ gist_id: string
361
+ gist_name: string
362
+ file_path: string
363
+ }) {
364
+ yield* Effect.logInfo(`Adding file ${file_path} to gist ${gist_name}`)
365
+ const editCommand = [
366
+ "gh",
367
+ "gist",
368
+ "edit",
369
+ gist_id,
370
+ "-a",
371
+ `"${file_path}"`
372
+ ]
373
+ .join(" ")
374
+
375
+ return yield* runGetExitCodeSuppressed(editCommand)
376
+ }
377
+ )
378
+
379
+ return {
380
+ /**
381
+ * Loads the gist cache from GitHub, containing mappings of YAML configuration names to gist IDs.
382
+ * If no cache exists, creates a new empty cache gist.
383
+ *
384
+ * @returns An Effect that yields a GistCache containing the loaded cache entries and cache gist ID
385
+ */
386
+ loadGistCache,
387
+
388
+ /**
389
+ * Saves the current gist cache state to the GitHub cache gist.
390
+ * Updates the existing cache gist with the current mappings of names to gist IDs.
391
+ *
392
+ * @param cache - The GistCache instance to save
393
+ * @returns An Effect that succeeds when the cache is successfully saved
394
+ */
395
+ saveGistCache,
396
+
397
+ /**
398
+ * Creates a new GitHub gist with the specified files and updates the local cache.
399
+ * Generates a GitHub CLI command to create the gist and extracts the resulting gist ID.
400
+ *
401
+ * @param cache - The current GistCache instance
402
+ * @param name - The human-readable name for this gist (used in cache mapping)
403
+ * @param description - The description for the GitHub gist
404
+ * @param files - Array of file paths to include in the gist
405
+ * @param is_public - Whether the gist should be public or private
406
+ * @returns An Effect that yields an updated GistCache with the new gist entry
407
+ */
408
+ createGistWithFiles,
409
+
410
+ /**
411
+ * Retrieves file names from a GitHub gist.
412
+ * Fetches the list of files contained in the specified gist.
413
+ *
414
+ * @param gist_id - The GitHub gist ID to retrieve file names from
415
+ * @param gist_name - The human-readable name of the gist (for logging purposes)
416
+ * @returns An Effect that yields an array of file names
417
+ */
418
+ getGistFileNames,
419
+
420
+ /**
421
+ * Removes a file from a specified GitHub gist.
422
+ * @param gist_id - The ID of the gist to modify
423
+ * @param gist_name - The human-readable name of the gist (for logging purposes)
424
+ * @param file_name - The name of the file to remove from the gist
425
+ * @returns An Effect that succeeds when the file is removed
426
+ */
427
+ removeFileFromGist,
428
+
429
+ /**
430
+ * Updates a file in a specified GitHub gist.
431
+ * @param gist_id - The ID of the gist to modify
432
+ * @param gist_name - The human-readable name of the gist (for logging purposes)
433
+ * @param file_name - The name of the file to remove from the gist
434
+ * @param file_path - The local path of the file to update in the gist
435
+ * @returns An Effect that succeeds when the file is updated
436
+ */
437
+ updateFileOfGist,
438
+
439
+ /**
440
+ * Adds a new file to a specified GitHub gist.
441
+ * @param gist_id - The ID of the gist to modify
442
+ * @param gist_name - The human-readable name of the gist (for logging purposes)
443
+ * @param file_path - The local path of the file to add to the gist
444
+ * @returns An Effect that succeeds when the file is added
445
+ */
446
+ addFileToGist
447
+ }
448
+ })
449
+ }) {}
450
+
451
+ // @effect-diagnostics-next-line missingEffectServiceDependency:off
452
+ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
453
+ accessors: true,
454
+ dependencies: [GHGistService.Default],
455
+ effect: Effect.gen(function*() {
456
+ const GH = yield* GHGistService
457
+
458
+ // I prefer to provide these two only once during the main CLI pipeline setup
459
+ const fs = yield* FileSystem.FileSystem
460
+ const path = yield* Path.Path
461
+
462
+ return {
463
+ handler: Effect.fn("effa-cli.gist.GistHandler")(function*({ YAMLPath }: { YAMLPath: string }) {
464
+ yield* Effect.logInfo(`Reading configuration from ${YAMLPath}`)
465
+
466
+ const configExists = yield* fs.exists(YAMLPath)
467
+ if (!configExists) {
468
+ return yield* Effect.fail(new Error(`Configuration file not found: ${YAMLPath}`))
469
+ }
470
+
471
+ const config = yield* pipe(
472
+ YAMLPath,
473
+ fs.readFileString,
474
+ Effect.andThen((content) =>
475
+ Effect.try({
476
+ try: () => yaml.load(content),
477
+ catch(error) {
478
+ return new GistYAMLError({ reason: `Failed to parse YAML: ${(error as Error).message}` })
479
+ }
480
+ })
481
+ ),
482
+ Effect.andThen(Schema.decodeUnknown(GistYAML))
483
+ )
484
+
485
+ // load GitHub token securely from environment variable
486
+ const redactedToken = yield* Config.redacted(config.settings.token_env)
487
+
488
+ yield* Effect.logInfo(`Using GitHub token from environment variable: ${config.settings.token_env}`)
489
+ yield* Effect.logInfo(`Token loaded: ${redactedToken}`) // this will show <redacted> in logs
490
+
491
+ const cache = yield* SynchronizedRef.make<GistCache>(yield* GH.loadGistCache())
492
+
493
+ // handle each gist entry in the configuration
494
+ for (const [name, gistConfig] of Object.entries(config.gists)) {
495
+ const { description, files_with_name, public: is_public } = gistConfig
496
+
497
+ yield* Effect.logInfo(`Processing gist ${name}`)
498
+
499
+ const filesOnDiskWithFullPath = yield* Effect
500
+ .all(
501
+ files_with_name.map((f) =>
502
+ Effect.gen(function*() {
503
+ const fullPath = path.join(config.settings.base_directory, f.path)
504
+ const fileExists = yield* fs.exists(fullPath)
505
+
506
+ if (!fileExists) {
507
+ yield* Effect.logWarning(`File not found: ${fullPath}, skipping...`)
508
+ return Option.none()
509
+ }
510
+
511
+ return Option.some({
512
+ path: fullPath,
513
+ name: f.name
514
+ })
515
+ })
516
+ ),
517
+ {
518
+ concurrency: "unbounded"
519
+ }
520
+ )
521
+ .pipe(Effect.map(Array.getSomes))
522
+
523
+ const gistFromCache = (yield* SynchronizedRef.get(cache)).entries.find((_) => _.name === name)
524
+
525
+ // if the gist's name exists in cache, update the existing gist
526
+ // otherwise, create a new gist and update the local cache
527
+ if (gistFromCache) {
528
+ yield* Effect.logInfo(`Updating existing gist ${gistFromCache.name} with ID ${gistFromCache.id}`)
529
+
530
+ // get current files in the gist to detect removed files
531
+ const gistFileNames = new Set(
532
+ yield* GH.getGistFileNames({
533
+ gist_id: gistFromCache.id,
534
+ gist_name: gistFromCache.name
535
+ })
536
+ )
537
+
538
+ const expectedFiles = new Set(filesOnDiskWithFullPath.map(({ name }) => name))
539
+
540
+ // remove files that are no longer in YAML configuration
541
+ for (const gf of gistFileNames) {
542
+ if (!expectedFiles.has(gf)) {
543
+ yield* GH.removeFileFromGist({
544
+ gist_id: gistFromCache.id,
545
+ gist_name: gistFromCache.name,
546
+ file_name: gf
547
+ })
548
+ }
549
+ }
550
+
551
+ // update/add files from configuration
552
+ for (const f of filesOnDiskWithFullPath) {
553
+ if (gistFileNames.has(f.name)) {
554
+ yield* GH.updateFileOfGist({
555
+ gist_id: gistFromCache.id,
556
+ gist_name: gistFromCache.name,
557
+ file_name: f.name,
558
+ file_path: f.path
559
+ })
560
+ } else {
561
+ yield* GH.addFileToGist({
562
+ gist_id: gistFromCache.id,
563
+ gist_name: gistFromCache.name,
564
+ file_path: f.path
565
+ })
566
+ }
567
+ }
568
+ } else {
569
+ if (filesOnDiskWithFullPath.length !== 0) {
570
+ yield* SynchronizedRef.getAndUpdateEffect(cache, (cache) => {
571
+ return GH.createGistWithFiles({
572
+ gist_name: name,
573
+ description,
574
+ is_public,
575
+ cache,
576
+ files: filesOnDiskWithFullPath.map((f) => f.path)
577
+ })
578
+ })
579
+ } else {
580
+ yield* Effect.logWarning(`No valid files found for gist ${name}, skipping creation...`)
581
+ }
582
+ }
583
+
584
+ // here the local cache has been updated, but not yet saved to GitHub
585
+ // we still want to remove gists from cache that are no longer in the configuration
586
+
587
+ const configGistNames = new Set(Object.entries(config.gists).map(([name]) => name))
588
+
589
+ const newCache = yield* SynchronizedRef.updateAndGetEffect(
590
+ cache,
591
+ Effect.fnUntraced(function*(cache) {
592
+ const newEntries = [...cache.entries]
593
+
594
+ for (let i = newEntries.length - 1; i >= 0; i--) {
595
+ const cacheEntry = newEntries[i]
596
+ if (cacheEntry && !configGistNames.has(cacheEntry.name)) {
597
+ yield* Effect.logInfo(
598
+ `Obsolete gist ${cacheEntry.name} with ID ${cacheEntry.id}) will be removed from cache`
599
+ )
600
+ newEntries.splice(i, 1)
601
+ }
602
+ }
603
+
604
+ return { ...cache, entries: newEntries }
605
+ })
606
+ )
607
+
608
+ yield* GH.saveGistCache(newCache)
609
+
610
+ yield* Effect.logInfo("Gist operations completed")
611
+ }
612
+ })
613
+ }
614
+ })
615
+ }) {}