@nifrajs/cli 0.1.0-alpha.1 → 0.1.0-alpha.2
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 +8 -0
- package/dist/check.d.ts +82 -3
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +326 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +178 -17
- package/dist/cli.js.map +1 -1
- package/dist/init-agents.d.ts +58 -0
- package/dist/init-agents.d.ts.map +1 -0
- package/dist/init-agents.js +126 -0
- package/dist/init-agents.js.map +1 -0
- package/dist/introspect.d.ts +77 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +138 -9
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +99 -8
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +49 -21
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +12 -3
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -4
- package/dist/openapi-tool.js.map +1 -1
- package/dist/port.d.ts +144 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +498 -0
- package/dist/port.js.map +1 -0
- package/docs/examples.json +91 -133
- package/docs/llms-full.txt +1090 -35
- package/package.json +3 -2
- package/src/check.ts +398 -14
- package/src/cli.ts +176 -21
- package/src/init-agents.ts +176 -0
- package/src/introspect.ts +208 -8
- package/src/mcp-render.ts +106 -11
- package/src/mcp.ts +56 -20
- package/src/openapi-tool.ts +24 -4
- package/src/port.ts +663 -0
package/src/port.ts
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra port` — a portability linter. It scans an app's source for features whose runtime guarantees
|
|
3
|
+
* differ across deploy targets, prints a feature × target capability matrix, and (in CI mode) fails
|
|
4
|
+
* when a *detected* feature is unsupported on the app's resolved deploy target.
|
|
5
|
+
*
|
|
6
|
+
* The premise: nifra runs the same app on five targets (Bun, Node, Deno, Cloudflare Pages, Vercel), but
|
|
7
|
+
* a per-instance in-memory store, a long-lived in-process cron loop, or a `Bun.*` global is portable
|
|
8
|
+
* across some and a silent footgun on others. The framework's stores already *runtime*-guard this (e.g.
|
|
9
|
+
* `MemorySessionStore` throws under `NODE_ENV=production`); `port` is the *build-time* counterpart — it
|
|
10
|
+
* tells you before you deploy, and gates CI so the regression can't ship.
|
|
11
|
+
*
|
|
12
|
+
* Detection works on reliable import/symbol signals only (a real `@nifrajs/*` import paired with the
|
|
13
|
+
* exact exported symbol, a `node:` specifier, a `Bun.`/`Deno.` global), recording `file:line` evidence
|
|
14
|
+
* per hit. The pure scanners are unit-tested; correctness of detection IS the product for a linter.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import { Glob } from "bun"
|
|
19
|
+
import { stripComments } from "./check.ts"
|
|
20
|
+
|
|
21
|
+
/** The five deploy targets a nifra `site` app can build for (mirrors create-nifra's DEPLOY presets). */
|
|
22
|
+
export const TARGETS = ["bun", "node", "deno", "cf-pages", "vercel"] as const
|
|
23
|
+
export type Target = (typeof TARGETS)[number]
|
|
24
|
+
|
|
25
|
+
const isTarget = (value: string): value is Target => (TARGETS as readonly string[]).includes(value)
|
|
26
|
+
|
|
27
|
+
/** The portability hazards `port` detects. Stable ids — they're part of the `--json` contract. */
|
|
28
|
+
export const FEATURE_IDS = [
|
|
29
|
+
"in-memory-session-store",
|
|
30
|
+
"in-memory-isr-cache",
|
|
31
|
+
"in-memory-rate-limit",
|
|
32
|
+
"in-process-cron",
|
|
33
|
+
"in-process-websocket",
|
|
34
|
+
"bun-runtime-api",
|
|
35
|
+
"deno-runtime-api",
|
|
36
|
+
"node-builtin",
|
|
37
|
+
] as const
|
|
38
|
+
export type FeatureId = (typeof FEATURE_IDS)[number]
|
|
39
|
+
|
|
40
|
+
/** A target's verdict for one feature: fully fine, works-with-a-caveat, or won't work at all. */
|
|
41
|
+
export type Verdict = "ok" | "caveat" | "unsupported"
|
|
42
|
+
|
|
43
|
+
/** A single feature's capability row: a human label, the per-target verdicts, and a reason per verdict. */
|
|
44
|
+
interface FeatureSpec {
|
|
45
|
+
readonly label: string
|
|
46
|
+
/** Short explanation of WHY this feature is a portability hazard at all (printed in the legend/report). */
|
|
47
|
+
readonly summary: string
|
|
48
|
+
readonly verdicts: Readonly<Record<Target, Verdict>>
|
|
49
|
+
/** Per-target reason — why this verdict, and (for caveat/unsupported) the fix. */
|
|
50
|
+
readonly reasons: Readonly<Record<Target, string>>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// In-memory stores (session/ISR/rate-limit) share a verdict shape: fine single-instance on a long-lived
|
|
54
|
+
// runtime (caveat — lost on restart, not shared across instances), unsupported on the distributed edge.
|
|
55
|
+
const inMemoryVerdicts: Readonly<Record<Target, Verdict>> = {
|
|
56
|
+
bun: "caveat",
|
|
57
|
+
node: "caveat",
|
|
58
|
+
deno: "caveat",
|
|
59
|
+
"cf-pages": "unsupported",
|
|
60
|
+
vercel: "unsupported",
|
|
61
|
+
}
|
|
62
|
+
const inMemoryReasons = (shared: string): Readonly<Record<Target, string>> => ({
|
|
63
|
+
bun: "ok single-instance; state is lost on restart and not shared across instances",
|
|
64
|
+
node: "ok single-instance; state is lost on restart and not shared across instances",
|
|
65
|
+
deno: "ok single-instance; state is lost on restart and not shared across instances",
|
|
66
|
+
"cf-pages": shared,
|
|
67
|
+
vercel: shared,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The capability matrix. The source of truth for every verdict + reason `port` prints. Verdicts mirror
|
|
72
|
+
* the framework's own runtime guards: the in-memory stores all `throw` under production multi-instance,
|
|
73
|
+
* cron documents the Workers `scheduled` trigger as its edge replacement, and the WS hub is the Workers
|
|
74
|
+
* cross-connection broadcast story.
|
|
75
|
+
*/
|
|
76
|
+
export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
77
|
+
"in-memory-session-store": {
|
|
78
|
+
label: "in-memory session store",
|
|
79
|
+
summary: "MemorySessionStore keeps sessions per-instance (@nifrajs/auth)",
|
|
80
|
+
verdicts: inMemoryVerdicts,
|
|
81
|
+
reasons: inMemoryReasons(
|
|
82
|
+
"distributed edge has no shared per-instance memory — use KVSessionStore (Workers KV) or another shared store",
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
"in-memory-isr-cache": {
|
|
86
|
+
label: "in-memory ISR cache",
|
|
87
|
+
summary: "MemoryCacheStore caches rendered pages per-instance (@nifrajs/web)",
|
|
88
|
+
verdicts: inMemoryVerdicts,
|
|
89
|
+
reasons: inMemoryReasons(
|
|
90
|
+
"each edge instance caches separately and revalidation won't propagate — use KVCacheStore (Workers KV) or the platform Cache API",
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
"in-memory-rate-limit": {
|
|
94
|
+
label: "in-memory rate limit",
|
|
95
|
+
summary: "MemoryStore counts hits per-instance (@nifrajs/middleware)",
|
|
96
|
+
verdicts: inMemoryVerdicts,
|
|
97
|
+
reasons: inMemoryReasons(
|
|
98
|
+
"a per-instance limiter doesn't hold across edge instances — use a shared store (Redis / Durable Object)",
|
|
99
|
+
),
|
|
100
|
+
},
|
|
101
|
+
"in-process-cron": {
|
|
102
|
+
label: "in-process cron",
|
|
103
|
+
summary: "createScheduler runs a long-lived in-process loop (@nifrajs/cron)",
|
|
104
|
+
verdicts: {
|
|
105
|
+
bun: "ok",
|
|
106
|
+
node: "ok",
|
|
107
|
+
deno: "ok",
|
|
108
|
+
"cf-pages": "caveat",
|
|
109
|
+
vercel: "unsupported",
|
|
110
|
+
},
|
|
111
|
+
reasons: {
|
|
112
|
+
bun: "long-lived process runs the scheduler loop",
|
|
113
|
+
node: "long-lived process runs the scheduler loop",
|
|
114
|
+
deno: "long-lived process runs the scheduler loop",
|
|
115
|
+
"cf-pages":
|
|
116
|
+
"no long-lived loop on Workers — use the platform `scheduled` trigger via toFetchHandler(app, { scheduled }) and a wrangler [triggers] cron",
|
|
117
|
+
vercel: "no long-lived process — use a Vercel Cron Job hitting an endpoint",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
"in-process-websocket": {
|
|
121
|
+
label: "in-process WebSocket",
|
|
122
|
+
summary: "app.ws() without createWebSocketHub broadcasts only within one process",
|
|
123
|
+
verdicts: {
|
|
124
|
+
bun: "ok",
|
|
125
|
+
node: "ok",
|
|
126
|
+
deno: "ok",
|
|
127
|
+
"cf-pages": "caveat",
|
|
128
|
+
vercel: "unsupported",
|
|
129
|
+
},
|
|
130
|
+
reasons: {
|
|
131
|
+
bun: "all sockets live in one process; app.publish broadcasts to every client",
|
|
132
|
+
node: "all sockets live in one process; app.publish broadcasts to every client",
|
|
133
|
+
deno: "all sockets live in one process; app.publish broadcasts to every client",
|
|
134
|
+
"cf-pages":
|
|
135
|
+
"a stateless fetch can't broadcast across isolates — wrap the app in createWebSocketHub (a Durable Object) from @nifrajs/workers",
|
|
136
|
+
vercel: "Vercel's serverless functions don't hold long-lived WebSocket connections",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
"bun-runtime-api": {
|
|
140
|
+
label: "Bun.* runtime API",
|
|
141
|
+
summary: "Bun.* globals (Bun.serve, Bun.file, …) exist only on the Bun runtime",
|
|
142
|
+
verdicts: {
|
|
143
|
+
bun: "ok",
|
|
144
|
+
node: "unsupported",
|
|
145
|
+
deno: "unsupported",
|
|
146
|
+
"cf-pages": "unsupported",
|
|
147
|
+
vercel: "unsupported",
|
|
148
|
+
},
|
|
149
|
+
reasons: {
|
|
150
|
+
bun: "the Bun runtime provides the Bun global",
|
|
151
|
+
node: "no Bun global on Node — use the node: / Web equivalents",
|
|
152
|
+
deno: "no Bun global on Deno — use the Deno / Web equivalents",
|
|
153
|
+
"cf-pages": "no Bun global in the Workers runtime",
|
|
154
|
+
vercel: "no Bun global in the Vercel runtime",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
"deno-runtime-api": {
|
|
158
|
+
label: "Deno.* runtime API",
|
|
159
|
+
summary: "Deno.* globals exist only on the Deno runtime",
|
|
160
|
+
verdicts: {
|
|
161
|
+
bun: "unsupported",
|
|
162
|
+
node: "unsupported",
|
|
163
|
+
deno: "ok",
|
|
164
|
+
"cf-pages": "unsupported",
|
|
165
|
+
vercel: "unsupported",
|
|
166
|
+
},
|
|
167
|
+
reasons: {
|
|
168
|
+
bun: "no Deno global on Bun — use the Bun / Web equivalents",
|
|
169
|
+
node: "no Deno global on Node — use the node: / Web equivalents",
|
|
170
|
+
deno: "the Deno runtime provides the Deno global",
|
|
171
|
+
"cf-pages": "no Deno global in the Workers runtime",
|
|
172
|
+
vercel: "no Deno global in the Vercel runtime",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
"node-builtin": {
|
|
176
|
+
label: "node: builtin",
|
|
177
|
+
summary: "node:* builtins (node:fs, node:crypto, …) need a Node-compatible runtime",
|
|
178
|
+
verdicts: {
|
|
179
|
+
bun: "ok",
|
|
180
|
+
node: "ok",
|
|
181
|
+
deno: "ok",
|
|
182
|
+
"cf-pages": "caveat",
|
|
183
|
+
vercel: "caveat",
|
|
184
|
+
},
|
|
185
|
+
reasons: {
|
|
186
|
+
bun: "Bun implements the node: builtins",
|
|
187
|
+
node: "native node: builtins",
|
|
188
|
+
deno: "Deno implements the node: builtins",
|
|
189
|
+
"cf-pages":
|
|
190
|
+
'Workers needs `compatibility_flags = ["nodejs_compat"]` in wrangler.toml (and not every builtin is covered)',
|
|
191
|
+
vercel: "needs the Node.js runtime (not the Edge runtime) so node: builtins resolve",
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A `file:line` evidence site for a detected feature. */
|
|
197
|
+
export interface Evidence {
|
|
198
|
+
readonly file: string
|
|
199
|
+
readonly line: number
|
|
200
|
+
/** The trimmed source line — the signal in context, for the human report. */
|
|
201
|
+
readonly snippet: string
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A detected feature: which one, where the evidence is, and its per-target verdicts. */
|
|
205
|
+
export interface DetectedFeature {
|
|
206
|
+
readonly id: FeatureId
|
|
207
|
+
readonly label: string
|
|
208
|
+
readonly evidence: readonly Evidence[]
|
|
209
|
+
readonly verdicts: Readonly<Record<Target, Verdict>>
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Line number (1-based) of a string index. */
|
|
213
|
+
const lineAt = (content: string, index: number): number =>
|
|
214
|
+
content.slice(0, index).split("\n").length
|
|
215
|
+
|
|
216
|
+
// --- Detection signals (all matched against comment-stripped source so doc examples don't false-positive) ---
|
|
217
|
+
|
|
218
|
+
// A non-type import (static, side-effect, dynamic, or re-export) from one of these `@nifrajs/*` packages.
|
|
219
|
+
// Used to confirm a symbol like `MemoryStore` is the framework's, not a same-named local class.
|
|
220
|
+
const importsFrom = (code: string, pkg: string): boolean => {
|
|
221
|
+
const escaped = pkg.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
222
|
+
// `from "@nifrajs/x"` | `from "@nifrajs/x/sub"` | `import("@nifrajs/x")` | `import "@nifrajs/x"`
|
|
223
|
+
const rx = new RegExp(`(?:from|import)\\s*\\(?\\s*["'\`]${escaped}(?:/[^"'\`]*)?["'\`]`)
|
|
224
|
+
return rx.test(code)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// `new MemorySessionStore(` / `MemorySessionStore` referenced as a value. We require the @nifrajs/auth
|
|
228
|
+
// import in the same file (importsFrom) so a same-named user class never trips it.
|
|
229
|
+
const SESSION_STORE = /(?<![.\w$])MemorySessionStore\b/g
|
|
230
|
+
const ISR_CACHE = /(?<![.\w$])MemoryCacheStore\b/g
|
|
231
|
+
// Rate-limit store. The exported symbol is the generic `MemoryStore`; require the @nifrajs/middleware
|
|
232
|
+
// import so it can't collide with an unrelated `MemoryStore` from elsewhere.
|
|
233
|
+
const RATE_LIMIT = /(?<![.\w$])MemoryStore\b/g
|
|
234
|
+
// In-process cron: the scheduler factory. Paired with the @nifrajs/cron import.
|
|
235
|
+
const CRON_SCHEDULER = /(?<![.\w$])createScheduler\s*\(/g
|
|
236
|
+
// `.ws(` method call — a WebSocket route registration on a nifra app. `(?<![.\w$])` keeps it from
|
|
237
|
+
// matching identifiers that merely END in `ws` (e.g. `rows(`, `views(`). It DOES match `app.ws(`,
|
|
238
|
+
// `server().ws(`, `x.ws(` — all the chained forms — because the dot is consumed by the call, and the
|
|
239
|
+
// negative lookbehind only guards the char BEFORE `ws`, which is the dot here. We exclude the dot from
|
|
240
|
+
// the lookbehind set on purpose: a method call is exactly what we want.
|
|
241
|
+
const WS_ROUTE = /\.ws\s*\(/g
|
|
242
|
+
const WS_HUB = /(?<![.\w$])createWebSocketHub\b/
|
|
243
|
+
// `Bun.` / `Deno.` runtime globals. `(?<![.\w$])` rejects `myBun.x`, `globalThis.Bun` stays a real use
|
|
244
|
+
// (we want it), and `obj.Bun.` won't match because the char before `Bun` would be `.`.
|
|
245
|
+
const BUN_GLOBAL = /(?<![.\w$])Bun\s*\./g
|
|
246
|
+
const DENO_GLOBAL = /(?<![.\w$])Deno\s*\./g
|
|
247
|
+
// `node:` builtin specifier in any import/require/export-from form.
|
|
248
|
+
const NODE_BUILTIN = /(?:from|import|require)\s*\(?\s*["'`](node:[A-Za-z0-9/_-]+)["'`]/g
|
|
249
|
+
|
|
250
|
+
const pushHits = (
|
|
251
|
+
out: Evidence[],
|
|
252
|
+
file: string,
|
|
253
|
+
content: string,
|
|
254
|
+
code: string,
|
|
255
|
+
rx: RegExp,
|
|
256
|
+
): void => {
|
|
257
|
+
rx.lastIndex = 0
|
|
258
|
+
const lines = content.split("\n")
|
|
259
|
+
for (let m = rx.exec(code); m !== null; m = rx.exec(code)) {
|
|
260
|
+
const line = lineAt(code, m.index)
|
|
261
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The per-file scan accumulator. Each feature's evidence is collected across the whole project before
|
|
267
|
+
* the WS post-pass (in-process WS is only a hazard when NO `createWebSocketHub` exists anywhere).
|
|
268
|
+
*/
|
|
269
|
+
interface ScanState {
|
|
270
|
+
readonly evidence: Map<FeatureId, Evidence[]>
|
|
271
|
+
/** True once any file references `createWebSocketHub` — suppresses the in-process-websocket finding. */
|
|
272
|
+
hasWebSocketHub: boolean
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Scan one file's source for every feature signal, mutating `state`. Pure w.r.t. the filesystem (takes
|
|
277
|
+
* the already-read `content`). Comment- and template-literal-stripped first, so a usage example in a
|
|
278
|
+
* doc comment or a code-as-text string never false-positives (shared with `nifra check`/`doctor`).
|
|
279
|
+
*/
|
|
280
|
+
export function scanFileForFeatures(file: string, content: string, state: ScanState): void {
|
|
281
|
+
const code = stripComments(content)
|
|
282
|
+
const add = (id: FeatureId): Evidence[] => {
|
|
283
|
+
const list = state.evidence.get(id) ?? []
|
|
284
|
+
if (!state.evidence.has(id)) state.evidence.set(id, list)
|
|
285
|
+
return list
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (WS_HUB.test(code)) state.hasWebSocketHub = true
|
|
289
|
+
|
|
290
|
+
if (importsFrom(code, "@nifrajs/auth"))
|
|
291
|
+
pushHits(add("in-memory-session-store"), file, content, code, SESSION_STORE)
|
|
292
|
+
if (importsFrom(code, "@nifrajs/web"))
|
|
293
|
+
pushHits(add("in-memory-isr-cache"), file, content, code, ISR_CACHE)
|
|
294
|
+
if (importsFrom(code, "@nifrajs/middleware"))
|
|
295
|
+
pushHits(add("in-memory-rate-limit"), file, content, code, RATE_LIMIT)
|
|
296
|
+
if (importsFrom(code, "@nifrajs/cron"))
|
|
297
|
+
pushHits(add("in-process-cron"), file, content, code, CRON_SCHEDULER)
|
|
298
|
+
// WS routes are collected unconditionally; the hub-presence post-pass decides whether they're a hazard.
|
|
299
|
+
pushHits(add("in-process-websocket"), file, content, code, WS_ROUTE)
|
|
300
|
+
pushHits(add("bun-runtime-api"), file, content, code, BUN_GLOBAL)
|
|
301
|
+
pushHits(add("deno-runtime-api"), file, content, code, DENO_GLOBAL)
|
|
302
|
+
pushHits(add("node-builtin"), file, content, code, NODE_BUILTIN)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Mirror nifra check/doctor's ignore set: deps, build output, generated/per-runtime dist dirs, VCS.
|
|
306
|
+
// PLUS *.config.* and obvious build scripts (build*.ts) — `port` scans request/app source, not tooling.
|
|
307
|
+
const IGNORED =
|
|
308
|
+
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)[^/]*\.config\.[cm]?[jt]sx?$|(^|\/)build[^/]*\.[cm]?[jt]sx?$/
|
|
309
|
+
|
|
310
|
+
/** Walk the project's source (same surface as `nifra check`, minus config + build scripts). */
|
|
311
|
+
async function walkPortSource(
|
|
312
|
+
cwd: string,
|
|
313
|
+
visit: (rel: string, content: string) => void,
|
|
314
|
+
): Promise<void> {
|
|
315
|
+
for await (const rel of new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}").scan({
|
|
316
|
+
cwd,
|
|
317
|
+
dot: false,
|
|
318
|
+
})) {
|
|
319
|
+
if (IGNORED.test(rel)) continue
|
|
320
|
+
visit(rel, await Bun.file(join(cwd, rel)).text())
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const byEvidence = (a: Evidence, b: Evidence): number =>
|
|
325
|
+
a.file.localeCompare(b.file) || a.line - b.line
|
|
326
|
+
|
|
327
|
+
/** Run every detector over the project and return the detected features (sorted, stable). */
|
|
328
|
+
export async function detectFeatures(cwd: string): Promise<DetectedFeature[]> {
|
|
329
|
+
const state: ScanState = { evidence: new Map(), hasWebSocketHub: false }
|
|
330
|
+
await walkPortSource(cwd, (rel, content) => scanFileForFeatures(rel, content, state))
|
|
331
|
+
|
|
332
|
+
const detected: DetectedFeature[] = []
|
|
333
|
+
for (const id of FEATURE_IDS) {
|
|
334
|
+
const evidence = state.evidence.get(id)
|
|
335
|
+
if (evidence === undefined || evidence.length === 0) continue
|
|
336
|
+
// in-process WebSocket is only a portability hazard when the app does NOT use createWebSocketHub
|
|
337
|
+
// (the Workers Durable Object hub) — its presence means the WS routes are already edge-ready.
|
|
338
|
+
if (id === "in-process-websocket" && state.hasWebSocketHub) continue
|
|
339
|
+
const spec = FEATURES[id]
|
|
340
|
+
detected.push({
|
|
341
|
+
id,
|
|
342
|
+
label: spec.label,
|
|
343
|
+
evidence: [...evidence].sort(byEvidence),
|
|
344
|
+
verdicts: spec.verdicts,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
return detected
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- Target auto-detection ----------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
export interface ResolvedTarget {
|
|
353
|
+
readonly target: Target
|
|
354
|
+
/** How the target was determined — surfaced so the report explains itself. */
|
|
355
|
+
readonly source: "flag" | "package-json-build" | "package-json-deploy" | "wrangler" | "vercel"
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const BUILD_SCRIPT_TARGET: ReadonlyArray<readonly [RegExp, Target]> = [
|
|
359
|
+
// create-nifra's canonical per-target build scripts (build-bun.ts / build-node.ts / build-deno.ts /
|
|
360
|
+
// build-vercel.ts), plus the bare `build.ts` the cf-pages preset uses.
|
|
361
|
+
[/build-vercel\.[cm]?[jt]s\b/, "vercel"],
|
|
362
|
+
[/build-deno\.[cm]?[jt]s\b/, "deno"],
|
|
363
|
+
[/build-node\.[cm]?[jt]s\b/, "node"],
|
|
364
|
+
[/build-bun\.[cm]?[jt]s\b/, "bun"],
|
|
365
|
+
]
|
|
366
|
+
const DEPLOY_SCRIPT_TARGET: ReadonlyArray<readonly [RegExp, Target]> = [
|
|
367
|
+
[/\bvercel\s+deploy\b/, "vercel"],
|
|
368
|
+
[/\bdeployctl\b/, "deno"],
|
|
369
|
+
[/wrangler\s+pages\s+deploy\b/, "cf-pages"],
|
|
370
|
+
[/\bdocker\s+build\b/, "node"],
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
interface PackageJsonScripts {
|
|
374
|
+
readonly scripts?: Readonly<Record<string, string>>
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Resolve the app's deploy target. `--target` (validated) always wins. Otherwise infer it, in priority
|
|
379
|
+
* order, from: the canonical `build` script (create-nifra's `build-<target>.ts`), the `deploy` script
|
|
380
|
+
* (the vendor CLI it shells out to), a `wrangler.toml` (Cloudflare Pages), or a `vercel.json`. Returns
|
|
381
|
+
* `undefined` when nothing signals a target (the matrix still prints; CI gating needs an explicit one).
|
|
382
|
+
*/
|
|
383
|
+
export async function resolveTarget(
|
|
384
|
+
cwd: string,
|
|
385
|
+
override?: string,
|
|
386
|
+
): Promise<ResolvedTarget | undefined> {
|
|
387
|
+
if (override !== undefined) {
|
|
388
|
+
if (!isTarget(override)) {
|
|
389
|
+
throw new Error(`[nifra] invalid --target "${override}". options: ${TARGETS.join(", ")}`)
|
|
390
|
+
}
|
|
391
|
+
return { target: override, source: "flag" }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const pkg = await readJson<PackageJsonScripts>(join(cwd, "package.json"))
|
|
395
|
+
const scripts = pkg?.scripts
|
|
396
|
+
if (scripts !== undefined) {
|
|
397
|
+
const build = scripts.build ?? ""
|
|
398
|
+
for (const [rx, target] of BUILD_SCRIPT_TARGET) {
|
|
399
|
+
if (rx.test(build)) return { target, source: "package-json-build" }
|
|
400
|
+
}
|
|
401
|
+
const deploy = scripts.deploy ?? ""
|
|
402
|
+
for (const [rx, target] of DEPLOY_SCRIPT_TARGET) {
|
|
403
|
+
if (rx.test(deploy)) return { target, source: "package-json-deploy" }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (await Bun.file(join(cwd, "wrangler.toml")).exists()) {
|
|
408
|
+
return { target: "cf-pages", source: "wrangler" }
|
|
409
|
+
}
|
|
410
|
+
if (
|
|
411
|
+
(await Bun.file(join(cwd, "vercel.json")).exists()) ||
|
|
412
|
+
(await Bun.file(join(cwd, ".vercel", "project.json")).exists())
|
|
413
|
+
) {
|
|
414
|
+
return { target: "vercel", source: "vercel" }
|
|
415
|
+
}
|
|
416
|
+
return undefined
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function readJson<T>(path: string): Promise<T | undefined> {
|
|
420
|
+
try {
|
|
421
|
+
const parsed = JSON.parse(await Bun.file(path).text()) as unknown
|
|
422
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as T) : undefined
|
|
423
|
+
} catch {
|
|
424
|
+
return undefined
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- Result shaping (the `--json` contract) -----------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
/** One feature in the `--json` output: its id, evidence as `file:line` strings, and per-target verdicts. */
|
|
431
|
+
export interface PortJsonFeature {
|
|
432
|
+
readonly id: FeatureId
|
|
433
|
+
readonly label: string
|
|
434
|
+
readonly evidence: readonly string[]
|
|
435
|
+
readonly verdicts: Readonly<Record<Target, Verdict>>
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** One blocking finding: a detected feature unsupported on the resolved target, with its fix. */
|
|
439
|
+
export interface PortBlock {
|
|
440
|
+
readonly feature: FeatureId
|
|
441
|
+
readonly verdict: Extract<Verdict, "unsupported" | "caveat">
|
|
442
|
+
readonly reason: string
|
|
443
|
+
readonly evidence: readonly string[]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The stable machine-readable result `--json` prints. No `any`; every field is shaped. */
|
|
447
|
+
export interface PortJson {
|
|
448
|
+
/** The resolved deploy target, or `null` when none could be determined and none was given. */
|
|
449
|
+
readonly target: Target | null
|
|
450
|
+
readonly targetSource: ResolvedTarget["source"] | null
|
|
451
|
+
readonly features: readonly PortJsonFeature[]
|
|
452
|
+
/** Findings that fail the gate (unsupported), plus caveats when `--strict`. Empty ⇒ portable. */
|
|
453
|
+
readonly blocked: readonly PortBlock[]
|
|
454
|
+
/** True when the app is portable to the resolved target (or to all targets when none is resolved). */
|
|
455
|
+
readonly ok: boolean
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const evidenceStrings = (evidence: readonly Evidence[]): string[] =>
|
|
459
|
+
evidence.map((e) => `${e.file}:${e.line}`)
|
|
460
|
+
|
|
461
|
+
export interface PortResult {
|
|
462
|
+
readonly resolved: ResolvedTarget | undefined
|
|
463
|
+
readonly detected: readonly DetectedFeature[]
|
|
464
|
+
readonly json: PortJson
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Compute the full `port` result: detect features, resolve the target, and derive the blocking findings.
|
|
469
|
+
* A finding blocks when the detected feature is `unsupported` on the resolved target — always — and
|
|
470
|
+
* (with `strict`) also when it's a `caveat`. With no resolved target there's nothing to gate against,
|
|
471
|
+
* so `blocked` is empty and `ok` reflects "are there hazards at all" only when the caller asks.
|
|
472
|
+
*/
|
|
473
|
+
export async function collectPortResult(
|
|
474
|
+
cwd: string,
|
|
475
|
+
opts: { readonly target?: string; readonly strict?: boolean } = {},
|
|
476
|
+
): Promise<PortResult> {
|
|
477
|
+
const [detected, resolved] = await Promise.all([
|
|
478
|
+
detectFeatures(cwd),
|
|
479
|
+
resolveTarget(cwd, opts.target),
|
|
480
|
+
])
|
|
481
|
+
|
|
482
|
+
const blocked: PortBlock[] = []
|
|
483
|
+
if (resolved !== undefined) {
|
|
484
|
+
for (const feature of detected) {
|
|
485
|
+
const verdict = feature.verdicts[resolved.target]
|
|
486
|
+
const blocks = verdict === "unsupported" || (verdict === "caveat" && opts.strict === true)
|
|
487
|
+
if (!blocks) continue
|
|
488
|
+
blocked.push({
|
|
489
|
+
feature: feature.id,
|
|
490
|
+
verdict,
|
|
491
|
+
reason: FEATURES[feature.id].reasons[resolved.target],
|
|
492
|
+
evidence: evidenceStrings(feature.evidence),
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const json: PortJson = {
|
|
498
|
+
target: resolved?.target ?? null,
|
|
499
|
+
targetSource: resolved?.source ?? null,
|
|
500
|
+
features: detected.map((f) => ({
|
|
501
|
+
id: f.id,
|
|
502
|
+
label: f.label,
|
|
503
|
+
evidence: evidenceStrings(f.evidence),
|
|
504
|
+
verdicts: f.verdicts,
|
|
505
|
+
})),
|
|
506
|
+
blocked,
|
|
507
|
+
ok: blocked.length === 0,
|
|
508
|
+
}
|
|
509
|
+
return { resolved, detected, json }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// --- Rendering ----------------------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
const CELL: Readonly<Record<Verdict, string>> = { ok: "✓", caveat: "⚠", unsupported: "✗" }
|
|
515
|
+
const COL_WIDTH = 9 // widest target header ("cf-pages") + padding
|
|
516
|
+
|
|
517
|
+
const padCell = (s: string, width: number): string => {
|
|
518
|
+
// Pad accounting for the symbol's display width (✓/⚠/✗ render as 1 cell in a monospace terminal).
|
|
519
|
+
const len = [...s].length
|
|
520
|
+
return s + " ".repeat(Math.max(0, width - len))
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Render the feature × target matrix + evidence + summary as a readable report. */
|
|
524
|
+
export function renderReport(result: PortResult, opts: { readonly strict?: boolean }): string {
|
|
525
|
+
const { detected, resolved, json } = result
|
|
526
|
+
const out: string[] = ["nifra port — portability matrix", ""]
|
|
527
|
+
|
|
528
|
+
if (resolved !== undefined) {
|
|
529
|
+
out.push(`target: ${resolved.target} (detected from ${describeSource(resolved.source)})`)
|
|
530
|
+
} else {
|
|
531
|
+
out.push(
|
|
532
|
+
"target: not detected — pass --target <bun|node|deno|cf-pages|vercel> to gate against one",
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
out.push("")
|
|
536
|
+
|
|
537
|
+
if (detected.length === 0) {
|
|
538
|
+
out.push("✓ no portability hazards detected — this app is portable across all targets.")
|
|
539
|
+
return out.join("\n")
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Header row.
|
|
543
|
+
const labelWidth = Math.max(22, ...detected.map((f) => f.label.length)) + 2
|
|
544
|
+
const header = padCell("feature", labelWidth) + TARGETS.map((t) => padCell(t, COL_WIDTH)).join("")
|
|
545
|
+
out.push(header)
|
|
546
|
+
out.push("-".repeat(header.length))
|
|
547
|
+
for (const feature of detected) {
|
|
548
|
+
const cells = TARGETS.map((t) => padCell(CELL[feature.verdicts[t]], COL_WIDTH)).join("")
|
|
549
|
+
out.push(padCell(feature.label, labelWidth) + cells)
|
|
550
|
+
}
|
|
551
|
+
out.push("")
|
|
552
|
+
out.push("legend: ✓ ok ⚠ caveat (works with a change) ✗ unsupported")
|
|
553
|
+
out.push("")
|
|
554
|
+
|
|
555
|
+
// Evidence per detected feature.
|
|
556
|
+
out.push("detected features + evidence:")
|
|
557
|
+
for (const feature of detected) {
|
|
558
|
+
out.push(` • ${feature.label} (${feature.id})`)
|
|
559
|
+
out.push(` why: ${FEATURES[feature.id].summary}`)
|
|
560
|
+
for (const e of feature.evidence) {
|
|
561
|
+
out.push(` ${e.file}:${e.line} ${e.snippet}`)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
out.push("")
|
|
565
|
+
|
|
566
|
+
// Summary + blocking detail.
|
|
567
|
+
if (resolved === undefined) {
|
|
568
|
+
out.push(
|
|
569
|
+
`• ${detected.length} portability hazard(s) detected; no target resolved, so nothing is gated. ` +
|
|
570
|
+
"Pass --target to check one.",
|
|
571
|
+
)
|
|
572
|
+
return out.join("\n")
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (json.blocked.length === 0) {
|
|
576
|
+
// With the gate passing, surface any caveats for the resolved target so the user still sees the
|
|
577
|
+
// "works, but with a change" cases (they don't fail without --strict, but they're not free either).
|
|
578
|
+
const caveatHere = detected.filter((f) => f.verdicts[resolved.target] === "caveat")
|
|
579
|
+
if (caveatHere.length === 0) {
|
|
580
|
+
out.push(`✓ portable to ${resolved.target}: every detected feature is supported there.`)
|
|
581
|
+
} else {
|
|
582
|
+
out.push(
|
|
583
|
+
`✓ no blockers for ${resolved.target}, but ${caveatHere.length} feature(s) need a change there:`,
|
|
584
|
+
)
|
|
585
|
+
for (const f of caveatHere) {
|
|
586
|
+
out.push(` ⚠ ${f.label} — ${FEATURES[f.id].reasons[resolved.target]}`)
|
|
587
|
+
}
|
|
588
|
+
out.push("\n(caveats don't fail the gate — run with --strict to treat them as blockers)")
|
|
589
|
+
}
|
|
590
|
+
return out.join("\n")
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const unsupported = json.blocked.filter((b) => b.verdict === "unsupported")
|
|
594
|
+
const caveats = json.blocked.filter((b) => b.verdict === "caveat")
|
|
595
|
+
out.push(`✗ ${json.blocked.length} feature(s) block deploying to ${resolved.target}:`)
|
|
596
|
+
for (const b of [...unsupported, ...caveats]) {
|
|
597
|
+
const mark = b.verdict === "unsupported" ? "✗" : "⚠"
|
|
598
|
+
out.push(` ${mark} ${FEATURES[b.feature].label} — ${b.verdict} on ${resolved.target}`)
|
|
599
|
+
out.push(` fix: ${b.reason}`)
|
|
600
|
+
out.push(` at: ${b.evidence.join(", ")}`)
|
|
601
|
+
}
|
|
602
|
+
if (!opts.strict && unsupported.length === 0) {
|
|
603
|
+
out.push("\n(only caveats — run with --strict to fail on these)")
|
|
604
|
+
}
|
|
605
|
+
return out.join("\n")
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function describeSource(source: ResolvedTarget["source"]): string {
|
|
609
|
+
switch (source) {
|
|
610
|
+
case "flag":
|
|
611
|
+
return "--target"
|
|
612
|
+
case "package-json-build":
|
|
613
|
+
return "the package.json build script"
|
|
614
|
+
case "package-json-deploy":
|
|
615
|
+
return "the package.json deploy script"
|
|
616
|
+
case "wrangler":
|
|
617
|
+
return "wrangler.toml"
|
|
618
|
+
case "vercel":
|
|
619
|
+
return "vercel config"
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export interface RunPortOptions {
|
|
624
|
+
readonly target?: string
|
|
625
|
+
readonly json?: boolean
|
|
626
|
+
/** Fail (exit non-zero) on a detected unsupported feature for the resolved target. Implied by --target. */
|
|
627
|
+
readonly ci?: boolean
|
|
628
|
+
/** With --ci, also fail on caveats (not just unsupported). */
|
|
629
|
+
readonly strict?: boolean
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Run `nifra port`: detect, resolve, render. Returns `true` when the run "passes" — meaning either the
|
|
634
|
+
* gate is off, or the gate is on and nothing blocks. The caller maps `false` → exit 1.
|
|
635
|
+
*
|
|
636
|
+
* The gate fires when `--ci` is passed OR a `--target` is set (an explicit target signals intent to
|
|
637
|
+
* gate). With no resolved target and `--ci`, the run is a hard error: there's nothing to gate against.
|
|
638
|
+
*/
|
|
639
|
+
export async function runPort(cwd: string, opts: RunPortOptions = {}): Promise<boolean> {
|
|
640
|
+
const result = await collectPortResult(cwd, {
|
|
641
|
+
...(opts.target !== undefined ? { target: opts.target } : {}),
|
|
642
|
+
...(opts.strict ? { strict: true } : {}),
|
|
643
|
+
})
|
|
644
|
+
// The gate is on when --ci or an explicit --target was given.
|
|
645
|
+
const gating = opts.ci === true || opts.target !== undefined
|
|
646
|
+
|
|
647
|
+
if (opts.json) {
|
|
648
|
+
console.log(JSON.stringify(result.json, null, 2))
|
|
649
|
+
} else {
|
|
650
|
+
console.log(renderReport(result, { strict: opts.strict ?? false }))
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (!gating) return true
|
|
654
|
+
if (result.resolved === undefined) {
|
|
655
|
+
if (!opts.json) {
|
|
656
|
+
console.error(
|
|
657
|
+
"\n[nifra] --ci needs a deploy target to gate against, and none was detected. Pass --target <bun|node|deno|cf-pages|vercel>.",
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
return false
|
|
661
|
+
}
|
|
662
|
+
return result.json.blocked.length === 0
|
|
663
|
+
}
|