sitevision-cli 0.1.2 → 0.3.1-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/dist/app.js CHANGED
@@ -8,6 +8,7 @@ import { BuildScreen } from './commands/build.js';
8
8
  import { DeployScreen } from './commands/deploy.js';
9
9
  import { SignScreen } from './commands/sign.js';
10
10
  import { SigningPropertiesForm } from './components/SigningPropertiesForm.js';
11
+ import { setDeployPassword as saveDeployPassword, setSigningPassword as saveSigningPassword } from './utils/keychain.js';
11
12
  export default function App({ project }) {
12
13
  const [state, setState] = useState('setup');
13
14
  const [currentCommand, setCurrentCommand] = useState('');
@@ -23,8 +24,11 @@ export default function App({ project }) {
23
24
  return project.devProperties;
24
25
  return { ...project.devProperties, password: devPassword };
25
26
  };
26
- const handleDevPasswordSubmit = (password) => {
27
+ const handleDevPasswordSubmit = (password, remember) => {
27
28
  setDevPassword(password);
29
+ if (remember && project.devProperties?.domain && project.devProperties.username && password) {
30
+ saveDeployPassword(project.devProperties.domain, project.devProperties.username, password);
31
+ }
28
32
  // Continue to the intended command
29
33
  if (currentCommand === 'dev' || currentCommand === 'dev-signed') {
30
34
  if (currentCommand === 'dev-signed' && !signingPassword) {
@@ -38,8 +42,11 @@ export default function App({ project }) {
38
42
  setState('deploy');
39
43
  }
40
44
  };
41
- const handleSigningPasswordSubmit = (password) => {
45
+ const handleSigningPasswordSubmit = (password, remember) => {
42
46
  setSigningPassword(password);
47
+ if (remember && project.devProperties?.signingUsername && password) {
48
+ saveSigningPassword(project.devProperties.signingUsername, password);
49
+ }
43
50
  if (currentCommand === 'dev-signed') {
44
51
  setState('dev');
45
52
  }
@@ -133,14 +140,16 @@ export default function App({ project }) {
133
140
  return React.createElement(InfoScreen, { project: project, onBack: () => setState('menu') });
134
141
  }
135
142
  if (state === 'dev-password-input') {
136
- return (React.createElement(PasswordInput, { key: "dev-password", label: "Enter Development Password (usually Sitevision Cloud Password)", onSubmit: handleDevPasswordSubmit, onCancel: () => setState('menu') }));
143
+ return (React.createElement(PasswordInput, { key: "dev-password", label: "Enter Development Password (usually Sitevision Cloud Password)", showRememberOption: Boolean(project.devProperties?.domain && project.devProperties?.username), onSubmit: handleDevPasswordSubmit, onCancel: () => setState('menu') }));
137
144
  }
138
145
  if (state === 'signing-password-input') {
139
- return (React.createElement(PasswordInput, { key: "signing-password", label: "Enter Signing Password (developer.sitevision.se)", onSubmit: handleSigningPasswordSubmit, onCancel: () => setState('menu') }));
146
+ return (React.createElement(PasswordInput, { key: "signing-password", label: "Enter Signing Password (developer.sitevision.se)", showRememberOption: Boolean(project.devProperties?.signingUsername), onSubmit: handleSigningPasswordSubmit, onCancel: () => setState('menu') }));
140
147
  }
141
148
  if (state === 'dev') {
142
149
  return (React.createElement(DevScreen, { projectRoot: project.root, manifest: project.manifest, devProperties: getEffectiveDevProperties(), signed: currentCommand === 'dev-signed', onBack: () => setState('menu'), onRetryCredentials: () => {
143
150
  setDevPassword('');
151
+ if (project.devProperties)
152
+ project.devProperties.password = undefined;
144
153
  if (currentCommand === 'dev-signed') {
145
154
  setSigningPassword('');
146
155
  }
@@ -165,6 +174,8 @@ export default function App({ project }) {
165
174
  if (state === 'deploy') {
166
175
  return (React.createElement(DeployScreen, { projectRoot: project.root, manifest: project.manifest, devProperties: getEffectiveDevProperties(), force: currentCommand === 'deploy-force', production: currentCommand === 'deploy-production', activate: currentCommand === 'deploy-production', onBack: () => setState('menu'), onRetryCredentials: () => {
167
176
  setDevPassword('');
177
+ if (project.devProperties)
178
+ project.devProperties.password = undefined;
168
179
  setState('dev-password-input');
169
180
  } }));
170
181
  }
package/dist/cli.js CHANGED
@@ -4,13 +4,12 @@ import { render } from 'ink';
4
4
  import { Text, Box } from 'ink';
5
5
  import meow from 'meow';
6
6
  import { readFileSync } from 'node:fs';
7
- import updateNotifier from 'update-notifier';
8
7
  import App from './app.js';
9
8
  import { getCommand } from './commands/index.js';
10
- import { requireProject } from './utils/project-detection.js';
11
- // Check for updates
9
+ import { requireProject, migrateLegacyPassword } from './utils/project-detection.js';
10
+ import { promptYesNo } from './utils/password-prompt.js';
11
+ import { checkForUpdate } from './utils/version-check.js';
12
12
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
13
- updateNotifier({ pkg }).notify();
14
13
  const cli = meow(`
15
14
  Usage
16
15
  $ svc Start interactive menu
@@ -55,6 +54,12 @@ const cli = meow(`
55
54
  });
56
55
  const [commandName, ...args] = cli.input;
57
56
  async function main() {
57
+ // Show the CLI version on startup, and check npm for a newer release.
58
+ console.log(`\x1b[36msvc v${pkg.version}\x1b[0m`);
59
+ const latestVersion = await checkForUpdate(pkg.name, pkg.version);
60
+ if (latestVersion) {
61
+ console.log(`\x1b[33m ↑ update available: ${pkg.version} → ${latestVersion} (run: npm i -g ${pkg.name})\x1b[0m`);
62
+ }
58
63
  // Check if we're in a Sitevision project
59
64
  const project = (() => {
60
65
  try {
@@ -87,6 +92,22 @@ async function main() {
87
92
  " to see available commands")));
88
93
  process.exit(1);
89
94
  }
95
+ // Offer to migrate a legacy plaintext password into the OS keychain.
96
+ // Interactive `svc` handles this in SetupFlow; this covers direct commands
97
+ // (svc deploy/dev/sign/…). Skip on non-TTY stdin (e.g. CI) where prompting
98
+ // would fail — the plaintext password is still used for this run.
99
+ if (project.hasLegacyPassword && process.stdin.isTTY) {
100
+ console.log('\n\x1b[33m⚠ Plaintext password found in .dev_properties.json\x1b[0m');
101
+ const move = await promptYesNo('Move it to the OS keychain and remove it from the file? (y/N): ');
102
+ if (move) {
103
+ if (migrateLegacyPassword(project)) {
104
+ console.log('\x1b[32m✓ Password moved to keychain.\x1b[0m\n');
105
+ }
106
+ else {
107
+ console.log('\x1b[31mCould not access keychain; leaving the file unchanged.\x1b[0m\n');
108
+ }
109
+ }
110
+ }
90
111
  // Execute the command
91
112
  await command.execute({
92
113
  project,
@@ -4,6 +4,8 @@ import { StatusIndicator } from '../components/StatusIndicator.js';
4
4
  import { deployApp, deployProduction } from '../utils/sitevision-api.js';
5
5
  import { getZipPath, getSignedZipPath, getAppType, } from '../utils/project-detection.js';
6
6
  import { zipExists } from '../utils/zip.js';
7
+ import { promptPassword, promptYesNo } from '../utils/password-prompt.js';
8
+ import { setDeployPassword } from '../utils/keychain.js';
7
9
  export function DeployScreen({ projectRoot, manifest, devProperties, force, production, activate, signingPassword, onBack, onRetryCredentials, }) {
8
10
  const [state, setState] = React.useState({
9
11
  status: 'deploying',
@@ -144,9 +146,24 @@ export const deployCommand = {
144
146
  // Check if dev properties are configured
145
147
  if (!project.hasDevProperties || !project.devProperties) {
146
148
  console.log('\n\x1b[33mDeployment credentials not configured.\x1b[0m');
147
- console.log('Create a .dev_properties.json file with domain, siteName, addonName, username, and password.\n');
149
+ console.log('Create a .dev_properties.json file with domain, siteName, addonName, and username, then run setup.\n');
148
150
  return;
149
151
  }
152
+ // Resolve deploy password (already loaded from keychain/env in detectProject — prompt if missing)
153
+ if (!project.devProperties.password) {
154
+ const { domain, username } = project.devProperties;
155
+ console.log('');
156
+ const password = await promptPassword(`Deploy password for ${username}@${domain}: `);
157
+ if (!password) {
158
+ console.log('\x1b[31mError: Password is required\x1b[0m');
159
+ return;
160
+ }
161
+ const remember = await promptYesNo('Save password to OS keychain? (y/N): ');
162
+ if (remember && domain && username) {
163
+ setDeployPassword(domain, username, password);
164
+ }
165
+ project.devProperties.password = password;
166
+ }
150
167
  const production = Boolean(flags['production']);
151
168
  const force = Boolean(flags['force']);
152
169
  const activate = Boolean(flags['activate']);
@@ -2,8 +2,9 @@ import React from 'react';
2
2
  import { render, Box, Text, useApp, useInput } from 'ink';
3
3
  import { StatusIndicator } from '../components/StatusIndicator.js';
4
4
  import { WebpackRunner } from '../utils/webpack-runner.js';
5
- import { promptPassword } from '../utils/password-prompt.js';
5
+ import { promptPassword, promptYesNo } from '../utils/password-prompt.js';
6
6
  import { signApp, deployApp } from '../utils/sitevision-api.js';
7
+ import { getSigningPassword, setSigningPassword, setDeployPassword } from '../utils/keychain.js';
7
8
  import { copyStaticToBuild, createBuildZip, cleanBuild, } from '../utils/zip.js';
8
9
  import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, } from '../utils/project-detection.js';
9
10
  export function DevScreen({ projectRoot, manifest, devProperties, signed, signingCredentials, onBack, onRetryCredentials, }) {
@@ -261,26 +262,53 @@ export const devCommand = {
261
262
  // Check if dev properties are configured
262
263
  if (!project.hasDevProperties || !project.devProperties) {
263
264
  console.log('\n\x1b[33mDeployment credentials not configured.\x1b[0m');
264
- console.log('Create a .dev_properties.json file with domain, siteName, addonName, username, and password.\n');
265
+ console.log('Create a .dev_properties.json file with domain, siteName, addonName, and username, then run setup.\n');
265
266
  return;
266
267
  }
268
+ // Resolve deploy password (already loaded from keychain/env in detectProject — prompt if missing)
269
+ if (!project.devProperties.password) {
270
+ const { domain, username } = project.devProperties;
271
+ console.log('');
272
+ const pw = await promptPassword(`Deploy password for ${username}@${domain}: `);
273
+ if (!pw) {
274
+ console.log('\x1b[31mError: Password is required\x1b[0m');
275
+ return;
276
+ }
277
+ const remember = await promptYesNo('Save password to OS keychain? (y/N): ');
278
+ if (remember && domain && username) {
279
+ setDeployPassword(domain, username, pw);
280
+ }
281
+ project.devProperties.password = pw;
282
+ }
267
283
  const signed = Boolean(flags['signed']);
268
284
  let signingCredentials;
269
- // If signed mode, prompt for signing password
285
+ // If signed mode, resolve signing password (keychain → env → prompt)
270
286
  if (signed) {
271
287
  if (!project.hasSigningProperties || !project.devProperties.signingUsername) {
272
288
  console.log('\n\x1b[33mSigning credentials not configured.\x1b[0m');
273
289
  console.log('Run \x1b[36msetup-signing\x1b[0m to configure credentials.\n');
274
290
  return;
275
291
  }
276
- console.log('');
277
- const password = await promptPassword('Signing password (developer.sitevision.se): ');
292
+ const signingUsername = project.devProperties.signingUsername;
293
+ let password = getSigningPassword(signingUsername) || process.env['SITEVISION_SIGNING_PASSWORD'] || '';
294
+ let promptedManually = false;
295
+ if (!password) {
296
+ console.log('');
297
+ password = await promptPassword('Signing password (developer.sitevision.se): ');
298
+ promptedManually = true;
299
+ }
278
300
  if (!password) {
279
301
  console.log('\x1b[31mError: Password is required for signed mode\x1b[0m');
280
302
  return;
281
303
  }
304
+ if (promptedManually) {
305
+ const remember = await promptYesNo('Save password to OS keychain? (y/N): ');
306
+ if (remember) {
307
+ setSigningPassword(signingUsername, password);
308
+ }
309
+ }
282
310
  signingCredentials = {
283
- username: project.devProperties.signingUsername,
311
+ username: signingUsername,
284
312
  password,
285
313
  certificateName: project.devProperties.certificateName,
286
314
  };
@@ -64,8 +64,8 @@ export const setupSigningCommand = {
64
64
  certificateName = defaultCertName;
65
65
  }
66
66
  rl.close();
67
- // Update dev properties (remove any stored password from old config)
68
- const { signingPassword: _removed, ...cleanedProperties } = existingProperties;
67
+ // Update dev properties (strip any plaintext passwords they live in the keychain)
68
+ const { signingPassword: _signingRemoved, password: _passwordRemoved, ...cleanedProperties } = existingProperties;
69
69
  const updatedProperties = {
70
70
  ...cleanedProperties,
71
71
  signingUsername,
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { render, Box, Text, useInput } from 'ink';
3
3
  import { StatusIndicator } from '../components/StatusIndicator.js';
4
4
  import { signApp } from '../utils/sitevision-api.js';
5
- import { promptPassword } from '../utils/password-prompt.js';
5
+ import { promptPassword, promptYesNo } from '../utils/password-prompt.js';
6
+ import { getSigningPassword, setSigningPassword } from '../utils/keychain.js';
6
7
  import { getZipPath, getSignedZipPath, } from '../utils/project-detection.js';
7
8
  import { formatFileSize, getZipSize, zipExists } from '../utils/zip.js';
8
9
  export function SignScreen({ projectRoot, manifest, devProperties, password, onBack, onRetryCredentials }) {
@@ -90,13 +91,25 @@ export const signCommand = {
90
91
  console.log('Run \x1b[36msetup-signing\x1b[0m to configure credentials.\n');
91
92
  return;
92
93
  }
93
- // Prompt for password
94
- console.log('');
95
- const password = await promptPassword('Signing password (developer.sitevision.se: ');
94
+ const signingUsername = project.devProperties.signingUsername;
95
+ // Try keychain first, then env var, then prompt
96
+ let password = getSigningPassword(signingUsername) || process.env['SITEVISION_SIGNING_PASSWORD'] || '';
97
+ let promptedManually = false;
98
+ if (!password) {
99
+ console.log('');
100
+ password = await promptPassword('Signing password (developer.sitevision.se): ');
101
+ promptedManually = true;
102
+ }
96
103
  if (!password) {
97
104
  console.log('\x1b[31mError: Password is required\x1b[0m');
98
105
  return;
99
106
  }
107
+ if (promptedManually) {
108
+ const remember = await promptYesNo('Save password to OS keychain? (y/N): ');
109
+ if (remember) {
110
+ setSigningPassword(signingUsername, password);
111
+ }
112
+ }
100
113
  const { waitUntilExit } = render(React.createElement(SignScreen, { projectRoot: project.root, manifest: project.manifest, devProperties: project.devProperties, password: password }));
101
114
  await waitUntilExit();
102
115
  },
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { TextInput } from './TextInput.js';
4
4
  import { writeDevProperties } from '../utils/project-detection.js';
5
+ import { setDeployPassword, deleteDeployPassword } from '../utils/keychain.js';
5
6
  const STEPS = [
6
7
  { id: 'domain', label: 'Domain' },
7
8
  { id: 'siteName', label: 'Site Name' },
@@ -28,8 +29,16 @@ export function DevPropertiesForm({ projectRoot, initialProperties, packageJson,
28
29
  setStepIndex(stepIndex + 1);
29
30
  }
30
31
  else {
31
- // Save and finish
32
- writeDevProperties(projectRoot, newProperties);
32
+ const finalProperties = newProperties;
33
+ const { password, domain, username } = finalProperties;
34
+ if (password && domain && username) {
35
+ setDeployPassword(domain, username, password);
36
+ }
37
+ else if (domain && username) {
38
+ // Empty password — clear any stale keychain entry so deploy falls through to prompt
39
+ deleteDeployPassword(domain, username);
40
+ }
41
+ writeDevProperties(projectRoot, finalProperties);
33
42
  onComplete();
34
43
  }
35
44
  };
@@ -44,7 +53,7 @@ export function DevPropertiesForm({ projectRoot, initialProperties, packageJson,
44
53
  case 'username':
45
54
  return (React.createElement(TextInput, { key: "username", label: "Username (usually your Sitevision Cloud email)", defaultValue: properties.username, onSubmit: (value) => handleNext('username', value), onCancel: onCancel }));
46
55
  case 'password':
47
- return (React.createElement(TextInput, { key: "password", label: "Password (Optional - leave empty to prompt on each run)", type: "password", defaultValue: properties.password, onSubmit: (value) => handleNext('password', value), onCancel: onCancel }));
56
+ return (React.createElement(TextInput, { key: "password", label: "Password (saved in OS keychain \u2014 leave empty to prompt on each run)", type: "password", defaultValue: properties.password, onSubmit: (value) => handleNext('password', value), onCancel: onCancel }));
48
57
  case 'useHTTP':
49
58
  return (React.createElement(BooleanInput, { key: "useHTTP", label: "Use HTTP for deployment? (y/n)", defaultValue: properties.useHTTPForDevDeploy, onSubmit: (value) => handleNext('useHTTPForDevDeploy', value) }));
50
59
  default:
@@ -1,8 +1,9 @@
1
1
  import React from 'react';
2
2
  interface Props {
3
3
  label?: string;
4
- onSubmit: (password: string) => void;
4
+ showRememberOption?: boolean;
5
+ onSubmit: (password: string, remember: boolean) => void;
5
6
  onCancel: () => void;
6
7
  }
7
- export declare function PasswordInput({ label, onSubmit, onCancel }: Props): React.JSX.Element;
8
+ export declare function PasswordInput({ label, showRememberOption, onSubmit, onCancel }: Props): React.JSX.Element;
8
9
  export {};
@@ -1,16 +1,21 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
- export function PasswordInput({ label = 'Enter Signing Password', onSubmit, onCancel }) {
3
+ export function PasswordInput({ label = 'Enter Signing Password', showRememberOption = false, onSubmit, onCancel }) {
4
4
  const [password, setPassword] = useState('');
5
+ const [remember, setRemember] = useState(false);
5
6
  useInput((input, key) => {
6
7
  if (key.return) {
7
- onSubmit(password);
8
+ onSubmit(password, remember);
8
9
  return;
9
10
  }
10
11
  if (key.escape) {
11
12
  onCancel();
12
13
  return;
13
14
  }
15
+ if (key.tab && showRememberOption) {
16
+ setRemember((prev) => !prev);
17
+ return;
18
+ }
14
19
  if (key.delete || key.backspace) {
15
20
  setPassword((prev) => prev.slice(0, -1));
16
21
  return;
@@ -25,6 +30,13 @@ export function PasswordInput({ label = 'Enter Signing Password', onSubmit, onCa
25
30
  React.createElement(Text, { bold: true, color: "cyan" }, label)),
26
31
  React.createElement(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1 },
27
32
  React.createElement(Text, null, '*'.repeat(password.length))),
33
+ showRememberOption && (React.createElement(Box, { marginTop: 1 },
34
+ React.createElement(Text, { dimColor: true }, "Save to OS keychain: "),
35
+ React.createElement(Text, { color: remember ? 'green' : 'gray' },
36
+ "[",
37
+ remember ? 'x' : ' ',
38
+ "]"),
39
+ React.createElement(Text, { dimColor: true }, " (Tab to toggle)"))),
28
40
  React.createElement(Box, { marginTop: 1 },
29
41
  React.createElement(Text, { dimColor: true }, "Press Enter to submit, Esc to cancel"))));
30
42
  }
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
- import { getAppType } from '../utils/project-detection.js';
3
+ import { getAppType, migrateLegacyPassword } from '../utils/project-detection.js';
4
4
  import { ProcessRunner } from '../utils/process-runner.js';
5
5
  import { ProcessOutputComponent } from './ProcessOutput.js';
6
6
  import { StatusIndicator } from './StatusIndicator.js';
@@ -23,7 +23,12 @@ export function SetupFlow({ project, onComplete }) {
23
23
  }
24
24
  else if (step === 'check-dev-properties') {
25
25
  if (project.hasDevProperties) {
26
- setStep('check-signing-properties');
26
+ if (project.hasLegacyPassword) {
27
+ setStep('confirm-password-migration');
28
+ }
29
+ else {
30
+ setStep('check-signing-properties');
31
+ }
27
32
  }
28
33
  else {
29
34
  setStep('confirm-dev-setup');
@@ -75,6 +80,15 @@ export function SetupFlow({ project, onComplete }) {
75
80
  setStep('check-signing-properties');
76
81
  }
77
82
  }
83
+ else if (step === 'confirm-password-migration') {
84
+ if (input === 'y' || input === 'Y') {
85
+ migrateLegacyPassword(project);
86
+ setStep('check-signing-properties');
87
+ }
88
+ else if (input === 'n' || input === 'N') {
89
+ setStep('check-signing-properties');
90
+ }
91
+ }
78
92
  else if (step === 'confirm-signing-setup') {
79
93
  if (input === 'y' || input === 'Y') {
80
94
  setStep('setup-signing-properties');
@@ -128,6 +142,17 @@ export function SetupFlow({ project, onComplete }) {
128
142
  React.createElement(Box, { marginBottom: 1 },
129
143
  React.createElement(Text, null, "Would you like to set up dev properties? (y/n)"))));
130
144
  }
145
+ // Confirm legacy password migration
146
+ if (step === 'confirm-password-migration') {
147
+ return (React.createElement(Box, { flexDirection: "column", padding: 1 },
148
+ React.createElement(Box, { marginBottom: 1 },
149
+ React.createElement(Text, { bold: true, color: "cyan" }, "Sitevision CLI")),
150
+ React.createElement(Box, { marginBottom: 1 },
151
+ React.createElement(Text, { color: "yellow" }, "\u26A0 Plaintext password found in .dev_properties.json")),
152
+ React.createElement(Box, { marginBottom: 1, flexDirection: "column" },
153
+ React.createElement(Text, null, "Move it to the OS keychain and remove it from the file? (y/n)"),
154
+ React.createElement(Text, { dimColor: true }, "Recommended \u2014 storing passwords in project files is insecure."))));
155
+ }
131
156
  // Confirm signing setup
132
157
  if (step === 'confirm-signing-setup') {
133
158
  return (React.createElement(Box, { flexDirection: "column", padding: 1 },
@@ -27,14 +27,18 @@ export interface SitevisionManifest {
27
27
  categories?: string[];
28
28
  }
29
29
  /**
30
- * Development properties stored in .dev_properties.json
30
+ * Development properties.
31
+ *
32
+ * Persisted fields live in .dev_properties.json. `password` is runtime-only:
33
+ * resolved from the OS keychain (or a legacy plaintext file during migration)
34
+ * and never written back to disk.
31
35
  */
32
36
  export interface DevProperties {
33
37
  domain: string;
34
38
  siteName: string;
35
39
  addonName: string;
36
40
  username: string;
37
- password: string;
41
+ password?: string;
38
42
  useHTTPForDevDeploy?: boolean;
39
43
  signingUsername?: string;
40
44
  certificateName?: string;
@@ -80,6 +84,7 @@ export interface ProjectInfo {
80
84
  manifest: SitevisionManifest;
81
85
  hasDevProperties: boolean;
82
86
  hasSigningProperties: boolean;
87
+ hasLegacyPassword: boolean;
83
88
  devProperties?: DevProperties;
84
89
  packageJson: PackageJson;
85
90
  hasSitevisionScripts: boolean;
@@ -0,0 +1,6 @@
1
+ export declare function getDeployPassword(domain: string, username: string): string | null;
2
+ export declare function setDeployPassword(domain: string, username: string, password: string): boolean;
3
+ export declare function deleteDeployPassword(domain: string, username: string): void;
4
+ export declare function getSigningPassword(username: string): string | null;
5
+ export declare function setSigningPassword(username: string, password: string): boolean;
6
+ export declare function deleteSigningPassword(username: string): void;
@@ -0,0 +1,63 @@
1
+ import { Entry } from '@napi-rs/keyring';
2
+ const SERVICE = 'sitevision-cli';
3
+ function deployAccount(domain, username) {
4
+ return `deploy:${username}@${domain}`;
5
+ }
6
+ function signingAccount(username) {
7
+ return `signing:${username}`;
8
+ }
9
+ function safeGet(account) {
10
+ try {
11
+ return new Entry(SERVICE, account).getPassword();
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ function safeSet(account, password) {
18
+ try {
19
+ new Entry(SERVICE, account).setPassword(password);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ function safeDelete(account) {
27
+ try {
28
+ new Entry(SERVICE, account).deletePassword();
29
+ }
30
+ catch {
31
+ // ignore
32
+ }
33
+ }
34
+ export function getDeployPassword(domain, username) {
35
+ if (!domain || !username)
36
+ return null;
37
+ return safeGet(deployAccount(domain, username));
38
+ }
39
+ export function setDeployPassword(domain, username, password) {
40
+ if (!domain || !username || !password)
41
+ return false;
42
+ return safeSet(deployAccount(domain, username), password);
43
+ }
44
+ export function deleteDeployPassword(domain, username) {
45
+ if (!domain || !username)
46
+ return;
47
+ safeDelete(deployAccount(domain, username));
48
+ }
49
+ export function getSigningPassword(username) {
50
+ if (!username)
51
+ return null;
52
+ return safeGet(signingAccount(username));
53
+ }
54
+ export function setSigningPassword(username, password) {
55
+ if (!username || !password)
56
+ return false;
57
+ return safeSet(signingAccount(username), password);
58
+ }
59
+ export function deleteSigningPassword(username) {
60
+ if (!username)
61
+ return;
62
+ safeDelete(signingAccount(username));
63
+ }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Prompt for a yes/no answer. Returns true for y/Y, false otherwise (incl. empty / Enter).
3
+ */
4
+ export declare function promptYesNo(prompt: string): Promise<boolean>;
1
5
  /**
2
6
  * Prompt for password input with masked display
3
7
  */
@@ -1,3 +1,27 @@
1
+ /**
2
+ * Prompt for a yes/no answer. Returns true for y/Y, false otherwise (incl. empty / Enter).
3
+ */
4
+ export function promptYesNo(prompt) {
5
+ return new Promise((resolve) => {
6
+ process.stdout.write(prompt);
7
+ const stdin = process.stdin;
8
+ stdin.setRawMode(true);
9
+ stdin.resume();
10
+ stdin.setEncoding('utf8');
11
+ const onData = (data) => {
12
+ const char = data[0] || '';
13
+ stdin.setRawMode(false);
14
+ stdin.removeListener('data', onData);
15
+ stdin.pause();
16
+ process.stdout.write(`${char}\n`);
17
+ if (char.charCodeAt(0) === 3) {
18
+ process.exit();
19
+ }
20
+ resolve(char === 'y' || char === 'Y');
21
+ };
22
+ stdin.on('data', onData);
23
+ });
24
+ }
1
25
  /**
2
26
  * Prompt for password input with masked display
3
27
  */
@@ -1,19 +1,5 @@
1
- import type { SitevisionManifest, DevProperties, ProjectPaths, SimpleAppType, PackageJson, ApiEndpoints } from '../types/index.js';
2
- export type { SitevisionManifest, DevProperties } from '../types/index.js';
3
- /**
4
- * Project information with paths
5
- */
6
- export interface ProjectInfo {
7
- root: string;
8
- manifest: SitevisionManifest;
9
- hasDevProperties: boolean;
10
- hasSigningProperties: boolean;
11
- devProperties?: DevProperties;
12
- packageJson: PackageJson;
13
- hasSitevisionScripts: boolean;
14
- hasNodeModules: boolean;
15
- paths: ProjectPaths;
16
- }
1
+ import type { SitevisionManifest, DevProperties, ProjectInfo, ProjectPaths, SimpleAppType, ApiEndpoints } from '../types/index.js';
2
+ export type { SitevisionManifest, DevProperties, ProjectInfo } from '../types/index.js';
17
3
  /**
18
4
  * Get standard project paths for a given root directory
19
5
  */
@@ -86,9 +72,18 @@ export declare function isBundledApp(manifest: SitevisionManifest): boolean;
86
72
  */
87
73
  export declare function readDevProperties(projectRoot: string): DevProperties | null;
88
74
  /**
89
- * Write dev properties to file
75
+ * Write dev properties to file. The `password` field is never persisted —
76
+ * it is held in the OS keychain instead.
90
77
  */
91
78
  export declare function writeDevProperties(projectRoot: string, properties: DevProperties): void;
79
+ /**
80
+ * Move a plaintext password from .dev_properties.json into the OS keychain and
81
+ * strip it from the file. Returns true if the password was migrated.
82
+ *
83
+ * The in-memory `project.devProperties.password` is intentionally left intact
84
+ * so the current invocation can keep using it; only the on-disk copy is removed.
85
+ */
86
+ export declare function migrateLegacyPassword(project: ProjectInfo): boolean;
92
87
  /**
93
88
  * Ensure the dist directory exists
94
89
  */
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { getDeployPassword, setDeployPassword } from './keychain.js';
3
4
  // =============================================================================
4
5
  // PATH UTILITIES
5
6
  // =============================================================================
@@ -173,10 +174,26 @@ export function detectProject(cwd = process.cwd()) {
173
174
  const devPropertiesPath = findDevPropertiesPath(cwd);
174
175
  let devProperties;
175
176
  let hasDevProperties = false;
177
+ let hasLegacyPassword = false;
176
178
  if (devPropertiesPath) {
177
179
  hasDevProperties = true;
178
180
  try {
179
- devProperties = JSON.parse(fs.readFileSync(devPropertiesPath, 'utf-8'));
181
+ const parsed = JSON.parse(fs.readFileSync(devPropertiesPath, 'utf-8'));
182
+ hasLegacyPassword = typeof parsed.password === 'string' && parsed.password.length > 0;
183
+ devProperties = parsed;
184
+ // Resolve deploy password: env var > keychain (file is legacy-only)
185
+ if (!hasLegacyPassword && devProperties.domain && devProperties.username) {
186
+ const envPassword = process.env['SITEVISION_DEPLOY_PASSWORD'];
187
+ if (envPassword) {
188
+ devProperties.password = envPassword;
189
+ }
190
+ else {
191
+ const stored = getDeployPassword(devProperties.domain, devProperties.username);
192
+ if (stored) {
193
+ devProperties.password = stored;
194
+ }
195
+ }
196
+ }
180
197
  }
181
198
  catch {
182
199
  // Invalid dev properties file
@@ -191,6 +208,7 @@ export function detectProject(cwd = process.cwd()) {
191
208
  manifest,
192
209
  hasDevProperties,
193
210
  hasSigningProperties,
211
+ hasLegacyPassword,
194
212
  devProperties,
195
213
  packageJson,
196
214
  hasSitevisionScripts,
@@ -250,11 +268,33 @@ export function readDevProperties(projectRoot) {
250
268
  }
251
269
  }
252
270
  /**
253
- * Write dev properties to file
271
+ * Write dev properties to file. The `password` field is never persisted —
272
+ * it is held in the OS keychain instead.
254
273
  */
255
274
  export function writeDevProperties(projectRoot, properties) {
256
275
  const devPropertiesPath = findDevPropertiesPath(projectRoot) || getDefaultDevPropertiesPath(projectRoot);
257
- fs.writeFileSync(devPropertiesPath, JSON.stringify(properties, null, 2));
276
+ const { password: _password, ...persisted } = properties;
277
+ fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
278
+ }
279
+ /**
280
+ * Move a plaintext password from .dev_properties.json into the OS keychain and
281
+ * strip it from the file. Returns true if the password was migrated.
282
+ *
283
+ * The in-memory `project.devProperties.password` is intentionally left intact
284
+ * so the current invocation can keep using it; only the on-disk copy is removed.
285
+ */
286
+ export function migrateLegacyPassword(project) {
287
+ if (!project.hasLegacyPassword || !project.devProperties)
288
+ return false;
289
+ const { domain, username, password } = project.devProperties;
290
+ if (!domain || !username || !password)
291
+ return false;
292
+ if (!setDeployPassword(domain, username, password))
293
+ return false;
294
+ // writeDevProperties strips `password` defensively; keep the in-memory value.
295
+ writeDevProperties(project.root, project.devProperties);
296
+ project.hasLegacyPassword = false;
297
+ return true;
258
298
  }
259
299
  /**
260
300
  * Ensure the dist directory exists
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Check the npm registry for a newer published version of `packageName`.
3
+ *
4
+ * Resolves to the latest version string when it is newer than
5
+ * `currentVersion`, otherwise `null`. Never rejects — network errors,
6
+ * timeouts, non-200 responses and parse failures all resolve to `null`, so a
7
+ * failed check never blocks startup or surfaces an error to the user.
8
+ */
9
+ export declare function checkForUpdate(packageName: string, currentVersion: string, timeoutMs?: number): Promise<string | null>;
@@ -0,0 +1,60 @@
1
+ import https from 'node:https';
2
+ /**
3
+ * True if version `a` is strictly greater than `b`, comparing the numeric
4
+ * MAJOR.MINOR.PATCH core. Pre-release suffixes (e.g. `-beta.1`) are ignored.
5
+ */
6
+ function isNewer(a, b) {
7
+ const core = (v) => (v.split('-')[0] ?? '').split('.');
8
+ const pa = core(a);
9
+ const pb = core(b);
10
+ for (let i = 0; i < 3; i++) {
11
+ const da = Number.parseInt(pa[i] ?? '0', 10) || 0;
12
+ const db = Number.parseInt(pb[i] ?? '0', 10) || 0;
13
+ if (da > db)
14
+ return true;
15
+ if (da < db)
16
+ return false;
17
+ }
18
+ return false;
19
+ }
20
+ /**
21
+ * Check the npm registry for a newer published version of `packageName`.
22
+ *
23
+ * Resolves to the latest version string when it is newer than
24
+ * `currentVersion`, otherwise `null`. Never rejects — network errors,
25
+ * timeouts, non-200 responses and parse failures all resolve to `null`, so a
26
+ * failed check never blocks startup or surfaces an error to the user.
27
+ */
28
+ export function checkForUpdate(packageName, currentVersion, timeoutMs = 1500) {
29
+ return new Promise((resolve) => {
30
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
31
+ const request = https.get(url, { timeout: timeoutMs, headers: { accept: 'application/json' } }, (response) => {
32
+ if (response.statusCode !== 200) {
33
+ response.resume();
34
+ resolve(null);
35
+ return;
36
+ }
37
+ let body = '';
38
+ response.setEncoding('utf8');
39
+ response.on('data', (chunk) => {
40
+ body += chunk;
41
+ });
42
+ response.on('end', () => {
43
+ try {
44
+ const latest = JSON.parse(body).version;
45
+ resolve(latest && isNewer(latest, currentVersion) ? latest : null);
46
+ }
47
+ catch {
48
+ resolve(null);
49
+ }
50
+ });
51
+ });
52
+ request.on('timeout', () => {
53
+ request.destroy();
54
+ resolve(null);
55
+ });
56
+ request.on('error', () => {
57
+ resolve(null);
58
+ });
59
+ });
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.1-beta.1",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"
@@ -12,23 +12,24 @@
12
12
  "scripts": {
13
13
  "build": "tsc",
14
14
  "dev": "tsc --watch",
15
- "test": "prettier --check . && xo && ava"
15
+ "test": "prettier --check . && xo && ava",
16
+ "release": "./scripts/publish.sh",
17
+ "release:beta": "./scripts/publish-beta.sh"
16
18
  },
17
19
  "files": [
18
20
  "dist"
19
21
  ],
20
22
  "dependencies": {
23
+ "@napi-rs/keyring": "^1.3.0",
21
24
  "ink": "^6.6.0",
22
25
  "ink-spinner": "^5.0.0",
23
26
  "meow": "^11.0.0",
24
- "react": "^19.2.3",
25
- "update-notifier": "^7.0.0"
27
+ "react": "^19.2.3"
26
28
  },
27
29
  "devDependencies": {
28
30
  "@sindresorhus/tsconfig": "^3.0.1",
29
31
  "@types/node": "^25.0.3",
30
32
  "@types/react": "^19.2.7",
31
- "@types/update-notifier": "^6.0.8",
32
33
  "@vdemedes/prettier-config": "^2.0.1",
33
34
  "ava": "^5.2.0",
34
35
  "chalk": "^5.2.0",
@@ -58,4 +59,4 @@
58
59
  }
59
60
  },
60
61
  "prettier": "@vdemedes/prettier-config"
61
- }
62
+ }
package/readme.md CHANGED
@@ -12,7 +12,7 @@ However, these scripts have some limitations:
12
12
  - **Project Detection** - Automatically detects Sitevision projects
13
13
  - **Two Modes** - Interactive menu OR direct command execution
14
14
  - **Automatic Setup** - Guided setup for dev properties and signing credentials
15
- - **Secure Credentials** - Passwords can be entered per-session (not stored on disk)
15
+ - **Secure Credentials** - Passwords live in the OS keychain (macOS Keychain / Windows Credential Manager / Linux libsecret), never on disk
16
16
 
17
17
  ## Install
18
18
 
@@ -117,14 +117,29 @@ Create this file in your project root for deployment configuration:
117
117
  "siteName": "YourSite",
118
118
  "addonName": "your-addon",
119
119
  "username": "your-email@example.com",
120
- "password": "",
121
120
  "useHTTPForDevDeploy": false,
122
121
  "signingUsername": "your-developer-account@example.com",
123
122
  "certificateName": "optional-certificate-name"
124
123
  }
125
124
  ```
126
125
 
127
- **Note:** You can leave `password` empty - the CLI will prompt for it securely at runtime and store it in session memory only.
126
+ ### Password storage
127
+
128
+ Passwords are stored in the OS-native secret store (macOS Keychain, Windows
129
+ Credential Manager, Linux libsecret) under the `sitevision-cli` service —
130
+ never in `.dev_properties.json`. Run `svc` and complete the setup form (or
131
+ enter the password when prompted at deploy/sign time and toggle "save to
132
+ keychain") to populate it.
133
+
134
+ If an existing `.dev_properties.json` contains a plaintext `password` field,
135
+ the CLI offers to migrate it to the keychain on next launch and strip the
136
+ field from the file. The migration prompt only appears in interactive mode
137
+ (plain `svc`) — if you only ever invoke commands directly (`svc deploy`,
138
+ `svc dev`), run `svc` once to migrate.
139
+
140
+ For CI / headless use, set `SITEVISION_DEPLOY_PASSWORD` and/or
141
+ `SITEVISION_SIGNING_PASSWORD` — these take precedence over the keychain and
142
+ are never written anywhere.
128
143
 
129
144
  ### Signing Credentials
130
145
 
@@ -132,7 +147,8 @@ Signing credentials are used to sign apps via developer.sitevision.se:
132
147
  - `signingUsername` - Your developer.sitevision.se account
133
148
  - `certificateName` - Optional, if you have multiple certificates
134
149
 
135
- The signing password is never stored on disk - it's prompted for each session.
150
+ The signing password is prompted on first use, with an option to save it to
151
+ the OS keychain for future runs.
136
152
 
137
153
  ## License
138
154