@adbayb/stack 2.32.0 → 2.33.0-next-40f7a45

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 (28) hide show
  1. package/configs/eslint/constants.js +2 -0
  2. package/configs/eslint/index.js +14 -14
  3. package/configs/eslint/presets/dependencies.js +14 -0
  4. package/configs/eslint/presets/eslint.js +3 -4
  5. package/configs/eslint/presets/import.js +9 -21
  6. package/configs/eslint/presets/jsdoc.js +1 -1
  7. package/configs/eslint/presets/node.js +6 -4
  8. package/configs/eslint/presets/react.js +64 -65
  9. package/configs/eslint/presets/sonar.js +1 -3
  10. package/configs/eslint/presets/stylistic.js +74 -71
  11. package/configs/eslint/presets/test.js +30 -5
  12. package/configs/eslint/presets/typescript.js +1 -2
  13. package/configs/eslint/presets/unicorn.js +13 -1
  14. package/dist/index.js +403 -409
  15. package/package.json +28 -30
  16. package/templates/multi-projects/.github/renovate.json +0 -3
  17. package/templates/multi-projects/.github/workflows/continuous_delivery.yml +6 -13
  18. package/templates/multi-projects/.github/workflows/conventional_commit.yml +2 -3
  19. package/templates/multi-projects/.github/workflows/dependency_changelog.yml +2 -2
  20. package/templates/multi-projects/.github/workflows/workflow.yml +3 -13
  21. package/templates/multi-projects/package.json.tmpl +11 -3
  22. package/templates/single-project/.github/renovate.json +0 -3
  23. package/templates/single-project/.github/workflows/continuous_delivery.yml +6 -13
  24. package/templates/single-project/.github/workflows/conventional_commit.yml +2 -3
  25. package/templates/single-project/.github/workflows/dependency_changelog.yml +2 -2
  26. package/templates/single-project/.github/workflows/workflow.yml +3 -13
  27. package/templates/single-project/package.json.tmpl +11 -3
  28. package/configs/eslint/presets/uncategorized.js +0 -42
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { helpers, termost } from 'termost';
2
- import { resolve, join } from 'node:path';
3
2
  import { createRequire } from 'node:module';
4
- import { writeFile, chmod, mkdir, symlink, rm } from 'node:fs/promises';
5
- import { existsSync, cpSync, readFileSync, renameSync, writeFileSync, readdirSync } from 'node:fs';
3
+ import { resolve, join } from 'node:path';
4
+ import { existsSync, readdirSync, cpSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { rm, mkdir, symlink, writeFile, chmod } from 'node:fs/promises';
6
6
  import { fdir } from 'fdir';
7
7
 
8
8
  const require$1 = createRequire(import.meta.url);
@@ -41,9 +41,9 @@ function assert(expectedCondition, createError) {
41
41
  │ ◠ ◠ ${input.title}
42
42
  │ ${type === "error" ? "◠" : "◡"} │ ${input.description}
43
43
  ╰─────╯
44
- ${!input.body ? "" : `
44
+ ${input.body ? `
45
45
  ${input.body}
46
- `}`, {
46
+ ` : ""}`, {
47
47
  color: colorByType[type]
48
48
  }));
49
49
  };
@@ -73,13 +73,6 @@ const createError = (bin, error)=>{
73
73
  }
74
74
  return new Error(errorMessage);
75
75
  };
76
- const checkPackageManager = async ()=>{
77
- try {
78
- await helpers.exec("npx only-allow pnpm");
79
- } catch {
80
- throw createError("pnpm", "The project must use `pnpm` as a node package manager tool. Follow this installation guide https://pnpm.io/installation");
81
- }
82
- };
83
76
  const getNpmVersion = async ()=>{
84
77
  try {
85
78
  return await helpers.exec("pnpm -v");
@@ -87,17 +80,8 @@ const getNpmVersion = async ()=>{
87
80
  throw createError("pnpm", "The project must use `pnpm` as a node package manager tool. Follow this installation guide https://pnpm.io/installation");
88
81
  }
89
82
  };
90
- const getStackCommand = (command, isNodeRuntime = true)=>{
91
- /**
92
- * `isNodeRuntime` allows executing node bin executables in a non node environment such as in git hooks context
93
- * Npx is used to make executable resolution independent from the build tool (npx is the built-in Node tool)
94
- * `--no` flag to prevent installation prompt and throw an error if the binary is not installed.
95
- */ return [
96
- ...isNodeRuntime ? [] : [
97
- "npx --no"
98
- ],
99
- `stack ${command}`
100
- ].join(" ");
83
+ const getStackCommand = (command)=>{
84
+ return `pnpm stack ${command}`;
101
85
  };
102
86
  const hasDependency = (packageName)=>{
103
87
  return Boolean(require$1.resolve(packageName));
@@ -197,190 +181,308 @@ const ESLINT_EXTENSIONS = [
197
181
  "mdx"
198
182
  ];
199
183
 
200
- var version = "2.32.0";
201
-
202
- const createWatchCommand = (program)=>{
184
+ const createBuildCommand = (program)=>{
203
185
  program.command({
204
- name: "watch",
205
- description: "Build and start the project in development mode"
186
+ description: "Build the project in production mode",
187
+ name: "build"
206
188
  }).task({
207
189
  async handler () {
208
- await turbo("watch");
190
+ await turbo("build");
209
191
  }
210
192
  });
211
193
  };
212
194
 
213
- const createTestCommand = (program)=>{
214
- program.command({
215
- name: "test",
216
- description: "Test the code execution"
217
- }).task({
218
- async handler () {
219
- await turbo("test");
220
- }
221
- });
195
+ const checkChangelog = async ()=>{
196
+ try {
197
+ return await helpers.exec("changeset status --since=origin/main");
198
+ } catch (error) {
199
+ throw createError("changeset", error);
200
+ }
222
201
  };
223
202
 
224
- const createStartCommand = (program)=>{
225
- program.command({
226
- name: "start",
227
- description: "Start the project in production mode"
228
- }).task({
229
- async handler () {
230
- await turbo("start");
231
- }
232
- });
233
- };
203
+ const checkCode = eslint({
204
+ isFixMode: false
205
+ });
234
206
 
235
- const createReleaseCommand = (program)=>{
236
- program.command({
237
- name: "release",
238
- description: "Log, version, and publish package(s)"
239
- }).option({
240
- key: "log",
241
- name: "log",
242
- description: "Add a new changelog entry"
243
- }).option({
244
- key: "tag",
245
- name: "tag",
246
- description: "Bump the package(s) version"
247
- }).option({
248
- key: "publish",
249
- name: "publish",
250
- description: "Publish package(s) to the registry"
251
- }).task({
252
- async handler () {
253
- helpers.message("New changelog entry\n");
254
- await changeset("changeset");
255
- },
256
- skip: ifNotEqualTo("log")
257
- }).task({
258
- async handler () {
259
- helpers.message("Bumping the package(s) version\n");
260
- await changeset("changeset version && pnpm install --no-frozen-lockfile");
261
- },
262
- skip: ifNotEqualTo("tag")
263
- }).task({
264
- async handler () {
265
- helpers.message("Publishing package(s) to the registry\n");
266
- await changeset("stack build && pnpm changeset publish");
267
- },
268
- skip: ifNotEqualTo("publish")
269
- });
207
+ const checkCommit = async ()=>{
208
+ try {
209
+ return await helpers.exec('commitlint --extends "@commitlint/config-conventional" --edit');
210
+ } catch (error) {
211
+ throw createError("commitlint", error);
212
+ }
270
213
  };
271
- const ifNotEqualTo = (validOption)=>(context)=>{
272
- return !context[validOption];
273
- };
274
214
 
275
- const createInstallCommand = (program)=>{
276
- program.command({
277
- name: "install",
278
- description: "Install required setup"
279
- }).task({
280
- label: label$4("Install `git.pre-commit` hook"),
281
- async handler () {
282
- const lineBreakMatcher = String.raw`\n|\r\n`;
283
- const modifiedFilesCommand = `node -e 'console.log(require("child_process").execSync("git status --porcelain", {encoding: "utf8"}).split(/${lineBreakMatcher}/).filter(Boolean).map(item => item.split(" ").at(-1)).join(" "))'`;
284
- await installGitHook("pre-commit", `${getStackCommand(`fix $(${modifiedFilesCommand})`, false)} && git add -A`);
285
- }
286
- }).task({
287
- label: label$4("Install `git.commit-msg` hook"),
288
- async handler () {
289
- await installGitHook("commit-msg", getStackCommand("check --filter commit", false));
290
- }
291
- }).task({
292
- label: label$4("Check the package manager"),
293
- async handler () {
294
- await checkPackageManager();
295
- }
215
+ const checkDependency = async ()=>{
216
+ const stdout = await helpers.exec("pnpm recursive ls --json");
217
+ const checkDependencyVersionMismatch = createPackagesVersionMismatchChecker();
218
+ const packages = JSON.parse(stdout).map((package_)=>{
219
+ const packagePath = join(package_.path, "package.json");
220
+ assert(package_.name, ()=>createPackageError(`\`${packagePath}\` must have a name field.`));
221
+ const packageContent = require$1(packagePath);
222
+ const peerDependencies = packageContent.peerDependencies ?? {};
223
+ const devDependencies = packageContent.devDependencies ?? {};
224
+ const dependencies = packageContent.dependencies ?? {};
225
+ return {
226
+ dependencies,
227
+ devDependencies,
228
+ name: package_.name,
229
+ peerDependencies
230
+ };
296
231
  });
232
+ for (const package_ of packages){
233
+ // Check version mismatches to guarantee a single copy for a given package in the monorepo (use case: prevent singleton-like issues with React contexts)
234
+ checkDependencyVersionMismatch(package_);
235
+ // Check version range accordingly to our dependency guidelines (ie. dev dependencies must be pinned and dependencies must have caret)
236
+ checkDependencyVersionRange(package_);
237
+ }
297
238
  };
298
- const label$4 = (message)=>`${message} 📲`;
299
- const installGitHook = async (hook, content)=>{
300
- const filename = resolveFromProjectDirectory(`.git/hooks/${hook}`);
301
- await writeFile(filename, content);
302
- return chmod(filename, "0755");
303
- };
304
-
305
- const fixLinter = eslint({
306
- isFixMode: true
307
- });
308
-
309
- const PRETTIER_IGNORE_FILES = [
310
- "pnpm-lock.yaml"
311
- ];
312
- const fixFormatting = async (files)=>{
313
- let prettierFiles = [];
314
- if (files.length === 0) {
315
- prettierFiles.push(`"**/!(${PRETTIER_IGNORE_FILES.join("|")})"`);
316
- } else {
317
- prettierFiles = files.filter((file)=>{
318
- return !PRETTIER_IGNORE_FILES.some((filename)=>file.endsWith(filename));
239
+ const checkDependencyVersionRange = ({ dependencies, devDependencies, name, peerDependencies })=>{
240
+ for (const dependencyName of Object.keys(devDependencies)){
241
+ const version = devDependencies[dependencyName];
242
+ assertVersion(version, {
243
+ consumedBy: name,
244
+ name: dependencyName
245
+ });
246
+ if (version !== "workspace:*" && !isExcluded(version) && !/^\d/.test(version)) throw createPackageError(`As a dev dependency, \`${dependencyName}\` version must be fixed (or set as "workspace:*" for local packages) to reduce accidental breaking change risks due to an implicit semver upgrade.`, {
247
+ consumedBy: name,
248
+ name: dependencyName
319
249
  });
320
- if (prettierFiles.length === 0) return;
321
250
  }
322
- const arguments_ = [
323
- ...prettierFiles
324
- ];
325
- if (existsSync(resolveFromProjectDirectory(".gitignore"))) {
326
- arguments_.push("--ignore-path .gitignore");
251
+ for (const dependencyName of Object.keys(dependencies)){
252
+ const version = dependencies[dependencyName];
253
+ assertVersion(version, {
254
+ consumedBy: name,
255
+ name: dependencyName
256
+ });
257
+ if (version !== "workspace:^" && !hasCaret(version) && !isExcluded(version)) throw createPackageError(`As a dependency, \`${dependencyName}\` version must be prefixed with a caret (or set as "workspace:^" for local packages) to optimize the size (whether of installation or bundle output) on the consumer side.`, {
258
+ consumedBy: name,
259
+ name: dependencyName
260
+ });
327
261
  }
328
- arguments_.push("--write", "--ignore-unknown", "--no-error-on-unmatched-pattern");
262
+ for (const dependencyName of Object.keys(peerDependencies)){
263
+ const version = peerDependencies[dependencyName];
264
+ assertVersion(version, {
265
+ consumedBy: name,
266
+ name: dependencyName
267
+ });
268
+ if (!hasCaret(version) && !isExcluded(version)) /*
269
+ * Why disallowing workspace protocol as a version resolver?
270
+ * To reduce the update frequency needs consumer-side and guarantee on our side the minimum compatible version,
271
+ * the best practice should be to keeping an explicit number version which represents the lowest compatible version from an API perspective.
272
+ */ throw createPackageError(`As a peer dependency, \`${dependencyName}\` version must be explicit (i.e. the "workspace:^" protocol a version resolver is not allowed) and prefixed with a caret to optimize the size (whether of installation or bundle output) on the consumer side.`, {
273
+ consumedBy: name,
274
+ name: dependencyName
275
+ });
276
+ }
277
+ };
278
+ const createPackagesVersionMismatchChecker = ()=>{
279
+ const monorepoDependencies = new Map();
280
+ const monorepoDevelopmentDependencies = new Map();
281
+ const lint = (package_, type)=>{
282
+ const packageName = package_.name;
283
+ const isDevelopment = type === "development";
284
+ const store = isDevelopment ? monorepoDevelopmentDependencies : monorepoDependencies;
285
+ const dependencies = isDevelopment ? package_.devDependencies : package_.dependencies;
286
+ for (const dependencyName of Object.keys(dependencies)){
287
+ const depVersion = dependencies[dependencyName];
288
+ if (!depVersion) continue;
289
+ const storedVersion = store.get(dependencyName);
290
+ if (!storedVersion) {
291
+ store.set(dependencyName, depVersion);
292
+ continue;
293
+ }
294
+ const isSameMonorepoVersion = depVersion === storedVersion;
295
+ if (!isSameMonorepoVersion) {
296
+ throw createPackageError(`Mismatched versions: received version \`${depVersion}\` while others use \`${storedVersion}\`. To prevent issues with singleton-like code (React contexts, …), please make sure to update all packages to use the same \`${dependencyName}\` version (either \`${storedVersion}\` or \`${depVersion}\`).`, {
297
+ consumedBy: packageName,
298
+ name: dependencyName
299
+ });
300
+ }
301
+ }
302
+ };
303
+ return (package_)=>{
304
+ lint(package_, "development");
305
+ lint(package_, "production");
306
+ };
307
+ };
308
+ const createPackageError = (message, context)=>{
309
+ return createError("stack check", context ? `\`${context.name}\` consumed by \`${context.consumedBy}\` doesn't conform to package guidelines.\n${message}` : message);
310
+ };
311
+ function assertVersion(version, { consumedBy, name }) {
312
+ assert(version, ()=>createPackageError(`\`${name}\` must have a valid version specified (current version equals to \`${String(version)}\`).`, {
313
+ consumedBy,
314
+ name
315
+ }));
316
+ }
317
+ const isExcluded = (version)=>{
318
+ const isPreReleaseVersion = /\d+\.\d+\.\d+-(alpha|beta|experimental|next|rc).*/.exec(version);
319
+ const isNpmProtocol = version.startsWith("npm:");
320
+ return isNpmProtocol || isPreReleaseVersion;
321
+ };
322
+ const hasCaret = (version)=>version.startsWith("^");
323
+
324
+ const checkType = async ()=>{
329
325
  try {
330
- return await helpers.exec(`prettier ${arguments_.join(" ")}`);
326
+ return await helpers.exec("pnpm --parallel exec tsc --noEmit");
331
327
  } catch (error) {
332
- throw createError("prettier", error);
328
+ throw createError("tsc", error);
333
329
  }
334
330
  };
335
331
 
336
- const createFixCommand = (program)=>{
332
+ const ONLY_VALUES = [
333
+ "changelog",
334
+ "commit",
335
+ "code",
336
+ "dependency",
337
+ "type"
338
+ ];
339
+ const createCheckCommand = (program)=>{
337
340
  program.command({
338
- name: "fix",
339
- description: "Fix auto-fixable issues"
341
+ description: "Check code health (static analysis)",
342
+ name: "check"
343
+ }).option({
344
+ defaultValue: undefined,
345
+ description: `Filter the compliance check to run (accepted value: ${ONLY_VALUES.join(", ")})`,
346
+ key: "filter",
347
+ name: "filter"
340
348
  }).task({
341
349
  handler (_, argv) {
342
350
  logCheckableFiles(argv.operands);
343
- }
351
+ },
352
+ skip: ifFilterDefinedAndNotEqualTo("code")
344
353
  }).task({
345
- label: label$3("Prepare the project"),
346
354
  async handler () {
347
355
  await turbo("build", {
348
356
  excludeExamples: true,
349
357
  hasLiveOutput: false
350
358
  });
359
+ },
360
+ label: label$4("Prepare the project"),
361
+ skip ({ filter }) {
362
+ return filter === "commit"; // No need to build if only commit is checked
351
363
  }
352
364
  }).task({
353
- label: label$3("Fix linter issues"),
365
+ async handler () {
366
+ await checkChangelog();
367
+ },
368
+ label: label$4("Check changelog compliance"),
369
+ skip: ifFilterDefinedAndNotEqualTo("changelog")
370
+ }).task({
371
+ async handler () {
372
+ await checkDependency();
373
+ },
374
+ label: label$4("Check dependency compliance"),
375
+ skip: ifFilterDefinedAndNotEqualTo("dependency")
376
+ }).task({
354
377
  async handler (_, argv) {
355
- await fixLinter(argv.operands);
378
+ const filenames = argv.operands;
379
+ await checkCode(filenames);
380
+ },
381
+ label: label$4("Check code compliance"),
382
+ skip: ifFilterDefinedAndNotEqualTo("code")
383
+ }).task({
384
+ async handler () {
385
+ await checkType();
386
+ },
387
+ label: label$4("Check type compliance"),
388
+ skip (context, argv) {
389
+ return ifFilterDefinedAndNotEqualTo("type")(context) || !hasDependency("typescript") || /**
390
+ * For now, skip type-checking if some files are passed down.
391
+ * @see https://github.com/microsoft/TypeScript/issues/27379
392
+ */ argv.operands.length > 0;
356
393
  }
357
394
  }).task({
358
- label: label$3("Fix formatting issues"),
359
- async handler (_, argv) {
360
- await fixFormatting(argv.operands);
395
+ async handler () {
396
+ await checkCommit();
397
+ },
398
+ label: label$4("Check commit compliance"),
399
+ skip (context) {
400
+ return context.filter !== "commit";
361
401
  }
362
402
  });
363
403
  };
364
- const label$3 = (message)=>`${message} 🚑`;
404
+ const label$4 = (message)=>`${message} 🧐`;
405
+ const ifFilterDefinedAndNotEqualTo = (filter)=>(context)=>{
406
+ return context.filter !== undefined && context.filter !== filter;
407
+ };
365
408
 
366
- const createCreateCommand = (program)=>{
409
+ const createCleanCommand = (program)=>{
367
410
  program.command({
368
- name: "create",
369
- description: "Scaffold a new project"
411
+ description: "Clean the project",
412
+ name: "clean"
370
413
  }).task({
371
- handler () {
372
- botMessage({
373
- title: `I'm Stack v${version}, your bot assistant`,
374
- description: "I can guarantee you a project creation in under 1 minute 🚀",
414
+ async handler () {
415
+ const cachePath = "node_modules/.cache";
416
+ const files = await retrieveIgnoredFiles();
417
+ if (isDirectoryExistAndNotEmpty(resolveFromProjectDirectory(cachePath))) {
418
+ files.push(cachePath);
419
+ }
420
+ return files;
421
+ },
422
+ key: "files",
423
+ label: label$3("Retrieve removable files")
424
+ }).task({
425
+ async handler ({ files }) {
426
+ if (files.length === 0) return;
427
+ await cleanFiles(files);
428
+ },
429
+ label ({ files }) {
430
+ return files.length > 0 ? label$3("Clean assets") : "Already clean ✨";
431
+ }
432
+ }).task({
433
+ handler ({ files }) {
434
+ helpers.message(files.join("\n "), {
435
+ label: "Removed assets",
436
+ lineBreak: {
437
+ end: false,
438
+ start: true
439
+ },
440
+ type: "information"
441
+ });
442
+ },
443
+ skip ({ files }) {
444
+ return files.length === 0;
445
+ }
446
+ });
447
+ };
448
+ const label$3 = (message)=>`${message} 🧹`;
449
+ const cleanFiles = async (files)=>{
450
+ return Promise.all(files.map(async (file)=>rm(file, {
451
+ force: true,
452
+ recursive: true
453
+ })));
454
+ };
455
+ const isDirectoryExistAndNotEmpty = (path)=>{
456
+ return existsSync(path) && readdirSync(path).length > 0;
457
+ };
458
+ const retrieveIgnoredFiles = async ()=>{
459
+ const rawFiles = await helpers.exec("git clean -fdXn");
460
+ return rawFiles.split(/\n|\r\n/).filter((cleanOutput)=>!PRESERVE_FILES.some((excludedFile)=>cleanOutput.includes(excludedFile))).map((cleanOutput)=>cleanOutput.split(" ").at(-1));
461
+ };
462
+ const PRESERVE_FILES = [
463
+ "node_modules"
464
+ ];
465
+
466
+ var version = "2.33.0-next-40f7a45";
467
+
468
+ const createCreateCommand = (program)=>{
469
+ program.command({
470
+ description: "Scaffold a new project",
471
+ name: "create"
472
+ }).task({
473
+ handler () {
474
+ botMessage({
475
+ description: "I can guarantee you a project creation in under 1 minute 🚀",
476
+ title: `I'm Stack v${version}, your bot assistant`,
375
477
  type: "information"
376
478
  });
377
479
  }
378
480
  }).task({
379
- label: label$2("Check pre-requisites"),
380
481
  async handler () {
381
482
  // Check pnpm availability by verifying its version
382
483
  await getNpmVersion();
383
- }
484
+ },
485
+ label: label$2("Check pre-requisites")
384
486
  }).input({
385
487
  key: "inputName",
386
488
  label: "What's your project name?",
@@ -390,22 +492,20 @@ const createCreateCommand = (program)=>{
390
492
  label: "How would you describe it?",
391
493
  type: "text"
392
494
  }).input({
495
+ defaultValue: "git@github.com:adbayb/xxx.git",
393
496
  key: "inputUrl",
394
497
  label: "Where will it be stored? (Git remote URL)",
395
- defaultValue: "git@github.com:adbayb/xxx.git",
396
498
  type: "text"
397
499
  }).input({
500
+ defaultValue: "single-project",
398
501
  key: "inputTemplate",
399
502
  label: "Which template you would like to apply?",
400
- defaultValue: "single-project",
401
503
  options: [
402
504
  "single-project",
403
505
  "multi-projects"
404
506
  ],
405
507
  type: "select"
406
508
  }).task({
407
- key: "data",
408
- label: label$2("Check and format input"),
409
509
  async handler ({ inputDescription, inputName, inputUrl }) {
410
510
  if (!inputName) {
411
511
  throw createError("stack create", "The project name is not optional. Make sure to provide a valid value (non-empty string).");
@@ -414,50 +514,51 @@ const createCreateCommand = (program)=>{
414
514
  if (!repoOwner || !repoName) {
415
515
  throw createError("git", "The owner and repository name can not be extracted. Please make sure to follow either `/^git@.*:(?<repoOwner>.*)/(?<repoName>.*).git$/` or `/^https?://.*/(?<repoOwner>.*)/(?<repoName>.*).git$/` pattern.");
416
516
  }
417
- const nodeVersion = (await request.get("https://resolve-node.vercel.app/lts", "text")).replace("v", "");
418
- const npmVersion = (await request.get("https://registry.npmjs.org/pnpm/latest", "json")).version;
517
+ const nodeVersion = await request.get("https://resolve-node.vercel.app/lts", "text");
518
+ const { version: npmVersion } = await request.get("https://registry.npmjs.org/pnpm/latest", "json");
419
519
  return {
420
520
  licenseYear: new Date().getFullYear().toString(),
421
- nodeVersion,
422
- npmVersion,
521
+ nodeVersion: nodeVersion.replace("v", ""),
522
+ npmVersion: String(npmVersion),
423
523
  projectDescription: inputDescription.charAt(0).toUpperCase() + inputDescription.slice(1),
424
524
  projectName: inputName.toLowerCase(),
425
525
  projectUrl: inputUrl,
426
526
  repoId: `${repoOwner}/${repoName}`
427
527
  };
428
- }
429
- }).task({
430
- label ({ data }) {
431
- return label$2(`Create \`${data.projectName}\` folder`);
432
528
  },
529
+ key: "data",
530
+ label: label$2("Check and format input")
531
+ }).task({
433
532
  async handler ({ data }) {
434
533
  const projectPath = resolve(process.cwd(), data.projectName);
435
534
  await mkdir(projectPath);
436
535
  process.chdir(projectPath);
536
+ },
537
+ label ({ data }) {
538
+ return label$2(`Create \`${data.projectName}\` folder`);
437
539
  }
438
540
  }).task({
439
- label: label$2("Initialize `git`"),
440
541
  async handler ({ data }) {
441
542
  await helpers.exec("git init");
442
543
  await helpers.exec(`git remote add origin ${data.projectUrl}`);
443
- }
544
+ },
545
+ label: label$2("Initialize `git`")
444
546
  }).task({
445
- label: label$2("Apply template"),
446
547
  handler ({ data, inputTemplate }) {
447
548
  applyTemplate(inputTemplate, data);
448
- }
549
+ },
550
+ label: label$2("Apply template")
449
551
  }).task({
450
- label: label$2("Create a symlink to `README.md` file"),
451
552
  async handler ({ data: { projectName }, inputTemplate }) {
452
553
  await symlink(join(inputTemplate === "single-project" ? projectName : join("libraries", projectName), "README.md"), "./README.md");
453
- }
554
+ },
555
+ label: label$2("Create a symlink to `README.md` file")
454
556
  }).task({
455
- label: label$2("Set up the package manager"),
456
557
  async handler () {
457
558
  await setPackageManager();
458
- }
559
+ },
560
+ label: label$2("Set up the package manager")
459
561
  }).task({
460
- label: label$2("Install dependencies"),
461
562
  async handler ({ data }) {
462
563
  const localDevelopmentDependencies = [
463
564
  "quickbundle",
@@ -473,23 +574,24 @@ const createCreateCommand = (program)=>{
473
574
  } catch (error) {
474
575
  throw createError("pnpm", error);
475
576
  }
476
- }
577
+ },
578
+ label: label$2("Install dependencies")
477
579
  }).task({
478
- label: label$2("Run `stack install`"),
479
580
  async handler () {
480
581
  await helpers.exec("stack install");
481
- }
582
+ },
583
+ label: label$2("Run `stack install`")
482
584
  }).task({
483
- label: label$2("Commit"),
484
585
  async handler () {
485
586
  await helpers.exec("git add -A");
486
587
  await helpers.exec('git commit -m "chore: initial commit"');
487
- }
588
+ },
589
+ label: label$2("Commit")
488
590
  }).task({
489
591
  handler ({ data }) {
490
592
  botMessage({
491
- title: "The project was successfully created",
492
593
  description: `Run \`cd ./${data.projectName}\` and Enjoy 🚀`,
594
+ title: "The project was successfully created",
493
595
  type: "success"
494
596
  });
495
597
  }
@@ -533,281 +635,173 @@ const label$2 = (message)=>`${message} 🔨`;
533
635
  });
534
636
  };
535
637
 
536
- const createCleanCommand = (program)=>{
537
- program.command({
538
- name: "clean",
539
- description: "Clean the project"
540
- }).task({
541
- key: "files",
542
- label: label$1("Retrieve removable files"),
543
- async handler () {
544
- const cachePath = "node_modules/.cache";
545
- const files = await retrieveIgnoredFiles();
546
- if (isDirectoryExistAndNotEmpty(resolveFromProjectDirectory(cachePath))) {
547
- files.push(cachePath);
548
- }
549
- return files;
550
- }
551
- }).task({
552
- label ({ files }) {
553
- return files.length > 0 ? label$1("Clean assets") : "Already clean ✨";
554
- },
555
- async handler ({ files }) {
556
- if (files.length === 0) return;
557
- await cleanFiles(files);
558
- }
559
- }).task({
560
- handler ({ files }) {
561
- helpers.message(files.join("\n "), {
562
- label: "Removed assets",
563
- lineBreak: {
564
- end: false,
565
- start: true
566
- },
567
- type: "information"
568
- });
569
- },
570
- skip ({ files }) {
571
- return files.length === 0;
572
- }
573
- });
574
- };
575
- const label$1 = (message)=>`${message} 🧹`;
576
- const cleanFiles = async (files)=>{
577
- return Promise.all(files.map(async (file)=>rm(file, {
578
- force: true,
579
- recursive: true
580
- })));
581
- };
582
- const isDirectoryExistAndNotEmpty = (path)=>{
583
- return existsSync(path) && readdirSync(path).length > 0;
584
- };
585
- const retrieveIgnoredFiles = async ()=>{
586
- const rawFiles = await helpers.exec("git clean -fdXn");
587
- return rawFiles.split(/\n|\r\n/).filter((cleanOutput)=>!PRESERVE_FILES.some((excludedFile)=>cleanOutput.includes(excludedFile))).map((cleanOutput)=>cleanOutput.split(" ").at(-1));
588
- };
589
- const PRESERVE_FILES = [
590
- "node_modules"
638
+ const PRETTIER_IGNORE_FILES = [
639
+ "pnpm-lock.yaml"
591
640
  ];
592
-
593
- const checkType = async ()=>{
594
- try {
595
- return await helpers.exec("pnpm --parallel exec tsc --noEmit");
596
- } catch (error) {
597
- throw createError("tsc", error);
598
- }
599
- };
600
-
601
- const checkDependency = async ()=>{
602
- const stdout = await helpers.exec("pnpm recursive ls --json");
603
- const checkDependencyVersionMismatch = createPackagesVersionMismatchChecker();
604
- const packages = JSON.parse(stdout).map((package_)=>{
605
- const packagePath = join(package_.path, "package.json");
606
- assert(package_.name, ()=>createPackageError(`\`${packagePath}\` must have a name field.`));
607
- const packageContent = require$1(packagePath);
608
- const peerDependencies = packageContent.peerDependencies ?? {};
609
- const devDependencies = packageContent.devDependencies ?? {};
610
- const dependencies = packageContent.dependencies ?? {};
611
- return {
612
- name: package_.name,
613
- dependencies,
614
- devDependencies,
615
- peerDependencies
616
- };
617
- });
618
- for (const package_ of packages){
619
- // Check version mismatches to guarantee a single copy for a given package in the monorepo (use case: prevent singleton-like issues with React contexts)
620
- checkDependencyVersionMismatch(package_);
621
- // Check version range accordingly to our dependency guidelines (ie. dev dependencies must be pinned and dependencies must have caret)
622
- checkDependencyVersionRange(package_);
623
- }
624
- };
625
- const checkDependencyVersionRange = ({ name, dependencies, devDependencies, peerDependencies })=>{
626
- for (const dependencyName of Object.keys(devDependencies)){
627
- const version = devDependencies[dependencyName];
628
- assertVersion(version, {
629
- name: dependencyName,
630
- consumedBy: name
631
- });
632
- if (version !== "workspace:*" && !isExcluded(version) && !/^\d/.test(version)) throw createPackageError(`As a dev dependency, \`${dependencyName}\` version must be fixed (or set as "workspace:*" for local packages) to reduce accidental breaking change risks due to an implicit semver upgrade.`, {
633
- name: dependencyName,
634
- consumedBy: name
635
- });
636
- }
637
- for (const dependencyName of Object.keys(dependencies)){
638
- const version = dependencies[dependencyName];
639
- assertVersion(version, {
640
- name: dependencyName,
641
- consumedBy: name
642
- });
643
- if (version !== "workspace:^" && !hasCaret(version) && !isExcluded(version)) throw createPackageError(`As a dependency, \`${dependencyName}\` version must be prefixed with a caret (or set as "workspace:^" for local packages) to optimize the size (whether of installation or bundle output) on the consumer side.`, {
644
- name: dependencyName,
645
- consumedBy: name
641
+ const fixFormatting = async (files)=>{
642
+ let prettierFiles = [];
643
+ if (files.length === 0) {
644
+ prettierFiles.push(`"**/!(${PRETTIER_IGNORE_FILES.join("|")})"`);
645
+ } else {
646
+ prettierFiles = files.filter((file)=>{
647
+ return !PRETTIER_IGNORE_FILES.some((filename)=>file.endsWith(filename));
646
648
  });
649
+ if (prettierFiles.length === 0) return;
647
650
  }
648
- for (const dependencyName of Object.keys(peerDependencies)){
649
- const version = peerDependencies[dependencyName];
650
- assertVersion(version, {
651
- name: dependencyName,
652
- consumedBy: name
653
- });
654
- if (!hasCaret(version) && !isExcluded(version)) /*
655
- * Why disallowing workspace protocol as a version resolver?
656
- * To reduce the update frequency needs consumer-side and guarantee on our side the minimum compatible version,
657
- * the best practice should be to keeping an explicit number version which represents the lowest compatible version from an API perspective.
658
- */ throw createPackageError(`As a peer dependency, \`${dependencyName}\` version must be explicit (i.e. the "workspace:^" protocol a version resolver is not allowed) and prefixed with a caret to optimize the size (whether of installation or bundle output) on the consumer side.`, {
659
- name: dependencyName,
660
- consumedBy: name
661
- });
651
+ const arguments_ = [
652
+ ...prettierFiles
653
+ ];
654
+ if (existsSync(resolveFromProjectDirectory(".gitignore"))) {
655
+ arguments_.push("--ignore-path .gitignore");
662
656
  }
663
- };
664
- const createPackagesVersionMismatchChecker = ()=>{
665
- const monorepoDependencies = new Map();
666
- const monorepoDevelopmentDependencies = new Map();
667
- const lint = (package_, type)=>{
668
- const packageName = package_.name;
669
- const isDevelopment = type === "development";
670
- const store = isDevelopment ? monorepoDevelopmentDependencies : monorepoDependencies;
671
- const dependencies = isDevelopment ? package_.devDependencies : package_.dependencies;
672
- for (const dependencyName of Object.keys(dependencies)){
673
- const depVersion = dependencies[dependencyName];
674
- if (!depVersion) continue;
675
- const storedVersion = store.get(dependencyName);
676
- if (!storedVersion) {
677
- store.set(dependencyName, depVersion);
678
- continue;
679
- }
680
- const isSameMonorepoVersion = depVersion === storedVersion;
681
- if (!isSameMonorepoVersion) {
682
- throw createPackageError(`Mismatched versions: received version \`${depVersion}\` while others use \`${storedVersion}\`. To prevent issues with singleton-like code (React contexts, …), please make sure to update all packages to use the same \`${dependencyName}\` version (either \`${storedVersion}\` or \`${depVersion}\`).`, {
683
- name: dependencyName,
684
- consumedBy: packageName
685
- });
686
- }
687
- }
688
- };
689
- return (package_)=>{
690
- lint(package_, "development");
691
- lint(package_, "production");
692
- };
693
- };
694
- const createPackageError = (message, context)=>{
695
- return createError("stack check", !context ? message : `\`${context.name}\` consumed by \`${context.consumedBy}\` doesn't conform to package guidelines.\n${message}`);
696
- };
697
- function assertVersion(version, { name, consumedBy }) {
698
- assert(version, ()=>createPackageError(`\`${name}\` must have a valid version specified (current version equals to \`${String(version)}\`).`, {
699
- name,
700
- consumedBy
701
- }));
702
- }
703
- const isExcluded = (version)=>{
704
- const isPreReleaseVersion = /\d+\.\d+\.\d+-(alpha|beta|experimental|next|rc).*/.exec(version);
705
- const isNpmProtocol = version.startsWith("npm:");
706
- return isNpmProtocol || isPreReleaseVersion;
707
- };
708
- const hasCaret = (version)=>version.startsWith("^");
709
-
710
- const checkCommit = async ()=>{
657
+ arguments_.push("--write", "--ignore-unknown", "--no-error-on-unmatched-pattern");
711
658
  try {
712
- return await helpers.exec('commitlint --extends "@commitlint/config-conventional" --edit');
659
+ return await helpers.exec(`prettier ${arguments_.join(" ")}`);
713
660
  } catch (error) {
714
- throw createError("commitlint", error);
661
+ throw createError("prettier", error);
715
662
  }
716
663
  };
717
664
 
718
- const checkCode = eslint({
719
- isFixMode: false
665
+ const fixLinter = eslint({
666
+ isFixMode: true
720
667
  });
721
668
 
722
- const ONLY_VALUES = [
723
- "commit",
724
- "code",
725
- "dependency",
726
- "type"
727
- ];
728
- const createCheckCommand = (program)=>{
669
+ const createFixCommand = (program)=>{
729
670
  program.command({
730
- name: "check",
731
- description: "Check code health (static analysis)"
732
- }).option({
733
- key: "filter",
734
- name: "filter",
735
- description: `Filter the compliance check to run (accepted value: ${ONLY_VALUES.join(", ")})`,
736
- defaultValue: undefined
671
+ description: "Fix auto-fixable issues",
672
+ name: "fix"
737
673
  }).task({
738
674
  handler (_, argv) {
739
675
  logCheckableFiles(argv.operands);
740
- },
741
- skip: ifFilterDefinedAndNotEqualTo("code")
676
+ }
742
677
  }).task({
743
- label: label("Prepare the project"),
744
678
  async handler () {
745
679
  await turbo("build", {
746
680
  excludeExamples: true,
747
681
  hasLiveOutput: false
748
682
  });
749
683
  },
750
- skip ({ filter }) {
751
- return filter === "commit"; // No need to build if only commit is checked
752
- }
684
+ label: label$1("Prepare the project")
753
685
  }).task({
754
- label: label("Check dependency compliance"),
755
- async handler () {
756
- await checkDependency();
686
+ async handler (_, argv) {
687
+ await fixLinter(argv.operands);
757
688
  },
758
- skip: ifFilterDefinedAndNotEqualTo("dependency")
689
+ label: label$1("Fix linter issues")
759
690
  }).task({
760
- label: label("Check code compliance"),
761
691
  async handler (_, argv) {
762
- const filenames = argv.operands;
763
- await checkCode(filenames);
692
+ await fixFormatting(argv.operands);
764
693
  },
765
- skip: ifFilterDefinedAndNotEqualTo("code")
694
+ label: label$1("Fix formatting issues")
695
+ });
696
+ };
697
+ const label$1 = (message)=>`${message} 🚑`;
698
+
699
+ const createInstallCommand = (program)=>{
700
+ program.command({
701
+ description: "Install required setup",
702
+ name: "install"
766
703
  }).task({
767
- label: label("Check type compliance"),
768
704
  async handler () {
769
- await checkType();
705
+ const lineBreakMatcher = String.raw`\n|\r\n`;
706
+ const stackCommand = getStackCommand(`fix $(node -e 'console.log(require("child_process").execSync("git status --porcelain", {encoding: "utf8"}).split(/${lineBreakMatcher}/).filter(Boolean).map(item => item.split(" ").at(-1)).join(" "))')`);
707
+ await installGitHook("pre-commit", `${stackCommand} && git add -A`);
770
708
  },
771
- skip (context, argv) {
772
- return ifFilterDefinedAndNotEqualTo("type")(context) || !hasDependency("typescript") || /**
773
- * For now, skip type-checking if some files are passed down.
774
- * @see https://github.com/microsoft/TypeScript/issues/27379
775
- */ argv.operands.length > 0;
776
- }
709
+ label: label("Install `git.pre-commit` hook")
777
710
  }).task({
778
- label: label("Check commit compliance"),
779
711
  async handler () {
780
- await checkCommit();
712
+ await installGitHook("commit-msg", getStackCommand("check --filter commit"));
781
713
  },
782
- skip (context) {
783
- return context.filter !== "commit";
784
- }
714
+ label: label("Install `git.commit-msg` hook")
785
715
  });
786
716
  };
787
- const label = (message)=>`${message} 🧐`;
788
- const ifFilterDefinedAndNotEqualTo = (filter)=>(context)=>{
789
- return context.filter !== undefined && context.filter !== filter;
717
+ const label = (message)=>`${message} 📲`;
718
+ const installGitHook = async (hook, content)=>{
719
+ const filename = resolveFromProjectDirectory(`.git/hooks/${hook}`);
720
+ await writeFile(filename, content);
721
+ return chmod(filename, "0755");
722
+ };
723
+
724
+ const createReleaseCommand = (program)=>{
725
+ program.command({
726
+ description: "Log, version, and publish package(s)",
727
+ name: "release"
728
+ }).option({
729
+ description: "Add a new changelog entry",
730
+ key: "log",
731
+ name: "log"
732
+ }).option({
733
+ description: "Bump the package(s) version",
734
+ key: "tag",
735
+ name: "tag"
736
+ }).option({
737
+ description: "Publish package(s) to the registry",
738
+ key: "publish",
739
+ name: "publish"
740
+ }).task({
741
+ async handler () {
742
+ helpers.message("New changelog entry\n");
743
+ await changeset("changeset");
744
+ },
745
+ skip: ifNotEqualTo("log")
746
+ }).task({
747
+ async handler () {
748
+ helpers.message("Bumping the package(s) version\n");
749
+ await changeset("changeset version && pnpm install --no-frozen-lockfile");
750
+ },
751
+ skip: ifNotEqualTo("tag")
752
+ }).task({
753
+ async handler () {
754
+ helpers.message("Publishing package(s) to the registry\n");
755
+ await changeset("stack build && pnpm changeset publish");
756
+ },
757
+ skip: ifNotEqualTo("publish")
758
+ });
759
+ };
760
+ const ifNotEqualTo = (validOption)=>(context)=>{
761
+ return !context[validOption];
790
762
  };
791
763
 
792
- const createBuildCommand = (program)=>{
764
+ const createStartCommand = (program)=>{
793
765
  program.command({
794
- name: "build",
795
- description: "Build the project in production mode"
766
+ description: "Start the project in production mode",
767
+ name: "start"
796
768
  }).task({
797
769
  async handler () {
798
- await turbo("build");
770
+ await turbo("start");
771
+ }
772
+ });
773
+ };
774
+
775
+ const createTestCommand = (program)=>{
776
+ program.command({
777
+ description: "Test the code execution",
778
+ name: "test"
779
+ }).task({
780
+ async handler () {
781
+ await turbo("test");
782
+ }
783
+ });
784
+ };
785
+
786
+ const createWatchCommand = (program)=>{
787
+ program.command({
788
+ description: "Build and start the project in development mode",
789
+ name: "watch"
790
+ }).task({
791
+ async handler () {
792
+ await turbo("watch");
799
793
  }
800
794
  });
801
795
  };
802
796
 
803
797
  const createProgram = (...commandFactories)=>{
804
798
  const program = termost({
805
- name: "stack",
806
799
  description: "Toolbox to easily scaffold and maintain a project",
800
+ name: "stack",
807
801
  onException () {
808
802
  botMessage({
809
- title: "Oops, an error occurred",
810
803
  description: "Keep calm and carry on with some coffee ☕️",
804
+ title: "Oops, an error occurred",
811
805
  type: "error"
812
806
  });
813
807
  },