sitevision-cli 1.0.0-beta.15 → 1.0.0-beta.17

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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useEffect, useRef, useState } from 'react';
3
- import { Box, Text } from 'ink';
3
+ import { Box, Text, useStdout } from 'ink';
4
4
  import { AUTHOR, BIG_LOGO } from '../utils/branding.js';
5
5
  const FRAME_MS = 45;
6
6
  const SWEEP_COLS_PER_FRAME = 7; // how fast the wipe edge moves left → right
@@ -83,5 +83,11 @@ export function AnimatedLogo({ art = BIG_LOGO, onDone }) {
83
83
  }
84
84
  }, [frame, total, onDone]);
85
85
  const edge = (frame + 1) * SWEEP_COLS_PER_FRAME;
86
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [art.map((line, y) => (_jsx(Text, { children: buildSpans(line, y, frame, edge).map((span, index) => (_jsx(Fragment, { children: span.color ? (_jsx(Text, { color: span.color, children: span.text })) : (_jsx(Text, { children: span.text })) }, index))) }, y))), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' a tool by ' }), _jsx(Text, { bold: true, children: AUTHOR })] })] }));
86
+ // Centre the wordmark on the alternate screen. `rows - 1` leaves room for
87
+ // Ink's trailing newline; a terminal too short for the art just renders it
88
+ // top-aligned rather than scrolling.
89
+ const { stdout } = useStdout();
90
+ const height = (stdout.rows || 24) - 1;
91
+ const fits = height >= art.length + 2;
92
+ return (_jsx(Box, { width: stdout.columns || 80, height: fits ? height : undefined, alignItems: "center", justifyContent: "center", children: _jsxs(Box, { flexDirection: "column", padding: 1, children: [art.map((line, y) => (_jsx(Text, { children: buildSpans(line, y, frame, edge).map((span, index) => (_jsx(Fragment, { children: span.color ? (_jsx(Text, { color: span.color, children: span.text })) : (_jsx(Text, { children: span.text })) }, index))) }, y))), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' a tool by ' }), _jsx(Text, { bold: true, children: AUTHOR })] })] }) }));
87
93
  }
@@ -4,7 +4,7 @@ interface Field {
4
4
  key: string;
5
5
  label: string;
6
6
  kind?: 'text' | 'method' | 'bool' | 'secret';
7
- required?: boolean;
7
+ required?: boolean | Method;
8
8
  when?: Method;
9
9
  section?: string;
10
10
  hint?: string;
@@ -11,8 +11,8 @@ const METHODS = ['basic', 'oauth2', 'cookie'];
11
11
  const FIELDS = [
12
12
  {
13
13
  key: 'domain',
14
- help: 'Domain of the development environment (USE or TSE) without https://, e.g. myorg-use.sitevision-cloud.se. Deploys and version lookups go here.',
15
- label: 'Development domain',
14
+ help: "Domain of this environment's site (USE or TSE) without https://, e.g. myorg-use.sitevision-cloud.se. Deploys and version lookups go here.",
15
+ label: 'Domain',
16
16
  required: true,
17
17
  },
18
18
  {
@@ -31,9 +31,9 @@ const FIELDS = [
31
31
  },
32
32
  {
33
33
  key: 'username',
34
- help: 'Sitevision account used for deploys, usually your Sitevision Cloud e-mail. It needs DEVELOPER or MANAGE_ADDONS permission on the site.',
34
+ help: '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.',
35
35
  label: 'Username',
36
- required: true,
36
+ required: 'basic',
37
37
  },
38
38
  {
39
39
  key: 'authMethod',
@@ -274,6 +274,7 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
274
274
  const [cursor, setCursor] = useState(0);
275
275
  const [editing, setEditing] = useState(false);
276
276
  const [draft, setDraft] = useState('');
277
+ const [caret, setCaret] = useState(0);
277
278
  const [note, setNote] = useState('');
278
279
  // Values always mirror the project; edits are committed field by field.
279
280
  useEffect(() => {
@@ -369,11 +370,21 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
369
370
  else if (key.ctrl && input === 'o' && current.key === 'addonName') {
370
371
  openPicker();
371
372
  }
373
+ else if (key.leftArrow) {
374
+ setCaret(Math.max(0, caret - 1));
375
+ }
376
+ else if (key.rightArrow) {
377
+ setCaret(Math.min(draft.length, caret + 1));
378
+ }
372
379
  else if (key.backspace || key.delete) {
373
- setDraft(d => d.slice(0, -1));
380
+ if (caret > 0) {
381
+ setDraft(draft.slice(0, caret - 1) + draft.slice(caret));
382
+ setCaret(caret - 1);
383
+ }
374
384
  }
375
385
  else if (input && !key.ctrl && !key.meta) {
376
- setDraft(d => d + input);
386
+ setDraft(draft.slice(0, caret) + input + draft.slice(caret));
387
+ setCaret(caret + input.length);
377
388
  }
378
389
  return;
379
390
  }
@@ -387,12 +398,15 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
387
398
  openPicker();
388
399
  }
389
400
  else if (key.return) {
390
- setDraft(current.kind === 'secret' ? '' : (values[current.key] ?? ''));
401
+ const start = current.kind === 'secret' ? '' : (values[current.key] ?? '');
402
+ setDraft(start);
403
+ setCaret(start.length);
391
404
  setEditing(true);
392
405
  }
393
406
  }, { isActive: active });
394
407
  const source = (f) => {
395
- if (f.required && !(values[f.key] ?? ''))
408
+ const required = f.required === true || f.required === method;
409
+ if (required && !(values[f.key] ?? ''))
396
410
  return { text: t('✗ required'), color: 'red' };
397
411
  if (f.kind === 'secret') {
398
412
  return { text: storedSecret(project, f.key) ? t('keychain') : '—' };
@@ -438,10 +452,17 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
438
452
  }
439
453
  const focused = active && f === current;
440
454
  const typing = focused && editing;
441
- // Keep the end of a long value (where the cursor is) visible while typing.
442
- const tail = (text) => typing && text.length > valueWidth - 1
443
- ? `…${text.slice(-(valueWidth - 2))}`
444
- : text;
455
+ // A value being typed into, scrolled so the caret stays in view and drawn
456
+ // with the caret as an inverted cell.
457
+ const withCaret = (text) => {
458
+ const max = Math.max(1, valueWidth - 1);
459
+ const start = text.length > max
460
+ ? Math.min(Math.max(0, caret - max + 1), text.length - max)
461
+ : 0;
462
+ const shown = text.slice(start, start + max);
463
+ const at = caret - start;
464
+ return (_jsxs(Text, { children: [shown.slice(0, at), _jsx(Text, { inverse: true, children: shown[at] ?? ' ' }), shown.slice(at + 1)] }));
465
+ };
445
466
  let display;
446
467
  if (f.kind === 'method' || f.kind === 'bool') {
447
468
  const choices = options(f);
@@ -449,18 +470,18 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
449
470
  display = choices.map((m, i) => (_jsxs(Text, { children: [_jsx(Text, { bold: m === chosen, color: m === chosen ? ACCENT : undefined, inverse: typing && m === chosen, dimColor: m !== chosen, children: f.kind === 'bool' ? t(m) : m }), i < choices.length - 1 && _jsx(Text, { dimColor: true, children: " \u00B7 " })] }, m)));
450
471
  }
451
472
  else if (f.kind === 'secret') {
452
- display = typing ? (_jsx(Text, { children: tail('•'.repeat(draft.length)) })) : (_jsx(Text, { dimColor: true, children: storedSecret(project, f.key)
473
+ display = typing ? (withCaret('•'.repeat(draft.length))) : (_jsx(Text, { dimColor: true, children: storedSecret(project, f.key)
453
474
  ? t('•••••••• keychain')
454
475
  : f.hint
455
476
  ? t(f.hint)
456
477
  : '' }));
457
478
  }
458
479
  else {
459
- const value = typing ? draft : values[f.key];
460
- display = value ? (_jsx(Text, { children: tail(value) })) : (_jsx(Text, { dimColor: true, children: typing ? '' : f.hint ? t(f.hint) : '—' }));
480
+ const value = values[f.key];
481
+ display = typing ? (withCaret(draft)) : value ? (_jsx(Text, { children: value })) : (_jsx(Text, { dimColor: true, children: f.hint ? t(f.hint) : '—' }));
461
482
  }
462
483
  const src = source(f);
463
- rows.push(_jsxs(Box, { height: 1, children: [_jsx(Text, { color: focused ? ACCENT : undefined, bold: focused, dimColor: !focused, children: (focused ? '▸ ' : ' ') + t(f.label).padEnd(22) }), _jsx(Box, { width: valueWidth, flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", children: [display, typing && options(f).length === 0 && _jsx(Text, { inverse: true, children: " " })] }) }), _jsx(Box, { width: SOURCE_WIDTH, flexShrink: 0, children: _jsx(Text, { dimColor: !src.color, color: src.color, wrap: "truncate", children: focused && f.hint && f.key === 'addonName' ? t(f.hint) : src.text }) })] }, f.key));
484
+ rows.push(_jsxs(Box, { height: 1, children: [_jsx(Text, { color: focused ? ACCENT : undefined, bold: focused, dimColor: !focused, children: (focused ? '▸ ' : ' ') + t(f.label).padEnd(22) }), _jsx(Box, { width: valueWidth, flexShrink: 0, children: _jsx(Text, { wrap: "truncate", children: display }) }), _jsx(Box, { width: SOURCE_WIDTH, flexShrink: 0, children: _jsx(Text, { dimColor: !src.color, color: src.color, wrap: "truncate", children: focused && f.hint && f.key === 'addonName' ? t(f.hint) : src.text }) })] }, f.key));
464
485
  }
465
486
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, overflow: "hidden", height: height, children: [_jsxs(Text, { dimColor: true, children: [(' ' + t('FIELD')).padEnd(24), t('VALUE').padEnd(valueWidth), t('SOURCE')] }), rows, project.workspace && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: t("Shared by every app below {root}. An app's own value wins.", {
466
487
  root: project.root,
@@ -28,6 +28,12 @@ export declare function appStatus(project: ProjectInfo): {
28
28
  signing: boolean;
29
29
  scriptsWarning: string | undefined;
30
30
  };
31
+ /** The name shown for an app in the navigator, and what the filter matches. */
32
+ export declare function appLabel(app: ProjectInfo): string;
33
+ /** Indices into `apps` whose label fuzzy-matches the filter. */
34
+ export declare function navMatches(apps: ProjectInfo[], filter: string): number[];
35
+ /** Next selectable index when moving by `delta`, wrapping at both ends. */
36
+ export declare function navMove(ring: number[], selected: number, delta: number): number;
31
37
  export interface NavigatorProps {
32
38
  apps: ProjectInfo[];
33
39
  groupOf: (app: ProjectInfo) => string;
@@ -38,8 +44,9 @@ export interface NavigatorProps {
38
44
  single: boolean;
39
45
  settingsSelected?: boolean;
40
46
  width: number;
47
+ filter?: string;
41
48
  }
42
- export declare function Navigator({ apps, groupOf, selected, focused, tasks, height, single, settingsSelected, width, }: NavigatorProps): import("react").JSX.Element;
49
+ export declare function Navigator({ apps, groupOf, selected, focused, tasks, height, single, settingsSelected, width, filter, }: NavigatorProps): import("react").JSX.Element;
43
50
  export declare function NavigatorStrip({ apps, selected, focused, }: Pick<NavigatorProps, 'apps' | 'selected' | 'focused'>): import("react").JSX.Element;
44
51
  export declare function elapsed(task: Task): string;
45
52
  export interface Hint {
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import Spinner from 'ink-spinner';
4
4
  import { appTypeOf, getPackageJsonSyncChanges, localizedText, } from '../utils/project-detection.js';
5
+ import { fuzzyMatch } from './actions.js';
5
6
  import { t } from '../utils/i18n.js';
6
7
  export const ACCENT = 'cyan';
7
8
  export const NARROW_BELOW = 100;
@@ -40,7 +41,24 @@ function Dots({ project }) {
40
41
  const dot = (ok, warn = false) => (_jsx(Text, { color: ok ? 'green' : warn ? 'yellow' : 'red', children: "\u25CF" }));
41
42
  return (_jsxs(Text, { children: [dot(s.deps), dot(s.config), dot(s.sync === 0, s.sync > 0), dot(s.signing, !s.signing)] }));
42
43
  }
43
- export function Navigator({ apps, groupOf, selected, focused, tasks, height, single, settingsSelected = false, width, }) {
44
+ /** The name shown for an app in the navigator, and what the filter matches. */
45
+ export function appLabel(app) {
46
+ return localizedText(app.manifest.name) || app.manifest.id;
47
+ }
48
+ /** Indices into `apps` whose label fuzzy-matches the filter. */
49
+ export function navMatches(apps, filter) {
50
+ return apps
51
+ .map((_, index) => index)
52
+ .filter(index => fuzzyMatch(filter, appLabel(apps[index])));
53
+ }
54
+ /** Next selectable index when moving by `delta`, wrapping at both ends. */
55
+ export function navMove(ring, selected, delta) {
56
+ if (ring.length === 0)
57
+ return selected;
58
+ const at = ring.indexOf(selected);
59
+ return ring[at === -1 ? 0 : (at + delta + ring.length) % ring.length];
60
+ }
61
+ export function Navigator({ apps, groupOf, selected, focused, tasks, height, single, settingsSelected = false, width, filter = '', }) {
44
62
  // Row: marker(1) glyph(3) sp name sp version(6) sp dots(4) inside the padding.
45
63
  const nameWidth = width - 2 - 17;
46
64
  const running = tasks.filter(task => task.status === 'running');
@@ -51,16 +69,20 @@ export function Navigator({ apps, groupOf, selected, focused, tasks, height, sin
51
69
  for (const [index, app] of apps.entries()) {
52
70
  const group = groupOf(app);
53
71
  if (!single && group !== lastGroup) {
54
- rows.push(_jsx(Box, { height: 1, flexShrink: 0, children: _jsxs(Text, { dimColor: true, wrap: "truncate", children: [' ', group] }) }, `g-${group}`));
72
+ rows.push(_jsx(Box, { height: 1, flexShrink: 0, children: _jsxs(Text, { dimColor: true, wrap: "truncate", children: [' ', group] }) }, `g-${index}-${group}`));
55
73
  rowApp.push(-1);
56
74
  lastGroup = group;
57
75
  }
58
76
  rowApp.push(index);
59
77
  const active = index === selected;
60
78
  const busy = running.some(task => task.appRoot === app.root);
61
- const name = localizedText(app.manifest.name) || app.manifest.id;
79
+ const name = appLabel(app);
62
80
  rows.push(_jsxs(Box, { width: width - 2, height: 1, flexShrink: 0, children: [_jsxs(Text, { backgroundColor: active && focused ? ACCENT : undefined, color: active && focused ? 'black' : undefined, bold: active, wrap: "truncate", children: [active ? '▎' : ' ', _jsx(Text, { dimColor: !active, children: typeGlyph(app.manifest) }), ' ', name.padEnd(nameWidth).slice(0, nameWidth), ' ', _jsx(Text, { dimColor: true, children: app.manifest.version.padStart(6).slice(0, 6) }), ' '] }), busy ? (_jsx(Text, { color: ACCENT, children: _jsx(Spinner, { type: "dots" }) })) : (_jsx(Dots, { project: app }))] }, app.root));
63
81
  }
82
+ if (rows.length === 0) {
83
+ rows.push(_jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: ' ' + t('no matches') }) }, "none"));
84
+ rowApp.push(-1);
85
+ }
64
86
  // Window the list so the selected app stays visible; the lines outside
65
87
  // are summarised as "… n more".
66
88
  const fixed = 1 + 1 + (single ? 0 : 2) + (running.length > 0 ? running.length + 2 : 0);
@@ -79,14 +101,16 @@ export function Navigator({ apps, groupOf, selected, focused, tasks, height, sin
79
101
  if (end < rows.length)
80
102
  shown[shown.length - 1] = more(rows.length - end, '↓');
81
103
  }
82
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: "single", borderDimColor: true, borderTop: false, borderBottom: false, borderLeft: false, paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, dimColor: true, children: single
104
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: "single", borderDimColor: true, borderTop: false, borderBottom: false, borderLeft: false, paddingX: 1, overflow: "hidden", children: [filter ? (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: "\u276F " }), filter, _jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { dimColor: true, children: [' ', apps.length === 1
105
+ ? t('1 match')
106
+ : t('{n} matches', { n: apps.length })] })] })) : (_jsx(Text, { bold: true, dimColor: true, children: single
83
107
  ? t('APP')
84
108
  : apps.length === 1
85
109
  ? t('WORKSPACE 1 app')
86
- : t('WORKSPACE {n} apps', { n: apps.length }) }), shown, _jsx(Text, { dimColor: true, children: ' ' + t('deps·config·sync·signing') }), !single && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { backgroundColor: settingsSelected && focused ? ACCENT : undefined, color: settingsSelected && focused ? 'black' : undefined, bold: settingsSelected, children: [settingsSelected ? '▎' : ' ', "\u2699 ", t('Workspace settings')] }) })), running.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: t('TASKS') }), running.map(task => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: _jsx(Spinner, { type: "dots" }) }), ' ', task.label, " ", task.appName, " ", _jsx(Text, { dimColor: true, children: elapsed(task) })] }, task.id)))] }))] }));
110
+ : t('WORKSPACE {n} apps', { n: apps.length }) })), shown, _jsx(Text, { dimColor: true, children: ' ' + t('deps·config·sync·signing') }), !single && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { backgroundColor: settingsSelected && focused ? ACCENT : undefined, color: settingsSelected && focused ? 'black' : undefined, bold: settingsSelected, children: [settingsSelected ? '▎' : ' ', "\u2699 ", t('Workspace settings')] }) })), running.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: t('TASKS') }), running.map(task => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: _jsx(Spinner, { type: "dots" }) }), ' ', task.label, " ", task.appName, " ", _jsx(Text, { dimColor: true, children: elapsed(task) })] }, task.id)))] }))] }));
87
111
  }
88
112
  export function NavigatorStrip({ apps, selected, focused, }) {
89
- return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { wrap: "truncate", children: apps.map((app, index) => (_jsxs(Text, { backgroundColor: index === selected && focused ? ACCENT : undefined, color: index === selected && focused ? 'black' : undefined, bold: index === selected, children: [' ', typeGlyph(app.manifest), ' ', localizedText(app.manifest.name) || app.manifest.id, ' '] }, app.root))) }) }));
113
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { wrap: "truncate", children: apps.map((app, index) => (_jsxs(Text, { backgroundColor: index === selected && focused ? ACCENT : undefined, color: index === selected && focused ? 'black' : undefined, bold: index === selected, children: [' ', typeGlyph(app.manifest), " ", appLabel(app), ' '] }, app.root))) }) }));
90
114
  }
91
115
  export function elapsed(task) {
92
116
  const ms = (task.endedAt ?? Date.now()) - task.startedAt;
@@ -4,12 +4,12 @@ import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
4
4
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
5
5
  import Spinner from 'ink-spinner';
6
6
  import { detectProject, appTypeOf, localizedText, readWorkspaceDevProperties, readSvcConfig, writeSvcConfig, writeDevProperties, } from '../utils/project-detection.js';
7
- import { appGroup } from '../utils/workspace.js';
7
+ import { appGroup, configIncomplete, needsOnboarding, } from '../utils/workspace.js';
8
8
  import { listAddons, listExecutables, } from '../utils/sitevision-api.js';
9
9
  import { useTasks, runningTasks, startActivate, getTasks, } from '../utils/tasks.js';
10
10
  import { PasswordInput } from '../components/PasswordInput.js';
11
11
  import { AuthLoginScreen } from '../components/AuthLoginScreen.js';
12
- import { TopBar, Navigator, NavigatorStrip, BottomBar, navWidth, NARROW_BELOW, ACCENT, } from './Frame.js';
12
+ import { TopBar, Navigator, NavigatorStrip, BottomBar, navMatches, navMove, appLabel, navWidth, NARROW_BELOW, ACCENT, } from './Frame.js';
13
13
  import { TabBar, TABS, Overview, Versions, Log, } from './Tabs.js';
14
14
  import { CommandPalette } from './CommandPalette.js';
15
15
  import { ConfigForm } from './ConfigForm.js';
@@ -37,10 +37,13 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
37
37
  const { columns, rows } = useSize();
38
38
  const tasks = useTasks();
39
39
  const [apps, setApps] = useState(initialApps);
40
- const [selected, setSelected] = useState(0);
40
+ // A workspace with nothing to deploy against opens on its settings form.
41
+ const onboard = useMemo(() => Boolean(workspaceRoot) && needsOnboarding(workspaceRoot, initialApps), [workspaceRoot, initialApps]);
42
+ const [selected, setSelected] = useState(onboard ? initialApps.length : 0);
41
43
  const [tab, setTab] = useState('overview');
42
- const [focus, setFocus] = useState(workspaceRoot ? 'nav' : 'content');
44
+ const [focus, setFocus] = useState(workspaceRoot && !onboard ? 'nav' : 'content');
43
45
  const [overlay, setOverlay] = useState(null);
46
+ const [filter, setFilter] = useState('');
44
47
  const [versions, setVersions] = useState({});
45
48
  const [versionRow, setVersionRow] = useState(0);
46
49
  const [logScroll, setLogScroll] = useState(0);
@@ -76,6 +79,10 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
76
79
  [workspaceRoot, apps, env]);
77
80
  const narrow = columns < NARROW_BELOW;
78
81
  const sidebar = navWidth(columns);
82
+ // The navigator shows the fuzzy matches; `selected` stays an index into
83
+ // `apps` (with `apps.length` meaning the workspace settings row).
84
+ const matches = useMemo(() => navMatches(apps, filter), [apps, filter]);
85
+ const ring = single ? matches : [...matches, apps.length];
79
86
  const running = runningTasks();
80
87
  // Re-render once a second while something runs so elapsed times move.
81
88
  useEffect(() => {
@@ -252,6 +259,13 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
252
259
  ? raw.toUpperCase()
253
260
  : raw;
254
261
  if (key.escape) {
262
+ if (focus === 'nav' && filter) {
263
+ setFilter('');
264
+ return;
265
+ }
266
+ // Esc backs out of the workspace settings pane, not just its focus.
267
+ if (settings)
268
+ setSelected(0);
255
269
  setFocus(single ? 'content' : 'nav');
256
270
  return;
257
271
  }
@@ -260,9 +274,40 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
260
274
  return;
261
275
  }
262
276
  if (key.tab) {
277
+ setFilter('');
263
278
  setFocus(f => f === 'nav' && !single ? 'content' : single ? 'content' : 'nav');
264
279
  return;
265
280
  }
281
+ // Navigator: typing searches, so no action key fires until Enter has
282
+ // moved the focus into the content pane.
283
+ if (focus === 'nav') {
284
+ const move = (delta) => {
285
+ setSelected(navMove(ring, selected, delta));
286
+ };
287
+ const search = (next) => {
288
+ setFilter(next);
289
+ const found = navMatches(apps, next);
290
+ if (found.length > 0 && !found.includes(selected))
291
+ setSelected(found[0]);
292
+ };
293
+ if (key.upArrow)
294
+ move(-1);
295
+ else if (key.downArrow)
296
+ move(1);
297
+ else if (key.return) {
298
+ if (matches.length > 0 || settings) {
299
+ setFilter('');
300
+ setFocus('content');
301
+ }
302
+ }
303
+ else if (key.backspace || key.delete)
304
+ search(filter.slice(0, -1));
305
+ else if (input === 'q' && !filter)
306
+ quit();
307
+ else if (input?.length === 1 && input >= ' ' && !key.ctrl && !key.meta)
308
+ search(filter + input);
309
+ return;
310
+ }
266
311
  if (input === 'a' && tab !== 'versions') {
267
312
  setTab('versions');
268
313
  setFocus('content');
@@ -287,16 +332,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
287
332
  quit();
288
333
  return;
289
334
  }
290
- if (focus === 'nav') {
291
- const last = single ? apps.length - 1 : apps.length;
292
- if (key.upArrow)
293
- setSelected(s => (s > 0 ? s - 1 : last));
294
- if (key.downArrow)
295
- setSelected(s => (s < last ? s + 1 : 0));
296
- if (key.return)
297
- setFocus('content');
298
- }
299
- else if (tab === 'versions') {
335
+ if (tab === 'versions') {
300
336
  const count = versions[versionsKey]?.executables?.length ?? 0;
301
337
  if (key.upArrow)
302
338
  setVersionRow(r => Math.max(0, r - 1));
@@ -331,7 +367,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
331
367
  const mainHeight = frameRows - 2;
332
368
  const contentHeight = mainHeight - 1 - (narrow ? 1 : 0);
333
369
  const groupOf = (app) => workspaceRoot ? appGroup(workspaceRoot, app.root) : '.';
334
- const appName = localizedText(project.manifest.name) || project.manifest.id;
370
+ const appName = appLabel(project);
335
371
  const tabName = t(TABS.find(entry => entry.id === tab).label).toLowerCase();
336
372
  const contextLabel = settings
337
373
  ? `${t('workspace')} ▸ ${t('settings')}`
@@ -356,61 +392,77 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
356
392
  ['↑↓', 'apps'],
357
393
  ['q', 'quit'],
358
394
  ]);
395
+ const navHints = filter
396
+ ? h([
397
+ ['↑↓', 'move'],
398
+ ['Enter', 'select'],
399
+ ['Esc', 'clear'],
400
+ ['/', 'commands'],
401
+ ])
402
+ : h([
403
+ ['a–z', 'search'],
404
+ ['↑↓', 'move'],
405
+ ['Enter', 'select'],
406
+ ['/', 'commands'],
407
+ ['q', 'quit'],
408
+ ]);
359
409
  const hints = overlay
360
410
  ? h([['Esc', 'cancel']])
361
- : settings
362
- ? settingsHints
363
- : tab === 'versions'
364
- ? h([
365
- ['a', 'activate'],
366
- ['R', 'refresh'],
367
- ['p', 'deploy'],
368
- ['/', 'commands'],
369
- ['q', 'quit'],
370
- ])
371
- : tab === 'log'
411
+ : focus === 'nav' && !settings
412
+ ? navHints
413
+ : settings
414
+ ? settingsHints
415
+ : tab === 'versions'
372
416
  ? h([
373
- ['f', 'follow'],
374
- ['x', 'wrap'],
375
- ['K', 'stop'],
417
+ ['a', 'activate'],
418
+ ['R', 'refresh'],
376
419
  ['p', 'deploy'],
377
420
  ['/', 'commands'],
378
421
  ['q', 'quit'],
379
422
  ])
380
- : tab === 'config'
381
- ? editing
382
- ? h([
383
- ['Enter', 'save'],
384
- ['Esc', 'cancel'],
385
- ])
386
- : formActive
387
- ? h([
388
- ['↑↓', 'field'],
389
- ['Enter', 'edit'],
390
- ['^O', 'pick addon'],
391
- ['y', 'sync'],
392
- ['/', 'commands'],
393
- ['q', 'quit'],
394
- ])
395
- : h([
396
- ['Tab', 'edit'],
397
- ['y', 'sync'],
398
- ['l', 'login'],
399
- ['/', 'commands'],
400
- ['q', 'quit'],
401
- ])
402
- : h([
403
- ['d', 'dev'],
404
- ['w', 'watch'],
405
- ['b', 'build'],
406
- ['s', 'sign'],
423
+ : tab === 'log'
424
+ ? h([
425
+ ['f', 'follow'],
426
+ ['x', 'wrap'],
427
+ ['K', 'stop'],
407
428
  ['p', 'deploy'],
408
- ['a', 'activate'],
409
- ['E', 'env'],
410
- ['i', 'install'],
411
429
  ['/', 'commands'],
412
430
  ['q', 'quit'],
413
- ]);
431
+ ])
432
+ : tab === 'config'
433
+ ? editing
434
+ ? h([
435
+ ['Enter', 'save'],
436
+ ['Esc', 'cancel'],
437
+ ])
438
+ : formActive
439
+ ? h([
440
+ ['↑↓', 'field'],
441
+ ['Enter', 'edit'],
442
+ ['^O', 'pick addon'],
443
+ ['y', 'sync'],
444
+ ['/', 'commands'],
445
+ ['q', 'quit'],
446
+ ])
447
+ : h([
448
+ ['Tab', 'edit'],
449
+ ['y', 'sync'],
450
+ ['l', 'login'],
451
+ ['/', 'commands'],
452
+ ['q', 'quit'],
453
+ ])
454
+ : h([
455
+ ['d', 'dev'],
456
+ ['w', 'watch'],
457
+ ['b', 'build'],
458
+ ['s', 'sign'],
459
+ ['p', 'deploy'],
460
+ ['a', 'activate'],
461
+ ['E', 'env'],
462
+ ['i', 'install'],
463
+ ['/', 'commands'],
464
+ ['q', 'quit'],
465
+ ]);
414
466
  const right = running.length > 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: _jsx(Spinner, { type: "dots" }) }), ' ', running[0].label, " ", running[0].appName, running.length > 1 && (_jsxs(Text, { dimColor: true, children: [" \u00B7 ", t('{n} tasks', { n: running.length })] }))] })) : notice ? (_jsx(Text, { color: { info: undefined, ok: 'green', warn: 'yellow', error: 'red' }[notice.level], wrap: "truncate", children: notice.text })) : (_jsx(Text, { dimColor: true, children: t('idle') }));
415
467
  const closeOverlay = () => setOverlay(null);
416
468
  const content = overlay ? (renderOverlay(overlay, {
@@ -443,7 +495,9 @@ export function Shell({ apps: initialApps, workspaceRoot, version }) {
443
495
  return (_jsxs(Box, { flexDirection: "column", width: columns, height: frameRows, children: [_jsx(TopBar, { context: contextLabel, domain: project.devProperties?.domain, auth: authState(project), environment: {
444
496
  name: env,
445
497
  color: environmentColor(env, rawProject.devProperties),
446
- }, version: version }), _jsxs(Box, { flexGrow: 1, height: mainHeight, borderStyle: "single", borderDimColor: true, borderLeft: false, borderRight: false, borderBottom: false, children: [!narrow && (_jsx(Navigator, { apps: apps, groupOf: groupOf, selected: selected, focused: focus === 'nav', tasks: tasks, height: mainHeight - 1, single: single, settingsSelected: settings, width: sidebar })), _jsxs(Box, { flexDirection: "column", width: narrow ? columns : columns - sidebar, overflow: "hidden", children: [narrow && !single && (_jsx(NavigatorStrip, { apps: apps, selected: selected, focused: focus === 'nav' })), settings ? (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: ACCENT, children: t('Workspace settings') }), _jsx(Text, { dimColor: true, children: t(' · shared .dev_properties.json at the root') })] })) : (_jsx(TabBar, { tab: tab, narrow: narrow, focused: focus === 'content' })), _jsx(Box, { height: contentHeight, overflow: "hidden", alignItems: "flex-start", children: content })] })] }), _jsx(BottomBar, { hints: hints, right: right })] }));
498
+ }, version: version }), _jsxs(Box, { flexGrow: 1, height: mainHeight, borderStyle: "single", borderDimColor: true, borderLeft: false, borderRight: false, borderBottom: false, children: [!narrow && (_jsx(Navigator, { apps: matches.map(i => apps[i]), groupOf: groupOf, selected: matches.indexOf(selected), focused: focus === 'nav', tasks: tasks, height: mainHeight - 1, single: single, settingsSelected: settings, width: sidebar, filter: filter })), _jsxs(Box, { flexDirection: "column", width: narrow ? columns : columns - sidebar, overflow: "hidden", children: [narrow && !single && (_jsx(NavigatorStrip, { apps: matches.map(i => apps[i]), selected: matches.indexOf(selected), focused: focus === 'nav' })), settings ? (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: ACCENT, children: t('Workspace settings') }), _jsx(Text, { dimColor: true, children: onboard && configIncomplete(workspaceTarget?.base)
499
+ ? t(' · new workspace: fill in once, every app inherits · Esc skips')
500
+ : t(' · shared .dev_properties.json at the root') })] })) : (_jsx(TabBar, { tab: tab, narrow: narrow, focused: focus === 'content' })), _jsx(Box, { height: contentHeight, overflow: "hidden", alignItems: "flex-start", children: content })] })] }), _jsx(BottomBar, { hints: hints, right: right })] }));
447
501
  }
448
502
  function renderOverlay(overlay, env) {
449
503
  const { project, closeOverlay, run, notify, loadAddons, height, rerender, openWorkspace, } = env;
@@ -147,7 +147,7 @@ const sv = {
147
147
  'logged in': 'inloggad',
148
148
  'credentials removed': 'uppgifter borttagna',
149
149
  // Config form
150
- 'Development domain': 'Utvecklingsdomän',
150
+ Domain: 'Domän',
151
151
  'Site name': 'Webbplatsnamn',
152
152
  'Addon name': 'Tilläggsnamn',
153
153
  Username: 'Användarnamn',
@@ -203,6 +203,13 @@ const sv = {
203
203
  edit: 'redigera',
204
204
  back: 'tillbaka',
205
205
  quit: 'avsluta',
206
+ search: 'sök',
207
+ move: 'flytta',
208
+ select: 'välj',
209
+ clear: 'rensa',
210
+ 'no matches': 'inga träffar',
211
+ '{n} matches': '{n} träffar',
212
+ '1 match': '1 träff',
206
213
  'edit settings': 'redigera inställningar',
207
214
  apps: 'appar',
208
215
  activate: 'aktivera',
@@ -224,12 +231,13 @@ const sv = {
224
231
  'workspace config saved': 'arbetsytans konfig sparad',
225
232
  'config saved': 'konfig sparad',
226
233
  ' · shared .dev_properties.json at the root': ' · delad .dev_properties.json i roten',
234
+ ' · new workspace: fill in once, every app inherits · Esc skips': ' · ny arbetsyta: fyll i en gång, alla appar ärver · Esc hoppar över',
227
235
  'y confirm · n cancel': 'y bekräfta · n avbryt',
228
236
  // Config help
229
- 'Domain of the development environment (USE or TSE) without https://, e.g. myorg-use.sitevision-cloud.se. Deploys and version lookups go here.': 'Domän för utvecklingsmiljön (USE eller TSE) utan https://, t.ex. myorg-use.sitevision-cloud.se. Driftsättningar och versionslistor går hit.',
237
+ "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.',
230
238
  "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.',
231
239
  "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.',
232
- 'Sitevision account used for deploys, usually your Sitevision Cloud e-mail. It needs DEVELOPER or MANAGE_ADDONS permission on the site.': 'Sitevision-konto som används för driftsättning, oftast din Sitevision Cloud-e-post. Behöver DEVELOPER eller MANAGE_ADDONS på webbplatsen.',
240
+ '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.',
233
241
  "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.',
234
242
  '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.',
235
243
  'Client id of the OAuth2 client registered on the site. Its redirect URI must be http://127.0.0.1:8137/callback.': 'Klient-id för OAuth2-klienten som är registrerad på webbplatsen. Dess redirect-URI måste vara http://127.0.0.1:8137/callback.',
@@ -343,7 +343,7 @@ export function resolveRuntimeSecrets(dev) {
343
343
  if (dev.authMethod === 'oauth2') {
344
344
  dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
345
345
  }
346
- if (dev.authMethod === 'cookie' && dev.domain && dev.username) {
346
+ if (dev.authMethod === 'cookie' && dev.domain) {
347
347
  dev.sessionCookie =
348
348
  process.env['SITEVISION_SESSION_COOKIE'] ??
349
349
  getSessionCookie(dev.domain, dev.username) ??
@@ -419,10 +419,13 @@ export function writeDevProperties(projectRoot, properties) {
419
419
  getDefaultDevPropertiesPath(projectRoot);
420
420
  const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
421
421
  // Keep the app file minimal: values identical to the inherited ones stay
422
- // at the workspace root instead of being copied into every app.
422
+ // at the workspace root instead of being copied into every app. An empty
423
+ // string means "unset", so it is dropped rather than written as an override
424
+ // that would shadow the inherited value.
423
425
  const inherited = readInheritedDevProperties(projectRoot);
424
- const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => !Object.hasOwn(inherited, key) ||
425
- JSON.stringify(inherited[key]) !== JSON.stringify(value)));
426
+ const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
427
+ (!Object.hasOwn(inherited, key) ||
428
+ JSON.stringify(inherited[key]) !== JSON.stringify(value))));
426
429
  fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
427
430
  }
428
431
  export function readSvcConfig(projectRoot) {
@@ -56,7 +56,7 @@ async function readAllCookies(browser, page) {
56
56
  */
57
57
  export async function beginCookieLogin(dev) {
58
58
  const { domain, username } = dev;
59
- if (!domain || !username)
59
+ if (!domain)
60
60
  return null;
61
61
  let puppeteer;
62
62
  try {
@@ -1,4 +1,5 @@
1
1
  import { type ProjectInfo } from './project-detection.js';
2
+ import type { DevProperties } from '../types/index.js';
2
3
  /**
3
4
  * Find every Sitevision app below `root` (e.g. root/webapps/x, root/restapps/y).
4
5
  * Depth-limited walk that skips dependency and output folders.
@@ -6,3 +7,11 @@ import { type ProjectInfo } from './project-detection.js';
6
7
  export declare function discoverApps(root: string): ProjectInfo[];
7
8
  /** Group label for an app: its parent folder relative to the workspace root. */
8
9
  export declare function appGroup(root: string, appRoot: string): string;
10
+ /** Missing something nothing can deploy without. */
11
+ export declare function configIncomplete(dev?: Partial<DevProperties>): boolean;
12
+ /**
13
+ * True when a workspace has apps but nothing usable to deploy with: neither the
14
+ * shared root config nor the apps themselves carry domain/site/username. The
15
+ * shell then opens on Workspace settings instead of the first app.
16
+ */
17
+ export declare function needsOnboarding(root: string, apps: ProjectInfo[]): boolean;
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { detectProject } from './project-detection.js';
3
+ import { detectProject, readWorkspaceDevProperties, } from './project-detection.js';
4
4
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
5
5
  const MAX_DEPTH = 3;
6
6
  /**
@@ -39,10 +39,29 @@ export function discoverApps(root) {
39
39
  }
40
40
  };
41
41
  walk(root, 1);
42
- return found.toSorted((a, b) => a.root.localeCompare(b.root));
42
+ // Group first, then path: a deeper folder (webapps/nested) must not split
43
+ // its parent's run of apps, or the same group heading renders twice.
44
+ return found.toSorted((a, b) => path.dirname(a.root).localeCompare(path.dirname(b.root)) ||
45
+ a.root.localeCompare(b.root));
43
46
  }
44
47
  /** Group label for an app: its parent folder relative to the workspace root. */
45
48
  export function appGroup(root, appRoot) {
46
49
  const relative = path.relative(root, path.dirname(appRoot));
47
50
  return relative === '' ? '.' : relative;
48
51
  }
52
+ /** Missing something nothing can deploy without. */
53
+ export function configIncomplete(dev) {
54
+ if (!dev?.domain || !dev.siteName)
55
+ return true;
56
+ return (dev.authMethod ?? 'basic') === 'basic' && !dev.username;
57
+ }
58
+ /**
59
+ * True when a workspace has apps but nothing usable to deploy with: neither the
60
+ * shared root config nor the apps themselves carry domain/site/username. The
61
+ * shell then opens on Workspace settings instead of the first app.
62
+ */
63
+ export function needsOnboarding(root, apps) {
64
+ return (apps.length > 0 &&
65
+ configIncomplete(readWorkspaceDevProperties(root)) &&
66
+ apps.some(app => configIncomplete(app.devProperties)));
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.15",
3
+ "version": "1.0.0-beta.17",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"