@cellajs/create-cella 0.1.5 → 0.2.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) 2024 CellaJS
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,57 @@
1
+ # @cellajs/create-cella
2
+
3
+ CLI tool to scaffold a new Cella project from the template.
4
+
5
+ ## Overview
6
+
7
+ This CLI creates a new Cella project by downloading the latest template, setting up your development environment, and configuring git with upstream tracking for future syncs.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ pnpm create @cellajs/cella my-app
13
+ ```
14
+
15
+ Running without arguments starts interactive mode, prompting for:
16
+
17
+ 1. **Project name** – Directory name and package name
18
+ 2. **New branch** – Optionally create a dev branch alongside `main`
19
+ 3. **Directory conflict** – If target exists, choose to cancel or continue
20
+
21
+ ## CLI Options
22
+
23
+ ```bash
24
+ pnpm create @cellajs/cella [directory] [options]
25
+ ```
26
+
27
+ ## What It Does
28
+
29
+ 1. Downloads latest Cella template via [giget](https://github.com/unjs/giget)
30
+ 2. Cleans template files (removes cella-specific docs, configs)
31
+ 3. Installs dependencies with `pnpm install`
32
+ 4. Generates initial database migrations
33
+ 5. Initializes git repository with initial commit
34
+ 6. Creates optional working branch
35
+ 7. Adds Cella as upstream remote for future syncs
36
+
37
+ ## Development
38
+
39
+ ```bash
40
+ cd cli/create-cella
41
+
42
+ # Type check
43
+ pnpm ts
44
+
45
+ # Lint
46
+ pnpm lint:fix
47
+
48
+ # Run tests
49
+ pnpm test
50
+
51
+ # Run locally
52
+ pnpm start
53
+
54
+ # Build for npm publish
55
+ pnpm build
56
+ ```
57
+
package/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import './dist/index.js'
3
+ import './dist/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cellajs/create-cella",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Cella is a TypeScript template to create web apps with sync and offline capabilities.",
@@ -15,32 +15,44 @@
15
15
  "homepage": "https://cellajs.com",
16
16
  "author": "CellaJS <info@cellajs.com>",
17
17
  "engines": {
18
- "node": ">=20.14.0"
18
+ "node": "24.x"
19
19
  },
20
20
  "type": "module",
21
- "main": "./src/index.ts",
21
+ "main": "./src/create-cella-cli.ts",
22
+ "imports": {
23
+ "#/*": "./src/*"
24
+ },
22
25
  "bin": {
23
26
  "create-cella": "index.js"
24
27
  },
28
+ "dependencies": {
29
+ "@inquirer/prompts": "^8.3.0",
30
+ "axios": "^1.13.6",
31
+ "commander": "^14.0.3",
32
+ "giget": "^3.1.2",
33
+ "nano-spawn": "^2.0.0",
34
+ "ora": "^9.3.0",
35
+ "picocolors": "^1.1.1",
36
+ "validate-npm-package-name": "^7.0.2"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "24.10.13",
40
+ "@types/validate-npm-package-name": "^4.0.2",
41
+ "tsup": "^8.5.1",
42
+ "tsx": "^4.21.0"
43
+ },
25
44
  "scripts": {
26
- "start": "tsx ./src/index.ts",
45
+ "start": "tsx ./src/create-cella-cli.ts",
27
46
  "clean": "rimraf ./dist",
28
47
  "build": "tsup",
29
48
  "test-build": "pnpm run build && node index.js",
30
- "prepublishOnly": "pnpm run build"
31
- },
32
- "packageManager": "pnpm@9.11.0",
33
- "dependencies": {
34
- "@inquirer/prompts": "^6.0.1",
35
- "commander": "^12.1.0",
36
- "cross-spawn": "^7.0.3",
37
- "giget": "^1.2.3",
38
- "picocolors": "^1.1.0",
39
- "validate-npm-package-name": "^5.0.1",
40
- "yocto-spinner": "^0.1.0"
41
- },
42
- "devDependencies": {
43
- "tsup": "^8.3.5",
44
- "tsx": "^4.19.2"
49
+ "check": "pnpm ts && pnpm biome check --write .",
50
+ "check:old": "pnpm ts:old && pnpm biome check --write .",
51
+ "ts": "tsgo --pretty",
52
+ "ts:old": "tsc --pretty",
53
+ "lint": "biome check .",
54
+ "lint:fix": "biome check --write .",
55
+ "test": "vitest run",
56
+ "test:watch": "vitest"
45
57
  }
46
- }
58
+ }
package/src/add-remote.ts CHANGED
@@ -1,50 +1,40 @@
1
- import yoctoSpinner from 'yocto-spinner';
2
- import { runGitCommand } from './utils/run-git-command.ts';
3
- import { CELLA_REMOTE_URL } from './constants.ts';
4
-
5
- interface AddRemoteOptions {
6
- targetFolder: string;
7
- remoteUrl?: string; // Optional, defaults to CELLA_REMOTE_URL
8
- remoteName?: string; // Optional, defaults to 'upstream'
9
- }
1
+ import { CELLA_REMOTE_URL } from '#/constants';
2
+ import type { AddRemoteOptions } from '#/modules/cli';
3
+ import { gitRemoteAdd, gitRemoteGetUrl, gitRemoteRemove } from '#/utils/git';
10
4
 
5
+ /**
6
+ * Adds or updates the upstream remote for the Cella template.
7
+ * @param options - Configuration options
8
+ * @param options.silent - If true, don't throw on failure (used when called from progress tracker)
9
+ */
11
10
  export async function addRemote({
12
11
  targetFolder,
13
12
  remoteUrl = CELLA_REMOTE_URL,
14
13
  remoteName = 'upstream',
14
+ silent = false,
15
15
  }: AddRemoteOptions): Promise<void> {
16
-
17
- // Spinner for adding remote
18
- const remoteSpinner = yoctoSpinner({
19
- text: 'Adding remote',
20
- }).start();
21
-
22
16
  try {
23
17
  // Check if the remote exists
24
18
  let remote: string | null = null;
25
19
 
26
20
  try {
27
- remote = await runGitCommand({ targetFolder, command: `remote get-url ${remoteName}` });
28
- } catch (error: any) {
21
+ remote = await gitRemoteGetUrl(targetFolder, remoteName);
22
+ } catch {
29
23
  // If the remote doesn't exist, it throws a fatal error
30
24
  remote = null;
31
25
  }
32
26
 
33
27
  // Add or update the remote if it doesn't exist or differs from `remoteUrl`
34
28
  if (!remote) {
35
- await runGitCommand({ targetFolder, command: `remote add ${remoteName} ${remoteUrl}` });
36
- remoteSpinner.success('Remote added successfully.');
29
+ await gitRemoteAdd(targetFolder, remoteName, remoteUrl);
37
30
  } else if (remote !== remoteUrl) {
38
31
  // Remove existing remote and set the new URL
39
- await runGitCommand({ targetFolder, command: `remote remove ${remoteName}` });
40
- await runGitCommand({ targetFolder, command: `remote add ${remoteName} ${remoteUrl}` });
41
- remoteSpinner.success('Remote updated successfully.');
42
- } else {
43
- remoteSpinner.success('Remote is already configured correctly.');
32
+ await gitRemoteRemove(targetFolder, remoteName);
33
+ await gitRemoteAdd(targetFolder, remoteName, remoteUrl);
44
34
  }
45
35
  } catch (error) {
46
- console.error(error);
47
- remoteSpinner.error('Failed to add remote.');
48
- process.exit(1);
36
+ if (!silent) {
37
+ throw error;
38
+ }
49
39
  }
50
40
  }
package/src/constants.ts CHANGED
@@ -1,62 +1,106 @@
1
- export const NAME = 'create-cella';
1
+ import pc from 'picocolors';
2
+ import packageJson from '../package.json' with { type: 'json' };
2
3
 
3
- // URL of the template repository
4
- export const TEMPLATE_URL = 'github:cellajs/cella';
4
+ /** Name of this CLI tool */
5
+ export const NAME = 'create cella';
6
+
7
+ /** Thin line divider for console output (60 chars wide) */
8
+ export const DIVIDER = '─'.repeat(60);
5
9
 
6
- // URL to the repository
7
- export const CELLA_REMOTE_URL: string = 'git@github.com:cellajs/cella.git';
10
+ /** URL of the template repository */
11
+ export const TEMPLATE_URL = 'github:cellajs/cella';
8
12
 
9
- // Import package.json dynamically for version and website information
10
- import packageJson from '../package.json' assert { type: 'json' };
13
+ /** URL to the repository */
14
+ export const CELLA_REMOTE_URL = 'git@github.com:cellajs/cella.git';
11
15
 
12
- // Export details from package.json
16
+ /** Export details from package.json */
13
17
  export const DESCRIPTION: string = packageJson.description;
14
18
  export const VERSION: string = packageJson.version;
15
19
  export const AUTHOR: string = packageJson.author;
16
20
  export const WEBSITE: string = packageJson.homepage;
17
21
  export const GITHUB: string = packageJson.repository.url;
18
22
 
23
+ export function getHeaderLine(templateVersion?: string): string {
24
+ const leftText = `⧈ ${NAME} · v${VERSION} · cella v${templateVersion}`;
25
+ const rightText = packageJson.homepage.replace('https://', '');
26
+ const left = `${pc.cyan(`⧈ ${NAME}`)} ${pc.dim(`· v${VERSION} · cella v${templateVersion}`)}`;
27
+ const right = pc.cyan(rightText);
28
+ const padding = Math.max(1, 60 - leftText.length - rightText.length);
29
+ return `${left}${' '.repeat(padding)}${right}`;
30
+ }
31
+
19
32
  // Files or folders to be removed from the template after downloading
20
- export const TO_REMOVE: string[] = [
21
- 'info',
22
- './cli/create-cella',
23
- ];
33
+ export const TO_REMOVE: string[] = ['./cli/create', './info/QUICKSTART.md'];
24
34
 
25
35
  // Specific folder contents to be cleaned out from the template
26
- export const TO_CLEAN: string[] = [
27
- './backend/drizzle',
28
- ];
36
+ export const TO_CLEAN: string[] = ['./backend/drizzle'];
29
37
 
30
38
  // Files to copy/paste after downloading
31
39
  export const TO_COPY: Record<string, string> = {
32
40
  './backend/.env.example': './backend/.env',
33
41
  './frontend/.env.example': './frontend/.env',
34
- './tus/.env.example': './tus/.env',
35
42
  './info/QUICKSTART.md': 'README.md',
36
43
  };
37
44
 
38
- // Files to be editted after downloading
39
- export const TO_EDIT: Record<string, { regexMatch: RegExp; replaceWith: string }[]> = {
40
- './config/default.ts': [
45
+ /** Type for file edit operations */
46
+ export type FileEdit = { regexMatch: RegExp; replaceWith: string };
47
+
48
+ // Files to be edited after downloading
49
+ export const TO_EDIT: Record<string, FileEdit[]> = {
50
+ './shared/default-config.ts': [
41
51
  {
42
- regexMatch: /enabledAuthenticationStrategies:\s*\[[^\]]+\]\s*as\s*const,/g,
43
- replaceWith: "enabledAuthenticationStrategies: ['password', 'passkey'] as const,",
52
+ regexMatch: /enabledAuthStrategies:\s*\[[^\]]+\]\s*as\s*const,/g,
53
+ replaceWith: "enabledAuthStrategies: ['password', 'passkey', 'totp'] as const,",
44
54
  },
45
55
  {
46
- regexMatch: /imado\:\s*(true|false),/g,
47
- replaceWith: "imado: false,",
56
+ regexMatch: /uploadEnabled:\s*(true|false),/g,
57
+ replaceWith: 'uploadEnabled: false,',
48
58
  },
49
59
  {
50
- regexMatch: /enabledOauthProviders:\s*\[[^\]]+\]\s*as\s*const,/g,
51
- replaceWith: "enabledOauthProviders: [] as const,",
60
+ regexMatch: /enabledOAuthProviders:\s*\[[^\]]+\]\s*as\s*const,/g,
61
+ replaceWith: 'enabledOAuthProviders: [] as const,',
52
62
  },
53
63
  ],
54
64
  };
55
- // ASCII logo for the CLI output
56
- export const LOGO = `
57
- _ _
58
- ▒▓█████▓▒ ___ ___| | | __ _
59
- ▒▓█ █▓▒ / __/ _ \\ | |/ _\` |
60
- ▒▓█ █▓▒ | (_| __/ | | (_| |
61
- ▒▓█████▓▒ \\___\\___|_|_|\\__,_|
62
- `;
65
+
66
+ /**
67
+ * Generate file edits to apply a port offset to a new fork.
68
+ * All dev ports are shifted by the given offset to avoid collisions with sibling forks.
69
+ *
70
+ * Only 3 files need editing — all other services derive ports from these:
71
+ * - development-config.ts → frontend/backend URLs (read by Vite, backend, CDC, studio, tests)
72
+ * - .env → backend PORT + database connection strings
73
+ * - compose.yaml → Docker container names + host port mappings
74
+ *
75
+ * Default ports: frontend=3000, backend=4000, db=5432, dbTest=5434
76
+ */
77
+ export function getPortEdits(projectName: string, offset: number): Record<string, FileEdit[]> {
78
+ if (offset === 0) return {};
79
+
80
+ const fe = 3000 + offset;
81
+ const be = 4000 + offset;
82
+ const db = 5432 + offset;
83
+ const dbTest = 5434 + offset;
84
+
85
+ return {
86
+ './shared/development-config.ts': [
87
+ { regexMatch: /frontendUrl:\s*'http:\/\/localhost:\d+'/g, replaceWith: `frontendUrl: 'http://localhost:${fe}'` },
88
+ { regexMatch: /backendUrl:\s*'http:\/\/localhost:\d+'/g, replaceWith: `backendUrl: 'http://localhost:${be}'` },
89
+ {
90
+ regexMatch: /backendAuthUrl:\s*'http:\/\/localhost:\d+\/auth'/g,
91
+ replaceWith: `backendAuthUrl: 'http://localhost:${be}/auth'`,
92
+ },
93
+ ],
94
+ './backend/.env': [
95
+ { regexMatch: /PORT=\d+/g, replaceWith: `PORT=${be}` },
96
+ { regexMatch: /@0\.0\.0\.0:5432\//g, replaceWith: `@0.0.0.0:${db}/` },
97
+ ],
98
+ './compose.yaml': [
99
+ { regexMatch: /name: cella/g, replaceWith: `name: ${projectName}` },
100
+ { regexMatch: /container_name: cella_db\b/g, replaceWith: `container_name: ${projectName}_db` },
101
+ { regexMatch: /container_name: cella_db_test/g, replaceWith: `container_name: ${projectName}_db_test` },
102
+ { regexMatch: /- 5432:5432/g, replaceWith: `- ${db}:5432` },
103
+ { regexMatch: /- 5434:5432/g, replaceWith: `- ${dbTest}:5432` },
104
+ ],
105
+ };
106
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { basename, resolve } from 'node:path';
5
+ import { input, select } from '@inquirer/prompts';
6
+ import pc from 'picocolors';
7
+
8
+ import { TEMPLATE_URL } from '#/constants';
9
+ import { create } from '#/create';
10
+ import { type CreateOptions, cli, showWelcome } from '#/modules/cli';
11
+ import { detectUsedPorts, findNextOffset } from '#/utils/detect-used-ports';
12
+ import { extractPackageJsonVersionFromUri } from '#/utils/extract-package-json-version-from-uri';
13
+ import { isEmptyDirectory } from '#/utils/is-empty-directory';
14
+ import { validateProjectName } from '#/utils/validate-project-name';
15
+
16
+ async function main(): Promise<void> {
17
+ // Get the latest version of the template
18
+ const templateVersion = await extractPackageJsonVersionFromUri(TEMPLATE_URL);
19
+
20
+ // Display CLI welcome banner
21
+ showWelcome(templateVersion);
22
+
23
+ // Shared theme to clear prompts after answering
24
+ const promptTheme = { prefix: '', style: { answer: (text: string) => text } };
25
+ const promptContext = { clearPromptOnDone: true };
26
+
27
+ // Prompt for project name if not provided
28
+ if (!cli.directory) {
29
+ cli.directory = await input(
30
+ {
31
+ message: 'Enter your project name',
32
+ default: 'my-cella-app',
33
+ theme: promptTheme,
34
+ validate: (name) => {
35
+ const validation = validateProjectName(basename(resolve(name)));
36
+ return validation.valid ? true : `Invalid project name: ${validation.problems?.[0] ?? 'unknown error'}`;
37
+ },
38
+ },
39
+ promptContext,
40
+ );
41
+ }
42
+
43
+ // Default to creating a 'development' working branch
44
+ if (!cli.newBranchName) {
45
+ cli.newBranchName = 'development';
46
+ }
47
+
48
+ const targetFolder = resolve(cli.directory);
49
+ const projectName = basename(targetFolder);
50
+
51
+ // Check if the target folder exists and is not empty
52
+ if (existsSync(targetFolder) && !(await isEmptyDirectory(targetFolder))) {
53
+ const dirName = cli.directory === '.' ? 'Current directory' : `Target directory "${targetFolder}"`;
54
+ const message = `${dirName} is not empty. Please choose how you would like to proceed:`;
55
+
56
+ const action = await select(
57
+ {
58
+ message,
59
+ theme: promptTheme,
60
+ choices: [
61
+ { name: 'Cancel and exit', value: 'cancel' },
62
+ { name: 'Ignore existing files and continue', value: 'ignore' },
63
+ ],
64
+ },
65
+ promptContext,
66
+ );
67
+
68
+ if (action === 'cancel') {
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ // Scan sibling directories and prompt for port offset
74
+ const portOffset = await promptPortOffset(targetFolder, promptTheme, promptContext);
75
+
76
+ // Proceed with the project creation
77
+ const createOptions: CreateOptions = {
78
+ projectName,
79
+ targetFolder,
80
+ newBranchName: cli.newBranchName,
81
+ packageManager: cli.packageManager,
82
+ templateUrl: cli.options.template,
83
+ portOffset,
84
+ };
85
+
86
+ await create(createOptions);
87
+ }
88
+
89
+ main().catch(console.error);
90
+
91
+ /** Format an offset as a port overview string, e.g. "10 → :3010 / :4010 / :5442" */
92
+ function formatOffset(o: number, suffix = ''): string {
93
+ return `${o} → :${3000 + o} / :${4000 + o} / :${5432 + o}${suffix}`;
94
+ }
95
+
96
+ /** Scan sibling forks and prompt the user to pick a port offset */
97
+ async function promptPortOffset(targetFolder: string, theme: object, context: object): Promise<number> {
98
+ const usedPorts = await detectUsedPorts(targetFolder);
99
+ const suggested = findNextOffset(usedPorts);
100
+
101
+ if (usedPorts.length > 0) {
102
+ console.info(pc.dim('\nDetected cella forks in sibling directories:'));
103
+ for (const p of usedPorts) {
104
+ console.info(pc.dim(` ${p.project}: frontend :${p.frontend}, backend :${p.backend} (offset ${p.offset})`));
105
+ }
106
+ console.info();
107
+ }
108
+
109
+ const presets = [0, 10, 20, 30].filter((o) => o !== suggested);
110
+ const choice = await select(
111
+ {
112
+ message: 'Port offset (avoids conflicts with sibling forks)',
113
+ theme,
114
+ choices: [
115
+ ...(suggested > 0 ? [{ name: formatOffset(suggested, ' (suggested)'), value: suggested }] : []),
116
+ { name: formatOffset(0, ' (default)'), value: 0 },
117
+ ...presets.map((o) => ({ name: formatOffset(o), value: o })),
118
+ { name: 'Custom offset', value: -1 },
119
+ ],
120
+ },
121
+ context,
122
+ );
123
+
124
+ if (choice !== -1) return choice;
125
+
126
+ const custom = await input(
127
+ {
128
+ message: 'Enter custom offset (0-490, multiples of 10)',
129
+ default: String(suggested),
130
+ theme,
131
+ validate: (val) => {
132
+ const n = Number(val);
133
+ if (Number.isNaN(n) || n < 0 || n > 490) return 'Must be between 0 and 490';
134
+ if (n % 10 !== 0) return 'Must be a multiple of 10';
135
+ return true;
136
+ },
137
+ },
138
+ context,
139
+ );
140
+ return Number(custom);
141
+ }