@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.
- package/README.md +2 -0
- package/dist/Codemods.d.ts +12 -0
- package/dist/Codemods.d.ts.map +1 -1
- package/dist/Codemods.js +114 -23
- package/dist/Codemods.js.map +1 -1
- package/dist/HttpKernel.d.ts +5 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +6 -6
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +87 -14
- package/dist/Ignitor.js.map +1 -1
- package/dist/console/Kernel.d.ts.map +1 -1
- package/dist/console/Kernel.js +20 -1
- package/dist/console/Kernel.js.map +1 -1
- package/dist/console/cliui.d.ts +9 -245
- package/dist/console/cliui.d.ts.map +1 -1
- package/dist/console/cliui.js +9 -583
- package/dist/console/cliui.js.map +1 -1
- package/dist/console/make/commands.d.ts +19 -0
- package/dist/console/make/commands.d.ts.map +1 -0
- package/dist/console/make/commands.js +252 -0
- package/dist/console/make/commands.js.map +1 -0
- package/dist/console/make/eject.d.ts +28 -0
- package/dist/console/make/eject.d.ts.map +1 -0
- package/dist/console/make/eject.js +161 -0
- package/dist/console/make/eject.js.map +1 -0
- package/dist/console/make/flush.d.ts +51 -0
- package/dist/console/make/flush.d.ts.map +1 -0
- package/dist/console/make/flush.js +93 -0
- package/dist/console/make/flush.js.map +1 -0
- package/dist/console/make/generators.d.ts +36 -0
- package/dist/console/make/generators.d.ts.map +1 -0
- package/dist/console/make/generators.js +148 -0
- package/dist/console/make/generators.js.map +1 -0
- package/dist/console/make/naming.d.ts +32 -0
- package/dist/console/make/naming.d.ts.map +1 -0
- package/dist/console/make/naming.js +82 -0
- package/dist/console/make/naming.js.map +1 -0
- package/dist/console/make/resolve.d.ts +46 -0
- package/dist/console/make/resolve.d.ts.map +1 -0
- package/dist/console/make/resolve.js +113 -0
- package/dist/console/make/resolve.js.map +1 -0
- package/dist/console/prompts.d.ts.map +1 -1
- package/dist/console/prompts.js +34 -46
- package/dist/console/prompts.js.map +1 -1
- package/dist/console/selection.d.ts +54 -0
- package/dist/console/selection.d.ts.map +1 -0
- package/dist/console/selection.js +235 -0
- package/dist/console/selection.js.map +1 -0
- package/dist/console/ui.d.ts +9 -15
- package/dist/console/ui.d.ts.map +1 -1
- package/dist/console/ui.js +11 -24
- package/dist/console/ui.js.map +1 -1
- package/dist/dev/fullReload.d.ts +35 -10
- package/dist/dev/fullReload.d.ts.map +1 -1
- package/dist/dev/fullReload.js +55 -14
- package/dist/dev/fullReload.js.map +1 -1
- package/dist/dev/hotWatcher.d.ts +19 -3
- package/dist/dev/hotWatcher.d.ts.map +1 -1
- package/dist/dev/hotWatcher.js +168 -18
- package/dist/dev/hotWatcher.js.map +1 -1
- package/dist/dev/readyBanner.d.ts +45 -0
- package/dist/dev/readyBanner.d.ts.map +1 -0
- package/dist/dev/readyBanner.js +72 -0
- package/dist/dev/readyBanner.js.map +1 -0
- package/dist/dev/shortcuts.d.ts +41 -0
- package/dist/dev/shortcuts.d.ts.map +1 -0
- package/dist/dev/shortcuts.js +142 -0
- package/dist/dev/shortcuts.js.map +1 -0
- package/dist/errors/prettyPrintError.d.ts.map +1 -1
- package/dist/errors/prettyPrintError.js +5 -8
- package/dist/errors/prettyPrintError.js.map +1 -1
- package/dist/errors/renderError.d.ts +46 -0
- package/dist/errors/renderError.d.ts.map +1 -0
- package/dist/errors/renderError.js +197 -0
- package/dist/errors/renderError.js.map +1 -0
- package/dist/hot.js +12 -2
- package/dist/hot.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/profiler/MemoryProfileStore.d.ts +23 -0
- package/dist/profiler/MemoryProfileStore.d.ts.map +1 -0
- package/dist/profiler/MemoryProfileStore.js +70 -0
- package/dist/profiler/MemoryProfileStore.js.map +1 -0
- package/dist/profiler/Profiler.d.ts +56 -0
- package/dist/profiler/Profiler.d.ts.map +1 -0
- package/dist/profiler/Profiler.js +162 -0
- package/dist/profiler/Profiler.js.map +1 -0
- package/dist/profiler/collectors.d.ts +105 -0
- package/dist/profiler/collectors.d.ts.map +1 -0
- package/dist/profiler/collectors.js +164 -0
- package/dist/profiler/collectors.js.map +1 -0
- package/dist/profiler/endpoint.d.ts +27 -0
- package/dist/profiler/endpoint.d.ts.map +1 -0
- package/dist/profiler/endpoint.js +79 -0
- package/dist/profiler/endpoint.js.map +1 -0
- package/dist/profiler/index.d.ts +26 -0
- package/dist/profiler/index.d.ts.map +1 -0
- package/dist/profiler/index.js +41 -0
- package/dist/profiler/index.js.map +1 -0
- package/dist/profiler/middleware.d.ts +17 -0
- package/dist/profiler/middleware.d.ts.map +1 -0
- package/dist/profiler/middleware.js +79 -0
- package/dist/profiler/middleware.js.map +1 -0
- package/dist/profiler/render.d.ts +23 -0
- package/dist/profiler/render.d.ts.map +1 -0
- package/dist/profiler/render.js +141 -0
- package/dist/profiler/render.js.map +1 -0
- package/dist/profiler/toolbar.d.ts +34 -0
- package/dist/profiler/toolbar.d.ts.map +1 -0
- package/dist/profiler/toolbar.js +104 -0
- package/dist/profiler/toolbar.js.map +1 -0
- package/dist/profiler/types.d.ts +70 -0
- package/dist/profiler/types.d.ts.map +1 -0
- package/dist/profiler/types.js +11 -0
- package/dist/profiler/types.js.map +1 -0
- package/dist/router/Router.d.ts +11 -0
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +16 -0
- package/dist/router/Router.js.map +1 -1
- package/dist/stubs/index.d.ts +11 -0
- package/dist/stubs/index.d.ts.map +1 -0
- package/dist/stubs/index.js +12 -0
- package/dist/stubs/index.js.map +1 -0
- package/dist/stubs/template.d.ts +63 -0
- package/dist/stubs/template.d.ts.map +1 -0
- package/dist/stubs/template.js +209 -0
- package/dist/stubs/template.js.map +1 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +7 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Codemods.ts +153 -29
- package/src/HttpKernel.ts +12 -8
- package/src/Ignitor.ts +95 -14
- package/src/console/Kernel.ts +21 -1
- package/src/console/cliui.ts +44 -754
- package/src/console/make/commands.ts +270 -0
- package/src/console/make/eject.ts +163 -0
- package/src/console/make/flush.ts +125 -0
- package/src/console/make/generators.ts +176 -0
- package/src/console/make/naming.ts +85 -0
- package/src/console/make/resolve.ts +134 -0
- package/src/console/prompts.ts +45 -64
- package/src/console/selection.ts +274 -0
- package/src/console/ui.ts +12 -24
- package/src/dev/fullReload.ts +57 -15
- package/src/dev/hotWatcher.ts +165 -21
- package/src/dev/readyBanner.ts +84 -0
- package/src/dev/shortcuts.ts +158 -0
- package/src/errors/prettyPrintError.ts +5 -5
- package/src/errors/renderError.ts +230 -0
- package/src/hot.ts +12 -2
- package/src/index.ts +23 -3
- package/src/profiler/MemoryProfileStore.ts +75 -0
- package/src/profiler/Profiler.ts +182 -0
- package/src/profiler/collectors.ts +230 -0
- package/src/profiler/endpoint.ts +98 -0
- package/src/profiler/index.ts +56 -0
- package/src/profiler/middleware.ts +83 -0
- package/src/profiler/render.ts +173 -0
- package/src/profiler/toolbar.ts +106 -0
- package/src/profiler/types.ts +75 -0
- package/src/router/Router.ts +16 -0
- package/src/stubs/index.ts +14 -0
- package/src/stubs/template.ts +262 -0
- package/stubs/make/command.stub +29 -0
- package/stubs/make/controller.stub +30 -0
- package/stubs/make/entity.stub +11 -0
- package/stubs/make/event.stub +13 -0
- package/stubs/make/listener.stub +13 -0
- package/stubs/make/middleware.stub +16 -0
- package/stubs/make/provider.stub +26 -0
- package/stubs/make/service.stub +27 -0
- package/stubs/make/validator.stub +10 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The profiler's own pages, and the bar injected into yours.
|
|
3
|
+
*
|
|
4
|
+
* Plain HTML and one inline stylesheet. A dev tool that needed a build step,
|
|
5
|
+
* or pulled a framework into the page it is inspecting, would be a worse
|
|
6
|
+
* neighbour than the problem it reports — and the bar lives in a shadow root
|
|
7
|
+
* for the same reason: your stylesheet must not reach it, and it must not
|
|
8
|
+
* reach yours.
|
|
9
|
+
*
|
|
10
|
+
* EVERY value that came from a request is escaped. A profiler renders the
|
|
11
|
+
* URL someone asked for, the SQL a query ran and the message an error carried
|
|
12
|
+
* — all attacker-reachable in exactly the case you most want to look at, and a
|
|
13
|
+
* tool that turned a crafted URL into script in your own browser would be a
|
|
14
|
+
* hole with a friendly face.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Profile, ProfileSummary } from './types.js'
|
|
18
|
+
|
|
19
|
+
/** `<`, `&` and the quotes. Everything below goes through it. */
|
|
20
|
+
export function escapeHtml(value: unknown): string {
|
|
21
|
+
return String(value)
|
|
22
|
+
.replaceAll('&', '&')
|
|
23
|
+
.replaceAll('<', '<')
|
|
24
|
+
.replaceAll('>', '>')
|
|
25
|
+
.replaceAll('"', '"')
|
|
26
|
+
.replaceAll("'", ''')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STYLE = `
|
|
30
|
+
:root { color-scheme: dark }
|
|
31
|
+
body { margin: 0; background: #14161a; color: #e6e6e6;
|
|
32
|
+
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace }
|
|
33
|
+
a { color: #6cb6ff; text-decoration: none }
|
|
34
|
+
a:hover { text-decoration: underline }
|
|
35
|
+
header { padding: 16px 20px; border-bottom: 1px solid #262a31 }
|
|
36
|
+
h1 { margin: 0; font-size: 15px; font-weight: 600 }
|
|
37
|
+
.meta { color: #8b949e; margin-top: 4px }
|
|
38
|
+
main { padding: 20px }
|
|
39
|
+
section { margin-bottom: 28px }
|
|
40
|
+
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
|
|
41
|
+
color: #8b949e; margin: 0 0 10px }
|
|
42
|
+
table { width: 100%; border-collapse: collapse }
|
|
43
|
+
td, th { text-align: left; padding: 6px 10px; border-bottom: 1px solid #21252c;
|
|
44
|
+
vertical-align: top }
|
|
45
|
+
th { color: #8b949e; font-weight: 500 }
|
|
46
|
+
.num { text-align: right; white-space: nowrap; color: #8b949e }
|
|
47
|
+
.sql { white-space: pre-wrap; word-break: break-word }
|
|
48
|
+
.slow { color: #f0883e }
|
|
49
|
+
.bad { color: #ff7b72 }
|
|
50
|
+
pre { white-space: pre-wrap; word-break: break-word; margin: 0; color: #c9d1d9 }
|
|
51
|
+
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px;
|
|
52
|
+
background: #21262d; color: #8b949e; margin-right: 6px }
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
/** A whole page, so the profiler needs nothing served alongside it. */
|
|
56
|
+
function page(title: string, body: string): string {
|
|
57
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
58
|
+
<title>${escapeHtml(title)}</title><style>${STYLE}</style></head><body>${body}</body></html>`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The history: what a developer lands on with no token in hand. */
|
|
62
|
+
export function renderProfileList(profiles: readonly ProfileSummary[], missing?: string): string {
|
|
63
|
+
const warning =
|
|
64
|
+
missing === undefined
|
|
65
|
+
? ''
|
|
66
|
+
: `<p class="bad">No profile for <code>${escapeHtml(missing)}</code> — it may have aged out.</p>`
|
|
67
|
+
|
|
68
|
+
const rows = profiles
|
|
69
|
+
.map(
|
|
70
|
+
(profile) => `<tr>
|
|
71
|
+
<td><a href="/__ream/profiler/${escapeHtml(profile.token)}">${escapeHtml(profile.token)}</a></td>
|
|
72
|
+
<td>${escapeHtml(profile.method)}</td>
|
|
73
|
+
<td class="sql">${escapeHtml(profile.url)}</td>
|
|
74
|
+
<td class="${profile.status >= 500 ? 'bad' : ''}">${escapeHtml(profile.status)}</td>
|
|
75
|
+
<td class="num">${escapeHtml(profile.duration.toFixed(1))} ms</td>
|
|
76
|
+
<td>${Object.values(profile.badges)
|
|
77
|
+
.map((badge) => `<span class="pill">${escapeHtml(badge)}</span>`)
|
|
78
|
+
.join('')}</td>
|
|
79
|
+
</tr>`,
|
|
80
|
+
)
|
|
81
|
+
.join('')
|
|
82
|
+
|
|
83
|
+
return page(
|
|
84
|
+
'Profiler',
|
|
85
|
+
`<header><h1>Profiler</h1><div class="meta">${profiles.length} request${
|
|
86
|
+
profiles.length === 1 ? '' : 's'
|
|
87
|
+
} kept</div></header><main>${warning}
|
|
88
|
+
<table><tr><th>Token</th><th>Method</th><th>URL</th><th>Status</th>
|
|
89
|
+
<th class="num">Time</th><th>Collected</th></tr>${rows}</table></main>`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One request, every panel. */
|
|
94
|
+
export function renderProfilePage(profile: Profile, badges: Record<string, string>): string {
|
|
95
|
+
const sections: string[] = []
|
|
96
|
+
|
|
97
|
+
const exception = profile.data.exception
|
|
98
|
+
if (exception !== undefined && typeof exception.message === 'string') {
|
|
99
|
+
sections.push(`<section><h2 class="bad">Exception</h2>
|
|
100
|
+
<p class="bad">${escapeHtml(exception.name ?? 'Error')}: ${escapeHtml(exception.message)}</p>
|
|
101
|
+
${typeof exception.stack === 'string' ? `<pre>${escapeHtml(exception.stack)}</pre>` : ''}
|
|
102
|
+
</section>`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sections.push(renderQueries(profile))
|
|
106
|
+
|
|
107
|
+
for (const [name, data] of Object.entries(profile.data)) {
|
|
108
|
+
if (name === 'database' || name === 'exception') continue
|
|
109
|
+
if (Object.keys(data).length === 0) continue
|
|
110
|
+
sections.push(`<section><h2>${escapeHtml(name)}</h2>
|
|
111
|
+
<pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre></section>`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return page(
|
|
115
|
+
`${profile.method} ${profile.url}`,
|
|
116
|
+
`<header>
|
|
117
|
+
<h1>${escapeHtml(profile.method)} ${escapeHtml(profile.url)}</h1>
|
|
118
|
+
<div class="meta">
|
|
119
|
+
${escapeHtml(profile.status)} · ${escapeHtml(profile.duration.toFixed(1))} ms ·
|
|
120
|
+
${escapeHtml(new Date(profile.time).toISOString())} ·
|
|
121
|
+
<a href="/__ream/profiler">all requests</a>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="meta">${Object.values(badges)
|
|
124
|
+
.map((badge) => `<span class="pill">${escapeHtml(badge)}</span>`)
|
|
125
|
+
.join('')}</div>
|
|
126
|
+
</header><main>${sections.join('')}</main>`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The panel the whole thing exists for. */
|
|
131
|
+
function renderQueries(profile: Profile): string {
|
|
132
|
+
const database = profile.data.database
|
|
133
|
+
if (database === undefined) return ''
|
|
134
|
+
const queries = Array.isArray(database.queries) ? database.queries : []
|
|
135
|
+
const total = typeof database.total === 'number' ? database.total : 0
|
|
136
|
+
|
|
137
|
+
if (total === 0) return '<section><h2>Database</h2><p class="meta">No query.</p></section>'
|
|
138
|
+
|
|
139
|
+
const rows = queries
|
|
140
|
+
.map((entry) => {
|
|
141
|
+
const query = asRecord(entry)
|
|
142
|
+
const duration = typeof query.duration === 'number' ? query.duration : 0
|
|
143
|
+
const bindings = Array.isArray(query.bindings) ? query.bindings : []
|
|
144
|
+
return `<tr>
|
|
145
|
+
<td class="num ${duration >= 50 ? 'slow' : ''}">${escapeHtml(duration.toFixed(2))} ms</td>
|
|
146
|
+
<td class="sql">${escapeHtml(query.sql)}${
|
|
147
|
+
bindings.length === 0
|
|
148
|
+
? ''
|
|
149
|
+
: `<div class="meta">${escapeHtml(JSON.stringify(bindings))}</div>`
|
|
150
|
+
}${
|
|
151
|
+
typeof query.error === 'string' ? `<div class="bad">${escapeHtml(query.error)}</div>` : ''
|
|
152
|
+
}</td>
|
|
153
|
+
<td class="meta">${escapeHtml(query.model ?? '')}${
|
|
154
|
+
query.inTransaction === true ? ' <span class="pill">tx</span>' : ''
|
|
155
|
+
}</td>
|
|
156
|
+
</tr>`
|
|
157
|
+
})
|
|
158
|
+
.join('')
|
|
159
|
+
|
|
160
|
+
const capped =
|
|
161
|
+
database.capped === true
|
|
162
|
+
? `<p class="meta">Listing stopped at ${queries.length}; ${escapeHtml(total)} ran.</p>`
|
|
163
|
+
: ''
|
|
164
|
+
|
|
165
|
+
return `<section><h2>Database</h2>${capped}
|
|
166
|
+
<table><tr><th class="num">Time</th><th>Query</th><th>Model</th></tr>${rows}</table></section>`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
170
|
+
return typeof value === 'object' && value !== null
|
|
171
|
+
? Object.fromEntries(Object.entries(value))
|
|
172
|
+
: {}
|
|
173
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bar at the bottom of the page.
|
|
3
|
+
*
|
|
4
|
+
* It carries its own token inline rather than reading `X-Debug-Token` off the
|
|
5
|
+
* response: a script in a page cannot see the headers of the navigation that
|
|
6
|
+
* delivered it. Symfony injects the token for the same reason.
|
|
7
|
+
*
|
|
8
|
+
* Everything lives in a shadow root — your stylesheet must not reach the bar,
|
|
9
|
+
* and the bar must not reach your page. It fetches its numbers once, on load,
|
|
10
|
+
* and then does nothing: a dev tool that polls is a dev tool that shows up in
|
|
11
|
+
* its own history.
|
|
12
|
+
*
|
|
13
|
+
* A string rather than a stringified function, as the hot-reload script is:
|
|
14
|
+
* this package compiles without the DOM library, deliberately — it is a server
|
|
15
|
+
* — so browser code here is text, and its only inputs are escaped below.
|
|
16
|
+
*
|
|
17
|
+
* It styles its nodes through the CSSOM — `node.style.cssText` — rather than
|
|
18
|
+
* with a `<style>` element. Under a CSP with `style-src 'self'` (or only a
|
|
19
|
+
* `default-src`, which is the common case) a stylesheet inserted at runtime is
|
|
20
|
+
* inline style and is dropped, with no error anywhere: the bar renders as a
|
|
21
|
+
* stack of grey text and nothing says why. Setting properties on an element is
|
|
22
|
+
* not inline style and is never blocked, which is also how the host below has
|
|
23
|
+
* always been positioned.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { PROFILER_PATH } from './Profiler.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The `<script>` to append to an HTML response.
|
|
30
|
+
*
|
|
31
|
+
* `token` is generated by the profiler (hex) and `nonce` by the response, so
|
|
32
|
+
* neither is user input; both are still put through `JSON.stringify` rather
|
|
33
|
+
* than interpolated raw, because "cannot contain a quote" is an invariant that
|
|
34
|
+
* holds until someone changes how one of them is made.
|
|
35
|
+
*/
|
|
36
|
+
export function toolbarScript(token: string, nonce?: string): string {
|
|
37
|
+
const attribute = nonce === undefined ? '' : ` nonce="${nonce.replace(/"/g, '"')}"`
|
|
38
|
+
const config = JSON.stringify({ token, path: PROFILER_PATH })
|
|
39
|
+
return `<script${attribute}>(${BAR})(${config})</script>`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The bar, as it runs in the browser.
|
|
44
|
+
*
|
|
45
|
+
* Kept on one indented block so what ships is readable in a page's source —
|
|
46
|
+
* someone will open the inspector and find this, and a minified blob would
|
|
47
|
+
* tell them nothing about where it came from.
|
|
48
|
+
*/
|
|
49
|
+
const BAR = `function(c){
|
|
50
|
+
var host=document.createElement('div');
|
|
51
|
+
host.style.cssText='position:fixed;left:0;right:0;bottom:0;z-index:2147483000';
|
|
52
|
+
var root=host.attachShadow({mode:'closed'});
|
|
53
|
+
document.body.appendChild(host);
|
|
54
|
+
var bar=document.createElement('div');
|
|
55
|
+
bar.style.cssText='display:flex;align-items:stretch;background:#14161a;color:#e6e6e6;'
|
|
56
|
+
+'font:12px/1 ui-monospace,Menlo,monospace;border-top:1px solid #262a31;'
|
|
57
|
+
+'box-shadow:0 -2px 8px rgba(0,0,0,.4)';
|
|
58
|
+
root.appendChild(bar);
|
|
59
|
+
var page=c.path+'/'+c.token;
|
|
60
|
+
var COLOR={bad:'#ff7b72',warn:'#f0883e',ok:'#7ee787'};
|
|
61
|
+
function cell(label,value,tone,plain){
|
|
62
|
+
var node=document.createElement('div');
|
|
63
|
+
node.style.cssText='padding:7px 11px;white-space:nowrap;'
|
|
64
|
+
+(plain?'flex:1;overflow:hidden;text-overflow:ellipsis;cursor:default'
|
|
65
|
+
:'border-right:1px solid #21252c;cursor:pointer');
|
|
66
|
+
if(label){
|
|
67
|
+
var tag=document.createElement('span');
|
|
68
|
+
tag.style.cssText='color:#8b949e;margin-right:6px';
|
|
69
|
+
tag.textContent=label;
|
|
70
|
+
node.appendChild(tag);
|
|
71
|
+
}
|
|
72
|
+
var text=document.createElement('span');
|
|
73
|
+
if(tone){text.style.color=COLOR[tone];}
|
|
74
|
+
text.textContent=value;
|
|
75
|
+
node.appendChild(text);
|
|
76
|
+
if(!plain){
|
|
77
|
+
node.addEventListener('mouseenter',function(){node.style.background='#1b1f25'});
|
|
78
|
+
node.addEventListener('mouseleave',function(){node.style.background=''});
|
|
79
|
+
node.addEventListener('click',function(){window.open(page,'_blank')});
|
|
80
|
+
}
|
|
81
|
+
bar.appendChild(node);
|
|
82
|
+
}
|
|
83
|
+
function panel(data,name){
|
|
84
|
+
var value=data&&data[name];
|
|
85
|
+
return (value&&typeof value==='object')?value:null;
|
|
86
|
+
}
|
|
87
|
+
fetch(page+'.json',{headers:{accept:'application/json'}})
|
|
88
|
+
.then(function(r){return r.ok?r.json():null})
|
|
89
|
+
.then(function(p){
|
|
90
|
+
if(!p){host.remove();return}
|
|
91
|
+
var status=Number(p.status||0);
|
|
92
|
+
cell('',String(status),status>=500?'bad':status>=400?'warn':'ok');
|
|
93
|
+
cell('time',Number(p.duration||0).toFixed(1)+' ms');
|
|
94
|
+
var db=panel(p.data,'database');
|
|
95
|
+
if(db){
|
|
96
|
+
var total=Number(db.total||0);
|
|
97
|
+
cell('db',total+' \u00b7 '+Number(db.duration||0).toFixed(1)+' ms',total>=50?'warn':'');
|
|
98
|
+
}
|
|
99
|
+
var mem=panel(p.data,'memory');
|
|
100
|
+
if(mem){cell('mem',Number(mem.usedMb||0).toFixed(1)+' MB');}
|
|
101
|
+
var err=panel(p.data,'exception');
|
|
102
|
+
if(err&&typeof err.message==='string'){cell('',err.message,'bad');}
|
|
103
|
+
cell('',String(p.url||''),'',true);
|
|
104
|
+
})
|
|
105
|
+
.catch(function(){host.remove()});
|
|
106
|
+
}`
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the profiler keeps about one request.
|
|
3
|
+
*
|
|
4
|
+
* Shaped after Symfony's profiler rather than Laravel's debugbar, which is a
|
|
5
|
+
* decision about where the data lives: a token per request, an endpoint that
|
|
6
|
+
* serves it, and a history. Inlining it in the page is simpler until the
|
|
7
|
+
* request you need to look at was a POST that redirected — and then the data
|
|
8
|
+
* is gone, which is exactly when you wanted it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One collector's contribution to a profile. Plain data: it crosses a wire. */
|
|
12
|
+
export type CollectedData = Record<string, unknown>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Everything kept about one request.
|
|
16
|
+
*
|
|
17
|
+
* `token` identifies it. It travels back on `X-Debug-Token`, so a client — the
|
|
18
|
+
* toolbar, curl, a test — can ask for the profile of a request it just made.
|
|
19
|
+
*/
|
|
20
|
+
export interface Profile {
|
|
21
|
+
token: string
|
|
22
|
+
/** When the request arrived, epoch milliseconds. */
|
|
23
|
+
time: number
|
|
24
|
+
method: string
|
|
25
|
+
url: string
|
|
26
|
+
status: number
|
|
27
|
+
/** Wall-clock milliseconds, first middleware to serialised response. */
|
|
28
|
+
duration: number
|
|
29
|
+
/** Keyed by collector name. */
|
|
30
|
+
data: Record<string, CollectedData>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A profile without its data — what a listing shows. */
|
|
34
|
+
export type ProfileSummary = Omit<Profile, 'data'> & {
|
|
35
|
+
/** The headline each collector wants on the bar: `{ database: '12 queries' }`. */
|
|
36
|
+
badges: Record<string, string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What a collector is handed while a request runs.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately not the HttpContext: a collector observes, and handing it the
|
|
43
|
+
* context would let it answer the request. What it needs is a place to put
|
|
44
|
+
* what it saw and the ability to know when the request ends.
|
|
45
|
+
*/
|
|
46
|
+
export interface CollectionScope {
|
|
47
|
+
token: string
|
|
48
|
+
/** Called by the framework when the request is done. */
|
|
49
|
+
readonly startedAt: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A source of data about a request.
|
|
54
|
+
*
|
|
55
|
+
* `start` runs when the request begins and answers a bag the collector will
|
|
56
|
+
* fill; `finish` turns that bag into what is stored. Two methods rather than
|
|
57
|
+
* one, because the interesting collectors — queries, logs, events — are fed by
|
|
58
|
+
* things happening DURING the request, not by looking at it afterwards.
|
|
59
|
+
*/
|
|
60
|
+
export interface ProfilerCollector<Bag = unknown> {
|
|
61
|
+
/** Unique. It keys the data and names the panel. */
|
|
62
|
+
readonly name: string
|
|
63
|
+
/** Shown on the bar, or nothing when there is nothing worth a badge. */
|
|
64
|
+
badge?: (data: CollectedData) => string | undefined
|
|
65
|
+
start(scope: CollectionScope): Bag
|
|
66
|
+
finish(bag: Bag, scope: CollectionScope): CollectedData
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where profiles are kept. Bounded: a dev session is not an archive. */
|
|
70
|
+
export interface ProfileStore {
|
|
71
|
+
save(profile: Profile): void
|
|
72
|
+
find(token: string): Profile | undefined
|
|
73
|
+
recent(limit?: number): ProfileSummary[]
|
|
74
|
+
clear(): void
|
|
75
|
+
}
|
package/src/router/Router.ts
CHANGED
|
@@ -1361,6 +1361,22 @@ export class Router extends Macroable {
|
|
|
1361
1361
|
return manifest
|
|
1362
1362
|
}
|
|
1363
1363
|
|
|
1364
|
+
/**
|
|
1365
|
+
* Every NAMED route with its path pattern AND the HTTP methods it answers,
|
|
1366
|
+
* e.g. `{ 'users.update': { path: '/users/:id', methods: ['PUT'] } }` — what
|
|
1367
|
+
* a client needs to follow a route by name and pick its verb, the way
|
|
1368
|
+
* AdonisJS's Tuyau registry gives `route` + `methods`. `namedManifest()`
|
|
1369
|
+
* stays the lighter name → path map.
|
|
1370
|
+
*/
|
|
1371
|
+
namedRoutes(): Record<string, { path: string; methods: string[] }> {
|
|
1372
|
+
if (this.#indexDirty) this.#buildIndex()
|
|
1373
|
+
const routes: Record<string, { path: string; methods: string[] }> = {}
|
|
1374
|
+
for (const [name, route] of this.#nameIndex) {
|
|
1375
|
+
routes[name] = { path: route.path, methods: [route.method] }
|
|
1376
|
+
}
|
|
1377
|
+
return routes
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1364
1380
|
/**
|
|
1365
1381
|
* Generate a TypeScript source string typing every named route and its
|
|
1366
1382
|
* params (AdonisJS `generateTypes`) — a `RouteName` union plus a
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@c9up/ream/stubs` — the stub engine, and where this package keeps its own.
|
|
3
|
+
*
|
|
4
|
+
* Every Ream package exports a `stubsRoot` from this subpath: it is what
|
|
5
|
+
* `codemods.makeUsingStub(stubsRoot, 'make/controller.stub')` takes, and what
|
|
6
|
+
* `ream eject --pkg` reads to copy a template into the application.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as path from 'node:path'
|
|
10
|
+
|
|
11
|
+
export * from './template.js'
|
|
12
|
+
|
|
13
|
+
/** `<package>/stubs` — resolved from this file, so it survives the build. */
|
|
14
|
+
export const stubsRoot = path.join(import.meta.dirname, '..', '..', 'stubs')
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stub template engine.
|
|
3
|
+
*
|
|
4
|
+
* A port of the one upstream uses (tempura), grammar for grammar: comments,
|
|
5
|
+
* declared props, inline variables, conditionals, loops, custom blocks, and
|
|
6
|
+
* the two interpolation forms. A template is compiled into a JavaScript
|
|
7
|
+
* function once and then called — the same design, for the same reason: a
|
|
8
|
+
* tree-walking interpreter re-reads the template on every render, and a stub
|
|
9
|
+
* is rendered in a loop by the generators.
|
|
10
|
+
*
|
|
11
|
+
* {{! a comment }}
|
|
12
|
+
* {{#expect name, entity }}
|
|
13
|
+
* {{#var plural = name + 's' }}
|
|
14
|
+
* {{#if entity.isModel }}…{{#elif entity.isView }}…{{#else}}…{{/if}}
|
|
15
|
+
* {{#each items as item, index }}…{{/each}}
|
|
16
|
+
* {{ name }} the value
|
|
17
|
+
* {{{ name }}} the value, unescaped
|
|
18
|
+
*
|
|
19
|
+
* NAMED DEVIATION — upstream escapes HTML in `{{ }}` and leaves `{{{ }}}`
|
|
20
|
+
* raw, because its templates are web pages. Ours are SOURCE FILES: escaping
|
|
21
|
+
* `"` into `"` there produces code that does not parse, and the mistake
|
|
22
|
+
* is silent. So `{{ }}` does not escape by default, `{{{ }}}` is accepted as
|
|
23
|
+
* the same thing (a stub written for upstream keeps working), and an `escape`
|
|
24
|
+
* option turns it back on for anyone templating markup.
|
|
25
|
+
*
|
|
26
|
+
* `new Function` compiles the result. That is how upstream does it too, and
|
|
27
|
+
* the trust boundary is the same: a stub ships inside a package whose
|
|
28
|
+
* `configure` hook is already executing.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** What a template may read. Scalars, lists and nested records. */
|
|
32
|
+
export type StubValue =
|
|
33
|
+
| string
|
|
34
|
+
| number
|
|
35
|
+
| boolean
|
|
36
|
+
| null
|
|
37
|
+
| undefined
|
|
38
|
+
| StubValue[]
|
|
39
|
+
| { [key: string]: StubValue }
|
|
40
|
+
|
|
41
|
+
export type StubState = Record<string, StubValue>
|
|
42
|
+
|
|
43
|
+
/** A custom block — `{{#myBlock key=value }}` calls it with those arguments. */
|
|
44
|
+
export type StubBlock = (args: Record<string, StubValue>) => string
|
|
45
|
+
|
|
46
|
+
export interface TemplateOptions {
|
|
47
|
+
/** Names the template may read even when the state does not carry them. */
|
|
48
|
+
props?: string[]
|
|
49
|
+
/** Custom `{{#name}}` blocks. */
|
|
50
|
+
blocks?: Record<string, StubBlock>
|
|
51
|
+
/** Applied to every `{{ }}`. Identity by default — see the header. */
|
|
52
|
+
escape?: (value: unknown) => string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Raised when a template cannot be read, naming what is wrong with it. */
|
|
56
|
+
export class StubSyntaxError extends Error {
|
|
57
|
+
readonly code = 'E_REAM_STUB_SYNTAX'
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message)
|
|
60
|
+
this.name = 'StubSyntaxError'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** HTML escaping, for a stub that does template markup. */
|
|
65
|
+
export function escapeHtml(value: unknown): string {
|
|
66
|
+
const text = value === null || value === undefined ? '' : String(value)
|
|
67
|
+
return text.replace(/[&"<>]/g, (char) => {
|
|
68
|
+
if (char === '&') return '&'
|
|
69
|
+
if (char === '"') return '"'
|
|
70
|
+
if (char === '<') return '<'
|
|
71
|
+
return '>'
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The default: a source file wants its quotes and its angle brackets back. */
|
|
76
|
+
function asIs(value: unknown): string {
|
|
77
|
+
return value === null || value === undefined ? '' : String(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const CURLY = /{{{?\s*([\s\S]*?)\s*}}}?/g
|
|
81
|
+
const BLOCK = /^#\s*(\w[\w\d]*)\s*([\s\S]*)/
|
|
82
|
+
const ARGS = /([a-zA-Z$_][^\s=]*)\s*=\s*((["`'])(?:(?=(\\?))\4.)*?\3|{[^}]*}|\[[^\]]*]|\S+)/g
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compile a template into the body of a function.
|
|
86
|
+
*
|
|
87
|
+
* Kept separate from `compile` so a test can read what a template became —
|
|
88
|
+
* the generated source is the only place a subtle mistake shows itself.
|
|
89
|
+
*/
|
|
90
|
+
export function generate(input: string, options: TemplateOptions = {}): string {
|
|
91
|
+
const blocks = options.blocks ?? {}
|
|
92
|
+
const declared = new Set<string>(options.props ?? [])
|
|
93
|
+
const stack: string[] = []
|
|
94
|
+
|
|
95
|
+
let body = ''
|
|
96
|
+
let literal = ''
|
|
97
|
+
let last = 0
|
|
98
|
+
CURLY.lastIndex = 0
|
|
99
|
+
|
|
100
|
+
/** Flush the literal text collected since the last tag. */
|
|
101
|
+
const flush = (): void => {
|
|
102
|
+
if (literal.length > 0) {
|
|
103
|
+
body += `${body ? 'x+=' : '='}\`${literal}\`;`
|
|
104
|
+
} else if (body.length === 0) {
|
|
105
|
+
body = '="";'
|
|
106
|
+
}
|
|
107
|
+
literal = ''
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let match: RegExpExecArray | null = CURLY.exec(input)
|
|
111
|
+
while (match !== null) {
|
|
112
|
+
literal += escapeForTemplate(input.slice(last, match.index))
|
|
113
|
+
last = match.index + match[0].length
|
|
114
|
+
|
|
115
|
+
const inner = (match[1] ?? '').trim()
|
|
116
|
+
const first = inner.charAt(0)
|
|
117
|
+
|
|
118
|
+
if (first === '!') {
|
|
119
|
+
// A comment. It leaves no trace in the output.
|
|
120
|
+
} else if (first === '#') {
|
|
121
|
+
flush()
|
|
122
|
+
const parsed = BLOCK.exec(inner)
|
|
123
|
+
if (parsed === null) {
|
|
124
|
+
throw new StubSyntaxError(`Unreadable block: {{${inner}}}`)
|
|
125
|
+
}
|
|
126
|
+
const action = parsed[1] ?? ''
|
|
127
|
+
const rest = (parsed[2] ?? '').trim()
|
|
128
|
+
body += openBlock(action, rest, stack, declared, blocks)
|
|
129
|
+
} else if (first === '/') {
|
|
130
|
+
const closing = inner.slice(1).trim()
|
|
131
|
+
const opened = stack.pop()
|
|
132
|
+
flush()
|
|
133
|
+
if (opened === undefined) {
|
|
134
|
+
throw new StubSyntaxError(`Closed "${closing}" with nothing open.`)
|
|
135
|
+
}
|
|
136
|
+
if (opened !== closing) {
|
|
137
|
+
throw new StubSyntaxError(`Expected to close "${opened}"; closed "${closing}" instead.`)
|
|
138
|
+
}
|
|
139
|
+
body += '}'
|
|
140
|
+
} else {
|
|
141
|
+
// Both forms interpolate the same way — see the header.
|
|
142
|
+
literal += `\${$$e(${inner})}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
match = CURLY.exec(input)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (stack.length > 0) {
|
|
149
|
+
throw new StubSyntaxError(`Unterminated "${stack.pop()}" block.`)
|
|
150
|
+
}
|
|
151
|
+
if (last < input.length) {
|
|
152
|
+
literal += escapeForTemplate(input.slice(last))
|
|
153
|
+
}
|
|
154
|
+
flush()
|
|
155
|
+
|
|
156
|
+
const bindings = declared.size > 0 ? `{${[...declared].join()}}=$$s,x` : ' x'
|
|
157
|
+
return `var${bindings}${body}return x`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function openBlock(
|
|
161
|
+
action: string,
|
|
162
|
+
rest: string,
|
|
163
|
+
stack: string[],
|
|
164
|
+
declared: Set<string>,
|
|
165
|
+
blocks: Record<string, StubBlock>,
|
|
166
|
+
): string {
|
|
167
|
+
if (action === 'expect') {
|
|
168
|
+
// Names the template reads. Declaring them is what lets an unknown one
|
|
169
|
+
// fail as `x is not defined` rather than as `undefined` in the output.
|
|
170
|
+
for (const key of rest.split(/[\n\r\s\t]*,[\n\r\s\t]*/g)) {
|
|
171
|
+
if (key !== '') declared.add(key)
|
|
172
|
+
}
|
|
173
|
+
return ''
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (action === 'var') {
|
|
177
|
+
const at = rest.indexOf('=')
|
|
178
|
+
if (at === -1) {
|
|
179
|
+
throw new StubSyntaxError(`{{#var}} needs an assignment: {{#var ${rest}}}`)
|
|
180
|
+
}
|
|
181
|
+
const name = rest.slice(0, at).trim()
|
|
182
|
+
const value = rest
|
|
183
|
+
.slice(at + 1)
|
|
184
|
+
.trim()
|
|
185
|
+
.replace(/;$/, '')
|
|
186
|
+
return `var ${name}=${value};`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (action === 'each') {
|
|
190
|
+
stack.push(action)
|
|
191
|
+
const at = rest.indexOf(' as ')
|
|
192
|
+
if (at === -1) {
|
|
193
|
+
return `for(var i=0,$$a=${rest};i<$$a.length;i++){`
|
|
194
|
+
}
|
|
195
|
+
const list = rest.slice(0, at).trim()
|
|
196
|
+
const [item = 'item', index = 'i'] = rest
|
|
197
|
+
.slice(at + 4)
|
|
198
|
+
.trim()
|
|
199
|
+
.replace(/[()\s]/g, '')
|
|
200
|
+
.split(',')
|
|
201
|
+
return `for(var ${index}=0,${item},$$a=${list};${index}<$$a.length;${index}++){${item}=$$a[${index}];`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (action === 'if') {
|
|
205
|
+
stack.push(action)
|
|
206
|
+
return `if(${rest}){`
|
|
207
|
+
}
|
|
208
|
+
if (action === 'elif') return `}else if(${rest}){`
|
|
209
|
+
if (action === 'else') return '}else{'
|
|
210
|
+
|
|
211
|
+
if (action in blocks) {
|
|
212
|
+
// `{{#note tone="warn" }}` — the arguments become one object.
|
|
213
|
+
const args: string[] = []
|
|
214
|
+
let found: RegExpExecArray | null = ARGS.exec(rest)
|
|
215
|
+
while (found !== null) {
|
|
216
|
+
args.push(`${found[1]}:${found[2]}`)
|
|
217
|
+
found = ARGS.exec(rest)
|
|
218
|
+
}
|
|
219
|
+
ARGS.lastIndex = 0
|
|
220
|
+
return `x+=$$b.${action}(${args.length > 0 ? `{${args.join()}}` : '{}'});`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw new StubSyntaxError(
|
|
224
|
+
`Unknown "${action}" block. Known: expect, var, if, elif, else, each${
|
|
225
|
+
Object.keys(blocks).length > 0 ? `, ${Object.keys(blocks).join(', ')}` : ''
|
|
226
|
+
}.`,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Protect the literal text from the template literal it is about to live in. */
|
|
231
|
+
function escapeForTemplate(text: string): string {
|
|
232
|
+
return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Compile a template into a render function. */
|
|
236
|
+
export function compile(
|
|
237
|
+
input: string,
|
|
238
|
+
options: TemplateOptions = {},
|
|
239
|
+
): (state: StubState) => string {
|
|
240
|
+
const source = generate(input, options)
|
|
241
|
+
let render: (escaper: (value: unknown) => string, blocks: unknown, state: StubState) => string
|
|
242
|
+
try {
|
|
243
|
+
// eslint-disable-next-line no-new-func -- the whole point of a compiler
|
|
244
|
+
render = new Function('$$e', '$$b', '$$s', source) as typeof render
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new StubSyntaxError(
|
|
247
|
+
`This stub does not compile: ${error instanceof Error ? error.message : String(error)}`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
const escaper = options.escape ?? asIs
|
|
251
|
+
const blocks = options.blocks ?? {}
|
|
252
|
+
return (state: StubState) => render(escaper, blocks, state)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Compile and render in one go. */
|
|
256
|
+
export function render(
|
|
257
|
+
input: string,
|
|
258
|
+
state: StubState = {},
|
|
259
|
+
options: TemplateOptions = {},
|
|
260
|
+
): string {
|
|
261
|
+
return compile(input, { props: Object.keys(state), ...options })(state)
|
|
262
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: commands/{{ fileName }}.ts
|
|
3
|
+
---
|
|
4
|
+
import { BaseCommand, flags } from '@c9up/ream/console'
|
|
5
|
+
import type { CommandOptions } from '@c9up/ream/console'
|
|
6
|
+
|
|
7
|
+
export default class {{ className }} extends BaseCommand {
|
|
8
|
+
static commandName = '{{ name }}'
|
|
9
|
+
static description = 'TODO describe what this command does'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `startApp` boots providers and the container before `run()`. Off by
|
|
13
|
+
* default: a command that only touches the filesystem has no
|
|
14
|
+
* reason to open a database connection. Turn it on to reach `this.app`.
|
|
15
|
+
*/
|
|
16
|
+
static options: CommandOptions = { startApp: false }
|
|
17
|
+
|
|
18
|
+
@flags.boolean({ description: 'Report what would happen without doing it' })
|
|
19
|
+
declare dryRun: boolean
|
|
20
|
+
|
|
21
|
+
async run(): Promise<void> {
|
|
22
|
+
if (this.dryRun) {
|
|
23
|
+
this.logger.info('Dry run — nothing was changed.')
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.logger.success('{{ name }} ran.')
|
|
28
|
+
}
|
|
29
|
+
}
|