@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/dist/Manage.d.ts +4 -3
- package/dist/Manage.d.ts.map +1 -1
- package/dist/Manage.js +18 -0
- package/dist/Manage.js.map +1 -1
- package/dist/Start.d.ts +6 -0
- package/dist/Start.d.ts.map +1 -1
- package/dist/Start.js +8 -3
- package/dist/Start.js.map +1 -1
- package/dist/bin.js +9 -4
- package/dist/bin.js.map +1 -1
- package/dist/internal/commandHost.js +1 -1
- package/dist/internal/env.js +71 -26
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/schemaFile.js +115 -0
- package/dist/internal/schemaFile.js.map +1 -0
- package/dist/internal/schemaFileName.js +6 -0
- package/dist/internal/schemaFileName.js.map +1 -0
- package/package.json +6 -6
- package/schema.json +0 -2321
- package/src/Manage.ts +26 -0
- package/src/Start.ts +22 -3
- package/src/bin.ts +29 -1
- package/src/internal/env.ts +175 -46
- package/src/internal/schemaFile.ts +186 -0
- package/src/internal/schemaFileName.ts +2 -0
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 {
|
|
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
|
|
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(
|
|
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,
|
package/src/internal/env.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where `!env` values come from: the process environment, then an optional
|
|
3
|
-
* `.env` file
|
|
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
|
-
|
|
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:
|
|
38
|
+
readonly reload: Reload
|
|
38
39
|
}
|
|
39
40
|
>()("@graftland/cli/internal/env/EnvFile") {}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
70
|
-
ConfigProvider.layer(
|
|
71
|
-
Layer.succeed(EnvFile,
|
|
72
|
-
|
|
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:
|
|
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(
|
|
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
|
+
})
|