/**
* `jss install ` install a Solid app into a running pod.
*
* Phase 1 of #464 (see #478 for the scoped issue). Hardcodes the
* default registry to `github.com/solid-apps/`. Later phases
* add `/` shorthand, full URLs, `#` pinning,
* `=` rename, `--did` resolution, `--nostr-privkey` NIP-98
* auth, a curated default set, and `--bundle`.
*
* The install is a thin shell over the auto-init + updateInstead +
* regular-repo machinery already in `src/handlers/git.js`:
*
* 1. POST /idp/credentials with the supplied creds bearer token
* 2. `git clone /tmp/` (full clone shallow pushes
* are rejected by JSS git-receive)
* 3. Push the clone to `/public/apps/` with the bearer
* header, on BOTH `HEAD:main` and `HEAD:gh-pages` so it works
* regardless of the operator's `init.defaultBranch`. Whichever
* matches server-side HEAD triggers `updateInstead` and extracts
* the working tree; the other creates a stranded ref.
*
* Idempotent on re-run (existing repo at the path accepts the push
* normally). Skip-on-existing-non-repo translates auto-init's
* "won't init a non-empty path" 404 into a friendly message.
*/
import { spawnSync } from 'child_process';
import { existsSync, readFileSync, rmSync } from 'fs';
import { join, isAbsolute } from 'path';
import { tmpdir } from 'os';
import { nip98Token } from '../nostr/event.js';
// ANSI helpers keep zero-dep so this works in any embedded usage.
const c = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
const green = c(32);
const yellow = c(33);
const red = c(31);
const dim = c(2);
const bold = c(1);
/**
* Parse an app spec. Accepts (Phase 2 of #464):
* - bare name github.com/solid-apps/ (default registry)
* - "/" github.com//
* - "https://..." full URL as-is (must point at a git repo)
* Each form may carry an optional "#" suffix to pin a tag or branch:
* chrome#v1.2 / solid-apps/chrome#main / https://...#v2
* And an optional "=" suffix to override the pod-path name:
* litecut/litecut.github.io=litecut
*/
function parseAppSpec(input) {
// Pull off the rename suffix first, then the ref suffix.
let base = input;
let renameName = null;
const eqIx = base.lastIndexOf('=');
if (eqIx > 0) {
renameName = base.slice(eqIx + 1);
base = base.slice(0, eqIx);
}
let ref = null;
const hashIx = base.lastIndexOf('#');
if (hashIx > 0) {
ref = base.slice(hashIx + 1) || null;
base = base.slice(0, hashIx);
}
let source, name;
if (/^https?:\/\//.test(base)) {
source = base.replace(/\.git$/, '').replace(/\/$/, '');
name = source.split('/').pop();
} else if (base.includes('/')) {
const cleaned = base.replace(/\.git$/, '').replace(/^\/+|\/+$/g, '');
if (cleaned.split('/').length !== 2) {
return { error: 'expected / shorthand' };
}
source = `https://github.com/${cleaned}`;
name = cleaned.split('/').pop();
} else {
source = `https://github.com/solid-apps/${base}`;
name = base;
}
if (renameName) name = renameName;
if (!/^[a-z0-9][a-z0-9_.-]*$/i.test(name)) {
return { error: `invalid pod-path name "${name}"` };
}
if (ref && !/^[a-z0-9][a-z0-9_./-]*$/i.test(ref)) {
return { error: `invalid ref "${ref}"` };
}
return { source, name, ref };
}
/**
* Resolve a `--bundle` source string to either a fully-qualified URL
* or an absolute local-file path.
*
* --bundle media github.com/solid-apps/bundles/HEAD/media.jsonld
* --bundle / github.com///HEAD/bundle.jsonld
* --bundle https://... as-is (must end in a JSON-LD doc)
* --bundle ./path or /abs/path absolute local-file path
*
* Returns { url } or { path } or { error }.
*/
function resolveBundleSource(input) {
if (!input || typeof input !== 'string') {
return { error: 'bundle source is required' };
}
if (/^https?:\/\//.test(input)) {
return { url: input };
}
if (input.startsWith('./') || input.startsWith('../') || isAbsolute(input) || input.startsWith('~/')) {
const path = input.startsWith('~/')
? join(process.env.HOME || '', input.slice(2))
: (isAbsolute(input) ? input : join(process.cwd(), input));
return { path };
}
// Bare names + / shorthand. We hit raw.githubusercontent.com
// so the response is the raw JSON-LD body (no HTML wrapper).
if (input.includes('/')) {
const cleaned = input.replace(/\.git$/, '').replace(/^\/+|\/+$/g, '');
if (cleaned.split('/').length !== 2) {
return { error: 'expected / shorthand for bundle source' };
}
return { url: `https://raw.githubusercontent.com/${cleaned}/HEAD/bundle.jsonld` };
}
if (!/^[a-z0-9][a-z0-9_.-]*$/i.test(input)) {
return { error: `invalid bundle name "${input}"` };
}
return { url: `https://raw.githubusercontent.com/solid-apps/bundles/HEAD/${input}.jsonld` };
}
/**
* Fetch + parse a bundle into a normalized list of app-spec strings.
*
* Returns { name, description, items } or { error }.
*
* Items are normalized: each becomes the string-form spec the caller
* feeds to `parseAppSpec`. Object items with `app:spec` are unwrapped
* to their spec string; the optional label/description are ignored
* by the install path (they're for UI tooling that consumes bundles).
*/
async function loadBundle(resolved) {
let body;
try {
if (resolved.path) {
if (!existsSync(resolved.path)) {
return { error: `bundle file not found: ${resolved.path}` };
}
body = readFileSync(resolved.path, 'utf8');
} else {
const r = await fetch(resolved.url);
if (!r.ok) return { error: `bundle fetch failed: HTTP ${r.status} on ${resolved.url}` };
body = await r.text();
}
} catch (e) {
return { error: `could not read bundle: ${e.message}` };
}
let doc;
try {
doc = JSON.parse(body);
} catch (e) {
return { error: `bundle is not valid JSON: ${e.message}` };
}
// Items live under `schema:itemListElement` (preferred) or the
// un-prefixed `itemListElement` (common when @context aliases it).
const rawItems = doc['schema:itemListElement']
?? doc['itemListElement']
?? doc['items'] // also accept a loose `items` key for hand-written bundles
?? null;
if (!Array.isArray(rawItems) || rawItems.length === 0) {
return { error: 'bundle has no items (expected schema:itemListElement array)' };
}
const items = [];
for (const it of rawItems) {
if (typeof it === 'string') {
items.push(it);
} else if (it && typeof it === 'object') {
const spec = it['app:spec'] || it['spec'] || it['urn:jss:app:spec'];
if (typeof spec !== 'string') {
return { error: `bundle item missing app:spec: ${JSON.stringify(it).slice(0, 100)}` };
}
items.push(spec);
} else {
return { error: `bundle item must be a string or object, got: ${typeof it}` };
}
}
return {
name: doc['schema:name'] || doc.name || null,
description: doc['schema:description'] || doc.description || null,
items
};
}
/**
* Fetch a bearer token from the pod's IDP, or null if the pod runs
* with `--public` (no auth required for writes).
*/
async function fetchToken({ pod, user, password }) {
// First check whether the pod accepts unauthenticated writes if so,
// we don't need a token. Probe with HEAD on /public/ (which any pod
// exposes); if writes there require auth, we'll need the token.
// For Phase 1 we just always try to fetch a token; if the IDP isn't
// running, we fall through with a clear error.
try {
const r = await fetch(`${pod}/idp/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username: user, password })
});
if (r.status === 404) {
// No IDP pod is likely in --public mode. Proceed without a token.
return null;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json();
if (!j.access_token) throw new Error('no access_token in response');
return j.access_token;
} catch (e) {
throw new Error(`Could not authenticate against ${pod}: ${e.message}`);
}
}
// Best-effort recursive cleanup of the scratch clone dir. fs.rmSync
// with `force: true` silently ignores ENOENT (so no existsSync guard
// is needed), and the try/catch absorbs the rare EBUSY / EACCES that
// can fire on Windows when an editor or antivirus still holds a
// handle inside the tree. Mirrors the original `spawnSync('rm',
// '-rf', ..., { stdio: 'ignore' })` contract: an install must not
// fail after a successful clone/push just because tmp cleanup didn't
// go through. Portable: no shell-out, no /tmp assumptions.
function cleanupTmp(tmp) {
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
}
/**
* Install one app spec to one pod. Returns a status object the caller
* uses for per-app output + exit-code aggregation.
*/
async function installOne({ spec, pod, token, nostrPrivkey }) {
const { source, name, ref } = spec;
const dest = `${pod}/public/apps/${name}`;
// Respect the platform's tmp dir '/tmp' is hardcoded out on
// Termux (Android), where the writable tmp is at $PREFIX/tmp.
// os.tmpdir() respects $TMPDIR and falls back sensibly everywhere.
const tmp = join(tmpdir(), `jss-install-${name}-${process.pid}`);
// Clean any stale tmp from a prior failed run.
cleanupTmp(tmp);
// Clone (no --depth: shallow pushes are rejected by JSS git-receive).
// --branch picks a tag or branch when pinned (e.g. `foo/bar#v2`).
const cloneArgs = ['clone', '--quiet'];
if (ref) cloneArgs.push('--branch', ref);
cloneArgs.push(source, tmp);
const clone = spawnSync('git', cloneArgs, {
stdio: ['ignore', 'pipe', 'pipe']
});
if (clone.status !== 0) {
const err = clone.stderr?.toString?.().trim() || '';
return { name, status: 'failed', reason: `clone failed${err ? `: ${err.slice(0, 300)}` : ''}` };
}
// Build the Authorization header for the push. NIP-98 (Phase 4)
// signs a Nostr event with the user's Schnorr key no IDP creds
// needed; JSS verifies via src/auth/token.js and returns a
// did:nostr: identity for ACL matching.
//
// Git makes BOTH an advertise GET (`info/refs?service=git-receive-pack`)
// and a receive POST (`git-receive-pack`) on the same http.extraHeader.
// JSS is lenient for git clients (src/auth/nostr.js): it accepts a
// NIP-98 event whose `u` is a prefix of the request URL, and whose
// `method` is `*` as a wildcard. Sign the base URL with method `*`
// so the same event passes auth on both requests.
const authHeader = () => {
if (nostrPrivkey) {
const b64 = nip98Token(dest, '*', nostrPrivkey);
return `Authorization: Nostr ${b64}`;
}
if (token) return `Authorization: Bearer ${token}`;
return null;
};
// Dual push: HEAD:main and HEAD:gh-pages. Whichever matches server-
// side HEAD triggers updateInstead and extracts the working tree.
// The other just creates a stranded ref (harmless). Idempotent.
// Each push gets a freshly-signed NIP-98 event (the signature is
// tied to a specific u + method + timestamp window).
const pushArgs = (branch) => {
const args = ['-C', tmp];
const auth = authHeader();
if (auth) args.push('-c', `http.extraHeader=${auth}`);
args.push('push', dest, `HEAD:${branch}`);
return args;
};
const pushMain = spawnSync('git', pushArgs('main'), { stdio: ['ignore', 'pipe', 'pipe'] });
const errMain = pushMain.stderr?.toString?.() || '';
// Auto-init refuses on a non-empty target dir 404 / "not found".
// Distinguish "path already in use" from real errors.
if (pushMain.status !== 0 && (errMain.includes('not found') || errMain.includes('404'))) {
cleanupTmp(tmp);
return { name, status: 'skipped', reason: 'path already in use' };
}
const pushPages = spawnSync('git', pushArgs('gh-pages'), { stdio: ['ignore', 'pipe', 'pipe'] });
if (pushMain.status !== 0 && pushPages.status !== 0) {
const err = (errMain + '\n' + (pushPages.stderr?.toString?.() || '')).trim();
cleanupTmp(tmp);
return { name, status: 'failed', reason: `push failed: ${err.slice(0, 400)}` };
}
cleanupTmp(tmp);
return { name, status: 'installed', dest: `${dest}/` };
}
/**
* Public entry point. Called from bin/jss.js's `install` subcommand
* action. Throws to signal a non-zero exit; returns normally for
* partial success (per-app errors are reported but don't fail-fast).
*/
export async function runInstall(names, options) {
const pod = (options.pod || 'http://localhost:4443').replace(/\/$/, '');
const user = options.user || 'me';
const password = options.password || process.env.JSS_SINGLE_USER_PASSWORD || 'me';
const nostrPrivkey = options.nostrPrivkey || process.env.NOSTR_PRIVKEY || null;
// Validate Nostr privkey if supplied (64 lowercase-hex chars).
if (nostrPrivkey && !/^[0-9a-f]{64}$/i.test(nostrPrivkey)) {
console.error(red(' --nostr-privkey must be 64 hex chars'));
throw new Error('invalid --nostr-privkey');
}
// Bundle mode: resolve, fetch, parse, then concatenate items with
// any positional ad-hoc apps. `--bundle chrome` installs the
// bundle + chrome; same auth + target flags apply to all.
let bundleMeta = null;
let allNames = [...(names || [])];
if (options.bundle) {
const resolved = resolveBundleSource(options.bundle);
if (resolved.error) {
console.error(red(` --bundle: ${resolved.error}`));
throw new Error(resolved.error);
}
const bundle = await loadBundle(resolved);
if (bundle.error) {
console.error(red(` --bundle: ${bundle.error}`));
throw new Error(bundle.error);
}
bundleMeta = { name: bundle.name, description: bundle.description, count: bundle.items.length };
// Bundle items go first; positional names appended afterwards.
allNames = [...bundle.items, ...allNames];
}
if (allNames.length === 0) {
throw new Error('expected at least one app name or a --bundle. Try: `jss install chrome`');
}
// Validate every spec up front so we report invalid names before
// doing any network work.
const specs = [];
for (const n of allNames) {
const spec = parseAppSpec(n);
if (spec.error) {
console.error(red(` ${n}: ${spec.error}`));
throw new Error(`invalid app spec: ${n}`);
}
specs.push(spec);
}
if (bundleMeta) {
const label = bundleMeta.name
? `bundle "${bundleMeta.name}"`
: 'bundle';
console.log(bold(`\nInstalling ${specs.length} app${specs.length === 1 ? '' : 's'} from ${label} `) + green(pod));
if (bundleMeta.description) console.log(dim(` ${bundleMeta.description}`));
} else {
console.log(bold(`\nInstalling ${specs.length} app${specs.length === 1 ? '' : 's'} `) + green(pod));
}
if (nostrPrivkey) console.log(dim(' (signing with Nostr privkey NIP-98)'));
console.log('');
// With --nostr-privkey we sign each push as NIP-98; no bearer token
// needed. Otherwise fetch the bearer token from the pod's IDP.
let token = null;
if (!nostrPrivkey) try {
token = await fetchToken({ pod, user, password });
} catch (e) {
console.error(red(` ${e.message}`));
console.error(dim(' Is the pod running? Try: `jss start`'));
throw e;
}
// Decode hex privkey once for installOne (nip98Token wants bytes).
const privkeyBytes = nostrPrivkey
? Uint8Array.from(Buffer.from(nostrPrivkey, 'hex'))
: null;
let okCount = 0;
let failCount = 0;
for (const spec of specs) {
const result = await installOne({ spec, pod, token, nostrPrivkey: privkeyBytes });
switch (result.status) {
case 'installed':
console.log(green(` ${result.name}`) + dim(` ${result.dest}`));
okCount++;
break;
case 'skipped':
console.log(yellow(` ${result.name}`) + dim(`: skipped (${result.reason})`));
break;
case 'failed':
console.error(red(` ${result.name}: ${result.reason.split(':')[0]}`));
if (result.reason.includes(':')) {
console.error(dim(` ${result.reason.slice(result.reason.indexOf(':') + 1).trim()}`));
}
failCount++;
break;
}
}
console.log('');
console.log(bold(`${okCount}/${specs.length} installed.`));
if (okCount > 0) {
console.log(dim('Open in browser: ') + `${pod}/public/apps/`);
}
if (failCount > 0) {
throw new Error(`${failCount} of ${specs.length} install(s) failed`);
}
}