@webjsdev/cli 0.10.55 → 0.10.57
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/lib/create.js +43 -1
- package/lib/doctor/codes.js +67 -0
- package/lib/doctor/manifest.js +161 -0
- package/lib/doctor/policy.js +124 -0
- package/lib/doctor/probes/elision.js +111 -0
- package/lib/doctor/probes/env.js +53 -0
- package/lib/doctor/probes/framework-resolves.js +260 -0
- package/lib/doctor/probes/git-hook.js +58 -0
- package/lib/doctor/probes/importmap-coherence.js +158 -0
- package/lib/doctor/probes/node.js +37 -0
- package/lib/doctor/probes/static-asset-freshness.js +58 -0
- package/lib/doctor/probes/tsconfig.js +55 -0
- package/lib/doctor/probes/unmarked-asset-links.js +199 -0
- package/lib/doctor/probes/vendor-gitignore.js +84 -0
- package/lib/doctor/probes/vendor-pin.js +77 -0
- package/lib/doctor/probes/webjs-versions.js +85 -0
- package/lib/doctor/route-modules.js +100 -0
- package/lib/doctor/runner.js +72 -0
- package/lib/doctor/util.js +160 -0
- package/lib/doctor.js +5 -1634
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +4 -3
- package/templates/.agents/skills/webjs/SKILL.md +46 -4
- package/templates/.agents/skills/webjs/references/built-ins.md +3 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +52 -5
- package/templates/.agents/skills/webjs/references/components.md +50 -2
- package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +27 -3
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
- package/templates/.agents/skills/webjs/references/runtime.md +6 -1
- package/templates/.agents/skills/webjs/references/styling.md +47 -2
- package/templates/.github/pull_request_template.md +0 -4
- package/templates/gallery/app/features/boundaries/page.ts +11 -0
- package/templates/gallery/app/features/client-router/page.ts +13 -2
- package/templates/gallery/app/features/metadata/page.ts +7 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +23 -2
- package/templates/gallery/modules/gallery/nav.ts +35 -26
- package/templates/gallery/modules/stream/components/browser/stream-demo.test.js +64 -0
- package/templates/gallery/modules/stream/components/stream-demo.ts +14 -9
- package/templates/gallery/modules/stream/utils/ui/row.ts +41 -0
- package/templates/gallery/test/rate-limit/rate-limit.test.ts +2 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is
|
|
11
|
+
* directory-relative, so this must probe FROM the app (not the CLI's own
|
|
12
|
+
* location, which resolves the framework fine from a global install even when
|
|
13
|
+
* the app cannot). A no-op-cheap resolve, no I/O beyond what Node's resolver
|
|
14
|
+
* does, no network. Returns true when the framework resolves, false otherwise.
|
|
15
|
+
* @param {string} appDir
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function frameworkResolves(appDir) {
|
|
19
|
+
try {
|
|
20
|
+
// The base file need not exist; createRequire only uses it to anchor the
|
|
21
|
+
// node_modules lookup at appDir.
|
|
22
|
+
const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
|
|
23
|
+
require.resolve('@webjsdev/core');
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* CHECK 8, framework resolvability (#954). WARN when `@webjsdev/core` cannot be
|
|
32
|
+
* resolved FROM the app directory, which is the fresh-git-worktree trap: a
|
|
33
|
+
* worktree does not copy `node_modules`, so a plain `webjs dev` there dies at
|
|
34
|
+
* SSR with a raw `ERR_MODULE_NOT_FOUND: Cannot find package '@webjsdev/core'`
|
|
35
|
+
* whose remedy is not obvious. Silent PASS when the framework resolves (the
|
|
36
|
+
* common case), so this never slows a healthy app. WARN (not a hard fail): it
|
|
37
|
+
* is a setup/environment concern, the same tier as the version-coherence check.
|
|
38
|
+
* @param {string} appDir
|
|
39
|
+
* @returns {DoctorResult}
|
|
40
|
+
*/
|
|
41
|
+
export function checkFrameworkResolves(appDir) {
|
|
42
|
+
const name = 'framework-resolve';
|
|
43
|
+
if (frameworkResolves(appDir)) {
|
|
44
|
+
return { name, status: 'pass', message: '@webjsdev/core resolves from the app directory.' };
|
|
45
|
+
}
|
|
46
|
+
const hasNodeModules = existsSync(join(appDir, 'node_modules'));
|
|
47
|
+
// A git worktree checks out `.git` as a FILE (a gitdir pointer), not a
|
|
48
|
+
// directory. That, plus a missing node_modules, is the exact #954 cause.
|
|
49
|
+
let isWorktree = false;
|
|
50
|
+
try {
|
|
51
|
+
isWorktree = statSync(join(appDir, '.git')).isFile();
|
|
52
|
+
} catch {
|
|
53
|
+
isWorktree = false;
|
|
54
|
+
}
|
|
55
|
+
if (isWorktree && !hasNodeModules) {
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
status: 'warn',
|
|
59
|
+
message:
|
|
60
|
+
'@webjsdev/core cannot be resolved from this directory, and this is a git worktree with no ' +
|
|
61
|
+
'node_modules. Git worktrees do not copy node_modules, so the framework is unresolvable here ' +
|
|
62
|
+
'and `webjs dev` / `webjs start` would fail at SSR with a raw ERR_MODULE_NOT_FOUND.',
|
|
63
|
+
fix: freshWorktreeFix(appDir),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (!hasNodeModules) {
|
|
67
|
+
return {
|
|
68
|
+
name,
|
|
69
|
+
status: 'warn',
|
|
70
|
+
message: '@webjsdev/core cannot be resolved from this directory (no node_modules present).',
|
|
71
|
+
fix: 'Run `npm install` in the app directory so the framework resolves.',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
status: 'warn',
|
|
77
|
+
message:
|
|
78
|
+
'@webjsdev/core cannot be resolved from this directory even though node_modules exists ' +
|
|
79
|
+
'(a partial or corrupted install).',
|
|
80
|
+
fix: linkAwareReinstallFix(appDir),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether `dir`'s own `node_modules` is a SYMLINK, which in a linked worktree
|
|
86
|
+
* means it points at the primary checkout's tree. The remedies below branch on
|
|
87
|
+
* this, because `npm install` is the right advice when it is false and is the
|
|
88
|
+
* exact command that corrupts the primary when it is true (#1442).
|
|
89
|
+
* @param {string} dir
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function modulesAreLinked(dir) {
|
|
93
|
+
try { return lstatSync(join(dir, 'node_modules')).isSymbolicLink(); } catch { return false; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The `npm run worktree:link` sentence, but ONLY where that script exists.
|
|
98
|
+
*
|
|
99
|
+
* This module ships in the PUBLISHED CLI, and `bin/webjs.js` prints these
|
|
100
|
+
* remedies verbatim as the `webjs dev` / `webjs start` preflight failure. A
|
|
101
|
+
* scaffolded app has no `worktree:link` script, so naming it unconditionally
|
|
102
|
+
* sends the exact audience this check exists for (#954, a fresh app worktree)
|
|
103
|
+
* to run something that does not exist. Walk up for a package.json that really
|
|
104
|
+
* declares it, and stay silent otherwise.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} appDir
|
|
107
|
+
* @returns {string} a leading-space sentence, or the empty string
|
|
108
|
+
*/
|
|
109
|
+
function linkScriptHint(appDir) {
|
|
110
|
+
let dir = resolve(appDir);
|
|
111
|
+
for (let i = 0; i < 8; i += 1) {
|
|
112
|
+
try {
|
|
113
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
114
|
+
if (pkg?.scripts?.['worktree:link']) {
|
|
115
|
+
return ' In this repo, `npm run worktree:link` does the whole setup and also repairs the shared tree.';
|
|
116
|
+
}
|
|
117
|
+
} catch { /* no package.json here, keep walking */ }
|
|
118
|
+
const up = dirname(dir);
|
|
119
|
+
if (up === dir) break;
|
|
120
|
+
dir = up;
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remedy for the #954 fresh-worktree case, which is a worktree with NO
|
|
127
|
+
* `node_modules` at all. There is no symlink in the way yet, so a real install
|
|
128
|
+
* is safe here, and this stays the app-generic advice it has always been.
|
|
129
|
+
* @param {string} appDir
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function freshWorktreeFix(appDir) {
|
|
133
|
+
return (
|
|
134
|
+
'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' +
|
|
135
|
+
'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`). If you symlink, ' +
|
|
136
|
+
'never run an install through that link afterwards: it acts on the checkout that owns the ' +
|
|
137
|
+
'tree, not this one (#1442).' + linkScriptHint(appDir)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Remedy for a `node_modules` that exists but does not resolve the framework.
|
|
143
|
+
* When it is a SYMLINK, a bare `npm install` is the action that corrupts the
|
|
144
|
+
* checkout that owns the tree, so the advice has to differ.
|
|
145
|
+
* @param {string} appDir
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
function linkAwareReinstallFix(appDir) {
|
|
149
|
+
if (modulesAreLinked(appDir)) {
|
|
150
|
+
return (
|
|
151
|
+
'node_modules here is a SYMLINK at another checkout, so do NOT run `npm install`: it would act ' +
|
|
152
|
+
'on that checkout, not this one (#1442). Either reinstall in the checkout that owns the tree, ' +
|
|
153
|
+
'or remove every node_modules symlink first, nested ones included ' +
|
|
154
|
+
'(`find . -maxdepth 5 -type l -name node_modules -delete`), and install here.' + linkScriptHint(appDir)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Classify the `@webjsdev/core` entry that `appDir` would resolve through.
|
|
162
|
+
*
|
|
163
|
+
* Walks up for the first `node_modules` carrying the package, then judges the
|
|
164
|
+
* link against the tree that PHYSICALLY owns that `node_modules`. That owner
|
|
165
|
+
* rule is the only one correct in a linked worktree: there `node_modules` is
|
|
166
|
+
* itself a symlink at the primary's, so the owning tree is the PRIMARY and a
|
|
167
|
+
* target inside it is right rather than foreign. Judging against `appDir` would
|
|
168
|
+
* report every correctly linked worktree as corrupted.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} appDir
|
|
171
|
+
* @param {string} [pkg]
|
|
172
|
+
* @returns {{ state: 'absent'|'real'|'ok'|'dangling'|'foreign', entry?: string, target?: string, owner?: string }}
|
|
173
|
+
*/
|
|
174
|
+
export function inspectFrameworkLink(appDir, pkg = '@webjsdev/core') {
|
|
175
|
+
const parts = pkg.split('/');
|
|
176
|
+
let dir = resolve(appDir);
|
|
177
|
+
for (;;) {
|
|
178
|
+
const modules = join(dir, 'node_modules');
|
|
179
|
+
const entry = join(modules, ...parts);
|
|
180
|
+
let st = null;
|
|
181
|
+
try { st = lstatSync(entry); } catch { st = null; }
|
|
182
|
+
if (st) {
|
|
183
|
+
if (!st.isSymbolicLink()) return { state: 'real', entry };
|
|
184
|
+
let target = '';
|
|
185
|
+
try { target = readlinkSync(entry); } catch { return { state: 'real', entry }; }
|
|
186
|
+
// Resolve the target against the directory the link PHYSICALLY sits in,
|
|
187
|
+
// which is what the OS does. In a linked worktree `node_modules` is itself
|
|
188
|
+
// a symlink, so the lexical `dirname(entry)` is under the WORKTREE while
|
|
189
|
+
// the link really lives in the primary. Resolving lexically turns every
|
|
190
|
+
// correct `../../packages/core` into a worktree path and reports a healthy
|
|
191
|
+
// linked worktree as `foreign`.
|
|
192
|
+
let base = dirname(entry);
|
|
193
|
+
try { base = realpathSync(base); } catch { /* fall back to the lexical path */ }
|
|
194
|
+
const abs = resolve(base, target);
|
|
195
|
+
let owner = dir;
|
|
196
|
+
try { owner = dirname(realpathSync(modules)); } catch { /* use dir as given */ }
|
|
197
|
+
if (!existsSync(abs)) return { state: 'dangling', entry, target, owner };
|
|
198
|
+
let real = abs;
|
|
199
|
+
try { real = realpathSync(abs); } catch { /* compare the unresolved path */ }
|
|
200
|
+
let ownerReal = owner;
|
|
201
|
+
try { ownerReal = realpathSync(owner); } catch { /* compare as given */ }
|
|
202
|
+
if (real !== ownerReal && !real.startsWith(ownerReal + sep)) {
|
|
203
|
+
return { state: 'foreign', entry, target, owner: ownerReal };
|
|
204
|
+
}
|
|
205
|
+
return { state: 'ok', entry, target, owner: ownerReal };
|
|
206
|
+
}
|
|
207
|
+
const up = dirname(dir);
|
|
208
|
+
if (up === dir) return { state: 'absent' };
|
|
209
|
+
dir = up;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* CHECK: framework link integrity (#1442). WARN when the `@webjsdev/core` entry
|
|
215
|
+
* in node_modules is a symlink that DANGLES or resolves OUTSIDE the tree that
|
|
216
|
+
* owns it. That is what an install run inside a linked worktree leaves behind,
|
|
217
|
+
* and it is invisible to the framework-resolve check above, which only asks
|
|
218
|
+
* whether the package resolves at all: a link into a live FOREIGN checkout
|
|
219
|
+
* resolves perfectly and silently runs another branch's framework source.
|
|
220
|
+
*
|
|
221
|
+
* Silent PASS for a real directory, a correct link, and no entry at all, so a
|
|
222
|
+
* normally installed app pays one `lstat`. WARN rather than fail, the same
|
|
223
|
+
* environment tier as the framework-resolve and version-coherence checks.
|
|
224
|
+
* @param {string} appDir
|
|
225
|
+
* @returns {DoctorResult}
|
|
226
|
+
*/
|
|
227
|
+
export function checkFrameworkLinks(appDir) {
|
|
228
|
+
const name = 'framework-links';
|
|
229
|
+
const r = inspectFrameworkLink(appDir);
|
|
230
|
+
if (r.state === 'absent' || r.state === 'real' || r.state === 'ok') {
|
|
231
|
+
return {
|
|
232
|
+
name,
|
|
233
|
+
status: 'pass',
|
|
234
|
+
message: 'The @webjsdev framework links resolve inside the checkout that owns them.',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const fix =
|
|
238
|
+
'Repoint the entry at the package inside the checkout that owns this node_modules. Do NOT run ' +
|
|
239
|
+
'`npm install` here while node_modules is a symlink: it acts on that owning checkout, not this ' +
|
|
240
|
+
'one (#1442).' + linkScriptHint(appDir);
|
|
241
|
+
if (r.state === 'dangling') {
|
|
242
|
+
return {
|
|
243
|
+
name,
|
|
244
|
+
status: 'warn',
|
|
245
|
+
message:
|
|
246
|
+
`${r.entry} is a symlink to ${r.target}, which does not exist. An install run inside a linked ` +
|
|
247
|
+
'worktree leaves these behind, and the worktree it named has since been removed.',
|
|
248
|
+
fix,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
name,
|
|
253
|
+
status: 'warn',
|
|
254
|
+
message:
|
|
255
|
+
`${r.entry} is a symlink to ${r.target}, which resolves OUTSIDE ${r.owner}, the checkout that ` +
|
|
256
|
+
'owns this node_modules. It resolves fine, so nothing fails, and the framework source being run ' +
|
|
257
|
+
"is another checkout's. A deliberate `npm link` produces the same shape.",
|
|
258
|
+
fix,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CHECK 6 (optional), git pre-commit hook installed + executable. WARN when the
|
|
10
|
+
* repo is a git checkout but `.git/hooks/pre-commit` is absent or
|
|
11
|
+
* non-executable, since the test-gate / changelog hook would not fire. PASS when
|
|
12
|
+
* present + executable, or skip (PASS) when this is not a git checkout at all
|
|
13
|
+
* (an exported tarball, a non-repo dir). Respects a configured `core.hooksPath`
|
|
14
|
+
* is OUT of scope here: the common scaffold installs into `.git/hooks`, so this
|
|
15
|
+
* checks the default location and a configured path is the user's own concern.
|
|
16
|
+
* @param {string} appDir
|
|
17
|
+
* @returns {DoctorResult}
|
|
18
|
+
*/
|
|
19
|
+
export function checkGitHook(appDir) {
|
|
20
|
+
const gitDir = join(appDir, '.git');
|
|
21
|
+
if (!existsSync(gitDir)) {
|
|
22
|
+
return {
|
|
23
|
+
name: 'git-hook',
|
|
24
|
+
status: 'pass',
|
|
25
|
+
message: 'Not a git checkout; no pre-commit hook expected.',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const hook = join(gitDir, 'hooks', 'pre-commit');
|
|
29
|
+
if (!existsSync(hook)) {
|
|
30
|
+
return {
|
|
31
|
+
name: 'git-hook',
|
|
32
|
+
status: 'warn',
|
|
33
|
+
message: 'No .git/hooks/pre-commit hook installed.',
|
|
34
|
+
fix: 'Install the project hooks (e.g. `npm install` runs the prepare step that wires them).',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
let executable = false;
|
|
38
|
+
try {
|
|
39
|
+
// Owner-execute bit. On a checkout without exec bits (some Windows / CI
|
|
40
|
+
// setups) the hook will not run, so flag it.
|
|
41
|
+
executable = (statSync(hook).mode & 0o100) !== 0;
|
|
42
|
+
} catch {
|
|
43
|
+
executable = false;
|
|
44
|
+
}
|
|
45
|
+
if (!executable) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'git-hook',
|
|
48
|
+
status: 'warn',
|
|
49
|
+
message: '.git/hooks/pre-commit exists but is not executable.',
|
|
50
|
+
fix: 'chmod +x .git/hooks/pre-commit',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
name: 'git-hook',
|
|
55
|
+
status: 'pass',
|
|
56
|
+
message: '.git/hooks/pre-commit is installed and executable.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { formatConflicts, makeInstalledManifestReader } from '../manifest.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an
|
|
9
|
+
* INCOHERENT client dependency graph in the produced importmap, regardless of
|
|
10
|
+
* how the incoherence arose (a hand-edited pin file, a partial vendor pin, or
|
|
11
|
+
* the #446 resolution skew). For each resolved package, it checks that the
|
|
12
|
+
* version actually pinned for every OTHER resolved package it depends on
|
|
13
|
+
* satisfies the declared range; a miss warns naming both packages, the range,
|
|
14
|
+
* and the pinned version.
|
|
15
|
+
*
|
|
16
|
+
* Runs the SAME check over BOTH inputs and produces the same verdict for the
|
|
17
|
+
* same dep set (the parity invariant): the live importmap (resolved the way the
|
|
18
|
+
* server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`.
|
|
19
|
+
* A vendored importmap is a freeze of the runtime-resolved graph, so a coherent
|
|
20
|
+
* runtime graph that gets vendored stays coherent.
|
|
21
|
+
*
|
|
22
|
+
* WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the
|
|
23
|
+
* app's concern, not a broken toolchain), and it degrades to a soft
|
|
24
|
+
* "could not verify" whenever metadata or a live resolve is unavailable rather
|
|
25
|
+
* than failing closed. Dependency metadata is read from the already-installed
|
|
26
|
+
* `node_modules` manifests, no network call of its own; the only network touch
|
|
27
|
+
* is the live importmap resolve, which is wrapped so any failure degrades.
|
|
28
|
+
*
|
|
29
|
+
* The vendor functions + manifest reader are injectable via `opts.coherence`
|
|
30
|
+
* so a test can drive every branch without a network call.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} appDir
|
|
33
|
+
* @param {{ coherence?: {
|
|
34
|
+
* liveImports?: () => Promise<Record<string,string> | null>,
|
|
35
|
+
* vendoredImports?: () => Promise<Record<string,string> | null>,
|
|
36
|
+
* getManifest?: (pkg: string, version: string) => Promise<any>,
|
|
37
|
+
* check?: (imports: Record<string,string>, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>,
|
|
38
|
+
* } }} opts
|
|
39
|
+
* @returns {Promise<DoctorResult>}
|
|
40
|
+
*/
|
|
41
|
+
export async function checkImportmapCoherence(appDir, opts) {
|
|
42
|
+
let inj = opts.coherence;
|
|
43
|
+
// Resolve the real vendor toolchain unless a test injected stubs. Both the
|
|
44
|
+
// importmap sources and the coherence-check function come from
|
|
45
|
+
// @webjsdev/server, so a missing install degrades to a WARN, never a throw.
|
|
46
|
+
if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) {
|
|
47
|
+
let mod;
|
|
48
|
+
try {
|
|
49
|
+
mod = await import('@webjsdev/server');
|
|
50
|
+
} catch {
|
|
51
|
+
return {
|
|
52
|
+
name: 'importmap-coherence',
|
|
53
|
+
status: 'warn',
|
|
54
|
+
bestEffort: true,
|
|
55
|
+
message: 'Could not load the vendor toolchain to check importmap coherence.',
|
|
56
|
+
fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const real = {
|
|
60
|
+
check: mod.checkImportmapCoherence,
|
|
61
|
+
// Hoist-aware manifest read from the already-installed node_modules (no
|
|
62
|
+
// network of its own), so a monorepo-hoisted dep still resolves. Falls
|
|
63
|
+
// back to the local app/node_modules read if the server build predates
|
|
64
|
+
// getPackageManifest.
|
|
65
|
+
getManifest: typeof mod.getPackageManifest === 'function'
|
|
66
|
+
? (pkg) => mod.getPackageManifest(pkg, appDir)
|
|
67
|
+
: makeInstalledManifestReader(appDir),
|
|
68
|
+
// Live importmap: resolve vendor imports the way the server does on the
|
|
69
|
+
// first request (prefers the pin file, else a live jspm.io resolve).
|
|
70
|
+
liveImports: async () => {
|
|
71
|
+
try {
|
|
72
|
+
const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir));
|
|
73
|
+
return resolved && resolved.imports ? resolved.imports : {};
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
// Vendored importmap: the committed pin file, no network.
|
|
79
|
+
vendoredImports: async () => {
|
|
80
|
+
try {
|
|
81
|
+
const pin = await mod.readPinFile(appDir);
|
|
82
|
+
return pin && pin.imports ? pin.imports : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
inj = { ...real, ...(inj || {}) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Gather both importmaps. Either may be absent (no pin file, or a live
|
|
92
|
+
// resolve that failed / found no vendor imports); the check runs over
|
|
93
|
+
// whichever exist, identically.
|
|
94
|
+
let live = null;
|
|
95
|
+
let vendored = null;
|
|
96
|
+
try { live = await inj.liveImports(); } catch { live = null; }
|
|
97
|
+
try { vendored = await inj.vendoredImports(); } catch { vendored = null; }
|
|
98
|
+
|
|
99
|
+
const liveHas = live && Object.keys(live).length > 0;
|
|
100
|
+
const vendoredHas = vendored && Object.keys(vendored).length > 0;
|
|
101
|
+
if (!liveHas && !vendoredHas) {
|
|
102
|
+
return {
|
|
103
|
+
name: 'importmap-coherence',
|
|
104
|
+
status: 'pass',
|
|
105
|
+
message: 'No vendor importmap to check (the app imports no npm packages on the client).',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Run the IDENTICAL check over each available importmap. The function is
|
|
110
|
+
// pure in (imports, getManifest), so the same pinned dep set produces the
|
|
111
|
+
// same verdict whichever input it came from (the runtime-vs-vendored parity
|
|
112
|
+
// invariant). Aggregate the conflicts; dedupe identical ones so a package
|
|
113
|
+
// pinned the same way in both maps is reported once.
|
|
114
|
+
/** @type {Map<string, any>} */
|
|
115
|
+
const conflictsByKey = new Map();
|
|
116
|
+
let anyChecked = 0;
|
|
117
|
+
let anyUnverified = 0;
|
|
118
|
+
for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) {
|
|
119
|
+
if (!imports) continue;
|
|
120
|
+
let report;
|
|
121
|
+
try {
|
|
122
|
+
report = await inj.check(imports, { getManifest: inj.getManifest });
|
|
123
|
+
} catch {
|
|
124
|
+
// A check that threw is a "could not verify", never a doctor crash.
|
|
125
|
+
anyUnverified++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
anyChecked += report.checked || 0;
|
|
129
|
+
anyUnverified += (report.unverified || []).length;
|
|
130
|
+
for (const c of report.conflicts || []) {
|
|
131
|
+
conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const conflicts = [...conflictsByKey.values()];
|
|
136
|
+
if (conflicts.length > 0) {
|
|
137
|
+
return {
|
|
138
|
+
name: 'importmap-coherence',
|
|
139
|
+
status: 'warn',
|
|
140
|
+
message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`,
|
|
141
|
+
fix: 'Align the pinned versions: re-run `webjs vendor pin` to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version satisfying every dependent.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (anyChecked === 0 && anyUnverified > 0) {
|
|
145
|
+
return {
|
|
146
|
+
name: 'importmap-coherence',
|
|
147
|
+
status: 'warn',
|
|
148
|
+
bestEffort: true,
|
|
149
|
+
message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
|
|
150
|
+
fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
name: 'importmap-coherence',
|
|
155
|
+
status: 'pass',
|
|
156
|
+
message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { checkNodeInline } from '../../node-preflight.js';
|
|
2
|
+
import { readEngines } from '../util.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CHECK 1, Node version. HARD-FAIL when the running major is below the required
|
|
10
|
+
* major (the strip-types + recursive fs.watch floor). `opts.nodeVersion` lets a
|
|
11
|
+
* test inject the running version so the fail case is assertable without being
|
|
12
|
+
* on old Node.
|
|
13
|
+
* @param {string} cliDir
|
|
14
|
+
* @param {{ nodeVersion?: string }} opts
|
|
15
|
+
* @returns {Promise<DoctorResult>}
|
|
16
|
+
*/
|
|
17
|
+
export async function checkNode(cliDir, opts) {
|
|
18
|
+
const engines = await readEngines(cliDir);
|
|
19
|
+
const current = opts.nodeVersion || process.versions.node;
|
|
20
|
+
const r = checkNodeInline(current, engines);
|
|
21
|
+
if (r.ok) {
|
|
22
|
+
return {
|
|
23
|
+
name: 'node-version',
|
|
24
|
+
status: 'pass',
|
|
25
|
+
message: `Node ${r.current} satisfies the required Node ${r.requiredMajor}+.`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
name: 'node-version',
|
|
30
|
+
status: 'fail',
|
|
31
|
+
message:
|
|
32
|
+
`Node ${r.current} is below the required Node ${r.requiredMajor}+. ` +
|
|
33
|
+
`webjs is buildless and relies on Node ${r.requiredMajor}'s built-in TypeScript ` +
|
|
34
|
+
`strip and recursive fs.watch.`,
|
|
35
|
+
fix: `Upgrade to Node ${r.requiredMajor}+ (see https://nodejs.org).`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { newestMtimeMs } from '../util.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ADVISORY: a declared `webjs.dev.regenerate` output is STALE on disk (a source
|
|
11
|
+
* is newer than the committed/built output). In DEV the framework recompiles it
|
|
12
|
+
* on request (#967), so this never bites locally, but the check is the explicit
|
|
13
|
+
* dev/prod PARITY backstop: it catches a stale `public/tailwind.css` that would
|
|
14
|
+
* be served as-is by `webjs start` (prod does NOT recompile on request) or
|
|
15
|
+
* committed into the repo. WARN-level: the fix is a one-line rebuild, and a
|
|
16
|
+
* missing output (a fresh clone before the first `css:build`) is not this app's
|
|
17
|
+
* bug to hard-fail on.
|
|
18
|
+
* @param {string} appDir
|
|
19
|
+
* @returns {Promise<DoctorResult>}
|
|
20
|
+
*/
|
|
21
|
+
export async function checkStaticAssetFreshness(appDir) {
|
|
22
|
+
const name = 'Static build outputs (dev.regenerate freshness)';
|
|
23
|
+
let pkg;
|
|
24
|
+
try {
|
|
25
|
+
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return { name, status: 'pass', message: 'no package.json to analyse' };
|
|
28
|
+
}
|
|
29
|
+
const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null;
|
|
30
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
31
|
+
return { name, status: 'pass', message: 'no webjs.dev.regenerate rules declared' };
|
|
32
|
+
}
|
|
33
|
+
const stale = [];
|
|
34
|
+
for (const rule of rules) {
|
|
35
|
+
if (!rule || typeof rule.output !== 'string') continue;
|
|
36
|
+
const output = rule.output.replace(/^\/+/, '');
|
|
37
|
+
const outMtime = newestMtimeMs(join(appDir, output));
|
|
38
|
+
if (outMtime === 0) continue; // missing output: not a staleness fail (built on first boot)
|
|
39
|
+
let newestSrc = 0;
|
|
40
|
+
for (const inp of Array.isArray(rule.inputs) ? rule.inputs : []) {
|
|
41
|
+
const m = newestMtimeMs(join(appDir, inp));
|
|
42
|
+
if (m > newestSrc) newestSrc = m;
|
|
43
|
+
}
|
|
44
|
+
if (newestSrc > outMtime) stale.push({ output, command: rule.command });
|
|
45
|
+
}
|
|
46
|
+
if (stale.length === 0) {
|
|
47
|
+
return { name, status: 'pass', message: 'every declared build output is up to date with its sources' };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
status: 'warn',
|
|
52
|
+
message:
|
|
53
|
+
`${stale.length} static build output(s) are older than a source file:\n` +
|
|
54
|
+
stale.map((s) => ` ${s.output} (rebuild: ${s.command})`).join('\n') +
|
|
55
|
+
'\n In dev the framework recompiles these on request, so this only bites a `webjs start` (prod) or a committed stale file.',
|
|
56
|
+
fix: 'Rebuild the output(s) with the command shown (e.g. `npm run css:build`) before deploying or committing. `webjs dev` regenerates them on request automatically.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { stripJsonc } from '../util.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CHECK 2, tsconfig erasableSyntaxOnly. PASS when `true`; WARN when no tsconfig
|
|
12
|
+
* (a JS-only app legitimately has none) or the file is unparseable; HARD-FAIL
|
|
13
|
+
* when the file EXISTS but the flag is missing/false (non-erasable TS 500s at
|
|
14
|
+
* strip time).
|
|
15
|
+
* @param {string} appDir
|
|
16
|
+
* @returns {Promise<DoctorResult>}
|
|
17
|
+
*/
|
|
18
|
+
export async function checkTsconfig(appDir) {
|
|
19
|
+
const path = join(appDir, 'tsconfig.json');
|
|
20
|
+
if (!existsSync(path)) {
|
|
21
|
+
return {
|
|
22
|
+
name: 'tsconfig-erasable',
|
|
23
|
+
status: 'warn',
|
|
24
|
+
message: 'No tsconfig.json found. A JS-only app needs none; a TypeScript app requires one.',
|
|
25
|
+
fix: 'If this app uses TypeScript, add a tsconfig.json with "erasableSyntaxOnly": true.',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(stripJsonc(await readFile(path, 'utf8')));
|
|
31
|
+
} catch {
|
|
32
|
+
return {
|
|
33
|
+
name: 'tsconfig-erasable',
|
|
34
|
+
status: 'warn',
|
|
35
|
+
message: 'tsconfig.json could not be parsed (even after stripping comments + trailing commas).',
|
|
36
|
+
fix: 'Fix the tsconfig.json syntax, then ensure "compilerOptions.erasableSyntaxOnly": true.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const flag = parsed?.compilerOptions?.erasableSyntaxOnly;
|
|
40
|
+
if (flag === true) {
|
|
41
|
+
return {
|
|
42
|
+
name: 'tsconfig-erasable',
|
|
43
|
+
status: 'pass',
|
|
44
|
+
message: 'tsconfig.json sets "erasableSyntaxOnly": true.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
name: 'tsconfig-erasable',
|
|
49
|
+
status: 'fail',
|
|
50
|
+
message:
|
|
51
|
+
'tsconfig.json is missing "compilerOptions.erasableSyntaxOnly": true. ' +
|
|
52
|
+
'Non-erasable TypeScript (enum, namespace, parameter properties, ...) 500s at strip time.',
|
|
53
|
+
fix: 'Set "compilerOptions": { "erasableSyntaxOnly": true } in tsconfig.json.',
|
|
54
|
+
};
|
|
55
|
+
}
|