@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.
- package/CHANGELOG.md +12 -0
- package/dist/gist.d.ts +231 -0
- package/dist/gist.d.ts.map +1 -0
- package/dist/gist.js +447 -0
- package/dist/index.js +38 -65
- package/dist/os-command.d.ts +18 -0
- package/dist/os-command.d.ts.map +1 -0
- package/dist/os-command.js +47 -0
- package/package.json +11 -17
- package/src/gist.ts +615 -0
- package/src/index.ts +56 -73
- package/src/os-command.ts +63 -0
- package/test.gists.yaml +20 -0
- package/dist/link.d.ts +0 -2
- package/dist/link.d.ts.map +0 -1
- package/dist/link.js +0 -17
- package/dist/old.d.ts +0 -2
- package/dist/old.d.ts.map +0 -1
- package/dist/old.js +0 -246
- package/dist/sync.d.ts +0 -2
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js +0 -16
- package/dist/unlink.d.ts +0 -2
- package/dist/unlink.d.ts.map +0 -1
- package/dist/unlink.js +0 -13
- package/src/link.ts +0 -20
- package/src/old.ts +0 -283
- package/src/sync.ts +0 -17
- package/src/unlink.ts +0 -14
- package/vitest.config.ts.timestamp-1709838404819-f2fb28517168c.mjs +0 -33
- package/vitest.config.ts.timestamp-1709838418683-9c399c96f9d78.mjs +0 -33
- package/vitest.config.ts.timestamp-1709838649058-0e8f9431c893d.mjs +0 -33
- package/vitest.config.ts.timestamp-1711724061889-4985ba59def8.mjs +0 -37
- package/vitest.config.ts.timestamp-1711743471019-3c5e0c6ca2188.mjs +0 -37
- package/vitest.config.ts.timestamp-1711743489536-5ca18d3f67759.mjs +0 -37
- package/vitest.config.ts.timestamp-1711743593444-e40a8dcd4fc31.mjs +0 -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
|
+
}) {}
|