sitevision-cli 1.0.0-beta.23 → 1.0.0-beta.25

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'
@@ -12,7 +12,7 @@ const GROUPS = [
12
12
  export function CommandPalette({ project, onRun, onClose, height, }) {
13
13
  const [query, setQuery] = useState('');
14
14
  const [index, setIndex] = useState(0);
15
- const matches = actions.filter(a => fuzzyMatch(query, t(a.label)));
15
+ const matches = actions.filter(a => !a.hidden?.(project) && fuzzyMatch(query, t(a.label)));
16
16
  const ordered = GROUPS.flatMap(g => matches.filter(a => a.group === g.id));
17
17
  useInput((input, key) => {
18
18
  if (key.escape) {
@@ -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
  ]);
@@ -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
  }
@@ -30,6 +30,7 @@ export interface Action {
30
30
  label: string;
31
31
  detail?: (project: ProjectInfo) => string | undefined;
32
32
  enabled?: (project: ProjectInfo) => boolean;
33
+ hidden?: (project: ProjectInfo) => boolean;
33
34
  run: (ctx: ActionContext) => Promise<void>;
34
35
  }
35
36
  export declare function forgetSession(project: ProjectInfo): void;
Binary file
@@ -180,6 +180,7 @@ export interface DeployResponse {
180
180
  message?: string;
181
181
  error?: string;
182
182
  authExpired?: boolean;
183
+ contextNodeMissing?: boolean;
183
184
  }
184
185
  /**
185
186
  * API response from addon creation
@@ -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',
@@ -94,10 +94,14 @@ const sv = {
94
94
  'to {env}': 'till {env}',
95
95
  'Switch environment': 'Byt miljö',
96
96
  'Add environment': 'Lägg till miljö',
97
+ 'Create addon': 'Skapa tillägg',
98
+ 'Create addon failed': 'Kunde inte skapa tillägget',
99
+ 'addon {addon} created': 'tillägget {addon} skapat',
100
+ 'Addon {addon} does not exist on {domain}. Create it and deploy again?': 'Tillägget {addon} finns inte på {domain}. Skapa det och driftsätt igen?',
97
101
  'e.g. test or prod, overriding domain and auth': 't.ex. test eller prod, med egen domän och auth',
98
102
  'Environment name (e.g. test, prod)': 'Miljönamn (t.ex. test, prod)',
99
103
  '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.',
104
+ 'Dev never deploys to a production environment ({env}). Switch with v.': 'Dev driftsätter aldrig till en produktionsmiljö ({env}). Byt med v.',
101
105
  'switched to {env}': 'bytte till {env}',
102
106
  'environment {env} added': 'miljön {env} tillagd',
103
107
  environment: 'miljö',
@@ -217,6 +221,8 @@ const sv = {
217
221
  activate: 'aktivera',
218
222
  refresh: 'uppdatera',
219
223
  deploy: 'driftsätt',
224
+ force: 'tvinga',
225
+ versions: 'versioner',
220
226
  commands: 'kommandon',
221
227
  follow: 'följ',
222
228
  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
  // =============================================================================
@@ -144,4 +144,12 @@ export declare function listAddons(config: DeployConfig): Promise<{
144
144
  addons?: AddonNode[];
145
145
  error?: string;
146
146
  }>;
147
+ /**
148
+ * Whether an addon exists, judged from a listAddons result. A failed or empty
149
+ * listing is "unknown": a dead session can look exactly like an empty site.
150
+ */
151
+ export declare function classifyAddon(listed: {
152
+ success: boolean;
153
+ addons?: AddonNode[];
154
+ }, name: string): 'missing' | 'present' | 'unknown';
147
155
  export { createBasicAuth, configAuth, unauthorizedMessage };
@@ -391,6 +391,14 @@ export async function deployApp(zipPath, config, appType, force = false) {
391
391
  error: 'Conflict. Addon already exists. Use --force to overwrite.',
392
392
  };
393
393
  }
394
+ if (response.statusCode === 400 &&
395
+ response.body.toString().includes('could not resolve context node')) {
396
+ return {
397
+ success: false,
398
+ error: `Deployment failed with status 400: ${summarizeErrorBody(response.body, response.headers)}`,
399
+ contextNodeMissing: true,
400
+ };
401
+ }
394
402
  return {
395
403
  success: false,
396
404
  error: `Deployment failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
@@ -445,10 +453,11 @@ export async function deployProduction(signedZipPath, config, appType) {
445
453
  */
446
454
  export async function createAddon(config, appType) {
447
455
  const url = buildAddonEndpointUrl(config.domain, config.siteName, appType, config.useHTTP);
448
- const body = JSON.stringify({
449
- name: config.addonName,
450
- category: 'Other',
451
- });
456
+ const payload = { name: config.addonName };
457
+ // Only the custommodule endpoints take (and require) a category.
458
+ if (appType === 'web' || appType === 'widget')
459
+ payload['category'] = 'Other';
460
+ const body = JSON.stringify(payload);
452
461
  const { auth, kind } = configAuth(config);
453
462
  try {
454
463
  const response = await makeRequest(url, {
@@ -617,6 +626,18 @@ export async function listAddons(config) {
617
626
  };
618
627
  }
619
628
  }
629
+ /**
630
+ * Whether an addon exists, judged from a listAddons result. A failed or empty
631
+ * listing is "unknown": a dead session can look exactly like an empty site.
632
+ */
633
+ export function classifyAddon(listed, name) {
634
+ if (!listed.success || !listed.addons?.length)
635
+ return 'unknown';
636
+ const wanted = name.toLowerCase();
637
+ return listed.addons.some(addon => addon.name.toLowerCase() === wanted)
638
+ ? 'present'
639
+ : 'missing';
640
+ }
620
641
  // =============================================================================
621
642
  // HELPER EXPORTS
622
643
  // =============================================================================
@@ -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
  });
@@ -30,6 +30,7 @@ export interface DeployOptions {
30
30
  force?: boolean;
31
31
  production?: boolean;
32
32
  activate?: boolean;
33
+ onAddonMissing?: (addonName: string) => Promise<boolean>;
33
34
  }
34
35
  export declare function startBuild(project: ProjectInfo): Task;
35
36
  export declare function startSign(project: ProjectInfo, credentials: SigningCredentials): Task;
@@ -40,6 +41,7 @@ export interface DevOptions {
40
41
  deploy: boolean;
41
42
  signingCredentials?: SigningCredentials;
42
43
  deployConfig?: DeployConfig;
44
+ onAddonMissing?: DeployOptions['onAddonMissing'];
43
45
  }
44
46
  /**
45
47
  * Dev / watch loop: build on every source change, then optionally sign and
@@ -5,8 +5,8 @@ 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';
9
- import { signApp, deployApp, deployProduction, activateApp, } from './sitevision-api.js';
8
+ import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, getDeployZipPath, localizedText, } from './project-detection.js';
9
+ import { signApp, deployApp, deployProduction, activateApp, createAddon, listAddons, classifyAddon, } from './sitevision-api.js';
10
10
  import { ProcessRunner } from './process-runner.js';
11
11
  const MAX_LINES = 2000;
12
12
  const emitter = new EventEmitter();
@@ -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,10 +148,28 @@ 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}`);
152
- const result = options.production
153
- ? await deployProduction(zipPath, { ...config, activate: options.activate }, appType)
154
- : await deployApp(zipPath, config, appType, options.force);
151
+ log('dep', `POST multipart → ${options.production ? 'production' : 'dev'} import · ${config.addonName} · ${path.basename(zipPath)}`);
152
+ const upload = async () => options.production
153
+ ? deployProduction(zipPath, { ...config, activate: options.activate }, appType)
154
+ : deployApp(zipPath, config, appType, options.force);
155
+ let result = await upload();
156
+ if (!result.success && result.contextNodeMissing) {
157
+ const addon = classifyAddon(await listAddons(config), config.addonName);
158
+ if (addon === 'unknown') {
159
+ throw new Error(`${result.error}\nCould not list the site's addons either, so the session may have expired. Press l to log in again.`);
160
+ }
161
+ if (addon === 'missing' && options.onAddonMissing) {
162
+ log('dep', `addon ${config.addonName} does not exist`, 'warn');
163
+ if (await options.onAddonMissing(config.addonName)) {
164
+ const created = await createAddon(config, appType);
165
+ if (!created.success) {
166
+ throw new Error(created.error ?? 'Create addon failed');
167
+ }
168
+ log('dep', `created addon ${config.addonName}`, 'ok');
169
+ result = await upload();
170
+ }
171
+ }
172
+ }
155
173
  if (!result.success)
156
174
  throw new Error(result.error ?? 'Deployment failed');
157
175
  log('dep', `${result.message ?? 'deployed'}${result.executableId ? ` · exec ${result.executableId}` : ''}`, 'ok');
@@ -161,10 +179,14 @@ async function deployOnce(project, task, log, zipPath, config, options) {
161
179
  // Tasks
162
180
  // ---------------------------------------------------------------------------
163
181
  export function startBuild(project) {
164
- const { task, log, finish } = createTask('build', project, 'build');
182
+ const controller = new AbortController();
183
+ const { task, log, finish } = createTask('build', project, 'build', () => {
184
+ controller.abort();
185
+ finish('stopped');
186
+ });
165
187
  void (async () => {
166
188
  try {
167
- const zip = await buildOnce(project, task, log, 'production');
189
+ const zip = await buildOnce(project, task, log, 'production', controller.signal);
168
190
  log('bld', `created ${zip}`, 'ok');
169
191
  finish('success');
170
192
  }
@@ -197,7 +219,7 @@ export function startDeploy(project, config, options) {
197
219
  try {
198
220
  const zipPath = options.production
199
221
  ? getSignedZipPath(project.root, project.manifest)
200
- : getZipPath(project.root, project.manifest);
222
+ : getDeployZipPath(project.root, project.manifest);
201
223
  if (!zipExists(zipPath)) {
202
224
  throw new Error(`${options.production ? 'Signed zip' : 'Zip'} not found: ${zipPath}. Run ${options.production ? 'sign' : 'build'} first.`);
203
225
  }
@@ -230,7 +252,10 @@ export function startActivate(project, config, executableId, versionLabel) {
230
252
  }
231
253
  export function startInstall(project) {
232
254
  const runner = new ProcessRunner('npm', ['install'], project.root);
233
- const { task, log, finish } = createTask('install', project, 'npm install', () => runner.kill());
255
+ const { task, log, finish } = createTask('install', project, 'npm install', () => {
256
+ runner.kill();
257
+ finish('stopped');
258
+ });
234
259
  setPhase(task, 'installing');
235
260
  runner.on('output', (output) => log('npm', output.data, output.type === 'stderr' ? 'warn' : 'info'));
236
261
  runner
@@ -260,7 +285,9 @@ export function startDev(project, options) {
260
285
  let debounce;
261
286
  let building = false;
262
287
  let pending = false;
288
+ const controller = new AbortController();
263
289
  const stop = () => {
290
+ controller.abort();
264
291
  clearTimeout(debounce);
265
292
  for (const watcher of watchers)
266
293
  watcher.close();
@@ -269,6 +296,9 @@ export function startDev(project, options) {
269
296
  };
270
297
  const { task, log, finish } = createTask(options.deploy ? 'dev' : 'watch', project, options.deploy ? 'dev' : 'watch', stop);
271
298
  const afterBuild = async (zipPath) => {
299
+ // Stopped mid-build: never sign or deploy what is left.
300
+ if (controller.signal.aborted)
301
+ return;
272
302
  let deployZip = zipPath;
273
303
  if (options.signingCredentials) {
274
304
  deployZip = await signOnce(project, task, log, zipPath, options.signingCredentials);
@@ -276,12 +306,15 @@ export function startDev(project, options) {
276
306
  if (options.deploy && options.deployConfig) {
277
307
  await deployOnce(project, task, log, deployZip, options.deployConfig, {
278
308
  force: true,
309
+ onAddonMissing: options.onAddonMissing,
279
310
  });
280
311
  }
281
312
  setPhase(task, 'watching');
282
313
  log('svc', 'watching for changes');
283
314
  };
284
315
  const fail = (error) => {
316
+ if (controller.signal.aborted)
317
+ return;
285
318
  log('svc', errorText(error), 'error');
286
319
  setPhase(task, 'error');
287
320
  };
@@ -296,12 +329,14 @@ export function startDev(project, options) {
296
329
  pending = false;
297
330
  try {
298
331
  // eslint-disable-next-line no-await-in-loop
299
- await afterBuild(await buildOnce(project, task, log, 'development'));
332
+ const zip = await buildOnce(project, task, log, 'development', controller.signal);
333
+ // eslint-disable-next-line no-await-in-loop
334
+ await afterBuild(zip);
300
335
  }
301
336
  catch (error) {
302
337
  fail(error);
303
338
  }
304
- } while (pending);
339
+ } while (pending && !controller.signal.aborted);
305
340
  }
306
341
  finally {
307
342
  building = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.23",
3
+ "version": "1.0.0-beta.25",
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 |