@portosaur/cli 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.
Files changed (40) hide show
  1. package/README.md +52 -0
  2. package/bin/porto.mjs +71 -0
  3. package/package.json +36 -0
  4. package/src/commands/build.mjs +85 -0
  5. package/src/commands/dev.mjs +61 -0
  6. package/src/commands/init.mjs +523 -0
  7. package/src/commands/initCi.mjs +227 -0
  8. package/src/commands/providers.mjs +170 -0
  9. package/src/commands/schema.mjs +208 -0
  10. package/src/commands/serve.mjs +29 -0
  11. package/src/index.d.ts +49 -0
  12. package/src/index.mjs +8 -0
  13. package/src/templates/README.md +58 -0
  14. package/src/templates/blog/authors.yml +4 -0
  15. package/src/templates/blog/welcome.md +11 -0
  16. package/src/templates/config.yml +150 -0
  17. package/src/templates/gitignore +9 -0
  18. package/src/templates/notes/index.mdx +9 -0
  19. package/src/templates/notes/welcome.mdx +9 -0
  20. package/src/templates/package.json +14 -0
  21. package/src/templates/registry.yml +107 -0
  22. package/src/templates/static/.nojekyll +0 -0
  23. package/src/templates/static/README.md +1 -0
  24. package/src/templates/workflows/codeberg/.forgejo/workflows/deploy.yml +39 -0
  25. package/src/templates/workflows/github/.github/workflows/deploy.yml +55 -0
  26. package/src/templates/workflows/gitlab/.gitlab-ci.yml +13 -0
  27. package/src/templates/workflows/netlify/netlify.toml +6 -0
  28. package/src/templates/workflows/surge/codeberg/.forgejo/workflows/deploy.yml +23 -0
  29. package/src/templates/workflows/surge/github/.github/workflows/deploy.yml +23 -0
  30. package/src/templates/workflows/surge/gitlab/.gitlab-ci.yml +16 -0
  31. package/src/templates/workflows/surge/sourcehut/.build.yml +26 -0
  32. package/src/templates/workflows/woodpecker/.woodpecker/deploy.yml +21 -0
  33. package/src/utils/git.mjs +52 -0
  34. package/src/utils/index.mjs +7 -0
  35. package/src/utils/interaction.mjs +24 -0
  36. package/src/utils/packageManager.mjs +85 -0
  37. package/src/utils/paths.mjs +33 -0
  38. package/src/utils/platforms.mjs +130 -0
  39. package/src/utils/projectName.mjs +20 -0
  40. package/src/utils/runner.mjs +192 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Determines if the CLI should run in interactive mode based on provided options.
3
+ *
4
+ * Providing any configuration flag forces non-interactive mode.
5
+ *
6
+ * @param {Object} options - Command options (flags).
7
+ * @returns {boolean}
8
+ */
9
+ export function isInteractive(options = {}) {
10
+ // Flags that trigger non-interactive mode
11
+ const hasConfigOptions = Object.keys(options).some((key) => {
12
+ if (key === "install") {
13
+ return false;
14
+ }
15
+ return options[key] !== undefined && options[key] !== null;
16
+ });
17
+
18
+ if (hasConfigOptions) {
19
+ return false;
20
+ }
21
+
22
+ // Otherwise, default to interactive
23
+ return true;
24
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Package manager detection and CLI utility (ESM)
3
+ */
4
+ import fs from "fs";
5
+ import path from "path";
6
+
7
+ /**
8
+ * Returns a package manager object for the given directory.
9
+ * Detects by lockfile, environment, or runtime.
10
+ * @param {string} dir - The directory to check.
11
+ * @returns {{ name: string, install: string, run: string, exec: string }} Package manager info.
12
+ */
13
+ export function getPackageManager(dir) {
14
+ let name = null;
15
+
16
+ // Detect by lockfile
17
+ if (
18
+ fs.existsSync(path.join(dir, "bun.lock")) ||
19
+ fs.existsSync(path.join(dir, "bun.lockb"))
20
+ ) {
21
+ name = "bun";
22
+ } else if (fs.existsSync(path.join(dir, "pnpm-lock.yaml"))) {
23
+ name = "pnpm";
24
+ } else if (fs.existsSync(path.join(dir, "yarn.lock"))) {
25
+ name = "yarn";
26
+ } else if (fs.existsSync(path.join(dir, "package-lock.json"))) {
27
+ name = "npm";
28
+ }
29
+
30
+ // Detect by environment
31
+ else if (process.env.npm_config_user_agent) {
32
+ if (process.env.npm_config_user_agent.includes("bun")) name = "bun";
33
+ else if (process.env.npm_config_user_agent.includes("pnpm")) name = "pnpm";
34
+ else if (process.env.npm_config_user_agent.includes("yarn")) name = "yarn";
35
+ else if (process.env.npm_config_user_agent.includes("npm")) name = "npm";
36
+ }
37
+
38
+ // Detect by runtime
39
+ else if (typeof process !== "undefined" && process.versions?.bun) {
40
+ name = "bun";
41
+ }
42
+
43
+ if (!name) {
44
+ throw new Error(
45
+ "Supported package manager (bun, pnpm, yarn, or npm) not detected.",
46
+ );
47
+ }
48
+
49
+ const exec = { npm: "npx", bun: "bunx", pnpm: "pnpm dlx", yarn: "yarn dlx" }[
50
+ name
51
+ ];
52
+
53
+ return { name, install: `${name} install`, run: `${name} run`, exec };
54
+ }
55
+
56
+ /**
57
+ * Resolves the command to run Docusaurus based on the package manager and project state.
58
+ * @param {string} UserRoot - The project root directory.
59
+ * @returns {{ command: string, args: string[], packageManager: string }} Docusaurus command info.
60
+ */
61
+ export function getDocuCmd(UserRoot) {
62
+ const pm = getPackageManager(UserRoot);
63
+
64
+ // Check for node_modules/.bin/docusaurus
65
+ const localDocusaurus = path.join(
66
+ UserRoot,
67
+ "node_modules",
68
+ ".bin",
69
+ "docusaurus",
70
+ );
71
+
72
+ if (fs.existsSync(localDocusaurus)) {
73
+ const local = {
74
+ bun: { command: "bun", args: ["docusaurus"] },
75
+ npm: { command: "npm", args: ["run", "docusaurus", "--"] },
76
+ pnpm: { command: "pnpm", args: ["docusaurus"] },
77
+ yarn: { command: "yarn", args: ["docusaurus"] },
78
+ }[pm.name];
79
+
80
+ return { ...local, packageManager: pm.name };
81
+ }
82
+
83
+ // Fallback to npx/bunx if not found locally
84
+ return { command: pm.exec, args: ["docusaurus"], packageManager: pm.name };
85
+ }
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+
3
+ const srcDir = path.resolve(import.meta.dirname, "../");
4
+ const pkgDir = path.resolve(srcDir, "../");
5
+
6
+ /**
7
+ * Centralized paths for the CLI package.
8
+ */
9
+ export const Paths = {
10
+ /** CLI package root. */
11
+ root: pkgDir,
12
+
13
+ /** CLI src directory. */
14
+ src: srcDir,
15
+
16
+ /** CLI templates directory. */
17
+ templates: path.join(srcDir, "templates"),
18
+
19
+ /** Registry file. */
20
+ registry: path.join(srcDir, "templates/registry.yml"),
21
+
22
+ /** Workflows directory. */
23
+ workflows: path.join(srcDir, "templates/workflows"),
24
+
25
+ /** CLI's package.json file. */
26
+ packageJson: path.join(pkgDir, "package.json"),
27
+
28
+ /** Absolute path to the core package. */
29
+ core: path.resolve(pkgDir, "../core"),
30
+
31
+ /** Absolute path to the theme package. */
32
+ theme: path.resolve(pkgDir, "../theme"),
33
+ };
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Paths } from "./paths.mjs";
4
+
5
+ /**
6
+ * Resolves a platform key from a name or ID.
7
+ * Supports exact match, case-insensitive ID match, and case-insensitive name match.
8
+ * @param {Object} platforms - The platforms registry object.
9
+ * @param {string} platform - The platform name or ID to resolve.
10
+ * @returns {string|null} The resolved platform key or null if not found.
11
+ */
12
+ export function resolvePlatformKey(platforms, platform) {
13
+ if (typeof platform !== "string") {
14
+ return null;
15
+ }
16
+
17
+ const requested = platform.trim();
18
+
19
+ if (!requested) {
20
+ return null;
21
+ }
22
+
23
+ const requestedLower = requested.toLowerCase();
24
+
25
+ // Resolve by direct key match (case-sensitive first)
26
+ if (Object.prototype.hasOwnProperty.call(platforms, requested)) {
27
+ return requested;
28
+ }
29
+
30
+ // Resolve by case-insensitive ID
31
+ const idMatch = Object.keys(platforms).find(
32
+ (key) => key.toLowerCase() === requestedLower,
33
+ );
34
+ if (idMatch) {
35
+ return idMatch;
36
+ }
37
+
38
+ // Resolve by display name
39
+ const nameMatch = Object.keys(platforms).find(
40
+ (key) => platforms[key].name?.toLowerCase() === requestedLower,
41
+ );
42
+ if (nameMatch) return nameMatch;
43
+
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Attempts to guess the VCS username from git configuration.
49
+ * @param {string} vcsProviderId - The ID of the VCS provider (e.g., 'github').
50
+ * @param {Record<string, string>} [gitConfig={}] - The current git configuration object.
51
+ * @returns {string} The guessed username or an empty string.
52
+ */
53
+ export function getPlatformUserGuess(vcsProviderId, gitConfig = {}) {
54
+ const vcsProvider = vcsProviderId?.toLowerCase();
55
+
56
+ const keysByProvider = {
57
+ github: ["github.user"],
58
+ gitlab: ["gitlab.user"],
59
+ codeberg: ["codeberg.user", "forgejo.user"],
60
+ };
61
+
62
+ const hostByProvider = {
63
+ github: "github.com",
64
+ gitlab: "gitlab.com",
65
+ codeberg: "codeberg.org",
66
+ sourcehut: "git.sr.ht",
67
+ };
68
+
69
+ // Try explicit configuration first
70
+ for (const key of keysByProvider[vcsProvider] || []) {
71
+ if (gitConfig[key]) return gitConfig[key];
72
+ }
73
+
74
+ // Fallback to remote URL parsing
75
+ const host = hostByProvider[vcsProvider];
76
+
77
+ if (!host) {
78
+ return "";
79
+ }
80
+
81
+ for (const [key, value] of Object.entries(gitConfig)) {
82
+ // Only look at remote URLs
83
+ if (!key.startsWith("remote.") || !key.endsWith(".url")) continue;
84
+ if (typeof value !== "string" || !value.includes(host)) continue;
85
+
86
+ const escapedHost = host.replace(/\./g, "\\.");
87
+ const match = value.match(
88
+ new RegExp(
89
+ `(?:https?://|ssh://git@|git@)${escapedHost}[:/]([^/]+)(?:/|$)`,
90
+ ),
91
+ );
92
+
93
+ if (match?.[1]) return match[1];
94
+ }
95
+
96
+ return "";
97
+ }
98
+
99
+ /**
100
+ * Dynamically discovers all possible workflow markers (files/dirs) from all available templates.
101
+ * @param {Object} registry - The platforms registry object.
102
+ * @returns {string[]} An array of unique file/directory names that serve as CI markers.
103
+ */
104
+ export function getWorkflowMarkers(registry) {
105
+ const markers = new Set();
106
+ const platforms = Object.values(registry.hosting_platforms || {});
107
+
108
+ for (const platform of platforms) {
109
+ const templates =
110
+ typeof platform.template_dir === "string"
111
+ ? [platform.template_dir]
112
+ : Object.values(platform.template_dir || {});
113
+
114
+ for (const template of templates) {
115
+ const templatePath = path.join(Paths.workflows, template);
116
+
117
+ if (fs.existsSync(templatePath)) {
118
+ const contents = fs.readdirSync(templatePath);
119
+
120
+ for (const item of contents) {
121
+ if (item !== "." && item !== "..") {
122
+ markers.add(item);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ return Array.from(markers);
130
+ }
@@ -0,0 +1,20 @@
1
+ export function looksLikeTestProject(projectName) {
2
+ const testTerms = [
3
+ "test",
4
+ "testing",
5
+ "spec",
6
+ "specs",
7
+ "ci",
8
+ "demo",
9
+ "example",
10
+ ];
11
+ const n = String(projectName).toLowerCase();
12
+
13
+ if (!projectName) {
14
+ return false;
15
+ }
16
+
17
+ return testTerms.some((t) => n.includes(t));
18
+ }
19
+
20
+ export default looksLikeTestProject;
@@ -0,0 +1,192 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { spawn } from "child_process";
4
+
5
+ /**
6
+ * Generates a Docusaurus config shim that loads Portosaur logic.
7
+ * @param {string} UserRoot - The user's project directory.
8
+ * @param {Object} portoPaths - Paths to Portosaur assets and core.
9
+ * @param {Object} [context={}] - Additional context for config generation.
10
+ * @returns {string} The path to the generated shim file.
11
+ */
12
+ export function writeConfigShim(UserRoot, portoPaths, context = {}) {
13
+ const dotDir = path.join(UserRoot, ".docusaurus", "portosaur");
14
+
15
+ if (!fs.existsSync(dotDir)) {
16
+ fs.mkdirSync(dotDir, { recursive: true });
17
+ }
18
+
19
+ // Resolve config file
20
+ const configYaml = ["config.yaml", "config.yml"].find((file) =>
21
+ fs.existsSync(path.join(UserRoot, file)),
22
+ );
23
+ if (!configYaml) throw new Error("config.yml not found");
24
+
25
+ const configYamlAbsolute = path
26
+ .resolve(UserRoot, configYaml)
27
+ .replace(/\\/g, "/");
28
+
29
+ // Prepare shim template
30
+ const shimContent = `// Auto-generated by portosaur
31
+ import fs from "fs";
32
+ import yaml from "js-yaml";
33
+
34
+ export default async function getConfig() {
35
+ const { buildDocuConfig } = await import("@portosaur/core");
36
+ const yamlContent = fs.readFileSync("${configYamlAbsolute}", "utf8");
37
+ const rawConf = yaml.load(yamlContent);
38
+
39
+ return buildDocuConfig(rawConf, "${UserRoot.replace(/\\/g, "/")}", {
40
+ portoPaths: ${JSON.stringify(portoPaths)},
41
+ portoRoot: "${path.resolve(portoPaths.root).replace(/\\/g, "/")}",
42
+ ...${JSON.stringify(context)}
43
+ });
44
+ }
45
+ `;
46
+
47
+ // Write and return shim path
48
+ const shimPath = path.join(dotDir, "docusaurus.config.js");
49
+ fs.writeFileSync(shimPath, shimContent);
50
+
51
+ return shimPath;
52
+ }
53
+
54
+ /**
55
+ * Validate that the current directory is a Portosaur project.
56
+ * @param {string} UserRoot - The directory to validate.
57
+ * @throws {Error} If config.yml is missing.
58
+ */
59
+ export function validateProject(UserRoot) {
60
+ const configPath = ["config.yaml", "config.yml"].find((file) =>
61
+ fs.existsSync(path.join(UserRoot, file)),
62
+ );
63
+
64
+ if (!configPath) {
65
+ throw new Error(
66
+ "config.yml/config.yaml not found. Are you in a Portosaur project directory?",
67
+ );
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Ensure essential user content directories exist.
73
+ * @param {string} UserRoot - The project root directory.
74
+ */
75
+ export function ensureContentDirs(UserRoot) {
76
+ for (const dir of ["notes", "blog", "static"]) {
77
+ if (!fs.existsSync(path.join(UserRoot, dir))) {
78
+ fs.mkdirSync(path.join(UserRoot, dir), { recursive: true });
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Print deployment tips defined in registry.yml
85
+ * @param {Object} hostingPlatforms - Hosting platforms registry.
86
+ * @param {string} hostingPlatformKey - The selected platform key.
87
+ * @param {Object} logger - The Portosaur logger instance.
88
+ * @param {Object} [vars={}] - Variables for replacement in tips.
89
+ */
90
+ export function printWorkflowTips(
91
+ hostingPlatforms,
92
+ hostingPlatformKey,
93
+ logger,
94
+ vars = {},
95
+ ) {
96
+ const config = hostingPlatforms[hostingPlatformKey];
97
+ if (config && config.post_setup_tips) {
98
+ for (let tip of config.post_setup_tips) {
99
+ if (vars.projectName)
100
+ tip = tip.replace(/\{\{projectName\}\}/g, vars.projectName);
101
+ if (vars.userName) tip = tip.replace(/\{\{user\}\}/g, vars.userName);
102
+ logger.tip(tip);
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Warn the user if their project name does not match the expected platform repository pattern.
109
+ * @param {Object} hostingPlatforms - Hosting platforms registry.
110
+ * @param {string} hostingPlatformKey - The selected platform key.
111
+ * @param {string} projectName - Current project name.
112
+ * @param {string} userName - VCS username.
113
+ * @param {Object} logger - The Portosaur logger instance.
114
+ * @param {string} [heading] - Warning heading.
115
+ */
116
+ export function warnIfRepoNameMismatch(
117
+ hostingPlatforms,
118
+ hostingPlatformKey,
119
+ projectName,
120
+ userName,
121
+ logger,
122
+ heading = "Project Name Mismatch",
123
+ ) {
124
+ const config = hostingPlatforms[hostingPlatformKey];
125
+ if (!config || !config.repo || !config.repo.ideal_name) return;
126
+
127
+ const expectedRepo = config.repo.ideal_name
128
+ .replace("{{user}}", userName || "yourusername")
129
+ .replace("{{domain}}", config.domain || "");
130
+
131
+ if (projectName === expectedRepo || !config.repo.mismatch_msg) return;
132
+
133
+ const projectNameForMsg = projectName || "(custom name)";
134
+
135
+ const customMsg = config.repo.mismatch_msg
136
+ .replace(/\{\{user\}\}/g, userName || "yourusername")
137
+ .replace(/\{\{projectName\}\}/g, projectNameForMsg)
138
+ .replace(/\{\{domain\}\}/g, config.domain || "")
139
+ .replace(/\{\{idealName\}\}/g, expectedRepo);
140
+
141
+ logger.warn(`[${heading}] ${customMsg}`);
142
+ }
143
+
144
+ /**
145
+ * Executes a Docusaurus command.
146
+ * @param {string} command - Docusaurus command to run (e.g., 'start', 'build').
147
+ * @param {string} UserRoot - The project directory.
148
+ * @param {string} configPath - Path to the docusaurus.config.js shim.
149
+ * @param {string[]} [extraArgs=[]] - Additional CLI arguments.
150
+ * @returns {Promise<void>}
151
+ */
152
+ export async function runDocusaurus(
153
+ command,
154
+ UserRoot,
155
+ configPath,
156
+ extraArgs = [],
157
+ ) {
158
+ const args = [
159
+ "run",
160
+ "--bun",
161
+ "docusaurus",
162
+ command,
163
+ UserRoot,
164
+ "--config",
165
+ configPath,
166
+ ...extraArgs,
167
+ ];
168
+
169
+ // Skip actual execution in test mode
170
+ if (process.env.PORTO_TEST_MODE === "true") {
171
+ console.log(`[TEST_MODE] Would run docusaurus ${command} in ${UserRoot}`);
172
+ return Promise.resolve();
173
+ }
174
+
175
+ const child = spawn("bun", args, {
176
+ stdio: "inherit",
177
+ cwd: UserRoot,
178
+ env: { ...process.env, FORCE_COLOR: "true" },
179
+ });
180
+
181
+ return new Promise((resolve, reject) => {
182
+ child.on("error", reject);
183
+
184
+ child.on("close", (code) => {
185
+ if (code === 0) {
186
+ resolve();
187
+ } else {
188
+ reject(new Error(`Docusaurus exited with code ${code}`));
189
+ }
190
+ });
191
+ });
192
+ }