-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathinstall.js
More file actions
433 lines (401 loc) · 16.4 KB
/
install.js
File metadata and controls
433 lines (401 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
/**
* `jss install <name>` — 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/<name>`. Later phases
* add `<org>/<repo>` shorthand, full URLs, `#<ref>` pinning,
* `=<name>` 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 <pod>/idp/credentials with the supplied creds → bearer token
* 2. `git clone <source> /tmp/<name>` (full clone — shallow pushes
* are rejected by JSS git-receive)
* 3. Push the clone to `<pod>/public/apps/<name>` 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/<name> (default registry)
* - "<org>/<repo>" → github.com/<org>/<repo>
* - "https://..." full URL → as-is (must point at a git repo)
* Each form may carry an optional "#<ref>" suffix to pin a tag or branch:
* chrome#v1.2 / solid-apps/chrome#main / https://...#v2
* And an optional "=<name>" 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 <org>/<repo> 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 <org>/<repo> → github.com/<org>/<repo>/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 + <org>/<repo> 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 <org>/<repo> 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:<hex> 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 <src> 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`);
}
}