scene-capability-engine 3.6.53 → 3.6.55

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 (33) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -1
  3. package/README.zh.md +6 -1
  4. package/bin/scene-capability-engine.js +2 -0
  5. package/docs/command-reference.md +29 -1
  6. package/docs/magicball-app-collection-phase-1.md +133 -0
  7. package/docs/magicball-cli-invocation-examples.md +40 -0
  8. package/docs/magicball-integration-doc-index.md +14 -6
  9. package/docs/magicball-integration-issue-tracker.md +42 -3
  10. package/docs/magicball-sce-adaptation-guide.md +36 -9
  11. package/docs/releases/README.md +2 -0
  12. package/docs/releases/v3.6.54.md +19 -0
  13. package/docs/releases/v3.6.55.md +18 -0
  14. package/docs/zh/releases/README.md +2 -0
  15. package/docs/zh/releases/v3.6.54.md +19 -0
  16. package/docs/zh/releases/v3.6.55.md +18 -0
  17. package/lib/app/collection-store.js +127 -0
  18. package/lib/app/install-apply-runner.js +192 -0
  19. package/lib/app/install-plan-service.js +410 -0
  20. package/lib/app/scene-workspace-store.js +132 -0
  21. package/lib/commands/app.js +281 -0
  22. package/lib/commands/device.js +194 -0
  23. package/lib/commands/scene.js +228 -0
  24. package/lib/device/current-device.js +158 -0
  25. package/lib/device/device-override-store.js +157 -0
  26. package/lib/problem/project-problem-projection.js +239 -0
  27. package/lib/workspace/collab-governance-audit.js +107 -0
  28. package/lib/workspace/collab-governance-gate.js +24 -4
  29. package/lib/workspace/takeover-baseline.js +76 -0
  30. package/package.json +1 -1
  31. package/template/.sce/README.md +1 -1
  32. package/template/.sce/config/problem-closure-policy.json +5 -0
  33. package/template/.sce/knowledge/problem/project-shared-problems.json +16 -0
@@ -5,6 +5,12 @@ const { spawnSync } = require('child_process');
5
5
  const fs = require('fs-extra');
6
6
  const chalk = require('chalk');
7
7
  const yaml = require('js-yaml');
8
+ const { listSceneWorkspaces, getSceneWorkspace } = require('../app/scene-workspace-store');
9
+ const { buildSceneWorkspaceApplyPlan } = require('../app/install-plan-service');
10
+ const { executeInstallPlan } = require('../app/install-apply-runner');
11
+ const { runAppRuntimeInstallCommand, runAppRuntimeActivateCommand, runAppRuntimeUninstallCommand } = require('./app');
12
+ const { loadDeviceOverride } = require('../device/device-override-store');
13
+ const { getCurrentDeviceProfile } = require('../device/current-device');
8
14
 
9
15
  const semver = require('semver');
10
16
  const SceneLoader = require('../scene-runtime/scene-loader');
@@ -50,6 +56,19 @@ const SCENE_ROUTE_POLICY_DIFF_KEYS = [
50
56
  'mode_bias.commit.critical',
51
57
  'max_alternatives'
52
58
  ];
59
+
60
+ async function runSceneWorkspaceCommand(handler, options = {}, context = 'scene workspace command') {
61
+ try {
62
+ await handler(options);
63
+ } catch (error) {
64
+ if (options && options.json) {
65
+ console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
66
+ } else {
67
+ console.error(chalk.red(`${context} failed:`), error.message);
68
+ }
69
+ process.exitCode = 1;
70
+ }
71
+ }
53
72
  const SCENE_PACKAGE_API_VERSION = 'sce.scene.package/v0.1';
54
73
  const SCENE_PACKAGE_KINDS = new Set([
55
74
  'scene-template',
@@ -202,6 +221,40 @@ function registerSceneCommands(program) {
202
221
  .command('scene')
203
222
  .description('Execute scene contracts with runtime guardrails');
204
223
 
224
+ const workspaceCmd = sceneCmd
225
+ .command('workspace')
226
+ .description('Inspect file-backed scene workspace intent definitions');
227
+
228
+ workspaceCmd
229
+ .command('list')
230
+ .description('List scene workspace definitions from .sce/app/scene-profiles')
231
+ .option('--limit <n>', 'Maximum rows', '100')
232
+ .option('--status <status>', 'Filter by workspace status')
233
+ .option('--query <text>', 'Free-text query against id/name/description/tags/collections')
234
+ .option('--json', 'Print machine-readable JSON output')
235
+ .action(async (options) => {
236
+ await runSceneWorkspaceCommand(runSceneWorkspaceListCommand, options, 'scene workspace list');
237
+ });
238
+
239
+ workspaceCmd
240
+ .command('show')
241
+ .description('Show one scene workspace definition')
242
+ .requiredOption('--workspace <id>', 'Workspace id or file basename')
243
+ .option('--json', 'Print machine-readable JSON output')
244
+ .action(async (options) => {
245
+ await runSceneWorkspaceCommand(runSceneWorkspaceShowCommand, options, 'scene workspace show');
246
+ });
247
+
248
+ workspaceCmd
249
+ .command('apply')
250
+ .description('Build a plan-first apply diff for one scene workspace')
251
+ .requiredOption('--workspace <id>', 'Workspace id or file basename')
252
+ .option('--execute', 'Reserved for future explicit execution; currently returns a blocked plan')
253
+ .option('--json', 'Print machine-readable JSON output')
254
+ .action(async (options) => {
255
+ await runSceneWorkspaceCommand(runSceneWorkspaceApplyCommand, options, 'scene workspace apply');
256
+ });
257
+
205
258
  sceneCmd
206
259
  .command('validate')
207
260
  .description('Validate scene manifest from spec or file path')
@@ -6883,6 +6936,178 @@ function printScaffoldSummary(options, summary) {
6883
6936
  console.log(` Dry Run: ${summary.dry_run ? 'yes' : 'no'}`);
6884
6937
  }
6885
6938
 
6939
+ async function runSceneWorkspaceListCommand(rawOptions = {}, dependencies = {}) {
6940
+ const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
6941
+ const projectRoot = dependencies.projectRoot || process.cwd();
6942
+ const fileSystem = dependencies.fileSystem || fs;
6943
+ const limitCandidate = Number.parseInt(`${options.limit || 100}`, 10);
6944
+ const limit = Number.isFinite(limitCandidate) && limitCandidate > 0 ? Math.min(limitCandidate, 1000) : 100;
6945
+ const workspaces = await listSceneWorkspaces(projectRoot, {
6946
+ fileSystem,
6947
+ query: options.query,
6948
+ status: options.status
6949
+ });
6950
+ const items = workspaces.slice(0, limit).map((item) => ({
6951
+ workspace_id: item.workspace_id,
6952
+ name: item.name,
6953
+ description: item.description,
6954
+ status: item.status,
6955
+ item_count: item.item_count,
6956
+ collection_ref_count: item.collection_refs.length,
6957
+ tags: item.tags,
6958
+ source_file: item.source_file
6959
+ }));
6960
+ const payload = {
6961
+ mode: 'scene-workspace-list',
6962
+ generated_at: new Date().toISOString(),
6963
+ query: {
6964
+ limit,
6965
+ status: typeof options.status === 'string' ? options.status.trim() || null : null,
6966
+ query: typeof options.query === 'string' ? options.query.trim() || null : null
6967
+ },
6968
+ summary: {
6969
+ total: items.length
6970
+ },
6971
+ items,
6972
+ view_model: {
6973
+ type: 'table',
6974
+ columns: ['workspace_id', 'name', 'status', 'item_count', 'collection_ref_count', 'source_file']
6975
+ }
6976
+ };
6977
+ if (options.json) {
6978
+ console.log(JSON.stringify(payload, null, 2));
6979
+ } else {
6980
+ console.log(chalk.blue(`Scene Workspace List (${items.length})`));
6981
+ items.forEach((item) => {
6982
+ console.log(` - ${item.workspace_id} | ${item.name} | ${item.status} | items=${item.item_count}`);
6983
+ });
6984
+ }
6985
+ return payload;
6986
+ }
6987
+
6988
+ async function runSceneWorkspaceShowCommand(rawOptions = {}, dependencies = {}) {
6989
+ const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
6990
+ const workspaceRef = typeof options.workspace === 'string' ? options.workspace.trim() : '';
6991
+ if (!workspaceRef) {
6992
+ throw new Error('--workspace is required');
6993
+ }
6994
+ const projectRoot = dependencies.projectRoot || process.cwd();
6995
+ const fileSystem = dependencies.fileSystem || fs;
6996
+ const workspace = await getSceneWorkspace(projectRoot, workspaceRef, { fileSystem });
6997
+ if (!workspace) {
6998
+ throw new Error(`scene workspace not found: ${workspaceRef}`);
6999
+ }
7000
+ const payload = {
7001
+ mode: 'scene-workspace-show',
7002
+ generated_at: new Date().toISOString(),
7003
+ query: {
7004
+ workspace: workspaceRef
7005
+ },
7006
+ summary: {
7007
+ workspace_id: workspace.workspace_id,
7008
+ name: workspace.name,
7009
+ status: workspace.status,
7010
+ item_count: workspace.item_count,
7011
+ collection_ref_count: workspace.collection_refs.length
7012
+ },
7013
+ workspace
7014
+ };
7015
+ if (options.json) {
7016
+ console.log(JSON.stringify(payload, null, 2));
7017
+ } else {
7018
+ console.log(chalk.blue('Scene Workspace Show'));
7019
+ console.log(` Workspace: ${payload.summary.workspace_id}`);
7020
+ console.log(` Name: ${payload.summary.name}`);
7021
+ console.log(` Status: ${payload.summary.status}`);
7022
+ console.log(` Items: ${payload.summary.item_count}`);
7023
+ console.log(` Collection Refs: ${payload.summary.collection_ref_count}`);
7024
+ }
7025
+ return payload;
7026
+ }
7027
+
7028
+ async function runSceneWorkspaceApplyCommand(rawOptions = {}, dependencies = {}) {
7029
+ const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
7030
+ const workspaceRef = typeof options.workspace === 'string' ? options.workspace.trim() : '';
7031
+ if (!workspaceRef) {
7032
+ throw new Error('--workspace is required');
7033
+ }
7034
+ const projectRoot = dependencies.projectRoot || process.cwd();
7035
+ const fileSystem = dependencies.fileSystem || fs;
7036
+ const stateStore = dependencies.stateStore || require('../state/sce-state-store').getSceStateStore(projectRoot, {
7037
+ fileSystem,
7038
+ env: dependencies.env || process.env
7039
+ });
7040
+ const currentDevice = await getCurrentDeviceProfile(projectRoot, {
7041
+ fileSystem,
7042
+ persistIfMissing: false
7043
+ });
7044
+ const deviceOverride = await loadDeviceOverride(projectRoot, { fileSystem });
7045
+ const plan = await buildSceneWorkspaceApplyPlan(projectRoot, {
7046
+ fileSystem,
7047
+ store: stateStore,
7048
+ workspaceRef,
7049
+ currentDevice,
7050
+ deviceOverride
7051
+ });
7052
+ const execution = options.execute
7053
+ ? await executeInstallPlan(plan, {
7054
+ store: stateStore,
7055
+ executeInstall: runAppRuntimeInstallCommand,
7056
+ executeActivate: runAppRuntimeActivateCommand,
7057
+ executeUninstall: runAppRuntimeUninstallCommand,
7058
+ dependencies: {
7059
+ projectPath: projectRoot,
7060
+ projectRoot,
7061
+ fileSystem,
7062
+ env: dependencies.env || process.env,
7063
+ stateStore
7064
+ },
7065
+ commandOptions: options
7066
+ })
7067
+ : {
7068
+ execute_supported: true,
7069
+ executed: false,
7070
+ blocked_reason: null,
7071
+ results: []
7072
+ };
7073
+ const payload = {
7074
+ mode: 'scene-workspace-apply',
7075
+ generated_at: new Date().toISOString(),
7076
+ execute_supported: execution.execute_supported,
7077
+ executed: execution.executed,
7078
+ execution_blocked_reason: execution.blocked_reason,
7079
+ execution: {
7080
+ results: execution.results,
7081
+ preflight_failures: execution.preflight_failures || []
7082
+ },
7083
+ current_device: currentDevice,
7084
+ device_override: deviceOverride,
7085
+ summary: {
7086
+ source_type: plan.source.type,
7087
+ source_id: plan.source.id,
7088
+ desired_app_count: plan.desired_apps.length,
7089
+ install_count: plan.counts.install,
7090
+ activate_count: plan.counts.activate,
7091
+ uninstall_count: plan.counts.uninstall,
7092
+ keep_count: plan.counts.keep,
7093
+ skip_count: plan.counts.skip
7094
+ },
7095
+ plan
7096
+ };
7097
+ if (options.json) {
7098
+ console.log(JSON.stringify(payload, null, 2));
7099
+ } else {
7100
+ console.log(chalk.blue('Scene Workspace Apply'));
7101
+ console.log(` Workspace: ${payload.summary.source_id}`);
7102
+ console.log(` Desired Apps: ${payload.summary.desired_app_count}`);
7103
+ console.log(` Install: ${payload.summary.install_count}`);
7104
+ console.log(` Uninstall: ${payload.summary.uninstall_count}`);
7105
+ console.log(` Keep: ${payload.summary.keep_count}`);
7106
+ console.log(` Skip: ${payload.summary.skip_count}`);
7107
+ }
7108
+ return payload;
7109
+ }
7110
+
6886
7111
  async function runSceneValidateCommand(rawOptions = {}, dependencies = {}) {
6887
7112
  const projectRoot = dependencies.projectRoot || process.cwd();
6888
7113
  const sceneLoader = dependencies.sceneLoader || new SceneLoader({ projectPath: projectRoot });
@@ -16552,6 +16777,9 @@ module.exports = {
16552
16777
  validateSceneContributeOptions,
16553
16778
  runSceneContributeCommand,
16554
16779
  printSceneContributeSummary,
16780
+ runSceneWorkspaceListCommand,
16781
+ runSceneWorkspaceShowCommand,
16782
+ runSceneWorkspaceApplyCommand,
16555
16783
  normalizeOntologyOptions,
16556
16784
  validateOntologyOptions,
16557
16785
  normalizeOntologyImpactOptions,
@@ -0,0 +1,158 @@
1
+ const crypto = require('crypto');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const fs = require('fs-extra');
5
+ const { MachineIdentifier } = require('../lock/machine-identifier');
6
+
7
+ function normalizeString(value) {
8
+ if (typeof value !== 'string') {
9
+ return '';
10
+ }
11
+ return value.trim();
12
+ }
13
+
14
+ function normalizeStringArray(value, fallback = []) {
15
+ if (!Array.isArray(value)) {
16
+ return [...fallback];
17
+ }
18
+ const seen = new Set();
19
+ const items = [];
20
+ for (const entry of value) {
21
+ const normalized = normalizeString(entry);
22
+ if (!normalized || seen.has(normalized)) {
23
+ continue;
24
+ }
25
+ seen.add(normalized);
26
+ items.push(normalized);
27
+ }
28
+ return items;
29
+ }
30
+
31
+ function safeHostname() {
32
+ try {
33
+ return os.hostname() || 'unknown-host';
34
+ } catch (_error) {
35
+ return 'unknown-host';
36
+ }
37
+ }
38
+
39
+ function safeUsername() {
40
+ try {
41
+ return os.userInfo().username || process.env.USER || process.env.USERNAME || 'unknown-user';
42
+ } catch (_error) {
43
+ return process.env.USER || process.env.USERNAME || 'unknown-user';
44
+ }
45
+ }
46
+
47
+ function buildEphemeralDeviceId({ hostname, user, platform, arch }) {
48
+ const hash = crypto
49
+ .createHash('sha1')
50
+ .update(`${hostname}::${user}::${platform}::${arch}`)
51
+ .digest('hex')
52
+ .slice(0, 12);
53
+ return `${hostname}-ephemeral-${hash}`;
54
+ }
55
+
56
+ function buildDefaultCapabilityTags({ platform, arch }) {
57
+ const tags = ['desktop'];
58
+ if (platform) {
59
+ tags.push(platform);
60
+ }
61
+ if (arch) {
62
+ tags.push(arch);
63
+ }
64
+ if (platform && arch) {
65
+ tags.push(`${platform}-${arch}`);
66
+ }
67
+ return normalizeStringArray(tags);
68
+ }
69
+
70
+ async function readJsonIfExists(filePath, fileSystem) {
71
+ if (!await fileSystem.pathExists(filePath)) {
72
+ return null;
73
+ }
74
+ return fileSystem.readJson(filePath);
75
+ }
76
+
77
+ async function getCurrentDeviceProfile(projectPath = process.cwd(), options = {}) {
78
+ const fileSystem = options.fileSystem || fs;
79
+ const configDir = options.configDir || path.join(projectPath, '.sce', 'config');
80
+ const machineIdFile = options.machineIdFile || path.join(configDir, 'machine-id.json');
81
+ const profilePath = options.profilePath || path.join(projectPath, '.sce', 'state', 'device', 'device-current.json');
82
+ const persistIfMissing = options.persistIfMissing === true;
83
+ const fallbackPlatform = normalizeString(options.platform) || os.platform();
84
+ const fallbackArch = normalizeString(options.arch) || os.arch();
85
+ const fallbackHostname = normalizeString(options.hostname) || safeHostname();
86
+ const fallbackUser = normalizeString(options.user) || safeUsername();
87
+
88
+ let machineInfo = null;
89
+ let identitySource = 'ephemeral-device-fingerprint';
90
+
91
+ if (persistIfMissing) {
92
+ const machineIdentifier = options.machineIdentifier || new MachineIdentifier(configDir);
93
+ machineInfo = await machineIdentifier.getMachineInfo();
94
+ identitySource = 'machine-identifier';
95
+ } else {
96
+ const persistedMachineId = await readJsonIfExists(machineIdFile, fileSystem);
97
+ if (persistedMachineId && typeof persistedMachineId === 'object') {
98
+ machineInfo = {
99
+ id: normalizeString(persistedMachineId.id),
100
+ hostname: normalizeString(persistedMachineId.hostname) || fallbackHostname,
101
+ createdAt: normalizeString(persistedMachineId.createdAt) || null,
102
+ platform: fallbackPlatform,
103
+ user: fallbackUser
104
+ };
105
+ if (machineInfo.id) {
106
+ identitySource = 'machine-identifier';
107
+ } else {
108
+ machineInfo = null;
109
+ }
110
+ }
111
+ }
112
+
113
+ if (!machineInfo) {
114
+ machineInfo = {
115
+ id: buildEphemeralDeviceId({
116
+ hostname: fallbackHostname,
117
+ user: fallbackUser,
118
+ platform: fallbackPlatform,
119
+ arch: fallbackArch
120
+ }),
121
+ hostname: fallbackHostname,
122
+ createdAt: null,
123
+ platform: fallbackPlatform,
124
+ user: fallbackUser
125
+ };
126
+ }
127
+
128
+ const profile = await readJsonIfExists(profilePath, fileSystem);
129
+ const normalizedProfile = profile && typeof profile === 'object' ? profile : {};
130
+ const platform = normalizeString(normalizedProfile.platform) || normalizeString(machineInfo.platform) || fallbackPlatform;
131
+ const arch = normalizeString(normalizedProfile.arch) || fallbackArch;
132
+ const capabilityTags = normalizeStringArray(
133
+ normalizedProfile.capability_tags || normalizedProfile.capabilityTags,
134
+ buildDefaultCapabilityTags({ platform, arch })
135
+ );
136
+
137
+ return {
138
+ device_id: normalizeString(normalizedProfile.device_id || normalizedProfile.deviceId || normalizedProfile.id) || machineInfo.id,
139
+ hostname: normalizeString(normalizedProfile.hostname) || normalizeString(machineInfo.hostname) || fallbackHostname,
140
+ label: normalizeString(normalizedProfile.label || normalizedProfile.name) || normalizeString(machineInfo.hostname) || fallbackHostname,
141
+ platform,
142
+ arch,
143
+ user: normalizeString(normalizedProfile.user) || normalizeString(machineInfo.user) || fallbackUser,
144
+ created_at: normalizeString(normalizedProfile.created_at || normalizedProfile.createdAt) || normalizeString(machineInfo.createdAt) || null,
145
+ capability_tags: capabilityTags,
146
+ metadata: normalizedProfile.metadata && typeof normalizedProfile.metadata === 'object' ? normalizedProfile.metadata : {},
147
+ profile_path: profile ? profilePath : null,
148
+ identity_source: profile
149
+ ? `${identitySource}+device-profile`
150
+ : identitySource,
151
+ persistent_id_available: identitySource === 'machine-identifier'
152
+ };
153
+ }
154
+
155
+ module.exports = {
156
+ getCurrentDeviceProfile,
157
+ buildDefaultCapabilityTags
158
+ };
@@ -0,0 +1,157 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+
4
+ function normalizeString(value) {
5
+ if (typeof value !== 'string') {
6
+ return '';
7
+ }
8
+ return value.trim();
9
+ }
10
+
11
+ function normalizeStringArray(value) {
12
+ if (!Array.isArray(value)) {
13
+ return [];
14
+ }
15
+ const seen = new Set();
16
+ const items = [];
17
+ for (const entry of value) {
18
+ const normalized = normalizeString(entry);
19
+ if (!normalized || seen.has(normalized)) {
20
+ continue;
21
+ }
22
+ seen.add(normalized);
23
+ items.push(normalized);
24
+ }
25
+ return items;
26
+ }
27
+
28
+ function normalizeBoolean(value, fallback = false) {
29
+ if (typeof value === 'boolean') {
30
+ return value;
31
+ }
32
+ return fallback;
33
+ }
34
+
35
+ function normalizeOverrideItem(item = {}) {
36
+ return {
37
+ app_id: normalizeString(item.app_id || item.appId) || null,
38
+ app_key: normalizeString(item.app_key || item.appKey) || null,
39
+ required: normalizeBoolean(item.required, false),
40
+ allow_local_remove: normalizeBoolean(item.allow_local_remove ?? item.allowLocalRemove, true),
41
+ priority: Number.isFinite(Number(item.priority)) ? Number(item.priority) : null,
42
+ default_entry: normalizeString(item.default_entry || item.defaultEntry) || null,
43
+ capability_tags: normalizeStringArray(item.capability_tags || item.capabilityTags),
44
+ metadata: item.metadata && typeof item.metadata === 'object' ? item.metadata : {}
45
+ };
46
+ }
47
+
48
+ function overrideItemKey(item = {}) {
49
+ return normalizeString(item.app_id) || normalizeString(item.app_key);
50
+ }
51
+
52
+ function normalizeDeviceOverride(raw = {}, filePath = null) {
53
+ const addedApps = Array.isArray(raw.added_apps || raw.addedApps)
54
+ ? (raw.added_apps || raw.addedApps)
55
+ .filter((item) => item && typeof item === 'object')
56
+ .map((item) => normalizeOverrideItem(item))
57
+ .filter((item) => item.app_id || item.app_key)
58
+ : [];
59
+
60
+ const dedupedAddedApps = [];
61
+ const seen = new Set();
62
+ for (const item of addedApps) {
63
+ const key = overrideItemKey(item);
64
+ if (!key || seen.has(key)) {
65
+ continue;
66
+ }
67
+ seen.add(key);
68
+ dedupedAddedApps.push(item);
69
+ }
70
+
71
+ return {
72
+ removed_apps: normalizeStringArray(raw.removed_apps || raw.removedApps),
73
+ added_apps: dedupedAddedApps,
74
+ metadata: raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : {},
75
+ source_file: filePath
76
+ };
77
+ }
78
+
79
+ function hasOwn(object, key) {
80
+ return Boolean(object) && Object.prototype.hasOwnProperty.call(object, key);
81
+ }
82
+
83
+ function mergeDeviceOverride(existing = {}, patch = {}, filePath = null) {
84
+ const normalizedExisting = normalizeDeviceOverride(existing, filePath);
85
+ const nextRaw = {
86
+ removed_apps: normalizedExisting.removed_apps,
87
+ added_apps: normalizedExisting.added_apps,
88
+ metadata: normalizedExisting.metadata
89
+ };
90
+
91
+ if (hasOwn(patch, 'removed_apps') || hasOwn(patch, 'removedApps')) {
92
+ nextRaw.removed_apps = patch.removed_apps || patch.removedApps;
93
+ }
94
+
95
+ if (hasOwn(patch, 'added_apps') || hasOwn(patch, 'addedApps')) {
96
+ nextRaw.added_apps = patch.added_apps || patch.addedApps;
97
+ }
98
+
99
+ if (hasOwn(patch, 'metadata')) {
100
+ const patchMetadata = patch.metadata && typeof patch.metadata === 'object' ? patch.metadata : {};
101
+ nextRaw.metadata = {
102
+ ...normalizedExisting.metadata,
103
+ ...patchMetadata
104
+ };
105
+ }
106
+
107
+ return normalizeDeviceOverride(nextRaw, filePath);
108
+ }
109
+
110
+ async function loadDeviceOverride(projectPath = process.cwd(), options = {}) {
111
+ const fileSystem = options.fileSystem || fs;
112
+ const filePath = options.filePath || path.join(projectPath, '.sce', 'state', 'device', 'device-override.json');
113
+ if (!await fileSystem.pathExists(filePath)) {
114
+ return normalizeDeviceOverride({}, null);
115
+ }
116
+ const raw = await fileSystem.readJson(filePath);
117
+ return normalizeDeviceOverride(raw, filePath);
118
+ }
119
+
120
+ async function saveDeviceOverride(projectPath = process.cwd(), override = {}, options = {}) {
121
+ const fileSystem = options.fileSystem || fs;
122
+ const filePath = options.filePath || path.join(projectPath, '.sce', 'state', 'device', 'device-override.json');
123
+ const normalized = normalizeDeviceOverride(override, filePath);
124
+ await fileSystem.ensureDir(path.dirname(filePath));
125
+ await fileSystem.writeJson(filePath, {
126
+ removed_apps: normalized.removed_apps,
127
+ added_apps: normalized.added_apps,
128
+ metadata: normalized.metadata
129
+ }, { spaces: 2 });
130
+ return normalizeDeviceOverride({
131
+ removed_apps: normalized.removed_apps,
132
+ added_apps: normalized.added_apps,
133
+ metadata: normalized.metadata
134
+ }, filePath);
135
+ }
136
+
137
+ async function upsertDeviceOverride(projectPath = process.cwd(), patch = {}, options = {}) {
138
+ const fileSystem = options.fileSystem || fs;
139
+ const filePath = options.filePath || path.join(projectPath, '.sce', 'state', 'device', 'device-override.json');
140
+ const existing = await loadDeviceOverride(projectPath, {
141
+ fileSystem,
142
+ filePath
143
+ });
144
+ const merged = mergeDeviceOverride(existing, patch, filePath);
145
+ return saveDeviceOverride(projectPath, merged, {
146
+ fileSystem,
147
+ filePath
148
+ });
149
+ }
150
+
151
+ module.exports = {
152
+ loadDeviceOverride,
153
+ normalizeDeviceOverride,
154
+ mergeDeviceOverride,
155
+ saveDeviceOverride,
156
+ upsertDeviceOverride
157
+ };