sitevision-cli 1.0.0-beta.22 → 1.0.0-beta.24

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.
package/dist/cli.js CHANGED
@@ -4,6 +4,9 @@ import { render } from 'ink';
4
4
  import { Text, Box } from 'ink';
5
5
  import meow from 'meow';
6
6
  import { readFileSync } from 'node:fs';
7
+ import os from 'node:os';
8
+ import { runningTasks } from './utils/tasks.js';
9
+ import { killAllChildren } from './utils/process-runner.js';
7
10
  import { Shell } from './shell/Shell.js';
8
11
  import { getCommand } from './commands/index.js';
9
12
  import { requireProject, detectProject, migrateLegacyPassword, } from './utils/project-detection.js';
@@ -105,6 +108,20 @@ function printMasthead(version) {
105
108
  `${spaces(gap)}${DIM}${right}${RESET}${spaces(padding)}${CYAN}│${RESET}`);
106
109
  console.log(`${CYAN}╰${border}╯${RESET}`);
107
110
  }
111
+ /**
112
+ * Stop every running task and kill leftover child processes. In raw mode Ink
113
+ * reads Ctrl+C as a keypress, so no SIGINT reaches anything else. Exits when
114
+ * there was work to stop (or on a signal); otherwise the process ends on its
115
+ * own, so a command's last output is never cut off.
116
+ */
117
+ function shutdown(code) {
118
+ const busy = runningTasks();
119
+ for (const task of busy)
120
+ task.stop();
121
+ const killed = killAllChildren();
122
+ if (busy.length > 0 || killed > 0 || code !== undefined)
123
+ process.exit(code);
124
+ }
108
125
  function fail(message, hint) {
109
126
  render(_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", children: ["Error: ", message] }), _jsx(Text, { dimColor: true, children: hint })] }));
110
127
  process.exit(1);
@@ -130,6 +147,8 @@ async function playIntro(art) {
130
147
  // inside the same buffer, when the terminal is wide enough for it.
131
148
  async function runShell(apps, workspaceRoot) {
132
149
  process.stdout.write('\x1b[?1049h\x1b[H');
150
+ // Also leave the alternate screen when a signal exits past the finally.
151
+ process.once('exit', () => process.stdout.write('\x1b[?1049l'));
133
152
  try {
134
153
  const art = process.stdin.isTTY && settings.introAnimation && !cli.flags.minimal
135
154
  ? pickIntroArt(process.stdout.columns ?? 0)
@@ -146,6 +165,11 @@ async function runShell(apps, workspaceRoot) {
146
165
  }
147
166
  }
148
167
  async function main() {
168
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
169
+ process.on(signal, () => {
170
+ shutdown(128 + os.constants.signals[signal]);
171
+ });
172
+ }
149
173
  // On the very first run we show a dedicated welcome screen instead of the
150
174
  // masthead, so the branding is the moment. Only when stdin is a TTY — the
151
175
  // welcome is interactive and would hang in CI / piped input.
@@ -188,6 +212,7 @@ async function main() {
188
212
  fail('No Sitevision apps found here.', 'Run svc inside an app directory (manifest.json) or at the root of a repo that contains apps.');
189
213
  }
190
214
  await runShell(apps, process.cwd());
215
+ shutdown();
191
216
  return;
192
217
  }
193
218
  if (firstRun) {
@@ -201,6 +226,7 @@ async function main() {
201
226
  });
202
227
  }
203
228
  await runShell([project]);
229
+ shutdown();
204
230
  return;
205
231
  }
206
232
  // Check if we're in a Sitevision project
@@ -255,6 +281,7 @@ async function main() {
255
281
  flags: cli.flags,
256
282
  args,
257
283
  });
284
+ shutdown();
258
285
  }
259
286
  main().catch(error => {
260
287
  console.error('Fatal error:', error);
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  import { render, Box, Text, useInput } from 'ink';
4
4
  import { StatusIndicator } from '../components/StatusIndicator.js';
5
5
  import { deployApp, deployProduction } from '../utils/sitevision-api.js';
6
- import { getZipPath, getSignedZipPath, getAppType, } from '../utils/project-detection.js';
6
+ import { getDeployZipPath, getSignedZipPath, getAppType, } from '../utils/project-detection.js';
7
7
  import { zipExists } from '../utils/zip.js';
8
8
  import { promptPassword, promptYesNo } from '../utils/password-prompt.js';
9
9
  import { setDeployPassword, deleteSessionCookie, deleteOAuth2RefreshToken, } from '../utils/keychain.js';
@@ -161,8 +161,7 @@ export function DeployScreen({ projectRoot, manifest, devProperties, force, prod
161
161
  });
162
162
  }
163
163
  else {
164
- // Dev deployment can use unsigned zip
165
- const zipPath = getZipPath(projectRoot, manifest);
164
+ const zipPath = getDeployZipPath(projectRoot, manifest);
166
165
  if (!zipExists(zipPath)) {
167
166
  setState({
168
167
  status: 'error',
@@ -21,18 +21,6 @@ export function DevScreen({ project, options }) {
21
21
  exit();
22
22
  }
23
23
  });
24
- React.useEffect(() => {
25
- const stop = () => {
26
- live.stop();
27
- exit();
28
- };
29
- process.on('SIGINT', stop);
30
- process.on('SIGTERM', stop);
31
- return () => {
32
- process.off('SIGINT', stop);
33
- process.off('SIGTERM', stop);
34
- };
35
- }, [live, exit]);
36
24
  const status = live.status === 'running'
37
25
  ? live.phase === 'error'
38
26
  ? 'error'
@@ -84,7 +84,8 @@ export function Navigator({ apps, groupOf, selected, focused, tasks, height, sin
84
84
  }
85
85
  // Window the list so the selected app stays visible; the lines outside
86
86
  // are summarised as "… n more".
87
- const fixed = 1 + 1 + (single ? 0 : 2) + (running.length > 0 ? running.length + 2 : 0);
87
+ // Top border, header and legend, then the settings row and running tasks.
88
+ const fixed = 3 + (single ? 0 : 2) + (running.length > 0 ? running.length + 2 : 0);
88
89
  const avail = Math.max(3, height - fixed);
89
90
  let shown = rows;
90
91
  if (rows.length > avail) {
@@ -100,9 +101,9 @@ export function Navigator({ apps, groupOf, selected, focused, tasks, height, sin
100
101
  if (end < rows.length)
101
102
  shown[shown.length - 1] = more(rows.length - end, '↓');
102
103
  }
103
- 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
104
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: "single", borderColor: focused ? ACCENT : undefined, borderDimColor: !focused, 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
104
105
  ? t('1 match')
105
- : t('{n} matches', { n: apps.length })] })] })) : (_jsx(Text, { bold: true, dimColor: true, children: single
106
+ : t('{n} matches', { n: apps.length })] })] })) : (_jsx(Text, { bold: true, color: focused ? ACCENT : undefined, dimColor: !focused, children: single
106
107
  ? t('APP')
107
108
  : apps.length === 1
108
109
  ? t('WORKSPACE 1 app')
@@ -251,7 +251,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
251
251
  useInput((raw, key) => {
252
252
  // Terminals speaking the kitty keyboard protocol report shift+s as
253
253
  // "s" plus a shift flag; fold that back into the uppercase letter so
254
- // P, K, R behave the same everywhere.
254
+ // P and K behave the same everywhere.
255
255
  const input = key.shift && raw.length === 1 && /[a-z]/.test(raw)
256
256
  ? raw.toUpperCase()
257
257
  : raw;
@@ -270,7 +270,8 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
270
270
  setOverlay({ kind: 'palette' });
271
271
  return;
272
272
  }
273
- if (key.tab) {
273
+ // The config form uses Tab/Shift+Tab to move between fields.
274
+ if (key.tab && !formActive) {
274
275
  setFilter('');
275
276
  setFocus(f => f === 'nav' && !single ? 'content' : single ? 'content' : 'nav');
276
277
  return;
@@ -346,7 +347,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
346
347
  setVersionRow(r => Math.max(0, r - 1));
347
348
  if (key.downArrow)
348
349
  setVersionRow(r => Math.min(Math.max(0, count - 1), r + 1));
349
- if (input === 'R')
350
+ if (input === 'r')
350
351
  void fetchVersions(false);
351
352
  else if (input === 'a' && count > 0)
352
353
  void activateSelected();
@@ -426,8 +427,9 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
426
427
  : tab === 'versions'
427
428
  ? h([
428
429
  ['a', 'activate'],
429
- ['R', 'refresh'],
430
+ ['r', 'refresh'],
430
431
  ['p', 'deploy'],
432
+ ['P', 'force'],
431
433
  ['/', 'commands'],
432
434
  ['q', 'quit'],
433
435
  ])
@@ -437,6 +439,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
437
439
  ['x', 'wrap'],
438
440
  ['K', 'stop'],
439
441
  ['p', 'deploy'],
442
+ ['P', 'force'],
440
443
  ['/', 'commands'],
441
444
  ['q', 'quit'],
442
445
  ])
@@ -468,9 +471,14 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
468
471
  ['b', 'build'],
469
472
  ['s', 'sign'],
470
473
  ['p', 'deploy'],
471
- ['a', 'activate'],
472
- ['E', 'env'],
474
+ ['P', 'force'],
475
+ ['v', 'env'],
476
+ ['K', 'stop'],
477
+ ['a', 'versions'],
478
+ ['e', 'config'],
473
479
  ['i', 'install'],
480
+ ['l', 'login'],
481
+ [',', 'settings'],
474
482
  ['/', 'commands'],
475
483
  ['q', 'quit'],
476
484
  ]);
@@ -507,7 +515,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
507
515
  return (_jsxs(Box, { flexDirection: "column", width: columns, height: frameRows, children: [_jsx(TopBar, { context: contextLabel, domain: project.devProperties?.domain, auth: authState(project), environment: {
508
516
  name: env,
509
517
  color: environmentColor(env, rawProject.devProperties),
510
- }, 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', width: columns, filter: filter })), 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)
518
+ }, version: version }), _jsxs(Box, { flexGrow: 1, height: mainHeight, children: [!narrow && (_jsx(Navigator, { apps: matches.map(i => apps[i]), groupOf: groupOf, selected: matches.indexOf(selected), focused: focus === 'nav', tasks: tasks, height: mainHeight, single: single, settingsSelected: settings, width: sidebar, filter: filter })), _jsxs(Box, { flexDirection: "column", width: narrow ? columns : columns - sidebar, overflow: "hidden", borderStyle: "single", borderLeft: false, borderRight: false, borderBottom: false, borderColor: focus === 'content' ? ACCENT : undefined, borderDimColor: focus !== 'content', children: [narrow && !single && (_jsx(NavigatorStrip, { apps: matches.map(i => apps[i]), selected: matches.indexOf(selected), focused: focus === 'nav', width: columns, filter: filter })), 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)
511
519
  ? t(' · new workspace: fill in once, every app inherits · Esc skips')
512
520
  : t(' · shared .dev_properties.json at the root') })] })) : (_jsx(TabBar, { tab: tab, narrow: narrow, focused: focus === 'content' })), _jsx(Box, { flexDirection: "column", height: contentHeight, overflow: "hidden", alignItems: "flex-start", children: content })] })] }), _jsx(BottomBar, { hints: hints, right: right })] }));
513
521
  }
@@ -12,7 +12,7 @@ export const TABS = [
12
12
  { id: 'log', label: 'Log', short: 'Log' },
13
13
  ];
14
14
  export function TabBar({ tab, narrow, focused, }) {
15
- return (_jsx(Box, { paddingX: 1, children: TABS.map((entry, i) => (_jsxs(Text, { children: [_jsxs(Text, { bold: entry.id === tab, color: entry.id === tab ? ACCENT : undefined, dimColor: entry.id !== tab, underline: entry.id === tab && focused, children: [i + 1, " ", t(narrow ? entry.short : entry.label)] }), ' '.repeat(3)] }, entry.id))) }));
15
+ return (_jsx(Box, { paddingX: 1, children: TABS.map((entry, i) => (_jsxs(Text, { children: [_jsxs(Text, { bold: entry.id === tab, color: entry.id === tab ? ACCENT : undefined, dimColor: entry.id !== tab, underline: entry.id === tab && focused, children: [focused && `${i + 1} `, t(narrow ? entry.short : entry.label)] }), ' '.repeat(3)] }, entry.id))) }));
16
16
  }
17
17
  function Row({ label, value, dim, }) {
18
18
  return (_jsx(Box, { height: 1, flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { dimColor: true, children: label.padEnd(14) }), value, dim && _jsxs(Text, { dimColor: true, children: [" ", dim] })] }) }));
@@ -72,8 +72,8 @@ export function Versions({ project, state, selected, }) {
72
72
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsxs(Text, { dimColor: true, children: [t('APP IDENTIFIER').padEnd(30), t('VERSION').padEnd(12), t('ACTIVE'), state.loading && (_jsxs(Text, { color: ACCENT, children: [' '.repeat(3), _jsx(Spinner, { type: "dots" })] }))] }), state.error && _jsx(Text, { color: "red", children: state.error }), list.map((e, i) => (_jsxs(Text, { backgroundColor: i === selected ? ACCENT : undefined, color: i === selected ? 'black' : undefined, wrap: "truncate", children: [e.appIdentifier.padEnd(30).slice(0, 30), e.appVersion.padEnd(12), e.active ? '●' : '○', i === selected ? ` ${e.id}` : ''] }, e.id))), !state.loading && !state.error && list.length === 0 && (_jsx(Text, { dimColor: true, children: t('No versions uploaded to {addon}.', {
73
73
  addon: project.devProperties.addonName,
74
74
  }) })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [list.length === 1
75
- ? t('1 version · a activate selected · R refresh')
76
- : t('{n} versions · a activate selected · R refresh', {
75
+ ? t('1 version · a activate selected · r refresh')
76
+ : t('{n} versions · a activate selected · r refresh', {
77
77
  n: list.length,
78
78
  }), state.fetchedAt
79
79
  ? t(' · fetched {time}', { time: time(state.fetchedAt) })
@@ -86,5 +86,5 @@ export function Log({ task, height, scroll, wrap, }) {
86
86
  const visible = Math.max(1, height - 2);
87
87
  const end = Math.max(0, task.lines.length - scroll);
88
88
  const lines = task.lines.slice(Math.max(0, end - visible), end);
89
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsxs(Text, { children: [STATUS_GLYPH[task.status], " ", task.label, " ", task.appName, ' ', _jsxs(Text, { dimColor: true, children: [task.phase, " \u00B7 ", elapsed(task), scroll > 0 ? ` · ↑${scroll}` : t(' · following')] })] }), lines.map((line, i) => (_jsxs(Text, { wrap: wrap ? 'wrap' : 'truncate', color: LEVEL_COLOR[line.level], children: [_jsxs(Text, { dimColor: true, children: [time(line.time, true), " ", line.tag.padEnd(3)] }), ' ', line.text] }, i)))] }));
89
+ return (_jsxs(Box, { width: "100%", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsxs(Text, { children: [STATUS_GLYPH[task.status], " ", task.label, " ", task.appName, ' ', _jsxs(Text, { dimColor: true, children: [task.phase, " \u00B7 ", elapsed(task), scroll > 0 ? ` · ↑${scroll}` : t(' · following')] })] }), _jsx(Box, { flexDirection: "column", maxHeight: visible, justifyContent: "flex-end", overflow: "hidden", children: lines.map((line, i) => (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: wrap ? 'wrap' : 'truncate', color: LEVEL_COLOR[line.level], children: [_jsxs(Text, { dimColor: true, children: [time(line.time, true), " ", line.tag.padEnd(3)] }), ' ', line.text] }) }, i))) }, wrap ? 'wrap' : 'truncate')] }));
90
90
  }
Binary file
@@ -68,8 +68,8 @@ const sv = {
68
68
  VERSION: 'VERSION',
69
69
  ACTIVE: 'AKTIV',
70
70
  'No versions uploaded to {addon}.': 'Inga versioner uppladdade till {addon}.',
71
- '{n} versions · a activate selected · R refresh': '{n} versioner · a aktivera vald · R uppdatera',
72
- '1 version · a activate selected · R refresh': '1 version · a aktivera vald · R uppdatera',
71
+ '{n} versions · a activate selected · r refresh': '{n} versioner · a aktivera vald · r uppdatera',
72
+ '1 version · a activate selected · r refresh': '1 version · a aktivera vald · r uppdatera',
73
73
  ' · fetched {time}': ' · hämtad {time}',
74
74
  'No task yet. d dev · w watch · b build · s sign · p deploy': 'Ingen uppgift ännu. d dev · w watch · b build · s sign · p deploy',
75
75
  ' · following': ' · följer',
@@ -97,7 +97,7 @@ const sv = {
97
97
  'e.g. test or prod, overriding domain and auth': 't.ex. test eller prod, med egen domän och auth',
98
98
  'Environment name (e.g. test, prod)': 'Miljönamn (t.ex. test, prod)',
99
99
  'Deploy the signed {id} to {env} and activate it?': 'Driftsätt signerade {id} till {env} och aktivera?',
100
- 'Dev never deploys to a production environment ({env}). Switch with E.': 'Dev driftsätter aldrig till en produktionsmiljö ({env}). Byt med E.',
100
+ 'Dev never deploys to a production environment ({env}). Switch with v.': 'Dev driftsätter aldrig till en produktionsmiljö ({env}). Byt med v.',
101
101
  'switched to {env}': 'bytte till {env}',
102
102
  'environment {env} added': 'miljön {env} tillagd',
103
103
  environment: 'miljö',
@@ -217,6 +217,8 @@ const sv = {
217
217
  activate: 'aktivera',
218
218
  refresh: 'uppdatera',
219
219
  deploy: 'driftsätt',
220
+ force: 'tvinga',
221
+ versions: 'versioner',
220
222
  commands: 'kommandon',
221
223
  follow: 'följ',
222
224
  wrap: 'radbryt',
@@ -1,3 +1,4 @@
1
+ import { type ChildProcess, type SpawnOptions } from 'child_process';
1
2
  import { EventEmitter } from 'events';
2
3
  export interface ProcessOutput {
3
4
  type: 'stdout' | 'stderr';
@@ -7,15 +8,23 @@ export interface ProcessResult {
7
8
  exitCode: number;
8
9
  output: ProcessOutput[];
9
10
  }
11
+ /**
12
+ * Spawn a child in its own process group, so stopping it also stops whatever it
13
+ * starts (npm, webpack workers), and track it so the CLI can kill leftovers on
14
+ * exit.
15
+ */
16
+ export declare function spawnChild(command: string, args: string[], options?: SpawnOptions): ChildProcess;
17
+ /** Stop a child and everything it started. */
18
+ export declare function killChild(child: ChildProcess): void;
19
+ /** Kill every child still running; returns how many were signalled. */
20
+ export declare function killAllChildren(): number;
10
21
  export declare class ProcessRunner extends EventEmitter {
11
22
  private process;
12
- private output;
23
+ private readonly output;
13
24
  private readonly command;
14
25
  private readonly args;
15
26
  private readonly cwd?;
16
- private readonly interactive;
17
- private readonly customEnv?;
18
- constructor(command: string, args?: string[], cwd?: string, interactive?: boolean, customEnv?: Record<string, string>);
27
+ constructor(command: string, args?: string[], cwd?: string);
19
28
  run(): Promise<ProcessResult>;
20
29
  kill(): void;
21
30
  getOutput(): ProcessOutput[];
@@ -1,54 +1,81 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn, spawnSync, } from 'child_process';
2
2
  import { EventEmitter } from 'events';
3
+ const children = new Set();
4
+ /**
5
+ * Spawn a child in its own process group, so stopping it also stops whatever it
6
+ * starts (npm, webpack workers), and track it so the CLI can kill leftovers on
7
+ * exit.
8
+ */
9
+ export function spawnChild(command, args, options = {}) {
10
+ const child = spawn(command, args, {
11
+ ...options,
12
+ detached: process.platform !== 'win32',
13
+ });
14
+ children.add(child);
15
+ const forget = () => children.delete(child);
16
+ child.on('close', forget);
17
+ child.on('error', forget);
18
+ return child;
19
+ }
20
+ /** Stop a child and everything it started. */
21
+ export function killChild(child) {
22
+ if (child.pid === undefined ||
23
+ child.exitCode !== null ||
24
+ child.signalCode !== null) {
25
+ return;
26
+ }
27
+ try {
28
+ if (process.platform === 'win32') {
29
+ // No process groups on Windows: taskkill /T takes the whole tree.
30
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
31
+ stdio: 'ignore',
32
+ windowsHide: true,
33
+ });
34
+ }
35
+ else {
36
+ process.kill(-child.pid, 'SIGTERM');
37
+ }
38
+ }
39
+ catch {
40
+ // Already gone.
41
+ }
42
+ }
43
+ /** Kill every child still running; returns how many were signalled. */
44
+ export function killAllChildren() {
45
+ const running = [...children];
46
+ for (const child of running)
47
+ killChild(child);
48
+ return running.length;
49
+ }
3
50
  export class ProcessRunner extends EventEmitter {
4
51
  process = null;
5
52
  output = [];
6
53
  command;
7
54
  args;
8
55
  cwd;
9
- interactive;
10
- customEnv;
11
- constructor(command, args = [], cwd, interactive = false, customEnv) {
56
+ constructor(command, args = [], cwd) {
12
57
  super();
13
58
  this.command = command;
14
59
  this.args = args;
15
60
  this.cwd = cwd;
16
- this.interactive = interactive;
17
- this.customEnv = customEnv;
18
61
  }
19
62
  run() {
20
63
  return new Promise((resolve, reject) => {
21
- this.process = spawn(this.command, this.args, {
64
+ this.process = spawnChild(this.command, this.args, {
22
65
  cwd: this.cwd || process.cwd(),
23
- env: { ...process.env, ...this.customEnv },
24
66
  shell: true,
25
- stdio: this.interactive ? 'inherit' : 'pipe',
67
+ stdio: 'pipe',
26
68
  });
27
- // Only capture output if not in interactive mode
28
- if (!this.interactive) {
29
- this.process.stdout?.on('data', data => {
30
- const output = {
31
- type: 'stdout',
32
- data: data.toString(),
33
- };
34
- this.output.push(output);
35
- if (this.output.length > 1000) {
36
- this.output.shift();
37
- }
38
- this.emit('output', output);
39
- });
40
- this.process.stderr?.on('data', data => {
41
- const output = {
42
- type: 'stderr',
43
- data: data.toString(),
44
- };
45
- this.output.push(output);
46
- if (this.output.length > 1000) {
47
- this.output.shift();
48
- }
49
- this.emit('output', output);
50
- });
51
- }
69
+ const capture = (type) => (data) => {
70
+ const output = { type, data: data.toString() };
71
+ this.output.push(output);
72
+ if (this.output.length > 1000) {
73
+ this.output.shift();
74
+ }
75
+ this.emit('output', output);
76
+ };
77
+ this.process.stdout?.on('data', capture('stdout'));
78
+ this.process.stderr?.on('data', capture('stderr'));
52
79
  this.process.on('error', error => {
53
80
  this.emit('error', error);
54
81
  reject(error);
@@ -64,9 +91,8 @@ export class ProcessRunner extends EventEmitter {
64
91
  });
65
92
  }
66
93
  kill() {
67
- if (this.process) {
68
- this.process.kill('SIGTERM');
69
- }
94
+ if (this.process)
95
+ killChild(this.process);
70
96
  }
71
97
  getOutput() {
72
98
  return this.output;
@@ -62,6 +62,11 @@ export declare function getZipPath(projectRoot: string, manifest: SitevisionMani
62
62
  * Get the full path to the signed zip file in dist/
63
63
  */
64
64
  export declare function getSignedZipPath(projectRoot: string, manifest: SitevisionManifest): string;
65
+ /**
66
+ * The zip a non-production deploy uploads: the signed one when it is at least
67
+ * as new as the build, since some sites reject unsigned apps even on dev.
68
+ */
69
+ export declare function getDeployZipPath(projectRoot: string, manifest: SitevisionManifest): string;
65
70
  /**
66
71
  * Get API endpoints for the given app type
67
72
  */
@@ -192,6 +192,20 @@ export function getZipPath(projectRoot, manifest) {
192
192
  export function getSignedZipPath(projectRoot, manifest) {
193
193
  return path.join(projectRoot, 'dist', getSignedZipFilename(manifest));
194
194
  }
195
+ /**
196
+ * The zip a non-production deploy uploads: the signed one when it is at least
197
+ * as new as the build, since some sites reject unsigned apps even on dev.
198
+ */
199
+ export function getDeployZipPath(projectRoot, manifest) {
200
+ const zip = getZipPath(projectRoot, manifest);
201
+ const signed = getSignedZipPath(projectRoot, manifest);
202
+ const mtime = (file) => fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs;
203
+ const signedAt = mtime(signed);
204
+ if (signedAt === undefined)
205
+ return zip;
206
+ const builtAt = mtime(zip);
207
+ return builtAt === undefined || signedAt >= builtAt ? signed : zip;
208
+ }
195
209
  // =============================================================================
196
210
  // API ENDPOINT UTILITIES
197
211
  // =============================================================================
@@ -79,5 +79,6 @@ export interface SitevisionBuildResult {
79
79
  *
80
80
  * @param projectRoot - Project root directory (used as cwd)
81
81
  * @param onOutput - Optional callback for streaming output chunks
82
+ * @param signal - Aborting kills the build and everything it started
82
83
  */
83
- export declare function runSitevisionScriptsBuild(projectRoot: string, onOutput?: (chunk: string) => void): Promise<SitevisionBuildResult>;
84
+ export declare function runSitevisionScriptsBuild(projectRoot: string, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<SitevisionBuildResult>;
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import path from 'path';
19
19
  import fs from 'fs';
20
- import { spawn } from 'child_process';
20
+ import { killChild, spawnChild } from './process-runner.js';
21
21
  /**
22
22
  * Resolve the path to the sitevision-scripts CLI entry inside a project.
23
23
  * Returns null if the package is not installed.
@@ -145,8 +145,9 @@ const MAX_OUTPUT_CHARS = 50_000;
145
145
  *
146
146
  * @param projectRoot - Project root directory (used as cwd)
147
147
  * @param onOutput - Optional callback for streaming output chunks
148
+ * @param signal - Aborting kills the build and everything it started
148
149
  */
149
- export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
150
+ export async function runSitevisionScriptsBuild(projectRoot, onOutput, signal) {
150
151
  const bin = getSitevisionScriptsBin(projectRoot);
151
152
  if (!bin) {
152
153
  return {
@@ -157,10 +158,16 @@ export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
157
158
  }
158
159
  return new Promise(resolve => {
159
160
  let output = '';
160
- const child = spawn(process.execPath, [bin, 'build'], {
161
+ const child = spawnChild(process.execPath, [bin, 'build'], {
161
162
  cwd: projectRoot,
162
163
  stdio: ['ignore', 'pipe', 'pipe'],
163
164
  });
165
+ const abort = () => {
166
+ killChild(child);
167
+ };
168
+ signal?.addEventListener('abort', abort, { once: true });
169
+ if (signal?.aborted)
170
+ abort();
164
171
  const handleData = (data) => {
165
172
  const text = data.toString();
166
173
  output += text;
@@ -175,12 +182,16 @@ export async function runSitevisionScriptsBuild(projectRoot, onOutput) {
175
182
  resolve({ success: false, output, error: error.message });
176
183
  });
177
184
  child.on('close', code => {
185
+ signal?.removeEventListener('abort', abort);
186
+ const stopped = signal?.aborted ?? false;
178
187
  resolve({
179
- success: code === 0,
188
+ success: code === 0 && !stopped,
180
189
  output,
181
- error: code === 0
182
- ? undefined
183
- : `sitevision-scripts build exited with code ${code}`,
190
+ error: stopped
191
+ ? 'Build stopped.'
192
+ : code === 0
193
+ ? undefined
194
+ : `sitevision-scripts build exited with code ${code}`,
184
195
  });
185
196
  });
186
197
  });
@@ -5,7 +5,7 @@ import { useSyncExternalStore } from 'react';
5
5
  import { WebpackRunner, hasLocalWebpackConfig } from './webpack-runner.js';
6
6
  import { hasSitevisionScripts, runSitevisionScriptsBuild, getDelegatedZipPath, checkSitevisionScriptsCompatibility, } from './sitevision-scripts-runner.js';
7
7
  import { copyStaticToBuild, copySrcToBuild, cleanBuild, createBuildZip, zipExists, } from './zip.js';
8
- import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, localizedText, } from './project-detection.js';
8
+ import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, getDeployZipPath, localizedText, } from './project-detection.js';
9
9
  import { signApp, deployApp, deployProduction, activateApp, } from './sitevision-api.js';
10
10
  import { ProcessRunner } from './process-runner.js';
11
11
  const MAX_LINES = 2000;
@@ -78,7 +78,7 @@ const errorText = (error) => error instanceof Error ? error.message : String(err
78
78
  // One-shot steps shared by the tasks below.
79
79
  // ---------------------------------------------------------------------------
80
80
  /** Build the app to dist/<appId>.zip. Returns the zip path or throws. */
81
- async function buildOnce(project, task, log, mode) {
81
+ async function buildOnce(project, task, log, mode, signal) {
82
82
  const { root, manifest } = project;
83
83
  cleanBuild(root);
84
84
  if (isBundledApp(manifest) && !hasLocalWebpackConfig(root)) {
@@ -90,7 +90,7 @@ async function buildOnce(project, task, log, mode) {
90
90
  log('bld', warning, 'warn');
91
91
  setPhase(task, 'building');
92
92
  log('bld', 'building via sitevision-scripts');
93
- const result = await runSitevisionScriptsBuild(root, chunk => log('bld', chunk));
93
+ const result = await runSitevisionScriptsBuild(root, chunk => log('bld', chunk), signal);
94
94
  if (!result.success)
95
95
  throw new Error(result.error ?? 'Build failed');
96
96
  const zipPath = getDelegatedZipPath(root, manifest.id);
@@ -148,7 +148,7 @@ async function signOnce(project, task, log, zipPath, credentials) {
148
148
  async function deployOnce(project, task, log, zipPath, config, options) {
149
149
  setPhase(task, 'deploying');
150
150
  const appType = getAppType(project.manifest);
151
- log('dep', `POST multipart → ${options.production ? 'production' : 'dev'} import · ${config.addonName}`);
151
+ log('dep', `POST multipart → ${options.production ? 'production' : 'dev'} import · ${config.addonName} · ${path.basename(zipPath)}`);
152
152
  const result = options.production
153
153
  ? await deployProduction(zipPath, { ...config, activate: options.activate }, appType)
154
154
  : await deployApp(zipPath, config, appType, options.force);
@@ -161,10 +161,14 @@ async function deployOnce(project, task, log, zipPath, config, options) {
161
161
  // Tasks
162
162
  // ---------------------------------------------------------------------------
163
163
  export function startBuild(project) {
164
- const { task, log, finish } = createTask('build', project, 'build');
164
+ const controller = new AbortController();
165
+ const { task, log, finish } = createTask('build', project, 'build', () => {
166
+ controller.abort();
167
+ finish('stopped');
168
+ });
165
169
  void (async () => {
166
170
  try {
167
- const zip = await buildOnce(project, task, log, 'production');
171
+ const zip = await buildOnce(project, task, log, 'production', controller.signal);
168
172
  log('bld', `created ${zip}`, 'ok');
169
173
  finish('success');
170
174
  }
@@ -197,7 +201,7 @@ export function startDeploy(project, config, options) {
197
201
  try {
198
202
  const zipPath = options.production
199
203
  ? getSignedZipPath(project.root, project.manifest)
200
- : getZipPath(project.root, project.manifest);
204
+ : getDeployZipPath(project.root, project.manifest);
201
205
  if (!zipExists(zipPath)) {
202
206
  throw new Error(`${options.production ? 'Signed zip' : 'Zip'} not found: ${zipPath}. Run ${options.production ? 'sign' : 'build'} first.`);
203
207
  }
@@ -230,7 +234,10 @@ export function startActivate(project, config, executableId, versionLabel) {
230
234
  }
231
235
  export function startInstall(project) {
232
236
  const runner = new ProcessRunner('npm', ['install'], project.root);
233
- const { task, log, finish } = createTask('install', project, 'npm install', () => runner.kill());
237
+ const { task, log, finish } = createTask('install', project, 'npm install', () => {
238
+ runner.kill();
239
+ finish('stopped');
240
+ });
234
241
  setPhase(task, 'installing');
235
242
  runner.on('output', (output) => log('npm', output.data, output.type === 'stderr' ? 'warn' : 'info'));
236
243
  runner
@@ -260,7 +267,9 @@ export function startDev(project, options) {
260
267
  let debounce;
261
268
  let building = false;
262
269
  let pending = false;
270
+ const controller = new AbortController();
263
271
  const stop = () => {
272
+ controller.abort();
264
273
  clearTimeout(debounce);
265
274
  for (const watcher of watchers)
266
275
  watcher.close();
@@ -269,6 +278,9 @@ export function startDev(project, options) {
269
278
  };
270
279
  const { task, log, finish } = createTask(options.deploy ? 'dev' : 'watch', project, options.deploy ? 'dev' : 'watch', stop);
271
280
  const afterBuild = async (zipPath) => {
281
+ // Stopped mid-build: never sign or deploy what is left.
282
+ if (controller.signal.aborted)
283
+ return;
272
284
  let deployZip = zipPath;
273
285
  if (options.signingCredentials) {
274
286
  deployZip = await signOnce(project, task, log, zipPath, options.signingCredentials);
@@ -282,6 +294,8 @@ export function startDev(project, options) {
282
294
  log('svc', 'watching for changes');
283
295
  };
284
296
  const fail = (error) => {
297
+ if (controller.signal.aborted)
298
+ return;
285
299
  log('svc', errorText(error), 'error');
286
300
  setPhase(task, 'error');
287
301
  };
@@ -296,12 +310,14 @@ export function startDev(project, options) {
296
310
  pending = false;
297
311
  try {
298
312
  // eslint-disable-next-line no-await-in-loop
299
- await afterBuild(await buildOnce(project, task, log, 'development'));
313
+ const zip = await buildOnce(project, task, log, 'development', controller.signal);
314
+ // eslint-disable-next-line no-await-in-loop
315
+ await afterBuild(zip);
300
316
  }
301
317
  catch (error) {
302
318
  fail(error);
303
319
  }
304
- } while (pending);
320
+ } while (pending && !controller.signal.aborted);
305
321
  }
306
322
  finally {
307
323
  building = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.22",
3
+ "version": "1.0.0-beta.24",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"
package/readme.md CHANGED
@@ -42,7 +42,7 @@ svc
42
42
  3. Press `i` to install dependencies if needed, then `d` to start dev: build on
43
43
  every change and deploy. Output is in the **Log** tab (`4`).
44
44
 
45
- For production: switch environment with `E`, press `b` to build, `s` to sign
45
+ For production: switch environment with `v`, press `b` to build, `s` to sign
46
46
  and `p` to deploy and activate.
47
47
 
48
48
  ## The shell
@@ -53,7 +53,7 @@ and `p` to deploy and activate.
53
53
  | `b` / `s` | Build / Sign |
54
54
  | `p` / `P` | Deploy / force deploy to the active environment |
55
55
  | `a` | Versions: list and activate uploaded versions |
56
- | `E` | Switch environment |
56
+ | `v` | Switch environment |
57
57
  | `e` / `y` / `i` | Config tab / sync `package.json` / `npm install` |
58
58
  | `l` | Log in again |
59
59
  | `K` | Stop running tasks |