@livx.cc/appwrap 0.53.0 → 0.55.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 (34) hide show
  1. package/package.json +4 -2
  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/env-scheme.test.ts +44 -0
  8. package/src/cli.ts +398 -65
  9. package/src/config.ts +8 -1
  10. package/src/packs.ts +174 -0
  11. package/src/testing.ts +173 -0
  12. package/templates/module-pack/README.md +44 -0
  13. package/templates/module-pack/handler.ts +9 -0
  14. package/templates/module-pack/manifest.ts +27 -0
  15. package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
  16. package/runtime/app/shell/billing-offer.ts +0 -54
  17. package/runtime/app/shell/handlers-billing.ts +0 -629
  18. package/runtime/app/shell/handlers-health.ts +0 -178
  19. package/runtime/app/shell/handlers-widget.ts +0 -97
  20. package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
  21. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
  22. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
  23. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
  24. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
  25. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
  26. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
  27. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
  28. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
  29. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
  30. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
  31. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
  32. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
  33. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
  34. package/runtime/tests/billing-offer.test.ts +0 -61
package/src/cli.ts CHANGED
@@ -10,7 +10,7 @@
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 { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
14
14
  import { builtinModules } from 'module';
15
15
  import { networkInterfaces, tmpdir } from 'os';
16
16
  import { basename, delimiter as pathDelimiter, dirname, extname, join, resolve } from 'path';
@@ -23,6 +23,7 @@ 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
28
  import { buildInfoPlist, buildMacEntitlements, deriveDesktopConfig, parseMacProfile, resolveCargoPath, stampTauriConf } from './desktop';
28
29
  import type { DesktopShellConfig } from './desktop';
@@ -103,10 +104,42 @@ function resolveAssetRoot(rel: string): string {
103
104
  return existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
104
105
  }
105
106
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
107
+ /** Ordered runtime-template roots copied into native/ (later roots overlay earlier — file-level
108
+ * last-wins), defaulting to the single built-in template. runCli (A5) can extend this so a consumer
109
+ * like appwrap-ee layers extra shell files on top of CE without forking the template. A single root
110
+ * is byte-identical to the pre-overlay behavior. */
111
+ let TEMPLATE_ROOTS: string[] = [TEMPLATE_DIR];
106
112
  const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
113
+ /** Scaffold for `appwrap create-module` — a starter module pack (see packs.ts / @livx.cc/appwrap/testing). */
114
+ const MODULE_PACK_TEMPLATE_DIR = resolveAssetRoot('templates/module-pack');
107
115
  /** 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
+ * is disposable, like `native/`; the template stays canonical (edit here, regenerate propagates).
117
+ * A `let` so runCli (A5) can point it at a host-provided template (e.g. appwrap-ee owns the desktop lane). */
118
+ let DESKTOP_TEMPLATE_DIR = resolveAssetRoot('runtime-desktop');
119
+
120
+ /**
121
+ * Composition seam for a HOST that wraps this CLI (appwrap-ee, or any consumer). Every field is
122
+ * optional and no-op by default, so the bare `appwrap` bin behaves exactly as before. A host calls
123
+ * `runCli({...})` to extend the CLI without forking it.
124
+ */
125
+ export interface CliOptions {
126
+ /** Extra or overriding top-level commands, keyed by command name — consulted BEFORE the built-in
127
+ * switch, so a host can add a command or replace a built-in one. */
128
+ commands?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
129
+ /** Pluggable platform handlers for `dev`/`build` (keyed by the platform arg, e.g. 'desktop') —
130
+ * consulted BEFORE the built-in platform routing, so a host can add/replace a platform lane. */
131
+ platforms?: Record<string, (cwd: string, flags: Record<string, string>, positionals: string[]) => Promise<void> | void>;
132
+ /** Module packs injected by the host, applied BEFORE the app config's own `modulePacks` (so the app
133
+ * can still override them, last-wins). */
134
+ modulePacks?: string[];
135
+ /** Extra runtime-template overlay roots, appended after the built-in template (later roots win). */
136
+ templateRoots?: string[];
137
+ /** Override for the desktop (Tauri) template dir. */
138
+ desktopTemplateDir?: string;
139
+ }
140
+
141
+ /** The host composition for THIS run, set once by runCli. Empty by default → bare-bin behavior. */
142
+ let cliOptions: CliOptions = {};
110
143
 
111
144
  /** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
112
145
  * emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
@@ -116,10 +149,67 @@ const CLI_VERSION: string = (await import(resolve(import.meta.dir, '..', 'packag
116
149
 
117
150
  // Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
118
151
  // Top-level await resolves before any command dispatches at the bottom of this file.
119
- const { MODULES, OPTIONAL_GROUPS } = (await import(
152
+ const { MODULES, OPTIONAL_GROUPS, LEGACY_BUNDLED_GROUPS, MANIFEST_SCHEMA_VERSION } = (await import(
120
153
  resolve(TEMPLATE_DIR, 'app/shell/capabilities.manifest')
121
154
  )) as typeof CapManifest;
122
155
 
156
+ /** The composed module set for THIS command run: built-ins + the config's `modulePacks`, resolved once
157
+ * at command entry by `applyModulePacks`. Stays `null` when NO packs are configured — every code path
158
+ * then reads the built-in `MODULES` directly, so a pack-less build is byte-identical to pre-packs. */
159
+ let RESOLVED_MODULES: ResolvedModule[] | null = null;
160
+
161
+ /** The active module set the CLI derives from — merged (built-ins + packs) when packs are configured,
162
+ * else the built-ins verbatim. */
163
+ function moduleList(): CapManifest.ModuleManifest[] {
164
+ return RESOLVED_MODULES ? RESOLVED_MODULES.map((r) => r.manifest) : MODULES;
165
+ }
166
+
167
+ /** Pack provenance for a module name (source label + pack dir) — used by the generator to locate a
168
+ * pack module's handler file + native source. Undefined for a built-in. */
169
+ function packInfo(name: string): { source: string; packDir: string } | undefined {
170
+ const r = RESOLVED_MODULES?.find((m) => m.manifest.name === name);
171
+ return r && r.packDir ? { source: r.source, packDir: r.packDir } : undefined;
172
+ }
173
+
174
+ /** Resolve the config's `modulePacks` (if any) into RESOLVED_MODULES for this run. No-op (leaves the
175
+ * built-ins untouched → byte-identical) when the config declares no packs. Async because pack manifests
176
+ * are dynamically imported; callers await it before the sync/regenerate pipeline. */
177
+ async function applyModulePacks(cwd: string, cfg: AppwrapConfig): Promise<void> {
178
+ // Host-injected packs first (appwrap-ee's), then the app config's — so the app can override, last-wins.
179
+ const refs = [...(cliOptions.modulePacks ?? []), ...(cfg.modulePacks ?? [])];
180
+ if (refs.length === 0) {
181
+ RESOLVED_MODULES = null;
182
+ } else {
183
+ const ctx: SyncContext = {
184
+ platform: 'both',
185
+ env: process.env.APPWRAP_ENV || 'default',
186
+ ci: !!process.env.CI,
187
+ };
188
+ const map = await resolveModulePacks({
189
+ builtins: MODULES,
190
+ builtinSchemaVersion: MANIFEST_SCHEMA_VERSION,
191
+ packRefs: refs,
192
+ ctx,
193
+ cwd,
194
+ });
195
+ RESOLVED_MODULES = [...map.values()];
196
+ }
197
+ warnUnknownModules(cfg);
198
+ }
199
+
200
+ /** Warn (never fail) for any name in `modules` that resolves to neither a built-in nor a pack module —
201
+ * with the pack model it's easy to list a capability (e.g. 'billing') and forget to add its pack to
202
+ * `modulePacks`, which would otherwise silently no-op (no handler, no capability). */
203
+ function warnUnknownModules(cfg: AppwrapConfig): void {
204
+ if (!cfg.modules) return;
205
+ const known = new Set(moduleList().map((m) => m.name));
206
+ for (const name of cfg.modules) {
207
+ if (!known.has(name)) {
208
+ 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)`);
209
+ }
210
+ }
211
+ }
212
+
123
213
  /** Native requirements composed for a build: the union (deduped) of the active modules' self-contained
124
214
  * manifest declarations. Two modes:
125
215
  * - `modules` ABSENT (legacy): every capability active; permissions come ONLY from `permissions{}`
@@ -137,20 +227,25 @@ interface NativeReqs {
137
227
  androidGradleDeps: string[];
138
228
  androidKotlin: boolean; // any active module ships Kotlin native source
139
229
  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)
230
+ nativeSrc: string[]; // active BUILT-IN modules' nativeSrc dir names (under runtime/modules-native/)
231
+ packNativeSrc: Array<{ name: string; srcDir: string }>; // active PACK modules' native source (absolute src dirs)
232
+ packHandlers: Array<{ name: string; source: string; packDir: string; file: string; fn: string }>; // active pack modules with a register handler (for the generated barrel + staging)
233
+ 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
234
  }
144
235
 
145
236
  function nativeReqs(cfg: AppwrapConfig): NativeReqs {
146
- const optIn = MODULES.filter((m) => !m.core);
237
+ const modules = moduleList();
238
+ const optIn = modules.filter((m) => !m.core);
147
239
  // Legacy default (no `modules` key) = every opt-in capability EXCEPT strictly-opt-in own-file
148
240
  // modules (OPTIONAL_GROUPS, e.g. health): those carry deps/perms legacy won't stamp, so they must
149
241
  // be explicitly requested. Explicit mode = exactly what `modules` lists.
150
242
  const active = cfg.modules
151
243
  ? 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));
244
+ : new Set(optIn.filter((m) =>
245
+ !OPTIONAL_GROUPS.includes(m.group as (typeof OPTIONAL_GROUPS)[number]) ||
246
+ LEGACY_BUNDLED_GROUPS.includes(m.group as (typeof LEGACY_BUNDLED_GROUPS)[number])
247
+ ).map((m) => m.name));
248
+ const activeMods = modules.filter((m) => m.core || active.has(m.name));
154
249
 
155
250
  const iosPlist: Array<{ key: string; usage: string }> = [];
156
251
  const seenKeys = new Set<string>();
@@ -158,6 +253,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
158
253
  const gradle = new Set<string>();
159
254
  const iosEntitlements: Record<string, boolean | string | string[]> = {};
160
255
  const nativeSrc: string[] = [];
256
+ const packNativeSrc: NativeReqs['packNativeSrc'] = [];
257
+ const packHandlers: NativeReqs['packHandlers'] = [];
258
+ const packModules: NativeReqs['packModules'] = [];
161
259
  const androidManifestApp: string[] = [];
162
260
  let androidKotlin = false;
163
261
 
@@ -174,7 +272,9 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
174
272
  Object.assign(iosEntitlements, m.ios?.entitlements ?? {});
175
273
  if (m.android?.kotlin) androidKotlin = true;
176
274
  if (m.android?.manifestApplication) androidManifestApp.push(m.android.manifestApplication);
177
- if (m.nativeSrc) nativeSrc.push(m.nativeSrc);
275
+ // built-in native source resolves under runtime/modules-native/; pack native source (below)
276
+ // resolves under the pack's own dir, so it must not be conflated with the built-in dir names.
277
+ if (m.nativeSrc && !packInfo(m.name)) nativeSrc.push(m.nativeSrc);
178
278
  }
179
279
  } else {
180
280
  // legacy: only what `permissions{}` declares (via the key maps) — no behavior change
@@ -186,6 +286,18 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
186
286
  }
187
287
  }
188
288
 
289
+ // Active PACK modules (from the config's modulePacks) — their native source + register handler
290
+ // resolve against the pack's own dir. No-op when no packs are configured (packInfo → undefined).
291
+ for (const m of activeMods) {
292
+ const info = packInfo(m.name);
293
+ if (!info) continue;
294
+ if (m.nativeSrc) packNativeSrc.push({ name: m.name, srcDir: join(info.packDir, 'native-src', m.nativeSrc) });
295
+ if (m.handler) packHandlers.push({ name: m.name, source: info.source, packDir: info.packDir, file: m.handler.file, fn: m.handler.fn });
296
+ // The runtime's static MODULES doesn't include pack modules, so their capabilities must be carried
297
+ // into the generated handshake map explicitly (see generateModuleArtifacts + buildCapabilityMap).
298
+ packModules.push({ name: m.name, core: m.core, capabilities: m.capabilities, group: m.group });
299
+ }
300
+
189
301
  return {
190
302
  explicit: !!cfg.modules,
191
303
  activeOptIn: optIn.filter((m) => active.has(m.name)).map((m) => m.name),
@@ -197,15 +309,14 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
197
309
  androidKotlin,
198
310
  androidManifestApp,
199
311
  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,
312
+ packNativeSrc,
313
+ packHandlers,
314
+ packModules,
203
315
  };
204
316
  }
205
317
 
206
318
  /** Map a strippable optional group → its handler file + register fn (for the generated barrel). */
207
319
  const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
208
- health: { file: './handlers-health', fn: 'registerHealthHandlers' },
209
320
  oauth: { file: './handlers-oauth', fn: 'registerOAuthHandlers' },
210
321
  reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
211
322
  scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
@@ -213,22 +324,112 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
213
324
  tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
214
325
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
215
326
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
216
- widget: { file: './handlers-widget', fn: 'registerWidgetHandlers' },
327
+ // billing/health/widget moved to appwrap-ee packs — their handlers stage from packages/ee/packs/*.
217
328
  };
218
329
 
330
+ /** The bare specifier a pack handler uses to import a built-in shell API — rewritten to a relative
331
+ * `./<mod>` when the file is staged next to the shell sources. A pack authored out-of-repo imports
332
+ * e.g. `@livx.cc/appwrap/runtime/app/shell/bridge` (resolvable, since the npm package ships runtime/),
333
+ * so it typechecks standalone; staging drops it beside the real shell files, where `./bridge` resolves. */
334
+ const SHELL_API_SPECIFIER = /(['"])@livx\.cc\/appwrap\/runtime\/app\/shell\//g;
335
+
336
+ /** Rewrite a pack handler's shell-API imports from the package specifier to relative, so the file
337
+ * resolves once staged beside the real shell sources. Pure (exported for tests). */
338
+ export function rewritePackShellImports(src: string): string {
339
+ return src.replace(SHELL_API_SPECIFIER, '$1./');
340
+ }
341
+
342
+ /** Resolve a pack-local relative import specifier to an absolute source file (trying the usual TS/JS
343
+ * extensions + an index file), or null if it doesn't resolve to a real file. */
344
+ function resolvePackImport(fromDir: string, spec: string): string | null {
345
+ const base = resolve(fromDir, spec);
346
+ const cands = [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.jsx`, join(base, 'index.ts'), join(base, 'index.js')];
347
+ return cands.find((c) => existsSync(c) && statSync(c).isFile()) ?? null;
348
+ }
349
+
350
+ /** Replace an exact (quoted) import specifier in source with another — anchored so `./x` never
351
+ * partial-matches `./x-y`. Pure (exported for tests). */
352
+ export function replaceImportSpecifier(src: string, oldSpec: string, newSpec: string): string {
353
+ const esc = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
354
+ return src.replace(new RegExp(`(['"])${esc}\\1`, 'g'), `$1${newSpec}$1`);
355
+ }
356
+
357
+ /** Stage a pack source file into the shell, namespaced `pack-<label>-<name>.ts` (never colliding with a
358
+ * built-in file — keeps built-in staged filenames stable, preserving the byte-compare gate). Rewrites
359
+ * (a) shell-API imports → relative `./<x>`, and (b) pack-LOCAL relative imports → their namespaced
360
+ * staged sibling, staging each RECURSIVELY — so a handler's own helper modules (e.g. billing's
361
+ * billing-offer.ts) are staged too. `staged` memoizes by abs path (dedup + cycle guard). `entryName`
362
+ * overrides the staged basename for the handler entry (so the barrel imports it by module identity). */
363
+ function stagePackFile(shell: string, label: string, packDir: string, absFile: string, staged: Map<string, string>, entryName?: string): string {
364
+ const cached = staged.get(absFile);
365
+ if (cached) return cached;
366
+ if (!existsSync(absFile)) {
367
+ console.error(`✖ module pack "${label}": imported file not found: ${absFile}`);
368
+ process.exit(1);
369
+ }
370
+ const name = entryName ?? basename(absFile).replace(/\.(tsx?|jsx?)$/, '');
371
+ const destName = `pack-${label}-${name}.ts`;
372
+ const spec = `./${destName.replace(/\.ts$/, '')}`;
373
+ staged.set(absFile, spec); // set BEFORE recursing so an import cycle terminates
374
+
375
+ let src = rewritePackShellImports(readFileSync(absFile, 'utf8'));
376
+ const fromDir = dirname(absFile);
377
+ for (const imp of new Bun.Transpiler({ loader: loaderForEntry(absFile) }).scanImports(src)) {
378
+ if (!imp.path.startsWith('.')) continue; // only pack-LOCAL relative imports need staging
379
+ const depAbs = resolvePackImport(fromDir, imp.path);
380
+ if (!depAbs || !depAbs.startsWith(packDir + '/')) continue; // must stay inside the pack dir
381
+ const depSpec = stagePackFile(shell, label, packDir, depAbs, staged);
382
+ src = replaceImportSpecifier(src, imp.path, depSpec);
383
+ }
384
+ writeFileSync(join(shell, destName), src);
385
+ return spec;
386
+ }
387
+
388
+ /** Stage a pack module's handler (+ its pack-local imports) into the shell. Returns the import
389
+ * specifier the generated barrel imports the handler by. */
390
+ function stagePackHandler(shell: string, h: NativeReqs['packHandlers'][number]): string {
391
+ const srcFile = resolve(h.packDir, h.file);
392
+ if (!existsSync(srcFile)) {
393
+ console.error(`✖ module pack "${h.source}": handler file not found: ${srcFile}`);
394
+ process.exit(1);
395
+ }
396
+ const label = h.source.replace(/[^a-zA-Z0-9_-]/g, '_');
397
+ const spec = stagePackFile(shell, label, h.packDir, srcFile, new Map(), h.name);
398
+ console.log(` pack ← ${h.source} (${h.name} handler)`);
399
+ return spec;
400
+ }
401
+
219
402
  /** Generate the two composition artifacts in the wrapper: the active capability list (drives the
220
403
  * handshake map) and the optional-handler barrel (imports only active strippable groups). */
221
404
  function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
222
405
  const shell = join(outDir, 'app/shell');
406
+ // Sweep stale pack-staged handlers first so a removed/renamed pack never leaves an orphan behind
407
+ // (self-pruning, like regenerateMobilePlugins wiping its dir) — the active ones are re-staged below.
408
+ for (const f of existsSync(shell) ? readdirSync(shell) : []) {
409
+ if (f.startsWith('pack-') && f.endsWith('.ts')) rmSync(join(shell, f), { force: true });
410
+ }
223
411
  writeFileSync(
224
412
  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`
413
+ `/** Generated by \`appwrap\` from the appwrap config \`modules\` + \`modulePacks\`. Do not edit. */\n` +
414
+ `import type { ModuleManifest } from './capabilities.manifest';\n` +
415
+ `export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n` +
416
+ // Pack modules aren't in the runtime's static MODULES, so their handshake capabilities travel here
417
+ // and buildCapabilityMap merges them (empty for a pack-less build).
418
+ `export const PACK_MODULES: Array<Pick<ModuleManifest, 'name' | 'core' | 'capabilities' | 'group'>> = ${JSON.stringify(req.packModules)};\n`
227
419
  );
228
420
 
229
421
  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');
422
+ const importLines = groups.map((g) => `import { ${OPTIONAL_GROUP_HANDLERS[g].fn} } from '${OPTIONAL_GROUP_HANDLERS[g].file}';`);
423
+ const callLines = groups.map((g) => ` ${OPTIONAL_GROUP_HANDLERS[g].fn}();`);
424
+ // Active PACK modules with a register handler — staged into the shell + APPENDED after the built-in
425
+ // groups (empty when no packs → byte-identical to the pre-packs barrel).
426
+ for (const h of req.packHandlers) {
427
+ const spec = stagePackHandler(shell, h);
428
+ importLines.push(`import { ${h.fn} } from '${spec}';`);
429
+ callLines.push(` ${h.fn}();`);
430
+ }
431
+ const imports = importLines.join('\n');
432
+ const calls = callLines.join('\n');
232
433
  writeFileSync(
233
434
  join(shell, 'optional-handlers.generated.ts'),
234
435
  `/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
@@ -394,9 +595,13 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
394
595
  const file = join(iosDir, 'app.entitlements');
395
596
  const ent: Record<string, boolean | string | string[]> = { ...req.iosEntitlements, ...cfg.iosEntitlements };
396
597
  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];
598
+ // Resolve build tokens in entitlement values (e.g. a module's `__APP_GROUP__` → group.<appId>) so a
599
+ // module declaring an app-derived entitlement (widget's App Group) needs no hardcoded stamper case.
600
+ const tokens = buildTokens(cfg);
601
+ for (const [k, v] of Object.entries(ent)) {
602
+ if (typeof v === 'string') ent[k] = substituteBuildTokens(v, tokens);
603
+ else if (Array.isArray(v)) ent[k] = v.map((x) => substituteBuildTokens(x, tokens));
604
+ }
400
605
  const keys = Object.keys(ent);
401
606
  if (keys.length === 0) { rmSync(file, { force: true }); return; }
402
607
  const val = (v: boolean | string | string[]): string =>
@@ -426,29 +631,52 @@ function stampPrivacyManifest(outDir: string, cfg: AppwrapConfig, req: NativeReq
426
631
  if (active) console.log(` priv ← NSPrivacyTracking=true${cfg.trackingDomains?.length ? ` (${cfg.trackingDomains.length} domain${cfg.trackingDomains.length > 1 ? 's' : ''})` : ''}`);
427
632
  }
428
633
 
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; }
634
+ /** Copy active modules' native source (runtime/modules-native/<name>/ for built-ins, <packDir>/
635
+ * native-src/<name>/ for packs) into native/ — only when the module is active, so module native code
636
+ * stays stripped from builds that don't use it. Returns the set of dest paths (relative to outDir) it
637
+ * copied, so regenerateCore can prune a now-INACTIVE module's native files on re-sync (they mirror the
638
+ * App_Resources layout and cpSync only overwrites, never deletes). */
639
+ function copyModuleNativeSrc(outDir: string, req: NativeReqs): Set<string> {
640
+ const copied = new Set<string>();
641
+ const copyFrom = (src: string, label: string) => {
642
+ if (!existsSync(src)) { console.warn(`⚠ module nativeSrc not found: ${src}`); return; }
435
643
  cpSync(src, outDir, { recursive: true, force: true });
436
- console.log(` natv ← module '${name}' native source`);
437
- }
644
+ for (const rel of collectRelFiles(src)) copied.add(rel);
645
+ console.log(` natv ← ${label} native source`);
646
+ };
647
+ for (const name of req.nativeSrc) copyFrom(join(MODULES_NATIVE_DIR, name), `module '${name}'`);
648
+ for (const { name, srcDir } of req.packNativeSrc) copyFrom(srcDir, `pack module '${name}'`);
649
+ return copied;
650
+ }
651
+
652
+ /** Build-time tokens a module's native source / entitlements may reference — resolved from the app
653
+ * config so a module ships app-agnostic and the CLI stamps the app-specific value in. Not module-
654
+ * specific: any module (built-in or pack) can use these tokens. Currently `__APP_GROUP__` → the App
655
+ * Group id shared by the app + an extension (e.g. widget); extend here as new app-derived needs arise. */
656
+ function buildTokens(cfg: AppwrapConfig): Record<string, string> {
657
+ return { __APP_GROUP__: `group.${cfg.id}` };
438
658
  }
439
659
 
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;
660
+ /** Substitute any {@link buildTokens} in a stamped string (entitlement value or native-source file). */
661
+ function substituteBuildTokens(s: string, tokens: Record<string, string>): string {
662
+ let out = s;
663
+ for (const [tok, val] of Object.entries(tokens)) out = out.replaceAll(tok, val);
664
+ return out;
665
+ }
666
+
667
+ /** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc) — applied
668
+ * generically to every iOS extension file, so a module's app-agnostic source (e.g. the widget
669
+ * extension's Swift/entitlements/plist) gets the app-specific value stamped in. Idempotent (source is
670
+ * re-copied verbatim each sync, then re-substituted). No-op when no extensions were copied. */
671
+ function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
446
672
  const extRoot = join(outDir, 'App_Resources/iOS/extensions');
447
673
  if (!existsSync(extRoot)) return;
674
+ const tokens = buildTokens(cfg);
675
+ let touched = false;
448
676
  const subst = (file: string) => {
449
677
  const s = readFileSync(file, 'utf8');
450
- if (!s.includes('__APP_GROUP__')) return;
451
- writeFileSync(file, s.replaceAll('__APP_GROUP__', req.widgetAppGroup!));
678
+ const next = substituteBuildTokens(s, tokens);
679
+ if (next !== s) { writeFileSync(file, next); touched = true; }
452
680
  };
453
681
  const walk = (dir: string) => {
454
682
  for (const e of readdirSync(dir, { withFileTypes: true })) {
@@ -458,7 +686,7 @@ function substituteModuleTokens(outDir: string, req: NativeReqs): void {
458
686
  }
459
687
  };
460
688
  walk(extRoot);
461
- console.log(` tokn ← __APP_GROUP__ = ${req.widgetAppGroup} (widget extension)`);
689
+ if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (extension source)`);
462
690
  }
463
691
 
464
692
  /** Hosts to register as Android App Links (autoVerify https intent-filters). Explicit
@@ -584,7 +812,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
584
812
  }
585
813
 
586
814
  // 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
815
+ // value (e.g. "app.example.com") produces an unusable URL — the app silently fails to load (or
588
816
  // shows a stale page) with no error. Normalize to https:// when no scheme is present, and fail loud
589
817
  // on a genuinely malformed URL rather than shipping a broken build.
590
818
  if (cfg.serverUrl) {
@@ -603,7 +831,7 @@ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<A
603
831
  return cfg;
604
832
  }
605
833
 
606
- function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
834
+ export function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
607
835
  // Resolve the env-switcher block. Absent block OR `enabled:false` → the whole feature is inert
608
836
  // (the shell reads `envSwitcher.enabled`). `allowPattern`/`envs` default to empty (default-deny).
609
837
  const es = cfg.envSwitcher;
@@ -628,6 +856,7 @@ export const SHELL_CONFIG = {
628
856
  loader: ${JSON.stringify(cfg.loader ?? 'app')} as 'app' | 'file' | 'server',
629
857
  serverUrl: ${JSON.stringify(cfg.serverUrl ?? '')},
630
858
  backendOrigin: ${JSON.stringify(cfg.backendOrigin ?? '')},
859
+ urlScheme: ${JSON.stringify(cfg.urlScheme ?? '')},
631
860
  debug: ${JSON.stringify(cfg.debug ?? false)},
632
861
  debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
633
862
  devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
@@ -1626,17 +1855,23 @@ export function applyOverrides(cwd: string, outDir: string, cfg: AppwrapConfig):
1626
1855
  const dir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
1627
1856
  // Files the template still provides must survive an override removal (regenerateCore ran first and
1628
1857
  // re-copied them) → protect them from the override prune.
1629
- const templateFiles = existsSync(TEMPLATE_DIR) ? collectRelFiles(TEMPLATE_DIR, templateCopyFilter) : new Set<string>();
1858
+ const templateFiles = allTemplateFiles();
1630
1859
  // ACTIVE modules' native source needs the SAME protection: copyModuleNativeSrc re-copied it into
1631
1860
  // these exact relative paths, but it lives under runtime/modules-native/<name>/, which
1632
1861
  // templateCopyFilter deliberately excludes — so it is absent from templateFiles above. Without this,
1633
1862
  // dropping a consumer override that happened to shadow a module file prunes the MODULE's own copy
1634
1863
  // too (the prune only sees "was in the overrides manifest, isn't in overrides now"), silently
1635
1864
  // deleting e.g. res/xml/appwrap_widget_info.xml and failing the build at AAPT.
1636
- for (const name of nativeReqs(cfg).nativeSrc) {
1865
+ const reqs = nativeReqs(cfg);
1866
+ for (const name of reqs.nativeSrc) {
1637
1867
  const modDir = join(MODULES_NATIVE_DIR, name);
1638
1868
  if (existsSync(modDir)) for (const rel of collectRelFiles(modDir)) templateFiles.add(rel);
1639
1869
  }
1870
+ // Same protection for active PACK modules' native source (lives under the pack dir, absent from the
1871
+ // template file set) — so an override removal never prunes a pack module's own copied files.
1872
+ for (const { srcDir } of reqs.packNativeSrc) {
1873
+ if (existsSync(srcDir)) for (const rel of collectRelFiles(srcDir)) templateFiles.add(rel);
1874
+ }
1640
1875
  if (!existsSync(dir)) {
1641
1876
  // No overrides now: prune anything a PRIOR overrides run left behind (renamed/removed override files).
1642
1877
  pruneStale(outDir, OVERRIDES_MANIFEST, new Set(), templateFiles);
@@ -1795,12 +2030,27 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
1795
2030
  // so without this a relocated source lingers in native/ and gets re-bundled.
1796
2031
  const TEMPLATE_MANIFEST = '.appwrap-template-manifest.json';
1797
2032
  const OVERRIDES_MANIFEST = '.appwrap-overrides-manifest.json';
2033
+ /** Tracks module-owned native source copied into native/ (built-in + pack), so a module DEACTIVATED
2034
+ * since the last sync has its App_Resources files pruned rather than lingering (cpSync never deletes). */
2035
+ const MODULE_NATIVE_MANIFEST = '.appwrap-module-native-manifest.json';
1798
2036
 
1799
2037
  /** Files under TEMPLATE_DIR to skip when copying/walking it (deps, build output, PWA staging, per-module
1800
2038
  * native — those are handled selectively elsewhere). Shared by the cpSync filter and the prune walk so
1801
2039
  * 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));
2040
+ const templateCopyFilterFor = (root: string) => (src: string): boolean =>
2041
+ !/(?:^|\/)(node_modules|platforms|hooks|app\/www|modules-native)(\/|$)/.test(src.slice(root.length));
2042
+ /** Single-root filter for the built-in template (the common path). */
2043
+ const templateCopyFilter = templateCopyFilterFor(TEMPLATE_DIR);
2044
+ /** Every file the active template roots contribute, relative to each root (deduped across roots) —
2045
+ * the union prune set + override-protection set for the overlay chain. */
2046
+ function allTemplateFiles(): Set<string> {
2047
+ const out = new Set<string>();
2048
+ for (const root of TEMPLATE_ROOTS) {
2049
+ if (!existsSync(root)) continue;
2050
+ for (const rel of collectRelFiles(root, templateCopyFilterFor(root))) out.add(rel);
2051
+ }
2052
+ return out;
2053
+ }
1804
2054
 
1805
2055
  /** File (not dir) paths under `root`, relative to it, skipping entries `accept` rejects. */
1806
2056
  function collectRelFiles(root: string, accept: (abs: string) => boolean = () => true): Set<string> {
@@ -1851,16 +2101,23 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
1851
2101
  if (opts.firstRun && !req.explicit) {
1852
2102
  console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
1853
2103
  }
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));
2104
+ // Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
2105
+ // consumer template can add or replace shell files without forking CE. Single root (default) is
2106
+ // exactly the pre-overlay copy.
2107
+ for (const root of TEMPLATE_ROOTS) {
2108
+ if (!existsSync(root)) { console.warn(`⚠ template root not found: ${root}`); continue; }
2109
+ cpSync(root, outDir, {
2110
+ recursive: true,
2111
+ force: true, // explicit: Bun's cpSync does not overwrite existing files by default
2112
+ // modules-native/ is copied selectively per active module (copyModuleNativeSrc), not wholesale.
2113
+ // Match RELATIVE to the root — when installed from npm, a root itself sits under node_modules/,
2114
+ // so testing the absolute path would wrongly exclude the entire template.
2115
+ filter: templateCopyFilterFor(root),
2116
+ });
2117
+ }
2118
+ // Delete template files removed/renamed since the last regenerate (cpSync only overwrites, never
2119
+ // deletes) — pruned against the UNION of all roots' files so an overlay-provided file isn't culled.
2120
+ pruneStale(outDir, TEMPLATE_MANIFEST, allTemplateFiles());
1864
2121
  stampShellConfig(outDir, cfg);
1865
2122
  stampNativeScriptConfig(outDir, cfg);
1866
2123
  stampIOSDisplayName(outDir, cfg, req);
@@ -1872,8 +2129,12 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
1872
2129
  stampKotlin(outDir, req.androidKotlin);
1873
2130
  generateModuleArtifacts(outDir, req);
1874
2131
  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
2132
+ const moduleNativeFiles = copyModuleNativeSrc(outDir, req); // module-owned native source (e.g. health's Kotlin shim)
2133
+ // Prune a now-inactive module's native files (in the prior manifest, not re-copied this sync). Protect
2134
+ // base-template files so a module that overrode a template App_Resources file, once removed, reverts to
2135
+ // the template copy instead of being deleted outright.
2136
+ pruneStale(outDir, MODULE_NATIVE_MANIFEST, moduleNativeFiles, allTemplateFiles());
2137
+ substituteModuleTokens(outDir, cfg); // stamp __APP_GROUP__ etc. into the copied extension source
1877
2138
  stampLaunchScreen(outDir, cfg);
1878
2139
  stampStoreKit(cwd, outDir, cfg);
1879
2140
  stampPush(cwd, outDir, cfg);
@@ -1908,6 +2169,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1908
2169
 
1909
2170
  console.log(`🎁 appwrap init → ${outDir}`);
1910
2171
  mkdirSync(outDir, { recursive: true });
2172
+ await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
1911
2173
  regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1912
2174
  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
2175
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
@@ -1928,6 +2190,7 @@ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: Ap
1928
2190
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1929
2191
  process.exit(1);
1930
2192
  }
2193
+ await applyModulePacks(cwd, cfg); // resolve config `modulePacks` (no-op when none) before deriving
1931
2194
  regenerateCore(cwd, outDir, cfg, { flags });
1932
2195
  applyOverrides(cwd, outDir, cfg); // overrides win last
1933
2196
  stampManualSigning(outDir, cfg, { configPath: resolveConfigPath(cwd, flags) }); // AFTER overrides: a consumer's extension.json / build.xcconfig would otherwise clobber the signing stamp
@@ -2002,6 +2265,7 @@ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platfo
2002
2265
  */
2003
2266
  async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2004
2267
  const platform = positionals[0];
2268
+ if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals);
2005
2269
  if (platform === 'desktop') return devDesktop(cwd, flags);
2006
2270
  const sim = 'sim' in flags || positionals[1] === 'sim';
2007
2271
  const wantDebug = 'debug' in flags;
@@ -2040,6 +2304,7 @@ async function dev(cwd: string, flags: Record<string, string>, positionals: stri
2040
2304
  : stamped?.loader === 'server'
2041
2305
  ? { ...cfg, loader: 'server', serverUrl: stamped.serverUrl, debug: stamped.debug }
2042
2306
  : cfg;
2307
+ await applyModulePacks(cwd, simCfg); // resolve config `modulePacks` (no-op when none) before deriving
2043
2308
  regenerateCore(cwd, outDir, simCfg, { flags });
2044
2309
  applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
2045
2310
  stampIOSExtensionVersions(outDir, simCfg); // AFTER overrides: keep extension version matching the app's (Xcode warns on mismatch)
@@ -2921,6 +3186,7 @@ async function buildDesktop(cwd: string, flags: Record<string, string>): Promise
2921
3186
 
2922
3187
  async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2923
3188
  const platform = positionals[0];
3189
+ if (platform && cliOptions.platforms?.[platform]) return void await cliOptions.platforms[platform](cwd, flags, positionals);
2924
3190
  if (platform === 'desktop') return buildDesktop(cwd, flags);
2925
3191
  if (platform !== 'ios' && platform !== 'android') {
2926
3192
  console.error('Usage: appwrap build <ios|android|desktop> [--release] [--aab] [--config <path>] [--out native]');
@@ -3277,8 +3543,8 @@ function pinTeamIdToConfig(configPath: string, teamId: string): void {
3277
3543
  * `app/www` (PWA staging) is build OUTPUT and `node_modules`/`platforms`/`hooks` are generated, so all four
3278
3544
  * would make the hash unstable across runs. Matched RELATIVE to TEMPLATE_DIR: when installed from npm
3279
3545
  * 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));
3546
+ const templateFingerprintFilterFor = (root: string) => (src: string): boolean =>
3547
+ !/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(root.length));
3282
3548
 
3283
3549
  /** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config + the appwrap
3284
3550
  * `runtime/` template tree + this CLI's version.
@@ -3317,12 +3583,16 @@ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: strin
3317
3583
  const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
3318
3584
  const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
3319
3585
  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.
3586
+ // The appwrap runtime template(s) — bundled into bundle.js, so a first-class build input. Every
3587
+ // overlay root counts, so editing a host-provided (e.g. appwrap-ee) template file busts the cache.
3321
3588
  let runtime = 0, runtimeNewest = 0;
3322
- for (const rel of collectRelFiles(TEMPLATE_DIR, templateFingerprintFilter)) {
3323
- const s = mtimeStats(join(TEMPLATE_DIR, rel));
3589
+ for (const root of TEMPLATE_ROOTS) {
3590
+ if (!existsSync(root)) continue;
3591
+ for (const rel of collectRelFiles(root, templateFingerprintFilterFor(root))) {
3592
+ const s = mtimeStats(join(root, rel));
3324
3593
  runtime += s.sum;
3325
3594
  if (s.newest > runtimeNewest) runtimeNewest = s.newest;
3595
+ }
3326
3596
  }
3327
3597
  stats.push({ sum: runtime, newest: runtimeNewest });
3328
3598
  return { parts: stats.map((s) => s.sum), newest: Math.max(...stats.map((s) => s.newest)) };
@@ -4240,10 +4510,69 @@ async function publish(cwd: string, flags: Record<string, string>, positionals:
4240
4510
  console.log(`✓ Uploaded to Play ${track} track.`);
4241
4511
  }
4242
4512
 
4243
- async function main(): Promise<void> {
4513
+ /** Scaffold a new module pack from the built-in template — the community entry point for authoring an
4514
+ * extension. `appwrap create-module <name> [--dir <parent>]` writes <parent>/<name>/ with a manifest +
4515
+ * handler + native-src stub, substituting the module name (and its PascalCase form) into the tokens,
4516
+ * then validates the result so a fresh scaffold is guaranteed pack-conformant. */
4517
+ async function createModule(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
4518
+ const name = positionals[0];
4519
+ if (!name || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
4520
+ console.error('Usage: appwrap create-module <name> [--dir <parent>]\n <name> must be a camelCase capability id (letters/digits, leading letter), e.g. `confetti`.');
4521
+ process.exit(1);
4522
+ }
4523
+ if (!existsSync(MODULE_PACK_TEMPLATE_DIR)) {
4524
+ console.error(`✖ module-pack template not found at ${MODULE_PACK_TEMPLATE_DIR}`);
4525
+ process.exit(1);
4526
+ }
4527
+ const pascal = name.replace(/(^|[-_])(\w)/g, (_m, _s, c: string) => c.toUpperCase());
4528
+ const dest = resolve(cwd, flags.dir ?? '.', name);
4529
+ if (existsSync(dest)) {
4530
+ console.error(`✖ ${dest} already exists — choose a different name or --dir.`);
4531
+ process.exit(1);
4532
+ }
4533
+ // Copy the scaffold, then substitute the name tokens in every text file + rename the token'd native-src dir.
4534
+ cpSync(MODULE_PACK_TEMPLATE_DIR, dest, { recursive: true });
4535
+ const rename = (from: string, to: string) => { if (existsSync(from)) renameSync(from, to); };
4536
+ rename(join(dest, 'native-src/__MODULE_NAME__'), join(dest, 'native-src', name));
4537
+ const walk = (dir: string) => {
4538
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
4539
+ const p = join(dir, e.name);
4540
+ if (e.isDirectory()) walk(p);
4541
+ else if (/\.(ts|md|json|swift|kt|xml)$/.test(e.name)) {
4542
+ writeFileSync(p, readFileSync(p, 'utf8').replaceAll('__MODULE_NAME__', name).replaceAll('__MODULE_PASCAL__', pascal));
4543
+ }
4544
+ }
4545
+ };
4546
+ walk(dest);
4547
+ const { validatePack } = await import('./testing');
4548
+ const res = await validatePack(dest);
4549
+ if (!res.ok) {
4550
+ console.error(`✖ scaffolded pack failed validation (this is a bug in the template):\n ${res.errors.join('\n ')}`);
4551
+ process.exit(1);
4552
+ }
4553
+ console.log(`✓ Created module pack '${name}' → ${dest}`);
4554
+ console.log(` Reference it from your appwrap.config.ts:`);
4555
+ console.log(` modulePacks: ['${flags.dir ? join(flags.dir, name) : `./${name}`}'],`);
4556
+ console.log(` modules: ['${name}', ...],`);
4557
+ }
4558
+
4559
+ /**
4560
+ * The CLI entry point — the bare `appwrap` bin calls `runCli()` with no options (identical to the
4561
+ * historical `main`). A host (appwrap-ee) calls `runCli({ commands, platforms, modulePacks,
4562
+ * templateRoots, desktopTemplateDir })` to compose extra capability on top of CE without forking it.
4563
+ */
4564
+ export async function runCli(options: CliOptions = {}): Promise<void> {
4565
+ cliOptions = { ...options };
4566
+ if (options.desktopTemplateDir) DESKTOP_TEMPLATE_DIR = options.desktopTemplateDir;
4567
+ // Host template overlays land AFTER the built-in runtime template → they win (file-level last-wins).
4568
+ if (options.templateRoots?.length) TEMPLATE_ROOTS = [...TEMPLATE_ROOTS, ...options.templateRoots];
4569
+
4244
4570
  const { command, flags, positionals } = parseArgs(process.argv.slice(2));
4245
4571
  const cwd = process.cwd();
4246
4572
 
4573
+ // Host-provided commands are consulted first — they can add a new command or override a built-in.
4574
+ if (command && cliOptions.commands?.[command]) { await cliOptions.commands[command](cwd, flags, positionals); return; }
4575
+
4247
4576
  switch (command) {
4248
4577
  case 'init':
4249
4578
  await init(cwd, flags);
@@ -4251,6 +4580,9 @@ async function main(): Promise<void> {
4251
4580
  case 'sync':
4252
4581
  await sync(cwd, flags);
4253
4582
  break;
4583
+ case 'create-module':
4584
+ await createModule(cwd, flags, positionals);
4585
+ break;
4254
4586
  case 'clean':
4255
4587
  await clean(cwd, flags);
4256
4588
  break;
@@ -4298,11 +4630,12 @@ async function main(): Promise<void> {
4298
4630
  ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
4299
4631
  ' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
4300
4632
  ' clean (ns clean: wipe generated platforms/hooks/node_modules, restore deps — fixes stale/corrupt Podfile after a repo move)\n' +
4633
+ ' create-module <name> [--dir <parent>] (scaffold a new module pack — a swappable/extensible capability; see modulePacks)\n' +
4301
4634
  ' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
4302
4635
  process.exit(command ? 1 : 0);
4303
4636
  }
4304
4637
  }
4305
4638
 
4306
4639
  if (import.meta.main) {
4307
- main();
4640
+ runCli();
4308
4641
  }