ronin-skillforce 0.1.0 → 0.2.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/LICENSE +202 -21
- package/NOTICE +19 -0
- package/README.md +38 -5
- package/bin/cli.js +15 -3
- package/bin/commands/archive.js +192 -0
- package/bin/commands/capstone-status.js +82 -0
- package/bin/commands/create.js +69 -12
- package/bin/commands/import.js +5 -5
- package/bin/commands/status.js +1 -1
- package/bin/commands/studio-autostart.js +414 -0
- package/bin/commands/studio-state.js +159 -0
- package/bin/commands/studio.js +351 -17
- package/bin/commands/trail.js +153 -3
- package/bin/commands/web.js +8 -10
- package/bin/course-reader.js +6 -15
- package/bin/platforms/_shared.js +154 -28
- package/bin/platforms/claude.js +23 -42
- package/bin/platforms/cursor.js +23 -20
- package/bin/platforms/gemini.js +18 -19
- package/bin/platforms/opencode.js +47 -28
- package/package.json +5 -4
- package/plugin.json +1 -1
- package/shared/SKILL.md +102 -72
- package/shared/agents/coach.md +19 -16
- package/shared/agents/navigator.md +27 -34
- package/shared/agents/professor.md +16 -34
- package/shared/agents/researcher.md +1 -1
- package/shared/agents/spotter.md +3 -4
- package/shared/commands/professor/archive.md +65 -72
- package/shared/commands/professor/capstone-review.md +5 -5
- package/shared/commands/professor/capstone.md +3 -3
- package/shared/commands/professor/done.md +59 -31
- package/shared/commands/professor/hint.md +14 -2
- package/shared/commands/professor/navigator.md +27 -1
- package/shared/commands/professor/new-topic.md +3 -3
- package/shared/commands/professor/next.md +9 -5
- package/shared/commands/professor/note.md +6 -6
- package/shared/commands/professor/progress.md +1 -1
- package/shared/commands/professor/publish.md +5 -5
- package/shared/commands/professor/quiz.md +2 -2
- package/shared/commands/professor/recall.md +1 -1
- package/shared/commands/professor/review.md +14 -0
- package/shared/commands/professor/skill-export.md +3 -4
- package/shared/commands/professor/spotter.md +21 -4
- package/shared/commands/professor/stuck.md +24 -0
- package/shared/commands/professor/switch.md +1 -1
- package/shared/commands/professor/syllabus.md +1 -1
- package/shared/commands/professor/template-export.md +2 -2
- package/shared/commands/professor/worktrees.md +3 -7
- package/shared/hooks/pre-compact.js +4 -15
- package/studio/dist/assets/BetaReportPanel-BJ9HIAnz.js +1 -0
- package/studio/dist/assets/{Capstone-Cv9kH6LY.js → Capstone-CoUU-jP7.js} +1 -1
- package/studio/dist/assets/{CodeWorkspace-BwCEINWo.js → CodeWorkspace-BNrL4qvR.js} +2 -2
- package/studio/dist/assets/{Course-BJ_hafUP.js → Course-BMGTXus_.js} +1 -1
- package/studio/dist/assets/{Editor-CV0mNhlz.js → Editor-Cw9j3cnp.js} +1 -1
- package/studio/dist/assets/{Home-C1ry89ak.js → Home-D2woITq5.js} +1 -1
- package/studio/dist/assets/{Lesson-2e9TD9yL.js → Lesson-CYQhrDCZ.js} +2 -2
- package/studio/dist/assets/{NewCourse-BZeOg2xn.js → NewCourse-CMpwVRQb.js} +1 -1
- package/studio/dist/assets/{Progress-CRc_xEWC.js → Progress-2rS19Fur.js} +1 -1
- package/studio/dist/assets/{Review-6QEbHEfk.js → Review-SlqK-hjp.js} +1 -1
- package/studio/dist/assets/{Settings-NFnqVQui.js → Settings-t42u7n9H.js} +1 -1
- package/studio/dist/assets/{TutorPanel-DXQ6Z3BY.js → TutorPanel-DQDWIU7f.js} +3 -3
- package/studio/dist/assets/{abnfDiagram-WEYOIA3C-qeSjKlGM.js → abnfDiagram-WEYOIA3C-jTUmGBuB.js} +1 -1
- package/studio/dist/assets/{anthropic-D7NiQCUH.js → anthropic-iIm2l85r.js} +3 -3
- package/studio/dist/assets/{architecture-WOLXFQ4H-COG-m35l.js → architecture-WOLXFQ4H-Ch9Ld_vv.js} +1 -1
- package/studio/dist/assets/{architectureDiagram-ZHVYKT53-VO7uKoeD.js → architectureDiagram-ZHVYKT53-Dy-z6wsy.js} +1 -1
- package/studio/dist/assets/{blockDiagram-RJAE7QA3-BGJF_qiL.js → blockDiagram-RJAE7QA3-2fHVrc6N.js} +1 -1
- package/studio/dist/assets/{bridge-BTh0AeNX.js → bridge-Cr0q4rLp.js} +1 -1
- package/studio/dist/assets/{c4Diagram-XDPPPE54-C-S10RgU.js → c4Diagram-XDPPPE54-BuuKlG4V.js} +1 -1
- package/studio/dist/assets/{capstone-Amn6Hztq.js → capstone-DhwBeNqk.js} +1 -1
- package/studio/dist/assets/{card-store-BrbrtX9w.js → card-store-CVMBc-Pv.js} +1 -1
- package/studio/dist/assets/channel-M_JkdHrL.js +1 -0
- package/studio/dist/assets/{chunk-2E7WHVMD-fv-iqTyC.js → chunk-2E7WHVMD-lZdQWttk.js} +1 -1
- package/studio/dist/assets/{chunk-4EMNKR2T-CY9IZrgv.js → chunk-4EMNKR2T-C6kbMwke.js} +1 -1
- package/studio/dist/assets/{chunk-55XCU76K-DyTZz7v_.js → chunk-55XCU76K-rkBzkZog.js} +1 -1
- package/studio/dist/assets/{chunk-7S6XSTFO-BNMzTLRW.js → chunk-7S6XSTFO-Skg347QF.js} +1 -1
- package/studio/dist/assets/{chunk-AQDTSKZB-CEYANohH.js → chunk-AQDTSKZB-0pSDBJ23.js} +1 -1
- package/studio/dist/assets/{chunk-DSNLSHJZ-CDk5EXjH.js → chunk-DSNLSHJZ-BaeQDl2i.js} +1 -1
- package/studio/dist/assets/{chunk-HAGFOTED-hNAi1zh_.js → chunk-HAGFOTED-BEm00juw.js} +1 -1
- package/studio/dist/assets/{chunk-HNE4Q36P-COJnSYoZ.js → chunk-HNE4Q36P-DyCR-LvS.js} +1 -1
- package/studio/dist/assets/{chunk-NQOKRVIF-BU6KVxRr.js → chunk-NQOKRVIF-BNJ9uMmB.js} +1 -1
- package/studio/dist/assets/{chunk-UHW7PFBP-DYvxL9JG.js → chunk-UHW7PFBP-DqJczBLs.js} +1 -1
- package/studio/dist/assets/{chunk-VQ5W62QI-CuRfk65j.js → chunk-VQ5W62QI-CVWYSP5Q.js} +1 -1
- package/studio/dist/assets/{chunk-WQQL4CDK-KaG7x-Mb.js → chunk-WQQL4CDK-DJf0Dx05.js} +1 -1
- package/studio/dist/assets/{chunk-YZO67HFG-DbuqIZmk.js → chunk-YZO67HFG-BFUm_JHA.js} +1 -1
- package/studio/dist/assets/{chunk-ZAUX2LTX-bk4ivUmc.js → chunk-ZAUX2LTX-C5ETkkSE.js} +1 -1
- package/studio/dist/assets/{chunk-ZCHMQSOR-B_LElfwh.js → chunk-ZCHMQSOR-WVX9r9PZ.js} +1 -1
- package/studio/dist/assets/{classDiagram-v2-4MYWR3UA-DtCJ6H_X.js → classDiagram-v2-4MYWR3UA-CI77SpuR.js} +1 -1
- package/studio/dist/assets/{course-export-DpaOTT3a.js → course-export-C8jSPuda.js} +1 -1
- package/studio/dist/assets/{cynefin-EF2NZ3EQ-D9btHJvU.js → cynefin-EF2NZ3EQ-CLffmM25.js} +1 -1
- package/studio/dist/assets/{cynefinDiagram-WF24UDRP-CsFJuv27.js → cynefinDiagram-WF24UDRP-BvLwocOb.js} +1 -1
- package/studio/dist/assets/{dagre-NK4GIZVX-Cg1Duec7.js → dagre-NK4GIZVX-C2nMAVdh.js} +1 -1
- package/studio/dist/assets/{diagram-CBK3MWTC-_KU2m5gd.js → diagram-CBK3MWTC-Bid-pdax.js} +1 -1
- package/studio/dist/assets/{diagram-I5XIIV2D-5RaBrwUA.js → diagram-I5XIIV2D-DYJeAvzl.js} +1 -1
- package/studio/dist/assets/{diagram-ODIL2UCW-C0VKxoD3.js → diagram-ODIL2UCW-B06X2r_F.js} +1 -1
- package/studio/dist/assets/{diagram-PFBEJUXE-BaHcZEP2.js → diagram-PFBEJUXE-CwY--5Rz.js} +1 -1
- package/studio/dist/assets/{diagram-RJA3I4VV-cwSAZ90p.js → diagram-RJA3I4VV-4SqhH8zw.js} +1 -1
- package/studio/dist/assets/{diagram-TYUNFSNR-BJImxCEN.js → diagram-TYUNFSNR-CrW4AIM9.js} +1 -1
- package/studio/dist/assets/{ebnfDiagram-V4P3TTPT-OQQLRXcm.js → ebnfDiagram-V4P3TTPT-xeoCmH-d.js} +1 -1
- package/studio/dist/assets/{elk-QONTBCTQ-BJGhQHxQ.js → elk-QONTBCTQ-Dadmz6iS.js} +1 -1
- package/studio/dist/assets/{erDiagram-KYQIGQAA-D91EKSep.js → erDiagram-KYQIGQAA-CSuH0bbF.js} +1 -1
- package/studio/dist/assets/{eventmodeling-K75KTNOO-DgdTRgnw.js → eventmodeling-K75KTNOO-CciVfx3I.js} +1 -1
- package/studio/dist/assets/flowDiagram-IUGLLWBU-DgNR54qK.js +1 -0
- package/studio/dist/assets/{ganttDiagram-R7GPNTJY-CSYLPWEI.js → ganttDiagram-R7GPNTJY-C0wtA-2y.js} +1 -1
- package/studio/dist/assets/{gitGraph-VSP46ZUC-D6GWaV9y.js → gitGraph-VSP46ZUC-BXoDzaA3.js} +1 -1
- package/studio/dist/assets/{gitGraphDiagram-PDYL7XHY-CaV31bvT.js → gitGraphDiagram-PDYL7XHY-vZBlNRPe.js} +1 -1
- package/studio/dist/assets/{index-DioXTZVc.js → index-CS8XPYbB.js} +2 -2
- package/studio/dist/assets/{info-OHQRW6UA-BN-x8GoN.js → info-OHQRW6UA-bs2Qpf7P.js} +1 -1
- package/studio/dist/assets/{infoDiagram-MVDRKAX5-CIose_cy.js → infoDiagram-MVDRKAX5-rPJio6-1.js} +1 -1
- package/studio/dist/assets/{ishikawaDiagram-HPEHV2GE-DCihSNBw.js → ishikawaDiagram-HPEHV2GE-CXf9XFff.js} +1 -1
- package/studio/dist/assets/{journeyDiagram-BOSHW7O7-Bz3o3RIX.js → journeyDiagram-BOSHW7O7-C3MSH79o.js} +1 -1
- package/studio/dist/assets/{kanban-definition-FNSA4HC5-BUyqvlFE.js → kanban-definition-FNSA4HC5-Bdh7SB4n.js} +1 -1
- package/studio/dist/assets/{line-B08T8KlK.js → line-CHkoQXJF.js} +1 -1
- package/studio/dist/assets/{mermaid-parser.core-BYwreSUy.js → mermaid-parser.core-Do1oxD1S.js} +3 -3
- package/studio/dist/assets/{mermaid.core-DTO1oDmq.js → mermaid.core-DgZUD0sU.js} +4 -4
- package/studio/dist/assets/{mindmap-definition-6OY57UG3-BGLwKheY.js → mindmap-definition-6OY57UG3-PUaK6oE_.js} +1 -1
- package/studio/dist/assets/{node.browser-BISniYn8.js → node.browser-DkpMze_r.js} +1 -1
- package/studio/dist/assets/{ollama-De5w84tG.js → ollama-bJNp3jaD.js} +1 -1
- package/studio/dist/assets/{openai-compatible-Joy1oK3L.js → openai-compatible-0tmAyMlN.js} +1 -1
- package/studio/dist/assets/{packet-JDAUHWVQ-CDIMjHS_.js → packet-JDAUHWVQ-vf2FtqFr.js} +1 -1
- package/studio/dist/assets/{pegDiagram-ECIUJNBX-DWXm4mwS.js → pegDiagram-ECIUJNBX-NEVrgPTY.js} +1 -1
- package/studio/dist/assets/{pie-XZMESJXO-CY3QQiKZ.js → pie-XZMESJXO-BIWydVNZ.js} +1 -1
- package/studio/dist/assets/{pieDiagram-GNMPY74T-M1aCnI7W.js → pieDiagram-GNMPY74T-BI9_u6rT.js} +1 -1
- package/studio/dist/assets/{quadrantDiagram-D57K4MC3-BFFtO7U5.js → quadrantDiagram-D57K4MC3-ryNpoBC5.js} +1 -1
- package/studio/dist/assets/{radar-ABXABTNO-DT0W4_Xn.js → radar-ABXABTNO-D8TjnvBB.js} +1 -1
- package/studio/dist/assets/{railroad-I3PHUGI6-_VCpgZR5.js → railroad-I3PHUGI6-BmEDV5kf.js} +1 -1
- package/studio/dist/assets/{railroad-abnf-LNEFI6M7-DkSpQ5YY.js → railroad-abnf-LNEFI6M7-Cs_sArMc.js} +1 -1
- package/studio/dist/assets/{railroad-ebnf-DWYD2UWJ-BqWILdGb.js → railroad-ebnf-DWYD2UWJ-DYuCq46d.js} +1 -1
- package/studio/dist/assets/{railroad-peg-JR7BVNR7-BB0bQL-Z.js → railroad-peg-JR7BVNR7-BcXSgdMy.js} +1 -1
- package/studio/dist/assets/{railroadDiagram-Y72NRBL7-CjLKjIdJ.js → railroadDiagram-Y72NRBL7-DBcc8b2c.js} +1 -1
- package/studio/dist/assets/{rehype-katex-C-sKddsH.js → rehype-katex-BfUIq5As.js} +1 -1
- package/studio/dist/assets/{remark-math-C-ox67hE.js → remark-math-XvZOjmDO.js} +1 -1
- package/studio/dist/assets/{requirementDiagram-EQYSW5RA-CnwzZqR8.js → requirementDiagram-EQYSW5RA-CVcBgM1L.js} +1 -1
- package/studio/dist/assets/{sankeyDiagram-SY24JHO5-CPDugTUl.js → sankeyDiagram-SY24JHO5-DjZ81hDs.js} +1 -1
- package/studio/dist/assets/{sequenceDiagram-BLEHNHQU-1dLfeU9Y.js → sequenceDiagram-BLEHNHQU-BNwOa2S7.js} +1 -1
- package/studio/dist/assets/{stateDiagram-v2-A6IESKWZ-DR7CmEjj.js → stateDiagram-v2-A6IESKWZ-BFWa5lEx.js} +1 -1
- package/studio/dist/assets/{swimlanes-B3ENRKFQ-BAWB9omQ.js → swimlanes-B3ENRKFQ-LfM_Jg6Z.js} +1 -1
- package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-BDmpoiGp.js +8 -0
- package/studio/dist/assets/{timeline-definition-JXYKGLGB-DNkj8z77.js → timeline-definition-JXYKGLGB-BwGzkWgZ.js} +1 -1
- package/studio/dist/assets/{treeView-D4JQ5SDB-BA-ouhMb.js → treeView-D4JQ5SDB-MLk3VuRt.js} +1 -1
- package/studio/dist/assets/{treemap-SAJKECNS-BrrGxsAb.js → treemap-SAJKECNS-D8iSjp7L.js} +1 -1
- package/studio/dist/assets/{usecaseDiagram-WPOLBNNX-BB3D0Dt_.js → usecaseDiagram-WPOLBNNX-BqxpaNlT.js} +1 -1
- package/studio/dist/assets/{vennDiagram-Q3Y3IDCW-BrtqtKTE.js → vennDiagram-Q3Y3IDCW-CpeUDMhQ.js} +1 -1
- package/studio/dist/assets/{wardley-7MLQ67FV-BKkemDql.js → wardley-7MLQ67FV-tAMbCZAj.js} +1 -1
- package/studio/dist/assets/{wardleyDiagram-IVAIDKWA-DFnI3P5z.js → wardleyDiagram-IVAIDKWA-CDJViWzt.js} +1 -1
- package/studio/dist/assets/{xychartDiagram-4URNHDU4-Da0DZG_q.js → xychartDiagram-4URNHDU4-CwaQrd3_.js} +1 -1
- package/studio/dist/index.html +1 -1
- package/templates/claude/plugin.json +1 -1
- package/templates/gemini/GEMINI.md +18 -16
- package/web/server.js +3 -3
- package/studio/dist/assets/BetaReportPanel-8NrDLiPm.js +0 -1
- package/studio/dist/assets/channel-SJiUQu_N.js +0 -1
- package/studio/dist/assets/flowDiagram-IUGLLWBU-CVnJYMum.js +0 -1
- package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-qeuHru16.js +0 -8
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background state for `skillforce studio` (RON-297): the `studio.json` snapshot that `--status`
|
|
3
|
+
* and `--stop` read, plus the two probes they need (pid liveness, one-shot health check).
|
|
4
|
+
*
|
|
5
|
+
* Nothing here writes during a read path: `writeState` is only called by a running server, and
|
|
6
|
+
* `readState`/`probeStudioHealth` never create a file or a token.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
11
|
+
|
|
12
|
+
export const STATE_FILE = 'studio.json';
|
|
13
|
+
export const LOG_FILE = 'studio.log';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve `${PROFESSOR_HOME:-~/.professor}` without consulting the real home when one is supplied.
|
|
17
|
+
* @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function professorHome({ env = process.env, home } = {}) {
|
|
21
|
+
return home || env.PROFESSOR_HOME || join(homedir(), '.professor');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Absolute path of the background state file.
|
|
26
|
+
* @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function stateFilePath({ env = process.env, home } = {}) {
|
|
30
|
+
return join(professorHome({ env, home }), STATE_FILE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Absolute path of the background log file used by the LaunchAgent.
|
|
35
|
+
* @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function logFilePath({ env = process.env, home } = {}) {
|
|
39
|
+
return join(professorHome({ env, home }), 'logs', LOG_FILE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True for the payload Studio's `/api/studio/health` returns; anything else is a foreign app. */
|
|
43
|
+
export function isStudioHealthPayload(body) {
|
|
44
|
+
return Boolean(body) && body.ok === true && typeof body.version === 'string';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A recorded state is usable only with a numeric pid; anything else is treated as absent. */
|
|
48
|
+
function isStateShape(value) {
|
|
49
|
+
return Boolean(value) && typeof value === 'object' && Number.isInteger(value.pid);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Write the state file atomically (temp file + rename, mode `0600`).
|
|
54
|
+
* @param {{ pid: number, port: number, apiPort: number, studioUrl: string, apiUrl: string,
|
|
55
|
+
* startedAt: string }} state
|
|
56
|
+
* @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
|
|
57
|
+
* @returns {string} the file path
|
|
58
|
+
*/
|
|
59
|
+
export function writeState(state, { env = process.env, home } = {}) {
|
|
60
|
+
const file = stateFilePath({ env, home });
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
62
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
65
|
+
renameSync(tmp, file);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
try {
|
|
68
|
+
rmSync(tmp, { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
/* ignore */
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read the state file. A missing, malformed or truncated file reads as `null`.
|
|
79
|
+
* @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
|
|
80
|
+
* @returns {{ pid: number, port: number, apiPort: number, studioUrl: string, apiUrl: string,
|
|
81
|
+
* startedAt: string } | null}
|
|
82
|
+
*/
|
|
83
|
+
export function readState({ env = process.env, home } = {}) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(stateFilePath({ env, home }), 'utf-8'));
|
|
86
|
+
return isStateShape(parsed) ? parsed : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Remove the state file; missing is not an error.
|
|
94
|
+
* @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
|
|
95
|
+
* @returns {boolean} true when a file was removed
|
|
96
|
+
*/
|
|
97
|
+
export function removeState({ env = process.env, home } = {}) {
|
|
98
|
+
try {
|
|
99
|
+
rmSync(stateFilePath({ env, home }), { force: true });
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True when the pid exists. `EPERM` counts as alive: the process exists but is not ours.
|
|
108
|
+
* @param {number} pid
|
|
109
|
+
* @param {{ kill?: (pid: number, signal?: number) => void }} [opts]
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
export function isPidAlive(pid, { kill = process.kill } = {}) {
|
|
113
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
114
|
+
try {
|
|
115
|
+
kill(pid, 0);
|
|
116
|
+
return true;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return err?.code === 'EPERM';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A timeout signal when the runtime has one; `undefined` keeps the probe working on older Nodes.
|
|
124
|
+
*/
|
|
125
|
+
function abortTimeout(ms) {
|
|
126
|
+
try {
|
|
127
|
+
return typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined;
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One `GET /api/studio/health` against the loopback bridge. Any answer that is not a Studio health
|
|
135
|
+
* payload (404, foreign JSON, timeout, refused connection) is `false`; it never throws.
|
|
136
|
+
*
|
|
137
|
+
* @param {{ apiUrl: string, token?: string|null, fetchImpl?: typeof fetch,
|
|
138
|
+
* timeoutMs?: number }} opts
|
|
139
|
+
* @returns {Promise<boolean>}
|
|
140
|
+
*/
|
|
141
|
+
export async function probeStudioHealth({
|
|
142
|
+
apiUrl,
|
|
143
|
+
token,
|
|
144
|
+
fetchImpl = globalThis.fetch,
|
|
145
|
+
timeoutMs = 1500,
|
|
146
|
+
} = {}) {
|
|
147
|
+
const headers = token ? { 'X-Studio-Token': token } : {};
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetchImpl(`${apiUrl}/api/studio/health`, {
|
|
150
|
+
headers,
|
|
151
|
+
signal: abortTimeout(timeoutMs),
|
|
152
|
+
});
|
|
153
|
+
if (!res || !res.ok) return false;
|
|
154
|
+
const body = await res.json().catch(() => null);
|
|
155
|
+
return isStudioHealthPayload(body);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/bin/commands/studio.js
CHANGED
|
@@ -5,21 +5,43 @@
|
|
|
5
5
|
* 2. starts the local server (`web/server.js`, production, on `--api-port`) with a pairing token
|
|
6
6
|
* 3. serves `studio/dist` on `--port` (tiny static server, loopback only)
|
|
7
7
|
* 4. waits for `/api/studio/health`, then opens
|
|
8
|
-
* `http://
|
|
8
|
+
* `http://127.0.0.1:<port>/#token=…` (plus `&bridge=…` for a non-default API port)
|
|
9
9
|
* (unless `--no-open`)
|
|
10
10
|
*
|
|
11
|
+
* Every URL this command polls or prints uses `127.0.0.1`, matching the server bind: `localhost`
|
|
12
|
+
* resolves to `::1` first on macOS, where another app may already be listening.
|
|
13
|
+
*
|
|
11
14
|
* Inherited `CLAUDECODE` / `CLAUDE_CODE_*` env vars are stripped from the child so a nested agent
|
|
12
15
|
* CLI (spawned by the bridge) behaves like a normal terminal.
|
|
13
16
|
*/
|
|
14
|
-
import { spawn, execSync } from 'child_process';
|
|
17
|
+
import { spawn, execSync, execFileSync } from 'child_process';
|
|
15
18
|
import { createServer } from 'http';
|
|
19
|
+
import { createServer as createNetServer } from 'net';
|
|
16
20
|
import { existsSync, statSync, readdirSync } from 'fs';
|
|
17
21
|
import { dirname, join } from 'path';
|
|
22
|
+
import { homedir } from 'os';
|
|
18
23
|
import { fileURLToPath } from 'url';
|
|
19
24
|
import { createRequire } from 'module';
|
|
20
25
|
import { createStaticHandler } from '../static-server.js';
|
|
21
26
|
import { requireStudioNode } from '../node-version.js';
|
|
22
27
|
import * as security from '../../web/security.js';
|
|
28
|
+
import {
|
|
29
|
+
isStudioHealthPayload,
|
|
30
|
+
removeState,
|
|
31
|
+
writeState,
|
|
32
|
+
} from './studio-state.js';
|
|
33
|
+
import {
|
|
34
|
+
LABEL,
|
|
35
|
+
UNSUPPORTED_PLATFORM,
|
|
36
|
+
collectStatus,
|
|
37
|
+
disableAutostart,
|
|
38
|
+
displayPath,
|
|
39
|
+
enableAutostart,
|
|
40
|
+
formatStatus,
|
|
41
|
+
stopStudio,
|
|
42
|
+
} from './studio-autostart.js';
|
|
43
|
+
|
|
44
|
+
export { isStudioHealthPayload };
|
|
23
45
|
|
|
24
46
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
47
|
export const REPO_ROOT = join(__dirname, '..', '..');
|
|
@@ -28,6 +50,19 @@ export const DEFAULT_PORT = 5180;
|
|
|
28
50
|
export const DEFAULT_API_PORT = 3000;
|
|
29
51
|
export const HEALTH_PATH = '/api/studio/health';
|
|
30
52
|
|
|
53
|
+
/** Hosts probed for a busy port: the `127.0.0.1` bind plus the IPv6 loopback `localhost` may use. */
|
|
54
|
+
export const LOOPBACK_HOSTS = ['127.0.0.1', '::1'];
|
|
55
|
+
|
|
56
|
+
/** Base URL the CLI polls and prints — the IPv4 literal, never `localhost`. */
|
|
57
|
+
export function loopbackUrl(port) {
|
|
58
|
+
return `http://127.0.0.1:${port}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Rewrite an `http://localhost…` URL to the IPv4 loopback literal. */
|
|
62
|
+
export function toLoopbackUrl(url) {
|
|
63
|
+
return String(url).replace(/^http:\/\/localhost(?=[:/]|$)/, 'http://127.0.0.1');
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
/**
|
|
32
67
|
* Usage text for `studio --help`. Kept as a plain string so the CLI can print it without starting
|
|
33
68
|
* or binding anything.
|
|
@@ -41,6 +76,11 @@ Options:
|
|
|
41
76
|
--port <n> Studio port (default ${DEFAULT_PORT})
|
|
42
77
|
--api-port <n> Local bridge server port (default ${DEFAULT_API_PORT})
|
|
43
78
|
--no-open Do not open the browser
|
|
79
|
+
--strict-port Fail instead of picking another port when one is busy
|
|
80
|
+
--autostart on Start Studio at login (macOS, global install only)
|
|
81
|
+
--autostart off Remove the login item and stop starting Studio at login
|
|
82
|
+
--status Show whether Studio is running, its URLs, autostart and log path
|
|
83
|
+
--stop Stop the running Studio (never another app's process)
|
|
44
84
|
--rotate-token Generate a new pairing token and exit
|
|
45
85
|
-h, --help Show this help and exit
|
|
46
86
|
`.trim();
|
|
@@ -52,9 +92,17 @@ export function wantsHelp(args = []) {
|
|
|
52
92
|
|
|
53
93
|
|
|
54
94
|
/**
|
|
55
|
-
* Parse `--port`, `--api-port`, `--no-open
|
|
95
|
+
* Parse `--port`, `--api-port`, `--no-open`, `--strict-port`, `--status`, `--stop` and
|
|
96
|
+
* `--autostart on|off` (both `--flag value` and `--flag=value` forms).
|
|
97
|
+
* `portExplicit` / `apiPortExplicit` say whether the value came from a valid flag, which decides
|
|
98
|
+
* whether a busy port is an error or an auto-pick.
|
|
99
|
+
* `autostart` is `'on'`, `'off'` or `'invalid'` when the flag was given without a usable value;
|
|
100
|
+
* `null` means the flag was not used.
|
|
101
|
+
*
|
|
56
102
|
* @param {string[]} [args]
|
|
57
|
-
* @returns {{ port: number, apiPort: number,
|
|
103
|
+
* @returns {{ port: number, apiPort: number, portExplicit: boolean, apiPortExplicit: boolean,
|
|
104
|
+
* open: boolean, rotateToken: boolean, strictPort: boolean, status: boolean,
|
|
105
|
+
* stop: boolean, autostart: 'on'|'off'|'invalid'|null }}
|
|
58
106
|
*/
|
|
59
107
|
export function parseStudioArgs(args = []) {
|
|
60
108
|
const valueOf = (name, fallback) => {
|
|
@@ -64,15 +112,32 @@ export function parseStudioArgs(args = []) {
|
|
|
64
112
|
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1];
|
|
65
113
|
return fallback;
|
|
66
114
|
};
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
115
|
+
const readPort = (name, fallback) => {
|
|
116
|
+
const raw = valueOf(name, null);
|
|
117
|
+
const parsed = Number.parseInt(raw, 10);
|
|
118
|
+
const valid = Number.isInteger(parsed) && parsed > 0 && parsed < 65536;
|
|
119
|
+
return { value: valid ? parsed : fallback, explicit: raw !== null && valid };
|
|
70
120
|
};
|
|
121
|
+
const port = readPort('--port', DEFAULT_PORT);
|
|
122
|
+
const apiPort = readPort('--api-port', DEFAULT_API_PORT);
|
|
123
|
+
const wantsAutostart = args.some((a) => a === '--autostart' || a.startsWith('--autostart='));
|
|
124
|
+
const autostartValue = valueOf('--autostart', null);
|
|
125
|
+
const autostart = wantsAutostart
|
|
126
|
+
? autostartValue === 'on' || autostartValue === 'off'
|
|
127
|
+
? autostartValue
|
|
128
|
+
: 'invalid'
|
|
129
|
+
: null;
|
|
71
130
|
return {
|
|
72
|
-
port:
|
|
73
|
-
apiPort:
|
|
131
|
+
port: port.value,
|
|
132
|
+
apiPort: apiPort.value,
|
|
133
|
+
portExplicit: port.explicit,
|
|
134
|
+
apiPortExplicit: apiPort.explicit,
|
|
74
135
|
open: !args.includes('--no-open'),
|
|
75
136
|
rotateToken: args.includes('--rotate-token'),
|
|
137
|
+
strictPort: args.includes('--strict-port'),
|
|
138
|
+
status: args.includes('--status'),
|
|
139
|
+
stop: args.includes('--stop'),
|
|
140
|
+
autostart,
|
|
76
141
|
};
|
|
77
142
|
}
|
|
78
143
|
|
|
@@ -171,8 +236,8 @@ export function mergeOrigins(existing, port) {
|
|
|
171
236
|
* @returns {{ apiUrl: string, studioUrl: string, pairingUrl: string }}
|
|
172
237
|
*/
|
|
173
238
|
export function buildPairingUrl({ studioPort, apiPort, token }) {
|
|
174
|
-
const apiUrl =
|
|
175
|
-
const studioUrl =
|
|
239
|
+
const apiUrl = loopbackUrl(apiPort);
|
|
240
|
+
const studioUrl = loopbackUrl(studioPort);
|
|
176
241
|
let pairingUrl = `${studioUrl}/#token=${encodeURIComponent(token)}`;
|
|
177
242
|
if (Number(apiPort) !== DEFAULT_API_PORT) {
|
|
178
243
|
pairingUrl += `&bridge=${encodeURIComponent(apiUrl)}`;
|
|
@@ -180,6 +245,87 @@ export function buildPairingUrl({ studioPort, apiPort, token }) {
|
|
|
180
245
|
return { apiUrl, studioUrl, pairingUrl };
|
|
181
246
|
}
|
|
182
247
|
|
|
248
|
+
/** `true` when `port` is busy on that family, `false` when free or the family is unavailable. */
|
|
249
|
+
function probeBind(port, host) {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
const server = createNetServer();
|
|
252
|
+
server.once('error', (err) => resolve(err?.code === 'EADDRINUSE'));
|
|
253
|
+
server.listen({ port, host, exclusive: true }, () => {
|
|
254
|
+
server.close(() => resolve(false));
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* True when something already listens on `port` on any loopback family. An IPv4-only listener
|
|
261
|
+
* leaves `::1:<port>` free and vice versa, so both families have to be probed.
|
|
262
|
+
*
|
|
263
|
+
* @param {number|string} port
|
|
264
|
+
* @param {{ hosts?: string[], probe?: (port: number, host: string) => Promise<boolean> }} [opts]
|
|
265
|
+
* @returns {Promise<boolean>}
|
|
266
|
+
*/
|
|
267
|
+
export async function isPortBusy(port, { hosts = LOOPBACK_HOSTS, probe = probeBind } = {}) {
|
|
268
|
+
const states = await Promise.all(hosts.map((host) => probe(Number(port), host)));
|
|
269
|
+
return states.some(Boolean);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* First port at or above `from` that is free on every loopback family.
|
|
274
|
+
*
|
|
275
|
+
* @param {number} from
|
|
276
|
+
* @param {{ hosts?: string[], maxTries?: number,
|
|
277
|
+
* isBusy?: (port: number, opts?: object) => Promise<boolean> }} [opts]
|
|
278
|
+
* @returns {Promise<number>}
|
|
279
|
+
*/
|
|
280
|
+
export async function findFreePort(
|
|
281
|
+
from,
|
|
282
|
+
{ hosts = LOOPBACK_HOSTS, maxTries = 25, isBusy = isPortBusy } = {}
|
|
283
|
+
) {
|
|
284
|
+
const start = Number(from);
|
|
285
|
+
for (let port = start; port < start + maxTries && port < 65536; port += 1) {
|
|
286
|
+
if (!(await isBusy(port, { hosts }))) return port;
|
|
287
|
+
}
|
|
288
|
+
throw new Error(`No free loopback port between ${start} and ${start + maxTries - 1}.`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Keep both ports usable before anything binds: a busy default port moves to the next free one,
|
|
293
|
+
* while a busy port the user pinned with a flag is a hard error. `strict` removes the auto-pick
|
|
294
|
+
* entirely, so a LaunchAgent start never changes the URL it was paired on.
|
|
295
|
+
*
|
|
296
|
+
* @param {{ port: number, apiPort: number, portExplicit?: boolean, apiPortExplicit?: boolean,
|
|
297
|
+
* strict?: boolean,
|
|
298
|
+
* isBusy?: (port: number) => Promise<boolean>,
|
|
299
|
+
* findFree?: (from: number) => Promise<number> }} opts
|
|
300
|
+
* @returns {Promise<{ port: number, apiPort: number, notices: string[] }>}
|
|
301
|
+
*/
|
|
302
|
+
export async function resolveStudioPorts({
|
|
303
|
+
port,
|
|
304
|
+
apiPort,
|
|
305
|
+
portExplicit = false,
|
|
306
|
+
apiPortExplicit = false,
|
|
307
|
+
strict = false,
|
|
308
|
+
isBusy = (value) => isPortBusy(value),
|
|
309
|
+
findFree = (from) => findFreePort(from),
|
|
310
|
+
} = {}) {
|
|
311
|
+
const notices = [];
|
|
312
|
+
const pick = async (value, explicit, flag, label) => {
|
|
313
|
+
if (!(await isBusy(value))) return value;
|
|
314
|
+
if (explicit || strict) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Port ${value} is already in use on loopback (127.0.0.1 or ::1). ` +
|
|
317
|
+
`Choose another port: --${flag} <free port>.`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const next = await findFree(value + 1);
|
|
321
|
+
notices.push(`Port ${value} is in use; using ${next} for ${label} instead.`);
|
|
322
|
+
return next;
|
|
323
|
+
};
|
|
324
|
+
const resolvedApiPort = await pick(apiPort, apiPortExplicit, 'api-port', 'the local server');
|
|
325
|
+
const resolvedPort = await pick(port, portExplicit, 'port', 'Studio');
|
|
326
|
+
return { port: resolvedPort, apiPort: resolvedApiPort, notices };
|
|
327
|
+
}
|
|
328
|
+
|
|
183
329
|
/**
|
|
184
330
|
* Resolve the pairing token through the shared helper (D-01/D-02). `repoRoot` is still accepted for
|
|
185
331
|
* tests but only supplies the one-time legacy `web/.studio-token` migration path; the token itself
|
|
@@ -198,6 +344,7 @@ export function resolveStudioToken({ repoRoot = REPO_ROOT, env = process.env, ho
|
|
|
198
344
|
|
|
199
345
|
/**
|
|
200
346
|
* Poll `/api/studio/health` until it answers 200 with the pairing token.
|
|
347
|
+
* A 404 or a JSON body that is not a Studio health payload means another app owns the port.
|
|
201
348
|
* @param {{ url: string, token: string, timeoutMs?: number, intervalMs?: number, fetchImpl?: typeof fetch }} opts
|
|
202
349
|
*/
|
|
203
350
|
export async function waitForHealth({
|
|
@@ -209,17 +356,36 @@ export async function waitForHealth({
|
|
|
209
356
|
}) {
|
|
210
357
|
const deadline = Date.now() + timeoutMs;
|
|
211
358
|
let lastError = 'no response';
|
|
359
|
+
let foreign = false;
|
|
212
360
|
while (Date.now() < deadline) {
|
|
213
361
|
try {
|
|
214
362
|
const res = await fetchImpl(url, { headers: { 'X-Studio-Token': token } });
|
|
215
|
-
if (res.ok)
|
|
216
|
-
|
|
363
|
+
if (res.ok) {
|
|
364
|
+
const body = await res.json().catch(() => null);
|
|
365
|
+
if (isStudioHealthPayload(body)) return body;
|
|
366
|
+
foreign = true;
|
|
367
|
+
lastError = 'an answer that is not Studio';
|
|
368
|
+
} else {
|
|
369
|
+
if (res.status === 404) foreign = true;
|
|
370
|
+
lastError = `HTTP ${res.status}`;
|
|
371
|
+
}
|
|
217
372
|
} catch (err) {
|
|
218
373
|
lastError = err.message;
|
|
219
374
|
}
|
|
220
375
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
221
376
|
}
|
|
222
|
-
|
|
377
|
+
const port = (() => {
|
|
378
|
+
try {
|
|
379
|
+
return new URL(url).port;
|
|
380
|
+
} catch {
|
|
381
|
+
return '';
|
|
382
|
+
}
|
|
383
|
+
})();
|
|
384
|
+
const hint =
|
|
385
|
+
foreign && port
|
|
386
|
+
? ` Another app is answering on port ${port}; start Studio with --api-port <free port>.`
|
|
387
|
+
: '';
|
|
388
|
+
throw new Error(`Studio server did not become healthy at ${url} (${lastError}).${hint}`);
|
|
223
389
|
}
|
|
224
390
|
|
|
225
391
|
/**
|
|
@@ -259,7 +425,7 @@ export async function createStudioStaticServer({
|
|
|
259
425
|
return {
|
|
260
426
|
server,
|
|
261
427
|
port: actualPort,
|
|
262
|
-
url:
|
|
428
|
+
url: loopbackUrl(actualPort),
|
|
263
429
|
close: () => new Promise((done) => server.close(() => done())),
|
|
264
430
|
};
|
|
265
431
|
}
|
|
@@ -337,10 +503,131 @@ function ensureBuild({ studioDir, spinner }) {
|
|
|
337
503
|
}
|
|
338
504
|
}
|
|
339
505
|
|
|
506
|
+
/**
|
|
507
|
+
* `--autostart on|off`, `--status` and `--stop`. None of them starts the Studio server, so they run
|
|
508
|
+
* before the Node 22.5 gate and never import `ora`.
|
|
509
|
+
*
|
|
510
|
+
* @param {{ parsed: object, ctx?: object, style?: Function, repoRoot: string }} opts
|
|
511
|
+
* @returns {Promise<number>} process exit code
|
|
512
|
+
*/
|
|
513
|
+
async function runBackground({ parsed, ctx = {}, style, repoRoot }) {
|
|
514
|
+
const {
|
|
515
|
+
env = process.env,
|
|
516
|
+
userHome,
|
|
517
|
+
platform = process.platform,
|
|
518
|
+
exec = execFileSync,
|
|
519
|
+
kill = process.kill,
|
|
520
|
+
fetchImpl = globalThis.fetch,
|
|
521
|
+
} = ctx;
|
|
522
|
+
const log = (line) => console.log(line);
|
|
523
|
+
const fail = (line) => console.error(style('error', line));
|
|
524
|
+
const show = (file) => displayPath(file, { home: userHome || homedir() });
|
|
525
|
+
|
|
526
|
+
if (parsed.autostart === 'invalid') {
|
|
527
|
+
fail('--autostart needs a value: on or off.');
|
|
528
|
+
return 1;
|
|
529
|
+
}
|
|
530
|
+
if (parsed.autostart && (parsed.status || parsed.stop)) {
|
|
531
|
+
fail('Use --autostart, --status or --stop one at a time.');
|
|
532
|
+
return 1;
|
|
533
|
+
}
|
|
534
|
+
if (parsed.autostart && platform !== 'darwin') {
|
|
535
|
+
fail(UNSUPPORTED_PLATFORM);
|
|
536
|
+
return 1;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (parsed.autostart === 'on') {
|
|
540
|
+
try {
|
|
541
|
+
const ports = await resolveStudioPorts({
|
|
542
|
+
port: parsed.port,
|
|
543
|
+
apiPort: parsed.apiPort,
|
|
544
|
+
portExplicit: parsed.portExplicit,
|
|
545
|
+
apiPortExplicit: parsed.apiPortExplicit,
|
|
546
|
+
strict: true,
|
|
547
|
+
isBusy: ctx.isPortBusy,
|
|
548
|
+
findFree: ctx.findFreePort,
|
|
549
|
+
});
|
|
550
|
+
const { logPath } = enableAutostart({
|
|
551
|
+
port: ports.port,
|
|
552
|
+
apiPort: ports.apiPort,
|
|
553
|
+
cliPath: join(repoRoot, 'bin', 'cli.js'),
|
|
554
|
+
execPath: ctx.execPath,
|
|
555
|
+
env,
|
|
556
|
+
userHome,
|
|
557
|
+
platform,
|
|
558
|
+
exec,
|
|
559
|
+
uid: ctx.uid,
|
|
560
|
+
});
|
|
561
|
+
log(style('success', `Studio will start at login (${LABEL}).`));
|
|
562
|
+
log(` Studio URL: ${loopbackUrl(ports.port)}`);
|
|
563
|
+
log(` Log: ${show(logPath)}`);
|
|
564
|
+
return 0;
|
|
565
|
+
} catch (err) {
|
|
566
|
+
fail(err.message);
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (parsed.autostart === 'off') {
|
|
572
|
+
try {
|
|
573
|
+
const { plistPath, removed } = disableAutostart({ userHome, platform, exec, uid: ctx.uid });
|
|
574
|
+
log(
|
|
575
|
+
removed
|
|
576
|
+
? `Studio will no longer start at login (removed ${show(plistPath)}).`
|
|
577
|
+
: 'Studio auto-start was already off.'
|
|
578
|
+
);
|
|
579
|
+
return 0;
|
|
580
|
+
} catch (err) {
|
|
581
|
+
fail(err.message);
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (parsed.status) {
|
|
587
|
+
const report = await collectStatus({ env, userHome, kill, fetchImpl });
|
|
588
|
+
const pairingUrl =
|
|
589
|
+
report.token && report.state?.port && report.state?.apiPort
|
|
590
|
+
? buildPairingUrl({
|
|
591
|
+
studioPort: report.state.port,
|
|
592
|
+
apiPort: report.state.apiPort,
|
|
593
|
+
token: report.token,
|
|
594
|
+
}).pairingUrl
|
|
595
|
+
: null;
|
|
596
|
+
log(formatStatus({ ...report, pairingUrl, userHome: userHome || homedir() }));
|
|
597
|
+
return report.status === 'running' ? 0 : 1;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (parsed.stop) {
|
|
601
|
+
const result = await stopStudio({ env, userHome, platform, exec, uid: ctx.uid, kill });
|
|
602
|
+
if (result.refused) {
|
|
603
|
+
fail(`Refusing to stop pid ${result.pid}: it is not a Studio process.`);
|
|
604
|
+
return 1;
|
|
605
|
+
}
|
|
606
|
+
if (result.stopped) {
|
|
607
|
+
log(
|
|
608
|
+
result.via === 'launchctl'
|
|
609
|
+
? 'Stopped Studio (launchd agent).'
|
|
610
|
+
: `Stopped Studio (pid ${result.pid}).`
|
|
611
|
+
);
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
if (result.reason === 'stale') {
|
|
615
|
+
log(`Studio is not running (removed a stale studio.json for pid ${result.pid}).`);
|
|
616
|
+
} else {
|
|
617
|
+
log('Studio is not running.');
|
|
618
|
+
}
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return 1;
|
|
623
|
+
}
|
|
624
|
+
|
|
340
625
|
/**
|
|
341
626
|
* @param {{ args?: string[], cwd?: string, chalk?: object, style?: Function, repoRoot?: string,
|
|
342
627
|
* ora?: Function, spawnImpl?: typeof spawn, fetchImpl?: typeof fetch,
|
|
343
|
-
* openImpl?: typeof openBrowser, nodeVersion?: string
|
|
628
|
+
* openImpl?: typeof openBrowser, nodeVersion?: string,
|
|
629
|
+
* isPortBusy?: (port: number) => Promise<boolean>,
|
|
630
|
+
* findFreePort?: (from: number) => Promise<number> }} [ctx]
|
|
344
631
|
*/
|
|
345
632
|
export async function run(ctx = {}) {
|
|
346
633
|
const {
|
|
@@ -358,10 +645,16 @@ export async function run(ctx = {}) {
|
|
|
358
645
|
return 0;
|
|
359
646
|
}
|
|
360
647
|
|
|
648
|
+
const parsed = parseStudioArgs(args);
|
|
649
|
+
if (parsed.autostart || parsed.status || parsed.stop) {
|
|
650
|
+
return runBackground({ parsed, ctx, style, repoRoot });
|
|
651
|
+
}
|
|
652
|
+
|
|
361
653
|
requireStudioNode(ctx.nodeVersion);
|
|
362
654
|
|
|
363
655
|
const ora = ctx.ora || (await import('ora')).default;
|
|
364
|
-
const {
|
|
656
|
+
const { open, rotateToken } = parsed;
|
|
657
|
+
let { port, apiPort } = parsed;
|
|
365
658
|
|
|
366
659
|
const hasStartRequest = args.some((arg) => arg !== '--rotate-token');
|
|
367
660
|
let rotatedToken = null;
|
|
@@ -394,6 +687,30 @@ export async function run(ctx = {}) {
|
|
|
394
687
|
throw new Error(`Local server not found at ${serverEntry}. Is the package installed fully?`);
|
|
395
688
|
}
|
|
396
689
|
|
|
690
|
+
const portOptions = {
|
|
691
|
+
port,
|
|
692
|
+
apiPort,
|
|
693
|
+
portExplicit: parsed.portExplicit,
|
|
694
|
+
apiPortExplicit: parsed.apiPortExplicit,
|
|
695
|
+
strict: parsed.strictPort,
|
|
696
|
+
isBusy: ctx.isPortBusy,
|
|
697
|
+
findFree: ctx.findFreePort,
|
|
698
|
+
};
|
|
699
|
+
let ports;
|
|
700
|
+
try {
|
|
701
|
+
ports = await resolveStudioPorts(portOptions);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
// The LaunchAgent run has no terminal: one readable line in the log beats a stack trace.
|
|
704
|
+
if (!parsed.strictPort) throw err;
|
|
705
|
+
console.error(style('error', err.message));
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
708
|
+
port = ports.port;
|
|
709
|
+
apiPort = ports.apiPort;
|
|
710
|
+
for (const notice of ports.notices) {
|
|
711
|
+
console.log(style('warning', notice));
|
|
712
|
+
}
|
|
713
|
+
|
|
397
714
|
console.log('');
|
|
398
715
|
const setupSpinner = ora('Preparing local platform…');
|
|
399
716
|
// `express`/`cors` are root dependencies, so the web server runs without a nested install. This
|
|
@@ -437,6 +754,7 @@ export async function run(ctx = {}) {
|
|
|
437
754
|
const shutdown = async (code = 0) => {
|
|
438
755
|
if (shuttingDown) return;
|
|
439
756
|
shuttingDown = true;
|
|
757
|
+
try { removeState(); } catch { /* best effort */ }
|
|
440
758
|
try { apiChild.kill('SIGTERM'); } catch { /* already gone */ }
|
|
441
759
|
if (staticServer) {
|
|
442
760
|
try { await staticServer.close(); } catch { /* ignore */ }
|
|
@@ -485,6 +803,22 @@ export async function run(ctx = {}) {
|
|
|
485
803
|
return done;
|
|
486
804
|
}
|
|
487
805
|
|
|
806
|
+
try {
|
|
807
|
+
writeState(
|
|
808
|
+
{
|
|
809
|
+
pid: process.pid,
|
|
810
|
+
port,
|
|
811
|
+
apiPort,
|
|
812
|
+
studioUrl,
|
|
813
|
+
apiUrl,
|
|
814
|
+
startedAt: new Date().toISOString(),
|
|
815
|
+
},
|
|
816
|
+
{ env: process.env }
|
|
817
|
+
);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
console.log(style('warning', `Could not record studio.json: ${err.message}`));
|
|
820
|
+
}
|
|
821
|
+
|
|
488
822
|
console.log('');
|
|
489
823
|
console.log(style('success', `Professor Studio is running at ${studioUrl}`));
|
|
490
824
|
console.log(` Pairing URL: ${pairingUrl}`);
|