redweb 0.12.0 → 0.13.1
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/CHANGELOG.md +170 -9
- package/README.md +283 -629
- package/bin/redweb.js +11 -20
- package/client.d.ts +7 -2
- package/config/tsconfig.json +14 -14
- package/contract.d.ts +45 -0
- package/contract.js +5 -0
- package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
- package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
- package/docs/AGENT_ACCESS.md +35 -0
- package/docs/AGENT_EVALUATION.md +58 -0
- package/docs/AGENT_READY_ACCEPTANCE.md +778 -0
- package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
- package/docs/BENCHMARK_VERIFICATION.md +307 -0
- package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
- package/docs/CLI.md +116 -0
- package/docs/CLIENT_DEVELOPMENT.md +152 -0
- package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
- package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
- package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
- package/docs/DEVELOPMENT.md +79 -0
- package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
- package/docs/DOCUMENTATION.md +37 -0
- package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
- package/docs/GETTING_STARTED.md +58 -0
- package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
- package/docs/LIVE_HTML.md +169 -21
- package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
- package/docs/MIGRATION.md +28 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
- package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
- package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
- package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
- package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
- package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
- package/docs/PRODUCTION_READINESS.md +12 -2
- package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
- package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
- package/docs/RECOVERY_CODE_CENSUS.md +158 -0
- package/docs/RECOVERY_COMPARISON.md +103 -0
- package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
- package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
- package/docs/RECOVERY_INVESTIGATION.md +229 -0
- package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
- package/docs/RELEASE_TRUST.md +61 -0
- package/docs/ROOM_AUTHORIZATION.md +49 -0
- package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
- package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
- package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
- package/docs/SOAK_VERIFICATION.md +154 -0
- package/docs/SOCKET_CONTRACTS.md +39 -0
- package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
- package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
- package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
- package/docs/STARTER_REPORT_RETENTION.md +73 -0
- package/docs/VERIFICATION_EVIDENCE.md +2 -0
- package/docs/generated.json +2154 -0
- package/docs/guides/chatroom.md +27 -0
- package/docs/guides/http-websocket.md +28 -0
- package/docs/guides/jsx-without-react.md +26 -0
- package/docs/guides/realtime-dashboard.md +29 -0
- package/docs/guides/typed-websockets.md +26 -0
- package/docs/reference.json +1207 -0
- package/docs/releases/0.13.0.json +2154 -0
- package/docs/releases/audit-0.13.0.json +1549 -0
- package/docs/snippets/room-access.tsx +51 -0
- package/docs/topics.json +21 -0
- package/examples/live-html/chatroom.js +207 -268
- package/examples/live-html/chatroom.tsx +167 -0
- package/examples/live-html/jsx-page.js +1 -1
- package/examples/live-html/jsx-page.tsx +1 -1
- package/examples/live-html/tsconfig.json +8 -7
- package/index.d.ts +170 -45
- package/index.js +2 -0
- package/jsx-dev-runtime.js +2 -2
- package/jsx-runtime.d.ts +7 -2
- package/package.json +88 -7
- package/recipes/add/artifact.test.cjs +57 -0
- package/recipes/add/live.tsx +18 -0
- package/recipes/add/socket-route.ts +24 -0
- package/recipes/chat/README.md +22 -0
- package/recipes/chat/app.test.cjs +105 -0
- package/recipes/chat/app.tsx +9 -0
- package/recipes/dashboard/README.md +43 -0
- package/recipes/dashboard/admin.ts +21 -0
- package/recipes/dashboard/app.css +16 -0
- package/recipes/dashboard/app.test.cjs +450 -0
- package/recipes/dashboard/app.tsx +86 -0
- package/recipes/dashboard/auth.ts +80 -0
- package/recipes/dashboard/cards.tsx +102 -0
- package/recipes/dashboard/rate-window.test.cjs +17 -0
- package/recipes/dashboard/store.ts +120 -0
- package/recipes/http-ws/README.md +11 -0
- package/recipes/http-ws/app.test.cjs +92 -0
- package/recipes/http-ws/app.tsx +36 -0
- package/recipes/realtime/README.md +8 -0
- package/recipes/realtime/app.test.cjs +15 -0
- package/recipes/realtime/app.tsx +28 -0
- package/recipes/shared/README.md +40 -0
- package/recipes/shared/app.css +8 -0
- package/recipes/shared/copy-assets.cjs +8 -0
- package/recipes/shared/network.cjs +59 -0
- package/recipes/shared/run-app.test.cjs +158 -0
- package/recipes/shared/run-app.ts +50 -0
- package/recipes/site/README.md +4 -0
- package/recipes/site/app.test.cjs +19 -0
- package/recipes/site/app.tsx +25 -0
- package/recipes/socket/README.md +39 -0
- package/recipes/socket/app.test.cjs +85 -0
- package/recipes/socket/app.tsx +30 -0
- package/recipes/socket/contract.ts +12 -0
- package/recipes/socket/handlers.ts +40 -0
- package/src/OwnedServerLifecycle.js +66 -0
- package/src/access/AccessPolicy.js +37 -0
- package/src/access/AuthenticationFailure.js +13 -0
- package/src/access/RequestFailure.js +33 -0
- package/src/access/failure-codes.json +25 -0
- package/src/async/BoundedOperation.js +62 -0
- package/src/cli/ActionReferences.js +193 -0
- package/src/cli/AdditionLayout.js +140 -0
- package/src/cli/FilePlan.js +94 -0
- package/src/cli/ProjectAddition.js +60 -0
- package/src/cli/ProjectConfig.js +26 -0
- package/src/cli/ProjectDoctor.js +112 -0
- package/src/cli/ProjectInitializer.js +20 -30
- package/src/cli/SourceInspector.js +207 -0
- package/src/cli/StaticSource.js +192 -0
- package/src/cli/arguments.js +62 -0
- package/src/cli/formatCommand.js +10 -0
- package/src/cli/run.js +57 -0
- package/src/cli/templates.js +86 -87
- package/src/context/RequestSnapshot.js +41 -0
- package/src/dataProperty.js +11 -0
- package/src/development/DevelopmentPageManager.js +48 -0
- package/src/development/Inspection.js +104 -0
- package/src/development/ObservedRenderer.js +42 -0
- package/src/development/description.js +35 -0
- package/src/development/loopbackRequest.js +27 -0
- package/src/development/refreshBrowser.js +96 -0
- package/src/development/refreshStyles.js +9 -0
- package/src/development/settings.js +17 -0
- package/src/docs/Documentation.js +182 -0
- package/src/htmx/ActionDefinition.js +44 -0
- package/src/htmx/Jsx.js +24 -8
- package/src/htmx/LiveHtmlServer.js +41 -19
- package/src/htmx/LivePage.js +63 -13
- package/src/htmx/PageIdentity.js +32 -0
- package/src/htmx/PageLifetime.js +37 -0
- package/src/htmx/PageManager.js +203 -74
- package/src/htmx/ReactiveRenderer.js +241 -0
- package/src/htmx/StaticExporter.js +1 -1
- package/src/htmx/TemplateRenderer.js +13 -7
- package/src/htmx/browserRuntime.js +2 -93
- package/src/htmx/metadata.js +19 -7
- package/src/validation/ActionInputError.js +12 -0
- package/src/validation/SchemaValidator.js +38 -0
- package/src/ws/AdmissionPolicy.js +24 -23
- package/src/ws/BaseSocketServer.js +53 -38
- package/src/ws/ContractValidationError.js +12 -0
- package/src/ws/HeartbeatMonitor.js +26 -8
- package/src/ws/ProtocolPolicy.js +1 -1
- package/src/ws/RoomAccess.js +82 -0
- package/src/ws/RoomRegistry.js +56 -6
- package/src/ws/RouteRuntime.js +56 -10
- package/src/ws/SocketContract.js +112 -0
- package/src/ws/SocketRoute.js +18 -0
- package/src/ws/protocol-schema.json +6 -1
- package/examples/live-html/chatroom.ts +0 -217
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const net = require('net');
|
|
6
|
+
const SourceInspector = require('./SourceInspector');
|
|
7
|
+
const { resolveDependency, readConfig } = require('./ProjectConfig');
|
|
8
|
+
|
|
9
|
+
function issue(code, file, message, suggestion, severity = 'error') {
|
|
10
|
+
return { code, severity, file, message, suggestion };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function nodeIssue(version) {
|
|
14
|
+
if (Number(version.split('.')[0]) >= 18) return null;
|
|
15
|
+
return issue('NODE_UNSUPPORTED', null, `Node ${version} is outside Redweb's supported range.`, 'Use a supported Node.js release (18 or newer).');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function projectNodeIssue(version, requirement) {
|
|
19
|
+
if (requirement === undefined) return null;
|
|
20
|
+
const minimum = typeof requirement === 'string' && /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(requirement);
|
|
21
|
+
if (!minimum) return issue('PROJECT_NODE_UNCHECKED', 'package.json', 'The project Node range could not be checked by doctor.', 'Check engines.node using npm install; doctor understands minimum-version ranges such as >=22.13.0.', 'warning');
|
|
22
|
+
const current = version.split('.').map(Number);
|
|
23
|
+
const required = minimum.slice(1).map(value => Number(value || 0));
|
|
24
|
+
const difference = required.findIndex((value, index) => value !== current[index]);
|
|
25
|
+
if (difference < 0 || current[difference] > required[difference]) return null;
|
|
26
|
+
return issue('PROJECT_NODE_UNSUPPORTED', 'package.json', `Node ${version} does not meet this project's ${requirement} requirement.`, `Install Node ${required.join('.')} or newer before running this application.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inspectTypeScript(root, issues) {
|
|
30
|
+
// TypeScript's diagnostic attachment expects normalized separators on Windows.
|
|
31
|
+
const configPath = path.join(root, 'tsconfig.json').replaceAll('\\', '/');
|
|
32
|
+
const typescriptPath = resolveDependency(root, 'typescript');
|
|
33
|
+
if (!typescriptPath) issues.push(issue('TYPESCRIPT_MISSING', 'package.json', 'TypeScript is not installed for this project.', 'Run npm install --save-dev typescript.'));
|
|
34
|
+
if (!fs.existsSync(configPath)) {
|
|
35
|
+
issues.push(issue('CONFIG_MISSING', 'tsconfig.json', 'No editor-discoverable TypeScript configuration was found.', 'Run redweb init --existing, then inspect the generated configuration.'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!typescriptPath) return;
|
|
39
|
+
const ts = require(typescriptPath);
|
|
40
|
+
if (Number(ts.version.split('.')[0]) < 5) {
|
|
41
|
+
issues.push(issue('TYPESCRIPT_UNSUPPORTED', 'package.json', `TypeScript ${ts.version} is not supported by these diagnostics.`, 'Install TypeScript 5 or newer; Redweb standard decorators and contract types require it.'));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const { config, syntaxError } = readConfig(ts, root);
|
|
45
|
+
if (syntaxError) {
|
|
46
|
+
issues.push(issue('CONFIG_INVALID', 'tsconfig.json', ts.flattenDiagnosticMessageText(syntaxError.messageText, '\n'), 'Correct the configuration syntax and run doctor again.'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (config.errors.length) {
|
|
50
|
+
for (const error of config.errors) issues.push(issue('CONFIG_INVALID', 'tsconfig.json', ts.flattenDiagnosticMessageText(error.messageText, '\n'), 'Resolve the TypeScript configuration diagnostic, including missing extends targets or source files.'));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (config.options.jsxImportSource !== 'redweb' || ![ts.JsxEmit.ReactJSX, ts.JsxEmit.ReactJSXDev].includes(config.options.jsx)) {
|
|
54
|
+
issues.push(issue('JSX_RUNTIME_MISMATCH', 'tsconfig.json', 'The effective JSX configuration does not use Redweb.', 'Extend redweb/tsconfig.json, or set jsx to react-jsx and jsxImportSource to redweb.'));
|
|
55
|
+
}
|
|
56
|
+
if (config.options.experimentalDecorators) {
|
|
57
|
+
issues.push(issue('LEGACY_DECORATORS', 'tsconfig.json', 'Legacy decorators are enabled; the starter examples use standard decorators.', 'Set experimentalDecorators to false for standard decorator examples.', 'warning'));
|
|
58
|
+
}
|
|
59
|
+
return new SourceInspector(ts, root, config).inspect();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function inspectPort(port) {
|
|
63
|
+
return new Promise(resolve => {
|
|
64
|
+
const server = net.createServer();
|
|
65
|
+
server.once('error', error => resolve(issue('PORT_UNAVAILABLE', null, `Cannot bind 127.0.0.1:${port} (${error.code}).`, 'Choose another port or stop the process that owns it.')));
|
|
66
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(null)));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class ProjectDoctor {
|
|
71
|
+
constructor(cliVersion) {
|
|
72
|
+
this.cliVersion = cliVersion;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async inspect(target, port = null) {
|
|
76
|
+
const root = path.resolve(target);
|
|
77
|
+
const issues = [nodeIssue(process.versions.node)].filter(Boolean);
|
|
78
|
+
const projectManifest = path.join(root, 'package.json');
|
|
79
|
+
if (fs.existsSync(projectManifest)) {
|
|
80
|
+
const finding = projectNodeIssue(process.versions.node, JSON.parse(fs.readFileSync(projectManifest, 'utf8')).engines?.node);
|
|
81
|
+
if (finding) issues.push(finding);
|
|
82
|
+
}
|
|
83
|
+
const manifestPath = resolveDependency(root, 'redweb/package.json');
|
|
84
|
+
let installedVersion = null;
|
|
85
|
+
if (!manifestPath) {
|
|
86
|
+
issues.push(issue('REDWEB_MISSING', 'package.json', 'Redweb is not installed for this project.', 'Run npm install redweb.'));
|
|
87
|
+
} else {
|
|
88
|
+
installedVersion = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version;
|
|
89
|
+
if (installedVersion !== this.cliVersion) {
|
|
90
|
+
issues.push(issue('CLI_VERSION_MISMATCH', 'package.json', `CLI ${this.cliVersion} is inspecting Redweb ${installedVersion}.`, 'Use the locally installed CLI with npx --no-install redweb doctor.', 'warning'));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const inspected = inspectTypeScript(root, issues);
|
|
94
|
+
if (inspected) issues.push(...inspected.issues);
|
|
95
|
+
if (port !== null) {
|
|
96
|
+
const unavailable = await inspectPort(port);
|
|
97
|
+
if (unavailable) issues.push(unavailable);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
operation: 'doctor',
|
|
102
|
+
root,
|
|
103
|
+
installedVersion,
|
|
104
|
+
checks: ['node', 'redweb-version', 'typescript-config', ...(inspected ? ['source-assets', 'source-routes', 'source-handlers', 'source-actions'] : []), ...(port === null ? [] : ['loopback-port'])],
|
|
105
|
+
source: inspected?.source || null,
|
|
106
|
+
ok: !issues.some(value => value.severity === 'error'),
|
|
107
|
+
issues,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { ProjectDoctor, nodeIssue, projectNodeIssue };
|
|
@@ -1,30 +1,20 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
class ProjectInitializer {
|
|
8
|
-
constructor(version) {
|
|
9
|
-
this.
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
initialize(target) {
|
|
13
|
-
const root = path.resolve(target);
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
fs.writeFileSync(destination, file.content, { encoding: 'utf8', flag: 'wx' });
|
|
24
|
-
created.push(file.path);
|
|
25
|
-
}
|
|
26
|
-
return Object.freeze({ root, created: Object.freeze(created), skipped: Object.freeze(skipped) });
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
module.exports = ProjectInitializer;
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { projectFiles } = require('./templates');
|
|
5
|
+
const { FilePlan } = require('./FilePlan');
|
|
6
|
+
|
|
7
|
+
class ProjectInitializer {
|
|
8
|
+
constructor(version) {
|
|
9
|
+
this.version = version;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
initialize(target, options = {}) {
|
|
13
|
+
const root = path.resolve(target);
|
|
14
|
+
const templateFiles = projectFiles(this.version, options.template);
|
|
15
|
+
const files = options.existing ? templateFiles.filter(file => file.path === 'tsconfig.json') : templateFiles;
|
|
16
|
+
return new FilePlan(root, files).write({ dryRun: options.dryRun });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = ProjectInitializer;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { StaticSource, UNKNOWN } = require('./StaticSource');
|
|
6
|
+
const ActionReferences = require('./ActionReferences');
|
|
7
|
+
|
|
8
|
+
const MAX_FILES = 256;
|
|
9
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function outside(root, file) {
|
|
12
|
+
const relative = path.relative(root, file);
|
|
13
|
+
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class SourceInspector {
|
|
17
|
+
constructor(ts, root, config) {
|
|
18
|
+
this.ts = ts;
|
|
19
|
+
this.root = root;
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.issues = [];
|
|
22
|
+
this.reported = new Set();
|
|
23
|
+
this.registrations = 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
finding(code, node, message, suggestion, severity = 'error') {
|
|
27
|
+
const file = typeof node === 'string' ? node : node.getSourceFile().fileName;
|
|
28
|
+
const location = typeof node === 'string' ? {} : (() => {
|
|
29
|
+
const point = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart());
|
|
30
|
+
return { line: point.line + 1, column: point.character + 1 };
|
|
31
|
+
})();
|
|
32
|
+
const entry = { code, severity, file: path.relative(this.root, file).replaceAll('\\', '/'), ...location, message, suggestion };
|
|
33
|
+
const key = JSON.stringify(entry);
|
|
34
|
+
if (!this.reported.has(key)) { this.reported.add(key); this.issues.push(entry); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
unknown(node, subject) {
|
|
38
|
+
this.finding('SOURCE_UNRESOLVED', node, `${subject} is not statically known.`,
|
|
39
|
+
'Use explicit constants/arrays for inspection, or verify this dynamic configuration with application tests. Doctor does not execute it.', 'warning');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
inspect() {
|
|
43
|
+
const ts = this.ts;
|
|
44
|
+
const sources = new Map();
|
|
45
|
+
const pendingFiles = [...this.config.fileNames];
|
|
46
|
+
const visited = new Set();
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
for (const file of pendingFiles) {
|
|
49
|
+
if (visited.has(file) || /\.d\.[cm]?ts$/.test(file)) continue;
|
|
50
|
+
visited.add(file);
|
|
51
|
+
try {
|
|
52
|
+
const details = fs.statSync(file);
|
|
53
|
+
if (!details.isFile()) throw new TypeError('Source is not a regular file.');
|
|
54
|
+
const size = details.size;
|
|
55
|
+
if (sources.size >= MAX_FILES || size > MAX_BYTES - bytes) {
|
|
56
|
+
this.finding('SOURCE_LIMIT', file, 'Source inspection reached its 256-file or 8 MiB limit.', 'Split large projects into smaller TypeScript configurations for inspection.', 'warning');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
60
|
+
bytes += Buffer.byteLength(content);
|
|
61
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
|
|
62
|
+
sources.set(path.resolve(file), source);
|
|
63
|
+
for (const dependency of ts.preProcessFile(content).importedFiles) {
|
|
64
|
+
if (!dependency.fileName.startsWith('.')) continue;
|
|
65
|
+
const imported = ts.resolveModuleName(dependency.fileName, file, this.config.options, ts.sys).resolvedModule;
|
|
66
|
+
if (imported && !outside(this.root, imported.resolvedFileName)) pendingFiles.push(imported.resolvedFileName);
|
|
67
|
+
}
|
|
68
|
+
for (const diagnostic of source.parseDiagnostics) {
|
|
69
|
+
this.finding('SOURCE_SYNTAX', file, ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), 'Correct the source syntax, then run doctor again.');
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.finding('SOURCE_UNREADABLE', file, `Cannot inspect source (${error.code || error.name}).`, 'Check that this configured source is a readable file.');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// No imports, plugins, or application modules execute. Project source imports are parsed, not loaded.
|
|
76
|
+
const options = { ...this.config.options, noLib: true, noResolve: true, types: [], noEmit: true };
|
|
77
|
+
const host = ts.createCompilerHost(options);
|
|
78
|
+
host.getSourceFile = file => sources.get(path.resolve(file));
|
|
79
|
+
const program = ts.createProgram([...sources.keys()], options, host);
|
|
80
|
+
this.syntax = new StaticSource(ts, program);
|
|
81
|
+
this.actions = new ActionReferences(this);
|
|
82
|
+
for (const source of sources.values()) {
|
|
83
|
+
const pending = [source];
|
|
84
|
+
while (pending.length) {
|
|
85
|
+
const node = pending.pop();
|
|
86
|
+
this.visit(node);
|
|
87
|
+
ts.forEachChild(node, child => { pending.push(child); });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (this.syntax.limited) this.finding('SOURCE_LIMIT', path.join(this.root, 'tsconfig.json'),
|
|
91
|
+
'Source expressions exceeded the 50,000-operation or 4,096-entry expansion limit.',
|
|
92
|
+
'Simplify large spread/alias structures or validate the dynamic configuration with application tests.', 'warning');
|
|
93
|
+
return { issues: this.issues, source: { files: sources.size, registrations: this.registrations, mode: 'static-source', unresolved: this.issues.filter(issue => issue.severity === 'warning').length } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
visit(node) {
|
|
97
|
+
const ts = this.ts;
|
|
98
|
+
const read = this.syntax;
|
|
99
|
+
if (ts.isClassDeclaration(node)) {
|
|
100
|
+
this.actions.inspect(node);
|
|
101
|
+
const options = read.constructorArgument(node, 'SocketRoute');
|
|
102
|
+
if (options !== UNKNOWN) this.group(read.property(options, 'handlers'), 'handler', node);
|
|
103
|
+
}
|
|
104
|
+
if (!ts.isCallExpression(node) && !ts.isNewExpression(node)) return;
|
|
105
|
+
const api = read.api(node.expression);
|
|
106
|
+
const args = node.arguments || [];
|
|
107
|
+
if (['start', 'exportStatic', 'site.export'].includes(api)) {
|
|
108
|
+
this.group(args[0], 'page', node, args[1]);
|
|
109
|
+
} else if (api === 'LiveHtmlServer') {
|
|
110
|
+
this.group(read.property(args[0], 'pages'), 'page', node, args[0]);
|
|
111
|
+
} else if (['SocketServer', 'SecureSocketServer'].includes(api)) {
|
|
112
|
+
const routes = read.property(args[0], 'routes');
|
|
113
|
+
if (routes !== undefined) this.group(routes, 'route', node);
|
|
114
|
+
} else if (api === 'SocketRoute') {
|
|
115
|
+
this.group(read.property(args[0], 'handlers'), 'handler', node);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
page(expression) {
|
|
120
|
+
const node = this.syntax.resolve(expression);
|
|
121
|
+
if (node === UNKNOWN || !this.ts.isClassDeclaration(node)) return null;
|
|
122
|
+
const decorators = this.ts.getDecorators(node) || [];
|
|
123
|
+
if (decorators.length !== 1) return null;
|
|
124
|
+
for (const decorator of decorators) {
|
|
125
|
+
const call = decorator.expression;
|
|
126
|
+
if (this.ts.isCallExpression(call) && ['page', 'site.page'].includes(this.syntax.api(call.expression))) return call;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
name(expression, kind) {
|
|
132
|
+
const read = this.syntax;
|
|
133
|
+
if (kind === 'page') return read.text(this.page(expression)?.arguments[0]);
|
|
134
|
+
if (kind === 'route') return read.text(read.property(read.constructorArgument(expression, 'SocketRoute'), 'path'));
|
|
135
|
+
const node = read.resolve(expression);
|
|
136
|
+
if (node !== UNKNOWN && this.ts.isCallExpression(node) && read.api(node.expression) === 'contract.handler') return read.text(node.arguments[0]);
|
|
137
|
+
return read.text(read.constructorArgument(expression, 'BaseHandler'));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
group(expression, kind, registration, options) {
|
|
141
|
+
this.registrations++;
|
|
142
|
+
const known = new Map();
|
|
143
|
+
for (const item of this.syntax.elements(expression)) {
|
|
144
|
+
const name = this.name(item, kind);
|
|
145
|
+
if (name === UNKNOWN) { this.unknown(registration, `${kind} registration`); continue; }
|
|
146
|
+
if (known.has(name)) {
|
|
147
|
+
this.finding(kind === 'handler' ? 'DUPLICATE_HANDLER' : 'DUPLICATE_ROUTE', registration,
|
|
148
|
+
`Duplicate ${kind} "${name}" in the same registration.`, 'Keep one registration per path/message type in this server or route.');
|
|
149
|
+
}
|
|
150
|
+
known.set(name, item);
|
|
151
|
+
if (kind === 'page') this.assets(this.page(item), options, registration);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
assets(page, options, registration) {
|
|
156
|
+
const read = this.syntax;
|
|
157
|
+
const configuredRoot = read.property(options, 'templateRoot');
|
|
158
|
+
let root;
|
|
159
|
+
if (configuredRoot !== undefined) {
|
|
160
|
+
const literal = read.text(configuredRoot);
|
|
161
|
+
if (literal !== UNKNOWN) root = path.resolve(this.root, literal);
|
|
162
|
+
else if (configuredRoot !== UNKNOWN && configuredRoot.getText() === '__dirname' && !read.checker.getSymbolAtLocation(configuredRoot)?.valueDeclaration) root = path.dirname(configuredRoot.getSourceFile().fileName);
|
|
163
|
+
else { this.unknown(registration, 'Asset templateRoot'); return; }
|
|
164
|
+
}
|
|
165
|
+
if (read.api(page.expression) === 'site.page') {
|
|
166
|
+
const site = read.resolve(read.resolve(page.expression).expression);
|
|
167
|
+
const shared = read.property(site.arguments[0], 'css');
|
|
168
|
+
const names = shared === undefined ? [] : read.elements(shared).map(item => read.text(item));
|
|
169
|
+
this.assetFields(page.arguments[1], page, root, ['template']);
|
|
170
|
+
if (names.includes(UNKNOWN)) this.unknown(site, 'Shared stylesheet provenance');
|
|
171
|
+
else this.assetFields(page.arguments[1], page, root, ['css'], new Set(names));
|
|
172
|
+
this.assetFields(site.arguments[0], site, root, ['css']);
|
|
173
|
+
} else this.assetFields(page.arguments[1], page, root);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
assetFields(options, declaration, explicitRoot, fields = ['css', 'template'], skip = new Set()) {
|
|
177
|
+
const read = this.syntax;
|
|
178
|
+
for (const field of fields) {
|
|
179
|
+
const expression = read.property(options, field);
|
|
180
|
+
if (expression === undefined) continue;
|
|
181
|
+
for (const item of read.elements(expression)) {
|
|
182
|
+
const file = read.text(item);
|
|
183
|
+
if (file === UNKNOWN) { this.unknown(declaration, `Page ${field}`); continue; }
|
|
184
|
+
if (skip.has(file)) continue;
|
|
185
|
+
const root = explicitRoot || path.dirname(declaration.getSourceFile().fileName);
|
|
186
|
+
const resolved = path.resolve(root, file);
|
|
187
|
+
if (outside(root, resolved)) {
|
|
188
|
+
this.finding('ASSET_OUTSIDE_ROOT', declaration, `Page ${field} "${file}" escapes its asset root.`, 'Keep assets within the page directory or the explicit templateRoot.');
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
if (outside(fs.realpathSync(root), fs.realpathSync(resolved))) {
|
|
193
|
+
this.finding('ASSET_OUTSIDE_ROOT', declaration, `Page ${field} "${file}" resolves through a link outside its asset root.`, 'Use an asset within the configured root.');
|
|
194
|
+
} else if (!fs.statSync(resolved).isFile()) {
|
|
195
|
+
this.finding('ASSET_NOT_FILE', declaration, `Page ${field} "${file}" is not a file.`, 'Point this declaration to a CSS or HTML file.');
|
|
196
|
+
} else if (field === 'template' && fs.statSync(resolved).size <= 1024 * 1024) {
|
|
197
|
+
this.actions.markup(declaration.parent.parent, fs.readFileSync(resolved, 'utf8'), declaration, resolved);
|
|
198
|
+
} else if (field === 'template') this.actions.unknown(declaration, 'HTML action inspection exceeds the 1 MiB per-template limit.');
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.finding('ASSET_UNAVAILABLE', declaration, `Page ${field} "${file}" is unavailable (${error.code}).`, 'Create/correct the asset at its source root and include it in the production asset-copy step.');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = SourceInspector;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const UNKNOWN = Symbol('not statically known');
|
|
4
|
+
|
|
5
|
+
// A deliberately small syntax reader, not an evaluator: it never calls application functions.
|
|
6
|
+
class StaticSource {
|
|
7
|
+
constructor(ts, program) {
|
|
8
|
+
this.ts = ts;
|
|
9
|
+
this.checker = program.getTypeChecker();
|
|
10
|
+
this.remaining = 50000;
|
|
11
|
+
this.limited = false;
|
|
12
|
+
this.tainted = new Set();
|
|
13
|
+
this.arrays = new Map();
|
|
14
|
+
for (const source of program.getSourceFiles()) {
|
|
15
|
+
const pending = [source];
|
|
16
|
+
while (pending.length) {
|
|
17
|
+
const node = pending.pop();
|
|
18
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
19
|
+
if (!this.api(node.expression) && !this.redwebSuper(node)) {
|
|
20
|
+
if (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression)) this.taint(node.expression.expression);
|
|
21
|
+
for (const argument of node.arguments || []) this.taint(argument, true);
|
|
22
|
+
}
|
|
23
|
+
} else if (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
|
|
24
|
+
this.taint(node.left, true);
|
|
25
|
+
this.taint(node.right);
|
|
26
|
+
} else if (ts.isDeleteExpression(node) || ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) {
|
|
27
|
+
this.taint(node.operand || node.expression);
|
|
28
|
+
} else if ((ts.isReturnStatement(node) || ts.isYieldExpression(node)) && node.expression) this.taint(node.expression);
|
|
29
|
+
else if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) this.taint(node.body);
|
|
30
|
+
else if (ts.isForOfStatement(node)) this.taint(node.expression);
|
|
31
|
+
else if (node.initializer && (
|
|
32
|
+
ts.isPropertyDeclaration(node) || ts.isParameter(node) ||
|
|
33
|
+
(ts.isVariableDeclaration(node) && (ts.isBindingPattern(node.name) || !(node.parent.flags & ts.NodeFlags.Const)))
|
|
34
|
+
)) this.taint(node.initializer);
|
|
35
|
+
ts.forEachChild(node, child => { pending.push(child); });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
spend() {
|
|
41
|
+
if (--this.remaining >= 0) return true;
|
|
42
|
+
this.limited = true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
redwebSuper(node) {
|
|
47
|
+
const owner = node.parent?.parent?.parent;
|
|
48
|
+
return Boolean(node.expression.kind === this.ts.SyntaxKind.SuperKeyword && owner && this.ts.isConstructorDeclaration(owner) &&
|
|
49
|
+
['SocketRoute', 'BaseHandler'].includes(this.api(this.base(owner.parent))));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
taint(expression, bindingWrite = false) {
|
|
53
|
+
const pending = [expression], seen = new Set();
|
|
54
|
+
while (pending.length && this.spend()) {
|
|
55
|
+
const node = pending.pop();
|
|
56
|
+
if (seen.has(node)) continue;
|
|
57
|
+
seen.add(node);
|
|
58
|
+
if (this.ts.isIdentifier(node)) {
|
|
59
|
+
const value = this.resolve(node, new Set(), true);
|
|
60
|
+
if (value !== UNKNOWN && (this.ts.isArrayLiteralExpression(value) || this.ts.isObjectLiteralExpression(value) || (bindingWrite && this.ts.isClassDeclaration(value)))) {
|
|
61
|
+
this.tainted.add(value);
|
|
62
|
+
pending.push(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.ts.forEachChild(node, child => { pending.push(child); });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
resolve(node, seen = new Set(), ignoreTaint = false) {
|
|
70
|
+
const ts = this.ts;
|
|
71
|
+
if (!this.spend() || !node || node === UNKNOWN || seen.has(node) || seen.size >= 64 || (!ignoreTaint && this.tainted.has(node))) return UNKNOWN;
|
|
72
|
+
seen.add(node);
|
|
73
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node)) {
|
|
74
|
+
return this.resolve(node.expression, seen, ignoreTaint);
|
|
75
|
+
}
|
|
76
|
+
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
77
|
+
const owner = this.resolve(node.expression, new Set(seen), ignoreTaint);
|
|
78
|
+
if (owner !== UNKNOWN && ts.isObjectLiteralExpression(owner)) {
|
|
79
|
+
const key = ts.isPropertyAccessExpression(node) ? node.name.text : this.text(node.argumentExpression);
|
|
80
|
+
return this.resolve(this.property(owner, key, new Set(), ignoreTaint), seen, ignoreTaint);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!ts.isIdentifier(node)) return node;
|
|
84
|
+
let symbol = ts.isShorthandPropertyAssignment(node.parent)
|
|
85
|
+
? this.checker.getShorthandAssignmentValueSymbol(node.parent) : this.checker.getSymbolAtLocation(node);
|
|
86
|
+
const imported = symbol?.declarations?.[0];
|
|
87
|
+
if (imported && (ts.isImportSpecifier(imported) || ts.isNamespaceImport(imported)) && this.moduleName(imported).startsWith('redweb')) return imported;
|
|
88
|
+
if (symbol?.flags & ts.SymbolFlags.Alias) symbol = this.checker.getAliasedSymbol(symbol);
|
|
89
|
+
const declaration = symbol?.valueDeclaration;
|
|
90
|
+
if (!declaration) return UNKNOWN;
|
|
91
|
+
if (ts.isVariableDeclaration(declaration)) {
|
|
92
|
+
if (!(declaration.parent.flags & ts.NodeFlags.Const)) return UNKNOWN;
|
|
93
|
+
return this.resolve(declaration.initializer, seen, ignoreTaint);
|
|
94
|
+
}
|
|
95
|
+
if (ts.isClassDeclaration(declaration) || ts.isFunctionDeclaration(declaration)) return !ignoreTaint && this.tainted.has(declaration) ? UNKNOWN : declaration;
|
|
96
|
+
return UNKNOWN;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
moduleName(imported) {
|
|
100
|
+
let node = imported;
|
|
101
|
+
while (!this.ts.isImportDeclaration(node)) node = node.parent;
|
|
102
|
+
return node.moduleSpecifier.text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
api(expression, seen = new Set()) {
|
|
106
|
+
const ts = this.ts;
|
|
107
|
+
const node = this.resolve(expression);
|
|
108
|
+
if (node === UNKNOWN || seen.has(node) || seen.size >= 64) return null;
|
|
109
|
+
seen.add(node);
|
|
110
|
+
if (ts.isImportSpecifier(node) && ['redweb', 'redweb/contract'].includes(this.moduleName(node))) {
|
|
111
|
+
return (node.propertyName || node.name).text;
|
|
112
|
+
}
|
|
113
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
114
|
+
const owner = this.resolve(node.expression);
|
|
115
|
+
if (owner !== UNKNOWN && ts.isNamespaceImport(owner) && ['redweb', 'redweb/contract'].includes(this.moduleName(owner))) return node.name.text;
|
|
116
|
+
if (owner !== UNKNOWN && ts.isCallExpression(owner)) {
|
|
117
|
+
const factory = this.api(owner.expression, seen);
|
|
118
|
+
if (factory === 'defineSite' && ['page', 'export'].includes(node.name.text)) return `site.${node.name.text}`;
|
|
119
|
+
if (factory === 'defineSocketContract' && node.name.text === 'handler') return 'contract.handler';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
text(expression) {
|
|
126
|
+
const node = this.resolve(expression);
|
|
127
|
+
return node !== UNKNOWN && this.ts.isStringLiteralLike(node) ? node.text : UNKNOWN;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
property(expression, name, seen = new Set(), ignoreTaint = false) {
|
|
131
|
+
if (!expression) return undefined;
|
|
132
|
+
const node = this.resolve(expression, new Set(), ignoreTaint);
|
|
133
|
+
const ts = this.ts;
|
|
134
|
+
if (node === UNKNOWN || !ts.isObjectLiteralExpression(node) || seen.has(node) || seen.size >= 64) return UNKNOWN;
|
|
135
|
+
seen.add(node);
|
|
136
|
+
let value;
|
|
137
|
+
for (const field of node.properties) {
|
|
138
|
+
if (ts.isSpreadAssignment(field)) {
|
|
139
|
+
const spread = this.property(field.expression, name, new Set(seen), ignoreTaint);
|
|
140
|
+
if (spread !== undefined) value = spread;
|
|
141
|
+
} else {
|
|
142
|
+
const key = ts.isComputedPropertyName(field.name) ? this.text(field.name.expression) : field.name.text;
|
|
143
|
+
if (key === UNKNOWN) value = UNKNOWN;
|
|
144
|
+
else if (key === name) value = ts.isPropertyAssignment(field) ? field.initializer : ts.isShorthandPropertyAssignment(field) ? field.name : UNKNOWN;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
elements(expression, seen = new Set()) {
|
|
151
|
+
const node = this.resolve(expression);
|
|
152
|
+
if (node === UNKNOWN || seen.has(node) || seen.size >= 64) return [UNKNOWN];
|
|
153
|
+
seen.add(node);
|
|
154
|
+
if (!this.ts.isArrayLiteralExpression(node)) return [node];
|
|
155
|
+
if (this.arrays.has(node)) return this.arrays.get(node);
|
|
156
|
+
const result = [];
|
|
157
|
+
for (const element of node.elements) {
|
|
158
|
+
const entries = this.ts.isSpreadElement(element) ? this.elements(element.expression, new Set(seen)) : [this.resolve(element)];
|
|
159
|
+
if (result.length + entries.length > 4096) { this.limited = true; return [UNKNOWN]; }
|
|
160
|
+
result.push(...entries);
|
|
161
|
+
}
|
|
162
|
+
this.arrays.set(node, result);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
base(node) {
|
|
167
|
+
const clause = node.heritageClauses?.find(value => value.token === this.ts.SyntaxKind.ExtendsKeyword);
|
|
168
|
+
return clause ? clause.types[0].expression : UNKNOWN;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
constructorArgument(expression, api, seen = new Set()) {
|
|
172
|
+
const node = this.resolve(expression);
|
|
173
|
+
if (node === UNKNOWN || seen.has(node) || seen.size >= 64 || !this.ts.isClassDeclaration(node)) return UNKNOWN;
|
|
174
|
+
seen.add(node);
|
|
175
|
+
const base = this.base(node);
|
|
176
|
+
const constructor = node.members.find(member => this.ts.isConstructorDeclaration(member));
|
|
177
|
+
const property = api === 'SocketRoute' ? 'path' : 'name';
|
|
178
|
+
if (node.members.some(member => member.name && (this.ts.isComputedPropertyName(member.name) || member.name.text === property))) return UNKNOWN;
|
|
179
|
+
if (this.ts.getDecorators(node)?.length || node.members.some(member =>
|
|
180
|
+
this.ts.isClassStaticBlockDeclaration(member) || (this.ts.isPropertyDeclaration(member) && member.initializer) ||
|
|
181
|
+
(this.ts.canHaveDecorators(member) && this.ts.getDecorators(member)?.length))) return UNKNOWN;
|
|
182
|
+
if (constructor?.parameters.some(parameter => parameter.modifiers?.some(modifier =>
|
|
183
|
+
[this.ts.SyntaxKind.PublicKeyword, this.ts.SyntaxKind.PrivateKeyword, this.ts.SyntaxKind.ProtectedKeyword, this.ts.SyntaxKind.ReadonlyKeyword].includes(modifier.kind)))) return UNKNOWN;
|
|
184
|
+
if (this.api(base) !== api) return constructor ? UNKNOWN : this.constructorArgument(base, api, seen);
|
|
185
|
+
if (constructor?.body?.statements.length !== 1) return UNKNOWN;
|
|
186
|
+
const statement = constructor?.body?.statements.find(item => this.ts.isExpressionStatement(item) &&
|
|
187
|
+
this.ts.isCallExpression(item.expression) && item.expression.expression.kind === this.ts.SyntaxKind.SuperKeyword);
|
|
188
|
+
return statement?.expression.arguments[0] || UNKNOWN;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { StaticSource, UNKNOWN };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { TEMPLATES } = require('./templates');
|
|
4
|
+
const { KINDS } = require('./ProjectAddition');
|
|
5
|
+
|
|
6
|
+
const USAGE = [
|
|
7
|
+
`Usage: redweb init [directory] [--template ${TEMPLATES.join('|')}] [--existing] [--dry-run] [--json]`,
|
|
8
|
+
' redweb doctor [directory] [--port number] [--json]',
|
|
9
|
+
` redweb add <${KINDS.join('|')}> <name> [directory] [--config file] [--source-dir dir] [--test-dir dir] [--dry-run] [--json]`,
|
|
10
|
+
' redweb --help | --version',
|
|
11
|
+
'',
|
|
12
|
+
'--existing creates only a missing tsconfig.json; no starter or package changes.',
|
|
13
|
+
'--dry-run reports planned files without writing anything.',
|
|
14
|
+
'doctor inspects configuration without executing application code or repairing files.',
|
|
15
|
+
].join('\n') + '\n';
|
|
16
|
+
|
|
17
|
+
function parseArguments(args) {
|
|
18
|
+
const [command = '--help', ...rest] = args;
|
|
19
|
+
if (['--help', '-h', '--version'].includes(command) && !rest.length) return { command };
|
|
20
|
+
if (!['init', 'doctor', 'add'].includes(command)) throw new Error('Unknown command. Run redweb --help.');
|
|
21
|
+
const result = { command, target: '.', existing: false, dryRun: false, json: false, port: null };
|
|
22
|
+
if (command === 'add') {
|
|
23
|
+
result.kind = rest.shift();
|
|
24
|
+
result.name = rest.shift();
|
|
25
|
+
if (!KINDS.includes(result.kind) || !result.name || result.name.startsWith('-')) throw new Error('Add requires a kind and name. Run redweb --help.');
|
|
26
|
+
}
|
|
27
|
+
let targetSeen = false;
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
30
|
+
const value = rest[i];
|
|
31
|
+
if (!value.startsWith('-')) {
|
|
32
|
+
if (targetSeen) throw new Error('Only one project directory is allowed.');
|
|
33
|
+
result.target = value;
|
|
34
|
+
targetSeen = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (seen.has(value)) throw new Error(`Duplicate option: ${value}`);
|
|
38
|
+
seen.add(value);
|
|
39
|
+
if (value === '--json') result.json = true;
|
|
40
|
+
else if (value === '--existing' && command === 'init') result.existing = true;
|
|
41
|
+
else if (value === '--dry-run' && ['init', 'add'].includes(command)) result.dryRun = true;
|
|
42
|
+
else if (command === 'add' && ['--config', '--source-dir', '--test-dir'].includes(value)) {
|
|
43
|
+
const argument = rest[++i];
|
|
44
|
+
if (!argument || argument.startsWith('-')) throw new Error(`${value} requires a path.`);
|
|
45
|
+
result[{ '--config': 'configFile', '--source-dir': 'sourceDir', '--test-dir': 'testDir' }[value]] = argument;
|
|
46
|
+
}
|
|
47
|
+
else if (value === '--template' && command === 'init') {
|
|
48
|
+
const template = rest[++i];
|
|
49
|
+
if (!TEMPLATES.includes(template)) throw new Error(`--template must be one of: ${TEMPLATES.join(', ')}.`);
|
|
50
|
+
result.template = template;
|
|
51
|
+
}
|
|
52
|
+
else if (value === '--port' && command === 'doctor') {
|
|
53
|
+
const port = rest[++i];
|
|
54
|
+
if (!/^\d+$/.test(port) || Number(port) > 65535) throw new Error('--port must be an integer from 0 through 65535.');
|
|
55
|
+
result.port = Number(port);
|
|
56
|
+
} else throw new Error(`Unknown option for ${command}: ${value}`);
|
|
57
|
+
}
|
|
58
|
+
if (result.existing && result.template) throw new Error('--existing and --template cannot be combined.');
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { parseArguments, USAGE };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Explicit argv remains authoritative; human output is quoted for the host shell. */
|
|
4
|
+
function formatCommand(args, platform = process.platform) {
|
|
5
|
+
return platform === 'win32'
|
|
6
|
+
? '& ' + args.map(value => `'${value.replaceAll("'", "''")}'`).join(' ')
|
|
7
|
+
: args.map(value => `'${value.replaceAll("'", "'\\''")}'`).join(' ');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = formatCommand;
|