@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,274 @@
1
+ /**
2
+ * Picking from a list with the arrow keys.
3
+ *
4
+ * Upstream's prompts are enquirer, so its selection prompts navigate: up and
5
+ * down move a pointer, space ticks a box, enter answers. Typing a number
6
+ * instead — which is what this package did — is the same prompt only in the
7
+ * sense that it eventually returns a value.
8
+ *
9
+ * Written here rather than taken from a package, for the reason lumen was
10
+ * written rather than `@poppinss/cliui` taken: a dev-time dependency is a
11
+ * dependency every application installs. What a selection prompt needs is
12
+ * bounded — raw mode, five keys, and redrawing in place — and it is all below.
13
+ *
14
+ * Only ever entered on a TTY. A prompt with nothing to read from is refused
15
+ * before this by the caller, which is what makes the loop here able to assume
16
+ * a terminal.
17
+ */
18
+
19
+ import { stdin, stdout } from 'node:process'
20
+ import { clearLine, cursorTo, moveCursor } from 'node:readline'
21
+ import { ReamError } from '../errors/ReamError.js'
22
+ import { colourise } from './ui.js'
23
+
24
+ /** One line of the list, as the renderer needs it. */
25
+ export interface SelectionItem {
26
+ label: string
27
+ hint?: string
28
+ }
29
+
30
+ export interface SelectionOptions {
31
+ /** Printed above the list. */
32
+ title: string
33
+ items: readonly SelectionItem[]
34
+ /** Tick boxes and let several be picked. */
35
+ many?: boolean
36
+ /** Where the pointer starts, and what is ticked to begin with. */
37
+ selected?: readonly number[]
38
+ /** Type to narrow the list — the autocomplete case. */
39
+ filter?: boolean
40
+ /** How many lines of the list to show at once. */
41
+ limit?: number
42
+ /** Where the keys come from. Defaults to this process's stdin. */
43
+ input?: NodeJS.ReadStream
44
+ /** Where the list is drawn. Defaults to this process's stdout. */
45
+ output?: NodeJS.WriteStream
46
+ }
47
+
48
+ /** What the user did. Cancelling is not an empty answer, it is no answer. */
49
+ export type SelectionResult = { kind: 'picked'; indexes: number[] } | { kind: 'cancelled' }
50
+
51
+ const ARROW_UP = '\u001B[A'
52
+ const ARROW_DOWN = '\u001B[B'
53
+ const ARROW_LEFT = '\u001B[D'
54
+ const ARROW_RIGHT = '\u001B[C'
55
+ const ENTER = ['\r', '\n']
56
+ const CTRL_C = '\u0003'
57
+ const CTRL_D = '\u0004'
58
+ const ESCAPE = '\u001B'
59
+ const BACKSPACE = ['\u0008', '\u007F']
60
+
61
+ /**
62
+ * Run the list until the user answers.
63
+ *
64
+ * Resolves with the chosen indexes, in the order they were ticked for a
65
+ * multiple — the order the user built them in is the order they expect back.
66
+ */
67
+ export function runSelection(options: SelectionOptions): Promise<SelectionResult> {
68
+ if (options.items.length === 0) {
69
+ return Promise.reject(
70
+ new ReamError('E_CONSOLE_EMPTY_CHOICES', `"${options.title}" was asked with no options.`),
71
+ )
72
+ }
73
+
74
+ const input = options.input ?? stdin
75
+ const output = options.output ?? stdout
76
+
77
+ const state = {
78
+ // The pointer starts on the default rather than at the top: for a single
79
+ // choice that IS the default, since enter takes whatever it is on.
80
+ cursor: Math.max(0, options.selected?.[0] ?? 0),
81
+ ticked: new Set<number>(options.selected ?? []),
82
+ query: '',
83
+ /** Lines drawn last time, so the next draw can wipe exactly those. */
84
+ drawn: 0,
85
+ }
86
+ const limit = Math.max(1, options.limit ?? 10)
87
+
88
+ const visible = (): number[] => {
89
+ if (options.filter !== true || state.query === '') return options.items.map((_, i) => i)
90
+ const needle = state.query.toLowerCase()
91
+ return options.items
92
+ .map((_, index) => index)
93
+ .filter((index) => (options.items[index]?.label ?? '').toLowerCase().includes(needle))
94
+ }
95
+
96
+ return new Promise<SelectionResult>((resolve, reject) => {
97
+ const finish = (result: SelectionResult): void => {
98
+ erase(output, state.drawn)
99
+ teardown()
100
+ resolve(result)
101
+ }
102
+
103
+ const onData = (chunk: Buffer | string): void => {
104
+ const key = chunk.toString()
105
+ const shown = visible()
106
+
107
+ if (key === CTRL_C || key === CTRL_D || key === ESCAPE) {
108
+ finish({ kind: 'cancelled' })
109
+ return
110
+ }
111
+
112
+ if (ENTER.includes(key)) {
113
+ if (options.many === true) {
114
+ finish({ kind: 'picked', indexes: [...state.ticked] })
115
+ return
116
+ }
117
+ const index = shown[state.cursor]
118
+ // Enter on a filter that matches nothing answers nothing: there is no
119
+ // line under the pointer to take.
120
+ if (index === undefined) return
121
+ finish({ kind: 'picked', indexes: [index] })
122
+ return
123
+ }
124
+
125
+ if (key === ARROW_UP) {
126
+ state.cursor = state.cursor === 0 ? shown.length - 1 : state.cursor - 1
127
+ } else if (key === ARROW_DOWN) {
128
+ state.cursor = state.cursor === shown.length - 1 ? 0 : state.cursor + 1
129
+ } else if (options.many === true && key === ' ') {
130
+ const index = shown[state.cursor]
131
+ if (index !== undefined) {
132
+ if (state.ticked.has(index)) state.ticked.delete(index)
133
+ else state.ticked.add(index)
134
+ }
135
+ } else if (options.many === true && (key === ARROW_LEFT || key === ARROW_RIGHT)) {
136
+ // Upstream's multiple ticks and unticks with left and right too.
137
+ const index = shown[state.cursor]
138
+ if (index !== undefined) {
139
+ if (key === ARROW_RIGHT) state.ticked.add(index)
140
+ else state.ticked.delete(index)
141
+ }
142
+ } else if (options.filter === true && BACKSPACE.includes(key)) {
143
+ state.query = state.query.slice(0, -1)
144
+ state.cursor = 0
145
+ } else if (options.filter === true && isTypable(key)) {
146
+ state.query += key
147
+ state.cursor = 0
148
+ } else {
149
+ // A key nothing is bound to must not redraw: a mouse event arrives as
150
+ // a burst of escape sequences, and redrawing on each one flickers.
151
+ return
152
+ }
153
+
154
+ draw()
155
+ }
156
+
157
+ const draw = (): void => {
158
+ erase(output, state.drawn)
159
+ const shown = visible()
160
+ if (state.cursor >= shown.length) state.cursor = Math.max(0, shown.length - 1)
161
+
162
+ const lines: string[] = [heading(options, state.query)]
163
+ const window = windowOf(shown.length, state.cursor, limit)
164
+ for (let row = window.start; row < window.end; row++) {
165
+ const index = shown[row]
166
+ if (index === undefined) continue
167
+ const item = options.items[index]
168
+ if (item === undefined) continue
169
+ lines.push(
170
+ renderItem(item, row === state.cursor, options.many === true, state.ticked.has(index)),
171
+ )
172
+ }
173
+ if (shown.length === 0) lines.push(colourise(' no match', 'dim'))
174
+ if (shown.length > window.end - window.start) {
175
+ lines.push(colourise(` … ${shown.length - (window.end - window.start)} more`, 'dim'))
176
+ }
177
+
178
+ for (const line of lines) output.write(`${line}\n`)
179
+ state.drawn = lines.length
180
+ }
181
+
182
+ const teardown = (): void => {
183
+ input.removeListener('data', onData)
184
+ if (input.isTTY) input.setRawMode(false)
185
+ input.pause()
186
+ }
187
+
188
+ try {
189
+ if (!input.isTTY) {
190
+ throw new ReamError(
191
+ 'E_CONSOLE_NOT_INTERACTIVE',
192
+ `Cannot prompt for "${options.title}" — stdin is not interactive.`,
193
+ { hint: 'Pass the value as a flag, or script the answer with prompt.trap(name).' },
194
+ )
195
+ }
196
+ input.setRawMode(true)
197
+ input.resume()
198
+ input.on('data', onData)
199
+ draw()
200
+ } catch (error) {
201
+ teardown()
202
+ reject(error)
203
+ }
204
+ })
205
+ }
206
+
207
+ /** The title line, with what has been typed so far when there is a filter. */
208
+ function heading(options: SelectionOptions, query: string): string {
209
+ const help =
210
+ options.many === true
211
+ ? colourise(' (space to pick, enter to confirm)', 'dim')
212
+ : colourise(' (use arrow keys)', 'dim')
213
+ const typed = options.filter === true && query !== '' ? ` ${colourise(query, 'cyan')}` : ''
214
+ return `${colourise('?', 'green')} ${options.title}${typed}${help}`
215
+ }
216
+
217
+ /**
218
+ * One line of the list.
219
+ *
220
+ * A pointer says where you are; a box, only on a multiple, says what is
221
+ * taken. Without the box a multiple has no way to show four ticks at once,
222
+ * which is the whole of what it is for.
223
+ */
224
+ function renderItem(
225
+ item: SelectionItem,
226
+ underCursor: boolean,
227
+ many: boolean,
228
+ ticked: boolean,
229
+ ): string {
230
+ const pointer = underCursor ? colourise('❯', 'cyan') : ' '
231
+ const box = many ? `${ticked ? colourise('◉', 'cyan') : '◯'} ` : ''
232
+ const label = underCursor ? colourise(item.label, 'cyan') : item.label
233
+ const hint = item.hint === undefined ? '' : colourise(` — ${item.hint}`, 'dim')
234
+ return `${pointer} ${box}${label}${hint}`
235
+ }
236
+
237
+ /**
238
+ * Which slice of a long list to show.
239
+ *
240
+ * Kept around the cursor rather than paged: a pointer that walks off the
241
+ * bottom and reappears at the top of a new page loses the reader.
242
+ */
243
+ function windowOf(total: number, cursor: number, limit: number): { start: number; end: number } {
244
+ if (total <= limit) return { start: 0, end: total }
245
+ const half = Math.floor(limit / 2)
246
+ const start = Math.min(Math.max(0, cursor - half), total - limit)
247
+ return { start, end: start + limit }
248
+ }
249
+
250
+ /** Wipe the lines drawn last time, so the next draw replaces them. */
251
+ function erase(output: NodeJS.WriteStream, lines: number): void {
252
+ if (lines === 0) return
253
+ for (let row = 0; row < lines; row++) {
254
+ moveCursor(output, 0, -1)
255
+ cursorTo(output, 0)
256
+ clearLine(output, 0)
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Printable text, and not an escape sequence.
262
+ *
263
+ * A chunk, not a character: typing quickly — or pasting — delivers several at
264
+ * once, and a filter that took only single-character chunks silently dropped
265
+ * everything typed faster than the event loop.
266
+ */
267
+ function isTypable(chunk: string): boolean {
268
+ if (chunk.length === 0) return false
269
+ for (const character of chunk) {
270
+ const code = character.codePointAt(0) ?? 0
271
+ if (code < 0x20 || code === 0x7f) return false
272
+ }
273
+ return true
274
+ }
package/src/console/ui.ts CHANGED
@@ -1,37 +1,25 @@
1
1
  /**
2
- * Console UI — `this.logger` and `this.prompt` on every command (Console parity).
2
+ * The one colour helper the Kernel's help and the prompts use.
3
3
  *
4
- * Kept dependency-free: the framework ships no colour or prompt library, and
5
- * pulling one in for the CLI surface alone is not worth the install weight.
4
+ * It is a thin call into `@c9up/lumen` rather than a second palette: two
5
+ * tables of escape codes in one package is how `[ warn ]` ends up yellow in
6
+ * one command and plain in another.
7
+ *
8
+ * The environment is read on EVERY call, not cached, because a command can be
9
+ * run in-process with the environment changed around it — which is exactly
10
+ * what the tests covering `FORCE_COLOR` do.
6
11
  */
7
12
 
8
13
  import { stdout } from 'node:process'
14
+ import { ansiColors, type Colors, silentColors, supportsColor } from '@c9up/lumen'
9
15
 
10
- /** Colour is opt-out via NO_COLOR and skipped when piped to a file. */
11
- function supportsColour(stream: NodeJS.WriteStream): boolean {
12
- if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
13
- if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '') return true
14
- return Boolean(stream.isTTY)
15
- }
16
-
17
- const CODES = {
18
- reset: '\u001B[0m',
19
- dim: '\u001B[2m',
20
- red: '\u001B[31m',
21
- green: '\u001B[32m',
22
- yellow: '\u001B[33m',
23
- blue: '\u001B[34m',
24
- magenta: '\u001B[35m',
25
- cyan: '\u001B[36m',
26
- } as const
27
-
28
- export type Colour = keyof Omit<typeof CODES, 'reset'>
16
+ export type Colour = 'dim' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan'
29
17
 
30
18
  export function colourise(
31
19
  text: string,
32
20
  colour: Colour,
33
21
  stream: NodeJS.WriteStream = stdout,
34
22
  ): string {
35
- if (!supportsColour(stream)) return text
36
- return `${CODES[colour]}${text}${CODES.reset}`
23
+ const colors: Colors = supportsColor(stream) ? ansiColors() : silentColors()
24
+ return colors[colour](text)
37
25
  }
@@ -1,34 +1,76 @@
1
1
  /**
2
- * Saying why the process is about to restart.
2
+ * What the dev server says when a file changed.
3
3
  *
4
- * A change the loader cannot swap in place becomes an exit code the dev server
5
- * restarts on. From the terminal the only trace of it was the boot banner
6
- * printing itself a second time — which reads as a server rebooting on its own
7
- * rather than as a file that fell outside `hotHook.boundaries`. Upstream's dev
8
- * server logs the file it reloaded for; this is that line.
4
+ * Upstream's vocabulary, read off `@adonisjs/assembler`'s build rather than
5
+ * invented: a module the loader swapped in place is `invalidated <path>`, and
6
+ * one that could not be — so the process restarts — is `update <path>`. The
7
+ * word is green, the path is not, and that is the whole line.
8
+ *
9
+ * It used to carry the reason as well: which `hotHook` entry the file fell
10
+ * outside of. That read well the first time and was noise every time after,
11
+ * and it is not what upstream prints. What the reason existed for — a boot
12
+ * banner appearing twice with nothing to explain it — is answered by the line
13
+ * being there at all.
14
+ *
15
+ * The terminal is cleared first, as upstream clears it, so the logs after a
16
+ * restart are the only ones on screen. `ream dev --no-clear` turns it off —
17
+ * `ace serve --no-clear` is the same flag for the same reason.
9
18
  *
10
19
  * Kept apart from the loader entry so it can be tested: importing that entry
11
20
  * registers module hooks, which a test cannot undo.
12
21
  */
13
22
 
23
+ import { ansiColors, silentColors, supportsColor } from '@c9up/lumen'
24
+
14
25
  /** Why a change could not be swapped in place. */
15
26
  export type ReloadReason = 'outside-boundaries' | 'restart-list'
16
27
 
17
28
  /**
18
- * The line to print before restarting.
29
+ * Coloured only when the stream takes colour.
30
+ *
31
+ * Decided per call rather than once at import: this module is loaded by the
32
+ * hot entry long before anything knows where its output goes, and a cached
33
+ * answer would be the wrong one for a piped run.
34
+ */
35
+ function colors(): ReturnType<typeof ansiColors> {
36
+ return supportsColor(process.stdout) ? ansiColors() : silentColors()
37
+ }
38
+
39
+ /**
40
+ * Wipe the terminal, the way upstream does before a restart.
41
+ *
42
+ * `\u001Bc` is the full reset upstream writes, not a scroll — the point is
43
+ * that what follows is the only thing on screen. Off when the dev server was
44
+ * started with `--no-clear`, and off when nothing is watching a terminal at
45
+ * all: writing escape codes into a pipe leaves them in the file.
46
+ */
47
+ export function clearScreen(): void {
48
+ if (process.env.REAM_DEV_CLEAR_SCREEN === 'false') return
49
+ if (!process.stdout.isTTY) return
50
+ process.stdout.write('\u001Bc')
51
+ }
52
+
53
+ /**
54
+ * The line printed when a module was swapped in the running process.
19
55
  *
20
56
  * @param file - absolute path of the file that changed.
21
- * @param relativise - turns it into the path the editor shows, so the line can
22
- * be read at a glance.
57
+ * @param relativise - turns it into the path the editor shows.
58
+ */
59
+ export function invalidatedNotice(file: string, relativise: (file: string) => string): string {
60
+ return `${colors().green('invalidated')} ${relativise(file)}`
61
+ }
62
+
63
+ /**
64
+ * The line printed before the process restarts.
65
+ *
66
+ * `reason` is not in the text — it is what the caller already decided, and
67
+ * upstream prints one word — but it stays in the signature because the caller
68
+ * has it and a future line may want it.
23
69
  */
24
70
  export function fullReloadNotice(
25
71
  file: string,
26
- reason: ReloadReason,
72
+ _reason: ReloadReason,
27
73
  relativise: (file: string) => string,
28
74
  ): string {
29
- const why =
30
- reason === 'restart-list'
31
- ? ' (it is on `hotHook.restart`, which always restarts)'
32
- : ' (nothing importing it is inside a `hotHook.boundaries` entry, so it could not be swapped)'
33
- return `[ream] full reload — ${relativise(file)}${why}`
75
+ return `${colors().green('update')} ${relativise(file)}`
34
76
  }
@@ -1,9 +1,23 @@
1
1
  /**
2
2
  * Watching the project for the changes a hot reload reacts to.
3
3
  *
4
- * `fs.watch` with `recursive: true`, which Node implements on Linux, macOS and
5
- * Windows alike — the reason a watcher used to be a dependency here, and is not
6
- * any more.
4
+ * ONE `fs.watch` PER DIRECTORY, walked ourselves — not `recursive: true`, and
5
+ * never a watch on a file. That is not a preference, it is the bug this module
6
+ * was reported for: most editors save by writing a temporary file and renaming
7
+ * it over the original (JetBrains IDEs, several formatters, anything using
8
+ * write-and-replace for crash safety). The rename gives the path a NEW inode,
9
+ * and a watcher holding the old one is watching something that no longer has a
10
+ * name. Measured on Node 25, Linux:
11
+ *
12
+ * fs.watch(dir, { recursive: true }) first rename fires, then silence
13
+ * fs.watch(file) first rename fires, then silence
14
+ * fs.watch(dir) every rename fires
15
+ *
16
+ * So the first save hot-swapped and every one after it was ignored without a
17
+ * word — no reload, no restart, nothing in the log. A directory keeps its inode
18
+ * when a file inside it is replaced, which is why watching directories is the
19
+ * form that survives. A file to watch is watched through its parent instead,
20
+ * filtered by name.
7
21
  *
8
22
  * Two behaviours are worth stating because they are not obvious from the API:
9
23
  *
@@ -18,12 +32,33 @@
18
32
  * that window, and hands back over by restarting the process.
19
33
  */
20
34
 
21
- import { type FSWatcher, watch } from 'node:fs'
22
- import { join, resolve } from 'node:path'
35
+ import { type FSWatcher, readdirSync, realpathSync, statSync, watch } from 'node:fs'
36
+ import { basename, dirname, join, resolve } from 'node:path'
23
37
 
24
38
  /** How long to wait for a save to finish arriving before acting on it. */
25
39
  const SETTLE_MS = 40
26
40
 
41
+ /** Never walked: none of it is application source, and some of it is enormous. */
42
+ const SKIP_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.wolf'])
43
+
44
+ /**
45
+ * The half-written files an atomic save leaves behind for a few milliseconds.
46
+ *
47
+ * Reported, they would be a change to `Controller.ts.tmp8f3` — a file the graph
48
+ * has never heard of, on its way to being renamed into the one that matters.
49
+ */
50
+ function isEditorArtefact(name: string): boolean {
51
+ return (
52
+ name.endsWith('~') ||
53
+ name.endsWith('.swp') ||
54
+ name.endsWith('.swx') ||
55
+ name.startsWith('.#') ||
56
+ name.startsWith('.goutputstream') ||
57
+ /\.tmp[\w-]*$/.test(name) ||
58
+ /^\.?[\w.-]+\.(?:tmp|temp|crswap|part|partial)$/i.test(name)
59
+ )
60
+ }
61
+
27
62
  export interface WatcherOptions {
28
63
  /** Project root; every reported path is absolute, resolved against it. */
29
64
  root: string
@@ -33,6 +68,8 @@ export interface WatcherOptions {
33
68
  files: readonly string[]
34
69
  /** Called once per settled change, with an absolute path. */
35
70
  onChange: (file: string) => void
71
+ /** Told when a directory could not be watched for a reason worth knowing. */
72
+ onWarning?: (message: string) => void
36
73
  }
37
74
 
38
75
  export interface Watcher {
@@ -40,8 +77,11 @@ export interface Watcher {
40
77
  }
41
78
 
42
79
  export function watchProject(options: WatcherOptions): Watcher {
43
- const watchers: FSWatcher[] = []
80
+ const watchers = new Map<string, FSWatcher>()
44
81
  const pending = new Map<string, NodeJS.Timeout>()
82
+ /** Real paths already walked, so a symlink loop cannot spin forever. */
83
+ const visited = new Set<string>()
84
+ let closed = false
45
85
 
46
86
  const report = (file: string): void => {
47
87
  const existing = pending.get(file)
@@ -55,35 +95,139 @@ export function watchProject(options: WatcherOptions): Watcher {
55
95
  pending.set(file, timer)
56
96
  }
57
97
 
58
- for (const directory of options.directories) {
59
- const absolute = resolve(options.root, directory)
98
+ const warn = (message: string): void => options.onWarning?.(message)
99
+
100
+ /**
101
+ * Watch one directory, then everything under it.
102
+ *
103
+ * Called again for a directory that appears later: a `mkdir app/billing`
104
+ * during a session must be watched, and the parent's event is what says so.
105
+ *
106
+ * `announce` closes the gap between that mkdir and the watch being in place.
107
+ * `mkdir -p app/billing && cp Controller.ts app/billing/` writes the file
108
+ * before the watcher on the new directory exists, so what is already inside a
109
+ * directory discovered mid-session is reported as a change. At startup it is
110
+ * false, or booting would report every file in the project.
111
+ */
112
+ const watchDirectory = (absolute: string, announce = false): void => {
113
+ if (closed || watchers.has(absolute)) return
114
+
115
+ let real: string
116
+ try {
117
+ real = realpathSync(absolute)
118
+ if (!statSync(real).isDirectory()) return
119
+ } catch (error) {
120
+ // Absent is ordinary — the list is the framework's conventional layout,
121
+ // not a requirement, and a directory can be removed mid-session. Any
122
+ // other reason (no permission, too many open files) is worth saying, or
123
+ // the project silently stops hot-reloading.
124
+ if (!isMissing(error)) warn(`cannot watch ${absolute}: ${describe(error)}`)
125
+ return
126
+ }
127
+ if (visited.has(real)) return
128
+ visited.add(real)
129
+
130
+ try {
131
+ const watcher = watch(absolute, (_event, name) => {
132
+ if (typeof name !== 'string' || name === '') return
133
+ const child = join(absolute, name)
134
+ // A new directory has to be picked up, or nothing in it is ever seen.
135
+ // Cheap: only for names that are directories right now.
136
+ if (!SKIP_DIRECTORIES.has(name) && isDirectory(child)) watchDirectory(child, true)
137
+ if (isEditorArtefact(name)) return
138
+ report(child)
139
+ })
140
+ watcher.on('error', (error) => {
141
+ // A watcher whose directory was removed is done; anything else is a
142
+ // surprise the developer should hear about rather than lose.
143
+ watchers.delete(absolute)
144
+ if (!isMissing(error)) warn(`watch on ${absolute} failed: ${describe(error)}`)
145
+ })
146
+ watchers.set(absolute, watcher)
147
+ } catch (error) {
148
+ if (!isMissing(error)) warn(`cannot watch ${absolute}: ${describe(error)}`)
149
+ return
150
+ }
151
+
152
+ let entries: string[] = []
60
153
  try {
61
- watchers.push(
62
- watch(absolute, { recursive: true }, (_event, name) => {
63
- if (typeof name !== 'string' || name === '') return
64
- report(join(absolute, name))
65
- }),
66
- )
67
- } catch {
68
- // A directory this project does not have is not an error: the list is
69
- // the framework's conventional layout, not a requirement.
154
+ entries = readdirSync(absolute)
155
+ } catch (error) {
156
+ if (!isMissing(error)) warn(`cannot read ${absolute}: ${describe(error)}`)
157
+ return
70
158
  }
159
+ for (const entry of entries) {
160
+ if (SKIP_DIRECTORIES.has(entry)) continue
161
+ const child = join(absolute, entry)
162
+ if (isDirectory(child)) {
163
+ watchDirectory(child, announce)
164
+ continue
165
+ }
166
+ if (announce && !isEditorArtefact(entry)) report(child)
167
+ }
168
+ }
169
+
170
+ for (const directory of options.directories) {
171
+ watchDirectory(resolve(options.root, directory))
71
172
  }
72
173
 
174
+ // A file is watched through its parent directory, for the reason at the top
175
+ // of this module: `fs.watch` on the file itself holds an inode, and an atomic
176
+ // save replaces it. `.env` is saved by the same editors as everything else.
177
+ const watchedFiles = new Map<string, Set<string>>()
73
178
  for (const file of options.files) {
74
179
  const absolute = resolve(options.root, file)
180
+ const parent = dirname(absolute)
181
+ const names = watchedFiles.get(parent) ?? new Set<string>()
182
+ names.add(basename(absolute))
183
+ watchedFiles.set(parent, names)
184
+ }
185
+
186
+ for (const [parent, names] of watchedFiles) {
187
+ // The parent may already be covered as a watched directory — `.env` sits at
188
+ // the root, which is not in the list, but a caller is free to pass both.
189
+ if (watchers.has(parent)) continue
75
190
  try {
76
- watchers.push(watch(absolute, () => report(absolute)))
77
- } catch {
78
- // Same: a `.env` that does not exist is simply not watched.
191
+ const watcher = watch(parent, (_event, name) => {
192
+ if (typeof name !== 'string' || !names.has(name)) return
193
+ report(join(parent, name))
194
+ })
195
+ watcher.on('error', (error) => {
196
+ watchers.delete(parent)
197
+ if (!isMissing(error)) warn(`watch on ${parent} failed: ${describe(error)}`)
198
+ })
199
+ watchers.set(parent, watcher)
200
+ } catch (error) {
201
+ if (!isMissing(error)) warn(`cannot watch ${parent}: ${describe(error)}`)
79
202
  }
80
203
  }
81
204
 
82
205
  return {
83
206
  close: () => {
207
+ closed = true
84
208
  for (const timer of pending.values()) clearTimeout(timer)
85
209
  pending.clear()
86
- for (const watcher of watchers) watcher.close()
210
+ for (const watcher of watchers.values()) watcher.close()
211
+ watchers.clear()
87
212
  },
88
213
  }
89
214
  }
215
+
216
+ function isDirectory(path: string): boolean {
217
+ try {
218
+ return statSync(path).isDirectory()
219
+ } catch {
220
+ return false
221
+ }
222
+ }
223
+
224
+ /** ENOENT and friends: the path is simply not there, which is not a failure. */
225
+ function isMissing(error: unknown): boolean {
226
+ const code = (error as { code?: unknown }).code
227
+ return code === 'ENOENT' || code === 'ENOTDIR'
228
+ }
229
+
230
+ function describe(error: unknown): string {
231
+ if (error instanceof Error) return error.message
232
+ return String(error)
233
+ }