@microsoft/power-apps-native-host 0.2.22 → 0.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -26
- package/compatibility/current.json +88 -0
- package/lib/NOTICE.txt +146 -1226
- package/lib/commonjs/auth/AuthContext.web.js +32 -0
- package/lib/commonjs/auth/completePowerAppsAuthSession.js +16 -0
- package/lib/commonjs/bridge/NativePowerAppsBridge.js +11 -1
- package/lib/commonjs/bridge/PowerPagesBridge.js +138 -0
- package/lib/commonjs/index.js +22 -2
- package/lib/commonjs/offline/useOfflineIntegration.js +12 -0
- package/lib/commonjs/offline/useOfflineIntegration.web.js +11 -0
- package/lib/commonjs/provider/PowerAppsHostProvider.web.js +41 -0
- package/lib/commonjs/provider/PowerAppsProvider.js +67 -10
- package/lib/module/auth/AuthContext.web.js +26 -0
- package/lib/module/auth/completePowerAppsAuthSession.js +11 -0
- package/lib/module/bridge/NativePowerAppsBridge.js +11 -1
- package/lib/module/bridge/PowerPagesBridge.js +132 -0
- package/lib/module/index.js +4 -2
- package/lib/module/offline/useOfflineIntegration.js +3 -0
- package/lib/module/offline/useOfflineIntegration.web.js +7 -0
- package/lib/module/provider/PowerAppsHostProvider.web.js +36 -0
- package/lib/module/provider/PowerAppsProvider.js +66 -9
- package/lib/typescript/commonjs/auth/AuthContext.web.d.ts +20 -0
- package/lib/typescript/commonjs/auth/completePowerAppsAuthSession.d.ts +8 -0
- package/lib/typescript/commonjs/bridge/PowerPagesBridge.d.ts +18 -0
- package/lib/typescript/commonjs/index.d.ts +3 -0
- package/lib/typescript/commonjs/offline/useOfflineIntegration.d.ts +2 -0
- package/lib/typescript/commonjs/offline/useOfflineIntegration.web.d.ts +4 -0
- package/lib/typescript/commonjs/provider/PowerAppsHostProvider.web.d.ts +5 -0
- package/lib/typescript/commonjs/provider/PowerAppsProvider.d.ts +2 -2
- package/lib/typescript/module/auth/AuthContext.web.d.ts +20 -0
- package/lib/typescript/module/auth/completePowerAppsAuthSession.d.ts +8 -0
- package/lib/typescript/module/bridge/PowerPagesBridge.d.ts +18 -0
- package/lib/typescript/module/index.d.ts +7 -4
- package/lib/typescript/module/offline/useOfflineIntegration.d.ts +2 -0
- package/lib/typescript/module/offline/useOfflineIntegration.web.d.ts +4 -0
- package/lib/typescript/module/provider/PowerAppsHostProvider.web.d.ts +5 -0
- package/lib/typescript/module/provider/PowerAppsProvider.d.ts +4 -4
- package/package.json +36 -16
- package/scripts/build-ios-base.js +66 -27
- package/scripts/bundle.js +212 -5
- package/scripts/generate-connector-schemas.js +8 -6
- package/scripts/start-web.mjs +137 -0
- package/scripts/upgrade/runtime.js +457 -0
- package/scripts/upgrade/system.js +244 -0
- package/scripts/upgrade-authoring.js +535 -0
- package/scripts/upgrade.js +188 -0
- package/scripts/wrap.js +536 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const childProcess = require('child_process');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { setLockProvider, withLock } = require('@ster5/global-mutex');
|
|
8
|
+
const { createTwoFilesPatch, parsePatch } = require('diff');
|
|
9
|
+
const writeFileAtomic = require('write-file-atomic');
|
|
10
|
+
const { HOST_PACKAGE, TEMPLATE_VERSION_PATH, assertSafeFilePath, hashContent, isCustomerOwnedPath, isHashOrNull, readJson } = require('./upgrade/runtime');
|
|
11
|
+
|
|
12
|
+
const AUTHORING_LOCK_PATH = 'packages/power-apps-native-host/compatibility/.migration-build.lock';
|
|
13
|
+
const AUTHORING_JOURNAL_PATH = 'packages/power-apps-native-host/compatibility/.migration-build-journal.json';
|
|
14
|
+
const TEMPLATE_PATH = 'templates/expo-app-standalone';
|
|
15
|
+
const MIGRATION_CHANGE_PATH = 'migration-change';
|
|
16
|
+
const TSCONFIG_PATH = 'tsconfig.json';
|
|
17
|
+
const CUSTOMER_SECTIONS = [
|
|
18
|
+
{
|
|
19
|
+
name: 'customer app settings',
|
|
20
|
+
start: '// CUSTOMER APP SETTINGS START - DO NOT REMOVE OR RENAME THE COMMENT',
|
|
21
|
+
end: '// CUSTOMER APP SETTINGS END - DO NOT REMOVE OR RENAME THE COMMENT',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'customer customization',
|
|
25
|
+
start: '// CUSTOMIZATION START - DO NOT REMOVE OR RENAME THE COMMENT',
|
|
26
|
+
end: '// CUSTOMIZATION END - DO NOT REMOVE OR RENAME THE COMMENT',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
setLockProvider('native');
|
|
31
|
+
|
|
32
|
+
function findRepositoryRoot(startPath) {
|
|
33
|
+
const result = childProcess.spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: startPath, encoding: 'utf8' });
|
|
34
|
+
if (result.status !== 0) throw new Error('Migration authoring commands must run inside a Git repository');
|
|
35
|
+
return result.stdout.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function git(repositoryRoot, args, allowFailure = false) {
|
|
39
|
+
const result = childProcess.spawnSync('git', args, { cwd: repositoryRoot, encoding: 'utf8' });
|
|
40
|
+
if (result.error) throw result.error;
|
|
41
|
+
if (result.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`);
|
|
42
|
+
return result.status === 0 ? result.stdout : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveVersionTag(repositoryRoot, tag) {
|
|
46
|
+
if (!tag) throw new Error('change/check requires --from-tag <version-tag> or MIGRATION_BASE_TAG');
|
|
47
|
+
const tagRef = `refs/tags/${tag}`;
|
|
48
|
+
if (git(repositoryRoot, ['show-ref', '--verify', '--quiet', tagRef], true) === null) {
|
|
49
|
+
throw new Error(`${tag} is not a local Git tag; fetch tags or provide a valid --from-tag`);
|
|
50
|
+
}
|
|
51
|
+
const commit = git(repositoryRoot, ['rev-parse', `${tagRef}^{commit}`]).trim();
|
|
52
|
+
if (git(repositoryRoot, ['merge-base', '--is-ancestor', commit, 'HEAD'], true) === null) {
|
|
53
|
+
throw new Error(`Version tag ${tag} is not an ancestor of HEAD`);
|
|
54
|
+
}
|
|
55
|
+
return { tag, commit };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getTemplateChanges(repositoryRoot, fromTag) {
|
|
59
|
+
const base = resolveVersionTag(repositoryRoot, fromTag);
|
|
60
|
+
const tracked = git(repositoryRoot, ['diff', '--no-renames', '--name-only', '--diff-filter=ACDMT', base.commit, '--', TEMPLATE_PATH])
|
|
61
|
+
.trim().split('\n').filter(Boolean);
|
|
62
|
+
const untracked = git(repositoryRoot, ['ls-files', '--others', '--exclude-standard', '--', TEMPLATE_PATH])
|
|
63
|
+
.trim().split('\n').filter(Boolean);
|
|
64
|
+
const paths = [...new Set([...tracked, ...untracked])].sort();
|
|
65
|
+
return {
|
|
66
|
+
baseTag: base.tag,
|
|
67
|
+
baseCommit: base.commit,
|
|
68
|
+
headCommit: git(repositoryRoot, ['rev-parse', 'HEAD']).trim(),
|
|
69
|
+
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) };
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
function assertNoCustomerOwnedChanges(files) {
|
|
156
|
+
const protectedFiles = files.filter(file => isCustomerOwnedPath(file.path));
|
|
157
|
+
if (protectedFiles.length) {
|
|
158
|
+
throw new Error(`Template migrations cannot modify customer-owned paths:\n${protectedFiles.map(file => ` - ${file.path}`).join('\n')}\nMove shared behavior into the native host package or document the change as manual guidance.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readGitFile(repositoryRoot, commit, relativeTemplatePath) {
|
|
163
|
+
const result = childProcess.spawnSync('git', ['show', `${commit}:${TEMPLATE_PATH}/${relativeTemplatePath}`], { cwd: repositoryRoot, encoding: null });
|
|
164
|
+
if (result.error) throw result.error;
|
|
165
|
+
return result.status === 0 ? result.stdout : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fileMode(repositoryRoot, commit, relativePath) {
|
|
169
|
+
if (commit) {
|
|
170
|
+
const output = git(repositoryRoot, ['ls-tree', commit, `${TEMPLATE_PATH}/${relativePath}`], true);
|
|
171
|
+
return output?.trim().split(/\s+/)[0] || null;
|
|
172
|
+
}
|
|
173
|
+
const absolutePath = path.join(repositoryRoot, TEMPLATE_PATH, relativePath);
|
|
174
|
+
return fs.existsSync(absolutePath) ? (fs.statSync(absolutePath).mode & 0o777).toString(8).padStart(6, '0') : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function runAuthoringCommand(command, startPath, options) {
|
|
178
|
+
const repositoryRoot = findRepositoryRoot(startPath);
|
|
179
|
+
if (command === 'change') return createMigrationChange(repositoryRoot, options);
|
|
180
|
+
if (command === 'check') return checkMigrationChanges(repositoryRoot, options);
|
|
181
|
+
return withAuthoringLock(repositoryRoot, () => {
|
|
182
|
+
recoverAuthoringJournal(repositoryRoot);
|
|
183
|
+
return buildMigrationStep(repositoryRoot, options);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function withAuthoringLock(repositoryRoot, action) {
|
|
188
|
+
const lockPath = path.join(repositoryRoot, AUTHORING_LOCK_PATH);
|
|
189
|
+
const compatibilityDirectory = path.dirname(lockPath);
|
|
190
|
+
fs.mkdirSync(compatibilityDirectory, { recursive: true });
|
|
191
|
+
try {
|
|
192
|
+
return await withLock({ fileToLock: lockPath, stale: 30000, retries: 0 }, action);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (/already being held/i.test(error.message)) throw new Error('Another migration build is running');
|
|
195
|
+
throw error;
|
|
196
|
+
} finally {
|
|
197
|
+
fs.rmSync(lockPath, { force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function recoverAuthoringJournal(repositoryRoot) {
|
|
202
|
+
const journalPath = path.join(repositoryRoot, AUTHORING_JOURNAL_PATH);
|
|
203
|
+
if (!fs.existsSync(journalPath)) return;
|
|
204
|
+
const journal = readJson(journalPath);
|
|
205
|
+
validateAuthoringJournal(repositoryRoot, journal);
|
|
206
|
+
if (journal.status !== 'committed') {
|
|
207
|
+
writeFileAtomic.sync(journal.profilePath, Buffer.from(journal.originalProfile, 'base64'));
|
|
208
|
+
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
|
+
console.warn('[upgrade-template] Recovered an interrupted migration build.');
|
|
214
|
+
}
|
|
215
|
+
fs.rmSync(journalPath, { force: true });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateAuthoringJournal(repositoryRoot, journal) {
|
|
219
|
+
if (journal.schemaVersion !== 1 || !['preparing', 'committed'].includes(journal.status)) throw new Error('Migration build journal has an unsupported schema');
|
|
220
|
+
const profilePath = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/current.json');
|
|
221
|
+
const migrationDirectory = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/migrations');
|
|
222
|
+
const changeDirectory = path.join(repositoryRoot, MIGRATION_CHANGE_PATH);
|
|
223
|
+
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');
|
|
225
|
+
const outputPaths = new Set();
|
|
226
|
+
for (const outputPath of journal.outputPaths) {
|
|
227
|
+
if (!isPathWithin(migrationDirectory, outputPath) || !/\.(?:json|patch)$/.test(outputPath) || outputPaths.has(path.resolve(outputPath))) {
|
|
228
|
+
throw new Error('Migration build journal has an unsafe output path');
|
|
229
|
+
}
|
|
230
|
+
assertSafeFilePath(repositoryRoot, path.resolve(outputPath));
|
|
231
|
+
outputPaths.add(path.resolve(outputPath));
|
|
232
|
+
}
|
|
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
|
+
}
|
|
242
|
+
|
|
243
|
+
function isPathWithin(directory, filePath) {
|
|
244
|
+
if (typeof filePath !== 'string') return false;
|
|
245
|
+
const root = path.resolve(directory);
|
|
246
|
+
const target = path.resolve(filePath);
|
|
247
|
+
return target.startsWith(`${root}${path.sep}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isCanonicalBase64(value) {
|
|
251
|
+
return typeof value === 'string' && value.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(value) && Buffer.from(value, 'base64').toString('base64') === value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function updateProfileFromTemplate(profile, repositoryRoot) {
|
|
255
|
+
const templatePackage = readJson(path.join(repositoryRoot, TEMPLATE_PATH, 'package.json'));
|
|
256
|
+
const dependencies = { ...templatePackage.dependencies };
|
|
257
|
+
profile.expoVersion = dependencies.expo;
|
|
258
|
+
delete dependencies.expo;
|
|
259
|
+
delete dependencies[HOST_PACKAGE];
|
|
260
|
+
profile.managedDependencies = dependencies;
|
|
261
|
+
profile.managedDevDependencies = { ...templatePackage.devDependencies };
|
|
262
|
+
profile.managedOverrides = { ...templatePackage.overrides };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function packageTransitions(baseValues = {}, targetValues = {}) {
|
|
266
|
+
const names = [...new Set([...Object.keys(baseValues), ...Object.keys(targetValues)])].sort();
|
|
267
|
+
return Object.fromEntries(names
|
|
268
|
+
.filter(name => JSON.stringify(baseValues[name]) !== JSON.stringify(targetValues[name]))
|
|
269
|
+
.map(name => [name, {
|
|
270
|
+
from: baseValues[name] ?? null,
|
|
271
|
+
to: targetValues[name] ?? null,
|
|
272
|
+
}]));
|
|
273
|
+
}
|
|
274
|
+
|
|
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;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildTsconfigTransitions(relativePath, baseContent, targetContent) {
|
|
285
|
+
const base = baseContent ? JSON.parse(baseContent.toString('utf8')) : {};
|
|
286
|
+
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
|
+
}
|
|
290
|
+
const paths = packageTransitions(base.compilerOptions?.paths, target.compilerOptions?.paths);
|
|
291
|
+
return Object.keys(paths).length ? { paths } : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function assertPatchableMode(relativePath, mode) {
|
|
295
|
+
if (mode && !['100644', '100755', '000644', '000755'].includes(mode)) {
|
|
296
|
+
throw new Error(`Template file mode ${mode} for ${relativePath} is not supported`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
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();
|
|
311
|
+
const patches = [];
|
|
312
|
+
const patchFiles = {};
|
|
313
|
+
let tsconfigTransitions = null;
|
|
314
|
+
for (const relativePath of paths) {
|
|
315
|
+
const baseContent = readGitFile(repositoryRoot, baseCommits[0], relativePath);
|
|
316
|
+
const targetPath = path.join(repositoryRoot, TEMPLATE_PATH, relativePath);
|
|
317
|
+
if (fs.existsSync(targetPath) && fs.lstatSync(targetPath).isSymbolicLink()) {
|
|
318
|
+
throw new Error(`Symbolic link template change ${relativePath} is not supported`);
|
|
319
|
+
}
|
|
320
|
+
const targetContent = fs.existsSync(targetPath) ? fs.readFileSync(targetPath) : null;
|
|
321
|
+
assertCustomerSectionUnchanged(relativePath, baseContent, targetContent);
|
|
322
|
+
if (relativePath === TSCONFIG_PATH) {
|
|
323
|
+
tsconfigTransitions = buildTsconfigTransitions(relativePath, baseContent, targetContent);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if ([TEMPLATE_VERSION_PATH, 'package.json', 'package-lock.json'].includes(relativePath)) continue;
|
|
327
|
+
if (!isPatchableText(baseContent) || !isPatchableText(targetContent)) {
|
|
328
|
+
throw new Error(`Binary template change ${relativePath} cannot be represented by a unified migration patch`);
|
|
329
|
+
}
|
|
330
|
+
const fromMode = fileMode(repositoryRoot, baseCommits[0], relativePath);
|
|
331
|
+
const toMode = fileMode(repositoryRoot, null, relativePath);
|
|
332
|
+
assertPatchableMode(relativePath, fromMode);
|
|
333
|
+
assertPatchableMode(relativePath, toMode);
|
|
334
|
+
patchFiles[relativePath] = {
|
|
335
|
+
fromHash: hashContent(baseContent),
|
|
336
|
+
toHash: hashContent(targetContent),
|
|
337
|
+
fromMode,
|
|
338
|
+
toMode,
|
|
339
|
+
};
|
|
340
|
+
patches.push(createTwoFilesPatch(
|
|
341
|
+
baseContent === null ? '/dev/null' : relativePath,
|
|
342
|
+
targetContent === null ? '/dev/null' : relativePath,
|
|
343
|
+
baseContent?.toString('utf8') || '',
|
|
344
|
+
targetContent?.toString('utf8') || '',
|
|
345
|
+
baseTags[0],
|
|
346
|
+
options.id,
|
|
347
|
+
{ context: 3 },
|
|
348
|
+
));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const baseStateContent = readGitFile(repositoryRoot, baseCommits[0], TEMPLATE_VERSION_PATH);
|
|
352
|
+
if (!baseStateContent && !options.fromVersion) {
|
|
353
|
+
throw new Error(`Version tag ${baseTags[0]} does not contain ${TEMPLATE_VERSION_PATH}; rerun build with --from-version <version>`);
|
|
354
|
+
}
|
|
355
|
+
const baseState = baseStateContent
|
|
356
|
+
? JSON.parse(baseStateContent.toString('utf8'))
|
|
357
|
+
: { schemaVersion: 1, templateVersion: options.fromVersion };
|
|
358
|
+
const targetState = readJson(path.join(repositoryRoot, TEMPLATE_PATH, TEMPLATE_VERSION_PATH));
|
|
359
|
+
if (targetState.templateVersion !== baseState.templateVersion + 1) {
|
|
360
|
+
throw new Error(`Migration must advance exactly one template version (${baseState.templateVersion} -> ${baseState.templateVersion + 1})`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const basePackageContent = readGitFile(repositoryRoot, baseCommits[0], 'package.json');
|
|
364
|
+
if (!basePackageContent) throw new Error(`Version tag ${baseTags[0]} does not contain template package.json`);
|
|
365
|
+
const basePackage = JSON.parse(basePackageContent.toString('utf8'));
|
|
366
|
+
const targetPackage = readJson(path.join(repositoryRoot, TEMPLATE_PATH, 'package.json'));
|
|
367
|
+
const profilePath = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/current.json');
|
|
368
|
+
const profile = readJson(profilePath);
|
|
369
|
+
if (profile.targetTemplateVersion !== undefined && profile.targetTemplateVersion !== baseState.templateVersion) {
|
|
370
|
+
throw new Error(`Current profile targets template ${profile.targetTemplateVersion}, but migration starts at ${baseState.templateVersion}`);
|
|
371
|
+
}
|
|
372
|
+
updateProfileFromTemplate(profile, repositoryRoot);
|
|
373
|
+
profile.targetTemplateVersion = targetState.templateVersion;
|
|
374
|
+
const step = {
|
|
375
|
+
schemaVersion: 1,
|
|
376
|
+
id: options.id,
|
|
377
|
+
fromTag: baseTags[0],
|
|
378
|
+
fromCommit: baseCommits[0],
|
|
379
|
+
fromTemplateVersion: baseState.templateVersion,
|
|
380
|
+
toTemplateVersion: targetState.templateVersion,
|
|
381
|
+
comments: records.map(record => record.comment),
|
|
382
|
+
patchFile: `${options.id}.patch`,
|
|
383
|
+
patchFiles,
|
|
384
|
+
packageTransitions: {
|
|
385
|
+
dependencies: Object.fromEntries(Object.entries(packageTransitions(basePackage.dependencies, targetPackage.dependencies))
|
|
386
|
+
.filter(([name]) => name !== HOST_PACKAGE && name !== 'expo')),
|
|
387
|
+
devDependencies: packageTransitions(basePackage.devDependencies, targetPackage.devDependencies),
|
|
388
|
+
overrides: packageTransitions(basePackage.overrides, targetPackage.overrides),
|
|
389
|
+
},
|
|
390
|
+
...(tsconfigTransitions ? { tsconfigTransitions } : {}),
|
|
391
|
+
targetProfile: {
|
|
392
|
+
id: profile.id,
|
|
393
|
+
hostVersion: readJson(path.join(repositoryRoot, 'packages/power-apps-native-host/package.json')).version,
|
|
394
|
+
expoSdk: profile.expoSdk,
|
|
395
|
+
expoVersion: profile.expoVersion,
|
|
396
|
+
expoDoctorVersion: profile.expoDoctorVersion,
|
|
397
|
+
managedDependencies: profile.managedDependencies,
|
|
398
|
+
managedDevDependencies: profile.managedDevDependencies,
|
|
399
|
+
managedOverrides: profile.managedOverrides,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
if (!step.targetProfile.expoDoctorVersion) throw new Error('Current compatibility profile must define expoDoctorVersion');
|
|
403
|
+
profile.migrations ||= [];
|
|
404
|
+
const existingEdges = profile.migrations.map(migrationId => {
|
|
405
|
+
const metadataPath = path.join(repositoryRoot, `packages/power-apps-native-host/compatibility/migrations/${migrationId}.json`);
|
|
406
|
+
if (!fs.existsSync(metadataPath)) throw new Error(`Existing migration metadata is missing: ${migrationId}`);
|
|
407
|
+
return readJson(metadataPath);
|
|
408
|
+
});
|
|
409
|
+
validateMigrationEdges([...existingEdges, step], targetState.templateVersion);
|
|
410
|
+
if (!profile.migrations.includes(options.id)) profile.migrations.push(options.id);
|
|
411
|
+
|
|
412
|
+
const migrationDirectory = path.join(repositoryRoot, 'packages/power-apps-native-host/compatibility/migrations');
|
|
413
|
+
const migrationPath = path.join(migrationDirectory, `${options.id}.json`);
|
|
414
|
+
const patchPath = path.join(migrationDirectory, step.patchFile);
|
|
415
|
+
if (fs.existsSync(migrationPath) || fs.existsSync(patchPath)) throw new Error(`Migration ${options.id} already exists`);
|
|
416
|
+
console.log(`${options.dryRun ? '[dry-run] ' : ''}Build migration ${options.id} from ${records.length} changelog(s)`);
|
|
417
|
+
if (!options.dryRun) {
|
|
418
|
+
fs.mkdirSync(migrationDirectory, { recursive: true });
|
|
419
|
+
const temporarySuffix = `.tmp-${process.pid}-${Date.now()}`;
|
|
420
|
+
const temporaryMigrationPath = `${migrationPath}${temporarySuffix}`;
|
|
421
|
+
const temporaryPatchPath = `${patchPath}${temporarySuffix}`;
|
|
422
|
+
const temporaryProfilePath = `${profilePath}${temporarySuffix}`;
|
|
423
|
+
const originalProfile = fs.readFileSync(profilePath);
|
|
424
|
+
const recordContents = new Map(records.map(record => {
|
|
425
|
+
const recordPath = path.join(repositoryRoot, MIGRATION_CHANGE_PATH, record.name);
|
|
426
|
+
return [recordPath, fs.readFileSync(recordPath)];
|
|
427
|
+
}));
|
|
428
|
+
const journalPath = path.join(repositoryRoot, AUTHORING_JOURNAL_PATH);
|
|
429
|
+
const journal = {
|
|
430
|
+
schemaVersion: 1,
|
|
431
|
+
status: 'preparing',
|
|
432
|
+
profilePath,
|
|
433
|
+
originalProfile: originalProfile.toString('base64'),
|
|
434
|
+
outputPaths: [migrationPath, patchPath],
|
|
435
|
+
records: [...recordContents].map(([recordPath, content]) => ({
|
|
436
|
+
path: recordPath,
|
|
437
|
+
content: content.toString('base64'),
|
|
438
|
+
})),
|
|
439
|
+
};
|
|
440
|
+
writeFileAtomic.sync(journalPath, `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 });
|
|
441
|
+
try {
|
|
442
|
+
fs.writeFileSync(temporaryMigrationPath, `${JSON.stringify(step, null, 2)}\n`);
|
|
443
|
+
fs.writeFileSync(temporaryPatchPath, patches.join('\n'), 'utf8');
|
|
444
|
+
fs.writeFileSync(temporaryProfilePath, `${JSON.stringify(profile, null, 2)}\n`);
|
|
445
|
+
readJson(temporaryMigrationPath);
|
|
446
|
+
readJson(temporaryProfilePath);
|
|
447
|
+
parsePatch(fs.readFileSync(temporaryPatchPath, 'utf8'));
|
|
448
|
+
fs.renameSync(temporaryPatchPath, patchPath);
|
|
449
|
+
fs.renameSync(temporaryMigrationPath, migrationPath);
|
|
450
|
+
fs.renameSync(temporaryProfilePath, profilePath);
|
|
451
|
+
for (const recordPath of recordContents.keys()) fs.unlinkSync(recordPath);
|
|
452
|
+
journal.status = 'committed';
|
|
453
|
+
writeFileAtomic.sync(journalPath, `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 });
|
|
454
|
+
fs.rmSync(journalPath, { force: true });
|
|
455
|
+
} catch (error) {
|
|
456
|
+
fs.writeFileSync(profilePath, originalProfile);
|
|
457
|
+
fs.rmSync(migrationPath, { force: true });
|
|
458
|
+
fs.rmSync(patchPath, { force: true });
|
|
459
|
+
for (const temporaryPath of [temporaryMigrationPath, temporaryPatchPath, temporaryProfilePath]) {
|
|
460
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
461
|
+
}
|
|
462
|
+
for (const [recordPath, content] of recordContents) {
|
|
463
|
+
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
|
|
464
|
+
fs.writeFileSync(recordPath, content);
|
|
465
|
+
}
|
|
466
|
+
fs.rmSync(journalPath, { force: true });
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return step;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function assertCustomerSectionUnchanged(relativePath, baseContent, targetContent) {
|
|
474
|
+
for (const section of CUSTOMER_SECTIONS) {
|
|
475
|
+
const baseSection = readCustomerSection(relativePath, baseContent, section);
|
|
476
|
+
if (baseSection === null) continue;
|
|
477
|
+
const targetSection = readCustomerSection(relativePath, targetContent, section);
|
|
478
|
+
if (targetSection !== baseSection) {
|
|
479
|
+
throw new Error(`Template change modifies the ${section.name} section in ${relativePath}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function readCustomerSection(relativePath, content, section) {
|
|
485
|
+
if (content === null) return null;
|
|
486
|
+
const source = content.toString('utf8');
|
|
487
|
+
const start = source.indexOf(section.start);
|
|
488
|
+
const end = source.indexOf(section.end);
|
|
489
|
+
if (start === -1 && end === -1) return null;
|
|
490
|
+
if (start === -1 || end < start || source.indexOf(section.start, start + 1) !== -1 || source.indexOf(section.end, end + 1) !== -1) {
|
|
491
|
+
throw new Error(`Template file ${relativePath} has invalid ${section.name} markers`);
|
|
492
|
+
}
|
|
493
|
+
return source.slice(start, end + section.end.length);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function isPatchableText(content) {
|
|
497
|
+
if (content === null) return true;
|
|
498
|
+
try {
|
|
499
|
+
new TextDecoder('utf-8', { fatal: true }).decode(content);
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
let controlCharacters = 0;
|
|
504
|
+
for (const byte of content) {
|
|
505
|
+
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) controlCharacters += 1;
|
|
506
|
+
}
|
|
507
|
+
return controlCharacters === 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function validateMigrationEdges(migrations, targetVersion) {
|
|
511
|
+
const fromVersions = new Set();
|
|
512
|
+
for (const migration of migrations) {
|
|
513
|
+
if (!Number.isInteger(migration.fromTemplateVersion) || migration.fromTemplateVersion < 1) {
|
|
514
|
+
throw new Error(`Migration ${migration.id} has an invalid source template version`);
|
|
515
|
+
}
|
|
516
|
+
if (migration.toTemplateVersion !== migration.fromTemplateVersion + 1) {
|
|
517
|
+
throw new Error(`Migration ${migration.id} is not an adjacent template edge`);
|
|
518
|
+
}
|
|
519
|
+
if (fromVersions.has(migration.fromTemplateVersion)) {
|
|
520
|
+
throw new Error(`Multiple migrations start at template version ${migration.fromTemplateVersion}`);
|
|
521
|
+
}
|
|
522
|
+
fromVersions.add(migration.fromTemplateVersion);
|
|
523
|
+
}
|
|
524
|
+
if (migrations.length) {
|
|
525
|
+
const firstVersion = Math.min(...fromVersions);
|
|
526
|
+
for (let version = firstVersion; version < targetVersion; version += 1) {
|
|
527
|
+
if (!fromVersions.has(version)) throw new Error(`Migration graph is missing template version ${version} -> ${version + 1}`);
|
|
528
|
+
}
|
|
529
|
+
if (Math.max(...migrations.map(migration => migration.toTemplateVersion)) !== targetVersion) {
|
|
530
|
+
throw new Error(`Migration graph does not reach target template version ${targetVersion}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
module.exports = { getTemplateChanges, runAuthoringCommand };
|