sitevision-cli 1.0.0-beta.13 → 1.0.0-beta.14

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.
Files changed (43) hide show
  1. package/dist/cli.js +70 -41
  2. package/dist/commands/build.js +1 -1
  3. package/dist/commands/dev.d.ts +8 -10
  4. package/dist/commands/dev.js +75 -390
  5. package/dist/commands/watch.js +5 -23
  6. package/dist/components/AuthLoginScreen.js +2 -1
  7. package/dist/components/DevPropertiesForm.js +6 -3
  8. package/dist/components/PasswordInput.js +2 -1
  9. package/dist/shell/AddonPicker.d.ts +14 -0
  10. package/dist/shell/AddonPicker.js +54 -0
  11. package/dist/shell/CommandPalette.d.ts +8 -0
  12. package/dist/shell/CommandPalette.js +63 -0
  13. package/dist/shell/ConfigForm.d.ts +35 -0
  14. package/dist/shell/ConfigForm.js +472 -0
  15. package/dist/shell/Frame.d.ts +52 -0
  16. package/dist/shell/Frame.js +98 -0
  17. package/dist/shell/Settings.d.ts +6 -0
  18. package/dist/shell/Settings.js +96 -0
  19. package/dist/shell/Shell.d.ts +8 -0
  20. package/dist/shell/Shell.js +520 -0
  21. package/dist/shell/Tabs.d.ts +36 -0
  22. package/dist/shell/Tabs.js +85 -0
  23. package/dist/shell/actions.d.ts +45 -0
  24. package/dist/shell/actions.js +0 -0
  25. package/dist/types/index.d.ts +12 -2
  26. package/dist/utils/config.d.ts +10 -0
  27. package/dist/utils/config.js +14 -0
  28. package/dist/utils/environments.d.ts +20 -0
  29. package/dist/utils/environments.js +74 -0
  30. package/dist/utils/i18n.d.ts +12 -0
  31. package/dist/utils/i18n.js +263 -0
  32. package/dist/utils/oauth2-auth.d.ts +1 -0
  33. package/dist/utils/oauth2-auth.js +4 -3
  34. package/dist/utils/project-detection.d.ts +23 -1
  35. package/dist/utils/project-detection.js +124 -51
  36. package/dist/utils/sitevision-api.d.ts +35 -0
  37. package/dist/utils/sitevision-api.js +74 -1
  38. package/dist/utils/tasks.d.ts +48 -0
  39. package/dist/utils/tasks.js +371 -0
  40. package/dist/utils/workspace.d.ts +8 -0
  41. package/dist/utils/workspace.js +48 -0
  42. package/package.json +1 -1
  43. package/readme.md +76 -24
@@ -0,0 +1,371 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { EventEmitter } from 'node:events';
4
+ import { useSyncExternalStore } from 'react';
5
+ import { WebpackRunner, hasLocalWebpackConfig } from './webpack-runner.js';
6
+ import { hasSitevisionScripts, runSitevisionScriptsBuild, getDelegatedZipPath, checkSitevisionScriptsCompatibility, } from './sitevision-scripts-runner.js';
7
+ import { copyStaticToBuild, copySrcToBuild, cleanBuild, createBuildZip, zipExists, } from './zip.js';
8
+ import { isBundledApp, getAppType, getFullAppId, getZipPath, getSignedZipPath, localizedText, } from './project-detection.js';
9
+ import { signApp, deployApp, deployProduction, activateApp, } from './sitevision-api.js';
10
+ import { ProcessRunner } from './process-runner.js';
11
+ const MAX_LINES = 2000;
12
+ const emitter = new EventEmitter();
13
+ let tasks = [];
14
+ let nextId = 1;
15
+ function notify() {
16
+ tasks = [...tasks];
17
+ emitter.emit('change');
18
+ }
19
+ export function getTasks() {
20
+ return tasks;
21
+ }
22
+ export function useTasks() {
23
+ return useSyncExternalStore(callback => {
24
+ emitter.on('change', callback);
25
+ return () => emitter.off('change', callback);
26
+ }, () => tasks);
27
+ }
28
+ export function runningTasks(appRoot) {
29
+ return tasks.filter(t => t.status === 'running' && (!appRoot || t.appRoot === appRoot));
30
+ }
31
+ export function clearFinished() {
32
+ tasks = tasks.filter(t => t.status === 'running');
33
+ notify();
34
+ }
35
+ function createTask(kind, project, label, stop = () => { }) {
36
+ const task = {
37
+ id: nextId++,
38
+ kind,
39
+ appRoot: project.root,
40
+ appName: localizedText(project.manifest.name) || project.manifest.id,
41
+ label,
42
+ status: 'running',
43
+ phase: 'starting',
44
+ startedAt: Date.now(),
45
+ lines: [],
46
+ stop,
47
+ };
48
+ tasks.push(task);
49
+ notify();
50
+ const log = (tag, text, level = 'info') => {
51
+ for (const line of text.trimEnd().split('\n')) {
52
+ task.lines.push({ time: Date.now(), tag, level, text: line });
53
+ }
54
+ if (task.lines.length > MAX_LINES) {
55
+ task.lines.splice(0, task.lines.length - MAX_LINES);
56
+ }
57
+ notify();
58
+ };
59
+ const finish = (status, error) => {
60
+ if (task.status !== 'running')
61
+ return;
62
+ task.status = status;
63
+ task.error = error;
64
+ task.endedAt = Date.now();
65
+ task.phase = status;
66
+ if (error)
67
+ log('svc', error, 'error');
68
+ notify();
69
+ };
70
+ return { task, log, finish };
71
+ }
72
+ function setPhase(task, phase) {
73
+ task.phase = phase;
74
+ notify();
75
+ }
76
+ const errorText = (error) => error instanceof Error ? error.message : String(error);
77
+ // ---------------------------------------------------------------------------
78
+ // One-shot steps shared by the tasks below.
79
+ // ---------------------------------------------------------------------------
80
+ /** Build the app to dist/<appId>.zip. Returns the zip path or throws. */
81
+ async function buildOnce(project, task, log, mode) {
82
+ const { root, manifest } = project;
83
+ cleanBuild(root);
84
+ if (isBundledApp(manifest) && !hasLocalWebpackConfig(root)) {
85
+ if (!hasSitevisionScripts(root)) {
86
+ throw new Error('No webpack.config.js found and @sitevision/sitevision-scripts is not installed. Run npm install.');
87
+ }
88
+ const { warning } = checkSitevisionScriptsCompatibility(root);
89
+ if (warning)
90
+ log('bld', warning, 'warn');
91
+ setPhase(task, 'building');
92
+ log('bld', 'building via sitevision-scripts');
93
+ const result = await runSitevisionScriptsBuild(root, chunk => log('bld', chunk));
94
+ if (!result.success)
95
+ throw new Error(result.error ?? 'Build failed');
96
+ const zipPath = getDelegatedZipPath(root, manifest.id);
97
+ if (!zipExists(zipPath)) {
98
+ throw new Error(`Build reported success but no zip was found at ${zipPath}.`);
99
+ }
100
+ return zipPath;
101
+ }
102
+ if (isBundledApp(manifest)) {
103
+ if (!WebpackRunner.isWebpackAvailable(root)) {
104
+ throw new Error('webpack not found. Run npm install.');
105
+ }
106
+ setPhase(task, 'building');
107
+ log('bld', 'compiling with webpack');
108
+ const runner = new WebpackRunner(root, {
109
+ mode,
110
+ cssPrefix: manifest.id,
111
+ restApp: getAppType(manifest) !== 'web' && getAppType(manifest) !== 'widget',
112
+ });
113
+ const result = await runner.run();
114
+ await runner.close();
115
+ reportBuild(result, log);
116
+ if (!result.success)
117
+ throw new Error(result.errors?.join('\n') || 'Build failed');
118
+ copyStaticToBuild(root);
119
+ }
120
+ else {
121
+ setPhase(task, 'copying');
122
+ log('bld', 'copying source files');
123
+ copySrcToBuild(root);
124
+ copyStaticToBuild(root);
125
+ }
126
+ setPhase(task, 'zipping');
127
+ return createBuildZip(root, getFullAppId(manifest.id));
128
+ }
129
+ function reportBuild(result, log) {
130
+ for (const warning of result.warnings ?? [])
131
+ log('bld', warning, 'warn');
132
+ for (const error of result.errors ?? [])
133
+ log('bld', error, 'error');
134
+ if (result.success) {
135
+ log('bld', `compiled in ${result.stats?.time ?? 0}ms${result.stats?.assets?.length ? ` · ${result.stats.assets.join(', ')}` : ''}`, 'ok');
136
+ }
137
+ }
138
+ async function signOnce(project, task, log, zipPath, credentials) {
139
+ setPhase(task, 'signing');
140
+ log('sgn', `signing via developer.sitevision.se${credentials.certificateName ? ` · cert ${credentials.certificateName}` : ''}`);
141
+ const signedPath = getSignedZipPath(project.root, project.manifest);
142
+ const result = await signApp(zipPath, credentials, signedPath);
143
+ if (!result.success)
144
+ throw new Error(result.error ?? 'Signing failed');
145
+ log('sgn', `signed ${path.basename(signedPath)}`, 'ok');
146
+ return signedPath;
147
+ }
148
+ async function deployOnce(project, task, log, zipPath, config, options) {
149
+ setPhase(task, 'deploying');
150
+ const appType = getAppType(project.manifest);
151
+ log('dep', `POST multipart → ${options.production ? 'production' : 'dev'} import · ${config.addonName}`);
152
+ const result = options.production
153
+ ? await deployProduction(zipPath, { ...config, activate: options.activate }, appType)
154
+ : await deployApp(zipPath, config, appType, options.force);
155
+ if (!result.success)
156
+ throw new Error(result.error ?? 'Deployment failed');
157
+ log('dep', `${result.message ?? 'deployed'}${result.executableId ? ` · exec ${result.executableId}` : ''}`, 'ok');
158
+ return result.executableId;
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // Tasks
162
+ // ---------------------------------------------------------------------------
163
+ export function startBuild(project) {
164
+ const { task, log, finish } = createTask('build', project, 'build');
165
+ void (async () => {
166
+ try {
167
+ const zip = await buildOnce(project, task, log, 'production');
168
+ log('bld', `created ${zip}`, 'ok');
169
+ finish('success');
170
+ }
171
+ catch (error) {
172
+ finish('error', errorText(error));
173
+ }
174
+ })();
175
+ return task;
176
+ }
177
+ export function startSign(project, credentials) {
178
+ const { task, log, finish } = createTask('sign', project, 'sign');
179
+ void (async () => {
180
+ try {
181
+ const zipPath = getZipPath(project.root, project.manifest);
182
+ if (!zipExists(zipPath)) {
183
+ throw new Error(`Zip file not found: ${zipPath}. Run build first.`);
184
+ }
185
+ await signOnce(project, task, log, zipPath, credentials);
186
+ finish('success');
187
+ }
188
+ catch (error) {
189
+ finish('error', errorText(error));
190
+ }
191
+ })();
192
+ return task;
193
+ }
194
+ export function startDeploy(project, config, options) {
195
+ const { task, log, finish } = createTask('deploy', project, options.production ? 'deploy production' : 'deploy');
196
+ void (async () => {
197
+ try {
198
+ const zipPath = options.production
199
+ ? getSignedZipPath(project.root, project.manifest)
200
+ : getZipPath(project.root, project.manifest);
201
+ if (!zipExists(zipPath)) {
202
+ throw new Error(`${options.production ? 'Signed zip' : 'Zip'} not found: ${zipPath}. Run ${options.production ? 'sign' : 'build'} first.`);
203
+ }
204
+ await deployOnce(project, task, log, zipPath, config, options);
205
+ finish('success');
206
+ }
207
+ catch (error) {
208
+ finish('error', errorText(error));
209
+ }
210
+ })();
211
+ return task;
212
+ }
213
+ export function startActivate(project, config, executableId, versionLabel) {
214
+ const { task, log, finish } = createTask('activate', project, `activate ${versionLabel}`);
215
+ void (async () => {
216
+ try {
217
+ setPhase(task, 'activating');
218
+ log('act', `PUT activateCustomModuleExecutable · ${versionLabel}`);
219
+ const result = await activateApp(executableId, config, getAppType(project.manifest));
220
+ if (!result.success)
221
+ throw new Error(result.error ?? 'Activation failed');
222
+ log('act', `${versionLabel} is now active`, 'ok');
223
+ finish('success');
224
+ }
225
+ catch (error) {
226
+ finish('error', errorText(error));
227
+ }
228
+ })();
229
+ return task;
230
+ }
231
+ export function startInstall(project) {
232
+ const runner = new ProcessRunner('npm', ['install'], project.root);
233
+ const { task, log, finish } = createTask('install', project, 'npm install', () => runner.kill());
234
+ setPhase(task, 'installing');
235
+ runner.on('output', (output) => log('npm', output.data, output.type === 'stderr' ? 'warn' : 'info'));
236
+ runner
237
+ .run()
238
+ .then(result => finish(result.exitCode === 0 ? 'success' : 'error', result.exitCode === 0
239
+ ? undefined
240
+ : `npm install exited with ${result.exitCode}`))
241
+ .catch(error => finish('error', errorText(error)));
242
+ return task;
243
+ }
244
+ const WATCH_TARGETS = [
245
+ 'src',
246
+ 'static',
247
+ 'i18n',
248
+ 'resource',
249
+ 'config',
250
+ 'manifest.json',
251
+ ];
252
+ /**
253
+ * Dev / watch loop: build on every source change, then optionally sign and
254
+ * deploy. Runs until `task.stop()`; the task stays in the registry meanwhile.
255
+ */
256
+ export function startDev(project, options) {
257
+ const { root, manifest } = project;
258
+ const watchers = [];
259
+ let webpack = null;
260
+ let debounce;
261
+ let building = false;
262
+ let pending = false;
263
+ const stop = () => {
264
+ clearTimeout(debounce);
265
+ for (const watcher of watchers)
266
+ watcher.close();
267
+ void webpack?.close().catch(() => { });
268
+ finish('stopped');
269
+ };
270
+ const { task, log, finish } = createTask(options.deploy ? 'dev' : 'watch', project, options.deploy ? 'dev' : 'watch', stop);
271
+ const afterBuild = async (zipPath) => {
272
+ let deployZip = zipPath;
273
+ if (options.signingCredentials) {
274
+ deployZip = await signOnce(project, task, log, zipPath, options.signingCredentials);
275
+ }
276
+ if (options.deploy && options.deployConfig) {
277
+ await deployOnce(project, task, log, deployZip, options.deployConfig, {
278
+ force: true,
279
+ });
280
+ }
281
+ setPhase(task, 'watching');
282
+ log('svc', 'watching for changes');
283
+ };
284
+ const fail = (error) => {
285
+ log('svc', errorText(error), 'error');
286
+ setPhase(task, 'error');
287
+ };
288
+ const rebuild = async () => {
289
+ if (building) {
290
+ pending = true;
291
+ return;
292
+ }
293
+ building = true;
294
+ try {
295
+ do {
296
+ pending = false;
297
+ try {
298
+ // eslint-disable-next-line no-await-in-loop
299
+ await afterBuild(await buildOnce(project, task, log, 'development'));
300
+ }
301
+ catch (error) {
302
+ fail(error);
303
+ }
304
+ } while (pending);
305
+ }
306
+ finally {
307
+ building = false;
308
+ }
309
+ };
310
+ const onChange = (name, file) => {
311
+ log('fs', `changed ${file ?? name}`);
312
+ clearTimeout(debounce);
313
+ debounce = setTimeout(() => {
314
+ void rebuild();
315
+ }, 300);
316
+ };
317
+ const watchFiles = () => {
318
+ for (const name of WATCH_TARGETS) {
319
+ const target = path.join(root, name);
320
+ if (!fs.existsSync(target))
321
+ continue;
322
+ const isDir = fs.statSync(target).isDirectory();
323
+ watchers.push(fs.watch(target, { recursive: isDir }, (_event, file) => {
324
+ onChange(name, file);
325
+ }));
326
+ }
327
+ log('svc', `watching ${WATCH_TARGETS.join(', ')}`);
328
+ };
329
+ void (async () => {
330
+ try {
331
+ cleanBuild(root);
332
+ if (isBundledApp(manifest) && hasLocalWebpackConfig(root)) {
333
+ // Project ships its own webpack config: incremental in-process watch.
334
+ if (!WebpackRunner.isWebpackAvailable(root)) {
335
+ throw new Error('webpack not found. Run npm install.');
336
+ }
337
+ setPhase(task, 'building');
338
+ webpack = new WebpackRunner(root, {
339
+ mode: 'development',
340
+ watch: true,
341
+ cssPrefix: manifest.id,
342
+ restApp: getAppType(manifest) !== 'web' && getAppType(manifest) !== 'widget',
343
+ });
344
+ await webpack.watch(result => {
345
+ void (async () => {
346
+ reportBuild(result, log);
347
+ if (!result.success) {
348
+ setPhase(task, 'error');
349
+ return;
350
+ }
351
+ try {
352
+ copyStaticToBuild(root);
353
+ await afterBuild(await createBuildZip(root, getFullAppId(manifest.id)));
354
+ }
355
+ catch (error) {
356
+ fail(error);
357
+ }
358
+ })();
359
+ });
360
+ }
361
+ else {
362
+ watchFiles();
363
+ await rebuild();
364
+ }
365
+ }
366
+ catch (error) {
367
+ finish('error', errorText(error));
368
+ }
369
+ })();
370
+ return task;
371
+ }
@@ -0,0 +1,8 @@
1
+ import { type ProjectInfo } from './project-detection.js';
2
+ /**
3
+ * Find every Sitevision app below `root` (e.g. root/webapps/x, root/restapps/y).
4
+ * Depth-limited walk that skips dependency and output folders.
5
+ */
6
+ export declare function discoverApps(root: string): ProjectInfo[];
7
+ /** Group label for an app: its parent folder relative to the workspace root. */
8
+ export declare function appGroup(root: string, appRoot: string): string;
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { detectProject } from './project-detection.js';
4
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
5
+ const MAX_DEPTH = 3;
6
+ /**
7
+ * Find every Sitevision app below `root` (e.g. root/webapps/x, root/restapps/y).
8
+ * Depth-limited walk that skips dependency and output folders.
9
+ */
10
+ export function discoverApps(root) {
11
+ const found = [];
12
+ const walk = (dir, depth) => {
13
+ let entries;
14
+ try {
15
+ entries = fs.readdirSync(dir, { withFileTypes: true });
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ for (const entry of entries) {
21
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name))
22
+ continue;
23
+ if (entry.name.startsWith('.'))
24
+ continue;
25
+ const full = path.join(dir, entry.name);
26
+ let project = null;
27
+ try {
28
+ project = detectProject(full);
29
+ }
30
+ catch {
31
+ // Unparseable manifest: skip it; the app can still be opened directly.
32
+ }
33
+ if (project) {
34
+ found.push(project);
35
+ }
36
+ else if (depth < MAX_DEPTH) {
37
+ walk(full, depth + 1);
38
+ }
39
+ }
40
+ };
41
+ walk(root, 1);
42
+ return found.toSorted((a, b) => a.root.localeCompare(b.root));
43
+ }
44
+ /** Group label for an app: its parent folder relative to the workspace root. */
45
+ export function appGroup(root, appRoot) {
46
+ const relative = path.relative(root, path.dirname(appRoot));
47
+ return relative === '' ? '.' : relative;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitevision-cli",
3
- "version": "1.0.0-beta.13",
3
+ "version": "1.0.0-beta.14",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "svc": "dist/cli.js"
package/readme.md CHANGED
@@ -25,33 +25,85 @@ npm install --global sitevision-cli
25
25
 
26
26
  The CLI must be run inside a Sitevision project directory (containing a `manifest.json`).
27
27
 
28
- ### Interactive Mode
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:
29
74
 
30
- Simply run `svc` to launch the interactive menu:
31
-
32
- ```bash
33
- svc
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
+ }
34
85
  ```
35
86
 
36
- On first run (or if setup is incomplete), the CLI will:
37
-
38
- 1. Check if `node_modules` exists and offer to run `npm install` if missing
39
- 2. Check if dev properties are configured and offer to set them up if missing
40
- 3. Check if you have setup signing credentials and offer to do so if missing
41
- 4. Display project information
42
- 5. Show the main menu
43
-
44
- Use arrow keys to navigate and Enter to select:
45
-
46
- - **Dev** - Start development server with watch mode
47
- - **Dev (Signed)** - Development with automatic signing before each deploy
48
- - **Build** - Build a dist bundle
49
- - **Sign** - Sign built dist bundle
50
- - **Deploy** - Deploy to configured development environment
51
- - **Deploy (Force)** - Force deploy (overwrite existing)
52
- - **Deploy Production** - Deploy signed app to configured production environment
53
- - **Info** - Show project info
54
- - **Exit**
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.
55
107
 
56
108
  ### Direct Commands
57
109