updates
This commit is contained in:
@@ -67,7 +67,7 @@ Work is sequenced for **POSIX/script ergonomics first**, then networking and lon
|
||||
1. **VFS and shell** — Synthetic **`/proc`** / **`/run`** / minimal **`/dev`** paths, bounded **pipeline** capture in **`execShellLine`**, kernel **boot trace** and **banner** hooks on the system image.
|
||||
2. **`/bin` and help** — **`man -w`**, optional **sliced** long pages via **`MAN_SLICE`** / **`PAGER=bare-slice`**, incremental **coreutils** flags (e.g. **`grep -w`**).
|
||||
3. **Networking** — **`CLI_PARITY.md`** items (cookies, TLS trust, finer timeouts) once storage and trust policy exist.
|
||||
4. **Init and protocol** — Persistent **`systemctl enable`**, richer **`bare_os.*`** RPC, after the above stabilize.
|
||||
4. **Init and protocol** — Richer **`bare_os.*`** RPC, after the above stabilize.
|
||||
|
||||
**Directories:** **`vfs.mkdir(path, { recursive })`** and **`vfs.rmdir(path)`** implement POSIX-like tree creation and removal using a **`.bareos_empty`** marker file for empty directories (aligned with **`git-fs-adapter`**). See [Chapter 9](09-posix-utilities-shell-and-vfs.md).
|
||||
|
||||
@@ -110,7 +110,7 @@ There is no arbitrary command execution, **`source`**, or control flow — it is
|
||||
**`bare-initd.js`**:
|
||||
|
||||
- **`registerBareService({ name, start, stop?, description?, logPath? })`** — optional **`stop`** enables **`systemctl stop`** / **`restart`** for that unit; **`logPath`** is a logical VFS path for **`systemctl status`** / **`logs`**
|
||||
- **`startBareInitd(ctx)`** — ensures **`/var/log/bare-os`** (see below), then sequential start, per-service `try/catch`, `[bare-initd] name: err` on failure; failures also append to **`/var/log/bare-os/initd.log`**; records per-unit runtime state (**`active`** / **`failed`**)
|
||||
- **`startBareInitd(ctx)`** — ensures **`/var/log/bare-os`** (see below), then ordered start (skips units listed in **`~/.config/bare-os/initd/disabled.txt`** on the personal drive; optional **`~/.config/bare-os/units/<name>.unit`** with **`[Unit]`** **`After=`** for boot ordering; default **`bare-cron`** runs after **`kernel-logger`**), per-service `try/catch`, `[bare-initd] name: err` on failure; failures also append to **`/var/log/bare-os/initd.log`**; records per-unit runtime state (**`active`** / **`failed`**)
|
||||
- **`listBareServices()`**, **`getBareServiceRuntime(name)`**, **`findBareServiceDefinition(name)`**, **`startBareService` / `stopBareService` / `restartBareService`** — introspection and lifecycle (used by the CLI below)
|
||||
- **`registerKernelShutdownHook(fn)`** + **`runKernelShutdownHooks()`** — async-friendly teardown before disposers (REPL **`session.cleanup`** awaits hooks, then **`stopBareInitd()`**)
|
||||
- **`registerBareInitdDisposer(fn)`** + **`stopBareInitd()`** — for intervals and synchronous teardown
|
||||
@@ -118,7 +118,7 @@ There is no arbitrary command execution, **`source`**, or control flow — it is
|
||||
|
||||
Built-in **`kernel-logger`** wraps **`ctx.console.log` / `error`** to append UTF-8 lines to that path (with **`stop`** / **`restart`** support). **`startBareInitd`** also creates **`/var/log/bare-os`** and a short **`README`** there. Other services use the same tree (e.g. **`cron.log`**). Failures to write logs are swallowed so logging never kills the session.
|
||||
|
||||
**Service control:** **`/bin/systemctl`** is implemented by the booter (**`kernel-runner`** delegates to **`systemctl-cli.js`**), not by eval’d image JS. Subcommands: **`list`** / **`list-units`**, **`status`**, **`logs`**, **`start`**, **`stop`**, **`restart`**. **`journalctl -u UNIT`** (log tail only) shares the same backend. The legacy name **`bare-initctl`** is still accepted as an alias. There is **no** **`enable`** / **`disable`** (units are session-scoped, not persistent on the image). See **`man systemctl`**.
|
||||
**Service control:** **`/bin/systemctl`** is implemented by the booter (**`kernel-runner`** delegates to **`systemctl-cli.js`**), not by eval’d image JS. Subcommands: **`list`** / **`list-units`** (shows **PRESET** enabled/disabled from **`disabled.txt`**), **`status`**, **`logs`**, **`start`**, **`stop`**, **`restart`**, **`enable`**, **`disable`**, **`is-enabled`**. **`journalctl -u UNIT`** (log tail only) shares the same backend. The legacy name **`bare-initctl`** is still accepted as an alias. **`enable`** / **`disable`** only affect the next **`startBareInitd`** (personal-drive config); runtime **`start`** / **`stop`** remain session commands. See **`man systemctl`**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Hyperdrive entries carry optional **`metadata.bareOs`** (mode, uid, gid, names,
|
||||
|
||||
| Command | Role |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `systemctl`, `journalctl` | **Booter-delegated:** list/status/logs/start/stop/restart for **bare-initd** units (session-scoped; **`journalctl -u`** only); **`bare-initctl`** accepted as alias; see ch. 4 |
|
||||
| `systemctl`, `journalctl` | **Booter-delegated:** list/status/logs/start/stop/restart/**enable**/**disable**/**is-enabled** for **bare-initd** units; preset file on personal drive; **`journalctl -u`** only; **`bare-initctl`** alias; see ch. 4 |
|
||||
| `basename`, `dirname` | Path manipulation |
|
||||
| `cat`, `head`, `tail`, `nl` | Text |
|
||||
| `clear` | ANSI clear screen |
|
||||
|
||||
@@ -11,7 +11,7 @@ Bare OS targets a **usable subset** of [POSIX.1-2017](https://pubs.opengroup.org
|
||||
| Expectation (full POSIX) | Bare OS reality |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **`sh`** grammar (`for`, subshells, …) | Line-at-a-time shell: builtins + `/bin`; lists with **`;`** (outside **`if`/`fi`**), **`&&`**, **`||`**, **`\|`** pipelines; bounded **`if`** **`then`** **[** **`else`** **]** **`fi`** (condition = last exit status like **`&&`**)—no **`for`**, groups, or full **`sh`** grammar. |
|
||||
| **Processes, `fork`, pipes as OS primitives** | Pipelines are simulated by capturing **`console.log`** into the next command. |
|
||||
| **Processes, `fork`, pipes as OS primitives** | Pipelines are simulated by capturing **`console.log`** (and merged **`console.error`** when **`2>&1`** is used) into the next command. |
|
||||
| **`chown` / `chgrp` / real UIDs across users** | Single-session identity; metadata carries **uid/gid** for display and checks. |
|
||||
| **FIFOs, `mknod`, real devices** | Not available; **`mkfifo`** is a documented stub. |
|
||||
| **`xargs`** full POSIX/GNU surface | Bounded implementation: **`ctx.runBinCommand`** only; **`-0`**, **`-n`** (and **`-nN`**); stdin/token/invocation caps (see **`src/xargs.js`**). |
|
||||
@@ -37,7 +37,7 @@ Hyperdrive does not always behave like a POSIX directory tree. Empty directories
|
||||
|
||||
### 2.3 Pseudo **`/proc`**, **`/sys`**, **`/run`**, **`/dev`**, and session **`/tmp`**
|
||||
|
||||
- **`/proc`**, **`/sys`**, **`/run`**, **`/dev`** — read-only synthetic trees except **`/dev/null`** and **`/dev/zero`** accept writes that are discarded (see [Chapter 4](04-the-booter-runtime.md)). Other pseudo **`writeFile`** / **`unlink`** / **`chmod`** paths fail as documented there.
|
||||
- **`/proc`**, **`/sys`**, **`/run`**, **`/dev`** — read-only synthetic trees except **`/dev/null`** and **`/dev/zero`** accept writes that are discarded (see [Chapter 4](04-the-booter-runtime.md)). Other pseudo **`writeFile`** / **`unlink`** / **`chmod`** paths fail as documented there. Extra Linux-shaped stubs include **`/proc/cpuinfo`**, **`/proc/loadavg`**, **`/proc/self/exe`**, and **`/dev/urandom`** (each **`readFile`** of urandom returns a **bounded** buffer; **not** suitable for cryptography).
|
||||
- **`/tmp`** — writable on the **personal** drive under **`/.bare-os/tmp/<HOME-basename>/…`**, isolated like **`$HOME`** and **`/var/log`**.
|
||||
|
||||
### 2.4 `chmod` (octal and symbolic)
|
||||
@@ -52,7 +52,7 @@ There is **no** **`chown`** / **`chgrp`** that changes stored ownership in a mul
|
||||
### 2.5 Copy and move
|
||||
|
||||
- **`cp`** — **`-R`/`-r`** recursive copy; follows the same marker convention (skips copying **`.bareos_empty`** as a separate “file” where appropriate).
|
||||
- **`mv`** — copy-tree + **`vfs.rm`** on the source (no single-key atomic rename API in the VFS).
|
||||
- **`mv`** — Hyperdrive has no atomic **`rename`**; the general case is copy-tree + **`vfs.rm`** on the source. A **single regular file** to a non-directory destination uses **`readFile` → `writeFile` → `unlink`** on the source (same semantics, slightly less overhead than recursive **`rm`**).
|
||||
|
||||
---
|
||||
|
||||
@@ -64,6 +64,8 @@ There is **no** **`chown`** / **`chgrp`** that changes stored ownership in a mul
|
||||
|
||||
**Pipeline limits:** simulated pipe capture is bounded. Defaults: **`BARE_OS_PIPELINE_MAX_STAGES`** (32), **`BARE_OS_PIPELINE_MAX_BYTES`** (2 MiB), **`BARE_OS_PIPELINE_MAX_LINES`** (50 000). Exceeding a limit fails the pipeline with exit status **1** and an error on stderr.
|
||||
|
||||
**Stderr redirection:** **`2>`** / **`2>>`** redirect **`console.error`** for the command (same byte/line caps as stdout when captured for a pipe). **`2>&1`** merges stderr into the stdout capture stream so both appear in the simulated pipe input for the next stage (and in a stdout redirect when it is the last command in the list).
|
||||
|
||||
**Last exit status:** after each full **`execLine`** evaluation, **`vfs.env.BARE_OS_EXIT_STATUS`** is updated (decimal string). Words expand **`$?`** and **`${?}`** from that value (default **`0`** if unset), similar to POSIX **`$?`**.
|
||||
|
||||
Beyond **`alias`**, **`unalias`**, **`cd`**, **`export`**, **`login`**, **`logout`**, **`exit`**:
|
||||
|
||||
@@ -116,6 +116,19 @@ async function run(ctx, argv) {
|
||||
? dest.replace(/\/+$/, '') + '/' + base
|
||||
: dest
|
||||
try {
|
||||
const st = await ctx.vfs.lstat(src)
|
||||
const singleFileToFile =
|
||||
sources.length === 1 &&
|
||||
!destIsDir &&
|
||||
st &&
|
||||
st.type === 'file'
|
||||
if (singleFileToFile) {
|
||||
const buf = await ctx.vfs.readFile(src)
|
||||
if (!buf) throw new Error('cannot read source')
|
||||
await ctx.vfs.writeFile(target, buf)
|
||||
await ctx.vfs.unlink(src)
|
||||
continue
|
||||
}
|
||||
if (!(await mvCopyPath(ctx, src, target, true))) {
|
||||
throw new Error('cannot copy')
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,9 +23,10 @@ HTTP in Pear/Bare uses **WHATWG Fetch** (Node `fetch` or **`bare-fetch`** on `ba
|
||||
| `-m` / `--max-time` | supported | Whole-request `AbortSignal` timeout (not separate connect timeout) |
|
||||
| `-w` / `--write-out` | partial | `%{http_code}`, `%{url_effective}`, `%{size_download}`; `%{num_redirects}` non-zero only for `-I -L` manual hop path |
|
||||
| `-V` / `--version`, `-h` / `--help` | supported | |
|
||||
| Cookie jar, `--cookie`, `-b` | out of scope | Needs persistent jar + spec |
|
||||
| Cookie jar, `--cookie`, `-b`, `-c` / `--cookie-jar` | supported | JSON map `hostname → { name → value }` on **`~/.config/bare-os/curl/cookies.json`** (or path from `-c`); `-b` file or `name=value`; see `curl-cli.js` |
|
||||
| `--connect-timeout`, `-Y`, `-y` | out of scope | Fetch exposes one abort timer |
|
||||
| TLS client certs, `--cacert`, `-k` | out of scope | Stack / trust store |
|
||||
| `--cacert`, `-k` / `--insecure` | partial | Node **undici** `Agent` when using global `fetch` (not when `ctx.httpFetch` is overridden) |
|
||||
| TLS client certs (mutual TLS) | out of scope | Stack only |
|
||||
| HTTP/2, HTTP/3, SOCKS, FTP, SCP | out of scope | |
|
||||
| `-J` content-disposition filename | supported | With **`-O`**; uses **`Content-Disposition`** basename only; rejects **`..`** and absolute paths in the server-provided name |
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* User initd config on the personal drive: disabled units and optional drop-in ordering.
|
||||
* Paths are logical VFS paths under $HOME.
|
||||
*/
|
||||
|
||||
import b4a from 'b4a'
|
||||
|
||||
/** One unit name per line; blank lines and `#…` comments ignored. */
|
||||
export const BARE_INITD_DISABLED_FILE = '~/.config/bare-os/initd/disabled.txt'
|
||||
|
||||
/** Optional `unit.unit` files: `[Unit]` with `After=other-unit …`. */
|
||||
export const BARE_INITD_UNITS_DIR = '~/.config/bare-os/units'
|
||||
|
||||
/** Built-in ordering when no drop-in file exists (kernel-logger before cron). */
|
||||
export const BARE_INITD_DEFAULT_AFTER = Object.freeze({
|
||||
'bare-cron': ['kernel-logger']
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||||
* @returns {Promise<Set<string>>}
|
||||
*/
|
||||
export async function readInitdDisabledSet(vfs) {
|
||||
const out = new Set()
|
||||
try {
|
||||
const buf = await vfs.readFile(BARE_INITD_DISABLED_FILE)
|
||||
if (!buf) return out
|
||||
const text = b4a.toString(buf, 'utf8')
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) continue
|
||||
out.add(t)
|
||||
}
|
||||
} catch {
|
||||
/* missing file */
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* mkdir: (p: string, opts?: { recursive?: boolean }) => Promise<unknown>,
|
||||
* writeFile: (p: string, buf: Uint8Array) => Promise<unknown>,
|
||||
* unlink: (p: string) => Promise<unknown>
|
||||
* }} vfs
|
||||
* @param {Set<string>} disabled
|
||||
*/
|
||||
export async function writeInitdDisabledSet(vfs, disabled) {
|
||||
await vfs.mkdir('~/.config/bare-os/initd', { recursive: true })
|
||||
if (disabled.size === 0) {
|
||||
try {
|
||||
await vfs.unlink(BARE_INITD_DISABLED_FILE)
|
||||
} catch {
|
||||
/* absent */
|
||||
}
|
||||
return
|
||||
}
|
||||
const body = [...disabled].sort().join('\n') + '\n'
|
||||
await vfs.writeFile(BARE_INITD_DISABLED_FILE, b4a.from(body, 'utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseAfterLine(text) {
|
||||
const m = text.match(/^\s*After\s*=\s*(.+)$/im)
|
||||
if (!m) return []
|
||||
return m[1]
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||||
* @param {string} unitName
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function readUnitAfterFromDropIn(vfs, unitName) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(unitName)) return []
|
||||
const path = `${BARE_INITD_UNITS_DIR}/${unitName}.unit`
|
||||
try {
|
||||
const buf = await vfs.readFile(path)
|
||||
if (!buf) return []
|
||||
const text = b4a.toString(buf, 'utf8')
|
||||
return parseAfterLine(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||||
* @param {{ name: string }[]} services
|
||||
* @param {Readonly<Record<string, string[]>>} defaultAfter
|
||||
* @returns {Promise<Map<string, string[]>>}
|
||||
*/
|
||||
export async function loadInitdAfterMap(vfs, services, defaultAfter) {
|
||||
const map = new Map()
|
||||
for (const s of services) {
|
||||
const fromFile = await readUnitAfterFromDropIn(vfs, s.name)
|
||||
const def = defaultAfter[s.name] || []
|
||||
const merged = [...new Set([...def, ...fromFile])]
|
||||
map.set(s.name, merged)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Topological sort; cycles fall back to registration order for the remainder.
|
||||
* @template T extends { name: string }
|
||||
* @param {T[]} services
|
||||
* @param {Set<string>} disabled
|
||||
* @param {Map<string, string[]>} afterMap
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export function sortServicesForBoot(services, disabled, afterMap) {
|
||||
const active = services.filter((s) => !disabled.has(s.name))
|
||||
const nameSet = new Set(active.map((s) => s.name))
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
const prereq = new Map()
|
||||
for (const s of active) {
|
||||
const inc = new Set()
|
||||
for (const a of afterMap.get(s.name) || []) {
|
||||
if (nameSet.has(a)) inc.add(a)
|
||||
}
|
||||
prereq.set(s.name, inc)
|
||||
}
|
||||
const remaining = new Set(active.map((s) => s.name))
|
||||
/** @type {T[]} */
|
||||
const ordered = []
|
||||
while (remaining.size) {
|
||||
const ready = [...remaining].filter((n) => {
|
||||
for (const p of prereq.get(n) || []) {
|
||||
if (remaining.has(p)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (!ready.length) {
|
||||
const regOrder = active.map((s) => s.name).filter((n) => remaining.has(n))
|
||||
for (const n of regOrder) {
|
||||
const svc = active.find((s) => s.name === n)
|
||||
if (svc) ordered.push(svc)
|
||||
remaining.delete(n)
|
||||
}
|
||||
break
|
||||
}
|
||||
ready.sort()
|
||||
for (const n of ready) {
|
||||
const svc = active.find((s) => s.name === n)
|
||||
if (svc) ordered.push(svc)
|
||||
remaining.delete(n)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
INITD_LOG,
|
||||
KERNEL_CONSOLE_LOG
|
||||
} from './bare-os-var-log.js'
|
||||
import {
|
||||
BARE_INITD_DEFAULT_AFTER,
|
||||
loadInitdAfterMap,
|
||||
readInitdDisabledSet,
|
||||
sortServicesForBoot
|
||||
} from './bare-initd-user.js'
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
@@ -201,7 +207,18 @@ export async function restartBareService(ctx, name) {
|
||||
export async function startBareInitd(ctx) {
|
||||
runtime.clear()
|
||||
await ensureBareOsVarLogTree(ctx)
|
||||
for (const s of registry) {
|
||||
const vfs = ctx.vfs
|
||||
let ordered = registry
|
||||
if (vfs && typeof vfs.readFile === 'function') {
|
||||
const disabled = await readInitdDisabledSet(vfs)
|
||||
const afterMap = await loadInitdAfterMap(
|
||||
vfs,
|
||||
registry,
|
||||
BARE_INITD_DEFAULT_AFTER
|
||||
)
|
||||
ordered = sortServicesForBoot(registry, disabled, afterMap)
|
||||
}
|
||||
for (const s of ordered) {
|
||||
const t0 = Date.now()
|
||||
try {
|
||||
await s.start(ctx)
|
||||
|
||||
@@ -144,12 +144,152 @@ function expandWriteOut(fmt, vars) {
|
||||
|
||||
const CURL_MAX_REDIRECTS = 50
|
||||
|
||||
/** Default persistent jar on the personal drive (JSON per CLI_PARITY). */
|
||||
export const BARE_OS_CURL_COOKIE_JAR = '~/.config/bare-os/curl/cookies.json'
|
||||
|
||||
function curlHostFromUrl(urlStr) {
|
||||
try {
|
||||
return new URL(urlStr).hostname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Record<string, Record<string, string>>} */
|
||||
function parseCookieJarJson(text) {
|
||||
try {
|
||||
const o = JSON.parse(text)
|
||||
if (!o || typeof o !== 'object') return {}
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
const out = {}
|
||||
for (const [h, bag] of Object.entries(o)) {
|
||||
if (!bag || typeof bag !== 'object') continue
|
||||
out[String(h)] = {}
|
||||
for (const [k, v] of Object.entries(bag)) {
|
||||
out[String(h)][String(k)] = String(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||||
* @param {string} path
|
||||
*/
|
||||
async function loadCookieJarFile(vfs, path) {
|
||||
const buf = await vfs.readFile(path)
|
||||
if (!buf) return {}
|
||||
return parseCookieJarJson(b4a.toString(buf, 'utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, Record<string, string>>} jar
|
||||
* @param {string} host
|
||||
* @param {string} line
|
||||
*/
|
||||
function mergeInlineCookieLine(jar, host, line) {
|
||||
if (!host) return
|
||||
for (const part of line.split(';')) {
|
||||
const p = part.trim()
|
||||
const eq = p.indexOf('=')
|
||||
if (eq < 1) continue
|
||||
const name = p.slice(0, eq).trim()
|
||||
const val = p.slice(eq + 1).trim()
|
||||
if (!jar[host]) jar[host] = {}
|
||||
jar[host][name] = val
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, Record<string, string>>} target
|
||||
* @param {Record<string, Record<string, string>>} add
|
||||
*/
|
||||
function mergeCookieJars(target, add) {
|
||||
for (const [h, bag] of Object.entries(add)) {
|
||||
if (!target[h]) target[h] = {}
|
||||
Object.assign(target[h], bag)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, Record<string, string>>} jar
|
||||
* @param {string} host
|
||||
*/
|
||||
function cookieHeaderForHost(jar, host) {
|
||||
const bag = jar[host]
|
||||
if (!bag || !Object.keys(bag).length) return ''
|
||||
return Object.entries(bag)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, Record<string, string>>} jar
|
||||
* @param {string} host
|
||||
* @param {string} headerVal
|
||||
*/
|
||||
function applySetCookieHeader(jar, host, headerVal) {
|
||||
if (!host || !headerVal) return
|
||||
const first = String(headerVal).split(';')[0].trim()
|
||||
const eq = first.indexOf('=')
|
||||
if (eq < 1) return
|
||||
const name = first.slice(0, eq).trim()
|
||||
const val = first.slice(eq + 1).trim()
|
||||
if (!jar[host]) jar[host] = {}
|
||||
jar[host][name] = val
|
||||
}
|
||||
|
||||
/** @param {Response} res */
|
||||
function gatherSetCookieValues(res) {
|
||||
const h = res.headers
|
||||
if (typeof h.getSetCookie === 'function') return h.getSetCookie()
|
||||
const v = h.get('Set-Cookie')
|
||||
return v ? [v] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Node undici Agent for `--cacert` / `-k` (no effect when ctx.httpFetch is a stub).
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
|
||||
* @param {{ insecure?: boolean, cacertPath?: string | null }} opts
|
||||
* @returns {Promise<{ dispatcher?: unknown, error?: string }>}
|
||||
*/
|
||||
async function buildTlsFetchExtras(ctx, vfs, opts) {
|
||||
const insecure = !!opts.insecure
|
||||
const cacertPath = opts.cacertPath || null
|
||||
if (!insecure && !cacertPath) return {}
|
||||
if (typeof ctx.httpFetch === 'function') {
|
||||
return {
|
||||
error:
|
||||
'curl: --cacert / -k are not applied when ctx.httpFetch is set (use native fetch)'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const undici = await import('undici')
|
||||
const Agent = undici.Agent
|
||||
if (insecure) {
|
||||
return { dispatcher: new Agent({ connect: { rejectUnauthorized: false } }) }
|
||||
}
|
||||
if (!cacertPath) return {}
|
||||
const buf = await vfs.readFile(cacertPath)
|
||||
if (!buf) return { error: 'curl: cannot read --cacert file' }
|
||||
const ca = b4a.toString(buf, 'utf8')
|
||||
return { dispatcher: new Agent({ connect: { ca } }) }
|
||||
} catch (e) {
|
||||
const msg = e && e.message ? e.message : String(e)
|
||||
return { error: 'curl: TLS options need undici (Node): ' + msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* curl -I -L: send HEAD first; after any 3xx, follow with GET (curl man: "GET is used after redirect").
|
||||
* Using fetch(HEAD, redirect:'follow') keeps HEAD on each hop and breaks many CDNs; manual hops match curl.
|
||||
* @returns {{ response: Response, redirectCount: number }}
|
||||
*/
|
||||
async function fetchHeadWithLocationFollow(fetchFn, startUrl, hdr, signal) {
|
||||
async function fetchHeadWithLocationFollow(fetchFn, startUrl, hdr, signal, fetchExtra = {}) {
|
||||
let url = startUrl
|
||||
let method = 'HEAD'
|
||||
let redirectCount = 0
|
||||
@@ -159,7 +299,8 @@ async function fetchHeadWithLocationFollow(fetchFn, startUrl, hdr, signal) {
|
||||
headers: hdr,
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
body: undefined
|
||||
body: undefined,
|
||||
...fetchExtra
|
||||
})
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const loc = res.headers.get('Location')
|
||||
@@ -225,6 +366,13 @@ export async function runCurlCli(ctx, argv) {
|
||||
let jsonBody = null
|
||||
/** @type {string | null} */
|
||||
let uploadPath = null
|
||||
let insecureTls = false
|
||||
/** @type {string | null} */
|
||||
let cacertPath = null
|
||||
/** @type {string | null} */
|
||||
let cookieJarOut = null
|
||||
/** @type {string[]} */
|
||||
const cookieSpecs = []
|
||||
|
||||
/** @type {string[]} */
|
||||
const urls = []
|
||||
@@ -429,6 +577,47 @@ export async function runCurlCli(ctx, argv) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (a === '-k' || a === '--insecure') {
|
||||
insecureTls = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '--cacert') {
|
||||
if (i + 1 >= args.length) {
|
||||
ctx.console.error('curl: option requires an argument: --cacert')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
cacertPath = String(args[++i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '-b' || a === '--cookie') {
|
||||
if (i + 1 >= args.length) {
|
||||
ctx.console.error('curl: option requires an argument: ' + a)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
cookieSpecs.push(String(args[++i]))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a.startsWith('-b') && a.length > 2) {
|
||||
cookieSpecs.push(a.slice(2))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (a === '-c' || a === '--cookie-jar') {
|
||||
if (i + 1 >= args.length) {
|
||||
ctx.console.error('curl: option requires an argument: ' + a)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
cookieJarOut = String(args[++i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (a === '-I' || a === '--head') {
|
||||
headOnly = true
|
||||
i++
|
||||
@@ -502,6 +691,15 @@ export async function runCurlCli(ctx, argv) {
|
||||
case 'J':
|
||||
remoteHeaderName = true
|
||||
break
|
||||
case 'k':
|
||||
insecureTls = true
|
||||
break
|
||||
case 'b':
|
||||
ctx.console.error(
|
||||
'curl: bundled -b requires a separate argument (use -b cookie.txt)'
|
||||
)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
default:
|
||||
ctx.console.error('curl: invalid option -- ' + c)
|
||||
ctx.exitCode = 2
|
||||
@@ -567,7 +765,26 @@ export async function runCurlCli(ctx, argv) {
|
||||
return
|
||||
}
|
||||
|
||||
const hdr = new Headers()
|
||||
if ((cookieSpecs.length > 0 || cookieJarOut) && !vfs) {
|
||||
ctx.console.error('curl: -b / -c require ctx.vfs')
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
|
||||
const tlsExtras = await buildTlsFetchExtras(ctx, vfs, {
|
||||
insecure: insecureTls,
|
||||
cacertPath
|
||||
})
|
||||
if (tlsExtras.error) {
|
||||
if (!silent || showError) ctx.console.error(tlsExtras.error)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const fetchTlsExtra = tlsExtras.dispatcher
|
||||
? { dispatcher: tlsExtras.dispatcher }
|
||||
: {}
|
||||
|
||||
const baseHdr = new Headers()
|
||||
for (const line of headers) {
|
||||
const colon = line.indexOf(':')
|
||||
if (colon < 1) {
|
||||
@@ -575,30 +792,44 @@ export async function runCurlCli(ctx, argv) {
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
hdr.set(line.slice(0, colon).trim(), line.slice(colon + 1).trim())
|
||||
baseHdr.set(line.slice(0, colon).trim(), line.slice(colon + 1).trim())
|
||||
}
|
||||
if (userColonPass) {
|
||||
const colon = userColonPass.indexOf(':')
|
||||
const user = colon >= 0 ? userColonPass.slice(0, colon) : userColonPass
|
||||
const pass = colon >= 0 ? userColonPass.slice(colon + 1) : ''
|
||||
hdr.set('Authorization', basicAuthHeader(user, pass))
|
||||
baseHdr.set('Authorization', basicAuthHeader(user, pass))
|
||||
}
|
||||
{
|
||||
const ua = userAgentSequence.length
|
||||
? userAgentSequence[userAgentSequence.length - 1]
|
||||
: DEFAULT_CURL_USER_AGENT
|
||||
hdr.set('User-Agent', ua)
|
||||
baseHdr.set('User-Agent', ua)
|
||||
}
|
||||
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
const sessionJar = {}
|
||||
if (vfs && cookieJarOut) {
|
||||
mergeCookieJars(sessionJar, await loadCookieJarFile(vfs, cookieJarOut))
|
||||
}
|
||||
if (vfs && cookieSpecs.length) {
|
||||
for (const spec of cookieSpecs) {
|
||||
if (!spec.includes('=')) {
|
||||
mergeCookieJars(sessionJar, await loadCookieJarFile(vfs, spec))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {string | Uint8Array | undefined} */
|
||||
let body = undefined
|
||||
if (jsonBody) {
|
||||
if (!hdr.has('Content-Type')) hdr.set('Content-Type', 'application/json')
|
||||
if (!baseHdr.has('Content-Type'))
|
||||
baseHdr.set('Content-Type', 'application/json')
|
||||
body = jsonBody
|
||||
} else if (dataChunks.length > 0) {
|
||||
const joined = dataChunks.join('&')
|
||||
if (!hdr.has('Content-Type') && !dataBinary)
|
||||
hdr.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
if (!baseHdr.has('Content-Type') && !dataBinary)
|
||||
baseHdr.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
body = joined
|
||||
} else if (uploadPath) {
|
||||
const buf = await vfs.readFile(uploadPath)
|
||||
@@ -609,8 +840,8 @@ export async function runCurlCli(ctx, argv) {
|
||||
return
|
||||
}
|
||||
body = new Uint8Array(buf)
|
||||
if (!hdr.has('Content-Type'))
|
||||
hdr.set('Content-Type', 'application/octet-stream')
|
||||
if (!baseHdr.has('Content-Type'))
|
||||
baseHdr.set('Content-Type', 'application/octet-stream')
|
||||
}
|
||||
|
||||
let lastSize = 0
|
||||
@@ -618,6 +849,22 @@ export async function runCurlCli(ctx, argv) {
|
||||
|
||||
for (let ui = 0; ui < urls.length; ui++) {
|
||||
const url = urls[ui]
|
||||
const reqHdr = new Headers(baseHdr)
|
||||
const host = curlHostFromUrl(url)
|
||||
if (vfs && cookieSpecs.length) {
|
||||
for (const spec of cookieSpecs) {
|
||||
if (spec.includes('=')) mergeInlineCookieLine(sessionJar, host, spec)
|
||||
}
|
||||
}
|
||||
const jarCookie = cookieHeaderForHost(sessionJar, host)
|
||||
if (jarCookie) {
|
||||
if (reqHdr.has('Cookie')) {
|
||||
reqHdr.set('Cookie', reqHdr.get('Cookie') + '; ' + jarCookie)
|
||||
} else {
|
||||
reqHdr.set('Cookie', jarCookie)
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {string | null} */
|
||||
let outForUrl = null
|
||||
if (outputPath != null) {
|
||||
@@ -646,18 +893,20 @@ export async function runCurlCli(ctx, argv) {
|
||||
const { response, redirectCount } = await fetchHeadWithLocationFollow(
|
||||
fetchFn,
|
||||
url,
|
||||
hdr,
|
||||
ac ? ac.signal : undefined
|
||||
reqHdr,
|
||||
ac ? ac.signal : undefined,
|
||||
fetchTlsExtra
|
||||
)
|
||||
res = response
|
||||
numRedirects = redirectCount
|
||||
} else {
|
||||
res = await fetchFn(url, {
|
||||
method: m,
|
||||
headers: hdr,
|
||||
headers: reqHdr,
|
||||
body: m === 'HEAD' || m === 'GET' ? undefined : body,
|
||||
redirect: location ? 'follow' : 'manual',
|
||||
signal: ac ? ac.signal : undefined
|
||||
signal: ac ? ac.signal : undefined,
|
||||
...fetchTlsExtra
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -671,6 +920,18 @@ export async function runCurlCli(ctx, argv) {
|
||||
|
||||
lastUrl = res.url || url
|
||||
|
||||
const cookieHost = curlHostFromUrl(lastUrl) || host
|
||||
for (const sc of gatherSetCookieValues(res)) {
|
||||
applySetCookieHeader(sessionJar, cookieHost, sc)
|
||||
}
|
||||
if (cookieJarOut && vfs) {
|
||||
await vfs.mkdir('~/.config/bare-os/curl', { recursive: true })
|
||||
await vfs.writeFile(
|
||||
cookieJarOut,
|
||||
b4a.from(JSON.stringify(sessionJar), 'utf8')
|
||||
)
|
||||
}
|
||||
|
||||
if (remoteName && remoteHeaderName && res.ok && m !== 'HEAD') {
|
||||
const raw = filenameFromContentDisposition(
|
||||
res.headers.get('Content-Disposition')
|
||||
|
||||
@@ -305,6 +305,21 @@ export function tokenize(line) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (c === '2' && line[i + 1] === '>') {
|
||||
if (line[i + 2] === '>') {
|
||||
tokens.push({ type: 'op', value: '2>>' })
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if (line[i + 2] === '&' && line[i + 3] === '1') {
|
||||
tokens.push({ type: 'op', value: '2>&1' })
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
tokens.push({ type: 'op', value: '2>' })
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (c === '>') {
|
||||
if (line[i + 1] === '>') {
|
||||
tokens.push({ type: 'op', value: '>>' })
|
||||
@@ -420,7 +435,16 @@ export function expandWord(s, env) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ argv: string[], assign: Record<string, string>, redirIn: string | null, redirOut: string | null, redirAppend: boolean }} SimpleCmd
|
||||
* @typedef {{
|
||||
* argv: string[],
|
||||
* assign: Record<string, string>,
|
||||
* redirIn: string | null,
|
||||
* redirOut: string | null,
|
||||
* redirAppend: boolean,
|
||||
* redirErr: string | null,
|
||||
* redirErrAppend: boolean,
|
||||
* mergeStderrToStdout: boolean
|
||||
* }} SimpleCmd
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -448,6 +472,9 @@ function parseSimpleCommand(seg) {
|
||||
let redirIn = null
|
||||
let redirOut = null
|
||||
let redirAppend = false
|
||||
let redirErr = null
|
||||
let redirErrAppend = false
|
||||
let mergeStderrToStdout = false
|
||||
/** @type {string[]} */
|
||||
const argvWords = []
|
||||
let seenCommand = false
|
||||
@@ -456,6 +483,20 @@ function parseSimpleCommand(seg) {
|
||||
while (w < seg.length) {
|
||||
const t = seg[w]
|
||||
if (t.type === 'op') {
|
||||
if (t.value === '2>' || t.value === '2>>') {
|
||||
const n = seg[w + 1]
|
||||
if (n && n.type === 'word') {
|
||||
redirErrAppend = t.value === '2>>'
|
||||
redirErr = n.value
|
||||
w += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (t.value === '2>&1') {
|
||||
mergeStderrToStdout = true
|
||||
w++
|
||||
continue
|
||||
}
|
||||
if (t.value === '>' || t.value === '>>') {
|
||||
const n = seg[w + 1]
|
||||
if (n && n.type === 'word') {
|
||||
@@ -493,6 +534,28 @@ function parseSimpleCommand(seg) {
|
||||
|
||||
let i = 0
|
||||
while (i < argvWords.length) {
|
||||
if (
|
||||
argvWords[i] === '2' &&
|
||||
argvWords[i + 1] === '>' &&
|
||||
argvWords[i + 2] === '&' &&
|
||||
argvWords[i + 3] === '1'
|
||||
) {
|
||||
mergeStderrToStdout = true
|
||||
argvWords.splice(i, 4)
|
||||
continue
|
||||
}
|
||||
if (argvWords[i] === '2' && argvWords[i + 1] === '>') {
|
||||
redirErr = argvWords[i + 2] ?? ''
|
||||
redirErrAppend = false
|
||||
argvWords.splice(i, 3)
|
||||
continue
|
||||
}
|
||||
if (argvWords[i] === '2' && argvWords[i + 1] === '>>') {
|
||||
redirErr = argvWords[i + 2] ?? ''
|
||||
redirErrAppend = true
|
||||
argvWords.splice(i, 3)
|
||||
continue
|
||||
}
|
||||
if (argvWords[i] === '>') {
|
||||
redirOut = argvWords[i + 1] ?? ''
|
||||
redirAppend = false
|
||||
@@ -513,7 +576,16 @@ function parseSimpleCommand(seg) {
|
||||
i++
|
||||
}
|
||||
|
||||
return { argv: argvWords, assign, redirIn, redirOut, redirAppend }
|
||||
return {
|
||||
argv: argvWords,
|
||||
assign,
|
||||
redirIn,
|
||||
redirOut,
|
||||
redirAppend,
|
||||
redirErr,
|
||||
redirErrAppend,
|
||||
mergeStderrToStdout
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,8 +687,35 @@ async function execParsedPipeline(ctx, pipeline) {
|
||||
}
|
||||
|
||||
const outChunks = []
|
||||
const errChunks = []
|
||||
const origLog = ctx.console.log
|
||||
const origErr = ctx.console.error
|
||||
const capOut = !isLast || cmd.redirOut != null
|
||||
const mergeErr = cmd.mergeStderrToStdout
|
||||
const capErrSeparate = cmd.redirErr != null && !mergeErr
|
||||
|
||||
const checkCaptureSize = (chunks) => {
|
||||
const joined = chunks.join('')
|
||||
if (joined.length > lim.maxBytes) {
|
||||
throw new Error(
|
||||
`shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_BYTES (${lim.maxBytes})`
|
||||
)
|
||||
}
|
||||
const lineCount = joined.split('\n').length - 1
|
||||
if (lineCount > lim.maxLines) {
|
||||
throw new Error(
|
||||
`shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_LINES (${lim.maxLines})`
|
||||
)
|
||||
}
|
||||
}
|
||||
const pushOut = (...args) => {
|
||||
outChunks.push(args.map(String).join(' ') + '\n')
|
||||
checkCaptureSize(outChunks)
|
||||
}
|
||||
const pushErr = (...args) => {
|
||||
errChunks.push(args.map(String).join(' ') + '\n')
|
||||
checkCaptureSize(errChunks)
|
||||
}
|
||||
|
||||
for (const [k, val] of Object.entries(cmd.assign)) {
|
||||
if (
|
||||
@@ -637,23 +736,16 @@ async function execParsedPipeline(ctx, pipeline) {
|
||||
|
||||
ctx.exitCode = 0
|
||||
|
||||
if (!isLast || cmd.redirOut) {
|
||||
ctx.console.log = (...args) => {
|
||||
outChunks.push(args.map(String).join(' ') + '\n')
|
||||
const joined = outChunks.join('')
|
||||
if (joined.length > lim.maxBytes) {
|
||||
throw new Error(
|
||||
`shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_BYTES (${lim.maxBytes})`
|
||||
)
|
||||
}
|
||||
const lineCount = joined.split('\n').length - 1
|
||||
if (lineCount > lim.maxLines) {
|
||||
throw new Error(
|
||||
`shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_LINES (${lim.maxLines})`
|
||||
)
|
||||
}
|
||||
}
|
||||
ctx.console.error = ctx.console.log
|
||||
if (capOut) {
|
||||
ctx.console.log = pushOut
|
||||
if (mergeErr) ctx.console.error = pushOut
|
||||
else if (capErrSeparate) ctx.console.error = pushErr
|
||||
else ctx.console.error = origErr
|
||||
} else {
|
||||
ctx.console.log = origLog
|
||||
if (capErrSeparate) ctx.console.error = pushErr
|
||||
else if (mergeErr) ctx.console.error = origLog
|
||||
else ctx.console.error = origErr
|
||||
}
|
||||
|
||||
let code = 'ok'
|
||||
@@ -854,7 +946,7 @@ async function execParsedPipeline(ctx, pipeline) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!isLast || cmd.redirOut) {
|
||||
if (capOut || capErrSeparate || mergeErr) {
|
||||
ctx.console.log = origLog
|
||||
ctx.console.error = origErr
|
||||
}
|
||||
@@ -862,6 +954,22 @@ async function execParsedPipeline(ctx, pipeline) {
|
||||
|
||||
if (code === 'exit') return 'exit'
|
||||
|
||||
if (ctx.exitCode !== 0) {
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
if (cmd.redirErr) {
|
||||
const epath = expandWord(cmd.redirErr, env)
|
||||
const edata = ctx.b4a.from(errChunks.join(''))
|
||||
if (cmd.redirErrAppend) {
|
||||
const prev = await vfs.readFile(epath)
|
||||
const merged = prev ? ctx.b4a.concat([prev, edata]) : edata
|
||||
await vfs.writeFile(epath, merged)
|
||||
} else {
|
||||
await vfs.writeFile(epath, edata)
|
||||
}
|
||||
}
|
||||
|
||||
let pipeOut = outChunks.join('')
|
||||
if (cmd.redirOut) {
|
||||
const path = expandWord(cmd.redirOut, env)
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
startBareService,
|
||||
stopBareService
|
||||
} from './bare-initd.js'
|
||||
import {
|
||||
readInitdDisabledSet,
|
||||
writeInitdDisabledSet
|
||||
} from './bare-initd-user.js'
|
||||
|
||||
/** @param {Uint8Array | null} buf @param {number} maxLines */
|
||||
function tailUtf8Lines(buf, maxLines) {
|
||||
@@ -58,9 +62,12 @@ function printHelp(ctx, prog) {
|
||||
${prog} status [UNIT] [--lines N]
|
||||
${prog} logs UNIT [--lines N]
|
||||
${prog} start|stop|restart UNIT
|
||||
${prog} enable|disable UNIT
|
||||
${prog} is-enabled UNIT
|
||||
${prog} help
|
||||
|
||||
Session-scoped bare-initd units. No enable/disable (not persistent).
|
||||
Persistent preset: ~/.config/bare-os/initd/disabled.txt (personal drive).
|
||||
Optional ~/.config/bare-os/units/<name>.unit with [Unit] After=other-unit.
|
||||
journalctl: journalctl -u UNIT [--lines N] (alias for logs)`
|
||||
)
|
||||
}
|
||||
@@ -127,8 +134,16 @@ export async function runSystemctlCli(ctx, argv) {
|
||||
|
||||
if (sub === 'list') {
|
||||
const defs = listBareServices()
|
||||
let disabled = new Set()
|
||||
if (ctx.vfs && typeof ctx.vfs.readFile === 'function') {
|
||||
try {
|
||||
disabled = await readInitdDisabledSet(ctx.vfs)
|
||||
} catch {
|
||||
disabled = new Set()
|
||||
}
|
||||
}
|
||||
ctx.console.log(
|
||||
'UNIT LOAD ACTIVE SUB DESCRIPTION'
|
||||
'UNIT LOAD PRESET ACTIVE SUB DESCRIPTION'
|
||||
)
|
||||
for (const d of defs) {
|
||||
const rt = getBareServiceRuntime(d.name)
|
||||
@@ -151,10 +166,11 @@ export async function runSystemctlCli(ctx, argv) {
|
||||
? 'dead'
|
||||
: 'n/a'
|
||||
const load = 'static'
|
||||
const preset = disabled.has(d.name) ? 'disabled' : 'enabled'
|
||||
const namePad = (d.name + ' '.repeat(22)).slice(0, 22)
|
||||
const desc = d.description || '—'
|
||||
ctx.console.log(
|
||||
`${namePad} ${load.padEnd(6)} ${active.padEnd(7)} ${subState.padEnd(12)} ${desc}`
|
||||
`${namePad} ${load.padEnd(6)} ${preset.padEnd(8)} ${active.padEnd(7)} ${subState.padEnd(12)} ${desc}`
|
||||
)
|
||||
}
|
||||
ctx.exitCode = 0
|
||||
@@ -181,9 +197,19 @@ export async function runSystemctlCli(ctx, argv) {
|
||||
return
|
||||
}
|
||||
const rt = getBareServiceRuntime(unit)
|
||||
let preset = 'enabled'
|
||||
if (ctx.vfs && typeof ctx.vfs.readFile === 'function') {
|
||||
try {
|
||||
const dis = await readInitdDisabledSet(ctx.vfs)
|
||||
if (dis.has(unit)) preset = 'disabled'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ctx.console.log(`● ${unit}`)
|
||||
ctx.console.log(` Description: ${def.description || '(none)'}`)
|
||||
ctx.console.log(` Load: static`)
|
||||
ctx.console.log(` Preset: ${preset}`)
|
||||
ctx.console.log(
|
||||
` Active: ${rt?.phase || 'unknown'}${rt?.error ? ` (${rt.error})` : ''}`
|
||||
)
|
||||
@@ -251,6 +277,76 @@ export async function runSystemctlCli(ctx, argv) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'enable' || sub === 'disable') {
|
||||
const unit = rest[0]
|
||||
if (!unit) {
|
||||
ctx.console.error(`${prog}: ${sub} requires a UNIT`)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const def = findBareServiceDefinition(unit)
|
||||
if (!def) {
|
||||
ctx.console.error(`${prog}: unknown unit: ${unit}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (!ctx.vfs || typeof ctx.vfs.readFile !== 'function') {
|
||||
ctx.console.error(`${prog}: vfs required for enable/disable`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dis = await readInitdDisabledSet(ctx.vfs)
|
||||
if (sub === 'disable') dis.add(unit)
|
||||
else dis.delete(unit)
|
||||
await writeInitdDisabledSet(ctx.vfs, dis)
|
||||
ctx.console.log(
|
||||
sub === 'disable'
|
||||
? `Disabled ${unit} for future boots (see ~/.config/bare-os/initd/disabled.txt)`
|
||||
: `Enabled ${unit} for future boots`
|
||||
)
|
||||
ctx.exitCode = 0
|
||||
} catch (e) {
|
||||
ctx.console.error(`${prog}: ${e?.message || String(e)}`)
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'is-enabled') {
|
||||
const unit = rest[0]
|
||||
if (!unit) {
|
||||
ctx.console.error(`${prog}: is-enabled requires a UNIT`)
|
||||
ctx.exitCode = 2
|
||||
return
|
||||
}
|
||||
const def = findBareServiceDefinition(unit)
|
||||
if (!def) {
|
||||
ctx.console.error(`${prog}: unknown unit: ${unit}`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (!ctx.vfs || typeof ctx.vfs.readFile !== 'function') {
|
||||
ctx.console.error(`${prog}: vfs required for is-enabled`)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dis = await readInitdDisabledSet(ctx.vfs)
|
||||
if (dis.has(unit)) {
|
||||
ctx.console.log('disabled')
|
||||
ctx.exitCode = 1
|
||||
} else {
|
||||
ctx.console.log('enabled')
|
||||
ctx.exitCode = 0
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.console.error(`${prog}: ${e?.message || String(e)}`)
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.console.error(`${prog}: unknown command: ${sub}`)
|
||||
ctx.exitCode = 2
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const DIR_MARKER = '.bareos_empty'
|
||||
* Unified path view: system Hyperdrive for OS paths, personal Hyperdrive under $HOME,
|
||||
* optional HDMS mounts under /mnt/<label>/…, virtual /var with writable /var/log/…
|
||||
* on the personal drive under /.bare-os/var/log/<home-seg>/… (session-isolated).
|
||||
* Read-only pseudo `proc`, `sys`, `run`, `dev` under `/`; session `tmp` maps to `/.bare-os/tmp/<seg>/` on the personal drive.
|
||||
* Read-only pseudo `proc`, `sys`, `run`, `dev` under `/` (including stub **`/proc/cpuinfo`**, **`/proc/loadavg`**, **`/proc/self/exe`**, and **`/dev/urandom`** — urandom is bounded and **not** crypto-grade); session `tmp` maps to `/.bare-os/tmp/<seg>/` on the personal drive.
|
||||
* @param {import('hyperdrive').default} systemDrive
|
||||
* @param {import('hyperdrive').default} personalDrive
|
||||
* @param {Record<string, string>} env
|
||||
@@ -161,6 +161,32 @@ export function createVfs(
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function pseudoCpuinfoText() {
|
||||
return [
|
||||
'processor\t: 0',
|
||||
'vendor_id\t: BareOS',
|
||||
'model name\t: Bare OS virtual CPU (stub)',
|
||||
'flags\t: bare_os',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function pseudoLoadavgText() {
|
||||
return '0.05 0.04 0.02 1/64 1\n'
|
||||
}
|
||||
|
||||
function pseudoSelfExeText() {
|
||||
return '/bin/sh (bare-os stub; not a real inode)\n'
|
||||
}
|
||||
|
||||
/** Bounded pseudo-random buffer; **not** suitable for cryptography. */
|
||||
function pseudoUrandomBytes() {
|
||||
const n = 4096
|
||||
const u = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i++) u[i] = Math.floor(Math.random() * 256)
|
||||
return u
|
||||
}
|
||||
|
||||
function pseudoFileBytes(routePseudo) {
|
||||
const f = routePseudo.file
|
||||
const k = routePseudo.kind
|
||||
@@ -172,6 +198,9 @@ export function createVfs(
|
||||
if (f === 'environ') return pseudoEnvironBytes()
|
||||
if (f === 'uptime') return utf8Encode(pseudoUptimeText())
|
||||
if (f === 'meminfo') return utf8Encode(pseudoMeminfoText())
|
||||
if (f === 'cpuinfo') return utf8Encode(pseudoCpuinfoText())
|
||||
if (f === 'loadavg') return utf8Encode(pseudoLoadavgText())
|
||||
if (f === 'exe') return utf8Encode(pseudoSelfExeText())
|
||||
}
|
||||
if (k === 'run' && f === 'units') {
|
||||
const t = initdRunText
|
||||
@@ -181,6 +210,7 @@ export function createVfs(
|
||||
}
|
||||
if (k === 'dev' && f === 'null') return utf8Encode('')
|
||||
if (k === 'dev' && f === 'zero') return new Uint8Array(65536)
|
||||
if (k === 'dev' && f === 'urandom') return pseudoUrandomBytes()
|
||||
return utf8Encode('')
|
||||
}
|
||||
|
||||
@@ -213,6 +243,15 @@ export function createVfs(
|
||||
if (sub === 'meminfo') {
|
||||
return { virtualPseudo: true, kind: 'proc', node: 'file', file: 'meminfo' }
|
||||
}
|
||||
if (sub === 'cpuinfo') {
|
||||
return { virtualPseudo: true, kind: 'proc', node: 'file', file: 'cpuinfo' }
|
||||
}
|
||||
if (sub === 'loadavg') {
|
||||
return { virtualPseudo: true, kind: 'proc', node: 'file', file: 'loadavg' }
|
||||
}
|
||||
if (sub === 'self/exe') {
|
||||
return { virtualPseudo: true, kind: 'proc', node: 'file', file: 'exe' }
|
||||
}
|
||||
return { virtualPseudo: true, kind: 'proc', node: 'enoent' }
|
||||
}
|
||||
if (n === '/run' || n.startsWith('/run/')) {
|
||||
@@ -237,6 +276,9 @@ export function createVfs(
|
||||
if (n === '/dev/zero') {
|
||||
return { virtualPseudo: true, kind: 'dev', node: 'file', file: 'zero' }
|
||||
}
|
||||
if (n === '/dev/urandom') {
|
||||
return { virtualPseudo: true, kind: 'dev', node: 'file', file: 'urandom' }
|
||||
}
|
||||
return { virtualPseudo: true, kind: 'dev', node: 'enoent' }
|
||||
}
|
||||
if (n === '/sys' || n.startsWith('/sys/')) {
|
||||
@@ -727,10 +769,18 @@ export function createVfs(
|
||||
throw new Error('Not a directory: ' + abs)
|
||||
}
|
||||
if (pr.kind === 'proc' && pr.node === 'root') {
|
||||
return ['bare_os_version', 'meminfo', 'self', 'uptime', 'version']
|
||||
return [
|
||||
'bare_os_version',
|
||||
'cpuinfo',
|
||||
'loadavg',
|
||||
'meminfo',
|
||||
'self',
|
||||
'uptime',
|
||||
'version'
|
||||
]
|
||||
}
|
||||
if (pr.kind === 'proc' && pr.node === 'dir' && pr.dir === 'self') {
|
||||
return ['cmdline', 'environ']
|
||||
return ['cmdline', 'environ', 'exe']
|
||||
}
|
||||
if (pr.kind === 'sys' && pr.node === 'root') {
|
||||
return ['fs']
|
||||
@@ -748,7 +798,7 @@ export function createVfs(
|
||||
return ['units']
|
||||
}
|
||||
if (pr.kind === 'dev' && pr.node === 'root') {
|
||||
return ['null', 'zero']
|
||||
return ['null', 'urandom', 'zero']
|
||||
}
|
||||
}
|
||||
const r = route(abs)
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
jobMatchesDate
|
||||
} from './lib/bare-cron.js'
|
||||
import {
|
||||
getBareServiceRuntime,
|
||||
registerBareInitdDisposer,
|
||||
registerKernelShutdownHook,
|
||||
runKernelShutdownHooks,
|
||||
@@ -462,6 +463,8 @@ test('vfs /proc /sys read-only pseudo files; write rejected', async (t) => {
|
||||
t.ok(root.includes('dev'))
|
||||
t.alike(await vfs.readdir('/proc').then((a) => [...a].sort()), [
|
||||
'bare_os_version',
|
||||
'cpuinfo',
|
||||
'loadavg',
|
||||
'meminfo',
|
||||
'self',
|
||||
'uptime',
|
||||
@@ -469,7 +472,8 @@ test('vfs /proc /sys read-only pseudo files; write rejected', async (t) => {
|
||||
])
|
||||
t.alike(await vfs.readdir('/proc/self').then((a) => [...a].sort()), [
|
||||
'cmdline',
|
||||
'environ'
|
||||
'environ',
|
||||
'exe'
|
||||
])
|
||||
const ver = b4a.toString(await vfs.readFile('/proc/version'))
|
||||
t.ok(ver.includes('Bare OS'))
|
||||
@@ -484,10 +488,19 @@ test('vfs /proc /sys read-only pseudo files; write rejected', async (t) => {
|
||||
t.ok(mem.includes('MemTotal:'))
|
||||
const up = b4a.toString(await vfs.readFile('/proc/uptime'))
|
||||
t.ok(/^\d+\.\d+\s+\d+\.\d+/.test(up.trim()))
|
||||
const cpu = b4a.toString(await vfs.readFile('/proc/cpuinfo'))
|
||||
t.ok(cpu.includes('Bare OS virtual CPU'))
|
||||
const la = b4a.toString(await vfs.readFile('/proc/loadavg'))
|
||||
t.ok(/^\d+\.\d+\s+\d+\.\d+\s+\d+\.\d+\s+/.test(la))
|
||||
const exe = b4a.toString(await vfs.readFile('/proc/self/exe'))
|
||||
t.ok(exe.includes('bare-os stub'))
|
||||
t.alike(await vfs.readdir('/dev').then((a) => [...a].sort()), [
|
||||
'null',
|
||||
'urandom',
|
||||
'zero'
|
||||
])
|
||||
const ur = await vfs.readFile('/dev/urandom')
|
||||
t.ok(ur && ur.byteLength === 4096)
|
||||
await vfs.writeFile('/dev/null', b4a.from('gone'))
|
||||
t.is((await vfs.readFile('/dev/null'))?.byteLength ?? 0, 0)
|
||||
const z = await vfs.readFile('/dev/zero')
|
||||
@@ -804,6 +817,55 @@ test('loadBarerc createSkeletonIfMissing writes ~/.barerc when absent', async (t
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('tokenize leaves echo 2 > file as stdout redirect not 2>', async (t) => {
|
||||
const toks = tokenize('echo 2 > /tmp/x')
|
||||
const words = toks.filter((x) => x.type === 'word').map((x) => x.value)
|
||||
t.ok(words.includes('2'))
|
||||
t.ok(toks.some((x) => x.type === 'op' && x.value === '>'))
|
||||
t.ok(!toks.some((x) => x.type === 'op' && x.value === '2>'))
|
||||
})
|
||||
|
||||
test('execShellLine stderr 2> and 2>&1 in pipeline', async (t) => {
|
||||
const dir = testCorestoreDir('sherr')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('serr'))
|
||||
await drive.ready()
|
||||
await personal.ready()
|
||||
const emit = `
|
||||
async function run(ctx) {
|
||||
ctx.console.log('OUT')
|
||||
ctx.console.error('ERR')
|
||||
}
|
||||
`
|
||||
const cat = `
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
async function run(ctx) {
|
||||
ctx.console.log(bareStdin(ctx).replace(/\\n$/, ''))
|
||||
}
|
||||
`
|
||||
await drive.put('/bin/emit', b4a.from(emit))
|
||||
await drive.put('/bin/cat', b4a.from(cat))
|
||||
const ctx = testCtx(drive, personal)
|
||||
await execShellLine(ctx, 'emit 2>~/e.out')
|
||||
const eb = await ctx.vfs.readFile('~/e.out')
|
||||
t.ok(eb && ctx.b4a.toString(eb).includes('ERR'))
|
||||
t.ok(!ctx.b4a.toString(eb).includes('OUT'))
|
||||
await ctx.vfs.unlink('~/e.out')
|
||||
const logs = []
|
||||
ctx.console = {
|
||||
log: (...a) => logs.push(['out', ...a]),
|
||||
error: (...a) => logs.push(['err', ...a])
|
||||
}
|
||||
await execShellLine(ctx, 'emit 2>&1 | cat')
|
||||
const piped = logs.filter((x) => x[0] === 'out').map((x) => x.slice(1).join(' '))
|
||||
t.ok(piped.some((l) => l.includes('OUT') && l.includes('ERR')))
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('execShellLine pipeline stage and byte limits', async (t) => {
|
||||
const dir = testCorestoreDir('shpipe')
|
||||
const store = new Corestore(dir)
|
||||
@@ -817,6 +879,9 @@ async function run(ctx, argv) {
|
||||
}
|
||||
`
|
||||
const cat = `
|
||||
function bareStdin(ctx) {
|
||||
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
|
||||
}
|
||||
async function run(ctx) {
|
||||
ctx.console.log(bareStdin(ctx).replace(/\\n$/, ''))
|
||||
}
|
||||
@@ -1145,6 +1210,38 @@ test('systemctl restart bare-cron succeeds', async (t) => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('initd disabled.txt skips unit on boot; systemctl enable undoes', async (t) => {
|
||||
const dir = testCorestoreDir('initdis')
|
||||
const store = new Corestore(dir)
|
||||
const sys = new Hyperdrive(store)
|
||||
const personal = new Hyperdrive(store.namespace('pdis'))
|
||||
await sys.ready()
|
||||
await personal.ready()
|
||||
const ctx = testCtx(sys, personal)
|
||||
ctx.execLine = async () => {}
|
||||
await ctx.vfs.mkdir('/home/user/.config/bare-os/initd', { recursive: true })
|
||||
await ctx.vfs.writeFile(
|
||||
'/home/user/.config/bare-os/initd/disabled.txt',
|
||||
b4a.from('bare-cron\n')
|
||||
)
|
||||
await startBareInitd(ctx)
|
||||
t.is(getBareServiceRuntime('bare-cron'), undefined)
|
||||
t.is(getBareServiceRuntime('kernel-logger')?.phase, 'active')
|
||||
stopBareInitd()
|
||||
ctx.exitCode = 0
|
||||
ctx.console = {
|
||||
log: () => {},
|
||||
error: () => {}
|
||||
}
|
||||
await runBinCommand(ctx, ['systemctl', 'enable', 'bare-cron'])
|
||||
t.is(ctx.exitCode, 0)
|
||||
await startBareInitd(ctx)
|
||||
t.is(getBareServiceRuntime('bare-cron')?.phase, 'active')
|
||||
stopBareInitd()
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('stopBareInitd runs registered disposers', async (t) => {
|
||||
let n = 0
|
||||
registerBareInitdDisposer(() => {
|
||||
@@ -1574,6 +1671,66 @@ test('curl delegated from booter with stub fetch', async (t) => {
|
||||
t.is(ctx.exitCode, 0)
|
||||
t.is(lines[lines.length - 1], '1')
|
||||
|
||||
lines.length = 0
|
||||
ctx.exitCode = 0
|
||||
/** @type {RequestInit | undefined} */
|
||||
let curlCookieInit
|
||||
ctx.httpFetch = async (_url, init) => {
|
||||
curlCookieInit = init
|
||||
return new Response('c', { status: 200 })
|
||||
}
|
||||
await runBinCommand(ctx, [
|
||||
'curl',
|
||||
'-b',
|
||||
'sid=abc',
|
||||
'https://stub.example/cookie-inline'
|
||||
])
|
||||
t.is(ctx.exitCode, 0)
|
||||
t.is(headerFromInit(curlCookieInit, 'Cookie'), 'sid=abc')
|
||||
|
||||
lines.length = 0
|
||||
ctx.exitCode = 0
|
||||
await ctx.vfs.writeFile(
|
||||
'/home/user/cookies.json',
|
||||
b4a.from(JSON.stringify({ 'stub.example': { x: 'y' } }), 'utf8')
|
||||
)
|
||||
ctx.httpFetch = async (_url, init) => {
|
||||
curlCookieInit = init
|
||||
return new Response('c', { status: 200 })
|
||||
}
|
||||
await runBinCommand(ctx, [
|
||||
'curl',
|
||||
'-b',
|
||||
'/home/user/cookies.json',
|
||||
'https://stub.example/cookie-file'
|
||||
])
|
||||
t.is(ctx.exitCode, 0)
|
||||
t.ok(String(headerFromInit(curlCookieInit, 'Cookie')).includes('x=y'))
|
||||
|
||||
lines.length = 0
|
||||
ctx.exitCode = 0
|
||||
ctx.httpFetch = async () =>
|
||||
new Response('z', {
|
||||
status: 200,
|
||||
headers: { 'Set-Cookie': 'sess=hello; Path=/' }
|
||||
})
|
||||
await runBinCommand(ctx, [
|
||||
'curl',
|
||||
'-c',
|
||||
'/home/user/jar.json',
|
||||
'https://stub.example/jar'
|
||||
])
|
||||
t.is(ctx.exitCode, 0)
|
||||
const jraw = await ctx.vfs.readFile('/home/user/jar.json')
|
||||
t.ok(jraw && ctx.b4a.toString(jraw).includes('sess'))
|
||||
|
||||
lines.length = 0
|
||||
ctx.exitCode = 0
|
||||
ctx.httpFetch = async () => new Response('z', { status: 200 })
|
||||
await runBinCommand(ctx, ['curl', '-k', 'https://stub.example/insecure'])
|
||||
t.is(ctx.exitCode, 2)
|
||||
t.ok(lines.some((l) => String(l).includes('httpFetch')))
|
||||
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -54,6 +54,19 @@ async function run(ctx, argv) {
|
||||
? dest.replace(/\/+$/, '') + '/' + base
|
||||
: dest
|
||||
try {
|
||||
const st = await ctx.vfs.lstat(src)
|
||||
const singleFileToFile =
|
||||
sources.length === 1 &&
|
||||
!destIsDir &&
|
||||
st &&
|
||||
st.type === 'file'
|
||||
if (singleFileToFile) {
|
||||
const buf = await ctx.vfs.readFile(src)
|
||||
if (!buf) throw new Error('cannot read source')
|
||||
await ctx.vfs.writeFile(target, buf)
|
||||
await ctx.vfs.unlink(src)
|
||||
continue
|
||||
}
|
||||
if (!(await mvCopyPath(ctx, src, target, true))) {
|
||||
throw new Error('cannot copy')
|
||||
}
|
||||
|
||||
@@ -116,6 +116,19 @@ async function run(ctx, argv) {
|
||||
? dest.replace(/\/+$/, '') + '/' + base
|
||||
: dest
|
||||
try {
|
||||
const st = await ctx.vfs.lstat(src)
|
||||
const singleFileToFile =
|
||||
sources.length === 1 &&
|
||||
!destIsDir &&
|
||||
st &&
|
||||
st.type === 'file'
|
||||
if (singleFileToFile) {
|
||||
const buf = await ctx.vfs.readFile(src)
|
||||
if (!buf) throw new Error('cannot read source')
|
||||
await ctx.vfs.writeFile(target, buf)
|
||||
await ctx.vfs.unlink(src)
|
||||
continue
|
||||
}
|
||||
if (!(await mvCopyPath(ctx, src, target, true))) {
|
||||
throw new Error('cannot copy')
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user