capstart 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adrien Villermois
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # Capstart CLI
2
+
3
+ Add Capacitor to an existing Next.js or TanStack Start application.
4
+
5
+ ```bash
6
+ npx capstart init ..
7
+ ```
8
+
9
+ Capstart detects the framework and package manager, configures a static or SPA
10
+ build, installs Capacitor, adds native projects, builds the web application, and
11
+ runs `cap sync`.
12
+
13
+ Installation, build, and Capacitor command output is hidden by default. Capstart
14
+ shows a single setup progress line and only prints a short command summary when
15
+ something fails.
16
+
17
+ Each main installation operation has its own step:
18
+
19
+ ```text
20
+ ◇ Configure Next.js
21
+ ◇ Configure Capacitor
22
+ ◇ Install Capacitor packages
23
+ ◇ Build the web app
24
+ ◇ Prepare iOS and Android projects
25
+ ◇ Synchronize native projects
26
+ ```
27
+
28
+ The commands executed inside each step remain hidden.
29
+
30
+ The detected framework is always shown and must be confirmed before Capstart
31
+ changes the project:
32
+
33
+ ```text
34
+ ✓ Detected Next.js
35
+ ? Use the detected framework Next.js? Yes
36
+ ```
37
+
38
+ If the detection is refused, Capstart lets you choose between Next.js and
39
+ TanStack Start.
40
+
41
+ ## Supported frameworks
42
+
43
+ - Next.js projects that can use static export
44
+ - TanStack Start projects that can use SPA mode
45
+
46
+ Server-only features must remain hosted remotely and be called from the mobile
47
+ application over HTTP.
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ npx capstart init [directory] [options]
53
+ ```
54
+
55
+ Examples:
56
+
57
+ ```bash
58
+ npx capstart init .
59
+ npx capstart init ../my-app --app-id com.example.myapp
60
+ npx capstart init . --platforms ios
61
+ npx capstart init . --framework tanstack-start --dry-run
62
+ npx capstart init . --yes
63
+ ```
64
+
65
+ Useful options:
66
+
67
+ ```text
68
+ --framework <nextjs|tanstack-start>
69
+ --app-id <id>
70
+ --app-name <name>
71
+ --platforms <ios,android>
72
+ --skip-install
73
+ --skip-build
74
+ --skip-native
75
+ --dry-run
76
+ --yes
77
+ ```
78
+
79
+ Use `--yes` to accept a single automatically detected framework in CI or other
80
+ non-interactive environments. Use `--framework` to bypass detection
81
+ confirmation and select an adapter explicitly.
82
+
83
+ After a successful interactive initialization, Capstart detects whether GitHub
84
+ CLI is installed and optionally proposes starring
85
+ [AdrienADV/capstart](https://github.com/AdrienADV/capstart). The repository is
86
+ only starred after explicit confirmation, and this step never runs in CI.
87
+
88
+ The final output includes:
89
+
90
+ - the scripts added to `package.json` and a short explanation of each one;
91
+ - recommended Capacitor packages and production guidance at
92
+ [capstart.dev/docs/installation/#3-add-recommended-capacitor-base-plugins](https://capstart.dev/docs/installation/#3-add-recommended-capacitor-base-plugins);
93
+ - an `Important` section explaining which framework server features must remain
94
+ remotely hosted.
95
+
96
+ Example:
97
+
98
+ ```text
99
+ Ready
100
+ ✓ Your base Capacitor setup is ready.
101
+
102
+ Scripts added
103
+ npm run cap:sync
104
+ Build the web app and sync the native projects.
105
+ npm run cap:ios
106
+ Build, sync, and open the iOS project in Xcode.
107
+ npm run cap:android
108
+ Build, sync, and open the Android project in Android Studio.
109
+
110
+ Next steps
111
+ • Review recommended plugins, native configuration, and production setup:
112
+ https://capstart.dev/docs/installation/#3-add-recommended-capacitor-base-plugins
113
+
114
+ Important
115
+ ! Next.js request-time features do not run inside the Capacitor app.
116
+ • Replace request-time Server Components and Server Actions with client-side
117
+ calls to API endpoints.
118
+ • Deploy those APIs, API routes, middleware, ISR, and other request-time logic
119
+ on a remote backend.
120
+ • Configure the mobile app with an HTTPS API base URL that is reachable from
121
+ the device.
122
+ • Do not use "localhost" for the backend URL: on a phone or emulator, it
123
+ points to the device itself.
124
+ ```
125
+
126
+ After initialization:
127
+
128
+ ```bash
129
+ npm run cap:sync
130
+ npm run cap:ios
131
+ npm run cap:android
132
+ ```
133
+
134
+ The exact package-manager prefix is generated for npm, pnpm, Yarn, or Bun.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ cd cli
140
+ npm install
141
+ npm run typecheck
142
+ npm test
143
+ npm run build
144
+ node dist/cli.js --help
145
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,957 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command, InvalidArgumentError } from "commander";
5
+
6
+ // src/adapters/nextjs.ts
7
+ import { writeFile as writeFile2 } from "fs/promises";
8
+ import path3 from "path";
9
+
10
+ // src/core/ast.ts
11
+ import path2 from "path";
12
+ import {
13
+ Node,
14
+ Project,
15
+ SyntaxKind
16
+ } from "ts-morph";
17
+
18
+ // src/core/project.ts
19
+ import { access, readFile, writeFile } from "fs/promises";
20
+ import path from "path";
21
+ var lockfiles = [
22
+ ["bun.lock", "bun"],
23
+ ["bun.lockb", "bun"],
24
+ ["pnpm-lock.yaml", "pnpm"],
25
+ ["yarn.lock", "yarn"],
26
+ ["package-lock.json", "npm"]
27
+ ];
28
+ async function pathExists(filePath) {
29
+ try {
30
+ await access(filePath);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+ async function loadProject(directory) {
37
+ const root = path.resolve(directory);
38
+ const packageJsonPath = path.join(root, "package.json");
39
+ if (!await pathExists(packageJsonPath)) {
40
+ throw new Error(`No package.json found in ${root}`);
41
+ }
42
+ const packageJson = JSON.parse(
43
+ await readFile(packageJsonPath, "utf8")
44
+ );
45
+ return {
46
+ root,
47
+ packageJsonPath,
48
+ packageJson,
49
+ packageManager: await detectPackageManager(root, packageJson)
50
+ };
51
+ }
52
+ async function savePackageJson(project, dryRun) {
53
+ if (dryRun) {
54
+ return;
55
+ }
56
+ await writeFile(
57
+ project.packageJsonPath,
58
+ `${JSON.stringify(project.packageJson, null, 2)}
59
+ `
60
+ );
61
+ }
62
+ function hasDependency(project, dependency) {
63
+ return Boolean(
64
+ project.packageJson.dependencies?.[dependency] ?? project.packageJson.devDependencies?.[dependency]
65
+ );
66
+ }
67
+ function getProjectName(project) {
68
+ const rawName = project.packageJson.name ?? path.basename(project.root);
69
+ return rawName.replace(/^@[^/]+\//, "");
70
+ }
71
+ function createDefaultAppId(project) {
72
+ const slug = getProjectName(project).toLowerCase().replace(/[^a-z0-9]+/g, "").replace(/^[^a-z]+/, "");
73
+ return `com.capstart.${slug || "app"}`;
74
+ }
75
+ async function detectPackageManager(root, packageJson) {
76
+ for (const [lockfile, packageManager] of lockfiles) {
77
+ if (await pathExists(path.join(root, lockfile))) {
78
+ return packageManager;
79
+ }
80
+ }
81
+ if (typeof packageJson.packageManager === "string") {
82
+ const packageManager = packageJson.packageManager.split("@")[0];
83
+ if (packageManager === "npm" || packageManager === "pnpm" || packageManager === "yarn" || packageManager === "bun") {
84
+ return packageManager;
85
+ }
86
+ }
87
+ return "npm";
88
+ }
89
+
90
+ // src/core/ast.ts
91
+ async function findConfigFile(root, names) {
92
+ for (const name of names) {
93
+ const filePath = path2.join(root, name);
94
+ if (await pathExists(filePath)) {
95
+ return filePath;
96
+ }
97
+ }
98
+ return void 0;
99
+ }
100
+ function loadSourceFile(filePath) {
101
+ const project = new Project({
102
+ skipAddingFilesFromTsConfig: true
103
+ });
104
+ return project.addSourceFileAtPath(filePath);
105
+ }
106
+ function findExportedObject(sourceFile) {
107
+ for (const exportAssignment of sourceFile.getExportAssignments()) {
108
+ const expression = exportAssignment.getExpression();
109
+ const object = resolveObjectExpression(expression);
110
+ if (object) {
111
+ return object;
112
+ }
113
+ }
114
+ for (const binary of sourceFile.getDescendantsOfKind(
115
+ SyntaxKind.BinaryExpression
116
+ )) {
117
+ if (binary.getLeft().getText() === "module.exports") {
118
+ const object = resolveObjectExpression(binary.getRight());
119
+ if (object) {
120
+ return object;
121
+ }
122
+ }
123
+ }
124
+ return void 0;
125
+ }
126
+ function setObjectProperty(object, name, initializer) {
127
+ const property = object.getProperty(name);
128
+ if (!property) {
129
+ object.addPropertyAssignment({ name, initializer });
130
+ return;
131
+ }
132
+ if (Node.isPropertyAssignment(property)) {
133
+ property.setInitializer(initializer);
134
+ return;
135
+ }
136
+ throw new Error(`Cannot safely update the "${name}" configuration property.`);
137
+ }
138
+ function getOrCreateNestedObject(object, name) {
139
+ const property = object.getProperty(name);
140
+ if (!property) {
141
+ const created = object.addPropertyAssignment({
142
+ name,
143
+ initializer: "{}"
144
+ });
145
+ const initializer = created.getInitializer();
146
+ if (!Node.isObjectLiteralExpression(initializer)) {
147
+ throw new Error(`Could not create the "${name}" configuration property.`);
148
+ }
149
+ return initializer;
150
+ }
151
+ if (Node.isPropertyAssignment(property)) {
152
+ const initializer = property.getInitializer();
153
+ if (Node.isObjectLiteralExpression(initializer)) {
154
+ return initializer;
155
+ }
156
+ }
157
+ throw new Error(`Cannot safely merge the "${name}" configuration property.`);
158
+ }
159
+ function resolveObjectExpression(expression) {
160
+ if (Node.isObjectLiteralExpression(expression)) {
161
+ return expression;
162
+ }
163
+ if (Node.isIdentifier(expression)) {
164
+ const declaration = expression.getDefinitions()[0]?.getDeclarationNode();
165
+ if (Node.isVariableDeclaration(declaration)) {
166
+ const initializer = declaration.getInitializer();
167
+ if (Node.isObjectLiteralExpression(initializer)) {
168
+ return initializer;
169
+ }
170
+ }
171
+ }
172
+ return void 0;
173
+ }
174
+
175
+ // src/adapters/nextjs.ts
176
+ var configNames = [
177
+ "next.config.ts",
178
+ "next.config.mts",
179
+ "next.config.mjs",
180
+ "next.config.js",
181
+ "next.config.cjs"
182
+ ];
183
+ var nextjsAdapter = {
184
+ id: "nextjs",
185
+ label: "Next.js",
186
+ webDir: "out",
187
+ detect(project) {
188
+ return hasDependency(project, "next");
189
+ },
190
+ async validate(project) {
191
+ const diagnostics = [];
192
+ if (!project.packageJson.scripts?.build) {
193
+ diagnostics.push({
194
+ level: "error",
195
+ message: 'Missing a "build" script in package.json.'
196
+ });
197
+ }
198
+ const configPath = await findConfigFile(project.root, configNames);
199
+ if (configPath) {
200
+ const sourceFile = loadSourceFile(configPath);
201
+ if (!findExportedObject(sourceFile)) {
202
+ diagnostics.push({
203
+ level: "error",
204
+ message: "The existing Next.js config is too dynamic to update safely. Export a config object before running Capstart."
205
+ });
206
+ }
207
+ }
208
+ return diagnostics;
209
+ },
210
+ async configure(project, dryRun) {
211
+ const disclaimers = [
212
+ {
213
+ title: "Next.js request-time features do not run inside the Capacitor app.",
214
+ details: [
215
+ "Replace request-time Server Components and Server Actions with client-side calls to API endpoints.",
216
+ "Deploy those APIs, API routes, middleware, ISR, and other request-time logic on a remote backend.",
217
+ "Configure the mobile app with an HTTPS API base URL that is reachable from the device.",
218
+ 'Do not use "localhost" for the backend URL: on a phone or emulator, it points to the device itself.'
219
+ ]
220
+ }
221
+ ];
222
+ const configPath = await findConfigFile(project.root, configNames);
223
+ if (!configPath) {
224
+ const newConfigPath = path3.join(project.root, "next.config.mjs");
225
+ if (!dryRun) {
226
+ await writeFile2(
227
+ newConfigPath,
228
+ [
229
+ "/** @type {import('next').NextConfig} */",
230
+ "const nextConfig = {",
231
+ ' output: "export",',
232
+ " trailingSlash: true,",
233
+ " images: {",
234
+ " unoptimized: true,",
235
+ " },",
236
+ "};",
237
+ "",
238
+ "export default nextConfig;",
239
+ ""
240
+ ].join("\n")
241
+ );
242
+ }
243
+ return { disclaimers };
244
+ }
245
+ const sourceFile = loadSourceFile(configPath);
246
+ const config = findExportedObject(sourceFile);
247
+ if (!config) {
248
+ throw new Error("Could not safely update the existing Next.js config.");
249
+ }
250
+ setObjectProperty(config, "output", '"export"');
251
+ setObjectProperty(config, "trailingSlash", "true");
252
+ const images = getOrCreateNestedObject(config, "images");
253
+ setObjectProperty(images, "unoptimized", "true");
254
+ if (!dryRun) {
255
+ await sourceFile.save();
256
+ }
257
+ return { disclaimers };
258
+ }
259
+ };
260
+
261
+ // src/adapters/tanstack-start.ts
262
+ import { Node as Node2, SyntaxKind as SyntaxKind2 } from "ts-morph";
263
+ var configNames2 = [
264
+ "vite.config.ts",
265
+ "vite.config.mts",
266
+ "vite.config.mjs",
267
+ "vite.config.js"
268
+ ];
269
+ var tanstackStartAdapter = {
270
+ id: "tanstack-start",
271
+ label: "TanStack Start",
272
+ webDir: "dist/client",
273
+ detect(project) {
274
+ return hasDependency(project, "@tanstack/react-start");
275
+ },
276
+ async validate(project) {
277
+ const diagnostics = [];
278
+ if (!project.packageJson.scripts?.build) {
279
+ diagnostics.push({
280
+ level: "error",
281
+ message: 'Missing a "build" script in package.json.'
282
+ });
283
+ }
284
+ const configPath = await findConfigFile(project.root, configNames2);
285
+ if (!configPath) {
286
+ diagnostics.push({
287
+ level: "error",
288
+ message: "Could not find a Vite configuration file."
289
+ });
290
+ return diagnostics;
291
+ }
292
+ const call = findTanstackStartCall(configPath);
293
+ if (!call) {
294
+ diagnostics.push({
295
+ level: "error",
296
+ message: "Could not find tanstackStart() in the Vite configuration file."
297
+ });
298
+ }
299
+ return diagnostics;
300
+ },
301
+ async configure(project, dryRun) {
302
+ const configPath = await findConfigFile(project.root, configNames2);
303
+ if (!configPath) {
304
+ throw new Error("Could not find a Vite configuration file.");
305
+ }
306
+ const sourceFile = loadSourceFile(configPath);
307
+ const call = sourceFile.getDescendantsOfKind(SyntaxKind2.CallExpression).find(
308
+ (candidate) => candidate.getExpression().getText() === "tanstackStart"
309
+ );
310
+ if (!call) {
311
+ throw new Error("Could not find tanstackStart() in the Vite config.");
312
+ }
313
+ let options = call.getArguments()[0];
314
+ if (!options) {
315
+ call.addArgument("{}");
316
+ options = call.getArguments()[0];
317
+ }
318
+ if (!Node2.isObjectLiteralExpression(options)) {
319
+ throw new Error(
320
+ "Cannot safely update tanstackStart() because its options are not an object literal."
321
+ );
322
+ }
323
+ const spa = getOrCreateNestedObject(options, "spa");
324
+ setObjectProperty(spa, "enabled", "true");
325
+ const prerender = getOrCreateNestedObject(spa, "prerender");
326
+ setObjectProperty(prerender, "outputPath", '"/index.html"');
327
+ if (!dryRun) {
328
+ await sourceFile.save();
329
+ }
330
+ return {
331
+ disclaimers: [
332
+ {
333
+ title: "TanStack Start server features do not run inside the Capacitor app.",
334
+ details: [
335
+ "Move server functions and server routes to a deployed backend.",
336
+ "Configure the mobile app to call an HTTPS API URL that is reachable from the device.",
337
+ 'Do not use "localhost" for the backend URL: on a phone or emulator, it points to the device itself.'
338
+ ]
339
+ }
340
+ ]
341
+ };
342
+ }
343
+ };
344
+ function findTanstackStartCall(configPath) {
345
+ return loadSourceFile(configPath).getDescendantsOfKind(SyntaxKind2.CallExpression).find((candidate) => candidate.getExpression().getText() === "tanstackStart");
346
+ }
347
+
348
+ // src/adapters/index.ts
349
+ var adapters = [nextjsAdapter, tanstackStartAdapter];
350
+ function getAdapter(id) {
351
+ const adapter = adapters.find((candidate) => candidate.id === id);
352
+ if (!adapter) {
353
+ throw new Error(`Unsupported framework: ${id}`);
354
+ }
355
+ return adapter;
356
+ }
357
+ function getAdapters() {
358
+ return adapters;
359
+ }
360
+ function detectAdapters(project) {
361
+ return adapters.filter((adapter) => adapter.detect(project));
362
+ }
363
+
364
+ // src/capacitor/configure.ts
365
+ import { readFile as readFile2, writeFile as writeFile3 } from "fs/promises";
366
+ import path4 from "path";
367
+
368
+ // src/core/process.ts
369
+ import { spawn } from "child_process";
370
+ var maxErrorLines = 12;
371
+ var maxCapturedOutput = 64e3;
372
+ async function runCommand(command, args, cwd) {
373
+ await new Promise((resolve, reject) => {
374
+ let output = "";
375
+ const child = spawn(command, args, {
376
+ cwd,
377
+ shell: process.platform === "win32",
378
+ stdio: ["ignore", "pipe", "pipe"]
379
+ });
380
+ child.on("error", reject);
381
+ child.stdout?.on("data", (chunk) => {
382
+ output = appendOutput(output, chunk.toString());
383
+ });
384
+ child.stderr?.on("data", (chunk) => {
385
+ output = appendOutput(output, chunk.toString());
386
+ });
387
+ child.on("exit", (code) => {
388
+ if (code === 0) {
389
+ resolve();
390
+ return;
391
+ }
392
+ reject(
393
+ new Error(
394
+ [
395
+ `Command failed: ${command} ${args.join(" ")}`,
396
+ getErrorSummary(output)
397
+ ].filter(Boolean).join("\n")
398
+ )
399
+ );
400
+ });
401
+ });
402
+ }
403
+ function appendOutput(current, chunk) {
404
+ return `${current}${chunk}`.slice(-maxCapturedOutput);
405
+ }
406
+ function getErrorSummary(output) {
407
+ return output.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "").split(/[\r\n]+/).map((line) => line.trim().slice(0, 300)).filter(Boolean).slice(-maxErrorLines).join("\n");
408
+ }
409
+ async function commandExists(command, args = ["--version"]) {
410
+ return new Promise((resolve) => {
411
+ const child = spawn(command, args, {
412
+ shell: process.platform === "win32",
413
+ stdio: "ignore"
414
+ });
415
+ child.on("error", () => resolve(false));
416
+ child.on("exit", (code) => resolve(code === 0));
417
+ });
418
+ }
419
+ function installCommand(packageManager, packages, dev) {
420
+ const devFlag = dev ? ["-D"] : [];
421
+ switch (packageManager) {
422
+ case "bun":
423
+ return ["bun", ["add", ...devFlag, ...packages]];
424
+ case "pnpm":
425
+ return ["pnpm", ["add", ...devFlag, ...packages]];
426
+ case "yarn":
427
+ return ["yarn", ["add", ...devFlag, ...packages]];
428
+ case "npm":
429
+ return ["npm", ["install", ...dev ? ["--save-dev"] : [], ...packages]];
430
+ }
431
+ }
432
+ function runScriptCommand(packageManager, script) {
433
+ switch (packageManager) {
434
+ case "yarn":
435
+ return ["yarn", [script]];
436
+ case "bun":
437
+ return ["bun", ["run", script]];
438
+ case "pnpm":
439
+ return ["pnpm", ["run", script]];
440
+ case "npm":
441
+ return ["npm", ["run", script]];
442
+ }
443
+ }
444
+ function capacitorCommand(packageManager, args) {
445
+ switch (packageManager) {
446
+ case "bun":
447
+ return ["bunx", ["cap", ...args]];
448
+ case "pnpm":
449
+ return ["pnpm", ["exec", "cap", ...args]];
450
+ case "yarn":
451
+ return ["yarn", ["cap", ...args]];
452
+ case "npm":
453
+ return ["npm", ["exec", "cap", "--", ...args]];
454
+ }
455
+ }
456
+ function packageScriptPrefix(packageManager) {
457
+ switch (packageManager) {
458
+ case "yarn":
459
+ return "yarn";
460
+ case "bun":
461
+ return "bun run";
462
+ case "pnpm":
463
+ return "pnpm run";
464
+ case "npm":
465
+ return "npm run";
466
+ }
467
+ }
468
+ function capacitorScriptPrefix(packageManager) {
469
+ switch (packageManager) {
470
+ case "bun":
471
+ return "bunx cap";
472
+ case "pnpm":
473
+ return "pnpm exec cap";
474
+ case "yarn":
475
+ return "yarn cap";
476
+ case "npm":
477
+ return "npm exec cap --";
478
+ }
479
+ }
480
+
481
+ // src/capacitor/configure.ts
482
+ var configNames3 = [
483
+ "capacitor.config.ts",
484
+ "capacitor.config.mts",
485
+ "capacitor.config.js",
486
+ "capacitor.config.mjs",
487
+ "capacitor.config.json"
488
+ ];
489
+ async function configureCapacitor(project, options) {
490
+ const configPath = await findConfigFile(project.root, configNames3);
491
+ if (!configPath) {
492
+ if (!options.dryRun) {
493
+ await writeFile3(
494
+ path4.join(project.root, "capacitor.config.ts"),
495
+ [
496
+ 'import type { CapacitorConfig } from "@capacitor/cli";',
497
+ "",
498
+ "const config: CapacitorConfig = {",
499
+ ` appId: ${JSON.stringify(options.appId)},`,
500
+ ` appName: ${JSON.stringify(options.appName)},`,
501
+ ` webDir: ${JSON.stringify(options.webDir)},`,
502
+ "};",
503
+ "",
504
+ "export default config;",
505
+ ""
506
+ ].join("\n")
507
+ );
508
+ }
509
+ } else if (configPath.endsWith(".json")) {
510
+ const config = JSON.parse(await readFile2(configPath, "utf8"));
511
+ config.webDir = options.webDir;
512
+ if (!options.dryRun) {
513
+ await writeFile3(configPath, `${JSON.stringify(config, null, 2)}
514
+ `);
515
+ }
516
+ } else {
517
+ const sourceFile = loadSourceFile(configPath);
518
+ const config = findExportedObject(sourceFile);
519
+ if (!config) {
520
+ throw new Error(
521
+ `Cannot safely update the existing ${path4.basename(configPath)}.`
522
+ );
523
+ }
524
+ setObjectProperty(config, "webDir", JSON.stringify(options.webDir));
525
+ if (!options.dryRun) {
526
+ await sourceFile.save();
527
+ }
528
+ }
529
+ project.packageJson.scripts ??= {};
530
+ const run2 = packageScriptPrefix(project.packageManager);
531
+ const cap = capacitorScriptPrefix(project.packageManager);
532
+ project.packageJson.scripts["cap:sync"] = `${run2} build && ${cap} sync`;
533
+ for (const platform of options.platforms) {
534
+ project.packageJson.scripts[`cap:${platform}`] = `${run2} cap:sync && ${cap} open ${platform}`;
535
+ }
536
+ await savePackageJson(project, options.dryRun);
537
+ }
538
+
539
+ // src/capacitor/install.ts
540
+ import path5 from "path";
541
+ async function installCapacitor(project, platforms) {
542
+ const runtimePackages = [
543
+ "@capacitor/core",
544
+ ...platforms.map((platform) => `@capacitor/${platform}`)
545
+ ];
546
+ await run(project, installCommand(project.packageManager, runtimePackages, false));
547
+ await run(
548
+ project,
549
+ installCommand(project.packageManager, ["@capacitor/cli"], true)
550
+ );
551
+ }
552
+ async function buildProject(project) {
553
+ await run(project, runScriptCommand(project.packageManager, "build"));
554
+ }
555
+ async function addNativePlatforms(project, platforms) {
556
+ for (const platform of platforms) {
557
+ if (await pathExists(path5.join(project.root, platform))) {
558
+ continue;
559
+ }
560
+ await run(project, capacitorCommand(project.packageManager, ["add", platform]));
561
+ }
562
+ }
563
+ async function syncNativeProjects(project) {
564
+ await run(project, capacitorCommand(project.packageManager, ["sync"]));
565
+ }
566
+ async function run(project, [command, args]) {
567
+ await runCommand(command, args, project.root);
568
+ }
569
+
570
+ // src/core/framework-selection.ts
571
+ import {
572
+ cancel,
573
+ confirm,
574
+ isCancel,
575
+ select
576
+ } from "@clack/prompts";
577
+ var terminalFrameworkPrompts = {
578
+ async confirmDetected(adapter) {
579
+ const answer = await confirm({
580
+ message: `Use the detected framework ${adapter.label}?`,
581
+ initialValue: true
582
+ });
583
+ return unwrapPrompt(answer);
584
+ },
585
+ async selectFramework(adapters2) {
586
+ const answer = await select({
587
+ message: "Which framework should Capstart configure?",
588
+ options: adapters2.map((adapter) => ({
589
+ value: adapter.id,
590
+ label: adapter.label
591
+ }))
592
+ });
593
+ return unwrapPrompt(answer);
594
+ }
595
+ };
596
+ async function chooseAdapter(options) {
597
+ if (options.requested) {
598
+ return getAdapter(options.requested);
599
+ }
600
+ if (options.detected.length === 1 && options.acceptDetected) {
601
+ return options.detected[0];
602
+ }
603
+ if (!options.interactive) {
604
+ if (options.detected.length === 1) {
605
+ throw new Error(
606
+ `Detected ${options.detected[0].label}, but confirmation requires an interactive terminal. Pass --yes to accept it or --framework to choose explicitly.`
607
+ );
608
+ }
609
+ throw new Error(
610
+ "Framework selection requires an interactive terminal. Pass --framework nextjs or --framework tanstack-start."
611
+ );
612
+ }
613
+ const prompts = options.prompts ?? terminalFrameworkPrompts;
614
+ if (options.detected.length === 1 && await prompts.confirmDetected(options.detected[0])) {
615
+ return options.detected[0];
616
+ }
617
+ return getAdapter(await prompts.selectFramework(getAdapters()));
618
+ }
619
+ function unwrapPrompt(value) {
620
+ if (isCancel(value)) {
621
+ cancel("Capstart initialization cancelled.");
622
+ throw new Error("Initialization cancelled.");
623
+ }
624
+ return value;
625
+ }
626
+
627
+ // src/core/github-star.ts
628
+ import { confirm as confirm2, isCancel as isCancel2 } from "@clack/prompts";
629
+
630
+ // src/core/logger.ts
631
+ import pc from "picocolors";
632
+ var logger = {
633
+ info(message) {
634
+ console.log(`${pc.cyan("\u2022")} ${message}`);
635
+ },
636
+ success(message) {
637
+ console.log(`${pc.green("\u2713")} ${message}`);
638
+ },
639
+ warning(message) {
640
+ console.log(`${pc.yellow("!")} ${message}`);
641
+ },
642
+ error(message) {
643
+ console.error(`${pc.red("\u2717")} ${message}`);
644
+ },
645
+ heading(message) {
646
+ console.log(`
647
+ ${pc.bold(message)}`);
648
+ },
649
+ link(url) {
650
+ console.log(` ${pc.underline(pc.cyan(url))}`);
651
+ },
652
+ command(command, description) {
653
+ console.log(` ${pc.cyan(command)}
654
+ ${pc.dim(description)}`);
655
+ },
656
+ detail(message) {
657
+ const lines = wrapText(message, getContentWidth());
658
+ console.log(
659
+ lines.map(
660
+ (line, index) => index === 0 ? ` ${pc.yellow("\u2022")} ${line}` : ` ${line}`
661
+ ).join("\n")
662
+ );
663
+ }
664
+ };
665
+ function getContentWidth() {
666
+ const terminalWidth = process.stdout.columns ?? 100;
667
+ return Math.max(40, Math.min(terminalWidth - 4, 80));
668
+ }
669
+ function wrapText(message, width) {
670
+ const lines = [];
671
+ let line = "";
672
+ for (const word of message.split(/\s+/)) {
673
+ if (line.length > 0 && line.length + word.length + 1 > width) {
674
+ lines.push(line);
675
+ line = word;
676
+ continue;
677
+ }
678
+ line = line.length === 0 ? word : `${line} ${word}`;
679
+ }
680
+ if (line.length > 0) {
681
+ lines.push(line);
682
+ }
683
+ return lines;
684
+ }
685
+
686
+ // src/core/github-star.ts
687
+ var repository = "AdrienADV/capstart";
688
+ var terminalGithubStarPrompt = {
689
+ async confirmStar() {
690
+ const answer = await confirm2({
691
+ message: "Would you like to star Capstart on GitHub?",
692
+ initialValue: true
693
+ });
694
+ return isCancel2(answer) ? false : answer;
695
+ }
696
+ };
697
+ async function offerGithubStar(options) {
698
+ if (!options.interactive) {
699
+ return "skipped";
700
+ }
701
+ const isGhInstalled = options.isGhInstalled ?? (() => commandExists("gh", ["--version"]));
702
+ if (!await isGhInstalled()) {
703
+ return "skipped";
704
+ }
705
+ const prompt = options.prompt ?? terminalGithubStarPrompt;
706
+ if (!await prompt.confirmStar()) {
707
+ return "declined";
708
+ }
709
+ const starRepository = options.starRepository ?? (() => runCommand(
710
+ "gh",
711
+ ["api", "--method", "PUT", `/user/starred/${repository}`],
712
+ options.cwd
713
+ ));
714
+ try {
715
+ await starRepository();
716
+ logger.success(`Starred https://github.com/${repository}`);
717
+ return "starred";
718
+ } catch {
719
+ logger.warning(
720
+ `Could not star ${repository}. Check your GitHub CLI authentication with "gh auth status".`
721
+ );
722
+ return "failed";
723
+ }
724
+ }
725
+
726
+ // src/core/progress.ts
727
+ import { spinner } from "@clack/prompts";
728
+ var silentProgress = {
729
+ message() {
730
+ },
731
+ start() {
732
+ },
733
+ stop() {
734
+ }
735
+ };
736
+ function createProgress(interactive) {
737
+ return interactive ? spinner() : silentProgress;
738
+ }
739
+
740
+ // src/commands/init.ts
741
+ async function initCommand(options) {
742
+ const project = await loadProject(options.directory);
743
+ logger.heading("Capstart");
744
+ const detected = detectAdapters(project);
745
+ logDetection(detected);
746
+ const adapter = await chooseAdapter({
747
+ acceptDetected: options.yes,
748
+ detected,
749
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
750
+ requested: options.framework
751
+ });
752
+ if (!detected.includes(adapter)) {
753
+ logger.warning(`${adapter.label} was selected but was not automatically detected.`);
754
+ }
755
+ const diagnostics = await adapter.validate(project);
756
+ for (const warning of diagnostics.filter((item) => item.level === "warning")) {
757
+ logger.warning(warning.message);
758
+ }
759
+ const errors = diagnostics.filter((item) => item.level === "error");
760
+ if (errors.length > 0) {
761
+ throw new Error(errors.map((error) => error.message).join("\n"));
762
+ }
763
+ const appId = options.appId ?? createDefaultAppId(project);
764
+ const appName = options.appName ?? getProjectName(project);
765
+ validateAppId(appId);
766
+ if (options.dryRun) {
767
+ const frameworkResult2 = await adapter.configure(project, true);
768
+ await configureCapacitor(project, {
769
+ appId,
770
+ appName,
771
+ dryRun: true,
772
+ platforms: options.platforms,
773
+ webDir: adapter.webDir
774
+ });
775
+ logger.heading("Planned changes");
776
+ logger.info(`Configure ${adapter.label} for Capacitor`);
777
+ logger.info(`Configure Capacitor with webDir "${adapter.webDir}"`);
778
+ logger.success("Dry run complete. No files were changed.");
779
+ printFinalGuidance(frameworkResult2.disclaimers);
780
+ return;
781
+ }
782
+ const frameworkResult = await runSetup({
783
+ adapter,
784
+ appId,
785
+ appName,
786
+ options,
787
+ project
788
+ });
789
+ logger.heading("Ready");
790
+ logger.success("Your base Capacitor setup is ready.");
791
+ logger.heading("Scripts added");
792
+ logger.command(
793
+ `${project.packageManager} run cap:sync`,
794
+ "Build the web app and sync the native projects."
795
+ );
796
+ if (options.platforms.includes("ios")) {
797
+ logger.command(
798
+ `${project.packageManager} run cap:ios`,
799
+ "Build, sync, and open the iOS project in Xcode."
800
+ );
801
+ }
802
+ if (options.platforms.includes("android")) {
803
+ logger.command(
804
+ `${project.packageManager} run cap:android`,
805
+ "Build, sync, and open the Android project in Android Studio."
806
+ );
807
+ }
808
+ logger.heading("Next steps");
809
+ logger.info(
810
+ "Review recommended plugins, native configuration, and production setup:"
811
+ );
812
+ logger.link(
813
+ "https://capstart.dev/docs/installation/#3-add-recommended-capacitor-base-plugins"
814
+ );
815
+ await offerGithubStar({
816
+ cwd: project.root,
817
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY)
818
+ });
819
+ printFinalGuidance(frameworkResult.disclaimers);
820
+ }
821
+ async function runSetup(context) {
822
+ const { adapter, appId, appName, options, project } = context;
823
+ const progress = createProgress(Boolean(process.stdout.isTTY));
824
+ const frameworkResult = await runStep(
825
+ progress,
826
+ `Configure ${adapter.label}`,
827
+ () => adapter.configure(project, false)
828
+ );
829
+ await runStep(
830
+ progress,
831
+ "Configure Capacitor",
832
+ () => configureCapacitor(project, {
833
+ appId,
834
+ appName,
835
+ dryRun: false,
836
+ platforms: options.platforms,
837
+ webDir: adapter.webDir
838
+ })
839
+ );
840
+ if (!options.skipInstall) {
841
+ await runStep(
842
+ progress,
843
+ "Install Capacitor packages",
844
+ () => installCapacitor(project, options.platforms)
845
+ );
846
+ }
847
+ if (!options.skipBuild) {
848
+ await runStep(progress, "Build the web app", () => buildProject(project));
849
+ }
850
+ if (!options.skipNative) {
851
+ await runStep(
852
+ progress,
853
+ `Prepare ${formatPlatforms(options.platforms)} projects`,
854
+ () => addNativePlatforms(project, options.platforms)
855
+ );
856
+ await runStep(
857
+ progress,
858
+ "Synchronize native projects",
859
+ () => syncNativeProjects(project)
860
+ );
861
+ }
862
+ return frameworkResult;
863
+ }
864
+ async function runStep(progress, label, action) {
865
+ progress.start(label);
866
+ try {
867
+ const result = await action();
868
+ progress.stop(label);
869
+ return result;
870
+ } catch (error) {
871
+ progress.stop(`${label} failed`);
872
+ throw error;
873
+ }
874
+ }
875
+ function formatPlatforms(platforms) {
876
+ return platforms.map((platform) => platform === "ios" ? "iOS" : "Android").join(" and ");
877
+ }
878
+ function logDetection(detected) {
879
+ if (detected.length === 0) {
880
+ logger.warning("No supported framework was automatically detected.");
881
+ return;
882
+ }
883
+ if (detected.length === 1) {
884
+ logger.success(`Detected ${detected[0].label}`);
885
+ return;
886
+ }
887
+ logger.warning(
888
+ `Detected multiple frameworks: ${detected.map((adapter) => adapter.label).join(", ")}`
889
+ );
890
+ }
891
+ function validateAppId(appId) {
892
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(appId)) {
893
+ throw new Error(
894
+ `Invalid app id "${appId}". Use reverse-domain notation, for example com.example.app.`
895
+ );
896
+ }
897
+ }
898
+ function printFinalGuidance(disclaimers) {
899
+ if (disclaimers.length === 0) {
900
+ return;
901
+ }
902
+ logger.heading("Important");
903
+ for (const disclaimer of disclaimers) {
904
+ logger.warning(disclaimer.title);
905
+ for (const detail of disclaimer.details) {
906
+ logger.detail(detail);
907
+ }
908
+ }
909
+ }
910
+
911
+ // src/cli.ts
912
+ var program = new Command();
913
+ program.name("capstart").description("Add Capacitor to an existing web application.").version("0.1.0");
914
+ program.command("init").description("Configure an existing application for Capacitor.").argument("[directory]", "project directory", ".").option(
915
+ "-f, --framework <framework>",
916
+ "framework adapter: nextjs or tanstack-start",
917
+ parseFramework
918
+ ).option("--app-id <id>", "native application id, for example com.example.app").option("--app-name <name>", "native application name").option(
919
+ "--platforms <platforms>",
920
+ "comma-separated native platforms",
921
+ parsePlatforms,
922
+ ["ios", "android"]
923
+ ).option("--skip-install", "do not install Capacitor packages").option("--skip-build", "do not build the web application").option("--skip-native", "do not add or synchronize native projects").option("--dry-run", "show configuration changes without writing files").option("-y, --yes", "accept the automatically detected framework").action(async (directory, commandOptions) => {
924
+ await initCommand({
925
+ appId: commandOptions.appId,
926
+ appName: commandOptions.appName,
927
+ directory,
928
+ dryRun: commandOptions.dryRun ?? false,
929
+ framework: commandOptions.framework,
930
+ platforms: commandOptions.platforms,
931
+ skipBuild: commandOptions.skipBuild ?? false,
932
+ skipInstall: commandOptions.skipInstall ?? false,
933
+ skipNative: commandOptions.skipNative ?? false,
934
+ yes: commandOptions.yes ?? false
935
+ });
936
+ });
937
+ program.parseAsync().catch((error) => {
938
+ logger.error(error instanceof Error ? error.message : String(error));
939
+ process.exitCode = 1;
940
+ });
941
+ function parseFramework(value) {
942
+ if (value === "nextjs" || value === "tanstack-start") {
943
+ return value;
944
+ }
945
+ throw new InvalidArgumentError(
946
+ 'Framework must be "nextjs" or "tanstack-start".'
947
+ );
948
+ }
949
+ function parsePlatforms(value) {
950
+ const platforms = value.split(",").map((platform) => platform.trim()).filter(Boolean);
951
+ if (platforms.length === 0 || platforms.some((platform) => platform !== "ios" && platform !== "android")) {
952
+ throw new InvalidArgumentError(
953
+ 'Platforms must contain "ios", "android", or both.'
954
+ );
955
+ }
956
+ return [...new Set(platforms)];
957
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "capstart",
3
+ "version": "0.1.0",
4
+ "description": "Add Capacitor to existing web framework projects.",
5
+ "type": "module",
6
+ "bin": {
7
+ "capstart": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "capacitor",
15
+ "cli",
16
+ "mobile",
17
+ "nextjs",
18
+ "tanstack-start"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/AdrienADV/capstart.git",
23
+ "directory": "cli"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup src/cli.ts --format esm --dts --clean",
30
+ "dev": "node --import tsx src/cli.ts",
31
+ "test": "node --import tsx --test test/**/*.test.ts",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@clack/prompts": "^1.0.0",
36
+ "commander": "^14.0.2",
37
+ "picocolors": "^1.1.1",
38
+ "ts-morph": "^27.0.2"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.10.1",
42
+ "tsup": "^8.5.1",
43
+ "tsx": "^4.21.0",
44
+ "typescript": "^5.9.3"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "license": "MIT"
50
+ }