sitevision-cli 1.0.0-beta.32 → 1.0.0-beta.33

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 (108) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.js +56 -35
  4. package/dist/commands/TaskScreen.js +32 -0
  5. package/dist/commands/build.js +6 -185
  6. package/dist/commands/deploy.js +85 -238
  7. package/dist/commands/dev.js +34 -37
  8. package/dist/commands/info.js +1 -1
  9. package/dist/commands/setup-signing.js +43 -28
  10. package/dist/commands/sign.js +9 -86
  11. package/dist/commands/use-finish.js +17 -0
  12. package/dist/commands/watch.js +0 -8
  13. package/dist/components/AuthLoginScreen.js +27 -12
  14. package/dist/shell/ConfigForm.js +90 -29
  15. package/dist/shell/Frame.js +4 -1
  16. package/dist/shell/Help.js +2 -1
  17. package/dist/shell/Settings.js +59 -6
  18. package/dist/shell/Shell.js +124 -41
  19. package/dist/shell/Tabs.js +8 -2
  20. package/dist/shell/actions.js +0 -0
  21. package/dist/utils/config.js +46 -3
  22. package/dist/utils/create-app.js +9 -2
  23. package/dist/utils/i18n.js +36 -13
  24. package/dist/utils/jsonc.js +2 -1
  25. package/dist/utils/keychain.js +44 -4
  26. package/dist/utils/oauth2-auth.js +35 -7
  27. package/dist/utils/password-prompt.js +23 -3
  28. package/dist/utils/process-runner.js +4 -2
  29. package/dist/utils/project-detection.js +84 -33
  30. package/dist/utils/session-cookie-auth.js +5 -4
  31. package/dist/utils/sitevision-api.js +21 -14
  32. package/dist/utils/sitevision-scripts-runner.js +32 -15
  33. package/dist/utils/tasks.js +130 -64
  34. package/dist/utils/version-check.js +3 -3
  35. package/dist/utils/webpack-runner.js +6 -5
  36. package/dist/utils/workspace.js +41 -3
  37. package/dist/utils/zip.js +18 -19
  38. package/package.json +24 -3
  39. package/readme.md +11 -4
  40. package/dist/app.d.ts +0 -6
  41. package/dist/app.js +0 -321
  42. package/dist/cli.d.ts +0 -2
  43. package/dist/commands/build.d.ts +0 -12
  44. package/dist/commands/deploy.d.ts +0 -17
  45. package/dist/commands/dev.d.ts +0 -14
  46. package/dist/commands/index.d.ts +0 -4
  47. package/dist/commands/info.d.ts +0 -2
  48. package/dist/commands/setup-signing.d.ts +0 -2
  49. package/dist/commands/sign.d.ts +0 -14
  50. package/dist/commands/types.d.ts +0 -18
  51. package/dist/commands/watch.d.ts +0 -2
  52. package/dist/components/AnimatedLogo.d.ts +0 -10
  53. package/dist/components/AuthLoginScreen.d.ts +0 -21
  54. package/dist/components/DevPropertiesForm.d.ts +0 -11
  55. package/dist/components/DevPropertiesForm.js +0 -246
  56. package/dist/components/InfoScreen.d.ts +0 -7
  57. package/dist/components/InfoScreen.js +0 -14
  58. package/dist/components/KeychainPasswordChoice.d.ts +0 -8
  59. package/dist/components/KeychainPasswordChoice.js +0 -30
  60. package/dist/components/MainMenu.d.ts +0 -7
  61. package/dist/components/MainMenu.js +0 -88
  62. package/dist/components/PasswordInput.d.ts +0 -10
  63. package/dist/components/ProcessOutput.d.ts +0 -6
  64. package/dist/components/ProcessOutput.js +0 -30
  65. package/dist/components/SetupFlow.d.ts +0 -8
  66. package/dist/components/SetupFlow.js +0 -233
  67. package/dist/components/SigningPropertiesForm.d.ts +0 -7
  68. package/dist/components/SigningPropertiesForm.js +0 -38
  69. package/dist/components/StatusIndicator.d.ts +0 -8
  70. package/dist/components/TextInput.d.ts +0 -10
  71. package/dist/components/TextInput.js +0 -29
  72. package/dist/components/WelcomeScreen.d.ts +0 -14
  73. package/dist/scaffold/inquirer-stub.d.ts +0 -6
  74. package/dist/scaffold/register.d.ts +0 -1
  75. package/dist/shell/AddonPicker.d.ts +0 -14
  76. package/dist/shell/Changelog.d.ts +0 -10
  77. package/dist/shell/CommandPalette.d.ts +0 -12
  78. package/dist/shell/ConfigForm.d.ts +0 -42
  79. package/dist/shell/Frame.d.ts +0 -59
  80. package/dist/shell/Help.d.ts +0 -9
  81. package/dist/shell/Popover.d.ts +0 -12
  82. package/dist/shell/Settings.d.ts +0 -6
  83. package/dist/shell/Shell.d.ts +0 -11
  84. package/dist/shell/Tabs.d.ts +0 -36
  85. package/dist/shell/actions.d.ts +0 -49
  86. package/dist/types/index.d.ts +0 -295
  87. package/dist/utils/branding.d.ts +0 -28
  88. package/dist/utils/config.d.ts +0 -27
  89. package/dist/utils/create-app.d.ts +0 -53
  90. package/dist/utils/environments.d.ts +0 -20
  91. package/dist/utils/i18n.d.ts +0 -12
  92. package/dist/utils/jsonc.d.ts +0 -19
  93. package/dist/utils/keychain.d.ts +0 -15
  94. package/dist/utils/oauth2-auth.d.ts +0 -64
  95. package/dist/utils/password-prompt.d.ts +0 -14
  96. package/dist/utils/process-runner.d.ts +0 -32
  97. package/dist/utils/project-detection.d.ts +0 -210
  98. package/dist/utils/session-cookie-auth.d.ts +0 -35
  99. package/dist/utils/signing-password.d.ts +0 -12
  100. package/dist/utils/signing-step.d.ts +0 -15
  101. package/dist/utils/signing-step.js +0 -16
  102. package/dist/utils/sitevision-api.d.ts +0 -155
  103. package/dist/utils/sitevision-scripts-runner.d.ts +0 -84
  104. package/dist/utils/tasks.d.ts +0 -61
  105. package/dist/utils/version-check.d.ts +0 -9
  106. package/dist/utils/webpack-runner.d.ts +0 -76
  107. package/dist/utils/workspace.d.ts +0 -17
  108. package/dist/utils/zip.d.ts +0 -71
@@ -45,7 +45,7 @@ const sv = {
45
45
  auth: 'auth',
46
46
  'signing user': 'signeringsanv.',
47
47
  'not set': 'ej satt',
48
- '↑ root': '↑ rot',
48
+ '↑ root': '↑ root',
49
49
  ' · bundled': ' · paketerad',
50
50
  deps: 'beroenden',
51
51
  config: 'konfig',
@@ -54,16 +54,16 @@ const sv = {
54
54
  scripts: 'scripts',
55
55
  'missing · i to install': 'saknas · i för att installera',
56
56
  'not installed · i to install': 'ej installerat · i för att installera',
57
- 'dev properties': 'dev-egenskaper',
57
+ 'dev properties': 'dev_properties',
58
58
  'missing · e to edit': 'saknas · e för att redigera',
59
59
  '{n} diffs · y to apply': '{n} skillnader · y för att tillämpa',
60
60
  '1 diff · y to apply': '1 skillnad · y för att tillämpa',
61
61
  'missing · / set up signing': 'saknas · / konfigurera signering',
62
62
  'not installed': 'ej installerat',
63
- '⚠ plaintext password in .dev_properties.json · / migrate': '⚠ lösenord i klartext i .dev_properties.json · / migrera',
63
+ '⚠ plaintext password in .dev_properties.json · / migrate': '⚠ lösenord i klartext funna i .dev_properties.json · / migrera',
64
64
  RECENT: 'SENASTE',
65
65
  '⚠ Configure dev properties first (e).': '⚠ Konfigurera dev-egenskaper först (e).',
66
- 'Press R to fetch versions from {domain}.': 'Tryck R för att hämta versioner från {domain}.',
66
+ 'Press r to fetch versions from {domain}.': 'Tryck r för att hämta versioner från {domain}.',
67
67
  'APP IDENTIFIER': 'APP-ID',
68
68
  VERSION: 'VERSION',
69
69
  ACTIVE: 'AKTIV',
@@ -89,13 +89,13 @@ const sv = {
89
89
  Watch: 'Watch',
90
90
  Build: 'Build',
91
91
  Sign: 'Sign',
92
- Deploy: 'Driftsätt',
93
- 'Deploy (force)': 'Driftsätt (tvinga)',
92
+ Deploy: 'Deploy',
93
+ 'Deploy (force)': 'Deploy (force)',
94
94
  'to {env}': 'till {env}',
95
95
  'Switch environment': 'Byt miljö',
96
96
  'Add environment': 'Lägg till miljö',
97
97
  Help: 'Hjälp',
98
- 'every key in one place': 'alla tangenter på ett ställe',
98
+ 'every key in one place': 'alla kortkommandon på ett ställe',
99
99
  Keys: 'Tangenter',
100
100
  'Esc close': 'Esc stäng',
101
101
  Here: 'Här',
@@ -129,7 +129,7 @@ const sv = {
129
129
  'Addon {addon} does not exist on {domain}. Create it and deploy again?': 'Tillägget {addon} finns inte på {domain}. Skapa det och driftsätt igen?',
130
130
  'e.g. test or prod, overriding domain and auth': 't.ex. test eller prod, med egen domän och auth',
131
131
  'Environment name (e.g. test, prod)': 'Miljönamn (t.ex. test, prod)',
132
- 'Deploy the signed {id} to {env} and activate it?': 'Driftsätt signerade {id} till {env} och aktivera?',
132
+ 'Deploy the signed {id} to {env} (addon {addon}) and activate it?': 'Driftsätt signerade {id} till {env} (addon {addon}) och aktivera?',
133
133
  'Dev never deploys to a production environment ({env}). Switch with v.': 'Dev driftsätter aldrig till en produktionsmiljö ({env}). Byt med v.',
134
134
  'switched to {env}': 'bytte till {env}',
135
135
  'environment {env} added': 'miljön {env} tillagd',
@@ -137,9 +137,16 @@ const sv = {
137
137
  production: 'produktion',
138
138
  env: 'miljö',
139
139
  '↑ dev': '↑ dev',
140
- 'Activate a remote version': 'Aktivera en fjärrversion',
141
- 'Stop running task': 'Stoppa pågående uppgift',
140
+ 'Activate a remote version': 'Aktivera en uppladdad version',
141
+ 'Stop running task': 'Stoppa pågående körning',
142
142
  'Edit config': 'Redigera konfig',
143
+ 'New app': 'Ny app',
144
+ 'Skipped: {reason}': 'Hoppade över: {reason}',
145
+ 'Skipped {n} apps. First: {reason}': 'Hoppade över {n} appar. Första: {reason}',
146
+ 'A new app is still being created. Stop it and quit?': 'En ny app håller på att skapas. Avbryta den och avsluta?',
147
+ 'An app is already being created.': 'En app håller redan på att skapas.',
148
+ 'Could not save to the OS keychain; you will be asked again next time.': 'Kunde inte spara i nyckelringen; du får frågan igen nästa gång.',
149
+ Navigator: 'Navigering',
143
150
  'Sync package.json': 'Synka package.json',
144
151
  'Migrate password to OS keychain': 'Flytta lösenord till nyckelringen',
145
152
  'Install dependencies': 'Installera beroenden',
@@ -187,7 +194,7 @@ const sv = {
187
194
  'Auth method': 'Auth-metod',
188
195
  Password: 'Lösenord',
189
196
  'OAuth2 client id': 'OAuth2 klient-id',
190
- 'Authorization endpoint': 'Auktoriseringsendpoint',
197
+ 'Authorization endpoint': 'Authorization url',
191
198
  'Token endpoint': 'Token-endpoint',
192
199
  Scopes: 'Scopes',
193
200
  'Client secret': 'Klienthemlighet',
@@ -197,7 +204,7 @@ const sv = {
197
204
  Certificate: 'Certifikat',
198
205
  'Signing password': 'Signeringslösenord',
199
206
  '^O pick from repo': '^O välj från förråd',
200
- 'Space-separated scopes to request. ALL grants the Sitevision API and offline_access adds a refresh token so later runs log in silently. Match the casing your client expects.': 'Scopes att begära, separerade med mellanslag. ALL ger Sitevision-API:et och offline_access ger en refresh-token så att senare körningar loggar in tyst. Använd det skiftläge klienten förväntar sig.',
207
+ 'Space-separated scopes to request. ALL grants the Sitevision API and offline_access adds a refresh token so later runs log in silently. Match the casing your client expects.': 'Scopes att begära, separerade med mellanslag. ALL ger Sitevision-API:et och offline_access ger en refresh-token så att senare körningar loggar in tyst. Skriftlägesskänsligt.',
201
208
  'blank = site root': 'tomt = webbplatsens rot',
202
209
  'required for signed deploys': 'krävs för signerad driftsättning',
203
210
  'blank = prompt each run': 'tomt = fråga varje gång',
@@ -306,7 +313,9 @@ const sv = {
306
313
  // Config help
307
314
  "Domain of this environment's site (USE or TSE) without https://, e.g. myorg-use.sitevision-cloud.se. Deploys and version lookups go here.": 'Domän för den här miljöns webbplats (USE eller TSE) utan https://, t.ex. myorg-use.sitevision-cloud.se. Driftsättningar och versionslistor går hit.',
308
315
  "Name of the site's root node in Sitevision, exactly as shown in the site tree. It becomes part of the REST API path.": 'Namnet på webbplatsens rotnod i Sitevision, exakt som i webbplatsträdet. Det blir en del av REST API-sökvägen.',
309
- "Name of the addon (custom module) in the site's Addon Repository that this app is uploaded into. Ctrl+O lists the existing ones.": 'Namnet på tillägget (custom module) i webbplatsens tilläggsförråd som appen laddas upp till. Ctrl+O listar befintliga.',
316
+ "Name of the addon (custom module) in the site's Addon Repository that this app is uploaded into. Ctrl+O lists the existing ones. It is separate from the manifest name: to make them match, rename the addon in Sitevision (Addons, General, Settings) and pick it again with Ctrl+O. For a RESTApp the addon name is part of its endpoint URL.": 'Namnet på tillägget (custom module) i webbplatsens tilläggsförråd som appen laddas upp till. Ctrl+O listar befintliga. Det är skilt från manifestets namn: för att få dem att stämma, byt namn på tillägget i Sitevision (Tillägg, Allmänt, Inställningar) och välj det igen med Ctrl+O. För en RESTApp ingår tilläggets namn i dess endpoint-URL.',
317
+ '≠ manifest': '≠ manifest',
318
+ 'addon name {addon} · manifest name {names}': 'tilläggsnamn {addon} · manifestnamn {names}',
310
319
  'Sitevision account used for deploys, usually your Sitevision Cloud e-mail. It needs DEVELOPER or MANAGE_ADDONS permission on the site. Required for basic auth; with oauth2 or cookie it only labels the stored credential.': 'Sitevision-konto som används för driftsättning, oftast din Sitevision Cloud-e-post. Behöver DEVELOPER eller MANAGE_ADDONS på webbplatsen. Krävs för basic; med oauth2 eller cookie används det bara för att märka den sparade inloggningen.',
311
320
  "How deploys authenticate: basic = username and password; oauth2 = bearer token from the site's OAuth2 provider (PKCE, opens a browser); cookie = reuse a browser SSO/SAML session.": 'Hur driftsättningar autentiseras: basic = användarnamn och lösenord; oauth2 = bearer-token från webbplatsens OAuth2-provider (PKCE, öppnar webbläsare); cookie = återanvänd en SSO/SAML-session från webbläsaren.',
312
321
  'Deploy password for the account above. Stored in the OS keychain, never in a file. Leave empty to be asked on each run.': 'Driftsättningslösenord för kontot ovan. Sparas i nyckelringen, aldrig i en fil. Lämna tomt för att bli tillfrågad varje gång.',
@@ -322,6 +331,20 @@ const sv = {
322
331
  // Settings screen
323
332
  Language: 'Språk',
324
333
  'Intro animation': 'Introanimation',
334
+ 'Update check': 'Uppdateringskoll',
335
+ 'Addon name warning': 'Varning om tilläggsnamn',
336
+ 'Default signing user': 'Förvald signeringsanvändare',
337
+ 'Default certificate': 'Förvalt certifikat',
338
+ 'The language svc itself speaks.': 'Språket som svc självt använder.',
339
+ 'Play the logo animation when the shell starts.': 'Spela logotypanimationen när skalet startar.',
340
+ 'Look for a newer svc on npm at start.': 'Leta efter en nyare svc på npm vid start.',
341
+ 'Mark the addon name with ≠ manifest when it is none of the names in manifest.json.': 'Markera tilläggsnamnet med ≠ manifest när det inte är något av namnen i manifest.json.',
342
+ 'The developer.sitevision.se account that signs in every project without a signing user of its own.': 'Kontot på developer.sitevision.se som signerar i alla projekt utan egen signeringsanvändare.',
343
+ 'Which certificate signs when the account has several. Empty = the account default.': 'Vilket certifikat som signerar när kontot har flera. Tomt = kontots standard.',
344
+ 'Enter confirm · Esc cancel': 'Enter bekräfta · Esc avbryt',
345
+ 'Not saved: the settings file does not parse.': 'Inte sparat: inställningsfilen går inte att tolka.',
346
+ 'The settings file does not parse and is ignored until it is fixed.': 'Inställningsfilen går inte att tolka och ignoreras tills den är lagad.',
347
+ global: 'global',
325
348
  on: 'på',
326
349
  off: 'av',
327
350
  'Saved.': 'Sparat.',
@@ -70,5 +70,6 @@ export function stripJsonComments(input) {
70
70
  * SyntaxError if the content is invalid even after comments are removed.
71
71
  */
72
72
  export function parseJsonc(input) {
73
- return JSON.parse(stripJsonComments(input));
73
+ // Editors on Windows may prepend a BOM, which JSON.parse rejects.
74
+ return JSON.parse(stripJsonComments(input.replace(/^\uFEFF/, '')));
74
75
  }
@@ -1,5 +1,36 @@
1
- import { Entry } from '@napi-rs/keyring';
1
+ import { createRequire } from 'node:module';
2
2
  const SERVICE = 'sitevision-cli';
3
+ let EntryImpl;
4
+ // SVC_NO_KEYCHAIN=1 turns the keychain off (documented, for CI). `memory` is
5
+ // for the test suite only: secrets live and die with the process.
6
+ const memory = new Map();
7
+ const memoryEntry = (account) => ({
8
+ getPassword: () => memory.get(account) ?? null,
9
+ setPassword(password) {
10
+ memory.set(account, password);
11
+ },
12
+ deletePassword: () => memory.delete(account),
13
+ });
14
+ // Loaded on first use so a missing native binding never breaks startup.
15
+ function entry(account) {
16
+ const off = process.env['SVC_NO_KEYCHAIN'];
17
+ if (off === 'memory')
18
+ return memoryEntry(account);
19
+ if (off)
20
+ throw new Error('Keychain disabled');
21
+ if (EntryImpl === undefined) {
22
+ try {
23
+ const keyring = createRequire(import.meta.url)('@napi-rs/keyring');
24
+ EntryImpl = keyring.Entry;
25
+ }
26
+ catch {
27
+ EntryImpl = null;
28
+ }
29
+ }
30
+ if (!EntryImpl)
31
+ throw new Error('Keychain unavailable');
32
+ return new EntryImpl(SERVICE, account);
33
+ }
3
34
  function deployAccount(domain, username) {
4
35
  return `deploy:${username}@${domain}`;
5
36
  }
@@ -17,24 +48,33 @@ function sessionCookieAccount(domain, username) {
17
48
  }
18
49
  function safeGet(account) {
19
50
  try {
20
- return new Entry(SERVICE, account).getPassword();
51
+ return entry(account).getPassword();
21
52
  }
22
53
  catch {
23
54
  return null;
24
55
  }
25
56
  }
57
+ let saveFailed = () => {
58
+ console.warn('\u001B[33mCould not save to the OS keychain; you will be asked again next time.\u001B[0m');
59
+ };
60
+ /** Replace how a failed keychain save is reported (the shell shows a notice). */
61
+ export function onKeychainSaveFailed(listener) {
62
+ saveFailed = listener;
63
+ }
26
64
  function safeSet(account, password) {
27
65
  try {
28
- new Entry(SERVICE, account).setPassword(password);
66
+ entry(account).setPassword(password);
29
67
  return true;
30
68
  }
31
69
  catch {
70
+ // Callers mostly ignore the result, so the user hears it from here.
71
+ saveFailed();
32
72
  return false;
33
73
  }
34
74
  }
35
75
  function safeDelete(account) {
36
76
  try {
37
- new Entry(SERVICE, account).deletePassword();
77
+ entry(account).deletePassword();
38
78
  }
39
79
  catch {
40
80
  // ignore
@@ -23,8 +23,32 @@ export function createPkcePair() {
23
23
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
24
24
  return { verifier, challenge };
25
25
  }
26
- function hasOAuth2Config(config) {
27
- return Boolean(config?.authorizationEndpoint && config.tokenEndpoint && config.clientId);
26
+ /**
27
+ * Why this project's OAuth2 settings cannot be used, if they cannot. The
28
+ * endpoints may come from a committed package.json, and the keychain secrets
29
+ * are stored per site, so they only ever go to that site, over TLS.
30
+ */
31
+ export function oauth2ConfigProblem(dev) {
32
+ const config = dev.oauth2;
33
+ if (!config?.authorizationEndpoint ||
34
+ !config.tokenEndpoint ||
35
+ !config.clientId) {
36
+ return 'OAuth2 is not fully configured (authorization/token endpoint or client ID missing).';
37
+ }
38
+ if (!dev.domain)
39
+ return 'OAuth2 needs the site domain to be set.';
40
+ const schemes = dev.useHTTPForDevDeploy ? ['https:', 'http:'] : ['https:'];
41
+ for (const endpoint of [config.authorizationEndpoint, config.tokenEndpoint]) {
42
+ const url = URL.canParse(endpoint) ? new URL(endpoint) : undefined;
43
+ if (!url || !schemes.includes(url.protocol)) {
44
+ return `OAuth2 endpoint ${endpoint} must be an https URL.`;
45
+ }
46
+ }
47
+ const tokenHost = new URL(config.tokenEndpoint).host.toLowerCase();
48
+ if (tokenHost !== dev.domain.toLowerCase()) {
49
+ return `OAuth2 token endpoint is on ${tokenHost}, not on ${dev.domain}. Stored secrets are only sent to the site they belong to.`;
50
+ }
51
+ return undefined;
28
52
  }
29
53
  async function postToken(config, params, secret) {
30
54
  const body = Buffer.from(new URLSearchParams(params).toString());
@@ -41,6 +65,7 @@ async function postToken(config, params, secret) {
41
65
  });
42
66
  if (response.statusCode !== 200) {
43
67
  return {
68
+ status: response.statusCode,
44
69
  error: `Token endpoint returned ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
45
70
  };
46
71
  }
@@ -169,7 +194,7 @@ function startLoopback(port, state) {
169
194
  */
170
195
  export function beginOAuth2Login(dev) {
171
196
  const config = dev.oauth2;
172
- if (!hasOAuth2Config(config))
197
+ if (!config || oauth2ConfigProblem(dev))
173
198
  return null;
174
199
  const { domain } = dev;
175
200
  const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
@@ -218,14 +243,14 @@ export function beginOAuth2Login(dev) {
218
243
  */
219
244
  export async function resolveOAuth2AccessToken(dev) {
220
245
  const config = dev.oauth2;
221
- if (!hasOAuth2Config(config))
246
+ if (!config || oauth2ConfigProblem(dev))
222
247
  return null;
223
248
  const { domain } = dev;
224
249
  const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
225
250
  const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
226
251
  if (!storedRefresh)
227
252
  return null;
228
- const { tokens } = await postToken(config, {
253
+ const { tokens, status } = await postToken(config, {
229
254
  grant_type: 'refresh_token',
230
255
  refresh_token: storedRefresh,
231
256
  client_id: config.clientId,
@@ -236,7 +261,10 @@ export async function resolveOAuth2AccessToken(dev) {
236
261
  }
237
262
  return tokens.access_token;
238
263
  }
239
- // Stale/expired refresh token — drop it so the next run logs in fresh.
240
- deleteOAuth2RefreshToken(domain, config.clientId);
264
+ // Only a rejection (invalid_grant) means the token is dead. Offline, a
265
+ // timeout or a 5xx says nothing about it, so it stays.
266
+ if (status === 400 || status === 401) {
267
+ deleteOAuth2RefreshToken(domain, config.clientId);
268
+ }
241
269
  return null;
242
270
  }
@@ -1,9 +1,18 @@
1
+ // Without a terminal nobody can answer: prompts resolve to their "no answer"
2
+ // value (default, nothing, empty password) and the caller reports what is missing.
3
+ const interactive = () => Boolean(process.stdin.isTTY);
4
+ // 130 = 128 + SIGINT, so `svc sign && svc deploy` stops at a cancelled prompt.
5
+ const cancelled = () => process.exit(130);
1
6
  /**
2
7
  * Prompt for a yes/no answer. Returns true for y/Y.
3
8
  * Pressing Enter (empty answer) returns `defaultYes` (default: false).
4
9
  */
5
10
  export function promptYesNo(prompt, defaultYes = false) {
6
11
  return new Promise(resolve => {
12
+ if (!interactive()) {
13
+ resolve(defaultYes);
14
+ return;
15
+ }
7
16
  process.stdout.write(prompt);
8
17
  const stdin = process.stdin;
9
18
  stdin.setRawMode(true);
@@ -17,7 +26,7 @@ export function promptYesNo(prompt, defaultYes = false) {
17
26
  process.stdout.write(`${char}\n`);
18
27
  const charCode = char.charCodeAt(0);
19
28
  if (charCode === 3) {
20
- process.exit();
29
+ cancelled();
21
30
  }
22
31
  // Enter (CR/LF) or empty input → use the default
23
32
  if (char === '' || charCode === 13 || charCode === 10) {
@@ -35,6 +44,10 @@ export function promptYesNo(prompt, defaultYes = false) {
35
44
  */
36
45
  export function promptEnter(prompt) {
37
46
  return new Promise(resolve => {
47
+ if (!interactive()) {
48
+ resolve();
49
+ return;
50
+ }
38
51
  process.stdout.write(prompt);
39
52
  const stdin = process.stdin;
40
53
  stdin.setRawMode(true);
@@ -44,7 +57,7 @@ export function promptEnter(prompt) {
44
57
  const char = data[0] || '';
45
58
  const charCode = char.charCodeAt(0);
46
59
  if (charCode === 3) {
47
- process.exit();
60
+ cancelled();
48
61
  }
49
62
  if (char === '' || charCode === 13 || charCode === 10) {
50
63
  stdin.setRawMode(false);
@@ -62,6 +75,10 @@ export function promptEnter(prompt) {
62
75
  */
63
76
  export function promptPassword(prompt) {
64
77
  return new Promise(resolve => {
78
+ if (!interactive()) {
79
+ resolve('');
80
+ return;
81
+ }
65
82
  process.stdout.write(prompt);
66
83
  const stdin = process.stdin;
67
84
  stdin.setRawMode(true);
@@ -69,6 +86,9 @@ export function promptPassword(prompt) {
69
86
  stdin.setEncoding('utf8');
70
87
  let password = '';
71
88
  const onData = (data) => {
89
+ // Arrow keys and the like arrive as escape sequences, not as text.
90
+ if (data.startsWith('\u001B'))
91
+ return;
72
92
  // Handle each character in the input (supports paste)
73
93
  for (const char of data) {
74
94
  const charCode = char.charCodeAt(0);
@@ -90,7 +110,7 @@ export function promptPassword(prompt) {
90
110
  }
91
111
  else if (charCode === 3) {
92
112
  // Ctrl+C
93
- process.exit();
113
+ cancelled();
94
114
  }
95
115
  else if (charCode >= 32) {
96
116
  // Printable characters
@@ -63,7 +63,9 @@ export class ProcessRunner extends EventEmitter {
63
63
  }
64
64
  run() {
65
65
  return new Promise((resolve, reject) => {
66
- this.process = spawnChild(this.command, this.args, {
66
+ // One pre-joined string: the shell resolves npm.cmd on Windows, and Node
67
+ // deprecates shell + args (DEP0190). Callers pass trusted args only.
68
+ this.process = spawnChild([this.command, ...this.args].join(' '), [], {
67
69
  cwd: this.cwd || process.cwd(),
68
70
  env: this.env,
69
71
  shell: true,
@@ -84,7 +86,7 @@ export class ProcessRunner extends EventEmitter {
84
86
  reject(error);
85
87
  });
86
88
  this.process.on('close', code => {
87
- const exitCode = code ?? 0;
89
+ const exitCode = code ?? 1;
88
90
  this.emit('exit', exitCode);
89
91
  resolve({
90
92
  exitCode,
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { getDeployPassword, setDeployPassword, getSessionCookie, } from './keychain.js';
4
4
  import { parseJsonc, stripJsonComments } from './jsonc.js';
5
5
  import { getLanguage } from './i18n.js';
6
+ import { getGlobalSigning } from './config.js';
6
7
  // =============================================================================
7
8
  // LOCALIZED TEXT
8
9
  // =============================================================================
@@ -22,6 +23,20 @@ export function localizedText(value, preferred = getLanguage()) {
22
23
  return value;
23
24
  return value[preferred] ?? value['en'] ?? Object.values(value)[0] ?? '';
24
25
  }
26
+ /**
27
+ * The manifest's names when the addon name is none of them. The two are
28
+ * separate things (the addon is an object on the site, the name a label in the
29
+ * zip), so a difference is only ever pointed out, never "fixed".
30
+ */
31
+ export function addonNameDrift(addonName, manifest) {
32
+ const same = (text) => text.trim().toLowerCase();
33
+ const names = (typeof manifest?.name === 'string'
34
+ ? [manifest.name]
35
+ : Object.values(manifest?.name ?? {})).filter(name => name.trim() !== '');
36
+ if (!addonName?.trim() || names.length === 0)
37
+ return undefined;
38
+ return names.some(name => same(name) === same(addonName)) ? undefined : names;
39
+ }
25
40
  // =============================================================================
26
41
  // PATH UTILITIES
27
42
  // =============================================================================
@@ -105,16 +120,33 @@ function readDevPropertiesFile(dir) {
105
120
  return null;
106
121
  }
107
122
  }
123
+ /**
124
+ * Merge config layers, later ones winning. `environments` merges per name, so a
125
+ * layer that overrides one field of an environment keeps the rest of it.
126
+ */
127
+ export function mergeDevLayers(...layers) {
128
+ let merged = {};
129
+ for (const layer of layers) {
130
+ if (!layer)
131
+ continue;
132
+ const environments = { ...merged.environments };
133
+ for (const [name, override] of Object.entries(layer.environments ?? {})) {
134
+ environments[name] = { ...environments[name], ...override };
135
+ }
136
+ merged = { ...merged, ...layer };
137
+ if (Object.keys(environments).length > 0) {
138
+ merged.environments = environments;
139
+ }
140
+ }
141
+ return merged;
142
+ }
108
143
  /**
109
144
  * The dev properties a workspace root defines (its own file merged over any
110
145
  * ancestors'), with the deploy password resolved from the keychain like an
111
146
  * app's would be. Used to edit shared config from the shell.
112
147
  */
113
148
  export function readWorkspaceDevProperties(root) {
114
- const merged = {
115
- ...readInheritedDevProperties(root),
116
- ...readDevPropertiesFile(root),
117
- };
149
+ const merged = mergeDevLayers(readInheritedDevProperties(root), readDevPropertiesFile(root));
118
150
  if (merged.domain && merged.username && !merged.password) {
119
151
  merged.password =
120
152
  process.env['SITEVISION_DEPLOY_PASSWORD'] ??
@@ -125,37 +157,31 @@ export function readWorkspaceDevProperties(root) {
125
157
  }
126
158
  /** Ancestor directories' .dev_properties.json merged, nearest wins (no own file). */
127
159
  export function readAncestorDevProperties(root) {
128
- let merged = {};
129
- for (const dir of ancestorDirs(root)) {
130
- merged = { ...merged, ...readDevPropertiesFile(dir) };
131
- }
132
- return merged;
160
+ return mergeDevLayers(...ancestorDirs(root).map(dir => readDevPropertiesFile(dir)));
133
161
  }
134
162
  /** Shared defaults from package.json: the workspace root's first, the app's on top. */
135
163
  export function readPackageDefaultsChain(root) {
136
- let merged = {};
137
- for (const dir of [...ancestorDirs(root), root]) {
138
- merged = { ...merged, ...readPackageDefaults(dir) };
139
- }
140
- return merged;
164
+ return mergeDevLayers(...[...ancestorDirs(root), root].map(dir => readPackageDefaults(dir)));
141
165
  }
142
166
  /**
143
167
  * Everything an app's own .dev_properties.json sits on top of: package.json
144
168
  * defaults (root, then app), then ancestor .dev_properties.json files.
145
169
  */
146
170
  export function readInheritedDevProperties(root) {
147
- return {
148
- ...readPackageDefaultsChain(root),
149
- ...readAncestorDevProperties(root),
150
- };
171
+ return mergeDevLayers(readPackageDefaultsChain(root), readAncestorDevProperties(root));
151
172
  }
152
173
  /**
153
174
  * Get app ID configuration from environment or defaults
154
175
  */
155
- function getAppIdConfig() {
176
+ export function getAppIdConfig() {
156
177
  return {
157
- prefix: process.env['SITEVISION_APP_ID_PREFIX'] || '',
158
- suffix: process.env['SITEVISION_APP_ID_SUFFIX'] || '',
178
+ // svc's own names first, then the ones sitevision-scripts reads.
179
+ prefix: process.env['SITEVISION_APP_ID_PREFIX'] ||
180
+ process.env['APP_ID_PREFIX'] ||
181
+ '',
182
+ suffix: process.env['SITEVISION_APP_ID_SUFFIX'] ||
183
+ process.env['APP_ID_SUFFIX'] ||
184
+ '',
159
185
  };
160
186
  }
161
187
  /**
@@ -270,7 +296,7 @@ export function buildImportEndpointUrl(domain, siteName, addonName, appType, use
270
296
  export class ManifestParseError extends Error {
271
297
  constructor(manifestPath, cause) {
272
298
  const reason = cause instanceof Error ? cause.message : String(cause);
273
- super(`${manifestPath} is not valid JSON: ${reason}`);
299
+ super(`${manifestPath} ${reason}`);
274
300
  this.name = 'ManifestParseError';
275
301
  }
276
302
  }
@@ -290,15 +316,23 @@ export function readManifest(cwd) {
290
316
  }
291
317
  // Manifests may contain comments (Sitevision's own docs show them), so
292
318
  // parse as JSONC.
319
+ let manifest;
293
320
  try {
294
- return {
295
- manifestPath,
296
- manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
297
- };
321
+ manifest = parseJsonc(fs.readFileSync(manifestPath, 'utf-8'));
298
322
  }
299
323
  catch (error) {
300
- throw new ManifestParseError(manifestPath, error);
324
+ throw new ManifestParseError(manifestPath, `is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
325
+ }
326
+ if (typeof manifest !== 'object' || manifest === null) {
327
+ throw new ManifestParseError(manifestPath, 'must be a JSON object');
328
+ }
329
+ // Zip names, endpoints and the build all derive from these.
330
+ for (const key of ['id', 'version', 'type']) {
331
+ if (typeof manifest[key] !== 'string' || manifest[key] === '') {
332
+ throw new ManifestParseError(manifestPath, `is missing "${key}"`);
333
+ }
301
334
  }
335
+ return { manifestPath, manifest };
302
336
  }
303
337
  return null;
304
338
  }
@@ -313,6 +347,11 @@ const escapeRegExp = (s) => s.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw `\$&`
313
347
  export function writeManifestField(manifestPath, key, value, lang) {
314
348
  const raw = fs.readFileSync(manifestPath, 'utf-8');
315
349
  const wanted = parseJsonc(raw);
350
+ const existing = wanted[key];
351
+ if (lang && typeof existing !== 'object') {
352
+ // A plain string becomes the English text of a multilingual value.
353
+ wanted[key] = existing ? { en: existing } : {};
354
+ }
316
355
  const holder = lang ? wanted[key] : wanted;
317
356
  const leaf = lang ?? key;
318
357
  // JSON.stringify drops an undefined key.
@@ -369,7 +408,9 @@ export function detectProject(cwd = process.cwd()) {
369
408
  let hasLegacyPassword = false;
370
409
  if (hasDevProperties) {
371
410
  try {
372
- const parsed = { ...inherited, ...own };
411
+ // ponytail: the global signing identity only reaches a project that
412
+ // has some config of its own, so "configured?" checks stay truthful.
413
+ const parsed = mergeDevLayers(getGlobalSigning(), inherited, own);
373
414
  hasLegacyPassword =
374
415
  typeof parsed.password === 'string' && parsed.password.length > 0;
375
416
  devProperties = parsed;
@@ -446,7 +487,7 @@ export function requireProject(cwd) {
446
487
  * the whole shell down.
447
488
  */
448
489
  export function appTypeOf(manifest) {
449
- const type = manifest.type.toLowerCase();
490
+ const type = String(manifest.type ?? '').toLowerCase();
450
491
  if (type.startsWith('web'))
451
492
  return 'web';
452
493
  if (type.startsWith('widget'))
@@ -505,10 +546,18 @@ export function writeDevProperties(projectRoot, properties,
505
546
  // that would shadow the inherited value.
506
547
  const inherited = complete
507
548
  ? {}
508
- : readInheritedDevProperties(projectRoot);
549
+ : mergeDevLayers(getGlobalSigning(), readInheritedDevProperties(projectRoot));
509
550
  const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
510
551
  (!Object.hasOwn(inherited, key) ||
511
552
  JSON.stringify(inherited[key]) !== JSON.stringify(value))));
553
+ // A plaintext password already in the file (sitevision-scripts puts it
554
+ // there) moves to the keychain; if that fails it stays rather than be lost.
555
+ const legacy = readDevPropertiesFile(projectRoot)?.password;
556
+ if (legacy) {
557
+ const { domain, username } = { ...inherited, ...persisted };
558
+ if (!setDeployPassword(domain, username, legacy))
559
+ own['password'] = legacy;
560
+ }
512
561
  fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
513
562
  }
514
563
  export function readSvcConfig(projectRoot) {
@@ -742,11 +791,13 @@ export function migrateLegacyPassword(project) {
742
791
  }
743
792
  }
744
793
  catch {
745
- // Unreadable file: nothing to migrate there.
794
+ // Unreadable file: nothing to migrate there. Unwritable: checked below.
746
795
  }
747
796
  }
748
- project.hasLegacyPassword = false;
749
- return true;
797
+ // Moved only if no file still holds it.
798
+ const left = [project.root, ...ancestorDirs(project.root)].some(dir => readDevPropertiesFile(dir)?.password);
799
+ project.hasLegacyPassword = left;
800
+ return !left;
750
801
  }
751
802
  /**
752
803
  * Ensure the dist directory exists
@@ -15,14 +15,15 @@ function domainRelated(a, b) {
15
15
  * browser — so it's unit-testable.
16
16
  */
17
17
  export function selectSessionCookie(all, siteDomain) {
18
- const sessions = all.filter(c => c.name === 'JSESSIONID');
19
- if (sessions.length === 0) {
18
+ // Only a session on the deploy site counts: a Java IdP sets a JSESSIONID of
19
+ // its own, and that one must never be sent to the site.
20
+ const chosen = all.find(c => c.name === 'JSESSIONID' && domainRelated(c.domain, siteDomain));
21
+ if (!chosen) {
20
22
  const domains = [...new Set(all.map(c => bareDomain(c.domain)))];
21
23
  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.`,
24
+ 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
25
  };
24
26
  }
25
- const chosen = sessions.find(c => domainRelated(c.domain, siteDomain)) ?? sessions[0];
26
27
  const cookies = all.filter(c => domainRelated(c.domain, chosen.domain));
27
28
  return {
28
29
  cookie: cookies.map(c => `${c.name}=${c.value}`).join('; '),