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