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.
@@ -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 { copyStaticToBuild, copySrcToBuild, cleanBuild, formatFileSize, createBuildZip, getZipSize, } from '../utils/zip.js';
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
- // Check if webpack is available
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 = {
@@ -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 handleBuildComplete = React.useCallback(async (result) => {
30
- if (!result.success) {
31
- setState(prev => ({
32
- ...prev,
33
- status: 'error',
34
- message: result.errors?.join('\n') || 'Build failed',
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: result.stats?.time,
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
- // Check if webpack is available
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
- }, [projectRoot, manifest, handleBuildComplete]);
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
- try {
124
- const response = await makeRequest(url, {
125
- method: 'POST',
126
- headers: {
127
- 'Content-Type': contentType,
128
- 'Content-Length': String(body.length),
129
- },
130
- body,
131
- auth: {
132
- username: credentials.username,
133
- password: credentials.password,
134
- },
135
- });
136
- if (response.statusCode === 200) {
137
- // Write signed zip to output path
138
- const outputDir = path.dirname(outputPath);
139
- if (!fs.existsSync(outputDir)) {
140
- fs.mkdirSync(outputDir, { recursive: true });
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
- if (response.statusCode === 401) {
149
- return {
150
- success: false,
151
- error: 'Unauthorized. Check username and password.',
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.toString()}`,
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.toString()}`,
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.toString()}`,
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
- // Try to find webpack config in standard locations
53
- // Project-specific configs take priority, fall back to @sitevision/sitevision-scripts
54
- const configPaths = [
55
- path.join(this.projectRoot, 'webpack.config.js'),
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
  }
@@ -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
- * Uses the system `zip` command for cross-platform compatibility.
11
- * Falls back to a basic implementation if zip is not available.
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 { spawn } from 'child_process';
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
- * Uses the system `zip` command for cross-platform compatibility.
18
- * Falls back to a basic implementation if zip is not available.
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
- return new Promise((resolve, reject) => {
35
- // Try using system zip command (works on macOS, Linux, and Windows with Git Bash)
36
- const zipProcess = spawn('zip', ['-r', outputPath, '.'], {
37
- cwd: sourceDir,
38
- stdio: ['ignore', 'pipe', 'pipe'],
39
- });
40
- let stderr = '';
41
- zipProcess.stderr?.on('data', (data) => {
42
- stderr += data.toString();
43
- });
44
- zipProcess.on('error', error => {
45
- // If zip command not found, try alternative methods
46
- if (error.code === 'ENOENT') {
47
- // Fall back to tar on systems without zip
48
- createZipWithTar(sourceDir, outputPath).then(resolve).catch(reject);
49
- }
50
- else {
51
- reject(new Error(`Zip process error: ${error.message}`));
52
- }
53
- });
54
- zipProcess.on('close', code => {
55
- if (code === 0) {
56
- resolve(outputPath);
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
- reject(new Error(`Zip failed with code ${code}: ${stderr}`));
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
- * Fallback: Create zip using tar (converts to zip format)
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 createZipWithTar(sourceDir, outputPath) {
69
- // On Windows without zip, we might need to use PowerShell
70
- const isWindows = process.platform === 'win32';
71
- if (isWindows) {
72
- return createZipWithPowerShell(sourceDir, outputPath);
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
- // On Unix without zip, this is unlikely but we'll throw an error
75
- throw new Error('zip command not found. Please install zip: apt-get install zip (Linux) or brew install zip (macOS)');
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
- * Create zip using PowerShell on Windows
165
+ * Deflate (raw, no zlib header) a buffer.
79
166
  */
80
- async function createZipWithPowerShell(sourceDir, outputPath) {
167
+ async function deflateRaw(data) {
81
168
  return new Promise((resolve, reject) => {
82
- const absoluteSourceDir = path.resolve(sourceDir);
83
- const absoluteOutputPath = path.resolve(outputPath);
84
- const command = `Compress-Archive -Path "${absoluteSourceDir}\\*" -DestinationPath "${absoluteOutputPath}" -Force`;
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
- reject(new Error(`PowerShell zip failed with code ${code}: ${stderr}`));
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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.5.0-beta.0",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"