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
@@ -1,42 +1,44 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import readline from 'readline';
4
- function question(rl, prompt) {
5
- return new Promise(resolve => {
6
- rl.question(prompt, answer => {
7
- resolve(answer);
8
- });
9
- });
4
+ import { findDevPropertiesPath } from '../utils/project-detection.js';
5
+ import { configProblem, getGlobalSigning, setGlobalSigning, settingsFile, } from '../utils/config.js';
6
+ // rl.question drops lines that arrive before it is called, which is every line
7
+ // but the first when stdin is piped. The iterator buffers them.
8
+ function lineReader(rl) {
9
+ const lines = rl[Symbol.asyncIterator]();
10
+ return async (prompt) => {
11
+ process.stdout.write(prompt);
12
+ const { value, done } = await lines.next();
13
+ return done ? '' : String(value);
14
+ };
10
15
  }
11
16
  export const setupSigningCommand = {
12
17
  name: 'setup-signing',
13
18
  description: 'Configure signing credentials for developer.sitevision.se',
14
19
  requiresProject: true,
15
- async execute({ project }) {
20
+ async execute({ project, flags }) {
21
+ const global = Boolean(flags['global']);
16
22
  const rl = readline.createInterface({
17
23
  input: process.stdin,
18
24
  output: process.stdout,
19
25
  });
26
+ const question = lineReader(rl);
20
27
  console.log('\n\x1b[36m\x1b[1mSetup Signing Credentials\x1b[0m\n');
21
28
  console.log('Configure credentials for signing apps on developer.sitevision.se');
22
29
  console.log('(Password will be prompted when running signing commands)\n');
23
- // Find existing dev properties file
24
- const devPropertiesPaths = [
25
- path.join(project.root, '.dev_properties.json'),
26
- path.join(project.root, '.dev-properties.json'),
27
- ];
28
- let devPropertiesPath = devPropertiesPaths[0];
30
+ const devPropertiesPath = findDevPropertiesPath(project.root) ??
31
+ path.join(project.root, '.dev_properties.json');
29
32
  let existingProperties = {};
30
- for (const p of devPropertiesPaths) {
31
- if (fs.existsSync(p)) {
32
- devPropertiesPath = p;
33
- try {
34
- existingProperties = JSON.parse(fs.readFileSync(p, 'utf-8'));
35
- }
36
- catch {
37
- // Invalid file, start fresh
38
- }
39
- break;
33
+ if (global) {
34
+ existingProperties = { ...getGlobalSigning() };
35
+ }
36
+ else {
37
+ try {
38
+ existingProperties = JSON.parse(fs.readFileSync(devPropertiesPath, 'utf8'));
39
+ }
40
+ catch {
41
+ // Missing or invalid file, start fresh
40
42
  }
41
43
  }
42
44
  try {
@@ -45,13 +47,14 @@ export const setupSigningCommand = {
45
47
  const usernamePrompt = defaultUsername
46
48
  ? `Signing username [${defaultUsername}]: `
47
49
  : 'Signing username: ';
48
- let signingUsername = await question(rl, usernamePrompt);
50
+ let signingUsername = await question(usernamePrompt);
49
51
  if (!signingUsername && defaultUsername) {
50
52
  signingUsername = defaultUsername;
51
53
  }
52
54
  if (!signingUsername) {
53
55
  console.log('\x1b[31mError: Signing username is required\x1b[0m');
54
56
  rl.close();
57
+ process.exitCode = 1;
55
58
  return;
56
59
  }
57
60
  // Get certificate name (optional)
@@ -59,15 +62,26 @@ export const setupSigningCommand = {
59
62
  const certPrompt = defaultCertName
60
63
  ? `Certificate name (blank for default) [${defaultCertName}]: `
61
64
  : 'Certificate name (blank for default): ';
62
- let certificateName = await question(rl, certPrompt);
65
+ let certificateName = await question(certPrompt);
63
66
  if (!certificateName && defaultCertName) {
64
67
  certificateName = defaultCertName;
65
68
  }
66
69
  rl.close();
67
- // Update dev properties (strip any plaintext passwords — they live in the keychain)
68
- const { signingPassword: _signingRemoved, password: _passwordRemoved, ...cleanedProperties } = existingProperties;
70
+ if (global) {
71
+ if (configProblem()) {
72
+ console.log(`\x1b[31mError: ${settingsFile()} does not parse; fix it first\x1b[0m`);
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+ setGlobalSigning({
77
+ signingUsername,
78
+ ...(certificateName && { certificateName }),
79
+ });
80
+ console.log(`\n\x1b[32mSigning credentials saved to ${settingsFile()}\x1b[0m\n`);
81
+ return;
82
+ }
69
83
  const updatedProperties = {
70
- ...cleanedProperties,
84
+ ...existingProperties,
71
85
  signingUsername,
72
86
  ...(certificateName && { certificateName }),
73
87
  };
@@ -77,6 +91,7 @@ export const setupSigningCommand = {
77
91
  catch (error) {
78
92
  rl.close();
79
93
  console.error('\x1b[31mError setting up signing credentials:\x1b[0m', error);
94
+ process.exitCode = 1;
80
95
  }
81
96
  },
82
97
  };
@@ -1,94 +1,17 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React from 'react';
3
- import { render, Box, Text, useInput } from 'ink';
4
- import { StatusIndicator } from '../components/StatusIndicator.js';
5
- import { signApp } from '../utils/sitevision-api.js';
6
- import { resolveSigningPassword } from '../utils/signing-password.js';
7
- import { getZipPath, getSignedZipPath } from '../utils/project-detection.js';
8
- import { formatFileSize, getZipSize, zipExists } from '../utils/zip.js';
9
- export function SignScreen({ projectRoot, manifest, devProperties, password, onBack, onRetryCredentials, }) {
10
- const [state, setState] = React.useState({
11
- status: 'signing',
12
- message: 'Signing app via developer.sitevision.se...',
13
- });
14
- useInput((input, key) => {
15
- if (state.status !== 'signing') {
16
- if (onBack && (key.escape || input === 'q')) {
17
- onBack();
18
- }
19
- if (onRetryCredentials && state.status === 'error' && input === 'r') {
20
- onRetryCredentials();
21
- }
22
- }
23
- });
24
- React.useEffect(() => {
25
- async function runSign() {
26
- try {
27
- const zipPath = getZipPath(projectRoot, manifest);
28
- const signedZipPath = getSignedZipPath(projectRoot, manifest);
29
- // Validate zip exists
30
- if (!zipExists(zipPath)) {
31
- setState({
32
- status: 'error',
33
- error: `Zip file not found: ${zipPath}\nRun 'build' first to create the zip file.`,
34
- });
35
- return;
36
- }
37
- // Sign the app
38
- const result = await signApp(zipPath, {
39
- username: devProperties.signingUsername,
40
- password,
41
- certificateName: devProperties.certificateName,
42
- }, signedZipPath);
43
- if (!result.success) {
44
- setState({
45
- status: 'error',
46
- error: result.error || 'Signing failed',
47
- });
48
- return;
49
- }
50
- const signedSize = getZipSize(signedZipPath);
51
- setState({
52
- status: 'success',
53
- message: 'App signed successfully',
54
- signedPath: signedZipPath,
55
- signedSize,
56
- });
57
- }
58
- catch (error) {
59
- setState({
60
- status: 'error',
61
- error: error instanceof Error ? error.message : String(error),
62
- });
63
- }
64
- }
65
- runSign();
66
- }, [projectRoot, manifest, devProperties, password]);
67
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(StatusIndicator, { status: state.status === 'signing' ? 'running' : state.status, label: state.status === 'signing'
68
- ? 'Signing'
69
- : state.status === 'success'
70
- ? 'Signed'
71
- : 'Failed', message: state.message }) }), state.status === 'success' && state.signedPath && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { color: "green", children: ["Created: ", state.signedPath] }), state.signedSize !== undefined && (_jsxs(Text, { dimColor: true, children: ["Size: ", formatFileSize(state.signedSize)] }))] })), state.status === 'error' && state.error && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: "red", children: state.error }) })), state.status !== 'signing' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.status === 'error' && onRetryCredentials && (_jsx(Text, { dimColor: true, children: "Press r to retry with new credentials" })), onBack && _jsx(Text, { dimColor: true, children: "Press q or Esc to return to menu" })] }))] }));
72
- }
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink';
3
+ import { TaskScreen } from './TaskScreen.js';
4
+ import { resolveSigningForCli } from './dev.js';
5
+ import { startSign } from '../utils/tasks.js';
73
6
  export const signCommand = {
74
7
  name: 'sign',
75
8
  description: 'Sign the app for production deployment',
76
9
  requiresProject: true,
77
10
  async execute({ project }) {
78
- // Check if signing credentials are configured
79
- if (!project.hasSigningProperties ||
80
- !project.devProperties?.signingUsername) {
81
- console.log('\n\x1b[33mSigning credentials not configured.\x1b[0m');
82
- console.log('Run \x1b[36msetup-signing\x1b[0m to configure credentials.\n');
11
+ const credentials = await resolveSigningForCli(project);
12
+ if (!credentials)
83
13
  return;
84
- }
85
- const signingUsername = project.devProperties.signingUsername;
86
- const password = await resolveSigningPassword(signingUsername);
87
- if (!password) {
88
- console.log('\x1b[31mError: Password is required\x1b[0m');
89
- return;
90
- }
91
- const { waitUntilExit } = render(_jsx(SignScreen, { projectRoot: project.root, manifest: project.manifest, devProperties: project.devProperties, password: password }));
92
- await waitUntilExit();
14
+ const task = startSign(project, credentials);
15
+ await render(_jsx(TaskScreen, { task: task })).waitUntilExit();
93
16
  },
94
17
  };
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+ import { useApp } from 'ink';
3
+ /**
4
+ * Standalone commands end themselves: once `result` is set the Ink app exits,
5
+ * and a failure becomes a non-zero exit code. `stay` keeps the screen up (for a
6
+ * retry key) while still recording the failure.
7
+ */
8
+ export function useFinish(result, stay = false) {
9
+ const { exit } = useApp();
10
+ React.useEffect(() => {
11
+ if (!result)
12
+ return;
13
+ process.exitCode = result === 'error' ? 1 : 0;
14
+ if (!stay)
15
+ exit();
16
+ }, [result, stay, exit]);
17
+ }
@@ -5,14 +5,6 @@ export const watchCommand = {
5
5
  name: 'watch',
6
6
  description: 'Watch and rebuild (optionally signing) without deploying',
7
7
  requiresProject: true,
8
- flags: {
9
- signed: {
10
- type: 'boolean',
11
- description: 'Sign after each build',
12
- alias: 's',
13
- default: false,
14
- },
15
- },
16
8
  async execute({ project, flags }) {
17
9
  let signingCredentials;
18
10
  if (flags['signed']) {
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
5
  import { t } from '../utils/i18n.js';
6
- import { beginOAuth2Login, openBrowser } from '../utils/oauth2-auth.js';
6
+ import { beginOAuth2Login, oauth2ConfigProblem, openBrowser, } from '../utils/oauth2-auth.js';
7
7
  import { beginCookieLogin, } from '../utils/session-cookie-auth.js';
8
8
  /**
9
9
  * Ink-native interactive login for OAuth2 and session-cookie auth. Drives the
@@ -19,11 +19,13 @@ export function AuthLoginScreen({ method, devProperties, onComplete, onError, on
19
19
  const cookieRef = React.useRef(null);
20
20
  const cancelOAuthRef = React.useRef(null);
21
21
  React.useEffect(() => {
22
+ // Set by the cleanup: the screen went away while a login was starting.
23
+ let gone = false;
22
24
  void (async () => {
23
25
  if (method === 'oauth2') {
24
26
  const session = beginOAuth2Login(devProperties);
25
27
  if (!session) {
26
- onError('OAuth2 is not fully configured (authorization/token endpoint or client ID missing).');
28
+ onError(oauth2ConfigProblem(devProperties) ?? 'Could not start the login.');
27
29
  return;
28
30
  }
29
31
  cancelOAuthRef.current = session.cancel;
@@ -32,6 +34,8 @@ export function AuthLoginScreen({ method, devProperties, onComplete, onError, on
32
34
  setPhase('awaiting');
33
35
  const { token, error } = await session.complete();
34
36
  cancelOAuthRef.current = null;
37
+ if (gone)
38
+ return;
35
39
  if (token) {
36
40
  onComplete({ accessToken: token });
37
41
  }
@@ -41,8 +45,12 @@ export function AuthLoginScreen({ method, devProperties, onComplete, onError, on
41
45
  }
42
46
  else {
43
47
  const session = await beginCookieLogin(devProperties);
48
+ if (gone) {
49
+ void session?.close();
50
+ return;
51
+ }
44
52
  if (!session) {
45
- onError('Could not open a login browser (is Chrome installed?). Set SITEVISION_SESSION_COOKIE or pass --cookie with a cookie copied from your browser.');
53
+ onError('Could not open a login browser (is Chrome installed?). Set SITEVISION_SESSION_COOKIE to a cookie copied from your browser.');
46
54
  return;
47
55
  }
48
56
  cookieRef.current = session;
@@ -53,6 +61,7 @@ export function AuthLoginScreen({ method, devProperties, onComplete, onError, on
53
61
  // Release resources if the screen unmounts before completing: close the
54
62
  // browser (cookie) and the loopback server (oauth2, frees the port).
55
63
  return () => {
64
+ gone = true;
56
65
  void cookieRef.current?.close();
57
66
  cookieRef.current = null;
58
67
  cancelOAuthRef.current?.();
@@ -72,16 +81,22 @@ export function AuthLoginScreen({ method, devProperties, onComplete, onError, on
72
81
  return;
73
82
  setPhase('capturing');
74
83
  void (async () => {
75
- const result = await session.capture();
76
- if (result.cookie) {
77
- cookieRef.current = null;
78
- await session.close();
79
- onComplete({ sessionCookie: result.cookie });
84
+ try {
85
+ const result = await session.capture();
86
+ if (result.cookie) {
87
+ cookieRef.current = null;
88
+ await session.close();
89
+ onComplete({ sessionCookie: result.cookie });
90
+ }
91
+ else {
92
+ // Keep the browser open so the user can navigate and retry.
93
+ setNote(result.error ?? 'No session cookie found.');
94
+ setPhase('awaiting');
95
+ }
80
96
  }
81
- else {
82
- // Keep the browser open so the user can navigate and retry.
83
- setNote(result.error ?? 'No session cookie found.');
84
- setPhase('awaiting');
97
+ catch (error) {
98
+ // The browser window was closed: nothing left to capture from.
99
+ onError(`Could not read the session from the browser: ${error instanceof Error ? error.message : String(error)}`);
85
100
  }
86
101
  })();
87
102
  }
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
- import { findDevPropertiesPath, getPackageJsonSyncChanges, hasPackageJson, IMPLICIT_VALUES, normalizeDomain, readAncestorDevProperties, readInheritedDevProperties, readWorkspaceDevProperties, updatePackageJson, writeDevProperties, writeManifestField, } from '../utils/project-detection.js';
4
+ import { addonNameDrift, findDevPropertiesPath, getPackageJsonSyncChanges, hasPackageJson, IMPLICIT_VALUES, normalizeDomain, readAncestorDevProperties, readInheritedDevProperties, readWorkspaceDevProperties, updatePackageJson, writeDevProperties, writeManifestField, } from '../utils/project-detection.js';
5
5
  import { setDeployPassword, deleteDeployPassword, getOAuth2ClientSecret, setOAuth2ClientSecret, deleteOAuth2ClientSecret, getSigningPassword, setSigningPassword, deleteSigningPassword, } from '../utils/keychain.js';
6
6
  import { DEFAULT_SCOPES, discoverOAuth2Config } from '../utils/oauth2-auth.js';
7
7
  import { ACCENT } from './Frame.js';
8
8
  import { baseEnvironment, withEnvironmentOverride, } from '../utils/environments.js';
9
9
  import { t } from '../utils/i18n.js';
10
+ import { getGlobalSigning, getSettings } from '../utils/config.js';
10
11
  const METHODS = ['basic', 'oauth2', 'cookie'];
11
12
  const FIELDS = [
12
13
  {
@@ -23,7 +24,7 @@ const FIELDS = [
23
24
  },
24
25
  {
25
26
  key: 'addonName',
26
- help: "Name of the addon (custom module) in the site's Addon Repository that this app is uploaded into. Ctrl+O lists the existing ones.",
27
+ help: "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.",
27
28
  label: 'Addon name',
28
29
  required: true,
29
30
  hint: '^O pick from repo',
@@ -168,6 +169,7 @@ const MANIFEST_FIELDS = [
168
169
  },
169
170
  ];
170
171
  /** Rows for manifest.json: one per text field, one per language of a localized one. */
172
+ const LOCALIZABLE = new Set(['name', 'description']);
171
173
  export function manifestFields(manifest) {
172
174
  if (!manifest)
173
175
  return [];
@@ -175,15 +177,20 @@ export function manifestFields(manifest) {
175
177
  return MANIFEST_FIELDS.flatMap(f => {
176
178
  const value = raw[f.key];
177
179
  const base = { ...f, section: 'MANIFEST', manifestKey: f.key };
180
+ const localized = (lang) => ({
181
+ ...base,
182
+ key: `manifest.${f.key}.${lang}`,
183
+ lang,
184
+ required: f.required && lang === 'en',
185
+ });
178
186
  if (value && typeof value === 'object') {
179
- return Object.keys(value).map(lang => ({
180
- ...base,
181
- key: `manifest.${f.key}.${lang}`,
182
- lang,
183
- required: f.required && lang === 'en',
184
- }));
187
+ const langs = Object.keys(value);
188
+ if (!langs.includes('sv'))
189
+ langs.push('sv');
190
+ return langs.map(lang => localized(lang));
185
191
  }
186
- return [{ ...base, key: `manifest.${f.key}` }];
192
+ const plain = { ...base, key: `manifest.${f.key}` };
193
+ return LOCALIZABLE.has(f.key) ? [plain, localized('sv')] : [plain];
187
194
  });
188
195
  }
189
196
  const labelOf = (f) => t(f.label) + (f.lang ? ` (${f.lang})` : '');
@@ -227,9 +234,11 @@ function fromProject(project) {
227
234
  signingPassword: '',
228
235
  ...Object.fromEntries(manifestFields(project.manifest).map(f => {
229
236
  const value = project.manifest[f.manifestKey];
230
- const text = f.lang
237
+ const text = f.lang && value && typeof value === 'object'
231
238
  ? value[f.lang]
232
- : value;
239
+ : f.lang
240
+ ? undefined
241
+ : value;
233
242
  return [f.key, typeof text === 'string' ? text : ''];
234
243
  })),
235
244
  };
@@ -252,8 +261,9 @@ const sameValue = (key, a, b) => JSON.stringify(a === '' || a === undefined ? IM
252
261
  JSON.stringify(b === '' || b === undefined ? IMPLICIT_VALUES[key] : b);
253
262
  /**
254
263
  * App mode writes the app's complete file. Workspace mode never creates an
255
- * app's .dev_properties.json: changes go to the root file, and the addon name
256
- * to the app's package.json. An app that already has its own file keeps it.
264
+ * app's .dev_properties.json: changes go to the root file, and addon names (the
265
+ * base one and each environment's) to the app's package.json, since the root
266
+ * file is shared by every app. An app that already has its own file keeps it.
257
267
  */
258
268
  function writeConfigFile(project, file) {
259
269
  const { workspaceRoot } = project;
@@ -276,6 +286,40 @@ function writeConfigFile(project, file) {
276
286
  packageJson['addonName'] = after[key] || undefined;
277
287
  });
278
288
  }
289
+ else if (key === 'environments') {
290
+ // `after` is this app's merged view. Only the environment being edited
291
+ // goes to the shared file, or the app's own overrides would reach
292
+ // every other app.
293
+ const environments = Object.entries((after[key] ?? {})).filter(([name]) => !project.environment || name === project.environment);
294
+ root[key] = {
295
+ ...root[key],
296
+ ...Object.fromEntries(environments.map(([name, { addonName: _addonName, ...shared }]) => [
297
+ name,
298
+ shared,
299
+ ])),
300
+ };
301
+ updatePackageJson(project.root, packageJson => {
302
+ const svc = { ...packageJson['svc'] };
303
+ const own = new Map(Object.entries((svc['environments'] ?? {})));
304
+ for (const [name, { addonName }] of environments) {
305
+ const override = {
306
+ ...own.get(name),
307
+ addonName: addonName || undefined,
308
+ };
309
+ if (Object.values(override).every(value => value === undefined)) {
310
+ own.delete(name);
311
+ }
312
+ else {
313
+ own.set(name, override);
314
+ }
315
+ }
316
+ svc['environments'] =
317
+ own.size > 0 ? Object.fromEntries(own) : undefined;
318
+ packageJson['svc'] = Object.values(svc).some(value => value !== undefined)
319
+ ? svc
320
+ : undefined;
321
+ });
322
+ }
279
323
  else {
280
324
  root[key] = after[key];
281
325
  }
@@ -285,22 +329,21 @@ function writeConfigFile(project, file) {
285
329
  /** Apply the form to disk and the keychain. Exported for the test. */
286
330
  export function saveConfig(project, values, edited) {
287
331
  const method = values['authMethod'];
332
+ // Start from what is there: keys the form does not show, and the settings of
333
+ // the auth methods not in use, survive a save.
288
334
  const next = {
335
+ ...project.devProperties,
289
336
  domain: values['domain'],
290
337
  siteName: values['siteName'],
291
338
  addonName: values['addonName'],
292
339
  username: values['username'],
293
340
  authMethod: method,
294
341
  useHTTPForDevDeploy: values['useHTTPForDevDeploy'] === 'yes',
342
+ baseEnvironment: values['baseEnvironment']?.trim().toLowerCase() || undefined,
343
+ production: values['production'] === 'yes' ? true : undefined,
344
+ signingUsername: values['signingUsername'] || undefined,
345
+ certificateName: values['certificateName'] || undefined,
295
346
  };
296
- if (values['baseEnvironment'])
297
- next.baseEnvironment = values['baseEnvironment'].trim().toLowerCase();
298
- if (values['production'] === 'yes')
299
- next.production = true;
300
- if (values['signingUsername'])
301
- next.signingUsername = values['signingUsername'];
302
- if (values['certificateName'])
303
- next.certificateName = values['certificateName'];
304
347
  if (method === 'oauth2') {
305
348
  const scopes = values['scopes'].split(/[\s,]+/).filter(Boolean);
306
349
  next.oauth2 = {
@@ -313,8 +356,8 @@ export function saveConfig(project, values, edited) {
313
356
  }),
314
357
  };
315
358
  }
316
- else if (method === 'cookie' && values['sessionLoginUrl']) {
317
- next.sessionLoginUrl = values['sessionLoginUrl'];
359
+ else if (method === 'cookie') {
360
+ next.sessionLoginUrl = values['sessionLoginUrl'] || undefined;
318
361
  }
319
362
  const env = project.environment;
320
363
  if (env && env !== baseEnvironment(project.base) && project.base) {
@@ -368,6 +411,11 @@ export function visibleFields(method, workspace = false, envMode = false) {
368
411
  }
369
412
  export function ConfigForm({ project, active, width, height, pickAddon, onSaved, onEditingChange, }) {
370
413
  const [values, setValues] = useState(() => fromProject(project));
414
+ // What the form holds when an async lookup lands, not when it started.
415
+ const latest = useRef({ values, project });
416
+ useEffect(() => {
417
+ latest.current = { values, project };
418
+ });
371
419
  const [cursor, setCursor] = useState(0);
372
420
  const [editing, setEditing] = useState(false);
373
421
  const [draft, setDraft] = useState('');
@@ -394,6 +442,7 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
394
442
  const current = fields[Math.min(cursor, fields.length - 1)];
395
443
  const inherited = readInheritedDevProperties(project.root);
396
444
  const ancestors = readAncestorDevProperties(project.root);
445
+ const globalSigning = getGlobalSigning();
397
446
  const changes = getPackageJsonSyncChanges(project.root);
398
447
  const packageJsonExists = hasPackageJson(project.root);
399
448
  // Write one field to disk (and the keychain for secrets) right away.
@@ -438,14 +487,15 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
438
487
  if (cancelled)
439
488
  return;
440
489
  if (found) {
490
+ const now = latest.current.values;
441
491
  const next = {
442
- ...values,
443
- authorizationEndpoint: values['authorizationEndpoint'] || found.authorizationEndpoint,
444
- tokenEndpoint: values['tokenEndpoint'] || found.tokenEndpoint,
492
+ ...now,
493
+ authorizationEndpoint: now['authorizationEndpoint'] || found.authorizationEndpoint,
494
+ tokenEndpoint: now['tokenEndpoint'] || found.tokenEndpoint,
445
495
  };
446
496
  setValues(next);
447
497
  try {
448
- saveConfig(project, next, new Set());
498
+ saveConfig(latest.current.project, next, new Set());
449
499
  }
450
500
  catch (error) {
451
501
  setNote(t('Not saved: {error}', { error: errorText(error) }));
@@ -536,10 +586,16 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
536
586
  setEditing(true);
537
587
  }
538
588
  }, { isActive: active });
589
+ // An environment's addon name differs from the app's name on purpose.
590
+ const drift = envMode || !getSettings().addonNameDriftWarning
591
+ ? undefined
592
+ : addonNameDrift(values['addonName'], project.manifest);
539
593
  const source = (f) => {
540
594
  const required = f.required === true || f.required === method;
541
595
  if (required && !(values[f.key] ?? ''))
542
596
  return { text: t('✗ required'), color: 'red' };
597
+ if (f.key === 'addonName' && drift)
598
+ return { text: t('≠ manifest'), color: 'yellow' };
543
599
  if (f.kind === 'secret') {
544
600
  return { text: storedSecret(project, f.key) ? t('keychain') : '—' };
545
601
  }
@@ -579,6 +635,8 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
579
635
  : 'package.json',
580
636
  };
581
637
  }
638
+ if (value && globalSigning[f.key] === value)
639
+ return { text: t('global') };
582
640
  return { text: (values[f.key] ?? '') ? t('local') : '' };
583
641
  };
584
642
  // label column (24) + source column + paddings; never below 20.
@@ -638,5 +696,8 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
638
696
  ? t('in sync')
639
697
  : changes.length === 1
640
698
  ? t('1 diff · y to apply')
641
- : t('{n} diffs · y to apply', { n: changes.length }) })] }), changes.map(c => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: c.from === undefined ? 'green' : 'yellow', children: c.from === undefined ? '+ ' : '~ ' }), c.key, ": ", c.from !== undefined && _jsxs(Text, { dimColor: true, children: [c.from, " \u2192 "] }), c.to] }, c.key)))] }), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderDimColor: true, borderLeft: false, borderRight: false, borderBottom: false, children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { bold: true, color: ACCENT, children: labelOf(current) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", t(current.help)] })] }) }), _jsx(Text, { color: "yellow", children: note })] }));
699
+ : t('{n} diffs · y to apply', { n: changes.length }) })] }), drift && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: "yellow", children: "\u2260 " }), t('addon name {addon} · manifest name {names}', {
700
+ addon: values['addonName'] ?? '',
701
+ names: drift.join(' / '),
702
+ })] })), changes.map(c => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: c.from === undefined ? 'green' : 'yellow', children: c.from === undefined ? '+ ' : '~ ' }), c.key, ": ", c.from !== undefined && _jsxs(Text, { dimColor: true, children: [c.from, " \u2192 "] }), c.to] }, c.key)))] }), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderDimColor: true, borderLeft: false, borderRight: false, borderBottom: false, children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { bold: true, color: ACCENT, children: labelOf(current) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", t(current.help)] })] }) }), _jsx(Text, { color: "yellow", children: note })] }));
642
703
  }
@@ -11,7 +11,10 @@ export function navWidth(columns) {
11
11
  return Math.min(48, Math.max(32, Math.floor(columns / 4)));
12
12
  }
13
13
  export function TopBar({ context, domain, auth, version, environment, }) {
14
- return (_jsxs(Box, { paddingX: 1, justifyContent: "space-between", height: 1, children: [_jsx(Box, { flexShrink: 1, marginRight: 2, children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { bold: true, color: ACCENT, children: "svc" }), environment && (_jsxs(Text, { children: [' ', _jsx(Text, { backgroundColor: environment.color, color: "black", bold: true, children: ` ${environment.name.toUpperCase()} ` })] })), _jsxs(Text, { dimColor: true, children: [" ", context] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", children: [domain && _jsxs(Text, { dimColor: true, children: [domain, " "] }), _jsx(Text, { color: auth.ready ? 'green' : 'yellow', children: auth.ready ? '●' : '○' }), _jsxs(Text, { children: [" ", auth.label, " "] }), _jsxs(Text, { dimColor: true, children: ["v", version] })] }) })] }));
14
+ // Narrow terminals squeeze the domain first, then the context, then the
15
+ // login. `svc` and the environment badge never give way: the badge is the
16
+ // one thing that says a deploy goes to production.
17
+ return (_jsxs(Box, { paddingX: 1, height: 1, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { bold: true, color: ACCENT, children: "svc" }), environment && (_jsxs(Text, { children: [' ', _jsx(Text, { backgroundColor: environment.color, color: "black", bold: true, children: ` ${environment.name.toUpperCase()} ` })] }))] }), _jsx(Box, { flexShrink: 2, flexGrow: 1, marginRight: 2, minWidth: 0, children: _jsxs(Text, { dimColor: true, wrap: "truncate", children: [' ', context] }) }), domain && (_jsx(Box, { flexShrink: 100, minWidth: 0, marginRight: 1, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: domain }) })), _jsx(Box, { flexShrink: 1, minWidth: 0, children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: auth.ready ? 'green' : 'yellow', children: auth.ready ? '●' : '○' }), _jsxs(Text, { children: [" ", auth.label] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { dimColor: true, children: [" v", version] }) })] }));
15
18
  }
16
19
  const TYPE_GLYPH = {
17
20
  web: 'web',
@@ -66,10 +66,11 @@ export function HelpPanel({ here, where, height, onClose, }) {
66
66
  const inner = useRef(null);
67
67
  const [top, setTop] = useState(0);
68
68
  const [total, setTotal] = useState(0);
69
+ // The text depends on the pane and the language, both fixed while this is open.
69
70
  useEffect(() => {
70
71
  if (inner.current)
71
72
  setTotal(measureElement(inner.current).height);
72
- });
73
+ }, [here, where, height]);
73
74
  const visible = Math.max(1, height - 1);
74
75
  const max = Math.max(0, total - visible);
75
76
  useInput((input, key) => {