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
@@ -1,12 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
4
5
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
5
6
  import Spinner from 'ink-spinner';
6
7
  import { detectProject, appTypeOf, localizedText, readWorkspaceDevProperties, readSvcConfig, writeSvcConfig, writeDevProperties, syncDevPropertiesToPackageJson, } from '../utils/project-detection.js';
7
- import { appGroup, configIncomplete, needsOnboarding, } from '../utils/workspace.js';
8
+ import { appGroup, configIncomplete, discoverApps, needsOnboarding, } from '../utils/workspace.js';
9
+ import { createAppInTerminal, isEmptyOrMissing, seedNewApp, startCreateApp, appNameAdvice, appNameProblem, } from '../utils/create-app.js';
8
10
  import { listAddons, listExecutables, } from '../utils/sitevision-api.js';
9
- import { useTasks, runningTasks, startActivate, getTasks, } from '../utils/tasks.js';
11
+ import { useTasks, runningTasks, startActivate, getTasks, scaffoldRunning, } from '../utils/tasks.js';
10
12
  import { PasswordInput } from '../components/PasswordInput.js';
11
13
  import { AuthLoginScreen } from '../components/AuthLoginScreen.js';
12
14
  import { TopBar, Navigator, NavigatorStrip, BottomBar, navMatches, navMove, appLabel, navWidth, NARROW_BELOW, ACCENT, } from './Frame.js';
@@ -20,7 +22,8 @@ import { Popover } from './Popover.js';
20
22
  import { ChangelogPanel } from './Changelog.js';
21
23
  import { baseEnvironment, environmentColor, environmentNames, environmentProject, isProductionEnvironment, resolveEnvironment, } from '../utils/environments.js';
22
24
  import { t } from '../utils/i18n.js';
23
- import { actionForKey, authState, resolveDeployConfig, } from './actions.js';
25
+ import { onKeychainSaveFailed } from '../utils/keychain.js';
26
+ import { actionForKey, actions, authState, resolveDeployConfig, } from './actions.js';
24
27
  function useSize() {
25
28
  const { stdout } = useStdout();
26
29
  const read = () => ({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
@@ -35,7 +38,7 @@ function useSize() {
35
38
  }, [stdout]);
36
39
  return size;
37
40
  }
38
- export function Shell({ apps: initialApps, workspaceRoot, version, minimal = false, updatedFrom, }) {
41
+ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = false, updatedFrom, handover, skipped, }) {
39
42
  const { exit } = useApp();
40
43
  const { columns, rows } = useSize();
41
44
  const tasks = useTasks();
@@ -45,13 +48,31 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
45
48
  const [selected, setSelected] = useState(onboard ? initialApps.length : 0);
46
49
  const [tab, setTab] = useState('overview');
47
50
  const [focus, setFocus] = useState(workspaceRoot && !onboard ? 'nav' : 'content');
48
- const [overlay, setOverlay] = useState(updatedFrom ? { kind: 'changelog', since: updatedFrom } : null);
51
+ const [overlays, setOverlays] = useState(updatedFrom ? [{ kind: 'changelog', since: updatedFrom }] : []);
52
+ const setOverlay = useCallback((next) => {
53
+ setOverlays(stack => overlayStack(stack, next));
54
+ }, []);
55
+ const [editing, setEditing] = useState(false);
56
+ // A confirm that turns up mid-edit stays hidden until the field is done.
57
+ const top = overlays.at(-1) ?? null;
58
+ const overlay = top?.kind === 'confirm' && editing ? null : top;
49
59
  const [filter, setFilter] = useState('');
50
60
  const [versions, setVersions] = useState({});
51
61
  const [versionRow, setVersionRow] = useState(0);
52
62
  const [logScroll, setLogScroll] = useState(0);
53
63
  const [logWrap, setLogWrap] = useState(false);
54
- const [notice, setNotice] = useState(null);
64
+ const [offerAddon, setOfferAddon] = useState();
65
+ const [notice, setNotice] = useState(skipped?.length
66
+ ? {
67
+ text: skipped.length === 1
68
+ ? t('Skipped: {reason}', { reason: skipped[0] })
69
+ : t('Skipped {n} apps. First: {reason}', {
70
+ n: skipped.length,
71
+ reason: skipped[0],
72
+ }),
73
+ level: 'warn',
74
+ }
75
+ : null);
55
76
  const [, tick] = useReducer((n) => n + 1, 0);
56
77
  // In workspace mode the row after the last app is "Workspace settings".
57
78
  const settings = Boolean(workspaceRoot) && selected === apps.length;
@@ -114,12 +135,35 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
114
135
  const notify = useCallback((text, level = 'info') => {
115
136
  setNotice({ text, level });
116
137
  }, []);
138
+ // "Save to keychain" that did not save would otherwise just ask again.
139
+ useEffect(() => {
140
+ onKeychainSaveFailed(() => {
141
+ notify(t('Could not save to the OS keychain; you will be asked again next time.'), 'warn');
142
+ });
143
+ }, [notify]);
117
144
  const quit = useCallback(() => {
118
- for (const task of getTasks())
119
- if (task.status === 'running')
120
- task.stop();
121
- exit();
122
- }, [exit]);
145
+ const leave = () => {
146
+ for (const task of getTasks())
147
+ if (task.status === 'running')
148
+ task.stop();
149
+ exit();
150
+ };
151
+ // Between the scaffolder's questions nothing is open, so q is live:
152
+ // quitting then would leave a half-made app behind without a word.
153
+ if (scaffoldRunning()) {
154
+ setOverlay({
155
+ kind: 'confirm',
156
+ message: t('A new app is still being created. Stop it and quit?'),
157
+ resolve(yes) {
158
+ if (yes)
159
+ leave();
160
+ },
161
+ });
162
+ }
163
+ else {
164
+ leave();
165
+ }
166
+ }, [exit, setOverlay]);
123
167
  const context = useMemo(() => ({
124
168
  project,
125
169
  reload,
@@ -169,6 +213,178 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
169
213
  writeSvcConfig(configRoot, { environment: clean });
170
214
  notify(t('environment {env} added', { env: clean }), 'ok');
171
215
  },
216
+ async createApp() {
217
+ // Only what this flow opened is closed when the scaffolder ends.
218
+ const asked = new Set();
219
+ const openOwn = (next) => {
220
+ asked.add(next);
221
+ setOverlay(next);
222
+ };
223
+ if (scaffoldRunning()) {
224
+ notify(t('An app is already being created.'), 'warn');
225
+ return;
226
+ }
227
+ const promptText = async (label, initial, validate, error, advise) => new Promise(resolve => {
228
+ openOwn({
229
+ kind: 'prompt',
230
+ label,
231
+ initial,
232
+ validate,
233
+ advise,
234
+ error,
235
+ resolve,
236
+ });
237
+ });
238
+ const base = workspaceRoot ?? path.dirname(rawProject.root);
239
+ const suggested = workspaceRoot
240
+ ? path.relative(workspaceRoot, path.dirname(rawProject.root)) || '.'
241
+ : base;
242
+ // Asked again, with the reason, until the name is free in that folder.
243
+ let name = '';
244
+ let parentDir = '';
245
+ let appDir = '';
246
+ let taken;
247
+ for (;;) {
248
+ // eslint-disable-next-line no-await-in-loop
249
+ const typed = await promptText(t('Name of the new app'), name, appNameProblem, taken, appNameAdvice);
250
+ if (typed === null)
251
+ return;
252
+ name = typed.trim();
253
+ // Folders that already hold apps, then a way to type another one.
254
+ const folders = [
255
+ ...new Set([
256
+ suggested,
257
+ ...apps.map(app => workspaceRoot
258
+ ? path.relative(base, path.dirname(app.root)) || '.'
259
+ : path.dirname(app.root)),
260
+ ]),
261
+ ].toSorted((a, b) => a.localeCompare(b));
262
+ // eslint-disable-next-line no-await-in-loop
263
+ const folderPick = await new Promise(resolve => {
264
+ openOwn({
265
+ kind: 'choice',
266
+ label: t('Create it in folder'),
267
+ choices: [...folders, t('Other folder…')],
268
+ multi: false,
269
+ initial: [folders.indexOf(suggested)],
270
+ resolve,
271
+ });
272
+ });
273
+ if (!folderPick)
274
+ return;
275
+ const folder = folders[folderPick[0]] ??
276
+ // eslint-disable-next-line no-await-in-loop
277
+ (await promptText(t('Create it in folder'), suggested));
278
+ if (folder === null)
279
+ return;
280
+ parentDir = path.resolve(base, folder.trim() || '.');
281
+ appDir = path.join(parentDir, name);
282
+ if (isEmptyOrMissing(appDir))
283
+ break;
284
+ taken = t('{dir} already exists. Pick another name, or keep it and choose a different folder next.', { dir: path.relative(base, appDir) });
285
+ }
286
+ // Both were checked above, so a run that leaves no app may remove them.
287
+ const madeParent = !fs.existsSync(parentDir);
288
+ const discard = () => {
289
+ fs.rmSync(appDir, { recursive: true, force: true });
290
+ if (madeParent && isEmptyOrMissing(parentDir))
291
+ fs.rmSync(parentDir, { recursive: true, force: true });
292
+ };
293
+ fs.mkdirSync(parentDir, { recursive: true });
294
+ const known = (workspaceRoot
295
+ ? readWorkspaceDevProperties(workspaceRoot)
296
+ : rawProject.devProperties);
297
+ const ask = async (question) => {
298
+ // svc keeps passwords in the keychain, never in the app's file.
299
+ if (question.name === 'password')
300
+ return { skip: true };
301
+ const shared = known?.[question.name];
302
+ if (!question.error &&
303
+ ['domain', 'siteName', 'username', 'useHTTPForDevDeploy'].includes(question.name) &&
304
+ shared !== undefined &&
305
+ shared !== '')
306
+ return { value: shared };
307
+ const label = question.error
308
+ ? `${question.message} (${question.error})`
309
+ : question.message;
310
+ if (question.type === 'confirm') {
311
+ const value = await new Promise(resolve => {
312
+ openOwn({ kind: 'confirm', message: label, resolve });
313
+ });
314
+ return { value };
315
+ }
316
+ if (question.type === 'password') {
317
+ const secret = await new Promise(resolve => {
318
+ openOwn({ kind: 'password', label, resolve });
319
+ });
320
+ return secret && { value: secret.password };
321
+ }
322
+ if (question.choices.length > 0) {
323
+ const multi = question.type === 'checkbox';
324
+ const picked = await new Promise(resolve => {
325
+ openOwn({
326
+ kind: 'choice',
327
+ label,
328
+ choices: question.choices,
329
+ multi,
330
+ initial: [question.default ?? []].flat(),
331
+ resolve,
332
+ });
333
+ });
334
+ return picked && { value: multi ? picked : picked[0] };
335
+ }
336
+ const initial = question.default ?? (question.name === 'addonName' ? name : '');
337
+ const value = await promptText(label, typeof initial === 'string' ? initial : JSON.stringify(initial));
338
+ return value === null ? null : { value };
339
+ };
340
+ notify(t('creating {app}: installing, questions follow', { app: name }));
341
+ setTab('log');
342
+ const { done } = startCreateApp({ name, parentDir, ask });
343
+ let outcome = await done;
344
+ setOverlays(stack => withoutOverlays(stack, asked));
345
+ if (outcome === 'unmanaged') {
346
+ if (!handover) {
347
+ notify(t('The scaffolder could not be run from here'), 'error');
348
+ return;
349
+ }
350
+ fs.rmSync(appDir, { recursive: true, force: true });
351
+ handover(async () => {
352
+ if (await createAppInTerminal(name, parentDir))
353
+ seedNewApp(appDir, workspaceRoot);
354
+ else
355
+ discard();
356
+ });
357
+ return;
358
+ }
359
+ if (outcome !== 'created') {
360
+ discard();
361
+ if (outcome === 'failed')
362
+ notify(t('creating {app} failed, see the log', { app: name }), 'error');
363
+ return;
364
+ }
365
+ try {
366
+ seedNewApp(appDir, workspaceRoot);
367
+ }
368
+ catch (error) {
369
+ notify(error instanceof Error ? error.message : String(error), 'warn');
370
+ outcome = 'failed';
371
+ }
372
+ if (!workspaceRoot ||
373
+ path.relative(workspaceRoot, appDir).startsWith('..')) {
374
+ notify(t('{app} created in {dir}', { app: name, dir: appDir }), 'ok');
375
+ return;
376
+ }
377
+ const left = [];
378
+ const found = discoverApps(workspaceRoot, left);
379
+ if (left[0])
380
+ notify(t('Skipped: {reason}', { reason: left[0] }), 'warn');
381
+ setApps(found);
382
+ setFilter('');
383
+ setSelected(Math.max(0, found.findIndex(app => app.root === appDir)));
384
+ if (outcome === 'created')
385
+ notify(t('{app} created', { app: name }), 'ok');
386
+ setOfferAddon(appDir);
387
+ },
172
388
  openWorkspaceSettings: workspaceRoot
173
389
  ? () => {
174
390
  setSelected(apps.length);
@@ -204,7 +420,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
204
420
  action.run(context).catch((error) => {
205
421
  notify(error instanceof Error ? error.message : String(error), 'error');
206
422
  });
207
- }, [context, project, notify]);
423
+ }, [context, project, notify, setOverlay]);
208
424
  const fetchVersions = useCallback(async (fresh = false) => {
209
425
  const key = versionsKey;
210
426
  const config = await resolveDeployConfig(context, fresh);
@@ -245,19 +461,52 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
245
461
  await wait();
246
462
  notify(task.error ?? t('{v} activated', { v: executable.appVersion }), task.error ? 'error' : 'ok');
247
463
  await fetchVersions();
248
- }, [versions, project, versionRow, context, notify, fetchVersions]);
464
+ }, [
465
+ versions,
466
+ versionsKey,
467
+ project,
468
+ versionRow,
469
+ context,
470
+ notify,
471
+ fetchVersions,
472
+ ]);
249
473
  // A popover owns the keyboard; the form stays mounted underneath it.
250
474
  const formActive = (tab === 'config' || settings) && focus === 'content' && !overlay;
251
- const [editing, setEditing] = useState(false);
252
475
  const pickAddon = useCallback(async () => new Promise(resolve => {
253
476
  setOverlay({ kind: 'picker', resolve });
254
- }), []);
477
+ }), [setOverlay]);
478
+ // A freshly created app: once it is the selected project, offer its addon.
479
+ useEffect(() => {
480
+ if (!offerAddon || project.root !== offerAddon || overlay)
481
+ return;
482
+ setOfferAddon(undefined);
483
+ const addon = project.devProperties?.addonName;
484
+ const action = actions.find(entry => entry.id === 'create-addon');
485
+ if (!addon || !action || !project.devProperties?.domain)
486
+ return;
487
+ void context
488
+ .confirm(t('Create the addon "{addon}" on {domain} now?', {
489
+ addon,
490
+ domain: project.devProperties.domain,
491
+ }))
492
+ .then(yes => {
493
+ if (yes)
494
+ run(action);
495
+ });
496
+ }, [offerAddon, project, overlay, context, run]);
255
497
  const loadAddons = useCallback(async () => {
256
- const config = await resolveDeployConfig(context);
498
+ const config = await resolveDeployConfig(context, false, { addon: false });
257
499
  return config ? listAddons(config) : { error: t('No credentials.') };
258
500
  }, [context]);
259
501
  const appTasks = tasks.filter(task => task.appRoot === project.root);
260
- const logTask = appTasks.find(task => task.status === 'running') ?? appTasks.at(-1);
502
+ // A new app is not in the list while it is being created, so its log shows
503
+ // wherever you are; a failed one stays up until another task runs.
504
+ const creating = tasks.findLast(task => task.kind === 'create');
505
+ const logTask = appTasks.find(task => task.status === 'running') ??
506
+ (creating?.status === 'running' ||
507
+ (creating?.status === 'error' && creating === tasks.at(-1))
508
+ ? creating
509
+ : appTasks.at(-1));
261
510
  useInput((raw, key) => {
262
511
  // Terminals speaking the kitty keyboard protocol report shift+s as
263
512
  // "s" plus a shift flag; fold that back into the uppercase letter so
@@ -276,6 +525,9 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
276
525
  setFocus(single ? 'content' : 'nav');
277
526
  return;
278
527
  }
528
+ // Ctrl+D is not d: only plain keys are shortcuts.
529
+ if (key.ctrl || key.meta)
530
+ return;
279
531
  if (input === '/') {
280
532
  setOverlay({ kind: 'palette' });
281
533
  return;
@@ -284,6 +536,11 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
284
536
  setOverlay({ kind: 'help' });
285
537
  return;
286
538
  }
539
+ // Reachable from the navigator's search and the workspace form too.
540
+ if (input === ',') {
541
+ setOverlay({ kind: 'settings' });
542
+ return;
543
+ }
287
544
  // The config form uses Tab/Shift+Tab to move between fields.
288
545
  if (key.tab && !formActive) {
289
546
  setFilter('');
@@ -320,6 +577,23 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
320
577
  search(filter + input);
321
578
  return;
322
579
  }
580
+ if (settings && focus === 'content') {
581
+ // Settings pane: the form owns everything but q, y and Tab/Esc above.
582
+ if (input === 'q')
583
+ quit();
584
+ else if (input === 'y') {
585
+ try {
586
+ if (syncDevPropertiesToPackageJson(workspaceRoot)) {
587
+ reload();
588
+ notify(t('package.json updated'), 'ok');
589
+ }
590
+ }
591
+ catch (error) {
592
+ notify(error instanceof Error ? error.message : String(error), 'error');
593
+ }
594
+ }
595
+ return;
596
+ }
323
597
  if (input === 'a' && tab !== 'versions') {
324
598
  setTab('versions');
325
599
  setFocus('content');
@@ -338,23 +612,6 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
338
612
  setTab(TABS[(i + (key.rightArrow ? 1 : TABS.length - 1)) % TABS.length].id);
339
613
  return;
340
614
  }
341
- if (settings && focus === 'content') {
342
- // Settings pane: the form owns everything but q, y and Tab/Esc above.
343
- if (input === 'q')
344
- quit();
345
- else if (input === 'y') {
346
- try {
347
- if (syncDevPropertiesToPackageJson(workspaceRoot)) {
348
- reload();
349
- notify(t('package.json updated'), 'ok');
350
- }
351
- }
352
- catch (error) {
353
- notify(error instanceof Error ? error.message : String(error), 'error');
354
- }
355
- }
356
- return;
357
- }
358
615
  if (tab === 'versions') {
359
616
  const count = versions[versionsKey]?.executables?.length ?? 0;
360
617
  if (key.upArrow)
@@ -532,6 +789,8 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
532
789
  base: rawProject.devProperties,
533
790
  environment: env,
534
791
  workspaceRoot,
792
+ manifest: project.manifest,
793
+ manifestPath: project.paths.manifest,
535
794
  }, active: formActive, width: narrow ? columns : columns - sidebar, height: contentHeight, pickAddon: pickAddon, onSaved: () => {
536
795
  reload();
537
796
  notify(t('config saved'), 'ok');
@@ -574,13 +833,21 @@ function renderOverlay(overlay, env) {
574
833
  overlay.resolve(answer);
575
834
  } }));
576
835
  case 'prompt':
577
- return (_jsx(TextPrompt, { label: overlay.label, onSubmit: value => {
836
+ return (_jsx(TextPrompt, { label: overlay.label, initial: overlay.initial, validate: overlay.validate, advise: overlay.advise, error: overlay.error, onSubmit: value => {
578
837
  closeOverlay();
579
838
  overlay.resolve(value);
580
839
  }, onCancel: () => {
581
840
  closeOverlay();
582
841
  overlay.resolve(null);
583
- } }));
842
+ } }, overlay.label));
843
+ case 'choice':
844
+ return (_jsx(ChoicePrompt, { label: overlay.label, choices: overlay.choices, multi: overlay.multi, initial: overlay.initial, onSubmit: picked => {
845
+ closeOverlay();
846
+ overlay.resolve(picked);
847
+ }, onCancel: () => {
848
+ closeOverlay();
849
+ overlay.resolve(null);
850
+ } }, overlay.label));
584
851
  case 'help':
585
852
  return (_jsx(HelpPanel, { here: here, where: where, height: height, onClose: closeOverlay }));
586
853
  case 'changelog':
@@ -597,26 +864,77 @@ function renderOverlay(overlay, env) {
597
864
  } }));
598
865
  }
599
866
  }
600
- function Confirm({ message, onAnswer, }) {
601
- useInput(input => {
867
+ /**
868
+ * The open overlays, last one shown. `null` closes the shown one. A new overlay
869
+ * covers the current one instead of replacing it, so whoever awaits the covered
870
+ * one still gets an answer. A confirm goes underneath: background tasks raise
871
+ * them, and on top it would take the keys being typed into a prompt.
872
+ */
873
+ export function overlayStack(stack, next) {
874
+ if (next === null)
875
+ return stack.slice(0, -1);
876
+ return next.kind === 'confirm' ? [next, ...stack] : [...stack, next];
877
+ }
878
+ export function withoutOverlays(stack, gone) {
879
+ return stack.filter(overlay => !gone.has(overlay));
880
+ }
881
+ export function Confirm({ message, onAnswer, }) {
882
+ useInput((input, key) => {
602
883
  if (input === 'y' || input === 'Y')
603
884
  onAnswer(true);
604
- else if (input === 'n' || input === 'N')
885
+ else if (input === 'n' || input === 'N' || key.escape)
605
886
  onAnswer(false);
606
887
  });
607
888
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { children: message }), _jsx(Text, { dimColor: true, children: t('y confirm · n cancel') })] }));
608
889
  }
609
- function TextPrompt({ label, onSubmit, onCancel, }) {
610
- const [value, setValue] = useState('');
890
+ function ChoicePrompt({ label, choices, multi, initial, onSubmit, onCancel, }) {
891
+ const [cursor, setCursor] = useState(multi ? 0 : (initial[0] ?? 0));
892
+ const [checked, setChecked] = useState(() => new Set(multi ? initial : []));
611
893
  useInput((input, key) => {
612
894
  if (key.escape)
613
895
  onCancel();
614
896
  else if (key.return)
615
- onSubmit(value);
897
+ onSubmit(multi ? [...checked].toSorted((a, b) => a - b) : [cursor]);
898
+ else if (key.upArrow)
899
+ setCursor(c => (c - 1 + choices.length) % choices.length);
900
+ else if (key.downArrow)
901
+ setCursor(c => (c + 1) % choices.length);
902
+ else if (multi && input === ' ') {
903
+ setChecked(current => {
904
+ const next = new Set(current);
905
+ if (!next.delete(cursor))
906
+ next.add(cursor);
907
+ return next;
908
+ });
909
+ }
910
+ });
911
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), choices.map((choice, i) => (_jsxs(Text, { color: i === cursor ? ACCENT : undefined, bold: i === cursor, children: [i === cursor ? '▸ ' : ' ', multi ? (checked.has(i) ? '◉ ' : '◯ ') : '', choice] }, choice))), _jsx(Text, { dimColor: true, children: multi
912
+ ? t('↑↓ move · Space toggle · Enter submit · Esc cancel')
913
+ : t('↑↓ move · Enter select · Esc cancel') })] }));
914
+ }
915
+ function TextPrompt({ label, initial = '', validate, advise, error, onSubmit, onCancel, }) {
916
+ const [value, setValue] = useState(initial);
917
+ const [problem, setProblem] = useState(error);
918
+ // The value an advice was shown for; submitting it again keeps it.
919
+ const [advised, setAdvised] = useState();
920
+ const advice = advised === value ? advise?.(value) : undefined;
921
+ useInput((input, key) => {
922
+ if (key.escape)
923
+ onCancel();
924
+ else if (key.return) {
925
+ const invalid = validate?.(value);
926
+ setProblem(invalid);
927
+ if (invalid)
928
+ return;
929
+ if (advise?.(value) && advised !== value)
930
+ setAdvised(value);
931
+ else
932
+ onSubmit(value);
933
+ }
616
934
  else if (key.backspace || key.delete)
617
935
  setValue(v => v.slice(0, -1));
618
936
  else if (input && !key.ctrl && !key.meta)
619
937
  setValue(v => v + input);
620
938
  });
621
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), _jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: "\u276F " }), value, _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t('Press Enter to submit, Esc to cancel') })] }));
939
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), _jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: "\u276F " }), value, _jsx(Text, { inverse: true, children: " " })] }), problem && _jsxs(Text, { color: "red", children: ["\u2717 ", problem] }), !problem && advice && _jsx(Text, { color: "yellow", children: advice }), _jsx(Text, { dimColor: true, children: t('Press Enter to submit, Esc to cancel') })] }));
622
940
  }
@@ -5,6 +5,7 @@ import { appTypeOf, getPackageJsonSyncChanges, readAncestorDevProperties, locali
5
5
  import { checkSitevisionScriptsCompatibility } from '../utils/sitevision-scripts-runner.js';
6
6
  import { ACCENT, elapsed } from './Frame.js';
7
7
  import { t } from '../utils/i18n.js';
8
+ import { getGlobalSigning } from '../utils/config.js';
8
9
  export const TABS = [
9
10
  { id: 'overview', label: 'Overview', short: 'Ovw' },
10
11
  { id: 'config', label: 'Config', short: 'Cfg' },
@@ -40,11 +41,16 @@ export function Overview({ project, tasks, height, }) {
40
41
  const dev = project.devProperties;
41
42
  const inherited = new Set(project.inheritedKeys);
42
43
  const ancestors = readAncestorDevProperties(project.root);
44
+ const globalSigning = getGlobalSigning();
43
45
  const src = (key) => inherited.has(key)
44
46
  ? Object.hasOwn(ancestors, key)
45
47
  ? t('↑ root')
46
48
  : 'package.json'
47
- : undefined;
49
+ : globalSigning[key] !== undefined &&
50
+ globalSigning[key] ===
51
+ dev?.[key]
52
+ ? t('global')
53
+ : undefined;
48
54
  const notSet = t('not set');
49
55
  const sync = getPackageJsonSyncChanges(project.root).length;
50
56
  const scripts = checkSitevisionScriptsCompatibility(project.root);
@@ -64,7 +70,7 @@ export function Versions({ project, state, selected, }) {
64
70
  return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "yellow", children: t('⚠ Configure dev properties first (e).') }) }));
65
71
  }
66
72
  if (!state) {
67
- return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: t('Press R to fetch versions from {domain}.', {
73
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: t('Press r to fetch versions from {domain}.', {
68
74
  domain: project.devProperties.domain,
69
75
  }) }) }));
70
76
  }
Binary file
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { parseJsonc } from './jsonc.js';
4
5
  function configDir() {
5
6
  const base = process.env['XDG_CONFIG_HOME'] || path.join(os.homedir(), '.config');
6
7
  return path.join(base, 'sitevision-cli');
@@ -10,13 +11,33 @@ function configFile() {
10
11
  }
11
12
  function readConfig() {
12
13
  try {
13
- return JSON.parse(fs.readFileSync(configFile(), 'utf8'));
14
+ return parseJsonc(fs.readFileSync(configFile(), 'utf8'));
14
15
  }
15
16
  catch {
16
17
  return {};
17
18
  }
18
19
  }
20
+ /** Set when the file exists but does not parse; it is then left untouched. */
21
+ export function configProblem() {
22
+ let text;
23
+ try {
24
+ text = fs.readFileSync(configFile(), 'utf8');
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ try {
30
+ parseJsonc(text);
31
+ return undefined;
32
+ }
33
+ catch (error) {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
36
+ }
19
37
  function writeConfig(config) {
38
+ // A hand-edited file with a typo must not be replaced by defaults.
39
+ if (configProblem())
40
+ return;
20
41
  try {
21
42
  fs.mkdirSync(configDir(), { recursive: true });
22
43
  fs.writeFileSync(configFile(), JSON.stringify(config, null, 2));
@@ -31,10 +52,30 @@ export function getSettings() {
31
52
  return {
32
53
  language: config.language ?? 'en',
33
54
  introAnimation: config.introAnimation ?? true,
55
+ updateCheck: config.updateCheck ?? true,
56
+ addonNameDriftWarning: config.warnings?.addonNameDrift ?? true,
34
57
  };
35
58
  }
36
59
  export function setSettings(patch) {
37
- writeConfig({ ...readConfig(), ...patch });
60
+ const { addonNameDriftWarning, ...rest } = patch;
61
+ const config = { ...readConfig(), ...rest };
62
+ if (addonNameDriftWarning !== undefined) {
63
+ config.warnings = {
64
+ ...config.warnings,
65
+ addonNameDrift: addonNameDriftWarning,
66
+ };
67
+ }
68
+ writeConfig(config);
69
+ }
70
+ export function getGlobalSigning() {
71
+ const { signingUsername, certificateName } = readConfig();
72
+ return {
73
+ ...(signingUsername && { signingUsername }),
74
+ ...(certificateName && { certificateName }),
75
+ };
76
+ }
77
+ export function setGlobalSigning(signing) {
78
+ writeConfig({ ...readConfig(), ...signing });
38
79
  }
39
80
  /** Path of the config file, for display. */
40
81
  export function settingsFile() {
@@ -44,7 +85,7 @@ export function settingsFile() {
44
85
  * True until the user has completed the first-run welcome at least once.
45
86
  */
46
87
  export function isFirstRun() {
47
- return !readConfig().firstRunCompleted;
88
+ return !configProblem() && !readConfig().firstRunCompleted;
48
89
  }
49
90
  /**
50
91
  * Persist that the first-run welcome has been seen.
@@ -66,6 +107,8 @@ export function getLastSeenVersion() {
66
107
  */
67
108
  export function setLastSeenVersion(version) {
68
109
  const config = readConfig();
110
+ if (config.lastSeenVersion === version)
111
+ return;
69
112
  config.lastSeenVersion = version;
70
113
  writeConfig(config);
71
114
  }