@effect-app/cli 1.23.1 → 1.23.3

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/index.ts CHANGED
@@ -1,283 +1,691 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- /* eslint-disable @typescript-eslint/no-unsafe-argument */
3
- import cp from "child_process"
4
- import fs from "fs"
5
- import w from "node-watch"
6
- import path from "path"
7
- import readline from "readline/promises"
8
- import { sync } from "./sync.js"
9
-
10
- function askQuestion(query: string) {
11
- const rl = readline.createInterface({
12
- input: process.stdin,
13
- output: process.stdout
14
- })
15
-
16
- return rl.question(query)
17
- }
18
-
19
- const _cmd = process.argv[2]
20
- const supportedCommands = [
21
- "watch",
22
- "index",
23
- "index-multi",
24
- "packagejson",
25
- "packagejson-target",
26
- "packagejson-packages",
27
- "link",
28
- "unlink",
29
- "sync",
30
- "ncu:effect",
31
- "ncu:effect-app"
32
- ] as const
33
- if (
34
- !supportedCommands.includes(_cmd as any)
35
- ) {
36
- console.log("unknown command: ", _cmd, "supported commands: ", supportedCommands.join(", "))
37
- process.exit(1)
38
- }
39
-
40
- const cmd = _cmd as typeof supportedCommands[number]
41
-
42
- const debug = process.argv.includes("--debug")
43
-
44
- function touch(path: string) {
45
- const time = new Date()
46
- try {
47
- fs.utimesSync(path, time, time)
48
- } catch (err) {
49
- fs.closeSync(fs.openSync(path, "w"))
50
- }
51
- }
52
-
53
- function* monitorIndexes_(path: string) {
54
- yield monitorChildIndexes(path)
55
- const indexFile = path + "/index.ts"
56
- if (fs.existsSync(indexFile)) {
57
- yield monitorRootIndexes(path, indexFile)
58
- }
59
- }
60
-
61
- function monitorIndexes(path: string) {
62
- return [...monitorIndexes_(path)]
63
- }
64
-
65
- function monitorChildIndexes(path: string) {
66
- return w.default(path, { recursive: true }, (evt, path) => {
67
- const pathParts = path.split("/")
68
- const isController = pathParts[pathParts.length - 1]?.toLowerCase().includes(".controllers.")
69
- if (!isController) return
70
-
71
- let i = 1
72
- const r = pathParts.toReversed()
73
- while (i < r.length) {
74
- const files = ["controllers.ts", "routes.ts"]
75
- .map((f) => [...pathParts.slice(0, pathParts.length - i), f].join("/"))
76
- .filter((f) => fs.existsSync(f))
77
- if (files.length) {
1
+ /* eslint-disable no-constant-binary-expression */
2
+ /* eslint-disable no-empty-pattern */
3
+ // import necessary modules from the libraries
4
+ import { Args, Command, Options, Prompt } from "@effect/cli"
5
+ import { Command as NodeCommand, FileSystem, Path } from "@effect/platform"
6
+ import { NodeContext, NodeRuntime } from "@effect/platform-node"
7
+ import { Effect, identity, Stream } from "effect"
8
+ import { ExtractExportMappingsService } from "./extract.js"
9
+ import { packages } from "./shared.js"
10
+
11
+ Effect
12
+ .fn("effa-cli")(function*() {
13
+ const fs = yield* FileSystem.FileSystem
14
+ const path = yield* Path.Path
15
+ const extractExportMappings = yield* ExtractExportMappingsService
16
+
17
+ /**
18
+ * Executes a shell command using Node.js Command API with inherited stdio streams.
19
+ * The command is run through the system shell (/bin/sh) for proper command parsing.
20
+ *
21
+ * @param cmd - The shell command to execute
22
+ * @param cwd - Optional working directory to execute the command in
23
+ * @returns An Effect that succeeds with the exit code or fails with a PlatformError
24
+ */
25
+ const runNodeCommand = (cmd: string, cwd?: string) =>
26
+ NodeCommand
27
+ .make("sh", "-c", cmd)
28
+ .pipe(
29
+ NodeCommand.stdout("inherit"),
30
+ NodeCommand.stderr("inherit"),
31
+ cwd ? NodeCommand.workingDirectory(cwd) : identity,
32
+ NodeCommand.exitCode
33
+ )
34
+
35
+ /**
36
+ * Executes a bash script file using Node.js Command API with inherited stdio streams.
37
+ * The script file is executed directly through the shell (/bin/sh).
38
+ *
39
+ * @param file - The path to the bash script file to execute
40
+ * @param cwd - Optional working directory to execute the script in
41
+ * @returns An Effect that succeeds with the output or fails with a PlatformError
42
+ */
43
+ // const runBashFile = (file: string, cwd?: string) =>
44
+ // NodeCommand
45
+ // .make("sh", file)
46
+ // .pipe(
47
+ // NodeCommand.stdout("inherit"),
48
+ // NodeCommand.stderr("inherit"),
49
+ // cwd ? NodeCommand.workingDirectory(cwd) : identity,
50
+ // NodeCommand.string
51
+ // )
52
+
53
+ /**
54
+ * Creates a file if it doesn't exist or updates the access and modification times of an existing file.
55
+ * This is the effectful equivalent of the Unix `touch` command.
56
+ *
57
+ * @param path - The path to the file to touch
58
+ * @returns An Effect that succeeds with void or fails with a FileSystem error
59
+ */
60
+ const touch = Effect.fn("touch")(function*(path: string) {
61
+ const time = new Date()
62
+
63
+ yield* fs.utimes(path, time, time).pipe(
64
+ Effect.catchTag("SystemError", (err) =>
65
+ err.reason === "NotFound"
66
+ ? fs.writeFileString(path, "")
67
+ : Effect.fail(err))
68
+ )
69
+ })
70
+
71
+ /**
72
+ * Updates effect-app packages to their latest versions using npm-check-updates.
73
+ * Runs both at workspace root and recursively in all workspace packages.
74
+ */
75
+ const updateEffectAppPackages = Effect.fn("effa-cli.ue.updateEffectAppPackages")(function*() {
76
+ const filters = ["effect-app", "@effect-app/*"]
77
+ for (const filter of filters) {
78
+ yield* runNodeCommand(`pnpm exec ncu -u --filter "${filter}"`)
79
+ yield* runNodeCommand(`pnpm -r exec ncu -u --filter "${filter}"`)
80
+ }
81
+ })()
82
+
83
+ /**
84
+ * Updates Effect ecosystem packages to their latest versions using npm-check-updates.
85
+ * Covers core Effect packages, Effect ecosystem packages, and Effect Atom packages.
86
+ * Runs both at workspace root and recursively in all workspace packages.
87
+ */
88
+ const updateEffectPackages = Effect.fn("effa-cli.ue.updateEffectPackages")(function*() {
89
+ const effectFilters = ["effect", "@effect/*", "@effect-atom/*"]
90
+ for (const filter of effectFilters) {
91
+ yield* runNodeCommand(`pnpm exec ncu -u --filter "${filter}"`)
92
+ yield* runNodeCommand(`pnpm -r exec ncu -u --filter "${filter}"`)
93
+ }
94
+ })()
95
+
96
+ /**
97
+ * Links local effect-app packages by adding file resolutions to package.json.
98
+ * Updates the package.json with file: protocol paths pointing to the local effect-app-libs directory,
99
+ * then runs pnpm install to apply the changes.
100
+ *
101
+ * @param effectAppLibsPath - Path to the local effect-app-libs directory
102
+ * @returns An Effect that succeeds when linking is complete
103
+ */
104
+ const linkPackages = Effect.fnUntraced(function*(effectAppLibsPath: string) {
105
+ yield* Effect.log("Linking local effect-app packages...")
106
+
107
+ const packageJsonPath = "./package.json"
108
+ const packageJsonContent = yield* fs.readFileString(packageJsonPath)
109
+ const pj = JSON.parse(packageJsonContent)
110
+
111
+ const resolutions = {
112
+ ...pj.resolutions,
113
+ "@effect-app/eslint-codegen-model": "file:" + effectAppLibsPath + "/packages/eslint-codegen-model",
114
+ "effect-app": "file:" + effectAppLibsPath + "/packages/effect-app",
115
+ "@effect-app/infra": "file:" + effectAppLibsPath + "/packages/infra",
116
+ "@effect-app/vue": "file:" + effectAppLibsPath + "/packages/vue",
117
+ "@effect-app/vue-components": "file:" + effectAppLibsPath + "/packages/vue-components",
118
+ ...packages.reduce((acc, p) => ({ ...acc, [p]: `file:${effectAppLibsPath}/node_modules/${p}` }), {})
119
+ }
120
+
121
+ pj.resolutions = resolutions
122
+
123
+ yield* fs.writeFileString(packageJsonPath, JSON.stringify(pj, null, 2))
124
+ yield* Effect.log("Updated package.json with local file resolutions")
125
+
126
+ yield* runNodeCommand("pnpm i")
127
+
128
+ yield* Effect.log("Successfully linked local packages")
129
+ })
130
+
131
+ /**
132
+ * Unlinks local effect-app packages by removing file resolutions from package.json.
133
+ * Filters out all effect-app related file: protocol resolutions from package.json,
134
+ * then runs pnpm install to restore registry packages.
135
+ *
136
+ * @returns An Effect that succeeds when unlinking is complete
137
+ */
138
+ const unlinkPackages = Effect.fnUntraced(function*() {
139
+ yield* Effect.log("Unlinking local effect-app packages...")
140
+
141
+ const packageJsonPath = "./package.json"
142
+ const packageJsonContent = yield* fs.readFileString(packageJsonPath)
143
+ const pj = JSON.parse(packageJsonContent)
144
+
145
+ const filteredResolutions = Object.entries(pj.resolutions as Record<string, string>).reduce(
146
+ (acc, [k, v]) => {
147
+ if (k.startsWith("@effect-app/") || k === "effect-app" || packages.includes(k)) return acc
148
+ acc[k] = v
149
+ return acc
150
+ },
151
+ {} as Record<string, string>
152
+ )
153
+
154
+ pj.resolutions = filteredResolutions
155
+
156
+ yield* fs.writeFileString(packageJsonPath, JSON.stringify(pj, null, 2))
157
+ yield* Effect.log("Removed effect-app file resolutions from package.json")
158
+
159
+ yield* runNodeCommand("pnpm i")
160
+ yield* Effect.log("Successfully unlinked local packages")
161
+ })()
162
+
163
+ /**
164
+ * Monitors controller files for changes and runs eslint on related controllers.ts/routes.ts files.
165
+ * Watches for .controllers. files and triggers eslint fixes on parent directory's controller files.
166
+ *
167
+ * @param watchPath - The path to watch for controller changes
168
+ * @param debug - Whether to enable debug logging
169
+ * @returns An Effect that sets up controller file monitoring
170
+ */
171
+ const monitorChildIndexes = Effect.fn("effa-cli.index-multi.monitorChildIndexes")(
172
+ function*(watchPath: string, debug: boolean) {
173
+ const fileSystem = yield* FileSystem.FileSystem
174
+
175
+ if (debug) {
176
+ yield* Effect.logInfo(`Starting controller monitoring for: ${watchPath}`)
177
+ }
178
+
179
+ const watchStream = fileSystem.watch(watchPath, { recursive: true })
180
+
181
+ yield* watchStream
182
+ .pipe(
183
+ Stream.runForEach(
184
+ Effect.fn("effa-cli.monitorChildIndexes.handleEvent")(function*(event) {
185
+ const pathParts = event.path.split("/")
186
+ const fileName = pathParts[pathParts.length - 1]
187
+ const isController = fileName?.toLowerCase().includes(".controllers.")
188
+
189
+ if (!isController) return
190
+
191
+ let i = 1
192
+ const reversedParts = pathParts.toReversed()
193
+
194
+ while (i < reversedParts.length) {
195
+ const candidateFiles = ["controllers.ts", "routes.ts"]
196
+ .map((f) => [...pathParts.slice(0, pathParts.length - i), f].join("/"))
197
+
198
+ const existingFiles: string[] = []
199
+ for (const file of candidateFiles) {
200
+ const exists = yield* fileSystem.exists(file)
201
+ if (exists) existingFiles.push(file)
202
+ }
203
+
204
+ if (existingFiles.length > 0) {
205
+ if (debug) {
206
+ yield* Effect.logInfo(
207
+ `Controller change detected: ${event.path}, fixing files: ${existingFiles.join(", ")}`
208
+ )
209
+ }
210
+
211
+ const eslintArgs = existingFiles.map((f) => `"../${f}"`).join(" ")
212
+ yield* runNodeCommand(`cd api && pnpm eslint --fix ${eslintArgs}`)
213
+ break
214
+ }
215
+ i++
216
+ }
217
+ })
218
+ )
219
+ )
220
+ .pipe(
221
+ Effect.forkScoped
222
+ )
223
+ }
224
+ )
225
+
226
+ /**
227
+ * Monitors a directory for changes and runs eslint on the specified index file.
228
+ * Triggers eslint fixes when any file in the directory changes (except the index file itself).
229
+ *
230
+ * @param watchPath - The path to watch for changes
231
+ * @param indexFile - The index file to run eslint on when changes occur
232
+ * @param debug - Whether to enable debug logging
233
+ * @returns An Effect that sets up root index monitoring
234
+ */
235
+ const monitorRootIndexes = Effect.fn("effa-cli.index-multi.monitorRootIndexes")(
236
+ function*(watchPath: string, indexFile: string, debug: boolean) {
237
+ const fileSystem = yield* FileSystem.FileSystem
238
+
78
239
  if (debug) {
79
- console.log("change!", evt, path, files)
240
+ yield* Effect.logInfo(`Starting root index monitoring for: ${watchPath} -> ${indexFile}`)
80
241
  }
81
- cp.execSync(`cd api && pnpm eslint --fix ${files.map((_) => `"../${_}"`).join(" ")}`)
82
- break
242
+
243
+ const watchStream = fileSystem.watch(watchPath)
244
+
245
+ yield* watchStream
246
+ .pipe(
247
+ Stream.runForEach(
248
+ Effect.fn("effa-cli.index-multi.monitorRootIndexes.handleEvent")(function*(event) {
249
+ if (event.path.endsWith(indexFile)) return
250
+
251
+ if (debug) {
252
+ yield* Effect.logInfo(`Root change detected: ${event.path}, fixing: ${indexFile}`)
253
+ }
254
+
255
+ yield* runNodeCommand(`pnpm eslint --fix "${indexFile}"`)
256
+ })
257
+ )
258
+ )
259
+ .pipe(
260
+ Effect.forkScoped
261
+ )
83
262
  }
84
- i++
85
- }
86
- })
87
- }
88
-
89
- function monitorRootIndexes(path: string, indexFile: string) {
90
- return w.default(path, (_, path) => {
91
- if (path.endsWith(indexFile)) return
92
- // const dirName = pathParts[pathParts.length - 2]!
93
- // console.log("change!", evt, path, dirName, indexFile)
94
- cp.execSync(`pnpm eslint --fix "${indexFile}"`)
95
- })
96
- }
97
-
98
- // TODO: cache, don't do things when it already existed before, so only file is updated, not created.
99
-
100
- const startDir = process.cwd()
101
-
102
- function packagejson(p: string, levels = 0) {
103
- const curDir = process.cwd()
104
- let r = ""
105
- // TODO: no chdir!
106
- try {
107
- process.chdir(path.resolve(startDir, p))
108
- r = cp.execSync(`sh ${p === "." ? "../.." : startDir}/scripts/extract.sh`, { encoding: "utf-8" })
109
- } finally {
110
- process.chdir(curDir)
111
- }
112
-
113
- const s = r.split("\n").sort((a, b) => a < b ? -1 : 1).join("\n")
114
- const items = JSON.parse(`{${s.substring(0, s.length - 1)} }`) as Record<string, unknown>
115
-
116
- const pkg = JSON.parse(fs.readFileSync(p + "/package.json", "utf-8"))
117
- const t = levels
118
- ? Object
119
- .keys(items)
120
- .filter((_) => _.split("/").length <= (levels + 1 /* `./` */))
121
- .reduce((prev, cur) => {
122
- prev[cur] = items[cur]
123
- return prev
124
- }, {} as Record<string, unknown>)
125
- : items
126
-
127
- const exps = {
128
- ...(fs.existsSync(p + "/src/index.ts")
129
- ? {
130
- ".": {
131
- "types": "./dist/index.d.ts",
132
- "default": "./dist/index.js"
263
+ )
264
+
265
+ /**
266
+ * Sets up comprehensive index monitoring for a given path.
267
+ * Combines both child controller monitoring and root index monitoring.
268
+ *
269
+ * @param watchPath - The path to monitor
270
+ * @param debug - Whether to enable debug logging
271
+ * @returns An Effect that sets up all index monitoring for the path
272
+ */
273
+ const monitorIndexes = Effect.fn("effa-cli.index-multi.monitorIndexes")(
274
+ function*(watchPath: string, debug: boolean) {
275
+ const fileSystem = yield* FileSystem.FileSystem
276
+
277
+ if (debug) {
278
+ yield* Effect.logInfo(`Setting up index monitoring for path: ${watchPath}`)
279
+ }
280
+
281
+ const indexFile = watchPath + "/index.ts"
282
+
283
+ const monitors = [monitorChildIndexes(watchPath, debug)]
284
+
285
+ if (yield* fileSystem.exists(indexFile)) {
286
+ monitors.push(monitorRootIndexes(watchPath, indexFile, debug))
287
+ } else {
288
+ yield* Effect.logInfo(`Index file ${indexFile} does not exist`)
289
+ }
290
+
291
+ if (debug) {
292
+ yield* Effect.logInfo(`Starting ${monitors.length} monitor(s) for ${watchPath}`)
133
293
  }
294
+
295
+ yield* Effect.all(monitors, { concurrency: monitors.length })
134
296
  }
135
- : undefined),
136
- ...Object
137
- .keys(t)
138
- .reduce((prev, cur) => {
139
- if (cur !== "./index" && !cur.includes("/internal/")) prev[cur] = t[cur]
140
- return prev
141
- }, {} as Record<string, unknown>)
142
- // ...pkg.name === "effect-app" ? {
143
- // "./types/awesome": { "types": "./types/awesome.d.ts" }
144
- // } : {},
145
- }
146
- pkg.exports = exps
147
- fs.writeFileSync(p + "/package.json", JSON.stringify(pkg, null, 2))
148
- }
149
-
150
- function monitorPackagejson(path: string, levels = 0) {
151
- packagejson(path, levels)
152
- w.default(path + "/src", { recursive: true }, (_, __) => {
153
- packagejson(path, levels)
154
- })
155
- }
156
-
157
- function updateEffectAppPackages() {
158
- const filters = ["effect-app", "@effect-app/*"]
159
- for (const filter of filters) {
160
- cp.execSync(`pnpm exec ncu -u --filter "${filter}"`, { stdio: "inherit" })
161
- cp.execSync(`pnpm -r exec ncu -u --filter "${filter}"`, { stdio: "inherit" })
162
- }
163
- }
164
-
165
- function updateEffectPackages() {
166
- const effectFilters = ["effect", "@effect/*", "@effect-atom/*"]
167
- for (const filter of effectFilters) {
168
- cp.execSync(`pnpm exec ncu -u --filter "${filter}"`, { stdio: "inherit" })
169
- cp.execSync(`pnpm -r exec ncu -u --filter "${filter}"`, { stdio: "inherit" })
170
- }
171
-
172
- updateEffectAppPackages()
173
- }
174
-
175
- ;(async () => {
176
- let cmds = process.argv.slice(3).filter((_) => _ !== "--debug")
177
- switch (cmd) {
178
- case "link":
179
- await import("./link.js")
180
- break
181
- case "unlink":
182
- await import("./unlink.js")
183
- break
184
- case "watch": {
297
+ )
298
+
299
+ /**
300
+ * Watches directories for file changes and updates tsconfig.json and vite.config.ts accordingly.
301
+ * Monitors API resources and models directories for changes using Effect's native file watching.
302
+ *
303
+ * @returns An Effect that sets up file watching streams
304
+ */
305
+ const watcher = Effect.fn("watch")(function*(debug: boolean) {
306
+ yield* Effect.log("Watch API resources and models for changes")
307
+
185
308
  const dirs = ["../api/src/resources", "../api/src/models"]
186
309
  const viteConfigFile = "./vite.config.ts"
187
- const viteConfigExists = fs.existsSync(viteConfigFile)
188
- dirs.forEach((d) => {
189
- if (fs.existsSync(d)) {
190
- const files: string[] = []
191
- w.default(d, { recursive: true }, (t, f) => {
192
- // console.log("change!", d)
193
- touch("./tsconfig.json")
194
- if (viteConfigExists && t === "update" && !files.includes(f)) {
195
- // TODO: only on new files
196
- touch(viteConfigFile)
197
- files.push(f)
198
- }
199
- })
200
- }
201
- })
310
+ const fileSystem = yield* FileSystem.FileSystem
202
311
 
203
- break
204
- }
312
+ const viteConfigExists = yield* fileSystem.exists(viteConfigFile)
205
313
 
206
- case "index-multi": {
207
- ;[
208
- "./api/src"
209
- ]
210
- .filter(
211
- (_) => fs.existsSync(_)
212
- )
213
- .forEach(monitorIndexes)
214
- break
215
- }
216
-
217
- case "index": {
218
- monitorIndexes("./src")
219
- break
220
- }
221
-
222
- case "packagejson": {
223
- monitorPackagejson(".")
224
- break
225
- }
226
-
227
- case "packagejson-target": {
228
- const target = process.argv[3]!
229
- target.split(",").forEach((_) => monitorPackagejson(_, 1))
230
- cmds = process.argv.slice(4)
231
- break
232
- }
233
-
234
- case "packagejson-packages": {
235
- fs
236
- .readdirSync(startDir + "/packages")
237
- .map((_) => startDir + "/packages/" + _)
238
- .filter((_) =>
239
- fs.existsSync(_ + "/package.json")
240
- && fs.existsSync(_ + "/src")
241
- && !_.endsWith("eslint-codegen-model")
242
- && !_.endsWith("vue-components")
243
- )
244
- .forEach((_) => monitorPackagejson(_))
245
- break
246
- }
314
+ if (debug) {
315
+ yield* Effect.logInfo("watcher debug mode is enabled")
316
+ }
247
317
 
248
- case "sync": {
249
- console.log("Sync all snippets?")
318
+ // validate directories and filter out non-existing ones
319
+ const existingDirs: string[] = []
320
+ for (const dir of dirs) {
321
+ const dirExists = yield* fileSystem.exists(dir)
322
+ if (dirExists) {
323
+ existingDirs.push(dir)
324
+ } else {
325
+ yield* Effect.logWarning(`Directory ${dir} does not exist - skipping`)
326
+ }
327
+ }
250
328
 
251
- await askQuestion("Are you sure you want to sync snippets")
252
- await sync()
253
- return process.exit(0)
254
- }
329
+ if (existingDirs.length === 0) {
330
+ return yield* Effect.logWarning("No directories to watch - exiting")
331
+ }
255
332
 
256
- case "ncu:effect": {
257
- console.log("Updating effect & effect-app dependencies...")
333
+ // start watching all existing directories concurrently
334
+ const watchStreams = existingDirs.map((dir) =>
335
+ Effect.gen(function*() {
336
+ if (debug) {
337
+ yield* Effect.logInfo(`Starting to watch directory: ${dir}`)
338
+ }
258
339
 
259
- updateEffectPackages()
340
+ const files: string[] = []
341
+ const watchStream = fileSystem.watch(dir, { recursive: true })
342
+
343
+ yield* watchStream
344
+ .pipe(
345
+ Stream.runForEach(
346
+ Effect.fn("effa-cli.watch.handleEvent")(function*(event) {
347
+ if (debug) {
348
+ yield* Effect.logInfo(`File ${event._tag.toLowerCase()}: ${event.path}`)
349
+ }
350
+
351
+ // touch tsconfig.json on any file change
352
+ yield* touch("./tsconfig.json")
353
+ if (debug) {
354
+ yield* Effect.logInfo("Updated tsconfig.json")
355
+ }
356
+
357
+ // touch vite config only on file updates (not creates/deletes)
358
+ if (
359
+ viteConfigExists
360
+ && event._tag === "Update"
361
+ && !files.includes(event.path)
362
+ ) {
363
+ yield* touch(viteConfigFile)
364
+ if (debug) {
365
+ yield* Effect.logInfo("Updated vite.config.ts")
366
+ }
367
+ files.push(event.path)
368
+ }
369
+ })
370
+ )
371
+ )
372
+ .pipe(
373
+ Effect.forkScoped
374
+ )
375
+
376
+ // also start monitoring indexes in the watched directory
377
+ yield* monitorIndexes(dir, debug)
378
+ })
379
+ )
380
+
381
+ // run all watch streams concurrently
382
+ yield* Effect.all(watchStreams, { concurrency: existingDirs.length })
383
+ })
384
+
385
+ /**
386
+ * Updates a package.json file with generated exports mappings for TypeScript modules.
387
+ * Scans TypeScript source files and creates export entries that map module paths
388
+ * to their compiled JavaScript and TypeScript declaration files.
389
+ *
390
+ * @param startDir - The starting directory path for resolving relative paths
391
+ * @param p - The package directory path to process
392
+ * @param levels - Optional depth limit for export filtering (0 = no limit)
393
+ * @returns An Effect that succeeds when the package.json is updated
394
+ */
395
+ const packagejsonUpdater = Effect.fn("effa-cli.packagejsonUpdater")(
396
+ function*(startDir: string, p: string, levels = 0) {
397
+ yield* Effect.log(`Generating exports for ${p}`)
398
+
399
+ const exportMappings = yield* extractExportMappings(path.resolve(startDir, p))
400
+
401
+ // if exportMappings is empty skip export generation
402
+ if (exportMappings === "") {
403
+ yield* Effect.log(`No src directory found for ${p}, skipping export generation`)
404
+ return
405
+ }
260
406
 
261
- cp.execSync("pnpm i", { stdio: "inherit" })
407
+ const sortedExportEntries = JSON.parse(
408
+ `{ ${exportMappings} }`
409
+ ) as Record<
410
+ string,
411
+ unknown
412
+ >
413
+
414
+ const filteredExportEntries = levels
415
+ ? Object
416
+ .keys(sortedExportEntries)
417
+ // filter exports by directory depth - only include paths up to specified levels deep
418
+ .filter((_) => _.split("/").length <= (levels + 1 /* `./` */))
419
+ .reduce(
420
+ (prev, cur) => ({ ...prev, [cur]: sortedExportEntries[cur] }),
421
+ {} as Record<string, unknown>
422
+ )
423
+ : sortedExportEntries
424
+
425
+ const packageExports = {
426
+ ...((yield* fs.exists(p + "/src/index.ts"))
427
+ && {
428
+ ".": {
429
+ "types": "./dist/index.d.ts",
430
+ "default": "./dist/index.js"
431
+ }
432
+ }),
433
+ ...Object
434
+ .keys(filteredExportEntries)
435
+ .reduce(
436
+ (prev, cur) => ({
437
+ ...prev,
438
+ // exclude index files and internal modules from package exports:
439
+ // - skip "./index" to avoid conflicts with the main "." export
440
+ // - skip "/internal/" paths to keep internal modules private
441
+ ...cur !== "./index" && !cur.includes("/internal/") && { [cur]: filteredExportEntries[cur] }
442
+ }),
443
+ {} as Record<string, unknown>
444
+ )
445
+ }
262
446
 
263
- break
264
- }
447
+ const pkgJson = JSON.parse(yield* fs.readFileString(p + "/package.json", "utf-8"))
448
+ pkgJson.exports = packageExports
265
449
 
266
- case "ncu:effect-app": {
267
- console.log("Updating effect-app dependencies...")
450
+ yield* Effect.log(`Writing updated package.json for ${p}`)
268
451
 
269
- updateEffectAppPackages()
452
+ return yield* fs.writeFileString(
453
+ p + "/package.json",
454
+ JSON.stringify(pkgJson, null, 2)
455
+ )
456
+ }
457
+ )
270
458
 
271
- cp.execSync("pnpm i", { stdio: "inherit" })
459
+ /*
460
+ * CLI
461
+ */
272
462
 
273
- break
274
- }
275
- }
463
+ const EffectAppLibsPath = Args
464
+ .directory({
465
+ exists: "yes",
466
+ name: "effect-app-libs-path"
467
+ })
468
+ .pipe(
469
+ Args.withDefault("../../effect-app/libs"),
470
+ Args.withDescription("Path to the effect-app-libs directory")
471
+ )
472
+
473
+ const link = Command
474
+ .make(
475
+ "link",
476
+ { effectAppLibsPath: EffectAppLibsPath },
477
+ Effect.fn("effa-cli.link")(function*({ effectAppLibsPath }) {
478
+ return yield* linkPackages(effectAppLibsPath)
479
+ })
480
+ )
481
+ .pipe(Command.withDescription("Link local effect-app packages using file resolutions"))
482
+
483
+ const unlink = Command
484
+ .make(
485
+ "unlink",
486
+ {},
487
+ Effect.fn("effa-cli.unlink")(function*({}) {
488
+ return yield* unlinkPackages
489
+ })
490
+ )
491
+ .pipe(Command.withDescription("Remove effect-app file resolutions and restore npm registry packages"))
492
+
493
+ const ue = Command
494
+ .make(
495
+ "ue",
496
+ {},
497
+ Effect.fn("effa-cli.ue")(function*({}) {
498
+ yield* Effect.log("Update effect-app and/or effect packages")
499
+
500
+ const prompted = yield* Prompt.select({
501
+ choices: [
502
+ {
503
+ title: "effect-app",
504
+ description: "Update only effect-app packages",
505
+ value: "effect-app"
506
+ },
507
+ {
508
+ title: "effect",
509
+ description: "Update only effect packages",
510
+ value: "effect"
511
+ },
512
+ {
513
+ title: "both",
514
+ description: "Update both effect-app and effect packages",
515
+ value: "both"
516
+ }
517
+ ],
518
+ message: "Select an option"
519
+ })
276
520
 
277
- if (cmds.length) {
278
- const p = cp.spawn(cmds[0]!, cmds.slice(1), { stdio: "inherit" })
279
- p.on("close", (code) => process.exit(code ?? 0))
280
- p.on("exit", (code) => process.exit(code ?? 0))
281
- p.on("disconnect", () => process.exit(1))
282
- }
283
- })()
521
+ switch (prompted) {
522
+ case "effect-app":
523
+ return yield* updateEffectAppPackages.pipe(
524
+ Effect.andThen(runNodeCommand("pnpm i"))
525
+ )
526
+
527
+ case "effect":
528
+ return yield* updateEffectPackages.pipe(
529
+ Effect.andThen(runNodeCommand("pnpm i"))
530
+ )
531
+ case "both":
532
+ return yield* updateEffectPackages.pipe(
533
+ Effect.andThen(updateEffectAppPackages),
534
+ Effect.andThen(runNodeCommand("pnpm i"))
535
+ )
536
+ }
537
+ })
538
+ )
539
+ .pipe(Command.withDescription("Update effect-app and/or effect packages"))
540
+
541
+ const DebugOption = Options.boolean("debug").pipe(
542
+ Options.withAlias("d"),
543
+ Options.withDescription("Enable debug logging")
544
+ )
545
+
546
+ const watch = Command
547
+ .make(
548
+ "watch",
549
+ { debug: DebugOption },
550
+ Effect.fn("effa-cli.watch")(function*({ debug }) {
551
+ return yield* watcher(debug)
552
+ })
553
+ )
554
+ .pipe(
555
+ Command.withDescription(
556
+ "Watch API resources and models for changes and update tsconfig.json and vite.config.ts accordingly"
557
+ )
558
+ )
559
+
560
+ const indexMulti = Command
561
+ .make(
562
+ "index-multi",
563
+ { debug: DebugOption },
564
+ Effect.fn("effa-cli.index-multi")(function*({ debug }) {
565
+ yield* Effect.log("Starting multi-index monitoring")
566
+
567
+ const dirs = ["./api/src"]
568
+ const fileSystem = yield* FileSystem.FileSystem
569
+
570
+ const existingDirs: string[] = []
571
+ for (const dir of dirs) {
572
+ const dirExists = yield* fileSystem.exists(dir)
573
+ if (dirExists) {
574
+ existingDirs.push(dir)
575
+ } else {
576
+ yield* Effect.logWarning(`Directory ${dir} does not exist - skipping`)
577
+ }
578
+ }
579
+
580
+ if (existingDirs.length === 0) {
581
+ return yield* Effect.logWarning("No directories to monitor - exiting")
582
+ }
583
+
584
+ const monitors = existingDirs.map((dir) => monitorIndexes(dir, debug))
585
+ yield* Effect.all(monitors, { concurrency: monitors.length })
586
+ })
587
+ )
588
+ .pipe(
589
+ Command.withDescription(
590
+ "Monitor multiple directories for index and controller file changes"
591
+ )
592
+ )
593
+
594
+ const packagejson = Command
595
+ .make(
596
+ "packagejson",
597
+ {},
598
+ Effect.fn("effa-cli.packagejson")(function*({}) {
599
+ // https://nodejs.org/api/path.html#pathresolvepaths
600
+ const startDir = path.resolve()
601
+
602
+ return yield* packagejsonUpdater(startDir, ".")
603
+ })
604
+ )
605
+ .pipe(
606
+ Command.withDescription("Generate and update root-level package.json exports mappings for TypeScript modules")
607
+ )
608
+
609
+ const packagejsonPackages = Command
610
+ .make(
611
+ "packagejson-packages",
612
+ {},
613
+ Effect.fn("effa-cli.packagejson-packages")(function*({}) {
614
+ // https://nodejs.org/api/path.html#pathresolvepaths
615
+ const startDir = path.resolve()
616
+
617
+ const packagesDir = path.join(startDir, "packages")
618
+
619
+ const packagesExists = yield* fs.exists(packagesDir)
620
+ if (!packagesExists) {
621
+ return yield* Effect.logWarning("No packages directory found")
622
+ }
623
+
624
+ // get all package directories
625
+ const packageDirs = yield* fs.readDirectory(packagesDir)
626
+
627
+ const validPackages: string[] = []
628
+
629
+ // filter packages that have package.json and src directory
630
+ for (const packageName of packageDirs) {
631
+ const packagePath = path.join(packagesDir, packageName)
632
+ const packageJsonExists = yield* fs.exists(path.join(packagePath, "package.json"))
633
+ const srcExists = yield* fs.exists(path.join(packagePath, "src"))
634
+
635
+ const shouldExclude = false
636
+ || packageName.endsWith("eslint-codegen-model")
637
+ || packageName.endsWith("vue-components")
638
+
639
+ if (packageJsonExists && srcExists && !shouldExclude) {
640
+ validPackages.push(packagePath)
641
+ }
642
+ }
643
+
644
+ if (validPackages.length === 0) {
645
+ return yield* Effect.logWarning("No valid packages found to update")
646
+ }
647
+
648
+ yield* Effect.log(`Found ${validPackages.length} packages to update`)
649
+
650
+ // update each package sequentially
651
+ yield* Effect.all(
652
+ validPackages.map((packagePath) =>
653
+ Effect.gen(function*() {
654
+ const relativePackagePath = path.relative(startDir, packagePath)
655
+ yield* Effect.log(`Updating ${relativePackagePath}`)
656
+ return yield* packagejsonUpdater(startDir, relativePackagePath)
657
+ })
658
+ )
659
+ )
660
+
661
+ yield* Effect.log("All packages updated successfully")
662
+ })
663
+ )
664
+ .pipe(Command.withDescription("Generate and update package.json exports mappings for all packages in monorepo"))
665
+
666
+ // configure CLI
667
+ const cli = Command.run(
668
+ Command
669
+ .make("effa")
670
+ .pipe(Command.withSubcommands([
671
+ ue,
672
+ link,
673
+ unlink,
674
+ watch,
675
+ indexMulti,
676
+ packagejson,
677
+ packagejsonPackages
678
+ ])),
679
+ {
680
+ name: "Effect-App CLI by jfet97 ❤️",
681
+ version: "v1.0.0"
682
+ }
683
+ )
684
+
685
+ return yield* cli(process.argv)
686
+ })()
687
+ .pipe(
688
+ Effect.scoped,
689
+ Effect.provide(NodeContext.layer),
690
+ NodeRuntime.runMain
691
+ )