sitevision-cli 1.0.0-beta.20 → 1.0.0-beta.22

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.
@@ -19,6 +19,7 @@ export interface ConfigTarget {
19
19
  base?: Partial<DevProperties>;
20
20
  environment?: string;
21
21
  workspace?: boolean;
22
+ workspaceRoot?: string;
22
23
  }
23
24
  /** Apply the form to disk and the keychain. Exported for the test. */
24
25
  export declare function saveConfig(project: ConfigTarget, values: Values, edited: Set<string>): void;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
- import { getPackageJsonSyncChanges, normalizeDomain, readInheritedDevProperties, writeDevProperties, } from '../utils/project-detection.js';
4
+ import { findDevPropertiesPath, getPackageJsonSyncChanges, hasPackageJson, IMPLICIT_VALUES, normalizeDomain, readAncestorDevProperties, readInheritedDevProperties, readWorkspaceDevProperties, updatePackageJson, writeDevProperties, } from '../utils/project-detection.js';
5
5
  import { setDeployPassword, deleteDeployPassword, getOAuth2ClientSecret, setOAuth2ClientSecret, deleteOAuth2ClientSecret, getSigningPassword, setSigningPassword, deleteSigningPassword, } from '../utils/keychain.js';
6
6
  import { DEFAULT_SCOPES, discoverOAuth2Config } from '../utils/oauth2-auth.js';
7
7
  import { ACCENT } from './Frame.js';
@@ -185,6 +185,41 @@ function storedSecret(project, key) {
185
185
  }
186
186
  return Boolean(dev.signingUsername && getSigningPassword(dev.signingUsername));
187
187
  }
188
+ const errorText = (error) => error instanceof Error ? error.message : String(error);
189
+ const sameValue = (key, a, b) => JSON.stringify(a === '' || a === undefined ? IMPLICIT_VALUES[key] : a) ===
190
+ JSON.stringify(b === '' || b === undefined ? IMPLICIT_VALUES[key] : b);
191
+ /**
192
+ * App mode writes the app's complete file. Workspace mode never creates an
193
+ * app's .dev_properties.json: changes go to the root file, and the addon name
194
+ * to the app's package.json. An app that already has its own file keeps it.
195
+ */
196
+ function writeConfigFile(project, file) {
197
+ const { workspaceRoot } = project;
198
+ if (!workspaceRoot ||
199
+ project.workspace ||
200
+ findDevPropertiesPath(project.root)) {
201
+ writeDevProperties(project.root, file, {
202
+ complete: !workspaceRoot && !project.workspace,
203
+ });
204
+ return;
205
+ }
206
+ const before = (project.base ?? project.devProperties ?? {});
207
+ const after = file;
208
+ const root = readWorkspaceDevProperties(workspaceRoot);
209
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
210
+ if (sameValue(key, after[key], before[key]))
211
+ continue;
212
+ if (key === 'addonName') {
213
+ updatePackageJson(project.root, packageJson => {
214
+ packageJson['addonName'] = after[key] || undefined;
215
+ });
216
+ }
217
+ else {
218
+ root[key] = after[key];
219
+ }
220
+ }
221
+ writeDevProperties(workspaceRoot, root);
222
+ }
188
223
  /** Apply the form to disk and the keychain. Exported for the test. */
189
224
  export function saveConfig(project, values, edited) {
190
225
  const method = values['authMethod'];
@@ -228,7 +263,7 @@ export function saveConfig(project, values, edited) {
228
263
  base.certificateName = next.certificateName;
229
264
  base.baseEnvironment = next.baseEnvironment;
230
265
  base.production = next.production;
231
- writeDevProperties(project.root, withEnvironmentOverride(base, env, {
266
+ writeConfigFile(project, withEnvironmentOverride(base, env, {
232
267
  domain: next.domain,
233
268
  siteName: next.siteName,
234
269
  addonName: next.addonName,
@@ -240,7 +275,7 @@ export function saveConfig(project, values, edited) {
240
275
  }));
241
276
  }
242
277
  else {
243
- writeDevProperties(project.root, {
278
+ writeConfigFile(project, {
244
279
  ...next,
245
280
  environments: project.base?.environments ?? project.devProperties?.environments,
246
281
  });
@@ -293,15 +328,21 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
293
328
  const fields = visibleFields(method, project.workspace, envMode);
294
329
  const current = fields[Math.min(cursor, fields.length - 1)];
295
330
  const inherited = readInheritedDevProperties(project.root);
296
- const changes = project.devProperties && !project.workspace
297
- ? getPackageJsonSyncChanges(project.root, project.devProperties)
298
- : [];
331
+ const ancestors = readAncestorDevProperties(project.root);
332
+ const changes = getPackageJsonSyncChanges(project.root);
333
+ const packageJsonExists = hasPackageJson(project.root);
299
334
  // Write one field to disk (and the keychain for secrets) right away.
300
335
  const commit = (key, value, label = current.label) => {
301
336
  const clean = key === 'domain' ? normalizeDomain(value) : value;
302
337
  const next = { ...values, [key]: clean };
303
338
  setValues(next);
304
- saveConfig(project, next, new Set([key]));
339
+ try {
340
+ saveConfig(project, next, new Set([key]));
341
+ }
342
+ catch (error) {
343
+ setNote(t('Not saved: {error}', { error: errorText(error) }));
344
+ return;
345
+ }
305
346
  setNote(clean === value
306
347
  ? t('Saved {label}.', { label: t(label) })
307
348
  : t('Saved {label} as {value} — a domain is a host only.', {
@@ -328,7 +369,13 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
328
369
  tokenEndpoint: values['tokenEndpoint'] || found.tokenEndpoint,
329
370
  };
330
371
  setValues(next);
331
- saveConfig(project, next, new Set());
372
+ try {
373
+ saveConfig(project, next, new Set());
374
+ }
375
+ catch (error) {
376
+ setNote(t('Not saved: {error}', { error: errorText(error) }));
377
+ return;
378
+ }
332
379
  onSaved();
333
380
  setNote(t('Endpoints filled from the site OpenID config.'));
334
381
  }
@@ -359,8 +406,12 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
359
406
  }
360
407
  else if (key.return) {
361
408
  setEditing(false);
362
- if (draft !== values[current.key])
409
+ // A secret row always starts empty, so Enter on an empty one means
410
+ // "remove the stored secret", not "no change".
411
+ if (draft !== values[current.key] ||
412
+ (current.kind === 'secret' && storedSecret(project, current.key))) {
363
413
  commit(current.key, draft);
414
+ }
364
415
  }
365
416
  else if (choices.length > 0) {
366
417
  const step = key.leftArrow || key.upArrow
@@ -434,16 +485,22 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
434
485
  color: overridden ? 'yellow' : undefined,
435
486
  };
436
487
  }
437
- const inheritedValue = [
488
+ const inOAuth2 = [
438
489
  'clientId',
439
490
  'authorizationEndpoint',
440
491
  'tokenEndpoint',
441
- ].includes(f.key)
492
+ ].includes(f.key);
493
+ const inheritedValue = inOAuth2
442
494
  ? inherited['oauth2']?.[f.key]
443
495
  : inherited[f.key];
444
496
  if (inheritedValue !== undefined &&
445
497
  JSON.stringify(inheritedValue) === JSON.stringify(value)) {
446
- return { text: t('↑ root') };
498
+ // A parent .dev_properties.json outranks package.json when it has the key.
499
+ return {
500
+ text: Object.hasOwn(ancestors, inOAuth2 ? 'oauth2' : f.key)
501
+ ? t('↑ root')
502
+ : 'package.json',
503
+ };
447
504
  }
448
505
  return { text: (values[f.key] ?? '') ? t('local') : '' };
449
506
  };
@@ -491,9 +548,11 @@ export function ConfigForm({ project, active, width, height, pickAddon, onSaved,
491
548
  }
492
549
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, overflow: "hidden", height: height, children: [_jsx(Box, { height: 1, flexShrink: 0, children: _jsxs(Text, { dimColor: true, wrap: "truncate", children: [(' ' + t('FIELD')).padEnd(24), t('VALUE').padEnd(valueWidth), t('SOURCE')] }) }), rows, project.workspace && (_jsx(Box, { marginTop: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: t("Shared by every app below {root}. An app's own value wins.", {
493
550
  root: project.root,
494
- }) }) })), !project.workspace && (_jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { bold: true, dimColor: true, children: [t('PACKAGE.JSON SYNC'), ' ', _jsx(Text, { color: changes.length > 0 ? 'yellow' : 'green', children: changes.length === 0
495
- ? t('in sync')
496
- : changes.length === 1
497
- ? t('1 diff · y to apply')
498
- : t('{n} diffs · y to apply', { n: changes.length }) })] }), changes.map(c => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: c.from === undefined ? 'green' : 'yellow', children: c.from === undefined ? '+ ' : '~ ' }), c.key, ":", ' ', c.from !== undefined && _jsxs(Text, { dimColor: true, children: [c.from, " \u2192 "] }), c.to] }, c.key)))] })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderDimColor: true, borderLeft: false, borderRight: false, borderBottom: false, children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { bold: true, color: ACCENT, children: t(current.label) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", t(current.help)] })] }) }), _jsx(Text, { color: "yellow", children: note })] }));
551
+ }) }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { bold: true, dimColor: true, children: [t('PACKAGE.JSON SYNC'), ' ', _jsx(Text, { color: changes.length > 0 || !packageJsonExists ? 'yellow' : 'green', children: !packageJsonExists
552
+ ? t('no workspace package.json · y to set up')
553
+ : changes.length === 0
554
+ ? t('in sync')
555
+ : changes.length === 1
556
+ ? t('1 diff · y to apply')
557
+ : t('{n} diffs · y to apply', { n: changes.length }) })] }), changes.map(c => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: c.from === undefined ? 'green' : 'yellow', children: c.from === undefined ? '+ ' : '~ ' }), c.key, ": ", c.from !== undefined && _jsxs(Text, { dimColor: true, children: [c.from, " \u2192 "] }), c.to] }, c.key)))] }), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderDimColor: true, borderLeft: false, borderRight: false, borderBottom: false, children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { bold: true, color: ACCENT, children: t(current.label) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", t(current.help)] })] }) }), _jsx(Text, { color: "yellow", children: note })] }));
499
558
  }
@@ -25,9 +25,7 @@ export function typeGlyph(manifest) {
25
25
  return type ? TYPE_GLYPH[type] : '???';
26
26
  }
27
27
  export function appStatus(project) {
28
- const sync = project.devProperties
29
- ? getPackageJsonSyncChanges(project.root, project.devProperties).length
30
- : 0;
28
+ const sync = getPackageJsonSyncChanges(project.root).length;
31
29
  return {
32
30
  deps: project.hasNodeModules,
33
31
  config: Boolean(project.devProperties),
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
4
4
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
5
5
  import Spinner from 'ink-spinner';
6
- import { detectProject, appTypeOf, localizedText, readWorkspaceDevProperties, readSvcConfig, writeSvcConfig, writeDevProperties, } from '../utils/project-detection.js';
6
+ import { detectProject, appTypeOf, localizedText, readWorkspaceDevProperties, readSvcConfig, writeSvcConfig, writeDevProperties, syncDevPropertiesToPackageJson, } from '../utils/project-detection.js';
7
7
  import { appGroup, configIncomplete, needsOnboarding, } from '../utils/workspace.js';
8
8
  import { listAddons, listExecutables, } from '../utils/sitevision-api.js';
9
9
  import { useTasks, runningTasks, startActivate, getTasks, } from '../utils/tasks.js';
@@ -157,10 +157,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
157
157
  : rawProject.devProperties);
158
158
  if (!base)
159
159
  return;
160
- writeDevProperties(targetRoot, {
161
- ...base,
162
- environments: { ...base.environments, [clean]: {} },
163
- });
160
+ writeDevProperties(targetRoot, { ...base, environments: { ...base.environments, [clean]: {} } }, { complete: !workspaceRoot });
164
161
  reload();
165
162
  setEnvChoice(clean);
166
163
  writeSvcConfig(configRoot, { environment: clean });
@@ -327,9 +324,20 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
327
324
  return;
328
325
  }
329
326
  if (settings && focus === 'content') {
330
- // Settings pane: the form owns everything but q and Tab/Esc above.
327
+ // Settings pane: the form owns everything but q, y and Tab/Esc above.
331
328
  if (input === 'q')
332
329
  quit();
330
+ else if (input === 'y') {
331
+ try {
332
+ if (syncDevPropertiesToPackageJson(workspaceRoot)) {
333
+ reload();
334
+ notify(t('package.json updated'), 'ok');
335
+ }
336
+ }
337
+ catch (error) {
338
+ notify(error instanceof Error ? error.message : String(error), 'error');
339
+ }
340
+ }
333
341
  return;
334
342
  }
335
343
  if (tab === 'versions') {
@@ -386,6 +394,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
386
394
  ? h([
387
395
  ['↑↓', 'field'],
388
396
  ['Enter', 'edit'],
397
+ ['y', 'sync'],
389
398
  ['Esc', 'back'],
390
399
  ['q', 'quit'],
391
400
  ])
@@ -490,6 +499,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
490
499
  devProperties: project.devProperties,
491
500
  base: rawProject.devProperties,
492
501
  environment: env,
502
+ workspaceRoot,
493
503
  }, active: formActive, width: narrow ? columns : columns - sidebar, height: contentHeight, pickAddon: pickAddon, onSaved: () => {
494
504
  reload();
495
505
  notify(t('config saved'), 'ok');
@@ -1,7 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import Spinner from 'ink-spinner';
4
- import { appTypeOf, getPackageJsonSyncChanges, localizedText, } from '../utils/project-detection.js';
4
+ import { appTypeOf, getPackageJsonSyncChanges, readAncestorDevProperties, localizedText, } from '../utils/project-detection.js';
5
5
  import { checkSitevisionScriptsCompatibility } from '../utils/sitevision-scripts-runner.js';
6
6
  import { ACCENT, elapsed } from './Frame.js';
7
7
  import { t } from '../utils/i18n.js';
@@ -39,9 +39,14 @@ function time(ms, seconds = false) {
39
39
  export function Overview({ project, tasks, height, }) {
40
40
  const dev = project.devProperties;
41
41
  const inherited = new Set(project.inheritedKeys);
42
- const src = (key) => (inherited.has(key) ? t('↑ root') : undefined);
42
+ const ancestors = readAncestorDevProperties(project.root);
43
+ const src = (key) => inherited.has(key)
44
+ ? Object.hasOwn(ancestors, key)
45
+ ? t('↑ root')
46
+ : 'package.json'
47
+ : undefined;
43
48
  const notSet = t('not set');
44
- const sync = dev ? getPackageJsonSyncChanges(project.root, dev).length : 0;
49
+ const sync = getPackageJsonSyncChanges(project.root).length;
45
50
  const scripts = checkSitevisionScriptsCompatibility(project.root);
46
51
  const recent = tasks
47
52
  .filter(task => task.appRoot === project.root && task.status !== 'running')
Binary file
@@ -187,6 +187,8 @@ const sv = {
187
187
  SOURCE: 'KÄLLA',
188
188
  "Shared by every app below {root}. An app's own value wins.": 'Delas av alla appar under {root}. Appens eget värde vinner.',
189
189
  'PACKAGE.JSON SYNC': 'PACKAGE.JSON-SYNK',
190
+ 'no workspace package.json · y to set up': 'ingen package.json i arbetsytan · y för att skapa',
191
+ 'Not saved: {error}': 'Inte sparat: {error}',
190
192
  '←→ choose · Enter confirm · Esc cancel': '←→ välj · Enter bekräfta · Esc avbryt',
191
193
  'Enter save · Esc cancel': 'Enter spara · Esc avbryt',
192
194
  '↑↓ field · Enter edit · ^O pick addon': '↑↓ fält · Enter redigera · ^O välj tillägg',
@@ -30,7 +30,14 @@ export declare function normalizeDomain(value: string): string;
30
30
  * app's would be. Used to edit shared config from the shell.
31
31
  */
32
32
  export declare function readWorkspaceDevProperties(root: string): Partial<DevProperties>;
33
- /** Dev properties inherited from ancestor directories only (no own file). */
33
+ /** Ancestor directories' .dev_properties.json merged, nearest wins (no own file). */
34
+ export declare function readAncestorDevProperties(root: string): Partial<DevProperties>;
35
+ /** Shared defaults from package.json: the workspace root's first, the app's on top. */
36
+ export declare function readPackageDefaultsChain(root: string): Partial<DevProperties>;
37
+ /**
38
+ * Everything an app's own .dev_properties.json sits on top of: package.json
39
+ * defaults (root, then app), then ancestor .dev_properties.json files.
40
+ */
34
41
  export declare function readInheritedDevProperties(root: string): Partial<DevProperties>;
35
42
  /**
36
43
  * Get the full app ID including any prefix/suffix from environment
@@ -125,7 +132,9 @@ export declare function readDevProperties(projectRoot: string): DevProperties |
125
132
  * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
126
133
  * runtime instead.
127
134
  */
128
- export declare function writeDevProperties(projectRoot: string, properties: DevProperties): void;
135
+ export declare function writeDevProperties(projectRoot: string, properties: DevProperties, { complete }?: {
136
+ complete?: boolean;
137
+ }): void;
129
138
  /**
130
139
  * CLI preferences stored in .svcconfig at the project root. Unknown keys are
131
140
  * preserved on write so hand-edited entries survive.
@@ -137,22 +146,35 @@ export interface SvcConfig {
137
146
  }
138
147
  export declare function readSvcConfig(projectRoot: string): SvcConfig;
139
148
  export declare function writeSvcConfig(projectRoot: string, updates: SvcConfig): void;
149
+ /**
150
+ * Values tied to the person running svc. They stay in .dev_properties.json and
151
+ * never go into package.json.
152
+ */
153
+ export declare const USER_KEYS: string[];
140
154
  export interface PackageJsonSyncChange {
141
155
  key: string;
142
156
  from?: string;
143
157
  to: string;
144
158
  }
159
+ /** The shared defaults one directory's package.json provides. */
160
+ export declare function readPackageDefaults(dir: string): Partial<DevProperties>;
161
+ /** What an unset field means, so spelling out the default is not a change. */
162
+ export declare const IMPLICIT_VALUES: Record<string, unknown>;
163
+ /** What syncing this directory would change in its package.json. */
164
+ export declare function getPackageJsonSyncChanges(dir: string): PackageJsonSyncChange[];
165
+ export declare function hasPackageJson(dir: string): boolean;
145
166
  /**
146
- * Which of the shared fields package.json is missing or disagrees on, relative
147
- * to the given dev properties. Reads package.json from disk an earlier
148
- * `npm install` in the same session may have rewritten it.
167
+ * Copy the pending shared values from .dev_properties.json into package.json,
168
+ * creating package.json when the directory has none. Throws if it cannot be
169
+ * read or written.
149
170
  */
150
- export declare function getPackageJsonSyncChanges(projectRoot: string, properties: DevProperties): PackageJsonSyncChange[];
171
+ export declare function syncDevPropertiesToPackageJson(dir: string): boolean;
151
172
  /**
152
- * Copy the shared fields from dev properties into package.json, preserving the
153
- * file's existing indentation and trailing newline.
173
+ * Edit package.json in place, keeping its indentation and trailing newline. A
174
+ * missing file is created; an unreadable or invalid one throws, so the caller
175
+ * can warn instead of dropping the change silently.
154
176
  */
155
- export declare function syncDevPropertiesToPackageJson(projectRoot: string, properties: DevProperties): boolean;
177
+ export declare function updatePackageJson(dir: string, mutate: (packageJson: Record<string, unknown>) => void): void;
156
178
  /**
157
179
  * Move a plaintext password from .dev_properties.json into the OS keychain and
158
180
  * strip it from the file. Returns true if the password was migrated.
@@ -123,14 +123,32 @@ export function readWorkspaceDevProperties(root) {
123
123
  }
124
124
  return merged;
125
125
  }
126
- /** Dev properties inherited from ancestor directories only (no own file). */
127
- export function readInheritedDevProperties(root) {
126
+ /** Ancestor directories' .dev_properties.json merged, nearest wins (no own file). */
127
+ export function readAncestorDevProperties(root) {
128
128
  let merged = {};
129
129
  for (const dir of ancestorDirs(root)) {
130
130
  merged = { ...merged, ...readDevPropertiesFile(dir) };
131
131
  }
132
132
  return merged;
133
133
  }
134
+ /** Shared defaults from package.json: the workspace root's first, the app's on top. */
135
+ export function readPackageDefaultsChain(root) {
136
+ let merged = {};
137
+ for (const dir of [...ancestorDirs(root), root]) {
138
+ merged = { ...merged, ...readPackageDefaults(dir) };
139
+ }
140
+ return merged;
141
+ }
142
+ /**
143
+ * Everything an app's own .dev_properties.json sits on top of: package.json
144
+ * defaults (root, then app), then ancestor .dev_properties.json files.
145
+ */
146
+ export function readInheritedDevProperties(root) {
147
+ return {
148
+ ...readPackageDefaultsChain(root),
149
+ ...readAncestorDevProperties(root),
150
+ };
151
+ }
134
152
  /**
135
153
  * Get app ID configuration from environment or defaults
136
154
  */
@@ -427,7 +445,9 @@ export function readDevProperties(projectRoot) {
427
445
  * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
428
446
  * runtime instead.
429
447
  */
430
- export function writeDevProperties(projectRoot, properties) {
448
+ export function writeDevProperties(projectRoot, properties,
449
+ // App mode: write every value, so plain sitevision-scripts finds them all.
450
+ { complete = false } = {}) {
431
451
  const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
432
452
  getDefaultDevPropertiesPath(projectRoot);
433
453
  const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
@@ -435,7 +455,9 @@ export function writeDevProperties(projectRoot, properties) {
435
455
  // at the workspace root instead of being copied into every app. An empty
436
456
  // string means "unset", so it is dropped rather than written as an override
437
457
  // that would shadow the inherited value.
438
- const inherited = readInheritedDevProperties(projectRoot);
458
+ const inherited = complete
459
+ ? {}
460
+ : readInheritedDevProperties(projectRoot);
439
461
  const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
440
462
  (!Object.hasOwn(inherited, key) ||
441
463
  JSON.stringify(inherited[key]) !== JSON.stringify(value))));
@@ -457,13 +479,24 @@ export function writeSvcConfig(projectRoot, updates) {
457
479
  // PACKAGE.JSON SYNC
458
480
  // =============================================================================
459
481
  /**
460
- * Fields duplicated between .dev_properties.json and package.json, where
461
- * sitevision-scripts reads them under different names.
462
- */
463
- const PACKAGE_JSON_SYNC_KEYS = [
464
- { packageKey: 'developmentDomain', devKey: 'domain' },
465
- { packageKey: 'siteName', devKey: 'siteName' },
466
- { packageKey: 'addonName', devKey: 'addonName' },
482
+ * Values tied to the person running svc. They stay in .dev_properties.json and
483
+ * never go into package.json.
484
+ */
485
+ export const USER_KEYS = ['username', 'signingUsername', 'certificateName'];
486
+ // Shared values package.json can hold: three top-level fields, the rest under "svc".
487
+ const PACKAGE_TOP_KEYS = {
488
+ domain: 'developmentDomain',
489
+ siteName: 'siteName',
490
+ addonName: 'addonName',
491
+ };
492
+ const PACKAGE_SVC_KEYS = [
493
+ 'authMethod',
494
+ 'oauth2',
495
+ 'sessionLoginUrl',
496
+ 'useHTTPForDevDeploy',
497
+ 'baseEnvironment',
498
+ 'production',
499
+ 'environments',
467
500
  ];
468
501
  function readPackageJson(projectRoot) {
469
502
  try {
@@ -473,59 +506,122 @@ function readPackageJson(projectRoot) {
473
506
  return null;
474
507
  }
475
508
  }
509
+ /** The shared defaults one directory's package.json provides. */
510
+ export function readPackageDefaults(dir) {
511
+ const packageJson = readPackageJson(dir);
512
+ if (!packageJson)
513
+ return {};
514
+ const defaults = {};
515
+ for (const [devKey, packageKey] of Object.entries(PACKAGE_TOP_KEYS)) {
516
+ const value = packageJson[packageKey];
517
+ if (typeof value === 'string' && value !== '')
518
+ defaults[devKey] = value;
519
+ }
520
+ const svc = packageJson['svc'];
521
+ for (const key of PACKAGE_SVC_KEYS) {
522
+ if (svc?.[key] !== undefined)
523
+ defaults[key] = svc[key];
524
+ }
525
+ if (typeof defaults['domain'] === 'string') {
526
+ defaults['domain'] = normalizeDomain(defaults['domain']);
527
+ }
528
+ return defaults;
529
+ }
476
530
  /**
477
- * Which of the shared fields package.json is missing or disagrees on, relative
478
- * to the given dev properties. Reads package.json from disk an earlier
479
- * `npm install` in the same session may have rewritten it.
531
+ * Shared values in this directory's own .dev_properties.json that package.json
532
+ * does not already provide, here or further up. User values never qualify.
480
533
  */
481
- export function getPackageJsonSyncChanges(projectRoot, properties) {
482
- const packageJson = readPackageJson(projectRoot);
483
- if (!packageJson)
534
+ function pendingSync(dir) {
535
+ const own = readDevPropertiesFile(dir);
536
+ if (!own)
484
537
  return [];
485
- const changes = [];
486
- for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
487
- const to = properties[devKey];
488
- if (typeof to !== 'string' || to === '')
489
- continue;
490
- const from = packageJson[packageKey];
491
- if (from !== to) {
492
- changes.push(from === undefined
493
- ? { key: packageKey, to }
494
- : { key: packageKey, from, to });
538
+ const defaults = readPackageDefaultsChain(dir);
539
+ const pending = [];
540
+ for (const key of [...Object.keys(PACKAGE_TOP_KEYS), ...PACKAGE_SVC_KEYS]) {
541
+ let value = own[key];
542
+ if (key === 'environments' && value) {
543
+ value = Object.fromEntries(Object.entries(value).map(([name, override]) => [
544
+ name,
545
+ Object.fromEntries(Object.entries(override).filter(([field]) => !USER_KEYS.includes(field))),
546
+ ]));
495
547
  }
548
+ if (value === undefined || value === '')
549
+ continue;
550
+ if (JSON.stringify(value) ===
551
+ JSON.stringify(defaults[key] ?? IMPLICIT_VALUES[key]))
552
+ continue;
553
+ pending.push({ key, value });
496
554
  }
497
- return changes;
555
+ return pending;
556
+ }
557
+ /** What an unset field means, so spelling out the default is not a change. */
558
+ export const IMPLICIT_VALUES = {
559
+ authMethod: 'basic',
560
+ useHTTPForDevDeploy: false,
561
+ production: false,
562
+ };
563
+ const packageLabel = (key) => PACKAGE_TOP_KEYS[key] ?? `svc.${key}`;
564
+ const display = (value) => typeof value === 'string' ? value : JSON.stringify(value);
565
+ /** What syncing this directory would change in its package.json. */
566
+ export function getPackageJsonSyncChanges(dir) {
567
+ const current = readPackageDefaults(dir);
568
+ return pendingSync(dir).map(({ key, value }) => current[key] === undefined
569
+ ? { key: packageLabel(key), to: display(value) }
570
+ : {
571
+ key: packageLabel(key),
572
+ from: display(current[key]),
573
+ to: display(value),
574
+ });
575
+ }
576
+ export function hasPackageJson(dir) {
577
+ return fs.existsSync(path.join(dir, 'package.json'));
578
+ }
579
+ /**
580
+ * Copy the pending shared values from .dev_properties.json into package.json,
581
+ * creating package.json when the directory has none. Throws if it cannot be
582
+ * read or written.
583
+ */
584
+ export function syncDevPropertiesToPackageJson(dir) {
585
+ const pending = pendingSync(dir);
586
+ if (pending.length === 0 && hasPackageJson(dir))
587
+ return false;
588
+ updatePackageJson(dir, packageJson => {
589
+ for (const { key, value } of pending) {
590
+ const topKey = PACKAGE_TOP_KEYS[key];
591
+ if (topKey) {
592
+ packageJson[topKey] = value;
593
+ }
594
+ else {
595
+ packageJson['svc'] = {
596
+ ...packageJson['svc'],
597
+ [key]: value,
598
+ };
599
+ }
600
+ }
601
+ });
602
+ return true;
498
603
  }
499
604
  /**
500
- * Copy the shared fields from dev properties into package.json, preserving the
501
- * file's existing indentation and trailing newline.
605
+ * Edit package.json in place, keeping its indentation and trailing newline. A
606
+ * missing file is created; an unreadable or invalid one throws, so the caller
607
+ * can warn instead of dropping the change silently.
502
608
  */
503
- export function syncDevPropertiesToPackageJson(projectRoot, properties) {
504
- const packageJsonPath = path.join(projectRoot, 'package.json');
505
- let raw;
609
+ export function updatePackageJson(dir, mutate) {
610
+ const packageJsonPath = path.join(dir, 'package.json');
611
+ // A new file is private, so a workspace root is never published by accident.
612
+ let raw = '{\n\t"private": true\n}\n';
506
613
  try {
507
- raw = fs.readFileSync(packageJsonPath, 'utf-8');
508
- }
509
- catch {
510
- return false;
511
- }
512
- let packageJson;
513
- try {
514
- packageJson = JSON.parse(raw);
515
- }
516
- catch {
517
- return false;
614
+ if (hasPackageJson(dir))
615
+ raw = fs.readFileSync(packageJsonPath, 'utf-8');
616
+ const packageJson = JSON.parse(raw);
617
+ mutate(packageJson);
618
+ const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
619
+ const newline = raw.endsWith('\n') ? '\n' : '';
620
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
518
621
  }
519
- for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
520
- const value = properties[devKey];
521
- if (typeof value === 'string' && value !== '') {
522
- packageJson[packageKey] = value;
523
- }
622
+ catch (error) {
623
+ throw new Error(`Could not update ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
524
624
  }
525
- const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
526
- const newline = raw.endsWith('\n') ? '\n' : '';
527
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
528
- return true;
529
625
  }
530
626
  /**
531
627
  * Move a plaintext password from .dev_properties.json into the OS keychain and
@@ -542,8 +638,22 @@ export function migrateLegacyPassword(project) {
542
638
  return false;
543
639
  if (!setDeployPassword(domain, username, password))
544
640
  return false;
545
- // writeDevProperties strips `password` defensively; keep the in-memory value.
546
- writeDevProperties(project.root, project.devProperties);
641
+ // Remove only the password, from whichever file holds it; every other value
642
+ // stays as written.
643
+ for (const dir of [project.root, ...ancestorDirs(project.root)]) {
644
+ const file = findDevPropertiesPath(dir);
645
+ if (!file)
646
+ continue;
647
+ try {
648
+ const { password: stored, ...rest } = JSON.parse(fs.readFileSync(file, 'utf8'));
649
+ if (stored !== undefined) {
650
+ fs.writeFileSync(file, JSON.stringify(rest, null, 2));
651
+ }
652
+ }
653
+ catch {
654
+ // Unreadable file: nothing to migrate there.
655
+ }
656
+ }
547
657
  project.hasLegacyPassword = false;
548
658
  return true;
549
659
  }
@@ -509,9 +509,8 @@ export async function createAddon(config, appType) {
509
509
  export async function activateApp(executableId, config, _appType) {
510
510
  const protocol = config.useHTTP ? 'http' : 'https';
511
511
  const url = `${protocol}://${config.domain}/rest-api/1/0/${encodeURIComponent(config.siteName)}/Addon%20Repository/${encodeURIComponent(config.addonName)}/activateCustomModuleExecutable`;
512
- const body = JSON.stringify({
513
- executableId,
514
- });
512
+ // Property name per the ActivateCustomModuleExecutable PUT docs.
513
+ const body = JSON.stringify({ customModuleExecutableId: executableId });
515
514
  const { auth, kind } = configAuth(config);
516
515
  try {
517
516
  const response = await makeRequest(url, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.20",
3
+ "version": "1.0.0-beta.22",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"
package/readme.md CHANGED
@@ -1,233 +1,139 @@
1
1
  # Sitevision CLI
2
2
 
3
- This CLI was largely built on the back of the [sitevision-scripts](https://github.com/sitevision/sitevision-scripts) project.
4
- However, these scripts have some limitations:
5
-
6
- - Clunky for use with environments requiring signed packages
7
- - Clunkly management of credentials and unsecure handling of credentials
8
- - No type safety
9
-
10
- ## Features
11
-
12
- - **Interactive Menu** - Full-screen TUI with arrow key navigation
13
- - **Project Detection** - Automatically detects Sitevision projects
14
- - **Two Modes** - Interactive menu OR direct command execution
15
- - **Automatic Setup** - Guided setup for dev properties and signing credentials
16
- - **Secure Credentials** - Passwords live in the OS keychain (macOS Keychain / Windows Credential Manager / Linux libsecret), never on disk
3
+ `svc` builds, signs and deploys Sitevision apps (WebApp, Widget, RESTApp,
4
+ MCPServer) from a full-screen terminal shell or as plain commands.
5
+
6
+ - **One shell for one app or a whole repo.** Run it inside an app, or at the
7
+ root of a repo with many apps and switch between them. Dev and watch keep
8
+ running in the background.
9
+ - **Three ways to authenticate deploys:** username and password, OAuth2 (PKCE,
10
+ works with SSO), or a captured browser session for SAML-only sites.
11
+ - **No secrets on disk.** Passwords, tokens and cookies live in the OS keychain.
12
+ - **Environments.** dev, test and prod in one config; production deploys use
13
+ the signed zip, confirm and activate.
14
+ - **Shared config in git.** Site and auth settings for the whole team live in
15
+ `package.json`, once at the repo root; your username stays in a local
16
+ `.dev_properties.json`. Compatible with plain sitevision-scripts.
17
+ - **Builds the way Sitevision does.** Bundled apps without their own webpack
18
+ config are built by `@sitevision/sitevision-scripts`.
19
+ - English and Swedish UI.
20
+
21
+ 📖 **[User guide](docs/user-guide.md)** · **[Användarguide (svenska)](docs/anvandarguide.md)**
17
22
 
18
23
  ## Install
19
24
 
20
- ```bash
21
- npm install --global sitevision-cli
22
- ```
23
-
24
- ## Usage
25
-
26
- The CLI must be run inside a Sitevision project directory (containing a `manifest.json`).
27
-
28
- ### Interactive shell
29
-
30
- Run `svc` with no arguments to open the full-screen shell. It works in two
31
- places:
32
-
33
- - **Inside an app** (a directory with `manifest.json`): single-app mode.
34
- - **At the root of a repo** that contains apps in subfolders such as
35
- `webapps/*`, `restapps/*` or `widgets/*`: workspace mode, with every app in
36
- the left navigator and per-app status dots (dependencies, config,
37
- package.json sync, signing).
38
-
39
- The right pane has four tabs: **Overview**, **Config** (the whole
40
- `.dev_properties.json` as one form, plus signing and keychain secrets; `Tab`
41
- moves between fields, `Enter` saves, `Ctrl+O` on the addon field picks an
42
- addon from the site's Addon Repository), **Versions** (the versions uploaded
43
- to the site, `a` activates one) and **Log** (streaming build and deploy
44
- output). Dev and watch keep running in the background while you navigate
45
- between apps.
46
-
47
- Single-letter keys drive everything; the bottom bar shows the ones that apply.
48
- `/` opens the command palette with every action, `Tab` switches between the
49
- navigator and the content pane, `1`–`4` pick a tab, `q` quits.
50
-
51
- | Key | Action |
52
- | --------------- | ----------------------------------------------------------------- |
53
- | `d` / `w` | Dev (build, sign, deploy on change) / Watch (build and sign only) |
54
- | `b` / `s` | Build / Sign |
55
- | `p` / `P` | Deploy to dev / force deploy |
56
- | `a` | Versions tab: list and activate remote versions |
57
- | `e` / `y` / `l` | Edit dev properties / apply package.json sync / log in |
58
- | `K` | Stop the running task for the selected app |
59
-
60
- ### Settings
61
-
62
- `,` (or "Settings" in the palette) opens the global preferences, stored in
63
- `~/.config/sitevision-cli/config.json`: the UI language (English or Swedish,
64
- which also picks the manifest name language) and whether the intro animation
65
- plays. In workspace mode the same screen has a row that jumps to the shared
66
- workspace config.
67
-
68
- ### Environments
69
-
70
- The top-level fields of `.dev_properties.json` are one environment, called
71
- **dev** unless `baseEnvironment` says otherwise (a repo that only has a
72
- production site can set `"baseEnvironment": "prod"`). Add more under
73
- `environments`, overriding only what differs:
74
-
75
- ```json
76
- {
77
- "domain": "acme-use.sitevision-cloud.se",
78
- "siteName": "Intranet",
79
- "username": "me@acme.se",
80
- "environments": {
81
- "test": {"domain": "acme-tse.sitevision-cloud.se"},
82
- "prod": {"domain": "acme.sitevision-cloud.se", "authMethod": "oauth2"}
83
- }
84
- }
85
- ```
86
-
87
- `E` cycles the active environment (also "Switch environment" and "Add
88
- environment" in the palette); the choice is remembered in `.svcconfig`. The
89
- top bar shows a badge, green for dev, yellow for others, red for production.
90
- Versions, deploy, login state and the Config tab all follow the active
91
- environment; on a non-dev environment the Config tab edits that environment's
92
- overrides. Override names containing `prod`, or any environment with `"production":
93
- true`, are production: deploy needs the signed zip, confirms, and activates,
94
- and dev or watch refuse to run against them. The base environment is never
95
- production by name, only by the flag, so a prod-only repo keeps its dev loop.
96
- Both settings have rows in the Config tab under ENVIRONMENT.
97
-
98
- ### Shared configuration in a workspace
99
-
100
- `.dev_properties.json` is resolved by merging every ancestor directory's file
101
- (up to the repo root) under the app's own file, nearest wins. Put the shared
102
- fields (`domain`, `siteName`, `username`, `authMethod`, `oauth2`,
103
- `signingUsername`, ...) once at the repo root and keep only `addonName` in each
104
- app. The Config tab marks inherited values with `↑ root`, and saving an app's
105
- config never copies inherited values into the app file. Keychain entries are
106
- keyed by domain and username, so one login covers every app on the site.
107
-
108
- ### Direct Commands
109
-
110
- You can also run commands directly:
111
-
112
- #### Development
113
-
114
- ```bash
115
- # Start development server with watch mode
116
- svc dev
117
-
118
- # Start development server with automatic signing
119
- svc dev --signed
120
- ```
121
-
122
- #### Building
25
+ Requires Node.js 22+.
123
26
 
124
27
  ```bash
125
- # Build the application for production
126
- svc build
127
- ```
128
-
129
- #### Signing
130
-
131
- ```bash
132
- # Sign the app for production deployment
133
- svc sign
28
+ npm install --global sitevision-cli
134
29
  ```
135
30
 
136
- #### Deployment
31
+ ## Quick start
137
32
 
138
33
  ```bash
139
- # Deploy to development server
140
- svc deploy
141
-
142
- # Force deploy (overwrite existing)
143
- svc deploy --force
144
-
145
- # Deploy to production (requires signed app)
146
- svc deploy --production
34
+ cd my-repo # or cd into a single app
35
+ svc
147
36
  ```
148
37
 
149
- #### Setup
38
+ 1. Pick an app in the navigator and press `Enter`. (In a new repo the shell
39
+ opens on **Workspace settings** first.)
40
+ 2. Press `2` for the **Config** tab and fill in domain, site name, addon name,
41
+ username and auth method. Each field saves on `Enter`.
42
+ 3. Press `i` to install dependencies if needed, then `d` to start dev: build on
43
+ every change and deploy. Output is in the **Log** tab (`4`).
44
+
45
+ For production: switch environment with `E`, press `b` to build, `s` to sign
46
+ and `p` to deploy and activate.
47
+
48
+ ## The shell
49
+
50
+ | Key | Action |
51
+ | --------------- | --------------------------------------------------- |
52
+ | `d` / `w` | Dev (build + deploy on change) / Watch (build only) |
53
+ | `b` / `s` | Build / Sign |
54
+ | `p` / `P` | Deploy / force deploy to the active environment |
55
+ | `a` | Versions: list and activate uploaded versions |
56
+ | `E` | Switch environment |
57
+ | `e` / `y` / `i` | Config tab / sync `package.json` / `npm install` |
58
+ | `l` | Log in again |
59
+ | `K` | Stop running tasks |
60
+ | `1`–`4` | Overview · Config · Versions · Log |
61
+ | `/` | Command palette |
62
+ | `,` | Settings (language, intro animation) |
63
+ | `Tab` / `Esc` | Switch pane / back |
64
+ | `q` | Quit |
65
+
66
+ In the navigator, typing filters the app list; action keys work once `Enter` or
67
+ `Tab` has moved focus to the content pane. The bottom bar always shows the keys
68
+ that apply. `svc --minimal` gives a compact layout for small panes.
69
+
70
+ ## Commands
150
71
 
151
72
  ```bash
152
- # Configure signing credentials
153
- svc setup-signing
73
+ svc # interactive shell
74
+ svc build # build to dist/<id>.zip
75
+ svc sign # sign to dist/<id>-signed.zip
76
+ svc deploy [--force] # deploy the zip
77
+ svc deploy --production [--activate] # deploy the signed zip
78
+ svc dev [--signed] # build + deploy on change
79
+ svc watch [--signed] # build on change, no deploy
80
+ svc info # project information
154
81
  ```
155
82
 
156
- #### Project Info
157
-
158
- ```bash
159
- # Show project information and configuration
160
- svc info
161
- ```
83
+ Direct commands use the base environment. `--token` and `--cookie` pass an
84
+ OAuth2 token or session cookie for one run.
162
85
 
163
86
  ## Configuration
164
87
 
165
- ### Development Properties (`.dev_properties.json`)
166
-
167
- Create this file in your project root for deployment configuration:
168
-
169
- ```json
170
- {
171
- "domain": "your-site.sitevision.se",
172
- "siteName": "YourSite",
173
- "addonName": "your-addon",
174
- "username": "your-email@example.com",
175
- "useHTTPForDevDeploy": false,
176
- "signingUsername": "your-developer-account@example.com",
177
- "certificateName": "optional-certificate-name"
178
- }
179
- ```
180
-
181
- ### Keeping `package.json` in sync
182
-
183
- `sitevision-scripts` reads `developmentDomain`, `siteName` and `addonName`
184
- from `package.json`, which duplicates three fields of
185
- `.dev_properties.json`. When they disagree — or when a fresh setup has just
186
- written `.dev_properties.json` — `svc` shows the differences and offers to
187
- update `package.json` from `.dev_properties.json`. Nothing is written without
188
- confirmation, and `.dev_properties.json` is always the source of truth for
189
- the copy. Existing indentation and unrelated fields are left alone.
190
-
191
- After answering, `svc` offers to remember the choice in a `.svcconfig` file
192
- in the project root:
88
+ `.dev_properties.json` is your local config and the main source; keep it out of
89
+ git. Shared values are committed in `package.json` and act as defaults
90
+ underneath it:
193
91
 
194
92
  ```json
195
93
  {
196
- "syncPackageJson": true
94
+ "developmentDomain": "acme-use.sitevision-cloud.se",
95
+ "siteName": "Intranet",
96
+ "addonName": "my-addon",
97
+ "svc": {
98
+ "authMethod": "oauth2",
99
+ "environments": {"prod": {"domain": "acme.sitevision-cloud.se"}}
100
+ }
197
101
  }
198
102
  ```
199
103
 
200
- With `true`, `svc` updates `package.json` automatically without asking; with
201
- `false`, the check is skipped entirely. Delete the key (or the file) to be
202
- asked again. The file contains no secrets, so it is safe to commit.
104
+ `username`, `signingUsername` and `certificateName` are user-specific and only
105
+ live in `.dev_properties.json`. `y` copies shared values from
106
+ `.dev_properties.json` into `package.json`. In a workspace, shared values go in
107
+ the root `package.json` and each app's `package.json` only needs `addonName`.
203
108
 
204
- ### Password storage
109
+ ## Authentication in short
205
110
 
206
- Passwords are stored in the OS-native secret store (macOS Keychain, Windows
207
- Credential Manager, Linux libsecret) under the `sitevision-cli` service —
208
- never in `.dev_properties.json`. Run `svc` and complete the setup form (or
209
- enter the password when prompted at deploy/sign time and toggle "save to
210
- keychain") to populate it.
111
+ There are two separate credentials:
211
112
 
212
- If an existing `.dev_properties.json` contains a plaintext `password` field,
213
- the CLI offers to migrate it to the keychain on next launch and strip the
214
- field from the file. The migration prompt only appears in interactive mode
215
- (plain `svc`) — if you only ever invoke commands directly (`svc deploy`,
216
- `svc dev`), run `svc` once to migrate.
113
+ - **Deploy**: your account on the site. `authMethod` is `basic` (password),
114
+ `oauth2` (browser login against the site's OAuth2 provider, refreshed
115
+ silently afterwards) or `cookie` (log in with SSO in a Chrome window, the
116
+ session is captured).
117
+ - **Signing**: your developer.sitevision.se account, always username and
118
+ password.
217
119
 
218
- For CI / headless use, set `SITEVISION_DEPLOY_PASSWORD` and/or
219
- `SITEVISION_SIGNING_PASSWORD` — these take precedence over the keychain and
220
- are never written anywhere.
120
+ Everything secret goes in the OS keychain under `sitevision-cli`. For CI, set
121
+ `SITEVISION_DEPLOY_PASSWORD`, `SITEVISION_SIGNING_PASSWORD`,
122
+ `SITEVISION_ACCESS_TOKEN` or `SITEVISION_SESSION_COOKIE`.
221
123
 
222
- ### Signing Credentials
124
+ OAuth2 needs a client registered on the site with the redirect URI
125
+ `http://127.0.0.1:8137/callback`. The [user guide](docs/user-guide.md#5-authentication)
126
+ covers the setup, the pitfalls, and which method works with which command.
223
127
 
224
- Signing credentials are used to sign apps via developer.sitevision.se:
128
+ ## Development
225
129
 
226
- - `signingUsername` - Your developer.sitevision.se account
227
- - `certificateName` - Optional, if you have multiple certificates
130
+ ```bash
131
+ npm install
132
+ npm run build # tsc → dist/
133
+ npm test # prettier, xo, ava
134
+ ```
228
135
 
229
- The signing password is prompted on first use, with an option to save it to
230
- the OS keychain for future runs.
136
+ Releasing: see [RELEASING.md](RELEASING.md).
231
137
 
232
138
  ## License
233
139