Updates
This commit is contained in:
@@ -304,7 +304,9 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
},
|
||||
async saveVault() {
|
||||
await saveVaultToDrive(this)
|
||||
}
|
||||
},
|
||||
/** Populated by `loadBarerc` / first `execShellLine` (default alias table). */
|
||||
shellAliases: undefined
|
||||
}
|
||||
|
||||
await applyGuestEnv(ctx)
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* git dispatcher backed by isomorphic-git (JavaScript implementation, not GNU git).
|
||||
* Requires Node-style fs from createGitFsFromVfs(ctx.vfs).
|
||||
*/
|
||||
|
||||
import path from 'path'
|
||||
import * as git from 'isomorphic-git'
|
||||
import { createGitFsFromVfs } from './git-fs-adapter.js'
|
||||
|
||||
/** @type {import('isomorphic-git').HttpClient | null} */
|
||||
let httpNode = null
|
||||
|
||||
/**
|
||||
* isomorphic-git/http/web calls bare `fetch`. Node 18+ and browsers have it;
|
||||
* Pear/Bare does not unless we install holepunch's fetch (bare-http1/https).
|
||||
*/
|
||||
async function ensureFetchForIsomorphicGit() {
|
||||
if (typeof globalThis.fetch === 'function') return
|
||||
try {
|
||||
const m = await import('bare-fetch')
|
||||
const f = m.default
|
||||
if (typeof f !== 'function') return
|
||||
globalThis.fetch = f
|
||||
if (f.Request) globalThis.Request = f.Request
|
||||
if (f.Response) globalThis.Response = f.Response
|
||||
if (f.Headers) globalThis.Headers = f.Headers
|
||||
if (typeof global !== 'undefined') {
|
||||
global.fetch = f
|
||||
if (f.Request) global.Request = f.Request
|
||||
if (f.Response) global.Response = f.Response
|
||||
if (f.Headers) global.Headers = f.Headers
|
||||
}
|
||||
} catch {
|
||||
/* bare-fetch is Bare-only; Node tests already have native fetch */
|
||||
}
|
||||
}
|
||||
|
||||
async function getHttp() {
|
||||
if (httpNode) return httpNode
|
||||
const mode = globalThis.process?.env?.BARE_OS_GIT_HTTP
|
||||
if (mode === 'web') {
|
||||
await ensureFetchForIsomorphicGit()
|
||||
const m = await import('isomorphic-git/http/web')
|
||||
httpNode = m.default
|
||||
return httpNode
|
||||
}
|
||||
try {
|
||||
const m = await import('isomorphic-git/http/node')
|
||||
httpNode = m.default
|
||||
return httpNode
|
||||
} catch {
|
||||
await ensureFetchForIsomorphicGit()
|
||||
if (typeof globalThis.fetch !== 'function') {
|
||||
throw new Error(
|
||||
'git over HTTP needs fetch (Node http client failed and global fetch is missing). On Pear/Bare, bare-fetch should be installed.'
|
||||
)
|
||||
}
|
||||
const m = await import('isomorphic-git/http/web')
|
||||
httpNode = m.default
|
||||
return httpNode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
function gitAuthor(ctx) {
|
||||
const env = /** @type {Record<string, string>} */ (ctx.env || {})
|
||||
return {
|
||||
name: env.GIT_AUTHOR_NAME || env.USER || 'bare-os',
|
||||
email: env.GIT_AUTHOR_EMAIL || 'nobody@localhost',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
timezoneOffset: new Date().getTimezoneOffset()
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp(console) {
|
||||
console.log(`bare-os git (isomorphic-git) — not full GNU git.
|
||||
|
||||
Usage: git [--git-dir <path>] [-C <path>] <command> [args]
|
||||
|
||||
Commands:
|
||||
version, --version Show isomorphic-git version
|
||||
init [--bare] Create a repository
|
||||
clone <url> [dir] Clone (needs network)
|
||||
status Working tree status
|
||||
add <pathspec...> Stage files (use "." for all)
|
||||
rm <pathspec...> Remove from index and working tree
|
||||
commit [-m msg] Create commit
|
||||
log [--oneline] [n] History
|
||||
branch [-a] [name] List or create branch
|
||||
checkout <ref> Switch branch or restore files
|
||||
fetch [remote] Fetch from remote
|
||||
pull [remote] Fetch + merge
|
||||
push [remote] [ref] Push to remote
|
||||
remote [-v] | add | rm | rename
|
||||
tag [list | -a name -m msg]
|
||||
merge [--ff-only] <branch>
|
||||
config [--get] key [value]
|
||||
show [ref] Show commit or object
|
||||
stash (push|pop|list|apply|drop)
|
||||
reset [--hard] [commit]
|
||||
cherry-pick <commit>
|
||||
clean -fd Remove untracked (not implemented)
|
||||
hash-object|rev-parse|ls-files|diff (limited)
|
||||
|
||||
See https://isomorphic-git.org for API behavior.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
* @returns {{ rest: string[], dir: string, gitdir?: string }}
|
||||
*/
|
||||
function parseGlobalArgs(args) {
|
||||
let dir = null
|
||||
let gitdir
|
||||
const rest = []
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]
|
||||
if (a === '-C' && args[i + 1]) {
|
||||
dir = args[++i]
|
||||
continue
|
||||
}
|
||||
if (a.startsWith('-C') && a.length > 2) {
|
||||
dir = a.slice(2)
|
||||
continue
|
||||
}
|
||||
if (a === '--git-dir' && args[i + 1]) {
|
||||
gitdir = args[++i]
|
||||
continue
|
||||
}
|
||||
rest.push(a)
|
||||
}
|
||||
return { rest, dir: dir || '', gitdir }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string[]} argv
|
||||
*/
|
||||
export async function runGitCli(ctx, argv) {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs) {
|
||||
ctx.console.error('git: no vfs')
|
||||
return
|
||||
}
|
||||
|
||||
const fs = createGitFsFromVfs(vfs)
|
||||
const args = argv.slice(1)
|
||||
if (args.length === 0) {
|
||||
printHelp(ctx.console)
|
||||
return
|
||||
}
|
||||
|
||||
const { rest, dir: cDir, gitdir: optGitdir } = parseGlobalArgs(args)
|
||||
const baseDir = cDir ? vfs.resolveLogical(cDir) : vfs.getcwd()
|
||||
const dir = baseDir
|
||||
|
||||
const baseOpts = () => ({
|
||||
fs,
|
||||
dir,
|
||||
...(optGitdir ? { gitdir: optGitdir } : {})
|
||||
})
|
||||
|
||||
const sub = rest[0]
|
||||
const tail = rest.slice(1)
|
||||
|
||||
try {
|
||||
if (sub === '--version' || sub === '-v' || sub === 'version') {
|
||||
const v = await git.version()
|
||||
ctx.console.log(`isomorphic-git ${v}`)
|
||||
return
|
||||
}
|
||||
if (sub === 'help' || sub === '--help' || sub === '-h') {
|
||||
if (tail[0]) {
|
||||
ctx.console.log(`git help ${tail[0]}: see isomorphic-git docs`)
|
||||
} else {
|
||||
printHelp(ctx.console)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'init') {
|
||||
const bare = tail.includes('--bare')
|
||||
await git.init({ ...baseOpts(), bare, defaultBranch: 'main' })
|
||||
ctx.console.log(
|
||||
bare ? 'Initialized empty Git repository' : 'Initialized empty Git repository in ' + dir
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'clone') {
|
||||
const http = await getHttp()
|
||||
const url = tail[0]
|
||||
if (!url) {
|
||||
ctx.console.error('git clone: missing url')
|
||||
return
|
||||
}
|
||||
let outDir = tail[1] ? vfs.resolveLogical(tail[1]) : null
|
||||
if (!outDir) {
|
||||
try {
|
||||
const u = new URL(url.replace(/\.git\/?$/, ''))
|
||||
const seg = u.pathname.split('/').filter(Boolean).pop() || 'repo'
|
||||
outDir = path.posix.join(dir, seg.replace(/\.git$/, ''))
|
||||
} catch {
|
||||
const m = url.match(/[/:]([^/]+?)(?:\.git)?\/?$/)
|
||||
const seg = (m && m[1]) || 'repo'
|
||||
outDir = path.posix.join(dir, seg.replace(/\.git$/, ''))
|
||||
}
|
||||
}
|
||||
await git.clone({
|
||||
fs,
|
||||
http,
|
||||
dir: outDir,
|
||||
url,
|
||||
singleBranch: true,
|
||||
depth: 1
|
||||
})
|
||||
ctx.console.log('Cloned into ' + outDir)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'status') {
|
||||
const matrix = await git.statusMatrix({ ...baseOpts() })
|
||||
if (matrix.length === 0) {
|
||||
ctx.console.log('nothing to show (empty repository)')
|
||||
return
|
||||
}
|
||||
for (const row of matrix) {
|
||||
const [filepath, head, workdir, stage] = row
|
||||
const h = head === 1 ? 'H' : ' '
|
||||
const w = workdir === 2 ? 'M' : workdir === 1 ? ' ' : '?'
|
||||
const s = stage === 2 ? 'S' : stage === 1 ? ' ' : '?'
|
||||
ctx.console.log(`${h}${w}${s} ${filepath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const paths = tail.filter((t) => !t.startsWith('-'))
|
||||
if (paths.length === 0) paths.push('.')
|
||||
await git.add({ ...baseOpts(), filepath: paths.length === 1 ? paths[0] : paths })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'rm') {
|
||||
const paths = tail.filter((t) => !t.startsWith('-'))
|
||||
for (const p of paths) {
|
||||
await git.remove({ ...baseOpts(), filepath: p })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'commit') {
|
||||
let message = ''
|
||||
const t = [...tail]
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
if (t[i] === '-m' && t[i + 1]) {
|
||||
message = t[++i]
|
||||
} else if (t[i].startsWith('-m') && t[i].length > 2) {
|
||||
message = t[i].slice(2)
|
||||
}
|
||||
}
|
||||
if (!message) message = 'commit'
|
||||
const sha = await git.commit({
|
||||
...baseOpts(),
|
||||
message,
|
||||
author: gitAuthor(ctx)
|
||||
})
|
||||
ctx.console.log(`[${dir}] ${sha}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'log') {
|
||||
const oneline = tail.includes('--oneline')
|
||||
let depth
|
||||
const num = tail.find((x) => /^\d+$/.test(x))
|
||||
if (num) depth = Number.parseInt(num, 10)
|
||||
const commits = await git.log({ ...baseOpts(), depth: depth || undefined })
|
||||
for (const c of commits) {
|
||||
if (oneline) ctx.console.log(`${c.oid.slice(0, 7)} ${c.commit.message.split('\n')[0]}`)
|
||||
else {
|
||||
ctx.console.log(`commit ${c.oid}`)
|
||||
ctx.console.log(c.commit.message)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'branch') {
|
||||
if (tail.includes('-a') || tail.includes('--all')) {
|
||||
const local = await git.listBranches({ ...baseOpts() })
|
||||
for (const b of local) ctx.console.log(b)
|
||||
return
|
||||
}
|
||||
if (tail[0] && !tail[0].startsWith('-')) {
|
||||
await git.branch({ ...baseOpts(), ref: tail[0], checkout: false })
|
||||
ctx.console.log('Branch ' + tail[0])
|
||||
return
|
||||
}
|
||||
const branches = await git.listBranches({ ...baseOpts() })
|
||||
const cur = await git.currentBranch({ ...baseOpts() })
|
||||
for (const b of branches) {
|
||||
ctx.console.log((b === cur ? '* ' : ' ') + b)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'checkout') {
|
||||
const ref = tail.filter((x) => !x.startsWith('-'))[0]
|
||||
if (!ref) {
|
||||
ctx.console.error('git checkout: missing ref')
|
||||
return
|
||||
}
|
||||
await git.checkout({ ...baseOpts(), ref })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'fetch') {
|
||||
const http = await getHttp()
|
||||
const remote = tail[0] || 'origin'
|
||||
await git.fetch({ ...baseOpts(), http, remote })
|
||||
ctx.console.log('fetch: done')
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'pull') {
|
||||
const http = await getHttp()
|
||||
const remote = tail[0] || 'origin'
|
||||
await git.pull({
|
||||
...baseOpts(),
|
||||
http,
|
||||
remote,
|
||||
author: gitAuthor(ctx)
|
||||
})
|
||||
ctx.console.log('pull: done')
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'push') {
|
||||
const http = await getHttp()
|
||||
const remote = tail[0] || 'origin'
|
||||
const ref = tail[1]
|
||||
await git.push({ ...baseOpts(), http, remote, ref })
|
||||
ctx.console.log('push: done')
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'remote') {
|
||||
if (tail[0] === 'add' && tail[1] && tail[2]) {
|
||||
await git.addRemote({ ...baseOpts(), remote: tail[1], url: tail[2] })
|
||||
return
|
||||
}
|
||||
if (tail[0] === 'remove' || tail[0] === 'rm') {
|
||||
await git.deleteRemote({ ...baseOpts(), remote: tail[1] })
|
||||
return
|
||||
}
|
||||
if (tail[0] === 'rename' && tail[1] && tail[2]) {
|
||||
ctx.console.error('git remote rename: use deleteRemote + addRemote')
|
||||
return
|
||||
}
|
||||
const list = await git.listRemotes({ ...baseOpts() })
|
||||
const verbose = tail.includes('-v') || tail.includes('-vv')
|
||||
for (const r of list) {
|
||||
ctx.console.log(verbose ? `${r.remote}\t${r.url}` : r.remote)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'tag') {
|
||||
if (tail[0] === '-a' && tail[1]) {
|
||||
let msg = ''
|
||||
const i = tail.indexOf('-m')
|
||||
if (i !== -1 && tail[i + 1]) msg = tail[i + 1]
|
||||
await git.tag({ ...baseOpts(), ref: tail[1], message: msg })
|
||||
return
|
||||
}
|
||||
const tags = await git.listTags({ ...baseOpts() })
|
||||
for (const t of tags) ctx.console.log(t)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'merge') {
|
||||
const ffOnly = tail.includes('--ff-only')
|
||||
const branch = tail.filter((x) => !x.startsWith('-'))[0]
|
||||
if (!branch) {
|
||||
ctx.console.error('git merge: missing branch/ref')
|
||||
return
|
||||
}
|
||||
const r = await git.merge({
|
||||
...baseOpts(),
|
||||
ours: 'HEAD',
|
||||
theirs: branch,
|
||||
fastForwardOnly: ffOnly,
|
||||
author: gitAuthor(ctx)
|
||||
})
|
||||
ctx.console.log(JSON.stringify(r))
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'config') {
|
||||
const positional = tail.filter((x) => !x.startsWith('-'))
|
||||
const cfgPath = positional[0]
|
||||
const val = positional[1]
|
||||
if (!cfgPath) {
|
||||
ctx.console.error('git config: missing key')
|
||||
return
|
||||
}
|
||||
if (val === undefined || tail.includes('--get')) {
|
||||
const v = await git.getConfig({ ...baseOpts(), path: cfgPath })
|
||||
ctx.console.log(v != null ? String(v) : '')
|
||||
return
|
||||
}
|
||||
await git.setConfig({ ...baseOpts(), path: cfgPath, value: val })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'show') {
|
||||
const ref = tail[0] || 'HEAD'
|
||||
const oid = await git.resolveRef({ ...baseOpts(), ref })
|
||||
const { commit } = await git.readCommit({ ...baseOpts(), oid })
|
||||
ctx.console.log(commit.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'stash') {
|
||||
const raw = tail[0]
|
||||
const op =
|
||||
raw === 'push' || raw === 'pop' || raw === 'list' || raw === 'drop' || raw === 'apply'
|
||||
? raw
|
||||
: 'push'
|
||||
const msg =
|
||||
(raw === 'push' && tail[1] && !tail[1].startsWith('-') ? tail[1] : null) ||
|
||||
(raw === 'save' && tail[1] && !tail[1].startsWith('-') ? tail[1] : null) ||
|
||||
'WIP'
|
||||
if (op === 'push' || raw === 'save') {
|
||||
await git.stash({ ...baseOpts(), op: 'push', message: msg })
|
||||
return
|
||||
}
|
||||
if (op === 'pop') {
|
||||
await git.stash({ ...baseOpts(), op: 'pop' })
|
||||
return
|
||||
}
|
||||
if (op === 'list') {
|
||||
const s = await git.stash({ ...baseOpts(), op: 'list' })
|
||||
ctx.console.log(JSON.stringify(s))
|
||||
return
|
||||
}
|
||||
if (op === 'drop' || op === 'apply') {
|
||||
await git.stash({ ...baseOpts(), op })
|
||||
return
|
||||
}
|
||||
ctx.console.error('git stash: use push | pop | list | apply | drop')
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'reset') {
|
||||
const hard = tail.includes('--hard')
|
||||
const ref = tail.filter((x) => !x.startsWith('-')).pop() || 'HEAD'
|
||||
await git.resetIndex({ ...baseOpts(), filepath: '.', ref: hard ? ref : undefined })
|
||||
ctx.console.log('reset (limited): see isomorphic-git resetIndex')
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'cherry-pick') {
|
||||
const c = tail.filter((x) => !x.startsWith('-'))[0]
|
||||
if (!c) {
|
||||
ctx.console.error('git cherry-pick: missing commit oid')
|
||||
return
|
||||
}
|
||||
const oid = await git.expandOid({ ...baseOpts(), oid: c })
|
||||
await git.cherryPick({ ...baseOpts(), oid })
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'clean') {
|
||||
ctx.console.error('git clean: not implemented; remove files via shell rm')
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'rev-parse') {
|
||||
const ref = tail.filter((x) => !x.startsWith('-'))[0] || 'HEAD'
|
||||
const oid = await git.resolveRef({ ...baseOpts(), ref })
|
||||
ctx.console.log(oid)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'hash-object') {
|
||||
const w = tail.includes('-w')
|
||||
const file = tail.filter((x) => !x.startsWith('-')).pop()
|
||||
if (!file) {
|
||||
ctx.console.error('git hash-object: missing file')
|
||||
return
|
||||
}
|
||||
const full = path.posix.isAbsolute(file) ? file : path.posix.join(dir, file)
|
||||
const buf = await fs.promises.readFile(full)
|
||||
const u8 = buf instanceof Buffer ? new Uint8Array(buf) : buf
|
||||
const { oid } = await git.hashBlob({ object: u8 })
|
||||
if (w) await git.writeBlob({ ...baseOpts(), blob: u8 })
|
||||
ctx.console.log(oid)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'ls-files') {
|
||||
const files = await git.listFiles({ ...baseOpts() })
|
||||
for (const f of files) ctx.console.log(f)
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'diff') {
|
||||
ctx.console.error(
|
||||
'git diff: use statusMatrix or external tools; full diff not wired in bare-os git'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.console.error(
|
||||
`git: '${sub}' is not supported by bare-os (isomorphic-git wrapper). Try: git help`
|
||||
)
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e)
|
||||
ctx.console.error('git: ' + msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Node fs.promises-shaped adapter over ctx.vfs for isomorphic-git.
|
||||
* Empty directories are represented by a hidden marker file (filtered from readdir).
|
||||
* Stat/lstat return Node-like objects (mtime/ctime, mtimeMs/ctimeMs, mode, dev, ino, uid, gid)
|
||||
* because isomorphic-git normalizeStats() requires them for the index.
|
||||
*/
|
||||
|
||||
import path from 'path'
|
||||
|
||||
const MARKER = '.bareos_empty'
|
||||
|
||||
function err(code, message) {
|
||||
const e = new Error(message || code)
|
||||
// @ts-ignore
|
||||
e.code = code
|
||||
return e
|
||||
}
|
||||
|
||||
/** Default Unix mode bits isomorphic-git expects for Git trees (see normalizeMode in upstream). */
|
||||
const MODE_FILE = 0o100644
|
||||
const MODE_DIR = 0o040755
|
||||
const MODE_SYMLINK = 0o120000
|
||||
|
||||
/**
|
||||
* Node fs.Stats-shaped object for isomorphic-git (normalizeStats reads mtime/ctime and numeric ids).
|
||||
*/
|
||||
export class VfsGitStats {
|
||||
/**
|
||||
* @param {'file' | 'directory' | 'symlink'} type
|
||||
* @param {number} [size]
|
||||
* @param {{ mtimeMs?: number, ctimeMs?: number, mode?: number, dev?: number, ino?: number, uid?: number, gid?: number }} [opts]
|
||||
*/
|
||||
constructor(type, size = 0, opts = {}) {
|
||||
this.type = type
|
||||
this.size = size
|
||||
const ms = typeof opts.mtimeMs === 'number' ? opts.mtimeMs : Date.now()
|
||||
this.mtimeMs = ms
|
||||
this.ctimeMs = typeof opts.ctimeMs === 'number' ? opts.ctimeMs : ms
|
||||
this.atimeMs = ms
|
||||
this.birthtimeMs = this.ctimeMs
|
||||
this.mtime = new Date(this.mtimeMs)
|
||||
this.ctime = new Date(this.ctimeMs)
|
||||
this.atime = this.mtime
|
||||
this.birthtime = this.ctime
|
||||
this.dev = opts.dev ?? 0
|
||||
this.ino = opts.ino ?? 0
|
||||
this.mode = opts.mode ?? (type === 'directory' ? MODE_DIR : type === 'symlink' ? MODE_SYMLINK : MODE_FILE)
|
||||
this.uid = opts.uid ?? 0
|
||||
this.gid = opts.gid ?? 0
|
||||
this.blocks = 0
|
||||
this.blksize = 4096
|
||||
this.nlink = 1
|
||||
this.rdev = 0
|
||||
}
|
||||
|
||||
isFile() {
|
||||
return this.type === 'file'
|
||||
}
|
||||
|
||||
isDirectory() {
|
||||
return this.type === 'directory'
|
||||
}
|
||||
|
||||
isSymbolicLink() {
|
||||
return this.type === 'symlink'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map vfs lstat/stat result to Stats or throw ENOENT.
|
||||
* @param {Record<string, unknown> | null} st
|
||||
*/
|
||||
function toStats(st) {
|
||||
if (!st) throw err('ENOENT', 'ENOENT')
|
||||
const ts = {
|
||||
mtimeMs: typeof st.mtimeMs === 'number' ? st.mtimeMs : undefined,
|
||||
ctimeMs: typeof st.ctimeMs === 'number' ? st.ctimeMs : undefined,
|
||||
mode: typeof st.mode === 'number' ? st.mode : undefined
|
||||
}
|
||||
if (st.type === 'file') return new VfsGitStats('file', Number(st.size) || 0, ts)
|
||||
if (st.type === 'directory') return new VfsGitStats('directory', 0, ts)
|
||||
if (st.type === 'symlink') return new VfsGitStats('symlink', 0, ts)
|
||||
throw err('ENOENT', 'ENOENT')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ readFile: Function, writeFile: Function, unlink: Function, readdir: Function, stat: Function, lstat: Function, readlink: Function, symlink: Function }} vfs
|
||||
*/
|
||||
export function createGitFsFromVfs(vfs) {
|
||||
async function rawReaddir(p) {
|
||||
return vfs.readdir(p)
|
||||
}
|
||||
|
||||
async function readDirFiltered(p) {
|
||||
const names = await rawReaddir(p)
|
||||
return names.filter((n) => n !== MARKER)
|
||||
}
|
||||
|
||||
async function stat(p) {
|
||||
const st = await vfs.stat(p)
|
||||
return toStats(st)
|
||||
}
|
||||
|
||||
async function lstat(p) {
|
||||
const st = await vfs.lstat(p)
|
||||
return toStats(st)
|
||||
}
|
||||
|
||||
async function readFile(p, opts = {}) {
|
||||
const buf = await vfs.readFile(p)
|
||||
if (buf == null) throw err('ENOENT', 'ENOENT')
|
||||
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
||||
if (opts.encoding === 'utf8' || opts.encoding === 'utf-8') {
|
||||
return Buffer.from(u8).toString('utf8')
|
||||
}
|
||||
return Buffer.from(u8)
|
||||
}
|
||||
|
||||
async function writeFile(p, data, _opts = {}) {
|
||||
const body =
|
||||
typeof data === 'string'
|
||||
? Buffer.from(data, 'utf8')
|
||||
: data instanceof Buffer
|
||||
? data
|
||||
: Buffer.from(data)
|
||||
await vfs.writeFile(p, body)
|
||||
}
|
||||
|
||||
async function unlink(p) {
|
||||
try {
|
||||
await vfs.unlink(p)
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') throw e
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function mkdir(p, options = {}) {
|
||||
const recursive = Boolean(options.recursive)
|
||||
const norm = path.posix.normalize(p)
|
||||
if (norm === '/' || norm === '') return
|
||||
|
||||
try {
|
||||
const s = await stat(norm)
|
||||
if (s.isDirectory()) return
|
||||
if (s.isFile()) throw err('EEXIST', 'EEXIST')
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e
|
||||
const parent = path.posix.dirname(norm)
|
||||
if (parent !== norm && parent !== '/' && parent !== '') {
|
||||
try {
|
||||
const ps = await stat(parent)
|
||||
if (!ps.isDirectory()) throw err('ENOTDIR', 'ENOTDIR')
|
||||
} catch (pe) {
|
||||
if (pe.code === 'ENOENT') {
|
||||
if (recursive) await mkdir(parent, { recursive: true })
|
||||
else throw err('ENOENT', 'ENOENT')
|
||||
} else throw pe
|
||||
}
|
||||
}
|
||||
const markerPath = path.posix.join(norm, MARKER)
|
||||
await vfs.writeFile(markerPath, Buffer.alloc(0))
|
||||
}
|
||||
}
|
||||
|
||||
async function rmdir(p, opts = {}) {
|
||||
if (opts.recursive) {
|
||||
await rm(p, { recursive: true, force: true })
|
||||
return
|
||||
}
|
||||
const names = await readDirFiltered(p)
|
||||
if (names.length) throw err('ENOTEMPTY', 'ENOTEMPTY')
|
||||
const markerPath = path.posix.join(p, MARKER)
|
||||
try {
|
||||
await vfs.unlink(markerPath)
|
||||
} catch (e) {
|
||||
if (e && e.code !== 'ENOENT') throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function rm(p, opts = {}) {
|
||||
const recursive = Boolean(opts.recursive)
|
||||
const force = Boolean(opts.force)
|
||||
let st
|
||||
try {
|
||||
st = await lstat(p)
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT' && force) return
|
||||
throw e
|
||||
}
|
||||
if (st.isSymbolicLink() || st.isFile()) {
|
||||
await unlink(p)
|
||||
return
|
||||
}
|
||||
if (!st.isDirectory()) {
|
||||
await unlink(p)
|
||||
return
|
||||
}
|
||||
if (!recursive) {
|
||||
await rmdir(p, {})
|
||||
return
|
||||
}
|
||||
const children = await rawReaddir(p)
|
||||
for (const name of children) {
|
||||
const full = path.posix.join(p, name)
|
||||
await rm(full, { recursive: true, force: true })
|
||||
}
|
||||
try {
|
||||
await vfs.unlink(path.posix.join(p, MARKER))
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
try {
|
||||
await rmdir(p, {})
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT' && e.code !== 'ENOTEMPTY') throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function readdir(p) {
|
||||
try {
|
||||
await stat(p)
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return null
|
||||
throw e
|
||||
}
|
||||
return readDirFiltered(p)
|
||||
}
|
||||
|
||||
async function readlink(p) {
|
||||
return vfs.readlink(p)
|
||||
}
|
||||
|
||||
async function symlink(target, p) {
|
||||
return vfs.symlink(target, p)
|
||||
}
|
||||
|
||||
async function chmod(_p, _mode) {
|
||||
/* no-op: hyperdrive mode not exposed */
|
||||
}
|
||||
|
||||
const promises = {
|
||||
readFile,
|
||||
writeFile,
|
||||
unlink,
|
||||
mkdir,
|
||||
rmdir,
|
||||
rm,
|
||||
stat,
|
||||
lstat,
|
||||
readdir,
|
||||
readlink,
|
||||
symlink,
|
||||
chmod
|
||||
}
|
||||
|
||||
return { promises }
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import b4a from 'b4a'
|
||||
import { loadBarerc } from './shell.js'
|
||||
import {
|
||||
ACCOUNT_PATH,
|
||||
decodeAccount,
|
||||
@@ -59,6 +60,11 @@ export async function applyGuestEnv(ctx) {
|
||||
} catch (e) {
|
||||
ctx.console?.error?.('[bare-os] onIdentityGuest: ' + (e?.message || e))
|
||||
}
|
||||
try {
|
||||
await loadBarerc(ctx)
|
||||
} catch (e) {
|
||||
ctx.console?.error?.('[bare-os] loadBarerc: ' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +101,11 @@ export async function applyUnlockedEnv(ctx, publicKey, secretKey) {
|
||||
} catch (e) {
|
||||
ctx.console?.error?.('[bare-os] onIdentityUnlocked: ' + (e?.message || e))
|
||||
}
|
||||
try {
|
||||
await loadBarerc(ctx, { createSkeletonIfMissing: true })
|
||||
} catch (e) {
|
||||
ctx.console?.error?.('[bare-os] loadBarerc: ' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import b4a from 'b4a'
|
||||
import path from 'path'
|
||||
import unixPathResolve from 'unix-path-resolve'
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
||||
|
||||
/**
|
||||
* Run booter-hosted `git` (isomorphic-git) instead of eval'd /bin scripts.
|
||||
* Skip for explicit relative paths like `./git` so a workspace script can win.
|
||||
* @param {string} cmd
|
||||
*/
|
||||
function shouldDelegateGit(cmd) {
|
||||
if (cmd === 'git') return true
|
||||
if (!cmd.includes('/')) return false
|
||||
if (path.posix.basename(cmd) !== 'git') return false
|
||||
if (cmd.startsWith('./') || cmd.startsWith('../')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Strip one leading Unix shebang so AsyncFunction does not see `#!` as invalid syntax. */
|
||||
function stripShebang(source) {
|
||||
if (typeof source !== 'string' || !source.startsWith('#!')) return source
|
||||
@@ -64,6 +78,11 @@ export async function runBinCommand(ctx, argv) {
|
||||
const vfs = ctx.vfs
|
||||
const pathEnv = (vfs && vfs.env && vfs.env.PATH) || '/bin'
|
||||
|
||||
if (shouldDelegateGit(cmd)) {
|
||||
const { runGitCli } = await import('./git-cli.js')
|
||||
return runGitCli(ctx, argv)
|
||||
}
|
||||
|
||||
if (cmd.includes('/')) {
|
||||
const abs = vfs.resolveLogical(cmd)
|
||||
const { drive, path } = vfs.route(abs)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path'
|
||||
import { statSync } from 'fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { fileURLToPath } from 'url'
|
||||
import os from 'bare-os'
|
||||
|
||||
function cwd() {
|
||||
|
||||
@@ -1,5 +1,193 @@
|
||||
import { runBinCommand } from './kernel-runner.js'
|
||||
|
||||
/** Max alias indirections (prevents cycles). */
|
||||
const MAX_ALIAS_DEPTH = 16
|
||||
|
||||
/**
|
||||
* Baseline aliases; `~/.barerc` and `unalias -a` merge/reset from this table.
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function defaultShellAliases() {
|
||||
return {
|
||||
ll: 'ls -la',
|
||||
la: 'ls -A',
|
||||
l: 'ls',
|
||||
'..': 'cd ..',
|
||||
'...': 'cd ../..'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand first argv[0] through alias chain; append original argv.slice(1).
|
||||
* @param {string[]} argv
|
||||
* @param {Record<string, string> | null | undefined} aliases
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function expandArgvAliases(argv, aliases) {
|
||||
if (!argv.length) return argv
|
||||
const map = aliases && typeof aliases === 'object' ? aliases : {}
|
||||
const out = [...argv]
|
||||
let depth = 0
|
||||
while (depth < MAX_ALIAS_DEPTH) {
|
||||
const first = out[0]
|
||||
const repl = map[first]
|
||||
if (repl == null || repl === '') break
|
||||
const words = tokenize(repl)
|
||||
.filter((t) => t.type === 'word')
|
||||
.map((t) => t.value)
|
||||
if (!words.length) break
|
||||
out.splice(0, 1, ...words)
|
||||
depth++
|
||||
}
|
||||
if (depth >= MAX_ALIAS_DEPTH && map[out[0]]) {
|
||||
throw new Error('alias: expansion nested too deeply')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip one layer of matching single/double quotes from alias value.
|
||||
* @param {string} val
|
||||
*/
|
||||
function stripAliasQuotes(val) {
|
||||
const v = val.trim()
|
||||
if (v.length >= 2) {
|
||||
const q = v[0]
|
||||
if ((q === "'" || q === '"') && v[v.length - 1] === q) {
|
||||
return v.slice(1, -1)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} rest content after `alias ` (name=value...)
|
||||
*/
|
||||
export function applyAliasDefinition(ctx, rest) {
|
||||
const eq = rest.indexOf('=')
|
||||
if (eq <= 0) return false
|
||||
const aname = rest.slice(0, eq).trim()
|
||||
if (!aname) return false
|
||||
let val = rest.slice(eq + 1).trim()
|
||||
val = stripAliasQuotes(val)
|
||||
if (!ctx.shellAliases) ctx.shellAliases = {}
|
||||
ctx.shellAliases[aname] = val
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string[]} argv argv for unalias builtin (includes 'unalias')
|
||||
*/
|
||||
export function runUnaliasBuiltin(ctx, argv, logError) {
|
||||
if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() }
|
||||
const args = argv.slice(1)
|
||||
if (args.length === 0) {
|
||||
logError('unalias: missing name')
|
||||
return
|
||||
}
|
||||
if (args.includes('-a')) {
|
||||
ctx.shellAliases = { ...defaultShellAliases() }
|
||||
return
|
||||
}
|
||||
for (const name of args) {
|
||||
if (name === '-a') continue
|
||||
delete ctx.shellAliases[name]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one line from ~/.barerc (without leading `unalias`).
|
||||
*/
|
||||
function applyBarercUnalias(ctx, rest) {
|
||||
if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() }
|
||||
const parts = rest.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1 && parts[0] === '-a') {
|
||||
ctx.shellAliases = { ...defaultShellAliases() }
|
||||
return
|
||||
}
|
||||
for (const name of parts) {
|
||||
if (name === '-a') ctx.shellAliases = { ...defaultShellAliases() }
|
||||
else delete ctx.shellAliases[name]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment-only template written on first login when `~/.barerc` is absent
|
||||
* (`loadBarerc(ctx, { createSkeletonIfMissing: true })`).
|
||||
*/
|
||||
export const BARERC_SKELETON = `# Bare OS — ~/.barerc (not full sh; only export, alias, unalias, # comments).
|
||||
#
|
||||
# export MY_VAR=value
|
||||
# alias gst='git status'
|
||||
# unalias ll
|
||||
`
|
||||
|
||||
/**
|
||||
* Load `~/.barerc`: only `export`, `alias`, `unalias`, comments, blank lines.
|
||||
* Resets aliases to defaults first, then applies file.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ createSkeletonIfMissing?: boolean }} [opts] If true and the file is missing, write {@link BARERC_SKELETON} (login / unlock only).
|
||||
*/
|
||||
export async function loadBarerc(ctx, opts = {}) {
|
||||
const { createSkeletonIfMissing = false } = opts
|
||||
const strict = globalThis.process?.env?.BARE_OS_STRICT_BARC === '1'
|
||||
ctx.shellAliases = { ...defaultShellAliases() }
|
||||
const vfs = ctx.vfs
|
||||
const env = vfs.env
|
||||
let buf = null
|
||||
try {
|
||||
buf = await vfs.readFile('~/.barerc')
|
||||
} catch {
|
||||
buf = null
|
||||
}
|
||||
|
||||
let text = null
|
||||
if (!buf && createSkeletonIfMissing) {
|
||||
try {
|
||||
await vfs.writeFile('~/.barerc', ctx.b4a.from(BARERC_SKELETON))
|
||||
text = BARERC_SKELETON
|
||||
} catch (e) {
|
||||
ctx.console?.error?.(
|
||||
'[bare-os] could not create ~/.barerc: ' + ((e && e.message) || e)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
if (!buf) return
|
||||
text = ctx.b4a.toString(buf)
|
||||
}
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) continue
|
||||
if (t.startsWith('export ')) {
|
||||
const rest = t.slice(7).trim()
|
||||
const eq = rest.indexOf('=')
|
||||
if (
|
||||
eq > 0 &&
|
||||
/^[A-Za-z_][A-Za-z0-9_]*$/.test(rest.slice(0, eq))
|
||||
) {
|
||||
env[rest.slice(0, eq)] = expandWord(rest.slice(eq + 1), env)
|
||||
} else if (strict) {
|
||||
ctx.console?.error?.('barerc: ignored: ' + t)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (t.startsWith('alias ')) {
|
||||
const ok = applyAliasDefinition(ctx, t.slice(6).trim())
|
||||
if (!ok && strict) ctx.console?.error?.('barerc: ignored: ' + t)
|
||||
continue
|
||||
}
|
||||
if (t.startsWith('unalias ')) {
|
||||
applyBarercUnalias(ctx, t.slice(8).trim())
|
||||
continue
|
||||
}
|
||||
if (strict) ctx.console?.error?.('barerc: ignored: ' + t)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ type: 'word', value: string } | { type: 'op', value: string }} Token
|
||||
*/
|
||||
@@ -246,7 +434,15 @@ export async function execShellLine(ctx, line) {
|
||||
|
||||
if (!cmd.argv.length) continue
|
||||
|
||||
const argv = cmd.argv.map((w) => expandWord(w, env))
|
||||
if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() }
|
||||
|
||||
let argv = cmd.argv.map((w) => expandWord(w, env))
|
||||
try {
|
||||
argv = expandArgvAliases(argv, ctx.shellAliases)
|
||||
} catch (e) {
|
||||
ctx.console.error((e && e.message) || String(e))
|
||||
continue
|
||||
}
|
||||
const name = argv[0]
|
||||
|
||||
if (cmd.redirIn) {
|
||||
@@ -267,7 +463,27 @@ export async function execShellLine(ctx, line) {
|
||||
|
||||
let code = 'ok'
|
||||
try {
|
||||
if (name === 'cd') {
|
||||
if (name === 'alias') {
|
||||
if (argv.length === 1) {
|
||||
const al = ctx.shellAliases || {}
|
||||
for (const k of Object.keys(al).sort()) {
|
||||
origLog.call(ctx.console, `${k}='${al[k]}'`)
|
||||
}
|
||||
} else {
|
||||
let okCount = 0
|
||||
for (const part of argv.slice(1)) {
|
||||
if (applyAliasDefinition(ctx, part)) okCount++
|
||||
}
|
||||
if (okCount === 0) {
|
||||
origErr.call(
|
||||
ctx.console,
|
||||
'alias: usage: alias name=value [name=value ...]'
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (name === 'unalias') {
|
||||
runUnaliasBuiltin(ctx, argv, (m) => origErr.call(ctx.console, m))
|
||||
} else if (name === 'cd') {
|
||||
try {
|
||||
await vfs.chdir(argv[1] || vfs.home)
|
||||
} catch (e) {
|
||||
|
||||
@@ -134,6 +134,138 @@ export function createVfs(systemDrive, personalDrive, env, mntRef = null) {
|
||||
return !!(e && e.value && e.value.blob)
|
||||
}
|
||||
|
||||
function joinLogical(base, name) {
|
||||
if (base === '/') return '/' + name.replace(/^\/+/, '')
|
||||
return unixPathResolve(base, name)
|
||||
}
|
||||
|
||||
async function lstatFromAbs(abs) {
|
||||
if (abs === '/mnt' || abs === '/mnt/') {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
const activeSeg = activeHomeBasename()
|
||||
if (activeSeg && abs === '/home') {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
const r = route(abs)
|
||||
if (r.virtualMntRoot) {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
if (abs === '/' || isHyperdriveRootPath(p)) {
|
||||
return { type: 'directory', path: abs }
|
||||
}
|
||||
const e = await entryOn(drive, p, { follow: false })
|
||||
if (e && e.value && e.value.linkname) {
|
||||
return { type: 'symlink', path: abs, linkname: e.value.linkname }
|
||||
}
|
||||
if (e && e.value && e.value.blob) {
|
||||
const bl = e.value.blob
|
||||
const len =
|
||||
typeof bl.byteLength === 'number'
|
||||
? bl.byteLength
|
||||
: (bl.blockLength ?? 0)
|
||||
return { type: 'file', size: len, path: abs }
|
||||
}
|
||||
const names = await (async () => {
|
||||
const out = []
|
||||
try {
|
||||
const stream = drive.readdir(p === '/' ? '/' : p)
|
||||
for await (const n of stream) out.push(n)
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
return out
|
||||
})()
|
||||
if (names.length) return { type: 'directory', path: abs }
|
||||
if (e) return { type: 'directory', path: abs }
|
||||
return null
|
||||
}
|
||||
|
||||
async function readdirFromAbs(abs) {
|
||||
if (abs === '/mnt' || abs === '/mnt/') {
|
||||
return [...getMntMap().keys()].sort()
|
||||
}
|
||||
const activeSeg = activeHomeBasename()
|
||||
if (activeSeg && abs === '/home') {
|
||||
return [activeSeg].sort()
|
||||
}
|
||||
const r = route(abs)
|
||||
if (r.virtualMntRoot) {
|
||||
return [...getMntMap().keys()].sort()
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
const folder = p === '/' ? '/' : p
|
||||
const names = []
|
||||
const stream = drive.readdir(folder)
|
||||
for await (const name of stream) {
|
||||
names.push(name)
|
||||
}
|
||||
if (activeSeg && abs === '/' && !names.includes('home')) {
|
||||
names.push('home')
|
||||
}
|
||||
if (abs === '/' && !names.includes('mnt')) {
|
||||
names.push('mnt')
|
||||
}
|
||||
return names.sort()
|
||||
}
|
||||
|
||||
async function delFromAbs(abs) {
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + abs)
|
||||
}
|
||||
if (r.mntReadOnly === true) {
|
||||
throw new Error('Read-only mount: ' + abs)
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
if (r.mntReadOnly === false) {
|
||||
if (isHyperdriveRootPath(p)) {
|
||||
throw new Error('Cannot unlink directory root')
|
||||
}
|
||||
return drive.del(p)
|
||||
}
|
||||
if (drive !== personalDrive) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + abs)
|
||||
}
|
||||
if (isHyperdriveRootPath(p)) {
|
||||
throw new Error('Cannot unlink directory root')
|
||||
}
|
||||
return drive.del(p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive remove: Hyperdrive `del` is one entry; directories need a tree walk.
|
||||
*/
|
||||
async function rmFromAbs(abs, { recursive = false, force = false } = {}) {
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) {
|
||||
if (force) return
|
||||
throw new Error('Read-only path (not under $HOME): ' + abs)
|
||||
}
|
||||
if (r.mntReadOnly === true) {
|
||||
if (force) return
|
||||
throw new Error('Read-only mount: ' + abs)
|
||||
}
|
||||
const st = await lstatFromAbs(abs)
|
||||
if (!st) {
|
||||
if (force) return
|
||||
throw new Error('ENOENT: no such file or directory')
|
||||
}
|
||||
if (st.type === 'symlink' || st.type === 'file') {
|
||||
return delFromAbs(abs)
|
||||
}
|
||||
if (st.type === 'directory') {
|
||||
if (!recursive) {
|
||||
throw new Error('Is a directory')
|
||||
}
|
||||
const names = await readdirFromAbs(abs)
|
||||
for (const n of names) {
|
||||
await rmFromAbs(joinLogical(abs, n), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get home() {
|
||||
return normalizeHome()
|
||||
@@ -217,6 +349,19 @@ export function createVfs(systemDrive, personalDrive, env, mntRef = null) {
|
||||
return drive.del(p)
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove file, symlink, or directory tree (with recursive). Single-key `del` is not enough for dirs.
|
||||
* @param {string} userPath
|
||||
* @param {{ recursive?: boolean, force?: boolean }} [opts]
|
||||
*/
|
||||
async rm(userPath, opts = {}) {
|
||||
const abs = resolveLogical(userPath)
|
||||
return rmFromAbs(abs, {
|
||||
recursive: Boolean(opts.recursive),
|
||||
force: Boolean(opts.force)
|
||||
})
|
||||
},
|
||||
|
||||
async exists(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const r = route(abs)
|
||||
@@ -228,32 +373,7 @@ export function createVfs(systemDrive, personalDrive, env, mntRef = null) {
|
||||
|
||||
/** @returns {Promise<string[]>} */
|
||||
async readdir(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
if (abs === '/mnt' || abs === '/mnt/') {
|
||||
return [...getMntMap().keys()].sort()
|
||||
}
|
||||
const activeSeg = activeHomeBasename()
|
||||
if (activeSeg && abs === '/home') {
|
||||
return [activeSeg].sort()
|
||||
}
|
||||
const r = route(abs)
|
||||
if (r.virtualMntRoot) {
|
||||
return [...getMntMap().keys()].sort()
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
const folder = p === '/' ? '/' : p
|
||||
const names = []
|
||||
const stream = drive.readdir(folder)
|
||||
for await (const name of stream) {
|
||||
names.push(name)
|
||||
}
|
||||
if (activeSeg && abs === '/' && !names.includes('home')) {
|
||||
names.push('home')
|
||||
}
|
||||
if (abs === '/' && !names.includes('mnt')) {
|
||||
names.push('mnt')
|
||||
}
|
||||
return names.sort()
|
||||
return readdirFromAbs(resolveLogical(userPath))
|
||||
},
|
||||
|
||||
async stat(userPath) {
|
||||
@@ -298,6 +418,58 @@ export function createVfs(systemDrive, personalDrive, env, mntRef = null) {
|
||||
if (names.length) return { type: 'directory', path: abs }
|
||||
if (e) return { type: 'directory', path: abs }
|
||||
return null
|
||||
},
|
||||
|
||||
/**
|
||||
* Like stat but do not follow symlinks at the final path (for isomorphic-git lstat).
|
||||
*/
|
||||
async lstat(userPath) {
|
||||
return lstatFromAbs(resolveLogical(userPath))
|
||||
},
|
||||
|
||||
async readlink(userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) {
|
||||
throw new Error('EINVAL readlink')
|
||||
}
|
||||
if (r.mntReadOnly === true) {
|
||||
throw new Error('Read-only mount: ' + userPath)
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
if (isHyperdriveRootPath(p)) throw new Error('EINVAL readlink')
|
||||
const e = await entryOn(drive, p, { follow: false })
|
||||
if (e && e.value && e.value.linkname) return e.value.linkname
|
||||
throw new Error('EINVAL not a symlink')
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} target link text (stored as-is)
|
||||
* @param {string} userPath new symlink path
|
||||
*/
|
||||
async symlink(target, userPath) {
|
||||
const abs = resolveLogical(userPath)
|
||||
const r = route(abs)
|
||||
if (r.virtualHomeDir || r.virtualMntRoot) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + userPath)
|
||||
}
|
||||
if (r.mntReadOnly === true) {
|
||||
throw new Error('Read-only mount: ' + userPath)
|
||||
}
|
||||
const { drive, path: p } = r
|
||||
if (r.mntReadOnly === false) {
|
||||
if (isHyperdriveRootPath(p)) {
|
||||
throw new Error('Cannot symlink at directory root')
|
||||
}
|
||||
return drive.symlink(p, target)
|
||||
}
|
||||
if (drive !== personalDrive) {
|
||||
throw new Error('Read-only path (not under $HOME): ' + userPath)
|
||||
}
|
||||
if (isHyperdriveRootPath(p)) {
|
||||
throw new Error('Cannot symlink at directory root')
|
||||
}
|
||||
return drive.symlink(p, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"autopass": "^3.4.0",
|
||||
"b4a": "^1.6.7",
|
||||
"bare-crypto": "^1.13.4",
|
||||
"bare-fetch": "^2.8.1",
|
||||
"bare-os": "^3.8.7",
|
||||
"bare-os-protocol": "*",
|
||||
"bare-readline": "^1.3.1",
|
||||
@@ -23,6 +24,7 @@
|
||||
"hypercore-id-encoding": "^1.3.0",
|
||||
"hyperdrive": "^13.3.2",
|
||||
"hyperswarm": "^4.16.0",
|
||||
"isomorphic-git": "^1.37.4",
|
||||
"protomux": "^3.10.1",
|
||||
"safety-catch": "^1.0.2",
|
||||
"unix-path-resolve": "^1.0.2"
|
||||
|
||||
@@ -8,9 +8,19 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { runKernelFromSource, runBinCommand } from './lib/kernel-runner.js'
|
||||
import { createGitFsFromVfs } from './lib/git-fs-adapter.js'
|
||||
import { runGitCli } from './lib/git-cli.js'
|
||||
import { createStreamLineReader } from './lib/cli-readline.js'
|
||||
import { createVfs } from './lib/vfs.js'
|
||||
import { tokenize, expandWord, execShellLine } from './lib/shell.js'
|
||||
import {
|
||||
tokenize,
|
||||
expandWord,
|
||||
execShellLine,
|
||||
expandArgvAliases,
|
||||
defaultShellAliases,
|
||||
loadBarerc,
|
||||
BARERC_SKELETON
|
||||
} from './lib/shell.js'
|
||||
import {
|
||||
fuzzyMatch,
|
||||
stripAnsi,
|
||||
@@ -344,6 +354,59 @@ test('expandWord reads env', async (t) => {
|
||||
t.is(expandWord('x${HOME}y', { HOME: '/h' }), 'x/hy')
|
||||
})
|
||||
|
||||
test('expandArgvAliases expands first word and keeps trailing argv', async (t) => {
|
||||
t.alike(expandArgvAliases(['ll', 'z'], defaultShellAliases()), [
|
||||
'ls',
|
||||
'-la',
|
||||
'z'
|
||||
])
|
||||
})
|
||||
|
||||
test('expandArgvAliases throws on cyclic alias chain', async (t) => {
|
||||
const cyclic = { a: 'b', b: 'a' }
|
||||
t.exception(
|
||||
() => expandArgvAliases(['a'], cyclic),
|
||||
/alias: expansion nested too deeply/
|
||||
)
|
||||
})
|
||||
|
||||
test('loadBarerc applies export and alias from personal ~/.barerc', async (t) => {
|
||||
const dir = testCorestoreDir('barerc')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('brc'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await personal.put(
|
||||
'/.barerc',
|
||||
b4a.from('export MYRC=1\nalias dog=echo woof\n')
|
||||
)
|
||||
const ctx = testCtx(drive, personal)
|
||||
await loadBarerc(ctx)
|
||||
t.is(ctx.vfs.env.MYRC, '1')
|
||||
t.is(ctx.shellAliases.dog, 'echo woof')
|
||||
t.is(ctx.shellAliases.ll, 'ls -la')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('loadBarerc createSkeletonIfMissing writes ~/.barerc when absent', async (t) => {
|
||||
const dir = testCorestoreDir('barercskel')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('brcsk'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
const ctx = testCtx(drive, personal)
|
||||
await loadBarerc(ctx, { createSkeletonIfMissing: true })
|
||||
const back = await ctx.vfs.readFile('~/.barerc')
|
||||
t.ok(back)
|
||||
t.is(ctx.b4a.toString(back), BARERC_SKELETON)
|
||||
t.is(ctx.shellAliases.ll, 'ls -la')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('execShellLine runs cd and external', async (t) => {
|
||||
const dir = testCorestoreDir('sh')
|
||||
const store = new Corestore(dir)
|
||||
@@ -370,6 +433,30 @@ async function run(ctx, argv) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('execShellLine expands default ll to ls -la', async (t) => {
|
||||
const dir = testCorestoreDir('llalias')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('lla'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await drive.put(
|
||||
'/bin/ls',
|
||||
b4a.from(`
|
||||
async function run(ctx, argv) {
|
||||
ctx.got.push(argv.join(' '))
|
||||
}
|
||||
`)
|
||||
)
|
||||
const got = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.got = got
|
||||
await execShellLine(ctx, 'll one')
|
||||
t.is(got[0], 'ls -la one')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('cron fieldMatches and dowFieldMatches', async (t) => {
|
||||
t.ok(fieldMatches('*', 0, 0, 59))
|
||||
t.ok(fieldMatches('*/5', 10, 0, 59))
|
||||
@@ -519,6 +606,117 @@ test('tier-1 grep from system drive', async (t) => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('tier-1 rm -rf removes directory tree on personal drive', async (t) => {
|
||||
const dir = testCorestoreDir('rmrf')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('prm'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await drive.put('/bin/rm', b4a.from(await readBuiltBin('rm')))
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.exitCode = 0
|
||||
ctx.console = { log() {}, error() {} }
|
||||
await personal.put('/nest/leaf/x.txt', b4a.from('x'))
|
||||
await personal.put('/nest/other/y.txt', b4a.from('y'))
|
||||
t.ok((await ctx.vfs.readdir('nest')).includes('leaf'))
|
||||
await runBinCommand(ctx, ['rm', '-rf', 'nest'])
|
||||
t.is(ctx.exitCode, 0)
|
||||
const after = await ctx.vfs.readdir('.')
|
||||
t.ok(!after.includes('nest'))
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('git-fs-adapter mkdir recursive and readdir hides .bareos_empty', async (t) => {
|
||||
const dir = testCorestoreDir('gitfs')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pgf'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
const ctx = testCtx(drive, personal)
|
||||
const fs = createGitFsFromVfs(ctx.vfs)
|
||||
const base = '/home/user/gittest'
|
||||
await fs.promises.mkdir(path.posix.join(base, 'a', 'b'), { recursive: true })
|
||||
const inA = await fs.promises.readdir(path.posix.join(base, 'a'))
|
||||
t.is(inA.indexOf('.bareos_empty'), -1)
|
||||
t.ok(inA.includes('b'))
|
||||
const top = await fs.promises.readdir(base)
|
||||
t.ok(top.includes('a'))
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('runBinCommand delegates git to booter (ignores /bin/git script body)', async (t) => {
|
||||
const dir = testCorestoreDir('gitdel')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pgd'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await drive.put(
|
||||
'/bin/git',
|
||||
b4a.from(`async function run() { throw new Error('eval git should not run') }`)
|
||||
)
|
||||
const logs = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.console = {
|
||||
log: (...a) => logs.push(a.join(' ')),
|
||||
error: (...a) => logs.push(a.join(' '))
|
||||
}
|
||||
await runBinCommand(ctx, ['git', 'version'])
|
||||
t.ok(logs.some((l) => /isomorphic-git/i.test(l)))
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('runBinCommand runs ./git from cwd instead of booter delegate', async (t) => {
|
||||
const dir = testCorestoreDir('gitlocal')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pgl'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
await personal.put(
|
||||
'/git',
|
||||
b4a.from(`
|
||||
async function run(ctx) {
|
||||
ctx.out.push('local-git-script')
|
||||
}
|
||||
`)
|
||||
)
|
||||
const out = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.out = out
|
||||
await runBinCommand(ctx, ['./git'])
|
||||
t.is(out[0], 'local-git-script')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('runGitCli init and status on personal drive', async (t) => {
|
||||
const dir = testCorestoreDir('gitcmd')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pgc'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
const logs = []
|
||||
const ctx = testCtx(drive, personal)
|
||||
ctx.console = {
|
||||
log: (...a) => logs.push(a.join(' ')),
|
||||
error: (...a) => logs.push(a.join(' '))
|
||||
}
|
||||
await runGitCli(ctx, ['git', 'init', '-C', '/home/user/myrepo'])
|
||||
t.ok(logs.some((l) => /initialized|git repository/i.test(l)))
|
||||
logs.length = 0
|
||||
await runGitCli(ctx, ['git', '-C', '/home/user/myrepo', 'status'])
|
||||
t.ok(logs.length > 0)
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function readBuiltBin(name) {
|
||||
const fs = await import('node:fs/promises')
|
||||
const p = path.join(__dirname, '../../kernel/bin', name)
|
||||
|
||||
Reference in New Issue
Block a user