@seeksaas/cli 0.0.2

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/README.md ADDED
@@ -0,0 +1 @@
1
+ # SeekSaaS CLI
package/README.use.md ADDED
@@ -0,0 +1,70 @@
1
+ ## Versioning
2
+
3
+ The package version is controlled by the `version` field in `package.json`.
4
+
5
+ Use `pnpm version` from this workspace when bumping releases:
6
+
7
+ ```bash
8
+ pnpm --filter @seeksaas/cli version patch
9
+ pnpm --filter @seeksaas/cli version minor
10
+ pnpm --filter @seeksaas/cli version major
11
+ ```
12
+
13
+ Version bump examples:
14
+
15
+ ```text
16
+ 0.0.1 -> patch -> 0.0.2
17
+ 0.0.1 -> minor -> 0.1.0
18
+ 0.0.1 -> major -> 1.0.0
19
+ ```
20
+
21
+ To set an exact version:
22
+
23
+ ```bash
24
+ pnpm --filter @seeksaas/cli version 0.1.0
25
+ ```
26
+
27
+ To update `package.json` without creating a git tag:
28
+
29
+ ```bash
30
+ pnpm --filter @seeksaas/cli version patch --no-git-tag-version
31
+ ```
32
+
33
+ ## Publishing
34
+
35
+ Use pnpm for publishing this workspace package. Do not use npm or yarn to
36
+ manage dependencies in this repository.
37
+
38
+ Log in to the official npm registry before publishing:
39
+
40
+ ```bash
41
+ pnpm login --registry https://registry.npmjs.org/
42
+ ```
43
+
44
+ Preview the package before publishing:
45
+
46
+ ```bash
47
+ pnpm --filter @seeksaas/cli release:dry-run
48
+ ```
49
+
50
+ Publish the public npm package:
51
+
52
+ ```bash
53
+ pnpm --filter @seeksaas/cli release
54
+ ```
55
+
56
+ The release scripts build the package before running `pnpm publish`.
57
+ They pass `--no-git-checks` because the build step may update package output
58
+ before publish runs. Check `git diff` yourself before publishing.
59
+ They also pass `--registry https://registry.npmjs.org/` so publishing does not
60
+ accidentally target a local mirror registry.
61
+
62
+ The package already declares:
63
+
64
+ ```json
65
+ {
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }
70
+ ```
package/dist/index.mjs ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ import { cac } from "cac";
3
+ import path from "node:path";
4
+ import { cancel, confirm, intro, isCancel, outro, select, spinner, text } from "@clack/prompts";
5
+ import chalk from "chalk";
6
+ import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { spawn } from "node:child_process";
8
+
9
+ //#region src/core/errors.ts
10
+ function fail(message) {
11
+ console.error(chalk.red(message));
12
+ process.exit(1);
13
+ }
14
+ function formatError(error) {
15
+ return error instanceof Error ? error.message : String(error);
16
+ }
17
+
18
+ //#endregion
19
+ //#region src/core/fs.ts
20
+ async function exists(filePath) {
21
+ try {
22
+ await access(filePath);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+ async function isDirectoryEmpty(dir) {
29
+ try {
30
+ return (await readdir(dir)).length === 0;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ async function assertCreatableTarget(targetDir) {
36
+ if (!await exists(targetDir)) return;
37
+ if (!(await stat(targetDir)).isDirectory()) throw new Error(`Target exists and is not a directory: ${targetDir}`);
38
+ if (!await isDirectoryEmpty(targetDir)) throw new Error(`Target directory is not empty: ${targetDir}`);
39
+ }
40
+ async function removeIfExists(filePath) {
41
+ if (await exists(filePath)) await rm(filePath, {
42
+ recursive: true,
43
+ force: true
44
+ });
45
+ }
46
+ async function renameIfNeeded(source, target) {
47
+ if (source === target) return;
48
+ if (await exists(target)) throw new Error(`Cannot rename because target already exists: ${target}`);
49
+ await rename(source, target);
50
+ }
51
+ function toPackageName(projectName) {
52
+ return projectName.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
53
+ }
54
+ function resolveTargetDir(projectName, cwd = process.cwd()) {
55
+ return projectName === "." ? cwd : path.resolve(cwd, projectName);
56
+ }
57
+
58
+ //#endregion
59
+ //#region src/core/runtime.ts
60
+ async function runCommand(command, args, options = {}) {
61
+ return await new Promise((resolve, reject) => {
62
+ const child = spawn(command, [...args], {
63
+ cwd: options.cwd,
64
+ stdio: options.stdio === "inherit" ? "inherit" : [
65
+ "ignore",
66
+ "pipe",
67
+ "pipe"
68
+ ]
69
+ });
70
+ let stdout = "";
71
+ let stderr = "";
72
+ if (child.stdout) child.stdout.on("data", (chunk) => {
73
+ stdout += chunk.toString();
74
+ });
75
+ if (child.stderr) child.stderr.on("data", (chunk) => {
76
+ stderr += chunk.toString();
77
+ });
78
+ child.on("error", reject);
79
+ child.on("close", (code) => {
80
+ if (code === 0) {
81
+ resolve(stdout.trim());
82
+ return;
83
+ }
84
+ const detail = stderr.trim() || stdout.trim();
85
+ reject(new Error(detail || `${command} exited with code ${code ?? "unknown"}`));
86
+ });
87
+ });
88
+ }
89
+ async function hasCommand(command) {
90
+ try {
91
+ await runCommand(command, ["--version"]);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ //#endregion
99
+ //#region src/templates.ts
100
+ const templates = [{
101
+ name: "seeksaas-react-router",
102
+ label: "SeekSaaS React Router",
103
+ repo: "git@github.com:seeksaas/seeksaas-react-router.git",
104
+ upstream: "https://github.com/seeksaas/seeksaas-react-router.git",
105
+ branch: "main"
106
+ }];
107
+ function getTemplate(name) {
108
+ return templates.find((template) => template.name === name);
109
+ }
110
+
111
+ //#endregion
112
+ //#region src/modules/render-template.ts
113
+ const SKIPPED_DIRS = new Set([
114
+ ".git",
115
+ "node_modules",
116
+ "dist",
117
+ "build",
118
+ ".turbo",
119
+ ".cache"
120
+ ]);
121
+ const TEXT_EXTENSIONS = new Set([
122
+ "",
123
+ ".cjs",
124
+ ".css",
125
+ ".csv",
126
+ ".html",
127
+ ".js",
128
+ ".json",
129
+ ".jsx",
130
+ ".md",
131
+ ".mdx",
132
+ ".mjs",
133
+ ".toml",
134
+ ".ts",
135
+ ".tsx",
136
+ ".txt",
137
+ ".yaml",
138
+ ".yml"
139
+ ]);
140
+ function renderText(source, options) {
141
+ return source.replaceAll("__PROJECT_NAME__", options.projectName).replaceAll("__PACKAGE_NAME__", options.packageName).replaceAll("__TEMPLATE_NAME__", options.templateName).replaceAll("{{ projectName }}", options.projectName).replaceAll("{{projectName}}", options.projectName).replaceAll("{{ packageName }}", options.packageName).replaceAll("{{packageName}}", options.packageName).replaceAll("{{ templateName }}", options.templateName).replaceAll("{{templateName}}", options.templateName);
142
+ }
143
+ function renderPathSegment(source, options) {
144
+ return renderText(source, options);
145
+ }
146
+ function isLikelyTextFile(filePath) {
147
+ return TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
148
+ }
149
+ async function renderFile(filePath, options) {
150
+ if (!isLikelyTextFile(filePath)) return;
151
+ const source = await readFile(filePath, "utf8");
152
+ const rendered = renderText(source, options);
153
+ if (rendered !== source) await writeFile(filePath, rendered);
154
+ }
155
+ async function renderEntry(entryPath, options) {
156
+ const entryStat = await stat(entryPath);
157
+ if (entryStat.isDirectory()) {
158
+ if (SKIPPED_DIRS.has(path.basename(entryPath))) return;
159
+ const children = await readdir(entryPath);
160
+ for (const child of children) await renderEntry(path.join(entryPath, child), options);
161
+ await renameIfNeeded(entryPath, path.join(path.dirname(entryPath), renderPathSegment(path.basename(entryPath), options)));
162
+ return;
163
+ }
164
+ if (entryStat.isFile()) {
165
+ await renderFile(entryPath, options);
166
+ const renamed = path.join(path.dirname(entryPath), renderPathSegment(path.basename(entryPath), options));
167
+ if (renamed !== entryPath) await rename(entryPath, renamed);
168
+ }
169
+ }
170
+ async function renderRootPackageJson(options) {
171
+ const packageJsonPath = path.join(options.rootDir, "package.json");
172
+ if (!await exists(packageJsonPath)) return;
173
+ const source = await readFile(packageJsonPath, "utf8");
174
+ const packageJson = JSON.parse(source);
175
+ packageJson.name = options.packageName;
176
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
177
+ }
178
+ async function renderTemplate(options) {
179
+ const children = await readdir(options.rootDir);
180
+ for (const child of children) await renderEntry(path.join(options.rootDir, child), options);
181
+ await renderRootPackageJson(options);
182
+ }
183
+
184
+ //#endregion
185
+ //#region src/modules/create-project.ts
186
+ async function createProject(options) {
187
+ const template = getTemplate(options.templateName);
188
+ if (!template) throw new Error(`Unknown template: ${options.templateName}`);
189
+ if (!await hasCommand("git")) throw new Error("Git is required to download the template.");
190
+ if (options.install && !await hasCommand("pnpm")) throw new Error("pnpm is required when --install is enabled.");
191
+ const targetDir = resolveTargetDir(options.projectName);
192
+ const packageName = toPackageName(path.basename(targetDir));
193
+ if (!packageName) throw new Error("Project name must contain at least one letter or number.");
194
+ await assertCreatableTarget(targetDir);
195
+ await mkdir(path.dirname(targetDir), { recursive: true });
196
+ const repo = options.repo ?? template.repo;
197
+ await runCommand("git", [
198
+ "clone",
199
+ "--depth",
200
+ "1",
201
+ "--branch",
202
+ options.branch ?? template.branch,
203
+ repo,
204
+ targetDir
205
+ ], { stdio: "inherit" });
206
+ await removeIfExists(path.join(targetDir, ".git"));
207
+ await renderTemplate({
208
+ rootDir: targetDir,
209
+ projectName: path.basename(targetDir),
210
+ packageName,
211
+ templateName: template.name
212
+ });
213
+ if (options.initGit) {
214
+ await runCommand("git", ["init"], { cwd: targetDir });
215
+ await runCommand("git", [
216
+ "remote",
217
+ "add",
218
+ "upstream",
219
+ template.upstream
220
+ ], { cwd: targetDir });
221
+ await runCommand("git", ["add", "."], { cwd: targetDir });
222
+ await runCommand("git", [
223
+ "commit",
224
+ "-m",
225
+ "Initial commit"
226
+ ], { cwd: targetDir });
227
+ }
228
+ if (options.install) await runCommand("pnpm", ["install"], {
229
+ cwd: targetDir,
230
+ stdio: "inherit"
231
+ });
232
+ return {
233
+ targetDir,
234
+ packageName,
235
+ templateName: template.name
236
+ };
237
+ }
238
+
239
+ //#endregion
240
+ //#region src/commands/create.ts
241
+ function handleCancel(value) {
242
+ if (isCancel(value)) {
243
+ cancel("Cancelled.");
244
+ process.exit(0);
245
+ }
246
+ }
247
+ async function resolveProjectName(input) {
248
+ if (input) return input;
249
+ const value = await text({
250
+ message: "Project name",
251
+ placeholder: "my-saas",
252
+ validate: (name) => {
253
+ if (!name.trim()) return "Project name is required.";
254
+ }
255
+ });
256
+ handleCancel(value);
257
+ return String(value).trim();
258
+ }
259
+ async function resolveTemplateName(input) {
260
+ if (input) return input;
261
+ const value = await select({
262
+ message: "Select template",
263
+ initialValue: templates[0]?.name,
264
+ options: templates.map((template) => ({
265
+ value: template.name,
266
+ label: template.label
267
+ }))
268
+ });
269
+ handleCancel(value);
270
+ return String(value);
271
+ }
272
+ async function resolveInstall(input) {
273
+ if (typeof input === "boolean") return input;
274
+ const value = await confirm({
275
+ message: "Install dependencies with pnpm?",
276
+ initialValue: true
277
+ });
278
+ handleCancel(value);
279
+ return Boolean(value);
280
+ }
281
+ function resolveInstallFlag(argv) {
282
+ if (argv.includes("--install")) return true;
283
+ if (argv.includes("--skip-install") || argv.includes("--no-install")) return false;
284
+ }
285
+ function resolveGitFlag(argv) {
286
+ if (argv.includes("--skip-git") || argv.includes("--no-git")) return false;
287
+ return true;
288
+ }
289
+ function registerCreateCommand(program$1) {
290
+ program$1.command("create [project-name]", "Create a SeekSaaS project").option("--template <name>", "Template name").option("--branch <branch>", "Template git branch").option("--repo <url>", "Override template repository URL").option("--install", "Install dependencies after creation").option("--skip-install", "Skip dependency installation").option("--git", "Initialize a fresh git repository").option("--skip-git", "Skip fresh git initialization").action(async (projectName, flags) => {
291
+ intro(chalk.cyan("SeekSaaS project creator"));
292
+ const resolvedProjectName = await resolveProjectName(projectName);
293
+ const templateName = await resolveTemplateName(flags.template);
294
+ const install = await resolveInstall(resolveInstallFlag(process.argv));
295
+ const initGit = resolveGitFlag(process.argv);
296
+ const s = spinner();
297
+ s.start("Creating project...");
298
+ try {
299
+ const created = await createProject({
300
+ projectName: resolvedProjectName,
301
+ templateName,
302
+ branch: flags.branch,
303
+ repo: flags.repo,
304
+ install,
305
+ initGit
306
+ });
307
+ s.stop("Project created.");
308
+ const relativeTarget = path.relative(process.cwd(), created.targetDir) || ".";
309
+ outro([
310
+ chalk.green(`Created ${created.packageName} from ${created.templateName}.`),
311
+ "",
312
+ "Next steps:",
313
+ ` cd ${relativeTarget}`,
314
+ install ? " pnpm dev" : " pnpm install",
315
+ install ? void 0 : " pnpm dev"
316
+ ].filter(Boolean).join("\n"));
317
+ } catch (error) {
318
+ s.stop("Project creation failed.");
319
+ fail(formatError(error));
320
+ }
321
+ });
322
+ }
323
+
324
+ //#endregion
325
+ //#region src/index.ts
326
+ const CLI_NAME = "seeksaas-cli";
327
+ const CLI_VERSION = "0.0.1";
328
+ const program = cac(CLI_NAME);
329
+ program.usage("[command] [options]").version(CLI_VERSION, "-v, --version").help();
330
+ program.example(`${CLI_NAME} create my-saas`);
331
+ program.example(`${CLI_NAME} create my-saas --skip-install`);
332
+ registerCreateCommand(program);
333
+ program.parse();
334
+ if (!program.matchedCommand && process.argv.length <= 2) program.outputHelp();
335
+
336
+ //#endregion
337
+ export { };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@seeksaas/cli",
3
+ "version": "0.0.2",
4
+ "description": "SeekSaaS Cli",
5
+ "bin": {
6
+ "seeksaas-cli": "./dist/index.mjs"
7
+ },
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "type": "module",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "dependencies": {
17
+ "@clack/prompts": "^1.4.0",
18
+ "cac": "^7.0.0",
19
+ "chalk": "^5.6.2"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^25.7.0",
23
+ "oxlint": "^1.65.0",
24
+ "rimraf": "^6.1.3",
25
+ "tsdown": "^0.17.0",
26
+ "tsx": "^4.22.1",
27
+ "typescript": "^6.0.3",
28
+ "@workspace/tsconfig": "0.0.0"
29
+ },
30
+ "scripts": {
31
+ "build": "tsdown && chmod +x dist/index.mjs",
32
+ "cli:dev": "tsx src/index.ts",
33
+ "release": "pnpm run build && pnpm publish --access public --no-git-checks --registry https://registry.npmjs.org/",
34
+ "release:dry-run": "pnpm run build && pnpm publish --access public --dry-run --no-git-checks --registry https://registry.npmjs.org/",
35
+ "lint": "oxlint",
36
+ "format": "oxfmt --write",
37
+ "clean": "rimraf .cache .turbo dist node_modules"
38
+ }
39
+ }