@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 +1 -0
- package/README.use.md +70 -0
- package/dist/index.mjs +337 -0
- package/package.json +39 -0
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
|
+
}
|