@graftland/cli 0.3.3 → 0.4.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.
Files changed (54) hide show
  1. package/dist/Application.d.ts +27 -0
  2. package/dist/Application.d.ts.map +1 -0
  3. package/dist/Application.js +27 -0
  4. package/dist/Application.js.map +1 -0
  5. package/dist/Commands.d.ts +34 -0
  6. package/dist/Commands.d.ts.map +1 -0
  7. package/dist/Commands.js +31 -0
  8. package/dist/Commands.js.map +1 -0
  9. package/dist/Manage.d.ts +8 -5
  10. package/dist/Manage.d.ts.map +1 -1
  11. package/dist/Manage.js +60 -2
  12. package/dist/Manage.js.map +1 -1
  13. package/dist/Start.d.ts +2 -1
  14. package/dist/Start.d.ts.map +1 -1
  15. package/dist/Start.js +25 -9
  16. package/dist/Start.js.map +1 -1
  17. package/dist/bin.js +38 -11
  18. package/dist/bin.js.map +1 -1
  19. package/dist/index.d.ts +3 -1
  20. package/dist/index.js +3 -1
  21. package/dist/internal/commandConfig.js +62 -0
  22. package/dist/internal/commandConfig.js.map +1 -0
  23. package/dist/internal/commandHost.js +80 -0
  24. package/dist/internal/commandHost.js.map +1 -0
  25. package/dist/internal/commandRegistry.js +59 -0
  26. package/dist/internal/commandRegistry.js.map +1 -0
  27. package/dist/internal/configSchema.d.ts +25 -4
  28. package/dist/internal/configSchema.d.ts.map +1 -1
  29. package/dist/internal/configSchema.js +6 -34
  30. package/dist/internal/configSchema.js.map +1 -1
  31. package/dist/internal/env.js.map +1 -1
  32. package/dist/internal/importer.js +18 -0
  33. package/dist/internal/importer.js.map +1 -0
  34. package/dist/internal/intercepts.js.map +1 -1
  35. package/dist/internal/protocolOutput.js +67 -0
  36. package/dist/internal/protocolOutput.js.map +1 -0
  37. package/dist/internal/watch.js.map +1 -1
  38. package/package.json +10 -7
  39. package/schema.json +2321 -0
  40. package/src/Application.ts +55 -0
  41. package/src/Commands.ts +47 -0
  42. package/src/Manage.ts +81 -2
  43. package/src/Start.ts +100 -28
  44. package/src/bin.ts +80 -12
  45. package/src/index.ts +3 -0
  46. package/src/internal/commandConfig.ts +91 -0
  47. package/src/internal/commandHost.ts +216 -0
  48. package/src/internal/commandRegistry.ts +100 -0
  49. package/src/internal/configSchema.ts +10 -70
  50. package/src/internal/env.ts +3 -2
  51. package/src/internal/importer.ts +28 -0
  52. package/src/internal/intercepts.ts +2 -1
  53. package/src/internal/protocolOutput.ts +101 -0
  54. package/src/internal/watch.ts +4 -2
@@ -0,0 +1,55 @@
1
+ import {
2
+ Context,
3
+ Effect,
4
+ Exit,
5
+ Layer,
6
+ Option,
7
+ Schema,
8
+ Scope,
9
+ Stdio,
10
+ } from "effect"
11
+ import { Digest, SecureRandom, Signature } from "effect/crypto"
12
+ import { FetchHttpClient } from "effect/http"
13
+ import { NodeServices } from "@effect/platform-node"
14
+
15
+ import { ProtocolStdio } from "./internal/protocolOutput"
16
+
17
+ /** Set before argument parsing when stdout belongs to a byte protocol. */
18
+ export const ProtocolOutput = Context.Reference(
19
+ "@graftland/cli/ProtocolOutput",
20
+ { defaultValue: () => false },
21
+ )
22
+
23
+ /** An application requested a failed process exit, preserving its original cause. */
24
+ export class ApplicationError extends Schema.TaggedError<
25
+ ApplicationError,
26
+ { readonly brand: unique symbol }
27
+ >()("ApplicationError", {
28
+ cause: Schema.Cause(Schema.Unknown, Schema.Unknown),
29
+ }) {}
30
+
31
+ /** Applications can request process completion and report cleanup independently of loader diagnostics. */
32
+ export class Application extends Context.Service<
33
+ Application,
34
+ {
35
+ /** Exclusively reserves the process protocol channel until the caller releases its scope. */
36
+ readonly claimProtocol: Effect.Effect<void, ApplicationError, Scope.Scope>
37
+ readonly finish: (exit: Exit.Exit<void, unknown>) => Effect.Effect<void>
38
+ readonly onExit: (release: Effect.Effect<void>) => Effect.Effect<void>
39
+ }
40
+ >()("@graftland/cli/Application") {}
41
+
42
+ /** Node capabilities shared by application and command hosts. */
43
+ export const platform = Layer.mergeAll(
44
+ Layer.effect(
45
+ Stdio.Stdio,
46
+ Effect.gen(function* () {
47
+ const reserved = yield* ProtocolStdio
48
+ return Option.isSome(reserved) ? reserved.value : yield* Stdio.Stdio
49
+ }),
50
+ ).pipe(Layer.provideMerge(NodeServices.layer)),
51
+ FetchHttpClient.layer,
52
+ Digest.layerWebCrypto,
53
+ SecureRandom.layerWebCrypto,
54
+ Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto)),
55
+ )
@@ -0,0 +1,47 @@
1
+ import { Context, Effect, Schema, Scope } from "effect"
2
+ import type { CliError, Command } from "effect/cli"
3
+
4
+ /** Services available when a registered command runs. Provide other services on the command itself. */
5
+ export type Environment = Command.Environment | Scope.Scope
6
+
7
+ /** A command name, alias, or registration lifetime conflicts with the host. */
8
+ export class CommandRegistrationError extends Schema.TaggedError<
9
+ CommandRegistrationError,
10
+ { readonly brand: unique symbol }
11
+ >()("CommandRegistrationError", {
12
+ command: Schema.String,
13
+ detail: Schema.String,
14
+ }) {
15
+ override get message() {
16
+ return `${this.command}: ${this.detail}`
17
+ }
18
+ }
19
+
20
+ /** Register one native Effect CLI command tree during the plugin's activation. */
21
+ export class Commands extends Context.Service<
22
+ Commands,
23
+ {
24
+ readonly register: <
25
+ const Name extends string,
26
+ Input,
27
+ ContextInput,
28
+ E extends CliError.CliError,
29
+ R extends Environment,
30
+ >(
31
+ command: Command.Command<Name, Input, ContextInput, E, R>,
32
+ ) => Effect.Effect<void, CommandRegistrationError, Scope.Scope>
33
+ }
34
+ >()("@graftland/cli/Commands") {}
35
+
36
+ /** A declared command plugin could not be decoded from its config file. */
37
+ export class CommandConfigError extends Schema.TaggedError<
38
+ CommandConfigError,
39
+ { readonly brand: unique symbol }
40
+ >()("CommandConfigError", {
41
+ file: Schema.String,
42
+ cause: Schema.Unknown,
43
+ }) {
44
+ override get message() {
45
+ return `Cannot read command plugins from ${this.file}`
46
+ }
47
+ }
package/src/Manage.ts CHANGED
@@ -1,18 +1,27 @@
1
1
  /**
2
2
  * Creating projects and changing their dependencies.
3
3
  */
4
- import * as Create from "@graftland/create"
5
- import * as PackageManager from "@graftland/package-manager"
6
4
  import {
7
5
  Array,
8
6
  Config,
9
7
  Effect,
8
+ FileSystem,
10
9
  Option,
10
+ Path,
11
11
  Predicate,
12
12
  Record,
13
13
  Schema,
14
+ String,
14
15
  } from "effect"
15
16
 
17
+ import * as Create from "@graftland/create"
18
+ import * as PackageManager from "@graftland/package-manager"
19
+
20
+ import { parseDocument, type Document } from "yaml"
21
+
22
+ import { CommandConfigError } from "./Commands"
23
+ import { home } from "./internal/commandConfig"
24
+
16
25
  const report = (result: PackageManager.InstallResult) =>
17
26
  Effect.logInfo(
18
27
  `${result.agent}: ${result.added.length} added, ${result.updated.length} updated, ${result.removed.length} removed`,
@@ -95,3 +104,73 @@ export const remove = (names: ReadonlyArray<string>) =>
95
104
  ".",
96
105
  Record.fromEntries(Array.map(names, (name) => [name, null] as const)),
97
106
  )
107
+
108
+ const enabledConfig = Effect.fnUntraced(function* (
109
+ file: string,
110
+ module: string,
111
+ ) {
112
+ const fs = yield* FileSystem.FileSystem
113
+ const text = (yield* fs.exists(file))
114
+ ? yield* fs.readFileString(file)
115
+ : "[]\n"
116
+ const document = yield* Effect.try({
117
+ try: () => {
118
+ const document: Document = parseDocument(text)
119
+ const error = document.errors[0]
120
+ if (error !== undefined) throw error
121
+ if (document.toJS() === null) document.contents = document.createNode([])
122
+ return document
123
+ },
124
+ catch: (cause) => CommandConfigError.make({ file, cause }),
125
+ })
126
+ const entries = yield* Schema.decodeUnknownEffect(
127
+ Schema.Array(Schema.Unknown),
128
+ )(document.toJS())
129
+ const existing = Array.findFirstIndex(
130
+ entries,
131
+ (entry) =>
132
+ Predicate.hasProperty(entry, "name") &&
133
+ entry.name === module &&
134
+ Predicate.hasProperty(entry, "cli") &&
135
+ entry.cli === true,
136
+ )
137
+ return yield* Effect.try({
138
+ try: () => {
139
+ if (Option.isSome(existing))
140
+ document.setIn([existing.value, "disabled"], false)
141
+ else document.add({ name: module, cli: true })
142
+ return document.toString()
143
+ },
144
+ catch: (cause) => CommandConfigError.make({ file, cause }),
145
+ })
146
+ })
147
+ const prepareHome = Effect.fnUntraced(function* (directory: string) {
148
+ const fs = yield* FileSystem.FileSystem
149
+ const path = yield* Path.Path
150
+ yield* fs.makeDirectory(directory, { recursive: true })
151
+ const manifest = path.join(directory, "package.json")
152
+ if (!(yield* fs.exists(manifest)))
153
+ yield* fs.writeFileString(manifest, '{"private":true,"type":"module"}\n')
154
+ }, Effect.uninterruptible)
155
+
156
+ /** Install a command plugin and explicitly enable its /cli entry. */
157
+ export const addPlugin = Effect.fn("Manage.addPlugin")(function* (
158
+ name: string,
159
+ global: boolean = false,
160
+ ) {
161
+ const path = yield* Path.Path
162
+ const fs = yield* FileSystem.FileSystem
163
+ const directory = global ? yield* home : path.resolve(".")
164
+ const packageName =
165
+ String.startsWith("@")(name) || String.startsWith("graft-plugin-")(name)
166
+ ? name
167
+ : `@graftland/plugin-${name}`
168
+ const module = `${packageName}/cli`
169
+ const file = path.join(directory, "graft.yml")
170
+ const text = yield* enabledConfig(file, module)
171
+ if (global) yield* prepareHome(directory)
172
+ yield* install(directory, Record.singleton(packageName, "latest"))
173
+ // Once accepted, the native write must finish before command cleanup can proceed.
174
+ yield* fs.writeFileString(file, text).pipe(Effect.uninterruptible)
175
+ yield* Effect.logInfo(`Enabled ${module} in ${file}`)
176
+ })
package/src/Start.ts CHANGED
@@ -1,14 +1,36 @@
1
1
  /**
2
2
  * Runs a Graft application from its config file.
3
3
  */
4
+ import {
5
+ Array,
6
+ Cause,
7
+ Deferred,
8
+ Effect,
9
+ Exit,
10
+ FileSystem,
11
+ Layer,
12
+ Logger,
13
+ Option,
14
+ Path,
15
+ Scope,
16
+ Semaphore,
17
+ type PlatformError,
18
+ type Schema,
19
+ } from "effect"
4
20
  import { NodeServices } from "@effect/platform-node"
5
- import { SecureRandom } from "effect/crypto"
21
+
6
22
  import { Graft, Plugin } from "@graftland/graft"
7
23
  import * as Hmr from "@graftland/hmr"
8
- import { Include, Loader, ModuleResolver, type Tree } from "@graftland/loader"
9
- import type { PlatformError, Schema } from "effect"
10
- import { Effect, FileSystem, Layer, Logger, Path } from "effect"
11
- import { EnvFile, layer as env, watchEnvFile } from "./internal/env"
24
+ import {
25
+ Entry,
26
+ Include,
27
+ Loader,
28
+ ModuleResolver,
29
+ type Tree,
30
+ } from "@graftland/loader"
31
+
32
+ import { Application, ApplicationError, platform } from "./Application"
33
+ import { layer as env, EnvFile, watchEnvFile } from "./internal/env"
12
34
  import { layer as intercepts } from "./internal/intercepts"
13
35
  import { watchModules } from "./internal/watch"
14
36
 
@@ -29,18 +51,48 @@ export interface Options {
29
51
  * modules are imported through the hot reloader, so bare specifiers resolve
30
52
  * from the application directory whether or not `watch` is on.
31
53
  */
32
- export const run: (
54
+ export const run = (
33
55
  options: Options,
34
- ) => Effect.Effect<
35
- never,
56
+ ): Effect.Effect<
57
+ void,
58
+ | ApplicationError
36
59
  | Graft.ServiceConflict
37
60
  | PlatformError.BadArgument
38
61
  | PlatformError.PlatformError
39
62
  | Schema.SchemaError
40
63
  | Loader.StoreError
41
64
  | Tree.InvalidOperation
42
- > = Effect.fn("Start.run")(
43
- function* (options: Options) {
65
+ > =>
66
+ Effect.gen(function* () {
67
+ const scope = yield* Effect.scope
68
+ const done = yield* Deferred.make<void, ApplicationError>()
69
+ const protocol = yield* Semaphore.make(1)
70
+ yield* Graft.provide(Application, {
71
+ claimProtocol: Effect.acquireRelease(
72
+ protocol.takeIfAvailable(1).pipe(
73
+ Effect.flatMap((acquired) =>
74
+ acquired
75
+ ? Effect.void
76
+ : Effect.fail(
77
+ ApplicationError.make({
78
+ cause: Cause.fail(
79
+ "Only one runner may own the process stdio channel",
80
+ ),
81
+ }),
82
+ ),
83
+ ),
84
+ ),
85
+ () => protocol.release(1),
86
+ ),
87
+ finish: (exit) =>
88
+ Deferred.done(
89
+ done,
90
+ Exit.isFailure(exit)
91
+ ? Exit.fail(ApplicationError.make({ cause: exit.cause }))
92
+ : Exit.void,
93
+ ).pipe(Effect.asVoid),
94
+ onExit: (release) => Scope.addFinalizer(scope, release),
95
+ })
44
96
  const path = yield* Path.Path
45
97
  const file = path.resolve(options.config)
46
98
  const directory = path.dirname(file)
@@ -52,16 +104,37 @@ export const run: (
52
104
  })
53
105
  const modules = yield* ModuleResolver.make({
54
106
  base: directory,
55
- importer: hmr.importModule,
107
+ importer: options.watch ? hmr.importModuleScoped : hmr.importModule,
56
108
  })
57
109
  yield* Graft.provide(ModuleResolver.ModuleResolver, {
58
110
  resolve: resolver.resolve,
59
111
  importModule: modules.importModule,
60
112
  })
113
+ const fs = yield* FileSystem.FileSystem
114
+ const observe = hmr.observation.pipe(
115
+ Effect.provideService(FileSystem.FileSystem, fs),
116
+ Effect.map((source) => ({
117
+ ...source,
118
+ watch: (files: ReadonlyArray<string>, activation: Scope.Scope) =>
119
+ source.watch(files, activation).pipe(
120
+ Effect.mapError((cause) =>
121
+ ModuleResolver.ModuleInvalid.make({
122
+ specifier: Array.join(files, ", "),
123
+ cause,
124
+ }),
125
+ ),
126
+ ),
127
+ })),
128
+ )
61
129
  const included = yield* Graft.plugin(Include.Include, {
62
130
  path: file,
63
131
  watch: options.watch,
64
- })
132
+ }).pipe(
133
+ Effect.provideService(
134
+ ModuleResolver.Watch,
135
+ options.watch ? Option.some(observe) : Option.none(),
136
+ ),
137
+ )
65
138
  yield* included.await
66
139
  if (options.watch) {
67
140
  const { reload } = yield* EnvFile
@@ -69,16 +142,17 @@ export const run: (
69
142
  const reloader = Plugin.make({
70
143
  name: "reloader",
71
144
  inject: [Loader.Loader, FileSystem.FileSystem, Path.Path],
72
- apply: Effect.fnUntraced(function* () {
73
- const loader = yield* Loader.Loader
74
- yield* watchModules(hmr, directory, loader.retry).pipe(
75
- Effect.forkScoped,
76
- )
77
- if (envFile !== undefined)
78
- yield* watchEnvFile(envFile, reload, loader.retry).pipe(
145
+ apply: () =>
146
+ Effect.gen(function* () {
147
+ const loader = yield* Loader.Loader
148
+ yield* watchModules(hmr, directory, loader.retry).pipe(
79
149
  Effect.forkScoped,
80
150
  )
81
- }),
151
+ if (envFile !== undefined)
152
+ yield* watchEnvFile(envFile, reload, loader.retry).pipe(
153
+ Effect.forkScoped,
154
+ )
155
+ }),
82
156
  })
83
157
  yield* Graft.plugin(reloader)
84
158
  }
@@ -86,22 +160,20 @@ export const run: (
86
160
  yield* Effect.logInfo(
87
161
  `Running ${path.relative(process.cwd(), file) || file}`,
88
162
  )
89
- return yield* Effect.never
90
- },
91
- Effect.scoped,
92
- (effect, options) =>
163
+ return yield* Deferred.await(done)
164
+ }).pipe(
165
+ Effect.scoped,
166
+ Effect.provideService(Entry.Select, (entry) => entry["cli"] !== true),
93
167
  // @effect-diagnostics-next-line strictEffectProvide:off -- this runs the whole application, so it is an entry point
94
168
  Effect.provide(
95
- effect,
96
169
  Layer.provideMerge(
97
170
  Graft.layer,
98
171
  Layer.mergeAll(
99
- SecureRandom.layerWebCrypto,
100
- NodeServices.layer,
172
+ platform,
101
173
  intercepts,
102
174
  env(options.envFile).pipe(Layer.provide(NodeServices.layer)),
103
175
  Logger.layer([Logger.consolePretty()]),
104
176
  ),
105
177
  ),
106
178
  ),
107
- )
179
+ )
package/src/bin.ts CHANGED
@@ -2,10 +2,15 @@
2
2
  /**
3
3
  * The `graft` executable. Running this module starts the program.
4
4
  */
5
- import { NodeRuntime, NodeServices } from "@effect/platform-node"
6
- import { Effect, Option } from "effect"
7
- import { Argument, Command, Flag } from "effect/cli"
5
+ import { Array, Effect, Layer, Logger, Option, Stdio } from "effect"
6
+ import { Argument, CliError, Command, Flag } from "effect/cli"
7
+ import { NodeRuntime } from "@effect/platform-node"
8
+
9
+ import { Graft } from "@graftland/graft"
10
+
8
11
  import { version } from "../package.json" with { type: "json" }
12
+ import { platform } from "./Application"
13
+ import * as Host from "./internal/commandHost"
9
14
 
10
15
  const start = Command.make(
11
16
  "start",
@@ -64,12 +69,28 @@ const create = Command.make(
64
69
 
65
70
  const add = Command.make(
66
71
  "add",
67
- { packages: Argument.variadic(Argument.String("package")) },
68
- ({ packages }) =>
69
- Effect.promise(() => import("./Manage")).pipe(
72
+ {
73
+ packages: Argument.variadic(Argument.String("package")),
74
+ plugin: Flag.String("plugin").pipe(Flag.optional),
75
+ global: Flag.Boolean("global").pipe(Flag.withDefault(false)),
76
+ },
77
+ Effect.fnUntraced(function* ({ packages, plugin, global }) {
78
+ if (
79
+ (global && Option.isNone(plugin)) ||
80
+ (Option.isSome(plugin) && packages.length > 0)
81
+ )
82
+ return yield* CliError.UserError.make({
83
+ cause: "Conflicting install options",
84
+ userMessage:
85
+ "Use add --plugin <name> [--global] without package arguments",
86
+ })
87
+ const Manage = yield* Effect.promise(() => import("./Manage")).pipe(
70
88
  Effect.uninterruptible,
71
- Effect.flatMap((Manage) => Manage.add(packages)),
72
- ),
89
+ )
90
+ return yield* Option.isSome(plugin)
91
+ ? Manage.addPlugin(plugin.value, global)
92
+ : Manage.add(packages)
93
+ }),
73
94
  ).pipe(
74
95
  Command.withDescription(
75
96
  "Add dependencies, restoring the project if installing fails",
@@ -92,10 +113,57 @@ const remove = Command.make(
92
113
 
93
114
  const graft = Command.make("graft").pipe(
94
115
  Command.withDescription("Run and manage Graft applications"),
95
- Command.withSubcommands([start, create, add, remove]),
116
+ Command.withSharedFlags({
117
+ stdio: Flag.Boolean("stdio").pipe(Flag.withDefault(false)),
118
+ noPlugins: Flag.Boolean("no-plugins").pipe(Flag.withDefault(false)),
119
+ }),
96
120
  )
97
121
 
98
- Command.run(graft, { version }).pipe(
99
- Effect.provide(NodeServices.layer),
100
- NodeRuntime.runMain,
122
+ const program = Effect.gen(function* () {
123
+ const stdio = yield* Stdio.Stdio
124
+ const args = yield* stdio.args
125
+ return yield* Host.output(
126
+ Effect.scoped(
127
+ Effect.gen(function* () {
128
+ const context = yield* Layer.build(Graft.layer)
129
+ return yield* Host.run(
130
+ [start, create, add, remove],
131
+ graft,
132
+ version,
133
+ args,
134
+ ).pipe(Effect.provideContext(context))
135
+ }),
136
+ ),
137
+ args,
138
+ )
139
+ })
140
+ NodeRuntime.runMain(
141
+ Effect.scoped(
142
+ Effect.gen(function* () {
143
+ const context = yield* Layer.build(platform)
144
+ return yield* program.pipe(Effect.provideContext(context))
145
+ }),
146
+ ).pipe(
147
+ (effect) =>
148
+ Effect.withFiber((main) =>
149
+ effect.pipe(
150
+ Effect.tapCause((cause) =>
151
+ Array.every(
152
+ cause.reasons,
153
+ (reason) =>
154
+ reason._tag === "Interrupt" && reason.fiberId === main.id,
155
+ ) ||
156
+ Array.every(
157
+ cause.reasons,
158
+ (reason) =>
159
+ reason._tag === "Fail" && CliError.isCliError(reason.error),
160
+ )
161
+ ? Effect.void
162
+ : Effect.logError(cause),
163
+ ),
164
+ ),
165
+ ),
166
+ Effect.provideService(Logger.LogToStderr, true),
167
+ ),
168
+ { disableErrorReporting: true },
101
169
  )
package/src/index.ts CHANGED
@@ -3,3 +3,6 @@
3
3
  */
4
4
  export * as Manage from "./Manage"
5
5
  export * as Start from "./Start"
6
+
7
+ export * as Commands from "./Commands"
8
+ export * as Application from "./Application"
@@ -0,0 +1,91 @@
1
+ import {
2
+ Array,
3
+ Config,
4
+ Effect,
5
+ FileSystem,
6
+ HashMap,
7
+ Option,
8
+ Path,
9
+ Predicate,
10
+ Schema,
11
+ } from "effect"
12
+
13
+ import { parseDocument } from "yaml"
14
+
15
+ import { CommandConfigError } from "../Commands"
16
+
17
+ const Description = Schema.Struct({
18
+ name: Schema.String,
19
+ config: Schema.optionalKey(Schema.Unknown),
20
+ disabled: Schema.optionalKey(Schema.Boolean),
21
+ })
22
+ export const home = Effect.gen(function* () {
23
+ const path = yield* Path.Path
24
+ const override = yield* Config.String("GRAFT_HOME").pipe(Config.option)
25
+ if (Option.isSome(override)) return path.resolve(override.value)
26
+ const directory = yield* Config.String("HOME")
27
+ return path.join(directory, ".graft")
28
+ })
29
+ export const read = Effect.fnUntraced(function* (file: string) {
30
+ const fs = yield* FileSystem.FileSystem
31
+ if (!(yield* fs.exists(file))) return []
32
+ const text = yield* fs.readFileString(file)
33
+ const { Entry } = yield* Effect.promise(
34
+ () => import("@graftland/loader"),
35
+ ).pipe(Effect.uninterruptible)
36
+ const value = yield* Effect.try({
37
+ try: () => {
38
+ const document = parseDocument(text, {
39
+ customTags: [
40
+ { tag: "!env", resolve: (name: string) => new Entry.Env({ name }) },
41
+ ],
42
+ })
43
+ const error = document.errors[0]
44
+ if (error !== undefined) throw error
45
+ return document.toJS()
46
+ },
47
+ catch: (cause) => CommandConfigError.make({ file, cause }),
48
+ })
49
+ const entries = yield* Schema.decodeUnknownEffect(
50
+ Schema.NullOr(Schema.Array(Schema.Unknown)),
51
+ )(value).pipe(
52
+ Effect.mapError((cause) => CommandConfigError.make({ file, cause })),
53
+ )
54
+ return yield* Effect.forEach(
55
+ Array.filter(
56
+ entries ?? [],
57
+ (entry) => Predicate.hasProperty(entry, "cli") && entry.cli === true,
58
+ ),
59
+ (entry) =>
60
+ Schema.decodeUnknownEffect(Description)(entry).pipe(
61
+ Effect.mapBoth({
62
+ onFailure: (cause) => CommandConfigError.make({ file, cause }),
63
+ onSuccess: (entry) => ({ ...entry, file }),
64
+ }),
65
+ ),
66
+ )
67
+ })
68
+ export const discover = Effect.fnUntraced(function* (watch: boolean) {
69
+ const path = yield* Path.Path
70
+ const directory = yield* home
71
+ const global = yield* read(path.join(directory, "graft.yml"))
72
+ const project = yield* read(path.resolve("graft.yml")).pipe(
73
+ Effect.catchTag("CommandConfigError", (error) =>
74
+ watch
75
+ ? Effect.logWarning(
76
+ "Command discovery skipped invalid project configuration; restart to load command plugins",
77
+ error,
78
+ ).pipe(Effect.as([]))
79
+ : Effect.fail(error),
80
+ ),
81
+ )
82
+ const merged = Array.reduce(
83
+ [...global, ...project],
84
+ HashMap.empty<string, (typeof global)[number]>(),
85
+ (map, entry) => HashMap.set(map, entry.name, entry),
86
+ )
87
+ return Array.filter(
88
+ Array.fromIterable(HashMap.values(merged)),
89
+ (entry) => entry.disabled !== true,
90
+ )
91
+ })