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 +27 -0
- package/dist/commands/deploy.js +2 -3
- package/dist/commands/dev.js +0 -12
- package/dist/shell/CommandPalette.js +1 -1
- package/dist/shell/Shell.js +14 -6
- package/dist/shell/Tabs.js +3 -3
- package/dist/shell/actions.d.ts +1 -0
- package/dist/shell/actions.js +0 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/i18n.js +9 -3
- package/dist/utils/process-runner.d.ts +13 -4
- package/dist/utils/process-runner.js +63 -37
- package/dist/utils/project-detection.d.ts +5 -0
- package/dist/utils/project-detection.js +14 -0
- package/dist/utils/sitevision-api.d.ts +8 -0
- package/dist/utils/sitevision-api.js +25 -4
- package/dist/utils/sitevision-scripts-runner.d.ts +2 -1
- package/dist/utils/sitevision-scripts-runner.js +18 -7
- package/dist/utils/tasks.d.ts +2 -0
- package/dist/utils/tasks.js +49 -14
- package/package.json +1 -1
- package/readme.md +2 -2
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);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
165
|
-
const zipPath = getZipPath(projectRoot, manifest);
|
|
164
|
+
const zipPath = getDeployZipPath(projectRoot, manifest);
|
|
166
165
|
if (!zipExists(zipPath)) {
|
|
167
166
|
setState({
|
|
168
167
|
status: 'error',
|
package/dist/commands/dev.js
CHANGED
|
@@ -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) {
|
package/dist/shell/Shell.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
['
|
|
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
|
-
['
|
|
472
|
-
['
|
|
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
|
]);
|
package/dist/shell/Tabs.js
CHANGED
|
@@ -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 ·
|
|
76
|
-
: t('{n} versions · a activate selected ·
|
|
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
|
}
|
package/dist/shell/actions.d.ts
CHANGED
|
@@ -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;
|
package/dist/shell/actions.js
CHANGED
|
Binary file
|
package/dist/types/index.d.ts
CHANGED
package/dist/utils/i18n.js
CHANGED
|
@@ -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 ·
|
|
72
|
-
'1 version · a activate selected ·
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
67
|
+
stdio: 'pipe',
|
|
26
68
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
this.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
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 {
|
|
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 =
|
|
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:
|
|
182
|
-
?
|
|
183
|
-
:
|
|
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
|
});
|
package/dist/utils/tasks.d.ts
CHANGED
|
@@ -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
|
package/dist/utils/tasks.js
CHANGED
|
@@ -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
|
|
153
|
-
?
|
|
154
|
-
:
|
|
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
|
|
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
|
-
:
|
|
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', () =>
|
|
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
|
-
|
|
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
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 `
|
|
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
|
-
| `
|
|
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 |
|