@bibobonking/opensol 0.0.0-stage → 3.0.2

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.
Files changed (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { open, readFile, stat, unlink } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { DATA_DIR, ensureDataDir } from './lib/store.mjs'
7
+ import { daemonStatus, startServer, stopDaemon } from './daemon.mjs'
8
+
9
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
10
+ const DAEMON = path.join(HERE, 'daemon.mjs')
11
+ const LOG_FILE = path.join(DATA_DIR, 'daemon.log')
12
+ const PID_FILE = path.join(DATA_DIR, 'daemon.json')
13
+
14
+ const HELP = `OpenSol — local coding agent daemon
15
+
16
+ Usage:
17
+ opensol start [--port=4174] [--open] [--foreground]
18
+ opensol stop
19
+ opensol status
20
+ opensol open
21
+ opensol logs
22
+
23
+ The daemon serves the built app from dist/ and keeps your settings, chats and
24
+ encrypted provider keys in ${DATA_DIR}. It listens on 127.0.0.1 only.
25
+ `
26
+
27
+ function parseFlags(args) {
28
+ const flags = { port: 4174, open: false, foreground: false }
29
+ for (const arg of args) {
30
+ if (arg === '--open') flags.open = true
31
+ else if (arg === '--foreground') flags.foreground = true
32
+ else if (arg.startsWith('--port=')) {
33
+ const value = Number(arg.slice('--port='.length))
34
+ if (!Number.isInteger(value) || value < 0 || value > 65535) {
35
+ throw new Error('Use --port=<1-65535>.')
36
+ }
37
+ flags.port = value
38
+ } else if (arg === '--help' || arg === '-h') {
39
+ flags.help = true
40
+ } else {
41
+ throw new Error(`Unknown option: ${arg}`)
42
+ }
43
+ }
44
+ return flags
45
+ }
46
+
47
+ function openerFor(url) {
48
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
49
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
50
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' })
51
+ child.on('error', () => console.log(`Open ${url} in your browser.`))
52
+ child.unref()
53
+ }
54
+
55
+ async function tailLog(lines = 40) {
56
+ const info = await stat(LOG_FILE).catch(() => null)
57
+ if (!info) {
58
+ console.log('No daemon log yet.')
59
+ return
60
+ }
61
+ const raw = await readFile(LOG_FILE, 'utf8')
62
+ const slice = raw.trimEnd().split('\n').slice(-lines)
63
+ console.log(slice.join('\n'))
64
+ }
65
+
66
+ async function rotateLog() {
67
+ const info = await stat(LOG_FILE).catch(() => null)
68
+ if (info && info.size > 5 * 1024 * 1024) {
69
+ const previous = `${LOG_FILE}.1`
70
+ await unlink(previous).catch(() => undefined)
71
+ const { rename } = await import('node:fs/promises')
72
+ await rename(LOG_FILE, previous).catch(() => undefined)
73
+ }
74
+ }
75
+
76
+ async function waitForDaemon(pid, timeoutMs = 15_000) {
77
+ const started = Date.now()
78
+ while (Date.now() - started < timeoutMs) {
79
+ const status = await daemonStatus()
80
+ if (status.running) return status
81
+ if (pid) {
82
+ try {
83
+ process.kill(pid, 0)
84
+ } catch {
85
+ return { running: false, info: null }
86
+ }
87
+ }
88
+ await new Promise((resolve) => setTimeout(resolve, 250))
89
+ }
90
+ return { running: false, info: null }
91
+ }
92
+
93
+ async function commandStart(args) {
94
+ const flags = parseFlags(args)
95
+ if (flags.help) {
96
+ console.log(HELP)
97
+ return
98
+ }
99
+ await ensureDataDir()
100
+ const existing = await daemonStatus()
101
+ if (existing.running) {
102
+ console.log(`OpenSol is already running at ${existing.info.url}`)
103
+ if (flags.open) openerFor(existing.info.url)
104
+ return
105
+ }
106
+ await unlink(PID_FILE).catch(() => undefined)
107
+
108
+ if (flags.foreground) {
109
+ await startServer({ port: flags.port, open: flags.open })
110
+ return
111
+ }
112
+
113
+ await rotateLog()
114
+ const handle = await open(LOG_FILE, 'a', 0o600)
115
+ const child = spawn(process.execPath, [DAEMON, `--port=${flags.port}`], {
116
+ detached: true,
117
+ stdio: ['ignore', handle.fd, handle.fd],
118
+ cwd: path.resolve(HERE, '..'),
119
+ env: { ...process.env, OPENSOL_DATA: DATA_DIR, MAXCODE_DATA: DATA_DIR },
120
+ })
121
+ child.unref()
122
+ await handle.close()
123
+
124
+ const status = await waitForDaemon(child.pid)
125
+ if (!status.running) {
126
+ console.error('OpenSol did not start. Recent log:')
127
+ await tailLog(20)
128
+ process.exitCode = 1
129
+ return
130
+ }
131
+ console.log(`OpenSol is running at ${status.info.url}`)
132
+ console.log(`Data: ${DATA_DIR}`)
133
+ console.log(`Log: ${LOG_FILE}`)
134
+ if (flags.open) openerFor(status.info.url)
135
+ }
136
+
137
+ async function commandStatus() {
138
+ const status = await daemonStatus()
139
+ if (!status.running) {
140
+ console.log('OpenSol is not running.')
141
+ return
142
+ }
143
+ console.log(`OpenSol is running at ${status.info.url}`)
144
+ console.log(`PID: ${status.info.pid}`)
145
+ console.log(`Data: ${status.info.dataDir ?? DATA_DIR}`)
146
+ console.log(`Dist ready: ${status.info.dist ? 'yes' : 'no'}`)
147
+ }
148
+
149
+ async function commandOpen() {
150
+ const status = await daemonStatus()
151
+ if (!status.running) {
152
+ console.error('OpenSol is not running. Start it with: opensol start')
153
+ process.exitCode = 1
154
+ return
155
+ }
156
+ openerFor(status.info.url)
157
+ console.log(`Opened ${status.info.url}`)
158
+ }
159
+
160
+ async function commandStop() {
161
+ const result = await stopDaemon()
162
+ if (!result.stopped) {
163
+ console.log(result.reason ?? 'OpenSol is not running.')
164
+ return
165
+ }
166
+ console.log(`Stopped OpenSol${result.pid ? ` (pid ${result.pid})` : ''}.`)
167
+ }
168
+
169
+ async function main() {
170
+ const [command = 'help', ...args] = process.argv.slice(2)
171
+ switch (command) {
172
+ case 'start':
173
+ await commandStart(args)
174
+ break
175
+ case 'stop':
176
+ await commandStop()
177
+ break
178
+ case 'status':
179
+ await commandStatus()
180
+ break
181
+ case 'open':
182
+ await commandOpen()
183
+ break
184
+ case 'logs':
185
+ await tailLog()
186
+ break
187
+ case 'help':
188
+ case '--help':
189
+ case '-h':
190
+ console.log(HELP)
191
+ break
192
+ default:
193
+ console.error(`Unknown command: ${command}\n`)
194
+ console.log(HELP)
195
+ process.exitCode = 1
196
+ }
197
+ }
198
+
199
+ main().catch((error) => {
200
+ console.error(error instanceof Error ? error.message : error)
201
+ process.exitCode = 1
202
+ })
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "maxcode",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "bin": {
7
+ "maxcode": "./server/maxcode.mjs"
8
+ },
9
+ "scripts": {
10
+ "dev": "node server/maxcode.mjs start && vite",
11
+ "build": "tsc -b && vite build",
12
+ "preview": "vite preview",
13
+ "typecheck": "tsc -b --pretty",
14
+ "start": "node server/maxcode.mjs start --open",
15
+ "daemon": "node server/maxcode.mjs start",
16
+ "daemon:foreground": "node server/daemon.mjs",
17
+ "daemon:stop": "node server/maxcode.mjs stop",
18
+ "daemon:status": "node server/maxcode.mjs status",
19
+ "daemon:logs": "node server/maxcode.mjs logs"
20
+ },
21
+ "dependencies": {
22
+ "clsx": "^2.1.1",
23
+ "highlight.js": "^11.11.1",
24
+ "lucide-react": "^0.544.0",
25
+ "react": "^19.1.1",
26
+ "react-dom": "^19.1.1",
27
+ "react-markdown": "^10.1.0",
28
+ "rehype-highlight": "^7.0.2",
29
+ "remark-gfm": "^4.0.1",
30
+ "zustand": "^5.0.8"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^19.1.10",
34
+ "@types/react-dom": "^19.1.7",
35
+ "@vitejs/plugin-react": "^5.0.0",
36
+ "autoprefixer": "^10.4.21",
37
+ "postcss": "^8.5.6",
38
+ "tailwindcss": "^3.4.19",
39
+ "typescript": "^5.9.2",
40
+ "vite": "^7.1.3"
41
+ }
42
+ }
@@ -0,0 +1,43 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildPluginArchive, readPluginArchive, validatePluginManifest } from './plugin-package.mjs'
4
+
5
+ const manifest = {
6
+ id: 'demo-plugin',
7
+ name: 'Demo plugin',
8
+ version: '1.0.0',
9
+ apiVersion: 1,
10
+ entry: 'main.js',
11
+ stylesheet: 'main.css',
12
+ capabilities: ['ui.commands'],
13
+ domains: [],
14
+ }
15
+
16
+ test('a built package reopens with its sources and assets intact', () => {
17
+ const archive = buildPluginArchive({
18
+ manifest,
19
+ files: { 'main.js': 'maxcode.log("hi")', 'main.css': '.a{}', 'assets/icon.txt': 'icon' },
20
+ })
21
+ const reopened = readPluginArchive(archive)
22
+ assert.equal(reopened.manifest.id, 'demo-plugin')
23
+ assert.equal(reopened.files['main.js'], 'maxcode.log("hi")')
24
+ assert.equal(reopened.files['main.css'], '.a{}')
25
+ assert.equal(reopened.files['assets/icon.txt'], 'icon')
26
+ })
27
+
28
+ test('a manifest may omit domains entirely — a plugin with no network declares none', () => {
29
+ const { domains, ...withoutDomains } = manifest
30
+ assert.deepEqual(validatePluginManifest(withoutDomains).domains, [])
31
+ assert.deepEqual(readPluginArchive(buildPluginArchive({
32
+ manifest: withoutDomains,
33
+ files: { 'main.js': 'maxcode.log("hi")', 'main.css': '' },
34
+ })).manifest.domains, [])
35
+ assert.throws(() => validatePluginManifest({ ...manifest, domains: 'api.example.com' }), /domains must be an array/)
36
+ })
37
+
38
+ test('the builder rejects an invalid manifest before packaging', () => {
39
+ assert.throws(() => validatePluginManifest({ ...manifest, id: 'Bad Id' }), /Invalid plugin package/)
40
+ assert.throws(() => validatePluginManifest({ ...manifest, entry: 'main.ts' }), /entry must be JavaScript/)
41
+ assert.throws(() => validatePluginManifest({ ...manifest, capabilities: ['shell.exec'] }), /capabilit/i)
42
+ assert.throws(() => validatePluginManifest({ ...manifest, domains: ['https://api.example.com'] }), /domain/i)
43
+ })
@@ -0,0 +1,34 @@
1
+ export type PluginCapability =
2
+ | 'workspace.list'
3
+ | 'workspace.read'
4
+ | 'workspace.write'
5
+ | 'workspace.edit'
6
+ | 'ui.commands'
7
+ | 'ui.panels'
8
+ | 'ui.themes'
9
+ | 'network.request'
10
+
11
+ export interface PluginManifest {
12
+ id: string
13
+ name: string
14
+ version: string
15
+ apiVersion: 1 | 2
16
+ entry: string
17
+ stylesheet: string
18
+ capabilities: PluginCapability[]
19
+ domains: string[]
20
+ }
21
+
22
+ export interface PluginPackageSources {
23
+ manifest: PluginManifest
24
+ files: Record<string, string | Uint8Array>
25
+ }
26
+
27
+ export interface PluginPackage {
28
+ manifest: PluginManifest
29
+ files: Record<string, string>
30
+ }
31
+
32
+ export function validatePluginManifest(value: unknown): PluginManifest
33
+ export function buildPluginArchive(sources: PluginPackageSources): Uint8Array
34
+ export function readPluginArchive(bytes: Uint8Array): PluginPackage
@@ -0,0 +1,197 @@
1
+ const MAX_ARCHIVE_BYTES = 5 * 1024 * 1024
2
+ const MAX_PACKAGE_BYTES = 10 * 1024 * 1024
3
+ const MAX_FILES = 100
4
+ const MAX_MANIFEST_BYTES = 64 * 1024
5
+ const CAPABILITIES = new Set([
6
+ 'workspace.list',
7
+ 'workspace.read',
8
+ 'workspace.write',
9
+ 'workspace.edit',
10
+ 'ui.commands',
11
+ 'ui.panels',
12
+ 'ui.themes',
13
+ 'network.request',
14
+ ])
15
+ const MANIFEST_KEYS = new Set([
16
+ 'id',
17
+ 'name',
18
+ 'version',
19
+ 'apiVersion',
20
+ 'entry',
21
+ 'stylesheet',
22
+ 'capabilities',
23
+ 'domains',
24
+ ])
25
+
26
+ function packageError(message) {
27
+ throw new Error(`Invalid plugin package: ${message}`)
28
+ }
29
+
30
+ function bytesToString(bytes) {
31
+ if (typeof bytes === 'string') return bytes
32
+ return new TextDecoder('utf-8').decode(bytes)
33
+ }
34
+
35
+ function stringToBytes(str) {
36
+ return new TextEncoder().encode(str)
37
+ }
38
+
39
+ function validateSource(path, content, apiVersion) {
40
+ // API v2: skip validation — full kernel access, blob isolation is enough
41
+ if (apiVersion === 2) return
42
+
43
+ const source = bytesToString(content)
44
+ const rule = path.endsWith('.js')
45
+ ? [
46
+ /\b(?:import|export)\b|\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|importScripts)\s*\(|\bnavigator\s*\.\s*sendBeacon\s*\(/i,
47
+ 'module imports and unmanaged network calls are forbidden',
48
+ ]
49
+ : path.endsWith('.css')
50
+ ? [
51
+ /@import\b|url\s*\(\s*(['"]?)(?!data:image\/(?:png|jpeg|gif|webp);base64,)[^)]*\)/i,
52
+ 'external CSS resources and imports are forbidden',
53
+ ]
54
+ : null
55
+ if (!rule) return
56
+ const found = rule[0].exec(source)
57
+ if (!found) return
58
+ const line = source.slice(0, found.index).split('\n').length
59
+ const hit = found[0].length > 60 ? `${found[0].slice(0, 60)}…` : found[0]
60
+ packageError(`${rule[1]}: found "${hit}" at ${path}:${line}`)
61
+ }
62
+
63
+ function safePath(value) {
64
+ if (
65
+ typeof value !== 'string' ||
66
+ !value ||
67
+ value.includes('\0') ||
68
+ value.includes('\\') ||
69
+ value.startsWith('/') ||
70
+ /^[a-zA-Z]:/.test(value) ||
71
+ value.split('/').some((part) => !part || part === '.' || part === '..')
72
+ ) {
73
+ packageError(`unsafe path "${String(value)}"`)
74
+ }
75
+ return value
76
+ }
77
+
78
+ function validateDomain(value) {
79
+ if (typeof value !== 'string' || value.length > 253 || value.endsWith('.')) {
80
+ packageError('invalid domain')
81
+ }
82
+ const labels = value.split('.')
83
+ if (
84
+ labels.length < 2 ||
85
+ labels.some((label) => !/^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))
86
+ ) {
87
+ packageError(`invalid domain "${value}"`)
88
+ }
89
+ return value.toLowerCase()
90
+ }
91
+
92
+ export function validatePluginManifest(value) {
93
+ if (!value || typeof value !== 'object' || Array.isArray(value)) packageError('manifest must be an object')
94
+ if (Object.keys(value).some((key) => !MANIFEST_KEYS.has(key))) packageError('unknown manifest field')
95
+ const { id, name, version, apiVersion, entry, stylesheet, capabilities, domains } = value
96
+ if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{1,63}$/.test(id)) packageError('invalid plugin id')
97
+ if (typeof name !== 'string' || !name.trim() || name.length > 100) packageError('invalid plugin name')
98
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
99
+ packageError('invalid plugin version')
100
+ }
101
+ if (apiVersion !== 1 && apiVersion !== 2) packageError('unsupported API version')
102
+ safePath(entry)
103
+ safePath(stylesheet)
104
+ if (!entry.endsWith('.js') || !stylesheet.endsWith('.css')) packageError('entry must be JavaScript and stylesheet must be CSS')
105
+
106
+ // API v2: capabilities optional (defaults to [])
107
+ const normalizedCapabilities = capabilities ?? []
108
+ if (!Array.isArray(normalizedCapabilities)) packageError('capabilities must be an array')
109
+ if (apiVersion === 1 && normalizedCapabilities.some((item) => !CAPABILITIES.has(item))) {
110
+ packageError('invalid capability')
111
+ }
112
+
113
+ if (domains !== undefined && !Array.isArray(domains)) packageError('domains must be an array')
114
+ const normalizedDomains = (domains ?? []).map(validateDomain)
115
+ if (new Set(normalizedDomains).size !== normalizedDomains.length) packageError('duplicate domain')
116
+ return {
117
+ id,
118
+ name: name.trim(),
119
+ version,
120
+ apiVersion,
121
+ entry,
122
+ stylesheet,
123
+ capabilities: [...new Set(normalizedCapabilities)],
124
+ domains: normalizedDomains,
125
+ }
126
+ }
127
+
128
+ function normalizeFiles(files, manifest) {
129
+ if (!files || typeof files !== 'object' || Array.isArray(files)) packageError('files must be a path-to-source object')
130
+ const paths = Object.keys(files)
131
+ if (paths.length > MAX_FILES) packageError('too many files')
132
+ const normalized = {}
133
+ let total = 0
134
+ for (const rawPath of paths) {
135
+ const filePath = safePath(rawPath)
136
+ if (filePath === 'manifest.json') packageError('manifest is generated from the manifest field')
137
+ if (/\.(?:mjs|cjs|jsx|ts|tsx)$/i.test(filePath) && filePath !== manifest.entry) {
138
+ packageError(`undeclared executable file "${filePath}"`)
139
+ }
140
+ if (filePath !== manifest.entry && filePath !== manifest.stylesheet && /\.(?:js|css)$/i.test(filePath)) {
141
+ packageError(`undeclared executable file "${filePath}"`)
142
+ }
143
+ const content = files[rawPath]
144
+ if (typeof content !== 'string' && !(content instanceof Uint8Array)) {
145
+ packageError(`file "${filePath}" must be text or bytes`)
146
+ }
147
+ const data = typeof content === 'string' ? stringToBytes(content) : content
148
+ total += data.byteLength
149
+ if (total > MAX_PACKAGE_BYTES) packageError('package is too large')
150
+ if (filePath === manifest.entry || filePath === manifest.stylesheet) validateSource(filePath, data, manifest.apiVersion)
151
+ normalized[filePath] = typeof content === 'string' ? content : bytesToString(data)
152
+ }
153
+ if (!(manifest.entry in normalized)) packageError('entry file is missing')
154
+ if (!(manifest.stylesheet in normalized)) packageError('stylesheet file is missing')
155
+ if (stringToBytes(normalized[manifest.entry]).byteLength > 2 * 1024 * 1024) packageError('entry file is too large')
156
+ return normalized
157
+ }
158
+
159
+ export function buildPluginArchive(sources) {
160
+ const manifest = validatePluginManifest(sources?.manifest)
161
+ const files = normalizeFiles(sources?.files, manifest)
162
+ const manifestJson = JSON.stringify(manifest)
163
+ if (stringToBytes(manifestJson).byteLength > MAX_MANIFEST_BYTES) packageError('manifest is too large')
164
+
165
+ const packageData = {
166
+ format: 'cordis-plugin',
167
+ manifest,
168
+ files,
169
+ }
170
+
171
+ const encoded = stringToBytes(JSON.stringify(packageData))
172
+ if (encoded.byteLength > MAX_ARCHIVE_BYTES) packageError('package is too large')
173
+ return encoded
174
+ }
175
+
176
+ export function readPluginArchive(bytes) {
177
+ if (!bytes || !(bytes instanceof Uint8Array) || bytes.byteLength === 0) {
178
+ packageError('package archive is empty or invalid')
179
+ }
180
+ let parsed
181
+ try {
182
+ const text = new TextDecoder('utf-8').decode(bytes)
183
+ parsed = JSON.parse(text)
184
+ } catch {
185
+ packageError('package is not a valid JSON archive')
186
+ }
187
+
188
+ if (!parsed || typeof parsed !== 'object') packageError('invalid package format')
189
+ const manifest = validatePluginManifest(parsed.manifest)
190
+ const files = parsed.files
191
+ if (!files || typeof files !== 'object') packageError('missing package files')
192
+
193
+ return {
194
+ manifest,
195
+ files,
196
+ }
197
+ }
@@ -0,0 +1,94 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildPluginArchive, readPluginArchive } from './plugin-package.mjs'
4
+
5
+ const source = {
6
+ manifest: {
7
+ id: 'sample-plugin',
8
+ name: 'Sample Plugin',
9
+ version: '1.0.0',
10
+ apiVersion: 1,
11
+ entry: 'plugin.js',
12
+ stylesheet: 'plugin.css',
13
+ capabilities: ['workspace.read'],
14
+ domains: ['api.example.test'],
15
+ },
16
+ files: {
17
+ 'plugin.js': 'api.registerCommand("sample", () => "ok")',
18
+ 'plugin.css': '.sample { color: red }',
19
+ 'icon.svg': '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
20
+ },
21
+ }
22
+
23
+ test('plugin archive round-trips manifest and local source files', () => {
24
+ const archive = buildPluginArchive(source)
25
+ const result = readPluginArchive(archive)
26
+ assert.deepEqual(result.manifest, source.manifest)
27
+ assert.deepEqual(result.files, source.files)
28
+ })
29
+
30
+ test('plugin archive rejects traversal paths before packaging', () => {
31
+ assert.throws(
32
+ () => buildPluginArchive({ ...source, files: { ...source.files, '../outside.js': 'bad' } }),
33
+ /path/i,
34
+ )
35
+ })
36
+
37
+ test('plugin archive rejects a manifest whose entry is missing', () => {
38
+ assert.throws(() => buildPluginArchive({ ...source, files: { 'plugin.css': 'x' } }), /entry/i)
39
+ })
40
+
41
+ test('plugin archive rejects undeclared executable source', () => {
42
+ assert.throws(
43
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'extra.js': 'alert(1)' } }),
44
+ /executable/i,
45
+ )
46
+ })
47
+
48
+ test('plugin archive rejects remote code declarations and invalid domains', () => {
49
+ assert.throws(
50
+ () => buildPluginArchive({ ...source, manifest: { ...source.manifest, entry: 'https://evil.test/p.js' } }),
51
+ /entry|path/i,
52
+ )
53
+ assert.throws(
54
+ () => buildPluginArchive({ ...source, manifest: { ...source.manifest, domains: ['https://example.test'] } }),
55
+ /domain/i,
56
+ )
57
+ })
58
+
59
+ test('plugin archive rejects unmanaged network APIs and external CSS resources', () => {
60
+ assert.throws(
61
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.js': 'fetch("https://evil.test")' } }),
62
+ /network|remote/i,
63
+ )
64
+ assert.throws(
65
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.css': '@import url("https://evil.test/style.css")' } }),
66
+ /network|remote|external/i,
67
+ )
68
+ })
69
+
70
+ test('a rejected source names the file, the line and the text that matched', () => {
71
+ // The pattern is text-only, so a comment or a string trips it too — the message
72
+ // has to point at the match or the author cannot find it.
73
+ assert.throws(
74
+ () =>
75
+ buildPluginArchive({
76
+ ...source,
77
+ files: { ...source.files, 'plugin.js': 'maxcode.log("hi")\n// export the palette\nconst x = 1' },
78
+ }),
79
+ /found "export" at plugin\.js:2/,
80
+ )
81
+ assert.throws(() => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.js': 'fetch("https://evil.test")' } }), /"fetch\("/)
82
+ assert.throws(
83
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.css': '.a{}\n@import url("https://evil.test/x.css")' } }),
84
+ /plugin\.css:2/,
85
+ )
86
+ })
87
+
88
+ test('plugin archive rejects malformed or oversized archives', () => {
89
+ assert.throws(() => readPluginArchive(new Uint8Array([0, 1, 2, 3])), /archive|zip|package/i)
90
+ assert.throws(
91
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.js': 'x'.repeat(10_485_761) } }),
92
+ /size|large/i,
93
+ )
94
+ })