sitevision-cli 1.0.0-beta.31 → 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 (107) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.js +73 -34
  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/scaffold/inquirer-stub.js +119 -0
  15. package/dist/scaffold/register.js +15 -0
  16. package/dist/shell/ConfigForm.js +174 -29
  17. package/dist/shell/Frame.js +4 -1
  18. package/dist/shell/Help.js +2 -1
  19. package/dist/shell/Settings.js +59 -6
  20. package/dist/shell/Shell.js +361 -43
  21. package/dist/shell/Tabs.js +8 -2
  22. package/dist/shell/actions.js +0 -0
  23. package/dist/utils/config.js +46 -3
  24. package/dist/utils/create-app.js +226 -0
  25. package/dist/utils/i18n.js +68 -13
  26. package/dist/utils/jsonc.js +2 -1
  27. package/dist/utils/keychain.js +44 -4
  28. package/dist/utils/oauth2-auth.js +35 -7
  29. package/dist/utils/password-prompt.js +23 -3
  30. package/dist/utils/process-runner.js +8 -3
  31. package/dist/utils/project-detection.js +172 -44
  32. package/dist/utils/session-cookie-auth.js +5 -4
  33. package/dist/utils/sitevision-api.js +21 -14
  34. package/dist/utils/sitevision-scripts-runner.js +32 -15
  35. package/dist/utils/tasks.js +132 -66
  36. package/dist/utils/version-check.js +3 -3
  37. package/dist/utils/webpack-runner.js +6 -5
  38. package/dist/utils/workspace.js +41 -3
  39. package/dist/utils/zip.js +18 -19
  40. package/package.json +24 -3
  41. package/readme.md +11 -4
  42. package/dist/app.d.ts +0 -6
  43. package/dist/app.js +0 -321
  44. package/dist/cli.d.ts +0 -2
  45. package/dist/commands/build.d.ts +0 -12
  46. package/dist/commands/deploy.d.ts +0 -17
  47. package/dist/commands/dev.d.ts +0 -14
  48. package/dist/commands/index.d.ts +0 -4
  49. package/dist/commands/info.d.ts +0 -2
  50. package/dist/commands/setup-signing.d.ts +0 -2
  51. package/dist/commands/sign.d.ts +0 -14
  52. package/dist/commands/types.d.ts +0 -18
  53. package/dist/commands/watch.d.ts +0 -2
  54. package/dist/components/AnimatedLogo.d.ts +0 -10
  55. package/dist/components/AuthLoginScreen.d.ts +0 -21
  56. package/dist/components/DevPropertiesForm.d.ts +0 -11
  57. package/dist/components/DevPropertiesForm.js +0 -246
  58. package/dist/components/InfoScreen.d.ts +0 -7
  59. package/dist/components/InfoScreen.js +0 -14
  60. package/dist/components/KeychainPasswordChoice.d.ts +0 -8
  61. package/dist/components/KeychainPasswordChoice.js +0 -30
  62. package/dist/components/MainMenu.d.ts +0 -7
  63. package/dist/components/MainMenu.js +0 -88
  64. package/dist/components/PasswordInput.d.ts +0 -10
  65. package/dist/components/ProcessOutput.d.ts +0 -6
  66. package/dist/components/ProcessOutput.js +0 -30
  67. package/dist/components/SetupFlow.d.ts +0 -8
  68. package/dist/components/SetupFlow.js +0 -233
  69. package/dist/components/SigningPropertiesForm.d.ts +0 -7
  70. package/dist/components/SigningPropertiesForm.js +0 -38
  71. package/dist/components/StatusIndicator.d.ts +0 -8
  72. package/dist/components/TextInput.d.ts +0 -10
  73. package/dist/components/TextInput.js +0 -29
  74. package/dist/components/WelcomeScreen.d.ts +0 -14
  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 -36
  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 -10
  84. package/dist/shell/Tabs.d.ts +0 -36
  85. package/dist/shell/actions.d.ts +0 -48
  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/environments.d.ts +0 -20
  90. package/dist/utils/i18n.d.ts +0 -12
  91. package/dist/utils/jsonc.d.ts +0 -19
  92. package/dist/utils/keychain.d.ts +0 -15
  93. package/dist/utils/oauth2-auth.d.ts +0 -64
  94. package/dist/utils/password-prompt.d.ts +0 -14
  95. package/dist/utils/process-runner.d.ts +0 -31
  96. package/dist/utils/project-detection.d.ts +0 -202
  97. package/dist/utils/session-cookie-auth.d.ts +0 -35
  98. package/dist/utils/signing-password.d.ts +0 -12
  99. package/dist/utils/signing-step.d.ts +0 -15
  100. package/dist/utils/signing-step.js +0 -16
  101. package/dist/utils/sitevision-api.d.ts +0 -155
  102. package/dist/utils/sitevision-scripts-runner.d.ts +0 -84
  103. package/dist/utils/tasks.d.ts +0 -50
  104. package/dist/utils/version-check.d.ts +0 -9
  105. package/dist/utils/webpack-runner.d.ts +0 -76
  106. package/dist/utils/workspace.d.ts +0 -17
  107. package/dist/utils/zip.d.ts +0 -71
@@ -0,0 +1,226 @@
1
+ /**
2
+ * New apps are scaffolded by Sitevision's own tool. svc runs it as a task and
3
+ * answers its questions through a socket (see source/scaffold); when the tool no
4
+ * longer asks through inquirer the run is reported as unmanaged, and the caller
5
+ * hands it the real terminal instead.
6
+ */
7
+ import fs from 'node:fs';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import readline from 'node:readline';
12
+ import { spawn } from 'node:child_process';
13
+ import { stripVTControlCharacters } from 'node:util';
14
+ import { t } from './i18n.js';
15
+ import { ProcessRunner } from './process-runner.js';
16
+ import { createTask, setPhase } from './tasks.js';
17
+ import { detectProject, findDevPropertiesPath, migrateLegacyPassword, readInheritedDevProperties, readManifest, updatePackageJson, writeManifestField, } from './project-detection.js';
18
+ export const CREATE_COMMAND = [
19
+ 'npx',
20
+ '--yes',
21
+ '@sitevision/create-sitevision-app',
22
+ ];
23
+ /** A folder name that is also a usable package name and manifest id. */
24
+ export const validAppName = (name) => /^[\da-z][\w\-.]*$/i.test(name);
25
+ /** The closest conventional name: "Min nya app!" and "MinNyaApp" become "min-nya-app". */
26
+ export function suggestAppName(name) {
27
+ return name
28
+ .trim()
29
+ .normalize('NFKD')
30
+ .replaceAll(/[\u0300-\u036F]/g, '')
31
+ .replaceAll(/(?<=[\da-z])(?=[A-Z])/g, '-')
32
+ .toLowerCase()
33
+ .replaceAll(/\s+/g, '-')
34
+ .replaceAll(/[^\w\-.]/g, '')
35
+ .replace(/^[^\da-z]+/i, '');
36
+ }
37
+ /** What is wrong with a typed app name, said so the fix is obvious. */
38
+ export function appNameProblem(typed) {
39
+ const name = typed.trim();
40
+ if (!name)
41
+ return t('Type a name for the app, for example my-new-app.');
42
+ if (validAppName(name))
43
+ return undefined;
44
+ const suggestion = suggestAppName(name);
45
+ const tryThis = suggestion
46
+ ? ' ' + t('Try "{suggestion}".', { suggestion })
47
+ : '';
48
+ if (/\s/.test(name) && validAppName(name.replaceAll(/\s+/g, '-')))
49
+ return t("An app name can't have spaces.") + tryThis;
50
+ const bad = [...new Set(name.replaceAll(/[\s\w\-.]/g, ''))].join(' ');
51
+ if (bad) {
52
+ return (t("An app name can't contain {chars}: it becomes the folder, the package name and the app id.", {
53
+ chars: bad,
54
+ }) + tryThis);
55
+ }
56
+ return t('Start the name with a letter or a digit.') + tryThis;
57
+ }
58
+ /** A valid name that could be better; the user may keep it. */
59
+ export function appNameAdvice(typed) {
60
+ const name = typed.trim();
61
+ if (!validAppName(name) || name === name.toLowerCase())
62
+ return undefined;
63
+ return t('Lowercase is safer: npm expects it, and "{name}" and "{lower}" are one folder on macOS and Windows but two on Linux. Try "{suggestion}", or press Enter again to keep "{name}".', { name, lower: name.toLowerCase(), suggestion: suggestAppName(name) });
64
+ }
65
+ export const isEmptyOrMissing = (dir) => !fs.existsSync(dir) || fs.readdirSync(dir).length === 0;
66
+ /** Listen for the stub's questions. `null` from `ask` means the user cancelled. */
67
+ export async function servePrompts(ask, onCancel) {
68
+ const id = `svc-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
69
+ const socketPath = process.platform === 'win32'
70
+ ? `\\\\.\\pipe\\${id}`
71
+ : path.join(os.tmpdir(), `${id}.sock`);
72
+ let connected = false;
73
+ const server = net.createServer(socket => {
74
+ connected = true;
75
+ socket.on('error', () => { });
76
+ void (async () => {
77
+ for await (const line of readline.createInterface({ input: socket })) {
78
+ const reply = await ask(JSON.parse(line));
79
+ if (!reply) {
80
+ socket.destroy();
81
+ onCancel();
82
+ return;
83
+ }
84
+ socket.write(JSON.stringify(reply) + '\n');
85
+ }
86
+ })();
87
+ });
88
+ await new Promise((resolve, reject) => {
89
+ server.once('error', reject);
90
+ server.listen(socketPath, resolve);
91
+ });
92
+ return {
93
+ socketPath,
94
+ connected: () => connected,
95
+ close: () => server.close(),
96
+ };
97
+ }
98
+ /**
99
+ * Run the scaffolder for `name` inside `parentDir`. "unmanaged" means it never
100
+ * asked svc anything and left no app: the prompt bridge did not take.
101
+ */
102
+ export function startCreateApp(options) {
103
+ const { name, parentDir, ask, command = CREATE_COMMAND } = options;
104
+ const appDir = path.join(parentDir, name);
105
+ let runner;
106
+ let stopped = false;
107
+ const { task, log, finish } = createTask('create', { root: appDir, manifest: { name, id: name } }, 'create-sitevision-app', () => {
108
+ stopped = true;
109
+ runner?.kill();
110
+ finish('stopped');
111
+ });
112
+ const done = (async () => {
113
+ const prompts = await servePrompts(ask, () => task.stop());
114
+ try {
115
+ // .js from dist, .ts when svc itself runs from source.
116
+ const register = new URL(`../scaffold/register${path.extname(import.meta.url)}`, import.meta.url).href;
117
+ runner = new ProcessRunner(command[0], [...command.slice(1), name], parentDir, {
118
+ ...process.env,
119
+ NODE_OPTIONS: `${process.env['NODE_OPTIONS'] ?? ''} --import=${register}`.trim(),
120
+ SVC_PROMPT_SOCKET: prompts.socketPath,
121
+ });
122
+ runner.on('output', (output) => {
123
+ // The tool clears the screen between steps; that is noise in a log.
124
+ const text = stripVTControlCharacters(output.data);
125
+ if (text.trim())
126
+ log('create', text, output.type === 'stderr' ? 'warn' : 'info');
127
+ });
128
+ setPhase(task, 'scaffolding');
129
+ await runner.run();
130
+ if (stopped)
131
+ return 'stopped';
132
+ // The exit code is 0 even when the prompts crash, so look at the result.
133
+ if (readManifest(appDir)) {
134
+ finish('success');
135
+ return 'created';
136
+ }
137
+ if (prompts.connected()) {
138
+ finish('error', 'The scaffolder finished without creating an app');
139
+ return 'failed';
140
+ }
141
+ finish('stopped');
142
+ return 'unmanaged';
143
+ }
144
+ catch (error) {
145
+ finish('error', error instanceof Error ? error.message : String(error));
146
+ return 'failed';
147
+ }
148
+ finally {
149
+ prompts.close();
150
+ }
151
+ })();
152
+ return { task, done };
153
+ }
154
+ /** Run the scaffolder on the real terminal; resolves when it exits. */
155
+ export async function createAppInTerminal(name, parentDir) {
156
+ await new Promise(resolve => {
157
+ // `name` is validated by the caller; joined to avoid DEP0190.
158
+ const child = spawn([...CREATE_COMMAND, name].join(' '), {
159
+ cwd: parentDir,
160
+ stdio: 'inherit',
161
+ shell: true,
162
+ });
163
+ child.on('close', () => resolve());
164
+ child.on('error', () => resolve());
165
+ });
166
+ try {
167
+ return Boolean(readManifest(path.join(parentDir, name)));
168
+ }
169
+ catch {
170
+ // A half-written manifest is a failed scaffold, not a crash.
171
+ return false;
172
+ }
173
+ }
174
+ /**
175
+ * Replace the template's placeholders with what is known, and fold the config
176
+ * the tool wrote into svc's layout: no plaintext password, and nothing in the
177
+ * app's .dev_properties.json that the workspace already provides.
178
+ */
179
+ export function seedNewApp(appDir, workspaceRoot) {
180
+ const found = readManifest(appDir);
181
+ if (!found)
182
+ return;
183
+ const { manifest, manifestPath } = found;
184
+ const seed = (key, value) => {
185
+ const localized = typeof manifest[key] === 'object';
186
+ writeManifestField(manifestPath, key, value, localized ? 'en' : undefined);
187
+ };
188
+ seed('name', path.basename(appDir));
189
+ if (workspaceRoot) {
190
+ let shared = {};
191
+ try {
192
+ shared = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8'));
193
+ }
194
+ catch { }
195
+ const author = typeof shared['author'] === 'object'
196
+ ? shared['author']?.name
197
+ : shared['author'];
198
+ if (typeof author === 'string' && author)
199
+ writeManifestField(manifestPath, 'author', author);
200
+ if (typeof shared['homepage'] === 'string' && shared['homepage'])
201
+ writeManifestField(manifestPath, 'helpUrl', shared['homepage']);
202
+ }
203
+ const project = detectProject(appDir);
204
+ if (project)
205
+ migrateLegacyPassword(project);
206
+ const file = findDevPropertiesPath(appDir);
207
+ if (!workspaceRoot || !file)
208
+ return;
209
+ const own = JSON.parse(fs.readFileSync(file, 'utf8'));
210
+ const inherited = readInheritedDevProperties(appDir);
211
+ if (typeof own['addonName'] === 'string' && own['addonName']) {
212
+ const addonName = own['addonName'];
213
+ updatePackageJson(appDir, packageJson => {
214
+ packageJson['addonName'] = addonName;
215
+ });
216
+ }
217
+ const rest = Object.fromEntries(Object.entries(own).filter(([key, value]) => key !== 'addonName' &&
218
+ value !== '' &&
219
+ value !== undefined &&
220
+ !(key === 'useHTTPForDevDeploy' && value === false) &&
221
+ JSON.stringify(value) !== JSON.stringify(inherited[key])));
222
+ if (Object.keys(rest).length === 0)
223
+ fs.rmSync(file);
224
+ else
225
+ fs.writeFileSync(file, JSON.stringify(rest, null, 2) + '\n');
226
+ }
@@ -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,11 +204,43 @@ 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',
204
211
  SIGNING: 'SIGNERING',
212
+ MANIFEST: 'MANIFEST',
213
+ // New app
214
+ 'Name of the new app': 'Namn på den nya appen',
215
+ 'Create it in folder': 'Skapa den i mappen',
216
+ 'Other folder…': 'Annan mapp…',
217
+ 'Type a name for the app, for example my-new-app.': 'Skriv ett namn på appen, till exempel min-nya-app.',
218
+ 'Try "{suggestion}".': 'Prova "{suggestion}".',
219
+ "An app name can't have spaces.": 'Ett appnamn kan inte innehålla mellanslag.',
220
+ "An app name can't contain {chars}: it becomes the folder, the package name and the app id.": 'Ett appnamn kan inte innehålla {chars}: det blir mappen, paketnamnet och appens id.',
221
+ 'Lowercase is safer: npm expects it, and "{name}" and "{lower}" are one folder on macOS and Windows but two on Linux. Try "{suggestion}", or press Enter again to keep "{name}".': 'Gemener är säkrare: npm förväntar sig det, och "{name}" och "{lower}" är samma mapp på macOS och Windows men två på Linux. Prova "{suggestion}", eller tryck Enter igen för att behålla "{name}".',
222
+ 'Start the name with a letter or a digit.': 'Börja namnet med en bokstav eller en siffra.',
223
+ '{dir} already exists. Pick another name, or keep it and choose a different folder next.': '{dir} finns redan. Välj ett annat namn, eller behåll det och välj en annan mapp i nästa steg.',
224
+ 'creating {app}: installing, questions follow': 'skapar {app}: installerar, frågor kommer strax',
225
+ 'The scaffolder could not be run from here': 'Verktyget kunde inte köras härifrån',
226
+ 'creating {app} failed, see the log': 'kunde inte skapa {app}, se loggen',
227
+ '{app} created in {dir}': '{app} skapad i {dir}',
228
+ '{app} created': '{app} skapad',
229
+ 'Create the addon "{addon}" on {domain} now?': 'Skapa tillägget "{addon}" på {domain} nu?',
230
+ '↑↓ move · Space toggle · Enter submit · Esc cancel': '↑↓ flytta · Mellanslag markera · Enter skicka · Esc avbryt',
231
+ 'App id': 'App-id',
232
+ Version: 'Version',
233
+ Name: 'Namn',
234
+ Description: 'Beskrivning',
235
+ Author: 'Författare',
236
+ 'Help URL': 'Hjälp-URL',
237
+ 'Not saved: {label} is required.': 'Inte sparat: {label} krävs.',
238
+ 'Identifier of the app in Sitevision; it also names the zip. Changing it makes the next deploy a new app instead of an update.': 'Appens identifierare i Sitevision; den namnger också zip-filen. Ändras den blir nästa driftsättning en ny app i stället för en uppdatering.',
239
+ 'Version of the app. Id and version together identify an upload, so bump it to deploy a new version instead of overwriting the current one.': 'Appens version. Id och version identifierar tillsammans en uppladdning, så höj den för att driftsätta en ny version i stället för att skriva över den nuvarande.',
240
+ 'Name shown when importing and administering the app in the Sitevision editor. A multilingual manifest needs at least English.': 'Namnet som visas när appen importeras och administreras i Sitevisions redigerare. Ett flerspråkigt manifest behöver minst engelska.',
241
+ 'Short description shown next to the name in the Sitevision editor.': 'Kort beskrivning som visas bredvid namnet i Sitevisions redigerare.',
242
+ 'Who made the app, shown in the Sitevision editor.': 'Vem som gjort appen, visas i Sitevisions redigerare.',
243
+ "Link to the app's documentation, shown in the Sitevision editor.": 'Länk till appens dokumentation, visas i Sitevisions redigerare.',
205
244
  ENVIRONMENT: 'MILJÖ',
206
245
  'Environment name': 'Miljönamn',
207
246
  Production: 'Produktion',
@@ -274,7 +313,9 @@ const sv = {
274
313
  // Config help
275
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.',
276
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.',
277
- "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}',
278
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.',
279
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.',
280
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.',
@@ -290,6 +331,20 @@ const sv = {
290
331
  // Settings screen
291
332
  Language: 'Språk',
292
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',
293
348
  on: 'på',
294
349
  off: 'av',
295
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
@@ -53,16 +53,21 @@ export class ProcessRunner extends EventEmitter {
53
53
  command;
54
54
  args;
55
55
  cwd;
56
- constructor(command, args = [], cwd) {
56
+ env;
57
+ constructor(command, args = [], cwd, env) {
57
58
  super();
58
59
  this.command = command;
59
60
  this.args = args;
60
61
  this.cwd = cwd;
62
+ this.env = env;
61
63
  }
62
64
  run() {
63
65
  return new Promise((resolve, reject) => {
64
- 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(' '), [], {
65
69
  cwd: this.cwd || process.cwd(),
70
+ env: this.env,
66
71
  shell: true,
67
72
  stdio: 'pipe',
68
73
  });
@@ -81,7 +86,7 @@ export class ProcessRunner extends EventEmitter {
81
86
  reject(error);
82
87
  });
83
88
  this.process.on('close', code => {
84
- const exitCode = code ?? 0;
89
+ const exitCode = code ?? 1;
85
90
  this.emit('exit', exitCode);
86
91
  resolve({
87
92
  exitCode,