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.
Files changed (100) hide show
  1. package/CHANGELOG.md +138 -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 +44 -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 +162 -32
  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 +27 -4
  49. package/readme.md +113 -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
@@ -1,4 +1,5 @@
1
1
  import { setSessionCookie } from './keychain.js';
2
+ import { debug } from './debug.js';
2
3
  function bareDomain(domain) {
3
4
  return domain.replace(/^\./, '');
4
5
  }
@@ -15,14 +16,15 @@ function domainRelated(a, b) {
15
16
  * browser — so it's unit-testable.
16
17
  */
17
18
  export function selectSessionCookie(all, siteDomain) {
18
- const sessions = all.filter(c => c.name === 'JSESSIONID');
19
- if (sessions.length === 0) {
19
+ // Only a session on the deploy site counts: a Java IdP sets a JSESSIONID of
20
+ // its own, and that one must never be sent to the site.
21
+ const chosen = all.find(c => c.name === 'JSESSIONID' && domainRelated(c.domain, siteDomain));
22
+ if (!chosen) {
20
23
  const domains = [...new Set(all.map(c => bareDomain(c.domain)))];
21
24
  return {
22
- error: `No JSESSIONID among ${all.length} cookies. Domains seen: ${domains.join(', ') || 'none'}. If these are only your IdP, open a Sitevision page in the browser, then press Enter again.`,
25
+ error: `No JSESSIONID for ${siteDomain} among ${all.length} cookies. Domains seen: ${domains.join(', ') || 'none'}. If these are only your IdP, open a Sitevision page in the browser, then press Enter again.`,
23
26
  };
24
27
  }
25
- const chosen = sessions.find(c => domainRelated(c.domain, siteDomain)) ?? sessions[0];
26
28
  const cookies = all.filter(c => domainRelated(c.domain, chosen.domain));
27
29
  return {
28
30
  cookie: cookies.map(c => `${c.name}=${c.value}`).join('; '),
@@ -56,7 +58,7 @@ async function readAllCookies(browser, page) {
56
58
  */
57
59
  export async function beginCookieLogin(dev) {
58
60
  const { domain, username } = dev;
59
- if (!domain || !username)
61
+ if (!domain)
60
62
  return null;
61
63
  let puppeteer;
62
64
  try {
@@ -70,12 +72,14 @@ export async function beginCookieLogin(dev) {
70
72
  browser = await puppeteer.launch({ headless: false, channel: 'chrome' });
71
73
  const page = await browser.newPage();
72
74
  const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
75
+ debug('auth', `cookie login ${loginUrl}`);
73
76
  await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }).catch(() => {
74
77
  // A SAML redirect may abort the initial navigation — that's fine.
75
78
  });
76
79
  const capture = async () => {
77
80
  const all = await readAllCookies(browser, page);
78
81
  const result = selectSessionCookie(all, domain);
82
+ debug('auth', `cookie capture ${domain}: ${result.cookie ? 'session found' : 'no session'} (${all.length} cookies)`);
79
83
  if (result.cookie) {
80
84
  setSessionCookie(domain, username, result.cookie);
81
85
  }
@@ -86,7 +90,7 @@ export async function beginCookieLogin(dev) {
86
90
  // Best-effort close.
87
91
  });
88
92
  };
89
- return { capture, close };
93
+ return { capture, close, loginUrl };
90
94
  }
91
95
  catch {
92
96
  if (browser) {
@@ -12,7 +12,8 @@ import fs from 'fs';
12
12
  import path from 'path';
13
13
  import https from 'https';
14
14
  import http from 'http';
15
- import { buildImportEndpointUrl, buildAddonEndpointUrl, } from './project-detection.js';
15
+ import { buildImportEndpointUrl, buildAddonEndpointUrl, buildApiBaseUrl, } from './project-detection.js';
16
+ import { debug } from './debug.js';
16
17
  // =============================================================================
17
18
  // CONSTANTS
18
19
  // =============================================================================
@@ -89,6 +90,9 @@ function createMultipartFormData(filePath, fieldName, boundary) {
89
90
  export function makeRequest(url, options) {
90
91
  return new Promise((resolve, reject) => {
91
92
  const parsedUrl = new URL(url);
93
+ const startedAt = Date.now();
94
+ const describe = `${options.method} ${parsedUrl.host}${parsedUrl.pathname}`;
95
+ debug('http', describe);
92
96
  const isHttps = parsedUrl.protocol === 'https:';
93
97
  const transport = isHttps ? https : http;
94
98
  const headers = {
@@ -121,18 +125,28 @@ export function makeRequest(url, options) {
121
125
  chunks.push(chunk);
122
126
  });
123
127
  res.on('end', () => {
128
+ debug('http', `${describe} -> ${res.statusCode} ${res.headers['content-type'] ?? ''} ${Date.now() - startedAt}ms`);
124
129
  resolve({
125
130
  statusCode: res.statusCode || 0,
126
131
  body: Buffer.concat(chunks),
127
132
  headers: res.headers,
128
133
  });
129
134
  });
135
+ // A connection cut mid-body never emits 'end'.
136
+ res.on('error', reject);
137
+ res.on('close', () => {
138
+ if (!res.complete)
139
+ reject(new Error('Response was cut off'));
140
+ });
130
141
  });
131
142
  // Abort hung connections instead of blocking the CLI indefinitely.
132
143
  req.setTimeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, () => {
133
144
  req.destroy(new Error(`Request timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
134
145
  });
135
- req.on('error', reject);
146
+ req.on('error', error => {
147
+ debug('http', `${describe} failed: ${error.message}`);
148
+ reject(error);
149
+ });
136
150
  if (options.body) {
137
151
  req.write(options.body);
138
152
  }
@@ -160,16 +174,36 @@ async function delay(ms) {
160
174
  */
161
175
  export function summarizeErrorBody(body, headers) {
162
176
  const contentType = headers['content-type'] ?? 'unknown';
163
- const isText = contentType.includes('text') ||
177
+ const declaredText = contentType.includes('text') ||
164
178
  contentType.includes('json') ||
165
179
  contentType.includes('xml');
166
- if (!isText) {
167
- return `(${contentType}, ${body.length} bytes)`;
180
+ const text = body.toString('utf8');
181
+ // Show the body when the server declares it text OR the bytes decode to
182
+ // mostly-printable text. Sitevision sometimes returns a text/JSON error with
183
+ // a missing or non-text content-type, and hiding it obscures the real cause.
184
+ if (declaredText || isMostlyPrintable(text)) {
185
+ const collapsed = text.replaceAll(/\s+/g, ' ').trim();
186
+ const max = 300;
187
+ const summary = collapsed.length > max ? collapsed.slice(0, max) + '…' : collapsed;
188
+ if (summary.length > 0)
189
+ return summary;
190
+ }
191
+ return `(${contentType}, ${body.length} bytes)`;
192
+ }
193
+ /** Heuristic: a decoded string is text if <10% of chars are control/undecodable. */
194
+ function isMostlyPrintable(text) {
195
+ if (text.length === 0)
196
+ return false;
197
+ let bad = 0;
198
+ for (const ch of text) {
199
+ const code = ch.codePointAt(0) ?? 0;
200
+ // Undecodable byte (replacement char), or a control char other than
201
+ // tab/newline/vertical-tab/form-feed/carriage-return.
202
+ if (code === 0xff_fd || code < 9 || (code > 13 && code < 32)) {
203
+ bad++;
204
+ }
168
205
  }
169
- const text = body.toString('utf8').replaceAll(/\s+/g, ' ').trim();
170
- const max = 300;
171
- const summary = text.length > max ? text.slice(0, max) + '…' : text;
172
- return summary.length > 0 ? summary : `(${contentType}, empty body)`;
206
+ return bad / text.length < 0.1;
173
207
  }
174
208
  /** ZIP local-file-header magic bytes: "PK\x03\x04". */
175
209
  const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
@@ -316,9 +350,11 @@ export async function deployApp(zipPath, config, appType, force = false) {
316
350
  if (force) {
317
351
  url += '?force=true';
318
352
  }
319
- // Create multipart form data
353
+ // The import endpoints read the archive from a multipart part named "data"
354
+ // (per the webAppImport/restAppImport docs, and confirmed against a live
355
+ // server). Signing uses "file" against a different endpoint.
320
356
  const boundary = generateBoundary();
321
- const { body, contentType } = createMultipartFormData(zipPath, 'file', boundary);
357
+ const { body, contentType } = createMultipartFormData(zipPath, 'data', boundary);
322
358
  const { auth, kind } = configAuth(config);
323
359
  try {
324
360
  const response = await makeRequest(url, {
@@ -369,6 +405,14 @@ export async function deployApp(zipPath, config, appType, force = false) {
369
405
  error: 'Conflict. Addon already exists. Use --force to overwrite.',
370
406
  };
371
407
  }
408
+ if (response.statusCode === 400 &&
409
+ response.body.toString().includes('could not resolve context node')) {
410
+ return {
411
+ success: false,
412
+ error: `Deployment failed with status 400: ${summarizeErrorBody(response.body, response.headers)}`,
413
+ contextNodeMissing: true,
414
+ };
415
+ }
372
416
  return {
373
417
  success: false,
374
418
  error: `Deployment failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
@@ -394,23 +438,24 @@ export async function deployProduction(signedZipPath, config, appType) {
394
438
  if (!deployResult.success) {
395
439
  return deployResult;
396
440
  }
397
- // If activation requested and we have an executable ID
398
- if (config.activate && deployResult.executableId) {
399
- const activationResult = await activateApp(deployResult.executableId, config, appType);
400
- if (!activationResult.success) {
401
- return {
402
- success: true,
403
- executableId: deployResult.executableId,
404
- message: `Deployed successfully but activation failed: ${activationResult.error}`,
405
- };
406
- }
441
+ if (!config.activate)
442
+ return deployResult;
443
+ if (!deployResult.executableId) {
407
444
  return {
408
- success: true,
409
- executableId: deployResult.executableId,
410
- message: 'Deployed and activated successfully',
445
+ ...deployResult,
446
+ activated: false,
447
+ message: 'Deployed, but not activated: the server did not return an executable id.',
411
448
  };
412
449
  }
413
- return deployResult;
450
+ const activationResult = await activateApp(deployResult.executableId, config, appType);
451
+ return {
452
+ success: true,
453
+ executableId: deployResult.executableId,
454
+ activated: activationResult.success,
455
+ message: activationResult.success
456
+ ? 'Deployed and activated successfully'
457
+ : `Deployed successfully but activation failed: ${activationResult.error}`,
458
+ };
414
459
  }
415
460
  // =============================================================================
416
461
  // ADDON MANAGEMENT API
@@ -423,10 +468,11 @@ export async function deployProduction(signedZipPath, config, appType) {
423
468
  */
424
469
  export async function createAddon(config, appType) {
425
470
  const url = buildAddonEndpointUrl(config.domain, config.siteName, appType, config.useHTTP);
426
- const body = JSON.stringify({
427
- name: config.addonName,
428
- category: 'Other',
429
- });
471
+ const payload = { name: config.addonName };
472
+ // Only the custommodule endpoints take (and require) a category.
473
+ if (appType === 'web' || appType === 'widget')
474
+ payload['category'] = 'Other';
475
+ const body = JSON.stringify(payload);
430
476
  const { auth, kind } = configAuth(config);
431
477
  try {
432
478
  const response = await makeRequest(url, {
@@ -487,9 +533,8 @@ export async function createAddon(config, appType) {
487
533
  export async function activateApp(executableId, config, _appType) {
488
534
  const protocol = config.useHTTP ? 'http' : 'https';
489
535
  const url = `${protocol}://${config.domain}/rest-api/1/0/${encodeURIComponent(config.siteName)}/Addon%20Repository/${encodeURIComponent(config.addonName)}/activateCustomModuleExecutable`;
490
- const body = JSON.stringify({
491
- executableId,
492
- });
536
+ // Property name per the ActivateCustomModuleExecutable PUT docs.
537
+ const body = JSON.stringify({ customModuleExecutableId: executableId });
493
538
  const { auth, kind } = configAuth(config);
494
539
  try {
495
540
  const response = await makeRequest(url, {
@@ -523,6 +568,91 @@ export async function activateApp(executableId, config, _appType) {
523
568
  };
524
569
  }
525
570
  }
571
+ /**
572
+ * List the executables (uploaded versions) of the configured addon.
573
+ */
574
+ export async function listExecutables(config) {
575
+ const protocol = config.useHTTP ? 'http' : 'https';
576
+ const url = `${protocol}://${config.domain}/rest-api/1/0/${encodeURIComponent(config.siteName)}/Addon%20Repository/${encodeURIComponent(config.addonName)}/activateCustomModuleExecutable`;
577
+ const { auth, kind } = configAuth(config);
578
+ try {
579
+ const response = await makeRequest(url, { method: 'GET', auth });
580
+ if (response.statusCode === 401) {
581
+ return {
582
+ success: false,
583
+ error: unauthorizedMessage(kind),
584
+ authExpired: true,
585
+ };
586
+ }
587
+ if (response.statusCode !== 200) {
588
+ return {
589
+ success: false,
590
+ error: `Listing versions failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
591
+ };
592
+ }
593
+ const data = JSON.parse(response.body.toString());
594
+ return { success: true, executables: data.executables ?? [] };
595
+ }
596
+ catch (error) {
597
+ return {
598
+ success: false,
599
+ error: `Listing versions failed: ${error instanceof Error ? error.message : String(error)}`,
600
+ };
601
+ }
602
+ }
603
+ const ADDON_TYPES = {
604
+ 'sv:customModule': 'web',
605
+ 'sv:marketplaceCustomModule': 'web',
606
+ 'sv:widgetCustomModule': 'widget',
607
+ 'sv:marketplaceWidgetCustomModule': 'widget',
608
+ 'sv:headlessCustomModule': 'rest',
609
+ 'sv:marketplaceHeadlessCustomModule': 'rest',
610
+ 'sv:mcpServerCustomModule': 'mcp',
611
+ };
612
+ /**
613
+ * List the addons (custom modules) in the site's Addon Repository.
614
+ */
615
+ export async function listAddons(config) {
616
+ const url = `${buildApiBaseUrl(config.domain, config.siteName, config.useHTTP)}/Addon%20Repository/nodes`;
617
+ const { auth, kind } = configAuth(config);
618
+ try {
619
+ const response = await makeRequest(url, { method: 'GET', auth });
620
+ if (response.statusCode === 401) {
621
+ return { success: false, error: unauthorizedMessage(kind) };
622
+ }
623
+ if (response.statusCode !== 200) {
624
+ return {
625
+ success: false,
626
+ error: `Listing addons failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
627
+ };
628
+ }
629
+ const nodes = JSON.parse(response.body.toString());
630
+ return {
631
+ success: true,
632
+ addons: nodes
633
+ .filter(node => Object.hasOwn(ADDON_TYPES, node.type))
634
+ .map(node => ({ ...node, appType: ADDON_TYPES[node.type] })),
635
+ };
636
+ }
637
+ catch (error) {
638
+ return {
639
+ success: false,
640
+ error: `Listing addons failed: ${error instanceof Error ? error.message : String(error)}`,
641
+ };
642
+ }
643
+ }
644
+ /**
645
+ * Whether an addon exists, judged from a listAddons result. A failed or empty
646
+ * listing is "unknown": a dead session can look exactly like an empty site.
647
+ */
648
+ export function classifyAddon(listed, name) {
649
+ if (!listed.success || !listed.addons?.length)
650
+ return 'unknown';
651
+ const wanted = name.toLowerCase();
652
+ return listed.addons.some(addon => addon.name.toLowerCase() === wanted)
653
+ ? 'present'
654
+ : 'missing';
655
+ }
526
656
  // =============================================================================
527
657
  // HELPER EXPORTS
528
658
  // =============================================================================
@@ -17,14 +17,33 @@
17
17
  */
18
18
  import path from 'path';
19
19
  import fs from 'fs';
20
- import { spawn } from 'child_process';
20
+ import { killChild, spawnChild } from './process-runner.js';
21
+ import { getAppIdConfig, getFullAppId } from './project-detection.js';
21
22
  /**
22
- * Resolve the path to the sitevision-scripts CLI entry inside a project.
23
+ * Directory of an installed package, looked up the way Node does: the project's
24
+ * node_modules, then each parent's, since workspaces hoist to the repo root.
25
+ */
26
+ export function findInstalledPackage(projectRoot, name) {
27
+ let dir = path.resolve(projectRoot);
28
+ for (;;) {
29
+ const candidate = path.join(dir, 'node_modules', name);
30
+ if (fs.existsSync(candidate))
31
+ return candidate;
32
+ const parent = path.dirname(dir);
33
+ if (parent === dir)
34
+ return null;
35
+ dir = parent;
36
+ }
37
+ }
38
+ const PACKAGE = '@sitevision/sitevision-scripts';
39
+ /**
40
+ * Resolve the path to the sitevision-scripts CLI entry for a project.
23
41
  * Returns null if the package is not installed.
24
42
  */
25
43
  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;
44
+ const dir = findInstalledPackage(projectRoot, PACKAGE);
45
+ const bin = dir && path.join(dir, 'bin', 'sitevision-scripts.js');
46
+ return bin && fs.existsSync(bin) ? bin : null;
28
47
  }
29
48
  /**
30
49
  * Whether the sitevision-scripts package is available in the project.
@@ -35,18 +54,12 @@ export function hasSitevisionScripts(projectRoot) {
35
54
  /**
36
55
  * Path of the zip that `sitevision-scripts build` writes.
37
56
  *
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.
57
+ * sitevision-scripts names it from `APP_ID_PREFIX`/`APP_ID_SUFFIX`; the build is
58
+ * started with those set from `getAppIdConfig`, so this is the same file that
59
+ * sign and deploy look for.
44
60
  */
45
61
  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`);
62
+ return path.join(projectRoot, 'dist', `${getFullAppId(manifestId)}.zip`);
50
63
  }
51
64
  // =============================================================================
52
65
  // VERSION COMPATIBILITY
@@ -68,9 +81,11 @@ export const SUPPORTED_SITEVISION_SCRIPTS_RANGE = `>=${SUPPORTED_SITEVISION_SCRI
68
81
  * is not installed / unreadable.
69
82
  */
70
83
  export function getSitevisionScriptsVersion(projectRoot) {
71
- const packageJsonPath = path.join(projectRoot, 'node_modules', '@sitevision', 'sitevision-scripts', 'package.json');
84
+ const dir = findInstalledPackage(projectRoot, PACKAGE);
85
+ if (!dir)
86
+ return null;
72
87
  try {
73
- const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
88
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
74
89
  return parsed.version ?? null;
75
90
  }
76
91
  catch {
@@ -145,8 +160,9 @@ const MAX_OUTPUT_CHARS = 50_000;
145
160
  *
146
161
  * @param projectRoot - Project root directory (used as cwd)
147
162
  * @param onOutput - Optional callback for streaming output chunks
163
+ * @param signal - Aborting kills the build and everything it started
148
164
  */
149
- export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
165
+ export async function runSitevisionScriptsBuild(projectRoot, onOutput, signal) {
150
166
  const bin = getSitevisionScriptsBin(projectRoot);
151
167
  if (!bin) {
152
168
  return {
@@ -157,10 +173,18 @@ export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
157
173
  }
158
174
  return new Promise(resolve => {
159
175
  let output = '';
160
- const child = spawn(process.execPath, [bin, 'build'], {
176
+ const { prefix, suffix } = getAppIdConfig();
177
+ const child = spawnChild(process.execPath, [bin, 'build'], {
161
178
  cwd: projectRoot,
179
+ env: { ...process.env, APP_ID_PREFIX: prefix, APP_ID_SUFFIX: suffix },
162
180
  stdio: ['ignore', 'pipe', 'pipe'],
163
181
  });
182
+ const abort = () => {
183
+ killChild(child);
184
+ };
185
+ signal?.addEventListener('abort', abort, { once: true });
186
+ if (signal?.aborted)
187
+ abort();
164
188
  const handleData = (data) => {
165
189
  const text = data.toString();
166
190
  output += text;
@@ -175,12 +199,16 @@ export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
175
199
  resolve({ success: false, output, error: error.message });
176
200
  });
177
201
  child.on('close', code => {
202
+ signal?.removeEventListener('abort', abort);
203
+ const stopped = signal?.aborted ?? false;
178
204
  resolve({
179
- success: code === 0,
205
+ success: code === 0 && !stopped,
180
206
  output,
181
- error: code === 0
182
- ? undefined
183
- : `sitevision-scripts build exited with code ${code}`,
207
+ error: stopped
208
+ ? 'Build stopped.'
209
+ : code === 0
210
+ ? undefined
211
+ : `sitevision-scripts build exited with code ${code}`,
184
212
  });
185
213
  });
186
214
  });