sitevision-cli 1.0.0-beta.3 → 1.0.0-beta.31

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 (72) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +59 -8
  4. package/dist/cli.js +125 -40
  5. package/dist/commands/build.js +1 -1
  6. package/dist/commands/deploy.d.ts +2 -2
  7. package/dist/commands/deploy.js +137 -28
  8. package/dist/commands/dev.d.ts +8 -10
  9. package/dist/commands/dev.js +70 -371
  10. package/dist/commands/watch.js +5 -23
  11. package/dist/components/AnimatedLogo.js +8 -2
  12. package/dist/components/AuthLoginScreen.d.ts +21 -0
  13. package/dist/components/AuthLoginScreen.js +90 -0
  14. package/dist/components/DevPropertiesForm.d.ts +2 -1
  15. package/dist/components/DevPropertiesForm.js +198 -33
  16. package/dist/components/MainMenu.js +5 -0
  17. package/dist/components/PasswordInput.js +2 -1
  18. package/dist/components/SetupFlow.d.ts +2 -1
  19. package/dist/components/SetupFlow.js +99 -10
  20. package/dist/shell/AddonPicker.d.ts +14 -0
  21. package/dist/shell/AddonPicker.js +54 -0
  22. package/dist/shell/Changelog.d.ts +10 -0
  23. package/dist/shell/Changelog.js +44 -0
  24. package/dist/shell/CommandPalette.d.ts +12 -0
  25. package/dist/shell/CommandPalette.js +63 -0
  26. package/dist/shell/ConfigForm.d.ts +36 -0
  27. package/dist/shell/ConfigForm.js +558 -0
  28. package/dist/shell/Frame.d.ts +59 -0
  29. package/dist/shell/Frame.js +135 -0
  30. package/dist/shell/Help.d.ts +9 -0
  31. package/dist/shell/Help.js +95 -0
  32. package/dist/shell/Popover.d.ts +12 -0
  33. package/dist/shell/Popover.js +15 -0
  34. package/dist/shell/Settings.d.ts +6 -0
  35. package/dist/shell/Settings.js +96 -0
  36. package/dist/shell/Shell.d.ts +10 -0
  37. package/dist/shell/Shell.js +622 -0
  38. package/dist/shell/Tabs.d.ts +36 -0
  39. package/dist/shell/Tabs.js +90 -0
  40. package/dist/shell/actions.d.ts +48 -0
  41. package/dist/shell/actions.js +0 -0
  42. package/dist/types/index.d.ts +37 -3
  43. package/dist/utils/config.d.ts +10 -0
  44. package/dist/utils/config.js +14 -0
  45. package/dist/utils/environments.d.ts +20 -0
  46. package/dist/utils/environments.js +74 -0
  47. package/dist/utils/i18n.d.ts +12 -0
  48. package/dist/utils/i18n.js +314 -0
  49. package/dist/utils/jsonc.d.ts +19 -0
  50. package/dist/utils/jsonc.js +74 -0
  51. package/dist/utils/keychain.d.ts +9 -0
  52. package/dist/utils/keychain.js +54 -0
  53. package/dist/utils/oauth2-auth.d.ts +64 -0
  54. package/dist/utils/oauth2-auth.js +242 -0
  55. package/dist/utils/password-prompt.d.ts +5 -0
  56. package/dist/utils/password-prompt.js +28 -0
  57. package/dist/utils/process-runner.d.ts +13 -4
  58. package/dist/utils/process-runner.js +63 -37
  59. package/dist/utils/project-detection.d.ts +90 -4
  60. package/dist/utils/project-detection.js +389 -63
  61. package/dist/utils/session-cookie-auth.d.ts +35 -0
  62. package/dist/utils/session-cookie-auth.js +99 -0
  63. package/dist/utils/sitevision-api.d.ts +72 -5
  64. package/dist/utils/sitevision-api.js +220 -38
  65. package/dist/utils/sitevision-scripts-runner.d.ts +2 -1
  66. package/dist/utils/sitevision-scripts-runner.js +18 -7
  67. package/dist/utils/tasks.d.ts +50 -0
  68. package/dist/utils/tasks.js +439 -0
  69. package/dist/utils/workspace.d.ts +17 -0
  70. package/dist/utils/workspace.js +67 -0
  71. package/package.json +5 -2
  72. package/readme.md +103 -121
@@ -13,6 +13,27 @@ import type { SigningCredentials, DeployConfig, ProductionDeployConfig, SigningR
13
13
  * Create Basic Auth header value
14
14
  */
15
15
  declare function createBasicAuth(username: string, password: string): string;
16
+ type RequestAuth = {
17
+ username: string;
18
+ password: string;
19
+ } | {
20
+ token: string;
21
+ } | {
22
+ cookie: string;
23
+ };
24
+ type AuthKind = 'basic' | 'bearer' | 'cookie';
25
+ /** Single source of the 401 message, worded for the auth kind actually used. */
26
+ declare function unauthorizedMessage(kind: AuthKind): string;
27
+ /** Pick cookie > bearer > basic based on what the deploy config carries. */
28
+ declare function configAuth(config: {
29
+ username: string;
30
+ password?: string;
31
+ accessToken?: string;
32
+ sessionCookie?: string;
33
+ }): {
34
+ auth: RequestAuth;
35
+ kind: AuthKind;
36
+ };
16
37
  /**
17
38
  * Make an HTTP/HTTPS request
18
39
  */
@@ -20,10 +41,7 @@ export declare function makeRequest(url: string, options: {
20
41
  method: string;
21
42
  headers?: Record<string, string>;
22
43
  body?: Buffer;
23
- auth?: {
24
- username: string;
25
- password: string;
26
- };
44
+ auth?: RequestAuth;
27
45
  timeoutMs?: number;
28
46
  }): Promise<{
29
47
  statusCode: number;
@@ -45,6 +63,12 @@ export declare function summarizeErrorBody(body: Buffer, headers: Record<string,
45
63
  * the signing endpoint returns an error page with HTTP 200.
46
64
  */
47
65
  export declare function looksLikeZip(body: Buffer): boolean;
66
+ /**
67
+ * A stale Sitevision session usually answers with a redirect to the login page
68
+ * or a 200 carrying an HTML login form — not a clean 401. Detect both so cookie
69
+ * auth can drop the dead session and re-login instead of showing a generic error.
70
+ */
71
+ export declare function looksLikeAuthExpired(statusCode: number, body: Buffer, headers: Record<string, string>): boolean;
48
72
  /**
49
73
  * Sign an app via developer.sitevision.se
50
74
  *
@@ -85,4 +109,47 @@ export declare function createAddon(config: DeployConfig, appType: SimpleAppType
85
109
  * @param appType - The app type (web, widget, rest)
86
110
  */
87
111
  export declare function activateApp(executableId: string, config: DeployConfig, _appType: SimpleAppType): Promise<ActivationResponse>;
88
- export { createBasicAuth };
112
+ /**
113
+ * A version uploaded to a custom module, as reported by the
114
+ * ActivateCustomModuleExecutable GET endpoint.
115
+ */
116
+ export interface Executable {
117
+ id: string;
118
+ name: string;
119
+ appIdentifier: string;
120
+ appVersion: string;
121
+ active: boolean;
122
+ }
123
+ export interface ListExecutablesResponse {
124
+ success: boolean;
125
+ executables?: Executable[];
126
+ error?: string;
127
+ authExpired?: boolean;
128
+ }
129
+ /**
130
+ * List the executables (uploaded versions) of the configured addon.
131
+ */
132
+ export declare function listExecutables(config: DeployConfig): Promise<ListExecutablesResponse>;
133
+ export interface AddonNode {
134
+ id: string;
135
+ name: string;
136
+ type: string;
137
+ appType?: SimpleAppType;
138
+ }
139
+ /**
140
+ * List the addons (custom modules) in the site's Addon Repository.
141
+ */
142
+ export declare function listAddons(config: DeployConfig): Promise<{
143
+ success: boolean;
144
+ addons?: AddonNode[];
145
+ error?: string;
146
+ }>;
147
+ /**
148
+ * Whether an addon exists, judged from a listAddons result. A failed or empty
149
+ * listing is "unknown": a dead session can look exactly like an empty site.
150
+ */
151
+ export declare function classifyAddon(listed: {
152
+ success: boolean;
153
+ addons?: AddonNode[];
154
+ }, name: string): 'missing' | 'present' | 'unknown';
155
+ export { createBasicAuth, configAuth, unauthorizedMessage };
@@ -12,7 +12,7 @@ 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
16
  // =============================================================================
17
17
  // CONSTANTS
18
18
  // =============================================================================
@@ -33,6 +33,30 @@ const RETRY_BASE_DELAY_MS = 1000;
33
33
  function createBasicAuth(username, password) {
34
34
  return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
35
35
  }
36
+ /** Single source of the 401 message, worded for the auth kind actually used. */
37
+ function unauthorizedMessage(kind) {
38
+ switch (kind) {
39
+ case 'bearer':
40
+ return 'Unauthorized. The access token was rejected or has expired.';
41
+ case 'cookie':
42
+ return 'Unauthorized. The session cookie was rejected or has expired — log in again.';
43
+ default:
44
+ return 'Unauthorized. Check username and password.';
45
+ }
46
+ }
47
+ /** Pick cookie > bearer > basic based on what the deploy config carries. */
48
+ function configAuth(config) {
49
+ if (config.sessionCookie) {
50
+ return { auth: { cookie: config.sessionCookie }, kind: 'cookie' };
51
+ }
52
+ if (config.accessToken) {
53
+ return { auth: { token: config.accessToken }, kind: 'bearer' };
54
+ }
55
+ return {
56
+ auth: { username: config.username, password: config.password ?? '' },
57
+ kind: 'basic',
58
+ };
59
+ }
36
60
  /**
37
61
  * Generate a random boundary for multipart form data
38
62
  */
@@ -71,7 +95,18 @@ export function makeRequest(url, options) {
71
95
  ...options.headers,
72
96
  };
73
97
  if (options.auth) {
74
- headers['Authorization'] = createBasicAuth(options.auth.username, options.auth.password);
98
+ if ('cookie' in options.auth) {
99
+ headers['Cookie'] = options.auth.cookie;
100
+ // Session-authenticated state-changing calls typically need this to
101
+ // pass Sitevision's CSRF guard, unlike Basic-auth requests.
102
+ headers['X-Requested-With'] ??= 'XMLHttpRequest';
103
+ }
104
+ else if ('token' in options.auth) {
105
+ headers['Authorization'] = `Bearer ${options.auth.token}`;
106
+ }
107
+ else {
108
+ headers['Authorization'] = createBasicAuth(options.auth.username, options.auth.password);
109
+ }
75
110
  }
76
111
  const requestOptions = {
77
112
  hostname: parsedUrl.hostname,
@@ -125,16 +160,36 @@ async function delay(ms) {
125
160
  */
126
161
  export function summarizeErrorBody(body, headers) {
127
162
  const contentType = headers['content-type'] ?? 'unknown';
128
- const isText = contentType.includes('text') ||
163
+ const declaredText = contentType.includes('text') ||
129
164
  contentType.includes('json') ||
130
165
  contentType.includes('xml');
131
- if (!isText) {
132
- return `(${contentType}, ${body.length} bytes)`;
166
+ const text = body.toString('utf8');
167
+ // Show the body when the server declares it text OR the bytes decode to
168
+ // mostly-printable text. Sitevision sometimes returns a text/JSON error with
169
+ // a missing or non-text content-type, and hiding it obscures the real cause.
170
+ if (declaredText || isMostlyPrintable(text)) {
171
+ const collapsed = text.replaceAll(/\s+/g, ' ').trim();
172
+ const max = 300;
173
+ const summary = collapsed.length > max ? collapsed.slice(0, max) + '…' : collapsed;
174
+ if (summary.length > 0)
175
+ return summary;
133
176
  }
134
- const text = body.toString('utf8').replaceAll(/\s+/g, ' ').trim();
135
- const max = 300;
136
- const summary = text.length > max ? text.slice(0, max) + '…' : text;
137
- return summary.length > 0 ? summary : `(${contentType}, empty body)`;
177
+ return `(${contentType}, ${body.length} bytes)`;
178
+ }
179
+ /** Heuristic: a decoded string is text if <10% of chars are control/undecodable. */
180
+ function isMostlyPrintable(text) {
181
+ if (text.length === 0)
182
+ return false;
183
+ let bad = 0;
184
+ for (const ch of text) {
185
+ const code = ch.codePointAt(0) ?? 0;
186
+ // Undecodable byte (replacement char), or a control char other than
187
+ // tab/newline/vertical-tab/form-feed/carriage-return.
188
+ if (code === 0xff_fd || code < 9 || (code > 13 && code < 32)) {
189
+ bad++;
190
+ }
191
+ }
192
+ return bad / text.length < 0.1;
138
193
  }
139
194
  /** ZIP local-file-header magic bytes: "PK\x03\x04". */
140
195
  const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
@@ -145,6 +200,31 @@ const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
145
200
  export function looksLikeZip(body) {
146
201
  return body.length >= 4 && body.subarray(0, 4).equals(ZIP_MAGIC);
147
202
  }
203
+ /**
204
+ * A stale Sitevision session usually answers with a redirect to the login page
205
+ * or a 200 carrying an HTML login form — not a clean 401. Detect both so cookie
206
+ * auth can drop the dead session and re-login instead of showing a generic error.
207
+ */
208
+ export function looksLikeAuthExpired(statusCode, body, headers) {
209
+ if (statusCode === 401)
210
+ return true;
211
+ if (statusCode >= 300 && statusCode < 400)
212
+ return true;
213
+ if (statusCode === 200) {
214
+ const contentType = headers['content-type'] ?? '';
215
+ if (contentType.includes('html'))
216
+ return true;
217
+ const head = body
218
+ .subarray(0, 64)
219
+ .toString('utf8')
220
+ .trimStart()
221
+ .toLowerCase();
222
+ if (head.startsWith('<!doctype html') || head.startsWith('<html')) {
223
+ return true;
224
+ }
225
+ }
226
+ return false;
227
+ }
148
228
  // =============================================================================
149
229
  // SIGNING API
150
230
  // =============================================================================
@@ -211,7 +291,7 @@ export async function signApp(zipPath, credentials, outputPath) {
211
291
  // Auth failures will not resolve on retry.
212
292
  return {
213
293
  success: false,
214
- error: 'Unauthorized. Check username and password.',
294
+ error: unauthorizedMessage('basic'),
215
295
  };
216
296
  }
217
297
  lastError = `Signing failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`;
@@ -256,9 +336,12 @@ export async function deployApp(zipPath, config, appType, force = false) {
256
336
  if (force) {
257
337
  url += '?force=true';
258
338
  }
259
- // Create multipart form data
339
+ // The import endpoints read the archive from a multipart part named "data"
340
+ // (per the webAppImport/restAppImport docs, and confirmed against a live
341
+ // server). Signing uses "file" against a different endpoint.
260
342
  const boundary = generateBoundary();
261
- const { body, contentType } = createMultipartFormData(zipPath, 'file', boundary);
343
+ const { body, contentType } = createMultipartFormData(zipPath, 'data', boundary);
344
+ const { auth, kind } = configAuth(config);
262
345
  try {
263
346
  const response = await makeRequest(url, {
264
347
  method: 'POST',
@@ -267,11 +350,25 @@ export async function deployApp(zipPath, config, appType, force = false) {
267
350
  'Content-Length': String(body.length),
268
351
  },
269
352
  body,
270
- auth: {
271
- username: config.username,
272
- password: config.password,
273
- },
353
+ auth,
274
354
  });
355
+ if (response.statusCode === 401) {
356
+ return {
357
+ success: false,
358
+ error: unauthorizedMessage(kind),
359
+ authExpired: true,
360
+ };
361
+ }
362
+ // A cookie session fails without a clean 401: a redirect to login or a
363
+ // 200 carrying an HTML login page. Flag it so the caller re-authenticates.
364
+ if (kind === 'cookie' &&
365
+ looksLikeAuthExpired(response.statusCode, response.body, response.headers)) {
366
+ return {
367
+ success: false,
368
+ error: unauthorizedMessage('cookie'),
369
+ authExpired: true,
370
+ };
371
+ }
275
372
  if (response.statusCode === 200) {
276
373
  // Try to parse response for executable ID
277
374
  let executableId;
@@ -288,16 +385,18 @@ export async function deployApp(zipPath, config, appType, force = false) {
288
385
  message: 'Deployment successful',
289
386
  };
290
387
  }
291
- if (response.statusCode === 401) {
388
+ if (response.statusCode === 409) {
292
389
  return {
293
390
  success: false,
294
- error: 'Unauthorized. Check username and password.',
391
+ error: 'Conflict. Addon already exists. Use --force to overwrite.',
295
392
  };
296
393
  }
297
- if (response.statusCode === 409) {
394
+ if (response.statusCode === 400 &&
395
+ response.body.toString().includes('could not resolve context node')) {
298
396
  return {
299
397
  success: false,
300
- error: 'Conflict. Addon already exists. Use --force to overwrite.',
398
+ error: `Deployment failed with status 400: ${summarizeErrorBody(response.body, response.headers)}`,
399
+ contextNodeMissing: true,
301
400
  };
302
401
  }
303
402
  return {
@@ -354,10 +453,12 @@ export async function deployProduction(signedZipPath, config, appType) {
354
453
  */
355
454
  export async function createAddon(config, appType) {
356
455
  const url = buildAddonEndpointUrl(config.domain, config.siteName, appType, config.useHTTP);
357
- const body = JSON.stringify({
358
- name: config.addonName,
359
- category: 'Other',
360
- });
456
+ const payload = { name: config.addonName };
457
+ // Only the custommodule endpoints take (and require) a category.
458
+ if (appType === 'web' || appType === 'widget')
459
+ payload['category'] = 'Other';
460
+ const body = JSON.stringify(payload);
461
+ const { auth, kind } = configAuth(config);
361
462
  try {
362
463
  const response = await makeRequest(url, {
363
464
  method: 'POST',
@@ -366,10 +467,7 @@ export async function createAddon(config, appType) {
366
467
  'Content-Length': String(Buffer.byteLength(body)),
367
468
  },
368
469
  body: Buffer.from(body),
369
- auth: {
370
- username: config.username,
371
- password: config.password,
372
- },
470
+ auth,
373
471
  });
374
472
  if (response.statusCode === 200 || response.statusCode === 201) {
375
473
  let addonId;
@@ -388,7 +486,8 @@ export async function createAddon(config, appType) {
388
486
  if (response.statusCode === 401) {
389
487
  return {
390
488
  success: false,
391
- error: 'Unauthorized. Check username and password.',
489
+ error: unauthorizedMessage(kind),
490
+ authExpired: true,
392
491
  };
393
492
  }
394
493
  if (response.statusCode === 409) {
@@ -419,9 +518,9 @@ export async function createAddon(config, appType) {
419
518
  export async function activateApp(executableId, config, _appType) {
420
519
  const protocol = config.useHTTP ? 'http' : 'https';
421
520
  const url = `${protocol}://${config.domain}/rest-api/1/0/${encodeURIComponent(config.siteName)}/Addon%20Repository/${encodeURIComponent(config.addonName)}/activateCustomModuleExecutable`;
422
- const body = JSON.stringify({
423
- executableId,
424
- });
521
+ // Property name per the ActivateCustomModuleExecutable PUT docs.
522
+ const body = JSON.stringify({ customModuleExecutableId: executableId });
523
+ const { auth, kind } = configAuth(config);
425
524
  try {
426
525
  const response = await makeRequest(url, {
427
526
  method: 'PUT',
@@ -430,10 +529,7 @@ export async function activateApp(executableId, config, _appType) {
430
529
  'Content-Length': String(Buffer.byteLength(body)),
431
530
  },
432
531
  body: Buffer.from(body),
433
- auth: {
434
- username: config.username,
435
- password: config.password,
436
- },
532
+ auth,
437
533
  });
438
534
  if (response.statusCode === 200) {
439
535
  return { success: true };
@@ -441,7 +537,8 @@ export async function activateApp(executableId, config, _appType) {
441
537
  if (response.statusCode === 401) {
442
538
  return {
443
539
  success: false,
444
- error: 'Unauthorized. Check username and password.',
540
+ error: unauthorizedMessage(kind),
541
+ authExpired: true,
445
542
  };
446
543
  }
447
544
  return {
@@ -456,7 +553,92 @@ export async function activateApp(executableId, config, _appType) {
456
553
  };
457
554
  }
458
555
  }
556
+ /**
557
+ * List the executables (uploaded versions) of the configured addon.
558
+ */
559
+ export async function listExecutables(config) {
560
+ const protocol = config.useHTTP ? 'http' : 'https';
561
+ const url = `${protocol}://${config.domain}/rest-api/1/0/${encodeURIComponent(config.siteName)}/Addon%20Repository/${encodeURIComponent(config.addonName)}/activateCustomModuleExecutable`;
562
+ const { auth, kind } = configAuth(config);
563
+ try {
564
+ const response = await makeRequest(url, { method: 'GET', auth });
565
+ if (response.statusCode === 401) {
566
+ return {
567
+ success: false,
568
+ error: unauthorizedMessage(kind),
569
+ authExpired: true,
570
+ };
571
+ }
572
+ if (response.statusCode !== 200) {
573
+ return {
574
+ success: false,
575
+ error: `Listing versions failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
576
+ };
577
+ }
578
+ const data = JSON.parse(response.body.toString());
579
+ return { success: true, executables: data.executables ?? [] };
580
+ }
581
+ catch (error) {
582
+ return {
583
+ success: false,
584
+ error: `Listing versions failed: ${error instanceof Error ? error.message : String(error)}`,
585
+ };
586
+ }
587
+ }
588
+ const ADDON_TYPES = {
589
+ 'sv:customModule': 'web',
590
+ 'sv:marketplaceCustomModule': 'web',
591
+ 'sv:widgetCustomModule': 'widget',
592
+ 'sv:marketplaceWidgetCustomModule': 'widget',
593
+ 'sv:headlessCustomModule': 'rest',
594
+ 'sv:marketplaceHeadlessCustomModule': 'rest',
595
+ 'sv:mcpServerCustomModule': 'mcp',
596
+ };
597
+ /**
598
+ * List the addons (custom modules) in the site's Addon Repository.
599
+ */
600
+ export async function listAddons(config) {
601
+ const url = `${buildApiBaseUrl(config.domain, config.siteName, config.useHTTP)}/Addon%20Repository/nodes`;
602
+ const { auth, kind } = configAuth(config);
603
+ try {
604
+ const response = await makeRequest(url, { method: 'GET', auth });
605
+ if (response.statusCode === 401) {
606
+ return { success: false, error: unauthorizedMessage(kind) };
607
+ }
608
+ if (response.statusCode !== 200) {
609
+ return {
610
+ success: false,
611
+ error: `Listing addons failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
612
+ };
613
+ }
614
+ const nodes = JSON.parse(response.body.toString());
615
+ return {
616
+ success: true,
617
+ addons: nodes
618
+ .filter(node => Object.hasOwn(ADDON_TYPES, node.type))
619
+ .map(node => ({ ...node, appType: ADDON_TYPES[node.type] })),
620
+ };
621
+ }
622
+ catch (error) {
623
+ return {
624
+ success: false,
625
+ error: `Listing addons failed: ${error instanceof Error ? error.message : String(error)}`,
626
+ };
627
+ }
628
+ }
629
+ /**
630
+ * Whether an addon exists, judged from a listAddons result. A failed or empty
631
+ * listing is "unknown": a dead session can look exactly like an empty site.
632
+ */
633
+ export function classifyAddon(listed, name) {
634
+ if (!listed.success || !listed.addons?.length)
635
+ return 'unknown';
636
+ const wanted = name.toLowerCase();
637
+ return listed.addons.some(addon => addon.name.toLowerCase() === wanted)
638
+ ? 'present'
639
+ : 'missing';
640
+ }
459
641
  // =============================================================================
460
642
  // HELPER EXPORTS
461
643
  // =============================================================================
462
- export { createBasicAuth };
644
+ export { createBasicAuth, configAuth, unauthorizedMessage };
@@ -79,5 +79,6 @@ export interface SitevisionBuildResult {
79
79
  *
80
80
  * @param projectRoot - Project root directory (used as cwd)
81
81
  * @param onOutput - Optional callback for streaming output chunks
82
+ * @param signal - Aborting kills the build and everything it started
82
83
  */
83
- export declare function runSitevisionScriptsBuild(projectRoot: string, onOutput?: (chunk: string) => void): Promise<SitevisionBuildResult>;
84
+ export declare function runSitevisionScriptsBuild(projectRoot: string, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<SitevisionBuildResult>;
@@ -17,7 +17,7 @@
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
21
  /**
22
22
  * Resolve the path to the sitevision-scripts CLI entry inside a project.
23
23
  * Returns null if the package is not installed.
@@ -145,8 +145,9 @@ const MAX_OUTPUT_CHARS = 50_000;
145
145
  *
146
146
  * @param projectRoot - Project root directory (used as cwd)
147
147
  * @param onOutput - Optional callback for streaming output chunks
148
+ * @param signal - Aborting kills the build and everything it started
148
149
  */
149
- export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
150
+ export async function runSitevisionScriptsBuild(projectRoot, onOutput, signal) {
150
151
  const bin = getSitevisionScriptsBin(projectRoot);
151
152
  if (!bin) {
152
153
  return {
@@ -157,10 +158,16 @@ export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
157
158
  }
158
159
  return new Promise(resolve => {
159
160
  let output = '';
160
- const child = spawn(process.execPath, [bin, 'build'], {
161
+ const child = spawnChild(process.execPath, [bin, 'build'], {
161
162
  cwd: projectRoot,
162
163
  stdio: ['ignore', 'pipe', 'pipe'],
163
164
  });
165
+ const abort = () => {
166
+ killChild(child);
167
+ };
168
+ signal?.addEventListener('abort', abort, { once: true });
169
+ if (signal?.aborted)
170
+ abort();
164
171
  const handleData = (data) => {
165
172
  const text = data.toString();
166
173
  output += text;
@@ -175,12 +182,16 @@ export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
175
182
  resolve({ success: false, output, error: error.message });
176
183
  });
177
184
  child.on('close', code => {
185
+ signal?.removeEventListener('abort', abort);
186
+ const stopped = signal?.aborted ?? false;
178
187
  resolve({
179
- success: code === 0,
188
+ success: code === 0 && !stopped,
180
189
  output,
181
- error: code === 0
182
- ? undefined
183
- : `sitevision-scripts build exited with code ${code}`,
190
+ error: stopped
191
+ ? 'Build stopped.'
192
+ : code === 0
193
+ ? undefined
194
+ : `sitevision-scripts build exited with code ${code}`,
184
195
  });
185
196
  });
186
197
  });
@@ -0,0 +1,50 @@
1
+ import type { ProjectInfo, SigningCredentials, DeployConfig } from '../types/index.js';
2
+ export type TaskKind = 'dev' | 'watch' | 'build' | 'sign' | 'deploy' | 'activate' | 'install';
3
+ export type TaskStatus = 'running' | 'success' | 'error' | 'stopped';
4
+ export type LogLevel = 'info' | 'ok' | 'warn' | 'error';
5
+ export interface LogLine {
6
+ time: number;
7
+ tag: string;
8
+ level: LogLevel;
9
+ text: string;
10
+ }
11
+ export interface Task {
12
+ id: number;
13
+ kind: TaskKind;
14
+ appRoot: string;
15
+ appName: string;
16
+ label: string;
17
+ status: TaskStatus;
18
+ phase: string;
19
+ startedAt: number;
20
+ endedAt?: number;
21
+ lines: LogLine[];
22
+ error?: string;
23
+ stop: () => void;
24
+ }
25
+ export declare function getTasks(): Task[];
26
+ export declare function useTasks(): Task[];
27
+ export declare function runningTasks(appRoot?: string): Task[];
28
+ export declare function clearFinished(): void;
29
+ export interface DeployOptions {
30
+ force?: boolean;
31
+ production?: boolean;
32
+ activate?: boolean;
33
+ onAddonMissing?: (addonName: string) => Promise<boolean>;
34
+ }
35
+ export declare function startBuild(project: ProjectInfo): Task;
36
+ export declare function startSign(project: ProjectInfo, credentials: SigningCredentials): Task;
37
+ export declare function startDeploy(project: ProjectInfo, config: DeployConfig, options: DeployOptions): Task;
38
+ export declare function startActivate(project: ProjectInfo, config: DeployConfig, executableId: string, versionLabel: string): Task;
39
+ export declare function startInstall(project: ProjectInfo): Task;
40
+ export interface DevOptions {
41
+ deploy: boolean;
42
+ signingCredentials?: SigningCredentials;
43
+ deployConfig?: DeployConfig;
44
+ onAddonMissing?: DeployOptions['onAddonMissing'];
45
+ }
46
+ /**
47
+ * Dev / watch loop: build on every source change, then optionally sign and
48
+ * deploy. Runs until `task.stop()`; the task stays in the registry meanwhile.
49
+ */
50
+ export declare function startDev(project: ProjectInfo, options: DevOptions): Task;