feat:基本最终完成
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -135,3 +135,4 @@ ndist
|
|||||||
# test files
|
# test files
|
||||||
*.zip
|
*.zip
|
||||||
*.mrpack
|
*.mrpack
|
||||||
|
instance/
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
"dotenv": "^17.0.1",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"got": "^14.4.7",
|
"got": "^14.4.7",
|
||||||
"inquirer": "^12.6.3",
|
"inquirer": "^12.6.3",
|
||||||
@@ -2524,6 +2525,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.0.1.tgz",
|
||||||
|
"integrity": "sha512-GLjkduuAL7IMJg/ZnOPm9AnWKJ82mSE2tzXLaJ/6hD6DhwGfZaXG77oB8qbReyiczNxnbxQKyh0OE5mXq0bAHA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/download": {
|
"node_modules/download": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/download/-/download-8.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/download/-/download-8.0.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
"dotenv": "^17.0.1",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"got": "^14.4.7",
|
"got": "^14.4.7",
|
||||||
"inquirer": "^12.6.3",
|
"inquirer": "^12.6.3",
|
||||||
|
|||||||
75
src/main.ts
75
src/main.ts
@@ -1,13 +1,26 @@
|
|||||||
import inquirer from "inquirer";
|
import inquirer from "inquirer";
|
||||||
import yauzl from "yauzl";
|
import yauzl from "yauzl";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { join, basename } from "node:path";
|
import fse from "fs-extra";
|
||||||
|
import { join, basename, dirname } from "node:path";
|
||||||
import { platform, what_platform } from "./platform/index.js";
|
import { platform, what_platform } from "./platform/index.js";
|
||||||
import { readzipentry } from "./utils/utils.js";
|
import { isDevelopment, readzipentry } from "./utils/utils.js";
|
||||||
|
import fabric from "./ml_install/fabric.js"
|
||||||
|
import forge from "./ml_install/forge.js"
|
||||||
|
import neoforge from "./ml_install/neoforge.js"
|
||||||
|
import { DeEarthMain } from "./utils/DeEarth.js";
|
||||||
|
import { LOGGER } from "./utils/logger.js";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
interface Answers {
|
interface Answers {
|
||||||
modpack_path: string | undefined;
|
modpack_path: string | undefined;
|
||||||
}
|
}
|
||||||
const unzip_path = "./instance";
|
let unzip_path:string = ""
|
||||||
|
if(isDevelopment){
|
||||||
|
unzip_path = join("./","instance")
|
||||||
|
}else{
|
||||||
|
unzip_path = join(getCurrnetDir(),"instance")
|
||||||
|
}
|
||||||
|
let zipnamew: string = ""
|
||||||
const argv = process.argv.slice(2)[0];
|
const argv = process.argv.slice(2)[0];
|
||||||
|
|
||||||
if (!argv) {
|
if (!argv) {
|
||||||
@@ -15,16 +28,25 @@ if (!argv) {
|
|||||||
{ type: "input", name: "modpack_path", message: "请输入整合包路径" },
|
{ type: "input", name: "modpack_path", message: "请输入整合包路径" },
|
||||||
]);
|
]);
|
||||||
if (answer.modpack_path) {
|
if (answer.modpack_path) {
|
||||||
|
initdir()
|
||||||
await main(answer.modpack_path);
|
await main(answer.modpack_path);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
initdir()
|
||||||
await main(argv);
|
await main(argv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initdir(){
|
||||||
|
if(!fse.existsSync(unzip_path)){
|
||||||
|
fse.ensureDirSync(join(unzip_path,"rubbish"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(modpack_path: string) {
|
async function main(modpack_path: string) {
|
||||||
const zipname = basename(modpack_path)
|
const zipname= basename(modpack_path)
|
||||||
.replace(".zip", "")
|
.replace(".zip", "")
|
||||||
.replace(".mrpack", "");
|
.replace(".mrpack", "");
|
||||||
|
zipnamew=zipname
|
||||||
let dud_files: Array<string> = [];
|
let dud_files: Array<string> = [];
|
||||||
let pack_info: object;
|
let pack_info: object;
|
||||||
//unzip
|
//unzip
|
||||||
@@ -33,10 +55,15 @@ async function main(modpack_path: string) {
|
|||||||
zipfile.on("entry", async (entry) => {
|
zipfile.on("entry", async (entry) => {
|
||||||
if (/\/$/.test(entry.fileName)) {
|
if (/\/$/.test(entry.fileName)) {
|
||||||
} else if (entry.fileName.includes("overrides/")) {
|
} else if (entry.fileName.includes("overrides/")) {
|
||||||
/*zipfile.openReadStream(entry,(err,stream)=>{ //读取overrides文件夹下的所有文件和文件夹
|
const zipfilex = join(unzip_path, zipname,entry.fileName.replace("overrides/",""))
|
||||||
|
if(!fse.existsSync(zipfilex)){
|
||||||
})*/
|
zipfile.openReadStream(entry,(err,stream)=>{ //读取overrides文件夹下的所有文件和文件夹
|
||||||
|
const dir = dirname(zipfilex)
|
||||||
|
fse.ensureDirSync(dir);
|
||||||
|
stream.pipe(fse.createWriteStream(zipfilex))
|
||||||
|
})
|
||||||
//console.log(entry.fileName)
|
//console.log(entry.fileName)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const name: string = entry.fileName;
|
const name: string = entry.fileName;
|
||||||
dud_files.push(name);
|
dud_files.push(name);
|
||||||
@@ -45,15 +72,41 @@ async function main(modpack_path: string) {
|
|||||||
(await readzipentry(zipfile, entry)).toString()
|
(await readzipentry(zipfile, entry)).toString()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(name);
|
|
||||||
}
|
}
|
||||||
zipfile.readEntry();
|
zipfile.readEntry();
|
||||||
});
|
});
|
||||||
zipfile.on("end", () => {
|
zipfile.on("end", async () => {
|
||||||
//zip
|
//zip
|
||||||
platform(what_platform(dud_files));
|
const dirx = join(unzip_path,zipname)
|
||||||
//console.log(dud_files)
|
const plat = platform(what_platform(dud_files))
|
||||||
|
const info = await plat.getinfo(pack_info);
|
||||||
|
await plat.downloadfile(pack_info,dirx)
|
||||||
|
await install(info.loader,info.minecraft,info.loader_version,dirx)
|
||||||
|
await DeEarthMain(join(dirx,"mods"),join(unzip_path,"rubbish"))
|
||||||
|
LOGGER.info("DeEarthX已将服务端制作完成!");
|
||||||
zipfile.close();
|
zipfile.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function install(type: string,minecraft:string,loaderver:string,path:string){
|
||||||
|
switch(type){
|
||||||
|
case "fabric":
|
||||||
|
await fabric(minecraft,loaderver,path)
|
||||||
|
break;
|
||||||
|
case "fabric-loader":
|
||||||
|
await fabric(minecraft,loaderver,path)
|
||||||
|
break;
|
||||||
|
case "forge":
|
||||||
|
await forge(minecraft,loaderver,path)
|
||||||
|
break;
|
||||||
|
case "neoforge":
|
||||||
|
await neoforge(minecraft,loaderver,path)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrnetDir () {
|
||||||
|
const url = new URL(".", import.meta.url);
|
||||||
|
return fileURLToPath(url);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export default async function install(
|
|||||||
.get(`version/${minecraftversion}/json`)
|
.get(`version/${minecraftversion}/json`)
|
||||||
.json()) as mcinfoX; //获取Minecraft版本JSON
|
.json()) as mcinfoX; //获取Minecraft版本JSON
|
||||||
const forgepath = path;
|
const forgepath = path;
|
||||||
|
console.log(loaderversion)
|
||||||
const forgedata = (
|
const forgedata = (
|
||||||
await gotx.get(
|
await gotx.get(
|
||||||
`forge/download?mcversion=${minecraftversion}&version=${loaderversion}&category=installer&format=jar`
|
`forge/download?mcversion=${minecraftversion}&version=${loaderversion}&category=installer&format=jar`
|
||||||
@@ -49,7 +50,7 @@ export default async function install(
|
|||||||
//下载依赖1
|
//下载依赖1
|
||||||
const t = fvdata[c].downloads.artifact;
|
const t = fvdata[c].downloads.artifact;
|
||||||
await xfastdownload(
|
await xfastdownload(
|
||||||
`maven${new URL(t.url).pathname}`,
|
`https://bmclapi2.bangbang93.com/maven${new URL(t.url).pathname}`,
|
||||||
`${forgepath}/libraries/${t.path}`,
|
`${forgepath}/libraries/${t.path}`,
|
||||||
16
|
16
|
||||||
);
|
);
|
||||||
@@ -61,7 +62,7 @@ export default async function install(
|
|||||||
//下载依赖2
|
//下载依赖2
|
||||||
const t = fvdata[c].downloads.artifact;
|
const t = fvdata[c].downloads.artifact;
|
||||||
await xfastdownload(
|
await xfastdownload(
|
||||||
`maven${new URL(t.url).pathname}`,
|
`https://bmclapi2.bangbang93.com/maven${new URL(t.url).pathname}`,
|
||||||
`${forgepath}/libraries/${t.path}`,
|
`${forgepath}/libraries/${t.path}`,
|
||||||
16
|
16
|
||||||
);
|
);
|
||||||
@@ -69,7 +70,7 @@ export default async function install(
|
|||||||
//下载MAPPING与MOJMAPS
|
//下载MAPPING与MOJMAPS
|
||||||
/*MOJMAPS*/
|
/*MOJMAPS*/
|
||||||
await xfastdownload(
|
await xfastdownload(
|
||||||
new URL(mcinfo.downloads.server_mappings.url).pathname,
|
`https://bmclapi2.bangbang93.com${new URL(mcinfo.downloads.server_mappings.url).pathname}`,
|
||||||
`${forgepath}/libraries/${mavenToUrl(
|
`${forgepath}/libraries/${mavenToUrl(
|
||||||
json.data.MOJMAPS.server.replace(/[[\]]/g, ""),
|
json.data.MOJMAPS.server.replace(/[[\]]/g, ""),
|
||||||
""
|
""
|
||||||
@@ -83,7 +84,7 @@ export default async function install(
|
|||||||
/['"]/g,
|
/['"]/g,
|
||||||
""
|
""
|
||||||
)}.zip`;
|
)}.zip`;
|
||||||
await xfastdownload(`maven/${tmp}`, `${forgepath}/libraries/${tmp}`);
|
await xfastdownload(`https://bmclapi2.bangbang93.com/maven/${tmp}`, `${forgepath}/libraries/${tmp}`);
|
||||||
LOGGER.info("下载MAPPING与MOJMAPS完成!");
|
LOGGER.info("下载MAPPING与MOJMAPS完成!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,14 +92,14 @@ export default async function install(
|
|||||||
for (let d = 0; d < mcinfo.libraries.length; d++) {
|
for (let d = 0; d < mcinfo.libraries.length; d++) {
|
||||||
const g = mcinfo.libraries[d].downloads.artifact;
|
const g = mcinfo.libraries[d].downloads.artifact;
|
||||||
await xfastdownload(
|
await xfastdownload(
|
||||||
`maven${new URL(g.url).pathname}`,
|
`https://bmclapi2.bangbang93.com/maven${new URL(g.url).pathname}`,
|
||||||
`${forgepath}/libraries/${g.path}`,
|
`${forgepath}/libraries/${g.path}`,
|
||||||
16
|
16
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
LOGGER.info(`下载Minecraft的Maven完成!`);
|
LOGGER.info(`下载Minecraft的Maven完成!`);
|
||||||
await xfastdownload(
|
await xfastdownload(
|
||||||
`version/${minecraftversion}/server`,
|
`https://bmclapi2.bangbang93.com/version/${minecraftversion}/server`,
|
||||||
`${forgepath}/libraries/net/minecraft/server/${minecraftversion}/server-${minecraftversion}.jar`,
|
`${forgepath}/libraries/net/minecraft/server/${minecraftversion}/server-${minecraftversion}.jar`,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { fastdownload } from "../utils/utils.js";
|
import got from "got";
|
||||||
|
import { fastdownload, usemirror } from "../utils/utils.js";
|
||||||
import { modpack_info, XPlatform } from "./index.js";
|
import { modpack_info, XPlatform } from "./index.js";
|
||||||
|
|
||||||
|
const cf_url = (()=>{if(usemirror){return "https://mod.mcimirror.top/curseforge"}else{return "https://api.curseforge.com"}})()
|
||||||
export interface CurseForgeManifest {
|
export interface CurseForgeManifest {
|
||||||
minecraft: {
|
minecraft: {
|
||||||
version: string;
|
version: string;
|
||||||
modLoaders: Array<{ id: string }>;
|
modLoaders: Array<{ id: string }>;
|
||||||
};
|
};
|
||||||
|
files: Array<{ projectID: number; fileID: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CurseForge implements XPlatform {
|
export class CurseForge implements XPlatform {
|
||||||
@@ -14,15 +17,39 @@ export class CurseForge implements XPlatform {
|
|||||||
const local_manifest = manifest as CurseForgeManifest;
|
const local_manifest = manifest as CurseForgeManifest;
|
||||||
if (result && local_manifest)
|
if (result && local_manifest)
|
||||||
result.minecraft = local_manifest.minecraft.version;
|
result.minecraft = local_manifest.minecraft.version;
|
||||||
const loader_all = local_manifest.minecraft.modLoaders[0].id.match(
|
const id = local_manifest.minecraft.modLoaders[0].id;
|
||||||
|
const loader_all = id.match(
|
||||||
/(.*)-/
|
/(.*)-/
|
||||||
) as RegExpMatchArray;
|
) as RegExpMatchArray;
|
||||||
result.loader = loader_all[1];
|
result.loader = loader_all[1];
|
||||||
result.loader_version = loader_all[0];
|
result.loader_version = id.replace(loader_all[0],"");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadfile(urls: [string, string]): Promise<void> {
|
async downloadfile(manifest: object,path:string): Promise<void> {
|
||||||
await fastdownload(urls);
|
const local_manifest = manifest as CurseForgeManifest;
|
||||||
|
const FileID = JSON.stringify({
|
||||||
|
fileIds: local_manifest.files.map((file: { fileID: number; }) => file.fileID),
|
||||||
|
});
|
||||||
|
let tmp: [string, string] | string[][] = [];
|
||||||
|
await got.post(cf_url+"/v1/mods/files",{
|
||||||
|
body: FileID,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key":"$2a$10$ydk0TLDG/Gc6uPMdz7mad.iisj2TaMDytVcIW4gcVP231VKngLBKy"
|
||||||
|
}
|
||||||
|
}).json().then((res:any)=>{
|
||||||
|
res.data.forEach((e: { fileName: string; downloadUrl: null|string; }) => {
|
||||||
|
if (e.fileName.endsWith(".zip")||e.downloadUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usemirror){
|
||||||
|
tmp.push(["https://mod.mcimirror.top"+new URL(e.downloadUrl).pathname,path+"/mods/" + e.fileName])
|
||||||
|
}else{
|
||||||
|
tmp.push([e.downloadUrl,path+"/mods/" + e.fileName])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
await fastdownload(tmp as unknown as [string, string]) //下载文件
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { Modrinth } from "./modrinth.js";
|
|||||||
|
|
||||||
export interface XPlatform {
|
export interface XPlatform {
|
||||||
getinfo(manifest: object): Promise<modpack_info>;
|
getinfo(manifest: object): Promise<modpack_info>;
|
||||||
downloadfile(
|
downloadfile(manifest: object,path:string): Promise<void>;
|
||||||
urls: [string, string] | [string, string, string]
|
|
||||||
): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface modpack_info {
|
export interface modpack_info {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { mr_fastdownload } from "../utils/utils.js";
|
import fs from "node:fs";
|
||||||
|
import { mr_fastdownload, usemirror } from "../utils/utils.js";
|
||||||
import { modpack_info, XPlatform } from "./index.js";
|
import { modpack_info, XPlatform } from "./index.js";
|
||||||
|
|
||||||
interface ModrinthManifest {
|
interface ModrinthManifest {
|
||||||
|
files: Array<{ path: string; downloads: string[]; fileSize: number; }>;
|
||||||
dependencies: {
|
dependencies: {
|
||||||
minecraft: string;
|
minecraft: string;
|
||||||
forge: string;
|
forge: string;
|
||||||
@@ -27,7 +29,19 @@ export class Modrinth implements XPlatform {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
async downloadfile(urls: [string, string, string]): Promise<void> {
|
async downloadfile(manifest: object,path:string): Promise<void> {
|
||||||
await mr_fastdownload(urls);
|
const index = manifest as ModrinthManifest;
|
||||||
|
let tmp: [string, string, string][] = []
|
||||||
|
index.files.forEach(async (e: { path: string; downloads: string[]; fileSize: number;}) => {
|
||||||
|
if (e.path.endsWith(".zip")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usemirror){
|
||||||
|
tmp.push(["https://mod.mcimirror.top"+new URL(e.downloads[0]).pathname,path+e.path,String(e.fileSize)])
|
||||||
|
}else{
|
||||||
|
tmp.push([e.downloads[0],path + e.path,String(e.fileSize)])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mr_fastdownload(tmp as unknown as [string, string, string])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export async function DeEarthMain(modspath: string, movepath: any) {
|
|||||||
fs.mkdirSync(movepath)
|
fs.mkdirSync(movepath)
|
||||||
}
|
}
|
||||||
LOGGER.info(`DeEarth V1.0.0`)
|
LOGGER.info(`DeEarth V1.0.0`)
|
||||||
|
LOGGER.info(`如有无法筛选的mods,请前往 https://dearth.0771010.xyz/ 提交未成功筛选的模组的modid`)
|
||||||
|
LOGGER.info(`Probejs 7.0.0以上版本为非客户端mod,如rubbish中有请自行添加回去`)
|
||||||
const resaddr = fs.readdirSync(modspath)
|
const resaddr = fs.readdirSync(modspath)
|
||||||
LOGGER.info(`获取目录列表,一共${resaddr.length}个jar文件。`)
|
LOGGER.info(`获取目录列表,一共${resaddr.length}个jar文件。`)
|
||||||
const totalBar = multibar.create(resaddr.length, 0, { filename: '总文件数' })
|
const totalBar = multibar.create(resaddr.length, 0, { filename: '总文件数' })
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function error(error: object | string) {
|
|||||||
console.log(
|
console.log(
|
||||||
`[${chalk.blue(
|
`[${chalk.blue(
|
||||||
new Date().toLocaleDateString() + " " + new Date().toLocaleTimeString()
|
new Date().toLocaleDateString() + " " + new Date().toLocaleTimeString()
|
||||||
)}](${process.pid})[${chalk.red("ERROR")}:${error.toString()}`
|
)}](${process.pid})[${chalk.red("ERROR")}:${JSON.stringify(error)}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import pRetry from "p-retry";
|
|||||||
import fse from "fs-extra"
|
import fse from "fs-extra"
|
||||||
import yauzl from "yauzl";
|
import yauzl from "yauzl";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
import env from "dotenv"
|
||||||
import { MultiBar } from "cli-progress";
|
import { MultiBar } from "cli-progress";
|
||||||
|
import {URL, fileURLToPath } from "node:url";
|
||||||
import { LOGGER } from "./logger.js";
|
import { LOGGER } from "./logger.js";
|
||||||
|
|
||||||
export async function readzipentry(zipfile: yauzl.ZipFile, entry: yauzl.Entry):Promise<string|Buffer> {
|
export async function readzipentry(zipfile: yauzl.ZipFile, entry: yauzl.Entry):Promise<string|Buffer> {
|
||||||
@@ -120,3 +122,18 @@ export async function xfastdownload(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const usemirror = (()=>{
|
||||||
|
env.config()
|
||||||
|
const mirror = process.env.MIRROR
|
||||||
|
if(mirror){
|
||||||
|
return false
|
||||||
|
}else{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
export const isDevelopment = (()=>{
|
||||||
|
env.config()
|
||||||
|
return process.env.DEVELOPMENT
|
||||||
|
})()
|
||||||
Reference in New Issue
Block a user