Skip to content

Commit ee7cc34

Browse files
sunnylqmclaude
andauthored
fix(target): preserve app context across bundle and publish (#74)
* fix(app): honor selected config path * fix(bundle): preserve app target for publish * fix(publish): bind using the resolved app id * test(publish): cover app target propagation * test(target): cover config and bundle context * style: apply biome formatting * style: format bundle target lookup * style: format publish regression test * refactor(target): add operation-scoped app resolver * fix(target): reuse one resolved app across bundle and publish * test(target): cover operation-scoped app resolution * docs(target): document app resolution lifecycle * docs(target): document bundle target flow * 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 * feat(target): verify an explicit --appId matches the command's platform The server accepts a bundle or native package for any app the account owns and never learns which platform it was built for, so `bundle --platform ios --appId <android app> --name v3` used to publish an iOS bundle into the Android app and bind it to Android packages. resolveAppId now looks the app up (GET /app/:id) whenever an explicit appId meets a known platform and fails before any expensive work when they disagree; a foreign or missing id fails there too instead of after the build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCaS35vZG4DCtmM24MYaVR --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent df02eaf commit ee7cc34

12 files changed

Lines changed: 639 additions & 136 deletions

File tree

src/app.ts

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'fs';
22
import { doDelete, get, post } from './api';
33
import type { Platform } from './types';
44
import { loadTtyTable, question } from './utils';
5+
import { updateJson } from './utils/constants';
56
import { t } from './utils/i18n';
67

78
interface AppSummary {
@@ -10,40 +11,64 @@ interface AppSummary {
1011
platform: Platform;
1112
}
1213

14+
export interface AppTargetOptions {
15+
appId?: string;
16+
config?: string;
17+
platform?: Platform | '';
18+
}
19+
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+
}
27+
}
28+
29+
/** Resolve an explicit platform or prompt for one interactively. */
1330
export async function getPlatform(platform?: string) {
1431
return assertPlatform(
1532
platform || (await question(t('platformQuestion'))),
1633
) as Platform;
1734
}
1835

36+
/** Validate that a string names a platform supported by the update service. */
1937
export function assertPlatform(platform: string): Platform {
2038
if (platform !== 'ios' && platform !== 'android' && platform !== 'harmony') {
2139
throw new Error(t('unsupportedPlatform', { platform }));
2240
}
2341
return platform as Platform;
2442
}
2543

44+
/** Read the selected app for a platform from the requested config file. */
2645
export async function getSelectedApp(
2746
platform: Platform,
2847
configPath?: string,
2948
): Promise<{ appId: string; appKey: string; platform: Platform }> {
3049
assertPlatform(platform);
3150

32-
let updateInfo: Partial<Record<Platform, { appId: number; appKey: string }>> =
33-
{};
51+
const resolvedConfigPath = configPath || updateJson;
52+
let raw: string;
3453
try {
35-
updateInfo = JSON.parse(
36-
await fs.promises.readFile(configPath || 'update.json', 'utf8'),
37-
);
54+
raw = await fs.promises.readFile(resolvedConfigPath, 'utf8');
3855
} catch (e: any) {
3956
if (e.code === 'ENOENT') {
40-
throw new Error(t('appNotSelected', { platform }));
57+
throw new AppNotSelectedError(platform);
4158
}
4259
throw e;
4360
}
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+
}
4469
const info = updateInfo[platform];
4570
if (!info) {
46-
throw new Error(t('appNotSelected', { platform }));
71+
throw new AppNotSelectedError(platform);
4772
}
4873
return {
4974
appId: String(info.appId),
@@ -52,6 +77,45 @@ export async function getSelectedApp(
5277
};
5378
}
5479

80+
/**
81+
* Fail fast when an explicit `--appId` names an app of another platform.
82+
* The server accepts a bundle for any app the account owns and never sees
83+
* the platform it was built for, so this is the only place the mistake can
84+
* be caught before it reaches devices. A missing or foreign app fails here
85+
* too (403/404) instead of after the expensive work.
86+
*/
87+
async function assertAppPlatform(appId: string, platform: Platform) {
88+
const app = (await get(`/app/${appId}`)) as { platform?: Platform };
89+
if (app.platform && app.platform !== platform) {
90+
throw new Error(
91+
t('appPlatformMismatch', { appId, appPlatform: app.platform, platform }),
92+
);
93+
}
94+
}
95+
96+
/**
97+
* Resolve the app an operation targets: an explicit `--appId` wins, otherwise
98+
* the app selected for the platform in `--config` (default: update.json).
99+
* Prompts for the platform only when it is needed and not given.
100+
*/
101+
export async function resolveAppId(
102+
options: AppTargetOptions = {},
103+
): Promise<string> {
104+
if (options.platform) {
105+
assertPlatform(options.platform);
106+
}
107+
if (options.appId) {
108+
const appId = String(options.appId);
109+
if (options.platform) {
110+
await assertAppPlatform(appId, options.platform);
111+
}
112+
return appId;
113+
}
114+
const platform = await getPlatform(options.platform || undefined);
115+
return (await getSelectedApp(platform, options.config)).appId;
116+
}
117+
118+
/** List apps, optionally filtering them to one platform. */
55119
export async function listApp(platform: Platform | '' = '') {
56120
const { data } = await get('/app/list');
57121
const allApps = data as AppSummary[];
@@ -77,6 +141,7 @@ export async function listApp(platform: Platform | '' = '') {
77141
return list;
78142
}
79143

144+
/** Prompt until the user chooses an app belonging to the target platform. */
80145
export async function chooseApp(platform: Platform) {
81146
const list = await listApp(platform);
82147

@@ -89,28 +154,27 @@ export async function chooseApp(platform: Platform) {
89154
}
90155
}
91156

157+
/** Persist an app selection in the requested brand-aware config file. */
92158
async function selectApp({
93159
args,
94160
options,
95161
}: {
96162
args: string[];
97-
options: { platform?: Platform | '' };
163+
options: { platform?: Platform | ''; config?: string };
98164
}) {
99165
const platform = await getPlatform(options.platform);
100166
const id = args[0]
101167
? Number.parseInt(args[0], 10)
102168
: (await chooseApp(platform)).id;
103169

104-
const configPath = (options as any).config as string | undefined;
170+
const configPath = options.config || updateJson;
105171
let updateInfo: Partial<Record<Platform, { appId: number; appKey: string }>> =
106172
{};
107173
try {
108-
updateInfo = JSON.parse(
109-
await fs.promises.readFile(configPath || 'update.json', 'utf8'),
110-
);
174+
updateInfo = JSON.parse(await fs.promises.readFile(configPath, 'utf8'));
111175
} catch (e: any) {
112176
if (e.code !== 'ENOENT') {
113-
console.error(t('failedToParseUpdateJson'));
177+
console.error(t('failedToParseUpdateJson', { configPath }));
114178
throw e;
115179
}
116180
}
@@ -120,18 +184,25 @@ async function selectApp({
120184
appKey,
121185
};
122186
await fs.promises.writeFile(
123-
configPath || 'update.json',
187+
configPath,
124188
JSON.stringify(updateInfo, null, 4),
125189
'utf8',
126190
);
127191
}
128192

193+
/** Build the application-management command handlers used by the CLI. */
129194
export function getAppCommands() {
130195
return {
196+
/** Create an app and select it in the same configuration file. */
131197
createApp: async ({
132198
options,
133199
}: {
134-
options: { name: string; downloadUrl: string; platform?: Platform | '' };
200+
options: {
201+
name: string;
202+
downloadUrl: string;
203+
platform?: Platform | '';
204+
config?: string;
205+
};
135206
}) => {
136207
const name = options.name || (await question(t('appNameQuestion')));
137208
const { downloadUrl } = options;
@@ -140,9 +211,10 @@ export function getAppCommands() {
140211
console.log(t('createAppSuccess', { id }));
141212
await selectApp({
142213
args: [String(id)],
143-
options: { platform },
214+
options: { platform, config: options.config },
144215
});
145216
},
217+
/** Delete the specified app, or prompt for one when no ID is supplied. */
146218
deleteApp: async ({
147219
args,
148220
options,
@@ -159,6 +231,7 @@ export function getAppCommands() {
159231
await doDelete(`/app/${id}`);
160232
console.log(t('operationSuccess'));
161233
},
234+
/** List apps through the command interface. */
162235
apps: async ({ options }: { options: { platform?: Platform | '' } }) => {
163236
const { platform = '' } = options;
164237
return listApp(platform);

0 commit comments

Comments
 (0)