react-native-acoustic-connect-beta 18.0.36 → 18.0.38
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/CHANGELOG.md +2 -1
- package/android/src/main/assets/ConnectBasicConfig.properties +4 -4
- package/cli/doctor.mjs +294 -0
- package/cli/index.mjs +64 -0
- package/cli/ios/add_push_extensions.rb +192 -0
- package/cli/ios/connect_pods.rb +48 -0
- package/cli/ios/setup-push.mjs +196 -0
- package/cli/ios/templates/nce/ConnectNCE.entitlements +10 -0
- package/cli/ios/templates/nce/Info.plist +45 -0
- package/cli/ios/templates/nse/ConnectNSE.entitlements +10 -0
- package/cli/ios/templates/nse/Info.plist +33 -0
- package/cli/lib.mjs +240 -0
- package/ios/AcousticConnectRNConfig.json +7 -153
- package/ios/HybridAcousticConnectRN.swift +16 -0
- package/package.json +7 -3
- package/plugin/build/index.d.ts +16 -7
- package/plugin/build/index.d.ts.map +1 -1
- package/plugin/build/index.js +18 -7
- package/plugin/build/index.js.map +1 -1
- package/plugin/build/withConnectAndroidConfig.d.ts +20 -0
- package/plugin/build/withConnectAndroidConfig.d.ts.map +1 -0
- package/plugin/build/withConnectAndroidConfig.js +64 -0
- package/plugin/build/withConnectAndroidConfig.js.map +1 -0
- package/plugin/build/withConnectNCE.d.ts.map +1 -1
- package/plugin/build/withConnectNCE.js +20 -1
- package/plugin/build/withConnectNCE.js.map +1 -1
- package/plugin/build/withConnectNSE.d.ts +10 -2
- package/plugin/build/withConnectNSE.d.ts.map +1 -1
- package/plugin/build/withConnectNSE.js +13 -2
- package/plugin/build/withConnectNSE.js.map +1 -1
- package/plugin/src/index.ts +17 -7
- package/plugin/src/withConnectAndroidConfig.ts +70 -0
- package/plugin/src/withConnectNCE.ts +26 -1
- package/plugin/src/withConnectNSE.ts +13 -2
- package/scripts/postinstall.mjs +35 -0
- package/scripts/xmlparser.js +1 -1
- package/scripts/postInstallScripts.sh +0 -10
- package/scripts/verifyConnectSetup.js +0 -87
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// `acoustic-connect setup-ios-push [dir]` — create/repair the iOS push extensions.
|
|
2
|
+
//
|
|
3
|
+
// The manual-setup counterpart to what the Expo Config Plugin does on
|
|
4
|
+
// `prebuild`: it provisions the two app-extension targets a bare React Native
|
|
5
|
+
// app needs for Acoustic Connect rich push —
|
|
6
|
+
//
|
|
7
|
+
// * ConnectNSE — Notification Service Extension
|
|
8
|
+
// * ConnectNCE — Notification Content Extension
|
|
9
|
+
//
|
|
10
|
+
// Division of labour:
|
|
11
|
+
// - THIS script (Node) owns the files: it writes the per-extension Swift,
|
|
12
|
+
// Info.plist and entitlements from the SDK templates (substituting the App
|
|
13
|
+
// Group from ConnectConfig.json), and ensures the host app has an
|
|
14
|
+
// entitlements file with aps-environment + the App Group.
|
|
15
|
+
// - add_push_extensions.rb (Ruby + xcodeproj) owns the pbxproj surgery:
|
|
16
|
+
// it creates/links the targets, the embed phase, and the system frameworks.
|
|
17
|
+
//
|
|
18
|
+
// Everything is idempotent — existing files are left untouched, existing
|
|
19
|
+
// targets are skipped. Re-running repairs missing pieces without duplicating.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import {fileURLToPath} from 'node:url'
|
|
24
|
+
|
|
25
|
+
import {color, commandExists, fileExists, readJson, readText, run, section} from '../lib.mjs'
|
|
26
|
+
|
|
27
|
+
const cliDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) // .../cli
|
|
28
|
+
const sdkRoot = path.dirname(cliDir) // package root
|
|
29
|
+
const templatesDir = path.join(cliDir, 'ios', 'templates')
|
|
30
|
+
// Swift principal-class templates are shared with the Expo Config Plugin.
|
|
31
|
+
const swiftDir = path.join(sdkRoot, 'plugin', 'swift')
|
|
32
|
+
|
|
33
|
+
const APP_GROUP_TOKEN = 'CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER'
|
|
34
|
+
|
|
35
|
+
const EXTENSIONS = [
|
|
36
|
+
{name: 'ConnectNSE', swift: 'NotificationService.swift'},
|
|
37
|
+
{name: 'ConnectNCE', swift: 'NotificationViewController.swift'},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
// Find the single .xcodeproj under <dir>/ios. The app target name follows the
|
|
41
|
+
// RN convention (the project basename), which is what `setup-ios-push` assumes.
|
|
42
|
+
function findXcodeproj(iosDir) {
|
|
43
|
+
let entries = []
|
|
44
|
+
try {
|
|
45
|
+
entries = fs.readdirSync(iosDir)
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
const proj = entries.find((e) => e.endsWith('.xcodeproj'))
|
|
50
|
+
return proj ? path.join(iosDir, proj) : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Write `content` to `dest` only if missing; report which happened.
|
|
54
|
+
function writeIfMissing(reporter, label, dest, content) {
|
|
55
|
+
if (fileExists(dest)) {
|
|
56
|
+
reporter.pass(label, 'present')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(path.dirname(dest), {recursive: true})
|
|
60
|
+
fs.writeFileSync(dest, content)
|
|
61
|
+
reporter.pass(label, 'created')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Render a template file, substituting the App Group placeholder. Only the
|
|
65
|
+
// real occurrences are replaced — the Swift string literal and the plist
|
|
66
|
+
// <string> entry — so any explanatory comment that mentions the token by name
|
|
67
|
+
// (e.g. "The <token> is replaced by …") stays readable.
|
|
68
|
+
function renderTemplate(srcPath, appGroup) {
|
|
69
|
+
const raw = readText(srcPath)
|
|
70
|
+
if (raw == null) return null
|
|
71
|
+
return raw
|
|
72
|
+
.split(`"${APP_GROUP_TOKEN}"`)
|
|
73
|
+
.join(`"${appGroup}"`)
|
|
74
|
+
.split(`<string>${APP_GROUP_TOKEN}</string>`)
|
|
75
|
+
.join(`<string>${appGroup}</string>`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Minimal host entitlements when the app has none yet: aps-environment +
|
|
79
|
+
// the App Group, matching what the extensions share.
|
|
80
|
+
function hostEntitlements(appGroup) {
|
|
81
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
82
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
83
|
+
<plist version="1.0">
|
|
84
|
+
<dict>
|
|
85
|
+
\t<key>aps-environment</key>
|
|
86
|
+
\t<string>development</string>
|
|
87
|
+
\t<key>com.apple.security.application-groups</key>
|
|
88
|
+
\t<array>
|
|
89
|
+
\t\t<string>${appGroup}</string>
|
|
90
|
+
\t</array>
|
|
91
|
+
</dict>
|
|
92
|
+
</plist>
|
|
93
|
+
`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function setupIosPush(dir, {reporter}) {
|
|
97
|
+
console.log(
|
|
98
|
+
color.bold('\nAcoustic Connect — iOS push extensions') +
|
|
99
|
+
color.dim(` (${dir})`),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if (process.platform !== 'darwin') {
|
|
103
|
+
reporter.warn('iOS', 'skipped — iOS project surgery requires macOS')
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const iosDir = path.join(dir, 'ios')
|
|
108
|
+
if (!fileExists(iosDir)) {
|
|
109
|
+
reporter.fail('ios/', `no ios/ directory under ${dir}`)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const projectPath = findXcodeproj(iosDir)
|
|
114
|
+
if (!projectPath) {
|
|
115
|
+
reporter.fail('.xcodeproj', `none found under ${iosDir}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const appTarget = path.basename(projectPath, '.xcodeproj')
|
|
119
|
+
reporter.pass('Xcode project', `${path.basename(projectPath)} (target ${appTarget})`)
|
|
120
|
+
|
|
121
|
+
// App Group is the linchpin — host + both extensions must share it.
|
|
122
|
+
const cfg = readJson(path.join(dir, 'ConnectConfig.json'))
|
|
123
|
+
const appGroup = cfg?.Connect?.iOSAppGroupIdentifier
|
|
124
|
+
if (!appGroup) {
|
|
125
|
+
reporter.fail(
|
|
126
|
+
'iOSAppGroupIdentifier',
|
|
127
|
+
'not set in ConnectConfig.json — required for the NSE/NCE shared store. Run `acoustic-connect doctor` first.',
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
reporter.pass('App Group', appGroup)
|
|
132
|
+
|
|
133
|
+
// ── Files (idempotent) ───────────────────────────────────────────────────
|
|
134
|
+
section('Source files')
|
|
135
|
+
|
|
136
|
+
// Host entitlements — only created if absent (never clobber a real one).
|
|
137
|
+
const hostEntPath = path.join(iosDir, appTarget, `${appTarget}.entitlements`)
|
|
138
|
+
writeIfMissing(
|
|
139
|
+
reporter,
|
|
140
|
+
`${appTarget}/${appTarget}.entitlements`,
|
|
141
|
+
hostEntPath,
|
|
142
|
+
hostEntitlements(appGroup),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for (const ext of EXTENSIONS) {
|
|
146
|
+
const extDir = path.join(iosDir, ext.name)
|
|
147
|
+
|
|
148
|
+
const swift = renderTemplate(path.join(swiftDir, ext.swift), appGroup)
|
|
149
|
+
if (swift == null) {
|
|
150
|
+
reporter.fail(`${ext.name}/${ext.swift}`, `template missing in ${swiftDir}`)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
writeIfMissing(reporter, `${ext.name}/${ext.swift}`, path.join(extDir, ext.swift), swift)
|
|
154
|
+
|
|
155
|
+
const tplKey = ext.name === 'ConnectNSE' ? 'nse' : 'nce'
|
|
156
|
+
const plist = readText(path.join(templatesDir, tplKey, 'Info.plist'))
|
|
157
|
+
writeIfMissing(reporter, `${ext.name}/Info.plist`, path.join(extDir, 'Info.plist'), plist)
|
|
158
|
+
|
|
159
|
+
const ent = renderTemplate(
|
|
160
|
+
path.join(templatesDir, tplKey, `${ext.name}.entitlements`),
|
|
161
|
+
appGroup,
|
|
162
|
+
)
|
|
163
|
+
writeIfMissing(
|
|
164
|
+
reporter,
|
|
165
|
+
`${ext.name}/${ext.name}.entitlements`,
|
|
166
|
+
path.join(extDir, `${ext.name}.entitlements`),
|
|
167
|
+
ent,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── pbxproj surgery (Ruby) ─────────────────────────────────────────────────
|
|
172
|
+
section('Xcode targets')
|
|
173
|
+
if (!commandExists('ruby')) {
|
|
174
|
+
reporter.warn(
|
|
175
|
+
'Push extensions',
|
|
176
|
+
'ruby not found — files written, but run add_push_extensions.rb on a machine with ruby + the xcodeproj gem to wire the targets',
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rubyScript = path.join(cliDir, 'ios', 'add_push_extensions.rb')
|
|
182
|
+
// Pass the project path / target name as real environment variables (the
|
|
183
|
+
// Ruby script reads them via ENV[…]) rather than interpolating them into the
|
|
184
|
+
// command line — a path with shell metacharacters must not be re-parsed.
|
|
185
|
+
const rubyEnv = {
|
|
186
|
+
ACOUSTIC_PROJECT_PATH: projectPath,
|
|
187
|
+
ACOUSTIC_APP_TARGET: appTarget,
|
|
188
|
+
}
|
|
189
|
+
if (run(`ruby "${rubyScript}"`, {cwd: iosDir, env: rubyEnv}))
|
|
190
|
+
reporter.pass('Push extensions wired (NSE + NCE)')
|
|
191
|
+
else
|
|
192
|
+
reporter.fail(
|
|
193
|
+
'Push extensions',
|
|
194
|
+
'add_push_extensions.rb failed — needs the xcodeproj gem (ships with CocoaPods: `gem install xcodeproj`)',
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.security.application-groups</key>
|
|
6
|
+
<array>
|
|
7
|
+
<string>CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER</string>
|
|
8
|
+
</array>
|
|
9
|
+
</dict>
|
|
10
|
+
</plist>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
6
|
+
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
7
|
+
<key>CFBundleDisplayName</key>
|
|
8
|
+
<string>ConnectNCE</string>
|
|
9
|
+
<key>CFBundleExecutable</key>
|
|
10
|
+
<string>$(EXECUTABLE_NAME)</string>
|
|
11
|
+
<key>CFBundleIdentifier</key>
|
|
12
|
+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
13
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
14
|
+
<string>6.0</string>
|
|
15
|
+
<key>CFBundleName</key>
|
|
16
|
+
<string>$(PRODUCT_NAME)</string>
|
|
17
|
+
<key>CFBundlePackageType</key>
|
|
18
|
+
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
|
19
|
+
<key>CFBundleShortVersionString</key>
|
|
20
|
+
<string>$(MARKETING_VERSION)</string>
|
|
21
|
+
<key>CFBundleVersion</key>
|
|
22
|
+
<string>$(CURRENT_PROJECT_VERSION)</string>
|
|
23
|
+
<key>NSExtension</key>
|
|
24
|
+
<dict>
|
|
25
|
+
<key>NSExtensionAttributes</key>
|
|
26
|
+
<dict>
|
|
27
|
+
<key>UNNotificationExtensionCategory</key>
|
|
28
|
+
<array>
|
|
29
|
+
<string>ACOUSTIC_RICH_NOTIFICATION</string>
|
|
30
|
+
<string>ACTIONABLE_NOTIFICATION</string>
|
|
31
|
+
</array>
|
|
32
|
+
<key>UNNotificationExtensionDefaultContentHidden</key>
|
|
33
|
+
<false/>
|
|
34
|
+
<key>UNNotificationExtensionInitialContentSizeRatio</key>
|
|
35
|
+
<real>1.0</real>
|
|
36
|
+
</dict>
|
|
37
|
+
<key>NSExtensionPointIdentifier</key>
|
|
38
|
+
<string>com.apple.usernotifications.content-extension</string>
|
|
39
|
+
<key>NSExtensionPrincipalClass</key>
|
|
40
|
+
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>
|
|
41
|
+
</dict>
|
|
42
|
+
<key>RCTNewArchEnabled</key>
|
|
43
|
+
<true/>
|
|
44
|
+
</dict>
|
|
45
|
+
</plist>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.security.application-groups</key>
|
|
6
|
+
<array>
|
|
7
|
+
<string>CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER</string>
|
|
8
|
+
</array>
|
|
9
|
+
</dict>
|
|
10
|
+
</plist>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
6
|
+
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
7
|
+
<key>CFBundleDisplayName</key>
|
|
8
|
+
<string>ConnectNSE</string>
|
|
9
|
+
<key>CFBundleExecutable</key>
|
|
10
|
+
<string>$(EXECUTABLE_NAME)</string>
|
|
11
|
+
<key>CFBundleIdentifier</key>
|
|
12
|
+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
13
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
14
|
+
<string>6.0</string>
|
|
15
|
+
<key>CFBundleName</key>
|
|
16
|
+
<string>$(PRODUCT_NAME)</string>
|
|
17
|
+
<key>CFBundlePackageType</key>
|
|
18
|
+
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
|
19
|
+
<key>CFBundleShortVersionString</key>
|
|
20
|
+
<string>$(MARKETING_VERSION)</string>
|
|
21
|
+
<key>CFBundleVersion</key>
|
|
22
|
+
<string>$(CURRENT_PROJECT_VERSION)</string>
|
|
23
|
+
<key>NSExtension</key>
|
|
24
|
+
<dict>
|
|
25
|
+
<key>NSExtensionPointIdentifier</key>
|
|
26
|
+
<string>com.apple.usernotifications.service</string>
|
|
27
|
+
<key>NSExtensionPrincipalClass</key>
|
|
28
|
+
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
|
29
|
+
</dict>
|
|
30
|
+
<key>RCTNewArchEnabled</key>
|
|
31
|
+
<true/>
|
|
32
|
+
</dict>
|
|
33
|
+
</plist>
|
package/cli/lib.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Shared helpers for the Acoustic Connect SDK setup CLI (`acoustic-connect`).
|
|
2
|
+
//
|
|
3
|
+
// This is the single source of truth that used to be duplicated across the
|
|
4
|
+
// Examples/expo and Examples/bare-workflow `scripts/lib.mjs` files. It ships in
|
|
5
|
+
// the published SDK package so client apps (and our own samples) can run the
|
|
6
|
+
// same doctor / scaffolding without copying it.
|
|
7
|
+
//
|
|
8
|
+
// Cross-platform (macOS / Linux / Windows): only node: built-ins, commands run
|
|
9
|
+
// through the platform shell. iOS-only commands (pod/bundle/ruby) are guarded
|
|
10
|
+
// by the callers, never run on Windows.
|
|
11
|
+
|
|
12
|
+
import {spawnSync} from 'node:child_process'
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
const isWindows = process.platform === 'win32'
|
|
17
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR
|
|
18
|
+
|
|
19
|
+
const paint = (code, s) => (useColor ? `[${code}m${s}[0m` : s)
|
|
20
|
+
export const color = {
|
|
21
|
+
bold: (s) => paint('1', s),
|
|
22
|
+
dim: (s) => paint('2', s),
|
|
23
|
+
red: (s) => paint('31', s),
|
|
24
|
+
green: (s) => paint('32', s),
|
|
25
|
+
yellow: (s) => paint('33', s),
|
|
26
|
+
cyan: (s) => paint('36', s),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function section(title) {
|
|
30
|
+
console.log('\n' + color.bold(color.cyan(`▸ ${title}`)))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function info(msg) {
|
|
34
|
+
console.log(` ${msg}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Run a shell command, streaming its output. Returns true on exit code 0.
|
|
38
|
+
// `command` is a full command line (shell-interpreted) so callers can write it
|
|
39
|
+
// the way they'd type it. Pass dynamic, path-derived values through `env`
|
|
40
|
+
// (merged over the parent environment) rather than interpolating them into
|
|
41
|
+
// `command` — that keeps a value containing shell metacharacters (`$`,
|
|
42
|
+
// backticks, quotes, spaces) from being re-parsed by the shell.
|
|
43
|
+
export function run(command, {cwd, env} = {}) {
|
|
44
|
+
return (
|
|
45
|
+
spawnSync(command, {
|
|
46
|
+
cwd,
|
|
47
|
+
env: {...process.env, ...env},
|
|
48
|
+
stdio: 'inherit',
|
|
49
|
+
shell: true,
|
|
50
|
+
}).status === 0
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Run a command and capture stdout/stderr instead of streaming. Used for quick
|
|
55
|
+
// version/state probes — never throws (spawnSync returns on a missing binary
|
|
56
|
+
// rather than throwing), and is bounded by `timeout` (default 60s) so an
|
|
57
|
+
// unresponsive probe — e.g. `adb shell` against an offline device — can't hang
|
|
58
|
+
// the caller. On timeout the child is killed and `ok` is false.
|
|
59
|
+
export function capture(command, {cwd, timeout = 60000} = {}) {
|
|
60
|
+
const result = spawnSync(command, {
|
|
61
|
+
cwd,
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
shell: true,
|
|
64
|
+
timeout,
|
|
65
|
+
killSignal: 'SIGKILL',
|
|
66
|
+
})
|
|
67
|
+
return {
|
|
68
|
+
ok: result.status === 0,
|
|
69
|
+
stdout: result.stdout || '',
|
|
70
|
+
stderr: result.stderr || '',
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Is a binary resolvable on PATH? Uses `where` on Windows, `command -v` elsewhere.
|
|
75
|
+
export function commandExists(cmd) {
|
|
76
|
+
const probe = isWindows ? `where ${cmd}` : `command -v ${cmd}`
|
|
77
|
+
return spawnSync(probe, {stdio: 'ignore', shell: true}).status === 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function fileExists(p) {
|
|
81
|
+
try {
|
|
82
|
+
return fs.existsSync(p)
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function readText(p) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.readFileSync(p, 'utf8')
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Read + parse JSON. Returns the parsed value, or `undefined` for any
|
|
97
|
+
// "no usable object" outcome — missing, unreadable, or malformed — so callers
|
|
98
|
+
// only need a single `=== undefined` check and can never hit a TypeError on a
|
|
99
|
+
// null result. Callers that must distinguish missing from malformed should call
|
|
100
|
+
// fileExists() first.
|
|
101
|
+
export function readJson(p) {
|
|
102
|
+
const raw = readText(p)
|
|
103
|
+
if (raw == null) return undefined
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(raw)
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Copy `src` to `dest` only when `dest` is missing. Returns 'created' |
|
|
112
|
+
// 'exists' | 'no-template'. Callers surface the result so the developer knows
|
|
113
|
+
// to fill the freshly-copied file in.
|
|
114
|
+
export function copyIfMissing(src, dest) {
|
|
115
|
+
if (fileExists(dest)) return 'exists'
|
|
116
|
+
if (!fileExists(src)) return 'no-template'
|
|
117
|
+
fs.mkdirSync(path.dirname(dest), {recursive: true})
|
|
118
|
+
fs.copyFileSync(src, dest)
|
|
119
|
+
return 'created'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Walk up from `startDir` for the nearest package.json declaring a `workspaces`
|
|
123
|
+
// array that actually includes `startDir` — that's where `npm install` must run
|
|
124
|
+
// so the SDK is hoisted/symlinked. Returns null for a standalone (published)
|
|
125
|
+
// checkout with no workspace.
|
|
126
|
+
//
|
|
127
|
+
// Only a workspace that actually declares THIS app as a member counts — we
|
|
128
|
+
// verify `target` matches one of the candidate's `workspaces` globs. That way
|
|
129
|
+
// an unrelated outer workspace (e.g. a CI tool's own monorepo root that happens
|
|
130
|
+
// to sit above the checkout) is skipped rather than mistaken for our root.
|
|
131
|
+
export function findWorkspaceRoot(startDir) {
|
|
132
|
+
const target = path.resolve(startDir)
|
|
133
|
+
let dir = target
|
|
134
|
+
for (;;) {
|
|
135
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
136
|
+
if (fileExists(pkgPath)) {
|
|
137
|
+
try {
|
|
138
|
+
const pkg = JSON.parse(readText(pkgPath))
|
|
139
|
+
if (pkg && pkg.workspaces && workspaceIncludes(dir, pkg.workspaces, target))
|
|
140
|
+
return dir
|
|
141
|
+
} catch {
|
|
142
|
+
// ignore malformed package.json and keep walking up
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const parent = path.dirname(dir)
|
|
146
|
+
if (parent === dir) return null
|
|
147
|
+
dir = parent
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Does `rootDir`'s `workspaces` declaration cover `target`? Handles the array
|
|
152
|
+
// form and the { packages: [...] } object form, with npm's glob semantics
|
|
153
|
+
// (`*` within a path segment, `**` across segments).
|
|
154
|
+
function workspaceIncludes(rootDir, workspaces, target) {
|
|
155
|
+
const globs = Array.isArray(workspaces)
|
|
156
|
+
? workspaces
|
|
157
|
+
: Array.isArray(workspaces.packages)
|
|
158
|
+
? workspaces.packages
|
|
159
|
+
: []
|
|
160
|
+
if (rootDir === target) return true // the workspace root itself
|
|
161
|
+
const rel = path.relative(rootDir, target).split(path.sep).join('/')
|
|
162
|
+
return globs.some((glob) => {
|
|
163
|
+
const pattern = String(glob).replace(/\/$/, '')
|
|
164
|
+
const re = new RegExp(
|
|
165
|
+
'^' +
|
|
166
|
+
pattern
|
|
167
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex specials (keep * /)
|
|
168
|
+
.replace(/\*\*?/g, (m) => (m === '**' ? '.*' : '[^/]*')) + // ** -> .* , * -> one path segment
|
|
169
|
+
'$',
|
|
170
|
+
)
|
|
171
|
+
return re.test(rel)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Accumulates pass/warn/fail results and prints a final summary. A single
|
|
176
|
+
// `fail` makes the process exit non-zero so CI / the developer notice.
|
|
177
|
+
export class Reporter {
|
|
178
|
+
constructor() {
|
|
179
|
+
this.results = []
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
record(status, label, detail) {
|
|
183
|
+
this.results.push({status, label, detail})
|
|
184
|
+
const mark =
|
|
185
|
+
status === 'pass'
|
|
186
|
+
? color.green('✓')
|
|
187
|
+
: status === 'warn'
|
|
188
|
+
? color.yellow('⚠')
|
|
189
|
+
: color.red('✗')
|
|
190
|
+
const tail = detail ? ` ${color.dim('— ' + detail)}` : ''
|
|
191
|
+
console.log(` ${mark} ${label}${tail}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
pass(label, detail) {
|
|
195
|
+
this.record('pass', label, detail)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
warn(label, detail) {
|
|
199
|
+
this.record('warn', label, detail)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fail(label, detail) {
|
|
203
|
+
this.record('fail', label, detail)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
hasFailures() {
|
|
207
|
+
return this.results.some((r) => r.status === 'fail')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Print the summary and any actionable next steps, then return the exit code.
|
|
211
|
+
summary(nextSteps = []) {
|
|
212
|
+
const counts = {pass: 0, warn: 0, fail: 0}
|
|
213
|
+
for (const r of this.results) counts[r.status]++
|
|
214
|
+
|
|
215
|
+
section('Summary')
|
|
216
|
+
info(
|
|
217
|
+
`${color.green(counts.pass + ' ok')}, ` +
|
|
218
|
+
`${color.yellow(counts.warn + ' warning(s)')}, ` +
|
|
219
|
+
`${color.red(counts.fail + ' failure(s)')}`,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const actionable = this.results.filter(
|
|
223
|
+
(r) => r.status !== 'pass' && r.detail,
|
|
224
|
+
)
|
|
225
|
+
if (actionable.length) {
|
|
226
|
+
section('Action needed')
|
|
227
|
+
for (const r of actionable) {
|
|
228
|
+
const mark = r.status === 'fail' ? color.red('✗') : color.yellow('⚠')
|
|
229
|
+
info(`${mark} ${r.label}: ${r.detail}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (nextSteps.length) {
|
|
234
|
+
section('Next steps')
|
|
235
|
+
for (const s of nextSteps) info(`• ${s}`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return this.hasFailures() ? 1 : 0
|
|
239
|
+
}
|
|
240
|
+
}
|