@lakutata/cli 2.17.1 → 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +455 -28
  2. package/dist/esm/CLIApp.js +6 -0
  3. package/dist/esm/Config.js +53 -0
  4. package/dist/esm/controllers/CommandLineController.js +180 -0
  5. package/dist/esm/controllers/I18nController.js +67 -0
  6. package/dist/esm/controllers/MigrationController.js +66 -0
  7. package/dist/esm/controllers/NativeController.js +41 -0
  8. package/dist/esm/controllers/PackController.js +88 -0
  9. package/dist/esm/controllers/WorkspaceController.js +91 -0
  10. package/dist/esm/dtos/AddPackageDTO.js +25 -0
  11. package/dist/esm/dtos/BundleDTO.js +29 -0
  12. package/dist/esm/dtos/CleanDTO.js +17 -0
  13. package/dist/esm/dtos/CreateProjectDTO.js +61 -0
  14. package/dist/esm/dtos/ExecutableDTO.js +37 -0
  15. package/dist/esm/dtos/I18nDTO.js +41 -0
  16. package/dist/esm/dtos/InstallerDTO.js +33 -0
  17. package/dist/{options/ListTemplatesOptions.js → esm/dtos/ListTemplatesDTO.js} +5 -14
  18. package/dist/esm/dtos/MigrationDTO.js +45 -0
  19. package/dist/esm/dtos/NativeDTO.js +41 -0
  20. package/dist/esm/dtos/RunScriptDTO.js +33 -0
  21. package/dist/esm/dtos/VersionDTO.js +29 -0
  22. package/dist/esm/lib/AddPackage.js +487 -0
  23. package/dist/esm/lib/Builder.js +21 -0
  24. package/dist/esm/lib/Clean.js +146 -0
  25. package/dist/esm/lib/Environment.js +11 -0
  26. package/dist/esm/lib/I18n.js +67 -0
  27. package/dist/esm/lib/Language.js +24 -0
  28. package/dist/esm/lib/Migration.js +94 -0
  29. package/dist/esm/lib/Output.js +40 -0
  30. package/dist/esm/lib/Processes.js +54 -0
  31. package/dist/esm/lib/ProjectCreator.js +191 -0
  32. package/dist/esm/lib/Registry.js +152 -0
  33. package/dist/esm/lib/Settings.js +25 -0
  34. package/dist/esm/lib/Tar.js +145 -0
  35. package/dist/esm/lib/TemplateCatalog.js +195 -0
  36. package/dist/esm/lib/TemplateFeatures.js +141 -0
  37. package/dist/esm/lib/Versions.js +58 -0
  38. package/dist/esm/lib/native/CMake.js +357 -0
  39. package/dist/esm/lib/native/Gyp.js +398 -0
  40. package/dist/esm/lib/native/NativeProject.js +370 -0
  41. package/dist/esm/lib/native/TextEdits.js +68 -0
  42. package/dist/esm/lib/native/Toml.js +325 -0
  43. package/dist/esm/lib/workspace/Run.js +111 -0
  44. package/dist/esm/lib/workspace/Version.js +144 -0
  45. package/dist/esm/lib/workspace/Workspace.js +231 -0
  46. package/dist/esm/locales/zh-CN.json +194 -0
  47. package/dist/esm/locales/zh-TW.json +194 -0
  48. package/dist/esm/types/ProcessResult.js +1 -0
  49. package/package.json +32 -43
  50. package/CHANGELOG.md +0 -460
  51. package/dist/CLIApp.d.ts +0 -3
  52. package/dist/CLIApp.d.ts.map +0 -1
  53. package/dist/CLIApp.js +0 -45
  54. package/dist/config/Config.d.ts +0 -3
  55. package/dist/config/Config.d.ts.map +0 -1
  56. package/dist/config/Config.js +0 -63
  57. package/dist/config/SetupCLIEntrypoint.d.ts +0 -3
  58. package/dist/config/SetupCLIEntrypoint.d.ts.map +0 -1
  59. package/dist/config/SetupCLIEntrypoint.js +0 -31
  60. package/dist/controllers/CommandLineController.d.ts +0 -25
  61. package/dist/controllers/CommandLineController.d.ts.map +0 -1
  62. package/dist/controllers/CommandLineController.js +0 -78
  63. package/dist/lib/ConvertDTO2Inquirer.d.ts +0 -3
  64. package/dist/lib/ConvertDTO2Inquirer.d.ts.map +0 -1
  65. package/dist/lib/ConvertDTO2Inquirer.js +0 -61
  66. package/dist/lib/GetDataDirectory.d.ts +0 -2
  67. package/dist/lib/GetDataDirectory.d.ts.map +0 -1
  68. package/dist/lib/GetDataDirectory.js +0 -11
  69. package/dist/lib/GetLocalDataFilename.d.ts +0 -2
  70. package/dist/lib/GetLocalDataFilename.d.ts.map +0 -1
  71. package/dist/lib/GetLocalDataFilename.js +0 -12
  72. package/dist/lib/ListTemplateNames.d.ts +0 -2
  73. package/dist/lib/ListTemplateNames.d.ts.map +0 -1
  74. package/dist/lib/ListTemplateNames.js +0 -44
  75. package/dist/lib/SpecialChar.d.ts +0 -9
  76. package/dist/lib/SpecialChar.d.ts.map +0 -1
  77. package/dist/lib/SpecialChar.js +0 -11
  78. package/dist/lib/components/DeGitPuller.d.ts +0 -19
  79. package/dist/lib/components/DeGitPuller.d.ts.map +0 -1
  80. package/dist/lib/components/DeGitPuller.js +0 -65
  81. package/dist/lib/components/Spinner.d.ts +0 -23
  82. package/dist/lib/components/Spinner.d.ts.map +0 -1
  83. package/dist/lib/components/Spinner.js +0 -61
  84. package/dist/lib/providers/Creator.d.ts +0 -46
  85. package/dist/lib/providers/Creator.d.ts.map +0 -1
  86. package/dist/lib/providers/Creator.js +0 -179
  87. package/dist/lib/providers/Information.d.ts +0 -91
  88. package/dist/lib/providers/Information.d.ts.map +0 -1
  89. package/dist/lib/providers/Information.js +0 -225
  90. package/dist/lib/providers/OnlineLatestVersion.d.ts +0 -14
  91. package/dist/lib/providers/OnlineLatestVersion.d.ts.map +0 -1
  92. package/dist/lib/providers/OnlineLatestVersion.js +0 -47
  93. package/dist/lib/providers/TemplateManager.d.ts +0 -36
  94. package/dist/lib/providers/TemplateManager.d.ts.map +0 -1
  95. package/dist/lib/providers/TemplateManager.js +0 -164
  96. package/dist/options/CreateProjectOptions.d.ts +0 -15
  97. package/dist/options/CreateProjectOptions.d.ts.map +0 -1
  98. package/dist/options/CreateProjectOptions.js +0 -94
  99. package/dist/options/ListTemplatesOptions.d.ts +0 -5
  100. package/dist/options/ListTemplatesOptions.d.ts.map +0 -1
  101. package/tsconfig.json +0 -7
@@ -0,0 +1,145 @@
1
+ import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { gunzipSync } from 'node:zlib';
4
+ /**
5
+ * A text field of a header
6
+ * @param header
7
+ * @param offset
8
+ * @param length
9
+ */
10
+ function readField(header, offset, length) {
11
+ const field = header.subarray(offset, offset + length);
12
+ const end = field.indexOf(0);
13
+ return field.subarray(0, end < 0 ? length : end).toString('utf8');
14
+ }
15
+ /**
16
+ * An octal number field of a header
17
+ * @param header
18
+ * @param offset
19
+ * @param length
20
+ */
21
+ function octal(header, offset, length) {
22
+ const text = readField(header, offset, length).trim();
23
+ return text ? parseInt(text, 8) : 0;
24
+ }
25
+ /**
26
+ * The records of an extended (pax) header
27
+ * @param data
28
+ */
29
+ function paxRecords(data) {
30
+ const records = {};
31
+ let offset = 0;
32
+ while (offset < data.length) {
33
+ const space = data.indexOf(0x20, offset);
34
+ if (space < 0)
35
+ break;
36
+ const length = parseInt(data.subarray(offset, space).toString('utf8'), 10);
37
+ if (!length)
38
+ break;
39
+ const record = data.subarray(space + 1, offset + length - 1).toString('utf8');
40
+ const equals = record.indexOf('=');
41
+ if (equals > 0)
42
+ records[record.slice(0, equals)] = record.slice(equals + 1);
43
+ offset += length;
44
+ }
45
+ return records;
46
+ }
47
+ /**
48
+ * The entries of a tar archive (ustar, with the long names of pax and GNU)
49
+ * @param archive
50
+ */
51
+ export function readTar(archive) {
52
+ const entries = [];
53
+ let offset = 0;
54
+ let longName;
55
+ let longLink;
56
+ while (offset + 512 <= archive.length) {
57
+ const header = archive.subarray(offset, offset + 512);
58
+ //Two empty blocks end the archive
59
+ if (header.every((byte) => byte === 0))
60
+ break;
61
+ const size = octal(header, 124, 12);
62
+ const type = readField(header, 156, 1) || '0';
63
+ const data = archive.subarray(offset + 512, offset + 512 + size);
64
+ offset += 512 + Math.ceil(size / 512) * 512;
65
+ if (type === 'x') {
66
+ const records = paxRecords(data);
67
+ longName = records.path ?? longName;
68
+ longLink = records.linkpath ?? longLink;
69
+ continue;
70
+ }
71
+ if (type === 'L') {
72
+ longName = readField(data, 0, data.length);
73
+ continue;
74
+ }
75
+ if (type === 'K') {
76
+ longLink = readField(data, 0, data.length);
77
+ continue;
78
+ }
79
+ if (type === 'g')
80
+ continue;
81
+ const prefix = readField(header, 345, 155);
82
+ const name = longName ?? (prefix ? `${prefix}/${readField(header, 0, 100)}` : readField(header, 0, 100));
83
+ entries.push({ name: name, type: type, mode: octal(header, 100, 8), data: data, linkName: longLink ?? readField(header, 157, 100) });
84
+ longName = undefined;
85
+ longLink = undefined;
86
+ }
87
+ return entries;
88
+ }
89
+ /**
90
+ * Extract a gzipped tar archive into a directory
91
+ * @param archive
92
+ * @param directory
93
+ * @param select the path of an entry in the directory, from its path in the archive (without its leading "/"), undefined to skip it
94
+ * @return the paths of the files extracted, relative to the directory
95
+ */
96
+ export async function extractTarGz(archive, directory, select = (name) => name) {
97
+ const root = path.resolve(directory);
98
+ const files = [];
99
+ for (const entry of readTar(gunzipSync(archive))) {
100
+ const selected = select(entry.name.split('/').filter((part) => !!part).join('/'));
101
+ if (!selected)
102
+ continue;
103
+ const target = path.resolve(root, selected);
104
+ //An entry cannot be written outside of the directory
105
+ if (target !== root && !target.startsWith(`${root}${path.sep}`))
106
+ throw new Error(`The archive entry "${entry.name}" is outside of the target directory`);
107
+ switch (entry.type) {
108
+ case '5':
109
+ await mkdir(target, { recursive: true });
110
+ break;
111
+ case '0':
112
+ case '7': {
113
+ await mkdir(path.dirname(target), { recursive: true });
114
+ await writeFile(target, entry.data);
115
+ //The executable bits are kept (the scripts of the templates)
116
+ if (entry.mode & 0o111)
117
+ await chmod(target, 0o755);
118
+ files.push(selected);
119
+ break;
120
+ }
121
+ case '2': {
122
+ const linked = path.resolve(path.dirname(target), entry.linkName);
123
+ if (!linked.startsWith(`${root}${path.sep}`))
124
+ throw new Error(`The archive link "${entry.name}" points outside of the target directory`);
125
+ await mkdir(path.dirname(target), { recursive: true });
126
+ await symlink(entry.linkName, target);
127
+ break;
128
+ }
129
+ default:
130
+ //The hard links and the special files are not extracted
131
+ break;
132
+ }
133
+ }
134
+ return files;
135
+ }
136
+ /**
137
+ * Select the entries of an archive under a path, without it
138
+ * @param prefix the path ("package/template"), or the number of the leading elements removed
139
+ */
140
+ export function under(prefix) {
141
+ if (typeof prefix === 'number')
142
+ return (name) => name.split('/').slice(prefix).join('/') || undefined;
143
+ const start = `${prefix.replace(/\/+$/, '')}/`;
144
+ return (name) => name.startsWith(start) ? name.slice(start.length) || undefined : undefined;
145
+ }
@@ -0,0 +1,195 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { existsSync } from 'node:fs';
8
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { Component } from 'lakutata';
12
+ import { Autoload, Singleton } from 'lakutata/decorator/di';
13
+ import { Settings } from './Settings.js';
14
+ import { warn } from './Output.js';
15
+ import { cachedPackage, downloadPackage, majorManifest, resolvePackage } from './Registry.js';
16
+ import { extractTarGz, under } from './Tar.js';
17
+ import { toolMajor } from './Versions.js';
18
+ import { readTemplateFeatures } from './TemplateFeatures.js';
19
+ /**
20
+ * The package listing the official templates (its "lakutata.templates" field)
21
+ */
22
+ const CATALOG_PACKAGE = '@lakutata/templates';
23
+ /**
24
+ * The official templates known when the tool was released, when the catalog cannot be fetched
25
+ */
26
+ export const BUILTIN_TEMPLATES = [
27
+ { name: 'default', description: 'A minimal application serving HTTP actions', package: '@lakutata/template-default', features: ['i18n'] },
28
+ { name: 'cli', description: 'A command line tool built into executables which run without Node.js', package: '@lakutata/template-cli', features: ['i18n', 'installer'] },
29
+ { name: 'service', description: 'An HTTP API deployed as a Docker image', package: '@lakutata/template-service', features: ['i18n', 'installer'] },
30
+ { name: 'web-react', description: 'An HTTP API serving a React single-page application (Vite)', package: '@lakutata/template-web-react', features: ['i18n', 'installer'] },
31
+ { name: 'web-next', description: 'An HTTP API serving a Next.js application from the same process', package: '@lakutata/template-web-next', features: ['i18n', 'installer'] },
32
+ { name: 'web-vue', description: 'An HTTP API serving a Vue single-page application (Vite)', package: '@lakutata/template-web-vue', features: ['i18n', 'installer'] },
33
+ { name: 'web-nuxt', description: 'An HTTP API serving a Nuxt application from the same process', package: '@lakutata/template-web-nuxt', features: ['i18n', 'installer'] },
34
+ { name: 'electron-react', description: 'A desktop application: Electron, its main process serving a React renderer over IPC', package: '@lakutata/template-electron-react', features: ['i18n'] },
35
+ { name: 'electron-vue', description: 'A desktop application: Electron, its main process serving a Vue renderer over IPC', package: '@lakutata/template-electron-vue', features: ['i18n'] }
36
+ ];
37
+ /**
38
+ * How long the catalog is kept, 1 day
39
+ */
40
+ const CACHE_TTL = 24 * 3600 * 1000;
41
+ /**
42
+ * The project templates: the official ones are the packages "@lakutata/template-<name>" of the npm registry,
43
+ * the others npm packages, GitHub repositories or local directories
44
+ */
45
+ let TemplateCatalog = class TemplateCatalog extends Component {
46
+ /**
47
+ * The file of the cache of the catalog
48
+ */
49
+ get cacheFile() {
50
+ return path.join(Settings.cacheDirectory, 'templates.json');
51
+ }
52
+ /**
53
+ * Read the cached catalog of the registry
54
+ */
55
+ async readCache() {
56
+ try {
57
+ const cache = JSON.parse(await readFile(this.cacheFile, 'utf8'));
58
+ return cache.registry === Settings.registry && Array.isArray(cache.templates) ? cache : undefined;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ /**
65
+ * The official templates: the catalog of the registry, cached for a day
66
+ * @param refresh fetch the catalog even when it is cached
67
+ */
68
+ async list(refresh = false) {
69
+ const cache = await this.readCache();
70
+ if (cache && !refresh && Date.now() - cache.updatedAt < CACHE_TTL)
71
+ return cache.templates;
72
+ //The catalog of the major version of the tool (its templates are the ones of this major version)
73
+ const templates = (await majorManifest(CATALOG_PACKAGE, toolMajor()))?.lakutata?.templates;
74
+ if (Array.isArray(templates)) {
75
+ await mkdir(path.dirname(this.cacheFile), { recursive: true });
76
+ await writeFile(this.cacheFile, JSON.stringify({ updatedAt: Date.now(), registry: Settings.registry, templates: templates }, null, 2));
77
+ return templates;
78
+ }
79
+ if (cache) {
80
+ warn(`The catalog of the templates cannot be fetched from ${Settings.registry}, the catalog of ${new Date(cache.updatedAt).toLocaleString()} is used`);
81
+ return cache.templates;
82
+ }
83
+ warn(`The catalog of the templates cannot be fetched from ${Settings.registry}, the templates known by the tool are listed`);
84
+ return BUILTIN_TEMPLATES;
85
+ }
86
+ /**
87
+ * Where a template comes from: "npm:<package>[@<version>]", "github:<owner>/<repo>[#<ref>]", a local directory,
88
+ * or the name of an official template
89
+ * @param template
90
+ */
91
+ async resolve(template) {
92
+ if (template.startsWith('npm:')) {
93
+ const matched = /^((?:@[^/@]+\/)?[^/@]+)(?:@(.+))?$/.exec(template.slice(4));
94
+ if (!matched)
95
+ throw new Error(`Invalid npm template "${template}": npm:<package>[@<version>]`);
96
+ return { kind: 'npm', package: matched[1], version: matched[2], official: false };
97
+ }
98
+ if (template.startsWith('github:')) {
99
+ const matched = /^([^/#]+\/[^/#]+)(?:#(.+))?$/.exec(template.slice(7));
100
+ if (!matched)
101
+ throw new Error(`Invalid GitHub template "${template}": github:<owner>/<repo>[#<ref>]`);
102
+ return { kind: 'github', repository: matched[1], ref: matched[2] };
103
+ }
104
+ if (/^[./~]/.test(template) || path.isAbsolute(template) || existsSync(template)) {
105
+ const directory = path.resolve(template.replace(/^~(?=$|\/)/, os.homedir()));
106
+ if (!existsSync(directory))
107
+ throw new Error(`The template directory ${directory} does not exist`);
108
+ return { kind: 'local', directory: directory };
109
+ }
110
+ const official = (await this.list()).find((info) => info.name === template);
111
+ return { kind: 'npm', package: official?.package ?? `@lakutata/template-${template}`, official: true };
112
+ }
113
+ /**
114
+ * The package of a template, the cached one when the registry cannot be reached
115
+ * @param source
116
+ * @param work
117
+ */
118
+ async preparePackage(source, work) {
119
+ //An official template has the major version of the tool
120
+ const wanted = source.version ?? (source.official ? toolMajor() : undefined);
121
+ let pkg;
122
+ try {
123
+ pkg = await resolvePackage(source.package, wanted);
124
+ }
125
+ catch (error) {
126
+ const cached = typeof wanted === 'string' ? undefined : await cachedPackage(source.package, wanted);
127
+ if (!cached)
128
+ throw error;
129
+ warn(`${error.message}, the cached ${source.package}@${cached.version} is used`);
130
+ await extractTarGz(cached.archive, work, under('package'));
131
+ return `${source.package}@${cached.version}`;
132
+ }
133
+ if (!pkg) {
134
+ const names = source.official ? `, the templates are: ${(await this.list()).map((info) => info.name).join(', ')}` : '';
135
+ throw new Error(`The template ${source.official ? `"${source.package.replace('@lakutata/template-', '')}" ` : ''}does not exist (${source.package} is not on ${Settings.registry})${names}`);
136
+ }
137
+ const archive = await downloadPackage(pkg);
138
+ const version = pkg.version;
139
+ await extractTarGz(archive, work, under('package'));
140
+ return `${source.package}@${version}`;
141
+ }
142
+ /**
143
+ * A template ready: a template package has its files in template/ and the ones of its features in features/ (its
144
+ * package.json declaring them), the other sources are the template
145
+ * @param directory
146
+ * @param label
147
+ * @param cleanup
148
+ */
149
+ async prepared(directory, label, cleanup) {
150
+ const packaged = existsSync(path.join(directory, 'template'));
151
+ return {
152
+ label: label,
153
+ root: packaged ? path.join(directory, 'template') : directory,
154
+ features: packaged ? await readTemplateFeatures(directory) : {},
155
+ featuresDirectory: path.join(directory, 'features'),
156
+ cleanup: cleanup
157
+ };
158
+ }
159
+ /**
160
+ * Get the files of a template
161
+ * @param source
162
+ */
163
+ async prepare(source) {
164
+ if (source.kind === 'local') {
165
+ return await this.prepared(source.directory, source.directory, async () => undefined);
166
+ }
167
+ const work = await mkdtemp(path.join(os.tmpdir(), 'lakutata-template-'));
168
+ const cleanup = async () => rm(work, { recursive: true, force: true });
169
+ try {
170
+ let label;
171
+ if (source.kind === 'npm') {
172
+ label = await this.preparePackage(source, work);
173
+ }
174
+ else {
175
+ const url = `${Settings.githubDownload}/${source.repository}/archive/${source.ref ?? 'HEAD'}.tar.gz`;
176
+ const response = await fetch(url, { headers: { 'User-Agent': 'lakutata-cli' }, signal: AbortSignal.timeout(60000) });
177
+ if (!response.ok)
178
+ throw new Error(`The template ${source.repository} cannot be downloaded from ${url}: ${response.status} ${response.statusText}`);
179
+ //The archives of GitHub have a root directory
180
+ await extractTarGz(Buffer.from(await response.arrayBuffer()), work, under(1));
181
+ label = `github:${source.repository}${source.ref ? `#${source.ref}` : ''}`;
182
+ }
183
+ return await this.prepared(work, label, cleanup);
184
+ }
185
+ catch (error) {
186
+ await cleanup();
187
+ throw error;
188
+ }
189
+ }
190
+ };
191
+ TemplateCatalog = __decorate([
192
+ Autoload(),
193
+ Singleton()
194
+ ], TemplateCatalog);
195
+ export { TemplateCatalog };
@@ -0,0 +1,141 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { copyFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ /**
5
+ * The files the npm packages cannot hold under their name, kept as "_<name>" in the templates
6
+ */
7
+ export const DOTFILES = ['gitignore', 'npmrc', 'npmignore', 'gitattributes', 'editorconfig', 'dockerignore', 'nvmrc', 'env', 'eslintrc.json', 'prettierrc'];
8
+ /**
9
+ * The path of a file of a template in the project: "_gitignore" becomes ".gitignore"
10
+ * @param file relative to the template
11
+ */
12
+ export function projectPath(file) {
13
+ const name = path.basename(file);
14
+ return name.startsWith('_') && DOTFILES.includes(name.slice(1)) ? path.join(path.dirname(file), `.${name.slice(1)}`) : file;
15
+ }
16
+ /**
17
+ * The optional features of a template, declared by its package (its "lakutata.features" field: their names and their
18
+ * descriptions), their files in its features/<name> directory
19
+ * @param packageRoot the directory of the package of the template
20
+ */
21
+ export async function readTemplateFeatures(packageRoot) {
22
+ const manifest = path.join(packageRoot, 'package.json');
23
+ if (!existsSync(manifest))
24
+ return {};
25
+ const features = JSON.parse(await readFile(manifest, 'utf8'))?.lakutata?.features;
26
+ if (features === undefined)
27
+ return {};
28
+ if (features === null || typeof features !== 'object' || Array.isArray(features) ||
29
+ Object.entries(features).some(([name, description]) => !/^[a-z][a-z0-9-]*$/.test(name) || typeof description !== 'string')) {
30
+ throw new Error(`The features of the template (${manifest}, lakutata.features) must be their names (lower case) and their descriptions`);
31
+ }
32
+ return features;
33
+ }
34
+ /**
35
+ * Whether a value is a plain object (of JSON)
36
+ * @param value
37
+ */
38
+ function isObjectValue(value) {
39
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
40
+ }
41
+ /**
42
+ * Merge a JSON value into another one: the objects merged by their keys, the other values replaced
43
+ * @param target
44
+ * @param source
45
+ */
46
+ export function mergeJson(target, source) {
47
+ if (!isObjectValue(target) || !isObjectValue(source))
48
+ return source;
49
+ const merged = { ...target };
50
+ for (const [key, value] of Object.entries(source))
51
+ merged[key] = key in target ? mergeJson(target[key], value) : value;
52
+ return merged;
53
+ }
54
+ /**
55
+ * The files of a directory, relative to it
56
+ * @param directory
57
+ * @param base
58
+ */
59
+ async function listFiles(directory, base = directory) {
60
+ const files = [];
61
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
62
+ const file = path.join(directory, entry.name);
63
+ if (entry.isDirectory())
64
+ files.push(...await listFiles(file, base));
65
+ else
66
+ files.push(path.relative(base, file));
67
+ }
68
+ return files;
69
+ }
70
+ /**
71
+ * Add the files of a feature to a project: the new files copied, the JSON files the project has merged into its ones
72
+ * (the scripts of package.json, the options of tsconfig.json)
73
+ * @param featureDirectory
74
+ * @param project
75
+ * @return the files added to the project, relative to it
76
+ */
77
+ export async function applyFeatureFiles(featureDirectory, project) {
78
+ const added = [];
79
+ for (const file of await listFiles(featureDirectory)) {
80
+ const relative = projectPath(file);
81
+ const target = path.join(project, relative);
82
+ await mkdir(path.dirname(target), { recursive: true });
83
+ if (existsSync(target) && file.endsWith('.json')) {
84
+ const merged = mergeJson(JSON.parse(await readFile(target, 'utf8')), JSON.parse(await readFile(path.join(featureDirectory, file), 'utf8')));
85
+ await writeFile(target, `${JSON.stringify(merged, null, 2)}\n`);
86
+ continue;
87
+ }
88
+ await copyFile(path.join(featureDirectory, file), target);
89
+ added.push(relative);
90
+ }
91
+ return added;
92
+ }
93
+ /**
94
+ * A line marking a block of a feature: "//@if <feature>", "//@if !<feature>", "//@else", "//@end", in the comments of
95
+ * the languages (//, #, <!-- -->, /* * /)
96
+ */
97
+ const MARKER = /^\s*(?:\/\/|#|<!--|\/\*)\s*@(if|else|end)\b\s*(!?[a-z][a-z0-9-]*)?\s*(?:-->|\*\/)?\s*$/;
98
+ /**
99
+ * The text of a file of a template with the blocks of the features: the blocks of the features enabled kept, the
100
+ * others removed, the markers removed
101
+ * @param text
102
+ * @param enabled the features enabled
103
+ * @param file the file, for the errors
104
+ */
105
+ export function applyFeatureBlocks(text, enabled, file) {
106
+ if (!/@(?:if|else|end)\b/.test(text))
107
+ return text;
108
+ const lines = text.split('\n');
109
+ const kept = [];
110
+ //The blocks the line is in: whether their branch is kept
111
+ const blocks = [];
112
+ for (const [index, line] of lines.entries()) {
113
+ const marker = MARKER.exec(line);
114
+ if (!marker) {
115
+ if (blocks.every((block) => block.keep))
116
+ kept.push(line);
117
+ continue;
118
+ }
119
+ const [, directive, condition] = marker;
120
+ if (directive === 'if') {
121
+ if (!condition)
122
+ throw new Error(`${file}:${index + 1}: @if needs a feature`);
123
+ const negated = condition.startsWith('!');
124
+ blocks.push({ keep: enabled.has(condition.replace(/^!/, '')) !== negated, line: index + 1, elsed: false });
125
+ }
126
+ else if (directive === 'else') {
127
+ const block = blocks.at(-1);
128
+ if (!block || block.elsed)
129
+ throw new Error(`${file}:${index + 1}: @else without its @if`);
130
+ block.keep = !block.keep;
131
+ block.elsed = true;
132
+ }
133
+ else {
134
+ if (!blocks.pop())
135
+ throw new Error(`${file}:${index + 1}: @end without its @if`);
136
+ }
137
+ }
138
+ if (blocks.length)
139
+ throw new Error(`${file}:${blocks.at(-1).line}: @if without its @end`);
140
+ return kept.join('\n');
141
+ }
@@ -0,0 +1,58 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { latestManifest } from './Registry.js';
5
+ const require = createRequire(import.meta.url);
6
+ /**
7
+ * The package.json of the tool
8
+ */
9
+ export function toolPackage() {
10
+ return require('../../../package.json');
11
+ }
12
+ /**
13
+ * The version of the framework used by the tool
14
+ */
15
+ export function frameworkVersion() {
16
+ return require('lakutata/package.json').version;
17
+ }
18
+ /**
19
+ * The latest version of a package on the registry, undefined when it cannot be fetched
20
+ * @param name
21
+ */
22
+ export async function latestVersion(name) {
23
+ return (await latestManifest(name))?.version;
24
+ }
25
+ /**
26
+ * The major version of the tool: the templates of the same major version are used
27
+ */
28
+ export function toolMajor() {
29
+ return Number(toolPackage().version.split('.')[0]);
30
+ }
31
+ /**
32
+ * The version of the framework of the project of a directory (its dependency, and the installed one)
33
+ * @param directory
34
+ */
35
+ export async function projectFrameworkVersion(directory) {
36
+ for (let current = path.resolve(directory);; current = path.dirname(current)) {
37
+ try {
38
+ const pkg = JSON.parse(await readFile(path.join(current, 'package.json'), 'utf8'));
39
+ const declared = pkg.dependencies?.lakutata ?? pkg.devDependencies?.lakutata;
40
+ if (!declared)
41
+ return undefined;
42
+ let installed;
43
+ try {
44
+ installed = createRequire(path.join(current, 'package.json'))('lakutata/package.json').version;
45
+ }
46
+ catch {
47
+ installed = undefined;
48
+ }
49
+ return { project: pkg.name ?? path.basename(current), declared: declared, installed: installed };
50
+ }
51
+ catch (error) {
52
+ if (error.code !== 'ENOENT')
53
+ return undefined;
54
+ }
55
+ if (path.dirname(current) === current)
56
+ return undefined;
57
+ }
58
+ }