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 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.
@@ -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`) or `svc deploy`.
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) {
@@ -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
  });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.1.0-beta.0",
3
+ "version": "1.1.0-beta.1",
4
4
  "description": "Build, sign and deploy Sitevision apps from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "Rasmus Söderström",
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. An unknown
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, run without a terminal, and exit
93
- non-zero when a step fails, so `svc build && svc sign && svc deploy -p -a` is
94
- safe in CI. `SITEVISION_DEPLOY_PASSWORD` and `SITEVISION_SIGNING_PASSWORD`
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