Skip to content

Commit 99d8bcb

Browse files
feat(cli): \jss install --bundle <source>\ (JavaScriptSolidServer#485, Phase 6 of JavaScriptSolidServer#464) (JavaScriptSolidServer#486)
Apt-style meta-packages on top of the install atom. Bundles are JSON-LD ItemList docs naming app specs to install. Same auth, same target, same per-app status; composes with positional ad-hoc names. jss install --bundle ./media.jsonld jss install --bundle media # → solid-apps/bundles/main/media.jsonld jss install --bundle <org>/<repo> # → that repo's main/bundle.jsonld jss install --bundle https://example.com/.jsonld jss install --bundle media chrome vellum # bundle + ad-hoc Bundle items are bare strings (any Phase 1+2 spec) OR objects with required app:spec + optional app:label / app:description. The install path normalizes both forms and feeds them through the existing parseAppSpec + installOne machinery. Bundle header (name + description) prints once. Per-app failures report individually; continue-on-error; exit non-zero if any failed. Resolves to raw.githubusercontent.com for the URL shorthands so the fetch returns the raw JSON-LD body (not GitHub's HTML page wrapper). Verified end-to-end: - local-file bundle (./media.jsonld) → all items installed - absolute-path bundle (/tmp/...) → works - items as bare strings + items as objects + items with rename suffix - bundle + ad-hoc args composed - bare-name shorthand → correctly fetches solid-apps/bundles/main/<name>.jsonld - malformed JSON, missing items, invalid spec — clear errors, exit 1 Fixes JavaScriptSolidServer#485
1 parent 1c5f608 commit 99d8bcb

2 files changed

Lines changed: 145 additions & 10 deletions

File tree

bin/jss.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,12 +316,13 @@ program
316316
* resolution, NIP-98 auth, curated default sets, and --bundle.
317317
*/
318318
program
319-
.command('install <names...>')
320-
.description('Install a Solid app from github.com/solid-apps/<name> into a running pod')
319+
.command('install [names...]')
320+
.description('Install a Solid app (or a bundle of apps) into a running pod')
321321
.option('--pod <url>', 'Target pod URL', 'http://localhost:4443')
322322
.option('--user <name>', 'Username for IDP auth', 'me')
323323
.option('--password <pw>', 'Password (default: $JSS_SINGLE_USER_PASSWORD or "me")')
324324
.option('--nostr-privkey <hex>', 'Sign install pushes with NIP-98 using this 64-char hex Nostr privkey instead of fetching a bearer token (default: $NOSTR_PRIVKEY)')
325+
.option('--bundle <source>', 'Install everything in a bundle (JSON-LD doc). Source: bare name → solid-apps/bundles, <org>/<repo>, https://..., or a local path')
325326
.action(async (names, options) => {
326327
try {
327328
const { runInstall } = await import('../src/cli/install.js');

src/cli/install.js

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
*/
2626

2727
import { spawnSync } from 'child_process';
28-
import { existsSync } from 'fs';
29-
import { join } from 'path';
28+
import { existsSync, readFileSync } from 'fs';
29+
import { join, isAbsolute } from 'path';
3030
import { nip98Token } from '../nostr/event.js';
3131

3232
// ANSI helpers — keep zero-dep so this works in any embedded usage.
@@ -87,6 +87,111 @@ function parseAppSpec(input) {
8787
return { source, name, ref };
8888
}
8989

90+
/**
91+
* Resolve a `--bundle` source string to either a fully-qualified URL
92+
* or an absolute local-file path.
93+
*
94+
* --bundle media → github.com/solid-apps/bundles/main/media.jsonld
95+
* --bundle <org>/<repo> → github.com/<org>/<repo>/main/bundle.jsonld
96+
* --bundle https://... → as-is (must end in a JSON-LD doc)
97+
* --bundle ./path or /abs/path → absolute local-file path
98+
*
99+
* Returns { url } or { path } or { error }.
100+
*/
101+
function resolveBundleSource(input) {
102+
if (!input || typeof input !== 'string') {
103+
return { error: 'bundle source is required' };
104+
}
105+
if (/^https?:\/\//.test(input)) {
106+
return { url: input };
107+
}
108+
if (input.startsWith('./') || input.startsWith('../') || isAbsolute(input) || input.startsWith('~/')) {
109+
const path = input.startsWith('~/')
110+
? join(process.env.HOME || '', input.slice(2))
111+
: (isAbsolute(input) ? input : join(process.cwd(), input));
112+
return { path };
113+
}
114+
// Bare names + <org>/<repo> shorthand. We hit raw.githubusercontent.com
115+
// so the response is the raw JSON-LD body (no HTML wrapper).
116+
if (input.includes('/')) {
117+
const cleaned = input.replace(/\.git$/, '').replace(/^\/+|\/+$/g, '');
118+
if (cleaned.split('/').length !== 2) {
119+
return { error: 'expected <org>/<repo> shorthand for bundle source' };
120+
}
121+
return { url: `https://raw.githubusercontent.com/${cleaned}/main/bundle.jsonld` };
122+
}
123+
if (!/^[a-z0-9][a-z0-9_.-]*$/i.test(input)) {
124+
return { error: `invalid bundle name "${input}"` };
125+
}
126+
return { url: `https://raw.githubusercontent.com/solid-apps/bundles/main/${input}.jsonld` };
127+
}
128+
129+
/**
130+
* Fetch + parse a bundle into a normalized list of app-spec strings.
131+
*
132+
* Returns { name, description, items } or { error }.
133+
*
134+
* Items are normalized: each becomes the string-form spec the caller
135+
* feeds to `parseAppSpec`. Object items with `app:spec` are unwrapped
136+
* to their spec string; the optional label/description are ignored
137+
* by the install path (they're for UI tooling that consumes bundles).
138+
*/
139+
async function loadBundle(resolved) {
140+
let body;
141+
try {
142+
if (resolved.path) {
143+
if (!existsSync(resolved.path)) {
144+
return { error: `bundle file not found: ${resolved.path}` };
145+
}
146+
body = readFileSync(resolved.path, 'utf8');
147+
} else {
148+
const r = await fetch(resolved.url);
149+
if (!r.ok) return { error: `bundle fetch failed: HTTP ${r.status} on ${resolved.url}` };
150+
body = await r.text();
151+
}
152+
} catch (e) {
153+
return { error: `could not read bundle: ${e.message}` };
154+
}
155+
156+
let doc;
157+
try {
158+
doc = JSON.parse(body);
159+
} catch (e) {
160+
return { error: `bundle is not valid JSON: ${e.message}` };
161+
}
162+
163+
// Items live under `schema:itemListElement` (preferred) or the
164+
// un-prefixed `itemListElement` (common when @context aliases it).
165+
const rawItems = doc['schema:itemListElement']
166+
?? doc['itemListElement']
167+
?? doc['items'] // also accept a loose `items` key for hand-written bundles
168+
?? null;
169+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
170+
return { error: 'bundle has no items (expected schema:itemListElement array)' };
171+
}
172+
173+
const items = [];
174+
for (const it of rawItems) {
175+
if (typeof it === 'string') {
176+
items.push(it);
177+
} else if (it && typeof it === 'object') {
178+
const spec = it['app:spec'] || it['spec'] || it['urn:jss:app:spec'];
179+
if (typeof spec !== 'string') {
180+
return { error: `bundle item missing app:spec: ${JSON.stringify(it).slice(0, 100)}` };
181+
}
182+
items.push(spec);
183+
} else {
184+
return { error: `bundle item must be a string or object, got: ${typeof it}` };
185+
}
186+
}
187+
188+
return {
189+
name: doc['schema:name'] || doc.name || null,
190+
description: doc['schema:description'] || doc.description || null,
191+
items
192+
};
193+
}
194+
90195
/**
91196
* Fetch a bearer token from the pod's IDP, or null if the pod runs
92197
* with `--public` (no auth required for writes).
@@ -207,20 +312,41 @@ export async function runInstall(names, options) {
207312
const password = options.password || process.env.JSS_SINGLE_USER_PASSWORD || 'me';
208313
const nostrPrivkey = options.nostrPrivkey || process.env.NOSTR_PRIVKEY || null;
209314

210-
if (!names || names.length === 0) {
211-
throw new Error('expected at least one app name. Try: `jss install chrome`');
212-
}
213-
214315
// Validate Nostr privkey if supplied (64 lowercase-hex chars).
215316
if (nostrPrivkey && !/^[0-9a-f]{64}$/i.test(nostrPrivkey)) {
216317
console.error(red('✗ --nostr-privkey must be 64 hex chars'));
217318
throw new Error('invalid --nostr-privkey');
218319
}
219320

321+
// Bundle mode: resolve, fetch, parse, then concatenate items with
322+
// any positional ad-hoc apps. `--bundle <src> chrome` installs the
323+
// bundle + chrome; same auth + target flags apply to all.
324+
let bundleMeta = null;
325+
let allNames = [...(names || [])];
326+
if (options.bundle) {
327+
const resolved = resolveBundleSource(options.bundle);
328+
if (resolved.error) {
329+
console.error(red(`✗ --bundle: ${resolved.error}`));
330+
throw new Error(resolved.error);
331+
}
332+
const bundle = await loadBundle(resolved);
333+
if (bundle.error) {
334+
console.error(red(`✗ --bundle: ${bundle.error}`));
335+
throw new Error(bundle.error);
336+
}
337+
bundleMeta = { name: bundle.name, description: bundle.description, count: bundle.items.length };
338+
// Bundle items go first; positional names appended afterwards.
339+
allNames = [...bundle.items, ...allNames];
340+
}
341+
342+
if (allNames.length === 0) {
343+
throw new Error('expected at least one app name or a --bundle. Try: `jss install chrome`');
344+
}
345+
220346
// Validate every spec up front so we report invalid names before
221347
// doing any network work.
222348
const specs = [];
223-
for (const n of names) {
349+
for (const n of allNames) {
224350
const spec = parseAppSpec(n);
225351
if (spec.error) {
226352
console.error(red(`✗ ${n}: ${spec.error}`));
@@ -229,7 +355,15 @@ export async function runInstall(names, options) {
229355
specs.push(spec);
230356
}
231357

232-
console.log(bold(`\nInstalling ${specs.length} app${specs.length === 1 ? '' : 's'} → `) + green(pod));
358+
if (bundleMeta) {
359+
const label = bundleMeta.name
360+
? `bundle "${bundleMeta.name}"`
361+
: 'bundle';
362+
console.log(bold(`\nInstalling ${specs.length} app${specs.length === 1 ? '' : 's'} from ${label} → `) + green(pod));
363+
if (bundleMeta.description) console.log(dim(` ${bundleMeta.description}`));
364+
} else {
365+
console.log(bold(`\nInstalling ${specs.length} app${specs.length === 1 ? '' : 's'} → `) + green(pod));
366+
}
233367
if (nostrPrivkey) console.log(dim(' (signing with Nostr privkey — NIP-98)'));
234368
console.log('');
235369

0 commit comments

Comments
 (0)