@graftland/cli 0.4.2 → 0.5.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/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* (
@@ -144,11 +170,23 @@ export const addPlugin = Effect.fn("Manage.addPlugin")(function* (
144
170
  const path = yield* Path.Path
145
171
  const fs = yield* FileSystem.FileSystem
146
172
  const directory = global ? yield* home : path.resolve(".")
147
- const packageName = Create.pluginPackage(name)
173
+ if (global) yield* prepareHome(directory)
174
+ const userAgent = yield* Config.String("npm_config_user_agent").pipe(
175
+ Config.withDefault(""),
176
+ )
177
+ const packageName = yield* Create.pluginPackage(name, {
178
+ directory,
179
+ userAgent,
180
+ }).pipe(
181
+ Effect.mapError((error) =>
182
+ error._tag === "PluginNotFound" || error._tag === "AmbiguousPluginName"
183
+ ? CliError.UserError.make({ cause: error, userMessage: error.message })
184
+ : error,
185
+ ),
186
+ )
148
187
  const module = `${packageName}/cli`
149
188
  const file = path.join(directory, "graft.yml")
150
189
  const text = yield* enabledConfig(file, module)
151
- if (global) yield* prepareHome(directory)
152
190
  yield* install(directory, Record.singleton(packageName, "latest"))
153
191
  // Once accepted, the native write must finish before command cleanup can proceed.
154
192
  yield* fs.writeFileString(file, text).pipe(Effect.uninterruptible)
package/src/Start.ts CHANGED
@@ -153,7 +153,7 @@ export const run = (
153
153
  const { reload: reloadDir } = yield* EnvDir
154
154
  const { envFile, envDir } = options
155
155
  const reloader = Plugin.make({
156
- name: "reloader",
156
+ name: "@graftland/cli/Start/reloader",
157
157
  inject: [Loader.Loader, FileSystem.FileSystem, Path.Path],
158
158
  apply: () =>
159
159
  Effect.gen(function* () {
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",
@@ -97,6 +98,26 @@ const remove = Command.make(
97
98
  ),
98
99
  )
99
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
+
100
121
  const graft = Command.make("graft").pipe(
101
122
  Command.withDescription("Run and manage Graft applications"),
102
123
  Command.withSharedFlags({
@@ -113,7 +134,7 @@ const program = Effect.gen(function* () {
113
134
  Effect.gen(function* () {
114
135
  const context = yield* Layer.build(Graft.layer)
115
136
  return yield* Host.run(
116
- [start, create, add, remove],
137
+ [start, create, add, remove, schema],
117
138
  graft,
118
139
  version,
119
140
  args,
@@ -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"
@@ -1,5 +1,6 @@
1
1
  import { Array, Cause, Effect, Exit, Stream, String } from "effect"
2
2
 
3
+ import type { Graft } from "@graftland/graft"
3
4
  import type * as Hmr from "@graftland/hmr"
4
5
 
5
6
  import { observeShutdown } from "./observeShutdown"
@@ -11,16 +12,40 @@ export const isModule = (path: string) =>
11
12
  !Array.contains(String.split(/[/\\]/)(path), "node_modules") &&
12
13
  Array.some(moduleExtensions, (extension) => String.endsWith(extension)(path))
13
14
 
15
+ /** One warning per failed or timed-out plugin; failures retain their full cause. */
16
+ const rolledBack = (failures: ReadonlyArray<Graft.ReplacementFailure>) =>
17
+ Effect.forEach(
18
+ failures,
19
+ (failure) =>
20
+ (failure._tag === "Failed"
21
+ ? Effect.logWarning(
22
+ `Kept the previous code: plugin "${failure.plugin}" failed to start`,
23
+ failure.cause,
24
+ )
25
+ : Effect.logWarning(
26
+ `Kept the previous code: plugin "${failure.plugin}" did not start in time`,
27
+ )
28
+ ).pipe(
29
+ Effect.annotateLogs({
30
+ "graft.fork": failure.fork,
31
+ "graft.plugin": failure.plugin,
32
+ }),
33
+ ),
34
+ { discard: true },
35
+ )
36
+
14
37
  const describe = (result: Hmr.ReloadResult) =>
15
38
  result._tag === "Reloaded"
16
39
  ? Effect.logInfo(`Reloaded ${result.entries.length} plugin module(s)`)
17
- : Effect.logWarning(
18
- result.reason === "ambiguous-exports"
19
- ? `Restart required: shared plugin exports diverged in ${Array.join(result.modules, ", ")}`
20
- : result.reason === "identity-conflict"
21
- ? `Restart required: plugin replacement identities conflict in ${Array.join(result.modules, ", ")}`
22
- : `Restart required to pick up ${Array.join(result.modules, ", ")}`,
23
- )
40
+ : result._tag === "RolledBack"
41
+ ? rolledBack(result.failures)
42
+ : Effect.logWarning(
43
+ result.reason === "ambiguous-exports"
44
+ ? `Restart required: shared plugin exports diverged in ${Array.join(result.modules, ", ")}`
45
+ : result.reason === "identity-conflict"
46
+ ? `Restart required: plugin replacement identities conflict in ${Array.join(result.modules, ", ")}`
47
+ : `Restart required to pick up ${Array.join(result.modules, ", ")}`,
48
+ )
24
49
 
25
50
  /** Owns the HMR stream and retries failed loader entries after each reload. */
26
51
  export const watchModules = <E, R>(