@terreno/agent-plugin 0.0.0-stage → 58.0.0-beta.2
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/.claude-plugin/plugin.json +32 -0
- package/CHANGELOG.md +2213 -0
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/agents/pre-commit.md +65 -0
- package/agents/ui-verifier.md +93 -0
- package/package.json +32 -4
- package/references/async-review-bots.md +76 -0
- package/references/documentation-contract.md +61 -0
- package/references/execution-state.schema.json +77 -0
- package/references/github-attention-contract.md +165 -0
- package/references/independent-review.md +31 -0
- package/references/lifecycle-contract.md +211 -0
- package/references/loop-engineering.md +125 -0
- package/references/pick-roast-loop.md +112 -0
- package/references/pr-deployments.md +69 -0
- package/references/product-ci.md +164 -0
- package/references/push-ownership.md +91 -0
- package/references/stage-result.schema.json +82 -0
- package/references/subagent-briefing.md +43 -0
- package/references/testing-plan.md +163 -0
- package/references/user-guide.md +171 -0
- package/skills/1-grow/SKILL.md +145 -0
- package/skills/1-grow/references/grilling.md +243 -0
- package/skills/2-pick/SKILL.md +170 -0
- package/skills/2-pick/references/mocking.md +34 -0
- package/skills/2-pick/references/testing.md +43 -0
- package/skills/3-roast/SKILL.md +168 -0
- package/skills/4-brew/SKILL.md +201 -0
- package/skills/5-taste/SKILL.md +311 -0
- package/skills/ai-prompt-governance/SKILL.md +100 -0
- package/skills/attach-pr-media/SKILL.md +89 -0
- package/skills/attach-pr-media/scripts/upload-pr-media.sh +151 -0
- package/skills/backend-test-env/SKILL.md +64 -0
- package/skills/backfill-regression/SKILL.md +101 -0
- package/skills/backfill-user-guide/SKILL.md +96 -0
- package/skills/bootstrap-mcp-server/SKILL.md +244 -0
- package/skills/building-admin-interfaces/SKILL.md +86 -0
- package/skills/building-admin-interfaces/agents/openai.yaml +4 -0
- package/skills/building-admin-interfaces/references/nav-and-shell.md +42 -0
- package/skills/building-admin-interfaces/references/patterns-and-tools.md +74 -0
- package/skills/building-admin-interfaces/references/screens-and-routes.md +50 -0
- package/skills/building-terreno-apps/SKILL.md +294 -0
- package/skills/building-terreno-apps/agents/openai.yaml +4 -0
- package/skills/building-terreno-apps/references/navigation.md +108 -0
- package/skills/building-terreno-apps/references/route-structure.md +104 -0
- package/skills/building-terreno-apps/references/screen-patterns.md +174 -0
- package/skills/debug-syncdb-with-mcp/SKILL.md +75 -0
- package/skills/deploy-gcp/SKILL.md +73 -0
- package/skills/deploy-gcp/references/troubleshooting.md +29 -0
- package/skills/generate-release-announcements/SKILL.md +145 -0
- package/skills/generate-sdk/SKILL.md +165 -0
- package/skills/mongoose-schema-safety/SKILL.md +164 -0
- package/skills/pick-roast-loop/SKILL.md +152 -0
- package/skills/planning-loop/SKILL.md +161 -0
- package/skills/prd/SKILL.md +138 -0
- package/skills/set-up-social-login/SKILL.md +76 -0
- package/skills/store-assets/SKILL.md +221 -0
- package/skills/store-assets/examples/screenshots.yaml +31 -0
- package/skills/store-assets/examples/storeAssets.config.json +44 -0
- package/skills/store-assets/scripts/banner.ts +165 -0
- package/skills/store-assets/scripts/config.ts +255 -0
- package/skills/store-assets/scripts/devClient.ts +635 -0
- package/skills/store-assets/scripts/devices.ts +286 -0
- package/skills/store-assets/scripts/icon.ts +81 -0
- package/skills/store-assets/scripts/maestro/goHome.yaml +21 -0
- package/skills/store-assets/scripts/maestro/login.yaml +27 -0
- package/skills/store-assets/scripts/maestro/openApp.yaml +26 -0
- package/skills/store-assets/scripts/screenshots.ts +848 -0
- package/skills/taste-sweep/SKILL.md +172 -0
- package/skills/terreno-backend-api/SKILL.md +355 -0
- package/skills/terreno-backend-api/agents/openai.yaml +4 -0
- package/skills/terreno-backend-api/references/custom-routes.md +132 -0
- package/skills/terreno-backend-api/references/model-router.md +112 -0
- package/skills/terreno-backend-api/references/server-setup.md +116 -0
- package/skills/terreno-backend-tests/SKILL.md +176 -0
- package/skills/terreno-data-fetching/SKILL.md +288 -0
- package/skills/terreno-data-fetching/agents/openai.yaml +4 -0
- package/skills/terreno-data-fetching/references/auth-and-tokens.md +89 -0
- package/skills/terreno-data-fetching/references/realtime.md +50 -0
- package/skills/terreno-data-fetching/references/sdk-customization.md +55 -0
- package/skills/terreno-frontend-tests/SKILL.md +190 -0
- package/skills/terreno-mobile-tests/SKILL.md +127 -0
- package/skills/terreno-test-setup/SKILL.md +81 -0
- package/skills/terreno-test-setup/references/backend.md +131 -0
- package/skills/terreno-test-setup/references/frontend.md +169 -0
- package/skills/terreno-test-setup/references/mobile.md +134 -0
- package/skills/terreno-ui/SKILL.md +252 -0
- package/skills/terreno-ui/agents/openai.yaml +4 -0
- package/skills/terreno-ui/references/data-display.md +87 -0
- package/skills/terreno-ui/references/feedback.md +122 -0
- package/skills/terreno-ui/references/forms.md +119 -0
- package/skills/terreno-ui/references/layout.md +124 -0
- package/skills/terreno-ui/references/theming.md +87 -0
- package/skills/update-docs/SKILL.md +82 -0
- package/skills/upgrading-terreno/SKILL.md +93 -0
- package/skills/upgrading-terreno/references/ordering.md +40 -0
- package/skills/verify-ui-changes/SKILL.md +605 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
//
|
|
3
|
+
// Keep a simulator/emulator Expo dev client in sync with an app's native fingerprint.
|
|
4
|
+
//
|
|
5
|
+
// The dev client only needs rebuilding when native code changes. EAS stamps each build with
|
|
6
|
+
// the project fingerprint, so we compute the local fingerprint and look for a matching EAS
|
|
7
|
+
// build: cached locally -> install, finished on EAS -> download, otherwise start an EAS build.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// bun run devclient:status <app> <platform>
|
|
11
|
+
// bun run devclient:download <app> <platform> [--wait]
|
|
12
|
+
// bun run devclient:build <app> <platform> [--wait] [--force]
|
|
13
|
+
// bun run devclient:install <app> <platform> [--device <udid|avd>]
|
|
14
|
+
// bun run devclient:ensure <app> <platform> [--wait] [--no-build] [--device <udid|avd>]
|
|
15
|
+
// bun run devclient:open <app> <platform> [--device <udid|avd>]
|
|
16
|
+
//
|
|
17
|
+
// Apps come from storeAssets.config.json. Platforms: ios | android.
|
|
18
|
+
// `ensure` exits 3 while an EAS build is running, and 4 when --no-build found none to use.
|
|
19
|
+
|
|
20
|
+
import {existsSync, mkdirSync, readdirSync, rmSync, writeFileSync} from "node:fs";
|
|
21
|
+
import {homedir} from "node:os";
|
|
22
|
+
import {basename, join} from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
type AppConfig,
|
|
25
|
+
EXIT_BUILD_NEEDED,
|
|
26
|
+
EXIT_BUILD_PENDING,
|
|
27
|
+
fail as failTagged,
|
|
28
|
+
getApiPort,
|
|
29
|
+
getApp,
|
|
30
|
+
getExpoInfo,
|
|
31
|
+
isMetroRunning,
|
|
32
|
+
loadConfig,
|
|
33
|
+
REPO_ROOT,
|
|
34
|
+
runOrFail as runOrFailTagged,
|
|
35
|
+
type StoreAssetsConfig,
|
|
36
|
+
say as sayTagged,
|
|
37
|
+
} from "./config";
|
|
38
|
+
import {
|
|
39
|
+
bootEmulator,
|
|
40
|
+
bootIosSimulator,
|
|
41
|
+
findEmulatorSerial,
|
|
42
|
+
launchDevClientIntoMetro,
|
|
43
|
+
listAvds,
|
|
44
|
+
listIosSimulators,
|
|
45
|
+
reversePorts,
|
|
46
|
+
} from "./devices";
|
|
47
|
+
|
|
48
|
+
const TAG = "devclient";
|
|
49
|
+
const CACHE_ROOT =
|
|
50
|
+
process.env.DEV_CLIENT_CACHE_DIR ??
|
|
51
|
+
join(homedir(), ".cache", "store-assets", basename(REPO_ROOT), "devClients");
|
|
52
|
+
const POLL_INTERVAL_MS = 30_000;
|
|
53
|
+
const METRO_WAIT_SECONDS = 120;
|
|
54
|
+
const COMPLETE_MARKER = ".complete";
|
|
55
|
+
const PENDING_STATUSES = ["NEW", "IN_QUEUE", "IN_PROGRESS"];
|
|
56
|
+
// iOS needs a simulator-only build; Android's internal development build is an installable APK.
|
|
57
|
+
const DEFAULT_PROFILES = {android: "development", ios: "development:simulator"};
|
|
58
|
+
|
|
59
|
+
const say = (message: string): void => sayTagged({message, tag: TAG});
|
|
60
|
+
const fail = (message: string): never => failTagged({message, tag: TAG});
|
|
61
|
+
const runOrFail = (args: Omit<Parameters<typeof runOrFailTagged>[0], "tag">): Promise<string> =>
|
|
62
|
+
runOrFailTagged({...args, tag: TAG});
|
|
63
|
+
|
|
64
|
+
interface Target {
|
|
65
|
+
app: AppConfig;
|
|
66
|
+
appKey: string;
|
|
67
|
+
apiPort: number;
|
|
68
|
+
platform: "ios" | "android";
|
|
69
|
+
profile: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Options {
|
|
73
|
+
device?: string;
|
|
74
|
+
isForce: boolean;
|
|
75
|
+
isNoBuild: boolean;
|
|
76
|
+
isWait: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface EasArtifacts {
|
|
80
|
+
buildUrl?: string;
|
|
81
|
+
applicationArchiveUrl?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface EasBuild {
|
|
85
|
+
id: string;
|
|
86
|
+
status: string;
|
|
87
|
+
artifacts?: EasArtifacts;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Status {
|
|
91
|
+
action: "install" | "download" | "wait" | "build";
|
|
92
|
+
app: string;
|
|
93
|
+
buildUrl?: string;
|
|
94
|
+
cachedPath?: string;
|
|
95
|
+
fingerprint: string;
|
|
96
|
+
platform: string;
|
|
97
|
+
profile: string;
|
|
98
|
+
remoteBuildId?: string;
|
|
99
|
+
remoteBuildStatus?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const getArtifactUrl = ({build}: {build: EasBuild}): string | undefined =>
|
|
103
|
+
build.artifacts?.buildUrl ?? build.artifacts?.applicationArchiveUrl;
|
|
104
|
+
|
|
105
|
+
const eas = async ({target, args}: {target: Target; args: string[]}): Promise<string> => {
|
|
106
|
+
return runOrFail({
|
|
107
|
+
cmd: ["bunx", "eas-cli", ...args],
|
|
108
|
+
cwd: join(REPO_ROOT, target.app.appDir),
|
|
109
|
+
isQuiet: true,
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getBuildPageUrl = async ({
|
|
114
|
+
target,
|
|
115
|
+
buildId,
|
|
116
|
+
}: {
|
|
117
|
+
target: Target;
|
|
118
|
+
buildId: string;
|
|
119
|
+
}): Promise<string> => {
|
|
120
|
+
const {owner, slug} = await getExpoInfo({app: target.app, tag: TAG});
|
|
121
|
+
return `https://expo.dev/accounts/${owner}/projects/${slug}/builds/${buildId}`;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const getFingerprint = async ({target}: {target: Target}): Promise<string> => {
|
|
125
|
+
const output = await eas({
|
|
126
|
+
args: [
|
|
127
|
+
"fingerprint:generate",
|
|
128
|
+
"-p",
|
|
129
|
+
target.platform,
|
|
130
|
+
// Long form on purpose: `-e` is also --build-profile here, but reads like --environment.
|
|
131
|
+
"--build-profile",
|
|
132
|
+
target.profile,
|
|
133
|
+
"--json",
|
|
134
|
+
"--non-interactive",
|
|
135
|
+
],
|
|
136
|
+
target,
|
|
137
|
+
});
|
|
138
|
+
const hash = JSON.parse(output)?.hash;
|
|
139
|
+
if (!hash) {
|
|
140
|
+
return fail("Could not compute fingerprint");
|
|
141
|
+
}
|
|
142
|
+
return hash;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const getCacheDir = ({target, fingerprint}: {target: Target; fingerprint: string}): string => {
|
|
146
|
+
return join(CACHE_ROOT, target.appKey, target.platform, fingerprint);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const findCachedArtifact = ({
|
|
150
|
+
target,
|
|
151
|
+
fingerprint,
|
|
152
|
+
}: {
|
|
153
|
+
target: Target;
|
|
154
|
+
fingerprint: string;
|
|
155
|
+
}): string | undefined => {
|
|
156
|
+
const dir = getCacheDir({fingerprint, target});
|
|
157
|
+
// Written only after a full download (and extract), so interrupted downloads aren't reused.
|
|
158
|
+
if (!existsSync(join(dir, COMPLETE_MARKER))) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const extension = target.platform === "ios" ? ".app" : ".apk";
|
|
162
|
+
// Simulator tarballs may nest the .app; skip bundles embedded inside another .app.
|
|
163
|
+
const match = readdirSync(dir, {recursive: true})
|
|
164
|
+
.map(String)
|
|
165
|
+
.find(
|
|
166
|
+
(name) => name.endsWith(extension) && !name.slice(0, -extension.length).includes(".app/")
|
|
167
|
+
);
|
|
168
|
+
return match ? join(dir, match) : undefined;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const findRemoteBuilds = async ({
|
|
172
|
+
target,
|
|
173
|
+
fingerprint,
|
|
174
|
+
}: {
|
|
175
|
+
target: Target;
|
|
176
|
+
fingerprint: string;
|
|
177
|
+
}): Promise<EasBuild[]> => {
|
|
178
|
+
const output = await eas({
|
|
179
|
+
args: [
|
|
180
|
+
"build:list",
|
|
181
|
+
"-p",
|
|
182
|
+
target.platform,
|
|
183
|
+
"-e",
|
|
184
|
+
target.profile,
|
|
185
|
+
"--fingerprint-hash",
|
|
186
|
+
fingerprint,
|
|
187
|
+
"--limit",
|
|
188
|
+
"10",
|
|
189
|
+
"--json",
|
|
190
|
+
"--non-interactive",
|
|
191
|
+
],
|
|
192
|
+
target,
|
|
193
|
+
});
|
|
194
|
+
return JSON.parse(output || "[]") as EasBuild[];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const getStatus = async ({target}: {target: Target}): Promise<Status> => {
|
|
198
|
+
const fingerprint = await getFingerprint({target});
|
|
199
|
+
const base = {
|
|
200
|
+
app: target.appKey,
|
|
201
|
+
fingerprint,
|
|
202
|
+
platform: target.platform,
|
|
203
|
+
profile: target.profile,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const cachedPath = findCachedArtifact({fingerprint, target});
|
|
207
|
+
if (cachedPath) {
|
|
208
|
+
return {...base, action: "install", cachedPath};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const builds = await findRemoteBuilds({fingerprint, target});
|
|
212
|
+
const finished = builds.find((build) => build.status === "FINISHED" && getArtifactUrl({build}));
|
|
213
|
+
if (finished) {
|
|
214
|
+
return {
|
|
215
|
+
...base,
|
|
216
|
+
action: "download",
|
|
217
|
+
buildUrl: await getBuildPageUrl({buildId: finished.id, target}),
|
|
218
|
+
remoteBuildId: finished.id,
|
|
219
|
+
remoteBuildStatus: finished.status,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const pending = builds.find((build) => PENDING_STATUSES.includes(build.status));
|
|
224
|
+
if (pending) {
|
|
225
|
+
return {
|
|
226
|
+
...base,
|
|
227
|
+
action: "wait",
|
|
228
|
+
buildUrl: await getBuildPageUrl({buildId: pending.id, target}),
|
|
229
|
+
remoteBuildId: pending.id,
|
|
230
|
+
remoteBuildStatus: pending.status,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {...base, action: "build"};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const viewBuild = async ({
|
|
238
|
+
target,
|
|
239
|
+
buildId,
|
|
240
|
+
}: {
|
|
241
|
+
target: Target;
|
|
242
|
+
buildId: string;
|
|
243
|
+
}): Promise<EasBuild> => {
|
|
244
|
+
return JSON.parse(await eas({args: ["build:view", buildId, "--json"], target})) as EasBuild;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const waitForBuild = async ({
|
|
248
|
+
target,
|
|
249
|
+
buildId,
|
|
250
|
+
}: {
|
|
251
|
+
target: Target;
|
|
252
|
+
buildId: string;
|
|
253
|
+
}): Promise<EasBuild> => {
|
|
254
|
+
say(`Waiting for build ${await getBuildPageUrl({buildId, target})}`);
|
|
255
|
+
for (;;) {
|
|
256
|
+
const build = await viewBuild({buildId, target});
|
|
257
|
+
if (build.status === "FINISHED") {
|
|
258
|
+
return build;
|
|
259
|
+
}
|
|
260
|
+
if (!PENDING_STATUSES.includes(build.status)) {
|
|
261
|
+
return fail(`Build ${buildId} ended with status ${build.status}`);
|
|
262
|
+
}
|
|
263
|
+
say(`Build ${buildId} is ${build.status}; checking again in ${POLL_INTERVAL_MS / 1000}s`);
|
|
264
|
+
await Bun.sleep(POLL_INTERVAL_MS);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const downloadBuild = async ({
|
|
269
|
+
target,
|
|
270
|
+
fingerprint,
|
|
271
|
+
build,
|
|
272
|
+
}: {
|
|
273
|
+
target: Target;
|
|
274
|
+
fingerprint: string;
|
|
275
|
+
build: EasBuild;
|
|
276
|
+
}): Promise<string> => {
|
|
277
|
+
const url = getArtifactUrl({build});
|
|
278
|
+
if (!url) {
|
|
279
|
+
return fail(`Build ${build.id} has no downloadable artifact`);
|
|
280
|
+
}
|
|
281
|
+
const dir = getCacheDir({fingerprint, target});
|
|
282
|
+
rmSync(dir, {force: true, recursive: true});
|
|
283
|
+
mkdirSync(dir, {recursive: true});
|
|
284
|
+
|
|
285
|
+
say(`Downloading ${url}`);
|
|
286
|
+
const archivePath = join(dir, url.endsWith(".apk") ? "app.apk" : "app.tar.gz");
|
|
287
|
+
// Android dev APKs are several hundred MB; abort stalled transfers and resume on retry.
|
|
288
|
+
await runOrFail({
|
|
289
|
+
cmd: [
|
|
290
|
+
"curl",
|
|
291
|
+
"-fL",
|
|
292
|
+
"--retry",
|
|
293
|
+
"5",
|
|
294
|
+
"--retry-all-errors",
|
|
295
|
+
"--speed-limit",
|
|
296
|
+
"10000",
|
|
297
|
+
"--speed-time",
|
|
298
|
+
"60",
|
|
299
|
+
"-C",
|
|
300
|
+
"-",
|
|
301
|
+
"--progress-bar",
|
|
302
|
+
"-o",
|
|
303
|
+
archivePath,
|
|
304
|
+
url,
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (target.platform === "ios") {
|
|
309
|
+
await runOrFail({cmd: ["tar", "-xzf", archivePath, "-C", dir]});
|
|
310
|
+
rmSync(archivePath);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
writeFileSync(join(dir, COMPLETE_MARKER), build.id);
|
|
314
|
+
const artifact = findCachedArtifact({fingerprint, target});
|
|
315
|
+
if (!artifact) {
|
|
316
|
+
rmSync(join(dir, COMPLETE_MARKER));
|
|
317
|
+
return fail(`No ${target.platform === "ios" ? ".app" : ".apk"} found in ${dir}`);
|
|
318
|
+
}
|
|
319
|
+
say(`Cached dev client at ${artifact}`);
|
|
320
|
+
return artifact;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const startBuild = async ({target}: {target: Target}): Promise<string> => {
|
|
324
|
+
say(`Starting EAS ${target.profile} build for ${target.appKey} (${target.platform})`);
|
|
325
|
+
const output = await eas({
|
|
326
|
+
args: [
|
|
327
|
+
"build",
|
|
328
|
+
"-p",
|
|
329
|
+
target.platform,
|
|
330
|
+
"-e",
|
|
331
|
+
target.profile,
|
|
332
|
+
"--non-interactive",
|
|
333
|
+
"--no-wait",
|
|
334
|
+
"--json",
|
|
335
|
+
],
|
|
336
|
+
target,
|
|
337
|
+
});
|
|
338
|
+
const [build] = JSON.parse(output) as EasBuild[];
|
|
339
|
+
if (!build?.id) {
|
|
340
|
+
return fail("EAS did not return a build id");
|
|
341
|
+
}
|
|
342
|
+
say(`Build queued: ${await getBuildPageUrl({buildId: build.id, target})}`);
|
|
343
|
+
return build.id;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const getSimulatorFamily = ({target}: {target: Target}): "iPad" | "iPhone" =>
|
|
347
|
+
target.app.preferredIosSimulator?.startsWith("iPad") ? "iPad" : "iPhone";
|
|
348
|
+
|
|
349
|
+
// Reuses a booted simulator of the app's family, else boots the preferred one.
|
|
350
|
+
const ensureIosSimulator = async ({
|
|
351
|
+
target,
|
|
352
|
+
device,
|
|
353
|
+
}: {
|
|
354
|
+
target: Target;
|
|
355
|
+
device?: string;
|
|
356
|
+
}): Promise<string> => {
|
|
357
|
+
if (device) {
|
|
358
|
+
await bootIosSimulator({tag: TAG, udid: device});
|
|
359
|
+
return device;
|
|
360
|
+
}
|
|
361
|
+
const family = getSimulatorFamily({target});
|
|
362
|
+
const booted = await listIosSimulators({filter: "booted", tag: TAG});
|
|
363
|
+
const running = booted.find((sim) => sim.name.startsWith(family));
|
|
364
|
+
if (running) {
|
|
365
|
+
return running.udid;
|
|
366
|
+
}
|
|
367
|
+
const available = await listIosSimulators({filter: "available", tag: TAG});
|
|
368
|
+
const match =
|
|
369
|
+
available.find((sim) => sim.name === target.app.preferredIosSimulator) ??
|
|
370
|
+
available.find((sim) => sim.name.startsWith(family));
|
|
371
|
+
if (!match) {
|
|
372
|
+
return fail(
|
|
373
|
+
`No available ${family} simulator. Install one from Xcode > Settings > Components.`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
say(`Booting iOS simulator ${match.name} (${match.udid})`);
|
|
377
|
+
await bootIosSimulator({tag: TAG, udid: match.udid});
|
|
378
|
+
return match.udid;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const ensureAndroidEmulator = async ({device}: {device?: string}): Promise<string> => {
|
|
382
|
+
const running = await findEmulatorSerial({avd: device, tag: TAG});
|
|
383
|
+
if (running) {
|
|
384
|
+
return running;
|
|
385
|
+
}
|
|
386
|
+
const avds = await listAvds({tag: TAG});
|
|
387
|
+
const avd = device ?? avds[0];
|
|
388
|
+
if (!avd) {
|
|
389
|
+
return fail("No Android AVDs found. Create one in Android Studio.");
|
|
390
|
+
}
|
|
391
|
+
if (!avds.includes(avd)) {
|
|
392
|
+
return fail(`AVD "${avd}" not found. Available: ${avds.join(", ")}`);
|
|
393
|
+
}
|
|
394
|
+
say(`Starting Android emulator ${avd}`);
|
|
395
|
+
return bootEmulator({avd, tag: TAG});
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const installArtifact = async ({
|
|
399
|
+
target,
|
|
400
|
+
artifact,
|
|
401
|
+
device,
|
|
402
|
+
}: {
|
|
403
|
+
target: Target;
|
|
404
|
+
artifact: string;
|
|
405
|
+
device?: string;
|
|
406
|
+
}): Promise<void> => {
|
|
407
|
+
if (target.platform === "ios") {
|
|
408
|
+
const udid = await ensureIosSimulator({device, target});
|
|
409
|
+
say(`Installing ${artifact} on ${udid}`);
|
|
410
|
+
await runOrFail({cmd: ["xcrun", "simctl", "install", udid, artifact]});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const serial = await ensureAndroidEmulator({device});
|
|
414
|
+
say(`Installing ${artifact} on ${serial}`);
|
|
415
|
+
await runOrFail({cmd: ["adb", "-s", serial, "install", "-r", artifact]});
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const printStatus = ({status}: {status: Status}): void => {
|
|
419
|
+
console.info(JSON.stringify(status, null, 2));
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const commandStatus = async ({target}: {target: Target; options: Options}): Promise<void> => {
|
|
423
|
+
printStatus({status: await getStatus({target})});
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const commandDownload = async ({
|
|
427
|
+
target,
|
|
428
|
+
options,
|
|
429
|
+
}: {
|
|
430
|
+
target: Target;
|
|
431
|
+
options: Options;
|
|
432
|
+
}): Promise<void> => {
|
|
433
|
+
const status = await getStatus({target});
|
|
434
|
+
if (status.cachedPath) {
|
|
435
|
+
say(`Already cached: ${status.cachedPath}`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (!status.remoteBuildId) {
|
|
439
|
+
fail(`No EAS build matches fingerprint ${status.fingerprint}. Run devclient:build first.`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (status.action === "wait" && !options.isWait) {
|
|
443
|
+
fail(`Build ${status.buildUrl} is still ${status.remoteBuildStatus}. Re-run with --wait.`);
|
|
444
|
+
}
|
|
445
|
+
const build =
|
|
446
|
+
status.action === "wait"
|
|
447
|
+
? await waitForBuild({buildId: status.remoteBuildId, target})
|
|
448
|
+
: await viewBuild({buildId: status.remoteBuildId, target});
|
|
449
|
+
await downloadBuild({build, fingerprint: status.fingerprint, target});
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const commandBuild = async ({
|
|
453
|
+
target,
|
|
454
|
+
options,
|
|
455
|
+
}: {
|
|
456
|
+
target: Target;
|
|
457
|
+
options: Options;
|
|
458
|
+
}): Promise<void> => {
|
|
459
|
+
const status = await getStatus({target});
|
|
460
|
+
if (!options.isForce && status.action !== "build") {
|
|
461
|
+
say(
|
|
462
|
+
`Skipping build: fingerprint ${status.fingerprint} already has action "${status.action}". Use --force.`
|
|
463
|
+
);
|
|
464
|
+
printStatus({status});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const buildId = await startBuild({target});
|
|
468
|
+
if (options.isWait) {
|
|
469
|
+
const build = await waitForBuild({buildId, target});
|
|
470
|
+
await downloadBuild({build, fingerprint: status.fingerprint, target});
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const commandInstall = async ({
|
|
475
|
+
target,
|
|
476
|
+
options,
|
|
477
|
+
}: {
|
|
478
|
+
target: Target;
|
|
479
|
+
options: Options;
|
|
480
|
+
}): Promise<void> => {
|
|
481
|
+
const fingerprint = await getFingerprint({target});
|
|
482
|
+
const artifact = findCachedArtifact({fingerprint, target});
|
|
483
|
+
if (!artifact) {
|
|
484
|
+
fail(`No cached dev client for fingerprint ${fingerprint}. Run devclient:ensure.`);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
await installArtifact({artifact, device: options.device, target});
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const commandEnsure = async ({
|
|
491
|
+
target,
|
|
492
|
+
options,
|
|
493
|
+
}: {
|
|
494
|
+
target: Target;
|
|
495
|
+
options: Options;
|
|
496
|
+
}): Promise<void> => {
|
|
497
|
+
const status = await getStatus({target});
|
|
498
|
+
say(`Fingerprint ${status.fingerprint}: ${status.action}`);
|
|
499
|
+
|
|
500
|
+
let artifact = status.cachedPath;
|
|
501
|
+
if (status.action === "download" && status.remoteBuildId) {
|
|
502
|
+
const build = await viewBuild({buildId: status.remoteBuildId, target});
|
|
503
|
+
artifact = await downloadBuild({build, fingerprint: status.fingerprint, target});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (status.action === "build" && options.isNoBuild) {
|
|
507
|
+
say(
|
|
508
|
+
`No dev client matches fingerprint ${status.fingerprint}; a new EAS build (uses Expo credits) is needed.`
|
|
509
|
+
);
|
|
510
|
+
say(`Start one with: bun run devclient:build ${target.appKey} ${target.platform}`);
|
|
511
|
+
process.exit(EXIT_BUILD_NEEDED);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (status.action === "wait" || status.action === "build") {
|
|
515
|
+
const buildId = status.remoteBuildId ?? (await startBuild({target}));
|
|
516
|
+
if (!options.isWait) {
|
|
517
|
+
say(`Dev client build pending: ${await getBuildPageUrl({buildId, target})}`);
|
|
518
|
+
say("Re-run with --wait to block until it finishes, then download and install.");
|
|
519
|
+
process.exit(EXIT_BUILD_PENDING);
|
|
520
|
+
}
|
|
521
|
+
const build = await waitForBuild({buildId, target});
|
|
522
|
+
artifact = await downloadBuild({build, fingerprint: status.fingerprint, target});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!artifact) {
|
|
526
|
+
fail("No dev client artifact to install");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
await installArtifact({artifact, device: options.device, target});
|
|
530
|
+
say(
|
|
531
|
+
`Dev client ready. Start Metro (cd ${target.app.appDir} && bun run start), then: ` +
|
|
532
|
+
`bun run devclient:open ${target.appKey} ${target.platform}`
|
|
533
|
+
);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// Launches the dev client straight into this app's Metro server on the same device `ensure`
|
|
537
|
+
// installed to, instead of letting `expo start --ios` pick whichever simulator is booted.
|
|
538
|
+
const commandOpen = async ({
|
|
539
|
+
target,
|
|
540
|
+
options,
|
|
541
|
+
}: {
|
|
542
|
+
target: Target;
|
|
543
|
+
options: Options;
|
|
544
|
+
}): Promise<void> => {
|
|
545
|
+
const port = target.app.metroPort;
|
|
546
|
+
for (let attempt = 0; !(await isMetroRunning({port})); attempt++) {
|
|
547
|
+
if (attempt === 0) {
|
|
548
|
+
say(`Waiting for Metro on :${port} (cd ${target.app.appDir} && bun run start)`);
|
|
549
|
+
}
|
|
550
|
+
if (attempt >= METRO_WAIT_SECONDS) {
|
|
551
|
+
fail(`Metro is not running on :${port}`);
|
|
552
|
+
}
|
|
553
|
+
await Bun.sleep(1_000);
|
|
554
|
+
}
|
|
555
|
+
const expo = await getExpoInfo({app: target.app, tag: TAG});
|
|
556
|
+
const deviceId =
|
|
557
|
+
target.platform === "ios"
|
|
558
|
+
? await ensureIosSimulator({device: options.device, target})
|
|
559
|
+
: await ensureAndroidEmulator({device: options.device});
|
|
560
|
+
if (target.platform === "android") {
|
|
561
|
+
await reversePorts({ports: [target.apiPort, port], serial: deviceId, tag: TAG});
|
|
562
|
+
}
|
|
563
|
+
say(`Launching ${target.appKey} on ${deviceId} with Metro http://localhost:${port}`);
|
|
564
|
+
await launchDevClientIntoMetro({
|
|
565
|
+
deviceId,
|
|
566
|
+
devMenu: "skipIntro",
|
|
567
|
+
expo,
|
|
568
|
+
metroPort: port,
|
|
569
|
+
platform: target.platform,
|
|
570
|
+
tag: TAG,
|
|
571
|
+
});
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const COMMANDS: Record<string, (args: {target: Target; options: Options}) => Promise<void>> = {
|
|
575
|
+
build: commandBuild,
|
|
576
|
+
download: commandDownload,
|
|
577
|
+
ensure: commandEnsure,
|
|
578
|
+
install: commandInstall,
|
|
579
|
+
open: commandOpen,
|
|
580
|
+
status: commandStatus,
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const parseArgs = ({
|
|
584
|
+
argv,
|
|
585
|
+
config,
|
|
586
|
+
}: {
|
|
587
|
+
argv: string[];
|
|
588
|
+
config: StoreAssetsConfig;
|
|
589
|
+
}): {command: string; target: Target; options: Options} => {
|
|
590
|
+
const [command, appKey, platform, ...rest] = argv;
|
|
591
|
+
if (!command || !COMMANDS[command]) {
|
|
592
|
+
return fail(
|
|
593
|
+
`Unknown command "${command ?? ""}". Use one of: ${Object.keys(COMMANDS).join(", ")}`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const app = getApp({appKey, config, tag: TAG});
|
|
597
|
+
if (platform !== "ios" && platform !== "android") {
|
|
598
|
+
return fail(`Unknown platform "${platform ?? ""}". Use ios or android.`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const options: Options = {isForce: false, isNoBuild: false, isWait: false};
|
|
602
|
+
for (let i = 0; i < rest.length; i++) {
|
|
603
|
+
const flag = rest[i];
|
|
604
|
+
if (flag === "--wait") {
|
|
605
|
+
options.isWait = true;
|
|
606
|
+
} else if (flag === "--force") {
|
|
607
|
+
options.isForce = true;
|
|
608
|
+
} else if (flag === "--no-build") {
|
|
609
|
+
options.isNoBuild = true;
|
|
610
|
+
} else if (flag === "--device") {
|
|
611
|
+
options.device = rest[++i] ?? fail("--device requires a value");
|
|
612
|
+
} else {
|
|
613
|
+
fail(`Unknown flag "${flag}"`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const profiles = config.devClientProfiles ?? DEFAULT_PROFILES;
|
|
618
|
+
return {
|
|
619
|
+
command,
|
|
620
|
+
options,
|
|
621
|
+
target: {
|
|
622
|
+
apiPort: getApiPort({config}),
|
|
623
|
+
app,
|
|
624
|
+
appKey: appKey as string,
|
|
625
|
+
platform,
|
|
626
|
+
profile: profiles[platform],
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
if (import.meta.main) {
|
|
632
|
+
const config = await loadConfig({tag: TAG});
|
|
633
|
+
const {command, target, options} = parseArgs({argv: process.argv.slice(2), config});
|
|
634
|
+
await COMMANDS[command]?.({options, target});
|
|
635
|
+
}
|