@c9up/ream 0.2.25 → 0.2.27

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.
Files changed (178) hide show
  1. package/README.md +2 -0
  2. package/dist/Codemods.d.ts +12 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +114 -23
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/HttpKernel.d.ts +5 -1
  7. package/dist/HttpKernel.d.ts.map +1 -1
  8. package/dist/HttpKernel.js +6 -6
  9. package/dist/HttpKernel.js.map +1 -1
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +87 -14
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/console/Kernel.d.ts.map +1 -1
  14. package/dist/console/Kernel.js +20 -1
  15. package/dist/console/Kernel.js.map +1 -1
  16. package/dist/console/cliui.d.ts +9 -245
  17. package/dist/console/cliui.d.ts.map +1 -1
  18. package/dist/console/cliui.js +9 -583
  19. package/dist/console/cliui.js.map +1 -1
  20. package/dist/console/make/commands.d.ts +19 -0
  21. package/dist/console/make/commands.d.ts.map +1 -0
  22. package/dist/console/make/commands.js +252 -0
  23. package/dist/console/make/commands.js.map +1 -0
  24. package/dist/console/make/eject.d.ts +28 -0
  25. package/dist/console/make/eject.d.ts.map +1 -0
  26. package/dist/console/make/eject.js +161 -0
  27. package/dist/console/make/eject.js.map +1 -0
  28. package/dist/console/make/flush.d.ts +51 -0
  29. package/dist/console/make/flush.d.ts.map +1 -0
  30. package/dist/console/make/flush.js +93 -0
  31. package/dist/console/make/flush.js.map +1 -0
  32. package/dist/console/make/generators.d.ts +36 -0
  33. package/dist/console/make/generators.d.ts.map +1 -0
  34. package/dist/console/make/generators.js +148 -0
  35. package/dist/console/make/generators.js.map +1 -0
  36. package/dist/console/make/naming.d.ts +32 -0
  37. package/dist/console/make/naming.d.ts.map +1 -0
  38. package/dist/console/make/naming.js +82 -0
  39. package/dist/console/make/naming.js.map +1 -0
  40. package/dist/console/make/resolve.d.ts +46 -0
  41. package/dist/console/make/resolve.d.ts.map +1 -0
  42. package/dist/console/make/resolve.js +113 -0
  43. package/dist/console/make/resolve.js.map +1 -0
  44. package/dist/console/prompts.d.ts.map +1 -1
  45. package/dist/console/prompts.js +34 -46
  46. package/dist/console/prompts.js.map +1 -1
  47. package/dist/console/selection.d.ts +54 -0
  48. package/dist/console/selection.d.ts.map +1 -0
  49. package/dist/console/selection.js +235 -0
  50. package/dist/console/selection.js.map +1 -0
  51. package/dist/console/ui.d.ts +9 -15
  52. package/dist/console/ui.d.ts.map +1 -1
  53. package/dist/console/ui.js +11 -24
  54. package/dist/console/ui.js.map +1 -1
  55. package/dist/dev/fullReload.d.ts +35 -10
  56. package/dist/dev/fullReload.d.ts.map +1 -1
  57. package/dist/dev/fullReload.js +55 -14
  58. package/dist/dev/fullReload.js.map +1 -1
  59. package/dist/dev/hotWatcher.d.ts +19 -3
  60. package/dist/dev/hotWatcher.d.ts.map +1 -1
  61. package/dist/dev/hotWatcher.js +168 -18
  62. package/dist/dev/hotWatcher.js.map +1 -1
  63. package/dist/dev/readyBanner.d.ts +45 -0
  64. package/dist/dev/readyBanner.d.ts.map +1 -0
  65. package/dist/dev/readyBanner.js +72 -0
  66. package/dist/dev/readyBanner.js.map +1 -0
  67. package/dist/dev/shortcuts.d.ts +41 -0
  68. package/dist/dev/shortcuts.d.ts.map +1 -0
  69. package/dist/dev/shortcuts.js +142 -0
  70. package/dist/dev/shortcuts.js.map +1 -0
  71. package/dist/errors/prettyPrintError.d.ts.map +1 -1
  72. package/dist/errors/prettyPrintError.js +5 -8
  73. package/dist/errors/prettyPrintError.js.map +1 -1
  74. package/dist/errors/renderError.d.ts +46 -0
  75. package/dist/errors/renderError.d.ts.map +1 -0
  76. package/dist/errors/renderError.js +197 -0
  77. package/dist/errors/renderError.js.map +1 -0
  78. package/dist/hot.js +12 -2
  79. package/dist/hot.js.map +1 -1
  80. package/dist/index.d.ts +4 -3
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +5 -2
  83. package/dist/index.js.map +1 -1
  84. package/dist/profiler/MemoryProfileStore.d.ts +23 -0
  85. package/dist/profiler/MemoryProfileStore.d.ts.map +1 -0
  86. package/dist/profiler/MemoryProfileStore.js +70 -0
  87. package/dist/profiler/MemoryProfileStore.js.map +1 -0
  88. package/dist/profiler/Profiler.d.ts +56 -0
  89. package/dist/profiler/Profiler.d.ts.map +1 -0
  90. package/dist/profiler/Profiler.js +162 -0
  91. package/dist/profiler/Profiler.js.map +1 -0
  92. package/dist/profiler/collectors.d.ts +105 -0
  93. package/dist/profiler/collectors.d.ts.map +1 -0
  94. package/dist/profiler/collectors.js +164 -0
  95. package/dist/profiler/collectors.js.map +1 -0
  96. package/dist/profiler/endpoint.d.ts +27 -0
  97. package/dist/profiler/endpoint.d.ts.map +1 -0
  98. package/dist/profiler/endpoint.js +79 -0
  99. package/dist/profiler/endpoint.js.map +1 -0
  100. package/dist/profiler/index.d.ts +26 -0
  101. package/dist/profiler/index.d.ts.map +1 -0
  102. package/dist/profiler/index.js +41 -0
  103. package/dist/profiler/index.js.map +1 -0
  104. package/dist/profiler/middleware.d.ts +17 -0
  105. package/dist/profiler/middleware.d.ts.map +1 -0
  106. package/dist/profiler/middleware.js +79 -0
  107. package/dist/profiler/middleware.js.map +1 -0
  108. package/dist/profiler/render.d.ts +23 -0
  109. package/dist/profiler/render.d.ts.map +1 -0
  110. package/dist/profiler/render.js +141 -0
  111. package/dist/profiler/render.js.map +1 -0
  112. package/dist/profiler/toolbar.d.ts +34 -0
  113. package/dist/profiler/toolbar.d.ts.map +1 -0
  114. package/dist/profiler/toolbar.js +104 -0
  115. package/dist/profiler/toolbar.js.map +1 -0
  116. package/dist/profiler/types.d.ts +70 -0
  117. package/dist/profiler/types.d.ts.map +1 -0
  118. package/dist/profiler/types.js +11 -0
  119. package/dist/profiler/types.js.map +1 -0
  120. package/dist/router/Router.d.ts +11 -0
  121. package/dist/router/Router.d.ts.map +1 -1
  122. package/dist/router/Router.js +16 -0
  123. package/dist/router/Router.js.map +1 -1
  124. package/dist/stubs/index.d.ts +11 -0
  125. package/dist/stubs/index.d.ts.map +1 -0
  126. package/dist/stubs/index.js +12 -0
  127. package/dist/stubs/index.js.map +1 -0
  128. package/dist/stubs/template.d.ts +63 -0
  129. package/dist/stubs/template.d.ts.map +1 -0
  130. package/dist/stubs/template.js +209 -0
  131. package/dist/stubs/template.js.map +1 -0
  132. package/events.win32-x64-msvc.node +0 -0
  133. package/index.win32-x64-msvc.node +0 -0
  134. package/package.json +7 -1
  135. package/scheduler.win32-x64-msvc.node +0 -0
  136. package/src/Codemods.ts +153 -29
  137. package/src/HttpKernel.ts +12 -8
  138. package/src/Ignitor.ts +95 -14
  139. package/src/console/Kernel.ts +21 -1
  140. package/src/console/cliui.ts +44 -754
  141. package/src/console/make/commands.ts +270 -0
  142. package/src/console/make/eject.ts +163 -0
  143. package/src/console/make/flush.ts +125 -0
  144. package/src/console/make/generators.ts +176 -0
  145. package/src/console/make/naming.ts +85 -0
  146. package/src/console/make/resolve.ts +134 -0
  147. package/src/console/prompts.ts +45 -64
  148. package/src/console/selection.ts +274 -0
  149. package/src/console/ui.ts +12 -24
  150. package/src/dev/fullReload.ts +57 -15
  151. package/src/dev/hotWatcher.ts +165 -21
  152. package/src/dev/readyBanner.ts +84 -0
  153. package/src/dev/shortcuts.ts +158 -0
  154. package/src/errors/prettyPrintError.ts +5 -5
  155. package/src/errors/renderError.ts +230 -0
  156. package/src/hot.ts +12 -2
  157. package/src/index.ts +23 -3
  158. package/src/profiler/MemoryProfileStore.ts +75 -0
  159. package/src/profiler/Profiler.ts +182 -0
  160. package/src/profiler/collectors.ts +230 -0
  161. package/src/profiler/endpoint.ts +98 -0
  162. package/src/profiler/index.ts +56 -0
  163. package/src/profiler/middleware.ts +83 -0
  164. package/src/profiler/render.ts +173 -0
  165. package/src/profiler/toolbar.ts +106 -0
  166. package/src/profiler/types.ts +75 -0
  167. package/src/router/Router.ts +16 -0
  168. package/src/stubs/index.ts +14 -0
  169. package/src/stubs/template.ts +262 -0
  170. package/stubs/make/command.stub +29 -0
  171. package/stubs/make/controller.stub +30 -0
  172. package/stubs/make/entity.stub +11 -0
  173. package/stubs/make/event.stub +13 -0
  174. package/stubs/make/listener.stub +13 -0
  175. package/stubs/make/middleware.stub +16 -0
  176. package/stubs/make/provider.stub +26 -0
  177. package/stubs/make/service.stub +27 -0
  178. package/stubs/make/validator.stub +10 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The `make:` commands.
3
+ *
4
+ * They used to live in the `ream` binary, which meant Rust had to render their
5
+ * stubs — and rendering a stub means evaluating `{{#if resourceful}}`, which is
6
+ * JavaScript. Two renderers is two behaviours waiting to drift, so the
7
+ * generators moved to where the engine already is, the way `make:migration`
8
+ * moved to atlas.
9
+ *
10
+ * One class per command, built from the generator table: Adonis ships a class
11
+ * per command too, and the table is what keeps ten of them from being ten
12
+ * copies. `--force` and the skip-rather-than-clobber rule are Adonis'.
13
+ * `--dry-run` and `--json` are ours, and they are why `@c9up/ream-mcp` can
14
+ * show a plan before anything is written.
15
+ */
16
+
17
+ import { BaseCommand } from '../BaseCommand.js'
18
+ import { args, flags } from '../decorators.js'
19
+ import type { CommandClass } from '../types.js'
20
+ import Eject from './eject.js'
21
+ import { flush, outcomePayload, type PlannedFile, plannedFile } from './flush.js'
22
+ import { GENERATORS, type Generator, generatorFor } from './generators.js'
23
+ import { nameProblem } from './naming.js'
24
+ import { renderStubFor } from './resolve.js'
25
+
26
+ /** A generator's output, or the message explaining why there is none. */
27
+ type Prepared = { file: PlannedFile; published: boolean } | { error: string }
28
+
29
+ /**
30
+ * Validate the names and render the stub. Shared by the single-kind commands
31
+ * and by `make:module`, so all four files are checked before the first write.
32
+ */
33
+ function prepare(
34
+ generator: Generator,
35
+ module: string | undefined,
36
+ name: string,
37
+ extra: { stack?: string; event?: string },
38
+ cwd: string,
39
+ ): Prepared {
40
+ for (const [label, value] of [
41
+ ['module', module],
42
+ ['name', name],
43
+ ] as const) {
44
+ if (value === undefined) continue
45
+ const problem = nameProblem(value)
46
+ if (problem !== undefined) return { error: `${label} "${value}": ${problem}` }
47
+ }
48
+
49
+ const state = generator.state(name, {
50
+ ...(module === undefined ? {} : { module }),
51
+ ...(extra.stack === undefined ? {} : { stack: extra.stack }),
52
+ ...(extra.event === undefined ? {} : { event: extra.event }),
53
+ })
54
+
55
+ try {
56
+ const rendered = renderStubFor(generator.kind, state, cwd)
57
+ return {
58
+ file: plannedFile(rendered.path, rendered.contents, cwd),
59
+ published: rendered.published,
60
+ }
61
+ } catch (error) {
62
+ return { error: error instanceof Error ? error.message : String(error) }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Write the plan and report it — as JSON when asked, as Adonis' action lines
68
+ * otherwise. `--json` prints nothing else: its whole contract is that the last
69
+ * line of stdout is the payload, and a stray warning above it is how that
70
+ * breaks.
71
+ */
72
+ function report(
73
+ command: BaseCommand,
74
+ entries: readonly PlannedFile[],
75
+ warnings: readonly string[],
76
+ options: { cwd: string; dryRun: boolean; force: boolean; json: boolean },
77
+ ): void {
78
+ const outcome = flush(entries, warnings, options)
79
+
80
+ if (options.json) {
81
+ process.stdout.write(`${JSON.stringify(outcomePayload(outcome))}\n`)
82
+ return
83
+ }
84
+
85
+ if (outcome.status === 'planned') {
86
+ for (const file of outcome.files) {
87
+ // The path AND the contents: a dry run that showed only the path would
88
+ // not answer the question it is asked.
89
+ command.logger.info(`${file.path}${file.exists ? ' (exists)' : ''} — dry run`)
90
+ command.logger.log(file.content)
91
+ }
92
+ } else {
93
+ for (const file of [...outcome.createdFiles, ...outcome.modifiedFiles]) {
94
+ command.logger.action(`create ${file}`).succeeded()
95
+ }
96
+ for (const file of outcome.skippedFiles) {
97
+ command.logger.action(`create ${file}`).skipped('file already exists, use --force')
98
+ }
99
+ }
100
+
101
+ for (const warning of warnings) command.logger.warning(warning)
102
+ }
103
+
104
+ /** Build the command class for one generator. */
105
+ function makeCommand(generator: Generator): CommandClass {
106
+ class MakeCommand extends BaseCommand {
107
+ static override commandName = generator.commandName
108
+ static override description = generator.description
109
+ // Filesystem only: scaffolding a file must not need a booted application,
110
+ // let alone a reachable database.
111
+ static override options = { startApp: false }
112
+
113
+ // `make:controller billing Invoice` — the module comes first, as the
114
+ // binary took it. Declared on the module-scoped commands only, so the
115
+ // others keep a single argument.
116
+ @args.string({
117
+ description: 'Module directory under app/',
118
+ required: generator.moduleScoped,
119
+ })
120
+ declare first: string
121
+
122
+ @args.string({ description: 'Name of the generated file', required: false })
123
+ declare second?: string
124
+
125
+ @flags.boolean({
126
+ description: 'Forcefully overwrite an existing file',
127
+ alias: 'f',
128
+ })
129
+ declare force?: boolean
130
+
131
+ @flags.boolean({
132
+ description: 'Print what would be written, without writing it',
133
+ })
134
+ declare dryRun?: boolean
135
+
136
+ @flags.boolean({ description: 'Report the outcome as a single JSON object' })
137
+ declare json?: boolean
138
+
139
+ @flags.string({
140
+ description: 'Which stack a middleware registers on: router, server or named',
141
+ })
142
+ declare stack?: string
143
+
144
+ @flags.string({ description: 'Event class this listener handles' })
145
+ declare event?: string
146
+
147
+ async run(): Promise<void> {
148
+ // Two positionals for a module-scoped generator, one for the rest.
149
+ const module = generator.moduleScoped ? this.first : undefined
150
+ const name = generator.moduleScoped ? (this.second ?? '') : this.first
151
+
152
+ if (generator.moduleScoped && this.second === undefined) {
153
+ this.logger.error(`${generator.commandName} takes a module and a name`)
154
+ this.exitCode = 1
155
+ return
156
+ }
157
+
158
+ const cwd = process.cwd()
159
+ const prepared = prepare(
160
+ generator,
161
+ module,
162
+ name,
163
+ {
164
+ ...(this.stack === undefined ? {} : { stack: this.stack }),
165
+ ...(this.event === undefined ? {} : { event: this.event }),
166
+ },
167
+ cwd,
168
+ )
169
+ if ('error' in prepared) {
170
+ this.logger.error(prepared.error)
171
+ this.exitCode = 1
172
+ return
173
+ }
174
+
175
+ report(this, [prepared.file], [], {
176
+ cwd,
177
+ dryRun: this.dryRun === true,
178
+ force: this.force === true,
179
+ json: this.json === true,
180
+ })
181
+
182
+ if (prepared.published && this.json !== true) {
183
+ // Worth saying: the output came from the project's own stub, so a
184
+ // surprise in it is not a bug in ream.
185
+ this.logger.info(`rendered from stubs/make/${generator.kind}.stub`)
186
+ }
187
+ }
188
+ }
189
+
190
+ // The class is anonymous to the debugger otherwise, and a stack trace
191
+ // naming `MakeCommand` ten times helps nobody.
192
+ Object.defineProperty(MakeCommand, 'name', {
193
+ value: `Make${generator.kind.charAt(0).toUpperCase()}${generator.kind.slice(1)}`,
194
+ })
195
+ return MakeCommand
196
+ }
197
+
198
+ /**
199
+ * `make:module` — an entity, a controller and a validator in one go.
200
+ *
201
+ * NOT the migration the binary also emitted: a migration is atlas' format and
202
+ * atlas' directory, and ream inventing one would produce a file its own
203
+ * `migration:run` never reads. `make:migration` is atlas' command, and this
204
+ * says so rather than guessing.
205
+ *
206
+ * The three are rendered before any is written, so a stub that fails to render
207
+ * leaves nothing half-generated behind.
208
+ */
209
+ class MakeModule extends BaseCommand {
210
+ static override commandName = 'make:module'
211
+ static override description = 'Generate an entity, a controller and a validator'
212
+ static override options = { startApp: false }
213
+
214
+ @args.string({ description: 'Module directory under app/' })
215
+ declare module: string
216
+
217
+ @args.string({ description: 'Name shared by the generated files' })
218
+ declare name: string
219
+
220
+ @flags.boolean({ description: 'Forcefully overwrite existing files', alias: 'f' })
221
+ declare force?: boolean
222
+
223
+ @flags.boolean({ description: 'Print what would be written, without writing it' })
224
+ declare dryRun?: boolean
225
+
226
+ @flags.boolean({ description: 'Report the outcome as a single JSON object' })
227
+ declare json?: boolean
228
+
229
+ async run(): Promise<void> {
230
+ const cwd = process.cwd()
231
+ const entries: PlannedFile[] = []
232
+
233
+ for (const kind of ['entity', 'controller', 'validator'] as const) {
234
+ const generator = generatorFor(kind)
235
+ if (generator === undefined) {
236
+ this.logger.error(`no generator for "${kind}"`)
237
+ this.exitCode = 1
238
+ return
239
+ }
240
+ const prepared = prepare(generator, this.module, this.name, {}, cwd)
241
+ if ('error' in prepared) {
242
+ this.logger.error(prepared.error)
243
+ this.exitCode = 1
244
+ return
245
+ }
246
+ entries.push(prepared.file)
247
+ }
248
+
249
+ const warnings = [
250
+ // Said, not done: a barrel is the application's file, and appending to it
251
+ // blindly is how an export ends up duplicated.
252
+ `if app/${this.module}/index.ts exists, add the new exports to it yourself`,
253
+ "no migration was generated — that one is atlas': run `ream make:migration`",
254
+ ]
255
+
256
+ report(this, entries, warnings, {
257
+ cwd,
258
+ dryRun: this.dryRun === true,
259
+ force: this.force === true,
260
+ json: this.json === true,
261
+ })
262
+ }
263
+ }
264
+
265
+ /** Every `make:` command, built once. */
266
+ export const MAKE_COMMANDS: readonly CommandClass[] = [
267
+ ...GENERATORS.map(makeCommand),
268
+ MakeModule,
269
+ Eject,
270
+ ]
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `eject` — copy a package's stub into the application so it can be edited.
3
+ *
4
+ * The other half of the override rule: `renderStub` prefers `stubs/<path>`
5
+ * when the application has one, and this is how it gets there. Copied
6
+ * verbatim, front matter included, because the `to:` line is part of what a
7
+ * project may want to change.
8
+ *
9
+ * Adonis' command, name and argument included — `node ace eject make/controller
10
+ * --pkg=@adonisjs/lucid`. `--pkg` reads the package's own `stubsRoot`, which
11
+ * every Ream package exports from its `./stubs` subpath, so ejecting atlas' or
12
+ * warden's templates needs nothing added here.
13
+ */
14
+
15
+ import * as fs from 'node:fs'
16
+ import * as path from 'node:path'
17
+ import { BaseCommand } from '../BaseCommand.js'
18
+ import { args, flags } from '../decorators.js'
19
+ import { GENERATORS } from './generators.js'
20
+ import {
21
+ isSafeStubPath,
22
+ PUBLISHED_ROOT,
23
+ REAM_STUBS_ROOT,
24
+ readStub,
25
+ splitFrontMatter,
26
+ stubPathFor,
27
+ } from './resolve.js'
28
+
29
+ /** Every `{{ name }}` a stub reads, so the listing cannot advertise a wrong one. */
30
+ function variablesOf(source: string): string[] {
31
+ const found = new Set<string>()
32
+ for (const match of source.matchAll(/\{\{\{?\s*([\w.]+)\s*\}?\}\}/g)) {
33
+ const name = match[1]
34
+ if (name !== undefined) found.add(name)
35
+ }
36
+ return [...found].sort()
37
+ }
38
+
39
+ /** The `stubsRoot` a module exports, when it exports one. */
40
+ async function stubsRootFrom(specifier: string): Promise<string | undefined> {
41
+ try {
42
+ const loaded: unknown = await import(specifier)
43
+ if (typeof loaded !== 'object' || loaded === null) return undefined
44
+ const root = (loaded as { stubsRoot?: unknown }).stubsRoot
45
+ return typeof root === 'string' ? root : undefined
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The `stubsRoot` a package exports, or nothing when it exports none.
53
+ *
54
+ * Read from the package rather than guessed from `node_modules/<pkg>/stubs`:
55
+ * a package is free to keep them elsewhere, and only its own export says
56
+ * where. Adonis reads the entrypoint, so that is tried first; the `./stubs`
57
+ * subpath is tried after, because a barrel that also runs in a browser cannot
58
+ * export a filesystem path — which is why Ream packages put it on a subpath.
59
+ */
60
+ async function stubsRootOf(pkg: string): Promise<string | undefined> {
61
+ if (pkg === '@c9up/ream') return REAM_STUBS_ROOT
62
+ return (await stubsRootFrom(pkg)) ?? (await stubsRootFrom(`${pkg}/stubs`))
63
+ }
64
+
65
+ /** Every `.stub` under `root`, as paths relative to it. */
66
+ function stubsUnder(root: string, prefix = ''): string[] {
67
+ const found: string[] = []
68
+ for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true })) {
69
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`
70
+ if (entry.isDirectory()) found.push(...stubsUnder(root, relative))
71
+ else if (entry.name.endsWith('.stub')) found.push(relative)
72
+ }
73
+ return found.sort()
74
+ }
75
+
76
+ export default class Eject extends BaseCommand {
77
+ static override commandName = 'eject'
78
+ static override description =
79
+ 'Copy a package stub into stubs/ so the project can customise what make: generates'
80
+ static override options = { startApp: false }
81
+
82
+ @args.string({
83
+ description: 'The stub to copy, e.g. make/controller.stub — omit for a directory or all',
84
+ required: false,
85
+ })
86
+ declare stubPath?: string
87
+
88
+ @flags.string({ description: 'Which package to read the stub from' })
89
+ declare pkg?: string
90
+
91
+ @flags.boolean({ description: 'List what can be ejected, and the variables each stub reads' })
92
+ declare list?: boolean
93
+
94
+ @flags.boolean({ description: 'Overwrite a stub the project already ejected' })
95
+ declare force?: boolean
96
+
97
+ async run(): Promise<void> {
98
+ const pkg = this.pkg ?? '@c9up/ream'
99
+ const stubsRoot = await stubsRootOf(pkg)
100
+ if (stubsRoot === undefined || !fs.existsSync(stubsRoot)) {
101
+ this.logger.error(`"${pkg}" ships no stubs (no \`stubsRoot\` on its ./stubs subpath)`)
102
+ this.exitCode = 1
103
+ return
104
+ }
105
+
106
+ if (this.list === true) {
107
+ this.#printList(stubsRoot)
108
+ return
109
+ }
110
+
111
+ const requested = this.stubPath
112
+ if (requested !== undefined && !isSafeStubPath(requested)) {
113
+ this.logger.error(`unusable stub path: ${requested}`)
114
+ this.exitCode = 1
115
+ return
116
+ }
117
+
118
+ // A path with no `.stub` suffix names a directory — `eject make` takes the
119
+ // lot, which is what makes ejecting every generator one command.
120
+ const available = stubsUnder(stubsRoot)
121
+ const selected =
122
+ requested === undefined
123
+ ? available
124
+ : requested.endsWith('.stub')
125
+ ? available.filter((candidate) => candidate === requested)
126
+ : available.filter((candidate) => candidate.startsWith(`${requested}/`))
127
+
128
+ if (selected.length === 0) {
129
+ this.logger.error(`"${pkg}" ships no stub matching "${requested ?? ''}"`)
130
+ this.exitCode = 1
131
+ return
132
+ }
133
+
134
+ for (const stub of selected) {
135
+ const destination = path.join(process.cwd(), PUBLISHED_ROOT, stub)
136
+ if (fs.existsSync(destination) && this.force !== true) {
137
+ this.logger
138
+ .action(`eject ${PUBLISHED_ROOT}/${stub}`)
139
+ .skipped('already ejected, use --force')
140
+ continue
141
+ }
142
+ fs.mkdirSync(path.dirname(destination), { recursive: true })
143
+ // The shipped one, never the project's copy: ejecting over your own edit
144
+ // with your own edit is a no-op that reads as success.
145
+ fs.writeFileSync(destination, fs.readFileSync(path.join(stubsRoot, stub)))
146
+ this.logger.action(`eject ${PUBLISHED_ROOT}/${stub}`).succeeded()
147
+ }
148
+ }
149
+
150
+ /** What each generator reads, taken from the stub rather than a kept list. */
151
+ #printList(stubsRoot: string): void {
152
+ const table = this.ui.table().head(['Stub', 'Variables'])
153
+ const generatorStubs = new Set(GENERATORS.map((generator) => stubPathFor(generator.kind)))
154
+ for (const stub of stubsUnder(stubsRoot)) {
155
+ const { source } = readStub(stubsRoot, stub, path.join(process.cwd(), '__none__'))
156
+ const variables = generatorStubs.has(stub)
157
+ ? variablesOf(splitFrontMatter(source).body).join(', ')
158
+ : ''
159
+ table.row([stub, variables])
160
+ }
161
+ table.render()
162
+ }
163
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Turning rendered stubs into files on disk.
3
+ *
4
+ * Split from the commands because the rules are the same for one file and for
5
+ * the three `make:module` emits, and they are not the obvious ones:
6
+ *
7
+ * - A file that is already there is SKIPPED, not overwritten and not an
8
+ * error — Adonis' generators answer `SKIPPED:` and carry on, and `--force`
9
+ * is how one asks for the overwrite.
10
+ * - With `--force`, a failure half-way through puts back what the same run
11
+ * had already written. A generator that leaves a clobbered entity behind
12
+ * after failing on the controller is worse than one that writes nothing.
13
+ * That rollback is ours, not Adonis': its generators never overwrite more
14
+ * than one file per command.
15
+ */
16
+
17
+ import * as fs from 'node:fs'
18
+ import * as path from 'node:path'
19
+
20
+ /** One file a generator intends to write. */
21
+ export interface PlannedFile {
22
+ path: string
23
+ content: string
24
+ /** Was something already there? Answered while planning, not after. */
25
+ exists: boolean
26
+ }
27
+
28
+ export interface FlushOptions {
29
+ cwd: string
30
+ dryRun: boolean
31
+ force: boolean
32
+ }
33
+
34
+ /**
35
+ * What a run did, in the shapes `--json` prints — see {@link outcomePayload}.
36
+ */
37
+ export type FlushOutcome =
38
+ | { status: 'planned'; files: PlannedFile[]; warnings: string[] }
39
+ | {
40
+ status: 'written'
41
+ createdFiles: string[]
42
+ modifiedFiles: string[]
43
+ skippedFiles: string[]
44
+ warnings: string[]
45
+ }
46
+
47
+ /** `exists` is read now, not at write time: a plan describes one moment. */
48
+ export function plannedFile(destination: string, content: string, cwd: string): PlannedFile {
49
+ return {
50
+ path: destination,
51
+ content,
52
+ exists: fs.existsSync(path.resolve(cwd, destination)),
53
+ }
54
+ }
55
+
56
+ export function flush(
57
+ entries: readonly PlannedFile[],
58
+ warnings: readonly string[],
59
+ options: FlushOptions,
60
+ ): FlushOutcome {
61
+ if (options.dryRun) {
62
+ return { status: 'planned', files: [...entries], warnings: [...warnings] }
63
+ }
64
+
65
+ const createdFiles: string[] = []
66
+ const modifiedFiles: string[] = []
67
+ const skippedFiles: string[] = []
68
+ /** What each written path held before, so a later failure can put it back. */
69
+ const rollbacks: Array<{ path: string; prior: Buffer | undefined }> = []
70
+
71
+ try {
72
+ for (const entry of entries) {
73
+ if (entry.exists && !options.force) {
74
+ skippedFiles.push(entry.path)
75
+ continue
76
+ }
77
+ const target = path.resolve(options.cwd, entry.path)
78
+ const prior = entry.exists ? fs.readFileSync(target) : undefined
79
+ fs.mkdirSync(path.dirname(target), { recursive: true })
80
+ fs.writeFileSync(target, entry.content)
81
+ rollbacks.push({ path: target, prior })
82
+ if (entry.exists) modifiedFiles.push(entry.path)
83
+ else createdFiles.push(entry.path)
84
+ }
85
+ } catch (error) {
86
+ rollback(rollbacks)
87
+ throw error
88
+ }
89
+
90
+ return { status: 'written', createdFiles, modifiedFiles, skippedFiles, warnings: [...warnings] }
91
+ }
92
+
93
+ /**
94
+ * Undo in reverse, and never throw: the caller is already reporting a failure,
95
+ * and a rollback error on top of it buries the one that matters. What could not
96
+ * be undone is named on stderr instead.
97
+ */
98
+ function rollback(rollbacks: readonly { path: string; prior: Buffer | undefined }[]): void {
99
+ for (const entry of [...rollbacks].reverse()) {
100
+ try {
101
+ if (entry.prior === undefined) fs.rmSync(entry.path, { force: true })
102
+ else fs.writeFileSync(entry.path, entry.prior)
103
+ } catch (error) {
104
+ const detail = error instanceof Error ? error.message : String(error)
105
+ process.stderr.write(` rollback: failed to restore ${entry.path}: ${detail}\n`)
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The outcome as the JSON object `--json` prints, kept in one place because it
112
+ * is a contract with another package rather than a debug dump: `ream-mcp` reads
113
+ * the last line of stdout and dispatches on which shape it is.
114
+ */
115
+ export function outcomePayload(outcome: FlushOutcome): Record<string, unknown> {
116
+ if (outcome.status === 'planned') {
117
+ return { files: outcome.files, warnings: outcome.warnings }
118
+ }
119
+ return {
120
+ createdFiles: outcome.createdFiles,
121
+ modifiedFiles: outcome.modifiedFiles,
122
+ skippedFiles: outcome.skippedFiles,
123
+ warnings: outcome.warnings,
124
+ }
125
+ }