@lakutata/cli 2.17.0 → 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.
- package/README.md +455 -28
- package/dist/esm/CLIApp.js +6 -0
- package/dist/esm/Config.js +53 -0
- package/dist/esm/controllers/CommandLineController.js +180 -0
- package/dist/esm/controllers/I18nController.js +67 -0
- package/dist/esm/controllers/MigrationController.js +66 -0
- package/dist/esm/controllers/NativeController.js +41 -0
- package/dist/esm/controllers/PackController.js +88 -0
- package/dist/esm/controllers/WorkspaceController.js +91 -0
- package/dist/esm/dtos/AddPackageDTO.js +25 -0
- package/dist/esm/dtos/BundleDTO.js +29 -0
- package/dist/esm/dtos/CleanDTO.js +17 -0
- package/dist/esm/dtos/CreateProjectDTO.js +61 -0
- package/dist/esm/dtos/ExecutableDTO.js +37 -0
- package/dist/esm/dtos/I18nDTO.js +41 -0
- package/dist/esm/dtos/InstallerDTO.js +33 -0
- package/dist/{options/ListTemplatesOptions.js → esm/dtos/ListTemplatesDTO.js} +5 -14
- package/dist/esm/dtos/MigrationDTO.js +45 -0
- package/dist/esm/dtos/NativeDTO.js +41 -0
- package/dist/esm/dtos/RunScriptDTO.js +33 -0
- package/dist/esm/dtos/VersionDTO.js +29 -0
- package/dist/esm/lib/AddPackage.js +487 -0
- package/dist/esm/lib/Builder.js +21 -0
- package/dist/esm/lib/Clean.js +146 -0
- package/dist/esm/lib/Environment.js +11 -0
- package/dist/esm/lib/I18n.js +67 -0
- package/dist/esm/lib/Language.js +24 -0
- package/dist/esm/lib/Migration.js +94 -0
- package/dist/esm/lib/Output.js +40 -0
- package/dist/esm/lib/Processes.js +54 -0
- package/dist/esm/lib/ProjectCreator.js +191 -0
- package/dist/esm/lib/Registry.js +152 -0
- package/dist/esm/lib/Settings.js +25 -0
- package/dist/esm/lib/Tar.js +145 -0
- package/dist/esm/lib/TemplateCatalog.js +195 -0
- package/dist/esm/lib/TemplateFeatures.js +141 -0
- package/dist/esm/lib/Versions.js +58 -0
- package/dist/esm/lib/native/CMake.js +357 -0
- package/dist/esm/lib/native/Gyp.js +398 -0
- package/dist/esm/lib/native/NativeProject.js +370 -0
- package/dist/esm/lib/native/TextEdits.js +68 -0
- package/dist/esm/lib/native/Toml.js +325 -0
- package/dist/esm/lib/workspace/Run.js +111 -0
- package/dist/esm/lib/workspace/Version.js +144 -0
- package/dist/esm/lib/workspace/Workspace.js +231 -0
- package/dist/esm/locales/zh-CN.json +194 -0
- package/dist/esm/locales/zh-TW.json +194 -0
- package/dist/esm/types/ProcessResult.js +1 -0
- package/package.json +32 -43
- package/CHANGELOG.md +0 -452
- package/dist/CLIApp.d.ts +0 -3
- package/dist/CLIApp.d.ts.map +0 -1
- package/dist/CLIApp.js +0 -45
- package/dist/config/Config.d.ts +0 -3
- package/dist/config/Config.d.ts.map +0 -1
- package/dist/config/Config.js +0 -63
- package/dist/config/SetupCLIEntrypoint.d.ts +0 -3
- package/dist/config/SetupCLIEntrypoint.d.ts.map +0 -1
- package/dist/config/SetupCLIEntrypoint.js +0 -31
- package/dist/controllers/CommandLineController.d.ts +0 -25
- package/dist/controllers/CommandLineController.d.ts.map +0 -1
- package/dist/controllers/CommandLineController.js +0 -78
- package/dist/lib/ConvertDTO2Inquirer.d.ts +0 -3
- package/dist/lib/ConvertDTO2Inquirer.d.ts.map +0 -1
- package/dist/lib/ConvertDTO2Inquirer.js +0 -61
- package/dist/lib/GetDataDirectory.d.ts +0 -2
- package/dist/lib/GetDataDirectory.d.ts.map +0 -1
- package/dist/lib/GetDataDirectory.js +0 -11
- package/dist/lib/GetLocalDataFilename.d.ts +0 -2
- package/dist/lib/GetLocalDataFilename.d.ts.map +0 -1
- package/dist/lib/GetLocalDataFilename.js +0 -12
- package/dist/lib/ListTemplateNames.d.ts +0 -2
- package/dist/lib/ListTemplateNames.d.ts.map +0 -1
- package/dist/lib/ListTemplateNames.js +0 -44
- package/dist/lib/SpecialChar.d.ts +0 -9
- package/dist/lib/SpecialChar.d.ts.map +0 -1
- package/dist/lib/SpecialChar.js +0 -11
- package/dist/lib/components/DeGitPuller.d.ts +0 -19
- package/dist/lib/components/DeGitPuller.d.ts.map +0 -1
- package/dist/lib/components/DeGitPuller.js +0 -65
- package/dist/lib/components/Spinner.d.ts +0 -23
- package/dist/lib/components/Spinner.d.ts.map +0 -1
- package/dist/lib/components/Spinner.js +0 -61
- package/dist/lib/providers/Creator.d.ts +0 -46
- package/dist/lib/providers/Creator.d.ts.map +0 -1
- package/dist/lib/providers/Creator.js +0 -179
- package/dist/lib/providers/Information.d.ts +0 -91
- package/dist/lib/providers/Information.d.ts.map +0 -1
- package/dist/lib/providers/Information.js +0 -225
- package/dist/lib/providers/OnlineLatestVersion.d.ts +0 -14
- package/dist/lib/providers/OnlineLatestVersion.d.ts.map +0 -1
- package/dist/lib/providers/OnlineLatestVersion.js +0 -47
- package/dist/lib/providers/TemplateManager.d.ts +0 -36
- package/dist/lib/providers/TemplateManager.d.ts.map +0 -1
- package/dist/lib/providers/TemplateManager.js +0 -164
- package/dist/options/CreateProjectOptions.d.ts +0 -15
- package/dist/options/CreateProjectOptions.d.ts.map +0 -1
- package/dist/options/CreateProjectOptions.js +0 -94
- package/dist/options/ListTemplatesOptions.d.ts +0 -5
- package/dist/options/ListTemplatesOptions.d.ts.map +0 -1
- 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
|
+
}
|