@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.
- package/dist/Application.d.ts +27 -0
- package/dist/Application.d.ts.map +1 -0
- package/dist/Application.js +27 -0
- package/dist/Application.js.map +1 -0
- package/dist/Commands.d.ts +34 -0
- package/dist/Commands.d.ts.map +1 -0
- package/dist/Commands.js +31 -0
- package/dist/Commands.js.map +1 -0
- package/dist/Manage.d.ts +8 -5
- package/dist/Manage.d.ts.map +1 -1
- package/dist/Manage.js +60 -2
- package/dist/Manage.js.map +1 -1
- package/dist/Start.d.ts +2 -1
- package/dist/Start.d.ts.map +1 -1
- package/dist/Start.js +25 -9
- package/dist/Start.js.map +1 -1
- package/dist/bin.js +38 -11
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/internal/commandConfig.js +62 -0
- package/dist/internal/commandConfig.js.map +1 -0
- package/dist/internal/commandHost.js +80 -0
- package/dist/internal/commandHost.js.map +1 -0
- package/dist/internal/commandRegistry.js +59 -0
- package/dist/internal/commandRegistry.js.map +1 -0
- package/dist/internal/configSchema.d.ts +25 -4
- package/dist/internal/configSchema.d.ts.map +1 -1
- package/dist/internal/configSchema.js +6 -34
- package/dist/internal/configSchema.js.map +1 -1
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/importer.js +18 -0
- package/dist/internal/importer.js.map +1 -0
- package/dist/internal/intercepts.js.map +1 -1
- package/dist/internal/protocolOutput.js +67 -0
- package/dist/internal/protocolOutput.js.map +1 -0
- package/dist/internal/watch.js.map +1 -1
- package/package.json +10 -7
- package/schema.json +2321 -0
- package/src/Application.ts +55 -0
- package/src/Commands.ts +47 -0
- package/src/Manage.ts +81 -2
- package/src/Start.ts +100 -28
- package/src/bin.ts +80 -12
- package/src/index.ts +3 -0
- package/src/internal/commandConfig.ts +91 -0
- package/src/internal/commandHost.ts +216 -0
- package/src/internal/commandRegistry.ts +100 -0
- package/src/internal/configSchema.ts +10 -70
- package/src/internal/env.ts +3 -2
- package/src/internal/importer.ts +28 -0
- package/src/internal/intercepts.ts +2 -1
- package/src/internal/protocolOutput.ts +101 -0
- package/src/internal/watch.ts +4 -2
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Array,
|
|
3
|
+
Cause,
|
|
4
|
+
Console,
|
|
5
|
+
Effect,
|
|
6
|
+
Option,
|
|
7
|
+
Path,
|
|
8
|
+
Schema,
|
|
9
|
+
Stream,
|
|
10
|
+
String,
|
|
11
|
+
} from "effect"
|
|
12
|
+
import { CliError, CliOutput, Command } from "effect/cli"
|
|
13
|
+
|
|
14
|
+
import { Graft, Plugin, type Fork } from "@graftland/graft"
|
|
15
|
+
|
|
16
|
+
import { ProtocolOutput } from "../Application"
|
|
17
|
+
import { Commands, type Environment } from "../Commands"
|
|
18
|
+
import * as Configuration from "./commandConfig"
|
|
19
|
+
import * as Registry from "./commandRegistry"
|
|
20
|
+
import * as Importer from "./importer"
|
|
21
|
+
import { reserve } from "./protocolOutput"
|
|
22
|
+
|
|
23
|
+
class CommandPluginError extends Schema.TaggedError<
|
|
24
|
+
CommandPluginError,
|
|
25
|
+
{ readonly brand: unique symbol }
|
|
26
|
+
>()("CommandPluginError", {
|
|
27
|
+
plugin: Schema.String,
|
|
28
|
+
detail: Schema.String,
|
|
29
|
+
cause: Schema.optionalKey(Schema.Cause(Schema.Unknown, Schema.Unknown)),
|
|
30
|
+
}) {
|
|
31
|
+
override get message() {
|
|
32
|
+
return `${this.plugin}: ${this.detail}`
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const failure = (name: string, cause: Cause.Cause<unknown>) =>
|
|
36
|
+
CommandPluginError.make({
|
|
37
|
+
plugin: name,
|
|
38
|
+
detail: "Command plugin failed",
|
|
39
|
+
cause,
|
|
40
|
+
})
|
|
41
|
+
const observe = (fork: Fork.Fork, name: string) =>
|
|
42
|
+
fork.state.pipe(
|
|
43
|
+
Stream.runForEach((state) =>
|
|
44
|
+
state._tag === "Failed"
|
|
45
|
+
? Effect.fail(failure(name, state.cause))
|
|
46
|
+
: Effect.void,
|
|
47
|
+
),
|
|
48
|
+
Effect.andThen(Effect.never),
|
|
49
|
+
)
|
|
50
|
+
const activate = Effect.fnUntraced(function* (
|
|
51
|
+
entry: Effect.Success<ReturnType<typeof Configuration.discover>>[number],
|
|
52
|
+
registry: Effect.Success<ReturnType<typeof Registry.make>>,
|
|
53
|
+
) {
|
|
54
|
+
const { ConfigDirectory, Entry, ModuleResolver } = yield* Effect.promise(
|
|
55
|
+
() => import("@graftland/loader"),
|
|
56
|
+
).pipe(Effect.uninterruptible)
|
|
57
|
+
const path = yield* Path.Path
|
|
58
|
+
const directory = path.dirname(entry.file)
|
|
59
|
+
const importer = yield* Importer.make(directory)
|
|
60
|
+
const resolver = yield* ModuleResolver.make({ base: directory, importer })
|
|
61
|
+
const plugin = yield* resolver.resolve(entry.name)
|
|
62
|
+
const input = yield* Entry.resolveEnv(entry.config)
|
|
63
|
+
const owner = Plugin.make({
|
|
64
|
+
name: `command:${entry.name}`,
|
|
65
|
+
apply: Effect.fnUntraced(function* () {
|
|
66
|
+
yield* Graft.provide(Commands, registry.service(entry.name))
|
|
67
|
+
const child = yield* Graft.plugin(plugin, input)
|
|
68
|
+
const state = yield* child.state.pipe(
|
|
69
|
+
Stream.filter(
|
|
70
|
+
(state) => state._tag === "Active" || state._tag === "Failed",
|
|
71
|
+
),
|
|
72
|
+
Stream.runHead,
|
|
73
|
+
)
|
|
74
|
+
if (Option.isSome(state) && state.value._tag === "Failed")
|
|
75
|
+
return yield* failure(entry.name, state.value.cause)
|
|
76
|
+
yield* Plugin.fork(observe(child, entry.name))
|
|
77
|
+
}),
|
|
78
|
+
})
|
|
79
|
+
const fork = yield* Graft.plugin(owner, undefined, {
|
|
80
|
+
isolate: [Commands],
|
|
81
|
+
}).pipe(Effect.provideService(ConfigDirectory, Option.some(directory)))
|
|
82
|
+
yield* Effect.addFinalizer(() => fork.dispose.pipe(Effect.orDie))
|
|
83
|
+
yield* fork.await.pipe(
|
|
84
|
+
Effect.timeout("5 seconds"),
|
|
85
|
+
Effect.catchCause((cause) => Effect.fail(failure(entry.name, cause))),
|
|
86
|
+
)
|
|
87
|
+
if (!(yield* registry.has(entry.name)))
|
|
88
|
+
return yield* CommandPluginError.make({
|
|
89
|
+
plugin: entry.name,
|
|
90
|
+
detail: "The plugin did not register a command",
|
|
91
|
+
})
|
|
92
|
+
return fork
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Registration checks the error/environment bounds before this heterogeneous tree erases its input types.
|
|
96
|
+
const execute = <Name extends string, Input, ContextInput>(
|
|
97
|
+
command: Command.Command<
|
|
98
|
+
Name,
|
|
99
|
+
Input,
|
|
100
|
+
ContextInput,
|
|
101
|
+
CliError.CliError,
|
|
102
|
+
Environment
|
|
103
|
+
>,
|
|
104
|
+
version: string,
|
|
105
|
+
args: ReadonlyArray<string>,
|
|
106
|
+
): Effect.Effect<void, CliError.CliError, Environment> =>
|
|
107
|
+
Command.runWith(command, { version })(args)
|
|
108
|
+
export const run = Effect.fnUntraced(function* (
|
|
109
|
+
builtins: ReadonlyArray<Command.Command.Any>,
|
|
110
|
+
root: Command.Command<
|
|
111
|
+
"graft",
|
|
112
|
+
{ readonly stdio: boolean; readonly noPlugins: boolean },
|
|
113
|
+
{ readonly stdio: boolean; readonly noPlugins: boolean },
|
|
114
|
+
never,
|
|
115
|
+
never
|
|
116
|
+
>,
|
|
117
|
+
version: string,
|
|
118
|
+
args: ReadonlyArray<string>,
|
|
119
|
+
) {
|
|
120
|
+
const registry = yield* Registry.make(builtins)
|
|
121
|
+
const flags = Array.takeWhile(args, (arg) => arg !== "--")
|
|
122
|
+
const entries =
|
|
123
|
+
flags.includes("--no-plugins") ||
|
|
124
|
+
(args.length === 1 && args[0] === "--version")
|
|
125
|
+
? []
|
|
126
|
+
: yield* Configuration.discover(
|
|
127
|
+
Option.contains(
|
|
128
|
+
Array.findFirst(flags, (arg) => !String.startsWith("-")(arg)),
|
|
129
|
+
"start",
|
|
130
|
+
) &&
|
|
131
|
+
(flags.includes("--watch") || flags.includes("-w")),
|
|
132
|
+
)
|
|
133
|
+
const forks = yield* Effect.forEach(
|
|
134
|
+
entries,
|
|
135
|
+
(entry) =>
|
|
136
|
+
activate(entry, registry).pipe(
|
|
137
|
+
Effect.mapError((error) => failure(entry.name, Cause.fail(error))),
|
|
138
|
+
),
|
|
139
|
+
{ concurrency: "unbounded" },
|
|
140
|
+
)
|
|
141
|
+
const commands = yield* registry.freeze
|
|
142
|
+
return yield* execute(
|
|
143
|
+
root.pipe(
|
|
144
|
+
Command.withSubcommands([
|
|
145
|
+
...Array.map(builtins, Registry.erase),
|
|
146
|
+
...commands,
|
|
147
|
+
]),
|
|
148
|
+
),
|
|
149
|
+
version,
|
|
150
|
+
args,
|
|
151
|
+
).pipe(
|
|
152
|
+
Effect.raceFirst(
|
|
153
|
+
Effect.forEach(
|
|
154
|
+
forks,
|
|
155
|
+
(fork, index) => observe(fork, entries[index]?.name ?? "command"),
|
|
156
|
+
{ concurrency: "unbounded" },
|
|
157
|
+
).pipe(Effect.andThen(Effect.never)),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const usage = (message: string) =>
|
|
163
|
+
Console.error(message).pipe(
|
|
164
|
+
Effect.andThen(
|
|
165
|
+
CliError.UserError.make({
|
|
166
|
+
cause: "Invalid process flags",
|
|
167
|
+
userMessage: message,
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
/** Select output policy before discovery or CLI action flags can produce text. */
|
|
173
|
+
export const output = <A, E, R>(
|
|
174
|
+
effect: Effect.Effect<A, E, R>,
|
|
175
|
+
args: ReadonlyArray<string>,
|
|
176
|
+
) =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const tokens = Array.takeWhile(args, (arg) => arg !== "--")
|
|
179
|
+
const stdio = tokens.includes("--stdio")
|
|
180
|
+
const program = Effect.gen(function* () {
|
|
181
|
+
if (
|
|
182
|
+
stdio &&
|
|
183
|
+
Array.some(
|
|
184
|
+
tokens,
|
|
185
|
+
(arg) =>
|
|
186
|
+
arg === "--wizard" ||
|
|
187
|
+
String.startsWith("--wizard=")(arg) ||
|
|
188
|
+
arg === "--completions" ||
|
|
189
|
+
String.startsWith("--completions=")(arg),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return yield* usage(
|
|
193
|
+
"--stdio cannot be combined with wizard or completions",
|
|
194
|
+
)
|
|
195
|
+
if (
|
|
196
|
+
Array.some(
|
|
197
|
+
tokens,
|
|
198
|
+
(arg) =>
|
|
199
|
+
String.startsWith("--stdio=")(arg) ||
|
|
200
|
+
arg === "--no-stdio" ||
|
|
201
|
+
String.startsWith("--no-plugins=")(arg),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
return yield* usage("Use --stdio and --no-plugins as standalone flags")
|
|
205
|
+
return yield* effect
|
|
206
|
+
}).pipe(Effect.provideService(ProtocolOutput, stdio))
|
|
207
|
+
return yield* stdio
|
|
208
|
+
? program.pipe(
|
|
209
|
+
reserve,
|
|
210
|
+
Effect.provideService(
|
|
211
|
+
CliOutput.Formatter,
|
|
212
|
+
CliOutput.defaultFormatter({ colors: false }),
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
: program
|
|
216
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Array, Effect, HashMap, Option, TxRef } from "effect"
|
|
2
|
+
import { Command, type CliError } from "effect/cli"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CommandRegistrationError,
|
|
6
|
+
Commands,
|
|
7
|
+
type Environment,
|
|
8
|
+
} from "../Commands"
|
|
9
|
+
|
|
10
|
+
export type Registered = Command.Command<
|
|
11
|
+
string,
|
|
12
|
+
never,
|
|
13
|
+
{},
|
|
14
|
+
CliError.CliError,
|
|
15
|
+
Environment
|
|
16
|
+
>
|
|
17
|
+
/** The public register method checks E and R before input/name erasure. */
|
|
18
|
+
export const erase = (command: Command.Command.Any): Registered => command
|
|
19
|
+
|
|
20
|
+
const names = (command: Command.Command.Any) =>
|
|
21
|
+
command.alias === undefined ? [command.name] : [command.name, command.alias]
|
|
22
|
+
const validate = (
|
|
23
|
+
commands: ReadonlyArray<Command.Command.Any>,
|
|
24
|
+
): Effect.Effect<void, CommandRegistrationError> => {
|
|
25
|
+
const all = Array.flatMap(commands, names)
|
|
26
|
+
const duplicate = Array.findFirst(
|
|
27
|
+
all,
|
|
28
|
+
(name, index) => all.indexOf(name) !== index,
|
|
29
|
+
)
|
|
30
|
+
return Option.isSome(duplicate)
|
|
31
|
+
? Effect.fail(
|
|
32
|
+
CommandRegistrationError.make({
|
|
33
|
+
command: duplicate.value,
|
|
34
|
+
detail: "Duplicate command name or alias",
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
: Effect.forEach(
|
|
38
|
+
commands,
|
|
39
|
+
(command) =>
|
|
40
|
+
validate(
|
|
41
|
+
Array.flatMap(command.subcommands, (group) => group.commands),
|
|
42
|
+
),
|
|
43
|
+
{ discard: true },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
export const make = Effect.fnUntraced(function* (
|
|
47
|
+
builtins: ReadonlyArray<Command.Command.Any>,
|
|
48
|
+
) {
|
|
49
|
+
const state = yield* TxRef.make({
|
|
50
|
+
frozen: false,
|
|
51
|
+
entries: HashMap.empty<string, Registered>(),
|
|
52
|
+
})
|
|
53
|
+
const service = (owner: string) =>
|
|
54
|
+
Commands.of({
|
|
55
|
+
register: (command) =>
|
|
56
|
+
Effect.acquireRelease(
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const current = yield* TxRef.get(state)
|
|
59
|
+
if (current.frozen)
|
|
60
|
+
return yield* CommandRegistrationError.make({
|
|
61
|
+
command: command.name,
|
|
62
|
+
detail: "Registration is closed",
|
|
63
|
+
})
|
|
64
|
+
if (HashMap.has(current.entries, owner))
|
|
65
|
+
return yield* CommandRegistrationError.make({
|
|
66
|
+
command: command.name,
|
|
67
|
+
detail: "A plugin must register exactly one root command",
|
|
68
|
+
})
|
|
69
|
+
yield* validate([
|
|
70
|
+
...builtins,
|
|
71
|
+
...HashMap.values(current.entries),
|
|
72
|
+
command,
|
|
73
|
+
])
|
|
74
|
+
yield* TxRef.set(state, {
|
|
75
|
+
...current,
|
|
76
|
+
entries: HashMap.set(current.entries, owner, erase(command)),
|
|
77
|
+
})
|
|
78
|
+
}).pipe(Effect.tx),
|
|
79
|
+
() =>
|
|
80
|
+
TxRef.update(state, (current) => ({
|
|
81
|
+
...current,
|
|
82
|
+
entries: HashMap.remove(current.entries, owner),
|
|
83
|
+
})).pipe(Effect.tx),
|
|
84
|
+
),
|
|
85
|
+
})
|
|
86
|
+
const freeze = Effect.gen(function* () {
|
|
87
|
+
const current = yield* TxRef.get(state)
|
|
88
|
+
yield* TxRef.set(state, { ...current, frozen: true })
|
|
89
|
+
return Array.fromIterable(HashMap.values(current.entries))
|
|
90
|
+
}).pipe(Effect.tx)
|
|
91
|
+
return {
|
|
92
|
+
service,
|
|
93
|
+
freeze,
|
|
94
|
+
has: (owner: string) =>
|
|
95
|
+
TxRef.get(state).pipe(
|
|
96
|
+
Effect.map((current) => HashMap.has(current.entries, owner)),
|
|
97
|
+
Effect.tx,
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
})
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
import { Entry } from "@graftland/loader"
|
|
7
|
-
import { Array, JsonSchema, Record, Schema } from "effect"
|
|
8
|
-
import { registrations } from "./intercepts"
|
|
1
|
+
/** The CLI adds its intercepts to the loader's recursive editor schema. */
|
|
2
|
+
import { Array, Record, Schema } from "effect"
|
|
3
|
+
|
|
4
|
+
import { ConfigSchema } from "@graftland/loader"
|
|
9
5
|
|
|
10
|
-
|
|
6
|
+
import { registrations } from "./intercepts"
|
|
11
7
|
|
|
12
8
|
const Intercept = Schema.Struct(
|
|
13
9
|
Record.fromEntries(
|
|
@@ -22,64 +18,8 @@ const Intercept = Schema.Struct(
|
|
|
22
18
|
"Patches for references, applying to this entry and its descendants.",
|
|
23
19
|
})
|
|
24
20
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
fields.id.annotate({
|
|
31
|
-
description:
|
|
32
|
-
"Identifies the entry. Graft adds one, and writes it to the file, if an entry has none.",
|
|
33
|
-
}),
|
|
34
|
-
),
|
|
35
|
-
intercept: Schema.optionalKey(Intercept),
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Two shapes instead of one, so that only a group's config is checked as a
|
|
39
|
-
// list of entries; a plugin's config is checked by the plugin's own Schema.
|
|
40
|
-
const FileEntry: Schema.Top = Schema.Union([
|
|
41
|
-
Schema.StructWithRest(
|
|
42
|
-
Schema.Struct({
|
|
43
|
-
...shared,
|
|
44
|
-
group: Schema.Literal(true).annotate({
|
|
45
|
-
description: "true makes the entry a group of other entries.",
|
|
46
|
-
}),
|
|
47
|
-
config: Schema.optionalKey(
|
|
48
|
-
Schema.Array(Schema.suspend(() => FileEntry)).annotate({
|
|
49
|
-
description: "The entries of this group.",
|
|
50
|
-
}),
|
|
51
|
-
),
|
|
52
|
-
}),
|
|
53
|
-
Metadata,
|
|
54
|
-
),
|
|
55
|
-
Schema.StructWithRest(
|
|
56
|
-
Schema.Struct({
|
|
57
|
-
...shared,
|
|
58
|
-
group: Schema.optionalKey(
|
|
59
|
-
Schema.Literal(false).annotate({
|
|
60
|
-
description: "true makes the entry a group of other entries.",
|
|
61
|
-
}),
|
|
62
|
-
),
|
|
63
|
-
}),
|
|
64
|
-
Metadata,
|
|
65
|
-
),
|
|
66
|
-
]).annotate({ identifier: "Entry" })
|
|
67
|
-
|
|
68
|
-
const File = Schema.NullOr(Schema.Array(FileEntry)).annotate({
|
|
69
|
-
title: "Graft config file",
|
|
70
|
-
description:
|
|
71
|
-
"The entries that graft start loads. Each entry loads one plugin, or groups other entries.",
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
/** Draft-07, which YAML language servers support most widely. */
|
|
75
|
-
export const make = () => {
|
|
76
|
-
const document = JsonSchema.toDocumentDraft07(
|
|
77
|
-
// Only the intercept struct is closed: entries keep a rest record for metadata.
|
|
78
|
-
Schema.toJsonSchemaDocument(File, { onExcessProperty: "error" }),
|
|
79
|
-
)
|
|
80
|
-
return {
|
|
81
|
-
$schema: JsonSchema.META_SCHEMA_URI_DRAFT_07,
|
|
82
|
-
...document.schema,
|
|
83
|
-
definitions: document.definitions,
|
|
84
|
-
}
|
|
85
|
-
}
|
|
21
|
+
export const make = (entries?: ReadonlyArray<ConfigSchema.Registration>) =>
|
|
22
|
+
ConfigSchema.make({
|
|
23
|
+
...(entries === undefined ? {} : { entries }),
|
|
24
|
+
intercept: Intercept,
|
|
25
|
+
})
|
package/src/internal/env.ts
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
* Where `!env` values come from: the process environment, then an optional
|
|
3
3
|
* `.env` file that watch mode reads again when it changes.
|
|
4
4
|
*/
|
|
5
|
-
import { observeShutdown } from "./observeShutdown"
|
|
6
|
-
import type { PlatformError } from "effect"
|
|
7
5
|
import {
|
|
8
6
|
Cause,
|
|
9
7
|
ConfigProvider,
|
|
@@ -17,8 +15,11 @@ import {
|
|
|
17
15
|
Queue,
|
|
18
16
|
Ref,
|
|
19
17
|
Stream,
|
|
18
|
+
type PlatformError,
|
|
20
19
|
} from "effect"
|
|
21
20
|
|
|
21
|
+
import { observeShutdown } from "./observeShutdown"
|
|
22
|
+
|
|
22
23
|
// An empty variable is still set: it overrides the file, and the plugin's
|
|
23
24
|
// schema decides whether it is valid.
|
|
24
25
|
const options = { preserveEmptyStrings: true } as const
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { registerHooks } from "node:module"
|
|
2
|
+
|
|
3
|
+
import { Effect, Path, String } from "effect"
|
|
4
|
+
import { SecureRandom } from "effect/crypto"
|
|
5
|
+
|
|
6
|
+
/** Bare names resolve from the application; imported dependencies keep their own parent URLs. */
|
|
7
|
+
export const make = Effect.fnUntraced(function* (directory: string) {
|
|
8
|
+
const path = yield* Path.Path
|
|
9
|
+
const base = yield* path.toFileUrl(path.join(directory, "graft.yml"))
|
|
10
|
+
const random = yield* SecureRandom.SecureRandom
|
|
11
|
+
const id = yield* random.uuidV4()
|
|
12
|
+
const prefix = `graft-module-${id}:`
|
|
13
|
+
yield* Effect.acquireRelease(
|
|
14
|
+
Effect.sync(() =>
|
|
15
|
+
registerHooks({
|
|
16
|
+
resolve: (specifier, context, next) =>
|
|
17
|
+
String.startsWith(prefix)(specifier)
|
|
18
|
+
? next(String.slice(prefix.length)(specifier), {
|
|
19
|
+
...context,
|
|
20
|
+
parentURL: base.href,
|
|
21
|
+
})
|
|
22
|
+
: next(specifier, context),
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
(hooks) => Effect.sync(() => hooks.deregister()),
|
|
26
|
+
)
|
|
27
|
+
return (specifier: string): Promise<unknown> => import(prefix + specifier)
|
|
28
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Intercept } from "@graftland/loader"
|
|
2
1
|
import { LogLevel, References, Schema } from "effect"
|
|
3
2
|
|
|
3
|
+
import { Intercept } from "@graftland/loader"
|
|
4
|
+
|
|
4
5
|
/** References that can be configured by stock CLI entries. */
|
|
5
6
|
export const registrations = [
|
|
6
7
|
Intercept.make(
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import process from "node:process"
|
|
2
|
+
// @effect-diagnostics-next-line nodeBuiltinImport:off -- a native Writable retains the protocol writer while Node forwards all diagnostic output to stderr.
|
|
3
|
+
import { finished, Writable } from "node:stream"
|
|
4
|
+
|
|
5
|
+
import { Context, Effect, Logger, Option, PlatformError, Stdio } from "effect"
|
|
6
|
+
import { NodeSink } from "@effect/platform-node"
|
|
7
|
+
|
|
8
|
+
// A peer that stops reading but keeps the pipe open must not hold shutdown forever.
|
|
9
|
+
const flushTimeout = "2 seconds"
|
|
10
|
+
const flushTimedOut = new Error(
|
|
11
|
+
"The protocol peer stopped reading during shutdown",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
export const ProtocolStdio = Context.Reference(
|
|
15
|
+
"@graftland/cli/internal/ProtocolStdio",
|
|
16
|
+
{ defaultValue: (): Option.Option<Stdio.Stdio> => Option.none() },
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
/** Reserve the original stdout writer for Stdio; all process diagnostics use stderr. */
|
|
20
|
+
export const reserve = <A, E, R>(program: Effect.Effect<A, E, R>) =>
|
|
21
|
+
Effect.scoped(
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const stdio = yield* Stdio.Stdio
|
|
24
|
+
const protocol = yield* Effect.acquireRelease(
|
|
25
|
+
Effect.sync(() => {
|
|
26
|
+
const original = process.stdout.write
|
|
27
|
+
const write = original.bind(process.stdout)
|
|
28
|
+
const stream = new Writable({
|
|
29
|
+
write: (chunk, encoding, done) => {
|
|
30
|
+
write(chunk, encoding, done)
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
// Default worker forwarding waits for stdout's drain after write returns false.
|
|
34
|
+
const drain = () => {
|
|
35
|
+
process.stdout.emit("drain")
|
|
36
|
+
}
|
|
37
|
+
const error = (cause: Error) => {
|
|
38
|
+
stream.destroy(cause)
|
|
39
|
+
}
|
|
40
|
+
process.stdout.on("error", error)
|
|
41
|
+
process.stderr.on("drain", drain)
|
|
42
|
+
// Node's console and default worker forwarding use this same writer.
|
|
43
|
+
process.stdout.write = process.stderr.write.bind(process.stderr)
|
|
44
|
+
return { original, stream, drain, error }
|
|
45
|
+
}),
|
|
46
|
+
({ original, stream, drain, error }) =>
|
|
47
|
+
Effect.raceFirst(
|
|
48
|
+
Effect.callback<void>((resume) => {
|
|
49
|
+
const cleanup = finished(stream, (cause) => {
|
|
50
|
+
cleanup()
|
|
51
|
+
const timedOut = cause === flushTimedOut
|
|
52
|
+
const restore = () => {
|
|
53
|
+
// After a timeout, part of a frame may still be queued on stdout, so later
|
|
54
|
+
// output keeps going to stderr instead of corrupting the abandoned stream.
|
|
55
|
+
if (!timedOut) {
|
|
56
|
+
process.stdout.off("error", error)
|
|
57
|
+
process.stderr.off("drain", drain)
|
|
58
|
+
process.stdout.write = original
|
|
59
|
+
}
|
|
60
|
+
resume(
|
|
61
|
+
cause == null || timedOut ? Effect.void : Effect.die(cause),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
// Keep the native error listener until every accepted write and delayed error has settled.
|
|
65
|
+
if (process.stdout.destroyed && !process.stdout.closed)
|
|
66
|
+
process.stdout.once("close", restore)
|
|
67
|
+
else restore()
|
|
68
|
+
})
|
|
69
|
+
stream.end()
|
|
70
|
+
}),
|
|
71
|
+
// Unflushed bytes for a peer that stopped reading are dropped, not a cleanup failure.
|
|
72
|
+
// Node cannot close stdio, so a clean exit still waits for the stalled peer; signals and failures exit.
|
|
73
|
+
Effect.sleep(flushTimeout).pipe(
|
|
74
|
+
Effect.andThen(Effect.sync(() => stream.destroy(flushTimedOut))),
|
|
75
|
+
Effect.andThen(Effect.never),
|
|
76
|
+
Effect.interruptible,
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
const reserved = {
|
|
81
|
+
...stdio,
|
|
82
|
+
stdout: (options?: { readonly endOnDone?: boolean }) =>
|
|
83
|
+
NodeSink.fromWritable({
|
|
84
|
+
evaluate: () => protocol.stream,
|
|
85
|
+
onError: (cause) =>
|
|
86
|
+
PlatformError.systemError({
|
|
87
|
+
module: "Stdio",
|
|
88
|
+
method: "stdout",
|
|
89
|
+
_tag: "Unknown",
|
|
90
|
+
cause,
|
|
91
|
+
}),
|
|
92
|
+
endOnDone: options?.endOnDone ?? false,
|
|
93
|
+
}),
|
|
94
|
+
}
|
|
95
|
+
return yield* program.pipe(
|
|
96
|
+
Effect.provideService(Logger.LogToStderr, true),
|
|
97
|
+
Effect.provideService(Stdio.Stdio, reserved),
|
|
98
|
+
Effect.provideService(ProtocolStdio, Option.some(reserved)),
|
|
99
|
+
)
|
|
100
|
+
}),
|
|
101
|
+
)
|
package/src/internal/watch.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { observeShutdown } from "./observeShutdown"
|
|
2
|
-
import type * as Hmr from "@graftland/hmr"
|
|
3
1
|
import { Array, Cause, Effect, Exit, Stream, String } from "effect"
|
|
4
2
|
|
|
3
|
+
import type * as Hmr from "@graftland/hmr"
|
|
4
|
+
|
|
5
|
+
import { observeShutdown } from "./observeShutdown"
|
|
6
|
+
|
|
5
7
|
const moduleExtensions = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]
|
|
6
8
|
|
|
7
9
|
/** Whether a change to this file should reload plugin modules. */
|