@graftland/cli 0.0.0-stage → 0.2.0-beta.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/LICENSE +202 -0
- package/README.md +22 -2
- package/README.zh-CN.md +22 -0
- package/README.zh-TW.md +22 -0
- package/dist/Manage.d.ts +13 -0
- package/dist/Manage.d.ts.map +1 -0
- package/dist/Manage.js +49 -0
- package/dist/Manage.js.map +1 -0
- package/dist/Start.d.ts +25 -0
- package/dist/Start.d.ts.map +1 -0
- package/dist/Start.js +58 -0
- package/dist/Start.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +12 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +36 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/internal/configSchema.d.ts +7 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +44 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/env.js +50 -0
- package/dist/internal/env.js.map +1 -0
- package/dist/internal/intercepts.js +12 -0
- package/dist/internal/intercepts.js.map +1 -0
- package/dist/internal/watch.js +25 -0
- package/dist/internal/watch.js.map +1 -0
- package/dist/package.js +5 -0
- package/dist/package.js.map +1 -0
- package/package.json +49 -3
- package/schema.json +178 -0
- package/src/Manage.ts +85 -0
- package/src/Start.ts +97 -0
- package/src/bin.ts +83 -0
- package/src/index.ts +5 -0
- package/src/internal/configSchema.ts +85 -0
- package/src/internal/env.ts +163 -0
- package/src/internal/intercepts.ts +23 -0
- package/src/internal/watch.ts +58 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON Schema of the config file that graft start reads, derived from the
|
|
3
|
+
* loader's entry fields and the intercepts the CLI registers. The build writes
|
|
4
|
+
* it to the package's schema.json, so each release ships its own version.
|
|
5
|
+
*/
|
|
6
|
+
import { Entry } from "@graftland/loader"
|
|
7
|
+
import { Array, JsonSchema, Record, Schema } from "effect"
|
|
8
|
+
import { registrations } from "./intercepts"
|
|
9
|
+
|
|
10
|
+
const fields = Entry.Entry.schema.fields
|
|
11
|
+
|
|
12
|
+
const Intercept = Schema.Struct(
|
|
13
|
+
Record.fromEntries(
|
|
14
|
+
Array.map(
|
|
15
|
+
registrations,
|
|
16
|
+
({ key, schema }) => [key, Schema.optionalKey(schema)] as const,
|
|
17
|
+
),
|
|
18
|
+
),
|
|
19
|
+
).annotate({
|
|
20
|
+
identifier: "Intercept",
|
|
21
|
+
description:
|
|
22
|
+
"Patches for references, applying to this entry and its descendants.",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const Metadata = [Schema.Record(Schema.String, Schema.Unknown)] as const
|
|
26
|
+
|
|
27
|
+
const shared = {
|
|
28
|
+
...fields,
|
|
29
|
+
id: Schema.optionalKey(
|
|
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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `!env` values come from: the process environment, then an optional
|
|
3
|
+
* `.env` file that watch mode reads again when it changes.
|
|
4
|
+
*/
|
|
5
|
+
import type { PlatformError } from "effect"
|
|
6
|
+
import {
|
|
7
|
+
Cause,
|
|
8
|
+
ConfigProvider,
|
|
9
|
+
Context,
|
|
10
|
+
Deferred,
|
|
11
|
+
Effect,
|
|
12
|
+
Exit,
|
|
13
|
+
FileSystem,
|
|
14
|
+
Layer,
|
|
15
|
+
Path,
|
|
16
|
+
Queue,
|
|
17
|
+
Ref,
|
|
18
|
+
Stream,
|
|
19
|
+
} from "effect"
|
|
20
|
+
|
|
21
|
+
// An empty variable is still set: it overrides the file, and the plugin's
|
|
22
|
+
// schema decides whether it is valid.
|
|
23
|
+
const options = { preserveEmptyStrings: true } as const
|
|
24
|
+
|
|
25
|
+
interface Loaded {
|
|
26
|
+
readonly target: string
|
|
27
|
+
readonly text: string
|
|
28
|
+
readonly provider: ConfigProvider.ConfigProvider
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class EnvFile extends Context.Service<
|
|
32
|
+
EnvFile,
|
|
33
|
+
{
|
|
34
|
+
/** Reads the file again; reports whether its contents changed. */
|
|
35
|
+
readonly reload: Effect.Effect<boolean, PlatformError.PlatformError>
|
|
36
|
+
}
|
|
37
|
+
>()("@graftland/cli/internal/env/EnvFile") {}
|
|
38
|
+
|
|
39
|
+
/** Provides `ConfigProvider` and `EnvFile`; a missing file fails the layer. */
|
|
40
|
+
export const layer = (file: string | undefined) =>
|
|
41
|
+
Layer.unwrap(
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const environment = ConfigProvider.fromEnv(options)
|
|
44
|
+
if (file === undefined)
|
|
45
|
+
return Layer.merge(
|
|
46
|
+
ConfigProvider.layer(environment),
|
|
47
|
+
Layer.succeed(EnvFile, { reload: Effect.succeed(false) }),
|
|
48
|
+
)
|
|
49
|
+
const fs = yield* FileSystem.FileSystem
|
|
50
|
+
// Resolve the request on every read, including polling. Never pin a
|
|
51
|
+
// symlink to its initial target. The provider and its baseline are
|
|
52
|
+
// committed together from the bytes actually read.
|
|
53
|
+
const read = Effect.gen(function* () {
|
|
54
|
+
const target = yield* fs.realPath(file)
|
|
55
|
+
const text = yield* fs.readFileString(target)
|
|
56
|
+
return {
|
|
57
|
+
target,
|
|
58
|
+
text,
|
|
59
|
+
provider: ConfigProvider.fromDotEnvContents(text, options),
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
const initial: Loaded = yield* read
|
|
63
|
+
const loaded = yield* Ref.make(initial)
|
|
64
|
+
const fromFile = ConfigProvider.make((path) =>
|
|
65
|
+
Effect.flatMap(Ref.get(loaded), ({ provider }) => provider.load(path)),
|
|
66
|
+
)
|
|
67
|
+
return Layer.merge(
|
|
68
|
+
ConfigProvider.layer(ConfigProvider.orElse(environment, fromFile)),
|
|
69
|
+
Layer.succeed(EnvFile, {
|
|
70
|
+
reload: Effect.gen(function* () {
|
|
71
|
+
const next = yield* read
|
|
72
|
+
const previous = yield* Ref.getAndSet(loaded, next)
|
|
73
|
+
return next.text !== previous.text
|
|
74
|
+
}),
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The handlers below let interruption through, and with it any failure raised
|
|
82
|
+
* while stopping. Log those; failures without interruption are handled there.
|
|
83
|
+
*/
|
|
84
|
+
const logStopped =
|
|
85
|
+
(file: string) =>
|
|
86
|
+
<A, E>(exit: Exit.Exit<A, E>) =>
|
|
87
|
+
Exit.isFailure(exit) &&
|
|
88
|
+
Cause.hasInterrupts(exit.cause) &&
|
|
89
|
+
(Cause.hasFails(exit.cause) || Cause.hasDies(exit.cause))
|
|
90
|
+
? Effect.logError(`Watching ${file} stopped with a failure`, exit.cause)
|
|
91
|
+
: Effect.void
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reloads the file when it changes and retries the loader's entries, which
|
|
95
|
+
* reads `!env` again. A file that cannot be read keeps its previous values.
|
|
96
|
+
*/
|
|
97
|
+
export const watchEnvFile = <E, R>(
|
|
98
|
+
file: string,
|
|
99
|
+
reload: Effect.Effect<boolean, PlatformError.PlatformError>,
|
|
100
|
+
retry: Effect.Effect<void, E, R>,
|
|
101
|
+
) =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
const fs = yield* FileSystem.FileSystem
|
|
104
|
+
const path = yield* Path.Path
|
|
105
|
+
const ready = yield* Deferred.make<void>()
|
|
106
|
+
// As in HMR, own the source scope inside the producer. Observe its
|
|
107
|
+
// complete Exit after cleanup, before merge can discard it.
|
|
108
|
+
const events = Stream.callback<
|
|
109
|
+
FileSystem.WatchEvent,
|
|
110
|
+
PlatformError.PlatformError
|
|
111
|
+
>(
|
|
112
|
+
(queue) =>
|
|
113
|
+
fs
|
|
114
|
+
.watch(path.dirname(file), {
|
|
115
|
+
onReady: () => Deferred.doneUnsafe(ready, Effect.void),
|
|
116
|
+
})
|
|
117
|
+
.pipe(
|
|
118
|
+
Stream.filter(
|
|
119
|
+
(event) => path.basename(event.path) === path.basename(file),
|
|
120
|
+
),
|
|
121
|
+
Stream.runForEachArray((events) => Queue.offerAll(queue, events)),
|
|
122
|
+
Effect.scoped,
|
|
123
|
+
Effect.onExit(logStopped(file)),
|
|
124
|
+
Effect.catchCauseIf(
|
|
125
|
+
(cause) => !Cause.hasInterrupts(cause),
|
|
126
|
+
(cause) => Effect.logError(`Watching ${file} failed`, cause),
|
|
127
|
+
),
|
|
128
|
+
// A failed native backend must not stop content reconciliation.
|
|
129
|
+
Effect.andThen(Effect.sleep("1 second")),
|
|
130
|
+
Effect.forever,
|
|
131
|
+
),
|
|
132
|
+
{ bufferSize: 16 },
|
|
133
|
+
)
|
|
134
|
+
// Native events accelerate updates. Poll the request too: its target
|
|
135
|
+
// may live outside this directory, change, or lose a notification.
|
|
136
|
+
yield* Stream.mergeAll(
|
|
137
|
+
[
|
|
138
|
+
Stream.as(events, undefined),
|
|
139
|
+
Stream.fromEffect(Deferred.await(ready)),
|
|
140
|
+
Stream.tick("1 second").pipe(Stream.drop(1)),
|
|
141
|
+
],
|
|
142
|
+
{ concurrency: "unbounded" },
|
|
143
|
+
).pipe(
|
|
144
|
+
Stream.runForEach(() =>
|
|
145
|
+
// Delay to let writes settle.
|
|
146
|
+
Effect.sleep("100 millis").pipe(
|
|
147
|
+
Effect.andThen(reload),
|
|
148
|
+
Effect.flatMap((changed) => (changed ? retry : Effect.void)),
|
|
149
|
+
Effect.catchCauseIf(
|
|
150
|
+
(cause) => !Cause.hasInterrupts(cause),
|
|
151
|
+
(cause) => Effect.logError(`Reloading ${file} failed`, cause),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
Effect.catchCauseIf(
|
|
156
|
+
(cause) => !Cause.hasInterrupts(cause),
|
|
157
|
+
(cause) => Effect.logError(`Stopped watching ${file}`, cause),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
}).pipe(
|
|
161
|
+
// Observe the whole watcher before forkScoped owns it.
|
|
162
|
+
Effect.onExit(logStopped(file)),
|
|
163
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Intercept } from "@graftland/loader"
|
|
2
|
+
import { LogLevel, References, Schema } from "effect"
|
|
3
|
+
|
|
4
|
+
/** References that can be configured by stock CLI entries. */
|
|
5
|
+
export const registrations = [
|
|
6
|
+
Intercept.make(
|
|
7
|
+
References.CurrentLogAnnotations,
|
|
8
|
+
Schema.Record(Schema.String, Schema.Unknown).annotate({
|
|
9
|
+
description:
|
|
10
|
+
"Shallowly merges with outer annotations; configured keys win.",
|
|
11
|
+
}),
|
|
12
|
+
(outer, patch) => ({ ...outer, ...patch }),
|
|
13
|
+
),
|
|
14
|
+
Intercept.make(
|
|
15
|
+
References.MinimumLogLevel,
|
|
16
|
+
Schema.Literals(LogLevel.values).annotate({
|
|
17
|
+
description: "Replaces the outer minimum log level.",
|
|
18
|
+
}),
|
|
19
|
+
(_, level) => level,
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export const layer = Intercept.layer(registrations)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type * as Hmr from "@graftland/hmr"
|
|
2
|
+
import { Array, Cause, Effect, Exit, Stream, String } from "effect"
|
|
3
|
+
|
|
4
|
+
const moduleExtensions = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]
|
|
5
|
+
|
|
6
|
+
/** Whether a change to this file should reload plugin modules. */
|
|
7
|
+
export const isModule = (path: string) =>
|
|
8
|
+
!Array.contains(String.split(/[/\\]/)(path), "node_modules") &&
|
|
9
|
+
Array.some(moduleExtensions, (extension) => String.endsWith(extension)(path))
|
|
10
|
+
|
|
11
|
+
const describe = (result: Hmr.ReloadResult) =>
|
|
12
|
+
result._tag === "Reloaded"
|
|
13
|
+
? Effect.logInfo(`Reloaded ${result.entries.length} plugin module(s)`)
|
|
14
|
+
: Effect.logWarning(
|
|
15
|
+
result.reason === "ambiguous-exports"
|
|
16
|
+
? `Restart required: shared plugin exports diverged in ${Array.join(result.modules, ", ")}`
|
|
17
|
+
: result.reason === "identity-conflict"
|
|
18
|
+
? `Restart required: plugin replacement identities conflict in ${Array.join(result.modules, ", ")}`
|
|
19
|
+
: `Restart required to pick up ${Array.join(result.modules, ", ")}`,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
/** Owns the HMR stream and retries failed loader entries after each reload. */
|
|
23
|
+
export const watchModules = <E, R>(
|
|
24
|
+
hmr: Pick<Hmr.Hmr, "watch">,
|
|
25
|
+
directory: string,
|
|
26
|
+
retry: Effect.Effect<void, E, R>,
|
|
27
|
+
) =>
|
|
28
|
+
hmr.watch([directory], { recursive: true, filter: isModule }).pipe(
|
|
29
|
+
Stream.runForEach((result) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
if (result._tag === "Failed")
|
|
32
|
+
return yield* Effect.failCause(result.cause)
|
|
33
|
+
yield* describe(result)
|
|
34
|
+
yield* retry
|
|
35
|
+
}).pipe(
|
|
36
|
+
Effect.catchCauseIf(
|
|
37
|
+
(cause) => !Cause.hasInterrupts(cause),
|
|
38
|
+
(cause) => Effect.logError("Reloading plugin modules failed", cause),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
Effect.catchCauseIf(
|
|
43
|
+
(cause) => !Cause.hasInterrupts(cause),
|
|
44
|
+
(cause) => Effect.logError("Watching plugin modules failed", cause),
|
|
45
|
+
),
|
|
46
|
+
// A backend failure ends the stream. Reinstall it after a delay rather than
|
|
47
|
+
// leaving the application running without a watcher.
|
|
48
|
+
Effect.andThen(Effect.sleep("1 second")),
|
|
49
|
+
Effect.forever,
|
|
50
|
+
// onExit runs uninterruptibly, after stream cleanup. Observe the entire
|
|
51
|
+
// worker before forkScoped owns it, including mixed interruption causes.
|
|
52
|
+
Effect.onExit((exit) =>
|
|
53
|
+
Exit.isFailure(exit) &&
|
|
54
|
+
(Cause.hasFails(exit.cause) || Cause.hasDies(exit.cause))
|
|
55
|
+
? Effect.logError("Module watcher exited with a failure", exit.cause)
|
|
56
|
+
: Effect.void,
|
|
57
|
+
),
|
|
58
|
+
)
|