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 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 after a production deploy
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) {
@@ -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;
@@ -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
+ };
@@ -109,7 +109,7 @@ const FIELDS = [
109
109
  label: 'Production',
110
110
  kind: 'bool',
111
111
  section: 'ENVIRONMENT',
112
- help: 'Treat deploys to this base environment as production: signed zip, confirmation, activation, and no dev loop. Off by default even when the name says prod, so a repo with only a production site still gets a dev loop.',
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',
@@ -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. Production deploys activate on their own.',
43
+ '↑↓ select, a activates the selected version, r refreshes. A production deploy asks whether to activate.',
44
44
  ],
45
45
  },
46
46
  log: {
@@ -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
  },
@@ -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 + activate. */
147
+ /** Deploy to the active environment; production means signed, and asks whether to activate. */
148
148
  async function deployTo(ctx, force) {
149
- if (ctx.isProduction &&
150
- !(await ctx.confirm(t('Deploy the signed {id} to {env} (addon {addon}) and activate it?', {
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
- return;
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
- ...(ctx.isProduction ? { production: true, activate: true } : { force }),
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.notify(t('Dev never deploys to a production environment ({env}). Switch with v.', {
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
- }), 'warn');
172
- return;
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: p => hasSigning(p)
202
- ? t('build, sign and deploy on change')
203
- : t('build and deploy on change'),
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);
@@ -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. Production deploys activate on their own.': '↑↓ väljer, a aktiverar vald version, r uppdaterar. Produktionsdriftsättningar aktiverar själva.',
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}) and activate it?': 'Driftsätt signerade {id} till {env} (addon {addon}) och aktivera?',
133
- 'Dev never deploys to a production environment ({env}). Switch with v.': 'Dev driftsätter aldrig till en produktionsmiljö ({env}). Byt med v.',
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 deploys to this base environment as production: signed zip, confirmation, activation, and no dev loop. Off by default even when the name says prod, so a repo with only a production site still gets a dev loop.': 'Behandla driftsättningar till grundmiljön som produktion: signerad zip, bekräftelse, aktivering och ingen dev-loop. Av som standard även om namnet säger prod, så att ett repo med bara en produktionssajt ändå får en dev-loop.',
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 to development environments
7
- * - Deploying to production environments
6
+ * - Deploying apps
8
7
  * - Creating addons
9
- * - Activating production apps
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 a deployed production app
491
+ * Activate an uploaded version
528
492
  *
529
493
  * @param executableId - The executable ID returned from deployment
530
494
  * @param config - Deployment configuration
@@ -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, deployProduction, activateApp, createAddon, listAddons, classifyAddon, } from './sitevision-api.js';
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 → ${options.production ? 'production' : 'dev'} import · ${config.addonName} · ${path.basename(zipPath)}`);
174
- const upload = async () => options.production
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
- setPhase(task, 'activating');
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) {
@@ -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.0.2",
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
@@ -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 and activate.
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] # deploy the signed zip
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. 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