sitevision-cli 1.0.0-beta.2 → 1.0.0-beta.21

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 (63) hide show
  1. package/dist/app.d.ts +1 -1
  2. package/dist/app.js +59 -8
  3. package/dist/cli.js +96 -39
  4. package/dist/commands/build.js +1 -1
  5. package/dist/commands/deploy.d.ts +2 -2
  6. package/dist/commands/deploy.js +135 -25
  7. package/dist/commands/dev.d.ts +8 -10
  8. package/dist/commands/dev.js +77 -366
  9. package/dist/commands/info.js +2 -2
  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/InfoScreen.js +2 -2
  17. package/dist/components/MainMenu.js +7 -2
  18. package/dist/components/PasswordInput.js +2 -1
  19. package/dist/components/SetupFlow.d.ts +2 -1
  20. package/dist/components/SetupFlow.js +100 -11
  21. package/dist/shell/AddonPicker.d.ts +14 -0
  22. package/dist/shell/AddonPicker.js +54 -0
  23. package/dist/shell/CommandPalette.d.ts +8 -0
  24. package/dist/shell/CommandPalette.js +63 -0
  25. package/dist/shell/ConfigForm.d.ts +36 -0
  26. package/dist/shell/ConfigForm.js +558 -0
  27. package/dist/shell/Frame.d.ts +59 -0
  28. package/dist/shell/Frame.js +134 -0
  29. package/dist/shell/Settings.d.ts +6 -0
  30. package/dist/shell/Settings.js +96 -0
  31. package/dist/shell/Shell.d.ts +9 -0
  32. package/dist/shell/Shell.js +586 -0
  33. package/dist/shell/Tabs.d.ts +36 -0
  34. package/dist/shell/Tabs.js +90 -0
  35. package/dist/shell/actions.d.ts +45 -0
  36. package/dist/shell/actions.js +0 -0
  37. package/dist/types/index.d.ts +44 -5
  38. package/dist/utils/config.d.ts +10 -0
  39. package/dist/utils/config.js +14 -0
  40. package/dist/utils/environments.d.ts +20 -0
  41. package/dist/utils/environments.js +74 -0
  42. package/dist/utils/i18n.d.ts +12 -0
  43. package/dist/utils/i18n.js +279 -0
  44. package/dist/utils/jsonc.d.ts +19 -0
  45. package/dist/utils/jsonc.js +74 -0
  46. package/dist/utils/keychain.d.ts +9 -0
  47. package/dist/utils/keychain.js +54 -0
  48. package/dist/utils/oauth2-auth.d.ts +64 -0
  49. package/dist/utils/oauth2-auth.js +242 -0
  50. package/dist/utils/password-prompt.d.ts +5 -0
  51. package/dist/utils/password-prompt.js +28 -0
  52. package/dist/utils/project-detection.d.ts +105 -6
  53. package/dist/utils/project-detection.js +411 -54
  54. package/dist/utils/session-cookie-auth.d.ts +35 -0
  55. package/dist/utils/session-cookie-auth.js +99 -0
  56. package/dist/utils/sitevision-api.d.ts +64 -5
  57. package/dist/utils/sitevision-api.js +195 -33
  58. package/dist/utils/tasks.d.ts +48 -0
  59. package/dist/utils/tasks.js +371 -0
  60. package/dist/utils/workspace.d.ts +17 -0
  61. package/dist/utils/workspace.js +67 -0
  62. package/package.json +3 -1
  63. package/readme.md +102 -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,39 @@ 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
+ 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,12 +385,6 @@ export async function deployApp(zipPath, config, appType, force = false) {
288
385
  message: 'Deployment successful',
289
386
  };
290
387
  }
291
- if (response.statusCode === 401) {
292
- return {
293
- success: false,
294
- error: 'Unauthorized. Check username and password.',
295
- };
296
- }
297
388
  if (response.statusCode === 409) {
298
389
  return {
299
390
  success: false,
@@ -358,6 +449,7 @@ export async function createAddon(config, appType) {
358
449
  name: config.addonName,
359
450
  category: 'Other',
360
451
  });
452
+ const { auth, kind } = configAuth(config);
361
453
  try {
362
454
  const response = await makeRequest(url, {
363
455
  method: 'POST',
@@ -366,10 +458,7 @@ export async function createAddon(config, appType) {
366
458
  'Content-Length': String(Buffer.byteLength(body)),
367
459
  },
368
460
  body: Buffer.from(body),
369
- auth: {
370
- username: config.username,
371
- password: config.password,
372
- },
461
+ auth,
373
462
  });
374
463
  if (response.statusCode === 200 || response.statusCode === 201) {
375
464
  let addonId;
@@ -388,7 +477,8 @@ export async function createAddon(config, appType) {
388
477
  if (response.statusCode === 401) {
389
478
  return {
390
479
  success: false,
391
- error: 'Unauthorized. Check username and password.',
480
+ error: unauthorizedMessage(kind),
481
+ authExpired: true,
392
482
  };
393
483
  }
394
484
  if (response.statusCode === 409) {
@@ -422,6 +512,7 @@ export async function activateApp(executableId, config, _appType) {
422
512
  const body = JSON.stringify({
423
513
  executableId,
424
514
  });
515
+ const { auth, kind } = configAuth(config);
425
516
  try {
426
517
  const response = await makeRequest(url, {
427
518
  method: 'PUT',
@@ -430,10 +521,7 @@ export async function activateApp(executableId, config, _appType) {
430
521
  'Content-Length': String(Buffer.byteLength(body)),
431
522
  },
432
523
  body: Buffer.from(body),
433
- auth: {
434
- username: config.username,
435
- password: config.password,
436
- },
524
+ auth,
437
525
  });
438
526
  if (response.statusCode === 200) {
439
527
  return { success: true };
@@ -441,7 +529,8 @@ export async function activateApp(executableId, config, _appType) {
441
529
  if (response.statusCode === 401) {
442
530
  return {
443
531
  success: false,
444
- error: 'Unauthorized. Check username and password.',
532
+ error: unauthorizedMessage(kind),
533
+ authExpired: true,
445
534
  };
446
535
  }
447
536
  return {
@@ -456,7 +545,80 @@ export async function activateApp(executableId, config, _appType) {
456
545
  };
457
546
  }
458
547
  }
548
+ /**
549
+ * List the executables (uploaded versions) of the configured addon.
550
+ */
551
+ export async function listExecutables(config) {
552
+ const protocol = config.useHTTP ? 'http' : 'https';
553
+ const url = `${protocol}://${config.domain}/rest-api/1/0/${encodeURIComponent(config.siteName)}/Addon%20Repository/${encodeURIComponent(config.addonName)}/activateCustomModuleExecutable`;
554
+ const { auth, kind } = configAuth(config);
555
+ try {
556
+ const response = await makeRequest(url, { method: 'GET', auth });
557
+ if (response.statusCode === 401) {
558
+ return {
559
+ success: false,
560
+ error: unauthorizedMessage(kind),
561
+ authExpired: true,
562
+ };
563
+ }
564
+ if (response.statusCode !== 200) {
565
+ return {
566
+ success: false,
567
+ error: `Listing versions failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
568
+ };
569
+ }
570
+ const data = JSON.parse(response.body.toString());
571
+ return { success: true, executables: data.executables ?? [] };
572
+ }
573
+ catch (error) {
574
+ return {
575
+ success: false,
576
+ error: `Listing versions failed: ${error instanceof Error ? error.message : String(error)}`,
577
+ };
578
+ }
579
+ }
580
+ const ADDON_TYPES = {
581
+ 'sv:customModule': 'web',
582
+ 'sv:marketplaceCustomModule': 'web',
583
+ 'sv:widgetCustomModule': 'widget',
584
+ 'sv:marketplaceWidgetCustomModule': 'widget',
585
+ 'sv:headlessCustomModule': 'rest',
586
+ 'sv:marketplaceHeadlessCustomModule': 'rest',
587
+ 'sv:mcpServerCustomModule': 'mcp',
588
+ };
589
+ /**
590
+ * List the addons (custom modules) in the site's Addon Repository.
591
+ */
592
+ export async function listAddons(config) {
593
+ const url = `${buildApiBaseUrl(config.domain, config.siteName, config.useHTTP)}/Addon%20Repository/nodes`;
594
+ const { auth, kind } = configAuth(config);
595
+ try {
596
+ const response = await makeRequest(url, { method: 'GET', auth });
597
+ if (response.statusCode === 401) {
598
+ return { success: false, error: unauthorizedMessage(kind) };
599
+ }
600
+ if (response.statusCode !== 200) {
601
+ return {
602
+ success: false,
603
+ error: `Listing addons failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
604
+ };
605
+ }
606
+ const nodes = JSON.parse(response.body.toString());
607
+ return {
608
+ success: true,
609
+ addons: nodes
610
+ .filter(node => Object.hasOwn(ADDON_TYPES, node.type))
611
+ .map(node => ({ ...node, appType: ADDON_TYPES[node.type] })),
612
+ };
613
+ }
614
+ catch (error) {
615
+ return {
616
+ success: false,
617
+ error: `Listing addons failed: ${error instanceof Error ? error.message : String(error)}`,
618
+ };
619
+ }
620
+ }
459
621
  // =============================================================================
460
622
  // HELPER EXPORTS
461
623
  // =============================================================================
462
- export { createBasicAuth };
624
+ export { createBasicAuth, configAuth, unauthorizedMessage };
@@ -0,0 +1,48 @@
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
+ }
34
+ export declare function startBuild(project: ProjectInfo): Task;
35
+ export declare function startSign(project: ProjectInfo, credentials: SigningCredentials): Task;
36
+ export declare function startDeploy(project: ProjectInfo, config: DeployConfig, options: DeployOptions): Task;
37
+ export declare function startActivate(project: ProjectInfo, config: DeployConfig, executableId: string, versionLabel: string): Task;
38
+ export declare function startInstall(project: ProjectInfo): Task;
39
+ export interface DevOptions {
40
+ deploy: boolean;
41
+ signingCredentials?: SigningCredentials;
42
+ deployConfig?: DeployConfig;
43
+ }
44
+ /**
45
+ * Dev / watch loop: build on every source change, then optionally sign and
46
+ * deploy. Runs until `task.stop()`; the task stays in the registry meanwhile.
47
+ */
48
+ export declare function startDev(project: ProjectInfo, options: DevOptions): Task;