sitevision-cli 1.1.0-beta.0 → 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 +21 -0
- package/dist/cli.js +54 -10
- package/dist/commands/dev.js +1 -1
- package/dist/commands/index.js +4 -0
- package/dist/commands/login.js +112 -0
- package/dist/commands/logs.js +95 -0
- package/dist/utils/log-tail.js +180 -0
- package/dist/utils/sitevision-api.js +12 -15
- package/dist/utils/workspace.js +31 -1
- package/package.json +1 -1
- package/readme.md +13 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
3
24
|
## 1.1.0-beta.0
|
|
4
25
|
|
|
5
26
|
- Production deploys ask whether to activate the new version or only upload
|
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,6 +34,8 @@ 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
|
|
@@ -42,6 +45,8 @@ const cli = meow(`
|
|
|
42
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) {
|
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
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -86,6 +86,16 @@ function createMultipartFormData(filePath, fieldName, boundary) {
|
|
|
86
86
|
/**
|
|
87
87
|
* Make an HTTP/HTTPS request
|
|
88
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
|
+
}
|
|
89
99
|
export function makeRequest(url, options) {
|
|
90
100
|
return new Promise((resolve, reject) => {
|
|
91
101
|
const parsedUrl = new URL(url);
|
|
@@ -95,22 +105,9 @@ export function makeRequest(url, options) {
|
|
|
95
105
|
const isHttps = parsedUrl.protocol === 'https:';
|
|
96
106
|
const transport = isHttps ? https : http;
|
|
97
107
|
const headers = {
|
|
108
|
+
...(options.auth && authHeaders(options.auth)),
|
|
98
109
|
...options.headers,
|
|
99
110
|
};
|
|
100
|
-
if (options.auth) {
|
|
101
|
-
if ('cookie' in options.auth) {
|
|
102
|
-
headers['Cookie'] = options.auth.cookie;
|
|
103
|
-
// Session-authenticated state-changing calls typically need this to
|
|
104
|
-
// pass Sitevision's CSRF guard, unlike Basic-auth requests.
|
|
105
|
-
headers['X-Requested-With'] ??= 'XMLHttpRequest';
|
|
106
|
-
}
|
|
107
|
-
else if ('token' in options.auth) {
|
|
108
|
-
headers['Authorization'] = `Bearer ${options.auth.token}`;
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
headers['Authorization'] = createBasicAuth(options.auth.username, options.auth.password);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
111
|
const requestOptions = {
|
|
115
112
|
hostname: parsedUrl.hostname,
|
|
116
113
|
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
@@ -161,7 +158,7 @@ export function isRetryableStatus(statusCode) {
|
|
|
161
158
|
/**
|
|
162
159
|
* Sleep helper for backoff between retries.
|
|
163
160
|
*/
|
|
164
|
-
async function delay(ms) {
|
|
161
|
+
export async function delay(ms) {
|
|
165
162
|
return new Promise(resolve => {
|
|
166
163
|
setTimeout(resolve, ms);
|
|
167
164
|
});
|
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
|
@@ -83,18 +83,26 @@ 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
|