Skip to content

Commit 7e89a29

Browse files
sunnylqmclaude
andcommitted
refactor(target): resolve one app id per operation; keep update.json for both brands
Follow-up to the review of #74. - keep `update.json` as the selected-app file for cresc too: the cresc docs and the client SDK read that name, and `cresc.config.json` was never wired in, so switching the default would have broken every existing cresc project - replace the nine hand-rolled `options.appId || getSelectedApp(...)` blocks in bundle/versions/package with one `resolveAppId()` helper - bundle: resolve the app before any side effect (.gitignore edits, plugin probes) so a named bundle without a selected app fails immediately; a bundle-only run only tolerates a missing selection (typed AppNotSelectedError) and reports malformed configs instead of swallowing them; drop the dead `config` forwarding and the three-way cached target - SDK: `BundleOptions.appId/config` and `provider.getSelectedApp(platform, config)` so programmatic callers get the same single-app guarantee - messages: parse/mismatch errors name the file (or `--appId`) actually used - tests: exercise bundleCommands.bundle end to end (Hermes base + publish get the same app, fail-fast, bundle-only fallback, dev bundles) and the default file, instead of the removed wrapper helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCaS35vZG4DCtmM24MYaVR
1 parent eeba089 commit 7e89a29

11 files changed

Lines changed: 401 additions & 335 deletions

File tree

src/app.ts

Lines changed: 34 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ interface AppSummary {
1414
export interface AppTargetOptions {
1515
appId?: string;
1616
config?: string;
17+
platform?: Platform | '';
1718
}
1819

19-
export interface ResolvedAppTarget {
20-
appId: string;
21-
appKey?: string;
22-
platform: Platform;
23-
configPath: string;
20+
/** The selected-app config file was missing or has no entry for the platform. */
21+
export class AppNotSelectedError extends Error {
22+
readonly code = 'APP_NOT_SELECTED';
23+
constructor(platform: Platform) {
24+
super(t('appNotSelected', { platform }));
25+
this.name = 'AppNotSelectedError';
26+
}
2427
}
2528

2629
/** Resolve an explicit platform or prompt for one interactively. */
@@ -46,21 +49,26 @@ export async function getSelectedApp(
4649
assertPlatform(platform);
4750

4851
const resolvedConfigPath = configPath || updateJson;
49-
let updateInfo: Partial<Record<Platform, { appId: number; appKey: string }>> =
50-
{};
52+
let raw: string;
5153
try {
52-
updateInfo = JSON.parse(
53-
await fs.promises.readFile(resolvedConfigPath, 'utf8'),
54-
);
54+
raw = await fs.promises.readFile(resolvedConfigPath, 'utf8');
5555
} catch (e: any) {
5656
if (e.code === 'ENOENT') {
57-
throw new Error(t('appNotSelected', { platform }));
57+
throw new AppNotSelectedError(platform);
5858
}
5959
throw e;
6060
}
61+
let updateInfo: Partial<Record<Platform, { appId: number; appKey: string }>>;
62+
try {
63+
updateInfo = JSON.parse(raw);
64+
} catch {
65+
throw new Error(
66+
t('failedToParseUpdateJson', { configPath: resolvedConfigPath }),
67+
);
68+
}
6169
const info = updateInfo[platform];
6270
if (!info) {
63-
throw new Error(t('appNotSelected', { platform }));
71+
throw new AppNotSelectedError(platform);
6472
}
6573
return {
6674
appId: String(info.appId),
@@ -69,42 +77,22 @@ export async function getSelectedApp(
6977
};
7078
}
7179

72-
/** Resolve an explicit or selected app into a stable operation target. */
73-
export async function resolveAppTarget(
74-
platform: Platform,
80+
/**
81+
* Resolve the app an operation targets: an explicit `--appId` wins, otherwise
82+
* the app selected for the platform in `--config` (default: update.json).
83+
* Prompts for the platform only when it is needed and not given.
84+
*/
85+
export async function resolveAppId(
7586
options: AppTargetOptions = {},
76-
): Promise<ResolvedAppTarget> {
77-
const configPath = options.config || updateJson;
87+
): Promise<string> {
88+
if (options.platform) {
89+
assertPlatform(options.platform);
90+
}
7891
if (options.appId) {
79-
return {
80-
appId: String(options.appId),
81-
platform,
82-
configPath,
83-
};
92+
return String(options.appId);
8493
}
85-
86-
return {
87-
...(await getSelectedApp(platform, configPath)),
88-
configPath,
89-
};
90-
}
91-
92-
/** Cache app selection for one operation and retry after a failed lookup. */
93-
export function createAppTargetResolver(
94-
platform: Platform,
95-
options: AppTargetOptions = {},
96-
): () => Promise<ResolvedAppTarget> {
97-
let pending: Promise<ResolvedAppTarget> | undefined;
98-
99-
return () => {
100-
if (!pending) {
101-
pending = resolveAppTarget(platform, options).catch((error) => {
102-
pending = undefined;
103-
throw error;
104-
});
105-
}
106-
return pending;
107-
};
94+
const platform = await getPlatform(options.platform || undefined);
95+
return (await getSelectedApp(platform, options.config)).appId;
10896
}
10997

11098
/** List apps, optionally filtering them to one platform. */
@@ -166,7 +154,7 @@ async function selectApp({
166154
updateInfo = JSON.parse(await fs.promises.readFile(configPath, 'utf8'));
167155
} catch (e: any) {
168156
if (e.code !== 'ENOENT') {
169-
console.error(t('failedToParseUpdateJson'));
157+
console.error(t('failedToParseUpdateJson', { configPath }));
170158
throw e;
171159
}
172160
}

src/bundle.ts

Lines changed: 50 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import path from 'path';
2-
import {
3-
createAppTargetResolver,
4-
getPlatform,
5-
type ResolvedAppTarget,
6-
} from './app';
2+
import { AppNotSelectedError, getPlatform, resolveAppId } from './app';
73
import { packBundle } from './bundle-pack';
84
import {
95
copyDebugidForSentry,
@@ -67,9 +63,8 @@ function parseCacheMaxMb(value: unknown): number | undefined {
6763
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
6864
}
6965

70-
export type PublishBundlePayload = {
71-
appId?: string;
72-
config?: string;
66+
type PublishBundlePayload = {
67+
appId: string;
7368
name?: string;
7469
description?: string;
7570
metaInfo?: string;
@@ -192,33 +187,19 @@ async function uploadSentryArtifactsIfNeeded(
192187
);
193188
}
194189

195-
/** Build the version publish request while preserving its resolved app target. */
196-
export function createPublishBundleRequest(
190+
/** Publish a packed bundle through the version command implementation. */
191+
async function publishBundleVersion(
197192
outputPath: string,
198193
platform: Platform,
199194
payload: PublishBundlePayload,
200-
): {
201-
args: string[];
202-
options: PublishBundlePayload & { platform: Platform };
203-
} {
204-
return {
195+
): Promise<string> {
196+
return versionCommands.publish({
205197
args: [outputPath],
206198
options: {
207199
platform,
208200
...payload,
209201
},
210-
};
211-
}
212-
213-
/** Publish a packed bundle through the version command implementation. */
214-
async function publishBundleVersion(
215-
outputPath: string,
216-
platform: Platform,
217-
payload: PublishBundlePayload,
218-
): Promise<string> {
219-
return versionCommands.publish(
220-
createPublishBundleRequest(outputPath, platform, payload),
221-
);
202+
});
222203
}
223204

224205
export const bundleCommands = {
@@ -240,10 +221,48 @@ export const bundleCommands = {
240221
});
241222
const normalized = normalizeBundleOptions(translatedOptions, platform);
242223

224+
// One app per operation: the Hermes base lookup and the publish step must
225+
// never see different apps, so the target is resolved once and reused.
226+
let appId: string | undefined;
227+
const getAppId = async () =>
228+
(appId ??= await resolveAppId({
229+
appId: normalized.appId,
230+
config: normalized.config,
231+
platform,
232+
}));
233+
const hermesBase =
234+
normalized.dev === 'true'
235+
? undefined
236+
: {
237+
option: normalized.hermesBase,
238+
verify: normalized.verifyHermesBase,
239+
cacheMaxMb: normalized.cacheMaxMb,
240+
};
241+
242+
// Resolve before any side effect or expensive work. A named bundle is
243+
// published, so a missing app fails right here; a bundle-only run only
244+
// needs the app for the remote Hermes base lookup and may go on without
245+
// one (it then compiles a full bundle). Any other config error (e.g.
246+
// malformed JSON) is reported immediately either way.
247+
if (normalized.name) {
248+
await getAppId();
249+
} else if (hermesBase?.option === 'auto') {
250+
try {
251+
await getAppId();
252+
} catch (error) {
253+
if (!(error instanceof AppNotSelectedError)) {
254+
throw error;
255+
}
256+
}
257+
}
258+
243259
checkLockFiles();
244260
addGitIgnore();
245261

246-
const bundleParams = await checkPlugins();
262+
const [bundleParams] = await Promise.all([
263+
checkPlugins(),
264+
cleanStaleTmp().catch(() => {}),
265+
]);
247266
const sourcemapOutput = path.join(
248267
normalized.intermediaDir,
249268
`${normalized.bundleName}.map`,
@@ -257,30 +276,8 @@ export const bundleCommands = {
257276
throw new Error(t('platformRequired'));
258277
}
259278

260-
const resolveTarget = createAppTargetResolver(platform, {
261-
appId: normalized.appId,
262-
config: normalized.config,
263-
});
264-
let resolvedTarget: ResolvedAppTarget | undefined;
265-
const shouldResolveTargetBeforeBundle =
266-
Boolean(normalized.name) ||
267-
(normalized.dev !== 'true' && normalized.hermesBase === 'auto');
268-
269-
if (shouldResolveTargetBeforeBundle) {
270-
try {
271-
resolvedTarget = await resolveTarget();
272-
} catch (error) {
273-
// A bundle-only command may still fall back when no remote Hermes base
274-
// is available. Named publishing must fail before doing expensive work.
275-
if (normalized.name) {
276-
throw error;
277-
}
278-
}
279-
}
280-
281279
console.log(t('bundlingWithRN', { version: depVersions['react-native'] }));
282280

283-
await cleanStaleTmp().catch(() => {});
284281
const hermesResult = await runReactNativeBundleCommand({
285282
bundleName: normalized.bundleName,
286283
dev: normalized.dev,
@@ -290,15 +287,7 @@ export const bundleCommands = {
290287
sourcemapOutput:
291288
normalized.sourcemap || bundleParams.sourcemap ? sourcemapOutput : '',
292289
forceHermes: normalized.hermes,
293-
hermesBase:
294-
normalized.dev === 'true'
295-
? undefined
296-
: {
297-
option: normalized.hermesBase,
298-
appId: resolvedTarget?.appId,
299-
verify: normalized.verifyHermesBase,
300-
cacheMaxMb: normalized.cacheMaxMb,
301-
},
290+
hermesBase: hermesBase ? { ...hermesBase, appId } : undefined,
302291
resetCache: normalized.resetCache,
303292
cli: {
304293
taro: normalized.taro,
@@ -318,10 +307,8 @@ export const bundleCommands = {
318307
: undefined;
319308

320309
if (normalized.name) {
321-
const target = resolvedTarget ?? (await resolveTarget());
322310
await publishBundleVersion(realOutput, platform, {
323-
appId: target.appId,
324-
config: target.configPath,
311+
appId: await getAppId(),
325312
name: normalized.name,
326313
description: normalized.description,
327314
metaInfo: normalized.metaInfo,
@@ -351,10 +338,8 @@ export const bundleCommands = {
351338
if (!getBooleanOption(options, 'no-interactive', false)) {
352339
const v = await question(t('uploadBundlePrompt'));
353340
if (v.toLowerCase() === 'y') {
354-
const target = resolvedTarget ?? (await resolveTarget());
355341
await publishBundleVersion(realOutput, platform, {
356-
appId: target.appId,
357-
config: target.configPath,
342+
appId: await getAppId(),
358343
hermesBase: baseMeta,
359344
});
360345
await uploadSentryArtifactsIfNeeded(

src/locales/en.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,17 @@ export default {
1616
aabParseResourcesError: 'Parser resources.arsc error: {{error}}',
1717
appId: 'App ID',
1818
appIdMismatchApk:
19-
'App ID mismatch! Current APK: {{appIdInPkg}}, current update.json: {{appId}}',
19+
'App ID mismatch! Current APK: {{appIdInPkg}}, current {{- source}}: {{appId}}',
2020
appIdMismatchApp:
21-
'App ID mismatch! Current APP: {{appIdInPkg}}, current update.json: {{appId}}',
21+
'App ID mismatch! Current APP: {{appIdInPkg}}, current {{- source}}: {{appId}}',
2222
appIdMismatchIpa:
23-
'App ID mismatch! Current IPA: {{appIdInPkg}}, current update.json: {{appId}}',
23+
'App ID mismatch! Current IPA: {{appIdInPkg}}, current {{- source}}: {{appId}}',
2424
appKeyMismatchApk:
25-
'App Key mismatch! Current APK: {{appKeyInPkg}}, current update.json: {{appKey}}',
25+
'App Key mismatch! Current APK: {{appKeyInPkg}}, current {{- source}}: {{appKey}}',
2626
appKeyMismatchApp:
27-
'App Key mismatch! Current APP: {{appKeyInPkg}}, current update.json: {{appKey}}',
27+
'App Key mismatch! Current APP: {{appKeyInPkg}}, current {{- source}}: {{appKey}}',
2828
appKeyMismatchIpa:
29-
'App Key mismatch! Current IPA: {{appKeyInPkg}}, current update.json: {{appKey}}',
29+
'App Key mismatch! Current IPA: {{appKeyInPkg}}, current {{- source}}: {{appKey}}',
3030
appName: 'App Name',
3131
appNameQuestion: 'App Name:',
3232
appNotSelected:
@@ -57,7 +57,7 @@ export default {
5757
expiredStatus: '(Expired)',
5858
failedToParseIcon: '[Warning] failed to parse icon: {{error}}',
5959
failedToParseUpdateJson:
60-
'Failed to parse file `update.json`. Try to remove it manually.',
60+
'Failed to parse file `{{- configPath}}`. Try to remove it manually.',
6161
fileGenerated: '{{- file}} generated.',
6262
fileSizeExceeded:
6363
'This file size is {{fileSize}} , exceeding the current quota {{maxSize}} . You may consider upgrading to a higher plan to increase this quota. Details can be found at: {{- pricingPageUrl}}',

src/locales/zh.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@ export default {
1414
aabParseResourcesError: '解析 resources.arsc 出错:{{error}}',
1515
appId: '应用 id',
1616
appIdMismatchApk:
17-
'appId不匹配!当前apk: {{appIdInPkg}}, 当前update.json: {{appId}}',
17+
'appId不匹配!当前apk: {{appIdInPkg}}, 当前{{- source}}: {{appId}}',
1818
appIdMismatchApp:
19-
'appId不匹配!当前app: {{appIdInPkg}}, 当前update.json: {{appId}}',
19+
'appId不匹配!当前app: {{appIdInPkg}}, 当前{{- source}}: {{appId}}',
2020
appIdMismatchIpa:
21-
'appId不匹配!当前ipa: {{appIdInPkg}}, 当前update.json: {{appId}}',
21+
'appId不匹配!当前ipa: {{appIdInPkg}}, 当前{{- source}}: {{appId}}',
2222
appKeyMismatchApk:
23-
'appKey不匹配!当前apk: {{appKeyInPkg}}, 当前update.json: {{appKey}}',
23+
'appKey不匹配!当前apk: {{appKeyInPkg}}, 当前{{- source}}: {{appKey}}',
2424
appKeyMismatchApp:
25-
'appKey不匹配!当前app: {{appKeyInPkg}}, 当前update.json: {{appKey}}',
25+
'appKey不匹配!当前app: {{appKeyInPkg}}, 当前{{- source}}: {{appKey}}',
2626
appKeyMismatchIpa:
27-
'appKey不匹配!当前ipa: {{appKeyInPkg}}, 当前update.json: {{appKey}}',
27+
'appKey不匹配!当前ipa: {{appKeyInPkg}}, 当前{{- source}}: {{appKey}}',
2828
appName: '应用名称',
2929
appNameQuestion: '应用名称:',
3030
appNotSelected:
@@ -53,7 +53,7 @@ export default {
5353
errorInHarmonyApp: '获取 Harmony 应用入口时出错:{{error}}',
5454
expiredStatus: '(已过期)',
5555
failedToParseIcon: '[警告] 解析图标失败:{{error}}',
56-
failedToParseUpdateJson: '无法解析文件 `update.json`。请手动删除它。',
56+
failedToParseUpdateJson: '无法解析文件 `{{- configPath}}`。请手动删除它。',
5757
fileGenerated: '已生成 {{- file}}',
5858
fileSizeExceeded:
5959
'此文件大小 {{fileSize}} , 超出当前额度 {{maxSize}} 。您可以考虑升级付费业务以提升此额度。详情请访问: {{- pricingPageUrl}}',

0 commit comments

Comments
 (0)