@appforge-ci/core 0.1.1
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/LICENSE +21 -0
- package/dist/agent-readiness.d.ts +163 -0
- package/dist/agent-readiness.d.ts.map +1 -0
- package/dist/agent-readiness.js +179 -0
- package/dist/agent-readiness.js.map +1 -0
- package/dist/agent-readiness.test.d.ts +2 -0
- package/dist/agent-readiness.test.d.ts.map +1 -0
- package/dist/agent-readiness.test.js +177 -0
- package/dist/agent-readiness.test.js.map +1 -0
- package/dist/api-client.d.ts +529 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +551 -0
- package/dist/api-client.js.map +1 -0
- package/dist/api-client.test.d.ts +2 -0
- package/dist/api-client.test.d.ts.map +1 -0
- package/dist/api-client.test.js +305 -0
- package/dist/api-client.test.js.map +1 -0
- package/dist/build-cost.test.d.ts +2 -0
- package/dist/build-cost.test.d.ts.map +1 -0
- package/dist/build-cost.test.js +21 -0
- package/dist/build-cost.test.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/ota-artifact.d.ts +41 -0
- package/dist/ota-artifact.d.ts.map +1 -0
- package/dist/ota-artifact.js +43 -0
- package/dist/ota-artifact.js.map +1 -0
- package/dist/ota-artifact.test.d.ts +2 -0
- package/dist/ota-artifact.test.d.ts.map +1 -0
- package/dist/ota-artifact.test.js +44 -0
- package/dist/ota-artifact.test.js.map +1 -0
- package/dist/ota-bundle-hash.d.ts +38 -0
- package/dist/ota-bundle-hash.d.ts.map +1 -0
- package/dist/ota-bundle-hash.js +40 -0
- package/dist/ota-bundle-hash.js.map +1 -0
- package/dist/ota-bundle-hash.test.d.ts +2 -0
- package/dist/ota-bundle-hash.test.d.ts.map +1 -0
- package/dist/ota-bundle-hash.test.js +28 -0
- package/dist/ota-bundle-hash.test.js.map +1 -0
- package/dist/ota-rollout.d.ts +38 -0
- package/dist/ota-rollout.d.ts.map +1 -0
- package/dist/ota-rollout.js +53 -0
- package/dist/ota-rollout.js.map +1 -0
- package/dist/ota-rollout.test.d.ts +2 -0
- package/dist/ota-rollout.test.d.ts.map +1 -0
- package/dist/ota-rollout.test.js +48 -0
- package/dist/ota-rollout.test.js.map +1 -0
- package/dist/schemas.d.ts +776 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +538 -0
- package/dist/schemas.js.map +1 -0
- package/dist/token-packs.test.d.ts +2 -0
- package/dist/token-packs.test.d.ts.map +1 -0
- package/dist/token-packs.test.js +34 -0
- package/dist/token-packs.test.js.map +1 -0
- package/dist/types.d.ts +445 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +80 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ApiClient, ApiError, apiErrorMessage } from "./api-client.js";
|
|
4
|
+
/**
|
|
5
|
+
* `ApiClient.request` is the shared HTTP wrapper every single call the
|
|
6
|
+
* dashboard, CLI, and admin CLI make to the control-plane API goes
|
|
7
|
+
* through — auth header injection, JSON parsing (including the
|
|
8
|
+
* empty-body case), routing to the admin Worker vs. the customer-facing
|
|
9
|
+
* one, and wrapping a non-2xx response into `ApiError`. None of it had
|
|
10
|
+
* ever been exercised, despite being the single most foundational piece
|
|
11
|
+
* of shared code in `packages/core`. `fetchImpl` is a small, additive,
|
|
12
|
+
* optional test seam on `ApiClientOptions` (same "defaults to the real
|
|
13
|
+
* thing" shape `followBuildLogs`/`waitForCommandResolution` already
|
|
14
|
+
* established) — every real caller omits it and gets the real global
|
|
15
|
+
* `fetch`, unchanged.
|
|
16
|
+
*/
|
|
17
|
+
function fakeFetch(handler) {
|
|
18
|
+
return (async (url, init) => {
|
|
19
|
+
const result = handler(url, init);
|
|
20
|
+
const text = "text" in result ? result.text : JSON.stringify(result.body);
|
|
21
|
+
return {
|
|
22
|
+
ok: result.status >= 200 && result.status < 300,
|
|
23
|
+
status: result.status,
|
|
24
|
+
text: async () => text,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
test("request sends a bearer token when one is configured, and none at all when it isn't", async () => {
|
|
29
|
+
let capturedAuth;
|
|
30
|
+
const withToken = new ApiClient({
|
|
31
|
+
baseUrl: "https://api.test",
|
|
32
|
+
token: "afk_secret",
|
|
33
|
+
fetchImpl: fakeFetch((_, init) => {
|
|
34
|
+
capturedAuth = init.headers.authorization;
|
|
35
|
+
return { status: 200, body: { user: { id: "u1" }, orgs: [] } };
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
await withToken.me();
|
|
39
|
+
assert.equal(capturedAuth, "Bearer afk_secret");
|
|
40
|
+
let sawAuthHeader = true;
|
|
41
|
+
const withoutToken = new ApiClient({
|
|
42
|
+
baseUrl: "https://api.test",
|
|
43
|
+
fetchImpl: fakeFetch((_, init) => {
|
|
44
|
+
sawAuthHeader = "authorization" in init.headers;
|
|
45
|
+
return { status: 200, body: { packs: [] } };
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
await withoutToken.listTokenPacks();
|
|
49
|
+
assert.equal(sawAuthHeader, false);
|
|
50
|
+
});
|
|
51
|
+
test("request JSON-serializes a request body, and sends none at all for a bodyless call", async () => {
|
|
52
|
+
let capturedBody;
|
|
53
|
+
const client = new ApiClient({
|
|
54
|
+
baseUrl: "https://api.test",
|
|
55
|
+
fetchImpl: fakeFetch((_, init) => {
|
|
56
|
+
capturedBody = init.body;
|
|
57
|
+
return { status: 201, body: { token: "t", user: {}, org: {} } };
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
await client.signup("a@example.com", "password1234", undefined, "Acme");
|
|
61
|
+
assert.equal(capturedBody, JSON.stringify({ email: "a@example.com", password: "password1234", name: undefined, orgName: "Acme" }));
|
|
62
|
+
let bodylessCaptured = "not yet set";
|
|
63
|
+
const bodylessClient = new ApiClient({
|
|
64
|
+
baseUrl: "https://api.test",
|
|
65
|
+
fetchImpl: fakeFetch((_, init) => {
|
|
66
|
+
bodylessCaptured = init.body;
|
|
67
|
+
return { status: 200, body: { user: {}, orgs: [] } };
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
await bodylessClient.me();
|
|
71
|
+
assert.equal(bodylessCaptured, undefined);
|
|
72
|
+
});
|
|
73
|
+
test("request throws ApiError with the real status and parsed body on a non-2xx response", async () => {
|
|
74
|
+
const client = new ApiClient({
|
|
75
|
+
baseUrl: "https://api.test",
|
|
76
|
+
fetchImpl: fakeFetch(() => ({ status: 402, body: { error: "insufficient tokens" } })),
|
|
77
|
+
});
|
|
78
|
+
await assert.rejects(() => client.triggerBuild({ appId: "app-1", workflow: "default" }), (err) => {
|
|
79
|
+
assert.ok(err instanceof ApiError);
|
|
80
|
+
assert.equal(err.status, 402);
|
|
81
|
+
assert.deepEqual(err.body, { error: "insufficient tokens" });
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
// Regression pin: a real deployment sits behind Cloudflare's edge, which
|
|
86
|
+
// can return a plain-text/HTML error page (a gateway timeout, a WAF
|
|
87
|
+
// block, a rate-limit page) instead of the JSON apps/api's/apps/admin-
|
|
88
|
+
// api's own jsonErrorHandler always produces from inside the Worker
|
|
89
|
+
// itself. Before this fix, `JSON.parse`-ing that unconditionally crashed
|
|
90
|
+
// every caller with a raw, uncatchable SyntaxError instead of the
|
|
91
|
+
// ApiError they already know how to catch.
|
|
92
|
+
test("request wraps a non-JSON error body in ApiError instead of crashing on JSON.parse", async () => {
|
|
93
|
+
const client = new ApiClient({
|
|
94
|
+
baseUrl: "https://api.test",
|
|
95
|
+
fetchImpl: fakeFetch(() => ({ status: 524, text: "<html>Gateway Timeout</html>" })),
|
|
96
|
+
});
|
|
97
|
+
await assert.rejects(() => client.triggerBuild({ appId: "app-1", workflow: "default" }), (err) => {
|
|
98
|
+
assert.ok(err instanceof ApiError);
|
|
99
|
+
assert.equal(err.status, 524);
|
|
100
|
+
assert.equal(err.body, "<html>Gateway Timeout</html>");
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// Same edge-error scenario, but on an ok-looking status — rarer, but a
|
|
105
|
+
// caller still deserves a clear thrown error over an uncatchable
|
|
106
|
+
// SyntaxError with no status or context at all.
|
|
107
|
+
test("request throws a clear error (not a raw SyntaxError) on a 2xx response with a non-JSON body", async () => {
|
|
108
|
+
const client = new ApiClient({
|
|
109
|
+
baseUrl: "https://api.test",
|
|
110
|
+
fetchImpl: fakeFetch(() => ({ status: 200, text: "<html>ok?</html>" })),
|
|
111
|
+
});
|
|
112
|
+
await assert.rejects(() => client.me(), (err) => {
|
|
113
|
+
assert.ok(err instanceof Error);
|
|
114
|
+
assert.ok(!(err instanceof SyntaxError), "must not leak a raw JSON.parse SyntaxError to the caller");
|
|
115
|
+
assert.match(err.message, /expected a JSON response/);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
test("request treats an empty response body as null rather than throwing on JSON.parse", async () => {
|
|
120
|
+
const client = new ApiClient({
|
|
121
|
+
baseUrl: "https://api.test",
|
|
122
|
+
fetchImpl: fakeFetch(() => ({ status: 200, text: "" })),
|
|
123
|
+
});
|
|
124
|
+
const result = await client.deleteAgent("org-1", "agent-1");
|
|
125
|
+
assert.equal(result, null);
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* Regression test for this cycle's fix: `getOtaManifest` is typed
|
|
129
|
+
* `Promise<OtaManifest | null>`, matching the real server's behavior —
|
|
130
|
+
* `apps/api/src/routes/ota.ts` answers "already up to date"/"no active
|
|
131
|
+
* release"/"not in this install's staged rollout" with a genuine HTTP
|
|
132
|
+
* 204 and a truly empty body (`c.body(null, 204)`), the overwhelmingly
|
|
133
|
+
* common response for a polling OTA-check endpoint. Before this fix,
|
|
134
|
+
* `request()`'s empty-body fallback resolved to `undefined`, not `null`
|
|
135
|
+
* — silently breaking that declared type contract. Any caller written
|
|
136
|
+
* against the type, doing the natural `if (manifest === null) return;`,
|
|
137
|
+
* would instead fall through and crash on `undefined.bundleUrl` on
|
|
138
|
+
* every single "nothing to do" response. `fakeFetch`'s `text: ""` shape
|
|
139
|
+
* (not `body: null`, which serializes to the *string* `"null"` and
|
|
140
|
+
* parses back fine either way) is what actually reproduces a real 204's
|
|
141
|
+
* empty body.
|
|
142
|
+
*/
|
|
143
|
+
test("getOtaManifest resolves to null (not undefined) on a real empty-body 204, matching its declared type", async () => {
|
|
144
|
+
const client = new ApiClient({
|
|
145
|
+
baseUrl: "https://api.test",
|
|
146
|
+
fetchImpl: fakeFetch(() => ({ status: 204, text: "" })),
|
|
147
|
+
});
|
|
148
|
+
const manifest = await client.getOtaManifest("app-1", "production", "key-1");
|
|
149
|
+
assert.equal(manifest, null);
|
|
150
|
+
});
|
|
151
|
+
test("admin-Worker methods hit adminApiUrl when configured, and fall back to baseUrl otherwise", async () => {
|
|
152
|
+
let capturedUrl;
|
|
153
|
+
const withAdminUrl = new ApiClient({
|
|
154
|
+
baseUrl: "https://api.test",
|
|
155
|
+
adminApiUrl: "https://admin.test",
|
|
156
|
+
fetchImpl: fakeFetch((url) => {
|
|
157
|
+
capturedUrl = url;
|
|
158
|
+
return { status: 200, body: { agents: [] } };
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
await withAdminUrl.listAgents("org-1");
|
|
162
|
+
assert.equal(capturedUrl, "https://admin.test/orgs/org-1/agents");
|
|
163
|
+
let fallbackUrl;
|
|
164
|
+
const withoutAdminUrl = new ApiClient({
|
|
165
|
+
baseUrl: "https://api.test",
|
|
166
|
+
fetchImpl: fakeFetch((url) => {
|
|
167
|
+
fallbackUrl = url;
|
|
168
|
+
return { status: 200, body: { agents: [] } };
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
await withoutAdminUrl.listAgents("org-1");
|
|
172
|
+
assert.equal(fallbackUrl, "https://api.test/orgs/org-1/agents");
|
|
173
|
+
});
|
|
174
|
+
// Regression pin: `verifyEmail`/`resendVerification` (this cycle's fix)
|
|
175
|
+
// used to not exist on `ApiClient` at all — the dashboard's own
|
|
176
|
+
// verification link had no method to call, so the token from a real
|
|
177
|
+
// signup verification email had no client-side way to ever be redeemed.
|
|
178
|
+
// This just pins the request shape (method, path, body); the dashboard
|
|
179
|
+
// page that calls it is covered separately in apps/dashboard.
|
|
180
|
+
test("verifyEmail POSTs the token to /auth/verify-email; resendVerification POSTs no body to /auth/resend-verification", async () => {
|
|
181
|
+
let captured;
|
|
182
|
+
const client = new ApiClient({
|
|
183
|
+
baseUrl: "https://api.test",
|
|
184
|
+
fetchImpl: fakeFetch((url, init) => {
|
|
185
|
+
captured = { url, method: init.method, body: init.body };
|
|
186
|
+
return { status: 200, body: { ok: true } };
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
await client.verifyEmail("tok_abc123");
|
|
190
|
+
assert.deepEqual(captured, {
|
|
191
|
+
url: "https://api.test/auth/verify-email",
|
|
192
|
+
method: "POST",
|
|
193
|
+
body: JSON.stringify({ token: "tok_abc123" }),
|
|
194
|
+
});
|
|
195
|
+
await client.resendVerification();
|
|
196
|
+
assert.deepEqual(captured, {
|
|
197
|
+
url: "https://api.test/auth/resend-verification",
|
|
198
|
+
method: "POST",
|
|
199
|
+
body: undefined,
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
// Regression pin: `changePassword` used to not exist on `ApiClient` at
|
|
203
|
+
// all — there was no password-rotation route anywhere in the product
|
|
204
|
+
// (found by a fresh Explore-subagent survey). Pins the request shape
|
|
205
|
+
// only; the dashboard panel that calls it is covered separately.
|
|
206
|
+
test("changePassword POSTs currentPassword/newPassword to /auth/change-password", async () => {
|
|
207
|
+
let captured;
|
|
208
|
+
const client = new ApiClient({
|
|
209
|
+
baseUrl: "https://api.test",
|
|
210
|
+
fetchImpl: fakeFetch((url, init) => {
|
|
211
|
+
captured = { url, method: init.method, body: init.body };
|
|
212
|
+
return { status: 200, body: { ok: true } };
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
await client.changePassword("old-pw", "a-brand-new-password");
|
|
216
|
+
assert.deepEqual(captured, {
|
|
217
|
+
url: "https://api.test/auth/change-password",
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: JSON.stringify({ currentPassword: "old-pw", newPassword: "a-brand-new-password" }),
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
test("getOtaManifest URL-encodes a channel name containing '/', matching the other OTA methods", async () => {
|
|
223
|
+
let capturedUrl;
|
|
224
|
+
const client = new ApiClient({
|
|
225
|
+
baseUrl: "https://api.test",
|
|
226
|
+
fetchImpl: fakeFetch((url) => {
|
|
227
|
+
capturedUrl = url;
|
|
228
|
+
return { status: 200, body: null };
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
await client.getOtaManifest("app-1", "beta/ios", "key-1");
|
|
232
|
+
assert.ok(capturedUrl?.includes("/ota/beta%2Fios/manifest"), capturedUrl);
|
|
233
|
+
assert.ok(!capturedUrl?.includes("/ota/beta/ios/manifest"), capturedUrl);
|
|
234
|
+
});
|
|
235
|
+
// `AbortSignal.timeout`'s own internal timer is unref'd (confirmed
|
|
236
|
+
// directly: a bare `node -e` script with nothing else scheduled exits
|
|
237
|
+
// before the timer ever fires) — harmless in a real long-running process
|
|
238
|
+
// (the dashboard's own event loop, a CLI command already doing other
|
|
239
|
+
// work), but a test whose only pending work is the awaited call has
|
|
240
|
+
// nothing else keeping it alive. A trivial ref'd interval for the
|
|
241
|
+
// duration of the call fixes that — same helper shape `packages/agent`'s
|
|
242
|
+
// own `api.test.ts`/`self-update.test.ts` already established for the
|
|
243
|
+
// identical issue there.
|
|
244
|
+
function withRefKeepAlive(fn) {
|
|
245
|
+
const keepAlive = setInterval(() => { }, 1000);
|
|
246
|
+
return fn().finally(() => clearInterval(keepAlive));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The actual bug this cycle's fix closes: before it, `request()`'s one
|
|
250
|
+
* `fetch` call — the single method every dashboard page, CLI command, and
|
|
251
|
+
* admin-cli command funnels every network call through — had no timeout
|
|
252
|
+
* at all. A connection that stalls after opening (a network partition, a
|
|
253
|
+
* proxy holding the connection open without sending further bytes, a hung
|
|
254
|
+
* Worker instance) left the returned promise pending forever: a CLI
|
|
255
|
+
* command would hang the terminal indefinitely with no error printed, and
|
|
256
|
+
* a dashboard page's data-loading call would leave its loading state
|
|
257
|
+
* stuck with no way to recover short of a full refresh. The mock fetch
|
|
258
|
+
* mimics what a real `fetch` does once aborted — reject once the signal
|
|
259
|
+
* fires — to isolate "does request() actually wire the signal through"
|
|
260
|
+
* from "does AbortSignal.timeout itself work" (a language feature, not
|
|
261
|
+
* this codebase's to test). Passes a tiny `requestTimeoutMs` via the
|
|
262
|
+
* constructor's test seam rather than waiting out the real
|
|
263
|
+
* `DEFAULT_REQUEST_TIMEOUT_MS`.
|
|
264
|
+
*/
|
|
265
|
+
test("request times out instead of hanging forever when the connection stalls", async () => {
|
|
266
|
+
await withRefKeepAlive(() => {
|
|
267
|
+
const client = new ApiClient({
|
|
268
|
+
baseUrl: "https://api.test",
|
|
269
|
+
requestTimeoutMs: 20,
|
|
270
|
+
fetchImpl: (async (_url, init) => {
|
|
271
|
+
return new Promise((_resolve, reject) => {
|
|
272
|
+
init?.signal?.addEventListener("abort", () => reject(new Error("The operation was aborted")));
|
|
273
|
+
});
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
return assert.rejects(() => client.me());
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
/**
|
|
280
|
+
* `apiErrorMessage` — the shared "what should the user see" extraction
|
|
281
|
+
* pulled out after the identical bug (an `ApiError`'s raw
|
|
282
|
+
* `API error ${status}: {...}` wire string shown instead of the
|
|
283
|
+
* server's own `body.error` text) was found and fixed independently in
|
|
284
|
+
* `Login.tsx`/`Signup.tsx`/`Device.tsx`/`VerifyEmail.tsx` and
|
|
285
|
+
* `VncViewer.tsx`'s own `vncViewerErrorMessage`. See ROADMAP.md's
|
|
286
|
+
* "shared apiErrorMessage helper" entry for the ~30 remaining call
|
|
287
|
+
* sites this is meant to eventually replace.
|
|
288
|
+
*/
|
|
289
|
+
test("apiErrorMessage extracts the server's body.error for an ApiError that has one", () => {
|
|
290
|
+
const err = new ApiError(409, { error: "app has an active build — wait for it to finish or cancel it first" });
|
|
291
|
+
assert.equal(apiErrorMessage(err, "fallback"), "app has an active build — wait for it to finish or cancel it first");
|
|
292
|
+
});
|
|
293
|
+
test("apiErrorMessage falls back to the ApiError's own message when body.error is missing or not a string", () => {
|
|
294
|
+
assert.equal(apiErrorMessage(new ApiError(500, {}), "fallback"), new ApiError(500, {}).message);
|
|
295
|
+
assert.equal(apiErrorMessage(new ApiError(500, { error: 42 }), "fallback"), new ApiError(500, { error: 42 }).message);
|
|
296
|
+
assert.equal(apiErrorMessage(new ApiError(500, null), "fallback"), new ApiError(500, null).message);
|
|
297
|
+
});
|
|
298
|
+
test("apiErrorMessage uses a plain Error's own message, never the ApiError-only body.error path", () => {
|
|
299
|
+
assert.equal(apiErrorMessage(new Error("network down"), "fallback"), "network down");
|
|
300
|
+
});
|
|
301
|
+
test("apiErrorMessage uses the caller's fallback for a non-Error throw", () => {
|
|
302
|
+
assert.equal(apiErrorMessage("some string throw", "fallback"), "fallback");
|
|
303
|
+
assert.equal(apiErrorMessage(undefined, "fallback"), "fallback");
|
|
304
|
+
});
|
|
305
|
+
//# sourceMappingURL=api-client.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-client.test.js","sourceRoot":"","sources":["../src/api-client.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-cost.test.d.ts","sourceRoot":"","sources":["../src/build-cost.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { getBuildCostTokens, BUILD_COST_TOKENS, BUILD_COST_TOKENS_BY_PLATFORM } from "./types.js";
|
|
4
|
+
test("getBuildCostTokens returns the flat default for every known platform (no pricing change bundled)", () => {
|
|
5
|
+
for (const platform of ["ios", "android", "flutter", "react-native"]) {
|
|
6
|
+
assert.equal(getBuildCostTokens(platform), BUILD_COST_TOKENS);
|
|
7
|
+
}
|
|
8
|
+
});
|
|
9
|
+
test("getBuildCostTokens converts the DB's underscore storage format (react_native)", () => {
|
|
10
|
+
assert.equal(getBuildCostTokens("react_native"), BUILD_COST_TOKENS_BY_PLATFORM["react-native"]);
|
|
11
|
+
});
|
|
12
|
+
test("getBuildCostTokens falls back to the flat default for an unrecognized platform instead of throwing", () => {
|
|
13
|
+
assert.equal(getBuildCostTokens("some-future-platform"), BUILD_COST_TOKENS);
|
|
14
|
+
});
|
|
15
|
+
test("BUILD_COST_TOKENS_BY_PLATFORM has an entry for every Platform", () => {
|
|
16
|
+
const platforms = ["ios", "android", "flutter", "react-native"];
|
|
17
|
+
for (const platform of platforms) {
|
|
18
|
+
assert.equal(typeof BUILD_COST_TOKENS_BY_PLATFORM[platform], "number");
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
//# sourceMappingURL=build-cost.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-cost.test.js","sourceRoot":"","sources":["../src/build-cost.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,6BAA6B,EAAE,MAAM,YAAY,CAAC;AAElG,IAAI,CAAC,kGAAkG,EAAE,GAAG,EAAE;IAC5G,KAAK,MAAM,QAAQ,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,CAAU,EAAE,CAAC;QAC9E,MAAM,CAAC,KAAK,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAC,CAAC;IAChE,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,+EAA+E,EAAE,GAAG,EAAE;IACzF,MAAM,CAAC,KAAK,CAAC,kBAAkB,CAAC,cAAc,CAAC,EAAE,6BAA6B,CAAC,cAAc,CAAC,CAAC,CAAC;AAClG,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,oGAAoG,EAAE,GAAG,EAAE;IAC9G,MAAM,CAAC,KAAK,CAAC,kBAAkB,CAAC,sBAAsB,CAAC,EAAE,iBAAiB,CAAC,CAAC;AAC9E,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,+DAA+D,EAAE,GAAG,EAAE;IACzE,MAAM,SAAS,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,CAAU,CAAC;IACzE,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,MAAM,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,CAAC;IACzE,CAAC;AACH,CAAC,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./schemas.js";
|
|
3
|
+
export * from "./api-client.js";
|
|
4
|
+
export * from "./agent-readiness.js";
|
|
5
|
+
export * from "./ota-rollout.js";
|
|
6
|
+
export * from "./ota-bundle-hash.js";
|
|
7
|
+
export * from "./ota-artifact.js";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./schemas.js";
|
|
3
|
+
export * from "./api-client.js";
|
|
4
|
+
export * from "./agent-readiness.js";
|
|
5
|
+
export * from "./ota-rollout.js";
|
|
6
|
+
export * from "./ota-bundle-hash.js";
|
|
7
|
+
export * from "./ota-artifact.js";
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picks which of a build's artifacts is "the OTA bundle" — shared by
|
|
3
|
+
* `apps/api/src/routes/ota.ts` (dashboard "promote build to OTA
|
|
4
|
+
* release" action) and `apps/admin-api/src/routes/agents.ts`
|
|
5
|
+
* (`POST /builds/:buildId/complete`'s auto-promote path). Both used to
|
|
6
|
+
* inline this as a single flat `Array.prototype.find` predicate:
|
|
7
|
+
*
|
|
8
|
+
* ```js
|
|
9
|
+
* artifacts.find(
|
|
10
|
+
* (a) => a.fileName.endsWith(".ota.zip") || a.fileName.includes("ota") || a.fileName.endsWith(".bundle"),
|
|
11
|
+
* ) ?? artifacts[0]
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* That was a real bug, not just duplicated logic: `find`'s three OR'd
|
|
15
|
+
* clauses have no priority between them — it returns whichever array
|
|
16
|
+
* element satisfies *any one* of the three first, so a build's very
|
|
17
|
+
* first artifact whose filename merely *contains* the substring "ota"
|
|
18
|
+
* (`quota-report.json`, `rotation-test.log`, `annotation-summary.xml`)
|
|
19
|
+
* would win over a real `app-release.ota.zip` listed later, purely
|
|
20
|
+
* because artifacts are read back in upload order
|
|
21
|
+
* (`packages/db/src/builds.ts`'s `listArtifacts`, `ORDER BY createdAt
|
|
22
|
+
* ASC`), which mirrors the order a workflow's own `artifacts:` glob
|
|
23
|
+
* patterns are listed in `appforge.yaml` — entirely ordinary config,
|
|
24
|
+
* e.g. a test-report glob listed before the release-bundle glob. The
|
|
25
|
+
* consequence: the wrong file gets promoted as the OTA release,
|
|
26
|
+
* `GET /apps/:appId/ota/:channel/manifest` hands it out to every
|
|
27
|
+
* client on that channel, and a device attempting to apply a JSON/log
|
|
28
|
+
* file as an app update breaks the update flow (or crashes the app, if
|
|
29
|
+
* it's loaded as a JS bundle) — with admin-api's auto-publish path
|
|
30
|
+
* (any workflow with `ota: {...}` that succeeds) doing this with no
|
|
31
|
+
* human review at all.
|
|
32
|
+
*
|
|
33
|
+
* Fixed by ranking, not flat-OR'ing: a precise match
|
|
34
|
+
* (`.ota.zip`/`.bundle` suffix) always wins over the loose substring
|
|
35
|
+
* heuristic, which only ever gets a chance if nothing precise matched
|
|
36
|
+
* anywhere in the list — never mid-scan against a single element.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pickOtaArtifact<T extends {
|
|
39
|
+
fileName: string;
|
|
40
|
+
}>(artifacts: T[]): T | undefined;
|
|
41
|
+
//# sourceMappingURL=ota-artifact.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ota-artifact.d.ts","sourceRoot":"","sources":["../src/ota-artifact.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,EAAE,SAAS,EAAE,CAAC,EAAE,GAAG,CAAC,GAAG,SAAS,CAM7F"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picks which of a build's artifacts is "the OTA bundle" — shared by
|
|
3
|
+
* `apps/api/src/routes/ota.ts` (dashboard "promote build to OTA
|
|
4
|
+
* release" action) and `apps/admin-api/src/routes/agents.ts`
|
|
5
|
+
* (`POST /builds/:buildId/complete`'s auto-promote path). Both used to
|
|
6
|
+
* inline this as a single flat `Array.prototype.find` predicate:
|
|
7
|
+
*
|
|
8
|
+
* ```js
|
|
9
|
+
* artifacts.find(
|
|
10
|
+
* (a) => a.fileName.endsWith(".ota.zip") || a.fileName.includes("ota") || a.fileName.endsWith(".bundle"),
|
|
11
|
+
* ) ?? artifacts[0]
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* That was a real bug, not just duplicated logic: `find`'s three OR'd
|
|
15
|
+
* clauses have no priority between them — it returns whichever array
|
|
16
|
+
* element satisfies *any one* of the three first, so a build's very
|
|
17
|
+
* first artifact whose filename merely *contains* the substring "ota"
|
|
18
|
+
* (`quota-report.json`, `rotation-test.log`, `annotation-summary.xml`)
|
|
19
|
+
* would win over a real `app-release.ota.zip` listed later, purely
|
|
20
|
+
* because artifacts are read back in upload order
|
|
21
|
+
* (`packages/db/src/builds.ts`'s `listArtifacts`, `ORDER BY createdAt
|
|
22
|
+
* ASC`), which mirrors the order a workflow's own `artifacts:` glob
|
|
23
|
+
* patterns are listed in `appforge.yaml` — entirely ordinary config,
|
|
24
|
+
* e.g. a test-report glob listed before the release-bundle glob. The
|
|
25
|
+
* consequence: the wrong file gets promoted as the OTA release,
|
|
26
|
+
* `GET /apps/:appId/ota/:channel/manifest` hands it out to every
|
|
27
|
+
* client on that channel, and a device attempting to apply a JSON/log
|
|
28
|
+
* file as an app update breaks the update flow (or crashes the app, if
|
|
29
|
+
* it's loaded as a JS bundle) — with admin-api's auto-publish path
|
|
30
|
+
* (any workflow with `ota: {...}` that succeeds) doing this with no
|
|
31
|
+
* human review at all.
|
|
32
|
+
*
|
|
33
|
+
* Fixed by ranking, not flat-OR'ing: a precise match
|
|
34
|
+
* (`.ota.zip`/`.bundle` suffix) always wins over the loose substring
|
|
35
|
+
* heuristic, which only ever gets a chance if nothing precise matched
|
|
36
|
+
* anywhere in the list — never mid-scan against a single element.
|
|
37
|
+
*/
|
|
38
|
+
export function pickOtaArtifact(artifacts) {
|
|
39
|
+
return (artifacts.find((a) => a.fileName.endsWith(".ota.zip") || a.fileName.endsWith(".bundle")) ??
|
|
40
|
+
artifacts.find((a) => a.fileName.includes("ota")) ??
|
|
41
|
+
artifacts[0]);
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=ota-artifact.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ota-artifact.js","sourceRoot":"","sources":["../src/ota-artifact.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,MAAM,UAAU,eAAe,CAAiC,SAAc;IAC5E,OAAO,CACL,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QACxF,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QACjD,SAAS,CAAC,CAAC,CAAC,CACb,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ota-artifact.test.d.ts","sourceRoot":"","sources":["../src/ota-artifact.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { pickOtaArtifact } from "./ota-artifact.js";
|
|
4
|
+
test("pickOtaArtifact picks a precise .ota.zip suffix match over an earlier, unrelated artifact", () => {
|
|
5
|
+
const artifacts = [
|
|
6
|
+
{ fileName: "app-release.apk" },
|
|
7
|
+
{ fileName: "index.android.ota.zip" },
|
|
8
|
+
];
|
|
9
|
+
assert.equal(pickOtaArtifact(artifacts), artifacts[1]);
|
|
10
|
+
});
|
|
11
|
+
test("pickOtaArtifact picks a precise .bundle suffix match over an earlier, unrelated artifact", () => {
|
|
12
|
+
const artifacts = [{ fileName: "app-release.apk" }, { fileName: "index.android.bundle" }];
|
|
13
|
+
assert.equal(pickOtaArtifact(artifacts), artifacts[1]);
|
|
14
|
+
});
|
|
15
|
+
test("pickOtaArtifact regression: a loose substring match listed FIRST no longer beats a precise match listed later", () => {
|
|
16
|
+
// This is the exact bug: a flat `Array.find` with three OR'd clauses
|
|
17
|
+
// has no priority between them, so it returns whichever element
|
|
18
|
+
// satisfies ANY clause first. "quota-report.json" merely *contains*
|
|
19
|
+
// "ota" — it isn't a real OTA bundle — but under the old code it was
|
|
20
|
+
// returned outright since it's first in the array and .find never
|
|
21
|
+
// looked further once it matched.
|
|
22
|
+
const artifacts = [
|
|
23
|
+
{ fileName: "quota-report.json" },
|
|
24
|
+
{ fileName: "app-release.ota.zip" },
|
|
25
|
+
];
|
|
26
|
+
assert.equal(pickOtaArtifact(artifacts), artifacts[1]);
|
|
27
|
+
// The old, buggy predicate for comparison — proves this is the exact
|
|
28
|
+
// regression being fixed, not a hypothetical.
|
|
29
|
+
const oldPick = (arts) => arts.find((a) => a.fileName.endsWith(".ota.zip") || a.fileName.includes("ota") || a.fileName.endsWith(".bundle")) ??
|
|
30
|
+
arts[0];
|
|
31
|
+
assert.equal(oldPick(artifacts), artifacts[0]);
|
|
32
|
+
});
|
|
33
|
+
test("pickOtaArtifact falls back to the loose substring match only when nothing precise matches anywhere", () => {
|
|
34
|
+
const artifacts = [{ fileName: "app-release.apk" }, { fileName: "rotation-test.log" }];
|
|
35
|
+
assert.equal(pickOtaArtifact(artifacts), artifacts[1]);
|
|
36
|
+
});
|
|
37
|
+
test("pickOtaArtifact falls back to artifacts[0] when nothing matches at all", () => {
|
|
38
|
+
const artifacts = [{ fileName: "app-release.apk" }, { fileName: "build.log" }];
|
|
39
|
+
assert.equal(pickOtaArtifact(artifacts), artifacts[0]);
|
|
40
|
+
});
|
|
41
|
+
test("pickOtaArtifact returns undefined for an empty artifacts array", () => {
|
|
42
|
+
assert.equal(pickOtaArtifact([]), undefined);
|
|
43
|
+
});
|
|
44
|
+
//# sourceMappingURL=ota-artifact.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ota-artifact.test.js","sourceRoot":"","sources":["../src/ota-artifact.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,IAAI,CAAC,2FAA2F,EAAE,GAAG,EAAE;IACrG,MAAM,SAAS,GAAG;QAChB,EAAE,QAAQ,EAAE,iBAAiB,EAAE;QAC/B,EAAE,QAAQ,EAAE,uBAAuB,EAAE;KACtC,CAAC;IACF,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AACzD,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,0FAA0F,EAAE,GAAG,EAAE;IACpG,MAAM,SAAS,GAAG,CAAC,EAAE,QAAQ,EAAE,iBAAiB,EAAE,EAAE,EAAE,QAAQ,EAAE,sBAAsB,EAAE,CAAC,CAAC;IAC1F,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AACzD,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,+GAA+G,EAAE,GAAG,EAAE;IACzH,qEAAqE;IACrE,gEAAgE;IAChE,oEAAoE;IACpE,qEAAqE;IACrE,kEAAkE;IAClE,kCAAkC;IAClC,MAAM,SAAS,GAAG;QAChB,EAAE,QAAQ,EAAE,mBAAmB,EAAE;QACjC,EAAE,QAAQ,EAAE,qBAAqB,EAAE;KACpC,CAAC;IAEF,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;IAEvD,qEAAqE;IACrE,8CAA8C;IAC9C,MAAM,OAAO,GAAG,CAAC,IAAsB,EAAE,EAAE,CACzC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QACjH,IAAI,CAAC,CAAC,CAAC,CAAC;IACV,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AACjD,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,oGAAoG,EAAE,GAAG,EAAE;IAC9G,MAAM,SAAS,GAAG,CAAC,EAAE,QAAQ,EAAE,iBAAiB,EAAE,EAAE,EAAE,QAAQ,EAAE,mBAAmB,EAAE,CAAC,CAAC;IACvF,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AACzD,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,wEAAwE,EAAE,GAAG,EAAE;IAClF,MAAM,SAAS,GAAG,CAAC,EAAE,QAAQ,EAAE,iBAAiB,EAAE,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;IAC/E,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AACzD,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,gEAAgE,EAAE,GAAG,EAAE;IAC1E,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC,CAAC;AAC/C,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives an OTA release's `bundleHash` from the promoted build's R2
|
|
3
|
+
* storage key (`builds/${buildId}/${fileName}`, see
|
|
4
|
+
* `apps/admin-api/src/lib/storage.ts` / `apps/api/src/lib/storage.ts`'s
|
|
5
|
+
* `artifactKey`). Both `apps/api/src/routes/ota.ts` (dashboard "promote
|
|
6
|
+
* build to OTA release" action) and `apps/admin-api/src/routes/
|
|
7
|
+
* agents.ts` (`POST /builds/:buildId/complete`'s auto-promote path,
|
|
8
|
+
* `packages/agent`'s only actual caller since it never sends its own
|
|
9
|
+
* `input.ota.bundleHash` — see `packages/agent/src/lib/builder.ts`)
|
|
10
|
+
* used to inline this as `storageKey.replace(/[^a-zA-Z0-9]/g,
|
|
11
|
+
* "").slice(-32)`.
|
|
12
|
+
*
|
|
13
|
+
* That `.slice(-32)` was a collision bug, not a stylistic choice:
|
|
14
|
+
* `buildId` is always exactly 32 alphanumeric characters (a
|
|
15
|
+
* dash-stripped `crypto.randomUUID()`, see `packages/db/src/id.ts`), so
|
|
16
|
+
* for any artifact `fileName` with 32 or more alphanumeric characters
|
|
17
|
+
* (ordinary for a descriptive OTA bundle name, e.g.
|
|
18
|
+
* "react-native-ota-bundle-production.zip"), keeping only the *last* 32
|
|
19
|
+
* characters of the stripped key drops the build-specific `buildId`
|
|
20
|
+
* entirely and keeps only a fixed suffix of `fileName` — making
|
|
21
|
+
* `bundleHash` an app-wide *constant* across every future release for
|
|
22
|
+
* that app. The manifest endpoint's `currentHash ===
|
|
23
|
+
* activeRelease.bundleHash` check (`ota.ts`'s `GET /apps/:appId/ota/
|
|
24
|
+
* :channel/manifest`) then permanently returns `204 No Content`
|
|
25
|
+
* ("already up to date") for that app, silently breaking OTA delivery
|
|
26
|
+
* the moment a device picks up one release with that hash.
|
|
27
|
+
*
|
|
28
|
+
* The fix: don't truncate at all. `bundleHash`'s DB column
|
|
29
|
+
* (`migrations/0012_ota_updates.sql`) is unconstrained `TEXT`, and
|
|
30
|
+
* every consumer either compares it for equality (`ota.ts`'s manifest
|
|
31
|
+
* check, `packages/agent`) or truncates it further purely for display
|
|
32
|
+
* (`AppDetail.tsx`, `packages/cli/src/commands/ota.ts`'s
|
|
33
|
+
* `formatReleaseLine`) — nothing depends on a fixed 32-character
|
|
34
|
+
* length, so keeping the whole stripped key preserves full
|
|
35
|
+
* build-specific entropy with no downstream breakage.
|
|
36
|
+
*/
|
|
37
|
+
export declare function deriveOtaBundleHash(storageKey: string): string;
|
|
38
|
+
//# sourceMappingURL=ota-bundle-hash.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ota-bundle-hash.d.ts","sourceRoot":"","sources":["../src/ota-bundle-hash.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE9D"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives an OTA release's `bundleHash` from the promoted build's R2
|
|
3
|
+
* storage key (`builds/${buildId}/${fileName}`, see
|
|
4
|
+
* `apps/admin-api/src/lib/storage.ts` / `apps/api/src/lib/storage.ts`'s
|
|
5
|
+
* `artifactKey`). Both `apps/api/src/routes/ota.ts` (dashboard "promote
|
|
6
|
+
* build to OTA release" action) and `apps/admin-api/src/routes/
|
|
7
|
+
* agents.ts` (`POST /builds/:buildId/complete`'s auto-promote path,
|
|
8
|
+
* `packages/agent`'s only actual caller since it never sends its own
|
|
9
|
+
* `input.ota.bundleHash` — see `packages/agent/src/lib/builder.ts`)
|
|
10
|
+
* used to inline this as `storageKey.replace(/[^a-zA-Z0-9]/g,
|
|
11
|
+
* "").slice(-32)`.
|
|
12
|
+
*
|
|
13
|
+
* That `.slice(-32)` was a collision bug, not a stylistic choice:
|
|
14
|
+
* `buildId` is always exactly 32 alphanumeric characters (a
|
|
15
|
+
* dash-stripped `crypto.randomUUID()`, see `packages/db/src/id.ts`), so
|
|
16
|
+
* for any artifact `fileName` with 32 or more alphanumeric characters
|
|
17
|
+
* (ordinary for a descriptive OTA bundle name, e.g.
|
|
18
|
+
* "react-native-ota-bundle-production.zip"), keeping only the *last* 32
|
|
19
|
+
* characters of the stripped key drops the build-specific `buildId`
|
|
20
|
+
* entirely and keeps only a fixed suffix of `fileName` — making
|
|
21
|
+
* `bundleHash` an app-wide *constant* across every future release for
|
|
22
|
+
* that app. The manifest endpoint's `currentHash ===
|
|
23
|
+
* activeRelease.bundleHash` check (`ota.ts`'s `GET /apps/:appId/ota/
|
|
24
|
+
* :channel/manifest`) then permanently returns `204 No Content`
|
|
25
|
+
* ("already up to date") for that app, silently breaking OTA delivery
|
|
26
|
+
* the moment a device picks up one release with that hash.
|
|
27
|
+
*
|
|
28
|
+
* The fix: don't truncate at all. `bundleHash`'s DB column
|
|
29
|
+
* (`migrations/0012_ota_updates.sql`) is unconstrained `TEXT`, and
|
|
30
|
+
* every consumer either compares it for equality (`ota.ts`'s manifest
|
|
31
|
+
* check, `packages/agent`) or truncates it further purely for display
|
|
32
|
+
* (`AppDetail.tsx`, `packages/cli/src/commands/ota.ts`'s
|
|
33
|
+
* `formatReleaseLine`) — nothing depends on a fixed 32-character
|
|
34
|
+
* length, so keeping the whole stripped key preserves full
|
|
35
|
+
* build-specific entropy with no downstream breakage.
|
|
36
|
+
*/
|
|
37
|
+
export function deriveOtaBundleHash(storageKey) {
|
|
38
|
+
return storageKey.replace(/[^a-zA-Z0-9]/g, "");
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=ota-bundle-hash.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ota-bundle-hash.js","sourceRoot":"","sources":["../src/ota-bundle-hash.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,UAAU,mBAAmB,CAAC,UAAkB;IACpD,OAAO,UAAU,CAAC,OAAO,CAAC,eAAe,EAAE,EAAE,CAAC,CAAC;AACjD,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ota-bundle-hash.test.d.ts","sourceRoot":"","sources":["../src/ota-bundle-hash.test.ts"],"names":[],"mappings":""}
|