@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,523 @@
1
+ import { readFileSync, existsSync, mkdirSync } from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { execSync, execFileSync } from "child_process";
5
+ import yaml from "js-yaml";
6
+
7
+ import { runWizard, cancel } from "@portosaur/wizard";
8
+ import { logger, colors } from "@portosaur/logger";
9
+ import { mirrorSync, openInBrowser, hasCommand, porto } from "@portosaur/core";
10
+
11
+ import {
12
+ Paths,
13
+ getGitConfig,
14
+ resolvePlatformKey,
15
+ getPlatformUserGuess,
16
+ getPackageManager,
17
+ ensureContentDirs,
18
+ printWorkflowTips,
19
+ isInteractive as getInteractivity,
20
+ looksLikeTestProject,
21
+ } from "../utils/index.mjs";
22
+
23
+ /**
24
+ * Initializes a new Portosaur project.
25
+ */
26
+ export async function initCommand(options = {}) {
27
+ /*
28
+ * ====================== Constants ======================
29
+ */
30
+
31
+ const registry = yaml.load(readFileSync(Paths.registry, "utf8"));
32
+
33
+ const gitConfig = getGitConfig();
34
+ const osUser = os.userInfo().username;
35
+
36
+ const isInteractive = getInteractivity(options);
37
+
38
+ let state = {
39
+ vcs: null,
40
+ hosting: null,
41
+ userName: null,
42
+ fullName: null,
43
+ projectName: options.projectName || null,
44
+ addRemote: false,
45
+ gitRemoteUrl: null,
46
+ };
47
+
48
+ /*
49
+ * ====================== Interactive mode ======================
50
+ */
51
+ if (isInteractive) {
52
+ const wizardState = await runWizard({
53
+ initialState: state,
54
+
55
+ intro: `${colors.bold("Initializing new Portosaur Project")} (v${porto.version})`,
56
+ outro: false,
57
+
58
+ steps: [
59
+ {
60
+ id: "vcs",
61
+ type: "select",
62
+ prompt: "Choose Your VCS Provider",
63
+ options: [
64
+ ...Object.entries(registry.vcs_providers).map(([id, cfg]) => ({
65
+ value: id,
66
+ label: cfg.name,
67
+ })),
68
+ { value: "none", label: "Local only / Skip" },
69
+ ],
70
+ },
71
+ {
72
+ id: "hosting",
73
+ type: "select",
74
+ prompt: "Where do you want host your portfolio site?",
75
+ hint: "Auto deployment (CI/CD) will be setup",
76
+ runIf: (state) => state.vcs !== "none",
77
+ options: (state) => {
78
+ const vcsConfig = registry.vcs_providers[state.vcs];
79
+ return [
80
+ ...Object.entries(registry.hosting_platforms)
81
+ .filter(([id, cfg]) => {
82
+ const supported = cfg.supported_providers || [];
83
+ const isAgnostic =
84
+ supported === "all" ||
85
+ (Array.isArray(supported) && supported.includes("all"));
86
+
87
+ const canProvideTemplate =
88
+ typeof cfg.template_dir === "string" ||
89
+ cfg.template_dir?.[state.vcs];
90
+
91
+ return (
92
+ (isAgnostic || supported.includes(state.vcs)) &&
93
+ canProvideTemplate
94
+ );
95
+ })
96
+ .map(([id, cfg]) => ({
97
+ value: id,
98
+ label: cfg.name,
99
+ hint: id === vcsConfig?.default_hosting ? "recommended" : "",
100
+ })),
101
+
102
+ // Skip Option
103
+ { value: "none", label: "Manual / Setup later" },
104
+ ];
105
+ },
106
+ initialValue: (state) =>
107
+ registry.vcs_providers[state.vcs]?.default_hosting || "none",
108
+ },
109
+ {
110
+ id: "userName",
111
+ type: "text",
112
+ required: true,
113
+ prompt: (state) =>
114
+ `Enter your ${registry.vcs_providers[state.vcs]?.name || state.vcs} username`,
115
+ runIf: (state) => state.vcs !== "none",
116
+ initialValue: (state) =>
117
+ getPlatformUserGuess(state.vcs, gitConfig) || osUser,
118
+ transform: (v) => v.trim(),
119
+ },
120
+ {
121
+ id: "fullName",
122
+ type: "text",
123
+ required: true,
124
+ prompt: "Enter your full name",
125
+ hint: "Used for the site title etc..",
126
+ runIf: () => !options.name,
127
+ initialValue: () => gitConfig["user.name"] || osUser || "Your Name",
128
+ transform: (v) => v.trim(),
129
+ },
130
+ {
131
+ id: "projectNameType",
132
+ type: "select",
133
+ prompt: "Choose your project name",
134
+ hint: (state) =>
135
+ state.hosting !== "none" &&
136
+ registry.hosting_platforms[state.hosting]?.repo?.ideal_name
137
+ ? "We recommend sticking with the default name"
138
+ : "The name of the directory/repository",
139
+ runIf: (state) =>
140
+ !state.projectName &&
141
+ state.hosting !== "none" &&
142
+ registry.hosting_platforms[state.hosting]?.repo?.ideal_name,
143
+ options: (state) => {
144
+ const hConfig = registry.hosting_platforms[state.hosting];
145
+ const vcsConfig = registry.vcs_providers[state.vcs];
146
+ const ideal = hConfig.repo.ideal_name
147
+ .replace("{{user}}", state.userName)
148
+ .replace("{{domain}}", vcsConfig.domain);
149
+ return [
150
+ { label: ideal, value: "ideal", hint: "recommended" },
151
+ { label: "Custom Name", value: "custom" },
152
+ ];
153
+ },
154
+ },
155
+ {
156
+ id: "confirmCustomName",
157
+ type: "confirm",
158
+ level: "warn",
159
+ prompt: "Are you sure?",
160
+ hint: "Custom names may break auto-deployments on some platforms",
161
+ runIf: (state) =>
162
+ state.projectNameType === "custom" && state.hosting !== "none",
163
+ initialValue: false,
164
+ },
165
+ {
166
+ id: "projectName",
167
+ type: "text",
168
+ required: true,
169
+ level: (state) =>
170
+ state.projectNameType === "custom" && state.hosting !== "none"
171
+ ? "warn"
172
+ : undefined,
173
+ prompt: "Enter project name",
174
+ hint: (state) =>
175
+ state.projectNameType === "custom" && state.hosting !== "none"
176
+ ? colors.warn("Warning! Custom names may break auto-deployments")
177
+ : "The name of the directory/repository",
178
+ runIf: (state) => {
179
+ if (state.projectName) return false;
180
+ if (state.hosting === "none") return true;
181
+ if (state.projectNameType === "ideal") return false;
182
+ if (state.projectNameType === "custom")
183
+ return state.confirmCustomName;
184
+ return true;
185
+ },
186
+ initialValue: (state) => {
187
+ if (state.projectNameType === "ideal" && state.vcs !== "none") {
188
+ const hConfig = registry.hosting_platforms[state.hosting];
189
+ const vcsConfig = registry.vcs_providers[state.vcs];
190
+ if (hConfig && vcsConfig) {
191
+ return hConfig.repo.ideal_name
192
+ .replace("{{user}}", state.userName)
193
+ .replace("{{domain}}", vcsConfig.domain);
194
+ }
195
+ }
196
+ return state.projectName || "my-portfolio";
197
+ },
198
+ transform: (v) => v.trim(),
199
+ },
200
+ {
201
+ id: "openBrowser",
202
+ type: "confirm",
203
+ prompt: "Open browser to create the repository?",
204
+ hint: "Redirects you to the 'New Repository' page on your Git host",
205
+ runIf: (state) => state.vcs !== "none",
206
+ initialValue: true,
207
+ onResponse: (val, state) => {
208
+ if (val && state.vcs !== "none") {
209
+ const vcsConfig = registry.vcs_providers[state.vcs];
210
+ const newRepoUrl = vcsConfig.new_url
211
+ .replace("{{user}}", state.userName)
212
+ .replace("{{projectName}}", state.projectName)
213
+ .replace("{{domain}}", vcsConfig.domain);
214
+
215
+ openInBrowser(newRepoUrl);
216
+ }
217
+ },
218
+ },
219
+ {
220
+ id: "repoCreated",
221
+ type: "pause",
222
+ prompt:
223
+ "Create the repository on your Git host, then press Enter to continue",
224
+ runIf: (state) => {
225
+ if (state.vcs === "none") return false;
226
+ // If unset, show the pause (openBrowser prompt runs before this step)
227
+ if (state.openBrowser === undefined) return true;
228
+ // Accept boolean false and common string equivalents as "no"
229
+ if (state.openBrowser === false) return false;
230
+ const sval = String(state.openBrowser).toLowerCase();
231
+ return !(sval === "false" || sval === "0" || sval === "no");
232
+ },
233
+ },
234
+ {
235
+ id: "gitRemoteUrl",
236
+ type: "text",
237
+ required: true,
238
+ prompt: "Confirm git repository URL",
239
+ hint: "Needed to link your local project to the remote repository",
240
+ runIf: (state) => state.vcs !== "none",
241
+ initialValue: (state) => {
242
+ const vcsConfig = registry.vcs_providers[state.vcs];
243
+ return vcsConfig.url
244
+ .replace("{{user}}", state.userName)
245
+ .replace("{{projectName}}", state.projectName)
246
+ .replace("{{domain}}", vcsConfig.domain);
247
+ },
248
+ transform: (v) => v.trim(),
249
+ },
250
+ {
251
+ id: "confirm",
252
+ type: "confirm",
253
+ prompt: "Are you sure you want to proceed?",
254
+ hint: "Please review settings & confirm",
255
+ initialValue: true,
256
+ },
257
+ ],
258
+ });
259
+
260
+ if (!wizardState.confirm) {
261
+ cancel("Initialization cancelled.");
262
+ process.exit(0);
263
+ }
264
+
265
+ state = { ...state, ...wizardState };
266
+
267
+ /*
268
+ * ====================== Non-Interactive Mode ======================
269
+ */
270
+ } else {
271
+ //----------- Parse Flags -------------
272
+
273
+ // Normalize vcs provider
274
+ let vcsProvider = options.vcsProvider;
275
+ if (vcsProvider && vcsProvider !== "none") {
276
+ vcsProvider =
277
+ Object.keys(registry.vcs_providers).find(
278
+ (k) => k.toLowerCase() === vcsProvider.toLowerCase(),
279
+ ) || vcsProvider;
280
+ }
281
+
282
+ let hosting = options.hosting;
283
+ if (hosting && hosting !== "none") {
284
+ hosting =
285
+ resolvePlatformKey(registry.hosting_platforms, hosting) || hosting;
286
+ }
287
+
288
+ //--------- Set state values------------
289
+ state.vcs = vcsProvider || Object.keys(registry.vcs_providers)[0];
290
+ state.hosting =
291
+ hosting || registry.vcs_providers[state.vcs]?.default_hosting || "none";
292
+
293
+ state.userName =
294
+ options.username || getPlatformUserGuess(state.vcs, gitConfig) || osUser;
295
+ state.fullName = options.name || gitConfig["user.name"] || osUser || "User";
296
+
297
+ state.projectName = options.projectName || "my-portfolio";
298
+
299
+ // Validate resolved hosting platform
300
+ if (
301
+ state.hosting !== "none" &&
302
+ !registry.hosting_platforms[state.hosting]
303
+ ) {
304
+ logger.error(`Unknown or invalid hosting platform: ${state.hosting}`);
305
+ process.exit(1);
306
+ }
307
+ }
308
+
309
+ //========================== Execution ==========================
310
+
311
+ const newProjDir = path.resolve(process.cwd(), state.projectName);
312
+ let hadError = false;
313
+
314
+ //--------- Clear the console ----------------
315
+ logger.resetView();
316
+ logger.info.box("Initializing New Portosaur Project", {
317
+ title: ` v${porto.version} `,
318
+ });
319
+
320
+ if (existsSync(newProjDir)) {
321
+ logger.error(`Directory "${state.projectName}" already exists.`);
322
+ process.exit(1);
323
+ }
324
+
325
+ //--------- Initialize Git repository ----------------
326
+
327
+ if (state.vcs !== "none") {
328
+ logger.info("Initializing new Git repository...");
329
+
330
+ if (!hasCommand("git")) {
331
+ throw new Error(
332
+ "Git is required for version control, but 'git' was not found in PATH.",
333
+ );
334
+ }
335
+
336
+ try {
337
+ execFileSync("git", ["init", "-b", "main", newProjDir], {
338
+ stdio: "pipe",
339
+ });
340
+
341
+ if (state.gitRemoteUrl) {
342
+ execFileSync("git", ["remote", "add", "origin", state.gitRemoteUrl], {
343
+ cwd: newProjDir,
344
+ stdio: "pipe",
345
+ });
346
+ }
347
+ } catch (e) {
348
+ if (e.code === "ENOENT") {
349
+ logger.error("Git executable not found in PATH.");
350
+ } else {
351
+ const message =
352
+ e.stderr?.toString()?.trim() ||
353
+ e.stdout?.toString()?.trim() ||
354
+ e.message;
355
+
356
+ logger.error(`Failed to initialize Git repository: ${message}`);
357
+ }
358
+ process.exitCode = 1;
359
+ return;
360
+ }
361
+
362
+ logger.info("Done!\n");
363
+ }
364
+
365
+ //------- Create directory and ensure content subdirectories -------------
366
+
367
+ logger.info("Bootstrapping project directories...");
368
+
369
+ try {
370
+ ensureContentDirs(newProjDir);
371
+ } catch (e) {
372
+ logger.error(`Failed to bootstrap: ${e.message}`);
373
+ process.exitCode = 1;
374
+ return;
375
+ }
376
+
377
+ //------- Mirror templates -------------
378
+
379
+ logger.info("Creating project files...");
380
+
381
+ // Prepare Template Variables
382
+
383
+ // If the project name looks like a testing/demo project, prefer linking
384
+ // to the CLI package to avoid loose dev-mode predictions.
385
+ const isTestProject = looksLikeTestProject(state.projectName);
386
+
387
+ const portoVer = isTestProject ? "link:portosaur" : porto.version || "0.0.0";
388
+
389
+ const templateVars = {
390
+ projectName: state.projectName,
391
+ userName: state.userName || "",
392
+ fullName: state.fullName || "",
393
+ portoVer,
394
+ portoHome: porto.homepage || "",
395
+ portoRepo: porto.repository || "",
396
+ };
397
+
398
+ try {
399
+ mirrorSync(Paths.templates, newProjDir, templateVars, [
400
+ "registry.yml",
401
+ "workflows",
402
+ ]);
403
+ } catch (e) {
404
+ logger.error(`Failed to create project files: ${e.message}`);
405
+ process.exitCode = 1;
406
+ return;
407
+ }
408
+
409
+ logger.info("Done!\n");
410
+
411
+ //------- Handle hosting workflows -------------
412
+
413
+ let autoSetupDone = false;
414
+
415
+ if (state.hosting && state.hosting !== "none") {
416
+ const hConfig = registry.hosting_platforms[state.hosting];
417
+ let workflowTemplate = hConfig?.template_dir;
418
+
419
+ logger.info("Setting up Auto Deployment...");
420
+
421
+ if (typeof workflowTemplate === "object") {
422
+ workflowTemplate = workflowTemplate[state.vcs];
423
+ }
424
+
425
+ if (workflowTemplate) {
426
+ const workflowDir = path.join(Paths.workflows, workflowTemplate);
427
+
428
+ if (existsSync(workflowDir)) {
429
+ try {
430
+ mirrorSync(workflowDir, newProjDir, templateVars, ["node_modules"]);
431
+ logger.info("Done!");
432
+ autoSetupDone = true;
433
+ } catch (e) {
434
+ hadError = true;
435
+ logger.error(`Failed to setup auto deployment: ${e.message}`);
436
+ logger.warn(
437
+ `Please setup later using ${colors.command("portosaur init-ci")}`,
438
+ );
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ //----------- Install dependencies -------------
445
+
446
+ const pm = getPackageManager(newProjDir);
447
+
448
+ if (options.install !== false) {
449
+ logger.info(`Installing dependencies with ${pm.name}...`);
450
+
451
+ try {
452
+ execSync(pm.install, { cwd: newProjDir, stdio: "inherit" });
453
+ logger.info("Dependencies installed!\n");
454
+ } catch (e) {
455
+ hadError = true;
456
+ logger.error(e.message);
457
+ logger.warn(
458
+ "Failed to install dependencies, Please install manually later!\n",
459
+ );
460
+ }
461
+ }
462
+
463
+ //------------ Initial commit -------------
464
+
465
+ if (state.vcs !== "none") {
466
+ logger.start("Creating initial commit...");
467
+
468
+ if (hasCommand("git")) {
469
+ try {
470
+ // Stage all files
471
+ execSync("git add .", { cwd: newProjDir, stdio: "ignore" });
472
+
473
+ // Commit the staged files
474
+ execSync('git commit -m "Initialize Portosaur project"', {
475
+ cwd: newProjDir,
476
+ stdio: "pipe",
477
+ });
478
+ logger.info("Done!");
479
+ } catch (e) {
480
+ hadError = true;
481
+ logger.error(e.stderr?.toString()?.trim() || e.message);
482
+ logger.warn("Failed to make initial commit. Please do it later.");
483
+ }
484
+ } else {
485
+ hadError = true;
486
+ logger.error("Couldn't find git in PATH!");
487
+ logger.warn("Skipping initial commit. Please do it later.");
488
+ }
489
+
490
+ logger.newLine();
491
+ }
492
+
493
+ // Add a .nojekyll to the project root during dev/build
494
+
495
+ //----------------------- Final Output -----------------------
496
+
497
+ if (!hadError) {
498
+ logger.newLine();
499
+ logger.resetView();
500
+ logger.success.box(`Project successfully initialized!`);
501
+ } else {
502
+ logger.newLine();
503
+ logger.success(`Project '${state.projectName}' initialized`);
504
+ logger.warn("Some non-critical errors occurred (check logs above).");
505
+ logger.newLine();
506
+ }
507
+
508
+ if (state.hosting !== "none" && autoSetupDone) {
509
+ printWorkflowTips(
510
+ registry.hosting_platforms,
511
+ state.hosting,
512
+ logger,
513
+ templateVars,
514
+ );
515
+ }
516
+
517
+ logger.newLine();
518
+ logger.info(
519
+ `Next Steps:\n ${colors.command(`cd ${state.projectName} && ${pm.name} run dev`)}`,
520
+ );
521
+
522
+ logger.newLine();
523
+ }