@capuchoo/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @capuchoo/core
2
+
3
+ The contract shared by every part of [Capuchooo](https://github.com/aybinv7/capuchoo): the CLI, the
4
+ app-side runtime, the update server and the dashboard.
5
+
6
+ **Dependency-free on purpose, and it stays that way.** The CLI imports it in Node,
7
+ `@capuchoo/updater` imports it inside a Capacitor WebView, and the server imports it in its own
8
+ process. Nothing here touches the filesystem, the network, or a framework — a single dependency
9
+ would leak into all of them.
10
+
11
+ ```sh
12
+ npm install @capuchoo/core
13
+ ```
14
+
15
+ ## What it contains
16
+
17
+ **The update contract** — `resolveUpdate()` narrows a server response into what the app should do,
18
+ and `isBlockingResponse()` separates "nothing to do" from a misconfiguration. That distinction
19
+ matters: a channel that does not exist, or a staging build pointed at a production channel, must not
20
+ be reported to the user as "you are up to date".
21
+
22
+ ```ts
23
+ import { resolveUpdate, isBlockingResponse } from "@capuchoo/core";
24
+
25
+ const resolved = resolveUpdate(response); // native outranks OTA - the server may return both
26
+ if (isBlockingResponse(response)) {
27
+ // Channel not found / environment mismatch: surface it, do not swallow it.
28
+ }
29
+ ```
30
+
31
+ **Project configuration** — `normaliseProjectConfig()`, `validateProjectConfig()`,
32
+ `defaultFlavour()` and the `ENVIRONMENTS` list, so the CLI and the server agree on what a flavour
33
+ is.
34
+
35
+ **Environment isolation** — `environmentFromAppId()` and `isEnvironmentAllowed()` implement one rule
36
+ in one place: a staging build may only see staging channels, while a production build may also read
37
+ a staging channel (deliberately, for beta testing). The server enforces the same rule with the same
38
+ function.
39
+
40
+ **Versioning** — `bumpVersion()`, `compareVersions()`, `parseVersion()`, `nextVersionCode()`. Used
41
+ by the CLI instead of `npm version`, which resolves the nearest `package.json` from the process
42
+ directory and would bump the wrong one inside a workspace.
43
+
44
+ ## Stability
45
+
46
+ Pre-1.0: the surface may change between minor versions. It is published because `@capuchoo/updater`
47
+ and `@capuchoo/cli` depend on it, not as a general-purpose library.
@@ -0,0 +1,371 @@
1
+ //#region src/update-contract.d.ts
2
+ /**
3
+ * The wire contract for `POST {endpoint}/api/update`.
4
+ *
5
+ * This file is the single definition shared by the backend that produces the
6
+ * response, the app runtime that consumes it, and the CLI that publishes the
7
+ * artefacts it points at. Before this package existed the three had drifted:
8
+ * the app template asked a second endpoint (`GET /api/native-updates/check`)
9
+ * with an `{ available, update }` envelope the backend never returns on the
10
+ * primary path, and sent a hard-coded `version_name: "builtin"` so the server
11
+ * always compared against 0.0.0.
12
+ */
13
+ type Platform = "android" | "ios" | "web";
14
+ /** Deployment environments. A channel is bound to exactly one of these. */
15
+ type Environment = "dev" | "staging" | "prod";
16
+ /**
17
+ * Messages the backend puts in `message`. Both sides must agree on the exact
18
+ * strings, so they live here rather than being retyped at each call site.
19
+ */
20
+ declare const UpdateMessage: {
21
+ /** A newer native binary is assigned to the channel and must be installed. */
22
+ readonly NATIVE_UPDATE_REQUIRED: "native_update_required";
23
+ /** A newer artefact is available. */
24
+ readonly UPDATE_AVAILABLE: "update_available";
25
+ /** The device already runs the newest artefact for its channel. */
26
+ readonly NO_UPDATE: "No update available";
27
+ /** The channel name does not exist for this application. */
28
+ readonly CHANNEL_NOT_FOUND: "Channel not found";
29
+ /**
30
+ * The requesting app id does not belong to the channel's environment - a
31
+ * staging build asking a production channel, for example.
32
+ */
33
+ readonly ENVIRONMENT_MISMATCH: "Environment mismatch";
34
+ };
35
+ type UpdateMessageValue = (typeof UpdateMessage)[keyof typeof UpdateMessage];
36
+ /** What the device tells the server about itself. */
37
+ interface UpdateCheckRequest {
38
+ /** Bundle identifier of the running build, e.g. `com.ayb.lowmaro.staging`. */
39
+ appId: string;
40
+ platform: Platform;
41
+ /** Channel to consult. Falls back to `defaultChannel` server-side. */
42
+ channel?: string;
43
+ defaultChannel?: string;
44
+ /**
45
+ * Native build number as a string. The server compares this against
46
+ * `native_updates.version_code` and against an OTA bundle's
47
+ * `min_update_version`, so an omitted or wrong value silently disables
48
+ * native-update gating.
49
+ */
50
+ versionCode?: string;
51
+ /** Historical alias for `versionCode`; the server accepts either. */
52
+ versionBuild?: string;
53
+ /**
54
+ * Semantic version of the *currently applied web bundle*, or `"builtin"`
55
+ * when the app still runs the bundle shipped inside the binary. Sending a
56
+ * constant here defeats version comparison entirely.
57
+ */
58
+ version_name?: string;
59
+ /** Stable per-install identifier, used for channel overrides and stats. */
60
+ deviceId?: string;
61
+ isProd?: boolean;
62
+ }
63
+ /** A native binary (APK/IPA) the device should install. */
64
+ interface NativeUpdatePayload {
65
+ version_name: string;
66
+ version_code: number;
67
+ download_url: string;
68
+ release_notes?: string;
69
+ required?: boolean;
70
+ platform?: Platform;
71
+ file_size?: number;
72
+ }
73
+ /**
74
+ * The response. Every field is optional because the server returns a partial
75
+ * object per outcome rather than a discriminated union - `resolveUpdate` below
76
+ * narrows it into something a caller can branch on safely.
77
+ */
78
+ interface UpdateCheckResponse {
79
+ message?: string;
80
+ error?: string;
81
+ /** OTA bundle fields. */
82
+ version_name?: string;
83
+ url?: string;
84
+ checksum?: string;
85
+ sessionKey?: string;
86
+ release_notes?: string;
87
+ required?: boolean;
88
+ /** Present when a native binary supersedes, or blocks, the OTA bundle. */
89
+ native_update?: NativeUpdatePayload | null;
90
+ /** Remote configuration resolved for the channel's environment. */
91
+ config?: Record<string, string>;
92
+ }
93
+ type UpdateKind = "native" | "ota";
94
+ /** A resolved, actionable update. */
95
+ interface ResolvedUpdate {
96
+ kind: UpdateKind;
97
+ version: string;
98
+ versionCode?: number;
99
+ downloadUrl?: string;
100
+ releaseNotes?: string;
101
+ required: boolean;
102
+ platform?: Platform;
103
+ checksum?: string;
104
+ sessionKey?: string;
105
+ /** Set once the OTA plugin has downloaded the bundle. */
106
+ bundleId?: string;
107
+ }
108
+ /**
109
+ * Narrows a raw response into an update to act on, or `null` when there is
110
+ * nothing to do.
111
+ *
112
+ * Native wins over OTA. The server can return both - a required native binary
113
+ * alongside the OTA bundle that needs it - and installing the bundle first
114
+ * would leave the device on a binary too old to run it.
115
+ */
116
+ declare function resolveUpdate(response: UpdateCheckResponse | null | undefined): ResolvedUpdate | null;
117
+ /**
118
+ * True when the response reports a condition the user cannot fix by updating.
119
+ * Callers should surface these instead of showing "you are up to date".
120
+ */
121
+ declare function isBlockingResponse(response: UpdateCheckResponse): boolean;
122
+ /** Analytics events posted to `POST {endpoint}/api/native-updates/log`. */
123
+ type UpdateEvent = "check" | "download" | "download_complete" | "install" | "cancel" | "error";
124
+ interface UpdateEventPayload {
125
+ event: UpdateEvent;
126
+ platform: Platform;
127
+ device_id: string;
128
+ current_version_code: number;
129
+ new_version?: string;
130
+ new_version_code?: number;
131
+ channel: string;
132
+ environment: string;
133
+ error?: string;
134
+ }
135
+ //#endregion
136
+ //#region src/project-config.d.ts
137
+ /**
138
+ * `.capucho/project.json` - the file that makes an application deployable.
139
+ *
140
+ * Version 1 held only the cloud identifiers, so the CLI had to *guess* how to
141
+ * build: it shelled out to `pnpm run assets:<env>`, `pnpm build:<env>`,
142
+ * `pnpm trapeze:<env>` and `pnpm exec cap sync`. Any application that named
143
+ * its scripts differently, used npm, or had not installed Trapeze simply
144
+ * failed halfway through a deploy.
145
+ *
146
+ * Version 2 describes the *inputs* instead - where each flavour's env file,
147
+ * Trapeze config and icon sources live - and lets the CLI own the execution.
148
+ * Every field has a default, so a v1 file keeps working: `normaliseProjectConfig`
149
+ * fills in the conventional layout both existing apps already use.
150
+ */
151
+ declare const PROJECT_CONFIG_VERSION = 2;
152
+ /** One build flavour, keyed by the environment its channels are bound to. */
153
+ interface FlavourConfig {
154
+ /**
155
+ * Env file supplying `VITE_APP_ID`, `VITE_APP_NAME`, `VITE_UPDATE_CHANNEL`
156
+ * and friends. The CLI reads it and passes the values to the build and to
157
+ * the native configuration step as environment variables - it does not
158
+ * rewrite the file, so a deploy leaves the working tree clean.
159
+ */
160
+ envFile: string;
161
+ /** Trapeze config applied to the native projects. Optional. */
162
+ trapezeConfig?: string;
163
+ /** Directory holding `icon.png` / `splash.png` for icon generation. */
164
+ assetPath?: string;
165
+ /** Vite `--mode`. Defaults to the flavour name. */
166
+ mode?: string;
167
+ }
168
+ interface BuildConfig {
169
+ /**
170
+ * Overrides the web build. Leave unset and the CLI runs the project's own
171
+ * Vite build through the workspace toolchain it detects.
172
+ */
173
+ command?: string;
174
+ /** Where to run the build from, relative to the app. For monorepo roots. */
175
+ cwd?: string;
176
+ }
177
+ interface ProjectConfig {
178
+ /** Absent on v1 files. */
179
+ version?: number;
180
+ /** Bundle identifier of the production flavour. */
181
+ appId: string;
182
+ /** Primary key of the application in Capucho. */
183
+ cloudAppId: string;
184
+ appName: string;
185
+ createdAt: string;
186
+ /** Vite output directory, and what Capacitor copies into the native app. */
187
+ webDir?: string;
188
+ androidDir?: string;
189
+ iosDir?: string;
190
+ /**
191
+ * Monotonic native build numbers per environment. Written by the CLI, and
192
+ * the one file a deploy is expected to modify.
193
+ */
194
+ versionCodeFile?: string;
195
+ flavours?: Partial<Record<Environment, FlavourConfig>>;
196
+ build?: BuildConfig;
197
+ /** Optional GitHub Pages mirror for generated web assets. */
198
+ ghPagesRepo?: string;
199
+ /** @deprecated v1 fields, folded into `build` by `normaliseProjectConfig`. */
200
+ monorepoRoot?: string;
201
+ /** @deprecated v1 field. */
202
+ packageName?: string;
203
+ }
204
+ /** A `ProjectConfig` with every optional resolved. */
205
+ interface ResolvedProjectConfig {
206
+ version: number;
207
+ appId: string;
208
+ cloudAppId: string;
209
+ appName: string;
210
+ createdAt: string;
211
+ webDir: string;
212
+ androidDir: string;
213
+ iosDir: string;
214
+ versionCodeFile: string;
215
+ flavours: Record<Environment, FlavourConfig>;
216
+ build: BuildConfig;
217
+ ghPagesRepo?: string;
218
+ }
219
+ declare const ENVIRONMENTS: readonly Environment[];
220
+ /**
221
+ * The layout both existing applications already use. Used as the default so a
222
+ * v1 `project.json` needs no migration to keep deploying.
223
+ */
224
+ declare function defaultFlavour(environment: Environment): FlavourConfig;
225
+ declare function normaliseProjectConfig(config: ProjectConfig): ResolvedProjectConfig;
226
+ /** Fields a `project.json` must carry for a deploy to be possible. */
227
+ declare function validateProjectConfig(config: Partial<ProjectConfig> | null | undefined): string[];
228
+ declare function isValidBundleId(value: string): boolean;
229
+ /**
230
+ * Derives the environment a bundle identifier belongs to.
231
+ *
232
+ * The backend enforces the same rule server-side: a `.staging` build may only
233
+ * be served staging channels. Mirroring it here lets the CLI refuse a
234
+ * mismatched deploy before it uploads several megabytes.
235
+ */
236
+ declare function environmentFromAppId(appId: string): Environment;
237
+ /**
238
+ * Whether a build may be served a channel bound to `channelEnvironment`.
239
+ *
240
+ * This mirrors the server's isolation check exactly, including its one
241
+ * deliberate exception: a production build is allowed on a staging channel, so
242
+ * a release candidate can be beta-tested by real installs without shipping a
243
+ * separate bundle identifier.
244
+ *
245
+ * The rule lives here rather than being restated at each call site because the
246
+ * CLI had reimplemented it as a plain equality check - which is *stricter* than
247
+ * the server and rejected the exact beta-testing setup Lowmaro uses, where all
248
+ * three channels are bound to staging and the app id carries no suffix.
249
+ */
250
+ declare function isEnvironmentAllowed(appId: string, channelEnvironment: Environment): boolean;
251
+ /** Explains a rejected pairing, or null when it is allowed. */
252
+ declare function describeEnvironmentMismatch(appId: string, channelEnvironment: Environment, channelName: string): string | null;
253
+ //#endregion
254
+ //#region src/version.d.ts
255
+ /**
256
+ * Version arithmetic, kept free of any filesystem or child-process access so
257
+ * both the CLI and the tests can use it directly.
258
+ *
259
+ * The CLI used to shell out to `npm version <type> --no-git-tag-version` for
260
+ * this. In a workspace that is actively wrong: npm resolves the *nearest*
261
+ * package.json, so running it from a monorepo root bumped the root package
262
+ * instead of the app, and it mixed npm into a pnpm/Vite+ project for a job
263
+ * that is three lines of string handling.
264
+ */
265
+ type BumpType = "major" | "minor" | "patch";
266
+ interface SemanticVersion {
267
+ major: number;
268
+ minor: number;
269
+ patch: number;
270
+ prerelease?: string;
271
+ build?: string;
272
+ }
273
+ declare function parseVersion(value: string): SemanticVersion | null;
274
+ declare function formatVersion(version: SemanticVersion): string;
275
+ /**
276
+ * Bumps a version string. Prerelease and build metadata are dropped, matching
277
+ * `npm version` semantics for a plain major/minor/patch bump.
278
+ */
279
+ declare function bumpVersion(value: string, type: BumpType): string;
280
+ /**
281
+ * Compares two semantic versions. Returns a negative number when `a` is older.
282
+ *
283
+ * A missing or unparseable version sorts oldest, which is what the app needs:
284
+ * the sentinel `"builtin"` must always look older than any published bundle.
285
+ */
286
+ declare function compareVersions(a: string, b: string): number;
287
+ type VersionCodes = Record<Environment, number>;
288
+ declare const INITIAL_VERSION_CODES: VersionCodes;
289
+ /**
290
+ * Native build numbers must increase monotonically per environment: Android
291
+ * refuses to install an APK whose versionCode is not greater than the
292
+ * installed one, and the backend uses the same number to decide whether a
293
+ * native update supersedes an OTA bundle.
294
+ */
295
+ declare function nextVersionCode(codes: Partial<VersionCodes> | null | undefined, environment: Environment): VersionCodes;
296
+ /**
297
+ * Build-time variables injected into the web build and the native
298
+ * configuration step.
299
+ *
300
+ * These used to be written *into* the committed `build/<env>/.env.<env>` file
301
+ * by every deploy, which dirtied the working tree and made two concurrent
302
+ * deploys race over one file. They are environment variables now: Trapeze
303
+ * reads its `vars:` block from the process environment, and Vite reads
304
+ * `VITE_*` the same way.
305
+ */
306
+ declare function versionEnv(version: string, versionCode: number): {
307
+ VITE_APP_VERSION: string;
308
+ VERSION_CODE: string;
309
+ BUILD_NUMBER: string;
310
+ };
311
+ //#endregion
312
+ //#region src/cloud.d.ts
313
+ /** Shapes returned by the authenticated `/api/*` endpoints. */
314
+ interface CloudOrganization {
315
+ id: string;
316
+ name: string;
317
+ slug: string;
318
+ role: "owner" | "admin" | "member";
319
+ }
320
+ interface CloudApp {
321
+ id: string;
322
+ name: string;
323
+ app_id: string;
324
+ platform: string;
325
+ organization_id: string;
326
+ created_at: string;
327
+ icon_url?: string;
328
+ }
329
+ interface CloudChannel {
330
+ id: string;
331
+ name: string;
332
+ app_id: string;
333
+ /**
334
+ * Which build flavour this channel serves. The CLI derives the whole build
335
+ * from it, so a channel without an environment cannot be deployed to.
336
+ */
337
+ environment: Environment;
338
+ public: boolean;
339
+ created_at: string;
340
+ current_version_id?: string | null;
341
+ current_native_version_id?: string | null;
342
+ }
343
+ interface CloudRelease {
344
+ id: string;
345
+ version_name: string;
346
+ platform: Platform;
347
+ channel?: string;
348
+ active: boolean;
349
+ required: boolean;
350
+ release_notes?: string;
351
+ created_at: string;
352
+ }
353
+ interface CloudUser {
354
+ id: string;
355
+ email: string;
356
+ role?: string;
357
+ }
358
+ /** Response of `GET /api/auth/me`. */
359
+ interface UserProfile {
360
+ user: CloudUser;
361
+ organizations: CloudOrganization[];
362
+ apps: Array<CloudApp & {
363
+ role: string;
364
+ }>;
365
+ }
366
+ /** Roles allowed to create an application inside an organization. */
367
+ declare const APP_CREATOR_ROLES: ReadonlySet<string>;
368
+ declare function canCreateApps(organization: CloudOrganization): boolean;
369
+ //#endregion
370
+ export { APP_CREATOR_ROLES, type BuildConfig, type BumpType, type CloudApp, type CloudChannel, type CloudOrganization, type CloudRelease, type CloudUser, ENVIRONMENTS, type Environment, type FlavourConfig, INITIAL_VERSION_CODES, type NativeUpdatePayload, PROJECT_CONFIG_VERSION, type Platform, type ProjectConfig, type ResolvedProjectConfig, type ResolvedUpdate, type SemanticVersion, type UpdateCheckRequest, type UpdateCheckResponse, type UpdateEvent, type UpdateEventPayload, type UpdateKind, UpdateMessage, type UpdateMessageValue, type UserProfile, type VersionCodes, bumpVersion, canCreateApps, compareVersions, defaultFlavour, describeEnvironmentMismatch, environmentFromAppId, formatVersion, isBlockingResponse, isEnvironmentAllowed, isValidBundleId, nextVersionCode, normaliseProjectConfig, parseVersion, resolveUpdate, validateProjectConfig, versionEnv };
371
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/update-contract.ts","../src/project-config.ts","../src/version.ts","../src/cloud.ts"],"mappings":";;;;;;;;;;;;KAYY;;KAGA;;;;;cAMC;;;;;;;;;;;;;;;KAgBD,6BAA6B,4BAA4B;;UAGpD;;EAEf;EACA,UAAU;;EAEV;EACA;;;;;;;EAOA;;EAEA;;;;;;EAMA;;EAEA;EACA;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA,WAAW;EACX;;;;;;;UAQe;EACf;EACA;;EAGA;EACA;EACA;EACA;EACA;EACA;;EAGA,gBAAgB;;EAGhB,SAAS;;KAGC;;UAGK;EACf,MAAM;EACN;EACA;EACA;EACA;EACA;EACA,WAAW;EACX;EACA;;EAEA;;;;;;;;;;iBAWc,cACd,UAAU,yCACT;;;;;iBAsCa,mBAAmB,UAAU;;KAQjC;UAQK;EACf,OAAO;EACP,UAAU;EACV;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;;;;;;;;;;;;cChLW;;UAGI;;;;;;;EAOf;;EAEA;;EAEA;;EAEA;;UAGe;;;;;EAKf;;EAEA;;UAGe;;EAEf;;EAGA;;EAEA;EACA;EACA;;EAGA;EACA;EACA;;;;;EAMA;EAEA,WAAW,QAAQ,OAAO,aAAa;EACvC,QAAQ;;EAGR;;EAGA;;EAEA;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,UAAU,OAAO,aAAa;EAC9B,OAAO;EACP;;cAGW,uBAAuB;;;;;iBAMpB,eAAe,aAAa,cAAc;iBAS1C,uBAAuB,QAAQ,gBAAgB;;iBAuC/C,sBAAsB,QAAQ,QAAQ;iBAiBtC,gBAAgB;;;;;;;;iBAWhB,qBAAqB,gBAAgB;;;;;;;;;;;;;;iBAoBrC,qBAAqB,eAAe,oBAAoB;;iBAOxD,4BACd,eACA,oBAAoB,aACpB;;;;;;;;;;;;;KClMU;UAEK;EACf;EACA;EACA;EACA;EACA;;iBAMc,aAAa,gBAAgB;iBAa7B,cAAc,SAAS;;;;;iBAWvB,YAAY,eAAe,MAAM;;;;;;;iBA8BjC,gBAAgB,WAAW;KAsB/B,eAAe,OAAO;cAErB,uBAAuB;;;;;;;iBAYpB,gBACd,OAAO,QAAQ,kCACf,aAAa,cACZ;;;;;;;;;;;iBAea,WAAW,iBAAiB;;;;;;;;UClI3B;EACf;EACA;EACA;EACA;;UAGe;EACf;EACA;EACA;EACA;EACA;EACA;EACA;;UAGe;EACf;EACA;EACA;;;;;EAKA,aAAa;EACb;EACA;EACA;EACA;;UAGe;EACf;EACA;EACA,UAAU;EACV;EACA;EACA;EACA;EACA;;UAGe;EACf;EACA;EACA;;;UAIe;EACf,MAAM;EACN,eAAe;EACf,MAAM,MAAM;IAAa;;;;cAId,mBAAmB;iBAEhB,cAAc,cAAc"}
package/dist/index.js ADDED
@@ -0,0 +1,286 @@
1
+ //#region src/update-contract.ts
2
+ /**
3
+ * Messages the backend puts in `message`. Both sides must agree on the exact
4
+ * strings, so they live here rather than being retyped at each call site.
5
+ */
6
+ const UpdateMessage = {
7
+ /** A newer native binary is assigned to the channel and must be installed. */
8
+ NATIVE_UPDATE_REQUIRED: "native_update_required",
9
+ /** A newer artefact is available. */
10
+ UPDATE_AVAILABLE: "update_available",
11
+ /** The device already runs the newest artefact for its channel. */
12
+ NO_UPDATE: "No update available",
13
+ /** The channel name does not exist for this application. */
14
+ CHANNEL_NOT_FOUND: "Channel not found",
15
+ /**
16
+ * The requesting app id does not belong to the channel's environment - a
17
+ * staging build asking a production channel, for example.
18
+ */
19
+ ENVIRONMENT_MISMATCH: "Environment mismatch"
20
+ };
21
+ /**
22
+ * Narrows a raw response into an update to act on, or `null` when there is
23
+ * nothing to do.
24
+ *
25
+ * Native wins over OTA. The server can return both - a required native binary
26
+ * alongside the OTA bundle that needs it - and installing the bundle first
27
+ * would leave the device on a binary too old to run it.
28
+ */
29
+ function resolveUpdate(response) {
30
+ if (!response) return null;
31
+ const native = response.native_update;
32
+ if (native?.download_url) return {
33
+ kind: "native",
34
+ version: native.version_name,
35
+ versionCode: native.version_code,
36
+ downloadUrl: native.download_url,
37
+ releaseNotes: native.release_notes,
38
+ required: response.message === UpdateMessage.NATIVE_UPDATE_REQUIRED || (native.required ?? false),
39
+ platform: native.platform
40
+ };
41
+ if (response.url && response.version_name) return {
42
+ kind: "ota",
43
+ version: response.version_name,
44
+ downloadUrl: response.url,
45
+ releaseNotes: response.release_notes,
46
+ required: response.required ?? false,
47
+ checksum: response.checksum,
48
+ sessionKey: response.sessionKey
49
+ };
50
+ return null;
51
+ }
52
+ /**
53
+ * True when the response reports a condition the user cannot fix by updating.
54
+ * Callers should surface these instead of showing "you are up to date".
55
+ */
56
+ function isBlockingResponse(response) {
57
+ return response.message === UpdateMessage.CHANNEL_NOT_FOUND || response.message === UpdateMessage.ENVIRONMENT_MISMATCH;
58
+ }
59
+ //#endregion
60
+ //#region src/project-config.ts
61
+ /**
62
+ * `.capucho/project.json` - the file that makes an application deployable.
63
+ *
64
+ * Version 1 held only the cloud identifiers, so the CLI had to *guess* how to
65
+ * build: it shelled out to `pnpm run assets:<env>`, `pnpm build:<env>`,
66
+ * `pnpm trapeze:<env>` and `pnpm exec cap sync`. Any application that named
67
+ * its scripts differently, used npm, or had not installed Trapeze simply
68
+ * failed halfway through a deploy.
69
+ *
70
+ * Version 2 describes the *inputs* instead - where each flavour's env file,
71
+ * Trapeze config and icon sources live - and lets the CLI own the execution.
72
+ * Every field has a default, so a v1 file keeps working: `normaliseProjectConfig`
73
+ * fills in the conventional layout both existing apps already use.
74
+ */
75
+ const PROJECT_CONFIG_VERSION = 2;
76
+ const ENVIRONMENTS = [
77
+ "dev",
78
+ "staging",
79
+ "prod"
80
+ ];
81
+ /**
82
+ * The layout both existing applications already use. Used as the default so a
83
+ * v1 `project.json` needs no migration to keep deploying.
84
+ */
85
+ function defaultFlavour(environment) {
86
+ return {
87
+ envFile: `build/${environment}/.env.${environment}`,
88
+ trapezeConfig: `build/${environment}/trapeze.${environment}.yaml`,
89
+ assetPath: `build/${environment}/assets`,
90
+ mode: environment
91
+ };
92
+ }
93
+ function normaliseProjectConfig(config) {
94
+ const flavours = {};
95
+ for (const environment of ENVIRONMENTS) {
96
+ const defaults = defaultFlavour(environment);
97
+ const declared = config.flavours?.[environment];
98
+ flavours[environment] = {
99
+ envFile: declared?.envFile ?? defaults.envFile,
100
+ trapezeConfig: declared?.trapezeConfig ?? defaults.trapezeConfig,
101
+ assetPath: declared?.assetPath ?? defaults.assetPath,
102
+ mode: declared?.mode ?? defaults.mode
103
+ };
104
+ }
105
+ const build = { ...config.build };
106
+ if (!build.cwd && config.monorepoRoot) build.cwd = config.monorepoRoot;
107
+ if (!build.command && config.packageName) build.command = `vp run ${config.packageName}#build`;
108
+ return {
109
+ version: config.version ?? 1,
110
+ appId: config.appId,
111
+ cloudAppId: config.cloudAppId,
112
+ appName: config.appName,
113
+ createdAt: config.createdAt,
114
+ webDir: config.webDir ?? "dist",
115
+ androidDir: config.androidDir ?? "android",
116
+ iosDir: config.iosDir ?? "ios",
117
+ versionCodeFile: config.versionCodeFile ?? "version-code.json",
118
+ flavours,
119
+ build,
120
+ ghPagesRepo: config.ghPagesRepo
121
+ };
122
+ }
123
+ /** Fields a `project.json` must carry for a deploy to be possible. */
124
+ function validateProjectConfig(config) {
125
+ if (!config) return ["project.json is missing or empty"];
126
+ const problems = [];
127
+ if (!config.appId) problems.push("appId is required");
128
+ if (!config.cloudAppId) problems.push("cloudAppId is required");
129
+ if (!config.appName) problems.push("appName is required");
130
+ if (config.appId && !isValidBundleId(config.appId)) problems.push(`appId "${config.appId}" is not a valid bundle identifier`);
131
+ return problems;
132
+ }
133
+ const BUNDLE_ID = /^[a-z][a-z\d_]*(\.[a-z][a-z\d_]*)+$/;
134
+ function isValidBundleId(value) {
135
+ return BUNDLE_ID.test(value);
136
+ }
137
+ /**
138
+ * Derives the environment a bundle identifier belongs to.
139
+ *
140
+ * The backend enforces the same rule server-side: a `.staging` build may only
141
+ * be served staging channels. Mirroring it here lets the CLI refuse a
142
+ * mismatched deploy before it uploads several megabytes.
143
+ */
144
+ function environmentFromAppId(appId) {
145
+ const id = appId.toLowerCase();
146
+ if (id.endsWith(".staging")) return "staging";
147
+ if (id.endsWith(".dev") || id.endsWith(".debug")) return "dev";
148
+ return "prod";
149
+ }
150
+ /**
151
+ * Whether a build may be served a channel bound to `channelEnvironment`.
152
+ *
153
+ * This mirrors the server's isolation check exactly, including its one
154
+ * deliberate exception: a production build is allowed on a staging channel, so
155
+ * a release candidate can be beta-tested by real installs without shipping a
156
+ * separate bundle identifier.
157
+ *
158
+ * The rule lives here rather than being restated at each call site because the
159
+ * CLI had reimplemented it as a plain equality check - which is *stricter* than
160
+ * the server and rejected the exact beta-testing setup Lowmaro uses, where all
161
+ * three channels are bound to staging and the app id carries no suffix.
162
+ */
163
+ function isEnvironmentAllowed(appId, channelEnvironment) {
164
+ const expected = environmentFromAppId(appId);
165
+ if (expected === channelEnvironment) return true;
166
+ return expected === "prod" && channelEnvironment === "staging";
167
+ }
168
+ /** Explains a rejected pairing, or null when it is allowed. */
169
+ function describeEnvironmentMismatch(appId, channelEnvironment, channelName) {
170
+ if (isEnvironmentAllowed(appId, channelEnvironment)) return null;
171
+ return `Channel "${channelName}" serves the ${channelEnvironment} environment, but the build's VITE_APP_ID is "${appId}", which is a ${environmentFromAppId(appId)} bundle id. The server rejects this pairing, so the upload would be wasted.`;
172
+ }
173
+ //#endregion
174
+ //#region src/version.ts
175
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?(?:\+([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?$/;
176
+ function parseVersion(value) {
177
+ const match = SEMVER.exec(value.trim());
178
+ if (!match) return null;
179
+ return {
180
+ major: Number(match[1]),
181
+ minor: Number(match[2]),
182
+ patch: Number(match[3]),
183
+ prerelease: match[4],
184
+ build: match[5]
185
+ };
186
+ }
187
+ function formatVersion(version) {
188
+ let out = `${version.major}.${version.minor}.${version.patch}`;
189
+ if (version.prerelease) out += `-${version.prerelease}`;
190
+ if (version.build) out += `+${version.build}`;
191
+ return out;
192
+ }
193
+ /**
194
+ * Bumps a version string. Prerelease and build metadata are dropped, matching
195
+ * `npm version` semantics for a plain major/minor/patch bump.
196
+ */
197
+ function bumpVersion(value, type) {
198
+ const parsed = parseVersion(value);
199
+ if (!parsed) throw new Error(`"${value}" is not a semantic version, so it cannot be bumped`);
200
+ switch (type) {
201
+ case "major": return formatVersion({
202
+ major: parsed.major + 1,
203
+ minor: 0,
204
+ patch: 0
205
+ });
206
+ case "minor": return formatVersion({
207
+ major: parsed.major,
208
+ minor: parsed.minor + 1,
209
+ patch: 0
210
+ });
211
+ case "patch": return formatVersion({
212
+ major: parsed.major,
213
+ minor: parsed.minor,
214
+ patch: parsed.patch + 1
215
+ });
216
+ }
217
+ }
218
+ /**
219
+ * Compares two semantic versions. Returns a negative number when `a` is older.
220
+ *
221
+ * A missing or unparseable version sorts oldest, which is what the app needs:
222
+ * the sentinel `"builtin"` must always look older than any published bundle.
223
+ */
224
+ function compareVersions(a, b) {
225
+ const left = parseVersion(a);
226
+ const right = parseVersion(b);
227
+ if (!left && !right) return 0;
228
+ if (!left) return -1;
229
+ if (!right) return 1;
230
+ if (left.major !== right.major) return left.major - right.major;
231
+ if (left.minor !== right.minor) return left.minor - right.minor;
232
+ if (left.patch !== right.patch) return left.patch - right.patch;
233
+ if (left.prerelease && !right.prerelease) return -1;
234
+ if (!left.prerelease && right.prerelease) return 1;
235
+ if (left.prerelease && right.prerelease) return left.prerelease < right.prerelease ? -1 : left.prerelease > right.prerelease ? 1 : 0;
236
+ return 0;
237
+ }
238
+ const INITIAL_VERSION_CODES = {
239
+ dev: 1,
240
+ staging: 1,
241
+ prod: 1
242
+ };
243
+ /**
244
+ * Native build numbers must increase monotonically per environment: Android
245
+ * refuses to install an APK whose versionCode is not greater than the
246
+ * installed one, and the backend uses the same number to decide whether a
247
+ * native update supersedes an OTA bundle.
248
+ */
249
+ function nextVersionCode(codes, environment) {
250
+ const current = {
251
+ ...INITIAL_VERSION_CODES,
252
+ ...codes
253
+ };
254
+ return {
255
+ ...current,
256
+ [environment]: (current[environment] ?? 0) + 1
257
+ };
258
+ }
259
+ /**
260
+ * Build-time variables injected into the web build and the native
261
+ * configuration step.
262
+ *
263
+ * These used to be written *into* the committed `build/<env>/.env.<env>` file
264
+ * by every deploy, which dirtied the working tree and made two concurrent
265
+ * deploys race over one file. They are environment variables now: Trapeze
266
+ * reads its `vars:` block from the process environment, and Vite reads
267
+ * `VITE_*` the same way.
268
+ */
269
+ function versionEnv(version, versionCode) {
270
+ return {
271
+ VITE_APP_VERSION: version,
272
+ VERSION_CODE: String(versionCode),
273
+ BUILD_NUMBER: String(versionCode)
274
+ };
275
+ }
276
+ //#endregion
277
+ //#region src/cloud.ts
278
+ /** Roles allowed to create an application inside an organization. */
279
+ const APP_CREATOR_ROLES = /* @__PURE__ */ new Set(["owner", "admin"]);
280
+ function canCreateApps(organization) {
281
+ return APP_CREATOR_ROLES.has(organization.role);
282
+ }
283
+ //#endregion
284
+ export { APP_CREATOR_ROLES, ENVIRONMENTS, INITIAL_VERSION_CODES, PROJECT_CONFIG_VERSION, UpdateMessage, bumpVersion, canCreateApps, compareVersions, defaultFlavour, describeEnvironmentMismatch, environmentFromAppId, formatVersion, isBlockingResponse, isEnvironmentAllowed, isValidBundleId, nextVersionCode, normaliseProjectConfig, parseVersion, resolveUpdate, validateProjectConfig, versionEnv };
285
+
286
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/update-contract.ts","../src/project-config.ts","../src/version.ts","../src/cloud.ts"],"sourcesContent":["/**\n * The wire contract for `POST {endpoint}/api/update`.\n *\n * This file is the single definition shared by the backend that produces the\n * response, the app runtime that consumes it, and the CLI that publishes the\n * artefacts it points at. Before this package existed the three had drifted:\n * the app template asked a second endpoint (`GET /api/native-updates/check`)\n * with an `{ available, update }` envelope the backend never returns on the\n * primary path, and sent a hard-coded `version_name: \"builtin\"` so the server\n * always compared against 0.0.0.\n */\n\nexport type Platform = \"android\" | \"ios\" | \"web\";\n\n/** Deployment environments. A channel is bound to exactly one of these. */\nexport type Environment = \"dev\" | \"staging\" | \"prod\";\n\n/**\n * Messages the backend puts in `message`. Both sides must agree on the exact\n * strings, so they live here rather than being retyped at each call site.\n */\nexport const UpdateMessage = {\n /** A newer native binary is assigned to the channel and must be installed. */\n NATIVE_UPDATE_REQUIRED: \"native_update_required\",\n /** A newer artefact is available. */\n UPDATE_AVAILABLE: \"update_available\",\n /** The device already runs the newest artefact for its channel. */\n NO_UPDATE: \"No update available\",\n /** The channel name does not exist for this application. */\n CHANNEL_NOT_FOUND: \"Channel not found\",\n /**\n * The requesting app id does not belong to the channel's environment - a\n * staging build asking a production channel, for example.\n */\n ENVIRONMENT_MISMATCH: \"Environment mismatch\",\n} as const;\n\nexport type UpdateMessageValue = (typeof UpdateMessage)[keyof typeof UpdateMessage];\n\n/** What the device tells the server about itself. */\nexport interface UpdateCheckRequest {\n /** Bundle identifier of the running build, e.g. `com.ayb.lowmaro.staging`. */\n appId: string;\n platform: Platform;\n /** Channel to consult. Falls back to `defaultChannel` server-side. */\n channel?: string;\n defaultChannel?: string;\n /**\n * Native build number as a string. The server compares this against\n * `native_updates.version_code` and against an OTA bundle's\n * `min_update_version`, so an omitted or wrong value silently disables\n * native-update gating.\n */\n versionCode?: string;\n /** Historical alias for `versionCode`; the server accepts either. */\n versionBuild?: string;\n /**\n * Semantic version of the *currently applied web bundle*, or `\"builtin\"`\n * when the app still runs the bundle shipped inside the binary. Sending a\n * constant here defeats version comparison entirely.\n */\n version_name?: string;\n /** Stable per-install identifier, used for channel overrides and stats. */\n deviceId?: string;\n isProd?: boolean;\n}\n\n/** A native binary (APK/IPA) the device should install. */\nexport interface NativeUpdatePayload {\n version_name: string;\n version_code: number;\n download_url: string;\n release_notes?: string;\n required?: boolean;\n platform?: Platform;\n file_size?: number;\n}\n\n/**\n * The response. Every field is optional because the server returns a partial\n * object per outcome rather than a discriminated union - `resolveUpdate` below\n * narrows it into something a caller can branch on safely.\n */\nexport interface UpdateCheckResponse {\n message?: string;\n error?: string;\n\n /** OTA bundle fields. */\n version_name?: string;\n url?: string;\n checksum?: string;\n sessionKey?: string;\n release_notes?: string;\n required?: boolean;\n\n /** Present when a native binary supersedes, or blocks, the OTA bundle. */\n native_update?: NativeUpdatePayload | null;\n\n /** Remote configuration resolved for the channel's environment. */\n config?: Record<string, string>;\n}\n\nexport type UpdateKind = \"native\" | \"ota\";\n\n/** A resolved, actionable update. */\nexport interface ResolvedUpdate {\n kind: UpdateKind;\n version: string;\n versionCode?: number;\n downloadUrl?: string;\n releaseNotes?: string;\n required: boolean;\n platform?: Platform;\n checksum?: string;\n sessionKey?: string;\n /** Set once the OTA plugin has downloaded the bundle. */\n bundleId?: string;\n}\n\n/**\n * Narrows a raw response into an update to act on, or `null` when there is\n * nothing to do.\n *\n * Native wins over OTA. The server can return both - a required native binary\n * alongside the OTA bundle that needs it - and installing the bundle first\n * would leave the device on a binary too old to run it.\n */\nexport function resolveUpdate(\n response: UpdateCheckResponse | null | undefined,\n): ResolvedUpdate | null {\n if (!response) return null;\n\n const native = response.native_update;\n if (native?.download_url) {\n return {\n kind: \"native\",\n version: native.version_name,\n versionCode: native.version_code,\n downloadUrl: native.download_url,\n releaseNotes: native.release_notes,\n // A native update is mandatory when the server says the OTA bundle\n // cannot run without it, whatever the record's own flag says.\n required:\n response.message === UpdateMessage.NATIVE_UPDATE_REQUIRED || (native.required ?? false),\n platform: native.platform,\n };\n }\n\n if (response.url && response.version_name) {\n return {\n kind: \"ota\",\n version: response.version_name,\n downloadUrl: response.url,\n releaseNotes: response.release_notes,\n required: response.required ?? false,\n checksum: response.checksum,\n sessionKey: response.sessionKey,\n };\n }\n\n return null;\n}\n\n/**\n * True when the response reports a condition the user cannot fix by updating.\n * Callers should surface these instead of showing \"you are up to date\".\n */\nexport function isBlockingResponse(response: UpdateCheckResponse): boolean {\n return (\n response.message === UpdateMessage.CHANNEL_NOT_FOUND ||\n response.message === UpdateMessage.ENVIRONMENT_MISMATCH\n );\n}\n\n/** Analytics events posted to `POST {endpoint}/api/native-updates/log`. */\nexport type UpdateEvent =\n | \"check\"\n | \"download\"\n | \"download_complete\"\n | \"install\"\n | \"cancel\"\n | \"error\";\n\nexport interface UpdateEventPayload {\n event: UpdateEvent;\n platform: Platform;\n device_id: string;\n current_version_code: number;\n new_version?: string;\n new_version_code?: number;\n channel: string;\n environment: string;\n error?: string;\n}\n","import type { Environment } from \"./update-contract.js\";\n\n/**\n * `.capucho/project.json` - the file that makes an application deployable.\n *\n * Version 1 held only the cloud identifiers, so the CLI had to *guess* how to\n * build: it shelled out to `pnpm run assets:<env>`, `pnpm build:<env>`,\n * `pnpm trapeze:<env>` and `pnpm exec cap sync`. Any application that named\n * its scripts differently, used npm, or had not installed Trapeze simply\n * failed halfway through a deploy.\n *\n * Version 2 describes the *inputs* instead - where each flavour's env file,\n * Trapeze config and icon sources live - and lets the CLI own the execution.\n * Every field has a default, so a v1 file keeps working: `normaliseProjectConfig`\n * fills in the conventional layout both existing apps already use.\n */\nexport const PROJECT_CONFIG_VERSION = 2;\n\n/** One build flavour, keyed by the environment its channels are bound to. */\nexport interface FlavourConfig {\n /**\n * Env file supplying `VITE_APP_ID`, `VITE_APP_NAME`, `VITE_UPDATE_CHANNEL`\n * and friends. The CLI reads it and passes the values to the build and to\n * the native configuration step as environment variables - it does not\n * rewrite the file, so a deploy leaves the working tree clean.\n */\n envFile: string;\n /** Trapeze config applied to the native projects. Optional. */\n trapezeConfig?: string;\n /** Directory holding `icon.png` / `splash.png` for icon generation. */\n assetPath?: string;\n /** Vite `--mode`. Defaults to the flavour name. */\n mode?: string;\n}\n\nexport interface BuildConfig {\n /**\n * Overrides the web build. Leave unset and the CLI runs the project's own\n * Vite build through the workspace toolchain it detects.\n */\n command?: string;\n /** Where to run the build from, relative to the app. For monorepo roots. */\n cwd?: string;\n}\n\nexport interface ProjectConfig {\n /** Absent on v1 files. */\n version?: number;\n\n /** Bundle identifier of the production flavour. */\n appId: string;\n /** Primary key of the application in Capucho. */\n cloudAppId: string;\n appName: string;\n createdAt: string;\n\n /** Vite output directory, and what Capacitor copies into the native app. */\n webDir?: string;\n androidDir?: string;\n iosDir?: string;\n\n /**\n * Monotonic native build numbers per environment. Written by the CLI, and\n * the one file a deploy is expected to modify.\n */\n versionCodeFile?: string;\n\n flavours?: Partial<Record<Environment, FlavourConfig>>;\n build?: BuildConfig;\n\n /** Optional GitHub Pages mirror for generated web assets. */\n ghPagesRepo?: string;\n\n /** @deprecated v1 fields, folded into `build` by `normaliseProjectConfig`. */\n monorepoRoot?: string;\n /** @deprecated v1 field. */\n packageName?: string;\n}\n\n/** A `ProjectConfig` with every optional resolved. */\nexport interface ResolvedProjectConfig {\n version: number;\n appId: string;\n cloudAppId: string;\n appName: string;\n createdAt: string;\n webDir: string;\n androidDir: string;\n iosDir: string;\n versionCodeFile: string;\n flavours: Record<Environment, FlavourConfig>;\n build: BuildConfig;\n ghPagesRepo?: string;\n}\n\nexport const ENVIRONMENTS: readonly Environment[] = [\"dev\", \"staging\", \"prod\"];\n\n/**\n * The layout both existing applications already use. Used as the default so a\n * v1 `project.json` needs no migration to keep deploying.\n */\nexport function defaultFlavour(environment: Environment): FlavourConfig {\n return {\n envFile: `build/${environment}/.env.${environment}`,\n trapezeConfig: `build/${environment}/trapeze.${environment}.yaml`,\n assetPath: `build/${environment}/assets`,\n mode: environment,\n };\n}\n\nexport function normaliseProjectConfig(config: ProjectConfig): ResolvedProjectConfig {\n const flavours = {} as Record<Environment, FlavourConfig>;\n for (const environment of ENVIRONMENTS) {\n const defaults = defaultFlavour(environment);\n const declared = config.flavours?.[environment];\n flavours[environment] = {\n envFile: declared?.envFile ?? defaults.envFile,\n trapezeConfig: declared?.trapezeConfig ?? defaults.trapezeConfig,\n assetPath: declared?.assetPath ?? defaults.assetPath,\n mode: declared?.mode ?? defaults.mode,\n };\n }\n\n // v1 expressed monorepo builds as `monorepoRoot` + `packageName`, which the\n // CLI turned into `pnpm exec vp run <pkg>#build:<env>`. Carry that forward as\n // an explicit build command so the behaviour is visible rather than implied.\n const build: BuildConfig = { ...config.build };\n if (!build.cwd && config.monorepoRoot) build.cwd = config.monorepoRoot;\n if (!build.command && config.packageName) {\n build.command = `vp run ${config.packageName}#build`;\n }\n\n return {\n version: config.version ?? 1,\n appId: config.appId,\n cloudAppId: config.cloudAppId,\n appName: config.appName,\n createdAt: config.createdAt,\n webDir: config.webDir ?? \"dist\",\n androidDir: config.androidDir ?? \"android\",\n iosDir: config.iosDir ?? \"ios\",\n versionCodeFile: config.versionCodeFile ?? \"version-code.json\",\n flavours,\n build,\n ghPagesRepo: config.ghPagesRepo,\n };\n}\n\n/** Fields a `project.json` must carry for a deploy to be possible. */\nexport function validateProjectConfig(config: Partial<ProjectConfig> | null | undefined): string[] {\n if (!config) return [\"project.json is missing or empty\"];\n\n const problems: string[] = [];\n if (!config.appId) problems.push(\"appId is required\");\n if (!config.cloudAppId) problems.push(\"cloudAppId is required\");\n if (!config.appName) problems.push(\"appName is required\");\n\n if (config.appId && !isValidBundleId(config.appId)) {\n problems.push(`appId \"${config.appId}\" is not a valid bundle identifier`);\n }\n\n return problems;\n}\n\nconst BUNDLE_ID = /^[a-z][a-z\\d_]*(\\.[a-z][a-z\\d_]*)+$/;\n\nexport function isValidBundleId(value: string): boolean {\n return BUNDLE_ID.test(value);\n}\n\n/**\n * Derives the environment a bundle identifier belongs to.\n *\n * The backend enforces the same rule server-side: a `.staging` build may only\n * be served staging channels. Mirroring it here lets the CLI refuse a\n * mismatched deploy before it uploads several megabytes.\n */\nexport function environmentFromAppId(appId: string): Environment {\n const id = appId.toLowerCase();\n if (id.endsWith(\".staging\")) return \"staging\";\n if (id.endsWith(\".dev\") || id.endsWith(\".debug\")) return \"dev\";\n return \"prod\";\n}\n\n/**\n * Whether a build may be served a channel bound to `channelEnvironment`.\n *\n * This mirrors the server's isolation check exactly, including its one\n * deliberate exception: a production build is allowed on a staging channel, so\n * a release candidate can be beta-tested by real installs without shipping a\n * separate bundle identifier.\n *\n * The rule lives here rather than being restated at each call site because the\n * CLI had reimplemented it as a plain equality check - which is *stricter* than\n * the server and rejected the exact beta-testing setup Lowmaro uses, where all\n * three channels are bound to staging and the app id carries no suffix.\n */\nexport function isEnvironmentAllowed(appId: string, channelEnvironment: Environment): boolean {\n const expected = environmentFromAppId(appId);\n if (expected === channelEnvironment) return true;\n return expected === \"prod\" && channelEnvironment === \"staging\";\n}\n\n/** Explains a rejected pairing, or null when it is allowed. */\nexport function describeEnvironmentMismatch(\n appId: string,\n channelEnvironment: Environment,\n channelName: string,\n): string | null {\n if (isEnvironmentAllowed(appId, channelEnvironment)) return null;\n\n const expected = environmentFromAppId(appId);\n return (\n `Channel \"${channelName}\" serves the ${channelEnvironment} environment, but ` +\n `the build's VITE_APP_ID is \"${appId}\", which is a ${expected} bundle id. ` +\n \"The server rejects this pairing, so the upload would be wasted.\"\n );\n}\n","import type { Environment } from \"./update-contract.js\";\n\n/**\n * Version arithmetic, kept free of any filesystem or child-process access so\n * both the CLI and the tests can use it directly.\n *\n * The CLI used to shell out to `npm version <type> --no-git-tag-version` for\n * this. In a workspace that is actively wrong: npm resolves the *nearest*\n * package.json, so running it from a monorepo root bumped the root package\n * instead of the app, and it mixed npm into a pnpm/Vite+ project for a job\n * that is three lines of string handling.\n */\n\nexport type BumpType = \"major\" | \"minor\" | \"patch\";\n\nexport interface SemanticVersion {\n major: number;\n minor: number;\n patch: number;\n prerelease?: string;\n build?: string;\n}\n\nconst SEMVER =\n /^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([\\dA-Za-z-]+(?:\\.[\\dA-Za-z-]+)*))?(?:\\+([\\dA-Za-z-]+(?:\\.[\\dA-Za-z-]+)*))?$/;\n\nexport function parseVersion(value: string): SemanticVersion | null {\n const match = SEMVER.exec(value.trim());\n if (!match) return null;\n\n return {\n major: Number(match[1]),\n minor: Number(match[2]),\n patch: Number(match[3]),\n prerelease: match[4],\n build: match[5],\n };\n}\n\nexport function formatVersion(version: SemanticVersion): string {\n let out = `${version.major}.${version.minor}.${version.patch}`;\n if (version.prerelease) out += `-${version.prerelease}`;\n if (version.build) out += `+${version.build}`;\n return out;\n}\n\n/**\n * Bumps a version string. Prerelease and build metadata are dropped, matching\n * `npm version` semantics for a plain major/minor/patch bump.\n */\nexport function bumpVersion(value: string, type: BumpType): string {\n const parsed = parseVersion(value);\n if (!parsed) {\n throw new Error(`\"${value}\" is not a semantic version, so it cannot be bumped`);\n }\n\n switch (type) {\n case \"major\":\n return formatVersion({ major: parsed.major + 1, minor: 0, patch: 0 });\n case \"minor\":\n return formatVersion({\n major: parsed.major,\n minor: parsed.minor + 1,\n patch: 0,\n });\n case \"patch\":\n return formatVersion({\n major: parsed.major,\n minor: parsed.minor,\n patch: parsed.patch + 1,\n });\n }\n}\n\n/**\n * Compares two semantic versions. Returns a negative number when `a` is older.\n *\n * A missing or unparseable version sorts oldest, which is what the app needs:\n * the sentinel `\"builtin\"` must always look older than any published bundle.\n */\nexport function compareVersions(a: string, b: string): number {\n const left = parseVersion(a);\n const right = parseVersion(b);\n\n if (!left && !right) return 0;\n if (!left) return -1;\n if (!right) return 1;\n\n if (left.major !== right.major) return left.major - right.major;\n if (left.minor !== right.minor) return left.minor - right.minor;\n if (left.patch !== right.patch) return left.patch - right.patch;\n\n // 1.0.0-beta precedes 1.0.0.\n if (left.prerelease && !right.prerelease) return -1;\n if (!left.prerelease && right.prerelease) return 1;\n if (left.prerelease && right.prerelease) {\n return left.prerelease < right.prerelease ? -1 : left.prerelease > right.prerelease ? 1 : 0;\n }\n\n return 0;\n}\n\nexport type VersionCodes = Record<Environment, number>;\n\nexport const INITIAL_VERSION_CODES: VersionCodes = {\n dev: 1,\n staging: 1,\n prod: 1,\n};\n\n/**\n * Native build numbers must increase monotonically per environment: Android\n * refuses to install an APK whose versionCode is not greater than the\n * installed one, and the backend uses the same number to decide whether a\n * native update supersedes an OTA bundle.\n */\nexport function nextVersionCode(\n codes: Partial<VersionCodes> | null | undefined,\n environment: Environment,\n): VersionCodes {\n const current: VersionCodes = { ...INITIAL_VERSION_CODES, ...codes };\n return { ...current, [environment]: (current[environment] ?? 0) + 1 };\n}\n\n/**\n * Build-time variables injected into the web build and the native\n * configuration step.\n *\n * These used to be written *into* the committed `build/<env>/.env.<env>` file\n * by every deploy, which dirtied the working tree and made two concurrent\n * deploys race over one file. They are environment variables now: Trapeze\n * reads its `vars:` block from the process environment, and Vite reads\n * `VITE_*` the same way.\n */\nexport function versionEnv(version: string, versionCode: number) {\n return {\n VITE_APP_VERSION: version,\n VERSION_CODE: String(versionCode),\n BUILD_NUMBER: String(versionCode),\n };\n}\n","import type { Environment, Platform } from \"./update-contract.js\";\n\n/** Shapes returned by the authenticated `/api/*` endpoints. */\n\nexport interface CloudOrganization {\n id: string;\n name: string;\n slug: string;\n role: \"owner\" | \"admin\" | \"member\";\n}\n\nexport interface CloudApp {\n id: string;\n name: string;\n app_id: string;\n platform: string;\n organization_id: string;\n created_at: string;\n icon_url?: string;\n}\n\nexport interface CloudChannel {\n id: string;\n name: string;\n app_id: string;\n /**\n * Which build flavour this channel serves. The CLI derives the whole build\n * from it, so a channel without an environment cannot be deployed to.\n */\n environment: Environment;\n public: boolean;\n created_at: string;\n current_version_id?: string | null;\n current_native_version_id?: string | null;\n}\n\nexport interface CloudRelease {\n id: string;\n version_name: string;\n platform: Platform;\n channel?: string;\n active: boolean;\n required: boolean;\n release_notes?: string;\n created_at: string;\n}\n\nexport interface CloudUser {\n id: string;\n email: string;\n role?: string;\n}\n\n/** Response of `GET /api/auth/me`. */\nexport interface UserProfile {\n user: CloudUser;\n organizations: CloudOrganization[];\n apps: Array<CloudApp & { role: string }>;\n}\n\n/** Roles allowed to create an application inside an organization. */\nexport const APP_CREATOR_ROLES: ReadonlySet<string> = new Set([\"owner\", \"admin\"]);\n\nexport function canCreateApps(organization: CloudOrganization): boolean {\n return APP_CREATOR_ROLES.has(organization.role);\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@capuchoo/core",
3
+ "version": "0.1.0",
4
+ "description": "Shared contract between the Capucho CLI, backend, dashboard and app runtime",
5
+ "keywords": [
6
+ "capacitor",
7
+ "capucho",
8
+ "ota",
9
+ "updates"
10
+ ],
11
+ "homepage": "https://github.com/aybinv7/capuchoo/tree/main/packages/core",
12
+ "license": "MIT",
13
+ "author": "aybinv7",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/aybinv7/capuchoo.git",
17
+ "directory": "packages/core"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "^5.9.3",
37
+ "vite-plus": "0.2.9"
38
+ },
39
+ "scripts": {
40
+ "build": "vp pack",
41
+ "test": "vp test --run"
42
+ }
43
+ }