sitevision-cli 1.0.0-beta.8 → 1.0.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/CHANGELOG.md +138 -0
- package/LICENSE +21 -0
- package/dist/cli.js +194 -70
- package/dist/commands/TaskScreen.js +32 -0
- package/dist/commands/build.js +6 -185
- package/dist/commands/deploy.js +95 -212
- package/dist/commands/dev.js +87 -416
- package/dist/commands/info.js +1 -1
- package/dist/commands/setup-signing.js +50 -33
- package/dist/commands/sign.js +9 -86
- package/dist/commands/use-finish.js +17 -0
- package/dist/commands/watch.js +5 -31
- package/dist/components/AnimatedLogo.js +8 -2
- package/dist/components/AuthLoginScreen.js +33 -15
- package/dist/components/PasswordInput.js +2 -1
- package/dist/scaffold/inquirer-stub.js +119 -0
- package/dist/scaffold/register.js +15 -0
- package/dist/shell/AddonPicker.js +54 -0
- package/dist/shell/Changelog.js +44 -0
- package/dist/shell/CommandPalette.js +63 -0
- package/dist/shell/ConfigForm.js +703 -0
- package/dist/shell/Frame.js +138 -0
- package/dist/shell/Help.js +96 -0
- package/dist/shell/Popover.js +15 -0
- package/dist/shell/Settings.js +149 -0
- package/dist/shell/Shell.js +953 -0
- package/dist/shell/Tabs.js +96 -0
- package/dist/shell/actions.js +493 -0
- package/dist/utils/config.js +62 -2
- package/dist/utils/create-app.js +231 -0
- package/dist/utils/debug.js +32 -0
- package/dist/utils/environments.js +74 -0
- package/dist/utils/i18n.js +369 -0
- package/dist/utils/jsonc.js +2 -1
- package/dist/utils/keychain.js +54 -6
- package/dist/utils/oauth2-auth.js +146 -43
- package/dist/utils/password-prompt.js +37 -3
- package/dist/utils/process-runner.js +77 -38
- package/dist/utils/project-detection.js +457 -112
- package/dist/utils/session-cookie-auth.js +10 -6
- package/dist/utils/sitevision-api.js +162 -32
- package/dist/utils/sitevision-scripts-runner.js +50 -22
- package/dist/utils/tasks.js +510 -0
- package/dist/utils/version-check.js +3 -3
- package/dist/utils/webpack-runner.js +9 -5
- package/dist/utils/workspace.js +105 -0
- package/dist/utils/zip.js +18 -19
- package/package.json +27 -4
- package/readme.md +113 -141
- package/dist/app.d.ts +0 -6
- package/dist/app.js +0 -307
- package/dist/cli.d.ts +0 -2
- package/dist/commands/build.d.ts +0 -12
- package/dist/commands/deploy.d.ts +0 -16
- package/dist/commands/dev.d.ts +0 -16
- package/dist/commands/index.d.ts +0 -4
- package/dist/commands/info.d.ts +0 -2
- package/dist/commands/setup-signing.d.ts +0 -2
- package/dist/commands/sign.d.ts +0 -14
- package/dist/commands/types.d.ts +0 -18
- package/dist/commands/watch.d.ts +0 -2
- package/dist/components/AnimatedLogo.d.ts +0 -10
- package/dist/components/AuthLoginScreen.d.ts +0 -21
- package/dist/components/DevPropertiesForm.d.ts +0 -10
- package/dist/components/DevPropertiesForm.js +0 -210
- package/dist/components/InfoScreen.d.ts +0 -7
- package/dist/components/InfoScreen.js +0 -14
- package/dist/components/KeychainPasswordChoice.d.ts +0 -8
- package/dist/components/KeychainPasswordChoice.js +0 -30
- package/dist/components/MainMenu.d.ts +0 -7
- package/dist/components/MainMenu.js +0 -83
- package/dist/components/PasswordInput.d.ts +0 -10
- package/dist/components/ProcessOutput.d.ts +0 -6
- package/dist/components/ProcessOutput.js +0 -30
- package/dist/components/SetupFlow.d.ts +0 -8
- package/dist/components/SetupFlow.js +0 -233
- package/dist/components/SigningPropertiesForm.d.ts +0 -7
- package/dist/components/SigningPropertiesForm.js +0 -38
- package/dist/components/StatusIndicator.d.ts +0 -8
- package/dist/components/TextInput.d.ts +0 -10
- package/dist/components/TextInput.js +0 -29
- package/dist/components/WelcomeScreen.d.ts +0 -14
- package/dist/types/index.d.ts +0 -284
- package/dist/utils/branding.d.ts +0 -28
- package/dist/utils/config.d.ts +0 -17
- package/dist/utils/jsonc.d.ts +0 -19
- package/dist/utils/keychain.d.ts +0 -15
- package/dist/utils/oauth2-auth.d.ts +0 -27
- package/dist/utils/password-prompt.d.ts +0 -14
- package/dist/utils/process-runner.d.ts +0 -22
- package/dist/utils/project-detection.d.ts +0 -151
- package/dist/utils/session-cookie-auth.d.ts +0 -34
- package/dist/utils/signing-password.d.ts +0 -12
- package/dist/utils/signing-step.d.ts +0 -15
- package/dist/utils/signing-step.js +0 -16
- package/dist/utils/sitevision-api.d.ts +0 -112
- package/dist/utils/sitevision-scripts-runner.d.ts +0 -83
- package/dist/utils/version-check.d.ts +0 -9
- package/dist/utils/webpack-runner.d.ts +0 -76
- package/dist/utils/zip.d.ts +0 -71
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
5
|
+
import { useSyncExternalStore } from 'react';
|
|
6
|
+
import { WebpackRunner, hasLocalWebpackConfig } from './webpack-runner.js';
|
|
7
|
+
import { hasSitevisionScripts, runSitevisionScriptsBuild, getDelegatedZipPath, checkSitevisionScriptsCompatibility, } from './sitevision-scripts-runner.js';
|
|
8
|
+
import { copyStaticToBuild, copySrcToBuild, cleanBuild, createBuildZip, formatFileSize, getZipSize, zipExists, } from './zip.js';
|
|
9
|
+
import { deleteSessionCookie } from './keychain.js';
|
|
10
|
+
import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, getDeployZipPath, localizedText, } from './project-detection.js';
|
|
11
|
+
import { signApp, deployApp, deployProduction, activateApp, createAddon, listAddons, classifyAddon, } from './sitevision-api.js';
|
|
12
|
+
import { ProcessRunner } from './process-runner.js';
|
|
13
|
+
import { debug } from './debug.js';
|
|
14
|
+
const MAX_LINES = 2000;
|
|
15
|
+
const emitter = new EventEmitter();
|
|
16
|
+
let tasks = [];
|
|
17
|
+
let nextId = 1;
|
|
18
|
+
function notify() {
|
|
19
|
+
tasks = [...tasks];
|
|
20
|
+
emitter.emit('change');
|
|
21
|
+
}
|
|
22
|
+
export function getTasks() {
|
|
23
|
+
return tasks;
|
|
24
|
+
}
|
|
25
|
+
export function useTasks() {
|
|
26
|
+
return useSyncExternalStore(callback => {
|
|
27
|
+
emitter.on('change', callback);
|
|
28
|
+
return () => emitter.off('change', callback);
|
|
29
|
+
}, () => tasks);
|
|
30
|
+
}
|
|
31
|
+
export function runningTasks(appRoot) {
|
|
32
|
+
return tasks.filter(t => t.status === 'running' && (!appRoot || t.appRoot === appRoot));
|
|
33
|
+
}
|
|
34
|
+
/** A scaffold belongs to no listed app yet, so it counts for every one. */
|
|
35
|
+
export function scaffoldRunning() {
|
|
36
|
+
return runningTasks().some(t => t.kind === 'create');
|
|
37
|
+
}
|
|
38
|
+
/** What "stop" means for the selected app: its own tasks and any scaffold. */
|
|
39
|
+
export function stoppableTasks(appRoot) {
|
|
40
|
+
return runningTasks().filter(t => t.kind === 'create' || t.appRoot === appRoot);
|
|
41
|
+
}
|
|
42
|
+
export function clearFinished() {
|
|
43
|
+
tasks = tasks.filter(t => t.status === 'running');
|
|
44
|
+
notify();
|
|
45
|
+
}
|
|
46
|
+
export function createTask(kind, project, label, stop = () => { }) {
|
|
47
|
+
const task = {
|
|
48
|
+
id: nextId++,
|
|
49
|
+
kind,
|
|
50
|
+
appRoot: project.root,
|
|
51
|
+
appName: localizedText(project.manifest.name) || project.manifest.id,
|
|
52
|
+
label,
|
|
53
|
+
status: 'running',
|
|
54
|
+
phase: 'starting',
|
|
55
|
+
startedAt: Date.now(),
|
|
56
|
+
lines: [],
|
|
57
|
+
stop,
|
|
58
|
+
};
|
|
59
|
+
tasks.push(task);
|
|
60
|
+
debug('task', `#${task.id} ${kind} ${label} (${task.appName})`);
|
|
61
|
+
notify();
|
|
62
|
+
const log = (tag, text, level = 'info') => {
|
|
63
|
+
for (const line of logLines(text)) {
|
|
64
|
+
task.lines.push({ time: Date.now(), tag, level, text: line });
|
|
65
|
+
debug('task', `#${task.id} ${tag} ${level === 'info' ? '' : level + ' '}${line}`);
|
|
66
|
+
}
|
|
67
|
+
if (task.lines.length > MAX_LINES) {
|
|
68
|
+
task.lines.splice(0, task.lines.length - MAX_LINES);
|
|
69
|
+
}
|
|
70
|
+
notify();
|
|
71
|
+
};
|
|
72
|
+
const finish = (status, error) => {
|
|
73
|
+
if (task.status !== 'running')
|
|
74
|
+
return;
|
|
75
|
+
task.status = status;
|
|
76
|
+
task.error = error;
|
|
77
|
+
task.endedAt = Date.now();
|
|
78
|
+
task.phase = status;
|
|
79
|
+
debug('task', `#${task.id} ${status} after ${task.endedAt - task.startedAt}ms`);
|
|
80
|
+
if (error)
|
|
81
|
+
log('svc', error, 'error');
|
|
82
|
+
notify();
|
|
83
|
+
};
|
|
84
|
+
return { task, log, finish };
|
|
85
|
+
}
|
|
86
|
+
export function setPhase(task, phase) {
|
|
87
|
+
task.phase = phase;
|
|
88
|
+
debug('task', `#${task.id} phase ${phase}`);
|
|
89
|
+
notify();
|
|
90
|
+
}
|
|
91
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// One-shot steps shared by the tasks below.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
/** Build the app to dist/<appId>.zip. Returns the zip path or throws. */
|
|
96
|
+
async function buildOnce(project, task, log, mode, signal,
|
|
97
|
+
// False (`svc build --no-zip`): leave build/ and no zip.
|
|
98
|
+
zip = true) {
|
|
99
|
+
const { root, manifest } = project;
|
|
100
|
+
cleanBuild(root);
|
|
101
|
+
if (isBundledApp(manifest) && !hasLocalWebpackConfig(root)) {
|
|
102
|
+
if (!hasSitevisionScripts(root)) {
|
|
103
|
+
throw new Error('No webpack.config.js found and @sitevision/sitevision-scripts is not installed. Run npm install.');
|
|
104
|
+
}
|
|
105
|
+
const { warning } = checkSitevisionScriptsCompatibility(root);
|
|
106
|
+
if (warning)
|
|
107
|
+
log('bld', warning, 'warn');
|
|
108
|
+
setPhase(task, 'building');
|
|
109
|
+
log('bld', 'building via sitevision-scripts');
|
|
110
|
+
const result = await runSitevisionScriptsBuild(root, chunk => log('bld', chunk), signal);
|
|
111
|
+
if (!result.success)
|
|
112
|
+
throw new Error(result.error ?? 'Build failed');
|
|
113
|
+
const zipPath = getDelegatedZipPath(root, manifest.id);
|
|
114
|
+
if (!zipExists(zipPath)) {
|
|
115
|
+
throw new Error(`Build reported success but no zip was found at ${zipPath}.`);
|
|
116
|
+
}
|
|
117
|
+
// sitevision-scripts always zips; without a zip means taking it away.
|
|
118
|
+
if (!zip)
|
|
119
|
+
fs.rmSync(zipPath);
|
|
120
|
+
return zip ? zipPath : undefined;
|
|
121
|
+
}
|
|
122
|
+
if (isBundledApp(manifest)) {
|
|
123
|
+
if (!WebpackRunner.isWebpackAvailable(root)) {
|
|
124
|
+
throw new Error('webpack not found. Run npm install.');
|
|
125
|
+
}
|
|
126
|
+
setPhase(task, 'building');
|
|
127
|
+
log('bld', 'compiling with webpack');
|
|
128
|
+
const runner = new WebpackRunner(root, {
|
|
129
|
+
mode,
|
|
130
|
+
cssPrefix: manifest.id,
|
|
131
|
+
restApp: getAppType(manifest) !== 'web' && getAppType(manifest) !== 'widget',
|
|
132
|
+
});
|
|
133
|
+
const result = await runner.run();
|
|
134
|
+
await runner.close();
|
|
135
|
+
reportBuild(result, log);
|
|
136
|
+
if (!result.success)
|
|
137
|
+
throw new Error(result.errors?.join('\n') || 'Build failed');
|
|
138
|
+
copyStaticToBuild(root);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
setPhase(task, 'copying');
|
|
142
|
+
log('bld', 'copying source files');
|
|
143
|
+
copySrcToBuild(root);
|
|
144
|
+
copyStaticToBuild(root);
|
|
145
|
+
}
|
|
146
|
+
if (!zip)
|
|
147
|
+
return undefined;
|
|
148
|
+
setPhase(task, 'zipping');
|
|
149
|
+
return createBuildZip(root, getFullAppId(manifest.id));
|
|
150
|
+
}
|
|
151
|
+
function reportBuild(result, log) {
|
|
152
|
+
for (const warning of result.warnings ?? [])
|
|
153
|
+
log('bld', warning, 'warn');
|
|
154
|
+
for (const error of result.errors ?? [])
|
|
155
|
+
log('bld', error, 'error');
|
|
156
|
+
if (result.success) {
|
|
157
|
+
log('bld', `compiled in ${result.stats?.time ?? 0}ms${result.stats?.assets?.length ? ` · ${result.stats.assets.join(', ')}` : ''}`, 'ok');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function signOnce(project, task, log, zipPath, credentials) {
|
|
161
|
+
setPhase(task, 'signing');
|
|
162
|
+
log('sgn', `signing via developer.sitevision.se${credentials.certificateName ? ` · cert ${credentials.certificateName}` : ''}`);
|
|
163
|
+
const signedPath = getSignedZipPath(project.root, project.manifest);
|
|
164
|
+
const result = await signApp(zipPath, credentials, signedPath);
|
|
165
|
+
if (!result.success)
|
|
166
|
+
throw new Error(result.error ?? 'Signing failed');
|
|
167
|
+
log('sgn', `signed ${path.basename(signedPath)}`, 'ok');
|
|
168
|
+
return signedPath;
|
|
169
|
+
}
|
|
170
|
+
async function deployOnce(project, task, log, zipPath, config, options) {
|
|
171
|
+
setPhase(task, 'deploying');
|
|
172
|
+
const appType = getAppType(project.manifest);
|
|
173
|
+
log('dep', `POST multipart → ${options.production ? 'production' : 'dev'} import · ${config.addonName} · ${path.basename(zipPath)}`);
|
|
174
|
+
const upload = async () => options.production
|
|
175
|
+
? deployProduction(zipPath, { ...config, activate: options.activate }, appType)
|
|
176
|
+
: deployApp(zipPath, config, appType, options.force);
|
|
177
|
+
let result = await upload();
|
|
178
|
+
if (!result.success && result.contextNodeMissing) {
|
|
179
|
+
const addon = classifyAddon(await listAddons(config), config.addonName);
|
|
180
|
+
if (addon === 'unknown') {
|
|
181
|
+
throw new Error(`${result.error}\nCould not list the site's addons either, so the session may have expired. Press l to log in again.`);
|
|
182
|
+
}
|
|
183
|
+
if (addon === 'missing' && options.onAddonMissing) {
|
|
184
|
+
log('dep', `addon ${config.addonName} does not exist`, 'warn');
|
|
185
|
+
if (await options.onAddonMissing(config.addonName)) {
|
|
186
|
+
const created = await createAddon(config, appType);
|
|
187
|
+
if (!created.success) {
|
|
188
|
+
throw new Error(created.error ?? 'Create addon failed');
|
|
189
|
+
}
|
|
190
|
+
log('dep', `created addon ${config.addonName}`, 'ok');
|
|
191
|
+
result = await upload();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// A stale session must not be replayed by the next run. Not when it came
|
|
196
|
+
// from SITEVISION_SESSION_COOKIE: that one would just be read again.
|
|
197
|
+
if (!result.success &&
|
|
198
|
+
result.authExpired &&
|
|
199
|
+
config.sessionCookie &&
|
|
200
|
+
!process.env['SITEVISION_SESSION_COOKIE']) {
|
|
201
|
+
deleteSessionCookie(config.domain, config.username);
|
|
202
|
+
}
|
|
203
|
+
if (!result.success)
|
|
204
|
+
throw new Error(result.error ?? 'Deployment failed');
|
|
205
|
+
if (options.production && options.activate && !result.activated) {
|
|
206
|
+
throw new Error(result.message ?? 'Deployed, but not activated');
|
|
207
|
+
}
|
|
208
|
+
log('dep', `${result.message ?? 'deployed'}${result.executableId ? ` · exec ${result.executableId}` : ''}`, 'ok');
|
|
209
|
+
return result.executableId;
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Tasks
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
export function startBuild(project, { zip = true } = {}) {
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const { task, log, finish } = createTask('build', project, 'build', () => {
|
|
217
|
+
controller.abort();
|
|
218
|
+
finish('stopped');
|
|
219
|
+
});
|
|
220
|
+
void (async () => {
|
|
221
|
+
try {
|
|
222
|
+
const built = await buildOnce(project, task, log, 'production', controller.signal, zip);
|
|
223
|
+
log('bld', built
|
|
224
|
+
? `created ${built} · ${formatFileSize(getZipSize(built))}`
|
|
225
|
+
: 'built, zip skipped', 'ok');
|
|
226
|
+
finish('success');
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
finish('error', errorText(error));
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
return task;
|
|
233
|
+
}
|
|
234
|
+
export function startSign(project, credentials) {
|
|
235
|
+
const { task, log, finish } = createTask('sign', project, 'sign');
|
|
236
|
+
void (async () => {
|
|
237
|
+
try {
|
|
238
|
+
const zipPath = getZipPath(project.root, project.manifest);
|
|
239
|
+
if (!zipExists(zipPath)) {
|
|
240
|
+
throw new Error(`Zip file not found: ${zipPath}. Run build first.`);
|
|
241
|
+
}
|
|
242
|
+
await signOnce(project, task, log, zipPath, credentials);
|
|
243
|
+
finish('success');
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
finish('error', errorText(error));
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
return task;
|
|
250
|
+
}
|
|
251
|
+
export function startDeploy(project, config, options) {
|
|
252
|
+
const { task, log, finish } = createTask('deploy', project, options.production ? 'deploy production' : 'deploy');
|
|
253
|
+
void (async () => {
|
|
254
|
+
try {
|
|
255
|
+
const zipPath = options.production
|
|
256
|
+
? getSignedZipPath(project.root, project.manifest)
|
|
257
|
+
: getDeployZipPath(project.root, project.manifest);
|
|
258
|
+
if (!zipExists(zipPath)) {
|
|
259
|
+
throw new Error(`${options.production ? 'Signed zip' : 'Zip'} not found: ${zipPath}. Run ${options.production ? 'sign' : 'build'} first.`);
|
|
260
|
+
}
|
|
261
|
+
await deployOnce(project, task, log, zipPath, config, options);
|
|
262
|
+
finish('success');
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
finish('error', errorText(error));
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
return task;
|
|
269
|
+
}
|
|
270
|
+
export function startActivate(project, config, executableId, versionLabel) {
|
|
271
|
+
const { task, log, finish } = createTask('activate', project, `activate ${versionLabel}`);
|
|
272
|
+
void (async () => {
|
|
273
|
+
try {
|
|
274
|
+
setPhase(task, 'activating');
|
|
275
|
+
log('act', `PUT activateCustomModuleExecutable · ${versionLabel}`);
|
|
276
|
+
const result = await activateApp(executableId, config, getAppType(project.manifest));
|
|
277
|
+
if (!result.success)
|
|
278
|
+
throw new Error(result.error ?? 'Activation failed');
|
|
279
|
+
log('act', `${versionLabel} is now active`, 'ok');
|
|
280
|
+
finish('success');
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
finish('error', errorText(error));
|
|
284
|
+
}
|
|
285
|
+
})();
|
|
286
|
+
return task;
|
|
287
|
+
}
|
|
288
|
+
export function startInstall(project) {
|
|
289
|
+
const runner = new ProcessRunner('npm', ['install'], project.root);
|
|
290
|
+
const { task, log, finish } = createTask('install', project, 'npm install', () => {
|
|
291
|
+
runner.kill();
|
|
292
|
+
finish('stopped');
|
|
293
|
+
});
|
|
294
|
+
setPhase(task, 'installing');
|
|
295
|
+
runner.on('output', (output) => log('npm', output.data, output.type === 'stderr' ? 'warn' : 'info'));
|
|
296
|
+
runner
|
|
297
|
+
.run()
|
|
298
|
+
.then(result => finish(result.exitCode === 0 ? 'success' : 'error', result.exitCode === 0
|
|
299
|
+
? undefined
|
|
300
|
+
: `npm install exited with ${result.exitCode}`))
|
|
301
|
+
.catch(error => finish('error', errorText(error)));
|
|
302
|
+
return task;
|
|
303
|
+
}
|
|
304
|
+
const WEBPACK_UNSEEN_TARGETS = ['static', 'manifest.json'];
|
|
305
|
+
const WATCH_TARGETS = [
|
|
306
|
+
'src',
|
|
307
|
+
'static',
|
|
308
|
+
'i18n',
|
|
309
|
+
'resource',
|
|
310
|
+
'config',
|
|
311
|
+
'manifest.json',
|
|
312
|
+
];
|
|
313
|
+
/**
|
|
314
|
+
* Dev / watch loop: build on every source change, then optionally sign and
|
|
315
|
+
* deploy. Runs until `task.stop()`; the task stays in the registry meanwhile.
|
|
316
|
+
*/
|
|
317
|
+
export function startDev(project, options) {
|
|
318
|
+
const { root, manifest } = project;
|
|
319
|
+
const watchers = [];
|
|
320
|
+
let webpack = null;
|
|
321
|
+
let debounce;
|
|
322
|
+
const controller = new AbortController();
|
|
323
|
+
const stop = () => {
|
|
324
|
+
controller.abort();
|
|
325
|
+
clearTimeout(debounce);
|
|
326
|
+
for (const watcher of watchers)
|
|
327
|
+
watcher.close();
|
|
328
|
+
void webpack?.close().catch(() => { });
|
|
329
|
+
finish('stopped');
|
|
330
|
+
};
|
|
331
|
+
const { task, log, finish } = createTask(options.deploy ? 'dev' : 'watch', project, options.deploy ? 'dev' : 'watch', stop);
|
|
332
|
+
const afterBuild = async (zipPath) => {
|
|
333
|
+
// Stopped mid-build: never sign or deploy what is left.
|
|
334
|
+
if (controller.signal.aborted)
|
|
335
|
+
return;
|
|
336
|
+
let deployZip = zipPath;
|
|
337
|
+
if (options.signingCredentials) {
|
|
338
|
+
deployZip = await signOnce(project, task, log, zipPath, options.signingCredentials);
|
|
339
|
+
}
|
|
340
|
+
if (options.deploy && options.deployConfig) {
|
|
341
|
+
await deployOnce(project, task, log, deployZip, options.deployConfig, {
|
|
342
|
+
force: true,
|
|
343
|
+
onAddonMissing: options.onAddonMissing,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
setPhase(task, 'watching');
|
|
347
|
+
log('svc', 'watching for changes');
|
|
348
|
+
};
|
|
349
|
+
const fail = (error) => {
|
|
350
|
+
if (controller.signal.aborted)
|
|
351
|
+
return;
|
|
352
|
+
log('svc', errorText(error), 'error');
|
|
353
|
+
setPhase(task, 'error');
|
|
354
|
+
};
|
|
355
|
+
const rebuild = serialized(async () => {
|
|
356
|
+
if (controller.signal.aborted)
|
|
357
|
+
return;
|
|
358
|
+
try {
|
|
359
|
+
const zip = await buildOnce(project, task, log, 'development', controller.signal);
|
|
360
|
+
if (zip)
|
|
361
|
+
await afterBuild(zip);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
fail(error);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
// Size + mtime of every watched file. An event that leaves this unchanged
|
|
368
|
+
// (editor metadata, xattrs, identical re-save) is noise and must not
|
|
369
|
+
// rebuild, or a stray event during sign/deploy loops forever.
|
|
370
|
+
const snapshot = (targets) => {
|
|
371
|
+
const entries = [];
|
|
372
|
+
for (const name of targets) {
|
|
373
|
+
const target = path.join(root, name);
|
|
374
|
+
const stat = fs.statSync(target, { throwIfNoEntry: false });
|
|
375
|
+
if (!stat)
|
|
376
|
+
continue;
|
|
377
|
+
const files = (stat.isDirectory()
|
|
378
|
+
? fs
|
|
379
|
+
.readdirSync(target, { recursive: true, withFileTypes: true })
|
|
380
|
+
.filter(entry => entry.isFile())
|
|
381
|
+
.map(entry => path.join(entry.parentPath, entry.name))
|
|
382
|
+
: [target]).filter(file => !path.basename(file).startsWith('.'));
|
|
383
|
+
for (const file of files) {
|
|
384
|
+
const info = fs.statSync(file, { throwIfNoEntry: false });
|
|
385
|
+
if (info)
|
|
386
|
+
entries.push(`${file}:${info.size}:${info.mtimeMs}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return entries.toSorted().join('\n');
|
|
390
|
+
};
|
|
391
|
+
let fingerprint = '';
|
|
392
|
+
const watchFiles = (targets, changed) => {
|
|
393
|
+
const onChange = (name, event, file) => {
|
|
394
|
+
const where = file ? path.join(name, file) : name;
|
|
395
|
+
clearTimeout(debounce);
|
|
396
|
+
debounce = setTimeout(() => {
|
|
397
|
+
const next = snapshot(targets);
|
|
398
|
+
if (next === fingerprint) {
|
|
399
|
+
log('fs', `${event} ${where} · no file changed, ignored`, 'warn');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
fingerprint = next;
|
|
403
|
+
log('fs', `${event} ${where}`);
|
|
404
|
+
changed();
|
|
405
|
+
}, 300);
|
|
406
|
+
};
|
|
407
|
+
for (const name of targets) {
|
|
408
|
+
const target = path.join(root, name);
|
|
409
|
+
if (!fs.existsSync(target))
|
|
410
|
+
continue;
|
|
411
|
+
const isDir = fs.statSync(target).isDirectory();
|
|
412
|
+
watchers.push(fs.watch(target, { recursive: isDir }, (event, file) => {
|
|
413
|
+
onChange(name, event, file);
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
fingerprint = snapshot(targets);
|
|
417
|
+
log('svc', `watching ${targets.join(', ')}`);
|
|
418
|
+
};
|
|
419
|
+
void (async () => {
|
|
420
|
+
try {
|
|
421
|
+
cleanBuild(root);
|
|
422
|
+
if (isBundledApp(manifest) && hasLocalWebpackConfig(root)) {
|
|
423
|
+
// Project ships its own webpack config: incremental in-process watch.
|
|
424
|
+
if (!WebpackRunner.isWebpackAvailable(root)) {
|
|
425
|
+
throw new Error('webpack not found. Run npm install.');
|
|
426
|
+
}
|
|
427
|
+
setPhase(task, 'building');
|
|
428
|
+
webpack = new WebpackRunner(root, {
|
|
429
|
+
mode: 'development',
|
|
430
|
+
watch: true,
|
|
431
|
+
cssPrefix: manifest.id,
|
|
432
|
+
restApp: getAppType(manifest) !== 'web' && getAppType(manifest) !== 'widget',
|
|
433
|
+
});
|
|
434
|
+
// Zip, sign and deploy what is in build/ now. Webpack compiles and
|
|
435
|
+
// static edits both end here, one at a time, the latest one last.
|
|
436
|
+
let compiled = false;
|
|
437
|
+
const repackage = serialized(async () => {
|
|
438
|
+
if (!compiled || controller.signal.aborted)
|
|
439
|
+
return;
|
|
440
|
+
try {
|
|
441
|
+
copyStaticToBuild(root);
|
|
442
|
+
await afterBuild(await createBuildZip(root, getFullAppId(manifest.id)));
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
fail(error);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
await webpack.watch(result => {
|
|
449
|
+
reportBuild(result, log);
|
|
450
|
+
compiled = result.success;
|
|
451
|
+
if (result.success)
|
|
452
|
+
void repackage();
|
|
453
|
+
else
|
|
454
|
+
setPhase(task, 'error');
|
|
455
|
+
});
|
|
456
|
+
// Webpack watches what it bundles; what is copied in as-is it never sees.
|
|
457
|
+
watchFiles(WEBPACK_UNSEEN_TARGETS, () => {
|
|
458
|
+
void repackage();
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
watchFiles(WATCH_TARGETS, () => {
|
|
463
|
+
void rebuild();
|
|
464
|
+
});
|
|
465
|
+
await rebuild();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
finish('error', errorText(error));
|
|
470
|
+
}
|
|
471
|
+
})();
|
|
472
|
+
return task;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Run `run` one call at a time. Calls made meanwhile collapse into one run with
|
|
476
|
+
* the latest value, so of several quick saves the last is what gets deployed
|
|
477
|
+
* last, never an older one that happened to upload slower.
|
|
478
|
+
*/
|
|
479
|
+
export function serialized(run) {
|
|
480
|
+
let running = false;
|
|
481
|
+
let waiting;
|
|
482
|
+
return async (value) => {
|
|
483
|
+
waiting = { value };
|
|
484
|
+
if (running)
|
|
485
|
+
return;
|
|
486
|
+
running = true;
|
|
487
|
+
try {
|
|
488
|
+
while (waiting) {
|
|
489
|
+
const { value: current } = waiting;
|
|
490
|
+
waiting = undefined;
|
|
491
|
+
// eslint-disable-next-line no-await-in-loop
|
|
492
|
+
await run(current);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
running = false;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Child output as log rows: split on LF or CRLF, no colour or cursor codes, and
|
|
502
|
+
* of a line redrawn with bare CRs (progress bars) only the final state. Any of
|
|
503
|
+
* those left in would move the cursor inside Ink's frame.
|
|
504
|
+
*/
|
|
505
|
+
export function logLines(text) {
|
|
506
|
+
return stripVTControlCharacters(text)
|
|
507
|
+
.trimEnd()
|
|
508
|
+
.split(/\r?\n/)
|
|
509
|
+
.map(line => line.slice(line.lastIndexOf('\r') + 1));
|
|
510
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import https from 'node:https';
|
|
2
2
|
/**
|
|
3
3
|
* True if version `a` is strictly greater than `b`, comparing the numeric
|
|
4
|
-
* MAJOR.MINOR.PATCH core.
|
|
4
|
+
* MAJOR.MINOR.PATCH core. With equal cores, a stable `a` beats a pre-release `b`.
|
|
5
5
|
*/
|
|
6
|
-
function isNewer(a, b) {
|
|
6
|
+
export function isNewer(a, b) {
|
|
7
7
|
const core = (v) => (v.split('-')[0] ?? '').split('.');
|
|
8
8
|
const pa = core(a);
|
|
9
9
|
const pb = core(b);
|
|
@@ -15,7 +15,7 @@ function isNewer(a, b) {
|
|
|
15
15
|
if (da < db)
|
|
16
16
|
return false;
|
|
17
17
|
}
|
|
18
|
-
return
|
|
18
|
+
return !a.includes('-') && b.includes('-');
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Check the npm registry for a newer published version of `packageName`.
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import { createRequire } from 'module';
|
|
10
|
+
import { pathToFileURL } from 'url';
|
|
10
11
|
import { copyChunksToResources } from './zip.js';
|
|
12
|
+
import { findInstalledPackage } from './sitevision-scripts-runner.js';
|
|
13
|
+
import { debug, errorText } from './debug.js';
|
|
11
14
|
// =============================================================================
|
|
12
15
|
// LOCAL CONFIG DETECTION
|
|
13
16
|
// =============================================================================
|
|
@@ -56,8 +59,7 @@ export class WebpackRunner {
|
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
58
61
|
// Load webpack from project's node_modules
|
|
59
|
-
|
|
60
|
-
if (!fs.existsSync(webpackPath)) {
|
|
62
|
+
if (!WebpackRunner.isWebpackAvailable(this.projectRoot)) {
|
|
61
63
|
throw new Error('webpack not found in project. Make sure webpack is installed: npm install webpack');
|
|
62
64
|
}
|
|
63
65
|
try {
|
|
@@ -83,7 +85,8 @@ export class WebpackRunner {
|
|
|
83
85
|
throw new Error('webpack.config.js not found. Make sure your project has a webpack configuration.');
|
|
84
86
|
}
|
|
85
87
|
try {
|
|
86
|
-
|
|
88
|
+
// A bare Windows path (C:\\...) is not a valid import specifier.
|
|
89
|
+
const configModule = await import(pathToFileURL(configPath).href);
|
|
87
90
|
const configFactory = configModule.default || configModule;
|
|
88
91
|
// If it's a function, call it with options
|
|
89
92
|
if (typeof configFactory === 'function') {
|
|
@@ -149,6 +152,7 @@ export class WebpackRunner {
|
|
|
149
152
|
}
|
|
150
153
|
catch (chunkError) {
|
|
151
154
|
// Non-fatal, just log
|
|
155
|
+
debug('bld', `Failed to copy chunks: ${errorText(chunkError)}`);
|
|
152
156
|
console.warn('Warning: Failed to copy chunks:', chunkError);
|
|
153
157
|
}
|
|
154
158
|
}
|
|
@@ -189,6 +193,7 @@ export class WebpackRunner {
|
|
|
189
193
|
copyChunksToResources(this.config.output.path);
|
|
190
194
|
}
|
|
191
195
|
catch (chunkError) {
|
|
196
|
+
debug('bld', `Failed to copy chunks: ${errorText(chunkError)}`);
|
|
192
197
|
console.warn('Warning: Failed to copy chunks:', chunkError);
|
|
193
198
|
}
|
|
194
199
|
}
|
|
@@ -225,8 +230,7 @@ export class WebpackRunner {
|
|
|
225
230
|
* Check if webpack is available in the project
|
|
226
231
|
*/
|
|
227
232
|
static isWebpackAvailable(projectRoot) {
|
|
228
|
-
|
|
229
|
-
return fs.existsSync(webpackPath);
|
|
233
|
+
return findInstalledPackage(projectRoot, 'webpack') !== null;
|
|
230
234
|
}
|
|
231
235
|
/**
|
|
232
236
|
* Get the webpack version from the project
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { detectProject, readWorkspaceDevProperties, } from './project-detection.js';
|
|
4
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
|
|
5
|
+
const MAX_DEPTH = 3;
|
|
6
|
+
/**
|
|
7
|
+
* Find every Sitevision app below `root` (e.g. root/webapps/x, root/restapps/y).
|
|
8
|
+
* Depth-limited walk that skips dependency and output folders.
|
|
9
|
+
*/
|
|
10
|
+
export function discoverApps(root,
|
|
11
|
+
// Filled with why each app that was left out is broken.
|
|
12
|
+
skipped = []) {
|
|
13
|
+
const found = [];
|
|
14
|
+
const walk = (dir, depth) => {
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name))
|
|
24
|
+
continue;
|
|
25
|
+
if (entry.name.startsWith('.'))
|
|
26
|
+
continue;
|
|
27
|
+
const full = path.join(dir, entry.name);
|
|
28
|
+
let project = null;
|
|
29
|
+
try {
|
|
30
|
+
project = detectProject(full);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// A broken app is left out, said out loud, and not searched for more.
|
|
34
|
+
skipped.push(error instanceof Error ? error.message : String(error));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (project) {
|
|
38
|
+
found.push(project);
|
|
39
|
+
}
|
|
40
|
+
else if (depth < MAX_DEPTH) {
|
|
41
|
+
walk(full, depth + 1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(root, 1);
|
|
46
|
+
// Group first, then path: a deeper folder (webapps/nested) must not split
|
|
47
|
+
// its parent's run of apps, or the same group heading renders twice.
|
|
48
|
+
return found.toSorted((a, b) => path.dirname(a.root).localeCompare(path.dirname(b.root)) ||
|
|
49
|
+
a.root.localeCompare(b.root));
|
|
50
|
+
}
|
|
51
|
+
/** Group label for an app: its parent folder relative to the workspace root. */
|
|
52
|
+
export function appGroup(root, appRoot) {
|
|
53
|
+
const relative = path.relative(root, path.dirname(appRoot));
|
|
54
|
+
return relative === '' ? '.' : relative;
|
|
55
|
+
}
|
|
56
|
+
/** Missing something nothing can deploy without. */
|
|
57
|
+
export function configIncomplete(dev) {
|
|
58
|
+
if (!dev?.domain || !dev.siteName)
|
|
59
|
+
return true;
|
|
60
|
+
return (dev.authMethod ?? 'basic') === 'basic' && !dev.username;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What a deploy is sent with, or which required value is missing. The one place
|
|
64
|
+
* dev properties become a DeployConfig, so nothing ever posts to "undefined".
|
|
65
|
+
*/
|
|
66
|
+
export function toDeployConfig(dev,
|
|
67
|
+
// False for what talks to the site rather than an addon: listing its addons
|
|
68
|
+
// (how an empty addon name gets picked) and logging in.
|
|
69
|
+
{ addon = true } = {}) {
|
|
70
|
+
const required = {
|
|
71
|
+
domain: dev?.domain,
|
|
72
|
+
siteName: dev?.siteName,
|
|
73
|
+
...(addon && { addonName: dev?.addonName }),
|
|
74
|
+
// A token or cookie login has no use for a username.
|
|
75
|
+
...((dev?.authMethod ?? 'basic') === 'basic' && { username: dev?.username }),
|
|
76
|
+
};
|
|
77
|
+
const missing = Object.entries(required).find(([, value]) => !value)?.[0];
|
|
78
|
+
if (!dev || missing) {
|
|
79
|
+
return {
|
|
80
|
+
error: `Deploy config is missing "${missing ?? 'domain'}". Run svc and press e to set it, or add it to .dev_properties.json.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
config: {
|
|
85
|
+
domain: dev.domain,
|
|
86
|
+
siteName: dev.siteName,
|
|
87
|
+
addonName: dev.addonName ?? '',
|
|
88
|
+
username: dev.username ?? '',
|
|
89
|
+
password: dev.password,
|
|
90
|
+
accessToken: dev.accessToken,
|
|
91
|
+
sessionCookie: dev.sessionCookie,
|
|
92
|
+
useHTTP: dev.useHTTPForDevDeploy,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* True when a workspace has apps but nothing usable to deploy with: neither the
|
|
98
|
+
* shared root config nor the apps themselves carry domain/site/username. The
|
|
99
|
+
* shell then opens on Workspace settings instead of the first app.
|
|
100
|
+
*/
|
|
101
|
+
export function needsOnboarding(root, apps) {
|
|
102
|
+
return (apps.length > 0 &&
|
|
103
|
+
configIncomplete(readWorkspaceDevProperties(root)) &&
|
|
104
|
+
apps.some(app => configIncomplete(app.devProperties)));
|
|
105
|
+
}
|