claude-toolkit 0.1.20 → 0.1.24
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/CHANGELOG.md +12 -0
- package/README.md +61 -26
- package/bin/cli.ts +57 -12
- package/docs/README.md +1 -0
- package/docs/best-practices/capacitor/README.md +64 -0
- package/docs/best-practices/capacitor/capacitor-8.md +60 -0
- package/docs/best-practices/capacitor/capgo-setup.md +151 -0
- package/docs/best-practices/capacitor/channels-and-rollouts.md +101 -0
- package/docs/best-practices/capacitor/live-updates-ota.md +75 -0
- package/docs/best-practices/capacitor/security-encryption.md +68 -0
- package/docs/stacks/capacitor-ota.md +145 -0
- package/package.json +1 -1
- package/src/detect.ts +152 -0
- package/src/index.ts +2 -0
- package/src/types.ts +5 -1
- package/stacks/capacitor/skills/ct-capacitor-ota/SKILL.md +178 -0
- package/stacks/capacitor/stack.json +64 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Live Updates & OTA Strategy
|
|
2
|
+
|
|
3
|
+
> OTA is a delivery mechanism for the **web layer**, not a way around the app stores. The strategy is mostly about discipline: knowing what you can ship over the air, keeping bundles small, and rolling out so a mistake is recoverable.
|
|
4
|
+
|
|
5
|
+
## What You Can — and Cannot — Ship Over the Air
|
|
6
|
+
|
|
7
|
+
| Change | OTA? | Why |
|
|
8
|
+
| ----------------------------------------------- | ---- | ----------------------------------------------------------- |
|
|
9
|
+
| Bug fix in JS/TS | ✅ | Pure web layer |
|
|
10
|
+
| Copy, translations, styling | ✅ | Static assets in the bundle |
|
|
11
|
+
| New feature built on existing native APIs | ✅ | No new native contract |
|
|
12
|
+
| Web-only dependency upgrade | ✅ | Compiles into the bundle |
|
|
13
|
+
| New Capacitor **plugin** (native code) | ❌ | Native shell lacks the implementation → crash |
|
|
14
|
+
| Native dependency / SDK upgrade | ❌ | Native binary change |
|
|
15
|
+
| Capacitor **major** version bump | ❌ | Native runtime change |
|
|
16
|
+
| Permission / entitlement / manifest change | ❌ | Declared in the native binary |
|
|
17
|
+
|
|
18
|
+
The failure mode is concrete: a bundle that calls `SomePlugin.doThing()` on a shell where that plugin isn't compiled in throws at runtime. **Match every bundle to the native version that can run it.**
|
|
19
|
+
|
|
20
|
+
## Store Compliance
|
|
21
|
+
|
|
22
|
+
OTA of interpreted code is explicitly permitted:
|
|
23
|
+
|
|
24
|
+
- **Apple** — Developer Agreement **§3.3.2** allows OTA of JavaScript and assets executed by the system WebView (since iOS 4.3).
|
|
25
|
+
- **Google Play** — the restriction on non-Play code updates **does not apply** to code running in a VM with limited API access, i.e. JavaScript in a WebView.
|
|
26
|
+
|
|
27
|
+
What gets apps pulled is shipping *native* executable code OTA. Stay in the web layer and you stay compliant.
|
|
28
|
+
|
|
29
|
+
## Performance: Keep Bundles Small
|
|
30
|
+
|
|
31
|
+
Smaller bundles download faster, fail less, and adopt quicker.
|
|
32
|
+
|
|
33
|
+
- **Delta / differential updates.** Capgo transfers only the files that changed between versions (auto-enabled with direct/instant updates). The win depends on file-level stability — see below.
|
|
34
|
+
- **Deterministic builds.** Strip build timestamps and pin your build tooling so unchanged source produces byte-identical output. Otherwise every file looks "changed" and the delta degrades to a full download.
|
|
35
|
+
- **Compression.** Capgo applies ZSTD compression to full-image updates; you don't configure it, but deterministic builds are what let delta + compression actually shrink the payload.
|
|
36
|
+
- **Prioritize the critical path.** Load auth and main navigation eagerly; lazy-load analytics, settings, animations, and heavy media after first paint. A small critical bundle reaches `notifyAppReady()` faster, which matters because of the rollback timeout.
|
|
37
|
+
|
|
38
|
+
## Update Timing & Scheduling
|
|
39
|
+
|
|
40
|
+
- **Off-peak rollouts.** Schedule heavy rollouts for **1–4 AM local time** and prefer Wi-Fi so you're not competing with active use or burning cellular data.
|
|
41
|
+
- **Background application.** The default `atBackground` mode downloads silently and applies when the app backgrounds, so users never watch a spinner. Reserve `onLaunch` / `always` for updates that must take effect immediately (e.g. a hotfix).
|
|
42
|
+
- **Respect `periodCheckDelay`.** Minimum 600s (10 min). Don't try to poll faster — it's clamped.
|
|
43
|
+
|
|
44
|
+
## Roll Out So You Can Roll Back
|
|
45
|
+
|
|
46
|
+
The whole point of OTA is that mistakes are cheap to undo. Treat every release as staged:
|
|
47
|
+
|
|
48
|
+
1. **Canary** to ~10% of users.
|
|
49
|
+
2. **Watch** the success signal (`appReady` vs `downloadFailed`, Capgo stats) for 1–2 hours.
|
|
50
|
+
3. **Widen** to 100% over ~24h if healthy.
|
|
51
|
+
4. **Roll back instantly** from the channel dashboard if not — devices pick up the previous bundle on next check-in.
|
|
52
|
+
|
|
53
|
+
Healthy benchmarks: **~95% adoption within 24h**, **~82% global success rate**. Below that, investigate before widening.
|
|
54
|
+
|
|
55
|
+
## The notifyAppReady() Safety Net
|
|
56
|
+
|
|
57
|
+
This is what makes the above safe. The plugin starts a timer (`appReadyTimeout`, default 10s) when a new bundle loads. If your JS calls `notifyAppReady()` before it fires, the bundle is "confirmed." If it doesn't — because the bundle white-screened or threw on boot — the plugin **automatically reverts** to the last good bundle. A broken OTA release self-heals on the next launch even before you notice.
|
|
58
|
+
|
|
59
|
+
Call it as early as possible, before heavy async work, so a slow boot doesn't trip the timeout on an otherwise-fine bundle.
|
|
60
|
+
|
|
61
|
+
## Anti-Patterns
|
|
62
|
+
|
|
63
|
+
| Anti-pattern | Consequence |
|
|
64
|
+
| ------------------------------------------- | ----------------------------------------------------------------- |
|
|
65
|
+
| OTA-ing a native change | Runtime crash on the mismatched shell |
|
|
66
|
+
| Non-deterministic builds | Delta updates degrade to full downloads |
|
|
67
|
+
| 100% rollout, no canary | A bad bundle hits the entire user base at once |
|
|
68
|
+
| Eager-loading everything | Slow boot risks tripping `appReadyTimeout` → false rollback |
|
|
69
|
+
| Polling faster than 600s | Silently clamped; wasted assumption |
|
|
70
|
+
|
|
71
|
+
## See Also
|
|
72
|
+
|
|
73
|
+
- [Capacitor 8 Platform](capacitor-8.md) — keeping native and OTA in lockstep
|
|
74
|
+
- [Capgo Setup & API](capgo-setup.md) — config and the manual update lifecycle
|
|
75
|
+
- [Channels & Staged Rollouts](channels-and-rollouts.md) — the mechanics of canarying and rollback
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Security & Encryption
|
|
2
|
+
|
|
3
|
+
> An OTA bundle travels over the network and lands on devices you don't control. End-to-end encryption with code signing makes each update unreadable in transit and verifiable on arrival — only your users can decrypt it, and only your signed bundles are accepted.
|
|
4
|
+
|
|
5
|
+
## Why Encrypt
|
|
6
|
+
|
|
7
|
+
A channel controls *who is eligible* for an update; it does **not** keep the bundle secret. An unencrypted bundle should be treated as a public asset. If your web layer contains anything you don't want readable — proprietary logic, embedded config — encryption is the only thing that keeps it private end to end, including from Capgo itself.
|
|
8
|
+
|
|
9
|
+
## How v2 Encryption Works (Hybrid RSA + AES)
|
|
10
|
+
|
|
11
|
+
RSA can't efficiently encrypt large payloads, so Capgo uses a hybrid scheme:
|
|
12
|
+
|
|
13
|
+
1. A **random AES-256 key** is generated for every upload and encrypts the bundle.
|
|
14
|
+
2. Your **private RSA-2048 key** encrypts (signs) the AES key and the bundle checksum.
|
|
15
|
+
3. The app holds your **public RSA key** (injected at build) and decrypts the AES key + signature.
|
|
16
|
+
4. The app decrypts the bundle with the AES key, recomputes the checksum, and compares it against the decrypted signature.
|
|
17
|
+
|
|
18
|
+
The result: the bundle is confidential (AES) **and** authenticated (RSA signature over the checksum). Tampering breaks the checksum match; a bundle not signed by your private key won't validate.
|
|
19
|
+
|
|
20
|
+
> Industry baseline this matches: **AES-256** for payloads, **RSA-2048** for key exchange, **SHA-256** for integrity.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @capgo/cli@latest key create # generate the RSA key pair
|
|
26
|
+
npx @capgo/cli@latest bundle upload --key-v2 --channel=production
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`key create` writes two files and injects the public key into your Capacitor config:
|
|
30
|
+
|
|
31
|
+
| File | Sensitivity | Rule |
|
|
32
|
+
| --------------------- | ----------- | ---------------------------------------------------------- |
|
|
33
|
+
| `.capgo_key_v2` | **Private** | **Never commit.** Store as a CI secret. Loss = can't sign. |
|
|
34
|
+
| `.capgo_key_v2.pub` | Public | Safe to commit — it's a backup of the embedded public key. |
|
|
35
|
+
|
|
36
|
+
Add `.capgo_key_v2` to `.gitignore` immediately. The private key only ever lives on trusted dev machines and in CI secret storage; it's used at upload time to sign, never shipped to devices.
|
|
37
|
+
|
|
38
|
+
## Strict Enforcement
|
|
39
|
+
|
|
40
|
+
Once encryption is configured, the plugin **requires** a valid signature on every update — this is enforced, not advisory. An unsigned or tampered bundle is **rejected**, not applied. Practical consequences:
|
|
41
|
+
|
|
42
|
+
- Every CI upload must run with `--key-v2` and access to the private key, or devices will reject the bundle.
|
|
43
|
+
- Rotating keys requires shipping a new native binary with the new public key; plan it as a store release, not an OTA.
|
|
44
|
+
- `--no-key` (unencrypted) and encrypted uploads don't mix on the same channel for the same audience — pick one posture per channel.
|
|
45
|
+
|
|
46
|
+
## Integrity Beyond Encryption
|
|
47
|
+
|
|
48
|
+
- **Checksums** verify the decrypted bundle matches what you signed — corruption or tampering fails closed.
|
|
49
|
+
- **`notifyAppReady()`** is the runtime backstop: even a validly-signed bundle that crashes on boot rolls back automatically (see [Capgo Setup](capgo-setup.md)). Signing proves *authenticity*; `notifyAppReady` proves *it actually runs*. You want both.
|
|
50
|
+
|
|
51
|
+
## Store Compliance
|
|
52
|
+
|
|
53
|
+
Encryption doesn't change what you're allowed to ship — it just protects the payload. The compliance rule is unchanged: OTA of **JavaScript/HTML/CSS** is permitted (Apple Developer Agreement §3.3.2; Google Play's webview/JS-VM exemption). Encrypting that web bundle is fine. Encrypting and shipping *native* executable code OTA is still not allowed — encryption is not a loophole around the native/web boundary.
|
|
54
|
+
|
|
55
|
+
## Checklist
|
|
56
|
+
|
|
57
|
+
- [ ] `npx @capgo/cli key create` run; `.capgo_key_v2` added to `.gitignore`.
|
|
58
|
+
- [ ] Private key stored as a CI secret; `--key-v2` used on every production upload.
|
|
59
|
+
- [ ] `.capgo_key_v2.pub` committed as a backup.
|
|
60
|
+
- [ ] `notifyAppReady()` present so signed-but-broken bundles still self-heal.
|
|
61
|
+
- [ ] Key rotation planned as a native binary release, not an OTA.
|
|
62
|
+
- [ ] Only web-layer code shipped OTA — never native code, encrypted or not.
|
|
63
|
+
|
|
64
|
+
## See Also
|
|
65
|
+
|
|
66
|
+
- [Capgo Setup & API](capgo-setup.md) — the upload flow and `publicKey` config
|
|
67
|
+
- [Channels & Staged Rollouts](channels-and-rollouts.md) — per-channel encryption posture
|
|
68
|
+
- [Live Updates & OTA Strategy](live-updates-ota.md) — what's legal to ship over the air
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Capacitor OTA (Capgo) Patterns
|
|
2
|
+
|
|
3
|
+
> Capacitor 8 native runtime and Capgo over-the-air live updates: channels, encryption, staged rollouts, and store-safe delivery.
|
|
4
|
+
|
|
5
|
+
**Type:** Stack Skill (requires `capacitor` stack)
|
|
6
|
+
**Source:** [`stacks/capacitor/skills/ct-capacitor-ota/SKILL.md`](../../stacks/capacitor/skills/ct-capacitor-ota/SKILL.md)
|
|
7
|
+
**Directory Mappings:** `capacitor.config.ts`, `capacitor.config.json`, `ios/`, `android/`
|
|
8
|
+
**File Extensions:** _(none — keyed on config + content)_
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
Capacitor wraps a web app in a native iOS/Android shell. Capgo's `@capgo/capacitor-updater` lets you replace the web bundle that shell loads, shipping JS/HTML/CSS fixes in minutes instead of waiting on store review.
|
|
13
|
+
|
|
14
|
+
**Golden rule:** OTA updates the **web layer only**. Native code changes (new plugin, native dependency, Capacitor major bump) require a new store binary. Never OTA a bundle whose native contract differs from the installed shell.
|
|
15
|
+
|
|
16
|
+
## Versions (as of June 2026)
|
|
17
|
+
|
|
18
|
+
| Package | Version | Notes |
|
|
19
|
+
| -------------------------- | ------- | ---------------------------------------------------------------- |
|
|
20
|
+
| `@capacitor/core` & CLI | ^8.3.x | 8.3.1 (2026-04-16); Node 22+, SPM default for new iOS apps |
|
|
21
|
+
| `@capgo/capacitor-updater` | ^8.x | Major version tracks Capacitor major; v8 stores channels locally |
|
|
22
|
+
| `@capgo/cli` | latest | `npx @capgo/cli@latest …` — pin in CI |
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm i @capgo/capacitor-updater && npx cap sync
|
|
28
|
+
npx @capgo/cli@latest init <API_KEY> # adds app, injects notifyAppReady, builds, uploads, tests
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Critical: notifyAppReady()
|
|
32
|
+
|
|
33
|
+
Call it as early as possible after the bundle boots. If the plugin doesn't hear it within `appReadyTimeout` (default 10s), it assumes the bundle is broken and **auto-rolls back** to the previous one — the safety net that makes OTA reversible.
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { CapacitorUpdater } from "@capgo/capacitor-updater";
|
|
37
|
+
|
|
38
|
+
CapacitorUpdater.notifyAppReady(); // top of bootstrap, before heavy async work
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// capacitor.config.ts
|
|
45
|
+
import type { CapacitorConfig } from "@capacitor/cli";
|
|
46
|
+
|
|
47
|
+
const config: CapacitorConfig = {
|
|
48
|
+
appId: "com.example.app",
|
|
49
|
+
appName: "Example",
|
|
50
|
+
webDir: "dist",
|
|
51
|
+
plugins: {
|
|
52
|
+
CapacitorUpdater: {
|
|
53
|
+
autoUpdate: "atBackground", // apply when app backgrounds
|
|
54
|
+
appReadyTimeout: 10000,
|
|
55
|
+
autoDeleteFailed: true,
|
|
56
|
+
autoDeletePrevious: true,
|
|
57
|
+
resetWhenUpdate: true, // wipe OTA bundles on native app update
|
|
58
|
+
// defaultChannel: "Development", // TEST/QA BUILDS ONLY — omit in production
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default config;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### autoUpdate modes
|
|
67
|
+
|
|
68
|
+
| Value | Behavior |
|
|
69
|
+
| ---------------------------- | ------------------------------------------------------------ |
|
|
70
|
+
| `false` / `"off"` | No automatic updates — drive everything from JS |
|
|
71
|
+
| `"atBackground"` *(default)* | Download silently, apply when app moves to background |
|
|
72
|
+
| `"atInstall"` | Apply right after a fresh install / native update |
|
|
73
|
+
| `"onLaunch"` | Apply on next launch, then background |
|
|
74
|
+
| `"always"` | Apply immediately whenever a check runs |
|
|
75
|
+
| `"onlyDownload"` | Download only; emit `updateAvailable` for you to act |
|
|
76
|
+
|
|
77
|
+
`periodCheckDelay` sets poll frequency (seconds, **minimum 600** / 10 min).
|
|
78
|
+
|
|
79
|
+
## Channels
|
|
80
|
+
|
|
81
|
+
A channel points to one JS bundle. Swap which bundle a channel points to for instant rollout/rollback without rebuilding.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx @capgo/cli@latest bundle upload --channel=production # build → upload → assign
|
|
85
|
+
npx @capgo/cli@latest channel set production -s default # make it the cloud default
|
|
86
|
+
npx @capgo/cli@latest channel set beta --self-assign # allow in-app setChannel()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Precedence** (highest first): forced device mapping → cloud per-device override → config `defaultChannel` → cloud Default Channel.
|
|
90
|
+
|
|
91
|
+
Ship production binaries **without** `defaultChannel` so users follow the cloud default. Recommended ladder:
|
|
92
|
+
|
|
93
|
+
| Channel | Bundle version | Audience |
|
|
94
|
+
| ------------- | -------------- | ------------------------- |
|
|
95
|
+
| `development` | `1.2.3-dev.1` | dev / emulator builds |
|
|
96
|
+
| `qa` | `1.2.3-qa.1` | QA verification |
|
|
97
|
+
| `staging` | `1.2.3-rc.1` | production-like sign-off |
|
|
98
|
+
| `production` | `1.2.3` | end users (store version) |
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// Runtime switch — needs "Allow device self-assignment" on the channel (v8+).
|
|
102
|
+
await CapacitorUpdater.setChannel({ channel: "beta", triggerAutoUpdate: true });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## End-to-end encryption (v2)
|
|
106
|
+
|
|
107
|
+
Hybrid RSA-2048 + AES-256: a random AES key encrypts each bundle; your private RSA key signs the key + checksum; the app decrypts with the embedded public key.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npx @capgo/cli@latest key create
|
|
111
|
+
npx @capgo/cli@latest bundle upload --key-v2 --channel=production
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- `.capgo_key_v2` (private) — **never commit**; store as a CI secret.
|
|
115
|
+
- `.capgo_key_v2.pub` (public) — safe to commit.
|
|
116
|
+
- With encryption configured, the plugin **strictly enforces** a valid signature; tampered/unsigned bundles are rejected.
|
|
117
|
+
|
|
118
|
+
## Staged rollouts & monitoring
|
|
119
|
+
|
|
120
|
+
- Start at ~10% of users, widen over 24h; schedule heavy rollouts 1–4 AM local, Wi-Fi-preferred.
|
|
121
|
+
- Targets: ~95% adoption within 24h, ~82% global success rate.
|
|
122
|
+
- Roll back instantly from the channel dashboard (crown icon on the previous build).
|
|
123
|
+
- Watch the `downloadFailed` / `appReady` ratio in Capgo stats; a spike means roll back.
|
|
124
|
+
|
|
125
|
+
## Store compliance
|
|
126
|
+
|
|
127
|
+
OTA of JS/HTML/CSS is permitted — Apple §3.3.2 (since iOS 4.3) and Google Play (webview/JS VM code is exempt). Shipping native code OTA is not.
|
|
128
|
+
|
|
129
|
+
## Anti-Patterns
|
|
130
|
+
|
|
131
|
+
| Anti-pattern | Why it's wrong |
|
|
132
|
+
| ----------------------------------------- | ------------------------------------------------------------------------ |
|
|
133
|
+
| **Not calling `notifyAppReady()`** | Every bundle auto-rolls back after `appReadyTimeout`. The #1 OTA failure. |
|
|
134
|
+
| **OTA-ing a native change** | Bundle needs a native API the shell lacks → crash. Bump the binary first. |
|
|
135
|
+
| **`defaultChannel` in production builds** | Pins users off the cloud default; breaks instant rollback. |
|
|
136
|
+
| **Committing `.capgo_key_v2`** | Leaks your signing key. Commit only the `.pub`. |
|
|
137
|
+
| **`periodCheckDelay` < 600** | Silently clamped; never polls faster than 10 min. |
|
|
138
|
+
| **100% rollout with no canary** | A bad bundle hits everyone at once. Stage it. |
|
|
139
|
+
| **Unpinned `@capgo/cli@latest` in CI** | A CLI minor can change upload behavior mid-pipeline. Pin it. |
|
|
140
|
+
|
|
141
|
+
## See Also
|
|
142
|
+
|
|
143
|
+
- [Capacitor & OTA Best Practices](../best-practices/capacitor/README.md) — full reference set
|
|
144
|
+
- [ct-vite-vitest-patterns](vite-vitest-patterns.md) — the build that produces `webDir`
|
|
145
|
+
- [ct-playwright-patterns](playwright-patterns.md) — E2E-verify a bundle before upload
|
package/package.json
CHANGED
package/src/detect.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { StackName } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** Result of detecting a stack in a project */
|
|
6
|
+
export interface DetectedStack {
|
|
7
|
+
name: StackName;
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PackageJson {
|
|
12
|
+
dependencies?: Record<string, string>;
|
|
13
|
+
devDependencies?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface StackDetector {
|
|
17
|
+
name: StackName;
|
|
18
|
+
detect: (projectDir: string, pkg: PackageJson | null) => DetectedStack | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadPackageJson(projectDir: string): PackageJson | null {
|
|
22
|
+
const pkgPath = join(projectDir, "package.json");
|
|
23
|
+
if (!existsSync(pkgPath)) return null;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(pkgPath, "utf-8")) as PackageJson;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasDep(pkg: PackageJson | null, name: string): boolean {
|
|
32
|
+
if (!pkg) return false;
|
|
33
|
+
return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fileExists(projectDir: string, ...segments: string[]): boolean {
|
|
37
|
+
return existsSync(join(projectDir, ...segments));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rootConfigExists(projectDir: string, prefix: string): string | null {
|
|
41
|
+
const glob = new Bun.Glob(`${prefix}.*`);
|
|
42
|
+
for (const match of glob.scanSync({ cwd: projectDir, onlyFiles: true })) {
|
|
43
|
+
return match;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DETECTORS: StackDetector[] = [
|
|
49
|
+
{
|
|
50
|
+
name: "solidjs",
|
|
51
|
+
detect: (_dir, pkg) =>
|
|
52
|
+
hasDep(pkg, "solid-js")
|
|
53
|
+
? { name: "solidjs", reason: "found solid-js in dependencies" }
|
|
54
|
+
: null,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "vite",
|
|
58
|
+
detect: (dir, pkg) => {
|
|
59
|
+
if (hasDep(pkg, "vite")) return { name: "vite", reason: "found vite in dependencies" };
|
|
60
|
+
const viteConfig = rootConfigExists(dir, "vite.config");
|
|
61
|
+
if (viteConfig) return { name: "vite", reason: `found ${viteConfig}` };
|
|
62
|
+
const vitestConfig = rootConfigExists(dir, "vitest.config");
|
|
63
|
+
if (vitestConfig) return { name: "vite", reason: `found ${vitestConfig}` };
|
|
64
|
+
return null;
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "vanilla-extract",
|
|
69
|
+
detect: (_dir, pkg) =>
|
|
70
|
+
hasDep(pkg, "@vanilla-extract/css")
|
|
71
|
+
? { name: "vanilla-extract", reason: "found @vanilla-extract/css in dependencies" }
|
|
72
|
+
: null,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "rust-wasm",
|
|
76
|
+
detect: (dir) =>
|
|
77
|
+
fileExists(dir, "Cargo.toml") ? { name: "rust-wasm", reason: "found Cargo.toml" } : null,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "protobuf",
|
|
81
|
+
detect: (dir) => {
|
|
82
|
+
if (fileExists(dir, "buf.yaml")) return { name: "protobuf", reason: "found buf.yaml" };
|
|
83
|
+
if (fileExists(dir, "buf.gen.yaml"))
|
|
84
|
+
return { name: "protobuf", reason: "found buf.gen.yaml" };
|
|
85
|
+
const glob = new Bun.Glob("**/*.proto");
|
|
86
|
+
for (const _match of glob.scanSync({ cwd: dir, onlyFiles: true })) {
|
|
87
|
+
return { name: "protobuf", reason: "found .proto files" };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "cloudflare",
|
|
94
|
+
detect: (dir) => {
|
|
95
|
+
if (fileExists(dir, "wrangler.toml"))
|
|
96
|
+
return { name: "cloudflare", reason: "found wrangler.toml" };
|
|
97
|
+
if (fileExists(dir, "wrangler.jsonc"))
|
|
98
|
+
return { name: "cloudflare", reason: "found wrangler.jsonc" };
|
|
99
|
+
return null;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "i18n-typesafe",
|
|
104
|
+
detect: (_dir, pkg) =>
|
|
105
|
+
hasDep(pkg, "typesafe-i18n")
|
|
106
|
+
? { name: "i18n-typesafe", reason: "found typesafe-i18n in dependencies" }
|
|
107
|
+
: null,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "playwright",
|
|
111
|
+
detect: (dir, pkg) => {
|
|
112
|
+
if (hasDep(pkg, "@playwright/test"))
|
|
113
|
+
return { name: "playwright", reason: "found @playwright/test in dependencies" };
|
|
114
|
+
const config = rootConfigExists(dir, "playwright.config");
|
|
115
|
+
if (config) return { name: "playwright", reason: `found ${config}` };
|
|
116
|
+
return null;
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "storybook",
|
|
121
|
+
detect: (dir, pkg) => {
|
|
122
|
+
if (hasDep(pkg, "storybook"))
|
|
123
|
+
return { name: "storybook", reason: "found storybook in dependencies" };
|
|
124
|
+
if (fileExists(dir, ".storybook"))
|
|
125
|
+
return { name: "storybook", reason: "found .storybook/ directory" };
|
|
126
|
+
return null;
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "capacitor",
|
|
131
|
+
detect: (dir, pkg) => {
|
|
132
|
+
if (hasDep(pkg, "@capgo/capacitor-updater"))
|
|
133
|
+
return { name: "capacitor", reason: "found @capgo/capacitor-updater in dependencies" };
|
|
134
|
+
if (hasDep(pkg, "@capacitor/core"))
|
|
135
|
+
return { name: "capacitor", reason: "found @capacitor/core in dependencies" };
|
|
136
|
+
const config = rootConfigExists(dir, "capacitor.config");
|
|
137
|
+
if (config) return { name: "capacitor", reason: `found ${config}` };
|
|
138
|
+
return null;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** Scan a project directory and detect which stacks are present */
|
|
144
|
+
export function detectStacks(projectDir: string): DetectedStack[] {
|
|
145
|
+
const pkg = loadPackageJson(projectDir);
|
|
146
|
+
const detected: DetectedStack[] = [];
|
|
147
|
+
for (const detector of DETECTORS) {
|
|
148
|
+
const result = detector.detect(projectDir, pkg);
|
|
149
|
+
if (result) detected.push(result);
|
|
150
|
+
}
|
|
151
|
+
return detected;
|
|
152
|
+
}
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type StackName =
|
|
|
6
6
|
| "protobuf"
|
|
7
7
|
| "cloudflare"
|
|
8
8
|
| "i18n-typesafe"
|
|
9
|
+
| "vite"
|
|
10
|
+
| "playwright"
|
|
11
|
+
| "storybook"
|
|
12
|
+
| "capacitor"
|
|
9
13
|
| (string & {});
|
|
10
14
|
|
|
11
15
|
/** Hook configuration for post-tool-use automation */
|
|
@@ -24,7 +28,7 @@ export interface HookConfig {
|
|
|
24
28
|
|
|
25
29
|
/** Git workflow configuration */
|
|
26
30
|
export interface GitConfig {
|
|
27
|
-
/** Branch name prefix (e.g., "
|
|
31
|
+
/** Branch name prefix (e.g., "feat" → "feat/feature-name") */
|
|
28
32
|
branchPrefix?: string;
|
|
29
33
|
/** Branches protected from direct edits */
|
|
30
34
|
protectedBranches?: string[];
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ct-capacitor-ota
|
|
3
|
+
description: Capacitor 8 native runtime and Capgo OTA live updates — channels, encryption, staged rollouts, and store-safe over-the-air delivery
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Capacitor OTA (Capgo) Patterns
|
|
7
|
+
|
|
8
|
+
Ship JavaScript, HTML, and CSS changes to installed apps in minutes instead of waiting days for app store review. Capgo's `@capgo/capacitor-updater` swaps the web bundle the native shell loads; native code still ships through the stores.
|
|
9
|
+
|
|
10
|
+
**Golden rule:** OTA updates only the **web layer**. Anything that changes native code (new plugin, native dependency, Capacitor major bump) requires a new store binary. Never OTA a bundle whose native contract differs from the installed shell.
|
|
11
|
+
|
|
12
|
+
## Versions (as of June 2026)
|
|
13
|
+
|
|
14
|
+
| Package | Version | Notes |
|
|
15
|
+
| -------------------------- | -------- | ----------------------------------------------------------- |
|
|
16
|
+
| `@capacitor/core` & CLI | ^8.3.x | 8.3.1 (2026-04-16); Node 22+, SPM default for new iOS apps |
|
|
17
|
+
| `@capgo/capacitor-updater` | ^8.x | Major version tracks Capacitor major; v8 stores channels locally |
|
|
18
|
+
| `@capgo/cli` | latest | `npx @capgo/cli@latest …` — pin in CI |
|
|
19
|
+
|
|
20
|
+
Capacitor 8 platform floor: Node 22, iOS deployment target 15.0 (Xcode 26+), Android `minSdk 24` / `compileSdk 36` / `targetSdk 36`, Gradle plugin 8.13.0, wrapper 8.14.3, Kotlin 2.2.20.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm i @capgo/capacitor-updater && npx cap sync
|
|
26
|
+
npx @capgo/cli@latest init <API_KEY> # adds app, injects notifyAppReady, builds, uploads, tests
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Critical: notifyAppReady()
|
|
30
|
+
|
|
31
|
+
Call it **as early as possible** after the web bundle boots. If the plugin doesn't hear it within `appReadyTimeout` (default 10s), it assumes the bundle is broken and **auto-rolls back** to the previous one.
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { CapacitorUpdater } from "@capgo/capacitor-updater";
|
|
35
|
+
|
|
36
|
+
// Run once, at the top of app bootstrap — before heavy async work.
|
|
37
|
+
CapacitorUpdater.notifyAppReady();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This is the safety net that makes OTA reversible. A bundle that white-screens never sticks.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// capacitor.config.ts
|
|
46
|
+
import type { CapacitorConfig } from "@capacitor/cli";
|
|
47
|
+
|
|
48
|
+
const config: CapacitorConfig = {
|
|
49
|
+
appId: "com.example.app",
|
|
50
|
+
appName: "Example",
|
|
51
|
+
webDir: "dist",
|
|
52
|
+
plugins: {
|
|
53
|
+
CapacitorUpdater: {
|
|
54
|
+
autoUpdate: "atBackground", // default — apply when app backgrounds
|
|
55
|
+
appReadyTimeout: 10000,
|
|
56
|
+
responseTimeout: 20,
|
|
57
|
+
autoDeleteFailed: true,
|
|
58
|
+
autoDeletePrevious: true,
|
|
59
|
+
resetWhenUpdate: true, // wipe OTA bundles on native app update
|
|
60
|
+
// defaultChannel: "Development", // TEST/QA BUILDS ONLY — omit in production
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default config;
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### autoUpdate modes
|
|
69
|
+
|
|
70
|
+
| Value | Behavior |
|
|
71
|
+
| --------------------------- | -------------------------------------------------------------------- |
|
|
72
|
+
| `false` / `"off"` | No automatic updates — drive everything from JS |
|
|
73
|
+
| `"atBackground"` *(default)* | Download silently, apply when app moves to background |
|
|
74
|
+
| `"atInstall"` | Apply immediately after a fresh install / native update, then background |
|
|
75
|
+
| `"onLaunch"` | Apply immediately on next launch, then background |
|
|
76
|
+
| `"always"` | Apply immediately whenever an update check runs |
|
|
77
|
+
| `"onlyDownload"` | Download but never auto-apply; emit `updateAvailable` for you to act |
|
|
78
|
+
|
|
79
|
+
`periodCheckDelay` controls how often the plugin polls (seconds, **minimum 600** / 10 min).
|
|
80
|
+
|
|
81
|
+
## Channels
|
|
82
|
+
|
|
83
|
+
A channel points to one JS bundle. Devices check their assigned channel; you swap which bundle a channel points to for instant rollout/rollback — no rebuild.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx @capgo/cli@latest bundle upload --channel=production # build → upload → assign
|
|
87
|
+
npx @capgo/cli@latest channel set production -s default # make it the cloud default
|
|
88
|
+
npx @capgo/cli@latest channel set beta --self-assign # allow in-app setChannel()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Channel precedence** (highest first): forced device mapping → cloud per-device override → config `defaultChannel` → cloud Default Channel (the ~99% path).
|
|
92
|
+
|
|
93
|
+
**Production rule:** ship production binaries **without** `defaultChannel` so users follow the cloud default. Only set `defaultChannel` in builds explicitly handed to testers.
|
|
94
|
+
|
|
95
|
+
Recommended ladder with semver pre-release tags:
|
|
96
|
+
|
|
97
|
+
| Channel | Bundle version | Audience |
|
|
98
|
+
| ------------- | --------------- | ------------------------- |
|
|
99
|
+
| `development` | `1.2.3-dev.1` | dev / emulator builds |
|
|
100
|
+
| `qa` | `1.2.3-qa.1` | QA verification |
|
|
101
|
+
| `staging` | `1.2.3-rc.1` | production-like sign-off |
|
|
102
|
+
| `production` | `1.2.3` | end users (store version) |
|
|
103
|
+
|
|
104
|
+
### Runtime channel switching
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// Requires "Allow device self-assignment" enabled on the channel (plugin v8+).
|
|
108
|
+
await CapacitorUpdater.setChannel({ channel: "beta", triggerAutoUpdate: true });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
In v8 `setChannel` stores the choice **locally** and takes effect on the next check (no replication lag). The backend still validates self-assignment permission.
|
|
112
|
+
|
|
113
|
+
## Manual / background update flow
|
|
114
|
+
|
|
115
|
+
For full control, set `autoUpdate: false` and drive the lifecycle yourself:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { App } from "@capacitor/app";
|
|
119
|
+
import { SplashScreen } from "@capacitor/splash-screen";
|
|
120
|
+
|
|
121
|
+
CapacitorUpdater.addListener("download", ({ percent }) => console.log(`↓ ${percent}%`));
|
|
122
|
+
|
|
123
|
+
App.addListener("appStateChange", async ({ isActive }) => {
|
|
124
|
+
if (isActive) {
|
|
125
|
+
const latest = await CapacitorUpdater.getLatest();
|
|
126
|
+
if (latest.url) {
|
|
127
|
+
const bundle = await CapacitorUpdater.download({ version: latest.version, url: latest.url });
|
|
128
|
+
await CapacitorUpdater.next({ id: bundle.id }); // apply on next background
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Key methods: `getLatest()`, `download()`, `next()` (queue), `set()` (apply now — destroys JS context), `current()`, `reload()`, `reset()`. Events: `download`, `downloadComplete`, `updateAvailable`, `downloadFailed`, `appReady`, `majorAvailable`.
|
|
135
|
+
|
|
136
|
+
## End-to-end encryption (v2)
|
|
137
|
+
|
|
138
|
+
Hybrid RSA-2048 + AES-256: a random AES key encrypts each bundle; your **private** RSA key signs the AES key + checksum; the app decrypts with the embedded **public** key. Only your users can read the bundle — not even Capgo.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npx @capgo/cli@latest key create # generates the key pair
|
|
142
|
+
npx @capgo/cli@latest bundle upload --key-v2 --channel=production
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- `.capgo_key_v2` (private) — **never commit**; store as a CI secret.
|
|
146
|
+
- `.capgo_key_v2.pub` (public) — safe to commit; injected into the app.
|
|
147
|
+
- Once encryption is configured, the plugin **strictly enforces** a valid signature — an unsigned/tampered bundle is rejected.
|
|
148
|
+
|
|
149
|
+
## Delta updates
|
|
150
|
+
|
|
151
|
+
Direct/instant updates auto-enable delta updates — only changed files transfer, not the whole bundle. Keep deltas small: pin build tooling and strip build timestamps so unchanged files hash identically. Use `--delta-only` to require delta-capable clients.
|
|
152
|
+
|
|
153
|
+
## Staged rollouts & monitoring
|
|
154
|
+
|
|
155
|
+
- Start at ~10% of users, widen over 24h; schedule heavy rollouts for 1–4 AM local time, Wi-Fi-preferred.
|
|
156
|
+
- Healthy targets: ~95% adoption within 24h, ~82% global success rate.
|
|
157
|
+
- Roll back instantly from the channel dashboard (crown icon on the previous build) — devices pick it up on next check-in.
|
|
158
|
+
- Watch the `downloadFailed` / `appReady` ratio and Capgo stats; a spike means rollback.
|
|
159
|
+
|
|
160
|
+
## Store compliance
|
|
161
|
+
|
|
162
|
+
OTA of JS/HTML/CSS is allowed: **Apple** developer agreement §3.3.2 (since iOS 4.3) and **Google Play** (code in a webview/JS VM is exempt from the non-Play update restriction). Shipping *native* code OTA is not — that's what gets apps pulled.
|
|
163
|
+
|
|
164
|
+
## Anti-Patterns
|
|
165
|
+
|
|
166
|
+
1. **Not calling `notifyAppReady()`** — every bundle auto-rolls back after `appReadyTimeout`. This is the #1 "OTA doesn't work" cause.
|
|
167
|
+
2. **OTA-ing a native change** — bundle that needs a plugin/native API the installed shell lacks crashes. Match the bundle to the shipped native version; bump the store binary first.
|
|
168
|
+
3. **`defaultChannel` in production builds** — pins users off the cloud default and breaks instant rollback. Test builds only.
|
|
169
|
+
4. **Committing `.capgo_key_v2`** — leaks your signing key. Commit only the `.pub`.
|
|
170
|
+
5. **`periodCheckDelay` < 600** — silently clamped; the plugin won't poll faster than 10 minutes.
|
|
171
|
+
6. **100% rollout with no canary** — a bad bundle hits everyone at once. Stage it.
|
|
172
|
+
7. **Unpinned `@capgo/cli@latest` in CI** — a CLI minor can change upload behavior mid-pipeline. Pin the version.
|
|
173
|
+
|
|
174
|
+
## See Also
|
|
175
|
+
|
|
176
|
+
- `ct-vite-vitest-patterns` — the build that produces `webDir`
|
|
177
|
+
- `ct-playwright-patterns` — E2E-verify a bundle before you upload it
|
|
178
|
+
- `ct-typescript-conventions` — typing `capacitor.config.ts` and the updater API
|