|
5 | 5 | * Just works, batteries included |
6 | 6 | */ |
7 | 7 |
|
8 | | -import { spawn } from 'child_process'; |
| 8 | +import { spawn, spawnSync } from 'child_process'; |
9 | 9 | import { fileURLToPath } from 'url'; |
10 | 10 | import { dirname, join, delimiter } from 'path'; |
11 | 11 | import chalk from 'chalk'; |
@@ -35,6 +35,155 @@ function formatUrl(host, port) { |
35 | 35 |
|
36 | 36 | // Parse CLI arguments |
37 | 37 | const args = process.argv.slice(2); |
| 38 | + |
| 39 | +// Subcommand dispatch — must run before the flag-parsing loop below, which |
| 40 | +// is shaped for the "start the server" command. New subcommands branch off |
| 41 | +// here and exit; the start path is reached only when args[0] isn't one. |
| 42 | +if (args[0] === 'install') { |
| 43 | + await runInstall(args.slice(1)); |
| 44 | + process.exit(0); |
| 45 | +} |
| 46 | + |
| 47 | +async function runInstall(rest) { |
| 48 | + const opts = { |
| 49 | + pod: 'http://localhost:5444', |
| 50 | + user: 'me', |
| 51 | + password: process.env.JSS_SINGLE_USER_PASSWORD || 'me', |
| 52 | + apps: [] |
| 53 | + }; |
| 54 | + for (let i = 0; i < rest.length; i++) { |
| 55 | + const a = rest[i]; |
| 56 | + if (a === '--pod') opts.pod = rest[++i]; |
| 57 | + else if (a === '--user') opts.user = rest[++i]; |
| 58 | + else if (a === '--password') opts.password = rest[++i]; |
| 59 | + else if (a === '--help' || a === '-h') { printInstallHelp(); process.exit(0); } |
| 60 | + else if (a.startsWith('--')) { |
| 61 | + console.error(chalk.red(`✗ Unknown flag: ${a}`)); |
| 62 | + printInstallHelp(); |
| 63 | + process.exit(1); |
| 64 | + } |
| 65 | + else opts.apps.push(a); |
| 66 | + } |
| 67 | + if (opts.apps.length === 0) { |
| 68 | + opts.apps = ['chrome', 'vellum', 'win98', 'pdf', 'hub']; |
| 69 | + } |
| 70 | + opts.pod = opts.pod.replace(/\/$/, ''); |
| 71 | + |
| 72 | + console.log(chalk.bold.white(`\nInstalling ${opts.apps.length} app${opts.apps.length === 1 ? '' : 's'} from `) + |
| 73 | + chalk.cyan('solid-apps') + chalk.bold.white(' → ') + chalk.green(opts.pod)); |
| 74 | + console.log(''); |
| 75 | + |
| 76 | + // Authenticate against the local pod's IDP. Token is needed to push to |
| 77 | + // /public/apps/<name>/ on a default jspod (private-write inherits from |
| 78 | + // /public/.acl: public-read, owner-write). |
| 79 | + let token; |
| 80 | + try { |
| 81 | + const r = await fetch(`${opts.pod}/idp/credentials`, { |
| 82 | + method: 'POST', |
| 83 | + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 84 | + body: new URLSearchParams({ username: opts.user, password: opts.password }) |
| 85 | + }); |
| 86 | + if (!r.ok) throw new Error(`HTTP ${r.status}`); |
| 87 | + const j = await r.json(); |
| 88 | + token = j.access_token; |
| 89 | + if (!token) throw new Error('no access_token in response'); |
| 90 | + } catch (e) { |
| 91 | + console.error(chalk.red(`✗ Could not authenticate against ${opts.pod}: ${e.message}`)); |
| 92 | + console.error(chalk.dim(' Is jspod running? → ') + chalk.bold('npx jspod')); |
| 93 | + process.exit(1); |
| 94 | + } |
| 95 | + |
| 96 | + let okCount = 0; |
| 97 | + for (const app of opts.apps) { |
| 98 | + if (!/^[a-z0-9][a-z0-9_.-]*$/i.test(app)) { |
| 99 | + console.error(chalk.red(`✗ ${app}: invalid app name`)); |
| 100 | + continue; |
| 101 | + } |
| 102 | + const source = `https://github.com/solid-apps/${app}`; |
| 103 | + const dest = `${opts.pod}/public/apps/${app}`; |
| 104 | + const tmp = join('/tmp', `jspod-install-${app}-${process.pid}`); |
| 105 | + |
| 106 | + // Clean stale tmp from a previous failed run |
| 107 | + if (existsSync(tmp)) spawnSync('rm', ['-rf', tmp], { stdio: 'ignore' }); |
| 108 | + |
| 109 | + // Full clone (no --depth: shallow pushes are rejected by JSS git-receive) |
| 110 | + const clone = spawnSync('git', ['clone', '--quiet', source, tmp], { |
| 111 | + stdio: ['ignore', 'pipe', 'pipe'] |
| 112 | + }); |
| 113 | + if (clone.status !== 0) { |
| 114 | + console.error(chalk.red(`✗ ${app}: clone failed`)); |
| 115 | + const err = clone.stderr?.toString?.().trim() || ''; |
| 116 | + if (err) console.error(chalk.dim(` ${err.slice(0, 300)}`)); |
| 117 | + continue; |
| 118 | + } |
| 119 | + |
| 120 | + // Push to the pod. `updateInstead` (which extracts the working tree) |
| 121 | + // only fires when the push targets the branch HEAD points at on the |
| 122 | + // server. JSS 0.0.197+ auto-inits with HEAD=main; older versions |
| 123 | + // honor the operator's `init.defaultBranch` (often `main`, sometimes |
| 124 | + // `gh-pages` for GitHub-Pages-heavy users). Push to both — the one |
| 125 | + // matching server-side HEAD extracts; the other just creates a ref. |
| 126 | + // Idempotent on re-run. |
| 127 | + const pushArgs = (branch) => ['-C', tmp, '-c', |
| 128 | + `http.extraHeader=Authorization: Bearer ${token}`, |
| 129 | + 'push', dest, `HEAD:${branch}`]; |
| 130 | + |
| 131 | + const pushMain = spawnSync('git', pushArgs('main'), |
| 132 | + { stdio: ['ignore', 'pipe', 'pipe'] }); |
| 133 | + const errMain = pushMain.stderr?.toString?.() || ''; |
| 134 | + |
| 135 | + // If the first push failed for a "won't auto-init" reason (path |
| 136 | + // already has content, e.g. jspod's bundled pilot), don't keep going. |
| 137 | + if (pushMain.status !== 0 && (errMain.includes('not found') || errMain.includes('404'))) { |
| 138 | + console.log(chalk.yellow(`⊘ ${app}: skipped (path already in use — bundled or manually placed)`)); |
| 139 | + spawnSync('rm', ['-rf', tmp], { stdio: 'ignore' }); |
| 140 | + continue; |
| 141 | + } |
| 142 | + |
| 143 | + const pushPages = spawnSync('git', pushArgs('gh-pages'), |
| 144 | + { stdio: ['ignore', 'pipe', 'pipe'] }); |
| 145 | + |
| 146 | + if (pushMain.status !== 0 && pushPages.status !== 0) { |
| 147 | + console.error(chalk.red(`✗ ${app}: push failed`)); |
| 148 | + const err = (errMain + '\n' + (pushPages.stderr?.toString?.() || '')).trim(); |
| 149 | + console.error(chalk.dim(` ${err.slice(0, 400)}`)); |
| 150 | + spawnSync('rm', ['-rf', tmp], { stdio: 'ignore' }); |
| 151 | + continue; |
| 152 | + } |
| 153 | + |
| 154 | + console.log(chalk.green(`✓ ${app}`) + chalk.dim(` → ${dest}/`)); |
| 155 | + okCount++; |
| 156 | + spawnSync('rm', ['-rf', tmp], { stdio: 'ignore' }); |
| 157 | + } |
| 158 | + |
| 159 | + console.log(''); |
| 160 | + console.log(chalk.bold(`${okCount}/${opts.apps.length} installed.`)); |
| 161 | + if (okCount > 0) { |
| 162 | + console.log(chalk.dim('Open in browser: ') + chalk.cyan(`${opts.pod}/public/apps/`)); |
| 163 | + } |
| 164 | +} |
| 165 | + |
| 166 | +function printInstallHelp() { |
| 167 | + console.log(chalk.cyan(` |
| 168 | +╔═══════════════════════════════════════════════════════════════════╗ |
| 169 | +║ jspod install - Help ║ |
| 170 | +╚═══════════════════════════════════════════════════════════════════╝ |
| 171 | +`)); |
| 172 | + console.log(chalk.white('Usage:')); |
| 173 | + console.log(chalk.yellow(' jspod install') + chalk.dim(' [options] [<app>...]\n')); |
| 174 | + console.log(chalk.white('Options:')); |
| 175 | + console.log(chalk.green(' --pod ') + chalk.yellow('<url>') + chalk.dim(' Target pod (default: http://localhost:5444)')); |
| 176 | + console.log(chalk.green(' --user ') + chalk.yellow('<name>') + chalk.dim(' Username (default: me)')); |
| 177 | + console.log(chalk.green(' --password ') + chalk.yellow('<pw>') + chalk.dim(' Password (default: $JSS_SINGLE_USER_PASSWORD or "me")')); |
| 178 | + console.log(chalk.green(' --help') + chalk.dim(' Show this help message\n')); |
| 179 | + console.log(chalk.white('Examples:')); |
| 180 | + console.log(chalk.dim(' jspod install chrome # install solid-apps/chrome')); |
| 181 | + console.log(chalk.dim(' jspod install chrome vellum pdf # several apps')); |
| 182 | + console.log(chalk.dim(' jspod install # curated set: chrome vellum win98 pdf hub')); |
| 183 | + console.log(chalk.dim(' jspod install --pod http://192.168.0.1:5444 chrome')); |
| 184 | + console.log(''); |
| 185 | +} |
| 186 | + |
38 | 187 | const options = { |
39 | 188 | port: 5444, |
40 | 189 | host: 'localhost', |
@@ -141,7 +290,9 @@ for (let i = 0; i < args.length; i++) { |
141 | 290 | ╚═══════════════════════════════════════════════════════════════════╝ |
142 | 291 | `)); |
143 | 292 | console.log(chalk.white('Usage:')); |
144 | | - console.log(chalk.yellow(' jspod') + chalk.dim(' [options]\n')); |
| 293 | + console.log(chalk.yellow(' jspod') + chalk.dim(' [options]') + chalk.dim(' Start the pod (default)')); |
| 294 | + console.log(chalk.yellow(' jspod install') + chalk.dim(' [<app>...]') + chalk.dim(' Install Solid apps from solid-apps/<name>')); |
| 295 | + console.log(chalk.dim(' (see `jspod install --help`)\n')); |
145 | 296 | console.log(chalk.white('Options:')); |
146 | 297 | console.log(chalk.green(' -p, --port ') + chalk.yellow('<number>') + chalk.dim(' Port to listen on (default: 5444)')); |
147 | 298 | console.log(chalk.green(' -h, --host ') + chalk.yellow('<address>') + chalk.dim(' Host to bind to (default: localhost)')); |
|
0 commit comments