@expo-harmony/expo-module-scripts 55.0.0-harmony.0

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 ADDED
@@ -0,0 +1,75 @@
1
+ # @expo-harmony/expo-module-scripts
2
+
3
+ [**GitHub 仓库**](https://github.com/renbaoshuo/expo-harmony/tree/master/packages/expo-module-scripts)
4
+
5
+ 为 Harmony Expo Module 提供 HarmonyOS 平台的构建与打包命令。包内提供独立的 `expo-harmony-module` 命令,负责 OHPM 依赖安装、Hvigor HAR 构建、发布产物清洗和 npm 打包前校验;命令名与官方的 `expo-module` 不同,两者可以在同一个模块包中共存。
6
+
7
+ ## 安装
8
+
9
+ ```sh
10
+ npm install --save-dev @expo-harmony/expo-module-scripts
11
+ ```
12
+
13
+ 需要 Node.js 20 或更高版本,以及本机可用的 OHPM 和 Hvigor(安装 DevEco Studio 后通常已经具备)。
14
+
15
+ ## 约定布局
16
+
17
+ 所有命令都按固定约定定位文件,模块包需要具备以下结构:
18
+
19
+ ```
20
+ my-expo-module/
21
+ ├── expo-module.config.json # platforms 须包含 "harmony",harmony.modules 声明 ArkTS 模块类名
22
+ ├── package.json
23
+ └── harmony/
24
+ ├── build-profile.json5 # 可选,其中的模块名用于定位构建产物
25
+ ├── library/ # HAR library 模块
26
+ │ ├── oh-package.json5 # OHPM 清单
27
+ │ └── build/default/outputs/default/<模块名>.har
28
+ └── library.har # 清洗后的发布产物,随 npm 包一起发布
29
+ ```
30
+
31
+ 模块名解析优先读取 `harmony/build-profile.json5` 中 `srcPath` 指向 `./library` 的模块名称;若未提供该文件,则默认回退至 `library`。
32
+
33
+ ## 命令
34
+
35
+ | 命令 | 作用 |
36
+ | ------------------------------------------ | ------------------------------------------------- |
37
+ | `expo-harmony-module inspect` | 输出解析后的模块配置和约定路径,不执行构建 |
38
+ | `expo-harmony-module prepare` | 安装依赖、构建 HAR 并发布到 `harmony/library.har` |
39
+ | `expo-harmony-module prepare --clean-only` | 只执行 Hvigor `clean` 任务 |
40
+ | `expo-harmony-module prepack` | 从干净状态重建 HAR,并校验 npm 包内容 |
41
+
42
+ 通用选项:`--root <path>` 指定模块包根目录(默认为当前目录);`--json` 以 JSON 输出结果;`--dry-run` 只打印将要执行的命令而不实际运行(不能与 `inspect` 同用)。`inspect` 始终输出 JSON。
43
+
44
+ `prepare` 在 `harmony/` 工程内依次执行 `ohpm install --all` 和 Hvigor `assembleHar`(module 模式、`default` product、release buildMode、`--no-daemon`),然后把 `harmony/library/build/default/outputs/default/<模块名>.har` 清洗后原子替换 `harmony/library.har`。
45
+
46
+ `prepack` 在构建前额外执行一次 Hvigor `clean` 以保证全量重建,构建完成后运行 `npm pack --dry-run --json --ignore-scripts` 检查将要发布的内容:只允许约定内的文件(源码与各平台目录、文档、`harmony/library.har`、`harmony/library/oh-package.json5` 等),且 `package.json`、`expo-module.config.json`、`main`/`types` 入口和两个 Harmony 产物必须齐全;出现多余或缺失的文件都会报错。
47
+
48
+ ## HAR 清洗
49
+
50
+ 发布产物在写入 `harmony/library.har` 前会重新打包:`oh-package.json5` 的 `dependencies`、`devDependencies` 和 `dynamicDependencies` 不允许使用 `file:`、`link:`、`workspace:` 或本地路径,`oh-package-lock.json5` 会被移除,最终以 portable 格式重新压缩,避免把宿主机路径或本地依赖带进发布产物。
51
+
52
+ ## 工具链
53
+
54
+ `ohpm` 和 `hvigorw` 可分别通过环境变量 `HARMONY_OHPM` 和 `HARMONY_HVIGORW` 指定路径;当 `HARMONY_HVIGORW` 指向 JS 脚本时,会改用 `HARMONY_NODE`(缺省为当前 Node 可执行文件)来运行。
55
+
56
+ ## 在模块包中使用
57
+
58
+ ```json
59
+ {
60
+ "scripts": {
61
+ "harmony:clean": "expo-harmony-module prepare --clean-only",
62
+ "harmony:build": "expo-harmony-module prepare",
63
+ "prepack": "expo-harmony-module prepack"
64
+ }
65
+ }
66
+ ```
67
+
68
+ `prepack` 挂在 npm 的同名生命周期钩子上,`npm pack` 和 `npm publish` 前会自动执行。除 CLI 外,包还导出 `inspectModule`、`prepareModule`、`prepackModule` 等函数供编程调用。
69
+
70
+ ## Author
71
+
72
+ **expo-harmony** © [Baoshuo](https://github.com/renbaoshuo), Released under the MIT License.<br>
73
+ Authored and maintained by Baoshuo with help from [contributors](https://github.com/renbaoshuo/expo-harmony/contributors).
74
+
75
+ > [Personal Website](https://baoshuo.ren) · [Blog](https://blog.baoshuo.ren) · GitHub [@renbaoshuo](https://github.com/renbaoshuo) · Twitter [@baoshuo](https://twitter.com/baoshuo)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/index.mjs';
4
+
5
+ runCli(process.argv.slice(2)).catch((error) => {
6
+ process.stderr.write(`expo-harmony-module: ${error instanceof Error ? error.message : String(error)}\n`);
7
+ process.exitCode = 1;
8
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@expo-harmony/expo-module-scripts",
3
+ "version": "55.0.0-harmony.0",
4
+ "description": "HarmonyOS port of expo-module-scripts",
5
+ "keywords": [
6
+ "react-native",
7
+ "expo",
8
+ "expo-harmony",
9
+ "harmonyos"
10
+ ],
11
+ "homepage": "https://github.com/renbaoshuo/expo-harmony/tree/master/packages/expo-module-scripts#readme",
12
+ "bugs": "https://github.com/renbaoshuo/expo-harmony/issues",
13
+ "license": "MIT",
14
+ "author": "Baoshuo <i@baoshuo.ren>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/renbaoshuo/expo-harmony.git",
18
+ "directory": "packages/expo-module-scripts"
19
+ },
20
+ "type": "module",
21
+ "main": "./src/index.mjs",
22
+ "bin": {
23
+ "expo-harmony-module": "bin/expo-harmony-module.js"
24
+ },
25
+ "dependencies": {
26
+ "@expo-harmony/expo-modules-autolinking": "55.0.25-harmony.1",
27
+ "fast-glob": "^3.3.2",
28
+ "json5": "2.2.3",
29
+ "semver": "7.7.3",
30
+ "tar": "6.2.1"
31
+ },
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/src/har.mjs ADDED
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import JSON5 from 'json5';
6
+ import tar from 'tar';
7
+
8
+ const localDependency = /^(?:file:|link:|workspace:|\.{1,2}[\\/])|^(?:\/|[A-Za-z]:[\\/])/u;
9
+ const absoluteSourcePath = /(?:\/Users\/[^/]+\/|\/home\/[^/]+\/|[A-Za-z]:\\Users\\)/u;
10
+
11
+ // Hvigor retains a relative OHPM dependency for a native library's type package.
12
+ // It is portable only when both the declaration package and binary are in this HAR.
13
+ function isBundledNativeTypeDependency(packageRoot, manifest, name, specifier) {
14
+ if (path.basename(name) !== name || !name.endsWith('.so') || !manifest.nativeComponents?.some(component => component.name === name)) return false;
15
+ const relative = specifier.replace(/^file:/u, '');
16
+ if (!relative.startsWith('./')) return false;
17
+
18
+ try {
19
+ const root = fs.realpathSync(packageRoot);
20
+ const typeRoot = fs.realpathSync(path.resolve(root, relative));
21
+ const relation = path.relative(root, typeRoot);
22
+ if (!relation || relation.startsWith(`..${path.sep}`) || relation === '..' || path.isAbsolute(relation)) return false;
23
+ const types = JSON5.parse(fs.readFileSync(path.join(typeRoot, 'oh-package.json5'), 'utf8'));
24
+ if (types.name !== name || typeof types.types !== 'string' || !types.types.endsWith('.d.ts')) return false;
25
+ const declaration = fs.realpathSync(path.resolve(typeRoot, types.types));
26
+ const declarationRelation = path.relative(typeRoot, declaration);
27
+ if (declarationRelation.startsWith(`..${path.sep}`) || declarationRelation === '..' || path.isAbsolute(declarationRelation)) return false;
28
+ if (!fs.statSync(declaration).isFile()) return false;
29
+ const libs = path.join(root, 'libs');
30
+ return fs.readdirSync(libs, { withFileTypes: true }).some((architecture) => {
31
+ const binary = path.join(libs, architecture.name, name);
32
+ return architecture.isDirectory() && fs.existsSync(binary) && fs.lstatSync(binary).isFile();
33
+ });
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export async function sanitizeHarmonyHar(file, { sourceManifest: source, workspaceVersions: versions = {} } = {}) {
40
+ const temp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'expo-har-'));
41
+ const output = path.join(temp, 'library.har');
42
+
43
+ try {
44
+ await tar.x({ cwd: temp, file, strict: true });
45
+
46
+ const root = path.join(temp, 'package');
47
+ const target = path.join(root, 'oh-package.json5');
48
+ const manifest = JSON5.parse(await fs.promises.readFile(target, 'utf8'));
49
+
50
+ for (const section of ['dependencies', 'devDependencies', 'dynamicDependencies']) {
51
+ for (const [name, value] of Object.entries(manifest[section] || {})) {
52
+ if (typeof value !== 'string' || !localDependency.test(value)) continue;
53
+ if (isBundledNativeTypeDependency(root, manifest, name, value)) continue;
54
+
55
+ // Hvigor can retain an override's local HAR path. Only rewrite known
56
+ // workspace dependencies, preserving the authored publication range.
57
+ if (Object.hasOwn(versions, name)) {
58
+ const version = source?.[section]?.[name] || versions[name];
59
+ if (typeof version === 'string' && !localDependency.test(version)) {
60
+ manifest[section][name] = version;
61
+ continue;
62
+ }
63
+ }
64
+
65
+ throw new Error(`Cannot publish ${file}: ${section}.${name} must use a package version.`);
66
+ }
67
+ }
68
+
69
+ await fs.promises.writeFile(target, `${JSON5.stringify(manifest, null, 2)}\n`);
70
+ await fs.promises.rm(path.join(root, 'oh-package-lock.json5'), { force: true });
71
+
72
+ await tar.c({ cwd: temp, file: output, gzip: true, portable: true }, ['package']);
73
+ await fs.promises.copyFile(output, file);
74
+ } finally {
75
+ await fs.promises.rm(temp, { recursive: true, force: true });
76
+ }
77
+ }
78
+
79
+ export function assertPortableHarmonyHarSync(harPath) {
80
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-har-check-'));
81
+ try {
82
+ tar.x({ cwd: tempRoot, file: harPath, strict: true, sync: true });
83
+
84
+ const packageRoot = path.join(tempRoot, 'package');
85
+ for (const name of ['oh-package.json5', 'oh-package-lock.json5']) {
86
+ const file = path.join(packageRoot, name);
87
+ if (!fs.existsSync(file)) continue;
88
+
89
+ const content = fs.readFileSync(file, 'utf8');
90
+ if (absoluteSourcePath.test(content) || content.includes('file:/')) {
91
+ throw new Error(`${harPath} leaks a local dependency path in package/${name}.`);
92
+ }
93
+ if (name === 'oh-package.json5') {
94
+ const manifest = JSON5.parse(content);
95
+
96
+ for (const section of ['dependencies', 'devDependencies', 'dynamicDependencies']) {
97
+ for (const [dependency, version] of Object.entries(manifest[section] || {})) {
98
+ if (typeof version === 'string' && localDependency.test(version)) {
99
+ if (isBundledNativeTypeDependency(packageRoot, manifest, dependency, version)) continue;
100
+ throw new Error(`${harPath} package/${name} ${section}.${dependency} must use a package version.`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ } finally {
107
+ fs.rmSync(tempRoot, { recursive: true, force: true });
108
+ }
109
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,485 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ import { normalizeHarmonyModuleMetadata } from '@expo-harmony/expo-modules-autolinking';
7
+ import JSON5 from 'json5';
8
+ import { sanitizeHarmonyHar } from './har.mjs';
9
+ import { findWorkspacePackages, planWorkspaceBuild, workspaceOverrides, withWorkspaceOverrides } from './workspace.mjs';
10
+
11
+ const HARMONY_PROJECT = 'harmony';
12
+ const HARMONY_MODULE = 'library';
13
+ const HARMONY_PRODUCT = 'default';
14
+ const BUNDLED_HAR = 'harmony/library.har';
15
+ const OH_PACKAGE_MANIFEST = 'harmony/library/oh-package.json5';
16
+
17
+ function requiredString(value, field) {
18
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${field} is required.`);
19
+ return value;
20
+ }
21
+
22
+ function normalizeConfig(raw, pkg) {
23
+ if (!Array.isArray(raw?.platforms) || !raw.platforms.includes('harmony')) {
24
+ throw new TypeError('expo-module.config.json#platforms must include harmony.');
25
+ }
26
+
27
+ const { modules } = normalizeHarmonyModuleMetadata(raw.harmony, {
28
+ packageName: requiredString(pkg.name, 'package.json#name'),
29
+ packageVersion: typeof pkg.version === 'string' ? pkg.version : undefined,
30
+ });
31
+
32
+ if (modules.length === 0) throw new TypeError('harmony.modules must declare at least one ArkTS module.');
33
+
34
+ return { modules };
35
+ }
36
+
37
+ async function readJson(file) {
38
+ return JSON.parse(await fs.promises.readFile(file, 'utf8'));
39
+ }
40
+
41
+ function inside(root, relative, field) {
42
+ const resolved = path.resolve(root, relative);
43
+ const relation = path.relative(root, resolved);
44
+
45
+ if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) {
46
+ throw new TypeError(`${field} escapes the package root.`);
47
+ }
48
+
49
+ return resolved;
50
+ }
51
+
52
+ async function assertExistingAncestorInside(root, target, field) {
53
+ let ancestor = target;
54
+ while (!(await exists(ancestor))) {
55
+ const parent = path.dirname(ancestor);
56
+ if (parent === ancestor) throw new TypeError(`${field} has no package-contained parent.`);
57
+
58
+ ancestor = parent;
59
+ }
60
+
61
+ const realAncestor = await fs.promises.realpath(ancestor);
62
+ const relation = path.relative(root, realAncestor);
63
+
64
+ if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) {
65
+ throw new TypeError(`${field} resolves outside the package root through a symbolic link.`);
66
+ }
67
+ }
68
+
69
+ function harmonyModuleName(packageRoot) {
70
+ const projectRoot = path.join(packageRoot, HARMONY_PROJECT);
71
+ const profilePath = path.join(projectRoot, 'build-profile.json5');
72
+ if (!fs.existsSync(profilePath)) return HARMONY_MODULE;
73
+
74
+ const profile = JSON5.parse(fs.readFileSync(profilePath, 'utf8'));
75
+ const moduleRoot = path.join(projectRoot, HARMONY_MODULE);
76
+ const module = profile.modules?.find(candidate =>
77
+ typeof candidate?.srcPath === 'string' && path.resolve(projectRoot, candidate.srcPath) === moduleRoot
78
+ );
79
+ const name = requiredString(module?.name, 'harmony/build-profile.json5 module name');
80
+
81
+ if (path.basename(name) !== name) throw new TypeError('Harmony module name must not contain a path.');
82
+
83
+ return name;
84
+ }
85
+
86
+ function modulePaths(packageRoot, moduleName = harmonyModuleName(packageRoot)) {
87
+ const projectRoot = inside(packageRoot, HARMONY_PROJECT, 'Harmony project');
88
+ return {
89
+ moduleName,
90
+ projectRoot,
91
+ moduleRoot: inside(projectRoot, HARMONY_MODULE, 'Harmony module'),
92
+ sourceOutput: inside(projectRoot, `library/build/default/outputs/default/${moduleName}.har`, 'Hvigor HAR output'),
93
+ bundledHar: inside(packageRoot, BUNDLED_HAR, 'Bundled HAR'),
94
+ ohPackageManifest: inside(packageRoot, OH_PACKAGE_MANIFEST, 'OHPM package manifest'),
95
+ };
96
+ }
97
+
98
+ async function loadBuildConfig(root, manifest) {
99
+ const file = path.join(root, 'expo-module.config.json');
100
+ if (await exists(file)) {
101
+ return normalizeConfig(await readJson(file), manifest);
102
+ }
103
+
104
+ // RNOH runtime packages (including expo-modules-core) have no Expo module
105
+ // registration. Keep their existing HAR filename and metadata contract.
106
+ const linking = manifest.harmony?.autolinking;
107
+ if (linking?.mainHarPath === 'harmony' && linking.ohPackageName === manifest.name) return { modules: [] };
108
+
109
+ throw new TypeError(`${manifest.name} must declare an Expo Harmony module or a Harmony RNOH package.`);
110
+ }
111
+
112
+ function projectPaths(root, config) {
113
+ const paths = modulePaths(root);
114
+ if (config.modules.length === 0) {
115
+ paths.bundledHar = inside(root, `harmony/${paths.moduleName}.har`, 'Bundled HAR');
116
+ }
117
+ return paths;
118
+ }
119
+
120
+ export async function loadModuleProject(root = process.cwd()) {
121
+ const packageRoot = await fs.promises.realpath(path.resolve(root));
122
+ const pkg = await readJson(path.join(packageRoot, 'package.json'));
123
+
124
+ const config = await loadBuildConfig(packageRoot, pkg);
125
+ const paths = projectPaths(packageRoot, config);
126
+
127
+ await assertExistingAncestorInside(packageRoot, paths.projectRoot, 'Harmony project');
128
+ const projectRoot = await fs.promises.realpath(paths.projectRoot);
129
+ const modulePath = inside(projectRoot, HARMONY_MODULE, 'Harmony module');
130
+ await assertExistingAncestorInside(packageRoot, modulePath, 'Harmony module');
131
+ const moduleRoot = await fs.promises.realpath(modulePath);
132
+ await assertExistingAncestorInside(packageRoot, paths.sourceOutput, 'Hvigor HAR output');
133
+ await assertExistingAncestorInside(packageRoot, paths.bundledHar, 'Bundled HAR');
134
+ await assertNonEmptyRegularFile(paths.ohPackageManifest, 'OHPM package manifest', moduleRoot);
135
+
136
+ const library = JSON5.parse(await fs.promises.readFile(paths.ohPackageManifest, 'utf8'));
137
+ const project = JSON5.parse(await fs.promises.readFile(path.join(projectRoot, 'oh-package.json5'), 'utf8'));
138
+
139
+ return {
140
+ packageRoot,
141
+ packageJson: pkg,
142
+ config,
143
+ projectRoot,
144
+ moduleRoot,
145
+ moduleName: paths.moduleName,
146
+ sourceOutput: paths.sourceOutput,
147
+ bundledHar: paths.bundledHar,
148
+ ohPackageManifest: paths.ohPackageManifest,
149
+ ohPackage: library,
150
+ ohProjectPackage: project,
151
+ };
152
+ }
153
+
154
+ async function exists(file) {
155
+ try {
156
+ await fs.promises.access(file);
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ export async function inspectModule(root = process.cwd()) {
164
+ const packageRoot = await fs.promises.realpath(path.resolve(root));
165
+ const pkg = await readJson(path.join(packageRoot, 'package.json'));
166
+
167
+ const config = await loadBuildConfig(packageRoot, pkg);
168
+ const paths = projectPaths(packageRoot, config);
169
+
170
+ return {
171
+ config,
172
+ paths: {
173
+ project: path.relative(packageRoot, paths.projectRoot).replace(/\\/g, '/'),
174
+ module: path.relative(packageRoot, paths.moduleRoot).replace(/\\/g, '/'),
175
+ output: path.relative(packageRoot, paths.sourceOutput).replace(/\\/g, '/'),
176
+ bundledHar: path.relative(packageRoot, paths.bundledHar).replace(/\\/g, '/'),
177
+ ohPackageManifest: path.relative(packageRoot, paths.ohPackageManifest).replace(/\\/g, '/'),
178
+ },
179
+ };
180
+ }
181
+
182
+ async function assertNonEmptyRegularFile(file, label, allowedRoot) {
183
+ let stat;
184
+ try {
185
+ stat = await fs.promises.lstat(file);
186
+ } catch (cause) {
187
+ if (cause.code === 'ENOENT') throw new Error(`${label} is missing: ${file}`);
188
+
189
+ throw new Error(cause instanceof Error ? cause.message : String(cause), { cause });
190
+ }
191
+
192
+ if (stat.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link: ${file}`);
193
+ if (!stat.isFile()) throw new Error(`${label} must be a regular file: ${file}`);
194
+ if (stat.size === 0) throw new Error(`${label} must not be empty: ${file}`);
195
+
196
+ if (allowedRoot) {
197
+ const [realRoot, realFile] = await Promise.all([
198
+ fs.promises.realpath(allowedRoot),
199
+ fs.promises.realpath(file),
200
+ ]);
201
+ const relation = path.relative(realRoot, realFile);
202
+
203
+ if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) {
204
+ throw new Error(`${label} resolves outside its allowed root: ${file}`);
205
+ }
206
+ }
207
+ }
208
+
209
+ export function fixedHvigorArgs(task = 'assembleHar', moduleName = HARMONY_MODULE) {
210
+ if (task !== 'assembleHar' && task !== 'clean') throw new TypeError(`Unsupported Hvigor task: ${task}`);
211
+
212
+ return [
213
+ '--mode', 'module',
214
+ '-p', `module=${moduleName}@default`,
215
+ '-p', `product=${HARMONY_PRODUCT}`,
216
+ '-p', 'buildMode=release',
217
+ '--no-daemon',
218
+ task,
219
+ ];
220
+ }
221
+
222
+ export function resolveModuleCommand(command, args, env = process.env) {
223
+ if (command === 'ohpm' && env.HARMONY_OHPM) {
224
+ return { command: env.HARMONY_OHPM, args };
225
+ }
226
+ if (command === 'hvigorw' && env.HARMONY_HVIGORW) {
227
+ return /\.(?:c|m)?js$/i.test(env.HARMONY_HVIGORW)
228
+ ? {
229
+ command: env.HARMONY_NODE || process.execPath,
230
+ args: [env.HARMONY_HVIGORW, ...args],
231
+ }
232
+ : { command: env.HARMONY_HVIGORW, args };
233
+ }
234
+ return { command, args };
235
+ }
236
+
237
+ function spawnCommand(command, args, options = {}) {
238
+ return new Promise((resolve, reject) => {
239
+ const invocation = resolveModuleCommand(command, args, process.env);
240
+ const child = spawn(invocation.command, invocation.args, {
241
+ cwd: options.cwd,
242
+ env: process.env,
243
+ shell: false,
244
+ signal: options.signal,
245
+ stdio: options.capture ? ['ignore', 'pipe', 'inherit'] : 'inherit',
246
+ });
247
+ let output = '';
248
+ let failure;
249
+
250
+ child.stdout?.on('data', (chunk) => {
251
+ output += chunk;
252
+ });
253
+ child.on('error', (error) => {
254
+ failure = error;
255
+ });
256
+
257
+ // Wait for the aborted child to stop before restoring its OHPM inputs.
258
+ child.on('close', (code) => {
259
+ if (failure) reject(failure);
260
+ else if (code === 0) resolve(output);
261
+ else reject(new Error(`${invocation.command} exited with code ${code}.`));
262
+ });
263
+ });
264
+ }
265
+
266
+ async function publishBuiltHar(project, dependencies = []) {
267
+ await assertNonEmptyRegularFile(project.sourceOutput, 'Hvigor HAR output', project.projectRoot);
268
+ await assertExistingAncestorInside(project.packageRoot, project.bundledHar, 'Bundled HAR');
269
+
270
+ const versions = Object.fromEntries(dependencies.map(dependency => [
271
+ dependency.ohPackage.name, dependency.ohPackage.version,
272
+ ]));
273
+ const temp = `${project.bundledHar}.${process.pid}.${Date.now()}.tmp`;
274
+
275
+ await fs.promises.mkdir(path.dirname(project.bundledHar), { recursive: true });
276
+
277
+ try {
278
+ await fs.promises.copyFile(project.sourceOutput, temp, fs.constants.COPYFILE_EXCL);
279
+ await sanitizeHarmonyHar(temp, {
280
+ sourceManifest: project.ohPackage,
281
+ workspaceVersions: versions,
282
+ });
283
+ await fs.promises.rename(temp, project.bundledHar);
284
+ } finally {
285
+ await fs.promises.rm(temp, { force: true });
286
+ }
287
+
288
+ await assertNonEmptyRegularFile(project.bundledHar, 'Bundled HAR', project.packageRoot);
289
+ }
290
+
291
+ async function createWorkspaceBuild(root, options = {}) {
292
+ root = await fs.promises.realpath(root);
293
+ const workspace = await findWorkspacePackages(root);
294
+
295
+ if (options.all && (!workspace || workspace.root !== root)) {
296
+ throw new Error('build-workspace must run at a package.json workspace root.');
297
+ }
298
+
299
+ const projects = [];
300
+ for (const directory of workspace?.packages || [root]) {
301
+ if (await exists(path.join(directory, OH_PACKAGE_MANIFEST))) {
302
+ projects.push(await loadModuleProject(directory));
303
+ }
304
+ }
305
+
306
+ const targets = options.all
307
+ ? projects.filter(project => !project.packageJson.private)
308
+ : projects.filter(project => project.packageRoot === root);
309
+ if (targets.length === 0) throw new Error(`No Harmony module projects found in ${root}.`);
310
+
311
+ return planWorkspaceBuild(projects, targets).map(({ project, dependencies }) => {
312
+ const install = { command: 'ohpm', args: ['install', '--all'], cwd: project.projectRoot };
313
+ const build = { command: 'hvigorw', args: fixedHvigorArgs('assembleHar', project.moduleName), cwd: project.projectRoot };
314
+ const clean = options.clean ? { ...build, args: fixedHvigorArgs('clean', project.moduleName) } : undefined;
315
+ const overrides = workspaceOverrides(project, dependencies);
316
+
317
+ return {
318
+ project,
319
+ dependencies,
320
+ plan: { ...build, packageName: project.packageJson.name, install, clean, build, overrides },
321
+ };
322
+ });
323
+ }
324
+
325
+ async function executeWorkspaceBuild(entries) {
326
+ for (const { project, dependencies, plan } of entries) {
327
+ process.stdout.write(`[Harmony] Building ${project.packageJson.name}\n`);
328
+
329
+ for (const dependency of dependencies) {
330
+ await assertNonEmptyRegularFile(dependency.bundledHar, 'Workspace dependency HAR', dependency.packageRoot);
331
+ }
332
+
333
+ await withWorkspaceOverrides(project, plan.overrides, async (signal) => {
334
+ const options = { cwd: project.projectRoot, signal };
335
+
336
+ await spawnCommand(plan.install.command, plan.install.args, options);
337
+ if (plan.clean) await spawnCommand(plan.clean.command, plan.clean.args, options);
338
+ await spawnCommand(plan.build.command, plan.build.args, options);
339
+
340
+ await publishBuiltHar(project, dependencies);
341
+ });
342
+ }
343
+ }
344
+
345
+ export async function buildWorkspace(root = process.cwd(), options = {}) {
346
+ const entries = await createWorkspaceBuild(root, { all: true });
347
+ if (!options.dryRun) await executeWorkspaceBuild(entries);
348
+ return { builds: entries.map(entry => entry.plan) };
349
+ }
350
+
351
+ export async function prepareModule(root = process.cwd(), options = {}) {
352
+ if (!options.cleanOnly) {
353
+ const entries = await createWorkspaceBuild(root);
354
+
355
+ if (!options.dryRun) await executeWorkspaceBuild(entries);
356
+
357
+ return { ...entries.at(-1).plan, workspaceBuilds: entries.map(entry => entry.plan) };
358
+ }
359
+
360
+ const project = await loadModuleProject(root);
361
+ const build = { command: 'hvigorw', args: fixedHvigorArgs('clean', project.moduleName), cwd: project.projectRoot };
362
+ const plan = { ...build, build };
363
+
364
+ if (options.dryRun) return plan;
365
+
366
+ await spawnCommand(build.command, build.args, { cwd: build.cwd });
367
+
368
+ return plan;
369
+ }
370
+
371
+ function allowedPackedFile(name, runtimeFiles) {
372
+ return runtimeFiles.has(name)
373
+ || name === 'package.json'
374
+ || name === 'expo-module.config.json'
375
+ || name === 'README.md'
376
+ || name === 'LICENSE'
377
+ || name === 'CHANGELOG.md'
378
+ || name === 'app.plugin.js'
379
+ || name === 'react-native.config.js'
380
+ || /^(?:[^/]+\.)?podspec(?:\.json)?$/.test(name)
381
+ || name.startsWith('build/')
382
+ || name.startsWith('src/')
383
+ || name.startsWith('ios/')
384
+ || name.startsWith('android/')
385
+ || name.startsWith('web/')
386
+ || name.startsWith('plugin/')
387
+ || name === BUNDLED_HAR
388
+ || name === OH_PACKAGE_MANIFEST;
389
+ }
390
+
391
+ function packageFile(value, field) {
392
+ const raw = requiredString(value, field).replace(/\\/g, '/');
393
+ const normalized = path.posix.normalize(raw).replace(/^\.\//, '');
394
+
395
+ if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(raw)
396
+ || normalized === '..' || normalized.startsWith('../')) {
397
+ throw new TypeError(`${field} must stay inside the package root.`);
398
+ }
399
+
400
+ return normalized;
401
+ }
402
+
403
+ export async function prepackModule(root = process.cwd(), options = {}) {
404
+ const project = await loadModuleProject(root);
405
+ const entries = await createWorkspaceBuild(root, { clean: true });
406
+
407
+ const plan = {
408
+ clean: { command: 'hvigorw', args: fixedHvigorArgs('clean', project.moduleName) },
409
+ install: { command: 'ohpm', args: ['install', '--all'] },
410
+ build: { command: 'hvigorw', args: fixedHvigorArgs('assembleHar', project.moduleName) },
411
+ pack: { command: 'npm', args: ['pack', '--dry-run', '--json', '--ignore-scripts'] },
412
+ workspaceBuilds: entries.map(entry => entry.plan),
413
+ };
414
+
415
+ if (options.dryRun) return plan;
416
+
417
+ await executeWorkspaceBuild(entries);
418
+
419
+ const output = await spawnCommand(plan.pack.command, plan.pack.args, { cwd: project.packageRoot, capture: true });
420
+ const result = JSON.parse(output);
421
+ const files = result[0]?.files?.map(entry => entry.path) ?? [];
422
+ const runtimeFiles = new Set([
423
+ packageFile(project.packageJson.main, 'package.json#main'),
424
+ packageFile(project.packageJson.types, 'package.json#types'),
425
+ ]);
426
+ const unexpected = files.filter(file => !allowedPackedFile(file, runtimeFiles));
427
+
428
+ if (unexpected.length > 0) throw new Error(`npm pack contains unexpected files: ${unexpected.join(', ')}`);
429
+
430
+ const required = [
431
+ 'package.json',
432
+ 'expo-module.config.json',
433
+ ...runtimeFiles,
434
+ BUNDLED_HAR,
435
+ OH_PACKAGE_MANIFEST,
436
+ ].filter(Boolean);
437
+ const missing = [...new Set(required)].filter(file => !files.includes(file));
438
+
439
+ if (missing.length > 0) throw new Error(`npm pack is missing required runtime files: ${missing.join(', ')}`);
440
+
441
+ return { files };
442
+ }
443
+
444
+ function parseCli(argv) {
445
+ const command = argv[0];
446
+ const options = { root: process.cwd(), dryRun: false, json: false, cleanOnly: false };
447
+
448
+ for (let index = 1; index < argv.length; index += 1) {
449
+ const arg = argv[index];
450
+ if (arg === '--dry-run') options.dryRun = true;
451
+ else if (arg === '--json') options.json = true;
452
+ else if (arg === '--clean-only') options.cleanOnly = true;
453
+ else if (arg === '--root') options.root = argv[++index];
454
+ else throw new TypeError(`Unknown option: ${arg}`);
455
+ }
456
+
457
+ return { command, options };
458
+ }
459
+
460
+ export async function runCli(argv) {
461
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
462
+ process.stdout.write('Usage: expo-harmony-module <inspect|prepare|prepack|build-workspace> [--root <path>] [--json] [--dry-run]\n');
463
+ return;
464
+ }
465
+
466
+ const { command, options } = parseCli(argv);
467
+
468
+ if (options.cleanOnly && command !== 'prepare') {
469
+ throw new TypeError('--clean-only is only valid with prepare.');
470
+ }
471
+ if (options.dryRun && command === 'inspect') {
472
+ throw new TypeError('--dry-run is not valid with inspect.');
473
+ }
474
+
475
+ let result;
476
+ if (command === 'inspect') result = await inspectModule(options.root);
477
+ else if (command === 'prepare') result = await prepareModule(options.root, options);
478
+ else if (command === 'prepack') result = await prepackModule(options.root, options);
479
+ else if (command === 'build-workspace') result = await buildWorkspace(options.root, options);
480
+ else throw new TypeError(`Unknown command: ${command}`);
481
+
482
+ if (options.json || options.dryRun || command === 'inspect') {
483
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
484
+ }
485
+ }
@@ -0,0 +1,172 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+
5
+ import glob from 'fast-glob';
6
+ import JSON5 from 'json5';
7
+ import semver from 'semver';
8
+
9
+ const sections = ['dependencies', 'devDependencies', 'dynamicDependencies'];
10
+
11
+ async function readOptional(file) {
12
+ try {
13
+ return await fs.readFile(file);
14
+ } catch (cause) {
15
+ if (cause.code === 'ENOENT') return undefined;
16
+
17
+ throw new Error(cause instanceof Error ? cause.message : String(cause), { cause });
18
+ }
19
+ }
20
+
21
+ export async function findWorkspacePackages(root) {
22
+ root = await fs.realpath(root);
23
+
24
+ for (let directory = root; ; directory = path.dirname(directory)) {
25
+ const source = await readOptional(path.join(directory, 'package.json'));
26
+ const manifest = source && JSON.parse(source);
27
+ const patterns = Array.isArray(manifest?.workspaces) ? manifest.workspaces : manifest?.workspaces?.packages;
28
+
29
+ if (Array.isArray(patterns)) {
30
+ const files = await glob(patterns.map(pattern => `${pattern.replace(/\/$/u, '')}/package.json`), {
31
+ cwd: directory,
32
+ absolute: true,
33
+ onlyFiles: true,
34
+ followSymbolicLinks: false,
35
+ ignore: ['**/node_modules/**'],
36
+ });
37
+ const roots = await Promise.all(files.map(file => fs.realpath(path.dirname(file))));
38
+
39
+ const packages = [...new Set(roots)].sort();
40
+ if (directory === root || packages.includes(root)) return { root: directory, packages };
41
+ }
42
+
43
+ if (path.dirname(directory) === directory) return undefined;
44
+ }
45
+ }
46
+
47
+ // Native manifests, rather than the JS dependency graph, determine HAR build order.
48
+ export function planWorkspaceBuild(projects, targets) {
49
+ const registry = new Map();
50
+ for (const project of projects) {
51
+ const { name, version } = project.ohPackage;
52
+
53
+ if (name !== project.packageJson.name || version !== project.packageJson.version || !semver.valid(version)) {
54
+ throw new Error(`${project.packageRoot}: npm and OHPM package names and versions must match.`);
55
+ }
56
+ if (registry.has(name)) throw new Error(`Duplicate workspace OHPM package: ${name}.`);
57
+
58
+ registry.set(name, project);
59
+ }
60
+
61
+ const ordered = new Map();
62
+ const visiting = [];
63
+
64
+ function visit(project) {
65
+ const name = project.ohPackage.name;
66
+ if (ordered.has(name)) return ordered.get(name);
67
+ if (visiting.includes(name)) throw new Error(`Circular Harmony workspace dependency: ${[...visiting, name].join(' -> ')}.`);
68
+
69
+ visiting.push(name);
70
+ const dependencies = new Map();
71
+ const declarations = [
72
+ ...sections.flatMap(section => Object.entries(project.ohPackage[section] || {})),
73
+ ...sections.flatMap(section => Object.entries(project.ohProjectPackage[section] || {})
74
+ // The project normally includes its own ./library module.
75
+ .filter(([dependency]) => dependency !== name)),
76
+ ...Object.entries(project.ohProjectPackage.overrides || {}).filter(([dependency]) => dependency !== name),
77
+ ];
78
+
79
+ for (const [target, range] of declarations) {
80
+ const dependency = registry.get(target);
81
+ if (!dependency) continue;
82
+
83
+ if (typeof range !== 'string' || !semver.validRange(range) || !semver.satisfies(dependency.ohPackage.version, range)) {
84
+ throw new Error(`${name} requires ${target}@${range}, but the workspace contains ${dependency.ohPackage.version}. Update the native dependency before building.`);
85
+ }
86
+
87
+ const entry = visit(dependency);
88
+ for (const transitive of entry.dependencies) dependencies.set(transitive.ohPackage.name, transitive);
89
+ dependencies.set(target, dependency);
90
+ }
91
+
92
+ visiting.pop();
93
+ const entry = { project, dependencies: [...dependencies.values()] };
94
+ ordered.set(name, entry);
95
+
96
+ return entry;
97
+ }
98
+
99
+ for (const target of targets) visit(target);
100
+
101
+ return [...ordered.values()];
102
+ }
103
+
104
+ export function workspaceOverrides(project, dependencies) {
105
+ return Object.fromEntries(dependencies.map(dependency => [
106
+ dependency.ohPackage.name,
107
+ `file:${path.relative(project.projectRoot, dependency.bundledHar).replace(/\\/gu, '/')}`,
108
+ ]));
109
+ }
110
+
111
+ export async function withWorkspaceOverrides(project, overrides, build) {
112
+ // Fail instead of letting simultaneous builds overwrite each other's manifests.
113
+ const lock = path.join(project.projectRoot, '.expo-harmony-build.lock');
114
+ try {
115
+ await fs.mkdir(lock);
116
+ } catch (cause) {
117
+ if (cause.code === 'EEXIST') throw new Error(`Harmony build already locked: ${lock}. If a previous process was killed, remove this directory after confirming it has stopped.`);
118
+
119
+ throw new Error(cause instanceof Error ? cause.message : String(cause), { cause });
120
+ }
121
+
122
+ const snapshots = new Map();
123
+ const controller = new globalThis.AbortController();
124
+ const { signal } = controller;
125
+ const interrupt = () => controller.abort();
126
+
127
+ process.once('SIGINT', interrupt);
128
+ process.once('SIGTERM', interrupt);
129
+
130
+ try {
131
+ if (Object.keys(overrides).length > 0) {
132
+ const file = path.join(project.projectRoot, 'oh-package.json5');
133
+ const files = [
134
+ file,
135
+ path.join(project.projectRoot, 'oh-package-lock.json5'),
136
+ path.join(project.moduleRoot, 'oh-package-lock.json5'),
137
+ ];
138
+
139
+ for (const target of files) {
140
+ snapshots.set(target, await readOptional(target));
141
+ }
142
+
143
+ const manifest = JSON5.parse(snapshots.get(file).toString());
144
+ manifest.overrides = { ...manifest.overrides, ...overrides };
145
+
146
+ await fs.writeFile(file, `${JSON5.stringify(manifest, null, 2)}\n`);
147
+
148
+ // Local HARs may change without their version or path changing. Force OHPM
149
+ // to re-resolve them instead of retaining an older lockfile resolution.
150
+ for (const target of files.slice(1)) await fs.rm(target, { force: true });
151
+ }
152
+
153
+ if (signal.aborted) throw new globalThis.DOMException('This operation was aborted', 'AbortError');
154
+
155
+ const result = await build(signal);
156
+ if (signal.aborted) throw new globalThis.DOMException('This operation was aborted', 'AbortError');
157
+
158
+ return result;
159
+ } finally {
160
+ try {
161
+ for (const [file, source] of snapshots) {
162
+ if (source === undefined) await fs.rm(file, { force: true });
163
+ else await fs.writeFile(file, source);
164
+ }
165
+ } finally {
166
+ process.removeListener('SIGINT', interrupt);
167
+ process.removeListener('SIGTERM', interrupt);
168
+
169
+ await fs.rmdir(lock);
170
+ }
171
+ }
172
+ }