sitevision-cli 1.0.0-beta.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.js +194 -70
  4. package/dist/commands/TaskScreen.js +32 -0
  5. package/dist/commands/build.js +6 -185
  6. package/dist/commands/deploy.js +95 -212
  7. package/dist/commands/dev.js +87 -416
  8. package/dist/commands/info.js +1 -1
  9. package/dist/commands/setup-signing.js +50 -33
  10. package/dist/commands/sign.js +9 -86
  11. package/dist/commands/use-finish.js +17 -0
  12. package/dist/commands/watch.js +5 -31
  13. package/dist/components/AnimatedLogo.js +8 -2
  14. package/dist/components/AuthLoginScreen.js +33 -15
  15. package/dist/components/PasswordInput.js +2 -1
  16. package/dist/scaffold/inquirer-stub.js +119 -0
  17. package/dist/scaffold/register.js +15 -0
  18. package/dist/shell/AddonPicker.js +54 -0
  19. package/dist/shell/Changelog.js +50 -0
  20. package/dist/shell/CommandPalette.js +63 -0
  21. package/dist/shell/ConfigForm.js +703 -0
  22. package/dist/shell/Frame.js +138 -0
  23. package/dist/shell/Help.js +96 -0
  24. package/dist/shell/Popover.js +15 -0
  25. package/dist/shell/Settings.js +149 -0
  26. package/dist/shell/Shell.js +953 -0
  27. package/dist/shell/Tabs.js +96 -0
  28. package/dist/shell/actions.js +493 -0
  29. package/dist/utils/config.js +62 -2
  30. package/dist/utils/create-app.js +231 -0
  31. package/dist/utils/debug.js +32 -0
  32. package/dist/utils/environments.js +74 -0
  33. package/dist/utils/i18n.js +369 -0
  34. package/dist/utils/jsonc.js +2 -1
  35. package/dist/utils/keychain.js +54 -6
  36. package/dist/utils/oauth2-auth.js +146 -43
  37. package/dist/utils/password-prompt.js +37 -3
  38. package/dist/utils/process-runner.js +77 -38
  39. package/dist/utils/project-detection.js +457 -112
  40. package/dist/utils/session-cookie-auth.js +10 -6
  41. package/dist/utils/sitevision-api.js +134 -25
  42. package/dist/utils/sitevision-scripts-runner.js +50 -22
  43. package/dist/utils/tasks.js +510 -0
  44. package/dist/utils/version-check.js +3 -3
  45. package/dist/utils/webpack-runner.js +9 -5
  46. package/dist/utils/workspace.js +105 -0
  47. package/dist/utils/zip.js +18 -19
  48. package/package.json +29 -5
  49. package/readme.md +117 -141
  50. package/dist/app.d.ts +0 -6
  51. package/dist/app.js +0 -307
  52. package/dist/cli.d.ts +0 -2
  53. package/dist/commands/build.d.ts +0 -12
  54. package/dist/commands/deploy.d.ts +0 -16
  55. package/dist/commands/dev.d.ts +0 -16
  56. package/dist/commands/index.d.ts +0 -4
  57. package/dist/commands/info.d.ts +0 -2
  58. package/dist/commands/setup-signing.d.ts +0 -2
  59. package/dist/commands/sign.d.ts +0 -14
  60. package/dist/commands/types.d.ts +0 -18
  61. package/dist/commands/watch.d.ts +0 -2
  62. package/dist/components/AnimatedLogo.d.ts +0 -10
  63. package/dist/components/AuthLoginScreen.d.ts +0 -21
  64. package/dist/components/DevPropertiesForm.d.ts +0 -10
  65. package/dist/components/DevPropertiesForm.js +0 -210
  66. package/dist/components/InfoScreen.d.ts +0 -7
  67. package/dist/components/InfoScreen.js +0 -14
  68. package/dist/components/KeychainPasswordChoice.d.ts +0 -8
  69. package/dist/components/KeychainPasswordChoice.js +0 -30
  70. package/dist/components/MainMenu.d.ts +0 -7
  71. package/dist/components/MainMenu.js +0 -83
  72. package/dist/components/PasswordInput.d.ts +0 -10
  73. package/dist/components/ProcessOutput.d.ts +0 -6
  74. package/dist/components/ProcessOutput.js +0 -30
  75. package/dist/components/SetupFlow.d.ts +0 -8
  76. package/dist/components/SetupFlow.js +0 -233
  77. package/dist/components/SigningPropertiesForm.d.ts +0 -7
  78. package/dist/components/SigningPropertiesForm.js +0 -38
  79. package/dist/components/StatusIndicator.d.ts +0 -8
  80. package/dist/components/TextInput.d.ts +0 -10
  81. package/dist/components/TextInput.js +0 -29
  82. package/dist/components/WelcomeScreen.d.ts +0 -14
  83. package/dist/types/index.d.ts +0 -284
  84. package/dist/utils/branding.d.ts +0 -28
  85. package/dist/utils/config.d.ts +0 -17
  86. package/dist/utils/jsonc.d.ts +0 -19
  87. package/dist/utils/keychain.d.ts +0 -15
  88. package/dist/utils/oauth2-auth.d.ts +0 -27
  89. package/dist/utils/password-prompt.d.ts +0 -14
  90. package/dist/utils/process-runner.d.ts +0 -22
  91. package/dist/utils/project-detection.d.ts +0 -151
  92. package/dist/utils/session-cookie-auth.d.ts +0 -34
  93. package/dist/utils/signing-password.d.ts +0 -12
  94. package/dist/utils/signing-step.d.ts +0 -15
  95. package/dist/utils/signing-step.js +0 -16
  96. package/dist/utils/sitevision-api.d.ts +0 -112
  97. package/dist/utils/sitevision-scripts-runner.d.ts +0 -83
  98. package/dist/utils/version-check.d.ts +0 -9
  99. package/dist/utils/webpack-runner.d.ts +0 -76
  100. 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. Pre-release suffixes (e.g. `-beta.1`) are ignored.
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 false;
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
- const webpackPath = path.join(this.projectRoot, 'node_modules', 'webpack');
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
- const configModule = await import(configPath);
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
- const webpackPath = path.join(projectRoot, 'node_modules', 'webpack');
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
+ }