mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
more hopper stuffs
This commit is contained in:
29
assets/disk0/hopper/README.md
Normal file
29
assets/disk0/hopper/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
Hopper is a package manager for TVDOS.
|
||||
|
||||
---
|
||||
|
||||
## For End Users
|
||||
|
||||
|
||||
---
|
||||
|
||||
## For Package Managers
|
||||
|
||||
A Hopper package is declared using the Hopper Manifest. Hopper Manifest has the following fields:
|
||||
|
||||
- **HopperManifestVersion.** The manifest version, always `1`
|
||||
- **HopperPackageName.** Package name that Hopper understands
|
||||
- **HopperPackageVersion.** The version. MUST STRICTLY follow Semantic Versioning 2.0.0
|
||||
1. MAJOR version when you make incompatible API changes
|
||||
2. MINOR version when you add functionality in a backward compatible manner
|
||||
3. PATCH version when you make backward compatible bug fixes
|
||||
- **HopperPackageMaintainer.** The maintainer of the package
|
||||
- **HopperProvides.** (plural) What does your package provides
|
||||
- **HopperRequires.** (plural) Dependencies
|
||||
- **ProperName.** The displayed name of the package. Must be human-readable
|
||||
- **ProperAuthor.** The displayed author of the package. Must be human-readable
|
||||
- **ProperDescription.** Human-readable description of the package
|
||||
- **Licence.** Licence of the package (e.g. `MIT`, `GPL-2.0-only`)
|
||||
- **SupportMe.** (optional, plural) Any donation links
|
||||
- **SystemPackagePath.** (for packages shipped with TVDOS only) path descriptor for the package file(s)
|
||||
- **PackageFileList.** (for upstream packages, plural) HTTP(S) path for the files.
|
||||
@@ -67,50 +67,374 @@ function findInstalledManifest(name) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isSystemPackage(manifest) {
|
||||
return !!(manifest.SystemPackagePath) // true if the field is truthy (not undefined, not empty string, not string '0', etc.)
|
||||
}
|
||||
|
||||
// Yes/no prompt. Empty input falls back to `defaultYes`.
|
||||
function confirm(prompt, defaultYes) {
|
||||
const hint = defaultYes ? "[Y/n]" : "[y/N]"
|
||||
print(`${prompt} ${hint} `)
|
||||
const ans = (read() || "").trim().toLowerCase()
|
||||
if (ans === "") return !!defaultYes
|
||||
return ans === "y" || ans === "yes"
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SemVer (strict X.Y.Z) and constraint matching
|
||||
// ============================================================
|
||||
//
|
||||
// Versions are strict Semantic Versioning: three non-negative integer
|
||||
// components MAJOR.MINOR.PATCH. No pre-release / build metadata.
|
||||
//
|
||||
// Constraint grammar (intentionally small, expandable later):
|
||||
// * any version
|
||||
// X.* major X, any minor/patch
|
||||
// X.Y.* major X, minor Y, any patch
|
||||
// X.Y.Z exact
|
||||
// ^X.Y.Z >= X.Y.Z and < (X+1).0.0 (major-compatible)
|
||||
// ~X.Y.Z >= X.Y.Z and < X.(Y+1).0 (minor-compatible)
|
||||
// >=X.Y.Z / >X.Y.Z / <=X.Y.Z / <X.Y.Z / =X.Y.Z
|
||||
//
|
||||
// Multiple comma-separated constraints are AND-ed: "^1.2.0,<1.5.0".
|
||||
|
||||
function parseVersion(v) {
|
||||
const m = String(v || "0.0.0").trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||
if (!m) return [0, 0, 0]
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
|
||||
}
|
||||
|
||||
function compareVersion(a, b) {
|
||||
const A = parseVersion(a), B = parseVersion(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (A[i] !== B[i]) return (A[i] < B[i]) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function _matchSingleConstraint(version, c) {
|
||||
c = c.trim()
|
||||
if (c === "" || c === "*") return true
|
||||
|
||||
// Operator form: ^, ~, >=, <=, >, <, =
|
||||
let opMatch = c.match(/^(\^|~|>=|<=|>|<|=)\s*(\d+\.\d+\.\d+)$/)
|
||||
if (opMatch) {
|
||||
const op = opMatch[1]
|
||||
const target = opMatch[2]
|
||||
const cmp = compareVersion(version, target)
|
||||
const [tM, tm] = parseVersion(target)
|
||||
switch (op) {
|
||||
case "=": return cmp === 0
|
||||
case ">": return cmp > 0
|
||||
case ">=": return cmp >= 0
|
||||
case "<": return cmp < 0
|
||||
case "<=": return cmp <= 0
|
||||
case "^": return cmp >= 0 && compareVersion(version, `${tM + 1}.0.0`) < 0
|
||||
case "~": return cmp >= 0 && compareVersion(version, `${tM}.${tm + 1}.0`) < 0
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard form: X.*, X.Y.*, X.x, X.Y.x, or exact X.Y.Z
|
||||
const parts = c.split(".")
|
||||
const vparts = parseVersion(version)
|
||||
for (let i = 0; i < parts.length && i < 3; i++) {
|
||||
if (parts[i] === "*" || parts[i] === "x" || parts[i] === "X") return true
|
||||
const expected = parseInt(parts[i], 10)
|
||||
if (isNaN(expected) || vparts[i] !== expected) return false
|
||||
}
|
||||
// All listed parts matched literally; remaining parts (if any) must be 0
|
||||
for (let i = parts.length; i < 3; i++) {
|
||||
if (vparts[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function satisfies(version, constraint) {
|
||||
if (!constraint) return true
|
||||
return constraint.split(",").every(c => _matchSingleConstraint(version, c))
|
||||
}
|
||||
|
||||
function parseRequires(s) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(entry => {
|
||||
// "<name>" or "<name> <constraint>"
|
||||
const idx = entry.search(/\s+/)
|
||||
if (idx < 0) {
|
||||
out.push({ name: entry, constraint: "*" })
|
||||
} else {
|
||||
out.push({ name: entry.substring(0, idx), constraint: entry.substring(idx + 1).trim() })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Candidate index (installed + upstream)
|
||||
// ============================================================
|
||||
|
||||
function _manifestToCandidate(m, source) {
|
||||
return {
|
||||
name: m.HopperPackageName || "",
|
||||
version: m.HopperPackageVersion || "0.0.0",
|
||||
requires: parseRequires(m.HopperRequires || ""),
|
||||
provides: splitList(m.HopperProvides || ""),
|
||||
source: source, // "installed" | "upstream"
|
||||
manifest: m
|
||||
}
|
||||
}
|
||||
|
||||
// Returns map: packageName -> array<Candidate>
|
||||
function buildCandidateIndex() {
|
||||
const idx = new Map()
|
||||
function add(c) {
|
||||
if (!idx.has(c.name)) idx.set(c.name, [])
|
||||
// De-dupe (name+version+source)
|
||||
const arr = idx.get(c.name)
|
||||
if (arr.some(x => x.version === c.version && x.source === c.source)) return
|
||||
arr.push(c)
|
||||
}
|
||||
|
||||
listInstalledManifests().forEach(m => add(_manifestToCandidate(m, "installed")))
|
||||
fetchRemoteCandidates().forEach(m => add(_manifestToCandidate(m, "upstream")))
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// Anything that satisfies a requirement on `name`: package whose own name is
|
||||
// `name`, OR whose HopperProvides includes `name`.
|
||||
function findProviders(idx, name) {
|
||||
const direct = idx.get(name) ? idx.get(name).slice() : []
|
||||
const indirect = []
|
||||
idx.forEach(candidates => {
|
||||
candidates.forEach(c => {
|
||||
if (c.name === name) return // already in `direct`
|
||||
if (c.provides.indexOf(name) >= 0) indirect.push(c)
|
||||
})
|
||||
})
|
||||
return direct.concat(indirect)
|
||||
}
|
||||
|
||||
// Sort: installed first (no churn), then highest version, then upstream order.
|
||||
function sortCandidates(cands) {
|
||||
return cands.slice().sort((a, b) => {
|
||||
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||
return -compareVersion(a.version, b.version)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
||||
// ============================================================
|
||||
//
|
||||
// State: chosen :: Map<packageName, Candidate>
|
||||
// At every choice point we snapshot the whole map so that backtracking
|
||||
// also undoes any transitive picks. The candidate ordering encodes the
|
||||
// preference policy:
|
||||
//
|
||||
// 1. Keep installed if it satisfies the constraint.
|
||||
// 2. Otherwise pick the newest upstream version that satisfies.
|
||||
// 3. If newer versions cause downstream conflicts, walk older versions
|
||||
// (downgrade) until either something fits or candidates are exhausted.
|
||||
//
|
||||
// The structure is intentionally close to DPLL: each "decision" is the
|
||||
// candidate we assign to a variable, and "unit propagation" is the
|
||||
// recursive resolve() call over each requirement. Replacing this with
|
||||
// clause learning / a watched-literals scheme later would be local.
|
||||
|
||||
function resolveAll(idx, requirements) {
|
||||
const chosen = new Map()
|
||||
const issues = []
|
||||
|
||||
function snapshot() { return new Map(chosen) }
|
||||
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||
|
||||
function _resolve(reqName, constraint, trail) {
|
||||
const existing = chosen.get(reqName)
|
||||
if (existing !== undefined) {
|
||||
return satisfies(existing.version, constraint)
|
||||
? { ok: true }
|
||||
: { ok: false, reason: `${reqName} pinned to ${existing.version}, but ${trail.join(" -> ")} requires ${constraint}` }
|
||||
}
|
||||
|
||||
const providers = findProviders(idx, reqName)
|
||||
if (providers.length === 0) {
|
||||
return { ok: false, reason: `no package provides "${reqName}" (required by ${trail.join(" -> ") || "<root>"})` }
|
||||
}
|
||||
const matching = sortCandidates(providers.filter(c => satisfies(c.version, constraint)))
|
||||
if (matching.length === 0) {
|
||||
const versions = providers.map(p => `${p.version}[${p.source}]`).join(", ")
|
||||
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||
}
|
||||
|
||||
let lastReason = null
|
||||
for (let i = 0; i < matching.length; i++) {
|
||||
const cand = matching[i]
|
||||
const snap = snapshot()
|
||||
chosen.set(cand.name, cand)
|
||||
|
||||
let allOk = true
|
||||
const subTrail = trail.concat([`${cand.name}@${cand.version}`])
|
||||
for (let j = 0; j < cand.requires.length; j++) {
|
||||
const req = cand.requires[j]
|
||||
const r = _resolve(req.name, req.constraint, subTrail)
|
||||
if (!r.ok) {
|
||||
allOk = false
|
||||
lastReason = r.reason
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allOk) return { ok: true }
|
||||
restore(snap)
|
||||
}
|
||||
|
||||
return { ok: false, reason: lastReason || `no working candidate for "${reqName}"` }
|
||||
}
|
||||
|
||||
requirements.forEach(req => {
|
||||
const r = _resolve(req.name, req.constraint, [])
|
||||
if (!r.ok) issues.push(r.reason)
|
||||
})
|
||||
|
||||
return { chosen, issues }
|
||||
}
|
||||
|
||||
// Compare resolved assignment against currently-installed state.
|
||||
function classifyPlan(idx, chosen) {
|
||||
const installedByName = new Map()
|
||||
listInstalledManifests().forEach(m => installedByName.set(m.HopperPackageName, m))
|
||||
|
||||
const actions = []
|
||||
chosen.forEach((cand, name) => {
|
||||
const inst = installedByName.get(name)
|
||||
if (cand.source === "installed") {
|
||||
actions.push({ action: "keep", name, version: cand.version })
|
||||
}
|
||||
else if (inst === undefined) {
|
||||
actions.push({ action: "install", name, version: cand.version })
|
||||
}
|
||||
else {
|
||||
const cmp = compareVersion(cand.version, inst.HopperPackageVersion)
|
||||
if (cmp > 0) actions.push({ action: "upgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else if (cmp < 0) actions.push({ action: "downgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else actions.push({ action: "reinstall", name, version: cand.version })
|
||||
}
|
||||
})
|
||||
return actions
|
||||
}
|
||||
|
||||
function printPlan(actions, target) {
|
||||
const changing = actions.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) {
|
||||
println(`Nothing to do: ${target} is already installed and satisfied.`)
|
||||
return
|
||||
}
|
||||
println("Plan:")
|
||||
changing.forEach(a => {
|
||||
switch (a.action) {
|
||||
case "install": println(` + install ${a.name} ${a.version}`); break
|
||||
case "upgrade": println(` ^ upgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "downgrade": println(` v downgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "reinstall": println(` = reinstall ${a.name} ${a.version}`); break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Search
|
||||
// ============================================================
|
||||
|
||||
// Dummy "remote" repository -- pretends to be a network query result.
|
||||
// Multiple entries per HopperPackageName represent multiple available
|
||||
// versions; the resolver picks among them.
|
||||
const FAKE_REMOTE_PACKAGES = [
|
||||
// doomster: single version, needs libgl 1.*
|
||||
{
|
||||
HopperPackageName: "doomster",
|
||||
HopperPackageVersion: "0.9.3",
|
||||
ProperName: "Doomster",
|
||||
ProperAuthor: "id Sortware",
|
||||
HopperPackageName: "doomster", HopperPackageVersion: "0.9.3",
|
||||
ProperName: "Doomster", ProperAuthor: "id Sortware",
|
||||
ProperDescription: "First-person shooter game for TSVM",
|
||||
HopperProvides: "doomster;",
|
||||
HopperRequires: "tvdos 1.*;libgl 1.*"
|
||||
HopperProvides: "doomster;", HopperRequires: "tvdos 1.*;libgl 1.*"
|
||||
},
|
||||
|
||||
// libfft: three versions
|
||||
{
|
||||
HopperPackageName: "libfft",
|
||||
HopperPackageVersion: "0.1.0",
|
||||
ProperName: "LibFFT",
|
||||
ProperAuthor: "Soraya Vaughn",
|
||||
HopperPackageName: "libfft", HopperPackageVersion: "0.1.0",
|
||||
ProperName: "LibFFT", ProperAuthor: "Soraya Vaughn",
|
||||
ProperDescription: "Fast Fourier Transform library for TSVM",
|
||||
HopperProvides: "libfft;",
|
||||
HopperRequires: "tvdos 1.*"
|
||||
HopperProvides: "libfft;", HopperRequires: "tvdos 1.*"
|
||||
},
|
||||
{
|
||||
HopperPackageName: "chatlite",
|
||||
HopperPackageVersion: "2.1.5",
|
||||
ProperName: "ChatLite",
|
||||
ProperAuthor: "TerraNetworks Co.",
|
||||
HopperPackageName: "libfft", HopperPackageVersion: "0.2.0",
|
||||
ProperName: "LibFFT", ProperAuthor: "Soraya Vaughn",
|
||||
ProperDescription: "Fast Fourier Transform library for TSVM",
|
||||
HopperProvides: "libfft;", HopperRequires: "tvdos 1.*"
|
||||
},
|
||||
{
|
||||
HopperPackageName: "libfft", HopperPackageVersion: "1.0.0",
|
||||
ProperName: "LibFFT", ProperAuthor: "Soraya Vaughn",
|
||||
ProperDescription: "Fast Fourier Transform library for TSVM",
|
||||
HopperProvides: "libfft;", HopperRequires: "tvdos 1.*"
|
||||
},
|
||||
|
||||
// chatlite: 2.1.5 fits installed wintex 1.*; 3.0.0 demands wintex 2.*
|
||||
{
|
||||
HopperPackageName: "chatlite", HopperPackageVersion: "2.1.5",
|
||||
ProperName: "ChatLite", ProperAuthor: "TerraNetworks Co.",
|
||||
ProperDescription: "Lightweight IRC-style chat client",
|
||||
HopperProvides: "chatlite;",
|
||||
HopperRequires: "tvdos 1.*;wintex 1.*"
|
||||
HopperProvides: "chatlite;", HopperRequires: "tvdos 1.*;wintex 1.*"
|
||||
},
|
||||
{
|
||||
HopperPackageName: "snakey",
|
||||
HopperPackageVersion: "1.4.0",
|
||||
ProperName: "Snakey",
|
||||
ProperAuthor: "Iben Holst",
|
||||
HopperPackageName: "chatlite", HopperPackageVersion: "3.0.0",
|
||||
ProperName: "ChatLite", ProperAuthor: "TerraNetworks Co.",
|
||||
ProperDescription: "Lightweight IRC-style chat client",
|
||||
HopperProvides: "chatlite;", HopperRequires: "tvdos 1.*;wintex 2.*"
|
||||
},
|
||||
|
||||
// snakey
|
||||
{
|
||||
HopperPackageName: "snakey", HopperPackageVersion: "1.4.0",
|
||||
ProperName: "Snakey", ProperAuthor: "Iben Holst",
|
||||
ProperDescription: "Classic snake game with TerranBASIC scripting",
|
||||
HopperProvides: "snakey;",
|
||||
HopperRequires: "tvdos 1.*;libterranbasic 1.*"
|
||||
}
|
||||
HopperProvides: "snakey;", HopperRequires: "tvdos 1.*;libterranbasic 1.*"
|
||||
},
|
||||
|
||||
// libgl future version (lets superchef pull in an upgrade)
|
||||
{
|
||||
HopperPackageName: "libgl", HopperPackageVersion: "2.0.0",
|
||||
ProperName: "LibGL", ProperAuthor: "CuriousTorvald",
|
||||
ProperDescription: "TVDOS Graphics Library, next-generation",
|
||||
HopperProvides: "libgl;", HopperRequires: ""
|
||||
},
|
||||
|
||||
// superchef: requires libgl 2.* -- triggers an upgrade of installed libgl
|
||||
{
|
||||
HopperPackageName: "superchef", HopperPackageVersion: "1.0.0",
|
||||
ProperName: "SuperChef", ProperAuthor: "Pavlo Kvasnik",
|
||||
ProperDescription: "Recipe-driven build automation",
|
||||
HopperProvides: "superchef;", HopperRequires: "tvdos 1.*;libgl 2.*"
|
||||
},
|
||||
|
||||
// phantomedit: needs something that does not exist -> unresolvable
|
||||
{
|
||||
HopperPackageName: "phantomedit", HopperPackageVersion: "0.1.0",
|
||||
ProperName: "PhantomEdit", ProperAuthor: "anonymous",
|
||||
ProperDescription: "Editor for non-existent files",
|
||||
HopperProvides: "phantomedit;", HopperRequires: "libquantum 1.*"
|
||||
},
|
||||
|
||||
// fake updated version of Microtone
|
||||
{
|
||||
HopperPackageName: "microtone", HopperPackageVersion: "1.3.0",
|
||||
ProperName: "Microtone", ProperAuthor: "CuriousTorvald",
|
||||
ProperDescription: "Fake updated version of Microtone",
|
||||
HopperProvides: "microtone;", HopperRequires: "tvdos 1.*;libgl 2.*"
|
||||
},
|
||||
]
|
||||
|
||||
// Indirection point: in the future this should hit a real upstream.
|
||||
function fetchRemoteCandidates() {
|
||||
return FAKE_REMOTE_PACKAGES
|
||||
}
|
||||
|
||||
function fieldCandidates(manifest, field) {
|
||||
switch (field) {
|
||||
case "provides": return splitList(manifest.HopperProvides || "")
|
||||
@@ -181,14 +505,60 @@ function cmdInstall(args) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const verSuffix = version ? ` (v${version})` : ""
|
||||
const targetConstraint = version || "*"
|
||||
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
|
||||
println(`Resolving ${query}${verSuffix} ...`)
|
||||
println(`Fetching manifest from remote ...`)
|
||||
println(`Resolving dependencies ...`)
|
||||
println(`Downloading package payload ...`)
|
||||
println(`Verifying integrity ...`)
|
||||
println(`Writing manifest to ${SYSTEM_PACKEAGE_DEF_DIR}/${query}${MANIFEST_EXT} ...`)
|
||||
println(`Installed ${query}${verSuffix}.`)
|
||||
|
||||
const idx = buildCandidateIndex()
|
||||
|
||||
// Sanity check: target must exist in the index (installed or upstream).
|
||||
if (findProviders(idx, query).length === 0) {
|
||||
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
|
||||
return 4
|
||||
}
|
||||
|
||||
// Seed order matters: the target goes FIRST so its (possibly tight)
|
||||
// constraints can drive upgrades of dependencies. The installed-set
|
||||
// requirements follow at "*" so the resolver still has to keep them
|
||||
// alive (preferring installed candidates when their version still fits,
|
||||
// otherwise upgrading or downgrading them).
|
||||
const seed = [{ name: query, constraint: targetConstraint }]
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (m.HopperPackageName === query) return
|
||||
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||
})
|
||||
|
||||
const { chosen, issues } = resolveAll(idx, seed)
|
||||
if (issues.length > 0) {
|
||||
printerrln("Resolution failed:")
|
||||
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||
printerrln("")
|
||||
printerrln("No solution found -- not installable.")
|
||||
return 3
|
||||
}
|
||||
|
||||
const plan = classifyPlan(idx, chosen)
|
||||
printPlan(plan, query)
|
||||
|
||||
const changing = plan.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) return 0
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with installation?", true)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
println("Fetching manifests from remote ...")
|
||||
println("Downloading package payloads ...")
|
||||
println("Verifying integrity ...")
|
||||
changing.forEach(a => {
|
||||
if (a.action === "install" || a.action === "reinstall") {
|
||||
println(` ${a.action} ${a.name} ${a.version}`)
|
||||
} else {
|
||||
println(` ${a.action} ${a.name} ${a.from} -> ${a.to}`)
|
||||
}
|
||||
})
|
||||
println("(dummy install: no files were actually created)")
|
||||
return 0
|
||||
}
|
||||
@@ -262,6 +632,11 @@ function cmdRemove(args) {
|
||||
println(` ${m._manifestPath}`)
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with removal?", false)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
println("(dry-run: no files were actually deleted)")
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user