sitevision-cli 1.0.0-beta.3 → 1.0.0-beta.5

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.d.ts CHANGED
@@ -2,5 +2,5 @@ import { type ProjectInfo } from './utils/project-detection.js';
2
2
  type Props = {
3
3
  project: ProjectInfo;
4
4
  };
5
- export default function App({ project }: Props): import("react").JSX.Element | null;
5
+ export default function App({ project: initialProject }: Props): import("react").JSX.Element | null;
6
6
  export {};
package/dist/app.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useMemo, useState } from 'react';
2
+ import { useCallback, useMemo, useState } from 'react';
3
+ import { detectProject } from './utils/project-detection.js';
3
4
  import { MainMenu } from './components/MainMenu.js';
4
5
  import { InfoScreen } from './components/InfoScreen.js';
5
6
  import { SetupFlow } from './components/SetupFlow.js';
@@ -12,7 +13,22 @@ import { DeployScreen } from './commands/deploy.js';
12
13
  import { SignScreen } from './commands/sign.js';
13
14
  import { SigningPropertiesForm } from './components/SigningPropertiesForm.js';
14
15
  import { getSigningPassword, setDeployPassword as saveDeployPassword, setSigningPassword as saveSigningPassword, } from './utils/keychain.js';
15
- export default function App({ project }) {
16
+ export default function App({ project: initialProject }) {
17
+ // The project is loaded once at startup, but setup flows write new values to
18
+ // disk and the OS keychain. Hold it in state so we can re-detect after setup
19
+ // and pick up those changes (e.g. saved passwords) without restarting the CLI.
20
+ const [project, setProject] = useState(initialProject);
21
+ const reloadProject = useCallback(() => {
22
+ try {
23
+ const refreshed = detectProject(initialProject.root);
24
+ if (refreshed)
25
+ setProject(refreshed);
26
+ }
27
+ catch {
28
+ // Re-detection failed (e.g. manifest became unparseable mid-session) —
29
+ // keep the existing in-memory project rather than crashing.
30
+ }
31
+ }, [initialProject.root]);
16
32
  const [state, setState] = useState('setup');
17
33
  const [currentCommand, setCurrentCommand] = useState('');
18
34
  const [signingPassword, setSigningPassword] = useState('');
@@ -185,7 +201,7 @@ export default function App({ project }) {
185
201
  }
186
202
  };
187
203
  if (state === 'setup') {
188
- return _jsx(SetupFlow, { project: project, onComplete: () => setState('menu') });
204
+ return (_jsx(SetupFlow, { project: project, onReload: reloadProject, onComplete: () => setState('menu') }));
189
205
  }
190
206
  if (state === 'menu') {
191
207
  return _jsx(MainMenu, { project: project, onSelect: handleCommandSelect });
@@ -260,9 +276,9 @@ export default function App({ project }) {
260
276
  }
261
277
  if (state === 'setup-signing') {
262
278
  return (_jsx(SigningPropertiesForm, { projectRoot: project.root, onComplete: () => {
263
- // We can't easily update project info here without full reload,
264
- // but since we are just returning to menu, it's fine.
265
- // The user might need to restart CLI or we implement a reload mechanism.
279
+ // Re-detect so the newly written signing credentials are reflected
280
+ // in memory (hasSigningProperties, keychain password) without a restart.
281
+ reloadProject();
266
282
  setState('menu');
267
283
  }, onCancel: () => setState('menu') }));
268
284
  }
@@ -11,9 +11,10 @@ import { signApp, deployApp } from '../utils/sitevision-api.js';
11
11
  import { setDeployPassword } from '../utils/keychain.js';
12
12
  import { resolveSigningPassword } from '../utils/signing-password.js';
13
13
  import { copyStaticToBuild, createBuildZip, cleanBuild } from '../utils/zip.js';
14
- import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, localizedText, } from '../utils/project-detection.js';
14
+ import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, localizedText, readManifest, } from '../utils/project-detection.js';
15
15
  export function DevScreen({ projectRoot, manifest, devProperties, signed, deploy = true, signingCredentials, onBack, onRetryCredentials, }) {
16
16
  const { exit } = useApp();
17
+ const [version, setVersion] = React.useState(manifest.version);
17
18
  const [state, setState] = React.useState({
18
19
  status: 'initializing',
19
20
  message: 'Starting webpack watch...',
@@ -34,6 +35,16 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, deploy
34
35
  const isBuildingRef = React.useRef(false);
35
36
  const pendingRebuildRef = React.useRef(false);
36
37
  // Sign (if needed) and deploy an already-built zip, updating UI state.
38
+ const refreshVersion = React.useCallback(() => {
39
+ try {
40
+ const fresh = readManifest(projectRoot)?.manifest.version;
41
+ if (fresh)
42
+ setVersion(fresh);
43
+ }
44
+ catch {
45
+ // keep last known version
46
+ }
47
+ }, [projectRoot]);
37
48
  const signAndDeploy = React.useCallback(async (zipPath, buildTime) => {
38
49
  try {
39
50
  let deployZipPath = zipPath;
@@ -57,6 +68,7 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, deploy
57
68
  }
58
69
  deployZipPath = signedZipPath;
59
70
  }
71
+ refreshVersion();
60
72
  // Watch/build-only mode: stop after building (and signing).
61
73
  if (!deploy || !devProperties) {
62
74
  setState(prev => ({
@@ -113,7 +125,15 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, deploy
113
125
  error: error instanceof Error ? error.message : String(error),
114
126
  }));
115
127
  }
116
- }, [projectRoot, manifest, devProperties, signed, deploy, signingCredentials]);
128
+ }, [
129
+ projectRoot,
130
+ manifest,
131
+ devProperties,
132
+ signed,
133
+ deploy,
134
+ signingCredentials,
135
+ refreshVersion,
136
+ ]);
117
137
  // In-house webpack path: copy static, zip, then sign + deploy.
118
138
  const handleBuildComplete = React.useCallback(async (result) => {
119
139
  if (!result.success) {
@@ -353,7 +373,7 @@ export function DevScreen({ projectRoot, manifest, devProperties, signed, deploy
353
373
  };
354
374
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(StatusIndicator, { status: getStatusType(), label: getStatusLabel(), message: state.message }) }), state.warning && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "yellow", children: ["\u26A0 ", state.warning] }) })), state.buildCount > 0 && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Builds: ", state.buildCount, state.lastBuildTime
355
375
  ? ` | Last build: ${state.lastBuildTime}ms`
356
- : '', signed ? ' | Signed mode' : ''] }) })), _jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [localizedText(manifest.name), " v", manifest.version] }) }), deploy && devProperties && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Target: ", devProperties.domain, "/", devProperties.siteName, "/", devProperties.addonName] }) })), state.status === 'error' && state.error && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: "red", children: state.error }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.status === 'error' && onRetryCredentials && (_jsx(Text, { dimColor: true, children: "Press r to retry with new credentials" })), onBack ? (_jsx(Text, { dimColor: true, children: "Press q or Esc to return to menu (Ctrl+C to stop process)" })) : (_jsx(Text, { dimColor: true, children: "Press Ctrl+C to stop" }))] })] }));
376
+ : '', signed ? ' | Signed mode' : ''] }) })), _jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [localizedText(manifest.name), " v", version] }) }), deploy && devProperties && (_jsx(Box, { marginLeft: 2, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Target: ", devProperties.domain, "/", devProperties.siteName, "/", devProperties.addonName] }) })), state.status === 'error' && state.error && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: "red", children: state.error }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.status === 'error' && onRetryCredentials && (_jsx(Text, { dimColor: true, children: "Press r to retry with new credentials" })), onBack ? (_jsx(Text, { dimColor: true, children: "Press q or Esc to return to menu (Ctrl+C to stop process)" })) : (_jsx(Text, { dimColor: true, children: "Press Ctrl+C to stop" }))] })] }));
357
377
  }
358
378
  export const devCommand = {
359
379
  name: 'dev',
@@ -1,7 +1,8 @@
1
1
  import { type ProjectInfo } from '../utils/project-detection.js';
2
2
  interface Props {
3
3
  project: ProjectInfo;
4
+ onReload: () => void;
4
5
  onComplete: () => void;
5
6
  }
6
- export declare function SetupFlow({ project, onComplete }: Props): import("react").JSX.Element | null;
7
+ export declare function SetupFlow({ project, onReload, onComplete }: Props): import("react").JSX.Element | null;
7
8
  export {};
@@ -1,16 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
- import { getAppType, localizedText, migrateLegacyPassword, } from '../utils/project-detection.js';
4
+ import { getAppType, localizedText, migrateLegacyPassword, getPackageJsonSyncChanges, syncDevPropertiesToPackageJson, readSvcConfig, writeSvcConfig, } from '../utils/project-detection.js';
5
5
  import { ProcessRunner } from '../utils/process-runner.js';
6
6
  import { ProcessOutputComponent } from './ProcessOutput.js';
7
7
  import { StatusIndicator } from './StatusIndicator.js';
8
8
  import { DevPropertiesForm } from './DevPropertiesForm.js';
9
9
  import { SigningPropertiesForm } from './SigningPropertiesForm.js';
10
- export function SetupFlow({ project, onComplete }) {
10
+ export function SetupFlow({ project, onReload, onComplete }) {
11
11
  const [step, setStep] = useState('check-node-modules');
12
12
  const [runner, setRunner] = useState(null);
13
13
  const [commandStatus, setCommandStatus] = useState('running');
14
+ const [syncChanges, setSyncChanges] = useState([]);
15
+ const [syncDecision, setSyncDecision] = useState(false);
14
16
  const appType = getAppType(project.manifest);
15
17
  // Auto-advance through checks
16
18
  useEffect(() => {
@@ -28,13 +30,32 @@ export function SetupFlow({ project, onComplete }) {
28
30
  setStep('confirm-password-migration');
29
31
  }
30
32
  else {
31
- setStep('check-signing-properties');
33
+ setStep('check-package-sync');
32
34
  }
33
35
  }
34
36
  else {
35
37
  setStep('confirm-dev-setup');
36
38
  }
37
39
  }
40
+ else if (step === 'check-package-sync') {
41
+ const preference = readSvcConfig(project.root).syncPackageJson;
42
+ const properties = project.devProperties;
43
+ const changes = preference !== false && properties
44
+ ? getPackageJsonSyncChanges(project.root, properties)
45
+ : [];
46
+ if (changes.length === 0 || !properties) {
47
+ setStep('check-signing-properties');
48
+ }
49
+ else if (preference === true) {
50
+ syncDevPropertiesToPackageJson(project.root, properties);
51
+ onReload();
52
+ setStep('check-signing-properties');
53
+ }
54
+ else {
55
+ setSyncChanges(changes);
56
+ setStep('confirm-package-sync');
57
+ }
58
+ }
38
59
  else if (step === 'check-signing-properties') {
39
60
  if (project.hasSigningProperties) {
40
61
  setStep('show-info');
@@ -84,6 +105,29 @@ export function SetupFlow({ project, onComplete }) {
84
105
  else if (step === 'confirm-password-migration') {
85
106
  if (input === 'y' || input === 'Y') {
86
107
  migrateLegacyPassword(project);
108
+ // The file was rewritten (plaintext stripped, password moved to
109
+ // keychain) — re-detect so hasLegacyPassword/password reflect that.
110
+ onReload();
111
+ setStep('check-package-sync');
112
+ }
113
+ else if (input === 'n' || input === 'N') {
114
+ setStep('check-package-sync');
115
+ }
116
+ }
117
+ else if (step === 'confirm-package-sync') {
118
+ if (['y', 'Y', 'n', 'N'].includes(input)) {
119
+ const accepted = input.toLowerCase() === 'y';
120
+ if (accepted && project.devProperties) {
121
+ syncDevPropertiesToPackageJson(project.root, project.devProperties);
122
+ onReload();
123
+ }
124
+ setSyncDecision(accepted);
125
+ setStep('confirm-save-sync-choice');
126
+ }
127
+ }
128
+ else if (step === 'confirm-save-sync-choice') {
129
+ if (input === 'y' || input === 'Y') {
130
+ writeSvcConfig(project.root, { syncPackageJson: syncDecision });
87
131
  setStep('check-signing-properties');
88
132
  }
89
133
  else if (input === 'n' || input === 'N') {
@@ -102,17 +146,19 @@ export function SetupFlow({ project, onComplete }) {
102
146
  // Setup Dev Properties Form
103
147
  if (step === 'setup-dev-properties') {
104
148
  return (_jsx(DevPropertiesForm, { projectRoot: project.root, initialProperties: project.devProperties, packageJson: project.packageJson, onComplete: () => {
105
- // Manually update project state locally if possible, or just proceed
106
- // Since we can't easily update 'project' prop from here without reloading,
107
- // we just move to next step. The file is written.
108
- project.hasDevProperties = true; // Optimization/Hack to pass check
109
- setStep('check-signing-properties');
149
+ // Re-detect from disk/keychain so devProperties (incl. the keychain
150
+ // password) populate in memory otherwise the rest of this flow and
151
+ // the menu would see stale state until the CLI is restarted.
152
+ onReload();
153
+ setStep('check-package-sync');
110
154
  }, onCancel: () => setStep('check-signing-properties') }));
111
155
  }
112
156
  // Setup Signing Properties Form
113
157
  if (step === 'setup-signing-properties') {
114
158
  return (_jsx(SigningPropertiesForm, { projectRoot: project.root, onComplete: () => {
115
- project.hasSigningProperties = true; // Optimization/Hack
159
+ // Re-detect so signing credentials are reflected in memory before
160
+ // the info screen / menu render.
161
+ onReload();
116
162
  setStep('show-info');
117
163
  }, onCancel: () => setStep('show-info') }));
118
164
  }
@@ -132,6 +178,16 @@ export function SetupFlow({ project, onComplete }) {
132
178
  if (step === 'confirm-password-migration') {
133
179
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 Plaintext password found in .dev_properties.json" }) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { children: "Move it to the OS keychain and remove it from the file? (y/n)" }), _jsx(Text, { dimColor: true, children: "Recommended \u2014 storing passwords in project files is insecure." })] })] }));
134
180
  }
181
+ // Confirm package.json sync
182
+ if (step === 'confirm-package-sync') {
183
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 package.json is out of sync with .dev_properties.json" }) }), _jsx(Box, { marginBottom: 1, flexDirection: "column", marginLeft: 2, children: syncChanges.map(change => (_jsxs(Box, { children: [_jsx(Text, { color: change.from === undefined ? 'green' : 'yellow', children: change.from === undefined ? '+ ' : '~ ' }), _jsxs(Text, { bold: true, children: [change.key, ": "] }), change.from !== undefined && (_jsxs(Text, { dimColor: true, children: [change.from, " \u2192 "] })), _jsx(Text, { children: change.to })] }, change.key))) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { children: "Update package.json from .dev_properties.json? (y/n)" }), _jsx(Text, { dimColor: true, children: "sitevision-scripts reads these fields from package.json." })] })] }));
184
+ }
185
+ // Offer to persist the sync decision in .svcconfig
186
+ if (step === 'confirm-save-sync-choice') {
187
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { children: "Remember this choice in .svcconfig? (y/n)" }), _jsx(Text, { dimColor: true, children: syncDecision
188
+ ? 'svc will update package.json automatically from now on.'
189
+ : 'svc will stop asking about package.json sync.' })] })] }));
190
+ }
135
191
  // Confirm signing setup
136
192
  if (step === 'confirm-signing-setup') {
137
193
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 signing credentials not configured" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Signing credentials are required for signing apps on developer.sitevision.se" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Would you like to set up signing credentials? (y/n)" }) })] }));
@@ -0,0 +1,19 @@
1
+ /**
2
+ * JSONC helpers.
3
+ *
4
+ * Sitevision's own manifest documentation shows manifest.json with line and
5
+ * block comments (e.g. `"name": { // Multilingual-manifest requires SV 10.1`),
6
+ * so real-world manifests copied from the docs contain them. Strict JSON.parse
7
+ * rejects those, so we strip comments before parsing.
8
+ */
9
+ /**
10
+ * Remove line comments (//...) and block comments from a JSON string, leaving
11
+ * everything inside string literals untouched (so values like
12
+ * `"https://example.com"` survive).
13
+ */
14
+ export declare function stripJsonComments(input: string): string;
15
+ /**
16
+ * Parse a JSON string that may contain comments (JSONC). Throws the underlying
17
+ * SyntaxError if the content is invalid even after comments are removed.
18
+ */
19
+ export declare function parseJsonc<T>(input: string): T;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * JSONC helpers.
3
+ *
4
+ * Sitevision's own manifest documentation shows manifest.json with line and
5
+ * block comments (e.g. `"name": { // Multilingual-manifest requires SV 10.1`),
6
+ * so real-world manifests copied from the docs contain them. Strict JSON.parse
7
+ * rejects those, so we strip comments before parsing.
8
+ */
9
+ /**
10
+ * Remove line comments (//...) and block comments from a JSON string, leaving
11
+ * everything inside string literals untouched (so values like
12
+ * `"https://example.com"` survive).
13
+ */
14
+ export function stripJsonComments(input) {
15
+ let result = '';
16
+ let inString = false;
17
+ let inLineComment = false;
18
+ let inBlockComment = false;
19
+ for (let i = 0; i < input.length; i++) {
20
+ const char = input[i];
21
+ const next = input[i + 1];
22
+ if (inLineComment) {
23
+ if (char === '\n') {
24
+ inLineComment = false;
25
+ result += char;
26
+ }
27
+ continue;
28
+ }
29
+ if (inBlockComment) {
30
+ if (char === '*' && next === '/') {
31
+ inBlockComment = false;
32
+ i++;
33
+ }
34
+ continue;
35
+ }
36
+ if (inString) {
37
+ result += char;
38
+ // Copy escaped characters verbatim so an escaped quote (\") does not
39
+ // end the string early.
40
+ if (char === '\\') {
41
+ result += next ?? '';
42
+ i++;
43
+ }
44
+ else if (char === '"') {
45
+ inString = false;
46
+ }
47
+ continue;
48
+ }
49
+ if (char === '"') {
50
+ inString = true;
51
+ result += char;
52
+ continue;
53
+ }
54
+ if (char === '/' && next === '/') {
55
+ inLineComment = true;
56
+ i++;
57
+ continue;
58
+ }
59
+ if (char === '/' && next === '*') {
60
+ inBlockComment = true;
61
+ i++;
62
+ continue;
63
+ }
64
+ result += char;
65
+ }
66
+ return result;
67
+ }
68
+ /**
69
+ * Parse a JSON string that may contain comments (JSONC). Throws the underlying
70
+ * SyntaxError if the content is invalid even after comments are removed.
71
+ */
72
+ export function parseJsonc(input) {
73
+ return JSON.parse(stripJsonComments(input));
74
+ }
@@ -69,6 +69,14 @@ export declare function buildImportEndpointUrl(domain: string, siteName: string,
69
69
  export declare class ManifestParseError extends Error {
70
70
  constructor(manifestPath: string, cause: unknown);
71
71
  }
72
+ /**
73
+ * Read manifest.json from its supported locations (root, static/, src/).
74
+ * Throws ManifestParseError on malformed JSON.
75
+ */
76
+ export declare function readManifest(cwd: string): {
77
+ manifestPath: string;
78
+ manifest: SitevisionManifest;
79
+ } | null;
72
80
  /**
73
81
  * Detect if the current directory is a Sitevision project
74
82
  */
@@ -94,6 +102,32 @@ export declare function readDevProperties(projectRoot: string): DevProperties |
94
102
  * it is held in the OS keychain instead.
95
103
  */
96
104
  export declare function writeDevProperties(projectRoot: string, properties: DevProperties): void;
105
+ /**
106
+ * CLI preferences stored in .svcconfig at the project root. Unknown keys are
107
+ * preserved on write so hand-edited entries survive.
108
+ */
109
+ export interface SvcConfig {
110
+ syncPackageJson?: boolean;
111
+ [key: string]: unknown;
112
+ }
113
+ export declare function readSvcConfig(projectRoot: string): SvcConfig;
114
+ export declare function writeSvcConfig(projectRoot: string, updates: SvcConfig): void;
115
+ export interface PackageJsonSyncChange {
116
+ key: string;
117
+ from?: string;
118
+ to: string;
119
+ }
120
+ /**
121
+ * Which of the shared fields package.json is missing or disagrees on, relative
122
+ * to the given dev properties. Reads package.json from disk — an earlier
123
+ * `npm install` in the same session may have rewritten it.
124
+ */
125
+ export declare function getPackageJsonSyncChanges(projectRoot: string, properties: DevProperties): PackageJsonSyncChange[];
126
+ /**
127
+ * Copy the shared fields from dev properties into package.json, preserving the
128
+ * file's existing indentation and trailing newline.
129
+ */
130
+ export declare function syncDevPropertiesToPackageJson(projectRoot: string, properties: DevProperties): boolean;
97
131
  /**
98
132
  * Move a plaintext password from .dev_properties.json into the OS keychain and
99
133
  * strip it from the file. Returns true if the password was migrated.
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { getDeployPassword, setDeployPassword } from './keychain.js';
4
+ import { parseJsonc } from './jsonc.js';
4
5
  // =============================================================================
5
6
  // LOCALIZED TEXT
6
7
  // =============================================================================
@@ -166,38 +167,44 @@ export class ManifestParseError extends Error {
166
167
  this.name = 'ManifestParseError';
167
168
  }
168
169
  }
170
+ /**
171
+ * Read manifest.json from its supported locations (root, static/, src/).
172
+ * Throws ManifestParseError on malformed JSON.
173
+ */
174
+ export function readManifest(cwd) {
175
+ const manifestPaths = [
176
+ path.join(cwd, 'manifest.json'),
177
+ path.join(cwd, 'static', 'manifest.json'),
178
+ path.join(cwd, 'src', 'manifest.json'),
179
+ ];
180
+ for (const manifestPath of manifestPaths) {
181
+ if (!fs.existsSync(manifestPath)) {
182
+ continue;
183
+ }
184
+ // Manifests may contain comments (Sitevision's own docs show them), so
185
+ // parse as JSONC.
186
+ try {
187
+ return {
188
+ manifestPath,
189
+ manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
190
+ };
191
+ }
192
+ catch (error) {
193
+ throw new ManifestParseError(manifestPath, error);
194
+ }
195
+ }
196
+ return null;
197
+ }
169
198
  /**
170
199
  * Detect if the current directory is a Sitevision project
171
200
  */
172
201
  export function detectProject(cwd = process.cwd()) {
173
202
  try {
174
- // Look for manifest.json in multiple locations (current, static/, src/)
175
- const manifestPaths = [
176
- path.join(cwd, 'manifest.json'),
177
- path.join(cwd, 'static', 'manifest.json'),
178
- path.join(cwd, 'src', 'manifest.json'),
179
- ];
180
- let manifestPath = null;
181
- let manifest = null;
182
- for (const p of manifestPaths) {
183
- if (fs.existsSync(p)) {
184
- manifestPath = p;
185
- // A present-but-unparseable manifest is a real, fixable error (a stray
186
- // comment, a trailing comma, …). Surface it rather than silently
187
- // reporting "Not a Sitevision project". JSON has no comments — strip
188
- // any `//` annotations from manifest.json.
189
- try {
190
- manifest = JSON.parse(fs.readFileSync(p, 'utf-8'));
191
- }
192
- catch (error) {
193
- throw new ManifestParseError(p, error);
194
- }
195
- break;
196
- }
197
- }
198
- if (!manifest || !manifestPath) {
203
+ const found = readManifest(cwd);
204
+ if (!found) {
199
205
  return null;
200
206
  }
207
+ const { manifestPath, manifest } = found;
201
208
  // Check for package.json
202
209
  const packageJsonPath = path.join(cwd, 'package.json');
203
210
  if (!fs.existsSync(packageJsonPath)) {
@@ -325,6 +332,92 @@ export function writeDevProperties(projectRoot, properties) {
325
332
  const { password: _password, ...persisted } = properties;
326
333
  fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
327
334
  }
335
+ export function readSvcConfig(projectRoot) {
336
+ try {
337
+ return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
338
+ }
339
+ catch {
340
+ return {};
341
+ }
342
+ }
343
+ export function writeSvcConfig(projectRoot, updates) {
344
+ const merged = { ...readSvcConfig(projectRoot), ...updates };
345
+ fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
346
+ }
347
+ // =============================================================================
348
+ // PACKAGE.JSON SYNC
349
+ // =============================================================================
350
+ /**
351
+ * Fields duplicated between .dev_properties.json and package.json, where
352
+ * sitevision-scripts reads them under different names.
353
+ */
354
+ const PACKAGE_JSON_SYNC_KEYS = [
355
+ { packageKey: 'developmentDomain', devKey: 'domain' },
356
+ { packageKey: 'siteName', devKey: 'siteName' },
357
+ { packageKey: 'addonName', devKey: 'addonName' },
358
+ ];
359
+ function readPackageJson(projectRoot) {
360
+ try {
361
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ }
367
+ /**
368
+ * Which of the shared fields package.json is missing or disagrees on, relative
369
+ * to the given dev properties. Reads package.json from disk — an earlier
370
+ * `npm install` in the same session may have rewritten it.
371
+ */
372
+ export function getPackageJsonSyncChanges(projectRoot, properties) {
373
+ const packageJson = readPackageJson(projectRoot);
374
+ if (!packageJson)
375
+ return [];
376
+ const changes = [];
377
+ for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
378
+ const to = properties[devKey];
379
+ if (typeof to !== 'string' || to === '')
380
+ continue;
381
+ const from = packageJson[packageKey];
382
+ if (from !== to) {
383
+ changes.push(from === undefined
384
+ ? { key: packageKey, to }
385
+ : { key: packageKey, from, to });
386
+ }
387
+ }
388
+ return changes;
389
+ }
390
+ /**
391
+ * Copy the shared fields from dev properties into package.json, preserving the
392
+ * file's existing indentation and trailing newline.
393
+ */
394
+ export function syncDevPropertiesToPackageJson(projectRoot, properties) {
395
+ const packageJsonPath = path.join(projectRoot, 'package.json');
396
+ let raw;
397
+ try {
398
+ raw = fs.readFileSync(packageJsonPath, 'utf-8');
399
+ }
400
+ catch {
401
+ return false;
402
+ }
403
+ let packageJson;
404
+ try {
405
+ packageJson = JSON.parse(raw);
406
+ }
407
+ catch {
408
+ return false;
409
+ }
410
+ for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
411
+ const value = properties[devKey];
412
+ if (typeof value === 'string' && value !== '') {
413
+ packageJson[packageKey] = value;
414
+ }
415
+ }
416
+ const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
417
+ const newline = raw.endsWith('\n') ? '\n' : '';
418
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
419
+ return true;
420
+ }
328
421
  /**
329
422
  * Move a plaintext password from .dev_properties.json into the OS keychain and
330
423
  * strip it from the file. Returns true if the password was migrated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"
package/readme.md CHANGED
@@ -126,6 +126,29 @@ Create this file in your project root for deployment configuration:
126
126
  }
127
127
  ```
128
128
 
129
+ ### Keeping `package.json` in sync
130
+
131
+ `sitevision-scripts` reads `developmentDomain`, `siteName` and `addonName`
132
+ from `package.json`, which duplicates three fields of
133
+ `.dev_properties.json`. When they disagree — or when a fresh setup has just
134
+ written `.dev_properties.json` — `svc` shows the differences and offers to
135
+ update `package.json` from `.dev_properties.json`. Nothing is written without
136
+ confirmation, and `.dev_properties.json` is always the source of truth for
137
+ the copy. Existing indentation and unrelated fields are left alone.
138
+
139
+ After answering, `svc` offers to remember the choice in a `.svcconfig` file
140
+ in the project root:
141
+
142
+ ```json
143
+ {
144
+ "syncPackageJson": true
145
+ }
146
+ ```
147
+
148
+ With `true`, `svc` updates `package.json` automatically without asking; with
149
+ `false`, the check is skipped entirely. Delete the key (or the file) to be
150
+ asked again. The file contains no secrets, so it is safe to commit.
151
+
129
152
  ### Password storage
130
153
 
131
154
  Passwords are stored in the OS-native secret store (macOS Keychain, Windows