3838 *
3939 * ## Spawn shape
4040 *
41- * `bin/run.js` with `NODE_ENV` unset (hence `requireBuiltCli`) — the operator's
42- * entrypoint, and the one whose bind is deterministic (no development
43- * auto-shift). Every workspace package the child loads, `@objectstack/runtime`
44- * and `@objectstack/cloud-connection` included, resolves through its `exports`
45- * to `dist/`: an ablation of either package's source reaches this file only
46- * after that package is rebuilt. Each `os start` gets its own process group and
47- * is stopped by signalling the group (`os start` supervises a `serve`
48- * grandchild).
41+ * The tsx source entry (`bin/run-dev.js`), as every `runServe()` caller spawns
42+ * it, for `os start` and for `os package install` alike. It pins
43+ * `NODE_ENV=development`, which lets `serve` auto-shift off a port taken
44+ * between the probe and the bind, so the ready banner is read back
45+ * (`portDriftError`) before any request is addressed to the port. The CLI runs
46+ * from `src/`, but every workspace package it loads — `@objectstack/runtime`
47+ * and `@objectstack/cloud-connection` included — resolves through its
48+ * `exports` to `dist/`: an ablation of either package's source reaches this
49+ * file only after that package is rebuilt. Each `os start` gets its own
50+ * process group and is stopped by signalling the group (`os start` supervises
51+ * a `serve` grandchild).
4952 */
5053
5154import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
52- import { spawn , spawnSync , type ChildProcess } from 'node:child_process' ;
55+ import { spawn , type ChildProcess } from 'node:child_process' ;
5356import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from 'node:fs' ;
5457import { tmpdir } from 'node:os' ;
55- import { dirname , join , resolve } from 'node:path' ;
56- import { fileURLToPath } from 'node:url' ;
58+ import { join } from 'node:path' ;
5759import {
60+ CLI ,
5861 childEnv ,
5962 E2E_SECRET_KEY ,
6063 portContentionError ,
64+ portDriftError ,
65+ probeThroughChild ,
6166 randomPort ,
62- requireBuiltCli ,
63- RUN_JS_RESOLVES_FROM_DIST ,
67+ TSX ,
6468} from './helpers/serve-process.js' ;
6569
66- const HERE = dirname ( fileURLToPath ( import . meta. url ) ) ;
67- const RUN_JS = resolve ( HERE , '../bin/run.js' ) ;
68-
6970/** The banner's tail — every row above it has printed. */
7071const READY = / P r e s s C t r l \+ C t o s t o p / ;
7172const BOOT_TIMEOUT_MS = 180_000 ;
7273
7374const APP_ID = 'com.example.tasksapp' ;
7475const OBJECT = 'tasks_app_task' ;
75- const EMAIL = 'owner@example.com' ;
76- const PASSWORD = 'Passw0rd!Passw0rd' ;
76+ /**
77+ * The development dev-admin seed (`objectstack dev`'s documented, loginable
78+ * admin — `admin@objectos.ai` / `admin123`, promoted to platform admin): the
79+ * tsx entry runs `os start` in development, which seeds it on every boot that
80+ * finds no login, so it is the operator on all three boots and holds the
81+ * `manage_metadata` capability the install route demands.
82+ */
83+ const EMAIL = 'admin@objectos.ai' ;
84+ const PASSWORD = 'admin123' ;
7785
7886const COMPLETE_TASK = {
7987 name : 'complete_task' ,
@@ -136,11 +144,10 @@ interface LiveStart {
136144
137145function bootStart ( cwd : string , home : string , port : string , extra : string [ ] = [ ] ) : Promise < LiveStart > {
138146 return new Promise ( ( resolveBoot , rejectBoot ) => {
139- const child = spawn ( process . execPath , [ RUN_JS , 'start' , '-p' , port , '--home' , home , '--auth-secret' , E2E_SECRET_KEY , '--no-ui' , ...extra ] , {
147+ const child = spawn ( TSX , [ CLI , 'start' , '-p' , port , '--home' , home , '--auth-secret' , E2E_SECRET_KEY , '--no-ui' , ...extra ] , {
140148 cwd,
141- // `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV`
142- // unset: the built entrypoint resolves commands from dist/ (#11464).
143- env : childEnv ( { NODE_ENV : undefined , NO_COLOR : '1' , OS_CLOUD_URL : 'off' , OS_LOG_LEVEL : 'warn' , OS_SECRET_KEY : E2E_SECRET_KEY } ) ,
149+ // `childEnv`, never a bare `...process.env` — see its header.
150+ env : childEnv ( { NO_COLOR : '1' , OS_CLOUD_URL : 'off' , OS_LOG_LEVEL : 'warn' , OS_SECRET_KEY : E2E_SECRET_KEY } ) ,
144151 stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
145152 // Own process group: `os start` supervises a `serve` grandchild.
146153 detached : true ,
@@ -161,7 +168,8 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = []
161168 ) ;
162169 const onData = ( d : unknown ) => {
163170 out += String ( d ) ;
164- if ( READY . test ( out ) ) settle ( null ) ;
171+ // The child is the authority on the port it bound.
172+ if ( READY . test ( out ) ) settle ( portDriftError ( out , 'os start' , port ) ) ;
165173 } ;
166174 child . stdout ?. on ( 'data' , onData ) ;
167175 child . stderr ?. on ( 'data' , onData ) ;
@@ -186,52 +194,83 @@ async function stopGroup(child: ChildProcess): Promise<void> {
186194
187195interface Answer { status : number ; body : any }
188196
189- async function http ( live : LiveStart , method : string , path : string , token : string , body ?: unknown ) : Promise < Answer > {
190- const r = await fetch ( `${ live . base } ${ path } ` , {
191- method,
192- headers : {
193- origin : live . base ,
194- ...( body !== undefined ? { 'content-type' : 'application/json' } : { } ) ,
195- ...( token ? { authorization : `Bearer ${ token } ` } : { } ) ,
197+ /**
198+ * One exchange against the running `os start`, attributed to the child if the
199+ * transport fails (`probeThroughChild`: a dead child is named with its
200+ * transcript; a socket the live server dropped — the keep-alive connection an
201+ * idle stretch outlives — is absorbed and retried, loudly, a bounded number of
202+ * times). ⛔ No assertion inside it.
203+ */
204+ function exchange < T > ( live : LiveStart , what : string , run : ( ) => Promise < T > ) : Promise < T > {
205+ return probeThroughChild (
206+ {
207+ child : live . child ,
208+ transcript : ( ) => `\n--- child output ---\n${ live . output ( ) . slice ( - 4000 ) } ` ,
209+ label : 'package-install-local-handlers' ,
210+ what,
196211 } ,
197- ...( body !== undefined ? { body : JSON . stringify ( body ) } : { } ) ,
212+ run ,
213+ ) ;
214+ }
215+
216+ function http ( live : LiveStart , method : string , path : string , token : string , body ?: unknown ) : Promise < Answer > {
217+ return exchange ( live , `${ method } ${ path } ` , async ( ) => {
218+ const r = await fetch ( `${ live . base } ${ path } ` , {
219+ method,
220+ headers : {
221+ origin : live . base ,
222+ ...( body !== undefined ? { 'content-type' : 'application/json' } : { } ) ,
223+ ...( token ? { authorization : `Bearer ${ token } ` } : { } ) ,
224+ } ,
225+ ...( body !== undefined ? { body : JSON . stringify ( body ) } : { } ) ,
226+ } ) ;
227+ const text = await r . text ( ) ;
228+ let parsed : any = text ;
229+ try { parsed = JSON . parse ( text ) ; } catch { /* keep the text */ }
230+ return { status : r . status , body : parsed } ;
198231 } ) ;
199- const text = await r . text ( ) ;
200- let parsed : any = text ;
201- try { parsed = JSON . parse ( text ) ; } catch { /* keep the text */ }
202- return { status : r . status , body : parsed } ;
203232}
204233
205- async function authenticate ( live : LiveStart , firstUser : boolean ) : Promise < string > {
206- const res = await http (
207- live , 'POST' , firstUser ? '/api/v1/auth/sign-up/email' : '/api/v1/auth/sign-in/email' , '' ,
208- firstUser ? { email : EMAIL , password : PASSWORD , name : 'Owner' } : { email : EMAIL , password : PASSWORD } ,
209- ) ;
234+ async function authenticate ( live : LiveStart ) : Promise < string > {
235+ const res = await http ( live , 'POST' , '/api/v1/auth/sign-in/email' , '' , { email : EMAIL , password : PASSWORD } ) ;
210236 const token = res . body ?. token ;
211237 if ( res . status !== 200 || typeof token !== 'string' ) {
212238 throw new Error ( `auth answered ${ res . status } : ${ JSON . stringify ( res . body ) } \n--- output ---\n${ live . output ( ) . slice ( - 3000 ) } ` ) ;
213239 }
214240 return token ;
215241}
216242
217- function packageInstall ( appDir : string , live : LiveStart ) : { exit : number | null ; output : string } {
218- const r = spawnSync ( process . execPath , [ RUN_JS , 'package' , 'install' , './dist/objectstack.json' , '--runtime' , live . base , '--email' , EMAIL , '--password' , PASSWORD ] , {
219- cwd : appDir ,
220- encoding : 'utf8' ,
221- env : childEnv ( { NODE_ENV : undefined , NO_COLOR : '1' } ) ,
222- timeout : 120_000 ,
243+ /**
244+ * `os package install ./dist/objectstack.json` against the running runtime.
245+ * ⛔ Asynchronous on purpose: a `spawnSync` would stop this process draining
246+ * the server's stdout/stderr pipes for the whole install, and a server that
247+ * fills its pipe while the install waits on it blocks until the timeout.
248+ */
249+ function packageInstall ( appDir : string , live : LiveStart ) : Promise < { exit : number | null ; output : string } > {
250+ return new Promise ( ( done ) => {
251+ const child = spawn ( TSX , [ CLI , 'package' , 'install' , './dist/objectstack.json' , '--runtime' , live . base , '--email' , EMAIL , '--password' , PASSWORD ] , {
252+ cwd : appDir ,
253+ env : childEnv ( { NO_COLOR : '1' } ) ,
254+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
255+ } ) ;
256+ let output = '' ;
257+ child . stdout ?. on ( 'data' , ( d ) => { output += String ( d ) ; } ) ;
258+ child . stderr ?. on ( 'data' , ( d ) => { output += String ( d ) ; } ) ;
259+ const timer = setTimeout ( ( ) => child . kill ( 'SIGKILL' ) , 120_000 ) ;
260+ child . on ( 'close' , ( code ) => { clearTimeout ( timer ) ; done ( { exit : code , output } ) ; } ) ;
223261 } ) ;
224- return { exit : r . status , output : `${ r . stdout } \n${ r . stderr } ` } ;
225262}
226263
227264/** One MCP JSON-RPC call over Streamable HTTP; the tool's JSON text, parsed. */
228265async function mcpTool ( live : LiveStart , apiKey : string , name : string , args : Record < string , unknown > ) {
229- const r = await fetch ( `${ live . base } /api/v1/mcp` , {
230- method : 'POST' ,
231- headers : { 'content-type' : 'application/json' , accept : 'application/json, text/event-stream' , 'x-api-key' : apiKey } ,
232- body : JSON . stringify ( { jsonrpc : '2.0' , id : 1 , method : 'tools/call' , params : { name, arguments : args } } ) ,
266+ const text = await exchange ( live , `MCP tools/call ${ name } ` , async ( ) => {
267+ const r = await fetch ( `${ live . base } /api/v1/mcp` , {
268+ method : 'POST' ,
269+ headers : { 'content-type' : 'application/json' , accept : 'application/json, text/event-stream' , 'x-api-key' : apiKey } ,
270+ body : JSON . stringify ( { jsonrpc : '2.0' , id : 1 , method : 'tools/call' , params : { name, arguments : args } } ) ,
271+ } ) ;
272+ return r . text ( ) ;
233273 } ) ;
234- const text = await r . text ( ) ;
235274 const data = text . split ( '\n' ) . find ( ( l ) => l . startsWith ( 'data:' ) ) ;
236275 const envelope = JSON . parse ( data ? data . slice ( 5 ) : text ) ;
237276 const content = envelope ?. result ?. content ?. [ 0 ] ?. text ;
@@ -279,7 +318,6 @@ const phases: Record<'install' | 'reinstall' | 'restart' | 'control', Phase | un
279318const installs : Array < { exit : number | null ; output : string } > = [ ] ;
280319
281320beforeAll ( async ( ) => {
282- requireBuiltCli ( RUN_JS_RESOLVES_FROM_DIST ) ;
283321 const root = mkdtempSync ( join ( tmpdir ( ) , 'install-local-handlers-' ) ) ;
284322 dirs . push ( root ) ;
285323 const appDir = join ( root , 'app' ) ;
@@ -293,23 +331,23 @@ beforeAll(async () => {
293331
294332 // ── boot 1: empty `os start`, install, probe, reinstall, probe ─────────
295333 const first = await bootStart ( runtimeDir , home , port ) ;
296- const token = await authenticate ( first , true ) ;
297- installs . push ( packageInstall ( appDir , first ) ) ;
334+ const token = await authenticate ( first ) ;
335+ installs . push ( await packageInstall ( appDir , first ) ) ;
298336 phases . install = await probe ( first , token ) ;
299- installs . push ( packageInstall ( appDir , first ) ) ;
337+ installs . push ( await packageInstall ( appDir , first ) ) ;
300338 phases . reinstall = await probe ( first , token ) ;
301339 await stopGroup ( first . child ) ;
302340
303341 // ── boot 2: same home and cwd — the ledger rehydrates on kernel:ready ──
304342 const second = await bootStart ( runtimeDir , home , port ) ;
305- phases . restart = await probe ( second , await authenticate ( second , false ) ) ;
343+ phases . restart = await probe ( second , await authenticate ( second ) ) ;
306344 await stopGroup ( second . child ) ;
307345
308346 // ── boot 3: the CONTROL — the same file as the boot artifact ───────────
309347 const controlDir = join ( root , 'control' ) ;
310348 mkdirSync ( controlDir , { recursive : true } ) ;
311349 const third = await bootStart ( controlDir , join ( controlDir , 'home' ) , port , [ '--artifact' , join ( appDir , 'dist' , 'objectstack.json' ) ] ) ;
312- phases . control = await probe ( third , await authenticate ( third , true ) ) ;
350+ phases . control = await probe ( third , await authenticate ( third ) ) ;
313351 await stopGroup ( third . child ) ;
314352} , 4 * BOOT_TIMEOUT_MS ) ;
315353
0 commit comments