@graftland/cli 0.1.0 → 0.2.0-beta.1

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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@graftland/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.1",
4
4
  "type": "module",
5
5
  "engines": {
6
- "node": ">=22.15.0"
6
+ "node": ">=22.19.0"
7
7
  },
8
8
  "description": "The graft command: start, create, add and remove",
9
9
  "license": "Apache-2.0",
@@ -15,15 +15,19 @@
15
15
  "sideEffects": false,
16
16
  "files": [
17
17
  "dist",
18
- "src"
18
+ "src",
19
+ "schema.json",
20
+ "README.zh-CN.md",
21
+ "README.zh-TW.md"
19
22
  ],
20
23
  "exports": {
21
24
  ".": "./dist/index.js",
22
25
  "./bin": "./dist/bin.js",
26
+ "./internal/configSchema": "./dist/internal/configSchema.js",
23
27
  "./package.json": "./package.json"
24
28
  },
25
29
  "peerDependencies": {
26
- "effect": "4.1.2"
30
+ "effect": "4.1.*"
27
31
  },
28
32
  "devDependencies": {
29
33
  "@effect/vitest": "4.1.2",
@@ -31,18 +35,18 @@
31
35
  "vitest": "5.1.0"
32
36
  },
33
37
  "dependencies": {
34
- "@graftland/graft": "0.1.0",
35
- "@graftland/loader": "0.1.0",
36
- "@graftland/hmr": "0.1.0",
37
- "@graftland/create": "0.1.0",
38
- "@graftland/package-manager": "0.1.0",
38
+ "@graftland/graft": "0.2.0-beta.1",
39
+ "@graftland/loader": "0.2.0-beta.1",
40
+ "@graftland/hmr": "0.2.0-beta.1",
41
+ "@graftland/create": "0.2.0-beta.1",
42
+ "@graftland/package-manager": "0.2.0-beta.1",
39
43
  "@effect/platform-node": "4.1.2"
40
44
  },
41
45
  "bin": {
42
46
  "graft": "./dist/bin.js"
43
47
  },
44
48
  "scripts": {
45
- "build": "tsdown",
49
+ "build": "tsdown && node scripts/schema.mjs",
46
50
  "test": "vitest run"
47
51
  }
48
52
  }
package/schema.json ADDED
@@ -0,0 +1,178 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "anyOf": [
4
+ {
5
+ "type": "array",
6
+ "items": {
7
+ "$ref": "#/definitions/Entry"
8
+ }
9
+ },
10
+ {
11
+ "type": "null"
12
+ }
13
+ ],
14
+ "title": "Graft config file",
15
+ "description": "The entries that graft start loads. Each entry loads one plugin, or groups other entries.",
16
+ "definitions": {
17
+ "Intercept": {
18
+ "type": "object",
19
+ "properties": {
20
+ "effect/References/CurrentLogAnnotations": {
21
+ "type": "object",
22
+ "description": "Shallowly merges with outer annotations; configured keys win."
23
+ },
24
+ "effect/References/MinimumLogLevel": {
25
+ "type": "string",
26
+ "enum": [
27
+ "All",
28
+ "Fatal",
29
+ "Error",
30
+ "Warn",
31
+ "Info",
32
+ "Debug",
33
+ "Trace",
34
+ "None"
35
+ ],
36
+ "description": "Replaces the outer minimum log level."
37
+ }
38
+ },
39
+ "additionalProperties": false,
40
+ "description": "Patches for references, applying to this entry and its descendants."
41
+ },
42
+ "Entry": {
43
+ "anyOf": [
44
+ {
45
+ "type": "object",
46
+ "properties": {
47
+ "id": {
48
+ "type": "string",
49
+ "description": "Identifies the entry. Graft adds one, and writes it to the file, if an entry has none."
50
+ },
51
+ "name": {
52
+ "type": "string",
53
+ "description": "The plugin's module: a relative path, a package name, or a short name. Ignored for groups."
54
+ },
55
+ "config": {
56
+ "type": "array",
57
+ "description": "The entries of this group.",
58
+ "items": {
59
+ "$ref": "#/definitions/Entry"
60
+ }
61
+ },
62
+ "disabled": {
63
+ "type": "boolean",
64
+ "description": "true unloads the entry but keeps it in the file."
65
+ },
66
+ "group": {
67
+ "type": "boolean",
68
+ "enum": [
69
+ true
70
+ ],
71
+ "description": "true makes the entry a group of other entries."
72
+ },
73
+ "inject": {
74
+ "type": "array",
75
+ "description": "Service keys to wait for before loading the plugin, in addition to its own inject.",
76
+ "items": {
77
+ "type": "string"
78
+ }
79
+ },
80
+ "isolate": {
81
+ "type": "object",
82
+ "additionalProperties": {
83
+ "anyOf": [
84
+ {
85
+ "type": "boolean",
86
+ "enum": [
87
+ true
88
+ ]
89
+ },
90
+ {
91
+ "type": "string"
92
+ }
93
+ ]
94
+ },
95
+ "description": "Service or event keys to isolate: true for a private realm, or a label for a shared one."
96
+ },
97
+ "intercept": {
98
+ "$ref": "#/definitions/Intercept"
99
+ }
100
+ },
101
+ "required": [
102
+ "name",
103
+ "group"
104
+ ],
105
+ "allOf": [
106
+ {
107
+ "type": "object",
108
+ "additionalProperties": {}
109
+ }
110
+ ]
111
+ },
112
+ {
113
+ "type": "object",
114
+ "properties": {
115
+ "id": {
116
+ "type": "string",
117
+ "description": "Identifies the entry. Graft adds one, and writes it to the file, if an entry has none."
118
+ },
119
+ "name": {
120
+ "type": "string",
121
+ "description": "The plugin's module: a relative path, a package name, or a short name. Ignored for groups."
122
+ },
123
+ "config": {
124
+ "description": "The plugin's input. For a group, its list of entries."
125
+ },
126
+ "disabled": {
127
+ "type": "boolean",
128
+ "description": "true unloads the entry but keeps it in the file."
129
+ },
130
+ "group": {
131
+ "type": "boolean",
132
+ "enum": [
133
+ false
134
+ ],
135
+ "description": "true makes the entry a group of other entries."
136
+ },
137
+ "inject": {
138
+ "type": "array",
139
+ "description": "Service keys to wait for before loading the plugin, in addition to its own inject.",
140
+ "items": {
141
+ "type": "string"
142
+ }
143
+ },
144
+ "isolate": {
145
+ "type": "object",
146
+ "additionalProperties": {
147
+ "anyOf": [
148
+ {
149
+ "type": "boolean",
150
+ "enum": [
151
+ true
152
+ ]
153
+ },
154
+ {
155
+ "type": "string"
156
+ }
157
+ ]
158
+ },
159
+ "description": "Service or event keys to isolate: true for a private realm, or a label for a shared one."
160
+ },
161
+ "intercept": {
162
+ "$ref": "#/definitions/Intercept"
163
+ }
164
+ },
165
+ "required": [
166
+ "name"
167
+ ],
168
+ "allOf": [
169
+ {
170
+ "type": "object",
171
+ "additionalProperties": {}
172
+ }
173
+ ]
174
+ }
175
+ ]
176
+ }
177
+ }
178
+ }
package/src/Manage.ts CHANGED
@@ -4,13 +4,13 @@
4
4
  import * as Create from "@graftland/create"
5
5
  import * as PackageManager from "@graftland/package-manager"
6
6
  import {
7
- Array as Arr,
7
+ Array,
8
8
  Config,
9
9
  Effect,
10
10
  Option,
11
+ Predicate,
11
12
  Record,
12
13
  Schema,
13
- String as Str,
14
14
  } from "effect"
15
15
 
16
16
  const report = (result: PackageManager.InstallResult) =>
@@ -18,16 +18,19 @@ const report = (result: PackageManager.InstallResult) =>
18
18
  `${result.agent}: ${result.added.length} added, ${result.updated.length} updated, ${result.removed.length} removed`,
19
19
  )
20
20
 
21
- /** Splits `name@range` (the scope's `@` excluded); a bare name means the latest version. */
22
- const parseSpec = (spec: string): readonly [string, string] =>
23
- Option.match(
24
- Option.filter(Str.lastIndexOf("@")(spec), (at) => at > 0),
25
- {
26
- onNone: () => [spec, "latest"] as const,
27
- onSome: (at) =>
28
- [Str.slice(0, at)(spec), Str.slice(at + 1)(spec)] as const,
29
- },
30
- )
21
+ /**
22
+ * `name@range`, split at the last `@` so a scope's `@` stays in the name. A
23
+ * bare name means the latest version.
24
+ */
25
+ const Spec = Schema.Union([
26
+ Schema.TemplateLiteralParser([
27
+ PackageManager.PackageName,
28
+ "@",
29
+ Schema.String,
30
+ ]),
31
+ PackageManager.PackageName,
32
+ ])
33
+ const decodeSpecs = Schema.decodeEffect(Schema.Array(Spec))
31
34
 
32
35
  const install = (
33
36
  directory: string,
@@ -62,10 +65,21 @@ export const create = (options: {
62
65
  })
63
66
 
64
67
  export const add = (specs: ReadonlyArray<string>) =>
65
- install(".", Record.fromEntries(Arr.map(specs, parseSpec)))
68
+ Effect.flatMap(decodeSpecs(specs), (specs) =>
69
+ install(
70
+ ".",
71
+ Record.fromEntries(
72
+ Array.map(specs, (spec) =>
73
+ Predicate.isString(spec)
74
+ ? ([spec, "latest"] as const)
75
+ : ([spec[0], spec[2]] as const),
76
+ ),
77
+ ),
78
+ ),
79
+ )
66
80
 
67
81
  export const remove = (names: ReadonlyArray<string>) =>
68
82
  install(
69
83
  ".",
70
- Record.fromEntries(Arr.map(names, (name) => [name, null] as const)),
84
+ Record.fromEntries(Array.map(names, (name) => [name, null] as const)),
71
85
  )
package/src/Start.ts CHANGED
@@ -4,41 +4,25 @@
4
4
  import { NodeServices } from "@effect/platform-node"
5
5
  import { Graft, Plugin } from "@graftland/graft"
6
6
  import * as Hmr from "@graftland/hmr"
7
- import { Include, Loader, ModuleResolver } from "@graftland/loader"
8
- import type { PlatformError } from "effect"
9
- import {
10
- Array as Arr,
11
- Effect,
12
- FileSystem,
13
- Layer,
14
- Logger,
15
- Path,
16
- Queue,
17
- Stream,
18
- String as Str,
19
- } from "effect"
7
+ import { Include, Loader, ModuleResolver, type Tree } from "@graftland/loader"
8
+ import type { PlatformError, Schema } from "effect"
9
+ import { Effect, FileSystem, Layer, Logger, Path } from "effect"
10
+ import { EnvFile, layer as env, watchEnvFile } from "./internal/env"
11
+ import { layer as intercepts } from "./internal/intercepts"
12
+ import { watchModules } from "./internal/watch"
20
13
 
21
14
  export interface Options {
22
15
  /** Path of the config file. */
23
16
  readonly config: string
24
17
  /** Reload plugin modules and the config file when they change. */
25
18
  readonly watch: boolean
19
+ /**
20
+ * A `.env` file that `!env` reads when the process environment does not set
21
+ * a variable. With `watch`, changes to it are read again.
22
+ */
23
+ readonly envFile?: string | undefined
26
24
  }
27
25
 
28
- const moduleExtensions = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]
29
-
30
- /** Whether a change to this file should reload plugin modules. */
31
- const isModule = (path: string) =>
32
- !Str.includes("node_modules")(path) &&
33
- Arr.some(moduleExtensions, (extension) => Str.endsWith(extension)(path))
34
-
35
- const describe = (result: Hmr.ReloadResult) =>
36
- result._tag === "Reloaded"
37
- ? Effect.logInfo(`Reloaded ${result.entries.length} plugin module(s)`)
38
- : Effect.logWarning(
39
- `Restart required to pick up ${Arr.join(result.modules, ", ")}`,
40
- )
41
-
42
26
  /**
43
27
  * Loads the entries of the config file and runs until interrupted. Plugin
44
28
  * modules are imported through the hot reloader, so bare specifiers resolve
@@ -46,9 +30,16 @@ const describe = (result: Hmr.ReloadResult) =>
46
30
  */
47
31
  export const run = (
48
32
  options: Options,
49
- ): Effect.Effect<never, Graft.ServiceConflict | PlatformError.BadArgument> =>
33
+ ): Effect.Effect<
34
+ never,
35
+ | Graft.ServiceConflict
36
+ | PlatformError.BadArgument
37
+ | PlatformError.PlatformError
38
+ | Schema.SchemaError
39
+ | Loader.StoreError
40
+ | Tree.InvalidOperation
41
+ > =>
50
42
  Effect.gen(function* () {
51
- const fs = yield* FileSystem.FileSystem
52
43
  const path = yield* Path.Path
53
44
  const file = path.resolve(options.config)
54
45
  const directory = path.dirname(file)
@@ -56,38 +47,30 @@ export const run = (
56
47
  const hmr = yield* Hmr.make({ base: base.href })
57
48
  const resolver = yield* ModuleResolver.make({
58
49
  base: directory,
59
- importer: hmr.importer,
50
+ importer: hmr.importerScoped,
60
51
  })
61
52
  yield* Graft.provide(ModuleResolver.ModuleResolver, resolver)
62
- yield* Graft.plugin(Include.Include, { path: file, watch: options.watch })
53
+ const included = yield* Graft.plugin(Include.Include, {
54
+ path: file,
55
+ watch: options.watch,
56
+ })
57
+ yield* included.await
63
58
  if (options.watch) {
64
- // A save raises several events; a one-slot sliding queue folds them into one pending reload.
65
- const changed = yield* Queue.sliding<void>(1)
66
- // Only module changes reload code; the config file is watched by the loader itself.
67
- yield* fs.watch(directory, { recursive: true }).pipe(
68
- Stream.filter((event) => isModule(event.path)),
69
- Stream.runForEach(() => Queue.offer(changed, undefined)),
70
- Effect.forkScoped,
71
- )
59
+ const { reload } = yield* EnvFile
60
+ const envFile = options.envFile
72
61
  const reloader = Plugin.make({
73
62
  name: "reloader",
74
- inject: [Loader.Loader],
63
+ inject: [Loader.Loader, FileSystem.FileSystem, Path.Path],
75
64
  apply: () =>
76
65
  Effect.gen(function* () {
77
66
  const loader = yield* Loader.Loader
78
- yield* Queue.take(changed).pipe(
79
- Effect.andThen(Effect.sleep("50 millis")),
80
- Effect.andThen(Queue.clear(changed)),
81
- Effect.andThen(hmr.reload),
82
- Effect.flatMap(describe),
83
- // Entries whose module failed to load get another chance with the new code.
84
- Effect.andThen(loader.retry),
85
- Effect.catchCause((cause) =>
86
- Effect.logError("Reloading plugin modules failed", cause),
87
- ),
88
- Effect.forever,
67
+ yield* watchModules(hmr, directory, loader.retry).pipe(
89
68
  Effect.forkScoped,
90
69
  )
70
+ if (envFile !== undefined)
71
+ yield* watchEnvFile(envFile, reload, loader.retry).pipe(
72
+ Effect.forkScoped,
73
+ )
91
74
  }),
92
75
  })
93
76
  yield* Graft.plugin(reloader)
@@ -105,6 +88,8 @@ export const run = (
105
88
  Graft.layer,
106
89
  Layer.mergeAll(
107
90
  NodeServices.layer,
91
+ intercepts,
92
+ env(options.envFile).pipe(Layer.provide(NodeServices.layer)),
108
93
  Logger.layer([Logger.consolePretty()]),
109
94
  ),
110
95
  ),
package/src/bin.ts CHANGED
@@ -3,10 +3,9 @@
3
3
  * The `graft` executable. Running this module starts the program.
4
4
  */
5
5
  import { NodeRuntime, NodeServices } from "@effect/platform-node"
6
- import { Effect, FileSystem, Path, Schema } from "effect"
6
+ import { Effect, Option } from "effect"
7
7
  import { Argument, Command, Flag } from "effect/cli"
8
- import * as Manage from "./Manage"
9
- import * as Start from "./Start"
8
+ import { version } from "../package.json" with { type: "json" }
10
9
 
11
10
  const start = Command.make(
12
11
  "start",
@@ -20,8 +19,22 @@ const start = Command.make(
20
19
  Flag.withDescription("Reload plugins and config when files change"),
21
20
  Flag.withDefault(false),
22
21
  ),
22
+ envFile: Flag.String("env-file").pipe(
23
+ Flag.withDescription(
24
+ "Read variables for !env from a .env file; the process environment takes precedence",
25
+ ),
26
+ Flag.optional,
27
+ ),
23
28
  },
24
- Start.run,
29
+ (options) =>
30
+ Effect.promise(() => import("./Start")).pipe(
31
+ Effect.flatMap((Start) =>
32
+ Start.run({
33
+ ...options,
34
+ envFile: Option.getOrUndefined(options.envFile),
35
+ }),
36
+ ),
37
+ ),
25
38
  ).pipe(
26
39
  Command.withDescription("Run the application described by a config file"),
27
40
  )
@@ -41,13 +54,19 @@ const create = Command.make(
41
54
  Flag.withDefault(false),
42
55
  ),
43
56
  },
44
- Manage.create,
57
+ (options) =>
58
+ Effect.promise(() => import("./Manage")).pipe(
59
+ Effect.flatMap((Manage) => Manage.create(options)),
60
+ ),
45
61
  ).pipe(Command.withDescription("Create a new Graft project"))
46
62
 
47
63
  const add = Command.make(
48
64
  "add",
49
65
  { packages: Argument.variadic(Argument.String("package")) },
50
- ({ packages }) => Manage.add(packages),
66
+ ({ packages }) =>
67
+ Effect.promise(() => import("./Manage")).pipe(
68
+ Effect.flatMap((Manage) => Manage.add(packages)),
69
+ ),
51
70
  ).pipe(
52
71
  Command.withDescription(
53
72
  "Add dependencies, restoring the project if installing fails",
@@ -57,7 +76,10 @@ const add = Command.make(
57
76
  const remove = Command.make(
58
77
  "remove",
59
78
  { packages: Argument.variadic(Argument.String("package")) },
60
- ({ packages }) => Manage.remove(packages),
79
+ ({ packages }) =>
80
+ Effect.promise(() => import("./Manage")).pipe(
81
+ Effect.flatMap((Manage) => Manage.remove(packages)),
82
+ ),
61
83
  ).pipe(
62
84
  Command.withDescription(
63
85
  "Remove dependencies, restoring the project if installing fails",
@@ -69,17 +91,7 @@ const graft = Command.make("graft").pipe(
69
91
  Command.withSubcommands([start, create, add, remove]),
70
92
  )
71
93
 
72
- const Manifest = Schema.fromJsonString(
73
- Schema.Struct({ version: Schema.String }),
94
+ Command.run(graft, { version }).pipe(
95
+ Effect.provide(NodeServices.layer),
96
+ NodeRuntime.runMain,
74
97
  )
75
-
76
- Effect.gen(function* () {
77
- const fs = yield* FileSystem.FileSystem
78
- const path = yield* Path.Path
79
- const file = yield* path.fromFileUrl(
80
- new URL("../package.json", import.meta.url),
81
- )
82
- const text = yield* fs.readFileString(file)
83
- const { version } = yield* Schema.decodeEffect(Manifest)(text)
84
- return yield* Command.run(graft, { version })
85
- }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain)
@@ -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
+ }