@cavulsqa/create 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/create.mjs CHANGED
@@ -7,11 +7,13 @@
7
7
  * silent default, because a generated app named "my-app" in the wrong directory is worse than a
8
8
  * failed command.
9
9
  */
10
+ import { spawnSync } from "node:child_process";
10
11
  import { readFileSync } from "node:fs";
11
12
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
12
13
  import { createInterface } from "node:readline/promises";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { scaffold } from "../lib/scaffold.mjs";
16
+ import { knownUpdateServer, linkCommands, normaliseUpdateUrl } from "../lib/updates.mjs";
15
17
  import {
16
18
  defaultTemplate,
17
19
  listTemplates,
@@ -50,6 +52,9 @@ Options:
50
52
  --app-id ID android application id (default: com.ayb.<name>)
51
53
  --engine ID which storage engine the app prefers, written to .env
52
54
  --pragmas PROFILE safe (default) or fast
55
+ --updates wire Capuchoo over-the-air updates (asked when interactive)
56
+ --update-url URL the update server; implies --updates
57
+ --no-link with updates, write the files but do not install or run capuchoo init
53
58
  --from PATH use a template directory on disk instead of the bundled ones
54
59
  --yes take the defaults, ask nothing
55
60
  --help this
@@ -73,6 +78,62 @@ function listEngines(templateDir) {
73
78
  }
74
79
  }
75
80
 
81
+ /**
82
+ * The update server, or undefined for an app without over-the-air updates - the default, because an
83
+ * updater with nowhere to check is dead weight in every build. Interactive answers are asked again
84
+ * until they parse; a flag that does not parse is an error.
85
+ */
86
+ async function chooseUpdates(flags, ask, interactive) {
87
+ const given = flags.get("update-url");
88
+ if (typeof given === "string") return normaliseUpdateUrl(given);
89
+
90
+ const wanted =
91
+ flags.has("updates") ||
92
+ (await ask("Capuchoo over-the-air updates? [y/N]", "n")).toLowerCase().startsWith("y");
93
+ if (!wanted) return undefined;
94
+
95
+ const known = knownUpdateServer();
96
+ if (!interactive) {
97
+ if (!known) throw new Error("--updates needs --update-url, or a signed-in capuchoo CLI");
98
+ return normaliseUpdateUrl(known);
99
+ }
100
+ for (;;) {
101
+ const answer = await ask("Update server URL", known);
102
+ try {
103
+ return normaliseUpdateUrl(answer ?? "");
104
+ } catch (error) {
105
+ console.log(` ${error instanceof Error ? error.message : String(error)}`);
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Runs the server-side half; stops at the first failure and says which step it was. */
111
+ function link(out, appId) {
112
+ for (const args of linkCommands(appId)) {
113
+ console.log(`\n> pnpm ${args.join(" ")}`);
114
+ const result = spawnSync("pnpm", args, {
115
+ cwd: out,
116
+ stdio: "inherit",
117
+ shell: process.platform === "win32",
118
+ });
119
+ if (result.status !== 0) {
120
+ const why = result.error
121
+ ? `could not start: ${result.error.message}`
122
+ : `exited ${result.status}`;
123
+ console.error(`\nStopped at "pnpm ${args.join(" ")}" (${why}). The rest is below.`);
124
+ return false;
125
+ }
126
+ }
127
+ return true;
128
+ }
129
+
130
+ function commandLines(appId) {
131
+ return linkCommands(appId)
132
+ .filter(([first]) => first !== "install")
133
+ .map((args) => ` pnpm ${args.join(" ")}`)
134
+ .join("\n");
135
+ }
136
+
76
137
  /** npm package names: lowercase, no spaces, no leading dot or underscore. */
77
138
  function validName(value) {
78
139
  return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(value);
@@ -143,6 +204,8 @@ async function main() {
143
204
  throw new Error(`--pragmas must be "safe" or "fast", not "${pragmas}"`);
144
205
  }
145
206
 
207
+ const updateUrl = await chooseUpdates(flags, ask, Boolean(rl));
208
+
146
209
  const dir = flags.get("dir") ?? (await ask("Directory", `./${name}`));
147
210
  const out = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
148
211
 
@@ -154,17 +217,26 @@ async function main() {
154
217
  appName: String(appName),
155
218
  engine: typeof engine === "string" ? engine : undefined,
156
219
  pragmas: typeof pragmas === "string" ? pragmas : undefined,
220
+ updateUrl,
157
221
  });
158
222
 
159
223
  console.log(`
160
224
  ${appName} created in ${out}
161
225
  template ${template}
162
- appId ${appId}
226
+ appId ${appId}${updateUrl ? `\n updates ${updateUrl}` : ""}`);
163
227
 
164
- cd ${basename(out)}
165
- pnpm install
166
- pnpm dev
228
+ const wantsLink =
229
+ Boolean(updateUrl && rl && !flags.has("no-link")) &&
230
+ (await ask("Install dependencies and link to Capuchoo now? [Y/n]", "y")).toLowerCase() !==
231
+ "n";
232
+ // capuchoo init asks its own questions; two readers on one stdin would split the keystrokes.
233
+ rl?.close();
234
+ const linked = wantsLink && link(out, appId);
167
235
 
236
+ console.log(`
237
+ cd ${basename(out)}${linked ? "" : "\n pnpm install"}
238
+ pnpm dev
239
+ ${updateUrl && !linked ? `\nThen link it to Capuchoo (needs your account):\n${commandLines(appId)}\n` : ""}
168
240
  Android needs JDK 21: npx cap add android, then pnpm build && npx cap sync android`);
169
241
  } finally {
170
242
  rl?.close();
package/lib/scaffold.mjs CHANGED
@@ -2,13 +2,19 @@ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { listTemplateFiles } from "./templateFiles.mjs";
4
4
  import { pruneEngines } from "./pruneEngines.mjs";
5
+ import { addUpdates, linkCommands } from "./updates.mjs";
5
6
 
6
7
  /** Files whose contents carry the app's identity and have to be rewritten, not copied. */
7
8
  function personalise(entry, text, { name, appName }) {
8
9
  if (entry === "capacitor.config.ts") {
9
10
  return text
10
- .replace(/appId: "[^"]*"/, `appId: "${name.appId}"`)
11
- .replace(/appName: "[^"]*"/, `appName: "${appName}"`);
11
+ .replace(/(appId: [^\n"]*")[^"\n]*(")/, `$1${name.appId}$2`)
12
+ .replace(/(appName: [^\n"]*")[^"\n]*(")/, `$1${appName}$2`);
13
+ }
14
+ if (entry === ".env.example") {
15
+ return text
16
+ .replace(/^VITE_APP_ID=.*$/m, `VITE_APP_ID=${name.appId}`)
17
+ .replace(/^VITE_APP_NAME=.*$/m, `VITE_APP_NAME=${appName}`);
12
18
  }
13
19
  if (entry === "vite.config.ts") {
14
20
  return text.replace(
@@ -22,6 +28,26 @@ function personalise(entry, text, { name, appName }) {
22
28
  return null;
23
29
  }
24
30
 
31
+ function updatesReadme(appId) {
32
+ const commands = linkCommands(appId)
33
+ .map((args) => `pnpm ${args.join(" ")}`)
34
+ .join("\n");
35
+ return `
36
+ ## Over-the-air updates
37
+
38
+ Wired for [Capuchoo](https://www.npmjs.com/package/@capuchoo/cli): the updater packages,
39
+ \`capacitor.config.ts\`, \`notifyAppReady()\` in \`src/main.ts\`, and one flavour per environment in
40
+ \`build/<env>/.env.<env>\`. Linking the app to the server, its channels and the dev and staging ids
41
+ need an account, so they run once:
42
+
43
+ \`\`\`bash
44
+ ${commands}
45
+ \`\`\`
46
+
47
+ The app ships no update screen: drive it with \`useUpdater()\` from \`@capuchoo/updater/vue\`.
48
+ `;
49
+ }
50
+
25
51
  function manifest(source, { name, appName, templateName }) {
26
52
  const pkg = JSON.parse(source);
27
53
  const { private: _private, cavulsqa: _cavulsqa, ...rest } = pkg;
@@ -56,7 +82,7 @@ function copyTree(from, to, transform) {
56
82
  * The template's dependencies are already concrete - `bundleTemplates.mjs` resolved them when the
57
83
  * creator was packed - so nothing here has to know about workspaces or catalogs.
58
84
  */
59
- export function scaffold({ templateDir, out, name, appId, appName, engine, pragmas }) {
85
+ export function scaffold({ templateDir, out, name, appId, appName, engine, pragmas, updateUrl }) {
60
86
  if (!existsSync(templateDir)) throw new Error(`no template at ${templateDir}`);
61
87
  if (existsSync(out)) throw new Error(`${out} already exists`);
62
88
 
@@ -92,6 +118,8 @@ export function scaffold({ templateDir, out, name, appId, appName, engine, pragm
92
118
  writeFileSync(join(out, ".env"), `${lines.join("\n")}\n`);
93
119
  }
94
120
 
121
+ if (updateUrl) addUpdates(out, { appId, appName, updateUrl });
122
+
95
123
  // pnpm will not finish an install while a dependency's build script is neither allowed nor
96
124
  // denied, and vite-plus pulls esbuild in. Without this every generated app fails its first
97
125
  // `pnpm install` with ERR_PNPM_IGNORED_BUILDS.
@@ -117,6 +145,9 @@ export function scaffold({ templateDir, out, name, appId, appName, engine, pragm
117
145
  "",
118
146
  existing,
119
147
  "",
148
+ "# Flavour files are configuration to commit. Last, because the last matching rule wins.",
149
+ "!build/*/.env.*",
150
+ "",
120
151
  ].join("\n"),
121
152
  );
122
153
 
@@ -136,7 +167,7 @@ pnpm build && npx cap sync android && npx cap run android
136
167
  Android builds need **JDK 21**; an older one fails with \`invalid source release: 21\`.
137
168
 
138
169
  \`CLAUDE.md\` and \`.claude/\` carry the architecture an agent needs before editing anything here.
139
- `,
170
+ ${updateUrl ? updatesReadme(appId) : ""}`,
140
171
  );
141
172
 
142
173
  return { templateName };
@@ -0,0 +1,209 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * What a Capuchoo-updated app installs, matching what `capuchoo init` would add so its `packages`
7
+ * step finds everything present. `@capacitor/app` is already a template dependency.
8
+ */
9
+ export const UPDATE_PACKAGES = {
10
+ dependencies: {
11
+ "@capacitor/device": "^8.0.0",
12
+ "@capgo/capacitor-updater": "^8.0.0",
13
+ "@capuchoo/updater": "^0.14.1",
14
+ },
15
+ devDependencies: {
16
+ "@capuchoo/cli": "^0.16.4",
17
+ },
18
+ };
19
+
20
+ export const FLAVOURS = [
21
+ { env: "dev", idSuffix: ".dev", nameSuffix: " Dev" },
22
+ { env: "staging", idSuffix: ".staging", nameSuffix: " Staging" },
23
+ { env: "prod", idSuffix: "", nameSuffix: "" },
24
+ ];
25
+
26
+ /**
27
+ * The update server's base URL, normalised: no trailing slash, and no `/api`, because the runtime
28
+ * appends `/api/update` itself and a doubled segment is a 404 that reads as "no update".
29
+ */
30
+ export function normaliseUpdateUrl(value) {
31
+ const trimmed = String(value).trim().replace(/\/+$/, "");
32
+ let url;
33
+ try {
34
+ url = new URL(trimmed);
35
+ } catch {
36
+ throw new Error(
37
+ `"${value}" is not a URL; give the update server's base, e.g. https://updates.example.com`,
38
+ );
39
+ }
40
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
41
+ throw new Error(`the update server must be http(s), not ${url.protocol}`);
42
+ }
43
+ if (url.pathname.endsWith("/api")) {
44
+ throw new Error(
45
+ `give the server's base URL without /api - the updater appends /api/update itself`,
46
+ );
47
+ }
48
+ return trimmed;
49
+ }
50
+
51
+ /**
52
+ * The server the Capuchoo CLI is already signed in to, as the default answer. Only the endpoint is
53
+ * read; the API key next to it is never touched.
54
+ */
55
+ export function knownUpdateServer() {
56
+ if (process.env.CAPUCHOO_ENDPOINT) return process.env.CAPUCHOO_ENDPOINT;
57
+ try {
58
+ const config = JSON.parse(readFileSync(join(homedir(), ".capuchoo", "config.json"), "utf8"));
59
+ return typeof config.endpoint === "string" ? config.endpoint : undefined;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ function sortedMerge(base = {}, extra) {
66
+ return Object.fromEntries(
67
+ Object.entries({ ...base, ...extra }).sort(([a], [b]) => a.localeCompare(b)),
68
+ );
69
+ }
70
+
71
+ export function withUpdatePackages(pkg) {
72
+ return {
73
+ ...pkg,
74
+ dependencies: sortedMerge(pkg.dependencies, UPDATE_PACKAGES.dependencies),
75
+ devDependencies: sortedMerge(pkg.devDependencies, UPDATE_PACKAGES.devDependencies),
76
+ };
77
+ }
78
+
79
+ function insertAfter(text, anchor, insertion, file) {
80
+ const index = text.indexOf(anchor);
81
+ if (index === -1) throw new Error(`${file} has no "${anchor.trim()}" to wire the updater into`);
82
+ const at = index + anchor.length;
83
+ return text.slice(0, at) + insertion + text.slice(at);
84
+ }
85
+
86
+ /**
87
+ * The shape `capuchoo init` writes and its doctor checks for. The helper refuses an empty server
88
+ * URL - an empty one silently disables updates - which is why every flavour file carries one.
89
+ */
90
+ export function wireCapacitorConfig(text) {
91
+ const withImport = insertAfter(
92
+ text,
93
+ 'import type { CapacitorConfig } from "@capacitor/cli";\n',
94
+ 'import { capuchooUpdaterConfig } from "@capuchoo/updater/capacitor";\n',
95
+ "capacitor.config.ts",
96
+ );
97
+ return insertAfter(
98
+ withImport,
99
+ " plugins: {\n",
100
+ [
101
+ " CapacitorUpdater: capuchooUpdaterConfig({",
102
+ " apiUrl: process.env.VITE_UPDATE_API_URL,",
103
+ " channel: process.env.VITE_UPDATE_CHANNEL,",
104
+ " }),",
105
+ "",
106
+ ].join("\n"),
107
+ "capacitor.config.ts",
108
+ );
109
+ }
110
+
111
+ /**
112
+ * `notifyAppReady()` right after the imports and before anything that can throw: a bundle that has
113
+ * not confirmed it booted within ten seconds is rolled back, working or not.
114
+ */
115
+ export function wireEntry(text) {
116
+ const lines = text.split("\n");
117
+ let last = -1;
118
+ for (let index = 0; index < lines.length; index++) {
119
+ if (!/^import\b/.test(lines[index])) continue;
120
+ last = index;
121
+ while (last < lines.length - 1 && !/;\s*$/.test(lines[last])) last++;
122
+ index = last;
123
+ }
124
+ if (last === -1) throw new Error("src/main.ts has no imports to place notifyAppReady after");
125
+ lines.splice(
126
+ last + 1,
127
+ 0,
128
+ 'import { notifyAppReady } from "@capuchoo/updater";',
129
+ "",
130
+ "/** First, unconditionally: an update that does not confirm it booted is rolled back. */",
131
+ "void notifyAppReady();",
132
+ );
133
+ return lines.join("\n");
134
+ }
135
+
136
+ export function flavourFile({ env, idSuffix, nameSuffix }, { appId, appName, updateUrl }) {
137
+ return [
138
+ `# The ${env} flavour. The Capuchoo CLI exports these values when it builds ${env}; see .env.example.`,
139
+ `VITE_APP_ID=${appId}${idSuffix}`,
140
+ `VITE_APP_NAME=${appName}${nameSuffix}`,
141
+ `VITE_ENVIRONMENT=${env}`,
142
+ `VITE_UPDATE_API_URL=${updateUrl}`,
143
+ `VITE_UPDATE_CHANNEL=${env}`,
144
+ "",
145
+ ].join("\n");
146
+ }
147
+
148
+ const ENV_EXAMPLE_UPDATES = `
149
+ # Capuchoo over-the-air updates. Each flavour sets these in build/<env>/.env.<env>; capacitor.config.ts
150
+ # loads the one VITE_ENVIRONMENT names (dev by default) so a local \`npx cap sync\` works too.
151
+ # VITE_UPDATE_API_URL the update server's base URL, without /api
152
+ # VITE_UPDATE_CHANNEL the channel this build follows: dev, staging or prod
153
+ # VITE_UPDATE_PUBLIC_KEY= release signing; \`capuchoo keys init\` prints it. Once set, unsigned updates are refused.
154
+ `;
155
+
156
+ /**
157
+ * Turns a scaffolded app into a Capuchoo-updated one, offline. What needs the server - linking the
158
+ * app, its channels, registering the flavour ids - is left to `capuchoo init`, which reports
159
+ * everything written here as already satisfied.
160
+ */
161
+ export function addUpdates(out, { appId, appName, updateUrl }) {
162
+ const packagePath = join(out, "package.json");
163
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
164
+ writeFileSync(packagePath, `${JSON.stringify(withUpdatePackages(pkg), null, 2)}\n`);
165
+
166
+ const configPath = join(out, "capacitor.config.ts");
167
+ writeFileSync(configPath, wireCapacitorConfig(readFileSync(configPath, "utf8")));
168
+
169
+ const entryPath = join(out, "src", "main.ts");
170
+ writeFileSync(entryPath, wireEntry(readFileSync(entryPath, "utf8")));
171
+
172
+ for (const flavour of FLAVOURS) {
173
+ const dir = join(out, "build", flavour.env);
174
+ mkdirSync(dir, { recursive: true });
175
+ writeFileSync(
176
+ join(dir, `.env.${flavour.env}`),
177
+ flavourFile(flavour, { appId, appName, updateUrl }),
178
+ );
179
+ }
180
+
181
+ const examplePath = join(out, ".env.example");
182
+ if (existsSync(examplePath)) {
183
+ writeFileSync(
184
+ examplePath,
185
+ `${readFileSync(examplePath, "utf8").trimEnd()}\n${ENV_EXAMPLE_UPDATES}`,
186
+ );
187
+ }
188
+ }
189
+
190
+ /** The commands that finish the job against the server, in order, for the README and the CLI. */
191
+ export function linkCommands(appId) {
192
+ return [
193
+ ["install"],
194
+ ["exec", "capuchoo", "init", "--app-id", appId],
195
+ ...FLAVOURS.filter((flavour) => flavour.idSuffix).map((flavour) => [
196
+ "exec",
197
+ "capuchoo",
198
+ "app",
199
+ "identifiers",
200
+ "add",
201
+ `${appId}${flavour.idSuffix}`,
202
+ "--flavour",
203
+ flavour.env,
204
+ "--platform",
205
+ "all",
206
+ "--yes",
207
+ ]),
208
+ ];
209
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavulsqa/create",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Create a Vue + Capacitor + SQLite app from the cavulsqa templates: Framework7 (iOS and Material) or Material 3 Expressive.",
5
5
  "keywords": [
6
6
  "capacitor",
@@ -60,7 +60,7 @@
60
60
  }
61
61
  ]
62
62
  },
63
- "templatesFingerprint": "sha256-3YSsClBPuShazZVtnS9LabHQ330E0wPZiedhN6vIadU=",
63
+ "templatesFingerprint": "sha256-eIGH2ix9joNhpsfimiVjXn35142qMIzl59n4E69AcYg=",
64
64
  "scripts": {
65
65
  "build": "node scripts/bundleTemplates.mjs",
66
66
  "check": "vp check",
@@ -41,6 +41,23 @@ The tab bar lives in the shell, outside every page, inside `.views.tabs`. So:
41
41
  twice.
42
42
  - FAB buttons open upward (`position="top"`), or they land behind it.
43
43
 
44
+ ## Identity, environments and updates come from env
45
+
46
+ `capacitor.config.ts` reads `VITE_APP_ID` and `VITE_APP_NAME`, falling back to the literals
47
+ `create` wrote. It loads env with Node's own `process.loadEnvFile` - there is no dotenv, and adding
48
+ one is a second loader that disagrees with this one about precedence. Nothing overwrites a value
49
+ already set, so the order is: the shell, `.env.local`, `.env`, then `build/<env>/.env.<env>` for
50
+ `VITE_ENVIRONMENT` (dev by default).
51
+
52
+ - Change the id or name in the env, never as a second literal in the config.
53
+ - Every variable the app reads is documented in `.env.example`; a new one goes there too.
54
+ - Live reload is `VITE_LIVE_RELOAD=true` in `.env.local`; staging and prod ignore it.
55
+ - An app generated with Capuchoo updates has `build/{dev,staging,prod}/.env.*` (committed - they
56
+ are configuration, not secrets), `capuchooUpdaterConfig` in the config and `notifyAppReady()`
57
+ first in `src/main.ts`. Keep that call unconditional and before anything that can throw: a bundle
58
+ that has not confirmed it booted within ten seconds is rolled back. `pnpm exec capuchoo doctor`
59
+ checks the whole setup.
60
+
44
61
  ## Proof obligations
45
62
 
46
63
  Say which platform you tested on. "Type-checks" is not a claim about a device, and neither is a
@@ -17,3 +17,24 @@ VITE_STORAGE_ENGINE=sqlite-wasm-opfs-sahpool
17
17
  # the flush decision to the OS, which is right for a benchmark and wrong for data a person would
18
18
  # miss. Batched writes cost almost the same either way.
19
19
  VITE_PRAGMA_PROFILE=safe
20
+
21
+ # App identity, read by capacitor.config.ts, which falls back to the values `create` wrote there. A
22
+ # Capuchoo flavour build exports build/<env>/.env.<env>, and those values win over this file.
23
+ VITE_APP_ID=com.example.app
24
+ VITE_APP_NAME=App
25
+
26
+ # dev, staging or prod. staging and prod never live-reload, whatever VITE_LIVE_RELOAD says.
27
+ # VITE_ENVIRONMENT=dev
28
+
29
+ # Live reload, for development only. Put these in `.env.local` (git-ignored), run the dev server,
30
+ # forward its port to the device, then `npx cap sync android` and install the debug build once:
31
+ #
32
+ # adb reverse tcp:5173 tcp:5173
33
+ #
34
+ # From then on every edit reloads on the device. The dev server listens on VITE_LIVE_RELOAD_PORT
35
+ # too, so if another project already holds 5173, change it here and reverse that port instead.
36
+ # A build with VITE_ENVIRONMENT=staging or prod ignores these and always loads its bundled files.
37
+ # VITE_LIVE_RELOAD=true
38
+ # VITE_LIVE_RELOAD_PORT=5173
39
+ # VITE_LIVE_RELOAD_HOST=localhost
40
+ # VITE_LIVE_RELOAD_SCHEME=http
@@ -1,9 +1,48 @@
1
1
  import type { CapacitorConfig } from "@capacitor/cli";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Node's own env loader, so no dotenv. It never overwrites a variable already set, which gives the
7
+ * precedence: the shell (a Capuchoo flavour build exports its flavour's values) over `.env.local`
8
+ * over `.env` over the flavour file in `build/`, when the app has one.
9
+ */
10
+ function loadEnv(file: string): void {
11
+ const path = join(__dirname, file);
12
+ if (existsSync(path)) process.loadEnvFile(path);
13
+ }
14
+
15
+ loadEnv(".env.local");
16
+ loadEnv(".env");
17
+ const ENVIRONMENT = process.env.VITE_ENVIRONMENT || "dev";
18
+ loadEnv(`build/${ENVIRONMENT}/.env.${ENVIRONMENT}`);
19
+
20
+ /** A flavour built for distribution never points at a laptop, whatever `.env.local` says. */
21
+ const DISTRIBUTED = new Set(["staging", "prod"]);
22
+
23
+ const liveReload = process.env.VITE_LIVE_RELOAD === "true" && !DISTRIBUTED.has(ENVIRONMENT);
24
+
25
+ /**
26
+ * Live reload: the installed debug app loads the dev server instead of its bundled files, so an
27
+ * edit shows on the device without a rebuild. With `adb reverse tcp:5173 tcp:5173` the device's
28
+ * localhost is this machine's, so the default host needs no IP and no shared network.
29
+ */
30
+ function liveReloadUrl(): string | undefined {
31
+ if (!liveReload) return undefined;
32
+ const scheme = process.env.VITE_LIVE_RELOAD_SCHEME || "http";
33
+ const host = process.env.VITE_LIVE_RELOAD_HOST || "localhost";
34
+ const port = process.env.VITE_LIVE_RELOAD_PORT || "5173";
35
+ return `${scheme}://${host}:${port}`;
36
+ }
2
37
 
3
38
  const config: CapacitorConfig = {
4
- appId: "com.example.app",
5
- appName: "App",
39
+ appId: process.env.VITE_APP_ID || "com.example.app",
40
+ appName: process.env.VITE_APP_NAME || "App",
6
41
  webDir: "dist",
42
+ server: {
43
+ url: liveReloadUrl(),
44
+ cleartext: liveReload,
45
+ },
7
46
  plugins: {
8
47
  SplashScreen: { launchAutoHide: false },
9
48
  Keyboard: { resizeOnFullScreen: true },
@@ -7,13 +7,23 @@ import AutoImport from "unplugin-auto-import/vite";
7
7
  import Icons from "unplugin-icons/vite";
8
8
  import IconsResolver from "unplugin-icons/resolver";
9
9
  import Components from "unplugin-vue-components/vite";
10
- import { defineConfig } from "vite-plus";
10
+ import { defineConfig, loadEnv } from "vite-plus";
11
11
  import {
12
12
  Framework7VueResolver,
13
13
  getFramework7AutoImports,
14
14
  } from "./src/shared/utils/resolvers/resolvers.js";
15
15
 
16
16
  const SRC = fileURLToPath(new URL("./src", import.meta.url));
17
+ const ROOT = fileURLToPath(new URL(".", import.meta.url));
18
+
19
+ /**
20
+ * One port for the dev server and the live-reload URL `capacitor.config.ts` builds, both read from
21
+ * `VITE_LIVE_RELOAD_PORT` - change it when another dev server already holds 5173.
22
+ */
23
+ function devPort(mode: string): number {
24
+ const port = Number(loadEnv(mode, ROOT, "VITE_").VITE_LIVE_RELOAD_PORT);
25
+ return Number.isInteger(port) && port > 0 ? port : 5173;
26
+ }
17
27
 
18
28
  // Read once so Settings can show the real name and version without importing the manifest
19
29
  // into the bundle.
@@ -21,7 +31,7 @@ const pkg = JSON.parse(
21
31
  readFileSync(fileURLToPath(new URL("./package.json", import.meta.url)), "utf-8"),
22
32
  ) as { name: string; version: string };
23
33
 
24
- export default defineConfig({
34
+ export default defineConfig(({ mode }) => ({
25
35
  define: {
26
36
  __APP_NAME__: JSON.stringify("App"),
27
37
  __APP_VERSION__: JSON.stringify(pkg.version),
@@ -89,7 +99,7 @@ export default defineConfig({
89
99
  ],
90
100
 
91
101
  resolve: { alias: { "@": SRC } },
92
- server: { port: 5173 },
102
+ server: { port: devPort(mode), strictPort: true },
93
103
  build: { target: "esnext" },
94
104
  lint: { options: { typeAware: false } },
95
105
  /**
@@ -101,4 +111,4 @@ export default defineConfig({
101
111
  fmt: {
102
112
  ignorePatterns: ["**/auto-imports.d.ts", "**/components.d.ts"],
103
113
  },
104
- });
114
+ }));
@@ -54,6 +54,23 @@ carried by the template. Add it to `android/app/src/main/AndroidManifest.xml` af
54
54
  asks the person at runtime only for a permission the manifest declares, so without these the
55
55
  lookup fails as `denied` and the chat offers the depot instead.
56
56
 
57
+ ## Identity, environments and updates come from env
58
+
59
+ `capacitor.config.ts` reads `VITE_APP_ID` and `VITE_APP_NAME`, falling back to the literals
60
+ `create` wrote. It loads env with Node's own `process.loadEnvFile` - there is no dotenv, and adding
61
+ one is a second loader that disagrees with this one about precedence. Nothing overwrites a value
62
+ already set, so the order is: the shell, `.env.local`, `.env`, then `build/<env>/.env.<env>` for
63
+ `VITE_ENVIRONMENT` (dev by default).
64
+
65
+ - Change the id or name in the env, never as a second literal in the config.
66
+ - Every variable the app reads is documented in `.env.example`; a new one goes there too.
67
+ - Live reload is `VITE_LIVE_RELOAD=true` in `.env.local`; staging and prod ignore it.
68
+ - An app generated with Capuchoo updates has `build/{dev,staging,prod}/.env.*` (committed - they
69
+ are configuration, not secrets), `capuchooUpdaterConfig` in the config and `notifyAppReady()`
70
+ first in `src/main.ts`. Keep that call unconditional and before anything that can throw: a bundle
71
+ that has not confirmed it booted within ten seconds is rolled back. `pnpm exec capuchoo doctor`
72
+ checks the whole setup.
73
+
57
74
  ## Proof obligations
58
75
 
59
76
  Say which platform you tested on. "Type-checks" is not a claim about a device, and neither is a
@@ -30,3 +30,11 @@ VITE_PRAGMA_PROFILE=safe
30
30
  # VITE_LIVE_RELOAD_PORT=5173
31
31
  # VITE_LIVE_RELOAD_HOST=localhost
32
32
  # VITE_LIVE_RELOAD_SCHEME=http
33
+
34
+ # App identity, read by capacitor.config.ts, which falls back to the values `create` wrote there. A
35
+ # Capuchoo flavour build exports build/<env>/.env.<env>, and those values win over this file.
36
+ VITE_APP_ID=com.example.m3e
37
+ VITE_APP_NAME=M3E
38
+
39
+ # dev, staging or prod. staging and prod never live-reload, whatever VITE_LIVE_RELOAD says.
40
+ # VITE_ENVIRONMENT=dev
@@ -2,16 +2,25 @@ import type { CapacitorConfig } from "@capacitor/cli";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
- for (const file of [".env.local", ".env"]) {
5
+ /**
6
+ * Node's own env loader, so no dotenv. It never overwrites a variable already set, which gives the
7
+ * precedence: the shell (a Capuchoo flavour build exports its flavour's values) over `.env.local`
8
+ * over `.env` over the flavour file in `build/`, when the app has one.
9
+ */
10
+ function loadEnv(file: string): void {
6
11
  const path = join(__dirname, file);
7
12
  if (existsSync(path)) process.loadEnvFile(path);
8
13
  }
9
14
 
15
+ loadEnv(".env.local");
16
+ loadEnv(".env");
17
+ const ENVIRONMENT = process.env.VITE_ENVIRONMENT || "dev";
18
+ loadEnv(`build/${ENVIRONMENT}/.env.${ENVIRONMENT}`);
19
+
10
20
  /** A flavour built for distribution never points at a laptop, whatever `.env.local` says. */
11
21
  const DISTRIBUTED = new Set(["staging", "prod"]);
12
22
 
13
- const liveReload =
14
- process.env.VITE_LIVE_RELOAD === "true" && !DISTRIBUTED.has(process.env.VITE_ENVIRONMENT ?? "");
23
+ const liveReload = process.env.VITE_LIVE_RELOAD === "true" && !DISTRIBUTED.has(ENVIRONMENT);
15
24
 
16
25
  /**
17
26
  * Live reload: the installed debug app loads the dev server instead of its bundled files, so an
@@ -20,15 +29,15 @@ const liveReload =
20
29
  */
21
30
  function liveReloadUrl(): string | undefined {
22
31
  if (!liveReload) return undefined;
23
- const scheme = process.env.VITE_LIVE_RELOAD_SCHEME ?? "http";
24
- const host = process.env.VITE_LIVE_RELOAD_HOST ?? "localhost";
25
- const port = process.env.VITE_LIVE_RELOAD_PORT ?? "5173";
32
+ const scheme = process.env.VITE_LIVE_RELOAD_SCHEME || "http";
33
+ const host = process.env.VITE_LIVE_RELOAD_HOST || "localhost";
34
+ const port = process.env.VITE_LIVE_RELOAD_PORT || "5173";
26
35
  return `${scheme}://${host}:${port}`;
27
36
  }
28
37
 
29
38
  const config: CapacitorConfig = {
30
- appId: "com.example.m3e",
31
- appName: "M3E",
39
+ appId: process.env.VITE_APP_ID || "com.example.m3e",
40
+ appName: process.env.VITE_APP_NAME || "M3E",
32
41
  webDir: "dist",
33
42
  server: {
34
43
  url: liveReloadUrl(),
@@ -44,6 +44,7 @@
44
44
  "@intlify/unplugin-vue-i18n": "^11.2.5",
45
45
  "@types/node": "^24.12.2",
46
46
  "@vitejs/plugin-vue": "^6.0.3",
47
+ "happy-dom": "^20.11.6",
47
48
  "sql.js": "1.13.0",
48
49
  "typescript": "^5.9.3",
49
50
  "unplugin-auto-import": "^21.1.0",