sitevision-cli 0.4.0-beta.2 → 0.5.0-beta.0
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/dist/commands/build.js +52 -4
- package/dist/commands/dev.js +139 -22
- package/dist/components/InfoScreen.js +3 -1
- package/dist/utils/process-runner.d.ts +0 -8
- package/dist/utils/process-runner.js +0 -15
- package/dist/utils/sitevision-api.d.ts +32 -0
- package/dist/utils/sitevision-api.js +111 -42
- package/dist/utils/sitevision-scripts-runner.d.ts +83 -0
- package/dist/utils/sitevision-scripts-runner.js +187 -0
- package/dist/utils/webpack-runner.d.ts +9 -0
- package/dist/utils/webpack-runner.js +30 -15
- package/dist/utils/zip.d.ts +10 -3
- package/dist/utils/zip.js +171 -59
- package/package.json +1 -1
package/dist/commands/build.js
CHANGED
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { render, Box, Text, useInput } from 'ink';
|
|
4
4
|
import { StatusIndicator } from '../components/StatusIndicator.js';
|
|
5
|
-
import { WebpackRunner } from '../utils/webpack-runner.js';
|
|
6
|
-
import {
|
|
5
|
+
import { WebpackRunner, hasLocalWebpackConfig } from '../utils/webpack-runner.js';
|
|
6
|
+
import { hasSitevisionScripts, runSitevisionScriptsBuild, getDelegatedZipPath, checkSitevisionScriptsCompatibility, } from '../utils/sitevision-scripts-runner.js';
|
|
7
|
+
import { copyStaticToBuild, copySrcToBuild, cleanBuild, formatFileSize, createBuildZip, getZipSize, zipExists, } from '../utils/zip.js';
|
|
7
8
|
import { isBundledApp, getAppType, getFullAppId, } from '../utils/project-detection.js';
|
|
8
9
|
export function BuildScreen({ projectRoot, manifest, createZip = true, onBack, }) {
|
|
9
10
|
const [state, setState] = React.useState({
|
|
@@ -25,8 +26,55 @@ export function BuildScreen({ projectRoot, manifest, createZip = true, onBack, }
|
|
|
25
26
|
setState({ status: 'cleaning', message: 'Cleaning build directory...' });
|
|
26
27
|
cleanBuild(projectRoot);
|
|
27
28
|
// Step 2: Build or copy files
|
|
29
|
+
if (isBundled && !hasLocalWebpackConfig(projectRoot)) {
|
|
30
|
+
// No project-local webpack config: delegate the whole build to
|
|
31
|
+
// @sitevision/sitevision-scripts, which compiles + zips to
|
|
32
|
+
// dist/<appId>.zip (the path the CLI's sign/deploy already use).
|
|
33
|
+
if (!hasSitevisionScripts(projectRoot)) {
|
|
34
|
+
setState({
|
|
35
|
+
status: 'error',
|
|
36
|
+
error: 'No webpack.config.js found and @sitevision/sitevision-scripts is not installed. Run npm install.',
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Warn if the installed sitevision-scripts is outside the range
|
|
41
|
+
// the delegated build was validated against.
|
|
42
|
+
const warning = checkSitevisionScriptsCompatibility(projectRoot).warning;
|
|
43
|
+
setState({
|
|
44
|
+
status: 'building',
|
|
45
|
+
message: 'Building via sitevision-scripts...',
|
|
46
|
+
warning,
|
|
47
|
+
});
|
|
48
|
+
const sitevisionResult = await runSitevisionScriptsBuild(projectRoot);
|
|
49
|
+
if (!sitevisionResult.success) {
|
|
50
|
+
setState({
|
|
51
|
+
status: 'error',
|
|
52
|
+
error: `${sitevisionResult.error}\n${sitevisionResult.output.slice(-1000)}`,
|
|
53
|
+
warning,
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// sitevision-scripts already produced the zip; report it directly.
|
|
58
|
+
const zipPath = getDelegatedZipPath(projectRoot, manifest.id);
|
|
59
|
+
if (!zipExists(zipPath)) {
|
|
60
|
+
setState({
|
|
61
|
+
status: 'error',
|
|
62
|
+
error: `Build reported success but no zip was found at ${zipPath}.`,
|
|
63
|
+
warning,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setState({
|
|
68
|
+
status: 'success',
|
|
69
|
+
message: 'Build complete',
|
|
70
|
+
zipPath,
|
|
71
|
+
zipSize: getZipSize(zipPath),
|
|
72
|
+
warning,
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
28
76
|
if (isBundled) {
|
|
29
|
-
//
|
|
77
|
+
// Project ships its own webpack config: build it in-process.
|
|
30
78
|
if (!WebpackRunner.isWebpackAvailable(projectRoot)) {
|
|
31
79
|
setState({
|
|
32
80
|
status: 'error',
|
|
@@ -122,7 +170,7 @@ export function BuildScreen({ projectRoot, manifest, createZip = true, onBack, }
|
|
|
122
170
|
return 'Build failed';
|
|
123
171
|
}
|
|
124
172
|
};
|
|
125
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(StatusIndicator, { status: getStatusIndicator(), label: getStatusLabel(), message: state.message }) }), state.status === 'success' && state.result?.stats && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { dimColor: true, children: ["Compiled in ", state.result.stats.time, "ms"] }), state.result.stats.assets &&
|
|
173
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(StatusIndicator, { status: getStatusIndicator(), label: getStatusLabel(), message: state.message }) }), state.warning && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "yellow", children: ["\u26A0 ", state.warning] }) })), state.status === 'success' && state.result?.stats && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { dimColor: true, children: ["Compiled in ", state.result.stats.time, "ms"] }), state.result.stats.assets &&
|
|
126
174
|
state.result.stats.assets.length > 0 && (_jsxs(Text, { dimColor: true, children: ["Assets: ", state.result.stats.assets.join(', ')] }))] })), state.status === 'success' && state.zipPath && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsxs(Text, { color: "green", children: ["\u2713 Created: ", state.zipPath] }), state.zipSize !== undefined && (_jsxs(Text, { dimColor: true, children: [" Size: ", formatFileSize(state.zipSize)] }))] })), state.result?.warnings && state.result.warnings.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "Warnings:" }), state.result.warnings.slice(0, 5).map((warning, i) => (_jsx(Text, { color: "yellow", dimColor: true, children: warning.substring(0, 200) }, i))), state.result.warnings.length > 5 && (_jsxs(Text, { color: "yellow", dimColor: true, children: ["...and ", state.result.warnings.length - 5, " more"] }))] })), state.status === 'error' && state.error && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: "red", children: state.error }) })), onBack && (state.status === 'success' || state.status === 'error') && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press q or Esc to return to menu" }) }))] }));
|
|
127
175
|
}
|
|
128
176
|
export const buildCommand = {
|
package/dist/commands/dev.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
2
4
|
import React from 'react';
|
|
3
5
|
import { render, Box, Text, useApp, useInput } from 'ink';
|
|
4
6
|
import { StatusIndicator } from '../components/StatusIndicator.js';
|
|
5
|
-
import { WebpackRunner } from '../utils/webpack-runner.js';
|
|
7
|
+
import { WebpackRunner, hasLocalWebpackConfig } from '../utils/webpack-runner.js';
|
|
8
|
+
import { hasSitevisionScripts, runSitevisionScriptsBuild, getDelegatedZipPath, checkSitevisionScriptsCompatibility, } from '../utils/sitevision-scripts-runner.js';
|
|
6
9
|
import { promptPassword, promptYesNo } from '../utils/password-prompt.js';
|
|
7
10
|
import { signApp, deployApp } from '../utils/sitevision-api.js';
|
|
8
11
|
import { setDeployPassword } from '../utils/keychain.js';
|
|
@@ -26,23 +29,13 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, signin
|
|
|
26
29
|
}
|
|
27
30
|
});
|
|
28
31
|
const webpackRunnerRef = React.useRef(null);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
error: result.errors?.join('\n'),
|
|
36
|
-
}));
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
32
|
+
const watchersRef = React.useRef([]);
|
|
33
|
+
const debounceTimerRef = React.useRef(null);
|
|
34
|
+
const isBuildingRef = React.useRef(false);
|
|
35
|
+
const pendingRebuildRef = React.useRef(false);
|
|
36
|
+
// Sign (if needed) and deploy an already-built zip, updating UI state.
|
|
37
|
+
const signAndDeploy = React.useCallback(async (zipPath, buildTime) => {
|
|
39
38
|
try {
|
|
40
|
-
// Copy static files
|
|
41
|
-
copyStaticToBuild(projectRoot);
|
|
42
|
-
// Create zip
|
|
43
|
-
const appId = getFullAppId(manifest.id);
|
|
44
|
-
await createBuildZip(projectRoot, appId);
|
|
45
|
-
const zipPath = getZipPath(projectRoot, manifest);
|
|
46
39
|
let deployZipPath = zipPath;
|
|
47
40
|
// Sign if needed
|
|
48
41
|
if (signed && signingCredentials) {
|
|
@@ -94,7 +87,7 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, signin
|
|
|
94
87
|
status: 'ready',
|
|
95
88
|
message: 'Deployed. Watching for changes...',
|
|
96
89
|
buildCount: prev.buildCount + 1,
|
|
97
|
-
lastBuildTime:
|
|
90
|
+
lastBuildTime: buildTime,
|
|
98
91
|
error: undefined,
|
|
99
92
|
}));
|
|
100
93
|
}
|
|
@@ -107,14 +100,122 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, signin
|
|
|
107
100
|
}));
|
|
108
101
|
}
|
|
109
102
|
}, [projectRoot, manifest, devProperties, signed, signingCredentials]);
|
|
103
|
+
// In-house webpack path: copy static, zip, then sign + deploy.
|
|
104
|
+
const handleBuildComplete = React.useCallback(async (result) => {
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
setState(prev => ({
|
|
107
|
+
...prev,
|
|
108
|
+
status: 'error',
|
|
109
|
+
message: result.errors?.join('\n') || 'Build failed',
|
|
110
|
+
error: result.errors?.join('\n'),
|
|
111
|
+
}));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
copyStaticToBuild(projectRoot);
|
|
116
|
+
const appId = getFullAppId(manifest.id);
|
|
117
|
+
await createBuildZip(projectRoot, appId);
|
|
118
|
+
await signAndDeploy(getZipPath(projectRoot, manifest), result.stats?.time);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
setState(prev => ({
|
|
122
|
+
...prev,
|
|
123
|
+
status: 'error',
|
|
124
|
+
message: error instanceof Error ? error.message : String(error),
|
|
125
|
+
error: error instanceof Error ? error.message : String(error),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
}, [projectRoot, manifest, signAndDeploy]);
|
|
129
|
+
// Delegated path: full `sitevision-scripts build` then sign + deploy.
|
|
130
|
+
// Coalesces overlapping triggers so a save mid-build queues one rebuild.
|
|
131
|
+
const runDelegatedBuild = React.useCallback(async () => {
|
|
132
|
+
if (isBuildingRef.current) {
|
|
133
|
+
pendingRebuildRef.current = true;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
isBuildingRef.current = true;
|
|
137
|
+
const buildOnce = async () => {
|
|
138
|
+
pendingRebuildRef.current = false;
|
|
139
|
+
setState(prev => ({
|
|
140
|
+
...prev,
|
|
141
|
+
status: 'building',
|
|
142
|
+
message: 'Building via sitevision-scripts...',
|
|
143
|
+
}));
|
|
144
|
+
const result = await runSitevisionScriptsBuild(projectRoot);
|
|
145
|
+
if (result.success) {
|
|
146
|
+
await signAndDeploy(getDelegatedZipPath(projectRoot, manifest.id));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
setState(prev => ({
|
|
150
|
+
...prev,
|
|
151
|
+
status: 'error',
|
|
152
|
+
message: result.error ?? 'Build failed',
|
|
153
|
+
error: `${result.error}\n${result.output.slice(-1000)}`,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
if (pendingRebuildRef.current) {
|
|
157
|
+
await buildOnce();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
try {
|
|
161
|
+
await buildOnce();
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
isBuildingRef.current = false;
|
|
165
|
+
}
|
|
166
|
+
}, [projectRoot, manifest, signAndDeploy]);
|
|
167
|
+
// Watch source files and trigger a delegated rebuild (debounced).
|
|
168
|
+
const startFileWatcher = React.useCallback(() => {
|
|
169
|
+
const targets = [
|
|
170
|
+
'src',
|
|
171
|
+
'static',
|
|
172
|
+
'i18n',
|
|
173
|
+
'resource',
|
|
174
|
+
'config',
|
|
175
|
+
'manifest.json',
|
|
176
|
+
]
|
|
177
|
+
.map(name => path.join(projectRoot, name))
|
|
178
|
+
.filter(target => fs.existsSync(target));
|
|
179
|
+
for (const target of targets) {
|
|
180
|
+
const isDir = fs.statSync(target).isDirectory();
|
|
181
|
+
const watcher = fs.watch(target, { recursive: isDir }, () => {
|
|
182
|
+
if (debounceTimerRef.current) {
|
|
183
|
+
clearTimeout(debounceTimerRef.current);
|
|
184
|
+
}
|
|
185
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
186
|
+
void runDelegatedBuild();
|
|
187
|
+
}, 300);
|
|
188
|
+
});
|
|
189
|
+
watchersRef.current.push(watcher);
|
|
190
|
+
}
|
|
191
|
+
}, [projectRoot, runDelegatedBuild]);
|
|
110
192
|
React.useEffect(() => {
|
|
111
193
|
const isBundled = isBundledApp(manifest);
|
|
112
194
|
async function startWatch() {
|
|
113
195
|
try {
|
|
114
196
|
// Clean build directory
|
|
115
197
|
cleanBuild(projectRoot);
|
|
116
|
-
if (isBundled) {
|
|
117
|
-
//
|
|
198
|
+
if (isBundled && !hasLocalWebpackConfig(projectRoot)) {
|
|
199
|
+
// No local webpack config: delegate each build to
|
|
200
|
+
// sitevision-scripts (full rebuild) and watch source files
|
|
201
|
+
// ourselves, keeping the CLI's own sign + deploy flow.
|
|
202
|
+
if (!hasSitevisionScripts(projectRoot)) {
|
|
203
|
+
setState({
|
|
204
|
+
status: 'error',
|
|
205
|
+
message: 'No webpack.config.js found and @sitevision/sitevision-scripts is not installed. Run npm install.',
|
|
206
|
+
buildCount: 0,
|
|
207
|
+
webpackReady: false,
|
|
208
|
+
error: 'No build pipeline available',
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const warning = checkSitevisionScriptsCompatibility(projectRoot).warning;
|
|
213
|
+
setState(prev => ({ ...prev, webpackReady: true, warning }));
|
|
214
|
+
startFileWatcher();
|
|
215
|
+
await runDelegatedBuild();
|
|
216
|
+
}
|
|
217
|
+
else if (isBundled) {
|
|
218
|
+
// Project ships its own webpack config: incremental in-process watch.
|
|
118
219
|
if (!WebpackRunner.isWebpackAvailable(projectRoot)) {
|
|
119
220
|
setState({
|
|
120
221
|
status: 'error',
|
|
@@ -175,14 +276,30 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, signin
|
|
|
175
276
|
if (webpackRunnerRef.current) {
|
|
176
277
|
webpackRunnerRef.current.close().catch(() => { });
|
|
177
278
|
}
|
|
279
|
+
if (debounceTimerRef.current) {
|
|
280
|
+
clearTimeout(debounceTimerRef.current);
|
|
281
|
+
}
|
|
282
|
+
for (const watcher of watchersRef.current) {
|
|
283
|
+
watcher.close();
|
|
284
|
+
}
|
|
285
|
+
watchersRef.current = [];
|
|
178
286
|
};
|
|
179
|
-
}, [
|
|
287
|
+
}, [
|
|
288
|
+
projectRoot,
|
|
289
|
+
manifest,
|
|
290
|
+
handleBuildComplete,
|
|
291
|
+
runDelegatedBuild,
|
|
292
|
+
startFileWatcher,
|
|
293
|
+
]);
|
|
180
294
|
// Handle Ctrl+C
|
|
181
295
|
React.useEffect(() => {
|
|
182
296
|
const handleExit = () => {
|
|
183
297
|
if (webpackRunnerRef.current) {
|
|
184
298
|
webpackRunnerRef.current.close().catch(() => { });
|
|
185
299
|
}
|
|
300
|
+
for (const watcher of watchersRef.current) {
|
|
301
|
+
watcher.close();
|
|
302
|
+
}
|
|
186
303
|
exit();
|
|
187
304
|
};
|
|
188
305
|
process.on('SIGINT', handleExit);
|
|
@@ -220,7 +337,7 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, signin
|
|
|
220
337
|
return 'Error';
|
|
221
338
|
}
|
|
222
339
|
};
|
|
223
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(StatusIndicator, { status: getStatusType(), label: getStatusLabel(), message: state.message }) }), state.buildCount > 0 && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Builds: ", state.buildCount, state.lastBuildTime
|
|
340
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(StatusIndicator, { status: getStatusType(), label: getStatusLabel(), message: state.message }) }), state.warning && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "yellow", children: ["\u26A0 ", state.warning] }) })), state.buildCount > 0 && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Builds: ", state.buildCount, state.lastBuildTime
|
|
224
341
|
? ` | Last build: ${state.lastBuildTime}ms`
|
|
225
342
|
: '', signed ? ' | Signed mode' : ''] }) })), _jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [manifest.name, " v", manifest.version] }) }), devProperties && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Target: ", devProperties.domain, "/", devProperties.siteName, "/", devProperties.addonName] }) })), state.status === 'error' && state.error && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: "red", children: state.error }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.status === 'error' && onRetryCredentials && (_jsx(Text, { dimColor: true, children: "Press r to retry with new credentials" })), onBack ? (_jsx(Text, { dimColor: true, children: "Press q or Esc to return to menu (Ctrl+C to stop process)" })) : (_jsx(Text, { dimColor: true, children: "Press Ctrl+C to stop" }))] })] }));
|
|
226
343
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import { getAppType } from '../utils/project-detection.js';
|
|
4
|
+
import { checkSitevisionScriptsCompatibility } from '../utils/sitevision-scripts-runner.js';
|
|
4
5
|
export function InfoScreen({ project, onBack }) {
|
|
5
6
|
const appType = getAppType(project.manifest);
|
|
7
|
+
const scriptsCompat = checkSitevisionScriptsCompatibility(project.root);
|
|
6
8
|
useInput((input, key) => {
|
|
7
9
|
if (key.escape || input === 'q' || key.return) {
|
|
8
10
|
onBack();
|
|
9
11
|
}
|
|
10
12
|
});
|
|
11
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision Project Information" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Name: " }), _jsx(Text, { children: project.manifest.name })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "ID: " }), _jsx(Text, { children: project.manifest.id })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Version: " }), _jsx(Text, { children: project.manifest.version })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Type: " }), _jsx(Text, { color: "green", children: project.manifest.type }), _jsxs(Text, { dimColor: true, children: [" (", appType, ")"] })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Bundled: " }), _jsx(Text, { children: project.manifest.bundled ? 'Yes' : 'No' })] })] }), project.hasDevProperties && project.devProperties && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Development Configuration" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Domain: " }), _jsx(Text, { children: project.devProperties.domain })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Site: " }), _jsx(Text, { children: project.devProperties.siteName })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Addon: " }), _jsx(Text, { children: project.devProperties.addonName })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Username: " }), _jsx(Text, { children: project.devProperties.username })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Use HTTP: " }), _jsx(Text, { children: project.devProperties.useHTTPForDevDeploy ? 'Yes' : 'No' })] })] })] })), !project.hasDevProperties && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 No dev properties found. Run setup-dev-properties to configure." }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, children: "Project Root: " }), _jsx(Text, { dimColor: true, children: project.root })] }), _jsx(Box, { marginTop: 2, children: _jsx(Text, { dimColor: true, children: "Press Enter or ESC to return to menu" }) })] }));
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision Project Information" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Name: " }), _jsx(Text, { children: project.manifest.name })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "ID: " }), _jsx(Text, { children: project.manifest.id })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Version: " }), _jsx(Text, { children: project.manifest.version })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Type: " }), _jsx(Text, { color: "green", children: project.manifest.type }), _jsxs(Text, { dimColor: true, children: [" (", appType, ")"] })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Bundled: " }), _jsx(Text, { children: project.manifest.bundled ? 'Yes' : 'No' })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Build Tooling" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "sitevision-scripts: " }), _jsx(Text, { children: scriptsCompat.installed ?? 'not installed' }), _jsxs(Text, { dimColor: true, children: [" (supported ", scriptsCompat.supportedRange, ")"] })] }), scriptsCompat.warning && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u26A0 ", scriptsCompat.warning] }) }))] }), project.hasDevProperties && project.devProperties && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Development Configuration" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Domain: " }), _jsx(Text, { children: project.devProperties.domain })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Site: " }), _jsx(Text, { children: project.devProperties.siteName })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Addon: " }), _jsx(Text, { children: project.devProperties.addonName })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Username: " }), _jsx(Text, { children: project.devProperties.username })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Use HTTP: " }), _jsx(Text, { children: project.devProperties.useHTTPForDevDeploy ? 'Yes' : 'No' })] })] })] })), !project.hasDevProperties && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 No dev properties found. Run setup-dev-properties to configure." }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, children: "Project Root: " }), _jsx(Text, { dimColor: true, children: project.root })] }), _jsx(Box, { marginTop: 2, children: _jsx(Text, { dimColor: true, children: "Press Enter or ESC to return to menu" }) })] }));
|
|
12
14
|
}
|
|
@@ -20,11 +20,3 @@ export declare class ProcessRunner extends EventEmitter {
|
|
|
20
20
|
kill(): void;
|
|
21
21
|
getOutput(): ProcessOutput[];
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Run a sitevision-scripts command in the project directory
|
|
25
|
-
*/
|
|
26
|
-
export declare function runSitevisionScript(scriptName: string, args?: string[], projectRoot?: string): ProcessRunner;
|
|
27
|
-
/**
|
|
28
|
-
* Run an NPM script
|
|
29
|
-
*/
|
|
30
|
-
export declare function runNpmScript(scriptName: string, args?: string[], projectRoot?: string, customEnv?: Record<string, string>): ProcessRunner;
|
|
@@ -72,18 +72,3 @@ export class ProcessRunner extends EventEmitter {
|
|
|
72
72
|
return this.output;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
/**
|
|
76
|
-
* Run a sitevision-scripts command in the project directory
|
|
77
|
-
*/
|
|
78
|
-
export function runSitevisionScript(scriptName, args = [], projectRoot) {
|
|
79
|
-
// Check if sitevision-scripts is available locally
|
|
80
|
-
const runner = new ProcessRunner('npm', ['run', scriptName, '--', ...args], projectRoot);
|
|
81
|
-
return runner;
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Run an NPM script
|
|
85
|
-
*/
|
|
86
|
-
export function runNpmScript(scriptName, args = [], projectRoot, customEnv) {
|
|
87
|
-
const runner = new ProcessRunner('npm', ['run', scriptName, ...args], projectRoot, false, customEnv);
|
|
88
|
-
return runner;
|
|
89
|
-
}
|
|
@@ -13,6 +13,38 @@ import type { SigningCredentials, DeployConfig, ProductionDeployConfig, SigningR
|
|
|
13
13
|
* Create Basic Auth header value
|
|
14
14
|
*/
|
|
15
15
|
declare function createBasicAuth(username: string, password: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Make an HTTP/HTTPS request
|
|
18
|
+
*/
|
|
19
|
+
export declare function makeRequest(url: string, options: {
|
|
20
|
+
method: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
body?: Buffer;
|
|
23
|
+
auth?: {
|
|
24
|
+
username: string;
|
|
25
|
+
password: string;
|
|
26
|
+
};
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
statusCode: number;
|
|
30
|
+
body: Buffer;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Whether an HTTP status is worth retrying (transient server-side failures).
|
|
35
|
+
*/
|
|
36
|
+
export declare function isRetryableStatus(statusCode: number): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Summarize a non-success response body for error messages.
|
|
39
|
+
* Avoids dumping raw bytes (e.g. an HTML error page or a binary blob) by
|
|
40
|
+
* trimming text bodies and labelling binary ones by their content type.
|
|
41
|
+
*/
|
|
42
|
+
export declare function summarizeErrorBody(body: Buffer, headers: Record<string, string>): string;
|
|
43
|
+
/**
|
|
44
|
+
* Check that a buffer begins with the ZIP magic bytes. Used to fail fast when
|
|
45
|
+
* the signing endpoint returns an error page with HTTP 200.
|
|
46
|
+
*/
|
|
47
|
+
export declare function looksLikeZip(body: Buffer): boolean;
|
|
16
48
|
/**
|
|
17
49
|
* Sign an app via developer.sitevision.se
|
|
18
50
|
*
|
|
@@ -18,6 +18,12 @@ import { buildImportEndpointUrl, buildAddonEndpointUrl, } from './project-detect
|
|
|
18
18
|
// =============================================================================
|
|
19
19
|
const SIGNING_API_HOST = 'developer.sitevision.se';
|
|
20
20
|
const SIGNING_API_PATH = '/rest-api/appsigner/signapp';
|
|
21
|
+
/** Default per-request timeout. Generous because signing uploads a full zip. */
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
23
|
+
/** Max attempts for transient failures (network errors, timeouts, 5xx). */
|
|
24
|
+
const SIGN_MAX_ATTEMPTS = 3;
|
|
25
|
+
/** Base backoff between retries; grows exponentially per attempt. */
|
|
26
|
+
const RETRY_BASE_DELAY_MS = 1000;
|
|
21
27
|
// =============================================================================
|
|
22
28
|
// UTILITY FUNCTIONS
|
|
23
29
|
// =============================================================================
|
|
@@ -56,7 +62,7 @@ function createMultipartFormData(filePath, fieldName, boundary) {
|
|
|
56
62
|
/**
|
|
57
63
|
* Make an HTTP/HTTPS request
|
|
58
64
|
*/
|
|
59
|
-
function makeRequest(url, options) {
|
|
65
|
+
export function makeRequest(url, options) {
|
|
60
66
|
return new Promise((resolve, reject) => {
|
|
61
67
|
const parsedUrl = new URL(url);
|
|
62
68
|
const isHttps = parsedUrl.protocol === 'https:';
|
|
@@ -87,6 +93,10 @@ function makeRequest(url, options) {
|
|
|
87
93
|
});
|
|
88
94
|
});
|
|
89
95
|
});
|
|
96
|
+
// Abort hung connections instead of blocking the CLI indefinitely.
|
|
97
|
+
req.setTimeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, () => {
|
|
98
|
+
req.destroy(new Error(`Request timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
|
|
99
|
+
});
|
|
90
100
|
req.on('error', reject);
|
|
91
101
|
if (options.body) {
|
|
92
102
|
req.write(options.body);
|
|
@@ -94,6 +104,47 @@ function makeRequest(url, options) {
|
|
|
94
104
|
req.end();
|
|
95
105
|
});
|
|
96
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether an HTTP status is worth retrying (transient server-side failures).
|
|
109
|
+
*/
|
|
110
|
+
export function isRetryableStatus(statusCode) {
|
|
111
|
+
return statusCode === 408 || statusCode === 429 || statusCode >= 500;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Sleep helper for backoff between retries.
|
|
115
|
+
*/
|
|
116
|
+
async function delay(ms) {
|
|
117
|
+
return new Promise(resolve => {
|
|
118
|
+
setTimeout(resolve, ms);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Summarize a non-success response body for error messages.
|
|
123
|
+
* Avoids dumping raw bytes (e.g. an HTML error page or a binary blob) by
|
|
124
|
+
* trimming text bodies and labelling binary ones by their content type.
|
|
125
|
+
*/
|
|
126
|
+
export function summarizeErrorBody(body, headers) {
|
|
127
|
+
const contentType = headers['content-type'] ?? 'unknown';
|
|
128
|
+
const isText = contentType.includes('text') ||
|
|
129
|
+
contentType.includes('json') ||
|
|
130
|
+
contentType.includes('xml');
|
|
131
|
+
if (!isText) {
|
|
132
|
+
return `(${contentType}, ${body.length} bytes)`;
|
|
133
|
+
}
|
|
134
|
+
const text = body.toString('utf8').replaceAll(/\s+/g, ' ').trim();
|
|
135
|
+
const max = 300;
|
|
136
|
+
const summary = text.length > max ? text.slice(0, max) + '…' : text;
|
|
137
|
+
return summary.length > 0 ? summary : `(${contentType}, empty body)`;
|
|
138
|
+
}
|
|
139
|
+
/** ZIP local-file-header magic bytes: "PK\x03\x04". */
|
|
140
|
+
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
141
|
+
/**
|
|
142
|
+
* Check that a buffer begins with the ZIP magic bytes. Used to fail fast when
|
|
143
|
+
* the signing endpoint returns an error page with HTTP 200.
|
|
144
|
+
*/
|
|
145
|
+
export function looksLikeZip(body) {
|
|
146
|
+
return body.length >= 4 && body.subarray(0, 4).equals(ZIP_MAGIC);
|
|
147
|
+
}
|
|
97
148
|
// =============================================================================
|
|
98
149
|
// SIGNING API
|
|
99
150
|
// =============================================================================
|
|
@@ -120,48 +171,66 @@ export async function signApp(zipPath, credentials, outputPath) {
|
|
|
120
171
|
// Create multipart form data
|
|
121
172
|
const boundary = generateBoundary();
|
|
122
173
|
const { body, contentType } = createMultipartFormData(zipPath, 'file', boundary);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
174
|
+
// Signing is idempotent (same input → same signed output), so transient
|
|
175
|
+
// failures (network errors, timeouts, 5xx) are safe to retry with backoff.
|
|
176
|
+
let lastError = 'Signing failed';
|
|
177
|
+
for (let attempt = 1; attempt <= SIGN_MAX_ATTEMPTS; attempt++) {
|
|
178
|
+
try {
|
|
179
|
+
const response = await makeRequest(url, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: {
|
|
182
|
+
'Content-Type': contentType,
|
|
183
|
+
'Content-Length': String(body.length),
|
|
184
|
+
},
|
|
185
|
+
body,
|
|
186
|
+
auth: {
|
|
187
|
+
username: credentials.username,
|
|
188
|
+
password: credentials.password,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
if (response.statusCode === 200) {
|
|
192
|
+
// Guard against an error page returned with a 200 status.
|
|
193
|
+
if (!looksLikeZip(response.body)) {
|
|
194
|
+
return {
|
|
195
|
+
success: false,
|
|
196
|
+
error: `Signing returned a non-zip response: ${summarizeErrorBody(response.body, response.headers)}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// Write signed zip to output path
|
|
200
|
+
const outputDir = path.dirname(outputPath);
|
|
201
|
+
if (!fs.existsSync(outputDir)) {
|
|
202
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
fs.writeFileSync(outputPath, response.body);
|
|
205
|
+
return {
|
|
206
|
+
success: true,
|
|
207
|
+
signedFilePath: outputPath,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (response.statusCode === 401) {
|
|
211
|
+
// Auth failures will not resolve on retry.
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
error: 'Unauthorized. Check username and password.',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
lastError = `Signing failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`;
|
|
218
|
+
if (!isRetryableStatus(response.statusCode)) {
|
|
219
|
+
return { success: false, error: lastError };
|
|
141
220
|
}
|
|
142
|
-
fs.writeFileSync(outputPath, response.body);
|
|
143
|
-
return {
|
|
144
|
-
success: true,
|
|
145
|
-
signedFilePath: outputPath,
|
|
146
|
-
};
|
|
147
221
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
222
|
+
catch (error) {
|
|
223
|
+
lastError = `Signing request failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
224
|
+
}
|
|
225
|
+
// Back off before the next attempt (skip after the final attempt).
|
|
226
|
+
if (attempt < SIGN_MAX_ATTEMPTS) {
|
|
227
|
+
await delay(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
|
|
153
228
|
}
|
|
154
|
-
return {
|
|
155
|
-
success: false,
|
|
156
|
-
error: `Signing failed with status ${response.statusCode}: ${response.body.toString()}`,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
catch (error) {
|
|
160
|
-
return {
|
|
161
|
-
success: false,
|
|
162
|
-
error: `Signing request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
163
|
-
};
|
|
164
229
|
}
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `${lastError} (after ${SIGN_MAX_ATTEMPTS} attempts)`,
|
|
233
|
+
};
|
|
165
234
|
}
|
|
166
235
|
// =============================================================================
|
|
167
236
|
// DEPLOYMENT API
|
|
@@ -233,7 +302,7 @@ export async function deployApp(zipPath, config, appType, force = false) {
|
|
|
233
302
|
}
|
|
234
303
|
return {
|
|
235
304
|
success: false,
|
|
236
|
-
error: `Deployment failed with status ${response.statusCode}: ${response.body.
|
|
305
|
+
error: `Deployment failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
|
|
237
306
|
};
|
|
238
307
|
}
|
|
239
308
|
catch (error) {
|
|
@@ -330,7 +399,7 @@ export async function createAddon(config, appType) {
|
|
|
330
399
|
}
|
|
331
400
|
return {
|
|
332
401
|
success: false,
|
|
333
|
-
error: `Create addon failed with status ${response.statusCode}: ${response.body.
|
|
402
|
+
error: `Create addon failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
|
|
334
403
|
};
|
|
335
404
|
}
|
|
336
405
|
catch (error) {
|
|
@@ -377,7 +446,7 @@ export async function activateApp(executableId, config, _appType) {
|
|
|
377
446
|
}
|
|
378
447
|
return {
|
|
379
448
|
success: false,
|
|
380
|
-
error: `Activation failed with status ${response.statusCode}: ${response.body.
|
|
449
|
+
error: `Activation failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
|
|
381
450
|
};
|
|
382
451
|
}
|
|
383
452
|
catch (error) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitevision Scripts Runner
|
|
3
|
+
*
|
|
4
|
+
* Delegates the compile/build step to the official sitevision-scripts npm
|
|
5
|
+
* package when a project has no local webpack config of its own.
|
|
6
|
+
*
|
|
7
|
+
* Sitevision WebApp builds are tightly coupled to the platform runtime (a
|
|
8
|
+
* dual server/client multi-compiler, AMD externals for React and the sitevision
|
|
9
|
+
* api packages, an embedded ES5 server engine, and a precise addon zip layout).
|
|
10
|
+
* Rather than
|
|
11
|
+
* reproduce that contract — which lives in proprietary babel presets and an
|
|
12
|
+
* undocumented internal config — we shell out to the package's public CLI, which
|
|
13
|
+
* is the canonical, maintained source of that build pipeline.
|
|
14
|
+
*
|
|
15
|
+
* `sitevision-scripts build` runs build + zip + cleanup and writes the archive to
|
|
16
|
+
* `dist/<appId>.zip` — the exact path the CLI's own sign/deploy steps already use.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the path to the sitevision-scripts CLI entry inside a project.
|
|
20
|
+
* Returns null if the package is not installed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getSitevisionScriptsBin(projectRoot: string): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the sitevision-scripts package is available in the project.
|
|
25
|
+
*/
|
|
26
|
+
export declare function hasSitevisionScripts(projectRoot: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Path of the zip that `sitevision-scripts build` writes.
|
|
29
|
+
*
|
|
30
|
+
* IMPORTANT: this mirrors sitevision-scripts' own app-id convention
|
|
31
|
+
* (`APP_ID_PREFIX`/`APP_ID_SUFFIX` env vars + `dist/<appId>.zip`), which differs
|
|
32
|
+
* from the CLI's own `getZipPath` env vars (`SITEVISION_APP_ID_*`). For delegated
|
|
33
|
+
* builds the package is the one writing the file, so its convention is the source
|
|
34
|
+
* of truth — using `getZipPath` here would look for the wrong filename whenever a
|
|
35
|
+
* prefix/suffix is configured.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getDelegatedZipPath(projectRoot: string, manifestId: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Range of the sitevision-scripts package the CLI's build delegation has been
|
|
40
|
+
* validated against. The delegation depends on the package's CLI commands, its
|
|
41
|
+
* `dist/<appId>.zip` output, and the app-id convention — all stable within a
|
|
42
|
+
* major. A new major may change that contract, so we warn rather than assume.
|
|
43
|
+
*
|
|
44
|
+
* Bump these (and re-validate) when adopting a new sitevision-scripts major.
|
|
45
|
+
*/
|
|
46
|
+
export declare const SUPPORTED_SITEVISION_SCRIPTS_MIN = "8.0.0";
|
|
47
|
+
/** Human-readable supported range, e.g. ">=8.0.0 <9.0.0". */
|
|
48
|
+
export declare const SUPPORTED_SITEVISION_SCRIPTS_RANGE = ">=8.0.0 <9.0.0";
|
|
49
|
+
export type SitevisionScriptsCompatStatus = 'ok' | 'too-old' | 'too-new' | 'not-installed' | 'unknown';
|
|
50
|
+
export interface SitevisionScriptsCompat {
|
|
51
|
+
installed: string | null;
|
|
52
|
+
supportedRange: string;
|
|
53
|
+
status: SitevisionScriptsCompatStatus;
|
|
54
|
+
/** Populated for 'too-old'/'too-new' — a ready-to-display warning. */
|
|
55
|
+
warning?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Read the installed sitevision-scripts version from the project, or null if it
|
|
59
|
+
* is not installed / unreadable.
|
|
60
|
+
*/
|
|
61
|
+
export declare function getSitevisionScriptsVersion(projectRoot: string): string | null;
|
|
62
|
+
/**
|
|
63
|
+
* Check the project's installed sitevision-scripts against the supported range.
|
|
64
|
+
* Use the `warning` field to surface a message when the version has drifted.
|
|
65
|
+
*/
|
|
66
|
+
export declare function checkSitevisionScriptsCompatibility(projectRoot: string): SitevisionScriptsCompat;
|
|
67
|
+
export interface SitevisionBuildResult {
|
|
68
|
+
success: boolean;
|
|
69
|
+
/** Combined stdout/stderr (tail-trimmed) for error reporting. */
|
|
70
|
+
output: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run `sitevision-scripts build` (build + zip + cleanup) as a subprocess.
|
|
75
|
+
*
|
|
76
|
+
* The package's own webpack pipeline produces the deployable `dist/<appId>.zip`.
|
|
77
|
+
* Invoked via the current Node binary so it works cross-platform without relying
|
|
78
|
+
* on the `node_modules/.bin` shims or shell PATH resolution.
|
|
79
|
+
*
|
|
80
|
+
* @param projectRoot - Project root directory (used as cwd)
|
|
81
|
+
* @param onOutput - Optional callback for streaming output chunks
|
|
82
|
+
*/
|
|
83
|
+
export declare function runSitevisionScriptsBuild(projectRoot: string, onOutput?: (chunk: string) => void): Promise<SitevisionBuildResult>;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitevision Scripts Runner
|
|
3
|
+
*
|
|
4
|
+
* Delegates the compile/build step to the official sitevision-scripts npm
|
|
5
|
+
* package when a project has no local webpack config of its own.
|
|
6
|
+
*
|
|
7
|
+
* Sitevision WebApp builds are tightly coupled to the platform runtime (a
|
|
8
|
+
* dual server/client multi-compiler, AMD externals for React and the sitevision
|
|
9
|
+
* api packages, an embedded ES5 server engine, and a precise addon zip layout).
|
|
10
|
+
* Rather than
|
|
11
|
+
* reproduce that contract — which lives in proprietary babel presets and an
|
|
12
|
+
* undocumented internal config — we shell out to the package's public CLI, which
|
|
13
|
+
* is the canonical, maintained source of that build pipeline.
|
|
14
|
+
*
|
|
15
|
+
* `sitevision-scripts build` runs build + zip + cleanup and writes the archive to
|
|
16
|
+
* `dist/<appId>.zip` — the exact path the CLI's own sign/deploy steps already use.
|
|
17
|
+
*/
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import { spawn } from 'child_process';
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the path to the sitevision-scripts CLI entry inside a project.
|
|
23
|
+
* Returns null if the package is not installed.
|
|
24
|
+
*/
|
|
25
|
+
export function getSitevisionScriptsBin(projectRoot) {
|
|
26
|
+
const bin = path.join(projectRoot, 'node_modules', '@sitevision', 'sitevision-scripts', 'bin', 'sitevision-scripts.js');
|
|
27
|
+
return fs.existsSync(bin) ? bin : null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether the sitevision-scripts package is available in the project.
|
|
31
|
+
*/
|
|
32
|
+
export function hasSitevisionScripts(projectRoot) {
|
|
33
|
+
return getSitevisionScriptsBin(projectRoot) !== null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Path of the zip that `sitevision-scripts build` writes.
|
|
37
|
+
*
|
|
38
|
+
* IMPORTANT: this mirrors sitevision-scripts' own app-id convention
|
|
39
|
+
* (`APP_ID_PREFIX`/`APP_ID_SUFFIX` env vars + `dist/<appId>.zip`), which differs
|
|
40
|
+
* from the CLI's own `getZipPath` env vars (`SITEVISION_APP_ID_*`). For delegated
|
|
41
|
+
* builds the package is the one writing the file, so its convention is the source
|
|
42
|
+
* of truth — using `getZipPath` here would look for the wrong filename whenever a
|
|
43
|
+
* prefix/suffix is configured.
|
|
44
|
+
*/
|
|
45
|
+
export function getDelegatedZipPath(projectRoot, manifestId) {
|
|
46
|
+
const prefix = process.env['APP_ID_PREFIX'] ?? '';
|
|
47
|
+
const suffix = process.env['APP_ID_SUFFIX'] ?? '';
|
|
48
|
+
const appId = `${prefix}${manifestId}${suffix}`;
|
|
49
|
+
return path.join(projectRoot, 'dist', `${appId}.zip`);
|
|
50
|
+
}
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// VERSION COMPATIBILITY
|
|
53
|
+
// =============================================================================
|
|
54
|
+
/**
|
|
55
|
+
* Range of the sitevision-scripts package the CLI's build delegation has been
|
|
56
|
+
* validated against. The delegation depends on the package's CLI commands, its
|
|
57
|
+
* `dist/<appId>.zip` output, and the app-id convention — all stable within a
|
|
58
|
+
* major. A new major may change that contract, so we warn rather than assume.
|
|
59
|
+
*
|
|
60
|
+
* Bump these (and re-validate) when adopting a new sitevision-scripts major.
|
|
61
|
+
*/
|
|
62
|
+
export const SUPPORTED_SITEVISION_SCRIPTS_MIN = '8.0.0';
|
|
63
|
+
const SUPPORTED_SITEVISION_SCRIPTS_MAX_EXCLUSIVE_MAJOR = 9;
|
|
64
|
+
/** Human-readable supported range, e.g. ">=8.0.0 <9.0.0". */
|
|
65
|
+
export const SUPPORTED_SITEVISION_SCRIPTS_RANGE = `>=${SUPPORTED_SITEVISION_SCRIPTS_MIN} <${SUPPORTED_SITEVISION_SCRIPTS_MAX_EXCLUSIVE_MAJOR}.0.0`;
|
|
66
|
+
/**
|
|
67
|
+
* Read the installed sitevision-scripts version from the project, or null if it
|
|
68
|
+
* is not installed / unreadable.
|
|
69
|
+
*/
|
|
70
|
+
export function getSitevisionScriptsVersion(projectRoot) {
|
|
71
|
+
const packageJsonPath = path.join(projectRoot, 'node_modules', '@sitevision', 'sitevision-scripts', 'package.json');
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
74
|
+
return parsed.version ?? null;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse a semver string into [major, minor, patch], ignoring any prerelease
|
|
82
|
+
* suffix. Returns null if it does not look like a version.
|
|
83
|
+
*/
|
|
84
|
+
function parseVersion(version) {
|
|
85
|
+
const match = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/.exec(version);
|
|
86
|
+
if (!match?.groups) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return [
|
|
90
|
+
Number(match.groups['major']),
|
|
91
|
+
Number(match.groups['minor']),
|
|
92
|
+
Number(match.groups['patch']),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Compare two parsed versions: negative if a < b, 0 if equal, positive if a > b.
|
|
97
|
+
*/
|
|
98
|
+
function compareVersions(a, b) {
|
|
99
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Check the project's installed sitevision-scripts against the supported range.
|
|
103
|
+
* Use the `warning` field to surface a message when the version has drifted.
|
|
104
|
+
*/
|
|
105
|
+
export function checkSitevisionScriptsCompatibility(projectRoot) {
|
|
106
|
+
const installed = getSitevisionScriptsVersion(projectRoot);
|
|
107
|
+
const supportedRange = SUPPORTED_SITEVISION_SCRIPTS_RANGE;
|
|
108
|
+
if (!installed) {
|
|
109
|
+
return {
|
|
110
|
+
installed: null,
|
|
111
|
+
supportedRange,
|
|
112
|
+
status: hasSitevisionScripts(projectRoot) ? 'unknown' : 'not-installed',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const parsed = parseVersion(installed);
|
|
116
|
+
if (!parsed) {
|
|
117
|
+
return { installed, supportedRange, status: 'unknown' };
|
|
118
|
+
}
|
|
119
|
+
if (compareVersions(parsed, parseVersion(SUPPORTED_SITEVISION_SCRIPTS_MIN)) < 0) {
|
|
120
|
+
return {
|
|
121
|
+
installed,
|
|
122
|
+
supportedRange,
|
|
123
|
+
status: 'too-old',
|
|
124
|
+
warning: `@sitevision/sitevision-scripts ${installed} is older than the supported range (${supportedRange}). Update it in your project: npm install @sitevision/sitevision-scripts@latest`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (parsed[0] >= SUPPORTED_SITEVISION_SCRIPTS_MAX_EXCLUSIVE_MAJOR) {
|
|
128
|
+
return {
|
|
129
|
+
installed,
|
|
130
|
+
supportedRange,
|
|
131
|
+
status: 'too-new',
|
|
132
|
+
warning: `@sitevision/sitevision-scripts ${installed} is newer than the range this CLI was validated against (${supportedRange}). The build may still work; update sitevision-cli if you hit problems.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { installed, supportedRange, status: 'ok' };
|
|
136
|
+
}
|
|
137
|
+
/** Keep at most this many trailing characters of build output in memory. */
|
|
138
|
+
const MAX_OUTPUT_CHARS = 50_000;
|
|
139
|
+
/**
|
|
140
|
+
* Run `sitevision-scripts build` (build + zip + cleanup) as a subprocess.
|
|
141
|
+
*
|
|
142
|
+
* The package's own webpack pipeline produces the deployable `dist/<appId>.zip`.
|
|
143
|
+
* Invoked via the current Node binary so it works cross-platform without relying
|
|
144
|
+
* on the `node_modules/.bin` shims or shell PATH resolution.
|
|
145
|
+
*
|
|
146
|
+
* @param projectRoot - Project root directory (used as cwd)
|
|
147
|
+
* @param onOutput - Optional callback for streaming output chunks
|
|
148
|
+
*/
|
|
149
|
+
export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
|
|
150
|
+
const bin = getSitevisionScriptsBin(projectRoot);
|
|
151
|
+
if (!bin) {
|
|
152
|
+
return {
|
|
153
|
+
success: false,
|
|
154
|
+
output: '',
|
|
155
|
+
error: '@sitevision/sitevision-scripts not found in project. Run npm install.',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return new Promise(resolve => {
|
|
159
|
+
let output = '';
|
|
160
|
+
const child = spawn(process.execPath, [bin, 'build'], {
|
|
161
|
+
cwd: projectRoot,
|
|
162
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
+
});
|
|
164
|
+
const handleData = (data) => {
|
|
165
|
+
const text = data.toString();
|
|
166
|
+
output += text;
|
|
167
|
+
if (output.length > MAX_OUTPUT_CHARS) {
|
|
168
|
+
output = output.slice(-MAX_OUTPUT_CHARS);
|
|
169
|
+
}
|
|
170
|
+
onOutput?.(text);
|
|
171
|
+
};
|
|
172
|
+
child.stdout?.on('data', handleData);
|
|
173
|
+
child.stderr?.on('data', handleData);
|
|
174
|
+
child.on('error', error => {
|
|
175
|
+
resolve({ success: false, output, error: error.message });
|
|
176
|
+
});
|
|
177
|
+
child.on('close', code => {
|
|
178
|
+
resolve({
|
|
179
|
+
success: code === 0,
|
|
180
|
+
output,
|
|
181
|
+
error: code === 0
|
|
182
|
+
? undefined
|
|
183
|
+
: `sitevision-scripts build exited with code ${code}`,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
* Dynamically loads webpack from the target project's node_modules.
|
|
6
6
|
*/
|
|
7
7
|
import type { BuildOptions, BuildResult } from '../types/index.js';
|
|
8
|
+
/**
|
|
9
|
+
* Find the project's own webpack config, or null if it has none.
|
|
10
|
+
*/
|
|
11
|
+
export declare function findLocalWebpackConfig(projectRoot: string): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the project ships its own webpack config (in-house build path),
|
|
14
|
+
* as opposed to relying on the sitevision-scripts package.
|
|
15
|
+
*/
|
|
16
|
+
export declare function hasLocalWebpackConfig(projectRoot: string): boolean;
|
|
8
17
|
export declare class WebpackRunner {
|
|
9
18
|
private webpack;
|
|
10
19
|
private config;
|
|
@@ -9,6 +9,32 @@ import fs from 'fs';
|
|
|
9
9
|
import { createRequire } from 'module';
|
|
10
10
|
import { copyChunksToResources } from './zip.js';
|
|
11
11
|
// =============================================================================
|
|
12
|
+
// LOCAL CONFIG DETECTION
|
|
13
|
+
// =============================================================================
|
|
14
|
+
/**
|
|
15
|
+
* Standard locations for a project-local webpack config, highest priority first.
|
|
16
|
+
*/
|
|
17
|
+
function localWebpackConfigPaths(projectRoot) {
|
|
18
|
+
return [
|
|
19
|
+
path.join(projectRoot, 'webpack.config.js'),
|
|
20
|
+
path.join(projectRoot, 'webpack.config.mjs'),
|
|
21
|
+
path.join(projectRoot, 'config', 'webpack', 'webpack.config.js'),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Find the project's own webpack config, or null if it has none.
|
|
26
|
+
*/
|
|
27
|
+
export function findLocalWebpackConfig(projectRoot) {
|
|
28
|
+
return (localWebpackConfigPaths(projectRoot).find(p => fs.existsSync(p)) ?? null);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether the project ships its own webpack config (in-house build path),
|
|
32
|
+
* as opposed to relying on the sitevision-scripts package.
|
|
33
|
+
*/
|
|
34
|
+
export function hasLocalWebpackConfig(projectRoot) {
|
|
35
|
+
return findLocalWebpackConfig(projectRoot) !== null;
|
|
36
|
+
}
|
|
37
|
+
// =============================================================================
|
|
12
38
|
// WEBPACK RUNNER CLASS
|
|
13
39
|
// =============================================================================
|
|
14
40
|
export class WebpackRunner {
|
|
@@ -49,21 +75,10 @@ export class WebpackRunner {
|
|
|
49
75
|
* Load webpack configuration from the project
|
|
50
76
|
*/
|
|
51
77
|
async loadConfig() {
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
path.join(this.projectRoot, 'webpack.config.mjs'),
|
|
57
|
-
path.join(this.projectRoot, 'config', 'webpack', 'webpack.config.js'),
|
|
58
|
-
path.join(this.projectRoot, 'node_modules', '@sitevision', 'sitevision-scripts', 'config', 'webpack', 'webpack.config.js'),
|
|
59
|
-
];
|
|
60
|
-
let configPath = null;
|
|
61
|
-
for (const p of configPaths) {
|
|
62
|
-
if (fs.existsSync(p)) {
|
|
63
|
-
configPath = p;
|
|
64
|
-
break;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
78
|
+
// Only project-local webpack configs are consumed in-process. Projects
|
|
79
|
+
// without one are built by delegating to @sitevision/sitevision-scripts
|
|
80
|
+
// (see sitevision-scripts-runner), so there is no config fallback here.
|
|
81
|
+
const configPath = findLocalWebpackConfig(this.projectRoot);
|
|
67
82
|
if (!configPath) {
|
|
68
83
|
throw new Error('webpack.config.js not found. Make sure your project has a webpack configuration.');
|
|
69
84
|
}
|
package/dist/utils/zip.d.ts
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* Also handles webpack chunk organization.
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
|
-
* Create a zip archive of a directory
|
|
8
|
+
* Create a zip archive of a directory.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* In-house, dependency-free implementation: walks the directory, deflates each
|
|
11
|
+
* file with Node's built-in zlib, and assembles a standard ZIP container (local
|
|
12
|
+
* file headers + central directory + end-of-central-directory record). This
|
|
13
|
+
* removes the previous reliance on the external `zip`/`tar`/PowerShell binaries
|
|
14
|
+
* and behaves identically across macOS, Linux, and Windows.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors `zip -r <out> .` run from inside `sourceDir`: archive paths are
|
|
17
|
+
* relative to `sourceDir`, use forward slashes, and directory entries are
|
|
18
|
+
* emitted so empty directories are preserved.
|
|
12
19
|
*
|
|
13
20
|
* @param sourceDir - Directory to zip
|
|
14
21
|
* @param outputPath - Path for the output zip file
|
package/dist/utils/zip.js
CHANGED
|
@@ -6,16 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
|
-
import
|
|
9
|
+
import zlib from 'zlib';
|
|
10
10
|
import { ensureDistDir } from './project-detection.js';
|
|
11
11
|
// =============================================================================
|
|
12
12
|
// ZIP CREATION
|
|
13
13
|
// =============================================================================
|
|
14
14
|
/**
|
|
15
|
-
* Create a zip archive of a directory
|
|
15
|
+
* Create a zip archive of a directory.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* In-house, dependency-free implementation: walks the directory, deflates each
|
|
18
|
+
* file with Node's built-in zlib, and assembles a standard ZIP container (local
|
|
19
|
+
* file headers + central directory + end-of-central-directory record). This
|
|
20
|
+
* removes the previous reliance on the external `zip`/`tar`/PowerShell binaries
|
|
21
|
+
* and behaves identically across macOS, Linux, and Windows.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors `zip -r <out> .` run from inside `sourceDir`: archive paths are
|
|
24
|
+
* relative to `sourceDir`, use forward slashes, and directory entries are
|
|
25
|
+
* emitted so empty directories are preserved.
|
|
19
26
|
*
|
|
20
27
|
* @param sourceDir - Directory to zip
|
|
21
28
|
* @param outputPath - Path for the output zip file
|
|
@@ -31,77 +38,182 @@ export async function createZip(sourceDir, outputPath) {
|
|
|
31
38
|
if (fs.existsSync(outputPath)) {
|
|
32
39
|
fs.unlinkSync(outputPath);
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
const entries = collectZipEntries(sourceDir);
|
|
42
|
+
const buffer = await buildZipBuffer(entries);
|
|
43
|
+
fs.writeFileSync(outputPath, buffer);
|
|
44
|
+
return outputPath;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Recursively collect file and directory entries for the archive.
|
|
48
|
+
* Directories are emitted before their contents, matching `zip -r`.
|
|
49
|
+
*/
|
|
50
|
+
function collectZipEntries(sourceDir) {
|
|
51
|
+
const entries = [];
|
|
52
|
+
const walk = (dir, prefix) => {
|
|
53
|
+
const dirEntries = fs.readdirSync(dir, { withFileTypes: true });
|
|
54
|
+
for (const entry of dirEntries) {
|
|
55
|
+
const absolutePath = path.join(dir, entry.name);
|
|
56
|
+
const archiveName = prefix + entry.name;
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
const stat = fs.statSync(absolutePath);
|
|
59
|
+
entries.push({
|
|
60
|
+
name: archiveName + '/',
|
|
61
|
+
isDirectory: true,
|
|
62
|
+
mtime: stat.mtime,
|
|
63
|
+
});
|
|
64
|
+
walk(absolutePath, archiveName + '/');
|
|
57
65
|
}
|
|
58
|
-
else {
|
|
59
|
-
|
|
66
|
+
else if (entry.isFile()) {
|
|
67
|
+
const stat = fs.statSync(absolutePath);
|
|
68
|
+
entries.push({
|
|
69
|
+
name: archiveName,
|
|
70
|
+
isDirectory: false,
|
|
71
|
+
absolutePath,
|
|
72
|
+
mtime: stat.mtime,
|
|
73
|
+
});
|
|
60
74
|
}
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
// Symlinks and special files are skipped (matches prior `zip` defaults
|
|
76
|
+
// closely enough for Sitevision build output, which has neither).
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
walk(sourceDir, '');
|
|
80
|
+
return entries;
|
|
63
81
|
}
|
|
64
82
|
/**
|
|
65
|
-
*
|
|
66
|
-
* This is a fallback for systems without the zip command.
|
|
83
|
+
* Assemble the full ZIP byte buffer from collected entries.
|
|
67
84
|
*/
|
|
68
|
-
async function
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
async function buildZipBuffer(entries) {
|
|
86
|
+
const localChunks = [];
|
|
87
|
+
const centralChunks = [];
|
|
88
|
+
let offset = 0;
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const nameBuffer = Buffer.from(entry.name, 'utf8');
|
|
91
|
+
const { dosTime, dosDate } = toDosDateTime(entry.mtime);
|
|
92
|
+
let rawData;
|
|
93
|
+
let compressed;
|
|
94
|
+
let method;
|
|
95
|
+
if (entry.isDirectory) {
|
|
96
|
+
rawData = Buffer.alloc(0);
|
|
97
|
+
compressed = Buffer.alloc(0);
|
|
98
|
+
method = 0; // stored
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
rawData = fs.readFileSync(entry.absolutePath);
|
|
102
|
+
if (rawData.length === 0) {
|
|
103
|
+
compressed = Buffer.alloc(0);
|
|
104
|
+
method = 0; // stored (deflating empty data is wasteful)
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
compressed = await deflateRaw(rawData);
|
|
108
|
+
method = 8; // deflate
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const crc = crc32(rawData);
|
|
112
|
+
const localHeaderOffset = offset;
|
|
113
|
+
// Local file header (signature 0x04034b50)
|
|
114
|
+
const localHeader = Buffer.alloc(30);
|
|
115
|
+
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
116
|
+
localHeader.writeUInt16LE(20, 4); // version needed to extract
|
|
117
|
+
localHeader.writeUInt16LE(0, 6); // general purpose flag
|
|
118
|
+
localHeader.writeUInt16LE(method, 8);
|
|
119
|
+
localHeader.writeUInt16LE(dosTime, 10);
|
|
120
|
+
localHeader.writeUInt16LE(dosDate, 12);
|
|
121
|
+
localHeader.writeUInt32LE(crc, 14);
|
|
122
|
+
localHeader.writeUInt32LE(compressed.length, 18);
|
|
123
|
+
localHeader.writeUInt32LE(rawData.length, 22);
|
|
124
|
+
localHeader.writeUInt16LE(nameBuffer.length, 26);
|
|
125
|
+
localHeader.writeUInt16LE(0, 28); // extra field length
|
|
126
|
+
localChunks.push(localHeader, nameBuffer, compressed);
|
|
127
|
+
offset += localHeader.length + nameBuffer.length + compressed.length;
|
|
128
|
+
// Central directory header (signature 0x02014b50)
|
|
129
|
+
const centralHeader = Buffer.alloc(46);
|
|
130
|
+
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
131
|
+
centralHeader.writeUInt16LE(20, 4); // version made by
|
|
132
|
+
centralHeader.writeUInt16LE(20, 6); // version needed
|
|
133
|
+
centralHeader.writeUInt16LE(0, 8); // general purpose flag
|
|
134
|
+
centralHeader.writeUInt16LE(method, 10);
|
|
135
|
+
centralHeader.writeUInt16LE(dosTime, 12);
|
|
136
|
+
centralHeader.writeUInt16LE(dosDate, 14);
|
|
137
|
+
centralHeader.writeUInt32LE(crc, 16);
|
|
138
|
+
centralHeader.writeUInt32LE(compressed.length, 20);
|
|
139
|
+
centralHeader.writeUInt32LE(rawData.length, 24);
|
|
140
|
+
centralHeader.writeUInt16LE(nameBuffer.length, 28);
|
|
141
|
+
centralHeader.writeUInt16LE(0, 30); // extra field length
|
|
142
|
+
centralHeader.writeUInt16LE(0, 32); // comment length
|
|
143
|
+
centralHeader.writeUInt16LE(0, 34); // disk number start
|
|
144
|
+
centralHeader.writeUInt16LE(0, 36); // internal attributes
|
|
145
|
+
// External attributes: directory vs file unix-ish mode in high bytes.
|
|
146
|
+
centralHeader.writeUInt32LE(entry.isDirectory ? 0x41ed0010 : 0x81a40000, 38);
|
|
147
|
+
centralHeader.writeUInt32LE(localHeaderOffset, 42);
|
|
148
|
+
centralChunks.push(centralHeader, nameBuffer);
|
|
73
149
|
}
|
|
74
|
-
|
|
75
|
-
|
|
150
|
+
const centralDirectory = Buffer.concat(centralChunks);
|
|
151
|
+
const centralDirectoryOffset = offset;
|
|
152
|
+
// End of central directory record (signature 0x06054b50)
|
|
153
|
+
const eocd = Buffer.alloc(22);
|
|
154
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
155
|
+
eocd.writeUInt16LE(0, 4); // disk number
|
|
156
|
+
eocd.writeUInt16LE(0, 6); // disk with central directory
|
|
157
|
+
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
|
|
158
|
+
eocd.writeUInt16LE(entries.length, 10); // total entries
|
|
159
|
+
eocd.writeUInt32LE(centralDirectory.length, 12);
|
|
160
|
+
eocd.writeUInt32LE(centralDirectoryOffset, 16);
|
|
161
|
+
eocd.writeUInt16LE(0, 20); // comment length
|
|
162
|
+
return Buffer.concat([...localChunks, centralDirectory, eocd]);
|
|
76
163
|
}
|
|
77
164
|
/**
|
|
78
|
-
*
|
|
165
|
+
* Deflate (raw, no zlib header) a buffer.
|
|
79
166
|
*/
|
|
80
|
-
async function
|
|
167
|
+
async function deflateRaw(data) {
|
|
81
168
|
return new Promise((resolve, reject) => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const psProcess = spawn('powershell', ['-Command', command], {
|
|
86
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
-
});
|
|
88
|
-
let stderr = '';
|
|
89
|
-
psProcess.stderr?.on('data', (data) => {
|
|
90
|
-
stderr += data.toString();
|
|
91
|
-
});
|
|
92
|
-
psProcess.on('error', error => {
|
|
93
|
-
reject(new Error(`PowerShell error: ${error.message}`));
|
|
94
|
-
});
|
|
95
|
-
psProcess.on('close', code => {
|
|
96
|
-
if (code === 0) {
|
|
97
|
-
resolve(absoluteOutputPath);
|
|
169
|
+
zlib.deflateRaw(data, (error, result) => {
|
|
170
|
+
if (error) {
|
|
171
|
+
reject(error);
|
|
98
172
|
}
|
|
99
173
|
else {
|
|
100
|
-
|
|
174
|
+
resolve(result);
|
|
101
175
|
}
|
|
102
176
|
});
|
|
103
177
|
});
|
|
104
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Convert a Date to DOS date/time fields used by the ZIP format.
|
|
181
|
+
* ZIP timestamps only span 1980–2107 with 2-second resolution.
|
|
182
|
+
*/
|
|
183
|
+
function toDosDateTime(date) {
|
|
184
|
+
const year = date.getFullYear();
|
|
185
|
+
if (year < 1980) {
|
|
186
|
+
// Clamp to the ZIP epoch (1980-01-01 00:00:00).
|
|
187
|
+
return { dosTime: 0, dosDate: (1 << 5) | 1 };
|
|
188
|
+
}
|
|
189
|
+
const dosTime = (date.getHours() << 11) |
|
|
190
|
+
(date.getMinutes() << 5) |
|
|
191
|
+
Math.floor(date.getSeconds() / 2);
|
|
192
|
+
const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
|
193
|
+
return { dosTime, dosDate };
|
|
194
|
+
}
|
|
195
|
+
// CRC-32 table (IEEE polynomial 0xEDB88320), built once and reused.
|
|
196
|
+
const crc32Table = (() => {
|
|
197
|
+
const table = new Uint32Array(256);
|
|
198
|
+
for (let n = 0; n < 256; n++) {
|
|
199
|
+
let c = n;
|
|
200
|
+
for (let k = 0; k < 8; k++) {
|
|
201
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
202
|
+
}
|
|
203
|
+
table[n] = c >>> 0;
|
|
204
|
+
}
|
|
205
|
+
return table;
|
|
206
|
+
})();
|
|
207
|
+
/**
|
|
208
|
+
* Compute the CRC-32 checksum of a buffer.
|
|
209
|
+
*/
|
|
210
|
+
function crc32(data) {
|
|
211
|
+
let crc = 0xffffffff;
|
|
212
|
+
for (const byte of data) {
|
|
213
|
+
crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
214
|
+
}
|
|
215
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
216
|
+
}
|
|
105
217
|
/**
|
|
106
218
|
* Create a zip of the build directory for deployment
|
|
107
219
|
*
|