sitevision-cli 1.0.2 → 1.1.0-beta.1
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/CHANGELOG.md +34 -0
- package/dist/cli.js +55 -11
- package/dist/commands/dev.js +13 -1
- package/dist/commands/index.js +4 -0
- package/dist/commands/login.js +112 -0
- package/dist/commands/logs.js +95 -0
- package/dist/shell/ConfigForm.js +1 -1
- package/dist/shell/Help.js +1 -1
- package/dist/shell/Shell.js +13 -0
- package/dist/shell/actions.js +28 -12
- package/dist/utils/i18n.js +8 -4
- package/dist/utils/log-tail.js +180 -0
- package/dist/utils/sitevision-api.js +15 -51
- package/dist/utils/tasks.js +18 -14
- package/dist/utils/workspace.js +31 -1
- package/package.json +1 -1
- package/readme.md +16 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0-beta.1
|
|
4
|
+
|
|
5
|
+
Log streaming! Tired of opening the Sitevision admin log, waiting for something
|
|
6
|
+
to show up, and then realising it stopped polling halfway through your test
|
|
7
|
+
run? No more. `svc logs` streams the server log straight into your terminal and
|
|
8
|
+
stays there — Sitevision cuts the stream every five minutes or so, and svc just
|
|
9
|
+
reconnects and carries on. `--app` follows the app log instead. `Ctrl+C` when
|
|
10
|
+
you've seen enough.
|
|
11
|
+
|
|
12
|
+
And logging in! Session expired, log page turns into a login form, and until
|
|
13
|
+
now the way back in was... running a deploy. Now it's `svc login`: one site, one
|
|
14
|
+
credential, straight into the OS keychain. `basic` asks for the deploy
|
|
15
|
+
password, `oauth2` and `cookie` open the same browser login the shell runs on
|
|
16
|
+
`l`. A tail that hits a dead session tells you exactly that instead of retrying
|
|
17
|
+
into the void.
|
|
18
|
+
|
|
19
|
+
Both take `-e <name>` to aim at a configured environment, and both work at a
|
|
20
|
+
workspace root, not just inside an app — the log and the login belong to the
|
|
21
|
+
site, not to any one addon. Everything else still runs on the base
|
|
22
|
+
environment, and says so if you point `-e` at it.
|
|
23
|
+
|
|
24
|
+
## 1.1.0-beta.0
|
|
25
|
+
|
|
26
|
+
- Production deploys ask whether to activate the new version or only upload
|
|
27
|
+
it. Press `p` or `P` on a production environment and pick; Esc cancels.
|
|
28
|
+
- Production deploys no longer overwrite an existing version on their own.
|
|
29
|
+
`P` in the shell and `--force` on the command line do that, as on dev.
|
|
30
|
+
- `svc deploy --activate` works with any deploy, not only `--production`.
|
|
31
|
+
- Dev (`d`) runs on a production environment. It needs signing credentials
|
|
32
|
+
and asks for confirmation before it starts, then signs and deploys every
|
|
33
|
+
build. `svc dev` needs `--signed` on a production base and asks the same.
|
|
34
|
+
- The Production help text in the Config tab and both user guides describe
|
|
35
|
+
the new behaviour.
|
|
36
|
+
|
|
3
37
|
## 1.0.2
|
|
4
38
|
|
|
5
39
|
- Workspace settings can switch environment again. Press `v` in the settings
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,8 @@ import { killAllChildren } from './utils/process-runner.js';
|
|
|
11
11
|
import { Shell } from './shell/Shell.js';
|
|
12
12
|
import { getCommand } from './commands/index.js';
|
|
13
13
|
import { requireProject, detectProject, migrateLegacyPassword, } from './utils/project-detection.js';
|
|
14
|
-
import { discoverApps } from './utils/workspace.js';
|
|
14
|
+
import { discoverApps, workspaceProject } from './utils/workspace.js';
|
|
15
|
+
import { environmentNames, environmentProject } from './utils/environments.js';
|
|
15
16
|
import { promptYesNo } from './utils/password-prompt.js';
|
|
16
17
|
import { checkForUpdate } from './utils/version-check.js';
|
|
17
18
|
import { isFirstRun, markFirstRunComplete, getLastSeenVersion, setLastSeenVersion, getSettings, configProblem, settingsFile, } from './utils/config.js';
|
|
@@ -33,15 +34,19 @@ const cli = meow(`
|
|
|
33
34
|
sign Sign the app for production deployment
|
|
34
35
|
deploy Deploy the application
|
|
35
36
|
info Show project information
|
|
37
|
+
logs Tail the server log (--app for the app log)
|
|
38
|
+
login Log in to a site and save the credential
|
|
36
39
|
setup-signing Store the signing username and certificate
|
|
37
40
|
|
|
38
41
|
Options
|
|
39
42
|
--signed, -s dev/watch: sign after each build
|
|
40
43
|
--force, -f deploy: overwrite the existing addon version
|
|
41
44
|
--production, -p deploy: upload the signed zip to production
|
|
42
|
-
--activate, -a deploy: activate
|
|
45
|
+
--activate, -a deploy: activate the uploaded version
|
|
43
46
|
--no-zip build: skip the zip archive
|
|
44
47
|
--global setup-signing: save for every project on this machine
|
|
48
|
+
--app logs: tail the app log instead of the server log
|
|
49
|
+
--environment, -e logs/login: which configured environment to target
|
|
45
50
|
--minimal Shell: compact layout for small terminals
|
|
46
51
|
--debug Write a log of every action to debug.log (or SVC_DEBUG=1)
|
|
47
52
|
--help Show this help message
|
|
@@ -61,6 +66,11 @@ const cli = meow(`
|
|
|
61
66
|
$ svc deploy --force
|
|
62
67
|
$ svc deploy --production
|
|
63
68
|
$ svc info
|
|
69
|
+
$ svc logs
|
|
70
|
+
$ svc logs --app
|
|
71
|
+
$ svc logs -e prod
|
|
72
|
+
$ svc login
|
|
73
|
+
$ svc login -e prod
|
|
64
74
|
`, {
|
|
65
75
|
importMeta: import.meta,
|
|
66
76
|
allowUnknownFlags: false,
|
|
@@ -93,6 +103,14 @@ const cli = meow(`
|
|
|
93
103
|
type: 'boolean',
|
|
94
104
|
default: false,
|
|
95
105
|
},
|
|
106
|
+
app: {
|
|
107
|
+
type: 'boolean',
|
|
108
|
+
default: false,
|
|
109
|
+
},
|
|
110
|
+
environment: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
shortFlag: 'e',
|
|
113
|
+
},
|
|
96
114
|
global: {
|
|
97
115
|
type: 'boolean',
|
|
98
116
|
default: false,
|
|
@@ -183,6 +201,24 @@ async function playIntro(art) {
|
|
|
183
201
|
// Run the full-screen shell on the alternate screen buffer so the scrollback
|
|
184
202
|
// is untouched, and restore it on exit. The animated wordmark plays first,
|
|
185
203
|
// inside the same buffer, when the terminal is wide enough for it.
|
|
204
|
+
/**
|
|
205
|
+
* Apply -e/--environment. The flag is global because meow rejects unknown
|
|
206
|
+
* flags, so a command that ignores it has to say so instead of quietly
|
|
207
|
+
* running against the base environment.
|
|
208
|
+
*/
|
|
209
|
+
function withEnvironmentFlag(project, command) {
|
|
210
|
+
const name = cli.flags.environment;
|
|
211
|
+
if (!name)
|
|
212
|
+
return project;
|
|
213
|
+
if (!command.supportsEnvironment) {
|
|
214
|
+
return fail(`${commandName ?? 'this command'} does not support --environment`, 'Only svc logs and svc login target an environment; other commands use the base environment.');
|
|
215
|
+
}
|
|
216
|
+
const names = environmentNames(project.devProperties);
|
|
217
|
+
if (!names.includes(name)) {
|
|
218
|
+
return fail(`Unknown environment "${name}"`, `Configured environments: ${names.join(', ')}`);
|
|
219
|
+
}
|
|
220
|
+
return environmentProject(project, name);
|
|
221
|
+
}
|
|
186
222
|
async function runShell(apps, workspaceRoot, updatedFrom, skipped = []) {
|
|
187
223
|
process.stdout.write('\x1b[?1049h\x1b[H');
|
|
188
224
|
// Also leave the alternate screen when a signal exits past the finally.
|
|
@@ -273,6 +309,9 @@ async function main() {
|
|
|
273
309
|
// No command: the shell. Inside an app it is single-app mode; anywhere
|
|
274
310
|
// above one or more apps it is workspace mode.
|
|
275
311
|
if (!commandName) {
|
|
312
|
+
if (cli.flags.environment) {
|
|
313
|
+
fail('The shell does not take --environment', 'Start it with svc and press v to switch environment.');
|
|
314
|
+
}
|
|
276
315
|
let project = null;
|
|
277
316
|
try {
|
|
278
317
|
project = detectProject();
|
|
@@ -305,15 +344,6 @@ async function main() {
|
|
|
305
344
|
shutdown();
|
|
306
345
|
return;
|
|
307
346
|
}
|
|
308
|
-
// Check if we're in a Sitevision project
|
|
309
|
-
const project = (() => {
|
|
310
|
-
try {
|
|
311
|
-
return requireProject();
|
|
312
|
-
}
|
|
313
|
-
catch (error) {
|
|
314
|
-
return fail(error.message, "Make sure you're in a Sitevision project directory");
|
|
315
|
-
}
|
|
316
|
-
})();
|
|
317
347
|
// Get the command
|
|
318
348
|
const command = getCommand(commandName);
|
|
319
349
|
if (!command) {
|
|
@@ -321,6 +351,20 @@ async function main() {
|
|
|
321
351
|
render(_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", children: ["Unknown command: ", commandName] }), _jsxs(Text, { children: ["Run ", _jsx(Text, { bold: true, children: "svc --help" }), " to see available commands"] })] }));
|
|
322
352
|
process.exit(1);
|
|
323
353
|
}
|
|
354
|
+
// A command that talks to the site rather than to an app also runs at a
|
|
355
|
+
// workspace root, where the shared config supplies the domain and login.
|
|
356
|
+
const detected = (() => {
|
|
357
|
+
try {
|
|
358
|
+
if (!command.requiresProject) {
|
|
359
|
+
return detectProject() ?? workspaceProject(process.cwd());
|
|
360
|
+
}
|
|
361
|
+
return requireProject();
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
return fail(error.message, "Make sure you're in a Sitevision project directory");
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
const project = withEnvironmentFlag(detected, command);
|
|
324
368
|
// Offer to migrate a legacy plaintext password into the OS keychain.
|
|
325
369
|
// Skip on non-TTY stdin (e.g. CI) where prompting would fail — the
|
|
326
370
|
// plaintext password is still used for this run.
|
package/dist/commands/dev.js
CHANGED
|
@@ -88,7 +88,7 @@ export const devCommand = {
|
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
90
|
// The standalone command only prompts for a basic password; OAuth2/cookie
|
|
91
|
-
// logins are interactive and live in the shell (`svc`)
|
|
91
|
+
// logins are interactive and live in `svc login` or the shell (`svc`).
|
|
92
92
|
if ((dev.authMethod ?? 'basic') !== 'basic' &&
|
|
93
93
|
!dev.accessToken &&
|
|
94
94
|
!dev.sessionCookie) {
|
|
@@ -96,6 +96,18 @@ export const devCommand = {
|
|
|
96
96
|
process.exitCode = 1;
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
|
+
if (dev.production) {
|
|
100
|
+
if (!flags['signed']) {
|
|
101
|
+
say('\n\x1b[31mThe base environment is production, which only takes the signed zip. Run `svc dev --signed`.\x1b[0m\n');
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const go = await promptYesNo(`\x1b[31m${dev.domain} is PRODUCTION.\x1b[0m Dev will sign and deploy every build of ${project.manifest.id} there (addon ${dev.addonName}). Start it? (y/N): `);
|
|
106
|
+
if (!go) {
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
99
111
|
if (!(await resolveDeployPasswordForCli(dev)))
|
|
100
112
|
return;
|
|
101
113
|
let signingCredentials;
|
package/dist/commands/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import { deployCommand } from './deploy.js';
|
|
|
5
5
|
import { infoCommand } from './info.js';
|
|
6
6
|
import { setupSigningCommand } from './setup-signing.js';
|
|
7
7
|
import { signCommand } from './sign.js';
|
|
8
|
+
import { logsCommand } from './logs.js';
|
|
9
|
+
import { loginCommand } from './login.js';
|
|
8
10
|
export const commands = [
|
|
9
11
|
devCommand,
|
|
10
12
|
watchCommand,
|
|
@@ -12,6 +14,8 @@ export const commands = [
|
|
|
12
14
|
signCommand,
|
|
13
15
|
deployCommand,
|
|
14
16
|
infoCommand,
|
|
17
|
+
logsCommand,
|
|
18
|
+
loginCommand,
|
|
15
19
|
setupSigningCommand,
|
|
16
20
|
];
|
|
17
21
|
export function getCommand(name) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { render, Box, Text } from 'ink';
|
|
4
|
+
import { useFinish } from './use-finish.js';
|
|
5
|
+
import { AuthLoginScreen } from '../components/AuthLoginScreen.js';
|
|
6
|
+
import { promptPassword, promptYesNo } from '../utils/password-prompt.js';
|
|
7
|
+
import { onKeychainSaveFailed, setDeployPassword } from '../utils/keychain.js';
|
|
8
|
+
import { baseEnvironment } from '../utils/environments.js';
|
|
9
|
+
import { say } from '../utils/debug.js';
|
|
10
|
+
const YELLOW = '\x1b[33m';
|
|
11
|
+
const GREEN = '\x1b[32m';
|
|
12
|
+
const DIM = '\x1b[2m';
|
|
13
|
+
const RESET = '\x1b[0m';
|
|
14
|
+
/** The variable that carries this credential for one run, when nothing stuck. */
|
|
15
|
+
const envVarFor = (method) => method === 'oauth2' ? 'SITEVISION_ACCESS_TOKEN' : 'SITEVISION_SESSION_COOKIE';
|
|
16
|
+
/**
|
|
17
|
+
* What to report once a login has run. The browser flows store the credential
|
|
18
|
+
* themselves and do not return whether that worked, so a refused keychain is
|
|
19
|
+
* heard through `onKeychainSaveFailed` and must not be reported as saved.
|
|
20
|
+
*/
|
|
21
|
+
export function loginResultMessage(domain, method, stored) {
|
|
22
|
+
if (stored) {
|
|
23
|
+
return `✓ Logged in to ${domain}. The ${method} login is saved in the OS keychain.`;
|
|
24
|
+
}
|
|
25
|
+
return `Logged in to ${domain}, but the OS keychain would not store the ${method} login. Set ${envVarFor(method)} to carry it into the next run.`;
|
|
26
|
+
}
|
|
27
|
+
/** The browser login for `oauth2` and `cookie`, as a standalone command. */
|
|
28
|
+
function LoginScreen({ dev, method, }) {
|
|
29
|
+
const [result, setResult] = useState();
|
|
30
|
+
const [message, setMessage] = useState('');
|
|
31
|
+
// The keychain reports a refused save through this hook, which the auth
|
|
32
|
+
// helpers themselves swallow.
|
|
33
|
+
const stored = useRef(true);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
onKeychainSaveFailed(() => {
|
|
36
|
+
stored.current = false;
|
|
37
|
+
});
|
|
38
|
+
}, []);
|
|
39
|
+
useFinish(result);
|
|
40
|
+
if (result) {
|
|
41
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: result === 'success' ? 'green' : 'red', children: message }) }));
|
|
42
|
+
}
|
|
43
|
+
return (_jsx(AuthLoginScreen, { method: method, devProperties: dev, onComplete: () => {
|
|
44
|
+
setMessage(loginResultMessage(dev.domain, method, stored.current));
|
|
45
|
+
setResult(stored.current ? 'success' : 'error');
|
|
46
|
+
}, onError: error => {
|
|
47
|
+
setMessage(error);
|
|
48
|
+
setResult('error');
|
|
49
|
+
}, onCancel: () => {
|
|
50
|
+
setMessage('Cancelled.');
|
|
51
|
+
setResult('error');
|
|
52
|
+
} }));
|
|
53
|
+
}
|
|
54
|
+
export const loginCommand = {
|
|
55
|
+
name: 'login',
|
|
56
|
+
description: 'Log in to a site and save the credential',
|
|
57
|
+
// The login belongs to the site, so this also runs at a workspace root.
|
|
58
|
+
requiresProject: false,
|
|
59
|
+
supportsEnvironment: true,
|
|
60
|
+
async execute({ project }) {
|
|
61
|
+
const dev = project.devProperties;
|
|
62
|
+
if (!dev?.domain) {
|
|
63
|
+
say(`\n${YELLOW}Deploy config is missing "domain".${RESET}\n`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const method = dev.authMethod ?? 'basic';
|
|
68
|
+
const where = `${dev.domain} (${dev.environmentName ?? baseEnvironment(dev)})`;
|
|
69
|
+
// The keychain keys a password and a session cookie by username, and
|
|
70
|
+
// stores neither without one. Only oauth2, keyed by client id, can do
|
|
71
|
+
// without.
|
|
72
|
+
if (method !== 'oauth2' && !dev.username) {
|
|
73
|
+
say(`\n${YELLOW}Deploy config is missing "username", which ${method} needs to store the login.${RESET}\n`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (method === 'basic') {
|
|
78
|
+
if (!process.stdin.isTTY) {
|
|
79
|
+
say(`\n${YELLOW}No terminal to ask for a password. Set SITEVISION_DEPLOY_PASSWORD instead.${RESET}\n`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
say(`${DIM}Basic authentication for ${where}.${RESET}`);
|
|
84
|
+
const password = await promptPassword(`Password for ${dev.username}@${dev.domain}: `);
|
|
85
|
+
if (!password) {
|
|
86
|
+
say(`${YELLOW}No password given.${RESET}`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (await promptYesNo('Save it to the OS keychain? (Y/n): ', true)) {
|
|
91
|
+
if (setDeployPassword(dev.domain, dev.username, password)) {
|
|
92
|
+
say(`${GREEN}✓ Saved for ${dev.username}@${dev.domain}.${RESET}`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
say(`${YELLOW}The OS keychain would not store it. Set SITEVISION_DEPLOY_PASSWORD instead.${RESET}`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
say(`${DIM}Not saved. Pass it as SITEVISION_DEPLOY_PASSWORD for one run.${RESET}`);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!process.stdin.isTTY) {
|
|
105
|
+
say(`\n${YELLOW}A ${method} login needs a terminal and a browser. Set ${method === 'oauth2' ? 'SITEVISION_ACCESS_TOKEN' : 'SITEVISION_SESSION_COOKIE'} instead.${RESET}\n`);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
say(`${DIM}Logging in to ${where} with ${method}.${RESET}`);
|
|
110
|
+
await render(_jsx(LoginScreen, { dev: dev, method: method })).waitUntilExit();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { resolveDeployPasswordForCli } from './dev.js';
|
|
2
|
+
import { configAuth, delay } from '../utils/sitevision-api.js';
|
|
3
|
+
import { resolveOAuth2AccessToken } from '../utils/oauth2-auth.js';
|
|
4
|
+
import { LogTailAuthError, isFatalEnd, tailLog } from '../utils/log-tail.js';
|
|
5
|
+
import { say } from '../utils/debug.js';
|
|
6
|
+
import { baseEnvironment } from '../utils/environments.js';
|
|
7
|
+
const YELLOW = '\x1b[33m';
|
|
8
|
+
const RED = '\x1b[31m';
|
|
9
|
+
const DIM = '\x1b[2m';
|
|
10
|
+
const RESET = '\x1b[0m';
|
|
11
|
+
const RETRY_DELAY_MS = 3000;
|
|
12
|
+
export const logsCommand = {
|
|
13
|
+
name: 'logs',
|
|
14
|
+
description: 'Tail the server log (or the app log with --app)',
|
|
15
|
+
// The log belongs to the site, so this also runs at a workspace root.
|
|
16
|
+
requiresProject: false,
|
|
17
|
+
supportsEnvironment: true,
|
|
18
|
+
async execute({ project, flags }) {
|
|
19
|
+
const dev = project.devProperties;
|
|
20
|
+
if (!dev?.domain) {
|
|
21
|
+
say(`\n${YELLOW}Deploy config is missing "domain".${RESET}\n`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const method = dev.authMethod ?? 'basic';
|
|
26
|
+
// OAuth2 refreshes silently from the keychain, exactly as deploy does.
|
|
27
|
+
if (method === 'oauth2' && !dev.accessToken) {
|
|
28
|
+
dev.accessToken = (await resolveOAuth2AccessToken(dev)) ?? undefined;
|
|
29
|
+
}
|
|
30
|
+
if (method !== 'basic' && !dev.accessToken && !dev.sessionCookie) {
|
|
31
|
+
say(`\n${YELLOW}No ${method} credential for ${dev.domain}. Run svc login, or set ${method === 'oauth2' ? 'SITEVISION_ACCESS_TOKEN' : 'SITEVISION_SESSION_COOKIE'}.${RESET}\n`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!(await resolveDeployPasswordForCli(dev)))
|
|
36
|
+
return;
|
|
37
|
+
const app = Boolean(flags['app']);
|
|
38
|
+
const { auth, kind } = configAuth(dev);
|
|
39
|
+
const reLogin = kind === 'basic'
|
|
40
|
+
? 'Run svc login to store the password, and check that the user has the developer permission.'
|
|
41
|
+
: 'Run svc login to log in again.';
|
|
42
|
+
let connects = 0;
|
|
43
|
+
let nologStreak = 0;
|
|
44
|
+
const options = {
|
|
45
|
+
domain: dev.domain,
|
|
46
|
+
useHTTP: dev.useHTTPForDevDeploy,
|
|
47
|
+
auth,
|
|
48
|
+
app,
|
|
49
|
+
session: {},
|
|
50
|
+
onConnect() {
|
|
51
|
+
connects++;
|
|
52
|
+
if (connects === 1) {
|
|
53
|
+
say(`${DIM}connected, waiting for log lines${RESET}`);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
say(`${DIM}Tailing ${app ? 'app' : 'server'} log on ${dev.domain} (${dev.environmentName ?? baseEnvironment(dev)}, ${kind}). Ctrl+C to stop.${RESET}`);
|
|
58
|
+
while (true) {
|
|
59
|
+
try {
|
|
60
|
+
// eslint-disable-next-line no-await-in-loop
|
|
61
|
+
const end = await tailLog(options, line => {
|
|
62
|
+
say(line);
|
|
63
|
+
});
|
|
64
|
+
nologStreak = end === 'nolog' ? nologStreak + 1 : 0;
|
|
65
|
+
if (isFatalEnd(end, { connects, nologStreak })) {
|
|
66
|
+
say(`\n${RED}The server answered with something other than the log page.${RESET}`);
|
|
67
|
+
say(`${YELLOW}The ${kind === 'basic' ? 'credentials were' : `${kind} login has`} most likely expired or been rejected. ${reLogin}${RESET}`);
|
|
68
|
+
say(`${DIM}Run with --debug to see what the server sent.${RESET}\n`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
say(`${DIM}-- stream ${end}, reconnecting --${RESET}`);
|
|
73
|
+
// The server cuts a healthy tail every few minutes, and
|
|
74
|
+
// reconnecting at once is what keeps the log gapless. Any other
|
|
75
|
+
// ending may be failing immediately, so pace those instead of
|
|
76
|
+
// hammering the site.
|
|
77
|
+
if (end !== 'aborted') {
|
|
78
|
+
// eslint-disable-next-line no-await-in-loop
|
|
79
|
+
await delay(RETRY_DELAY_MS);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
say(`${RED}${message}${RESET}`);
|
|
85
|
+
if (error instanceof LogTailAuthError) {
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
say(`${DIM}-- retrying in 3s --${RESET}`);
|
|
90
|
+
// eslint-disable-next-line no-await-in-loop
|
|
91
|
+
await delay(RETRY_DELAY_MS);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
package/dist/shell/ConfigForm.js
CHANGED
|
@@ -109,7 +109,7 @@ const FIELDS = [
|
|
|
109
109
|
label: 'Production',
|
|
110
110
|
kind: 'bool',
|
|
111
111
|
section: 'ENVIRONMENT',
|
|
112
|
-
help: 'Treat
|
|
112
|
+
help: 'Treat this base environment as production: deploys use the signed zip, ask for confirmation and offer activation, and dev confirms before it starts. Off by default even when the name says prod.',
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
key: 'signingUsername',
|
package/dist/shell/Help.js
CHANGED
|
@@ -40,7 +40,7 @@ const GUIDE = {
|
|
|
40
40
|
title: 'Versions',
|
|
41
41
|
text: [
|
|
42
42
|
"The versions uploaded to the addon on the active environment's site, the domain in the top bar. v switches environment.",
|
|
43
|
-
'↑↓ select, a activates the selected version, r refreshes.
|
|
43
|
+
'↑↓ select, a activates the selected version, r refreshes. A production deploy asks whether to activate.',
|
|
44
44
|
],
|
|
45
45
|
},
|
|
46
46
|
log: {
|
package/dist/shell/Shell.js
CHANGED
|
@@ -422,6 +422,19 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
|
|
|
422
422
|
confirm: message => new Promise(resolve => {
|
|
423
423
|
setOverlay({ kind: 'confirm', message, resolve });
|
|
424
424
|
}),
|
|
425
|
+
async choose(label, choices) {
|
|
426
|
+
const picked = await new Promise(resolve => {
|
|
427
|
+
setOverlay({
|
|
428
|
+
kind: 'choice',
|
|
429
|
+
label,
|
|
430
|
+
choices,
|
|
431
|
+
multi: false,
|
|
432
|
+
initial: [0],
|
|
433
|
+
resolve,
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
return picked?.[0] ?? null;
|
|
437
|
+
},
|
|
425
438
|
openHelp() {
|
|
426
439
|
setOverlay({ kind: 'help' });
|
|
427
440
|
},
|
package/dist/shell/actions.js
CHANGED
|
@@ -144,21 +144,25 @@ const deployDetail = (p) => {
|
|
|
144
144
|
? `${env} · ${t('signed zip · confirms first')}`
|
|
145
145
|
: env;
|
|
146
146
|
};
|
|
147
|
-
/** Deploy to the active environment; production means signed
|
|
147
|
+
/** Deploy to the active environment; production means signed, and asks whether to activate. */
|
|
148
148
|
async function deployTo(ctx, force) {
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
let activate = false;
|
|
150
|
+
if (ctx.isProduction) {
|
|
151
|
+
const pick = await ctx.choose(t('Deploy the signed {id} to {env} (addon {addon})', {
|
|
151
152
|
id: ctx.project.manifest.id,
|
|
152
153
|
env: ctx.environment,
|
|
153
154
|
addon: ctx.project.devProperties?.addonName ?? '?',
|
|
154
|
-
}))))
|
|
155
|
-
|
|
155
|
+
}), [t('Deploy and activate'), t('Deploy only')]);
|
|
156
|
+
if (pick === null)
|
|
157
|
+
return;
|
|
158
|
+
activate = pick === 0;
|
|
156
159
|
}
|
|
157
160
|
const config = await resolveDeployConfig(ctx);
|
|
158
161
|
if (!config)
|
|
159
162
|
return;
|
|
160
163
|
startDeploy(ctx.project, config, {
|
|
161
|
-
|
|
164
|
+
force,
|
|
165
|
+
...(ctx.isProduction && { production: true, activate }),
|
|
162
166
|
onAddonMissing: askCreateAddon(ctx),
|
|
163
167
|
});
|
|
164
168
|
ctx.setTab('log');
|
|
@@ -166,10 +170,17 @@ async function deployTo(ctx, force) {
|
|
|
166
170
|
const hasSigning = (p) => Boolean(p.devProperties?.signingUsername);
|
|
167
171
|
async function startDevOrWatch(ctx, deploy) {
|
|
168
172
|
if (deploy && ctx.isProduction) {
|
|
169
|
-
ctx.
|
|
173
|
+
if (!hasSigning(ctx.project)) {
|
|
174
|
+
ctx.notify(t('Dev on {env} needs signing credentials: production only takes the signed zip. / sets them up.', { env: ctx.environment }), 'warn');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const go = await ctx.confirm(t('{env} is PRODUCTION. Dev will sign and deploy every build of {id} there (addon {addon}). Start it?', {
|
|
170
178
|
env: ctx.environment,
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
id: ctx.project.manifest.id,
|
|
180
|
+
addon: ctx.project.devProperties?.addonName ?? '?',
|
|
181
|
+
}));
|
|
182
|
+
if (!go)
|
|
183
|
+
return;
|
|
173
184
|
}
|
|
174
185
|
if (runningTasks(ctx.project.root).some(task => task.kind === 'dev' || task.kind === 'watch')) {
|
|
175
186
|
ctx.notify(t('A dev/watch task is already running for this app (K stops it).'), 'warn');
|
|
@@ -198,9 +209,14 @@ export const actions = [
|
|
|
198
209
|
key: 'd',
|
|
199
210
|
group: 'app',
|
|
200
211
|
label: 'Dev',
|
|
201
|
-
detail
|
|
202
|
-
|
|
203
|
-
|
|
212
|
+
detail(p) {
|
|
213
|
+
const base = hasSigning(p)
|
|
214
|
+
? t('build, sign and deploy on change')
|
|
215
|
+
: t('build and deploy on change');
|
|
216
|
+
return p.devProperties?.productionEnvironment
|
|
217
|
+
? `${base} · ${t('confirms first')}`
|
|
218
|
+
: base;
|
|
219
|
+
},
|
|
204
220
|
enabled: hasDev,
|
|
205
221
|
async run(ctx) {
|
|
206
222
|
await startDevOrWatch(ctx, true);
|
package/dist/utils/i18n.js
CHANGED
|
@@ -114,7 +114,7 @@ const sv = {
|
|
|
114
114
|
'Enter edits and saves a field, Esc cancels, ←→ or space cycles choices, Ctrl+O lists the addons on the site. y copies shared values into package.json for the team.': 'Enter redigerar och sparar ett fält, Esc avbryter, ←→ eller mellanslag bläddrar bland val, Ctrl+O listar webbplatsens tillägg. y kopierar delade värden till package.json för teamet.',
|
|
115
115
|
'Passwords and secrets go to the OS keychain, never to a file. Leave them empty to be asked on each run.': 'Lösenord och hemligheter sparas i nyckelringen, aldrig i en fil. Lämna dem tomma för att bli tillfrågad varje gång.',
|
|
116
116
|
"The versions uploaded to the addon on the active environment's site, the domain in the top bar. v switches environment.": 'Versionerna som laddats upp till tillägget på den aktiva miljöns webbplats, domänen i toppraden. v byter miljö.',
|
|
117
|
-
'↑↓ select, a activates the selected version, r refreshes.
|
|
117
|
+
'↑↓ select, a activates the selected version, r refreshes. A production deploy asks whether to activate.': '↑↓ väljer, a aktiverar vald version, r uppdaterar. En produktionsdriftsättning frågar om den ska aktivera.',
|
|
118
118
|
'Output from build, sign, deploy, dev and watch for the selected app. Dev and watch keep running while you switch apps; K stops them.': 'Utdata från build, sign, deploy, dev och watch för vald app. Dev och watch fortsätter köra medan du byter app; K stoppar dem.',
|
|
119
119
|
'↑↓ scroll, PgUp/PgDn page, f jumps to the end, x toggles line wrap.': '↑↓ scrollar, PgUp/PgDn bläddrar sida, f hoppar till slutet, x växlar radbrytning.',
|
|
120
120
|
"What's new": 'Nyheter',
|
|
@@ -129,8 +129,12 @@ const sv = {
|
|
|
129
129
|
'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?',
|
|
130
130
|
'e.g. test or prod, overriding domain and auth': 't.ex. test eller prod, med egen domän och auth',
|
|
131
131
|
'Environment name (e.g. test, prod)': 'Miljönamn (t.ex. test, prod)',
|
|
132
|
-
'Deploy the signed {id} to {env} (addon {addon})
|
|
133
|
-
'
|
|
132
|
+
'Deploy the signed {id} to {env} (addon {addon})': 'Driftsätt signerade {id} till {env} (addon {addon})',
|
|
133
|
+
'Deploy and activate': 'Driftsätt och aktivera',
|
|
134
|
+
'Deploy only': 'Bara driftsätt',
|
|
135
|
+
'Dev on {env} needs signing credentials: production only takes the signed zip. / sets them up.': 'Dev mot {env} kräver signeringsuppgifter: produktion tar bara den signerade zip-filen. / ställer in dem.',
|
|
136
|
+
'{env} is PRODUCTION. Dev will sign and deploy every build of {id} there (addon {addon}). Start it?': '{env} är PRODUKTION. Dev signerar och driftsätter varje bygge av {id} dit (addon {addon}). Starta?',
|
|
137
|
+
'confirms first': 'bekräftar först',
|
|
134
138
|
'switched to {env}': 'bytte till {env}',
|
|
135
139
|
'environment {env} added': 'miljön {env} tillagd',
|
|
136
140
|
environment: 'miljö',
|
|
@@ -245,7 +249,7 @@ const sv = {
|
|
|
245
249
|
'Environment name': 'Miljönamn',
|
|
246
250
|
Production: 'Produktion',
|
|
247
251
|
'What this base configuration is: dev, test, prod… Other environments are added on top of it with E or the palette and override only what differs.': 'Vad denna grundkonfiguration är: dev, test, prod… Andra miljöer läggs ovanpå med E eller paletten och skriver bara över det som skiljer.',
|
|
248
|
-
'Treat
|
|
252
|
+
'Treat this base environment as production: deploys use the signed zip, ask for confirmation and offer activation, and dev confirms before it starts. Off by default even when the name says prod.': 'Behandla grundmiljön som produktion: driftsättningar använder den signerade zip-filen, ber om bekräftelse och erbjuder aktivering, och dev bekräftar innan den startar. Av som standard även om namnet säger prod.',
|
|
249
253
|
'Saved {label}.': 'Sparade {label}.',
|
|
250
254
|
'Looking up OAuth2 endpoints…': 'Slår upp OAuth2-endpoints…',
|
|
251
255
|
'Endpoints filled from the site OpenID config.': 'Endpoints ifyllda från webbplatsens OpenID-konfig.',
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import https from 'https';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import zlib from 'zlib';
|
|
4
|
+
import { authHeaders } from './sitevision-api.js';
|
|
5
|
+
import { debug } from './debug.js';
|
|
6
|
+
const CONTAINER = '<div id="log-message-container">';
|
|
7
|
+
const ABORTED = 'Log tail aborted';
|
|
8
|
+
const ENTITIES = {
|
|
9
|
+
lt: '<',
|
|
10
|
+
gt: '>',
|
|
11
|
+
amp: '&',
|
|
12
|
+
quot: '"',
|
|
13
|
+
apos: "'",
|
|
14
|
+
nbsp: ' ',
|
|
15
|
+
};
|
|
16
|
+
const MAX_CODE_POINT = 0x10_ff_ff;
|
|
17
|
+
function decodeEntity(match, code) {
|
|
18
|
+
if (code.startsWith('#')) {
|
|
19
|
+
const n = /^#x/i.test(code)
|
|
20
|
+
? Number.parseInt(code.slice(2), 16)
|
|
21
|
+
: Number.parseInt(code.slice(1), 10);
|
|
22
|
+
// A log line is arbitrary text: an out-of-range escape must read
|
|
23
|
+
// literally, never throw and take the tail down with it.
|
|
24
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > MAX_CODE_POINT)
|
|
25
|
+
return match;
|
|
26
|
+
return String.fromCodePoint(n);
|
|
27
|
+
}
|
|
28
|
+
return ENTITIES[code.toLowerCase()] ?? match;
|
|
29
|
+
}
|
|
30
|
+
function htmlToText(html) {
|
|
31
|
+
const stripped = html
|
|
32
|
+
.replaceAll(/<br\s*\/?>/gi, '\n')
|
|
33
|
+
.replaceAll(/<[^<>]*>/g, '');
|
|
34
|
+
let out = '';
|
|
35
|
+
let last = 0;
|
|
36
|
+
for (const m of stripped.matchAll(/&(?<code>#x?[0-9a-f]+|[a-z]+);/gi)) {
|
|
37
|
+
out +=
|
|
38
|
+
stripped.slice(last, m.index) + decodeEntity(m[0], m.groups['code']);
|
|
39
|
+
last = m.index + m[0].length;
|
|
40
|
+
}
|
|
41
|
+
return (out + stripped.slice(last)).trim();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Incremental parser for the admin log tail stream. Feed it raw chunks; it
|
|
45
|
+
* emits one plain-text line per `<div>` inside the log container and reports
|
|
46
|
+
* when the server writes its "Log tail aborted" marker.
|
|
47
|
+
*/
|
|
48
|
+
export class LogTailParser {
|
|
49
|
+
buffer = '';
|
|
50
|
+
inContainer = false;
|
|
51
|
+
sawContainer = false;
|
|
52
|
+
bytes = 0;
|
|
53
|
+
head = '';
|
|
54
|
+
aborted = false;
|
|
55
|
+
onLine;
|
|
56
|
+
constructor(onLine) {
|
|
57
|
+
this.onLine = onLine;
|
|
58
|
+
}
|
|
59
|
+
push(chunk) {
|
|
60
|
+
this.bytes += chunk.length;
|
|
61
|
+
if (this.head.length < 200)
|
|
62
|
+
this.head += chunk.slice(0, 200);
|
|
63
|
+
this.buffer += chunk;
|
|
64
|
+
if (!this.inContainer) {
|
|
65
|
+
const i = this.buffer.indexOf(CONTAINER);
|
|
66
|
+
if (i === -1)
|
|
67
|
+
return;
|
|
68
|
+
this.buffer = this.buffer.slice(i + CONTAINER.length);
|
|
69
|
+
this.inContainer = true;
|
|
70
|
+
this.sawContainer = true;
|
|
71
|
+
}
|
|
72
|
+
for (;;) {
|
|
73
|
+
const end = this.buffer.indexOf('</div>');
|
|
74
|
+
if (end === -1)
|
|
75
|
+
break;
|
|
76
|
+
const line = htmlToText(this.buffer.slice(0, end));
|
|
77
|
+
this.buffer = this.buffer.slice(end + 6);
|
|
78
|
+
if (line)
|
|
79
|
+
this.onLine(line);
|
|
80
|
+
}
|
|
81
|
+
if (this.buffer.includes(ABORTED))
|
|
82
|
+
this.aborted = true;
|
|
83
|
+
}
|
|
84
|
+
/** What the response was, for the debug log when it was not a log page. */
|
|
85
|
+
describe() {
|
|
86
|
+
return `${this.bytes} bytes, log page ${this.sawContainer ? 'yes' : 'no'}: ${JSON.stringify(this.head.slice(0, 200))}`;
|
|
87
|
+
}
|
|
88
|
+
end() {
|
|
89
|
+
// A response that never reaches the log page is not a log stream at
|
|
90
|
+
// all. An expired session is served the login page with a 200 rather
|
|
91
|
+
// than a redirect, and a rejected token gets an empty 200; both land
|
|
92
|
+
// here, as does a connection cut before the page arrived.
|
|
93
|
+
if (!this.sawContainer)
|
|
94
|
+
return 'nolog';
|
|
95
|
+
return this.aborted ? 'aborted' : 'closed';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export class LogTailAuthError extends Error {
|
|
99
|
+
}
|
|
100
|
+
function connectOnce(options, onLine) {
|
|
101
|
+
const path = options.app ? '/admin-log-app/tail' : '/admin-log/tail';
|
|
102
|
+
const url = new URL(`${options.useHTTP ? 'http' : 'https'}://${options.domain}${path}`);
|
|
103
|
+
const transport = url.protocol === 'http:' ? http : https;
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const req = transport.get(url, {
|
|
106
|
+
headers: {
|
|
107
|
+
...authHeaders(options.auth),
|
|
108
|
+
...(options.session?.cookie && { Cookie: options.session.cookie }),
|
|
109
|
+
'Accept-Encoding': 'identity',
|
|
110
|
+
},
|
|
111
|
+
}, res => {
|
|
112
|
+
const status = res.statusCode ?? 0;
|
|
113
|
+
debug('log-tail', `GET ${path} -> ${status} ${res.headers['content-type'] ?? ''}`);
|
|
114
|
+
// The log paths answer a dead session or bad credentials with a
|
|
115
|
+
// redirect to the site login page, never a 401.
|
|
116
|
+
if (status >= 300 && status < 400) {
|
|
117
|
+
res.resume();
|
|
118
|
+
reject(new LogTailAuthError('Redirected to login. Wrong credentials or missing developer permission.'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (status !== 200) {
|
|
122
|
+
res.resume();
|
|
123
|
+
reject(new Error(`Log tail failed with HTTP ${status}`));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const setCookie = res.headers['set-cookie']
|
|
127
|
+
?.map(c => c.split(';')[0])
|
|
128
|
+
.find(c => c.startsWith('JSESSIONID='));
|
|
129
|
+
if (options.session && setCookie && !('cookie' in options.auth)) {
|
|
130
|
+
options.session.cookie = setCookie;
|
|
131
|
+
}
|
|
132
|
+
const encoding = res.headers['content-encoding'];
|
|
133
|
+
options.onConnect?.();
|
|
134
|
+
const parser = new LogTailParser(onLine);
|
|
135
|
+
const body = encoding === 'gzip' ? res.pipe(zlib.createGunzip()) : res;
|
|
136
|
+
body.setEncoding('utf8');
|
|
137
|
+
body.on('data', (chunk) => {
|
|
138
|
+
parser.push(chunk);
|
|
139
|
+
});
|
|
140
|
+
body.on('end', () => {
|
|
141
|
+
const end = parser.end();
|
|
142
|
+
debug('log-tail', end === 'nolog'
|
|
143
|
+
? `stream ${end} (${parser.describe()})`
|
|
144
|
+
: `stream ${end}`);
|
|
145
|
+
resolve(end);
|
|
146
|
+
});
|
|
147
|
+
body.on('error', reject);
|
|
148
|
+
res.on('error', reject);
|
|
149
|
+
});
|
|
150
|
+
req.on('error', reject);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Whether to give up on a stream that carried nothing. The first connect is
|
|
155
|
+
* the one that proves the credential. After that a single empty stream is a
|
|
156
|
+
* blip worth reconnecting through, but a second one in a row means the
|
|
157
|
+
* credential stopped working rather than the network hiccuping.
|
|
158
|
+
*/
|
|
159
|
+
export function isFatalEnd(end, { connects, nologStreak }) {
|
|
160
|
+
if (end !== 'nolog')
|
|
161
|
+
return false;
|
|
162
|
+
return connects <= 1 || nologStreak >= 2;
|
|
163
|
+
}
|
|
164
|
+
/** Stream one tail session; resolves when the server ends it. */
|
|
165
|
+
export async function tailLog(options, onLine) {
|
|
166
|
+
try {
|
|
167
|
+
return await connectOnce(options, onLine);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
// A reused session goes stale eventually, and a stale one is refused
|
|
171
|
+
// exactly like bad credentials. Drop it and try the configured
|
|
172
|
+
// credentials alone before calling this an auth failure.
|
|
173
|
+
if (error instanceof LogTailAuthError && options.session?.cookie) {
|
|
174
|
+
debug('log-tail', 'refused with a reused session; retrying without it');
|
|
175
|
+
options.session.cookie = undefined;
|
|
176
|
+
return connectOnce(options, onLine);
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles all API interactions with Sitevision servers:
|
|
5
5
|
* - Signing apps via developer.sitevision.se
|
|
6
|
-
* - Deploying
|
|
7
|
-
* - Deploying to production environments
|
|
6
|
+
* - Deploying apps
|
|
8
7
|
* - Creating addons
|
|
9
|
-
* - Activating
|
|
8
|
+
* - Activating uploaded versions
|
|
10
9
|
*/
|
|
11
10
|
import fs from 'fs';
|
|
12
11
|
import path from 'path';
|
|
@@ -87,6 +86,16 @@ function createMultipartFormData(filePath, fieldName, boundary) {
|
|
|
87
86
|
/**
|
|
88
87
|
* Make an HTTP/HTTPS request
|
|
89
88
|
*/
|
|
89
|
+
export function authHeaders(auth) {
|
|
90
|
+
if ('cookie' in auth) {
|
|
91
|
+
// Session-authenticated state-changing calls typically need this to
|
|
92
|
+
// pass Sitevision's CSRF guard, unlike Basic-auth requests.
|
|
93
|
+
return { Cookie: auth.cookie, 'X-Requested-With': 'XMLHttpRequest' };
|
|
94
|
+
}
|
|
95
|
+
if ('token' in auth)
|
|
96
|
+
return { Authorization: `Bearer ${auth.token}` };
|
|
97
|
+
return { Authorization: createBasicAuth(auth.username, auth.password) };
|
|
98
|
+
}
|
|
90
99
|
export function makeRequest(url, options) {
|
|
91
100
|
return new Promise((resolve, reject) => {
|
|
92
101
|
const parsedUrl = new URL(url);
|
|
@@ -96,22 +105,9 @@ export function makeRequest(url, options) {
|
|
|
96
105
|
const isHttps = parsedUrl.protocol === 'https:';
|
|
97
106
|
const transport = isHttps ? https : http;
|
|
98
107
|
const headers = {
|
|
108
|
+
...(options.auth && authHeaders(options.auth)),
|
|
99
109
|
...options.headers,
|
|
100
110
|
};
|
|
101
|
-
if (options.auth) {
|
|
102
|
-
if ('cookie' in options.auth) {
|
|
103
|
-
headers['Cookie'] = options.auth.cookie;
|
|
104
|
-
// Session-authenticated state-changing calls typically need this to
|
|
105
|
-
// pass Sitevision's CSRF guard, unlike Basic-auth requests.
|
|
106
|
-
headers['X-Requested-With'] ??= 'XMLHttpRequest';
|
|
107
|
-
}
|
|
108
|
-
else if ('token' in options.auth) {
|
|
109
|
-
headers['Authorization'] = `Bearer ${options.auth.token}`;
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
headers['Authorization'] = createBasicAuth(options.auth.username, options.auth.password);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
111
|
const requestOptions = {
|
|
116
112
|
hostname: parsedUrl.hostname,
|
|
117
113
|
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
@@ -162,7 +158,7 @@ export function isRetryableStatus(statusCode) {
|
|
|
162
158
|
/**
|
|
163
159
|
* Sleep helper for backoff between retries.
|
|
164
160
|
*/
|
|
165
|
-
async function delay(ms) {
|
|
161
|
+
export async function delay(ms) {
|
|
166
162
|
return new Promise(resolve => {
|
|
167
163
|
setTimeout(resolve, ms);
|
|
168
164
|
});
|
|
@@ -425,38 +421,6 @@ export async function deployApp(zipPath, config, appType, force = false) {
|
|
|
425
421
|
};
|
|
426
422
|
}
|
|
427
423
|
}
|
|
428
|
-
/**
|
|
429
|
-
* Deploy an app to production
|
|
430
|
-
*
|
|
431
|
-
* @param signedZipPath - Path to the SIGNED zip file (required for production)
|
|
432
|
-
* @param config - Production deployment configuration
|
|
433
|
-
* @param appType - The app type (web, widget, rest)
|
|
434
|
-
*/
|
|
435
|
-
export async function deployProduction(signedZipPath, config, appType) {
|
|
436
|
-
// Deploy the signed app
|
|
437
|
-
const deployResult = await deployApp(signedZipPath, config, appType, true);
|
|
438
|
-
if (!deployResult.success) {
|
|
439
|
-
return deployResult;
|
|
440
|
-
}
|
|
441
|
-
if (!config.activate)
|
|
442
|
-
return deployResult;
|
|
443
|
-
if (!deployResult.executableId) {
|
|
444
|
-
return {
|
|
445
|
-
...deployResult,
|
|
446
|
-
activated: false,
|
|
447
|
-
message: 'Deployed, but not activated: the server did not return an executable id.',
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
const activationResult = await activateApp(deployResult.executableId, config, appType);
|
|
451
|
-
return {
|
|
452
|
-
success: true,
|
|
453
|
-
executableId: deployResult.executableId,
|
|
454
|
-
activated: activationResult.success,
|
|
455
|
-
message: activationResult.success
|
|
456
|
-
? 'Deployed and activated successfully'
|
|
457
|
-
: `Deployed successfully but activation failed: ${activationResult.error}`,
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
424
|
// =============================================================================
|
|
461
425
|
// ADDON MANAGEMENT API
|
|
462
426
|
// =============================================================================
|
|
@@ -524,7 +488,7 @@ export async function createAddon(config, appType) {
|
|
|
524
488
|
}
|
|
525
489
|
}
|
|
526
490
|
/**
|
|
527
|
-
* Activate
|
|
491
|
+
* Activate an uploaded version
|
|
528
492
|
*
|
|
529
493
|
* @param executableId - The executable ID returned from deployment
|
|
530
494
|
* @param config - Deployment configuration
|
package/dist/utils/tasks.js
CHANGED
|
@@ -8,7 +8,7 @@ import { hasSitevisionScripts, runSitevisionScriptsBuild, getDelegatedZipPath, c
|
|
|
8
8
|
import { copyStaticToBuild, copySrcToBuild, cleanBuild, createBuildZip, formatFileSize, getZipSize, zipExists, } from './zip.js';
|
|
9
9
|
import { deleteSessionCookie } from './keychain.js';
|
|
10
10
|
import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, getDeployZipPath, localizedText, } from './project-detection.js';
|
|
11
|
-
import { signApp, deployApp,
|
|
11
|
+
import { signApp, deployApp, activateApp, createAddon, listAddons, classifyAddon, } from './sitevision-api.js';
|
|
12
12
|
import { ProcessRunner } from './process-runner.js';
|
|
13
13
|
import { debug } from './debug.js';
|
|
14
14
|
const MAX_LINES = 2000;
|
|
@@ -170,10 +170,8 @@ async function signOnce(project, task, log, zipPath, credentials) {
|
|
|
170
170
|
async function deployOnce(project, task, log, zipPath, config, options) {
|
|
171
171
|
setPhase(task, 'deploying');
|
|
172
172
|
const appType = getAppType(project.manifest);
|
|
173
|
-
log('dep', `POST multipart →
|
|
174
|
-
const upload = async () => options.
|
|
175
|
-
? deployProduction(zipPath, { ...config, activate: options.activate }, appType)
|
|
176
|
-
: deployApp(zipPath, config, appType, options.force);
|
|
173
|
+
log('dep', `POST multipart → import · ${config.addonName} · ${path.basename(zipPath)}`);
|
|
174
|
+
const upload = async () => deployApp(zipPath, config, appType, options.force);
|
|
177
175
|
let result = await upload();
|
|
178
176
|
if (!result.success && result.contextNodeMissing) {
|
|
179
177
|
const addon = classifyAddon(await listAddons(config), config.addonName);
|
|
@@ -202,12 +200,23 @@ async function deployOnce(project, task, log, zipPath, config, options) {
|
|
|
202
200
|
}
|
|
203
201
|
if (!result.success)
|
|
204
202
|
throw new Error(result.error ?? 'Deployment failed');
|
|
205
|
-
if (options.production && options.activate && !result.activated) {
|
|
206
|
-
throw new Error(result.message ?? 'Deployed, but not activated');
|
|
207
|
-
}
|
|
208
203
|
log('dep', `${result.message ?? 'deployed'}${result.executableId ? ` · exec ${result.executableId}` : ''}`, 'ok');
|
|
204
|
+
if (options.activate) {
|
|
205
|
+
if (!result.executableId) {
|
|
206
|
+
throw new Error('Deployed, but not activated: the server did not return an executable id.');
|
|
207
|
+
}
|
|
208
|
+
await activateOnce(task, log, config, appType, result.executableId, `exec ${result.executableId}`);
|
|
209
|
+
}
|
|
209
210
|
return result.executableId;
|
|
210
211
|
}
|
|
212
|
+
async function activateOnce(task, log, config, appType, executableId, versionLabel) {
|
|
213
|
+
setPhase(task, 'activating');
|
|
214
|
+
log('act', `PUT activateCustomModuleExecutable · ${versionLabel}`);
|
|
215
|
+
const result = await activateApp(executableId, config, appType);
|
|
216
|
+
if (!result.success)
|
|
217
|
+
throw new Error(result.error ?? 'Activation failed');
|
|
218
|
+
log('act', `${versionLabel} is now active`, 'ok');
|
|
219
|
+
}
|
|
211
220
|
// ---------------------------------------------------------------------------
|
|
212
221
|
// Tasks
|
|
213
222
|
// ---------------------------------------------------------------------------
|
|
@@ -271,12 +280,7 @@ export function startActivate(project, config, executableId, versionLabel) {
|
|
|
271
280
|
const { task, log, finish } = createTask('activate', project, `activate ${versionLabel}`);
|
|
272
281
|
void (async () => {
|
|
273
282
|
try {
|
|
274
|
-
|
|
275
|
-
log('act', `PUT activateCustomModuleExecutable · ${versionLabel}`);
|
|
276
|
-
const result = await activateApp(executableId, config, getAppType(project.manifest));
|
|
277
|
-
if (!result.success)
|
|
278
|
-
throw new Error(result.error ?? 'Activation failed');
|
|
279
|
-
log('act', `${versionLabel} is now active`, 'ok');
|
|
283
|
+
await activateOnce(task, log, config, getAppType(project.manifest), executableId, versionLabel);
|
|
280
284
|
finish('success');
|
|
281
285
|
}
|
|
282
286
|
catch (error) {
|
package/dist/utils/workspace.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { detectProject, readWorkspaceDevProperties, } from './project-detection.js';
|
|
3
|
+
import { detectProject, findDevPropertiesPath, getProjectPaths, readWorkspaceDevProperties, resolveRuntimeSecrets, } from './project-detection.js';
|
|
4
4
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
|
|
5
5
|
const MAX_DEPTH = 3;
|
|
6
6
|
/**
|
|
@@ -103,3 +103,33 @@ export function needsOnboarding(root, apps) {
|
|
|
103
103
|
configIncomplete(readWorkspaceDevProperties(root)) &&
|
|
104
104
|
apps.some(app => configIncomplete(app.devProperties)));
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* A stand-in project for a workspace root, so a command that talks to the site
|
|
108
|
+
* rather than to an app can run above the apps instead of inside one. It
|
|
109
|
+
* carries the shared dev properties and an empty manifest, so a command that
|
|
110
|
+
* sets `requiresProject: false` must not read the manifest.
|
|
111
|
+
*/
|
|
112
|
+
export function workspaceProject(root) {
|
|
113
|
+
const merged = readWorkspaceDevProperties(root);
|
|
114
|
+
// The shared file only resolves the password on its own; a saved session
|
|
115
|
+
// cookie or token has to be looked up the same way an app's config does,
|
|
116
|
+
// or a root run reports no credential when one is sitting in the keychain.
|
|
117
|
+
const fromFile = merged.password;
|
|
118
|
+
const devProperties = resolveRuntimeSecrets(merged);
|
|
119
|
+
devProperties.password ??= fromFile;
|
|
120
|
+
return {
|
|
121
|
+
root,
|
|
122
|
+
manifest: { id: '', name: '', version: '', type: 'WebApp' },
|
|
123
|
+
hasDevProperties: Boolean(devProperties.domain),
|
|
124
|
+
hasSigningProperties: false,
|
|
125
|
+
// A plaintext password in the shared file is still used, but migrating
|
|
126
|
+
// it belongs to the app flow that owns the file.
|
|
127
|
+
hasLegacyPassword: false,
|
|
128
|
+
devProperties,
|
|
129
|
+
inheritedKeys: [],
|
|
130
|
+
packageJson: {},
|
|
131
|
+
hasSitevisionScripts: false,
|
|
132
|
+
hasNodeModules: false,
|
|
133
|
+
paths: getProjectPaths(root, path.join(root, 'src', 'manifest.json'), findDevPropertiesPath(root)),
|
|
134
|
+
};
|
|
135
|
+
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -13,7 +13,7 @@ MCPServer) from a full-screen terminal shell or as plain commands.
|
|
|
13
13
|
works with SSO), or a captured browser session for SAML-only sites.
|
|
14
14
|
- **No secrets on disk.** Passwords, tokens and cookies live in the OS keychain.
|
|
15
15
|
- **Environments.** dev, test and prod in one config; production deploys use
|
|
16
|
-
the signed zip, confirm and activate.
|
|
16
|
+
the signed zip, confirm, and activate only if you say so.
|
|
17
17
|
- **Shared config in git.** Site and auth settings for the whole team live in
|
|
18
18
|
`package.json`, once at the repo root; your username stays in a local
|
|
19
19
|
`.dev_properties.json`. Compatible with plain sitevision-scripts.
|
|
@@ -46,7 +46,7 @@ svc
|
|
|
46
46
|
every change and deploy. Output is in the **Log** tab (`4`).
|
|
47
47
|
|
|
48
48
|
For production: switch environment with `v`, press `b` to build, `s` to sign
|
|
49
|
-
and `p` to deploy
|
|
49
|
+
and `p` to deploy. It asks whether to activate the new version or just upload it.
|
|
50
50
|
|
|
51
51
|
## The shell
|
|
52
52
|
|
|
@@ -79,22 +79,30 @@ svc # interactive shell
|
|
|
79
79
|
svc build [--no-zip] # build to dist/<id>.zip
|
|
80
80
|
svc sign # sign to dist/<id>-signed.zip
|
|
81
81
|
svc deploy [--force] # deploy the zip
|
|
82
|
-
svc deploy --production [--activate] #
|
|
82
|
+
svc deploy [--production] [--activate] # signed zip only / activate after
|
|
83
83
|
svc dev [--signed] # build + deploy on change
|
|
84
84
|
svc watch [--signed] # build on change, no deploy
|
|
85
85
|
svc info # project information
|
|
86
|
+
svc logs [--app] [-e <env>] # follow the server log (or the app log)
|
|
87
|
+
svc login [-e <env>] # log in to a site and save the credential
|
|
86
88
|
svc setup-signing [--global] # save the signing username and certificate
|
|
87
89
|
```
|
|
88
90
|
|
|
89
|
-
Short flags: `-f` force, `-p` production, `-a` activate, `-s` signed
|
|
90
|
-
flag is an error.
|
|
91
|
+
Short flags: `-f` force, `-p` production, `-a` activate, `-s` signed, `-e`
|
|
92
|
+
environment (`logs` and `login`). An unknown flag is an error.
|
|
91
93
|
|
|
92
|
-
Direct commands use the base environment
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
Direct commands use the base environment unless `-e` names another one, which
|
|
95
|
+
`svc logs` and `svc login` accept. Apart from `svc login`, which needs a
|
|
96
|
+
terminal and a browser, they run unattended and exit non-zero when a step
|
|
97
|
+
fails, so `svc build && svc sign && svc deploy -p -a` is safe in CI. `SITEVISION_DEPLOY_PASSWORD` and `SITEVISION_SIGNING_PASSWORD`
|
|
95
98
|
supply the passwords; `SITEVISION_ACCESS_TOKEN` and `SITEVISION_SESSION_COOKIE`
|
|
96
99
|
pass an OAuth2 token or session cookie for one run.
|
|
97
100
|
|
|
101
|
+
`svc logs` follows the same server log the admin interface shows, reconnecting
|
|
102
|
+
on its own when Sitevision cuts the stream after five minutes. The log belongs
|
|
103
|
+
to the site, so it runs at a workspace root too, not only inside an app, and so
|
|
104
|
+
does `svc login` when a session has expired.
|
|
105
|
+
|
|
98
106
|
## Configuration
|
|
99
107
|
|
|
100
108
|
`.dev_properties.json` is your local config and the main source; keep it out of
|