@adbayb/stack 0.0.0-next-5dd260c → 0.0.0-next.d8e717b

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 (82) hide show
  1. package/README.md +4 -4
  2. package/bin/index.js +2 -4
  3. package/configs/{prettier → oxfmt}/README.md +6 -14
  4. package/configs/oxfmt/index.ts +41 -0
  5. package/configs/{eslint → oxlint}/README.md +4 -4
  6. package/configs/oxlint/index.ts +319 -0
  7. package/configs/typescript/index.json +1 -7
  8. package/dist/configs/oxfmt.d.ts +32 -0
  9. package/dist/configs/oxfmt.js +37 -0
  10. package/dist/configs/oxlint.d.ts +273 -0
  11. package/dist/configs/oxlint.js +325 -0
  12. package/dist/index.js +819 -779
  13. package/package.json +44 -51
  14. package/templates/multi-projects/.github/renovate.json +0 -3
  15. package/templates/multi-projects/.github/workflows/continuous_delivery.yml +8 -14
  16. package/templates/multi-projects/.github/workflows/conventional_commit.yml +1 -1
  17. package/templates/multi-projects/.github/workflows/dependency_changelog.yml +1 -1
  18. package/templates/multi-projects/.github/workflows/workflow.yml +4 -4
  19. package/templates/multi-projects/.gitignore.tmpl +45 -32
  20. package/templates/multi-projects/.vscode/extensions.json +1 -6
  21. package/templates/multi-projects/.vscode/settings.json +10 -26
  22. package/templates/multi-projects/libraries/{{projectName}}/{package.json.tmpl → package.json} +15 -17
  23. package/templates/multi-projects/libraries/{{projectName}}/src/{index.test.ts.tmpl → index.test.ts} +2 -0
  24. package/templates/multi-projects/oxfmt.config.ts +1 -0
  25. package/templates/multi-projects/oxlint.config.ts +1 -0
  26. package/templates/{single-project/package.json.tmpl → multi-projects/package.json} +11 -5
  27. package/templates/multi-projects/pnpm-workspace.yaml +21 -0
  28. package/templates/single-project/.github/renovate.json +0 -3
  29. package/templates/single-project/.github/workflows/continuous_delivery.yml +8 -14
  30. package/templates/single-project/.github/workflows/conventional_commit.yml +1 -1
  31. package/templates/single-project/.github/workflows/dependency_changelog.yml +1 -1
  32. package/templates/single-project/.github/workflows/workflow.yml +4 -4
  33. package/templates/single-project/.gitignore.tmpl +45 -32
  34. package/templates/single-project/.vscode/extensions.json +1 -6
  35. package/templates/single-project/.vscode/settings.json +10 -26
  36. package/templates/single-project/oxfmt.config.ts +1 -0
  37. package/templates/single-project/oxlint.config.ts +1 -0
  38. package/templates/{multi-projects/package.json.tmpl → single-project/package.json} +11 -5
  39. package/templates/single-project/pnpm-workspace.yaml +20 -0
  40. package/templates/single-project/{{projectName}}/{package.json.tmpl → package.json} +15 -17
  41. package/templates/single-project/{{projectName}}/src/{index.test.ts.tmpl → index.test.ts} +2 -0
  42. package/configs/eslint/constants.js +0 -37
  43. package/configs/eslint/helpers.js +0 -5
  44. package/configs/eslint/index.js +0 -30
  45. package/configs/eslint/presets/dependencies.js +0 -14
  46. package/configs/eslint/presets/eslint.js +0 -133
  47. package/configs/eslint/presets/import.js +0 -65
  48. package/configs/eslint/presets/jsdoc.js +0 -78
  49. package/configs/eslint/presets/markdown.js +0 -1
  50. package/configs/eslint/presets/node.js +0 -50
  51. package/configs/eslint/presets/prettier.js +0 -1
  52. package/configs/eslint/presets/react.js +0 -101
  53. package/configs/eslint/presets/sonar.js +0 -202
  54. package/configs/eslint/presets/stylistic.js +0 -79
  55. package/configs/eslint/presets/test.js +0 -87
  56. package/configs/eslint/presets/typescript.js +0 -198
  57. package/configs/eslint/presets/unicorn.js +0 -143
  58. package/configs/prettier/index.js +0 -25
  59. package/templates/multi-projects/.npmrc.tmpl +0 -8
  60. package/templates/multi-projects/.nvmrc.tmpl +0 -1
  61. package/templates/multi-projects/eslint.config.js.tmpl +0 -1
  62. package/templates/multi-projects/pnpm-workspace.yaml.tmpl +0 -5
  63. package/templates/single-project/.npmrc.tmpl +0 -8
  64. package/templates/single-project/.nvmrc.tmpl +0 -1
  65. package/templates/single-project/eslint.config.js.tmpl +0 -1
  66. package/templates/single-project/pnpm-workspace.yaml.tmpl +0 -4
  67. /package/templates/multi-projects/.changeset/{config.json.tmpl → config.json} +0 -0
  68. /package/templates/multi-projects/.changeset/{welcome.md.tmpl → welcome.md} +0 -0
  69. /package/templates/multi-projects/.github/{PULL_REQUEST_TEMPLATE.md.tmpl → PULL_REQUEST_TEMPLATE.md} +0 -0
  70. /package/templates/multi-projects/{CONTRIBUTING.md.tmpl → CONTRIBUTING.md} +0 -0
  71. /package/templates/multi-projects/{LICENSE.tmpl → LICENSE} +0 -0
  72. /package/templates/multi-projects/examples/default/{package.json.tmpl → package.json} +0 -0
  73. /package/templates/multi-projects/examples/default/src/{index.ts.tmpl → index.ts} +0 -0
  74. /package/templates/multi-projects/libraries/{{projectName}}/{README.md.tmpl → README.md} +0 -0
  75. /package/templates/single-project/.changeset/{config.json.tmpl → config.json} +0 -0
  76. /package/templates/single-project/.changeset/{welcome.md.tmpl → welcome.md} +0 -0
  77. /package/templates/single-project/.github/{PULL_REQUEST_TEMPLATE.md.tmpl → PULL_REQUEST_TEMPLATE.md} +0 -0
  78. /package/templates/single-project/{CONTRIBUTING.md.tmpl → CONTRIBUTING.md} +0 -0
  79. /package/templates/single-project/{LICENSE.tmpl → LICENSE} +0 -0
  80. /package/templates/single-project/examples/default/{package.json.tmpl → package.json} +0 -0
  81. /package/templates/single-project/examples/default/src/{index.ts.tmpl → index.ts} +0 -0
  82. /package/templates/single-project/{{projectName}}/{README.md.tmpl → README.md} +0 -0
package/dist/index.js CHANGED
@@ -1,820 +1,860 @@
1
- import { helpers, termost } from 'termost';
2
- import { createRequire } from 'node:module';
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
- import { fdir } from 'fdir';
1
+ import { helpers, termost } from "termost";
2
+ import { createRequire } from "node:module";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { chmod, cp, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
7
6
 
8
- const require$1 = createRequire(import.meta.url);
9
- function assert(expectedCondition, createError) {
10
- if (!expectedCondition) {
11
- throw createError();
12
- }
13
- }
7
+ //#region src/helpers.ts
8
+ const require = createRequire(import.meta.url);
9
+ const assert = (expectedCondition, createError) => {
10
+ if (!expectedCondition) throw createError();
11
+ };
14
12
  /**
15
- * Helper to format log messages with a welcoming bot.
16
- * @param input - Message factory.
17
- * @param input.title - Title input.
18
- * @param input.description - Description input.
19
- * @param input.body - Body input.
20
- * @param input.type - Message type.
21
- * @example
22
- * botMessage(
23
- * {
24
- * title: "Oops, an error occurred",
25
- * description:
26
- * "Keep calm and carry on with some coffee ☕️",
27
- * body: String(previousTaskError),
28
- * type: "error",
29
- * },
30
- * );
31
- */ const botMessage = (input)=>{
32
- const { type } = input;
33
- const log = type === "error" ? console.error : console.log;
34
- const colorByType = {
35
- error: "red",
36
- information: "blue",
37
- success: "green"
38
- };
39
- log(helpers.format(`
13
+ * Helper to format log messages with a welcoming bot.
14
+ *
15
+ * @example
16
+ * botMessage({
17
+ * title: "Oops, an error occurred",
18
+ * description: "Keep calm and carry on with some coffee ☕️",
19
+ * body: String(previousTaskError),
20
+ * type: "error",
21
+ * });
22
+ *
23
+ * @param input - Message factory.
24
+ * @param input.title - Title input.
25
+ * @param input.description - Description input.
26
+ * @param input.body - Body input.
27
+ * @param input.type - Message type.
28
+ */
29
+ const botMessage = (input) => {
30
+ const { type } = input;
31
+ const log = console[type === "error" ? "error" : "log"];
32
+ log(helpers.format(`
40
33
  ╭─────╮
41
34
  │ ◠ ◠ ${input.title}
42
35
  │ ${type === "error" ? "◠" : "◡"} │ ${input.description}
43
36
  ╰─────╯
44
37
  ${input.body ? `
45
38
  ${input.body}
46
- ` : ""}`, {
47
- color: colorByType[type]
48
- }));
39
+ ` : ""}`, { color: {
40
+ error: "red",
41
+ information: "blue",
42
+ success: "green"
43
+ }[type] }));
49
44
  };
50
45
  /**
51
- * Resolve a relative path to an absolute one resolved from the generated project root directory.
52
- * @param path - The relative path.
53
- * @returns The resolved absolute path.
54
- * @example
55
- * resolveFromProjectDirectory(".gitignore");
56
- */ const resolveFromProjectDirectory = (path)=>{
57
- return resolve(process.cwd(), path);
46
+ * Resolve a relative path to an absolute one resolved from the generated project root directory.
47
+ *
48
+ * @example
49
+ * resolveFromWorkingDirectory(".gitignore");
50
+ *
51
+ * @param path - The relative path.
52
+ * @returns The resolved absolute path.
53
+ */
54
+ const resolveFromWorkingDirectory = (...path) => {
55
+ return resolve(process.cwd(), ...path);
58
56
  };
59
57
  /**
60
- * Resolve a relative path to an absolute one resolved from the `stack` node module directory.
61
- * @param path - The relative path.
62
- * @returns The resolved absolute path.
63
- * @example
64
- * resolveFromStackDirectory("./templates");
65
- */ const resolveFromStackDirectory = (path)=>{
66
- return resolve(import.meta.dirname, "../", path);
67
- };
68
- const createError = (bin, error)=>{
69
- const errorMessage = `\`${bin}\` command failed.\n${String(error)}`;
70
- if (error instanceof Error) {
71
- error.message = errorMessage;
72
- return error;
73
- }
74
- return new Error(errorMessage);
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
- const getNpmVersion = async ()=>{
84
- try {
85
- return await helpers.exec("pnpm -v");
86
- } catch {
87
- throw createError("pnpm", "The project must use `pnpm` as a node package manager tool. Follow this installation guide https://pnpm.io/installation");
88
- }
89
- };
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(" ");
101
- };
102
- const hasDependency = (packageName)=>{
103
- return Boolean(require$1.resolve(packageName));
104
- };
105
- const setPackageManager = async ()=>{
106
- /**
107
- * Corepack is downloaded remotely to get always up-to-date npm registry fingerprints since they're hardcoded.
108
- * @see {@link https://github.com/nodejs/corepack/issues/613}
109
- */ return helpers.exec("npx corepack enable");
110
- };
111
- const request = {
112
- async get (url, responseType) {
113
- const response = await fetch(url);
114
- if (!response.ok) {
115
- throw createError("fetch", `Failed to fetch resources from ${url} (${JSON.stringify({
116
- status: response.status,
117
- statusText: response.statusText
118
- })})`);
119
- }
120
- return responseType === "text" ? response.text() : response.json();
121
- }
122
- };
123
- const eslint = (options)=>async (files = [])=>{
124
- let eslintFiles = [];
125
- if (files.length === 0) {
126
- eslintFiles.push(".");
127
- } else {
128
- eslintFiles = files.filter((file)=>{
129
- return ESLINT_EXTENSIONS.some((extension)=>file.endsWith(extension));
130
- });
131
- if (eslintFiles.length === 0) return;
132
- }
133
- const arguments_ = [
134
- ...eslintFiles,
135
- "--cache",
136
- `--cache-location ${resolveFromProjectDirectory("node_modules/.cache/.eslintcache")}`,
137
- "--no-error-on-unmatched-pattern"
138
- ];
139
- if (options.isFixMode) {
140
- arguments_.push("--fix");
141
- }
142
- try {
143
- return await helpers.exec(`eslint ${arguments_.join(" ")}`);
144
- } catch (error) {
145
- throw createError("eslint", error);
146
- }
147
- };
148
- const turbo = async (command, options = {})=>{
149
- try {
150
- const { excludeExamples = false, hasLiveOutput = true, ...restOptions } = options;
151
- return await helpers.exec(`turbo run ${command} ${excludeExamples ? "--filter !@examples/*" : ""}`, {
152
- ...restOptions,
153
- hasLiveOutput
154
- });
155
- } catch (error) {
156
- throw createError("turbo", error);
157
- }
158
- };
159
- const logCheckableFiles = (files)=>{
160
- if (files.length === 0) {
161
- helpers.message("The whole project will be checked.", {
162
- label: false,
163
- lineBreak: {
164
- end: true,
165
- start: false
166
- }
167
- });
168
- return;
169
- }
170
- helpers.message(files.join("\n "), {
171
- label: "Following files will be checked:",
172
- lineBreak: {
173
- end: true,
174
- start: false
175
- }
176
- });
177
- };
178
- const changeset = async (command)=>{
179
- try {
180
- return await helpers.exec(command, {
181
- hasLiveOutput: true
182
- });
183
- } catch (error) {
184
- throw createError("changeset", error);
185
- }
186
- };
187
- const ESLINT_EXTENSIONS = [
188
- "js",
189
- "jsx",
190
- "cjs",
191
- "mjs",
192
- "ts",
193
- "tsx",
194
- "cts",
195
- "mts",
196
- "md",
197
- "mdx"
198
- ];
58
+ * Resolve a relative path to an absolute one resolved from the `stack` node module directory.
59
+ *
60
+ * @example
61
+ * resolveFromPackageDirectory("./templates");
62
+ *
63
+ * @param path - The relative path.
64
+ * @returns The resolved absolute path.
65
+ */
66
+ const resolveFromPackageDirectory = (...path) => {
67
+ return resolve(import.meta.dirname, "../", ...path);
68
+ };
69
+ const createError = (bin, error) => {
70
+ const errorMessage = `\`${bin}\` command failed.\n${String(error)}`;
71
+ if (error instanceof Error) {
72
+ error.message = errorMessage;
73
+ return error;
74
+ }
75
+ return new Error(errorMessage);
76
+ };
77
+ const getPnpmVersion = async () => {
78
+ try {
79
+ return await helpers.exec("pnpm -v");
80
+ } catch {
81
+ throw createError("pnpm", "The project must use `pnpm` as a node package manager tool. Follow this installation guide https://pnpm.io/installation");
82
+ }
83
+ };
84
+ const getStackCommand = (command) => {
85
+ return `pnpm stack ${command}`;
86
+ };
87
+ const setPackageManager = async () => {
88
+ /**
89
+ * Corepack is downloaded remotely to get always up-to-date npm registry fingerprints since
90
+ * they're hardcoded.
91
+ *
92
+ * @see {@link https://github.com/nodejs/corepack/issues/613}
93
+ */
94
+ await helpers.exec("pnx corepack enable");
95
+ };
96
+ const request = { async get(url, responseType) {
97
+ const response = await fetch(url);
98
+ if (!response.ok) throw createError("fetch", `Failed to fetch resources from ${url} (${JSON.stringify({
99
+ status: response.status,
100
+ statusText: response.statusText
101
+ })})`);
102
+ return responseType === "text" ? response.text() : response.json();
103
+ } };
104
+ const oxlint = (options) => {
105
+ return async (files = []) => {
106
+ const args = [
107
+ ...files,
108
+ "--disable-nested-config",
109
+ "--no-error-on-unmatched-pattern"
110
+ ];
111
+ if (options.isFixMode) args.push("--fix-dangerously");
112
+ try {
113
+ return await helpers.exec(`oxlint ${args.join(" ")}`);
114
+ } catch (error) {
115
+ throw createError("oxlint", error instanceof Error ? error : new Error(String(error)));
116
+ }
117
+ };
118
+ };
119
+ const oxfmt = (options) => {
120
+ return async (files = []) => {
121
+ const args = [
122
+ ...files,
123
+ "--disable-nested-config",
124
+ "--no-error-on-unmatched-pattern",
125
+ options.isFixMode ? "--write" : "--check"
126
+ ];
127
+ try {
128
+ return await helpers.exec(`oxfmt ${args.join(" ")}`);
129
+ } catch (error) {
130
+ throw createError("oxfmt", error instanceof Error ? error : new Error(String(error)));
131
+ }
132
+ };
133
+ };
134
+ const turbo = async (command, options = {}) => {
135
+ try {
136
+ const { excludeExamples = false, hasLiveOutput = true, ...restOptions } = options;
137
+ return await helpers.exec(`turbo run ${command} ${excludeExamples ? "--filter !@examples/*" : ""}`, {
138
+ ...restOptions,
139
+ hasLiveOutput
140
+ });
141
+ } catch (error) {
142
+ throw createError("turbo", error instanceof Error ? error : new Error(String(error)));
143
+ }
144
+ };
145
+ const logCheckableFiles = (files) => {
146
+ if (files.length === 0) {
147
+ helpers.message("The whole project will be checked.", {
148
+ label: false,
149
+ lineBreak: {
150
+ end: true,
151
+ start: false
152
+ }
153
+ });
154
+ return;
155
+ }
156
+ helpers.message(files.join("\n "), {
157
+ label: "Following files will be checked:",
158
+ lineBreak: {
159
+ end: true,
160
+ start: false
161
+ }
162
+ });
163
+ };
164
+ const changeset = async (command) => {
165
+ try {
166
+ return await helpers.exec(command, { hasLiveOutput: true });
167
+ } catch (error) {
168
+ throw createError("changeset", error instanceof Error ? error : new Error(String(error)));
169
+ }
170
+ };
199
171
 
200
- const createBuildCommand = (program)=>{
201
- program.command({
202
- description: "Build the project in production mode",
203
- name: "build"
204
- }).task({
205
- async handler () {
206
- await turbo("build");
207
- }
208
- });
172
+ //#endregion
173
+ //#region src/commands/build.ts
174
+ const createBuildCommand = (program) => {
175
+ program.command({
176
+ name: "build",
177
+ description: "Build the project in production mode"
178
+ }).task({ async handler() {
179
+ await turbo("build");
180
+ } });
209
181
  };
210
182
 
211
- const checkCode = eslint({
212
- isFixMode: false
213
- });
183
+ //#endregion
184
+ //#region src/commands/check/checkCode.ts
185
+ const checkCode = oxlint({ isFixMode: false });
214
186
 
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
- }
187
+ //#endregion
188
+ //#region src/commands/check/checkCommit.ts
189
+ const checkCommit = async () => {
190
+ try {
191
+ return await helpers.exec("commitlint --extends \"@commitlint/config-conventional\" --edit");
192
+ } catch (error) {
193
+ throw createError("commitlint", error instanceof Error ? error : new Error(String(error)));
194
+ }
221
195
  };
222
196
 
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
- };
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
- }
246
- };
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
- }
309
- }
310
- };
311
- return (package_)=>{
312
- lint(package_, "development");
313
- lint(package_, "production");
314
- };
315
- };
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);
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("^");
331
-
332
- const checkType = async ()=>{
333
- try {
334
- return await helpers.exec("pnpm --parallel exec tsc --noEmit");
335
- } catch (error) {
336
- throw createError("tsc", error);
337
- }
197
+ //#endregion
198
+ //#region src/commands/check/checkDependency.ts
199
+ const checkDependency = async () => {
200
+ const stdout = await helpers.exec("pnpm recursive ls --json");
201
+ const checkDependencyVersionMismatch = createPackagesVersionMismatchChecker();
202
+ const packages = JSON.parse(stdout).map((pkg) => {
203
+ const packagePath = join(pkg.path, "package.json");
204
+ assert(pkg.name, () => {
205
+ return createPackageError(`\`${packagePath}\` must have a name field.`);
206
+ });
207
+ const packageContent = require(packagePath);
208
+ const peerDependencies = packageContent.peerDependencies ?? {};
209
+ const devDependencies = packageContent.devDependencies ?? {};
210
+ const dependencies = packageContent.dependencies ?? {};
211
+ return {
212
+ name: pkg.name,
213
+ dependencies,
214
+ devDependencies,
215
+ peerDependencies
216
+ };
217
+ });
218
+ for (const pkg of packages) {
219
+ checkDependencyVersionMismatch(pkg);
220
+ checkDependencyVersionRange(pkg);
221
+ }
222
+ };
223
+ const STARTING_WITH_DIGIT_REGEXP = /^\d/u;
224
+ const checkDependencyVersionRange = ({ name, dependencies, devDependencies, peerDependencies }) => {
225
+ for (const [dependencyName, version] of Object.entries(devDependencies)) {
226
+ assertVersion(version, {
227
+ name: dependencyName,
228
+ consumedBy: name
229
+ });
230
+ if (version !== "workspace:*" && !isExcluded(version) && !STARTING_WITH_DIGIT_REGEXP.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.`, {
231
+ name: dependencyName,
232
+ consumedBy: name
233
+ });
234
+ }
235
+ for (const [dependencyName, version] of Object.entries(dependencies)) {
236
+ assertVersion(version, {
237
+ name: dependencyName,
238
+ consumedBy: name
239
+ });
240
+ 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.`, {
241
+ name: dependencyName,
242
+ consumedBy: name
243
+ });
244
+ }
245
+ for (const [dependencyName, version] of Object.entries(peerDependencies)) {
246
+ assertVersion(version, {
247
+ name: dependencyName,
248
+ consumedBy: name
249
+ });
250
+ if (!hasCaret(version) && !isExcluded(version)) 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.`, {
251
+ name: dependencyName,
252
+ consumedBy: name
253
+ });
254
+ }
255
+ };
256
+ const createPackagesVersionMismatchChecker = () => {
257
+ const monorepoDependencies = /* @__PURE__ */ new Map();
258
+ const monorepoDevelopmentDependencies = /* @__PURE__ */ new Map();
259
+ const lint = (pkg, type) => {
260
+ const packageName = pkg.name;
261
+ const isDevelopment = type === "development";
262
+ const store = isDevelopment ? monorepoDevelopmentDependencies : monorepoDependencies;
263
+ const dependencies = pkg[isDevelopment ? "devDependencies" : "dependencies"];
264
+ for (const [dependencyName, dependencyVersion] of Object.entries(dependencies)) {
265
+ if (!dependencyVersion) continue;
266
+ const storedVersion = store.get(dependencyName);
267
+ if (!storedVersion) {
268
+ store.set(dependencyName, dependencyVersion);
269
+ continue;
270
+ }
271
+ if (!(dependencyVersion === storedVersion)) throw createPackageError(`Mismatched versions: received version \`${dependencyVersion}\` 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 \`${dependencyVersion}\`).`, {
272
+ name: dependencyName,
273
+ consumedBy: packageName
274
+ });
275
+ }
276
+ };
277
+ return (pkg) => {
278
+ lint(pkg, "development");
279
+ lint(pkg, "production");
280
+ };
281
+ };
282
+ const createPackageError = (message, context) => {
283
+ return createError("stack check", context ? `\`${context.name}\` consumed by \`${context.consumedBy}\` doesn't conform to package guidelines.\n${message}` : message);
284
+ };
285
+ const assertVersion = (version, { name, consumedBy }) => {
286
+ assert(version, () => {
287
+ return createPackageError(`\`${name}\` must have a valid version specified (current version equals to \`${String(version)}\`).`, {
288
+ name,
289
+ consumedBy
290
+ });
291
+ });
292
+ };
293
+ const PRERELEASE_VERSION_REGEXP = /\d+\.\d+\.\d+-(alpha|beta|experimental|next|rc).*/u;
294
+ const isExcluded = (version) => {
295
+ const isPreReleaseVersion = PRERELEASE_VERSION_REGEXP.exec(version);
296
+ return version.startsWith("npm:") || isPreReleaseVersion;
297
+ };
298
+ const hasCaret = (version) => {
299
+ return version.startsWith("^");
338
300
  };
339
301
 
302
+ //#endregion
303
+ //#region src/commands/check/checkFormatting.ts
304
+ const checkFormatting = oxfmt({ isFixMode: false });
305
+
306
+ //#endregion
307
+ //#region src/commands/check/check.ts
340
308
  const ONLY_VALUES = [
341
- "commit",
342
- "code",
343
- "dependency",
344
- "type"
309
+ "commit",
310
+ "code",
311
+ "dependency",
312
+ "formatting"
345
313
  ];
346
- const createCheckCommand = (program)=>{
347
- program.command({
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"
355
- }).task({
356
- handler (_, argv) {
357
- logCheckableFiles(argv.operands);
358
- },
359
- skip: ifFilterDefinedAndNotEqualTo("code")
360
- }).task({
361
- async handler () {
362
- await turbo("build", {
363
- excludeExamples: true,
364
- hasLiveOutput: false
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
370
- }
371
- }).task({
372
- async handler () {
373
- await checkDependency();
374
- },
375
- label: label$4("Check dependency compliance"),
376
- skip: ifFilterDefinedAndNotEqualTo("dependency")
377
- }).task({
378
- async handler (_, argv) {
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;
394
- }
395
- }).task({
396
- async handler () {
397
- await checkCommit();
398
- },
399
- label: label$4("Check commit compliance"),
400
- skip (context) {
401
- return context.filter !== "commit";
402
- }
403
- });
404
- };
405
- const label$4 = (message)=>`${message} 🧐`;
406
- const ifFilterDefinedAndNotEqualTo = (filter)=>(context)=>{
407
- return context.filter !== undefined && context.filter !== filter;
408
- };
314
+ const createCheckCommand = (program) => {
315
+ program.command({
316
+ name: "check",
317
+ description: "Check code health (static analysis)"
318
+ }).option({
319
+ key: "filter",
320
+ name: "filter",
321
+ description: `Filter the compliance check to run (accepted value: ${ONLY_VALUES.join(", ")})`,
322
+ defaultValue: void 0
323
+ }).task({
324
+ handler(_, argv) {
325
+ logCheckableFiles(argv.operands);
326
+ },
327
+ skip: ifFilterDefinedAndNotEqualTo("code")
328
+ }).task({
329
+ label: label$4("Prepare the project"),
330
+ async handler() {
331
+ await turbo("build", {
332
+ excludeExamples: true,
333
+ hasLiveOutput: false
334
+ });
335
+ },
336
+ skip({ filter }) {
337
+ return filter === "commit";
338
+ }
339
+ }).task({
340
+ label: label$4("Check dependency compliance"),
341
+ async handler() {
342
+ await checkDependency();
343
+ },
344
+ skip: ifFilterDefinedAndNotEqualTo("dependency")
345
+ }).task({
346
+ label: label$4("Check formatting compliance"),
347
+ async handler(_, argv) {
348
+ const filenames = argv.operands;
349
+ await checkFormatting(filenames);
350
+ },
351
+ skip: ifFilterDefinedAndNotEqualTo("formatting")
352
+ }).task({
353
+ label: label$4("Check code compliance"),
354
+ async handler(_, argv) {
355
+ const filenames = argv.operands;
356
+ await checkCode(filenames);
357
+ },
358
+ skip: ifFilterDefinedAndNotEqualTo("code")
359
+ }).task({
360
+ label: label$4("Check commit compliance"),
361
+ async handler() {
362
+ await checkCommit();
363
+ },
364
+ skip(context) {
365
+ return context.filter !== "commit";
366
+ }
367
+ });
368
+ };
369
+ const label$4 = (message) => {
370
+ return `${message} 🧐`;
371
+ };
372
+ const ifFilterDefinedAndNotEqualTo = (filter) => {
373
+ return (context) => {
374
+ return context.filter !== void 0 && context.filter !== filter;
375
+ };
376
+ };
409
377
 
410
- const createCleanCommand = (program)=>{
411
- program.command({
412
- description: "Clean the project",
413
- name: "clean"
414
- }).task({
415
- async handler () {
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
- ];
378
+ //#endregion
379
+ //#region src/commands/clean.ts
380
+ const createCleanCommand = (program) => {
381
+ program.command({
382
+ name: "clean",
383
+ description: "Clean the project"
384
+ }).task({
385
+ key: "files",
386
+ label: label$3("Retrieve removable files"),
387
+ async handler() {
388
+ const cachePath = "node_modules/.cache";
389
+ const files = await retrieveIgnoredFiles();
390
+ if (isDirectoryExistAndNotEmpty(resolveFromWorkingDirectory(cachePath))) files.push(cachePath);
391
+ return files;
392
+ }
393
+ }).task({
394
+ label({ files }) {
395
+ return files.length > 0 ? label$3("Clean assets") : "Already clean ✨";
396
+ },
397
+ async handler({ files }) {
398
+ if (files.length === 0) return;
399
+ await cleanFiles(files);
400
+ }
401
+ }).task({
402
+ handler({ files }) {
403
+ helpers.message(files.join("\n "), {
404
+ label: "Removed assets",
405
+ lineBreak: {
406
+ end: false,
407
+ start: true
408
+ },
409
+ type: "information"
410
+ });
411
+ },
412
+ skip({ files }) {
413
+ return files.length === 0;
414
+ }
415
+ });
416
+ };
417
+ const label$3 = (message) => {
418
+ return `${message} 🧹`;
419
+ };
420
+ const cleanFiles = async (files) => {
421
+ await Promise.all(files.map(async (file) => {
422
+ await rm(file, {
423
+ force: true,
424
+ recursive: true
425
+ });
426
+ }));
427
+ };
428
+ const isDirectoryExistAndNotEmpty = (path) => {
429
+ return existsSync(path) && readdirSync(path).length > 0;
430
+ };
431
+ const LINEBREAK_REGEXP = /\n|\r\n/u;
432
+ const retrieveIgnoredFiles = async () => {
433
+ return (await helpers.exec("git clean -fdXn")).split(LINEBREAK_REGEXP).filter((cleanOutput) => {
434
+ return PRESERVE_FILES.every((excludedFile) => {
435
+ return !cleanOutput.includes(excludedFile);
436
+ });
437
+ }).map((cleanOutput) => {
438
+ return cleanOutput.split(" ").at(-1) ?? "";
439
+ }).filter(Boolean);
440
+ };
441
+ const PRESERVE_FILES = ["node_modules"];
466
442
 
467
- var version = "0.0.0-next-5dd260c";
443
+ //#endregion
444
+ //#region package.json
445
+ var version = "0.0.0-next.d8e717b";
468
446
 
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?",
490
- type: "text"
491
- }).input({
492
- key: "inputDescription",
493
- label: "How would you describe it?",
494
- type: "text"
495
- }).input({
496
- defaultValue: "git@github.com:adbayb/xxx.git",
497
- key: "inputUrl",
498
- label: "Where will it be stored? (Git remote URL)",
499
- type: "text"
500
- }).input({
501
- defaultValue: "single-project",
502
- key: "inputTemplate",
503
- label: "Which template you would like to apply?",
504
- options: [
505
- "single-project",
506
- "multi-projects"
507
- ],
508
- type: "select"
509
- }).task({
510
- async handler ({ inputDescription, inputName, inputUrl }) {
511
- if (!inputName) {
512
- throw createError("stack create", "The project name is not optional. Make sure to provide a valid value (non-empty string).");
513
- }
514
- const { repoName, repoOwner } = (inputUrl.startsWith("git") ? /^git@.*:(?<repoOwner>.*)\/(?<repoName>.*)\.git$/ : /^https?:\/\/.*\/(?<repoOwner>.*)\/(?<repoName>.*)\.git$/).exec(inputUrl)?.groups ?? {};
515
- if (!repoOwner || !repoName) {
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.");
517
- }
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");
520
- return {
521
- licenseYear: new Date().getFullYear().toString(),
522
- nodeVersion: nodeVersion.replace("v", ""),
523
- npmVersion: String(npmVersion),
524
- projectDescription: inputDescription.charAt(0).toUpperCase() + inputDescription.slice(1),
525
- projectName: inputName.toLowerCase(),
526
- projectUrl: inputUrl,
527
- repoId: `${repoOwner}/${repoName}`
528
- };
529
- },
530
- key: "data",
531
- label: label$2("Check and format input")
532
- }).task({
533
- async handler ({ data }) {
534
- const projectPath = resolve(process.cwd(), data.projectName);
535
- await mkdir(projectPath);
536
- process.chdir(projectPath);
537
- },
538
- label ({ data }) {
539
- return label$2(`Create \`${data.projectName}\` folder`);
540
- }
541
- }).task({
542
- async handler ({ data }) {
543
- await helpers.exec("git init");
544
- await helpers.exec(`git remote add origin ${data.projectUrl}`);
545
- },
546
- label: label$2("Initialize `git`")
547
- }).task({
548
- handler ({ data, inputTemplate }) {
549
- applyTemplate(inputTemplate, data);
550
- },
551
- label: label$2("Apply template")
552
- }).task({
553
- async handler ({ data: { projectName }, inputTemplate }) {
554
- await symlink(join(inputTemplate === "single-project" ? projectName : join("libraries", projectName), "README.md"), "./README.md");
555
- },
556
- label: label$2("Create a symlink to `README.md` file")
557
- }).task({
558
- async handler () {
559
- await setPackageManager();
560
- },
561
- label: label$2("Set up the package manager")
562
- }).task({
563
- async handler ({ data }) {
564
- const localDevelopmentDependencies = [
565
- "quickbundle",
566
- "vitest"
567
- ];
568
- const globalDevelopmentDependencies = [
569
- "@adbayb/stack"
570
- ];
571
- try {
572
- await helpers.exec(`pnpm add ${globalDevelopmentDependencies.join(" ")} --save-dev --ignore-workspace-root-check`);
573
- await helpers.exec(`pnpm add ${localDevelopmentDependencies.join(" ")} --save-dev --filter ${data.projectName}`);
574
- await helpers.exec("pnpm install");
575
- } catch (error) {
576
- throw createError("pnpm", error);
577
- }
578
- },
579
- label: label$2("Install dependencies")
580
- }).task({
581
- async handler () {
582
- await helpers.exec("stack install");
583
- },
584
- label: label$2("Run `stack install`")
585
- }).task({
586
- async handler () {
587
- await helpers.exec("git add -A");
588
- await helpers.exec('git commit -m "chore: initial commit"');
589
- },
590
- label: label$2("Commit")
591
- }).task({
592
- handler ({ data }) {
593
- botMessage({
594
- description: `Run \`cd ./${data.projectName}\` and Enjoy 🚀`,
595
- title: "The project was successfully created",
596
- type: "success"
597
- });
598
- }
599
- });
600
- };
601
- const label$2 = (message)=>`${message} 🔨`;
602
- /**
603
- * A simple template engine to evaluate dynamic expressions and apply side effets (such as hydrating a content with values from an input object) on impacted template files.
604
- * @param template - The selected template.
605
- * @param dataModel - Data model mapping the template expression key with its corresponding value.
606
- * @example
607
- * applyTemplate(
608
- * { toReplace: "value" },
609
- * );
610
- */ const applyTemplate = (template, dataModel)=>{
611
- const templateExtension = ".tmpl";
612
- const templateRootPath = resolveFromStackDirectory(join("./templates", template));
613
- const projectRootPath = resolveFromProjectDirectory("./");
614
- const templateExpressionRegExp = /{{(.*?)}}/g;
615
- const evaluate = (content)=>{
616
- return content.replaceAll(templateExpressionRegExp, (_, key)=>dataModel[key] || "");
617
- };
618
- /** Copy the template before mutations. */ cpSync(templateRootPath, projectRootPath, {
619
- force: true,
620
- recursive: true
621
- });
622
- /** Template file mutations. */ new fdir().withBasePath().glob(`**/*${templateExtension}`).crawl(projectRootPath).sync().forEach((templateFilePath)=>{
623
- const projectFilePath = templateFilePath.slice(0, templateFilePath.lastIndexOf(templateExtension));
624
- const content = evaluate(readFileSync(templateFilePath, "utf8"));
625
- renameSync(templateFilePath, projectFilePath);
626
- writeFileSync(projectFilePath, content, "utf8");
627
- });
628
- /** Template folder mutations. */ new fdir().withBasePath().onlyDirs().filter((path)=>{
629
- return templateExpressionRegExp.test(path);
630
- }).crawl(projectRootPath).sync()// Re-order from longest to lowest path length to apply first renaming operations on deepest file structure
631
- .toSorted((a, b)=>b.length - a.length).forEach((templateFolderPath)=>{
632
- const newPath = templateFolderPath.replaceAll(templateExpressionRegExp, (_, dataModelKey)=>{
633
- return dataModel[dataModelKey];
634
- });
635
- renameSync(templateFolderPath, newPath);
636
- });
447
+ //#endregion
448
+ //#region src/commands/create.ts
449
+ const REPOSITORY_REGEXP = /^(?:git@.*:|https?:\/\/.*\/)(?<repoOwner>[^/]+)\/(?<repoName>[^/]+)\.git$/u;
450
+ const createCreateCommand = (program) => {
451
+ program.command({
452
+ name: "create",
453
+ description: "Scaffold a new project"
454
+ }).task({ handler() {
455
+ botMessage({
456
+ title: `I'm Stack v${version} 👋`,
457
+ description: "I can guarantee you a project creation in under 1 minute 🚀",
458
+ type: "information"
459
+ });
460
+ } }).task({
461
+ label: label$2("Check pre-requisites"),
462
+ async handler() {
463
+ await getPnpmVersion();
464
+ }
465
+ }).input({
466
+ key: "inputName",
467
+ label: "What's your project name?",
468
+ type: "text"
469
+ }).input({
470
+ key: "inputDescription",
471
+ label: "How would you describe it?",
472
+ type: "text"
473
+ }).input({
474
+ key: "inputUrl",
475
+ label: "Where will it be stored? (Git remote URL)",
476
+ defaultValue: "git@github.com:adbayb/xxx.git",
477
+ type: "text"
478
+ }).input({
479
+ key: "inputTemplate",
480
+ label: "Which template you would like to apply?",
481
+ defaultValue: "single-project",
482
+ options: ["single-project", "multi-projects"],
483
+ type: "select"
484
+ }).task({
485
+ key: "data",
486
+ label: label$2("Check and format input"),
487
+ async handler({ inputDescription, inputName, inputTemplate, inputUrl }) {
488
+ if (!inputName) throw createError("stack create", "The project name is not optional. Make sure to provide a valid value (non-empty string).");
489
+ const { repoName, repoOwner } = REPOSITORY_REGEXP.exec(inputUrl)?.groups ?? {};
490
+ if (!repoOwner || !repoName) 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.");
491
+ const nodeVersion = await request.get("https://resolve-node.vercel.app/lts", "text");
492
+ const { version: pnpmVersion } = await request.get("https://registry.npmjs.org/pnpm/latest", "json");
493
+ const projectName = slugify(inputName);
494
+ return {
495
+ licenseYear: (/* @__PURE__ */ new Date()).getFullYear().toString(),
496
+ nodeVersion: `^${Number(nodeVersion.replace("v", "").split(".")[0])}.0.0`,
497
+ pnpmVersion: String(pnpmVersion),
498
+ projectDescription: toCapitalLetter(inputDescription),
499
+ projectName,
500
+ projectUrl: inputUrl,
501
+ repoId: `${repoOwner}/${repoName}`,
502
+ templatePath: resolveFromPackageDirectory("templates", inputTemplate),
503
+ workingPath: resolveFromWorkingDirectory(projectName)
504
+ };
505
+ }
506
+ }).input({
507
+ key: "canRemoveExistingDirectoryInput",
508
+ label({ data: { projectName } }) {
509
+ return label$2(`\`${projectName}\` directory already exists, do you want to remove it?`);
510
+ },
511
+ defaultValue: true,
512
+ skip({ data: { workingPath } }) {
513
+ return !existsSync(workingPath);
514
+ },
515
+ type: "confirm",
516
+ validate({ canRemoveExistingDirectoryInput, data: { projectName } }) {
517
+ if (canRemoveExistingDirectoryInput) return;
518
+ return createError("mkdir", `Remove or rename the \`${projectName}\` existing directory to apply the template from a clean state.`);
519
+ }
520
+ }).task({
521
+ key: "templateEngine",
522
+ label({ data: { projectName }, inputTemplate }) {
523
+ return label$2(`Copy \`${inputTemplate}\` template to \`${projectName}\` directory`);
524
+ },
525
+ async handler({ canRemoveExistingDirectoryInput, data: { licenseYear, nodeVersion, pnpmVersion, projectDescription, projectName, projectUrl, repoId, templatePath, workingPath }, inputTemplate }) {
526
+ if (canRemoveExistingDirectoryInput) await rm(workingPath, {
527
+ force: true,
528
+ recursive: true
529
+ });
530
+ return createTemplateEngine(workingPath, {
531
+ projectName,
532
+ templateModel: {
533
+ licenseYear,
534
+ nodeVersion,
535
+ pnpmVersion,
536
+ projectDescription,
537
+ projectName,
538
+ projectUrl,
539
+ repoId
540
+ },
541
+ templateName: inputTemplate,
542
+ templatePath
543
+ });
544
+ }
545
+ }).task({
546
+ label() {
547
+ return label$2("Process template");
548
+ },
549
+ async handler({ templateEngine }) {
550
+ await templateEngine.processContents();
551
+ await templateEngine.processPaths();
552
+ }
553
+ }).task({
554
+ label: label$2("Initialize `git`"),
555
+ async handler({ data: { projectUrl } }) {
556
+ await helpers.exec("git init");
557
+ await helpers.exec(`git remote add origin ${projectUrl}`);
558
+ }
559
+ }).task({
560
+ label: label$2("Set up the package manager"),
561
+ async handler() {
562
+ await setPackageManager();
563
+ }
564
+ }).task({
565
+ label: label$2("Install dependencies"),
566
+ async handler({ data: { projectName } }) {
567
+ const localDevelopmentDependencies = ["quickbundle", "vitest"];
568
+ const globalDevelopmentDependencies = ["@adbayb/stack"];
569
+ try {
570
+ await helpers.exec(`pnpm add ${globalDevelopmentDependencies.join(" ")} --save-dev --ignore-workspace-root-check`);
571
+ await helpers.exec(`pnpm add ${localDevelopmentDependencies.join(" ")} --save-dev --filter ${projectName}`);
572
+ await helpers.exec("pnpm install");
573
+ } catch (error) {
574
+ throw createError("pnpm", error instanceof Error ? error : new Error(String(error)));
575
+ }
576
+ }
577
+ }).task({
578
+ label: label$2("Run `stack install`"),
579
+ async handler() {
580
+ await helpers.exec("stack install");
581
+ }
582
+ }).task({
583
+ label: label$2("Commit"),
584
+ async handler() {
585
+ await helpers.exec("git add -A");
586
+ await helpers.exec("git commit -m \"chore: initial commit\"");
587
+ }
588
+ }).task({ handler({ data: { projectName } }) {
589
+ botMessage({
590
+ title: "The project was successfully created",
591
+ description: `Run \`cd ./${projectName}\` and Enjoy 🚀`,
592
+ type: "success"
593
+ });
594
+ } });
595
+ };
596
+ const label$2 = (message) => {
597
+ return `${message} 🔨`;
598
+ };
599
+ const slugify = (input) => {
600
+ return input.toLowerCase().replaceAll(/[^a-z0-9\s-]/gu, "").trim().replaceAll(/\s+/gu, "-").replaceAll(/-+/gu, "-");
601
+ };
602
+ const toCapitalLetter = (input) => {
603
+ return input.charAt(0).toUpperCase() + input.slice(1);
604
+ };
605
+ const createTemplateEngine = async (workingPath, { projectName, templateModel, templateName, templatePath }) => {
606
+ if (!existsSync(workingPath)) await mkdir(workingPath);
607
+ await cp(templatePath, workingPath, {
608
+ force: true,
609
+ recursive: true
610
+ });
611
+ const gitignoreFile = join(workingPath, ".gitignore.tmpl");
612
+ if (existsSync(gitignoreFile)) await rename(gitignoreFile, join(workingPath, ".gitignore"));
613
+ const templateEntries = await getTemplateEntries(workingPath);
614
+ process.chdir(workingPath);
615
+ return {
616
+ async processContents() {
617
+ await Promise.all(templateEntries.filter(({ type }) => {
618
+ return type === "content";
619
+ }).map(async (entry) => {
620
+ await writeFile(entry.path, setTemplateVariables(entry, templateModel));
621
+ }));
622
+ },
623
+ async processPaths() {
624
+ const sortedDirectoryEntries = templateEntries.filter(({ content, type }) => {
625
+ if (type === "path.directory" || type === "path.file") return hasTemplateVariable(basename(content));
626
+ return false;
627
+ }).toSorted(({ path: pathA }, { path: pathB }) => {
628
+ return pathB.length - pathA.length;
629
+ });
630
+ for (const entry of sortedDirectoryEntries) {
631
+ const newPath = setTemplateVariables(entry, templateModel);
632
+ await rename(entry.path, newPath);
633
+ }
634
+ await symlink(join(templateName === "single-project" ? projectName : join("libraries", projectName), "README.md"), "./README.md");
635
+ }
636
+ };
637
+ };
638
+ const getTemplateEntries = async (path) => {
639
+ const entries = await readdir(path, {
640
+ recursive: true,
641
+ withFileTypes: true
642
+ });
643
+ return (await Promise.all(entries.map(async (entry) => {
644
+ const isDirectory = entry.isDirectory();
645
+ if (!isDirectory && !entry.isFile()) return [];
646
+ const entryPath = resolve(entry.parentPath, entry.name);
647
+ return (isDirectory ? [{
648
+ content: entryPath,
649
+ type: "path.directory"
650
+ }] : [{
651
+ content: entryPath,
652
+ type: "path.file"
653
+ }, {
654
+ content: await readFile(entryPath, "utf8"),
655
+ type: "content"
656
+ }]).map(({ content, type }) => {
657
+ if (!hasTemplateVariable(content)) return;
658
+ return {
659
+ content,
660
+ path: entryPath,
661
+ type
662
+ };
663
+ }).filter((input) => {
664
+ return Boolean(input);
665
+ });
666
+ }))).flat();
667
+ };
668
+ const setTemplateVariables = (entry, model) => {
669
+ return entry.content.replaceAll(TEMPLATE_VARIABLE_MATCHER, (match, dataModelKey) => {
670
+ return model[dataModelKey] ?? match;
671
+ });
672
+ };
673
+ const TEMPLATE_VARIABLE_MATCHER = /\{\{(.*?)\}\}/giu;
674
+ const hasTemplateVariable = (input) => {
675
+ /**
676
+ * TemplateVariableMatcher.test() is not used since the `RegExp` is stateful when the global is
677
+ * used leading to some unstable results (relying on latest `lastIndex` set (lastIndex specifies
678
+ * the index at which to start the next match)). String.search is stateless.
679
+ *
680
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test MDN documentation}.
681
+ */
682
+ return input.search(TEMPLATE_VARIABLE_MATCHER) >= 0;
637
683
  };
638
684
 
639
- const PRETTIER_IGNORE_FILES = [
640
- "pnpm-lock.yaml"
641
- ];
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));
649
- });
650
- if (prettierFiles.length === 0) return;
651
- }
652
- const arguments_ = [
653
- ...prettierFiles
654
- ];
655
- if (existsSync(resolveFromProjectDirectory(".gitignore"))) {
656
- arguments_.push("--ignore-path .gitignore");
657
- }
658
- arguments_.push("--write", "--ignore-unknown", "--no-error-on-unmatched-pattern");
659
- try {
660
- return await helpers.exec(`prettier ${arguments_.join(" ")}`);
661
- } catch (error) {
662
- throw createError("prettier", error);
663
- }
664
- };
685
+ //#endregion
686
+ //#region src/commands/fix/fixCode.ts
687
+ const fixCode = oxlint({ isFixMode: true });
665
688
 
666
- const fixLinter = eslint({
667
- isFixMode: true
668
- });
689
+ //#endregion
690
+ //#region src/commands/fix/fixFormatting.ts
691
+ const fixFormatting = oxfmt({ isFixMode: true });
669
692
 
670
- const createFixCommand = (program)=>{
671
- program.command({
672
- description: "Fix auto-fixable issues",
673
- name: "fix"
674
- }).task({
675
- handler (_, argv) {
676
- logCheckableFiles(argv.operands);
677
- }
678
- }).task({
679
- async handler () {
680
- await turbo("build", {
681
- excludeExamples: true,
682
- hasLiveOutput: false
683
- });
684
- },
685
- label: label$1("Prepare the project")
686
- }).task({
687
- async handler (_, argv) {
688
- await fixLinter(argv.operands);
689
- },
690
- label: label$1("Fix linter issues")
691
- }).task({
692
- async handler (_, argv) {
693
- await fixFormatting(argv.operands);
694
- },
695
- label: label$1("Fix formatting issues")
696
- });
697
- };
698
- const label$1 = (message)=>`${message} 🚑`;
693
+ //#endregion
694
+ //#region src/commands/fix/fix.ts
695
+ const createFixCommand = (program) => {
696
+ program.command({
697
+ name: "fix",
698
+ description: "Fix auto-fixable issues"
699
+ }).task({ handler(_, argv) {
700
+ logCheckableFiles(argv.operands);
701
+ } }).task({
702
+ label: label$1("Prepare the project"),
703
+ async handler() {
704
+ await turbo("build", {
705
+ excludeExamples: true,
706
+ hasLiveOutput: false
707
+ });
708
+ }
709
+ }).task({
710
+ label: label$1("Fix formatting issues"),
711
+ async handler(_, argv) {
712
+ await fixFormatting(argv.operands);
713
+ }
714
+ }).task({
715
+ label: label$1("Fix code issues"),
716
+ async handler(_, argv) {
717
+ await fixCode(argv.operands);
718
+ }
719
+ });
720
+ };
721
+ const label$1 = (message) => {
722
+ return `${message} 🚑`;
723
+ };
699
724
 
700
- const createInstallCommand = (program)=>{
701
- program.command({
702
- description: "Install required setup",
703
- name: "install"
704
- }).task({
705
- async handler () {
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`);
709
- },
710
- label: label("Install `git.pre-commit` hook")
711
- }).task({
712
- async handler () {
713
- await installGitHook("commit-msg", getStackCommand("check --filter commit", false));
714
- },
715
- label: label("Install `git.commit-msg` hook")
716
- }).task({
717
- async handler () {
718
- await checkPackageManager();
719
- },
720
- label: label("Check the package manager")
721
- });
722
- };
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");
725
+ //#endregion
726
+ //#region src/commands/install.ts
727
+ const createInstallCommand = (program) => {
728
+ program.command({
729
+ name: "install",
730
+ description: "Install required setup"
731
+ }).task({
732
+ label: label("Install `git.pre-commit` hook"),
733
+ async handler() {
734
+ const lineBreakMatcher = String.raw`\n|\r\n`;
735
+ 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(" "))')`);
736
+ await installGitHook("pre-commit", `${stackCommand} && git add -A`);
737
+ }
738
+ }).task({
739
+ label: label("Install `git.commit-msg` hook"),
740
+ async handler() {
741
+ await installGitHook("commit-msg", getStackCommand("check --filter commit"));
742
+ }
743
+ });
744
+ };
745
+ const label = (message) => {
746
+ return `${message} 📲`;
747
+ };
748
+ const installGitHook = async (hook, content) => {
749
+ const filename = resolveFromWorkingDirectory(`.git/hooks/${hook}`);
750
+ await writeFile(filename, content);
751
+ return chmod(filename, "0755");
728
752
  };
729
753
 
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];
768
- };
754
+ //#endregion
755
+ //#region src/commands/release.ts
756
+ const createReleaseCommand = (program) => {
757
+ program.command({
758
+ name: "release",
759
+ description: "Log, version, and publish package(s)"
760
+ }).option({
761
+ key: "log",
762
+ name: "log",
763
+ description: "Add a new changelog entry"
764
+ }).option({
765
+ key: "emptyLog",
766
+ name: "empty-log",
767
+ description: "Add an empty changelog entry"
768
+ }).option({
769
+ key: "tag",
770
+ name: "tag",
771
+ description: "Bump the package(s) version"
772
+ }).option({
773
+ key: "publish",
774
+ name: "publish",
775
+ description: "Publish package(s) to the registry"
776
+ }).task({
777
+ async handler() {
778
+ helpers.message("New changelog entry\n");
779
+ await changeset("changeset");
780
+ },
781
+ skip: ifNotEqualTo("log")
782
+ }).task({
783
+ async handler() {
784
+ helpers.message("New empty changelog entry\n");
785
+ await changeset("changeset --empty");
786
+ },
787
+ skip: ifNotEqualTo("emptyLog")
788
+ }).task({
789
+ async handler() {
790
+ helpers.message("Bumping the package(s) version\n");
791
+ await changeset("changeset version");
792
+ },
793
+ skip: ifNotEqualTo("tag")
794
+ }).task({
795
+ async handler() {
796
+ helpers.message("Publishing package(s) to the registry\n");
797
+ await changeset("stack build && changeset publish");
798
+ },
799
+ skip: ifNotEqualTo("publish")
800
+ });
801
+ };
802
+ const ifNotEqualTo = (validOption) => {
803
+ return (context) => {
804
+ return !context[validOption];
805
+ };
806
+ };
769
807
 
770
- const createStartCommand = (program)=>{
771
- program.command({
772
- description: "Start the project in production mode",
773
- name: "start"
774
- }).task({
775
- async handler () {
776
- await turbo("start");
777
- }
778
- });
808
+ //#endregion
809
+ //#region src/commands/start.ts
810
+ const createStartCommand = (program) => {
811
+ program.command({
812
+ name: "start",
813
+ description: "Start the project in production mode"
814
+ }).task({ async handler() {
815
+ await turbo("start");
816
+ } });
779
817
  };
780
818
 
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
- });
819
+ //#endregion
820
+ //#region src/commands/test.ts
821
+ const createTestCommand = (program) => {
822
+ program.command({
823
+ name: "test",
824
+ description: "Test the code execution"
825
+ }).task({ async handler() {
826
+ await turbo("test");
827
+ } });
790
828
  };
791
829
 
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
- }
800
- });
830
+ //#endregion
831
+ //#region src/commands/watch.ts
832
+ const createWatchCommand = (program) => {
833
+ program.command({
834
+ name: "watch",
835
+ description: "Build and start the project in development mode"
836
+ }).task({ async handler() {
837
+ await turbo("watch");
838
+ } });
801
839
  };
802
840
 
803
- const createProgram = (...commandFactories)=>{
804
- const program = termost({
805
- description: "Toolbox to easily scaffold and maintain a project",
806
- name: "stack",
807
- onException () {
808
- botMessage({
809
- description: "Keep calm and carry on with some coffee ☕️",
810
- title: "Oops, an error occurred",
811
- type: "error"
812
- });
813
- },
814
- version: version
815
- });
816
- for (const commandBuilder of commandFactories){
817
- commandBuilder(program);
818
- }
841
+ //#endregion
842
+ //#region src/index.ts
843
+ const createProgram = (...commandFactories) => {
844
+ const program = termost({
845
+ name: "stack",
846
+ description: "Toolbox to easily scaffold and maintain a project",
847
+ onException() {
848
+ botMessage({
849
+ title: "Oops, an error occurred",
850
+ description: "Keep calm and carry on with some coffee ☕️",
851
+ type: "error"
852
+ });
853
+ },
854
+ version
855
+ });
856
+ for (const commandBuilder of commandFactories) commandBuilder(program);
819
857
  };
820
858
  createProgram(createCreateCommand, createInstallCommand, createCleanCommand, createCheckCommand, createFixCommand, createStartCommand, createBuildCommand, createWatchCommand, createTestCommand, createReleaseCommand);
859
+
860
+ //#endregion