@microsoft/power-apps-native-host 0.3.0 → 0.3.3

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 (62) hide show
  1. package/README.md +1 -9
  2. package/compatibility/current.json +31 -3
  3. package/lib/NOTICE.txt +1323 -16
  4. package/lib/commonjs/auth/AuthContext.js +36 -15
  5. package/lib/commonjs/config/babelConfig.js +13 -0
  6. package/lib/commonjs/config/expoConfig.js +71 -0
  7. package/lib/commonjs/config/metroConfig.js +70 -0
  8. package/lib/commonjs/config/metroLogger.js +182 -0
  9. package/lib/commonjs/config/tamaguiConfig.js +98 -0
  10. package/lib/commonjs/connections/resolveConnections.js +3 -2
  11. package/lib/commonjs/connections/useConnectionRefs.js +3 -1
  12. package/lib/commonjs/index.js +1 -0
  13. package/lib/commonjs/offline/useOfflineIntegrationWithStartupGate.js +3 -1
  14. package/lib/commonjs/platform/DataverseDataSources.js +4 -0
  15. package/lib/commonjs/platform/DataverseGlobalDiscoveryApi.js +45 -0
  16. package/lib/commonjs/provider/PowerAppsHostProvider.js +1 -0
  17. package/lib/commonjs/provider/PowerAppsProvider.js +10 -5
  18. package/lib/commonjs/provider/SessionDetailsGesture.js +1 -9
  19. package/lib/module/auth/AuthContext.js +36 -15
  20. package/lib/module/config/babelConfig.js +9 -0
  21. package/lib/module/config/expoConfig.js +67 -0
  22. package/lib/module/config/metroConfig.js +65 -0
  23. package/{scripts/metro-logger.js → lib/module/config/metroLogger.js} +58 -96
  24. package/lib/module/config/tamaguiConfig.js +93 -0
  25. package/lib/module/connections/resolveConnections.js +3 -2
  26. package/lib/module/connections/useConnectionRefs.js +3 -1
  27. package/lib/module/index.js +4 -0
  28. package/lib/module/offline/useOfflineIntegrationWithStartupGate.js +3 -1
  29. package/lib/module/platform/DataverseDataSources.js +3 -0
  30. package/lib/module/platform/DataverseGlobalDiscoveryApi.js +44 -0
  31. package/lib/module/provider/PowerAppsHostProvider.js +2 -1
  32. package/lib/module/provider/PowerAppsProvider.js +10 -5
  33. package/lib/module/provider/SessionDetailsGesture.js +1 -9
  34. package/lib/typescript/commonjs/config/babelConfig.d.ts +12 -0
  35. package/lib/typescript/commonjs/config/expoConfig.d.ts +21 -0
  36. package/lib/typescript/commonjs/config/metroConfig.d.ts +6 -0
  37. package/lib/typescript/commonjs/config/metroLogger.d.ts +21 -0
  38. package/lib/typescript/commonjs/config/tamaguiConfig.d.ts +1792 -0
  39. package/lib/typescript/commonjs/connections/resolveConnections.d.ts +3 -1
  40. package/lib/typescript/commonjs/connections/useConnectionRefs.d.ts +3 -1
  41. package/lib/typescript/commonjs/index.d.ts +2 -1
  42. package/lib/typescript/commonjs/platform/DataverseDataSources.d.ts +1 -0
  43. package/lib/typescript/commonjs/provider/PowerAppsProvider.d.ts +27 -4
  44. package/lib/typescript/module/config/babelConfig.d.ts +12 -0
  45. package/lib/typescript/module/config/expoConfig.d.ts +21 -0
  46. package/lib/typescript/module/config/metroConfig.d.ts +6 -0
  47. package/lib/typescript/module/config/metroLogger.d.ts +21 -0
  48. package/lib/typescript/module/config/tamaguiConfig.d.ts +1792 -0
  49. package/lib/typescript/module/connections/resolveConnections.d.ts +3 -1
  50. package/lib/typescript/module/connections/useConnectionRefs.d.ts +3 -1
  51. package/lib/typescript/module/index.d.ts +2 -1
  52. package/lib/typescript/module/platform/DataverseDataSources.d.ts +1 -0
  53. package/lib/typescript/module/provider/PowerAppsProvider.d.ts +27 -4
  54. package/package.json +47 -13
  55. package/scripts/build-codegen-package.js +283 -0
  56. package/scripts/start-web.mjs +9 -5
  57. package/scripts/upgrade/runtime.js +126 -17
  58. package/scripts/upgrade-authoring.js +274 -179
  59. package/scripts/upgrade.js +10 -16
  60. package/src/config/generated-config.d.ts +4 -0
  61. package/src/config/index.d.ts +3 -0
  62. package/src/config/tsconfig.json +26 -0
@@ -1,19 +1,27 @@
1
1
  'use strict';
2
2
 
3
3
  const childProcess = require('child_process');
4
- const crypto = require('crypto');
5
4
  const fs = require('fs');
6
5
  const path = require('path');
7
6
  const { setLockProvider, withLock } = require('@ster5/global-mutex');
8
7
  const { createTwoFilesPatch, parsePatch } = require('diff');
9
8
  const writeFileAtomic = require('write-file-atomic');
10
- const { HOST_PACKAGE, TEMPLATE_VERSION_PATH, assertSafeFilePath, hashContent, isCustomerOwnedPath, isHashOrNull, readJson } = require('./upgrade/runtime');
9
+ const { HOST_PACKAGE, TEMPLATE_VERSION_PATH, assertSafeFilePath, hashContent, isCustomerOwnedPath, readJson } = require('./upgrade/runtime');
11
10
 
12
11
  const AUTHORING_LOCK_PATH = 'packages/power-apps-native-host/compatibility/.migration-build.lock';
13
12
  const AUTHORING_JOURNAL_PATH = 'packages/power-apps-native-host/compatibility/.migration-build-journal.json';
14
13
  const TEMPLATE_PATH = 'templates/expo-app-standalone';
15
- const MIGRATION_CHANGE_PATH = 'migration-change';
16
14
  const TSCONFIG_PATH = 'tsconfig.json';
15
+ const GITIGNORE_PATH = '.gitignore';
16
+ const GITIGNORE_START = '# POWER APPS MANAGED START - DO NOT REMOVE OR RENAME THE COMMENT';
17
+ const GITIGNORE_END = '# POWER APPS MANAGED END - DO NOT REMOVE OR RENAME THE COMMENT';
18
+ const MANAGED_PACKAGE_FIELDS = ['main'];
19
+ const PROFILE_PATH = 'packages/power-apps-native-host/compatibility/current.json';
20
+ const TEMPLATE_BASELINE_TAG_PREFIX = 'standalone-template-migration-baseline-v';
21
+ const LEGACY_TEMPLATE_BASELINE_TAG_PATTERN = /^template-migration-baseline-v\d+$/;
22
+ const RELEASE_HOST_VERSION = '__RELEASE_HOST_VERSION__';
23
+ const NATIVE_RUNTIME_FINGERPRINT_SCRIPT = 'scripts/repository/native-runtime-fingerprint.js';
24
+ const NATIVE_RUNTIME_PLATFORMS = ['android', 'ios'];
17
25
  const CUSTOMER_SECTIONS = [
18
26
  {
19
27
  name: 'customer app settings',
@@ -43,7 +51,7 @@ function git(repositoryRoot, args, allowFailure = false) {
43
51
  }
44
52
 
45
53
  function resolveVersionTag(repositoryRoot, tag) {
46
- if (!tag) throw new Error('change/check requires --from-tag <version-tag> or MIGRATION_BASE_TAG');
54
+ if (!tag) throw new Error('build requires --from-tag <version-tag>');
47
55
  const tagRef = `refs/tags/${tag}`;
48
56
  if (git(repositoryRoot, ['show-ref', '--verify', '--quiet', tagRef], true) === null) {
49
57
  throw new Error(`${tag} is not a local Git tag; fetch tags or provide a valid --from-tag`);
@@ -55,6 +63,26 @@ function resolveVersionTag(repositoryRoot, tag) {
55
63
  return { tag, commit };
56
64
  }
57
65
 
66
+ function templateBaselineTag(templateVersion) {
67
+ return `${TEMPLATE_BASELINE_TAG_PREFIX}${templateVersion}`;
68
+ }
69
+
70
+ function inferBuildBaseTag(repositoryRoot) {
71
+ const profile = readJson(path.join(repositoryRoot, PROFILE_PATH));
72
+ if (!Number.isInteger(profile.targetTemplateVersion)) throw new Error('Current compatibility profile has an invalid targetTemplateVersion');
73
+ const currentTag = templateBaselineTag(profile.targetTemplateVersion);
74
+ if (git(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/tags/${currentTag}`], true) !== null) return currentTag;
75
+ const legacyTags = git(repositoryRoot, ['tag', '--merged', 'HEAD', '--list', 'template-migration-baseline-v*', '--sort=-version:refname'])
76
+ .trim().split('\n').filter(Boolean);
77
+ const legacyTag = legacyTags.find(tag => {
78
+ if (!LEGACY_TEMPLATE_BASELINE_TAG_PATTERN.test(tag)) return false;
79
+ const base = resolveVersionTag(repositoryRoot, tag);
80
+ return readTemplateState(repositoryRoot, base.commit)?.templateVersion === profile.targetTemplateVersion;
81
+ });
82
+ if (legacyTag) return legacyTag;
83
+ throw new Error(`Could not infer template migration baseline. Expected ${currentTag}.`);
84
+ }
85
+
58
86
  function getTemplateChanges(repositoryRoot, fromTag) {
59
87
  const base = resolveVersionTag(repositoryRoot, fromTag);
60
88
  const tracked = git(repositoryRoot, ['diff', '--no-renames', '--name-only', '--diff-filter=ACDMT', base.commit, '--', TEMPLATE_PATH])
@@ -65,93 +93,12 @@ function getTemplateChanges(repositoryRoot, fromTag) {
65
93
  return {
66
94
  baseTag: base.tag,
67
95
  baseCommit: base.commit,
68
- headCommit: git(repositoryRoot, ['rev-parse', 'HEAD']).trim(),
69
96
  files: paths.map(filePath => {
70
- const absolutePath = path.join(repositoryRoot, filePath);
71
- const content = fs.existsSync(absolutePath) ? fs.readFileSync(absolutePath) : null;
72
- return { path: filePath.slice(`${TEMPLATE_PATH}/`.length), targetHash: hashContent(content) };
97
+ return { path: filePath.slice(`${TEMPLATE_PATH}/`.length) };
73
98
  }),
74
99
  };
75
100
  }
76
101
 
77
- function readMigrationChanges(repositoryRoot) {
78
- const directory = path.join(repositoryRoot, MIGRATION_CHANGE_PATH);
79
- if (!fs.existsSync(directory)) return [];
80
- return fs.readdirSync(directory)
81
- .filter(name => name.endsWith('.json'))
82
- .sort()
83
- .map(name => validateMigrationChange(name, readJson(path.join(directory, name))));
84
- }
85
-
86
- function validateMigrationChange(name, record) {
87
- if (record.schemaVersion !== 1 || record.type !== 'template-migration' || typeof record.baseTag !== 'string' || typeof record.baseCommit !== 'string') {
88
- throw new Error(`Migration changelog ${name} has an invalid schema`);
89
- }
90
- if (!Array.isArray(record.files)) throw new Error(`Migration changelog ${name} has an invalid files list`);
91
- const seen = new Set();
92
- for (const file of record.files) {
93
- if (!file || !isSafeTemplatePath(file.path) || !isHashOrNull(file.targetHash)) throw new Error(`Migration changelog ${name} contains invalid file metadata`);
94
- if (seen.has(file.path)) throw new Error(`Migration changelog ${name} contains duplicate path ${file.path}`);
95
- seen.add(file.path);
96
- }
97
- return { name, ...record };
98
- }
99
-
100
- function isSafeTemplatePath(relativePath) {
101
- return typeof relativePath === 'string'
102
- && relativePath.length > 0
103
- && !relativePath.includes('\\')
104
- && !path.posix.isAbsolute(relativePath)
105
- && path.posix.normalize(relativePath) === relativePath
106
- && !relativePath.split('/').includes('..');
107
- }
108
-
109
- function createMigrationChange(repositoryRoot, options) {
110
- if (!options.message) throw new Error('change requires --message');
111
- if (!options.fromTag) throw new Error('change requires --from-tag <version-tag> or MIGRATION_BASE_TAG');
112
- const templateChanges = getTemplateChanges(repositoryRoot, options.fromTag);
113
- assertNoCustomerOwnedChanges(templateChanges.files);
114
- if (!templateChanges.files.length) throw new Error('No template changes were found');
115
- const record = {
116
- schemaVersion: 1,
117
- type: 'template-migration',
118
- comment: options.message,
119
- createdAt: new Date().toISOString(),
120
- ...templateChanges,
121
- };
122
- const slug = options.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'template-change';
123
- const relativePath = path.join(MIGRATION_CHANGE_PATH, `${slug}-${crypto.randomUUID().slice(0, 8)}.json`);
124
- console.log(`${options.dryRun ? '[dry-run] ' : ''}Create ${relativePath}`);
125
- if (!options.dryRun) {
126
- fs.mkdirSync(path.dirname(path.join(repositoryRoot, relativePath)), { recursive: true });
127
- fs.writeFileSync(path.join(repositoryRoot, relativePath), `${JSON.stringify(record, null, 2)}\n`);
128
- }
129
- return record;
130
- }
131
-
132
- function checkMigrationChanges(repositoryRoot, options) {
133
- const records = readMigrationChanges(repositoryRoot);
134
- const recordedTags = [...new Set(records.map(record => record.baseTag).filter(Boolean))];
135
- const changes = getTemplateChanges(repositoryRoot, options.fromTag || (recordedTags.length === 1 ? recordedTags[0] : null));
136
- assertNoCustomerOwnedChanges(changes.files);
137
- const covered = new Set(records.flatMap(record => record.files || []).map(file => `${file.path}:${file.targetHash}`));
138
- const current = new Set(changes.files.map(file => `${file.path}:${file.targetHash}`));
139
- if (!changes.files.length) {
140
- if (covered.size) throw new Error('Migration changelogs contain stale template changes but the template matches the version tag');
141
- console.log('No template changes require a migration changelog.');
142
- return;
143
- }
144
- const wrongBase = records.filter(record => record.baseTag !== changes.baseTag || record.baseCommit !== changes.baseCommit);
145
- if (wrongBase.length) throw new Error(`Migration changelogs must all use version tag ${changes.baseTag}; regenerate: ${wrongBase.map(record => record.name).join(', ')}`);
146
- const missing = changes.files.filter(file => !covered.has(`${file.path}:${file.targetHash}`));
147
- const stale = [...covered].filter(file => !current.has(file));
148
- if (missing.length) {
149
- throw new Error(`Template changes require an up-to-date migration changelog:\n${missing.map(file => ` - ${file.path}`).join('\n')}\nRun upgrade-template change --from-tag ${changes.baseTag} --message "description"`);
150
- }
151
- if (stale.length) throw new Error(`Migration changelogs contain stale template changes:\n${stale.map(file => ` - ${file.split(':')[0]}`).join('\n')}`);
152
- console.log(`${changes.files.length} template change(s) are covered by migration changelogs.`);
153
- }
154
-
155
102
  function assertNoCustomerOwnedChanges(files) {
156
103
  const protectedFiles = files.filter(file => isCustomerOwnedPath(file.path));
157
104
  if (protectedFiles.length) {
@@ -176,10 +123,9 @@ function fileMode(repositoryRoot, commit, relativePath) {
176
123
 
177
124
  async function runAuthoringCommand(command, startPath, options) {
178
125
  const repositoryRoot = findRepositoryRoot(startPath);
179
- if (command === 'change') return createMigrationChange(repositoryRoot, options);
180
- if (command === 'check') return checkMigrationChanges(repositoryRoot, options);
126
+ if (command !== 'build') throw new Error(`Unsupported template authoring command: ${command}`);
181
127
  return withAuthoringLock(repositoryRoot, () => {
182
- recoverAuthoringJournal(repositoryRoot);
128
+ if (recoverAuthoringJournal(repositoryRoot) === 'committed') return null;
183
129
  return buildMigrationStep(repositoryRoot, options);
184
130
  });
185
131
  }
@@ -200,28 +146,40 @@ async function withAuthoringLock(repositoryRoot, action) {
200
146
 
201
147
  function recoverAuthoringJournal(repositoryRoot) {
202
148
  const journalPath = path.join(repositoryRoot, AUTHORING_JOURNAL_PATH);
203
- if (!fs.existsSync(journalPath)) return;
149
+ if (!fs.existsSync(journalPath)) return null;
204
150
  const journal = readJson(journalPath);
205
151
  validateAuthoringJournal(repositoryRoot, journal);
206
152
  if (journal.status !== 'committed') {
207
153
  writeFileAtomic.sync(journal.profilePath, Buffer.from(journal.originalProfile, 'base64'));
154
+ if (journal.appJsonPath && journal.originalAppJson) writeFileAtomic.sync(journal.appJsonPath, Buffer.from(journal.originalAppJson, 'base64'));
208
155
  for (const outputPath of journal.outputPaths) fs.rmSync(outputPath, { force: true });
209
- for (const record of journal.records) {
210
- fs.mkdirSync(path.dirname(record.path), { recursive: true });
211
- writeFileAtomic.sync(record.path, Buffer.from(record.content, 'base64'));
212
- }
213
156
  console.warn('[upgrade-template] Recovered an interrupted migration build.');
214
157
  }
158
+ for (const filePath of [journal.profilePath, journal.appJsonPath, ...journal.outputPaths].filter(Boolean)) {
159
+ removeTemporarySiblings(filePath);
160
+ }
215
161
  fs.rmSync(journalPath, { force: true });
162
+ return journal.status;
163
+ }
164
+
165
+ function removeTemporarySiblings(filePath) {
166
+ const directory = path.dirname(filePath);
167
+ const temporaryPrefix = `${path.basename(filePath)}.tmp-`;
168
+ for (const name of fs.readdirSync(directory)) {
169
+ if (name.startsWith(temporaryPrefix)) fs.rmSync(path.join(directory, name), { force: true });
170
+ }
216
171
  }
217
172
 
218
173
  function validateAuthoringJournal(repositoryRoot, journal) {
219
174
  if (journal.schemaVersion !== 1 || !['preparing', 'committed'].includes(journal.status)) throw new Error('Migration build journal has an unsupported schema');
220
175
  const profilePath = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/current.json');
176
+ const appJsonPath = path.join(repositoryRoot, TEMPLATE_PATH, TEMPLATE_VERSION_PATH);
221
177
  const migrationDirectory = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/migrations');
222
- const changeDirectory = path.join(repositoryRoot, MIGRATION_CHANGE_PATH);
223
178
  if (path.resolve(journal.profilePath || '') !== profilePath || !isCanonicalBase64(journal.originalProfile)) throw new Error('Migration build journal has invalid profile backup data');
224
- if (!Array.isArray(journal.outputPaths) || !Array.isArray(journal.records)) throw new Error('Migration build journal has invalid file entries');
179
+ if (journal.appJsonPath !== undefined || journal.originalAppJson !== undefined) {
180
+ if (path.resolve(journal.appJsonPath || '') !== appJsonPath || !isCanonicalBase64(journal.originalAppJson)) throw new Error('Migration build journal has invalid app.json backup data');
181
+ }
182
+ if (!Array.isArray(journal.outputPaths)) throw new Error('Migration build journal has invalid file entries');
225
183
  const outputPaths = new Set();
226
184
  for (const outputPath of journal.outputPaths) {
227
185
  if (!isPathWithin(migrationDirectory, outputPath) || !/\.(?:json|patch)$/.test(outputPath) || outputPaths.has(path.resolve(outputPath))) {
@@ -230,14 +188,6 @@ function validateAuthoringJournal(repositoryRoot, journal) {
230
188
  assertSafeFilePath(repositoryRoot, path.resolve(outputPath));
231
189
  outputPaths.add(path.resolve(outputPath));
232
190
  }
233
- const recordPaths = new Set();
234
- for (const record of journal.records) {
235
- if (!record || !isPathWithin(changeDirectory, record.path) || !record.path.endsWith('.json') || !isCanonicalBase64(record.content) || recordPaths.has(path.resolve(record.path))) {
236
- throw new Error('Migration build journal has invalid changelog backup data');
237
- }
238
- assertSafeFilePath(repositoryRoot, path.resolve(record.path));
239
- recordPaths.add(path.resolve(record.path));
240
- }
241
191
  }
242
192
 
243
193
  function isPathWithin(directory, filePath) {
@@ -253,13 +203,76 @@ function isCanonicalBase64(value) {
253
203
 
254
204
  function updateProfileFromTemplate(profile, repositoryRoot) {
255
205
  const templatePackage = readJson(path.join(repositoryRoot, TEMPLATE_PATH, 'package.json'));
206
+ validateDevPlayerDependencies(repositoryRoot, templatePackage);
207
+ updateProfileFromPackage(profile, templatePackage);
208
+ }
209
+
210
+ function nextNativeRuntime(current, fingerprint) {
211
+ return {
212
+ version: current.version + (fingerprint === current.fingerprint ? 0 : 1),
213
+ fingerprint,
214
+ };
215
+ }
216
+
217
+ function updateNativeRuntime(profile, repositoryRoot) {
218
+ const previous = JSON.parse(JSON.stringify(profile.nativeRuntime));
219
+ for (const platform of NATIVE_RUNTIME_PLATFORMS) {
220
+ const current = profile.nativeRuntime[platform];
221
+ if (!Number.isInteger(current?.version) || current.version < 1 || !/^[a-f0-9]{64}$/.test(current.fingerprint || '')) {
222
+ throw new Error(`Current compatibility profile has invalid nativeRuntime.${platform}`);
223
+ }
224
+ const result = childProcess.spawnSync(
225
+ process.execPath,
226
+ [path.join(repositoryRoot, NATIVE_RUNTIME_FINGERPRINT_SCRIPT), platform, path.join(repositoryRoot, TEMPLATE_PATH)],
227
+ { cwd: repositoryRoot, encoding: 'utf8' },
228
+ );
229
+ if (result.error) throw result.error;
230
+ if (result.status !== 0) {
231
+ throw new Error(`Could not fingerprint the ${platform} native runtime: ${String(result.stderr || '').trim()}`);
232
+ }
233
+ const fingerprint = String(result.stdout || '').trim();
234
+ if (!/^[a-f0-9]{64}$/.test(fingerprint)) throw new Error(`Invalid ${platform} native runtime fingerprint`);
235
+ profile.nativeRuntime[platform] = nextNativeRuntime(current, fingerprint);
236
+ }
237
+ return previous;
238
+ }
239
+
240
+ function updateProfileFromPackage(profile, templatePackage) {
256
241
  const dependencies = { ...templatePackage.dependencies };
257
- profile.expoVersion = dependencies.expo;
242
+ const expoVersion = dependencies.expo;
243
+ const expoMajorMatch = typeof expoVersion === 'string' && expoVersion.match(/^(?:[~^])?(\d+)\./);
244
+ if (!expoMajorMatch) throw new Error('Standalone template package.json must define a supported Expo version');
245
+ profile.expoSdk = Number(expoMajorMatch[1]);
246
+ profile.expoVersion = expoVersion;
258
247
  delete dependencies.expo;
259
248
  delete dependencies[HOST_PACKAGE];
260
249
  profile.managedDependencies = dependencies;
261
250
  profile.managedDevDependencies = { ...templatePackage.devDependencies };
262
251
  profile.managedOverrides = { ...templatePackage.overrides };
252
+ profile.managedScripts = { ...templatePackage.scripts };
253
+ profile.managedFields = Object.fromEntries(MANAGED_PACKAGE_FIELDS
254
+ .filter(name => templatePackage[name] !== undefined)
255
+ .map(name => [name, templatePackage[name]]));
256
+ }
257
+
258
+ function validateDevPlayerDependencies(repositoryRoot, templatePackage) {
259
+ const devPlayerPackagePath = path.join(repositoryRoot, 'apps/dev-player/package.json');
260
+ if (!fs.existsSync(devPlayerPackagePath)) {
261
+ throw new Error('Dev Player package.json is missing; cannot validate standalone template dependencies');
262
+ }
263
+
264
+ const devPlayerPackage = readJson(devPlayerPackagePath);
265
+ const missingDependencies = Object.keys(templatePackage.dependencies || {})
266
+ .filter(name => !Object.hasOwn(devPlayerPackage.dependencies || {}, name));
267
+ const missingDevDependencies = Object.keys(templatePackage.devDependencies || {})
268
+ .filter(name => !Object.hasOwn(devPlayerPackage.devDependencies || {}, name));
269
+ const missing = [
270
+ ...missingDependencies.map(name => `dependencies.${name}`),
271
+ ...missingDevDependencies.map(name => `devDependencies.${name}`),
272
+ ].sort();
273
+ if (missing.length > 0) {
274
+ throw new Error(`Dev Player package.json is missing standalone template dependencies: ${missing.join(', ')}`);
275
+ }
263
276
  }
264
277
 
265
278
  function packageTransitions(baseValues = {}, targetValues = {}) {
@@ -272,23 +285,48 @@ function packageTransitions(baseValues = {}, targetValues = {}) {
272
285
  }]));
273
286
  }
274
287
 
275
- function tsconfigWithoutManagedPaths(tsconfig) {
276
- const clone = JSON.parse(JSON.stringify(tsconfig || {}));
277
- if (clone.compilerOptions?.paths) {
278
- delete clone.compilerOptions.paths;
279
- if (Object.keys(clone.compilerOptions).length === 0) delete clone.compilerOptions;
280
- }
281
- return clone;
288
+ function buildPackageTransitions(basePackage, targetPackage) {
289
+ return {
290
+ fields: packageTransitions(
291
+ Object.fromEntries(MANAGED_PACKAGE_FIELDS.filter(name => basePackage[name] !== undefined).map(name => [name, basePackage[name]])),
292
+ Object.fromEntries(MANAGED_PACKAGE_FIELDS.filter(name => targetPackage[name] !== undefined).map(name => [name, targetPackage[name]])),
293
+ ),
294
+ scripts: packageTransitions(basePackage.scripts, targetPackage.scripts),
295
+ dependencies: Object.fromEntries(Object.entries(packageTransitions(basePackage.dependencies, targetPackage.dependencies))
296
+ .filter(([name]) => name !== HOST_PACKAGE && name !== 'expo')),
297
+ devDependencies: packageTransitions(basePackage.devDependencies, targetPackage.devDependencies),
298
+ overrides: packageTransitions(basePackage.overrides, targetPackage.overrides),
299
+ };
282
300
  }
283
301
 
284
302
  function buildTsconfigTransitions(relativePath, baseContent, targetContent) {
285
303
  const base = baseContent ? JSON.parse(baseContent.toString('utf8')) : {};
286
304
  const target = targetContent ? JSON.parse(targetContent.toString('utf8')) : {};
287
- if (JSON.stringify(tsconfigWithoutManagedPaths(base)) !== JSON.stringify(tsconfigWithoutManagedPaths(target))) {
288
- throw new Error(`Template change ${relativePath} modifies unsupported tsconfig fields; add a structured upgrade handler before building this migration`);
289
- }
305
+ const fields = packageTransitions(
306
+ Object.fromEntries(['extends', 'include', 'exclude'].filter(name => base[name] !== undefined).map(name => [name, base[name]])),
307
+ Object.fromEntries(['extends', 'include', 'exclude'].filter(name => target[name] !== undefined).map(name => [name, target[name]])),
308
+ );
309
+ const baseCompilerOptions = { ...base.compilerOptions };
310
+ const targetCompilerOptions = { ...target.compilerOptions };
311
+ delete baseCompilerOptions.paths;
312
+ delete targetCompilerOptions.paths;
313
+ const compilerOptions = packageTransitions(baseCompilerOptions, targetCompilerOptions);
290
314
  const paths = packageTransitions(base.compilerOptions?.paths, target.compilerOptions?.paths);
291
- return Object.keys(paths).length ? { paths } : null;
315
+ const transitions = {};
316
+ if (Object.keys(fields).length) transitions.fields = fields;
317
+ if (Object.keys(compilerOptions).length) transitions.compilerOptions = compilerOptions;
318
+ if (Object.keys(paths).length) transitions.paths = paths;
319
+ return Object.keys(transitions).length ? transitions : null;
320
+ }
321
+
322
+ function readManagedGitignoreBlock(relativePath, content, required) {
323
+ if (content === null) return null;
324
+ const source = content.toString('utf8');
325
+ const start = source.indexOf(GITIGNORE_START);
326
+ const end = source.indexOf(GITIGNORE_END);
327
+ if (start === -1 && end === -1 && !required) return null;
328
+ if (start === -1 || end < start) throw new Error(`Template file ${relativePath} has invalid Power Apps managed markers`);
329
+ return source.slice(start + GITIGNORE_START.length, end).replace(/^\r?\n|\r?\n$/g, '');
292
330
  }
293
331
 
294
332
  function assertPatchableMode(relativePath, mode) {
@@ -297,22 +335,78 @@ function assertPatchableMode(relativePath, mode) {
297
335
  }
298
336
  }
299
337
 
338
+ function readTemplateState(repositoryRoot, commit) {
339
+ const content = commit
340
+ ? readGitFile(repositoryRoot, commit, TEMPLATE_VERSION_PATH)
341
+ : fs.readFileSync(path.join(repositoryRoot, TEMPLATE_PATH, TEMPLATE_VERSION_PATH));
342
+ return content ? JSON.parse(content.toString('utf8')).expo?.extra?.powerappsNative : null;
343
+ }
344
+
345
+ function buildTemplateVersionContent(repositoryRoot, templateVersion, nativeRuntime) {
346
+ const appJsonPath = path.join(repositoryRoot, TEMPLATE_PATH, TEMPLATE_VERSION_PATH);
347
+ const appConfig = readJson(appJsonPath);
348
+ appConfig.expo ||= {};
349
+ appConfig.expo.extra ||= {};
350
+ appConfig.expo.extra.powerappsNative ||= { schemaVersion: 1 };
351
+ appConfig.expo.extra.powerappsNative.schemaVersion ||= 1;
352
+ appConfig.expo.extra.powerappsNative.templateVersion = templateVersion;
353
+ appConfig.expo.extra.powerappsNative.nativeRuntimeVersions = Object.fromEntries(
354
+ NATIVE_RUNTIME_PLATFORMS.map(platform => [platform, nativeRuntime[platform].version]),
355
+ );
356
+ return `${JSON.stringify(appConfig, null, 2)}\n`;
357
+ }
358
+
300
359
  function buildMigrationStep(repositoryRoot, options) {
301
- if (!options.id) throw new Error('build requires --id');
302
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(options.id)) throw new Error('--id may contain only letters, numbers, dots, underscores, and hyphens');
303
- const records = readMigrationChanges(repositoryRoot);
304
- if (!records.length) throw new Error('No pending migration changelogs were found');
305
- const baseTags = [...new Set(records.map(record => record.baseTag))];
306
- const baseCommits = [...new Set(records.map(record => record.baseCommit))];
307
- if (baseTags.length !== 1 || baseCommits.length !== 1 || !baseTags[0]) throw new Error('All pending migration changelogs must use the same version tag');
308
- checkMigrationChanges(repositoryRoot, { fromTag: baseTags[0] });
309
-
310
- const paths = [...new Set(records.flatMap(record => (record.files || []).map(file => file.path)))].sort();
360
+ const fromTag = options.fromTag || inferBuildBaseTag(repositoryRoot);
361
+ const templateChanges = getTemplateChanges(repositoryRoot, fromTag);
362
+ const base = { tag: templateChanges.baseTag, commit: templateChanges.baseCommit };
363
+ const isLegacyBootstrap = !options.fromTag && LEGACY_TEMPLATE_BASELINE_TAG_PATTERN.test(fromTag);
364
+ const migrationFiles = isLegacyBootstrap
365
+ ? templateChanges.files.filter(file => !isCustomerOwnedPath(file.path))
366
+ : templateChanges.files;
367
+ assertNoCustomerOwnedChanges(migrationFiles);
368
+ if (!migrationFiles.length) {
369
+ if (options.skipIfNoChanges) {
370
+ console.log(`No standalone template changes found since ${base.tag}.`);
371
+ return null;
372
+ }
373
+ throw new Error('No template changes were found');
374
+ }
375
+
376
+ const appJsonPath = path.join(repositoryRoot, TEMPLATE_PATH, TEMPLATE_VERSION_PATH);
377
+ const originalAppJson = fs.readFileSync(appJsonPath);
378
+ const baseState = readTemplateState(repositoryRoot, base.commit);
379
+ if (!Number.isInteger(baseState?.templateVersion)) {
380
+ throw new Error(`Version tag ${base.tag} does not contain ${TEMPLATE_VERSION_PATH} expo.extra.powerappsNative.templateVersion`);
381
+ }
382
+ const currentState = readTemplateState(repositoryRoot, null);
383
+ if (currentState?.templateVersion !== baseState.templateVersion) {
384
+ throw new Error(`Template version must not be advanced before release build (${TEMPLATE_VERSION_PATH} is ${currentState?.templateVersion ?? 'missing'}, expected ${baseState.templateVersion})`);
385
+ }
386
+ if (migrationFiles.some(file => file.path === TEMPLATE_VERSION_PATH)) {
387
+ throw new Error(`${TEMPLATE_VERSION_PATH} changes are limited to the release-owned templateVersion bump; add a structured migration handler before changing other fields`);
388
+ }
389
+ const expectedTargetVersion = baseState.templateVersion + 1;
390
+ const migrationId = options.id || `template-v${expectedTargetVersion}`;
391
+ const migrationMessage = options.message || `Release standalone template v${expectedTargetVersion}`;
392
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(migrationId)) throw new Error('--id may contain only letters, numbers, dots, underscores, and hyphens');
393
+ const profilePath = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/current.json');
394
+ const profile = readJson(profilePath);
395
+ if (profile.targetTemplateVersion !== undefined && profile.targetTemplateVersion !== baseState.templateVersion) {
396
+ throw new Error(`Current profile targets template ${profile.targetTemplateVersion}, but migration starts at ${baseState.templateVersion}`);
397
+ }
398
+ const previousNativeRuntime = updateNativeRuntime(profile, repositoryRoot);
399
+ updateProfileFromTemplate(profile, repositoryRoot);
400
+ profile.targetTemplateVersion = expectedTargetVersion;
401
+ const targetAppJson = buildTemplateVersionContent(repositoryRoot, expectedTargetVersion, profile.nativeRuntime);
402
+
403
+ const paths = migrationFiles.map(file => file.path).sort();
311
404
  const patches = [];
312
405
  const patchFiles = {};
313
406
  let tsconfigTransitions = null;
407
+ let gitignoreTransition = null;
314
408
  for (const relativePath of paths) {
315
- const baseContent = readGitFile(repositoryRoot, baseCommits[0], relativePath);
409
+ const baseContent = readGitFile(repositoryRoot, base.commit, relativePath);
316
410
  const targetPath = path.join(repositoryRoot, TEMPLATE_PATH, relativePath);
317
411
  if (fs.existsSync(targetPath) && fs.lstatSync(targetPath).isSymbolicLink()) {
318
412
  throw new Error(`Symbolic link template change ${relativePath} is not supported`);
@@ -323,11 +417,18 @@ function buildMigrationStep(repositoryRoot, options) {
323
417
  tsconfigTransitions = buildTsconfigTransitions(relativePath, baseContent, targetContent);
324
418
  continue;
325
419
  }
420
+ if (relativePath === GITIGNORE_PATH) {
421
+ gitignoreTransition = {
422
+ from: readManagedGitignoreBlock(relativePath, baseContent, false),
423
+ to: readManagedGitignoreBlock(relativePath, targetContent, true),
424
+ };
425
+ continue;
426
+ }
326
427
  if ([TEMPLATE_VERSION_PATH, 'package.json', 'package-lock.json'].includes(relativePath)) continue;
327
428
  if (!isPatchableText(baseContent) || !isPatchableText(targetContent)) {
328
429
  throw new Error(`Binary template change ${relativePath} cannot be represented by a unified migration patch`);
329
430
  }
330
- const fromMode = fileMode(repositoryRoot, baseCommits[0], relativePath);
431
+ const fromMode = fileMode(repositoryRoot, base.commit, relativePath);
331
432
  const toMode = fileMode(repositoryRoot, null, relativePath);
332
433
  assertPatchableMode(relativePath, fromMode);
333
434
  assertPatchableMode(relativePath, toMode);
@@ -342,60 +443,48 @@ function buildMigrationStep(repositoryRoot, options) {
342
443
  targetContent === null ? '/dev/null' : relativePath,
343
444
  baseContent?.toString('utf8') || '',
344
445
  targetContent?.toString('utf8') || '',
345
- baseTags[0],
346
- options.id,
446
+ base.tag,
447
+ migrationId,
347
448
  { context: 3 },
348
449
  ));
349
450
  }
350
451
 
351
- const baseStateContent = readGitFile(repositoryRoot, baseCommits[0], TEMPLATE_VERSION_PATH);
352
- const recordedBaseState = baseStateContent ? JSON.parse(baseStateContent.toString('utf8')).expo?.extra?.powerappsNative : null;
353
- if (!recordedBaseState && !options.fromVersion) {
354
- throw new Error(`Version tag ${baseTags[0]} does not contain ${TEMPLATE_VERSION_PATH} expo.extra.powerappsNative; rerun build with --from-version <version>`);
355
- }
356
- const baseState = recordedBaseState || { schemaVersion: 1, templateVersion: options.fromVersion };
357
- const targetState = readJson(path.join(repositoryRoot, TEMPLATE_PATH, TEMPLATE_VERSION_PATH)).expo?.extra?.powerappsNative;
358
- if (targetState.templateVersion !== baseState.templateVersion + 1) {
359
- throw new Error(`Migration must advance exactly one template version (${baseState.templateVersion} -> ${baseState.templateVersion + 1})`);
360
- }
452
+ const targetState = JSON.parse(targetAppJson).expo?.extra?.powerappsNative;
453
+ if (targetState.templateVersion !== expectedTargetVersion) throw new Error(`Migration must advance exactly one template version (${baseState.templateVersion} -> ${expectedTargetVersion})`);
361
454
 
362
- const basePackageContent = readGitFile(repositoryRoot, baseCommits[0], 'package.json');
363
- if (!basePackageContent) throw new Error(`Version tag ${baseTags[0]} does not contain template package.json`);
455
+ const basePackageContent = readGitFile(repositoryRoot, base.commit, 'package.json');
456
+ if (!basePackageContent) throw new Error(`Version tag ${base.tag} does not contain template package.json`);
364
457
  const basePackage = JSON.parse(basePackageContent.toString('utf8'));
365
458
  const targetPackage = readJson(path.join(repositoryRoot, TEMPLATE_PATH, 'package.json'));
366
- const profilePath = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/current.json');
367
- const profile = readJson(profilePath);
368
- if (profile.targetTemplateVersion !== undefined && profile.targetTemplateVersion !== baseState.templateVersion) {
369
- throw new Error(`Current profile targets template ${profile.targetTemplateVersion}, but migration starts at ${baseState.templateVersion}`);
370
- }
371
- updateProfileFromTemplate(profile, repositoryRoot);
372
- profile.targetTemplateVersion = targetState.templateVersion;
373
459
  const step = {
374
460
  schemaVersion: 1,
375
- id: options.id,
376
- fromTag: baseTags[0],
377
- fromCommit: baseCommits[0],
461
+ id: migrationId,
462
+ fromTag: base.tag,
463
+ fromCommit: base.commit,
378
464
  fromTemplateVersion: baseState.templateVersion,
379
465
  toTemplateVersion: targetState.templateVersion,
380
- comments: records.map(record => record.comment),
381
- patchFile: `${options.id}.patch`,
466
+ comments: [migrationMessage],
467
+ patchFile: `${migrationId}.patch`,
382
468
  patchFiles,
383
- packageTransitions: {
384
- dependencies: Object.fromEntries(Object.entries(packageTransitions(basePackage.dependencies, targetPackage.dependencies))
385
- .filter(([name]) => name !== HOST_PACKAGE && name !== 'expo')),
386
- devDependencies: packageTransitions(basePackage.devDependencies, targetPackage.devDependencies),
387
- overrides: packageTransitions(basePackage.overrides, targetPackage.overrides),
469
+ packageTransitions: buildPackageTransitions(basePackage, targetPackage),
470
+ nativeRuntimeTransition: {
471
+ from: previousNativeRuntime,
472
+ to: profile.nativeRuntime,
388
473
  },
389
474
  ...(tsconfigTransitions ? { tsconfigTransitions } : {}),
475
+ ...(gitignoreTransition ? { gitignoreTransition } : {}),
390
476
  targetProfile: {
391
477
  id: profile.id,
392
- hostVersion: readJson(path.join(repositoryRoot, 'packages/power-apps-native-host/package.json')).version,
478
+ hostVersion: RELEASE_HOST_VERSION,
393
479
  expoSdk: profile.expoSdk,
394
480
  expoVersion: profile.expoVersion,
395
481
  expoDoctorVersion: profile.expoDoctorVersion,
396
482
  managedDependencies: profile.managedDependencies,
397
483
  managedDevDependencies: profile.managedDevDependencies,
398
484
  managedOverrides: profile.managedOverrides,
485
+ managedScripts: profile.managedScripts,
486
+ managedFields: profile.managedFields,
487
+ nativeRuntime: profile.nativeRuntime,
399
488
  },
400
489
  };
401
490
  if (!step.targetProfile.expoDoctorVersion) throw new Error('Current compatibility profile must define expoDoctorVersion');
@@ -406,62 +495,56 @@ function buildMigrationStep(repositoryRoot, options) {
406
495
  return readJson(metadataPath);
407
496
  });
408
497
  validateMigrationEdges([...existingEdges, step], targetState.templateVersion);
409
- if (!profile.migrations.includes(options.id)) profile.migrations.push(options.id);
498
+ if (!profile.migrations.includes(migrationId)) profile.migrations.push(migrationId);
410
499
 
411
500
  const migrationDirectory = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/migrations');
412
- const migrationPath = path.join(migrationDirectory, `${options.id}.json`);
501
+ const migrationPath = path.join(migrationDirectory, `${migrationId}.json`);
413
502
  const patchPath = path.join(migrationDirectory, step.patchFile);
414
- if (fs.existsSync(migrationPath) || fs.existsSync(patchPath)) throw new Error(`Migration ${options.id} already exists`);
415
- console.log(`${options.dryRun ? '[dry-run] ' : ''}Build migration ${options.id} from ${records.length} changelog(s)`);
503
+ if (fs.existsSync(migrationPath) || fs.existsSync(patchPath)) throw new Error(`Migration ${migrationId} already exists`);
504
+ console.log(`${options.dryRun ? '[dry-run] ' : ''}Build migration ${migrationId} from ${base.tag}`);
416
505
  if (!options.dryRun) {
417
506
  fs.mkdirSync(migrationDirectory, { recursive: true });
418
507
  const temporarySuffix = `.tmp-${process.pid}-${Date.now()}`;
419
508
  const temporaryMigrationPath = `${migrationPath}${temporarySuffix}`;
420
509
  const temporaryPatchPath = `${patchPath}${temporarySuffix}`;
421
510
  const temporaryProfilePath = `${profilePath}${temporarySuffix}`;
511
+ const temporaryAppJsonPath = `${appJsonPath}${temporarySuffix}`;
422
512
  const originalProfile = fs.readFileSync(profilePath);
423
- const recordContents = new Map(records.map(record => {
424
- const recordPath = path.join(repositoryRoot, MIGRATION_CHANGE_PATH, record.name);
425
- return [recordPath, fs.readFileSync(recordPath)];
426
- }));
427
513
  const journalPath = path.join(repositoryRoot, AUTHORING_JOURNAL_PATH);
428
514
  const journal = {
429
515
  schemaVersion: 1,
430
516
  status: 'preparing',
431
517
  profilePath,
432
518
  originalProfile: originalProfile.toString('base64'),
519
+ appJsonPath,
520
+ originalAppJson: originalAppJson.toString('base64'),
433
521
  outputPaths: [migrationPath, patchPath],
434
- records: [...recordContents].map(([recordPath, content]) => ({
435
- path: recordPath,
436
- content: content.toString('base64'),
437
- })),
438
522
  };
439
523
  writeFileAtomic.sync(journalPath, `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 });
440
524
  try {
441
525
  fs.writeFileSync(temporaryMigrationPath, `${JSON.stringify(step, null, 2)}\n`);
442
526
  fs.writeFileSync(temporaryPatchPath, patches.join('\n'), 'utf8');
443
527
  fs.writeFileSync(temporaryProfilePath, `${JSON.stringify(profile, null, 2)}\n`);
528
+ fs.writeFileSync(temporaryAppJsonPath, targetAppJson, 'utf8');
444
529
  readJson(temporaryMigrationPath);
445
530
  readJson(temporaryProfilePath);
531
+ readJson(temporaryAppJsonPath);
446
532
  parsePatch(fs.readFileSync(temporaryPatchPath, 'utf8'));
447
533
  fs.renameSync(temporaryPatchPath, patchPath);
448
534
  fs.renameSync(temporaryMigrationPath, migrationPath);
535
+ fs.renameSync(temporaryAppJsonPath, appJsonPath);
449
536
  fs.renameSync(temporaryProfilePath, profilePath);
450
- for (const recordPath of recordContents.keys()) fs.unlinkSync(recordPath);
451
537
  journal.status = 'committed';
452
538
  writeFileAtomic.sync(journalPath, `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 });
453
539
  fs.rmSync(journalPath, { force: true });
454
540
  } catch (error) {
455
541
  fs.writeFileSync(profilePath, originalProfile);
542
+ fs.writeFileSync(appJsonPath, originalAppJson);
456
543
  fs.rmSync(migrationPath, { force: true });
457
544
  fs.rmSync(patchPath, { force: true });
458
- for (const temporaryPath of [temporaryMigrationPath, temporaryPatchPath, temporaryProfilePath]) {
545
+ for (const temporaryPath of [temporaryMigrationPath, temporaryPatchPath, temporaryProfilePath, temporaryAppJsonPath]) {
459
546
  fs.rmSync(temporaryPath, { force: true });
460
547
  }
461
- for (const [recordPath, content] of recordContents) {
462
- fs.mkdirSync(path.dirname(recordPath), { recursive: true });
463
- fs.writeFileSync(recordPath, content);
464
- }
465
548
  fs.rmSync(journalPath, { force: true });
466
549
  throw error;
467
550
  }
@@ -522,13 +605,25 @@ function validateMigrationEdges(migrations, targetVersion) {
522
605
  }
523
606
  if (migrations.length) {
524
607
  const firstVersion = Math.min(...fromVersions);
608
+ if (firstVersion !== 1) throw new Error('Migration graph must start at template version 1');
525
609
  for (let version = firstVersion; version < targetVersion; version += 1) {
526
610
  if (!fromVersions.has(version)) throw new Error(`Migration graph is missing template version ${version} -> ${version + 1}`);
527
611
  }
528
612
  if (Math.max(...migrations.map(migration => migration.toTemplateVersion)) !== targetVersion) {
529
613
  throw new Error(`Migration graph does not reach target template version ${targetVersion}`);
530
614
  }
615
+ } else if (targetVersion > 1) {
616
+ throw new Error(`Migration graph does not reach target template version ${targetVersion}`);
531
617
  }
532
618
  }
533
619
 
534
- module.exports = { getTemplateChanges, runAuthoringCommand };
620
+ module.exports = {
621
+ RELEASE_HOST_VERSION,
622
+ buildPackageTransitions,
623
+ buildTsconfigTransitions,
624
+ getTemplateChanges,
625
+ nextNativeRuntime,
626
+ runAuthoringCommand,
627
+ updateProfileFromPackage,
628
+ validateDevPlayerDependencies,
629
+ };