react-native-intune 0.1.0-alpha.1

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 (110) hide show
  1. package/LICENSE +27 -0
  2. package/NOTICE +49 -0
  3. package/README.md +164 -0
  4. package/RNIntune.podspec +69 -0
  5. package/android/build.gradle +152 -0
  6. package/android/consumer-rules.pro +28 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/java/com/reactnativeintune/RNIntuneAuth.kt +330 -0
  9. package/android/src/main/java/com/reactnativeintune/RNIntuneAuthCallback.kt +195 -0
  10. package/android/src/main/java/com/reactnativeintune/RNIntuneConfig.kt +63 -0
  11. package/android/src/main/java/com/reactnativeintune/RNIntuneEventQueue.kt +50 -0
  12. package/android/src/main/java/com/reactnativeintune/RNIntuneModule.kt +752 -0
  13. package/android/src/main/java/com/reactnativeintune/RNIntuneMsalConfig.kt +78 -0
  14. package/android/src/main/java/com/reactnativeintune/RNIntuneNotifications.kt +187 -0
  15. package/android/src/main/java/com/reactnativeintune/RNIntunePackage.kt +33 -0
  16. package/android/src/main/java/com/reactnativeintune/RNIntunePolicy.kt +108 -0
  17. package/android/src/main/java/com/reactnativeintune/RNIntuneReset.kt +85 -0
  18. package/android/src/main/java/com/reactnativeintune/RNIntuneResetJournal.kt +140 -0
  19. package/app.plugin.js +10 -0
  20. package/ios/RNIntune.h +20 -0
  21. package/ios/RNIntune.mm +467 -0
  22. package/ios/RNIntuneAuth.h +121 -0
  23. package/ios/RNIntuneAuth.m +390 -0
  24. package/ios/RNIntuneCore.h +150 -0
  25. package/ios/RNIntuneCore.m +535 -0
  26. package/ios/RNIntuneDelegates.h +70 -0
  27. package/ios/RNIntuneDelegates.m +268 -0
  28. package/ios/RNIntunePendingRequests.h +57 -0
  29. package/ios/RNIntunePendingRequests.m +230 -0
  30. package/ios/RNIntunePlistGuard.h +37 -0
  31. package/ios/RNIntunePlistGuard.m +147 -0
  32. package/ios/RNIntunePolicy.h +25 -0
  33. package/ios/RNIntunePolicy.m +112 -0
  34. package/ios/RNIntuneReset.h +34 -0
  35. package/ios/RNIntuneReset.m +93 -0
  36. package/ios/RNIntuneResetJournal.h +69 -0
  37. package/ios/RNIntuneResetJournal.m +106 -0
  38. package/lib/module/NativeIntune.js +20 -0
  39. package/lib/module/NativeIntune.js.map +1 -0
  40. package/lib/module/api/auth.js +66 -0
  41. package/lib/module/api/auth.js.map +1 -0
  42. package/lib/module/api/enrollment.js +104 -0
  43. package/lib/module/api/enrollment.js.map +1 -0
  44. package/lib/module/api/events.js +50 -0
  45. package/lib/module/api/events.js.map +1 -0
  46. package/lib/module/api/lifecycle.js +73 -0
  47. package/lib/module/api/lifecycle.js.map +1 -0
  48. package/lib/module/api/policy.js +23 -0
  49. package/lib/module/api/policy.js.map +1 -0
  50. package/lib/module/api/tokenProvider.js +80 -0
  51. package/lib/module/api/tokenProvider.js.map +1 -0
  52. package/lib/module/index.js +82 -0
  53. package/lib/module/index.js.map +1 -0
  54. package/lib/module/internal/decode.js +153 -0
  55. package/lib/module/internal/decode.js.map +1 -0
  56. package/lib/module/internal/emitter.js +15 -0
  57. package/lib/module/internal/emitter.js.map +1 -0
  58. package/lib/module/internal/errors.js +32 -0
  59. package/lib/module/internal/errors.js.map +1 -0
  60. package/lib/module/package.json +1 -0
  61. package/lib/module/types.js +143 -0
  62. package/lib/module/types.js.map +1 -0
  63. package/lib/typescript/package.json +1 -0
  64. package/lib/typescript/src/NativeIntune.d.ts +120 -0
  65. package/lib/typescript/src/NativeIntune.d.ts.map +1 -0
  66. package/lib/typescript/src/api/auth.d.ts +37 -0
  67. package/lib/typescript/src/api/auth.d.ts.map +1 -0
  68. package/lib/typescript/src/api/enrollment.d.ts +54 -0
  69. package/lib/typescript/src/api/enrollment.d.ts.map +1 -0
  70. package/lib/typescript/src/api/events.d.ts +24 -0
  71. package/lib/typescript/src/api/events.d.ts.map +1 -0
  72. package/lib/typescript/src/api/lifecycle.d.ts +33 -0
  73. package/lib/typescript/src/api/lifecycle.d.ts.map +1 -0
  74. package/lib/typescript/src/api/policy.d.ts +14 -0
  75. package/lib/typescript/src/api/policy.d.ts.map +1 -0
  76. package/lib/typescript/src/api/tokenProvider.d.ts +18 -0
  77. package/lib/typescript/src/api/tokenProvider.d.ts.map +1 -0
  78. package/lib/typescript/src/index.d.ts +67 -0
  79. package/lib/typescript/src/index.d.ts.map +1 -0
  80. package/lib/typescript/src/internal/decode.d.ts +48 -0
  81. package/lib/typescript/src/internal/decode.d.ts.map +1 -0
  82. package/lib/typescript/src/internal/emitter.d.ts +11 -0
  83. package/lib/typescript/src/internal/emitter.d.ts.map +1 -0
  84. package/lib/typescript/src/internal/errors.d.ts +22 -0
  85. package/lib/typescript/src/internal/errors.d.ts.map +1 -0
  86. package/lib/typescript/src/types.d.ts +317 -0
  87. package/lib/typescript/src/types.d.ts.map +1 -0
  88. package/package.json +204 -0
  89. package/plugin/transforms.js +153 -0
  90. package/plugin/withIntune.js +320 -0
  91. package/scripts/cli.mjs +72 -0
  92. package/scripts/doctor.mjs +232 -0
  93. package/scripts/fetch-sdks.mjs +456 -0
  94. package/scripts/lib/checks.mjs +720 -0
  95. package/scripts/lib/project.mjs +196 -0
  96. package/scripts/setup.mjs +233 -0
  97. package/sdk-lock.json +27 -0
  98. package/sdk-versions.json +70 -0
  99. package/src/NativeIntune.ts +130 -0
  100. package/src/api/auth.ts +73 -0
  101. package/src/api/enrollment.ts +112 -0
  102. package/src/api/events.ts +87 -0
  103. package/src/api/lifecycle.ts +75 -0
  104. package/src/api/policy.ts +22 -0
  105. package/src/api/tokenProvider.ts +80 -0
  106. package/src/index.ts +127 -0
  107. package/src/internal/decode.ts +191 -0
  108. package/src/internal/emitter.ts +14 -0
  109. package/src/internal/errors.ts +43 -0
  110. package/src/types.ts +381 -0
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reports what the consumer's project still needs, and changes nothing.
4
+ *
5
+ * The value is not the checklist — it is that most of these failures name the wrong
6
+ * cause when they happen. A missing keychain entitlement surfaces as an authorization
7
+ * error, a missing URL type as an infinite login loop with an empty log, an unregistered
8
+ * auth callback as a licensing-shaped status that retries quietly forever. Every one of
9
+ * them was found on a real device rather than by reading documentation.
10
+ *
11
+ * Usage:
12
+ * npx react-native-intune doctor
13
+ * npx react-native-intune doctor --json # machine-readable, for CI
14
+ *
15
+ * Exit code: 1 when anything blocking is missing or wrong, 0 otherwise. Two exclusions,
16
+ * both deliberate. Advisories never fail the run, because a tool whose non-zero exit can
17
+ * mean "nothing important" teaches people to ignore it — and the exit exists for the
18
+ * class of failure that leaves an app unprotected while looking fine. Checks that could
19
+ * not be inspected are reported and do not fail either — a Kotlin DSL build file this
20
+ * tool cannot parse should not break someone's CI — but they are never counted as
21
+ * passing.
22
+ */
23
+
24
+ import { execFileSync } from 'node:child_process';
25
+ import { checks } from './lib/checks.mjs';
26
+ import { findProject, rel } from './lib/project.mjs';
27
+
28
+ const args = process.argv.slice(2);
29
+ const asJson = args.includes('--json');
30
+
31
+ const SEVERITY_LABEL = {
32
+ silent: 'leaves the app unprotected',
33
+ loud: 'breaks the build or the first sign-in',
34
+ advisory: 'advisory',
35
+ };
36
+
37
+ export function runChecks(project) {
38
+ return checks
39
+ .filter((c) => project[c.platform] !== null)
40
+ .map((c) => {
41
+ let result;
42
+ try {
43
+ result = c.inspect(project);
44
+ } catch (e) {
45
+ // A check that throws is a bug in this tool, not a finding about the project.
46
+ // Say which, so nobody spends an afternoon on their manifest.
47
+ result = {
48
+ state: 'unknown',
49
+ detail: `check failed to run: ${e.message}`,
50
+ };
51
+ }
52
+ return { check: c, ...result };
53
+ });
54
+ }
55
+
56
+ export function summarise(results) {
57
+ const failing = results.filter((r) => r.state === 'missing' || r.state === 'wrong');
58
+ return {
59
+ ok: results.filter((r) => r.state === 'ok').length,
60
+ failing,
61
+ /**
62
+ * What decides the exit code — advisories are excluded on purpose.
63
+ *
64
+ * `advisory` is defined as worth knowing and not blocking, so letting one fail a
65
+ * build would contradict the label and, worse, teach people that a non-zero exit
66
+ * from this tool can be ignored. That habit is exactly what must not form: the
67
+ * whole point of the non-zero exit is the `silent` class, where the app builds and
68
+ * runs while protecting nothing.
69
+ */
70
+ blocking: failing.filter((r) => r.check.severity !== 'advisory'),
71
+ unknown: results.filter((r) => r.state === 'unknown'),
72
+ skipped: results.filter((r) => r.state === 'skip'),
73
+ // Ordered so the dangerous class is read first, whatever order the checks are in.
74
+ silent: failing.filter((r) => r.check.severity === 'silent'),
75
+ };
76
+ }
77
+
78
+ function main() {
79
+ const project = findProject();
80
+
81
+ if (project.isThisPackage) {
82
+ process.stdout.write(
83
+ `This is react-native-intune's own repository, not a project that consumes it.\n\n` +
84
+ `Applying the MAM Gradle plugin here is the mistake these commands exist to\n` +
85
+ `catch: it rewrites only the library, builds cleanly, and protects nothing.\n` +
86
+ `Run this from your app's project root instead.\n`
87
+ );
88
+ process.exit(1);
89
+ }
90
+
91
+ if (!project.android && !project.ios) {
92
+ process.stdout.write(
93
+ `No native project found under ${project.root}.\n` +
94
+ `Run this from a React Native project root — the folder holding android/ or ios/.\n`
95
+ );
96
+ process.exit(1);
97
+ }
98
+
99
+ const results = runChecks(project);
100
+ const s = summarise(results);
101
+
102
+ if (asJson) {
103
+ process.stdout.write(
104
+ JSON.stringify(
105
+ {
106
+ root: project.root,
107
+ results: results.map((r) => ({
108
+ id: r.check.id,
109
+ platform: r.check.platform,
110
+ severity: r.check.severity,
111
+ state: r.state,
112
+ detail: r.detail ?? null,
113
+ })),
114
+ },
115
+ null,
116
+ 2
117
+ ) + '\n'
118
+ );
119
+ process.exit(s.blocking.length > 0 ? 1 : 0);
120
+ }
121
+
122
+ process.stdout.write(`\nreact-native-intune doctor\n${project.root}\n`);
123
+
124
+ for (const platform of ['ios', 'android']) {
125
+ const forPlatform = results.filter((r) => r.check.platform === platform);
126
+ if (forPlatform.length === 0) {
127
+ continue;
128
+ }
129
+ process.stdout.write(`\n${platform === 'ios' ? 'iOS' : 'Android'}\n`);
130
+ for (const r of forPlatform) {
131
+ process.stdout.write(` ${glyph(r.state)} ${r.check.title}${suffix(r)}\n`);
132
+ }
133
+ }
134
+
135
+ if (s.failing.length === 0 && s.unknown.length === 0) {
136
+ process.stdout.write(
137
+ `\nEverything this tool can check is in place.\n\n` +
138
+ `It cannot check the parts that live outside your project: that the account is\n` +
139
+ `licensed for Intune, that an App Protection Policy targets this app, and that\n` +
140
+ `a broker is installed on the device. Enrollment needs all three.\n\n`
141
+ );
142
+ process.exit(0);
143
+ }
144
+
145
+ if (s.silent.length > 0) {
146
+ process.stdout.write(
147
+ `\n${'─'.repeat(72)}\n` +
148
+ `${s.silent.length} of these build and run while leaving the app unprotected.\n` +
149
+ `Nothing fails, nothing logs, and everyone believes protection is in place.\n` +
150
+ `${'─'.repeat(72)}\n`
151
+ );
152
+ }
153
+
154
+ for (const r of [...s.silent, ...s.failing.filter((f) => !s.silent.includes(f))]) {
155
+ process.stdout.write(
156
+ `\n${r.check.title}\n` +
157
+ ` ${SEVERITY_LABEL[r.check.severity]}${r.detail ? ` — ${r.detail}` : ''}\n\n` +
158
+ ` ${r.check.why}\n\n` +
159
+ indent(r.check.instruction(project)) +
160
+ '\n'
161
+ );
162
+ }
163
+
164
+ if (s.unknown.length > 0) {
165
+ process.stdout.write(
166
+ `\nNot verified — check these by hand:\n` +
167
+ s.unknown
168
+ .map((r) => ` - ${r.check.title}${r.detail ? ` (${r.detail})` : ''}`)
169
+ .join('\n') +
170
+ '\n'
171
+ );
172
+ }
173
+
174
+ const applicable = s.failing.filter((r) => typeof r.check.apply === 'function');
175
+ if (applicable.length > 0) {
176
+ process.stdout.write(
177
+ `\n${applicable.length} of the above can be applied for you:\n` +
178
+ ` npx react-native-intune setup\n`
179
+ );
180
+ }
181
+
182
+ process.stdout.write('\n');
183
+ process.exit(s.blocking.length > 0 ? 1 : 0);
184
+ }
185
+
186
+ function glyph(state) {
187
+ return { ok: 'ok ', missing: 'MISS', wrong: 'WRONG', unknown: '? ', skip: '-- ' }[
188
+ state
189
+ ];
190
+ }
191
+
192
+ function suffix(r) {
193
+ if (r.state === 'ok') {
194
+ return r.detail ? ` (${r.detail})` : '';
195
+ }
196
+ if (r.state === 'skip') {
197
+ return r.detail ? ` ${r.detail}` : '';
198
+ }
199
+ return '';
200
+ }
201
+
202
+ function indent(text) {
203
+ return text
204
+ .split('\n')
205
+ .map((l) => (l.length > 0 ? ` ${l}` : l))
206
+ .join('\n');
207
+ }
208
+
209
+ /** Exported for `setup`, which refuses to edit files the developer cannot revert. */
210
+ export function gitIsClean(root) {
211
+ try {
212
+ const out = execFileSync('git', ['status', '--porcelain'], {
213
+ cwd: root,
214
+ encoding: 'utf8',
215
+ // Not a git repository is an outcome this function reports, not an error the user
216
+ // needs to see git's own words for. Without this, "fatal: not a git repository"
217
+ // leaks to the terminal and reads like the tool broke.
218
+ stdio: ['ignore', 'pipe', 'ignore'],
219
+ });
220
+ return out.trim().length === 0;
221
+ } catch {
222
+ // Not a git repository, or no git. Absence of a safety net is not the same as a
223
+ // dirty tree, and refusing to run would be unhelpful — the caller decides.
224
+ return null;
225
+ }
226
+ }
227
+
228
+ export { rel };
229
+
230
+ if (import.meta.url === `file://${process.argv[1]}`) {
231
+ main();
232
+ }
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Downloads the pinned Microsoft Intune App SDK builds into vendor/.
4
+ *
5
+ * Why this exists: the SDKs are Microsoft's, under Microsoft's licence terms, and are
6
+ * not ours to redistribute. They are never committed to this repository and never
7
+ * published in the npm tarball. This script fetches them at install time instead.
8
+ *
9
+ * Safe to run from postinstall: it only ever writes inside this package's own vendor/
10
+ * directory. It never touches the consumer's project files — that is what
11
+ * `react-native-intune setup` is for, and that one is never automatic.
12
+ *
13
+ * Usage:
14
+ * node scripts/fetch-sdks.mjs # fetch what is missing
15
+ * node scripts/fetch-sdks.mjs --force # re-fetch even if present
16
+ * node scripts/fetch-sdks.mjs --platform ios # ios | android
17
+ * node scripts/fetch-sdks.mjs --check # verify only, exit 1 if incomplete
18
+ * node scripts/fetch-sdks.mjs --soft # never exit non-zero (for postinstall)
19
+ * node scripts/fetch-sdks.mjs --print-layout # list the archive's artifacts and exit
20
+ * node scripts/fetch-sdks.mjs --print-layout-all # ... including every nested file
21
+ *
22
+ * Requires Node 18+ (global fetch) and the `tar` CLI.
23
+ */
24
+
25
+ import { createHash } from 'node:crypto';
26
+ import { execFileSync } from 'node:child_process';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const VENDOR = path.join(ROOT, 'vendor');
34
+ /**
35
+ * Lives at the repository root, not inside vendor/, because vendor/ is gitignored and
36
+ * this file is meant to be committed: it records the resolved tag and a sha256 per
37
+ * artifact, so every developer and CI run can verify they built against identical
38
+ * binaries rather than whatever "latest" meant that day.
39
+ */
40
+ const LOCK = path.join(ROOT, 'sdk-lock.json');
41
+ const CONFIG = path.join(ROOT, 'sdk-versions.json');
42
+
43
+ const args = process.argv.slice(2);
44
+ const has = (flag) => args.includes(flag);
45
+ const opt = (flag) => {
46
+ const i = args.indexOf(flag);
47
+ return i === -1 ? null : args[i + 1];
48
+ };
49
+
50
+ const FORCE = has('--force');
51
+ /**
52
+ * Never fail the process. Used by postinstall: a library must remain installable
53
+ * without network access or a GitHub token. The build is where a missing SDK must
54
+ * fail loudly — the podspec raises, and Gradle checks before compiling.
55
+ */
56
+ const SOFT = has('--soft');
57
+ const CHECK_ONLY = has('--check');
58
+ const PRINT_LAYOUT = has('--print-layout') || has('--print-layout-all');
59
+ const PRINT_ALL = has('--print-layout-all');
60
+ const ONLY = opt('--platform');
61
+ const QUIET = has('--quiet') || process.env.npm_config_loglevel === 'silent';
62
+
63
+ const log = (...m) => !QUIET && console.log('[react-native-intune]', ...m);
64
+ const warn = (...m) => console.warn('[react-native-intune]', ...m);
65
+
66
+ /**
67
+ * Built from the repositories actually being downloaded, not hardcoded: a licence notice
68
+ * that points somewhere other than the source of the bytes is worse than no notice.
69
+ */
70
+ const licenceNotice = (cfg) => `
71
+ The Microsoft Intune App SDK is a Microsoft product, licensed by Microsoft under its
72
+ own terms. This library neither includes nor relicenses it, and downloading it here
73
+ does not grant you any rights to it. Review and accept Microsoft's terms before use:
74
+ iOS https://github.com/${cfg.ios.repo}
75
+ Android https://github.com/${cfg.android.repo}
76
+ `;
77
+
78
+ // ---------------------------------------------------------------- helpers
79
+
80
+ function readConfig() {
81
+ if (!fs.existsSync(CONFIG)) fail(`sdk-versions.json not found at ${CONFIG}`);
82
+ const cfg = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
83
+ for (const p of ['ios', 'android']) {
84
+ if (!cfg[p]) fail(`sdk-versions.json is missing the "${p}" section`);
85
+ if (cfg[p].tag === 'LATEST') {
86
+ warn(
87
+ `"${p}.tag" is still LATEST. Pin a real release tag before shipping — an ` +
88
+ `unpinned SDK means two developers can build against different binaries.`
89
+ );
90
+ }
91
+ }
92
+ return cfg;
93
+ }
94
+
95
+ function readLock() {
96
+ try {
97
+ return JSON.parse(fs.readFileSync(LOCK, 'utf8'));
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ function writeLock(lock) {
104
+ fs.writeFileSync(LOCK, JSON.stringify(lock, null, 2) + '\n');
105
+ }
106
+
107
+ function fail(msg) {
108
+ if (SOFT) {
109
+ console.warn(
110
+ `\n[react-native-intune] Could not fetch the Microsoft SDKs yet.\n\n` +
111
+ msg
112
+ .split('\n')
113
+ .map((l) => ` ${l}`)
114
+ .join('\n') +
115
+ `\n\n Installation continues. Run \`yarn fetch-sdks\` before building —\n` +
116
+ ` the iOS pod install and the Android build will fail with instructions if it is missing.\n`
117
+ );
118
+ process.exit(0);
119
+ }
120
+ console.error(`\n[react-native-intune] ${msg}\n`);
121
+ process.exit(1);
122
+ }
123
+
124
+ /**
125
+ * Turn a simple `**\/name` or `**\/name*.jar` pattern into a RegExp against a POSIX path.
126
+ *
127
+ * Tokens are substituted via placeholders rather than sequential string replacement:
128
+ * replacing `*` after inserting the `**\/` expansion would corrupt the `*` inside it,
129
+ * which silently breaks matching for nested paths.
130
+ */
131
+ function globToRegExp(glob) {
132
+ const ANY_DIRS = '\u0000';
133
+ const ANY_SEG = '\u0001';
134
+ const body = glob
135
+ .replace(/\*\*\//g, ANY_DIRS)
136
+ .replace(/\*/g, ANY_SEG)
137
+ .replace(/[.+^${}()|[\]\\?]/g, '\\$&')
138
+ .split(ANY_DIRS)
139
+ .join('(?:[^\\0]*/)?')
140
+ .split(ANY_SEG)
141
+ .join('[^/]*');
142
+ return new RegExp(`^${body}$`);
143
+ }
144
+
145
+ function walk(dir, base = dir, out = []) {
146
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
147
+ const abs = path.join(dir, entry.name);
148
+ const rel = path.relative(base, abs).split(path.sep).join('/');
149
+ // An .xcframework is a directory but must be treated as a single artifact.
150
+ if (entry.isDirectory() && !entry.name.endsWith('.xcframework')) {
151
+ out.push({ rel, abs, dir: true });
152
+ walk(abs, base, out);
153
+ } else {
154
+ out.push({ rel, abs, dir: entry.isDirectory() });
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ function sha256OfPath(target) {
161
+ const hash = createHash('sha256');
162
+ const add = (p) => {
163
+ const st = fs.statSync(p);
164
+ if (st.isDirectory()) {
165
+ for (const name of fs.readdirSync(p).sort()) add(path.join(p, name));
166
+ } else {
167
+ hash.update(path.basename(p));
168
+ hash.update(fs.readFileSync(p));
169
+ }
170
+ };
171
+ add(target);
172
+ return hash.digest('hex');
173
+ }
174
+
175
+ function copyInto(from, toRel, { executable = false } = {}) {
176
+ const isDirTarget = toRel.endsWith('/');
177
+ const dest = isDirTarget
178
+ ? path.join(VENDOR, toRel, path.basename(from))
179
+ : path.join(VENDOR, toRel);
180
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
181
+ fs.rmSync(dest, { recursive: true, force: true });
182
+ fs.cpSync(from, dest, { recursive: true });
183
+ if (executable) fs.chmodSync(dest, 0o755);
184
+ return dest;
185
+ }
186
+
187
+ /**
188
+ * Copy one named file out of a .dmg. No-op off macOS.
189
+ *
190
+ * The disk image is **converted before it is attached**, and that is the whole trick.
191
+ * Microsoft ships this one with a software license agreement attached
192
+ * (`hdiutil imageinfo` reports `Software License Agreement: true`), and `hdiutil attach`
193
+ * blocks on it — the mount is refused with "attach canceled" and the extraction silently
194
+ * produced nothing. That is why the configurator was never in `vendor/` despite this
195
+ * function existing and being wired up.
196
+ *
197
+ * Converting strips the agreement resource from the copy, so the copy attaches without a
198
+ * prompt. Nothing is auto-accepted on the user's behalf: `fetch-sdks` already asked them
199
+ * to accept Microsoft's terms before downloading anything, and this avoids clicking
200
+ * through a second agreement in their name rather than doing it for them.
201
+ *
202
+ * Returns the destination path, or null when extraction was not possible.
203
+ */
204
+ function extractFromDmg(dmgPath, innerName, destRel) {
205
+ if (process.platform !== 'darwin') {
206
+ log(` (${path.basename(dmgPath)} kept as-is — extraction needs macOS)`);
207
+ return null;
208
+ }
209
+ let mountPoint = null;
210
+ let converted = null;
211
+ try {
212
+ converted = path.join(
213
+ fs.mkdtempSync(path.join(os.tmpdir(), 'rni-dmg-')),
214
+ 'plain.dmg'
215
+ );
216
+ execFileSync('hdiutil', ['convert', dmgPath, '-format', 'UDRW', '-o', converted], {
217
+ stdio: 'pipe',
218
+ });
219
+
220
+ const out = execFileSync(
221
+ 'hdiutil',
222
+ ['attach', '-nobrowse', '-readonly', '-plist', converted],
223
+ { encoding: 'utf8' }
224
+ );
225
+ // Cheap plist scrape: the mount point is the only /Volumes path in the output.
226
+ mountPoint = (out.match(/<string>(\/Volumes\/[^<]+)<\/string>/) ?? [])[1] ?? null;
227
+ if (!mountPoint) throw new Error('could not determine the mount point');
228
+
229
+ // Recursive, because the volume root is the .app bundle itself and the binary lives
230
+ // at Contents/MacOS/<name>.
231
+ const found = walk(mountPoint).find((e) => !e.dir && path.basename(e.rel) === innerName);
232
+ if (!found) throw new Error(`${innerName} was not inside the disk image`);
233
+
234
+ return copyInto(found.abs, destRel, { executable: true });
235
+ } catch (e) {
236
+ warn(
237
+ `Could not extract ${innerName} from ${path.basename(dmgPath)}: ${e.message}\n` +
238
+ ` The disk image is at vendor/${path.relative(VENDOR, dmgPath)} — open it and copy\n` +
239
+ ` ${innerName} to vendor/${destRel} by hand.`
240
+ );
241
+ return null;
242
+ } finally {
243
+ if (mountPoint) {
244
+ try {
245
+ execFileSync('hdiutil', ['detach', mountPoint, '-quiet'], { stdio: 'pipe' });
246
+ } catch {
247
+ /* leaving a volume mounted is not worth failing the install over */
248
+ }
249
+ }
250
+ if (converted) {
251
+ fs.rmSync(path.dirname(converted), { recursive: true, force: true });
252
+ }
253
+ }
254
+ }
255
+
256
+ // ---------------------------------------------------------------- download
257
+
258
+ async function resolveTag(repo, tag) {
259
+ if (tag && tag !== 'LATEST') return tag;
260
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
261
+ headers: {
262
+ accept: 'application/vnd.github+json',
263
+ // A token lifts the very low unauthenticated rate limit. Optional.
264
+ ...(process.env.GITHUB_TOKEN ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
265
+ },
266
+ });
267
+ if (!res.ok) {
268
+ fail(
269
+ `Could not resolve the latest release for ${repo} (HTTP ${res.status}).\n` +
270
+ ` GitHub rate-limits unauthenticated requests aggressively. Either:\n` +
271
+ ` - pin an explicit tag in sdk-versions.json (recommended), or\n` +
272
+ ` - set GITHUB_TOKEN in your environment, or\n` +
273
+ ` - download the SDK manually and place it under vendor/ (see README).`
274
+ );
275
+ }
276
+ const json = await res.json();
277
+ if (!json.tag_name) fail(`${repo} has no published releases to resolve.`);
278
+ return json.tag_name;
279
+ }
280
+
281
+ /**
282
+ * Download the source tarball for a tag and return the extraction directory.
283
+ *
284
+ * The tarball is used rather than release assets on purpose: both SDK repositories
285
+ * carry the binaries in the repository tree, and asset naming has changed across
286
+ * releases. Extracting the tree and locating artifacts by pattern is stable across
287
+ * both layouts, so this keeps working when Microsoft reorganises a release.
288
+ */
289
+ async function downloadTree(repo, tag) {
290
+ const url = `https://codeload.github.com/${repo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`;
291
+ log(`fetching ${repo}@${tag}`);
292
+ const res = await fetch(url);
293
+ if (!res.ok) {
294
+ fail(
295
+ `Download failed for ${repo}@${tag} (HTTP ${res.status}).\n` +
296
+ ` Check that the tag exists: https://github.com/${repo}/tags`
297
+ );
298
+ }
299
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-intune-'));
300
+ const tgz = path.join(tmp, 'sdk.tar.gz');
301
+ fs.writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
302
+ const out = path.join(tmp, 'src');
303
+ fs.mkdirSync(out);
304
+ try {
305
+ execFileSync('tar', ['-xzf', tgz, '-C', out, '--strip-components=1'], { stdio: 'pipe' });
306
+ } catch (e) {
307
+ fail(`Could not extract the archive. Is the \`tar\` CLI available?\n ${e.message}`);
308
+ }
309
+ fs.rmSync(tgz, { force: true });
310
+ return { dir: out, cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) };
311
+ }
312
+
313
+ // ---------------------------------------------------------------- platform
314
+
315
+ async function fetchPlatform(name, cfg, lock) {
316
+ const spec = cfg[name];
317
+ const tag = await resolveTag(spec.repo, spec.tag);
318
+
319
+ const satisfied =
320
+ !FORCE &&
321
+ lock[name]?.tag === tag &&
322
+ spec.expect
323
+ .filter((e) => e.required)
324
+ .every((e) => fs.existsSync(path.join(VENDOR, e.as.replace(/\/$/, ''))));
325
+
326
+ if (satisfied) {
327
+ log(`${name}: already at ${tag}`);
328
+ return { name, tag, skipped: true };
329
+ }
330
+
331
+ const { dir, cleanup } = await downloadTree(spec.repo, tag);
332
+ try {
333
+ const entries = walk(dir);
334
+
335
+ if (PRINT_LAYOUT) {
336
+ console.log(`\n--- ${spec.repo}@${tag} ---`);
337
+ // Top level first, then the bundle/framework artifacts, then everything else.
338
+ // Directories matter here: .xcframework and .bundle are single artifacts, and
339
+ // hiding them would omit exactly the entries the globs need to match.
340
+ const interesting = PRINT_ALL
341
+ ? entries
342
+ : entries.filter(
343
+ (e) => !e.rel.includes('/') || /\.(xcframework|bundle|aar|jar|dmg)$/.test(e.rel)
344
+ );
345
+ for (const e of interesting.sort((a, b) => a.rel.localeCompare(b.rel))) {
346
+ console.log(` ${e.dir ? '[dir] ' : ' '}${e.rel}`);
347
+ }
348
+ const hidden = entries.length - interesting.length;
349
+ if (hidden > 0) console.log(` … ${hidden} nested files omitted (pass --print-layout-all to see them)`);
350
+ return { name, tag, printed: true };
351
+ }
352
+
353
+ const resolved = {};
354
+ const missing = [];
355
+
356
+ for (const want of spec.expect) {
357
+ const re = globToRegExp(want.glob);
358
+ const hits = entries.filter((e) => re.test(e.rel));
359
+ if (hits.length === 0) {
360
+ if (want.required) missing.push(want.glob);
361
+ continue;
362
+ }
363
+ // Shallowest path wins: repositories often ship a copy inside a sample app too.
364
+ hits.sort((a, b) => a.rel.split('/').length - b.rel.split('/').length);
365
+ for (const hit of want.as.endsWith('/') ? hits : [hits[0]]) {
366
+ const dest = copyInto(hit.abs, want.as, { executable: want.executable });
367
+ resolved[path.relative(VENDOR, dest)] = sha256OfPath(dest);
368
+ log(` ${path.relative(VENDOR, dest)}`);
369
+
370
+ // Microsoft ships IntuneMAMConfigurator inside a .dmg. Mount it and take the
371
+ // binary out so consumers can call it from an Xcode build phase. macOS only —
372
+ // the dmg is kept either way so a Linux CI checkout is still complete.
373
+ if (want.extractDmg) {
374
+ const extracted = extractFromDmg(dest, want.extractDmg, want.extractAs);
375
+ if (extracted) {
376
+ resolved[path.relative(VENDOR, extracted)] = sha256OfPath(extracted);
377
+ log(` ${path.relative(VENDOR, extracted)}`);
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ if (missing.length) {
384
+ fail(
385
+ `${spec.repo}@${tag} did not contain expected artifacts:\n` +
386
+ missing.map((m) => ` ${m}`).join('\n') +
387
+ `\n\n Microsoft may have changed the release layout. Run:\n` +
388
+ ` node scripts/fetch-sdks.mjs --platform ${name} --print-layout\n` +
389
+ ` and update the "expect" globs in sdk-versions.json to match.`
390
+ );
391
+ }
392
+
393
+ return { name, tag, files: resolved };
394
+ } finally {
395
+ cleanup();
396
+ }
397
+ }
398
+
399
+ // ---------------------------------------------------------------- check
400
+
401
+ function check(cfg, lock) {
402
+ const problems = [];
403
+ for (const name of ['ios', 'android']) {
404
+ if (ONLY && ONLY !== name) continue;
405
+ if (!lock[name]) {
406
+ problems.push(`${name}: not fetched`);
407
+ continue;
408
+ }
409
+ for (const want of cfg[name].expect.filter((e) => e.required)) {
410
+ const target = path.join(VENDOR, want.as.replace(/\/$/, ''));
411
+ if (!fs.existsSync(target)) problems.push(`${name}: missing ${want.as}`);
412
+ }
413
+ for (const [rel, expected] of Object.entries(lock[name].files ?? {})) {
414
+ const target = path.join(VENDOR, rel);
415
+ if (fs.existsSync(target) && sha256OfPath(target) !== expected) {
416
+ problems.push(`${name}: ${rel} has been modified since it was fetched`);
417
+ }
418
+ }
419
+ }
420
+
421
+ if (problems.length) {
422
+ console.error('\n[react-native-intune] vendor/ is incomplete:\n');
423
+ for (const p of problems) console.error(` ${p}`);
424
+ console.error('\n Run: npx react-native-intune fetch-sdks\n');
425
+ process.exit(1);
426
+ }
427
+ log('vendor/ is complete');
428
+ }
429
+
430
+ // ---------------------------------------------------------------- main
431
+
432
+ async function main() {
433
+ const cfg = readConfig();
434
+ const lock = readLock();
435
+
436
+ if (CHECK_ONLY) return check(cfg, lock);
437
+
438
+ const platforms = ['ios', 'android'].filter((p) => !ONLY || ONLY === p);
439
+ if (platforms.length === 0) fail(`--platform must be "ios" or "android", got "${ONLY}"`);
440
+
441
+ // Only iOS artifacts are needed on a machine that cannot build for iOS anyway,
442
+ // but fetching both keeps the lockfile meaningful and the download is one-time.
443
+ if (!QUIET && !PRINT_LAYOUT) console.log(licenceNotice(cfg));
444
+
445
+ for (const name of platforms) {
446
+ const result = await fetchPlatform(name, cfg, lock);
447
+ if (result.printed) continue;
448
+ if (result.skipped) continue;
449
+ lock[name] = { repo: cfg[name].repo, tag: result.tag, fetchedAt: new Date().toISOString(), files: result.files };
450
+ writeLock(lock);
451
+ }
452
+
453
+ if (!PRINT_LAYOUT) log('done — vendor/ ready');
454
+ }
455
+
456
+ main().catch((e) => fail(e?.stack ?? String(e)));