pantheon-opencode 1.1.1-beta.3 → 1.1.1-beta.5
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/bin/pantheon-init.mjs +19 -6
- package/package.json +1 -1
- package/scripts/install/cli-ui.mjs +160 -0
- package/scripts/install/component.mjs +24 -0
- package/scripts/install/interactive.mjs +395 -0
- package/scripts/install/migrate-config.mjs +308 -0
- package/scripts/install/migrate.mjs +210 -30
- package/scripts/install/opencode.mjs +96 -78
- package/scripts/install/plugin.mjs +139 -0
- package/scripts/install/state.mjs +194 -0
|
@@ -3,17 +3,16 @@
|
|
|
3
3
|
* opencode.mjs — OpenCode platform installer
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { execSync } from 'node:child_process'
|
|
7
6
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
7
|
import { homedir } from 'node:os'
|
|
9
8
|
import { join, resolve } from 'node:path'
|
|
10
9
|
import { healthCheck } from './health-check.mjs'
|
|
11
10
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
11
|
+
import { colors, configure, section, step, success, warning, error, info, bullet, spinner, printSummary } from './cli-ui.mjs'
|
|
12
12
|
import {
|
|
13
13
|
collectSkillNames,
|
|
14
14
|
copyFiles,
|
|
15
15
|
installSkills,
|
|
16
|
-
PLATFORM_DIR,
|
|
17
16
|
parseFrontmatter,
|
|
18
17
|
ROOT,
|
|
19
18
|
sourceDirValid,
|
|
@@ -22,6 +21,10 @@ import {
|
|
|
22
21
|
writeIfChanged,
|
|
23
22
|
} from './shared.mjs'
|
|
24
23
|
import { setupVenv } from './venv.mjs'
|
|
24
|
+
import { installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
25
|
+
import { resolveComponents } from './component.mjs'
|
|
26
|
+
|
|
27
|
+
const COMPONENT_NAMES = ['agents', 'skills', 'instructions', 'prompts', 'commands', 'plugins', 'runtime']
|
|
25
28
|
|
|
26
29
|
function deepClone(value) {
|
|
27
30
|
return JSON.parse(JSON.stringify(value))
|
|
@@ -71,11 +74,12 @@ function isGlobalConfigDir(target) {
|
|
|
71
74
|
return resolve(target) === xdgDir
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
export function installOpenCode(
|
|
77
|
+
export async function installOpenCode(
|
|
75
78
|
target,
|
|
76
79
|
dryRun = false,
|
|
77
80
|
clean = false,
|
|
78
81
|
components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
|
|
82
|
+
opts = {},
|
|
79
83
|
) {
|
|
80
84
|
// Default target if not provided (global ~/.config/opencode)
|
|
81
85
|
if (!target) {
|
|
@@ -85,6 +89,35 @@ export function installOpenCode(
|
|
|
85
89
|
const componentSet = new Set(components)
|
|
86
90
|
const stats = summary.opencode
|
|
87
91
|
|
|
92
|
+
// Interactive mode handling
|
|
93
|
+
const stdinTTY = process.stdin.isTTY && process.stdout.isTTY
|
|
94
|
+
const forceInteractive = opts.interactive === true
|
|
95
|
+
const forceHeadless = opts.headless === true
|
|
96
|
+
const autoYes = opts.yes === true
|
|
97
|
+
const interactive = forceInteractive || (stdinTTY && !forceHeadless)
|
|
98
|
+
|
|
99
|
+
if (interactive && !dryRun) {
|
|
100
|
+
const { runInteractiveInstall } = await import('./interactive.mjs')
|
|
101
|
+
const result = await runInteractiveInstall({
|
|
102
|
+
components,
|
|
103
|
+
defaultComponents: COMPONENT_NAMES,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (!result.confirmed && !autoYes) {
|
|
107
|
+
info('Installation canceled.')
|
|
108
|
+
return stats
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Map target selection to actual path
|
|
112
|
+
const newComponents = [...result.components]
|
|
113
|
+
componentSet.clear()
|
|
114
|
+
for (const c of newComponents) componentSet.add(c)
|
|
115
|
+
|
|
116
|
+
if (result.target === 'project') {
|
|
117
|
+
target = process.cwd()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
88
121
|
// Determine layout based on install scope.
|
|
89
122
|
// Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
|
|
90
123
|
// agents/ skills/ commands/ plugins/
|
|
@@ -94,21 +127,23 @@ export function installOpenCode(
|
|
|
94
127
|
const _subDir = isGlobal ? '' : '.opencode'
|
|
95
128
|
const agentPrefix = isGlobal ? 'agents' : '.opencode/agents'
|
|
96
129
|
|
|
130
|
+
configure({ dryRun })
|
|
131
|
+
|
|
97
132
|
if (isGlobal) {
|
|
98
|
-
|
|
99
|
-
' 🌐 Global config directory detected — using flat layout (agents/, skills/, commands/)',
|
|
100
|
-
)
|
|
133
|
+
info('Global config directory detected — using flat layout (agents/, skills/, commands/)')
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
// -----------------------------------------------------------------------
|
|
104
137
|
// 1. Install agents (--components agents)
|
|
105
138
|
// -----------------------------------------------------------------------
|
|
106
139
|
if (componentSet.has('agents')) {
|
|
140
|
+
section('\uD83D\uDCE6 Agents')
|
|
107
141
|
const srcDir = join(ROOT, 'src', 'agents')
|
|
108
142
|
if (!sourceDirValid(srcDir)) {
|
|
109
|
-
|
|
143
|
+
warning(`Agent source directory not found: ${srcDir}`)
|
|
110
144
|
stats.errors++
|
|
111
145
|
} else {
|
|
146
|
+
const agentStep = step('Installing agents')
|
|
112
147
|
const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
|
|
113
148
|
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
114
149
|
if (clean && existsSync(dstDir) && !dryRun) {
|
|
@@ -120,6 +155,7 @@ export function installOpenCode(
|
|
|
120
155
|
const { created, skipped } = copyFiles(srcDir, dstDir, dryRun)
|
|
121
156
|
stats.created += created
|
|
122
157
|
stats.skipped += skipped
|
|
158
|
+
agentStep(true)
|
|
123
159
|
}
|
|
124
160
|
}
|
|
125
161
|
|
|
@@ -139,9 +175,10 @@ export function installOpenCode(
|
|
|
139
175
|
// 2. Install skills (--components skills)
|
|
140
176
|
// -----------------------------------------------------------------------
|
|
141
177
|
if (componentSet.has('skills')) {
|
|
178
|
+
section('\uD83D\uDCDA Skills')
|
|
142
179
|
const skillNames = collectSkillNames()
|
|
143
180
|
if (skillNames.length > 0) {
|
|
144
|
-
|
|
181
|
+
const skillStep = step(`Installing ${skillNames.length} skills`)
|
|
145
182
|
const dstSkillsDir = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
|
|
146
183
|
if (clean && existsSync(dstSkillsDir) && !dryRun) {
|
|
147
184
|
const existing = readdirSync(dstSkillsDir)
|
|
@@ -158,6 +195,7 @@ export function installOpenCode(
|
|
|
158
195
|
)
|
|
159
196
|
stats.created += sCreated
|
|
160
197
|
stats.skipped += sSkipped
|
|
198
|
+
skillStep(true)
|
|
161
199
|
}
|
|
162
200
|
}
|
|
163
201
|
|
|
@@ -165,6 +203,7 @@ export function installOpenCode(
|
|
|
165
203
|
// 2.5 Install instructions: AGENTS.md + instructions/ (--components instructions)
|
|
166
204
|
// -----------------------------------------------------------------------
|
|
167
205
|
if (componentSet.has('instructions')) {
|
|
206
|
+
section('\uD83D\uDCCB Instructions')
|
|
168
207
|
// AGENTS.md
|
|
169
208
|
const srcAgentsMd = join(ROOT, 'AGENTS.md')
|
|
170
209
|
const dstAgentsMd = join(target, 'AGENTS.md')
|
|
@@ -188,6 +227,7 @@ export function installOpenCode(
|
|
|
188
227
|
// 2.6 Install prompts (--components prompts)
|
|
189
228
|
// -----------------------------------------------------------------------
|
|
190
229
|
if (componentSet.has('prompts')) {
|
|
230
|
+
section('\uD83D\uDCAC Prompts')
|
|
191
231
|
const srcPrompts = join(ROOT, 'prompts')
|
|
192
232
|
const dstPrompts = join(target, 'prompts')
|
|
193
233
|
if (existsSync(srcPrompts)) {
|
|
@@ -201,6 +241,7 @@ export function installOpenCode(
|
|
|
201
241
|
// 2.7 Install commands (--components commands)
|
|
202
242
|
// -----------------------------------------------------------------------
|
|
203
243
|
if (componentSet.has('commands')) {
|
|
244
|
+
section('\u26A1 Commands')
|
|
204
245
|
const srcCmds = join(ROOT, 'commands')
|
|
205
246
|
const dstCmds = isGlobal ? join(target, 'commands') : join(target, '.opencode', 'commands')
|
|
206
247
|
if (existsSync(srcCmds)) {
|
|
@@ -223,10 +264,10 @@ export function installOpenCode(
|
|
|
223
264
|
if (cfg.subagent_depth === undefined) { cfg.subagent_depth = 2; changed = true }
|
|
224
265
|
if (changed && !dryRun) {
|
|
225
266
|
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n')
|
|
226
|
-
|
|
267
|
+
success('subagent_depth: 2 added to opencode.json')
|
|
227
268
|
}
|
|
228
269
|
} catch (e) {
|
|
229
|
-
|
|
270
|
+
warning(`Could not update opencode.json: ${e.message}`)
|
|
230
271
|
}
|
|
231
272
|
}
|
|
232
273
|
}
|
|
@@ -234,46 +275,15 @@ export function installOpenCode(
|
|
|
234
275
|
// 2.8 Install TUI plugins (--components plugins)
|
|
235
276
|
// -----------------------------------------------------------------------
|
|
236
277
|
if (componentSet.has('plugins')) {
|
|
278
|
+
section('\uD83D\uDD0C Plugins')
|
|
237
279
|
const srcPluginDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
238
280
|
const dstPluginDir = isGlobal
|
|
239
281
|
? join(target, 'plugins', 'pantheon-tui')
|
|
240
282
|
: join(target, '.opencode', 'plugins', 'pantheon-tui')
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
if (!dryRun) mkdirSync(join(dstPluginDir, "dist"), { recursive: true })
|
|
247
|
-
// Copy src/index.tsx -> index.tsx
|
|
248
|
-
const srcIdx = join(srcPluginDir, "src", "index.tsx")
|
|
249
|
-
if (existsSync(srcIdx)) {
|
|
250
|
-
writeIfChanged(join(dstPluginDir, "index.tsx"), readFileSync(srcIdx, "utf8"), dryRun)
|
|
251
|
-
stats.created++
|
|
252
|
-
}
|
|
253
|
-
// Copy dist files
|
|
254
|
-
const distSrc = join(srcPluginDir, "dist")
|
|
255
|
-
if (existsSync(distSrc)) {
|
|
256
|
-
for (const f of readdirSync(distSrc)) {
|
|
257
|
-
const c = readFileSync(join(distSrc, f), "utf8")
|
|
258
|
-
writeIfChanged(join(dstPluginDir, "dist", f), c, dryRun)
|
|
259
|
-
stats.created++
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
// Copy package.json
|
|
263
|
-
const pkgSrc = join(srcPluginDir, "package.json")
|
|
264
|
-
if (existsSync(pkgSrc)) {
|
|
265
|
-
writeIfChanged(join(dstPluginDir, "package.json"), readFileSync(pkgSrc, "utf8"), dryRun)
|
|
266
|
-
stats.created++
|
|
267
|
-
}
|
|
268
|
-
// Install plugin dependencies (@opentui/core, @opentui/solid, solid-js)
|
|
269
|
-
if (!dryRun) {
|
|
270
|
-
try {
|
|
271
|
-
execSync('npm install --omit=dev --no-audit', { cwd: dstPluginDir, stdio: 'pipe' })
|
|
272
|
-
} catch (e) {
|
|
273
|
-
console.warn(' ⚠️ Failed to install TUI plugin dependencies:', e.message)
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
283
|
+
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean })
|
|
284
|
+
stats.created += pluginStats.created
|
|
285
|
+
stats.skipped += pluginStats.skipped
|
|
286
|
+
stats.errors += pluginStats.errors
|
|
277
287
|
}
|
|
278
288
|
|
|
279
289
|
// -----------------------------------------------------------------------
|
|
@@ -282,30 +292,9 @@ export function installOpenCode(
|
|
|
282
292
|
const targetTuiConfigPath = isGlobal
|
|
283
293
|
? join(target, 'tui.json')
|
|
284
294
|
: join(target, '.opencode', 'tui.json')
|
|
285
|
-
let tuiConfig = { plugin: [] }
|
|
286
|
-
if (existsSync(targetTuiConfigPath)) {
|
|
287
|
-
try {
|
|
288
|
-
tuiConfig = JSON.parse(readFileSync(targetTuiConfigPath, 'utf8'))
|
|
289
|
-
} catch {
|
|
290
|
-
/* use default */
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
if (!Array.isArray(tuiConfig.plugin)) {
|
|
294
|
-
tuiConfig.plugin = []
|
|
295
|
-
}
|
|
296
|
-
// Remove stale plugin refs (old dist/tui.tsx path)
|
|
297
|
-
const staleRefs = ["plugins/pantheon-tui/dist/tui.tsx", "plugins/pantheon-tui/dist/tui.js"]
|
|
298
|
-
for (const stale of staleRefs) {
|
|
299
|
-
const idx = tuiConfig.plugin.indexOf(stale)
|
|
300
|
-
if (idx !== -1) tuiConfig.plugin.splice(idx, 1)
|
|
301
|
-
}
|
|
302
|
-
// Add our plugin if not already present
|
|
303
295
|
const pluginRef = 'plugins/pantheon-tui'
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
|
|
308
|
-
const tuiStatus = writeIfChanged(targetTuiConfigPath, tuiContent, dryRun)
|
|
296
|
+
unregisterPlugin(targetTuiConfigPath, pluginRef, { dryRun })
|
|
297
|
+
const tuiStatus = registerPlugin(targetTuiConfigPath, pluginRef, { dryRun })
|
|
309
298
|
if (tuiStatus === 'created') stats.created++
|
|
310
299
|
else stats.skipped++
|
|
311
300
|
|
|
@@ -314,6 +303,7 @@ export function installOpenCode(
|
|
|
314
303
|
// MCP server scripts, code-mode scripts, tiers.json
|
|
315
304
|
// -----------------------------------------------------------------------
|
|
316
305
|
if (componentSet.has('runtime')) {
|
|
306
|
+
section('\u2699\uFE0F Runtime')
|
|
317
307
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
318
308
|
|
|
319
309
|
// ── MCP server scripts ──
|
|
@@ -331,7 +321,7 @@ export function installOpenCode(
|
|
|
331
321
|
for (const script of mcpScripts) {
|
|
332
322
|
const src = join(srcScriptsDir, script)
|
|
333
323
|
if (!existsSync(src)) {
|
|
334
|
-
|
|
324
|
+
warning(`Script not found: ${src}`)
|
|
335
325
|
stats.errors++
|
|
336
326
|
continue
|
|
337
327
|
}
|
|
@@ -683,9 +673,9 @@ export function installOpenCode(
|
|
|
683
673
|
if (currentVersion) {
|
|
684
674
|
const migration = runMigrations(target, currentVersion, { dryRun })
|
|
685
675
|
if (migration.applied > 0) {
|
|
686
|
-
|
|
676
|
+
success(`Applied ${migration.applied} migration(s)`)
|
|
687
677
|
for (const msg of migration.messages) {
|
|
688
|
-
|
|
678
|
+
bullet(msg, 1)
|
|
689
679
|
}
|
|
690
680
|
}
|
|
691
681
|
}
|
|
@@ -696,22 +686,50 @@ export function installOpenCode(
|
|
|
696
686
|
// -----------------------------------------------------------------------
|
|
697
687
|
if (componentSet.has('runtime')) {
|
|
698
688
|
try {
|
|
689
|
+
const venvSpinner = spinner('Setting up Python virtual environment')
|
|
699
690
|
setupVenv(target, { dryRun, force: clean })
|
|
691
|
+
venvSpinner(true)
|
|
700
692
|
const health = healthCheck(target, { dryRun })
|
|
701
693
|
|
|
702
694
|
// Print health summary
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
for (const
|
|
706
|
-
for (const
|
|
695
|
+
section('\uD83D\uDD0D Health Check')
|
|
696
|
+
const healthStep = step('Running health checks')
|
|
697
|
+
for (const p of health.passed) success(`${p.check}: ${p.detail}`)
|
|
698
|
+
for (const w of health.warnings) warning(`${w.check}: ${w.detail}`)
|
|
699
|
+
for (const f of health.failed) error(`${f.check}: ${f.detail}`)
|
|
707
700
|
|
|
708
701
|
if (health.failed.length > 0) {
|
|
709
|
-
|
|
702
|
+
healthStep(false)
|
|
703
|
+
warning(`${health.failed.length} check(s) failed — review above`)
|
|
710
704
|
stats.errors += health.failed.length
|
|
705
|
+
} else {
|
|
706
|
+
healthStep(true)
|
|
711
707
|
}
|
|
712
708
|
} catch (err) {
|
|
713
|
-
|
|
709
|
+
error(`Setup failed: ${err.message}`)
|
|
714
710
|
throw err // Fatal — abort installation
|
|
715
711
|
}
|
|
716
712
|
}
|
|
713
|
+
|
|
714
|
+
if (interactive && !dryRun) {
|
|
715
|
+
const created = stats.created
|
|
716
|
+
const skipped = stats.skipped
|
|
717
|
+
const errors = stats.errors
|
|
718
|
+
|
|
719
|
+
process.stdout.write('\n')
|
|
720
|
+
process.stdout.write(` ${colors.bold(colors.green('Installation complete'))}\n`)
|
|
721
|
+
process.stdout.write(` ${colors.dim('\u2500'.repeat(Math.min(process.stdout.columns || 60, 60)))}\n`)
|
|
722
|
+
process.stdout.write(` ${colors.green('\u2713')} ${created} component(s) installed\n`)
|
|
723
|
+
if (skipped > 0) process.stdout.write(` ${colors.dim('\u2014')} ${skipped} already up-to-date\n`)
|
|
724
|
+
if (errors > 0) process.stdout.write(` ${colors.yellow('\u26a0')} ${errors} error(s) encountered\n`)
|
|
725
|
+
process.stdout.write('\n')
|
|
726
|
+
process.stdout.write(` ${colors.bold('Next steps:')}\n`)
|
|
727
|
+
process.stdout.write(` ${colors.dim('\u2022')} Configure agents in opencode.json\n`)
|
|
728
|
+
process.stdout.write(` ${colors.dim('\u2022')} Add MCP servers in mcp.json\n`)
|
|
729
|
+
process.stdout.write(` ${colors.dim('\u2022')} Run 'opencode doctor' to verify\n`)
|
|
730
|
+
process.stdout.write(` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`)
|
|
731
|
+
process.stdout.write('\n')
|
|
732
|
+
} else {
|
|
733
|
+
printSummary(target, ['opencode'], stats)
|
|
734
|
+
}
|
|
717
735
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin.mjs — Plugin installation utilities for the OpenCode installer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { warning } from './cli-ui.mjs'
|
|
9
|
+
import { writeIfChanged } from './shared.mjs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Install a plugin from source to destination.
|
|
13
|
+
* @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
|
|
14
|
+
* @param {string} dstDir - Destination plugin directory
|
|
15
|
+
* @param {object} [options]
|
|
16
|
+
* @param {boolean} [options.dryRun=false] - If true, don't write files
|
|
17
|
+
* @param {boolean} [options.clean=false] - If true, remove existing before install
|
|
18
|
+
* @returns {{ created: number, skipped: number, errors: number }}
|
|
19
|
+
*/
|
|
20
|
+
export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
|
|
21
|
+
const result = { created: 0, skipped: 0, errors: 0 }
|
|
22
|
+
|
|
23
|
+
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
24
|
+
if (clean && existsSync(dstDir) && !dryRun) {
|
|
25
|
+
rmSync(dstDir, { recursive: true, force: true })
|
|
26
|
+
mkdirSync(dstDir, { recursive: true })
|
|
27
|
+
}
|
|
28
|
+
if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
|
|
29
|
+
|
|
30
|
+
// Copy src/index.tsx -> index.tsx
|
|
31
|
+
const srcIdx = join(srcDir, 'src', 'index.tsx')
|
|
32
|
+
if (existsSync(srcIdx)) {
|
|
33
|
+
const idxStatus = writeIfChanged(join(dstDir, 'index.tsx'), readFileSync(srcIdx, 'utf8'), dryRun)
|
|
34
|
+
if (idxStatus === 'created') result.created++
|
|
35
|
+
else result.skipped++
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Copy dist/* files
|
|
39
|
+
const distSrc = join(srcDir, 'dist')
|
|
40
|
+
if (existsSync(distSrc)) {
|
|
41
|
+
for (const f of readdirSync(distSrc)) {
|
|
42
|
+
const c = readFileSync(join(distSrc, f), 'utf8')
|
|
43
|
+
writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
|
|
44
|
+
result.created++
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Copy package.json
|
|
49
|
+
const pkgSrc = join(srcDir, 'package.json')
|
|
50
|
+
if (existsSync(pkgSrc)) {
|
|
51
|
+
const pkgStatus = writeIfChanged(join(dstDir, 'package.json'), readFileSync(pkgSrc, 'utf8'), dryRun)
|
|
52
|
+
if (pkgStatus === 'created') result.created++
|
|
53
|
+
else result.skipped++
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Install plugin dependencies (@opentui/core, @opentui/solid, solid-js)
|
|
57
|
+
if (!dryRun) {
|
|
58
|
+
try {
|
|
59
|
+
execSync('npm install --omit=dev --no-audit', { cwd: dstDir, stdio: 'pipe' })
|
|
60
|
+
} catch (e) {
|
|
61
|
+
warning('Failed to install TUI plugin dependencies: ' + e.message)
|
|
62
|
+
result.errors++
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Install plugin dependencies via npm.
|
|
71
|
+
* @param {string} pluginDir - Directory containing package.json
|
|
72
|
+
* @param {object} [options]
|
|
73
|
+
* @param {boolean} [options.dryRun=false] - If true, skip npm install
|
|
74
|
+
*/
|
|
75
|
+
export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
|
|
76
|
+
if (dryRun) return
|
|
77
|
+
try {
|
|
78
|
+
execSync('npm install --omit=dev --no-audit', { cwd: pluginDir, stdio: 'pipe' })
|
|
79
|
+
} catch (e) {
|
|
80
|
+
warning('Failed to install plugin dependencies: ' + e.message)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register a plugin in tui.json.
|
|
86
|
+
* @param {string} tuiJsonPath - Path to tui.json
|
|
87
|
+
* @param {string} pluginId - Plugin identifier string
|
|
88
|
+
* @param {object} [options]
|
|
89
|
+
* @param {boolean} [options.dryRun=false]
|
|
90
|
+
* @returns {'created'|'skipped'}
|
|
91
|
+
*/
|
|
92
|
+
export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
93
|
+
let tuiConfig = { plugin: [] }
|
|
94
|
+
if (existsSync(tuiJsonPath)) {
|
|
95
|
+
try {
|
|
96
|
+
tuiConfig = JSON.parse(readFileSync(tuiJsonPath, 'utf8'))
|
|
97
|
+
} catch {
|
|
98
|
+
/* use default */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(tuiConfig.plugin)) {
|
|
102
|
+
tuiConfig.plugin = []
|
|
103
|
+
}
|
|
104
|
+
if (!tuiConfig.plugin.includes(pluginId)) {
|
|
105
|
+
tuiConfig.plugin.push(pluginId)
|
|
106
|
+
}
|
|
107
|
+
const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
|
|
108
|
+
return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Unregister stale plugin references from tui.json.
|
|
113
|
+
* @param {string} tuiJsonPath - Path to tui.json
|
|
114
|
+
* @param {string} pluginId - Plugin to unregister
|
|
115
|
+
* @param {object} [options]
|
|
116
|
+
* @param {boolean} [options.dryRun=false]
|
|
117
|
+
* @returns {'created'|'skipped'}
|
|
118
|
+
*/
|
|
119
|
+
export function unregisterPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
120
|
+
let tuiConfig = { plugin: [] }
|
|
121
|
+
if (existsSync(tuiJsonPath)) {
|
|
122
|
+
try {
|
|
123
|
+
tuiConfig = JSON.parse(readFileSync(tuiJsonPath, 'utf8'))
|
|
124
|
+
} catch {
|
|
125
|
+
/* use default */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(tuiConfig.plugin)) {
|
|
129
|
+
tuiConfig.plugin = []
|
|
130
|
+
}
|
|
131
|
+
// Remove stale plugin refs (old dist/tui.tsx path)
|
|
132
|
+
const staleRefs = [`${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
|
|
133
|
+
for (const stale of staleRefs) {
|
|
134
|
+
const idx = tuiConfig.plugin.indexOf(stale)
|
|
135
|
+
if (idx !== -1) tuiConfig.plugin.splice(idx, 1)
|
|
136
|
+
}
|
|
137
|
+
const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
|
|
138
|
+
return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
|
|
139
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* state.mjs — State manifest for Pantheon installations (schema v2)
|
|
4
|
+
*
|
|
5
|
+
* Manages the install-state.json manifest with version tracking,
|
|
6
|
+
* component state, and applied migration history.
|
|
7
|
+
*
|
|
8
|
+
* Schema v2:
|
|
9
|
+
* {
|
|
10
|
+
* "schema_version": 2,
|
|
11
|
+
* "pantheon_version": "1.1.1",
|
|
12
|
+
* "previous_version": "1.1.0",
|
|
13
|
+
* "updated_at": "2026-07-26T10:30:00Z",
|
|
14
|
+
* "migration_checkpoint": null,
|
|
15
|
+
* "applied_migrations": [
|
|
16
|
+
* { "from": "1.1.0", "to": "1.1.1", "applied_at": "2026-07-26T10:30:00Z", "status": "completed" }
|
|
17
|
+
* ],
|
|
18
|
+
* "components": {
|
|
19
|
+
* "agents": { "version": 1, "status": "installed" },
|
|
20
|
+
* "skills": { "version": 1, "status": "installed" },
|
|
21
|
+
* "plugins": { "version": 1, "status": "installed" },
|
|
22
|
+
* "runtime": { "version": 1, "status": "installed" }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
const STATE_FILE = 'install-state.json'
|
|
31
|
+
const STATE_DIR = '.pantheon'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a fresh v2 state object for a new install.
|
|
35
|
+
* @param {string} version - Pantheon version string
|
|
36
|
+
* @returns {object} v2 state
|
|
37
|
+
*/
|
|
38
|
+
export function createInitialState(version) {
|
|
39
|
+
return {
|
|
40
|
+
schema_version: 2,
|
|
41
|
+
pantheon_version: version || '0.0.0',
|
|
42
|
+
previous_version: null,
|
|
43
|
+
updated_at: new Date().toISOString(),
|
|
44
|
+
migration_checkpoint: null,
|
|
45
|
+
applied_migrations: [],
|
|
46
|
+
components: {
|
|
47
|
+
agents: { version: 1, status: 'pending' },
|
|
48
|
+
skills: { version: 1, status: 'pending' },
|
|
49
|
+
plugins: { version: 1, status: 'pending' },
|
|
50
|
+
runtime: { version: 1, status: 'pending' },
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read install-state.json from a target directory.
|
|
57
|
+
* Backward-compatible with v1 schema (auto-upgrades on read).
|
|
58
|
+
* @param {string} target - Installation directory
|
|
59
|
+
* @returns {object|null} v2 state object, or null if not found
|
|
60
|
+
*/
|
|
61
|
+
export function readState(target) {
|
|
62
|
+
const stateFile = join(target, STATE_DIR, STATE_FILE)
|
|
63
|
+
if (!existsSync(stateFile)) return null
|
|
64
|
+
try {
|
|
65
|
+
const raw = JSON.parse(readFileSync(stateFile, 'utf8'))
|
|
66
|
+
return normalizeState(raw)
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Detect whether the raw state is v1 format (pre-schema_version).
|
|
74
|
+
* v1 state has pantheon_version (or version) but no schema_version.
|
|
75
|
+
* @param {object} state
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function isV1State(state) {
|
|
79
|
+
if (!state || typeof state !== 'object') return false
|
|
80
|
+
return !state.schema_version && (!!state.pantheon_version || !!state.version)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Upgrade a v1 state to v2 schema.
|
|
85
|
+
* Preserves all existing fields, adds defaults for new ones.
|
|
86
|
+
* @param {object} state - v1 state object
|
|
87
|
+
* @returns {object} v2 state object
|
|
88
|
+
*/
|
|
89
|
+
export function upgradeStateV1toV2(state) {
|
|
90
|
+
const now = new Date().toISOString()
|
|
91
|
+
return {
|
|
92
|
+
schema_version: 2,
|
|
93
|
+
pantheon_version: state.pantheon_version || state.version || 'unknown',
|
|
94
|
+
previous_version: state.previous_version || null,
|
|
95
|
+
updated_at: state.updated_at || now,
|
|
96
|
+
migration_checkpoint: null,
|
|
97
|
+
applied_migrations: Array.isArray(state.applied_migrations) ? state.applied_migrations : [],
|
|
98
|
+
components: state.components && typeof state.components === 'object' ? state.components : {
|
|
99
|
+
agents: { version: 1, status: 'installed' },
|
|
100
|
+
skills: { version: 1, status: 'installed' },
|
|
101
|
+
plugins: { version: 1, status: 'unknown' },
|
|
102
|
+
runtime: { version: 1, status: 'unknown' },
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Normalize any state to v2 (handles v1 → v2 upgrade).
|
|
109
|
+
* @param {object} state
|
|
110
|
+
* @returns {object|null}
|
|
111
|
+
*/
|
|
112
|
+
function normalizeState(state) {
|
|
113
|
+
if (!state || typeof state !== 'object') return null
|
|
114
|
+
if (isV1State(state)) return upgradeStateV1toV2(state)
|
|
115
|
+
if (!state.schema_version || state.schema_version < 2) return upgradeStateV1toV2(state)
|
|
116
|
+
return state
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Write state to target's install-state.json (always v2 format).
|
|
121
|
+
* @param {string} target - Installation directory
|
|
122
|
+
* @param {object} state - v2 state object
|
|
123
|
+
*/
|
|
124
|
+
export function writeState(target, state) {
|
|
125
|
+
const stateDir = join(target, STATE_DIR)
|
|
126
|
+
if (!existsSync(stateDir)) {
|
|
127
|
+
mkdirSync(stateDir, { recursive: true })
|
|
128
|
+
}
|
|
129
|
+
const stateFile = join(stateDir, STATE_FILE)
|
|
130
|
+
state.updated_at = new Date().toISOString()
|
|
131
|
+
state.schema_version = 2
|
|
132
|
+
writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check whether install-state.json exists in target.
|
|
137
|
+
* @param {string} target
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
export function hasState(target) {
|
|
141
|
+
return existsSync(join(target, STATE_DIR, STATE_FILE))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Add a migration record to the applied_migrations array.
|
|
146
|
+
* @param {object} state
|
|
147
|
+
* @param {string} fromVersion
|
|
148
|
+
* @param {string} toVersion
|
|
149
|
+
* @param {string} [status='completed']
|
|
150
|
+
*/
|
|
151
|
+
export function addMigrationRecord(state, fromVersion, toVersion, status = 'completed') {
|
|
152
|
+
if (!Array.isArray(state.applied_migrations)) {
|
|
153
|
+
state.applied_migrations = []
|
|
154
|
+
}
|
|
155
|
+
state.applied_migrations.push({
|
|
156
|
+
from: fromVersion,
|
|
157
|
+
to: toVersion,
|
|
158
|
+
applied_at: new Date().toISOString(),
|
|
159
|
+
status,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Update the pantheon_version and previous_version.
|
|
165
|
+
* @param {object} state
|
|
166
|
+
* @param {string} newVersion
|
|
167
|
+
*/
|
|
168
|
+
export function updateVersion(state, newVersion) {
|
|
169
|
+
state.previous_version = state.pantheon_version || null
|
|
170
|
+
state.pantheon_version = newVersion
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get a component's state object.
|
|
175
|
+
* @param {object} state
|
|
176
|
+
* @param {string} component - Component name (agents, skills, plugins, runtime)
|
|
177
|
+
* @returns {{ version: number, status: string }|null}
|
|
178
|
+
*/
|
|
179
|
+
export function getComponentState(state, component) {
|
|
180
|
+
if (!state.components) return null
|
|
181
|
+
return state.components[component] || null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Set a component's version and status.
|
|
186
|
+
* @param {object} state
|
|
187
|
+
* @param {string} component
|
|
188
|
+
* @param {number} version
|
|
189
|
+
* @param {string} status
|
|
190
|
+
*/
|
|
191
|
+
export function setComponentState(state, component, version, status) {
|
|
192
|
+
if (!state.components) state.components = {}
|
|
193
|
+
state.components[component] = { version, status }
|
|
194
|
+
}
|