77 * library shouldn't touch.
88 */
99
10- import { spawn , spawnSync } from 'child_process' ;
10+ import { spawn } from 'child_process' ;
1111import { fileURLToPath } from 'url' ;
12- import { dirname , join } from 'path' ;
12+ import { dirname , join , delimiter } from 'path' ;
1313import chalk from 'chalk' ;
14- import { existsSync , readFileSync , promises as fsPromises } from 'fs' ;
15- import { tmpdir } from 'os' ;
14+ import { readFileSync } from 'fs' ;
1615import { start , formatUrl } from './lib/start.js' ;
1716
1817const __dirname = dirname ( fileURLToPath ( import . meta. url ) ) ;
@@ -29,265 +28,48 @@ if (args[0] === 'install') {
2928 process . exit ( 0 ) ;
3029}
3130
31+ // `jspod install` is a thin wrapper over upstream `jss install` — the
32+ // plumbing (spec parsing, bundles, IdP auth, git dual-push) moved into
33+ // JSS itself in 0.0.198 (JSS#464; see jspod#48). jspod keeps two policies:
34+ // 1. --pod defaults to jspod's port (5444; jss alone defaults to 4443)
35+ // 2. bare `jspod install` → the `default` bundle from solid-apps/bundles
36+ // `jss install --help` is the source of truth for flags and spec forms.
3237async function runInstall ( rest ) {
33- const opts = {
34- pod : 'http://localhost:5444' ,
35- user : 'me' ,
36- password : process . env . JSS_SINGLE_USER_PASSWORD || 'me' ,
37- apps : [ ] ,
38- bundles : [ ]
39- } ;
38+ const jssArgs = [ 'install' , ...rest ] ;
39+ if ( ! rest . includes ( '--pod' ) ) jssArgs . push ( '--pod' , 'http://localhost:5444' ) ;
40+
41+ // Detect a "bare" invocation (no app names, no --bundle) while stepping
42+ // over value-taking flags, so `jspod install --user bob` still gets the
43+ // default bundle but `jspod install chrome` does not.
44+ const valueFlags = new Set ( [ '--pod' , '--user' , '--password' , '--bundle' , '--nostr-privkey' ] ) ;
45+ let bare = true ;
4046 for ( let i = 0 ; i < rest . length ; i ++ ) {
4147 const a = rest [ i ] ;
42- if ( a === '--pod' ) opts . pod = rest [ ++ i ] ;
43- else if ( a === '--user' ) opts . user = rest [ ++ i ] ;
44- else if ( a === '--password' ) opts . password = rest [ ++ i ] ;
45- else if ( a === '--bundle' ) opts . bundles . push ( rest [ ++ i ] ) ;
46- else if ( a === '--help' || a === '-h' ) { printInstallHelp ( ) ; process . exit ( 0 ) ; }
47- else if ( a . startsWith ( '--' ) ) {
48- console . error ( chalk . red ( `✗ Unknown flag: ${ a } ` ) ) ;
49- printInstallHelp ( ) ;
50- process . exit ( 1 ) ;
51- }
52- else opts . apps . push ( a ) ;
48+ if ( a === '--bundle' ) { bare = false ; i ++ ; }
49+ else if ( valueFlags . has ( a ) ) i ++ ;
50+ else if ( ! a . startsWith ( '-' ) ) bare = false ;
5351 }
54-
55- // Bare `jspod install` (no apps, no bundles) → install the `default`
56- // bundle. The bundle definition lives at solid-apps/bundles, so updates
57- // ship without a jspod release. Power users skip this by naming apps
58- // or passing --bundle explicitly.
59- if ( opts . apps . length === 0 && opts . bundles . length === 0 ) {
60- opts . bundles . push ( 'default' ) ;
52+ if ( bare && ! rest . includes ( '--help' ) && ! rest . includes ( '-h' ) ) {
53+ jssArgs . push ( '--bundle' , 'default' ) ;
6154 }
6255
63- // Expand any --bundle sources into the apps[] list.
64- for ( const source of opts . bundles ) {
65- let bundleSpecs ;
66- try {
67- bundleSpecs = await loadBundle ( source ) ;
68- } catch ( e ) {
69- console . error ( chalk . red ( `✗ Couldn't load bundle "${ source } ": ${ e . message } ` ) ) ;
70- process . exit ( 1 ) ;
56+ // Same PATH-prepend trick as lib/start.js so the locally-installed jss
57+ // binary wins regardless of how jspod itself was installed.
58+ const child = spawn ( 'jss' , jssArgs , {
59+ stdio : 'inherit' ,
60+ env : {
61+ ...process . env ,
62+ PATH : join ( __dirname , 'node_modules' , '.bin' ) + delimiter + process . env . PATH
7163 }
72- console . log ( chalk . dim ( `bundle "${ source } " → ${ bundleSpecs . length } apps: ${ bundleSpecs . join ( ', ' ) } ` ) ) ;
73- opts . apps . push ( ...bundleSpecs ) ;
74- }
75- opts . pod = opts . pod . replace ( / \/ $ / , '' ) ;
76-
77- console . log ( chalk . bold . white ( `\nInstalling ${ opts . apps . length } app${ opts . apps . length === 1 ? '' : 's' } → ` ) +
78- chalk . green ( opts . pod ) ) ;
79- console . log ( '' ) ;
80-
81- // Authenticate against the local pod's IDP. Token is needed to push to
82- // /public/apps/<name>/ on a default jspod (private-write inherits from
83- // /public/.acl: public-read, owner-write).
84- let token ;
85- try {
86- const r = await fetch ( `${ opts . pod } /idp/credentials` , {
87- method : 'POST' ,
88- headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
89- body : new URLSearchParams ( { username : opts . user , password : opts . password } )
90- } ) ;
91- if ( ! r . ok ) throw new Error ( `HTTP ${ r . status } ` ) ;
92- const j = await r . json ( ) ;
93- token = j . access_token ;
94- if ( ! token ) throw new Error ( 'no access_token in response' ) ;
95- } catch ( e ) {
96- console . error ( chalk . red ( `✗ Could not authenticate against ${ opts . pod } : ${ e . message } ` ) ) ;
97- console . error ( chalk . dim ( ' Is jspod running? → ' ) + chalk . bold ( 'npx jspod' ) ) ;
98- process . exit ( 1 ) ;
99- }
100-
101- let okCount = 0 ;
102- for ( const input of opts . apps ) {
103- const spec = parseAppSpec ( input ) ;
104- if ( spec . error ) {
105- console . error ( chalk . red ( `✗ ${ input } : ${ spec . error } ` ) ) ;
106- continue ;
107- }
108- const { source, name, ref } = spec ;
109- const dest = `${ opts . pod } /public/apps/${ name } ` ;
110- // Respect the platform's tmp dir — `/tmp` is hardcoded out on
111- // Termux (Android), where the writable tmp is at $PREFIX/tmp.
112- const tmp = join ( tmpdir ( ) , `jspod-install-${ name } -${ process . pid } ` ) ;
113-
114- // Clean stale tmp from a previous failed run
115- if ( existsSync ( tmp ) ) spawnSync ( 'rm' , [ '-rf' , tmp ] , { stdio : 'ignore' } ) ;
116-
117- // Full clone (no --depth: shallow pushes are rejected by JSS git-receive).
118- // --branch picks a tag or branch when pinned (e.g. `foo/bar#v2`).
119- const cloneArgs = [ 'clone' , '--quiet' ] ;
120- if ( ref ) cloneArgs . push ( '--branch' , ref ) ;
121- cloneArgs . push ( source , tmp ) ;
122- const clone = spawnSync ( 'git' , cloneArgs , {
123- stdio : [ 'ignore' , 'pipe' , 'pipe' ]
64+ } ) ;
65+ const code = await new Promise ( ( resolve ) => {
66+ child . once ( 'error' , ( e ) => {
67+ console . error ( chalk . red ( `✗ Could not run jss: ${ e . message } ` ) ) ;
68+ resolve ( 1 ) ;
12469 } ) ;
125- if ( clone . status !== 0 ) {
126- console . error ( chalk . red ( `✗ ${ input } : clone failed` ) ) ;
127- const err = clone . stderr ?. toString ?. ( ) . trim ( ) || '' ;
128- if ( err ) console . error ( chalk . dim ( ` ${ err . slice ( 0 , 300 ) } ` ) ) ;
129- continue ;
130- }
131-
132- // Push to the pod. `updateInstead` (which extracts the working tree)
133- // only fires when the push targets the branch HEAD points at on the
134- // server. JSS 0.0.197+ auto-inits with HEAD=main; older versions
135- // honor the operator's `init.defaultBranch` (often `main`, sometimes
136- // `gh-pages` for GitHub-Pages-heavy users). Push to both — the one
137- // matching server-side HEAD extracts; the other just creates a ref.
138- // Idempotent on re-run.
139- const pushArgs = ( branch ) => [ '-C' , tmp , '-c' ,
140- `http.extraHeader=Authorization: Bearer ${ token } ` ,
141- 'push' , dest , `HEAD:${ branch } ` ] ;
142-
143- const pushMain = spawnSync ( 'git' , pushArgs ( 'main' ) ,
144- { stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ) ;
145- const errMain = pushMain . stderr ?. toString ?. ( ) || '' ;
146-
147- // If the first push failed for a "won't auto-init" reason (path
148- // already has content, e.g. jspod's bundled pilot), don't keep going.
149- if ( pushMain . status !== 0 && ( errMain . includes ( 'not found' ) || errMain . includes ( '404' ) ) ) {
150- console . log ( chalk . yellow ( `⊘ ${ input } : skipped (path already in use — bundled or manually placed)` ) ) ;
151- spawnSync ( 'rm' , [ '-rf' , tmp ] , { stdio : 'ignore' } ) ;
152- continue ;
153- }
154-
155- const pushPages = spawnSync ( 'git' , pushArgs ( 'gh-pages' ) ,
156- { stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ) ;
157-
158- if ( pushMain . status !== 0 && pushPages . status !== 0 ) {
159- console . error ( chalk . red ( `✗ ${ input } : push failed` ) ) ;
160- const err = ( errMain + '\n' + ( pushPages . stderr ?. toString ?. ( ) || '' ) ) . trim ( ) ;
161- console . error ( chalk . dim ( ` ${ err . slice ( 0 , 400 ) } ` ) ) ;
162- spawnSync ( 'rm' , [ '-rf' , tmp ] , { stdio : 'ignore' } ) ;
163- continue ;
164- }
165-
166- console . log ( chalk . green ( `✓ ${ input } ` ) + chalk . dim ( ` → ${ dest } /` ) ) ;
167- okCount ++ ;
168- spawnSync ( 'rm' , [ '-rf' , tmp ] , { stdio : 'ignore' } ) ;
169- }
170-
171- console . log ( '' ) ;
172- console . log ( chalk . bold ( `${ okCount } /${ opts . apps . length } installed.` ) ) ;
173- if ( okCount > 0 ) {
174- console . log ( chalk . dim ( 'Open in browser: ' ) + chalk . cyan ( `${ opts . pod } /public/apps/` ) ) ;
175- }
176- }
177-
178- // Resolve a --bundle <source> argument to a fetchable URL or local file path.
179- function resolveBundleSource ( source ) {
180- if ( ! source ) throw new Error ( 'bundle source required' ) ;
181- if ( / ^ h t t p s ? : \/ \/ / . test ( source ) ) return { kind : 'url' , loc : source } ;
182- if ( source . startsWith ( './' ) || source . startsWith ( '/' ) || source . endsWith ( '.jsonld' ) ) {
183- if ( source . startsWith ( './' ) || source . startsWith ( '/' ) ) {
184- return { kind : 'file' , loc : source } ;
185- }
186- }
187- if ( source . includes ( '/' ) ) {
188- const cleaned = source . replace ( / ^ \/ + | \/ + $ / g, '' ) ;
189- if ( cleaned . split ( '/' ) . length !== 2 ) {
190- throw new Error ( 'expected <name>, <org>/<repo>, URL, or filesystem path' ) ;
191- }
192- return { kind : 'url' , loc : `https://raw.githubusercontent.com/${ cleaned } /HEAD/bundle.jsonld` } ;
193- }
194- if ( ! / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 _ . - ] * $ / i. test ( source ) ) {
195- throw new Error ( `invalid bundle name "${ source } "` ) ;
196- }
197- return { kind : 'url' , loc : `https://raw.githubusercontent.com/solid-apps/bundles/HEAD/${ source } .jsonld` } ;
198- }
199-
200- async function loadBundle ( source ) {
201- const { kind, loc } = resolveBundleSource ( source ) ;
202- let text ;
203- if ( kind === 'file' ) {
204- text = await fsPromises . readFile ( loc , 'utf8' ) ;
205- } else {
206- const r = await fetch ( loc ) ;
207- if ( ! r . ok ) throw new Error ( `HTTP ${ r . status } fetching ${ loc } ` ) ;
208- text = await r . text ( ) ;
209- }
210- let doc ;
211- try { doc = JSON . parse ( text ) ; }
212- catch ( e ) { throw new Error ( `bundle is not valid JSON: ${ e . message } ` ) ; }
213- const items = doc [ 'schema:itemListElement' ] || doc . itemListElement || [ ] ;
214- if ( ! Array . isArray ( items ) ) throw new Error ( 'bundle has no schema:itemListElement array' ) ;
215- return items . map ( item => {
216- if ( typeof item === 'string' ) return item ;
217- if ( item && typeof item === 'object' ) return item [ 'app:spec' ] || item . spec || null ;
218- return null ;
219- } ) . filter ( Boolean ) ;
220- }
221-
222- function parseAppSpec ( input ) {
223- let base = input ;
224- let renameName = null ;
225- const eqIx = base . lastIndexOf ( '=' ) ;
226- if ( eqIx > 0 ) {
227- renameName = base . slice ( eqIx + 1 ) ;
228- base = base . slice ( 0 , eqIx ) ;
229- }
230- let ref = null ;
231- const hashIx = base . lastIndexOf ( '#' ) ;
232- if ( hashIx > 0 ) {
233- ref = base . slice ( hashIx + 1 ) || null ;
234- base = base . slice ( 0 , hashIx ) ;
235- }
236- let source , name ;
237- if ( / ^ h t t p s ? : \/ \/ / . test ( base ) ) {
238- source = base . replace ( / \. g i t $ / , '' ) . replace ( / \/ $ / , '' ) ;
239- name = source . split ( '/' ) . pop ( ) ;
240- } else if ( base . includes ( '/' ) ) {
241- const cleaned = base . replace ( / \. g i t $ / , '' ) . replace ( / ^ \/ + | \/ + $ / g, '' ) ;
242- if ( cleaned . split ( '/' ) . length !== 2 ) {
243- return { error : 'expected <org>/<repo> shorthand' } ;
244- }
245- source = `https://github.com/${ cleaned } ` ;
246- name = cleaned . split ( '/' ) . pop ( ) ;
247- } else {
248- source = `https://github.com/solid-apps/${ base } ` ;
249- name = base ;
250- }
251- if ( renameName ) name = renameName ;
252- if ( ! / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 _ . - ] * $ / i. test ( name ) ) {
253- return { error : `invalid pod-path name "${ name } "` } ;
254- }
255- if ( ref && ! / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 _ . / - ] * $ / i. test ( ref ) ) {
256- return { error : `invalid ref "${ ref } "` } ;
257- }
258- return { source, name, ref } ;
259- }
260-
261- function printInstallHelp ( ) {
262- console . log ( chalk . cyan ( `
263- ╔═══════════════════════════════════════════════════════════════════╗
264- ║ jspod install - Help ║
265- ╚═══════════════════════════════════════════════════════════════════╝
266- ` ) ) ;
267- console . log ( chalk . white ( 'Usage:' ) ) ;
268- console . log ( chalk . yellow ( ' jspod install' ) + chalk . dim ( ' [options] [<app>...]\n' ) ) ;
269- console . log ( chalk . white ( 'Options:' ) ) ;
270- console . log ( chalk . green ( ' --pod ' ) + chalk . yellow ( '<url>' ) + chalk . dim ( ' Target pod (default: http://localhost:5444)' ) ) ;
271- console . log ( chalk . green ( ' --user ' ) + chalk . yellow ( '<name>' ) + chalk . dim ( ' Username (default: me)' ) ) ;
272- console . log ( chalk . green ( ' --password ' ) + chalk . yellow ( '<pw>' ) + chalk . dim ( ' Password (default: $JSS_SINGLE_USER_PASSWORD or "me")' ) ) ;
273- console . log ( chalk . green ( ' --bundle ' ) + chalk . yellow ( '<src>' ) + chalk . dim ( ' Install every app in a bundle. Source: <name> (e.g. starter,' ) ) ;
274- console . log ( chalk . dim ( ' agentic, all), <org>/<repo>, https://… URL, or ./local.jsonld' ) ) ;
275- console . log ( chalk . green ( ' --help' ) + chalk . dim ( ' Show this help message\n' ) ) ;
276- console . log ( chalk . white ( 'App spec:' ) + chalk . dim ( ' <name> | <org>/<repo> | https://github.com/<org>/<repo>' ) ) ;
277- console . log ( chalk . dim ( ' Optional suffixes: #<branch-or-tag> =<pod-path-name>' ) ) ;
278- console . log ( '' ) ;
279- console . log ( chalk . white ( 'Examples:' ) ) ;
280- console . log ( chalk . dim ( ' jspod install chrome # solid-apps/chrome' ) ) ;
281- console . log ( chalk . dim ( ' jspod install chrome vellum pdf # several at once' ) ) ;
282- console . log ( chalk . dim ( ' jspod install # default bundle (home, plaza, vellum, plume, …)' ) ) ;
283- console . log ( chalk . dim ( ' jspod install JavaScriptSolidServer/git # any GitHub org/repo' ) ) ;
284- console . log ( chalk . dim ( ' jspod install litecut/litecut.github.io=litecut # rename pod path' ) ) ;
285- console . log ( chalk . dim ( ' jspod install solid-apps/chrome#v1 # pin a tag or branch' ) ) ;
286- console . log ( chalk . dim ( ' jspod install --bundle starter # curated starter set' ) ) ;
287- console . log ( chalk . dim ( ' jspod install --bundle agentic # agent stack' ) ) ;
288- console . log ( chalk . dim ( ' jspod install --bundle all # every solid-app' ) ) ;
289- console . log ( chalk . dim ( ' jspod install --pod http://192.168.0.1:5444 chrome' ) ) ;
290- console . log ( '' ) ;
70+ child . once ( 'exit' , ( c ) => resolve ( c ?? 1 ) ) ;
71+ } ) ;
72+ process . exit ( code ) ;
29173}
29274
29375const options = {
0 commit comments