@livx.cc/appwrap 0.54.0 → 0.56.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.
Files changed (70) hide show
  1. package/package.json +5 -4
  2. package/runtime/app/main-page.ts +0 -2
  3. package/runtime/app/shell/capabilities.manifest.ts +27 -92
  4. package/runtime/app/shell/config.ts +4 -0
  5. package/runtime/app/shell/env.ts +8 -1
  6. package/runtime/app/shell/handlers.ts +2 -2
  7. package/runtime/tests/bridge-response-delivery.test.ts +10 -1
  8. package/runtime/tests/env-scheme.test.ts +50 -0
  9. package/scripts/stage-assets.mjs +1 -1
  10. package/src/cli.ts +424 -621
  11. package/src/config.ts +8 -1
  12. package/src/handlers.ts +1 -1
  13. package/src/packs.ts +174 -0
  14. package/src/testing.ts +173 -0
  15. package/templates/module-pack/README.md +44 -0
  16. package/templates/module-pack/handler.ts +9 -0
  17. package/templates/module-pack/manifest.ts +27 -0
  18. package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
  19. package/runtime/app/shell/billing-offer.ts +0 -54
  20. package/runtime/app/shell/handlers-billing.ts +0 -662
  21. package/runtime/app/shell/handlers-health.ts +0 -178
  22. package/runtime/app/shell/handlers-widget.ts +0 -97
  23. package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
  24. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
  25. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
  26. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
  27. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
  28. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
  29. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
  30. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
  31. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
  32. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
  33. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
  34. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
  35. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
  36. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
  37. package/runtime/tests/billing-offer.test.ts +0 -61
  38. package/runtime-desktop/autotest/index.html +0 -77
  39. package/runtime-desktop/bridge-shim/build.sh +0 -9
  40. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  41. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  42. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  43. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  44. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  45. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  46. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  47. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  48. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  49. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  50. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  51. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  52. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  53. package/runtime-desktop/src-tauri/build.rs +0 -3
  54. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  55. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  56. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  57. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  58. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  59. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  60. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  61. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  62. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  63. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  64. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  65. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  66. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  67. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  68. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  69. package/src/desktop.ts +0 -300
  70. package/src/plugin/host.ts +0 -242
package/src/cli.ts CHANGED
@@ -10,10 +10,10 @@
10
10
  */
11
11
  import { execFileSync, spawn, spawnSync } from 'child_process';
12
12
  import { createHash } from 'crypto';
13
- import { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
13
+ import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
14
14
  import { builtinModules } from 'module';
15
15
  import { networkInterfaces, tmpdir } from 'os';
16
- import { basename, delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
16
+ import { basename, dirname, extname, join, resolve } from 'path';
17
17
  import { pathToFileURL } from 'url';
18
18
  // PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
19
19
  // the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
@@ -23,10 +23,11 @@ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manif
23
23
  // type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
24
24
  import type { AppwrapConfig } from './config';
25
25
  import { unknownConfigKeys } from './config';
26
+ import { resolveModulePacks, type ResolvedModule, type SyncContext } from './packs';
26
27
  import { createHash } from 'crypto';
27
- import { buildInfoPlist, buildMacEntitlements, deriveDesktopConfig, parseMacProfile, resolveCargoPath, stampTauriConf } from './desktop';
28
- import type { DesktopShellConfig } from './desktop';
29
- import { APPLE_ICON_GRID_SCALE, makeDockRuntimeIcon } from './icon';
28
+ // Icon helpers re-exported so the EE desktop lane (which owns the Tauri chassis) can reuse them via
29
+ // `@livx.cc/appwrap/cli` — the CE/EE split (Phase D) moved the desktop lane out but keeps these shared.
30
+ export { APPLE_ICON_GRID_SCALE, makeDockRuntimeIcon } from './icon';
30
31
  import {
31
32
  androidScreenOrientation,
32
33
  applyBuildNumberFlag,
@@ -97,16 +98,61 @@ const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
97
98
  };
98
99
 
99
100
  /** Resolve a bundled asset dir. A published tarball ships runtime/ + templates/ at the package root
100
- * (one level above src/); the monorepo resolves them at the repo root (three levels up). */
101
+ * (one level above src/); the monorepo resolves them at the repo root (three levels up). Returns the
102
+ * REAL path — a dev checkout may stage the package-root dir as a symlink to the repo-root source (what
103
+ * prepack does with a copy), and Bun's cpSync refuses a symlink as a copy-source root. */
101
104
  function resolveAssetRoot(rel: string): string {
102
105
  const local = resolve(import.meta.dir, '..', rel);
103
- return existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
106
+ const dir = existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
107
+ try { return realpathSync(dir); } catch { return dir; }
104
108
  }
105
109
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
110
+ /** Ordered runtime-template roots copied into native/ (later roots overlay earlier — file-level
111
+ * last-wins), defaulting to the single built-in template. runCli (A5) can extend this so a consumer
112
+ * (a host) layers extra shell files on top of CE without forking the template. A single root
113
+ * is byte-identical to the pre-overlay behavior. */
114
+ let TEMPLATE_ROOTS: string[] = [TEMPLATE_DIR];
106
115
  const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
107
- /** Desktop (Tauri chassis) template — phase-0, macOS-only. Its own generated wrapper (`native-desktop/`)
108
- * is disposable, like `native/`; the template stays canonical (edit here, regenerate propagates). */
109
- const DESKTOP_TEMPLATE_DIR = resolveAssetRoot('runtime-desktop');
116
+ /** Scaffold for `appwrap create-module` — a starter module pack (see packs.ts / @livx.cc/appwrap/testing). */
117
+ const MODULE_PACK_TEMPLATE_DIR = resolveAssetRoot('templates/module-pack');
118
+ /** Desktop (Tauri chassis) template dir. CE ships no bundled desktop template — the desktop lane is
119
+ * host-owned: a consumer points this at its own template via `runCli({ desktopTemplateDir })` (A5).
120
+ * A `let` so runCli can override it; empty by default so the mobile lanes are unaffected. The desktop
121
+ * lane itself lives in the EE package (Phase D split) and reads this value via `getDesktopTemplateDir()`
122
+ * — CE keeps the seam (set by `runCli({ desktopTemplateDir })`) but ships no desktop code. */
123
+ let DESKTOP_TEMPLATE_DIR = '';
124
+
125
+ /** The desktop (Tauri) template dir set via `runCli({ desktopTemplateDir })`. Read by the EE desktop
126
+ * lane (hosted on top of CE through the `platforms` seam) so the seam stays authoritative in one place. */
127
+ export function getDesktopTemplateDir(): string {
128
+ return DESKTOP_TEMPLATE_DIR;
129
+ }
130
+
131
+ /**
132
+ * Composition seam for a HOST that wraps this CLI (any consumer). Every field is
133
+ * optional and no-op by default, so the bare `appwrap` bin behaves exactly as before. A host calls
134
+ * `runCli({...})` to extend the CLI without forking it.
135
+ */
136
+ export interface CliOptions {
137
+ /** Extra or overriding top-level commands, keyed by command name — consulted BEFORE the built-in
138
+ * switch, so a host can add a command or replace a built-in one. */
139
+ commands?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
140
+ /** Pluggable platform handlers for `dev`/`build` (keyed by the platform arg, e.g. 'desktop') —
141
+ * consulted BEFORE the built-in platform routing, so a host can add/replace a platform lane. The
142
+ * 4th arg is the driving command ('dev' | 'build') so a single handler (e.g. the EE desktop lane)
143
+ * can branch dev-vs-build — `dev desktop` and `build desktop` route to the SAME handler key. */
144
+ platforms?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[], command: 'dev' | 'build') => Promise<void> | void>;
145
+ /** Module packs injected by the host, applied BEFORE the app config's own `modulePacks` (so the app
146
+ * can still override them, last-wins). */
147
+ modulePacks?: string[];
148
+ /** Extra runtime-template overlay roots, appended after the built-in template (later roots win). */
149
+ templateRoots?: string[];
150
+ /** Override for the desktop (Tauri) template dir. */
151
+ desktopTemplateDir?: string;
152
+ }
153
+
154
+ /** The host composition for THIS run, set once by runCli. Empty by default → bare-bin behavior. */
155
+ let cliOptions: CliOptions = {};
110
156
 
111
157
  /** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
112
158
  * emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
@@ -116,10 +162,67 @@ const CLI_VERSION: string = (await import(resolve(import.meta.dir, '..', 'packag
116
162
 
117
163
  // Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
118
164
  // Top-level await resolves before any command dispatches at the bottom of this file.
119
- const { MODULES, OPTIONAL_GROUPS } = (await import(
165
+ const { MODULES, OPTIONAL_GROUPS, LEGACY_BUNDLED_GROUPS, MANIFEST_SCHEMA_VERSION } = (await import(
120
166
  resolve(TEMPLATE_DIR, 'app/shell/capabilities.manifest')
121
167
  )) as typeof CapManifest;
122
168
 
169
+ /** The composed module set for THIS command run: built-ins + the config's `modulePacks`, resolved once
170
+ * at command entry by `applyModulePacks`. Stays `null` when NO packs are configured — every code path
171
+ * then reads the built-in `MODULES` directly, so a pack-less build is byte-identical to pre-packs. */
172
+ let RESOLVED_MODULES: ResolvedModule[] | null = null;
173
+
174
+ /** The active module set the CLI derives from — merged (built-ins + packs) when packs are configured,
175
+ * else the built-ins verbatim. */
176
+ function moduleList(): CapManifest.ModuleManifest[] {
177
+ return RESOLVED_MODULES ? RESOLVED_MODULES.map((r) => r.manifest) : MODULES;
178
+ }
179
+
180
+ /** Pack provenance for a module name (source label + pack dir) — used by the generator to locate a
181
+ * pack module's handler file + native source. Undefined for a built-in. */
182
+ function packInfo(name: string): { source: string; packDir: string } | undefined {
183
+ const r = RESOLVED_MODULES?.find((m) => m.manifest.name === name);
184
+ return r && r.packDir ? { source: r.source, packDir: r.packDir } : undefined;
185
+ }
186
+
187
+ /** Resolve the config's `modulePacks` (if any) into RESOLVED_MODULES for this run. No-op (leaves the
188
+ * built-ins untouched → byte-identical) when the config declares no packs. Async because pack manifests
189
+ * are dynamically imported; callers await it before the sync/regenerate pipeline. */
190
+ async function applyModulePacks(cwd: string, cfg: AppwrapConfig): Promise<void> {
191
+ // Host-injected packs first (the host's), then the app config's — so the app can override, last-wins.
192
+ const refs = [...(cliOptions.modulePacks ?? []), ...(cfg.modulePacks ?? [])];
193
+ if (refs.length === 0) {
194
+ RESOLVED_MODULES = null;
195
+ } else {
196
+ const ctx: SyncContext = {
197
+ platform: 'both',
198
+ env: process.env.APPWRAP_ENV || 'default',
199
+ ci: !!process.env.CI,
200
+ };
201
+ const map = await resolveModulePacks({
202
+ builtins: MODULES,
203
+ builtinSchemaVersion: MANIFEST_SCHEMA_VERSION,
204
+ packRefs: refs,
205
+ ctx,
206
+ cwd,
207
+ });
208
+ RESOLVED_MODULES = [...map.values()];
209
+ }
210
+ warnUnknownModules(cfg);
211
+ }
212
+
213
+ /** Warn (never fail) for any name in `modules` that resolves to neither a built-in nor a pack module —
214
+ * with the pack model it's easy to list a capability (e.g. 'billing') and forget to add its pack to
215
+ * `modulePacks`, which would otherwise silently no-op (no handler, no capability). */
216
+ function warnUnknownModules(cfg: AppwrapConfig): void {
217
+ if (!cfg.modules) return;
218
+ const known = new Set(moduleList().map((m) => m.name));
219
+ for (const name of cfg.modules) {
220
+ if (!known.has(name)) {
221
+ console.warn(`⚠ module '${name}' is listed in \`modules\` but isn't a built-in or provided by any \`modulePacks\` — did you forget to add its pack? (ignored)`);
222
+ }
223
+ }
224
+ }
225
+
123
226
  /** Native requirements composed for a build: the union (deduped) of the active modules' self-contained
124
227
  * manifest declarations. Two modes:
125
228
  * - `modules` ABSENT (legacy): every capability active; permissions come ONLY from `permissions{}`
@@ -137,20 +240,25 @@ interface NativeReqs {
137
240
  androidGradleDeps: string[];
138
241
  androidKotlin: boolean; // any active module ships Kotlin native source
139
242
  androidManifestApp: string[]; // raw XML injected inside AndroidManifest <application>
140
- nativeSrc: string[]; // active modules' nativeSrc dir names (under runtime/modules-native/)
141
- widgetAppGroup?: string; // 'group.<appId>' when the `widget` module is active (App Group id
142
- // shared by the app + its widget extension; templated into both)
243
+ nativeSrc: string[]; // active BUILT-IN modules' nativeSrc dir names (under runtime/modules-native/)
244
+ packNativeSrc: Array<{ name: string; srcDir: string }>; // active PACK modules' native source (absolute src dirs)
245
+ packHandlers: Array<{ name: string; source: string; packDir: string; file: string; fn: string }>; // active pack modules with a register handler (for the generated barrel + staging)
246
+ packModules: Array<Pick<CapManifest.ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>>; // active pack modules' handshake-relevant subset (their capabilities aren't in the runtime's static MODULES)
143
247
  }
144
248
 
145
249
  function nativeReqs(cfg: AppwrapConfig): NativeReqs {
146
- const optIn = MODULES.filter((m) => !m.core);
250
+ const modules = moduleList();
251
+ const optIn = modules.filter((m) => !m.core);
147
252
  // Legacy default (no `modules` key) = every opt-in capability EXCEPT strictly-opt-in own-file
148
253
  // modules (OPTIONAL_GROUPS, e.g. health): those carry deps/perms legacy won't stamp, so they must
149
254
  // be explicitly requested. Explicit mode = exactly what `modules` lists.
150
255
  const active = cfg.modules
151
256
  ? new Set(cfg.modules)
152
- : new Set(optIn.filter((m) => !OPTIONAL_GROUPS.includes(m.group as (typeof OPTIONAL_GROUPS)[number])).map((m) => m.name));
153
- const activeMods = MODULES.filter((m) => m.core || active.has(m.name));
257
+ : new Set(optIn.filter((m) =>
258
+ !OPTIONAL_GROUPS.includes(m.group as (typeof OPTIONAL_GROUPS)[number]) ||
259
+ LEGACY_BUNDLED_GROUPS.includes(m.group as (typeof LEGACY_BUNDLED_GROUPS)[number])
260
+ ).map((m) => m.name));
261
+ const activeMods = modules.filter((m) => m.core || active.has(m.name));
154
262
 
155
263
  const iosPlist: Array<{ key: string; usage: string }> = [];
156
264
  const seenKeys = new Set<string>();
@@ -158,6 +266,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
158
266
  const gradle = new Set<string>();
159
267
  const iosEntitlements: Record<string, boolean | string | string[]> = {};
160
268
  const nativeSrc: string[] = [];
269
+ const packNativeSrc: NativeReqs['packNativeSrc'] = [];
270
+ const packHandlers: NativeReqs['packHandlers'] = [];
271
+ const packModules: NativeReqs['packModules'] = [];
161
272
  const androidManifestApp: string[] = [];
162
273
  let androidKotlin = false;
163
274
 
@@ -174,7 +285,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
174
285
  Object.assign(iosEntitlements, m.ios?.entitlements ?? {});
175
286
  if (m.android?.kotlin) androidKotlin = true;
176
287
  if (m.android?.manifestApplication) androidManifestApp.push(m.android.manifestApplication);
177
- if (m.nativeSrc) nativeSrc.push(m.nativeSrc);
288
+ // built-in native source resolves under runtime/modules-native/; pack native source (below)
289
+ // resolves under the pack's own dir, so it must not be conflated with the built-in dir names.
290
+ if (m.nativeSrc && !packInfo(m.name)) nativeSrc.push(m.nativeSrc);
178
291
  }
179
292
  } else {
180
293
  // legacy: only what `permissions{}` declares (via the key maps) — no behavior change
@@ -186,6 +299,18 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
186
299
  }
187
300
  }
188
301
 
302
+ // Active PACK modules (from the config's modulePacks) — their native source + register handler
303
+ // resolve against the pack's own dir. No-op when no packs are configured (packInfo → undefined).
304
+ for (const m of activeMods) {
305
+ const info = packInfo(m.name);
306
+ if (!info) continue;
307
+ if (m.nativeSrc) packNativeSrc.push({ name: m.name, srcDir: join(info.packDir, 'native-src', m.nativeSrc) });
308
+ if (m.handler) packHandlers.push({ name: m.name, source: info.source, packDir: info.packDir, file: m.handler.file, fn: m.handler.fn });
309
+ // The runtime's static MODULES doesn't include pack modules, so their capabilities must be carried
310
+ // into the generated handshake map explicitly (see generateModuleArtifacts + buildCapabilityMap).
311
+ packModules.push({ name: m.name, core: m.core, capabilities: m.capabilities, group: m.group });
312
+ }
313
+
189
314
  return {
190
315
  explicit: !!cfg.modules,
191
316
  activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
@@ -197,15 +322,14 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
197
322
  androidKotlin,
198
323
  androidManifestApp,
199
324
  nativeSrc,
200
- // App Group for the widget extension ↔ app shared container. Derived from the app id so the module
201
- // stays app-agnostic; templated into app.entitlements + the extension's entitlements/Swift.
202
- widgetAppGroup: active.has('widget') ? `group.${cfg.id}` : undefined,
325
+ packNativeSrc,
326
+ packHandlers,
327
+ packModules,
203
328
  };
204
329
  }
205
330
 
206
331
  /** Map a strippable optional group → its handler file + register fn (for the generated barrel). */
207
332
  const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
208
- health: { file: './handlers-health', fn: 'registerHealthHandlers' },
209
333
  oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
210
334
  reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
211
335
  scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
@@ -213,22 +337,112 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
213
337
  tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
214
338
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
215
339
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
216
- widget: { file: './handlers-widget', fn: 'registerWidgetHandlers' },
340
+ // billing/health/widget live in host-provided module packs (not part of CE) — a consumer opts in via modulePacks.
217
341
  };
218
342
 
343
+ /** The bare specifier a pack handler uses to import a built-in shell API — rewritten to a relative
344
+ * `./<mod>` when the file is staged next to the shell sources. A pack authored out-of-repo imports
345
+ * e.g. `@livx.cc/appwrap/runtime/app/shell/bridge` (resolvable, since the npm package ships runtime/),
346
+ * so it typechecks standalone; staging drops it beside the real shell files, where `./bridge` resolves. */
347
+ const SHELL_API_SPECIFIER = /(['"])@livx\.cc\/appwrap\/runtime\/app\/shell\//g;
348
+
349
+ /** Rewrite a pack handler's shell-API imports from the package specifier to relative, so the file
350
+ * resolves once staged beside the real shell sources. Pure (exported for tests). */
351
+ export function rewritePackShellImports(src: string): string {
352
+ return src.replace(SHELL_API_SPECIFIER, '$1./');
353
+ }
354
+
355
+ /** Resolve a pack-local relative import specifier to an absolute source file (trying the usual TS/JS
356
+ * extensions + an index file), or null if it doesn't resolve to a real file. */
357
+ function resolvePackImport(fromDir: string, spec: string): string | null {
358
+ const base = resolve(fromDir, spec);
359
+ const cands = [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.jsx`, join(base, 'index.ts'), join(base, 'index.js')];
360
+ return cands.find((c) => existsSync(c) && statSync(c).isFile()) ?? null;
361
+ }
362
+
363
+ /** Replace an exact (quoted) import specifier in source with another — anchored so `./x` never
364
+ * partial-matches `./x-y`. Pure (exported for tests). */
365
+ export function replaceImportSpecifier(src: string, oldSpec: string, newSpec: string): string {
366
+ const esc = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
367
+ return src.replace(new RegExp(`(['"])${esc}\\1`, 'g'), `$1${newSpec}$1`);
368
+ }
369
+
370
+ /** Stage a pack source file into the shell, namespaced `pack-<label>-<name>.ts` (never colliding with a
371
+ * built-in file — keeps built-in staged filenames stable, preserving the byte-compare gate). Rewrites
372
+ * (a) shell-API imports → relative `./<x>`, and (b) pack-LOCAL relative imports → their namespaced
373
+ * staged sibling, staging each RECURSIVELY — so a handler's own helper modules (e.g. billing's
374
+ * billing-offer.ts) are staged too. `staged` memoizes by abs path (dedup + cycle guard). `entryName`
375
+ * overrides the staged basename for the handler entry (so the barrel imports it by module identity). */
376
+ function stagePackFile(shell: string, label: string, packDir: string, absFile: string, staged: Map<string, string>, entryName?: string): string {
377
+ const cached = staged.get(absFile);
378
+ if (cached) return cached;
379
+ if (!existsSync(absFile)) {
380
+ console.error(`✖ module pack "${label}": imported file not found: ${absFile}`);
381
+ process.exit(1);
382
+ }
383
+ const name = entryName ?? basename(absFile).replace(/\.(tsx?|jsx?)$/, '');
384
+ const destName = `pack-${label}-${name}.ts`;
385
+ const spec = `./${destName.replace(/\.ts$/, '')}`;
386
+ staged.set(absFile, spec); // set BEFORE recursing so an import cycle terminates
387
+
388
+ let src = rewritePackShellImports(readFileSync(absFile, 'utf8'));
389
+ const fromDir = dirname(absFile);
390
+ for (const imp of new Bun.Transpiler({ loader: loaderForEntry(absFile) }).scanImports(src)) {
391
+ if (!imp.path.startsWith('.')) continue; // only pack-LOCAL relative imports need staging
392
+ const depAbs = resolvePackImport(fromDir, imp.path);
393
+ if (!depAbs || !depAbs.startsWith(packDir + '/')) continue; // must stay inside the pack dir
394
+ const depSpec = stagePackFile(shell, label, packDir, depAbs, staged);
395
+ src = replaceImportSpecifier(src, imp.path, depSpec);
396
+ }
397
+ writeFileSync(join(shell, destName), src);
398
+ return spec;
399
+ }
400
+
401
+ /** Stage a pack module's handler (+ its pack-local imports) into the shell. Returns the import
402
+ * specifier the generated barrel imports the handler by. */
403
+ function stagePackHandler(shell: string, h: NativeReqs['packHandlers'][number]): string {
404
+ const srcFile = resolve(h.packDir, h.file);
405
+ if (!existsSync(srcFile)) {
406
+ console.error(`✖ module pack "${h.source}": handler file not found: ${srcFile}`);
407
+ process.exit(1);
408
+ }
409
+ const label = h.source.replace(/[^a-zA-Z0-9_-]/g, '_');
410
+ const spec = stagePackFile(shell, label, h.packDir, srcFile, new Map(), h.name);
411
+ console.log(` pack ← ${h.source} (${h.name} handler)`);
412
+ return spec;
413
+ }
414
+
219
415
  /** Generate the two composition artifacts in the wrapper: the active capability list (drives the
220
416
  * handshake map) and the optional-handler barrel (imports only active strippable groups). */
221
417
  function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
222
418
  const shell = join(outDir, 'app/shell');
419
+ // Sweep stale pack-staged handlers first so a removed/renamed pack never leaves an orphan behind
420
+ // (self-pruning, like regenerateMobilePlugins wiping its dir) — the active ones are re-staged below.
421
+ for (const f of existsSync(shell) ? readdirSync(shell) : []) {
422
+ if (f.startsWith('pack-') && f.endsWith('.ts')) rmSync(join(shell, f), { force: true });
423
+ }
223
424
  writeFileSync(
224
425
  join(shell, 'active-modules.generated.ts'),
225
- `/** Generated by \`appwrap\` from the appwrap config \`modules\`. Do not edit. */\n` +
226
- `export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n`
426
+ `/** Generated by \`appwrap\` from the appwrap config \`modules\` + \`modulePacks\`. Do not edit. */\n` +
427
+ `import type { ModuleManifest } from './capabilities.manifest';\n` +
428
+ `export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n` +
429
+ // Pack modules aren't in the runtime's static MODULES, so their handshake capabilities travel here
430
+ // and buildCapabilityMap merges them (empty for a pack-less build).
431
+ `export const PACK_MODULES: Array<Pick<ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>> = ${JSON.stringify(req.packModules)};\n`
227
432
  );
228
433
 
229
434
  const groups = req.activeOptionalGroups.filter((g) => OPTIONAL_GROUP_HANDLERS[g]);
230
- const imports = groups.map((g) => `import { ${OPTIONAL_GROUP_HANDLERS[g].fn} } from '${OPTIONAL_GROUP_HANDLERS[g].file}';`).join('\n');
231
- const calls = groups.map((g) => ` ${OPTIONAL_GROUP_HANDLERS[g].fn}();`).join('\n');
435
+ const importLines = groups.map((g) => `import { ${OPTIONAL_GROUP_HANDLERS[g].fn} } from '${OPTIONAL_GROUP_HANDLERS[g].file}';`);
436
+ const callLines = groups.map((g) => ` ${OPTIONAL_GROUP_HANDLERS[g].fn}();`);
437
+ // Active PACK modules with a register handler — staged into the shell + APPENDED after the built-in
438
+ // groups (empty when no packs → byte-identical to the pre-packs barrel).
439
+ for (const h of req.packHandlers) {
440
+ const spec = stagePackHandler(shell, h);
441
+ importLines.push(`import { ${h.fn} } from '${spec}';`);
442
+ callLines.push(` ${h.fn}();`);
443
+ }
444
+ const imports = importLines.join('\n');
445
+ const calls = callLines.join('\n');
232
446
  writeFileSync(
233
447
  join(shell, 'optional-handlers.generated.ts'),
234
448
  `/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
@@ -394,9 +608,13 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
394
608
  const file = join(iosDir, 'app.entitlements');
395
609
  const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements, ...cfg.iosEntitlements };
396
610
  if (!!cfg.push?.enabled && cfg.push?.ios !== false) ent['aps-environment'] = cfg.push.apsEnvironment ?? 'development';
397
- // Widget module → App Group on the MAIN app target (the extension's own entitlements file gets the
398
- // same group via token substitution). Derived from the app id so no per-app config is needed.
399
- if (req.widgetAppGroup) ent['com.apple.security.application-groups'] = [req.widgetAppGroup];
611
+ // Resolve build tokens in entitlement values (e.g. a module's `__APP_GROUP__` → group.<appId>) so a
612
+ // module declaring an app-derived entitlement (widget's App Group) needs no hardcoded stamper case.
613
+ const tokens = buildTokens(cfg);
614
+ for (const [k, v] of Object.entries(ent)) {
615
+ if (typeof v === 'string') ent[k] = substituteBuildTokens(v, tokens);
616
+ else if (Array.isArray(v)) ent[k] = v.map((x) => substituteBuildTokens(x, tokens));
617
+ }
400
618
  const keys = Object.keys(ent);
401
619
  if (keys.length === 0) { rmSync(file, { force: true }); return; }
402
620
  const val = (v: boolean | string | string[]): string =>
@@ -426,29 +644,52 @@ function stampPrivacyManifest(outDir: string, cfg: AppwrapConfig, req: NativeReq
426
644
  if (active) console.log(` priv ← NSPrivacyTracking=true${cfg.trackingDomains?.length ? ` (${cfg.trackingDomains.length} domain${cfg.trackingDomains.length > 1 ? 's' : ''})` : ''}`);
427
645
  }
428
646
 
429
- /** Copy active modules' native source (runtime/modules-native/<name>/) into native/ — only when the
430
- * module is active, so module native code stays stripped from builds that don't use it. */
431
- function copyModuleNativeSrc(outDir: string, req: NativeReqs): void {
432
- for (const name of req.nativeSrc) {
433
- const src = join(MODULES_NATIVE_DIR, name);
434
- if (!existsSync(src)) { console.warn(`⚠ module nativeSrc not found: ${src}`); continue; }
647
+ /** Copy active modules' native source (runtime/modules-native/<name>/ for built-ins, <packDir>/
648
+ * native-src/<name>/ for packs) into native/ — only when the module is active, so module native code
649
+ * stays stripped from builds that don't use it. Returns the set of dest paths (relative to outDir) it
650
+ * copied, so regenerateCore can prune a now-INACTIVE module's native files on re-sync (they mirror the
651
+ * App_Resources layout and cpSync only overwrites, never deletes). */
652
+ function copyModuleNativeSrc(outDir: string, req: NativeReqs): Set<string> {
653
+ const copied = new Set<string>();
654
+ const copyFrom = (src: string, label: string) => {
655
+ if (!existsSync(src)) { console.warn(`⚠ module nativeSrc not found: ${src}`); return; }
435
656
  cpSync(src, outDir, { recursive: true, force: true });
436
- console.log(` natv ← module '${name}' native source`);
437
- }
657
+ for (const rel of collectRelFiles(src)) copied.add(rel);
658
+ console.log(` natv ← ${label} native source`);
659
+ };
660
+ for (const name of req.nativeSrc) copyFrom(join(MODULES_NATIVE_DIR, name), `module '${name}'`);
661
+ for (const { name, srcDir } of req.packNativeSrc) copyFrom(srcDir, `pack module '${name}'`);
662
+ return copied;
438
663
  }
439
664
 
440
- /** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc). Currently
441
- * only `__APP_GROUP__` → the derived App Group id, applied to the widget extension's Swift/entitlements/
442
- * plist so the module ships app-agnostic and the CLI stamps the app-specific group in. Idempotent (the
443
- * source is re-copied verbatim each sync, then re-substituted). */
444
- function substituteModuleTokens(outDir: string, req: NativeReqs): void {
445
- if (!req.widgetAppGroup) return;
665
+ /** Build-time tokens a module's native source / entitlements may reference — resolved from the app
666
+ * config so a module ships app-agnostic and the CLI stamps the app-specific value in. Not module-
667
+ * specific: any module (built-in or pack) can use these tokens. Currently `__APP_GROUP__` → the App
668
+ * Group id shared by the app + an extension (e.g. widget); extend here as new app-derived needs arise. */
669
+ function buildTokens(cfg: AppwrapConfig): Record<string, string> {
670
+ return { __APP_GROUP__: `group.${cfg.id}` };
671
+ }
672
+
673
+ /** Substitute any {@link buildTokens} in a stamped string (entitlement value or native-source file). */
674
+ function substituteBuildTokens(s: string, tokens: Record<string, string>): string {
675
+ let out = s;
676
+ for (const [tok, val] of Object.entries(tokens)) out = out.replaceAll(tok, val);
677
+ return out;
678
+ }
679
+
680
+ /** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc) — applied
681
+ * generically to every iOS extension file, so a module's app-agnostic source (e.g. the widget
682
+ * extension's Swift/entitlements/plist) gets the app-specific value stamped in. Idempotent (source is
683
+ * re-copied verbatim each sync, then re-substituted). No-op when no extensions were copied. */
684
+ function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
446
685
  const extRoot = join(outDir, 'App_Resources/iOS/extensions');
447
686
  if (!existsSync(extRoot)) return;
687
+ const tokens = buildTokens(cfg);
688
+ let touched = false;
448
689
  const subst = (file: string) => {
449
690
  const s = readFileSync(file, 'utf8');
450
- if (!s.includes('__APP_GROUP__')) return;
451
- writeFileSync(file, s.replaceAll('__APP_GROUP__', req.widgetAppGroup!));
691
+ const next = substituteBuildTokens(s, tokens);
692
+ if (next !== s) { writeFileSync(file, next); touched = true; }
452
693
  };
453
694
  const walk = (dir: string) => {
454
695
  for (const e of readdirSync(dir, { withFileTypes: true })) {
@@ -458,7 +699,7 @@ function substituteModuleTokens(outDir: string, req: NativeReqs): void {
458
699
  }
459
700
  };
460
701
  walk(extRoot);
461
- console.log(` tokn ← __APP_GROUP__ = ${req.widgetAppGroup} (widget extension)`);
702
+ if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (extension source)`);
462
703
  }
463
704
 
464
705
  /** Hosts to register as Android App Links (autoVerify https intent-filters). Explicit
@@ -555,7 +796,7 @@ function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
555
796
  : (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
556
797
  }
557
798
 
558
- async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
799
+ export async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
559
800
  const configPath = resolveConfigPath(cwd, flags);
560
801
  if (!existsSync(configPath)) {
561
802
  console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
@@ -584,7 +825,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
584
825
  }
585
826
 
586
827
  // loader:'server' bakes serverUrl into the shell and loads it via NSURL/WKWebView. A scheme-less
587
- // value (e.g. "agf.circlesup.com") produces an unusable URL — the app silently fails to load (or
828
+ // value (e.g. "app.example.com") produces an unusable URL — the app silently fails to load (or
588
829
  // shows a stale page) with no error. Normalize to https:// when no scheme is present, and fail loud
589
830
  // on a genuinely malformed URL rather than shipping a broken build.
590
831
  if (cfg.serverUrl) {
@@ -603,7 +844,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
603
844
  return cfg;
604
845
  }
605
846
 
606
- function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
847
+ export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
607
848
  // Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
608
849
  // (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
609
850
  const es = cfg.envSwitcher;
@@ -628,6 +869,7 @@ export const SHELL_CONFIG = {
628
869
  loader: ${JSON.stringify(cfg.loader ?? 'app')} as 'app' | 'file' | 'server',
629
870
  serverUrl: ${JSON.stringify(cfg.serverUrl ?? '')},
630
871
  backendOrigin: ${JSON.stringify(cfg.backendOrigin ?? '')},
872
+ urlScheme: ${JSON.stringify(cfg.urlScheme ?? '')},
631
873
  debug: ${JSON.stringify(cfg.debug ?? false)},
632
874
  debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
633
875
  devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
@@ -1391,7 +1633,7 @@ function stampAndroidVersion(outDir: string, cfg: AppwrapConfig): void {
1391
1633
  }
1392
1634
 
1393
1635
  /** Locate the icon source: explicit cfg.icon, else the largest icon in the PWA manifest. */
1394
- function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
1636
+ export function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
1395
1637
  if (cfg.icon) {
1396
1638
  const p = resolve(cwd, cfg.icon);
1397
1639
  if (existsSync(p)) return p;
@@ -1626,17 +1868,23 @@ export function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig):
1626
1868
  const dir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
1627
1869
  // Files the template still provides must survive an override removal (regenerateCore ran first and
1628
1870
  // re-copied them) → protect them from the override prune.
1629
- const templateFiles = existsSync(TEMPLATE_DIR) ? collectRelFiles(TEMPLATE_DIR, templateCopyFilter) : new Set<string>();
1871
+ const templateFiles = allTemplateFiles();
1630
1872
  // ACTIVE modules' native source needs the SAME protection: copyModuleNativeSrc re-copied it into
1631
1873
  // these exact relative paths, but it lives under runtime/modules-native/<name>/, which
1632
1874
  // templateCopyFilter deliberately excludes — so it is absent from templateFiles above. Without this,
1633
1875
  // dropping a consumer override that happened to shadow a module file prunes the MODULE's own copy
1634
1876
  // too (the prune only sees "was in the overrides manifest, isn't in overrides now"), silently
1635
1877
  // deleting e.g. res/xml/appwrap_widget_info.xml and failing the build at AAPT.
1636
- for (const name of nativeReqs(cfg).nativeSrc) {
1878
+ const reqs = nativeReqs(cfg);
1879
+ for (const name of reqs.nativeSrc) {
1637
1880
  const modDir = join(MODULES_NATIVE_DIR, name);
1638
1881
  if (existsSync(modDir)) for (const rel of collectRelFiles(modDir)) templateFiles.add(rel);
1639
1882
  }
1883
+ // Same protection for active PACK modules' native source (lives under the pack dir, absent from the
1884
+ // template file set) — so an override removal never prunes a pack module's own copied files.
1885
+ for (const { srcDir } of reqs.packNativeSrc) {
1886
+ if (existsSync(srcDir)) for (const rel of collectRelFiles(srcDir)) templateFiles.add(rel);
1887
+ }
1640
1888
  if (!existsSync(dir)) {
1641
1889
  // No overrides now: prune anything a PRIOR overrides run left behind (renamed/removed override files).
1642
1890
  pruneStale(outDir, OVERRIDES_MANIFEST, new Set(), templateFiles);
@@ -1795,12 +2043,27 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
1795
2043
  // so without this a relocated source lingers in native/ and gets re-bundled.
1796
2044
  const TEMPLATE_MANIFEST = '.appwrap-template-manifest.json';
1797
2045
  const OVERRIDES_MANIFEST = '.appwrap-overrides-manifest.json';
2046
+ /** Tracks module-owned native source copied into native/ (built-in + pack), so a module DEACTIVATED
2047
+ * since the last sync has its App_Resources files pruned rather than lingering (cpSync never deletes). */
2048
+ const MODULE_NATIVE_MANIFEST = '.appwrap-module-native-manifest.json';
1798
2049
 
1799
2050
  /** Files under TEMPLATE_DIR to skip when copying/walking it (deps, build output, PWA staging, per-module
1800
2051
  * native — those are handled selectively elsewhere). Shared by the cpSync filter and the prune walk so
1801
2052
  * the copied set and the pruned set are defined identically. */
1802
- const templateCopyFilter = (src: string): boolean =>
1803
- !/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(TEMPLATE_DIR.length));
2053
+ const templateCopyFilterFor = (root: string) => (src: string): boolean =>
2054
+ !/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(root.length));
2055
+ /** Single-root filter for the built-in template (the common path). */
2056
+ const templateCopyFilter = templateCopyFilterFor(TEMPLATE_DIR);
2057
+ /** Every file the active template roots contribute, relative to each root (deduped across roots) —
2058
+ * the union prune set + override-protection set for the overlay chain. */
2059
+ function allTemplateFiles(): Set<string> {
2060
+ const out = new Set<string>();
2061
+ for (const root of TEMPLATE_ROOTS) {
2062
+ if (!existsSync(root)) continue;
2063
+ for (const rel of collectRelFiles(root, templateCopyFilterFor(root))) out.add(rel);
2064
+ }
2065
+ return out;
2066
+ }
1804
2067
 
1805
2068
  /** File (not dir) paths under `root`, relative to it, skipping entries `accept` rejects. */
1806
2069
  function collectRelFiles(root: string, accept: (abs: string) => boolean = () => true): Set<string> {
@@ -1851,16 +2114,23 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
1851
2114
  if (opts.firstRun && !req.explicit) {
1852
2115
  console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
1853
2116
  }
1854
- cpSync(TEMPLATE_DIR, outDir, {
1855
- recursive: true,
1856
- force: true, // explicit: Bun's cpSync does not overwrite existing files by default
1857
- // modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
1858
- // Match RELATIVE to TEMPLATE_DIR — when installed from npm, TEMPLATE_DIR itself sits under
1859
- // node_modules/, so testing the absolute path would wrongly exclude the entire template.
1860
- filter: templateCopyFilter,
1861
- });
1862
- // Delete template files removed/renamed since the last regenerate (cpSync only overwrites, never deletes).
1863
- pruneStale(outDir, TEMPLATE_MANIFEST, collectRelFiles(TEMPLATE_DIR, templateCopyFilter));
2117
+ // Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
2118
+ // consumer template can add or replace shell files without forking CE. Single root (default) is
2119
+ // exactly the pre-overlay copy.
2120
+ for (const root of TEMPLATE_ROOTS) {
2121
+ if (!existsSync(root)) { console.warn(`⚠ template root not found: ${root}`); continue; }
2122
+ cpSync(root, outDir, {
2123
+ recursive: true,
2124
+ force: true, // explicit: Bun's cpSync does not overwrite existing files by default
2125
+ // modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
2126
+ // Match RELATIVE to the root — when installed from npm, a root itself sits under node_modules/,
2127
+ // so testing the absolute path would wrongly exclude the entire template.
2128
+ filter: templateCopyFilterFor(root),
2129
+ });
2130
+ }
2131
+ // Delete template files removed/renamed since the last regenerate (cpSync only overwrites, never
2132
+ // deletes) — pruned against the UNION of all roots' files so an overlay-provided file isn't culled.
2133
+ pruneStale(outDir, TEMPLATE_MANIFEST, allTemplateFiles());
1864
2134
  stampShellConfig(outDir, cfg);
1865
2135
  stampNativeScriptConfig(outDir, cfg);
1866
2136
  stampIOSDisplayName(outDir, cfg, req);
@@ -1872,8 +2142,12 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
1872
2142
  stampKotlin(outDir, req.androidKotlin);
1873
2143
  generateModuleArtifacts(outDir, req);
1874
2144
  regenerateMobilePlugins(cwd, cfg, outDir); // config-gated TS plugins → bridge handlers (in-process)
1875
- copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
1876
- substituteModuleTokens(outDir, req); // stamp __APP_GROUP__ etc. into the copied extension source
2145
+ const moduleNativeFiles = copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
2146
+ // Prune a now-inactive module's native files (in the prior manifest, not re-copied this sync). Protect
2147
+ // base-template files so a module that overrode a template App_Resources file, once removed, reverts to
2148
+ // the template copy instead of being deleted outright.
2149
+ pruneStale(outDir, MODULE_NATIVE_MANIFEST, moduleNativeFiles, allTemplateFiles());
2150
+ substituteModuleTokens(outDir, cfg); // stamp __APP_GROUP__ etc. into the copied extension source
1877
2151
  stampLaunchScreen(outDir, cfg);
1878
2152
  stampStoreKit(cwd, outDir, cfg);
1879
2153
  stampPush(cwd, outDir, cfg);
@@ -1908,6 +2182,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1908
2182
 
1909
2183
  console.log(`🎁 appwrap init → ${outDir}`);
1910
2184
  mkdirSync(outDir, { recursive: true });
2185
+ await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
1911
2186
  regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1912
2187
  copyCiTemplates(cwd, outDir, cfg, 'force' in flags, 'ci' in flags); // GH workflows: opt-in (--ci), first-time only; fastlane lane: re-emit on --force
1913
2188
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
@@ -1928,6 +2203,7 @@ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: Ap
1928
2203
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1929
2204
  process.exit(1);
1930
2205
  }
2206
+ await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
1931
2207
  regenerateCore(cwd, outDir, cfg, { flags });
1932
2208
  applyOverrides(cwd, outDir, cfg); // overrides win last
1933
2209
  stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
@@ -2002,11 +2278,11 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
2002
2278
  */
2003
2279
  async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2004
2280
  const platform = positionals[0];
2005
- if (platform === 'desktop') return devDesktop(cwd, flags);
2281
+ if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals, 'dev');
2006
2282
  const sim = 'sim' in flags || positionals[1] === 'sim';
2007
2283
  const wantDebug = 'debug' in flags;
2008
2284
  if (platform !== 'ios' && platform !== 'android') {
2009
- console.error('Usage: appwrap dev <ios|android|desktop> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
2285
+ console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--device <id|ip[:port]>] [--url <devserver>|--port <p>]');
2010
2286
  process.exit(1);
2011
2287
  }
2012
2288
  const cfg = await loadConfig(cwd, flags);
@@ -2040,6 +2316,7 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
2040
2316
  : stamped?.loader === 'server'
2041
2317
  ? { ...cfg, loader: 'server', serverUrl: stamped.serverUrl, debug: stamped.debug }
2042
2318
  : cfg;
2319
+ await applyModulePacks(cwd, simCfg); // resolve config `modulePacks` (no-op when none) before deriving
2043
2320
  regenerateCore(cwd, outDir, simCfg, { flags });
2044
2321
  applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
2045
2322
  stampIOSExtensionVersions(outDir, simCfg); // AFTER overrides: keep extension version matching the app's (Xcode warns on mismatch)
@@ -2378,552 +2655,12 @@ async function watchAndSync(cwd: string, flags: Record<string, string>, outDir:
2378
2655
  * Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
2379
2656
  * never live in the appwrap config. iOS distribution signing/upload is the fastlane release lane's job (the
2380
2657
  * cicd templates); `--release` here just builds the Release config for the device. */
2381
- // ─────────────────────────────── desktop (Tauri chassis) lane ────────────────────────────────
2382
- // Phase-0, macOS-only. Mirrors the managed-CNG model of the mobile lanes but far leaner: copy the
2383
- // `runtime-desktop/` template into the app's disposable `native-desktop/`, stamp the app's identity +
2384
- // window shape (shell_config.json, embedded into the Rust binary) and Tauri config (tauri.conf.json),
2385
- // stage the built web dist, then `cargo run`/`build`. The template is canonical; `native-desktop/` is
2386
- // gitignored + regenerated each run (framework edits propagate — same contract as `native/`).
2387
-
2388
- /** Fail clearly on non-macOS — the desktop chassis is macOS-only for phase-0 (osascript dialogs/
2389
- * notifications, `open`, the app-data path are all macOS). */
2390
- function ensureDesktopMacOS(): void {
2391
- if (process.platform !== 'darwin') {
2392
- console.error(`✖ appwrap desktop is macOS-only for now (phase-0). Detected platform: ${process.platform}.`);
2393
- process.exit(1);
2394
- }
2395
- }
2396
-
2397
- /** Skip the heavy/generated dirs when copying the desktop template (Rust build output + Tauri codegen +
2398
- * deps): they regenerate, and `target/` alone is GBs. Matches RELATIVE to the template root. */
2399
- function desktopCopyFilter(src: string): boolean {
2400
- const rel = src.slice(DESKTOP_TEMPLATE_DIR.length);
2401
- return !/(^|\/)(target|gen|node_modules)(\/|$)/.test(rel);
2402
- }
2403
-
2404
- /** Resolve a `desktop.server.command` to an absolute path: an absolute/relative path is resolved
2405
- * against the app root; a bare name is looked up on `path` (build-time PATH). Falls back to the bare
2406
- * name (the Rust shell will surface the spawn ENOENT) when it's nowhere. */
2407
- function resolveServerCommand(command: string, cwd: string, path: string): string {
2408
- if (command.includes('/')) return resolve(cwd, command);
2409
- for (const dir of path.split(pathDelimiter).filter(Boolean)) {
2410
- const abs = join(dir, command);
2411
- if (existsSync(abs)) return abs;
2412
- }
2413
- return command;
2414
- }
2415
-
2416
- /** The bundled splash shown while a local-server app boots (before the shell navigates to the live
2417
- * server). A dependency-free, theme-neutral holding page. */
2418
- function desktopSplashHtml(name: string): string {
2419
- const safe = name.replace(/</g, '&lt;').replace(/&/g, '&amp;');
2420
- return `<!doctype html><html><head><meta charset="utf-8"><title>${safe}</title>
2421
- <style>html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;
2422
- flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple-system,system-ui,sans-serif}
2423
- .s{width:34px;height:34px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:r .8s linear infinite}
2424
- @keyframes r{to{transform:rotate(360deg)}}#e{color:#f87171;max-width:70%;text-align:center;line-height:1.5}</style>
2425
- </head><body><div class="s" id="spin"></div><div id="m">Starting ${safe}…</div><div id="e"></div></body></html>`;
2426
- }
2427
-
2428
- /** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
2429
- * `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
2430
- * frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
2431
- /**
2432
- * Make `cfg.plugins` LIVE (A.1 loader): resolve each entry (path OR npm package), bun-build its
2433
- * entrypoint to a single embedded file under `native-desktop/plugins/`, bun-build the multiplexed
2434
- * plugin-host once, and stamp `{ pluginHost, plugins:[{bundleId, attachTo, bundlePath}] }` into the
2435
- * shell config. The Rust shell (plugin_host.rs) spawns the host with the stamped plugins at boot.
2436
- *
2437
- * A.1 scope: manifest/perms/native-deps merge is STUBBED (noted) — full `capabilities.manifest.ts`
2438
- * reuse (perms/entitlements union) lands later. `bundleId` here is the file/package basename (a
2439
- * pre-load build id for the bundle filename only); the authoritative routing/diagnostic identifier is
2440
- * the plugin def's `name`, which the host reads from the bundle after `import()`.
2441
- */
2442
- export function regeneratePlugins(cwd: string, cfg: AppwrapConfig, outDir: string, shell: DesktopShellConfig): void {
2443
- const entries = cfg.plugins ?? [];
2444
- if (entries.length === 0) return;
2445
- const outPlugins = join(outDir, 'plugins');
2446
- mkdirSync(outPlugins, { recursive: true });
2447
- const bun = process.execPath; // the CLI runs under bun → the exact runtime to build/spawn with
2448
-
2449
- const bunBuild = (entrypoint: string, outfile: string): boolean => {
2450
- try {
2451
- execFileSync(bun, ['build', entrypoint, '--target=bun', '--outfile', outfile], { stdio: 'pipe' });
2452
- return true;
2453
- } catch (e: unknown) {
2454
- console.warn(`⚠ plugin bun-build failed (${entrypoint}): ${e instanceof Error ? e.message : String(e)}`);
2455
- return false;
2456
- }
2457
- };
2458
-
2459
- // Build the host bundle once (from THIS package's src/plugin/host.ts).
2460
- const hostSrc = resolve(import.meta.dir, 'plugin', 'host.ts');
2461
- const hostOut = join(outPlugins, 'host.js');
2462
- if (!bunBuild(hostSrc, hostOut)) {
2463
- console.warn('⚠ plugin host failed to build — plugins disabled for this build.');
2464
- return;
2465
- }
2466
-
2467
- const stamped: DesktopShellConfig['plugins'] = [];
2468
- for (const raw of entries) {
2469
- const name = typeof raw === 'string' ? raw : raw.name;
2470
- const attachTo = typeof raw === 'string' ? undefined : raw.attachTo;
2471
- // Resolve: an existing path (relative to the app root) wins; else treat as an npm package name.
2472
- let entrypoint = resolve(cwd, name);
2473
- if (!existsSync(entrypoint)) {
2474
- try {
2475
- entrypoint = (Bun as unknown as { resolveSync(id: string, parent: string): string }).resolveSync(name, cwd);
2476
- } catch {
2477
- console.warn(`⚠ plugin "${name}" not found (no such path, not resolvable as an npm package) — skipping.`);
2478
- continue;
2479
- }
2480
- }
2481
- const bundleId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2482
- const bundlePath = join(outPlugins, `${bundleId}.js`);
2483
- if (!bunBuild(entrypoint, bundlePath)) continue;
2484
- stamped.push({ bundleId, attachTo, bundlePath });
2485
- console.log(` plugin ← ${name} (attachTo: ${attachTo ?? 'def-declared'})`);
2486
- }
2487
-
2488
- if (stamped.length > 0) {
2489
- shell.pluginHost = hostOut;
2490
- shell.plugins = stamped;
2491
- // TODO(A.x): read each plugin's `manifest` and merge perms/entitlements/native-deps via
2492
- // generateModuleArtifacts + capabilities.manifest.ts (plugins are manifest-compatible with modules).
2493
- }
2494
- }
2495
-
2496
- function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, pushSigned = false): void {
2497
- if (!existsSync(DESKTOP_TEMPLATE_DIR)) {
2498
- console.error(`✖ Desktop template not found at ${DESKTOP_TEMPLATE_DIR}`);
2499
- process.exit(1);
2500
- }
2501
- const srcTauri = join(outDir, 'src-tauri');
2502
- mkdirSync(outDir, { recursive: true });
2503
- cpSync(DESKTOP_TEMPLATE_DIR, outDir, { recursive: true, force: true, filter: desktopCopyFilter });
2504
- // `native-desktop/` is generated + disposable (regenerated from the template on every build) — mark
2505
- // the whole tree git-invisible so a consuming repo never commits generated native code (incl. the
2506
- // copied `crates/` + `src-tauri/`). A self-ignoring `*` .gitignore works regardless of the app's root
2507
- // .gitignore; mirrors the mobile lane's disposable-`native/` convention. Written AFTER cpSync so it wins.
2508
- writeFileSync(join(outDir, '.gitignore'), '*\n');
2509
-
2510
- const shell = deriveDesktopConfig(cfg);
2511
- shell.pushSigned = pushSigned;
2512
-
2513
- // Stage the frontendDist INTO the wrapper (mirrors mobile copyPwa) so it's a stable relative path
2514
- // independent of where the app project sits. Local-server apps (`desktop.server`) have no meaningful
2515
- // web dist — the window loads the live server — so we stage a tiny bundled SPLASH instead (shown
2516
- // while the server boots, then the shell navigates away from it).
2517
- const stagedDist = join(outDir, 'dist');
2518
- rmSync(stagedDist, { recursive: true, force: true });
2519
- if (shell.server) {
2520
- mkdirSync(stagedDist, { recursive: true });
2521
- writeFileSync(join(stagedDist, 'index.html'), desktopSplashHtml(shell.name));
2522
- } else {
2523
- const dist = resolve(cwd, cfg.pwaDist);
2524
- if (!existsSync(dist)) {
2525
- console.error(`✖ Web dist not found at ${dist} — build the PWA first (or check pwaDist).`);
2526
- process.exit(1);
2527
- }
2528
- cpSync(dist, stagedDist, { recursive: true, force: true });
2529
- }
2530
-
2531
- // Stage the app-owned browser sub-window chrome (‹ › ⟳ + address bar + profile▾) INTO the dist so
2532
- // `WebviewUrl::App("appwrap-browser-chrome.html")` resolves it (browser_tab::browser_subwindow_open).
2533
- const chromeSrc = join(DESKTOP_TEMPLATE_DIR, 'chrome', 'appwrap-browser-chrome.html');
2534
- if (existsSync(chromeSrc)) {
2535
- copyFileSync(chromeSrc, join(stagedDist, 'appwrap-browser-chrome.html'));
2536
- } else {
2537
- console.warn(`⚠ browser chrome not found at ${chromeSrc} — sub-window chrome will 404.`);
2538
- }
2539
-
2540
- // Local-server mode: resolve `command` (bare name → PATH lookup) and `cwd` to ABSOLUTE, and stamp
2541
- // the build-time PATH — a GUI-launched .app inherits the minimal launchd PATH (no ~/.bun/bin), so
2542
- // a bare-name spawn (or a child that itself shells out to `bun`/`node`) would fail there.
2543
- if (shell.server) {
2544
- const s = shell.server;
2545
- s.cwd = s.cwd ? resolve(cwd, s.cwd) : cwd;
2546
- s.command = resolveServerCommand(s.command, cwd, process.env.PATH ?? '');
2547
- s.path = process.env.PATH ?? '';
2548
- if (!existsSync(s.command)) {
2549
- console.warn(`⚠ desktop.server.command not found (${s.command}) — the server will fail to spawn at launch.`);
2550
- }
2551
- }
2552
- // Resolve the handlers path to ABSOLUTE (against the app root): the shell binary runs from a
2553
- // different cwd, and for `build desktop` the .app runs from wherever it's installed — an absolute
2554
- // path is the only one that stays valid. The handler file is NOT copied into the wrapper/.app; the
2555
- // app dir (with its node_modules) must remain present at runtime (documented in the config type).
2556
- if (shell.handlers) {
2557
- const abs = resolve(cwd, shell.handlers);
2558
- if (!existsSync(abs)) {
2559
- console.warn(`⚠ desktop.handlers points to a missing file (${abs}) — the sidecar will not spawn.`);
2560
- }
2561
- shell.handlers = abs;
2562
- // Stamp the ABSOLUTE Bun binary the sidecar must be spawned with: the CLI itself runs under bun,
2563
- // so process.execPath IS the exact runtime the app was built with. A GUI-launched .app inherits
2564
- // the launchd PATH (no ~/.bun/bin), so the Rust shell can't rely on a bare `bun` lookup.
2565
- shell.handlersRuntime = process.execPath;
2566
- }
2567
- // Resolve + bun-build the configured plugins (make `cfg.plugins` LIVE). Runs BEFORE writing
2568
- // shell_config (and before applyOverrides, which happens after regenerateDesktop returns) so the
2569
- // stamped plugin bundles + host are embedded and the .app is self-contained.
2570
- regeneratePlugins(cwd, cfg, outDir, shell);
2571
- writeFileSync(join(srcTauri, 'shell_config.json'), JSON.stringify(shell, null, 2) + '\n');
2572
-
2573
- const tauriConfPath = join(srcTauri, 'tauri.conf.json');
2574
- const template = JSON.parse(readFileSync(tauriConfPath, 'utf8')) as Record<string, unknown>;
2575
- const stamped = stampTauriConf(template, shell, '../dist'); // relative to src-tauri/
2576
- writeFileSync(tauriConfPath, JSON.stringify(stamped, null, 2) + '\n');
2577
-
2578
- stampDesktopRuntimeIcon(cwd, cfg, join(srcTauri, 'icons', 'icon.png'));
2579
- }
2580
-
2581
- /** Stamp the Tauri RUNTIME icon (`src-tauri/icons/icon.png`, embedded via `generate_context!`) from
2582
- * the app's real icon — else it OVERRIDES the bundle icns/Assets.car in cmd+tab/Dock with the template
2583
- * placeholder square. Downscale to 512 via sips → round the macOS app-icon corners + ensure RGBA (our
2584
- * dependency-free codec) → write. On any failure, keep the template placeholder (Tauri needs *some*
2585
- * RGBA icon.png to compile) and warn — never fail the build over an icon. */
2586
- function stampDesktopRuntimeIcon(cwd: string, cfg: AppwrapConfig, destPng: string): void {
2587
- const source = findIconSource(cwd, cfg);
2588
- if (!source) return; // no cfg.icon / manifest icon → keep template placeholder
2589
- const tmpPng = join(tmpdir(), `appwrap-desktop-icon-${Date.now()}.png`);
2590
- try {
2591
- // Artwork at Apple's icon-grid fraction of the canvas, then pad — full-bleed runtime icons
2592
- // render visibly larger than bundle-masked Dock neighbors (no OS-applied margin/mask).
2593
- const art = Math.round(512 * APPLE_ICON_GRID_SCALE);
2594
- execFileSync('sips', ['-s', 'format', 'png', '-z', String(art), String(art), source, '--out', tmpPng], { stdio: 'ignore' });
2595
- writeFileSync(destPng, makeDockRuntimeIcon(readFileSync(tmpPng), 512));
2596
- console.log(` desktop runtime icon ← ${source} (${art}² rounded on 512² canvas)`);
2597
- } catch (e: unknown) {
2598
- console.warn(`⚠ desktop runtime icon stamp failed (${e instanceof Error ? e.message : String(e)}) — keeping placeholder`);
2599
- } finally {
2600
- rmSync(tmpPng, { force: true });
2601
- }
2602
- }
2603
-
2604
- /** Shared target dir for the desktop crate — reuse the template's warm `target/` so a stamped copy
2605
- * builds incrementally (~seconds) instead of a ~4-min cold compile of the Tauri dep tree. */
2606
- /** Resolve a spawnable `cargo` (bin + PATH-augmented env) for the desktop lane. rustup installs into
2607
- * `~/.cargo/bin`, which a GUI/login shell often lacks on PATH → a bare spawn throws a raw ENOENT. We
2608
- * append it when the binary lives there, else fail with an actionable install/source message. */
2609
- function desktopCargo(): { bin: string; env: NodeJS.ProcessEnv } {
2610
- const home = process.env.HOME ?? '';
2611
- const res = resolveCargoPath({
2612
- path: process.env.PATH ?? '',
2613
- home,
2614
- delimiter: pathDelimiter,
2615
- hasCargo: (dir) => existsSync(join(dir, 'cargo')),
2616
- });
2617
- if (!res.found) {
2618
- console.error(
2619
- `✖ cargo not found on PATH${home ? ` (and no ${home}/.cargo/bin/cargo)` : ''}.\n` +
2620
- ` Install the Rust toolchain via rustup: https://rustup.rs\n` +
2621
- ` If it's already installed, add it to this shell: source "$HOME/.cargo/env"`
2622
- );
2623
- process.exit(1);
2624
- }
2625
- if (res.augmented) console.log(` cargo ← ${home}/.cargo/bin (appended to PATH; not in your shell's PATH)`);
2626
- return {
2627
- bin: res.bin,
2628
- env: { ...process.env, PATH: res.path, CARGO_TARGET_DIR: join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target') },
2629
- };
2630
- }
2631
-
2632
- /** `appwrap dev desktop` — stamp the wrapper + `cargo run` (launches the window; blocks until closed). */
2633
- async function devDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
2634
- ensureDesktopMacOS();
2635
- const cfg = await loadConfig(cwd, flags);
2636
- const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2637
- buildWebIfBundled(cwd, cfg, flags);
2638
- regenerateDesktop(cwd, cfg, outDir);
2639
- const { bin, env } = desktopCargo();
2640
- console.log(`▶ cargo run (appwrap desktop shell — ${cfg.name})`);
2641
- execFileSync(bin, ['run'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
2642
- }
2643
-
2644
- /** Generate `AppIcon.icns` from the app's icon PNG via `sips` + `iconutil` (macOS-only, like the whole
2645
- * desktop lane). Returns the icns basename, or null when no icon source / tooling — skip gracefully. */
2646
- function generateMacIcns(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2647
- const source = findIconSource(cwd, cfg);
2648
- if (!source) return null;
2649
- const iconset = join(tmpdir(), `appwrap-icns-${Date.now()}.iconset`);
2650
- mkdirSync(iconset, { recursive: true });
2651
- try {
2652
- for (const px of [16, 32, 128, 256, 512]) {
2653
- execFileSync('sips', ['-z', String(px), String(px), source, '--out', join(iconset, `icon_${px}x${px}.png`)], { stdio: 'ignore' });
2654
- execFileSync('sips', ['-z', String(px * 2), String(px * 2), source, '--out', join(iconset, `icon_${px}x${px}@2x.png`)], { stdio: 'ignore' });
2655
- }
2656
- execFileSync('iconutil', ['-c', 'icns', iconset, '-o', join(resourcesDir, 'AppIcon.icns')], { stdio: 'ignore' });
2657
- return 'AppIcon.icns';
2658
- } catch (e: unknown) {
2659
- console.warn(`⚠ icns generation failed (${e instanceof Error ? e.message : String(e)}) — .app ships without an icon`);
2660
- return null;
2661
- } finally {
2662
- rmSync(iconset, { recursive: true, force: true });
2663
- }
2664
- }
2665
-
2666
- /** Compile a Tahoe-native `Assets.car` (+ its own `AppIcon.icns`) from the app's icon via `actool`,
2667
- * into `resourcesDir`. macOS 26 (Tahoe) resolves a bundle's Finder/Dock icon through
2668
- * `CFBundleIconName` + `Assets.car`, NOT the legacy top-level `.icns`. Returns the asset icon name
2669
- * (`AppIcon`) on success, or null when there's no icon / `xcrun`/`actool` is unavailable or fails —
2670
- * caller then keeps the iconutil `.icns` path (fine pre-26; Tahoe Finder icon may look generic). */
2671
- function compileMacAssetsCar(cwd: string, cfg: AppwrapConfig, resourcesDir: string): string | null {
2672
- const source = findIconSource(cwd, cfg);
2673
- if (!source) return null;
2674
- const work = join(tmpdir(), `appwrap-actool-${Date.now()}`);
2675
- const xcassets = join(work, 'Assets.xcassets');
2676
- const iconset = join(xcassets, 'AppIcon.appiconset');
2677
- try {
2678
- mkdirSync(iconset, { recursive: true });
2679
- // 10 macOS reps: 16/32/128/256/512 pt at @1x and @2x.
2680
- const reps = [16, 32, 128, 256, 512].flatMap((pt) => [
2681
- { pt, scale: 1, px: pt },
2682
- { pt, scale: 2, px: pt * 2 },
2683
- ]);
2684
- const images = reps.map(({ pt, scale, px }) => {
2685
- const filename = `icon_${pt}x${pt}@${scale}x.png`;
2686
- execFileSync('sips', ['-s', 'format', 'png', '-z', String(px), String(px), source, '--out', join(iconset, filename)], { stdio: 'ignore' });
2687
- return { idiom: 'mac', size: `${pt}x${pt}`, scale: `${scale}x`, filename };
2688
- });
2689
- writeFileSync(join(iconset, 'Contents.json'), JSON.stringify({ images, info: { version: 1, author: 'appwrap' } }, null, 2));
2690
- writeFileSync(join(xcassets, 'Contents.json'), JSON.stringify({ info: { version: 1, author: 'appwrap' } }, null, 2));
2691
-
2692
- const partialPlist = join(work, 'partial.plist');
2693
- execFileSync('xcrun', ['actool', xcassets,
2694
- '--compile', resourcesDir,
2695
- '--platform', 'macosx',
2696
- '--target-device', 'mac',
2697
- '--minimum-deployment-target', '11.0',
2698
- '--app-icon', 'AppIcon',
2699
- '--output-partial-info-plist', partialPlist,
2700
- ], { stdio: 'ignore' });
2701
-
2702
- if (!existsSync(join(resourcesDir, 'Assets.car'))) throw new Error('actool produced no Assets.car');
2703
- return 'AppIcon';
2704
- } catch (e: unknown) {
2705
- console.warn(`⚠ actool Assets.car compile skipped (${e instanceof Error ? e.message : String(e)}) — Tahoe Finder icon may look generic; legacy .icns kept`);
2706
- return null;
2707
- } finally {
2708
- rmSync(work, { recursive: true, force: true });
2709
- }
2710
- }
2711
-
2712
- /** Resolved macOS signing lane. `identity` is the cert SHA-1 hash (fed to codesign — unambiguous,
2713
- * unlike a CN which can collide); `cn` is kept for logging; `kind` records the cert class. The
2714
- * aps-environment is NOT derived from `kind` — it comes from the provisioning profile itself. */
2715
- interface MacSigning { identity: string; cn: string; kind: 'developer-id' | 'development'; teamId: string }
2716
-
2717
- /** A matched macOS profile + its own aps-environment entitlement. */
2718
- interface MacProfileMatch { path: string; apsEnvironment?: string }
2719
-
2720
- /** Resolve a real code-signing identity for the desktop lane. Team precedence mirrors stampTeamId:
2721
- * non-placeholder cfg.teamId → $APPWRAP_TEAM_ID → none (adhoc). Identity preference within the team:
2722
- * "Developer ID Application" (direct distribution) → "Apple Development" (local/dev). The CN of a
2723
- * dev cert does NOT carry the team id — match via the certificate's OU field. Returns null (→ adhoc
2724
- * fallback) when no team is set or no identity of either class belongs to it. */
2725
- function resolveMacSigning(cfg: AppwrapConfig): MacSigning | null {
2726
- const teamId = (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId))
2727
- ? process.env.APPWRAP_TEAM_ID?.trim() || null
2728
- : cfg.teamId;
2729
- if (!teamId) return null;
2730
- let listing = '';
2731
- try {
2732
- listing = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' });
2733
- } catch { return null; }
2734
- // find-identity -v lines: ` 1) <40-hex-SHA1> "<CN>"`. Capture BOTH — codesign is fed the hash.
2735
- const entries = [...listing.matchAll(/\b([0-9A-F]{40})\s+"([^"]+)"/g)].map((m) => ({ hash: m[1], cn: m[2] }));
2736
- const teamOf = (cn: string): string | null => {
2737
- try {
2738
- const pem = execFileSync('security', ['find-certificate', '-c', cn, '-p'], { encoding: 'utf8' });
2739
- const subject = execFileSync('openssl', ['x509', '-noout', '-subject'], { input: pem, encoding: 'utf8' });
2740
- return subject.match(/OU\s*=\s*([A-Z0-9]{10})/)?.[1] ?? null;
2741
- } catch { return null; }
2742
- };
2743
- for (const prefix of ['Developer ID Application', 'Apple Development'] as const) {
2744
- const match = entries.find((e) => e.cn.startsWith(prefix) && teamOf(e.cn) === teamId);
2745
- if (match) return { identity: match.hash, cn: match.cn, kind: prefix === 'Developer ID Application' ? 'developer-id' : 'development', teamId };
2746
- }
2747
- return null;
2748
- }
2749
-
2750
- /** Find an installed, unexpired macOS provisioning profile for `<teamId>.<bundleId>`. macOS profiles
2751
- * are `.provisionprofile` (same CMS envelope as iOS `.mobileprovision`; findProvisioningProfiles only
2752
- * scans the latter, so this is the mac twin). Returns the file path, or null (→ sign without the
2753
- * restricted push entitlement — a restricted entitlement with no embedded profile kills the app at
2754
- * launch, which is strictly worse than a signed app without push). */
2755
- function findMacProvisionProfile(teamId: string, bundleId: string, signingCertHash?: string): MacProfileMatch | null {
2756
- const dirs = [
2757
- join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles'),
2758
- join(process.env.HOME ?? '', 'Library/Developer/Xcode/UserData/Provisioning Profiles'),
2759
- ];
2760
- const wanted = signingCertHash?.toUpperCase();
2761
- for (const dir of dirs) {
2762
- if (!existsSync(dir)) continue;
2763
- let files: string[] = [];
2764
- try { files = readdirSync(dir).filter((f) => f.endsWith('.provisionprofile')); } catch { continue; }
2765
- for (const f of files) {
2766
- try {
2767
- const raw = execFileSync('security', ['cms', '-D', '-i', join(dir, f)],
2768
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
2769
- const info = parseMacProfile(raw);
2770
- const { teamId: team, appId, expiration } = info;
2771
- if (team !== teamId || !appId) continue;
2772
- if (expiration && Date.parse(expiration) < Date.now()) continue;
2773
- const profBundle = appId.startsWith(team + '.') ? appId.slice(team.length + 1) : appId;
2774
- if (profBundle !== bundleId && profBundle !== '*') continue;
2775
- // The chosen signing cert MUST be one the profile authorizes (DER SHA-1 == find-identity hash),
2776
- // else codesign succeeds but the app launch is rejected. Mismatch → warn + keep scanning.
2777
- if (wanted) {
2778
- const authorized = info.certificates.some(
2779
- (b64) => createHash('sha1').update(Buffer.from(b64, 'base64')).digest('hex').toUpperCase() === wanted,
2780
- );
2781
- if (!authorized) {
2782
- console.log(` ⚠ profile ${f} does not authorize the chosen signing certificate — skipping`);
2783
- continue;
2784
- }
2785
- }
2786
- return { path: join(dir, f), apsEnvironment: info.apsEnvironment };
2787
- } catch { /* skip unreadable profile */ }
2788
- }
2789
- }
2790
- return null;
2791
- }
2792
-
2793
- /** Assemble a double-clickable `<Name>.app` around the built shell binary. The Info.plist's
2794
- * CFBundleURLTypes is what registers `urlScheme` with LaunchServices — a bare binary can't receive
2795
- * `<scheme>://…` opens (tauri-plugin-deep-link needs the bundle). Signed with a real identity +
2796
- * entitlements + embedded profile when teamId resolves (resolveMacSigning); ad-hoc otherwise. */
2797
- function assembleMacApp(cwd: string, cfg: AppwrapConfig, shell: DesktopShellConfig, outDir: string, binary: string,
2798
- lane?: { signing: MacSigning | null; profile: MacProfileMatch | null }): string {
2799
- const appDir = join(outDir, 'dist-app', `${shell.name}.app`);
2800
- const macosDir = join(appDir, 'Contents/MacOS');
2801
- const resourcesDir = join(appDir, 'Contents/Resources');
2802
- rmSync(appDir, { recursive: true, force: true });
2803
- mkdirSync(macosDir, { recursive: true });
2804
- mkdirSync(resourcesDir, { recursive: true });
2805
- cpSync(binary, join(macosDir, shell.name));
2806
- const iconFile = generateMacIcns(cwd, cfg, resourcesDir) ?? undefined;
2807
- // Also compile a Tahoe-native Assets.car (macOS 26 reads CFBundleIconName/Assets.car, not the .icns).
2808
- // actool emits its own AppIcon.icns alongside — let it replace the iconutil one when present.
2809
- const iconName = compileMacAssetsCar(cwd, cfg, resourcesDir) ?? undefined;
2810
- writeFileSync(join(appDir, 'Contents/Info.plist'), buildInfoPlist(shell, shell.name, iconFile, iconName));
2811
- const signing = lane ? lane.signing : resolveMacSigning(cfg);
2812
- if (signing) {
2813
- // Signed lane: real identity; when an installed macOS profile matches, embed it and claim the
2814
- // push entitlement (restricted → only launches profile-backed). Developer ID gets the hardened
2815
- // runtime (notarization prerequisite; harmless otherwise).
2816
- const profile = lane ? lane.profile : findMacProvisionProfile(signing.teamId, shell.identifier, signing.identity);
2817
- if (profile) cpSync(profile.path, join(appDir, 'Contents/embedded.provisionprofile'));
2818
- else console.log(` ⓘ no macOS provisioning profile installed for ${signing.teamId}.${shell.identifier} — signing without entitlements (cert only)`);
2819
- // aps-environment comes from the PROFILE's own entitlement, not the cert kind — a Developer ID cert
2820
- // paired with a development profile must claim `development`, or the app is killed at launch.
2821
- const aps = profile?.apsEnvironment;
2822
- if (profile && aps !== 'development' && aps !== 'production') {
2823
- console.log(` ⓘ profile has no aps-environment entitlement — signing without push`);
2824
- }
2825
- const args = ['--force', '--deep', '-s', signing.identity];
2826
- // Entitlements ONLY when a profile backs them: application-identifier/team-identifier are
2827
- // RESTRICTED — claiming them without an embedded profile makes macOS refuse to launch the app
2828
- // ("can't be opened"). No profile → plain cert signature (launches fine, no push).
2829
- if (profile) {
2830
- const entitlements = buildMacEntitlements({
2831
- teamId: signing.teamId,
2832
- bundleId: shell.identifier,
2833
- apsEnvironment: aps === 'development' || aps === 'production' ? aps : undefined,
2834
- });
2835
- const entFile = join(outDir, 'dist-app', `${shell.name}.entitlements`);
2836
- writeFileSync(entFile, entitlements);
2837
- args.push('--entitlements', entFile);
2838
- }
2839
- if (signing.kind === 'developer-id') args.push('--options', 'runtime', '--timestamp');
2840
- console.log(`▶ codesign (${signing.cn} [${signing.identity}]${profile ? ', profile embedded' : ''})`);
2841
- execFileSync('codesign', [...args, appDir], { stdio: 'inherit' });
2842
- } else {
2843
- // Ad-hoc sign so Gatekeeper/LaunchServices treat the bundle as intact on this machine.
2844
- // Only flag a skip when a REAL team was requested (placeholder teamId = signing never asked for).
2845
- const requested = process.env.APPWRAP_TEAM_ID?.trim() || (cfg.teamId && !/YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId) ? cfg.teamId : '');
2846
- if (requested) console.log(` ⓘ signed lane skipped — no Developer ID / Apple Development identity for team ${requested}; ad-hoc signing`);
2847
- execFileSync('codesign', ['--force', '--deep', '-s', '-', appDir], { stdio: 'inherit' });
2848
- }
2849
- return appDir;
2850
- }
2851
-
2852
- /** Opt-in notarization (env `APPWRAP_NOTARIZE=1`, off by default). Requires the Developer ID signed
2853
- * lane — assembleMacApp already signs it with `--options runtime --timestamp` (hardened runtime +
2854
- * secure timestamp, both notarization prerequisites; no get-task-allow is ever claimed). Credentials:
2855
- * `APPWRAP_NOTARY_KEYCHAIN_PROFILE` (a `notarytool store-credentials` profile) or an ASC API key —
2856
- * `APPWRAP_NOTARY_KEY_ID` + `APPWRAP_NOTARY_ISSUER` (+ optional `APPWRAP_NOTARY_KEY` p8 path,
2857
- * default ~/.appstoreconnect/private_keys/AuthKey_<id>.p8). */
2858
- function notarizeMacApp(appDir: string, signing: MacSigning | null): void {
2859
- if (process.env.APPWRAP_NOTARIZE !== '1') return;
2860
- if (!signing || signing.kind !== 'developer-id') {
2861
- console.error(`✖ APPWRAP_NOTARIZE=1 needs a Developer ID signed .app (current lane: ${signing ? signing.kind : 'ad-hoc'}) — install a "Developer ID Application" identity for the team and rebuild`);
2862
- process.exit(1);
2863
- }
2864
- const kcProfile = process.env.APPWRAP_NOTARY_KEYCHAIN_PROFILE?.trim();
2865
- const keyId = process.env.APPWRAP_NOTARY_KEY_ID?.trim();
2866
- const issuer = process.env.APPWRAP_NOTARY_ISSUER?.trim();
2867
- const keyPath = process.env.APPWRAP_NOTARY_KEY?.trim()
2868
- || (keyId ? join(process.env.HOME ?? '', `.appstoreconnect/private_keys/AuthKey_${keyId}.p8`) : '');
2869
- let auth: string[];
2870
- if (kcProfile) auth = ['--keychain-profile', kcProfile];
2871
- else if (keyId && issuer) {
2872
- if (!existsSync(keyPath)) {
2873
- console.error(`✖ notarization: ASC API key file not found at ${keyPath} (set APPWRAP_NOTARY_KEY to the .p8 path)`);
2874
- process.exit(1);
2875
- }
2876
- auth = ['--key', keyPath, '--key-id', keyId, '--issuer', issuer];
2877
- } else {
2878
- console.error('✖ APPWRAP_NOTARIZE=1 but no credentials — set APPWRAP_NOTARY_KEY_ID + APPWRAP_NOTARY_ISSUER (ASC API key) or APPWRAP_NOTARY_KEYCHAIN_PROFILE (notarytool store-credentials)');
2879
- process.exit(1);
2880
- }
2881
- const zip = `${appDir}.notarize.zip`;
2882
- rmSync(zip, { force: true });
2883
- execFileSync('ditto', ['-c', '-k', '--keepParent', appDir, zip], { stdio: 'inherit' });
2884
- console.log('▶ notarytool submit --wait (Apple-side processing — can take several minutes)');
2885
- try {
2886
- execFileSync('xcrun', ['notarytool', 'submit', zip, ...auth, '--wait'], { stdio: 'inherit' });
2887
- execFileSync('xcrun', ['stapler', 'staple', appDir], { stdio: 'inherit' });
2888
- } finally {
2889
- rmSync(zip, { force: true });
2890
- }
2891
- console.log(`✓ Notarized + stapled: ${appDir}`);
2892
- }
2893
-
2894
- /** `appwrap build desktop` — stamp the wrapper, `cargo build --release`, then assemble the `.app`. */
2895
- async function buildDesktop(cwd: string, flags: Record<string, string>): Promise<void> {
2896
- ensureDesktopMacOS();
2897
- const cfg = await loadConfig(cwd, flags);
2898
- const outDir = resolve(cwd, flags.out ?? 'native-desktop');
2899
- // Local-server apps load the live server, not a bundled web build — skip it (regenerateDesktop
2900
- // stages a boot splash for them). Otherwise build/stage the PWA as usual.
2901
- if (!cfg.desktop?.server) buildWebIfBundled(cwd, cfg, flags);
2902
- // Resolve the signing lane BEFORE regeneration: shell_config.json is embedded at compile time,
2903
- // and the Rust shell's push capability must only claim 'native' when the .app really ships the
2904
- // profile-backed aps-environment entitlement (signed lane + installed macOS profile).
2905
- const signing = resolveMacSigning(cfg);
2906
- const shellCfg = deriveDesktopConfig(cfg);
2907
- const profile = signing ? findMacProvisionProfile(signing.teamId, shellCfg.identifier, signing.identity) : null;
2908
- // Only let the Rust shell claim NATIVE push when the app will actually ship a profile-backed
2909
- // aps-environment entitlement (profile matched AND it carries the entitlement).
2910
- const nativePush = !!(signing && profile && (profile.apsEnvironment === 'development' || profile.apsEnvironment === 'production'));
2911
- regenerateDesktop(cwd, cfg, outDir, nativePush);
2912
- const { bin, env } = desktopCargo();
2913
- console.log(`▶ cargo build --release (appwrap desktop shell — ${cfg.name})`);
2914
- execFileSync(bin, ['build', '--release'], { cwd: join(outDir, 'src-tauri'), stdio: 'inherit', env });
2915
- const binary = join(DESKTOP_TEMPLATE_DIR, 'src-tauri/target/release/appwrap-desktop-shell');
2916
- console.log(`✓ Desktop binary: ${binary}`);
2917
- const appDir = assembleMacApp(cwd, cfg, shellCfg, outDir, binary, { signing, profile });
2918
- console.log(`✓ App bundle: ${appDir}`);
2919
- notarizeMacApp(appDir, signing);
2920
- }
2921
2658
 
2922
2659
  async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2923
2660
  const platform = positionals[0];
2924
- if (platform === 'desktop') return buildDesktop(cwd, flags);
2661
+ if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals, 'build');
2925
2662
  if (platform !== 'ios' && platform !== 'android') {
2926
- console.error('Usage: appwrap build <ios|android|desktop> [--release] [--aab] [--config <path>] [--out native]');
2663
+ console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config <path>] [--out native]');
2927
2664
  process.exit(1);
2928
2665
  }
2929
2666
  const outDir = resolve(cwd, flags.out ?? 'native');
@@ -3277,8 +3014,8 @@ function pinTeamIdToConfig(configPath: string, teamId: string): void {
3277
3014
  * `app/www` (PWA staging) is build OUTPUT and `node_modules`/`platforms`/`hooks` are generated, so all four
3278
3015
  * would make the hash unstable across runs. Matched RELATIVE to TEMPLATE_DIR: when installed from npm
3279
3016
  * TEMPLATE_DIR itself lives under node_modules/, and testing the absolute path would exclude everything. */
3280
- const templateFingerprintFilter = (src: string): boolean =>
3281
- !/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(TEMPLATE_DIR.length));
3017
+ const templateFingerprintFilterFor = (root: string) => (src: string): boolean =>
3018
+ !/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(root.length));
3282
3019
 
3283
3020
  /** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config + the appwrap
3284
3021
  * `runtime/` template tree + this CLI's version.
@@ -3317,12 +3054,16 @@ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: strin
3317
3054
  const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
3318
3055
  const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
3319
3056
  const stats = [mtimeStats(distDir), mtimeStats(overridesDir), mtimeStats(resolveConfigPath(cwd, flags))];
3320
- // The appwrap runtime template — bundled into bundle.js, so it is a first-class build input.
3057
+ // The appwrap runtime template(s) — bundled into bundle.js, so a first-class build input. Every
3058
+ // overlay root counts, so editing a host-provided template file busts the cache.
3321
3059
  let runtime = 0, runtimeNewest = 0;
3322
- for (const rel of collectRelFiles(TEMPLATE_DIR, templateFingerprintFilter)) {
3323
- const s = mtimeStats(join(TEMPLATE_DIR, rel));
3060
+ for (const root of TEMPLATE_ROOTS) {
3061
+ if (!existsSync(root)) continue;
3062
+ for (const rel of collectRelFiles(root, templateFingerprintFilterFor(root))) {
3063
+ const s = mtimeStats(join(root, rel));
3324
3064
  runtime += s.sum;
3325
3065
  if (s.newest > runtimeNewest) runtimeNewest = s.newest;
3066
+ }
3326
3067
  }
3327
3068
  stats.push({ sum: runtime, newest: runtimeNewest });
3328
3069
  return { parts: stats.map((s) => s.sum), newest: Math.max(...stats.map((s) => s.newest)) };
@@ -3540,7 +3281,7 @@ function detectWebBuildCmd(cwd: string, cfg: AppwrapConfig): string[] | null {
3540
3281
  * app loads the live serverUrl, so its dist is unused (building it would be wasteful + misleading).
3541
3282
  * Prints exactly what it's doing either way. `--no-web-build` skips it (ship the current dist as-is,
3542
3283
  * e.g. to hit the native build-skip fast-path when only the wrapper changed). */
3543
- function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
3284
+ export function buildWebIfBundled(cwd: string, cfg: AppwrapConfig, flags: Record<string, string>): void {
3544
3285
  if ((cfg.loader ?? 'app') === 'server') {
3545
3286
  console.log('ℹ loader:server — NOT building the web (the app loads serverUrl live; the bundle is unused).');
3546
3287
  return;
@@ -4240,10 +3981,69 @@ async function publish(cwd: string, flags: Record<string, string>, positionals:
4240
3981
  console.log(`✓ Uploaded to Play ${track} track.`);
4241
3982
  }
4242
3983
 
4243
- async function main(): Promise<void> {
3984
+ /** Scaffold a new module pack from the built-in template — the community entry point for authoring an
3985
+ * extension. `appwrap create-module <name> [--dir <parent>]` writes <parent>/<name>/ with a manifest +
3986
+ * handler + native-src stub, substituting the module name (and its PascalCase form) into the tokens,
3987
+ * then validates the result so a fresh scaffold is guaranteed pack-conformant. */
3988
+ async function createModule(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
3989
+ const name = positionals[0];
3990
+ if (!name || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
3991
+ console.error('Usage: appwrap create-module <name> [--dir <parent>]\n <name> must be a camelCase capability id (letters/digits, leading letter), e.g. `confetti`.');
3992
+ process.exit(1);
3993
+ }
3994
+ if (!existsSync(MODULE_PACK_TEMPLATE_DIR)) {
3995
+ console.error(`✖ module-pack template not found at ${MODULE_PACK_TEMPLATE_DIR}`);
3996
+ process.exit(1);
3997
+ }
3998
+ const pascal = name.replace(/(^|[-_])(\w)/g, (_m, _s, c: string) => c.toUpperCase());
3999
+ const dest = resolve(cwd, flags.dir ?? '.', name);
4000
+ if (existsSync(dest)) {
4001
+ console.error(`✖ ${dest} already exists — choose a different name or --dir.`);
4002
+ process.exit(1);
4003
+ }
4004
+ // Copy the scaffold, then substitute the name tokens in every text file + rename the token'd native-src dir.
4005
+ cpSync(MODULE_PACK_TEMPLATE_DIR, dest, { recursive: true });
4006
+ const rename = (from: string, to: string) => { if (existsSync(from)) renameSync(from, to); };
4007
+ rename(join(dest, 'native-src/__MODULE_NAME__'), join(dest, 'native-src', name));
4008
+ const walk = (dir: string) => {
4009
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
4010
+ const p = join(dir, e.name);
4011
+ if (e.isDirectory()) walk(p);
4012
+ else if (/\.(ts|md|json|swift|kt|xml)$/.test(e.name)) {
4013
+ writeFileSync(p, readFileSync(p, 'utf8').replaceAll('__MODULE_NAME__', name).replaceAll('__MODULE_PASCAL__', pascal));
4014
+ }
4015
+ }
4016
+ };
4017
+ walk(dest);
4018
+ const { validatePack } = await import('./testing');
4019
+ const res = await validatePack(dest);
4020
+ if (!res.ok) {
4021
+ console.error(`✖ scaffolded pack failed validation (this is a bug in the template):\n ${res.errors.join('\n ')}`);
4022
+ process.exit(1);
4023
+ }
4024
+ console.log(`✓ Created module pack '${name}' → ${dest}`);
4025
+ console.log(` Reference it from your appwrap.config.ts:`);
4026
+ console.log(` modulePacks: ['${flags.dir ? join(flags.dir, name) : `./${name}`}'],`);
4027
+ console.log(` modules: ['${name}', ...],`);
4028
+ }
4029
+
4030
+ /**
4031
+ * The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
4032
+ * historical `main`). A host calls `runCli({ commands, platforms, modulePacks,
4033
+ * templateRoots, desktopTemplateDir })` to compose extra capability on top of CE without forking it.
4034
+ */
4035
+ export async function runCli(options: CliOptions = {}): Promise<void> {
4036
+ cliOptions = { ...options };
4037
+ if (options.desktopTemplateDir) DESKTOP_TEMPLATE_DIR = options.desktopTemplateDir;
4038
+ // Host template overlays land AFTER the built-in runtime template → they win (file-level last-wins).
4039
+ if (options.templateRoots?.length) TEMPLATE_ROOTS = [...TEMPLATE_ROOTS, ...options.templateRoots];
4040
+
4244
4041
  const { command, flags, positionals } = parseArgs(process.argv.slice(2));
4245
4042
  const cwd = process.cwd();
4246
4043
 
4044
+ // Host-provided commands are consulted first — they can add a new command or override a built-in.
4045
+ if (command && cliOptions.commands?.[command]) { await cliOptions.commands[command](cwd, flags, positionals); return; }
4046
+
4247
4047
  switch (command) {
4248
4048
  case 'init':
4249
4049
  await init(cwd, flags);
@@ -4251,6 +4051,9 @@ async function main(): Promise<void> {
4251
4051
  case 'sync':
4252
4052
  await sync(cwd, flags);
4253
4053
  break;
4054
+ case 'create-module':
4055
+ await createModule(cwd, flags, positionals);
4056
+ break;
4254
4057
  case 'clean':
4255
4058
  await clean(cwd, flags);
4256
4059
  break;
@@ -4288,21 +4091,21 @@ async function main(): Promise<void> {
4288
4091
  ' Android wireless: --wifi flips a USB device to wireless adb (unplug + keep going); thereafter the\n' +
4289
4092
  ' device is auto-discovered via mDNS — plain `dev android` finds it with NO flag (iOS parity).\n' +
4290
4093
  ' --device <ip[:port]> `adb connect`s an already-paired one.\n\n' +
4291
- ' dev <ios|android|desktop> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
4094
+ ' dev <ios|android> [--sim] [--detached] [--debug] [--wifi] [--url <devserver>|--port <p>]\n' +
4292
4095
  ' live-dev: ANDROID device → ns run livesync (true on-device HMR) + re-stage on save;\n' +
4293
4096
  ' iOS device → deploy + rebuild/reinstall on save. --sim = ns run/HMR on emulator;\n' +
4294
4097
  ' --url/--port = web HMR from a dev server inside the WebView; --detached = install & exit.\n' +
4295
- ' desktop → macOS Tauri shell (cargo run) speaking appwrap protocol v1 (phase-0, macOS-only).\n' +
4296
4098
  ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
4297
4099
  ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
4298
4100
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
4299
4101
  ' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
4300
4102
  ' clean (ns clean: wipe generated platforms/hooks/node_modules, restore deps — fixes stale/corrupt Podfile after a repo move)\n' +
4103
+ ' create-module <name> [--dir <parent>] (scaffold a new module pack — a swappable/extensible capability; see modulePacks)\n' +
4301
4104
  ' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
4302
4105
  process.exit(command ? 1 : 0);
4303
4106
  }
4304
4107
  }
4305
4108
 
4306
4109
  if (import.meta.main) {
4307
- main();
4110
+ runCli();
4308
4111
  }