@puredesktop/create-app 1.0.0-beta.2 → 2.1.1

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.
@@ -1,55 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Scaffold an external PureScience Desktop plugin app.
3
+ * Scaffold an external PureDesktop plugin app.
4
4
  *
5
- * Published as @puredesktop/create-app npm create @puredesktop/app@latest
5
+ * Published as @puredesktop/create-app -> npm create @puredesktop/app@latest
6
6
  */
7
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
8
8
  import { dirname, join, resolve } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
 
11
- const __dirname = dirname(fileURLToPath(import.meta.url))
12
- const TEMPLATE_DIR = join(__dirname, '..', 'template')
13
- const DEFAULT_UI = '@puredesktop/puredesktop-ui-bridge'
14
- const DEFAULT_UI_VERSION = '1.0.0-beta.2'
15
-
16
- function usage() {
17
- console.log(`Usage: npm create @puredesktop/app@latest [dir] [options]
18
-
19
- Options:
20
- --name <title> Display name (default: derived from dir)
21
- --slug <slug> App slug for settings/bridge (default: dir name)
22
- --port <number> Dev server port in plugin.json (default: 5300)
23
- --ui <spec> UI package spec (default: ${DEFAULT_UI}@${DEFAULT_UI_VERSION})
24
-
25
- Examples:
26
- npm create @puredesktop/app@latest my-tool
27
- npm create @puredesktop/app@latest my-tool -- --name "My Tool" --slug mytool --port 5310
28
- `)
29
- }
30
-
31
- function uiDependencySpec(spec) {
32
- if (spec.startsWith('file:')) {
33
- return {
34
- name: '@puredesktop/puredesktop-ui-bridge',
35
- version: spec,
36
- }
37
- }
38
- const at = spec.lastIndexOf('@')
39
- if (at > 1) {
40
- return { name: spec.slice(0, at), version: spec.slice(at + 1) }
41
- }
42
- return { name: spec, version: DEFAULT_UI_VERSION }
43
- }
11
+ const __dirname = dirname(fileURLToPath(import.meta.url))
12
+ const TEMPLATE_DIR = join(__dirname, '..', 'template')
13
+
14
+ function usage() {
15
+ console.log(`Usage: npm create @puredesktop/app@latest [dir] [options]
44
16
 
45
- function parseArgs(argv) {
46
- const positional = []
47
- const options = {
48
- name: '',
49
- slug: '',
50
- port: 5300,
51
- ui: `${DEFAULT_UI}@${DEFAULT_UI_VERSION}`,
52
- }
17
+ Options:
18
+ --name <title> Display name (default: derived from dir)
19
+ --slug <slug> App slug for settings/bridge (default: dir name)
20
+ --port <number> Dev server port in plugin.json (default: 5300)
21
+ --manifest-json-base64 <base64>
22
+ Structured plugin.json manifest draft for app-builder use
23
+ --agents-md-base64 <base64>
24
+ Structured agents.md content for app-builder use
25
+
26
+ Examples:
27
+ npm create @puredesktop/app@latest my-tool
28
+ npm create @puredesktop/app@latest my-tool -- --name "My Tool" --slug mytool --port 5310
29
+ `)
30
+ }
31
+
32
+ function parseArgs(argv) {
33
+ const positional = []
34
+ const options = {
35
+ name: '',
36
+ slug: '',
37
+ port: 5300,
38
+ manifestJsonBase64: '',
39
+ agentsMdBase64: '',
40
+ }
53
41
 
54
42
  for (let i = 0; i < argv.length; i += 1) {
55
43
  const arg = argv[i]
@@ -65,14 +53,18 @@ function parseArgs(argv) {
65
53
  options.slug = argv[++i] ?? ''
66
54
  continue
67
55
  }
68
- if (arg === '--port') {
69
- options.port = Number(argv[++i])
70
- continue
71
- }
72
- if (arg === '--ui') {
73
- options.ui = argv[++i] ?? options.ui
74
- continue
75
- }
56
+ if (arg === '--port') {
57
+ options.port = Number(argv[++i])
58
+ continue
59
+ }
60
+ if (arg === '--manifest-json-base64') {
61
+ options.manifestJsonBase64 = argv[++i] ?? ''
62
+ continue
63
+ }
64
+ if (arg === '--agents-md-base64') {
65
+ options.agentsMdBase64 = argv[++i] ?? ''
66
+ continue
67
+ }
76
68
  if (arg.startsWith('-')) {
77
69
  console.error(`Unknown option: ${arg}`)
78
70
  usage()
@@ -97,15 +89,223 @@ function slugify(value) {
97
89
  .replace(/^-+|-+$/g, '')
98
90
  }
99
91
 
100
- function titleCaseFromSlug(slug) {
92
+ function titleCaseFromSlug(slug) {
101
93
  return slug
102
94
  .split('-')
103
95
  .filter(Boolean)
104
96
  .map(part => part.charAt(0).toUpperCase() + part.slice(1))
105
97
  .join(' ')
106
- }
107
-
108
- function copyTemplate(srcDir, destDir, vars) {
98
+ }
99
+
100
+ function decodeBase64Utf8(value, label) {
101
+ if (!isBase64(value)) {
102
+ console.error(`${label} must be valid base64`)
103
+ process.exit(1)
104
+ }
105
+ try {
106
+ return Buffer.from(value, 'base64').toString('utf8')
107
+ } catch (error) {
108
+ console.error(`${label} must be valid base64: ${errorMessage(error)}`)
109
+ process.exit(1)
110
+ }
111
+ }
112
+
113
+ function readStructuredManifest(value, port) {
114
+ const text = decodeBase64Utf8(value, '--manifest-json-base64')
115
+ let manifest
116
+ try {
117
+ manifest = JSON.parse(text)
118
+ } catch (error) {
119
+ console.error(`--manifest-json-base64 must decode to JSON: ${errorMessage(error)}`)
120
+ process.exit(1)
121
+ }
122
+ return normalizeStructuredManifest(manifest, port)
123
+ }
124
+
125
+ function normalizeStructuredManifest(manifest, port) {
126
+ if (!isPlainObject(manifest)) {
127
+ console.error('--manifest-json-base64 must decode to a manifest object')
128
+ process.exit(1)
129
+ }
130
+ if (manifest.schemaVersion !== 1) {
131
+ console.error('manifest.schemaVersion must be 1')
132
+ process.exit(1)
133
+ }
134
+ const id = requiredString(manifest.id, 'manifest.id')
135
+ const name = requiredString(manifest.name, 'manifest.name')
136
+ if (!isPlainObject(manifest.app)) {
137
+ console.error('manifest.app is required')
138
+ process.exit(1)
139
+ }
140
+ const appSlug = requiredString(manifest.app.slug, 'manifest.app.slug')
141
+ const appName = requiredString(manifest.app.name, 'manifest.app.name')
142
+ if (slugify(appSlug) !== appSlug) {
143
+ console.error('manifest.app.slug must use lowercase letters, numbers, and hyphens')
144
+ process.exit(1)
145
+ }
146
+
147
+ return stripUndefinedProperties({
148
+ ...manifest,
149
+ id,
150
+ name,
151
+ entrypoint: {
152
+ kind: 'dev-url',
153
+ url: `http://localhost:${port}`,
154
+ },
155
+ app: stripUndefinedProperties({
156
+ ...manifest.app,
157
+ slug: appSlug,
158
+ name: appName,
159
+ entrypoint: undefined,
160
+ }),
161
+ })
162
+ }
163
+
164
+ function readStructuredAgentsMd(value) {
165
+ const text = decodeBase64Utf8(value, '--agents-md-base64')
166
+ if (!text.trim()) {
167
+ console.error('--agents-md-base64 must decode to non-empty agents.md content')
168
+ process.exit(1)
169
+ }
170
+ return text.endsWith('\n') ? text : `${text}\n`
171
+ }
172
+
173
+ function requiredString(value, label) {
174
+ const text = typeof value === 'string' ? value.trim() : ''
175
+ if (!text) {
176
+ console.error(`${label} is required`)
177
+ process.exit(1)
178
+ }
179
+ return text
180
+ }
181
+
182
+ function isPlainObject(value) {
183
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
184
+ }
185
+
186
+ function isBase64(value) {
187
+ if (typeof value !== 'string' || !value.trim()) return false
188
+ const normalized = value.trim()
189
+ if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(normalized)) return false
190
+ if (normalized.length % 4 !== 0) return false
191
+ return Buffer.from(normalized, 'base64').toString('base64') === normalized
192
+ }
193
+
194
+ function stripUndefinedProperties(value) {
195
+ return Object.fromEntries(
196
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
197
+ )
198
+ }
199
+
200
+ function errorMessage(error) {
201
+ return error instanceof Error ? error.message : String(error)
202
+ }
203
+
204
+ function readGeneratedManifest(outDir) {
205
+ try {
206
+ return JSON.parse(readFileSync(join(outDir, 'plugin.json'), 'utf8'))
207
+ } catch (error) {
208
+ console.error(`Generated plugin.json could not be read: ${errorMessage(error)}`)
209
+ process.exit(1)
210
+ }
211
+ }
212
+
213
+ function readAgentToolNames(manifest) {
214
+ const tools = manifest?.app?.agents?.tools
215
+ if (!Array.isArray(tools)) return []
216
+ return tools
217
+ .map(tool => (typeof tool?.name === 'string' ? tool.name.trim() : ''))
218
+ .filter(Boolean)
219
+ }
220
+
221
+ function jsonString(value) {
222
+ return JSON.stringify(value)
223
+ }
224
+
225
+ function writeAgentToolScaffold(outDir, appSlug, toolNames) {
226
+ if (toolNames.length === 0) return
227
+
228
+ const agentsDir = join(outDir, 'src', 'agents')
229
+ const handlersDir = join(agentsDir, 'handlers')
230
+ const hooksDir = join(outDir, 'src', 'hooks')
231
+ mkdirSync(handlersDir, { recursive: true })
232
+ mkdirSync(hooksDir, { recursive: true })
233
+
234
+ writeFileSync(
235
+ join(agentsDir, 'catalog.ts'),
236
+ `export const APP_AGENT_TOOL_NAMES = ${JSON.stringify(toolNames, null, 2)} as const
237
+
238
+ export const APP_AGENT_LOG_LABEL = ${jsonString(appSlug)}
239
+ `,
240
+ )
241
+
242
+ const handlerEntries = toolNames
243
+ .map(
244
+ name =>
245
+ ` ${jsonString(name)}: async () => notImplemented(${jsonString(name)}),`,
246
+ )
247
+ .join('\n')
248
+
249
+ writeFileSync(
250
+ join(handlersDir, 'index.ts'),
251
+ `import { agentToolErrorContent } from '@puredesktop/puredesktop-ui-bridge/bridge/agentToolHelpers'
252
+ import type { AgentToolHandler } from '@puredesktop/puredesktop-ui-bridge/bridge/react/usePlatformAgentTools'
253
+
254
+ function notImplemented(toolName: string) {
255
+ return agentToolErrorContent(
256
+ \`Tool "\${toolName}" is declared in plugin.json but its runtime handler has not been implemented yet.\`,
257
+ )
258
+ }
259
+
260
+ export const appAgentHandlers = {
261
+ ${handlerEntries}
262
+ } satisfies Record<string, AgentToolHandler>
263
+ `,
264
+ )
265
+
266
+ writeFileSync(
267
+ join(hooksDir, 'useAppAgentTools.ts'),
268
+ `import { usePlatformAgentTools } from '@puredesktop/puredesktop-ui-bridge/bridge/react/usePlatformAgentTools'
269
+ import { APP_AGENT_LOG_LABEL, APP_AGENT_TOOL_NAMES } from '../agents/catalog'
270
+ import { appAgentHandlers } from '../agents/handlers'
271
+
272
+ export function useAppAgentTools(ready: boolean): void {
273
+ usePlatformAgentTools({
274
+ ready,
275
+ tools: APP_AGENT_TOOL_NAMES,
276
+ logLabel: APP_AGENT_LOG_LABEL,
277
+ handlers: appAgentHandlers,
278
+ })
279
+ }
280
+ `,
281
+ )
282
+
283
+ wireAgentToolHook(outDir)
284
+ }
285
+
286
+ function wireAgentToolHook(outDir) {
287
+ const appPath = join(outDir, 'src', 'App.tsx')
288
+ const source = readFileSync(appPath, 'utf8')
289
+ const importPattern = /import \{ useAppBoot \} from '\.\/hooks\/useAppBoot'\r?\n/
290
+ const callPattern = /( const \{ error: bridgeError, ready \} = usePlatformBridge\(\)\r?\n)/
291
+
292
+ if (!importPattern.test(source) || !callPattern.test(source)) {
293
+ console.error('Generated src/App.tsx does not match the agent tool scaffold insertion points.')
294
+ process.exit(1)
295
+ }
296
+
297
+ const withImport = source.replace(
298
+ importPattern,
299
+ match => `${match}import { useAppAgentTools } from './hooks/useAppAgentTools'\n`,
300
+ )
301
+ const withHook = withImport.replace(
302
+ callPattern,
303
+ (_match, line) => `${line} useAppAgentTools(ready)\n`,
304
+ )
305
+ writeFileSync(appPath, withHook)
306
+ }
307
+
308
+ function copyTemplate(srcDir, destDir, vars) {
109
309
  for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
110
310
  const srcPath = join(srcDir, entry.name)
111
311
  const destName = entry.name.replace(/\.tpl$/u, '')
@@ -119,15 +319,30 @@ function copyTemplate(srcDir, destDir, vars) {
119
319
  const rendered = raw.replace(/\{\{(\w+)\}\}/g, (_match, key) => vars[key] ?? '')
120
320
  writeFileSync(destPath, rendered)
121
321
  }
122
- }
123
-
124
- const { targetDir, options } = parseArgs(process.argv.slice(2))
125
- const uiDep = uiDependencySpec(options.ui)
126
- const dirName = targetDir
127
- const slug = options.slug || slugify(dirName) || 'my-app'
128
- const title = options.name || titleCaseFromSlug(slug) || 'My App'
129
- const pluginId = slug
130
- const outDir = resolve(process.cwd(), dirName)
322
+ }
323
+
324
+ const { targetDir, options } = parseArgs(process.argv.slice(2))
325
+ const structuredManifest = options.manifestJsonBase64
326
+ ? readStructuredManifest(options.manifestJsonBase64, options.port)
327
+ : null
328
+ const structuredAgentsMd = options.agentsMdBase64
329
+ ? readStructuredAgentsMd(options.agentsMdBase64)
330
+ : null
331
+ const dirName = targetDir
332
+ const structuredSlug = structuredManifest?.app.slug ?? ''
333
+ const structuredTitle = structuredManifest?.app.name ?? ''
334
+ if (structuredManifest && options.slug && options.slug !== structuredSlug) {
335
+ console.error('--slug must match manifest.app.slug when --manifest-json-base64 is used')
336
+ process.exit(1)
337
+ }
338
+ if (structuredManifest && options.name && options.name !== structuredTitle) {
339
+ console.error('--name must match manifest.app.name when --manifest-json-base64 is used')
340
+ process.exit(1)
341
+ }
342
+ const slug = structuredSlug || options.slug || slugify(dirName) || 'my-app'
343
+ const title = structuredTitle || options.name || titleCaseFromSlug(slug) || 'My App'
344
+ const pluginId = structuredManifest?.id ?? slug
345
+ const outDir = resolve(process.cwd(), dirName)
131
346
 
132
347
  if (existsSync(outDir)) {
133
348
  console.error(`Target already exists: ${outDir}`)
@@ -139,18 +354,34 @@ mkdirSync(outDir, { recursive: true })
139
354
  const vars = {
140
355
  APP_TITLE: title,
141
356
  APP_SLUG: slug,
142
- APP_NAME: slug,
143
- PLUGIN_ID: pluginId,
144
- APP_PORT: String(options.port),
145
- UI_PACKAGE_NAME: uiDep.name,
146
- UI_PACKAGE_VERSION: uiDep.version,
147
- PACKAGE_NAME: `@puredesktop/${slug}`,
148
- }
357
+ APP_NAME: slug,
358
+ PLUGIN_ID: pluginId,
359
+ APP_PORT: String(options.port),
360
+ PACKAGE_NAME: `@puredesktop/${slug}`,
361
+ }
149
362
 
150
- copyTemplate(TEMPLATE_DIR, outDir, vars)
151
-
152
- console.log(`
153
- Created ${title} at ${outDir}
363
+ copyTemplate(TEMPLATE_DIR, outDir, vars)
364
+
365
+ if (structuredManifest) {
366
+ writeFileSync(
367
+ join(outDir, 'plugin.json'),
368
+ `${JSON.stringify(structuredManifest, null, 2)}\n`,
369
+ )
370
+ }
371
+
372
+ if (structuredAgentsMd) {
373
+ writeFileSync(join(outDir, 'agents.md'), structuredAgentsMd)
374
+ }
375
+
376
+ const generatedManifest = readGeneratedManifest(outDir)
377
+ writeAgentToolScaffold(
378
+ outDir,
379
+ generatedManifest?.app?.slug ?? slug,
380
+ readAgentToolNames(generatedManifest),
381
+ )
382
+
383
+ console.log(`
384
+ Created ${title} at ${outDir}
154
385
 
155
386
  cd ${dirName}
156
387
  npm install
@@ -161,7 +392,7 @@ Before shipping:
161
392
  npm run build
162
393
  npm run puredesktop:check
163
394
 
164
- Register in PureScience Desktop (File Register App) with:
395
+ Register in PureDesktop (File -> Register App...) with:
165
396
  entrypoint: http://localhost:${options.port}
166
397
  permissions: network, settings, agents
167
398
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@puredesktop/create-app",
3
- "version": "1.0.0-beta.2",
4
- "description": "Scaffold a PureScience Desktop plugin app (Vite + bridge SDK)",
3
+ "version": "2.1.1",
4
+ "description": "Scaffold a PureDesktop plugin app (Vite + bridge SDK)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-app": "./bin/create-puredesktop-app.mjs",