@graftland/cli 0.4.1 → 0.4.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/Manage.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  Record,
13
13
  Schema,
14
14
  } from "effect"
15
+ import { CliError } from "effect/cli"
15
16
 
16
17
  import * as Create from "@graftland/create"
17
18
  import * as PackageManager from "@graftland/package-manager"
@@ -20,6 +21,7 @@ import { parseDocument, type Document } from "yaml"
20
21
 
21
22
  import { CommandConfigError } from "./Commands"
22
23
  import { home } from "./internal/commandConfig"
24
+ import { schemaFileName } from "./internal/schemaFileName"
23
25
 
24
26
  const report = (result: PackageManager.InstallResult) =>
25
27
  Effect.logInfo(
@@ -52,6 +54,29 @@ const Spec = Schema.Union([
52
54
  ])
53
55
  const decodeSpecs = Schema.decodeEffect(Schema.Array(Spec))
54
56
 
57
+ /**
58
+ * Regenerates the project's editor schema, but only when it has one, so that
59
+ * the plugins just added or removed are checked. Imports no plugin.
60
+ */
61
+ const refreshSchema = Effect.fnUntraced(function* (directory: string) {
62
+ const fs = yield* FileSystem.FileSystem
63
+ const path = yield* Path.Path
64
+ const root = path.resolve(directory)
65
+ const file = path.join(root, schemaFileName)
66
+ if (!(yield* fs.exists(file))) return
67
+ const EditorSchema = yield* Effect.promise(
68
+ () => import("./internal/schemaFile"),
69
+ ).pipe(Effect.uninterruptible)
70
+ yield* EditorSchema.write(root, file).pipe(
71
+ Effect.mapError((cause) =>
72
+ CliError.UserError.make({
73
+ cause,
74
+ userMessage: `Dependencies changed, but ${file} was not regenerated: ${cause.message}`,
75
+ }),
76
+ ),
77
+ )
78
+ })
79
+
55
80
  const install = Effect.fn("Manage.install")(function* (
56
81
  directory: string,
57
82
  changes: Readonly<Record<string, string | null>>,
@@ -64,6 +89,7 @@ const install = Effect.fn("Manage.install")(function* (
64
89
  const manager = yield* PackageManager.make({ directory, userAgent })
65
90
  const result = yield* manager.install(decoded)
66
91
  yield* report(result)
92
+ yield* refreshSchema(directory)
67
93
  })
68
94
 
69
95
  export const add = Effect.fn("Manage.add")(function* (
package/src/Start.ts CHANGED
@@ -30,7 +30,13 @@ import {
30
30
  } from "@graftland/loader"
31
31
 
32
32
  import { Application, ApplicationError, platform } from "./Application"
33
- import { layer as env, EnvFile, watchEnvFile } from "./internal/env"
33
+ import {
34
+ layer as env,
35
+ EnvDir,
36
+ EnvFile,
37
+ watchEnvDir,
38
+ watchEnvFile,
39
+ } from "./internal/env"
34
40
  import { layer as intercepts } from "./internal/intercepts"
35
41
  import { watchModules } from "./internal/watch"
36
42
 
@@ -44,6 +50,12 @@ export interface Options {
44
50
  * a variable. With `watch`, changes to it are read again.
45
51
  */
46
52
  readonly envFile?: string | undefined
53
+ /**
54
+ * A directory of secret files that `!env` reads when neither the process
55
+ * environment nor `envFile` sets a variable. Each regular file is one
56
+ * variable, named after the file. With `watch`, changes are read again.
57
+ */
58
+ readonly envDir?: string | undefined
47
59
  }
48
60
 
49
61
  /**
@@ -138,7 +150,8 @@ export const run = (
138
150
  yield* included.await
139
151
  if (options.watch) {
140
152
  const { reload } = yield* EnvFile
141
- const envFile = options.envFile
153
+ const { reload: reloadDir } = yield* EnvDir
154
+ const { envFile, envDir } = options
142
155
  const reloader = Plugin.make({
143
156
  name: "reloader",
144
157
  inject: [Loader.Loader, FileSystem.FileSystem, Path.Path],
@@ -152,6 +165,10 @@ export const run = (
152
165
  yield* watchEnvFile(envFile, reload, loader.retry).pipe(
153
166
  Effect.forkScoped,
154
167
  )
168
+ if (envDir !== undefined)
169
+ yield* watchEnvDir(envDir, reloadDir, loader.retry).pipe(
170
+ Effect.forkScoped,
171
+ )
155
172
  }),
156
173
  })
157
174
  yield* Graft.plugin(reloader)
@@ -171,7 +188,9 @@ export const run = (
171
188
  Layer.mergeAll(
172
189
  platform,
173
190
  intercepts,
174
- env(options.envFile).pipe(Layer.provide(NodeServices.layer)),
191
+ env({ file: options.envFile, dir: options.envDir }).pipe(
192
+ Layer.provide(NodeServices.layer),
193
+ ),
175
194
  Logger.layer([Logger.consolePretty()]),
176
195
  ),
177
196
  ),
package/src/bin.ts CHANGED
@@ -12,6 +12,7 @@ import { Graft } from "@graftland/graft"
12
12
  import { version } from "../package.json" with { type: "json" }
13
13
  import { platform } from "./Application"
14
14
  import * as Host from "./internal/commandHost"
15
+ import { schemaFileName } from "./internal/schemaFileName"
15
16
 
16
17
  const start = Command.make(
17
18
  "start",
@@ -31,6 +32,12 @@ const start = Command.make(
31
32
  ),
32
33
  Flag.optional,
33
34
  ),
35
+ envDir: Flag.String("env-dir").pipe(
36
+ Flag.withDescription(
37
+ "Read variables for !env from a directory with one file per variable, such as /run/secrets",
38
+ ),
39
+ Flag.optional,
40
+ ),
34
41
  },
35
42
  (options) =>
36
43
  Effect.promise(() => import("./Start")).pipe(
@@ -39,6 +46,7 @@ const start = Command.make(
39
46
  Start.run({
40
47
  ...options,
41
48
  envFile: Option.getOrUndefined(options.envFile),
49
+ envDir: Option.getOrUndefined(options.envDir),
42
50
  }),
43
51
  ),
44
52
  ),
@@ -90,6 +98,26 @@ const remove = Command.make(
90
98
  ),
91
99
  )
92
100
 
101
+ const schema = Command.make(
102
+ "schema",
103
+ {
104
+ output: Flag.String("output").pipe(
105
+ Flag.withAlias("o"),
106
+ Flag.withDescription("Path of the schema file"),
107
+ Flag.withDefault(schemaFileName),
108
+ ),
109
+ },
110
+ ({ output }) =>
111
+ Effect.promise(() => import("./internal/schemaFile")).pipe(
112
+ Effect.uninterruptible,
113
+ Effect.flatMap((EditorSchema) => EditorSchema.write(".", output)),
114
+ ),
115
+ ).pipe(
116
+ Command.withDescription(
117
+ "Write the JSON Schema of graft.yml, with the config of installed plugins",
118
+ ),
119
+ )
120
+
93
121
  const graft = Command.make("graft").pipe(
94
122
  Command.withDescription("Run and manage Graft applications"),
95
123
  Command.withSharedFlags({
@@ -106,7 +134,7 @@ const program = Effect.gen(function* () {
106
134
  Effect.gen(function* () {
107
135
  const context = yield* Layer.build(Graft.layer)
108
136
  return yield* Host.run(
109
- [start, create, add, remove],
137
+ [start, create, add, remove, schema],
110
138
  graft,
111
139
  version,
112
140
  args,
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Where `!env` values come from: the process environment, then an optional
3
- * `.env` file that watch mode reads again when it changes.
3
+ * `.env` file, then an optional directory of secret files. Watch mode reads
4
+ * the file and the directory again when they change.
4
5
  */
5
6
  import {
7
+ Array,
6
8
  Cause,
7
9
  ConfigProvider,
8
10
  Context,
@@ -11,10 +13,13 @@ import {
11
13
  Exit,
12
14
  FileSystem,
13
15
  Layer,
16
+ Option,
14
17
  Path,
15
18
  Queue,
19
+ Record,
16
20
  Ref,
17
21
  Stream,
22
+ String,
18
23
  type PlatformError,
19
24
  } from "effect"
20
25
 
@@ -24,57 +29,156 @@ import { observeShutdown } from "./observeShutdown"
24
29
  // schema decides whether it is valid.
25
30
  const options = { preserveEmptyStrings: true } as const
26
31
 
27
- interface Loaded {
28
- readonly target: string
29
- readonly text: string
30
- readonly provider: ConfigProvider.ConfigProvider
31
- }
32
+ type Reload = Effect.Effect<boolean, PlatformError.PlatformError>
32
33
 
33
34
  export class EnvFile extends Context.Service<
34
35
  EnvFile,
35
36
  {
36
37
  /** Reads the file again; reports whether its contents changed. */
37
- readonly reload: Effect.Effect<boolean, PlatformError.PlatformError>
38
+ readonly reload: Reload
38
39
  }
39
40
  >()("@graftland/cli/internal/env/EnvFile") {}
40
41
 
41
- /** Provides `ConfigProvider` and `EnvFile`; a missing file fails the layer. */
42
- export const layer = (file: string | undefined) =>
42
+ export class EnvDir extends Context.Service<
43
+ EnvDir,
44
+ {
45
+ /** Reads the directory again; reports whether any value changed. */
46
+ readonly reload: Reload
47
+ }
48
+ >()("@graftland/cli/internal/env/EnvDir") {}
49
+
50
+ const unchanged = { reload: Effect.succeed(false) }
51
+
52
+ /**
53
+ * A source that keeps the provider read from its latest snapshot. A failed
54
+ * read keeps the previous snapshot.
55
+ */
56
+ const source = Effect.fnUntraced(function* <A>(
57
+ read: Effect.Effect<A, PlatformError.PlatformError>,
58
+ provider: (snapshot: A) => ConfigProvider.ConfigProvider,
59
+ same: (a: A, b: A) => boolean,
60
+ ) {
61
+ const initial = yield* read
62
+ const loaded = yield* Ref.make({
63
+ snapshot: initial,
64
+ provider: provider(initial),
65
+ })
66
+ return {
67
+ provider: ConfigProvider.make((path) =>
68
+ Effect.flatMap(Ref.get(loaded), ({ provider }) => provider.load(path)),
69
+ ),
70
+ reload: Effect.gen(function* () {
71
+ const snapshot = yield* read
72
+ const previous = yield* Ref.getAndSet(loaded, {
73
+ snapshot,
74
+ provider: provider(snapshot),
75
+ })
76
+ return !same(snapshot, previous.snapshot)
77
+ }),
78
+ }
79
+ })
80
+
81
+ const readFile = Effect.fnUntraced(function* (file: string) {
82
+ const fs = yield* FileSystem.FileSystem
83
+ // Resolve the request on every read, including polling. Never pin a
84
+ // symlink to its initial target.
85
+ const target = yield* fs.realPath(file)
86
+ return yield* fs.readFileString(target)
87
+ })
88
+
89
+ const isNotFound = (error: PlatformError.PlatformError) =>
90
+ error.reason._tag === "NotFound"
91
+
92
+ /**
93
+ * One variable per regular file, named after the file. Hidden entries are
94
+ * skipped, which also skips the bookkeeping links of Kubernetes volumes.
95
+ * Trailing line breaks are not part of the value.
96
+ */
97
+ const readDir = Effect.fnUntraced(function* (directory: string) {
98
+ const fs = yield* FileSystem.FileSystem
99
+ const path = yield* Path.Path
100
+ const names = yield* fs.readDirectory(directory)
101
+ const values = yield* Effect.forEach(
102
+ Array.filter(names, (name) => !String.startsWith(".")(name)),
103
+ (name) => {
104
+ const file = path.join(directory, name)
105
+ return fs.stat(file).pipe(
106
+ Effect.flatMap((info) =>
107
+ info.type === "File"
108
+ ? fs
109
+ .readFileString(file)
110
+ .pipe(
111
+ Effect.map((text) =>
112
+ Option.some([
113
+ name,
114
+ String.replace(/[\r\n]+$/, "")(text),
115
+ ] as const),
116
+ ),
117
+ )
118
+ : Effect.succeedNone,
119
+ ),
120
+ // A file removed since the listing is no longer a variable.
121
+ Effect.catchIf(isNotFound, () => Effect.succeedNone),
122
+ )
123
+ },
124
+ { concurrency: 16 },
125
+ )
126
+ return Record.fromEntries(Array.getSomes(values))
127
+ })
128
+
129
+ const sameValues = Record.makeEquivalence(String.Equivalence)
130
+
131
+ /**
132
+ * Provides `ConfigProvider`, `EnvFile` and `EnvDir`. A missing file or
133
+ * directory fails the layer.
134
+ */
135
+ export const layer = (sources: {
136
+ readonly file?: string | undefined
137
+ readonly dir?: string | undefined
138
+ }) =>
43
139
  Layer.unwrap(
44
140
  Effect.gen(function* () {
45
- const environment = ConfigProvider.fromEnv(options)
46
- if (file === undefined)
47
- return Layer.merge(
48
- ConfigProvider.layer(environment),
49
- Layer.succeed(EnvFile, { reload: Effect.succeed(false) }),
50
- )
51
141
  const fs = yield* FileSystem.FileSystem
52
- // Resolve the request on every read, including polling. Never pin a
53
- // symlink to its initial target. The provider and its baseline are
54
- // committed together from the bytes actually read.
55
- const read = Effect.gen(function* () {
56
- const target = yield* fs.realPath(file)
57
- const text = yield* fs.readFileString(target)
58
- return {
59
- target,
60
- text,
61
- provider: ConfigProvider.fromDotEnvContents(text, options),
62
- }
63
- })
64
- const initial: Loaded = yield* read
65
- const loaded = yield* Ref.make(initial)
66
- const fromFile = ConfigProvider.make((path) =>
67
- Effect.flatMap(Ref.get(loaded), ({ provider }) => provider.load(path)),
142
+ const path = yield* Path.Path
143
+ const provide = <A>(
144
+ read: Effect.Effect<
145
+ A,
146
+ PlatformError.PlatformError,
147
+ FileSystem.FileSystem | Path.Path
148
+ >,
149
+ ) =>
150
+ read.pipe(
151
+ Effect.provideService(FileSystem.FileSystem, fs),
152
+ Effect.provideService(Path.Path, path),
153
+ )
154
+ const file =
155
+ sources.file === undefined
156
+ ? undefined
157
+ : yield* source(
158
+ provide(readFile(sources.file)),
159
+ (text) => ConfigProvider.fromDotEnvContents(text, options),
160
+ (a, b) => a === b,
161
+ )
162
+ const dir =
163
+ sources.dir === undefined
164
+ ? undefined
165
+ : yield* source(
166
+ provide(readDir(sources.dir)),
167
+ (values) => ConfigProvider.fromEnvRecord(values, options),
168
+ sameValues,
169
+ )
170
+ const provider = Array.reduce(
171
+ [file, dir],
172
+ ConfigProvider.fromEnv(options),
173
+ (first, next) =>
174
+ next === undefined
175
+ ? first
176
+ : ConfigProvider.orElse(first, next.provider),
68
177
  )
69
- return Layer.merge(
70
- ConfigProvider.layer(ConfigProvider.orElse(environment, fromFile)),
71
- Layer.succeed(EnvFile, {
72
- reload: Effect.gen(function* () {
73
- const next = yield* read
74
- const previous = yield* Ref.getAndSet(loaded, next)
75
- return next.text !== previous.text
76
- }),
77
- }),
178
+ return Layer.mergeAll(
179
+ ConfigProvider.layer(provider),
180
+ Layer.succeed(EnvFile, file ?? unchanged),
181
+ Layer.succeed(EnvDir, dir ?? unchanged),
78
182
  )
79
183
  }),
80
184
  )
@@ -98,11 +202,38 @@ const logStopped =
98
202
  */
99
203
  export const watchEnvFile = Effect.fnUntraced(function* <E, R>(
100
204
  file: string,
101
- reload: Effect.Effect<boolean, PlatformError.PlatformError>,
205
+ reload: Reload,
102
206
  retry: Effect.Effect<void, E, R>,
103
207
  ) {
104
- const fs = yield* FileSystem.FileSystem
105
208
  const path = yield* Path.Path
209
+ const name = path.basename(file)
210
+ return yield* watchSource(
211
+ file,
212
+ path.dirname(file),
213
+ (changed) => path.basename(changed) === name,
214
+ reload,
215
+ retry,
216
+ )
217
+ })
218
+
219
+ /**
220
+ * Reloads the directory when anything in it changes and retries the loader's
221
+ * entries. A directory that cannot be read keeps its previous values.
222
+ */
223
+ export const watchEnvDir = <E, R>(
224
+ directory: string,
225
+ reload: Reload,
226
+ retry: Effect.Effect<void, E, R>,
227
+ ) => watchSource(directory, directory, () => true, reload, retry)
228
+
229
+ const watchSource = Effect.fnUntraced(function* <E, R>(
230
+ file: string,
231
+ directory: string,
232
+ matches: (path: string) => boolean,
233
+ reload: Reload,
234
+ retry: Effect.Effect<void, E, R>,
235
+ ) {
236
+ const fs = yield* FileSystem.FileSystem
106
237
  const ready = yield* Deferred.make<void>()
107
238
  // As in HMR, own the source scope inside the producer. Observe its
108
239
  // complete Exit after cleanup, before merge can discard it.
@@ -112,13 +243,11 @@ export const watchEnvFile = Effect.fnUntraced(function* <E, R>(
112
243
  >(
113
244
  (queue) =>
114
245
  fs
115
- .watch(path.dirname(file), {
246
+ .watch(directory, {
116
247
  onReady: () => Deferred.doneUnsafe(ready, Effect.void),
117
248
  })
118
249
  .pipe(
119
- Stream.filter(
120
- (event) => path.basename(event.path) === path.basename(file),
121
- ),
250
+ Stream.filter((event) => matches(event.path)),
122
251
  Stream.runForEachArray((events) => Queue.offerAll(queue, events)),
123
252
  Effect.scoped,
124
253
  (effect) =>
@@ -0,0 +1,186 @@
1
+ /** Composes the editor schema of a project's config file from its installed plugins. */
2
+ import {
3
+ Array,
4
+ Effect,
5
+ FileSystem,
6
+ Option,
7
+ Path,
8
+ Predicate,
9
+ Record,
10
+ Schema,
11
+ type PlatformError,
12
+ } from "effect"
13
+
14
+ import { make } from "./configSchema"
15
+ import * as Importer from "./importer"
16
+
17
+ /** A dependency's `./schema` export could not be read. */
18
+ export class SchemaRegistrationError extends Schema.TaggedError<
19
+ SchemaRegistrationError,
20
+ { readonly brand: unique symbol }
21
+ >()("SchemaRegistrationError", {
22
+ packageName: Schema.String,
23
+ detail: Schema.String,
24
+ cause: Schema.optionalKey(Schema.Unknown),
25
+ }) {
26
+ override get message() {
27
+ return `${this.packageName}/schema: ${this.detail}`
28
+ }
29
+ }
30
+
31
+ const Dependencies = Schema.optionalKey(
32
+ Schema.Record(Schema.String, Schema.String),
33
+ )
34
+ const ProjectManifest = Schema.fromJsonString(
35
+ Schema.Struct({
36
+ dependencies: Dependencies,
37
+ devDependencies: Dependencies,
38
+ optionalDependencies: Dependencies,
39
+ }),
40
+ )
41
+ const InstalledManifest = Schema.fromJsonString(
42
+ Schema.Struct({
43
+ exports: Schema.optionalKey(Schema.Unknown),
44
+ dependencies: Dependencies,
45
+ peerDependencies: Dependencies,
46
+ }),
47
+ )
48
+ const Registrations = Schema.Array(
49
+ Schema.Struct({
50
+ names: Schema.Array(Schema.String),
51
+ schema: Schema.Record(Schema.String, Schema.Unknown),
52
+ required: Schema.optionalKey(Schema.Boolean),
53
+ }),
54
+ )
55
+
56
+ /**
57
+ * The manifest of an installed package, looked up as Node does: in
58
+ * `node_modules` of the directory and then of each parent, so hoisted
59
+ * workspace installs are found.
60
+ */
61
+ const installedManifest = (
62
+ directory: string,
63
+ packageName: string,
64
+ ): Effect.Effect<
65
+ Option.Option<string>,
66
+ PlatformError.PlatformError,
67
+ FileSystem.FileSystem | Path.Path
68
+ > =>
69
+ Effect.gen(function* () {
70
+ const fs = yield* FileSystem.FileSystem
71
+ const path = yield* Path.Path
72
+ const file = path.join(
73
+ directory,
74
+ "node_modules",
75
+ packageName,
76
+ "package.json",
77
+ )
78
+ if (yield* fs.exists(file)) return Option.some(file)
79
+ const parent = path.dirname(directory)
80
+ return parent === directory
81
+ ? Option.none()
82
+ : yield* installedManifest(parent, packageName)
83
+ })
84
+
85
+ /**
86
+ * Whether an installed dependency is a plugin package that exports `./schema`,
87
+ * read without importing it. Other packages, such as `effect`, may export a
88
+ * `./schema` of their own.
89
+ */
90
+ const exportsSchema = Effect.fnUntraced(function* (
91
+ directory: string,
92
+ packageName: string,
93
+ ) {
94
+ const fs = yield* FileSystem.FileSystem
95
+ const file = yield* installedManifest(directory, packageName)
96
+ if (Option.isNone(file)) {
97
+ yield* Effect.logWarning(
98
+ `${packageName} is not installed; its config is left open`,
99
+ )
100
+ return false
101
+ }
102
+ const text = yield* fs.readFileString(file.value)
103
+ const manifest = yield* Schema.decodeEffect(InstalledManifest)(text)
104
+ return (
105
+ Predicate.hasProperty(manifest.exports, "./schema") &&
106
+ (Record.has(manifest.dependencies ?? {}, "@graftland/graft") ||
107
+ Record.has(manifest.peerDependencies ?? {}, "@graftland/graft"))
108
+ )
109
+ })
110
+
111
+ const load = Effect.fnUntraced(function* (
112
+ importer: (specifier: string) => Promise<unknown>,
113
+ packageName: string,
114
+ ) {
115
+ const failed = (detail: string) => (cause: unknown) =>
116
+ SchemaRegistrationError.make({ packageName, detail, cause })
117
+ const module = yield* Effect.tryPromise({
118
+ try: () => importer(`${packageName}/schema`),
119
+ catch: failed("cannot be imported"),
120
+ })
121
+ if (
122
+ !Predicate.hasProperty(module, "entries") ||
123
+ !Predicate.isFunction(module.entries)
124
+ )
125
+ return yield* SchemaRegistrationError.make({
126
+ packageName,
127
+ detail: "does not export an entries function",
128
+ })
129
+ const entries = module.entries
130
+ const value = yield* Effect.try({
131
+ try: () => entries(),
132
+ catch: failed("entries() threw"),
133
+ })
134
+ return yield* Schema.decodeUnknownEffect(Registrations)(value).pipe(
135
+ Effect.mapError(failed("entries() returned invalid registrations")),
136
+ )
137
+ })
138
+
139
+ /**
140
+ * The registrations exported as `./schema` by the dependencies in the
141
+ * project's package.json. Only these modules are imported, never the plugins.
142
+ */
143
+ export const registrations = Effect.fn("EditorSchema.registrations")(function* (
144
+ directory: string,
145
+ ) {
146
+ const fs = yield* FileSystem.FileSystem
147
+ const path = yield* Path.Path
148
+ const text = yield* fs.readFileString(path.join(directory, "package.json"))
149
+ const manifest = yield* Schema.decodeEffect(ProjectManifest)(text)
150
+ const names = Array.dedupe(
151
+ Array.flatMap(
152
+ [
153
+ manifest.dependencies,
154
+ manifest.devDependencies,
155
+ manifest.optionalDependencies,
156
+ ],
157
+ (dependencies) => Record.keys(dependencies ?? {}),
158
+ ),
159
+ )
160
+ const plugins = yield* Effect.filter(names, (name) =>
161
+ exportsSchema(directory, name),
162
+ )
163
+ const importer = yield* Importer.make(directory)
164
+ const loaded = yield* Effect.forEach(plugins, (name) => load(importer, name))
165
+ return { plugins, entries: Array.flatten(loaded) }
166
+ }, Effect.scoped)
167
+
168
+ const encode = Schema.encodeEffect(
169
+ Schema.fromJsonString(Schema.Unknown, { space: "\t" }),
170
+ )
171
+
172
+ /** Writes the editor schema for the project in a directory. */
173
+ export const write = Effect.fn("EditorSchema.write")(function* (
174
+ directory: string,
175
+ output: string,
176
+ ) {
177
+ const fs = yield* FileSystem.FileSystem
178
+ const { plugins, entries } = yield* registrations(directory)
179
+ const text = yield* encode(make(entries))
180
+ yield* fs.writeFileString(output, text + "\n")
181
+ yield* Effect.logInfo(
182
+ plugins.length === 0
183
+ ? `Wrote ${output}; no dependency exports ./schema`
184
+ : `Wrote ${output} with ${Array.join(plugins, ", ")}`,
185
+ )
186
+ })
@@ -0,0 +1,2 @@
1
+ /** The editor schema that `graft schema` writes, and that add, remove and create keep current. */
2
+ export const schemaFileName = "graft.schema.json"