openpets 1.0.4 → 1.0.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.
@@ -44,6 +44,27 @@ export abstract class BaseAIClient {
44
44
 
45
45
  abstract get providerName(): string
46
46
 
47
+ protected log(message: string, level: "info" | "warn" | "error" | "debug" = "info"): void {
48
+ if (!this.debug && level === "debug") return
49
+
50
+ const timestamp = new Date().toISOString()
51
+ const prefix = `[${this.providerName.toUpperCase()}] ${timestamp}`
52
+
53
+ switch (level) {
54
+ case "error":
55
+ console.error(`${prefix} ERROR:`, message)
56
+ break
57
+ case "warn":
58
+ console.warn(`${prefix} WARN:`, message)
59
+ break
60
+ case "debug":
61
+ console.debug(`${prefix} DEBUG:`, message)
62
+ break
63
+ default:
64
+ console.log(`${prefix} INFO:`, message)
65
+ }
66
+ }
67
+
47
68
  protected ensureApiKey(envVarName: string): void {
48
69
  if (!this.apiKey || this.apiKey.trim() === "") {
49
70
  throw new Error(`${envVarName} environment variable is required but not set`)
@@ -114,4 +135,95 @@ export abstract class BaseAIClient {
114
135
  }
115
136
  return mimeTypes[ext] || 'application/octet-stream'
116
137
  }
138
+
139
+ protected async downloadAndOpen<T extends AIImageResult | AIVideoResult>(
140
+ result: T,
141
+ mediaType: "image" | "video",
142
+ fileExtension: string,
143
+ openInViewer: boolean = true
144
+ ): Promise<T & { localPath?: string }> {
145
+ if (!result.success) {
146
+ return result
147
+ }
148
+
149
+ const mediaArray = mediaType === "image"
150
+ ? (result as AIImageResult).images
151
+ : (result as AIVideoResult).videos
152
+
153
+ if (!mediaArray || mediaArray.length === 0) {
154
+ return result
155
+ }
156
+
157
+ try {
158
+ const downloadsDir = path.join(process.cwd(), "output")
159
+ if (!fs.existsSync(downloadsDir)) {
160
+ fs.mkdirSync(downloadsDir, { recursive: true })
161
+ }
162
+
163
+ const timestamp = Date.now()
164
+ const filename = `${this.providerName}-${mediaType}-${timestamp}.${fileExtension}`
165
+ const localPath = path.join(downloadsDir, filename)
166
+
167
+ const response = await fetch(mediaArray[0].url)
168
+ if (!response.ok) {
169
+ throw new Error(`Failed to download: ${response.statusText}`)
170
+ }
171
+
172
+ const buffer = await response.arrayBuffer()
173
+ fs.writeFileSync(localPath, Buffer.from(buffer))
174
+
175
+ logger.info(`${mediaType} saved to ${localPath}`)
176
+
177
+ if (openInViewer) {
178
+ await this.openInDefaultViewer(localPath)
179
+ }
180
+
181
+ return {
182
+ ...result,
183
+ localPath
184
+ }
185
+ } catch (error: any) {
186
+ this.log(
187
+ `${mediaType.charAt(0).toUpperCase() + mediaType.slice(1)} generated successfully but post-processing failed: ${error.message}`,
188
+ "error"
189
+ )
190
+
191
+ return {
192
+ ...result,
193
+ localPath: undefined
194
+ }
195
+ }
196
+ }
197
+
198
+ protected extractCreditsFromResponse(response: any): any {
199
+ const credits: any = {}
200
+
201
+ if (response?.credits !== undefined) {
202
+ credits.balance = response.credits.balance
203
+ credits.cost = response.credits.cost
204
+ credits.currency = response.credits.currency
205
+ credits.remaining = response.credits.remaining
206
+ credits.limit = response.credits.limit
207
+ credits.used = response.credits.used
208
+ }
209
+
210
+ if (response?.billing !== undefined) {
211
+ credits.balance = response.billing.balance
212
+ credits.cost = response.billing.cost
213
+ credits.currency = response.billing.currency
214
+ credits.remaining = response.billing.remaining
215
+ }
216
+
217
+ if (response?.usage !== undefined) {
218
+ credits.used = response.usage.total_tokens || response.usage.used
219
+ credits.limit = response.usage.limit
220
+ credits.remaining = response.usage.remaining
221
+ }
222
+
223
+ if (Object.keys(credits).length === 0) {
224
+ return undefined
225
+ }
226
+
227
+ return credits
228
+ }
117
229
  }
package/cli.ts CHANGED
@@ -7,6 +7,74 @@ import { spawn } from 'child_process'
7
7
  import { resolve } from 'path'
8
8
  import { readFileSync, existsSync } from 'fs'
9
9
 
10
+ async function publishPet(petName: string | undefined, options: { preview?: boolean; channel?: string } = {}) {
11
+ const { preview = false, channel = 'latest' } = options
12
+
13
+ // Find the script path - try multiple locations
14
+ let scriptPath = resolve(__dirname, '../../scripts/publish-pet.ts')
15
+ if (!existsSync(scriptPath)) {
16
+ // We might be in a different location, try relative to cwd
17
+ scriptPath = resolve(process.cwd(), '../../scripts/publish-pet.ts')
18
+ }
19
+
20
+ if (!existsSync(scriptPath)) {
21
+ console.error(`❌ Publish script not found: ${scriptPath}`)
22
+ console.error(` Tried: ${resolve(__dirname, '../../scripts/publish-pet.ts')}`)
23
+ console.error(` And: ${resolve(process.cwd(), '../../scripts/publish-pet.ts')}`)
24
+ process.exit(1)
25
+ }
26
+
27
+ // If no petName provided, try to infer from current directory
28
+ let finalPetName = petName
29
+ if (!finalPetName) {
30
+ const cwd = process.cwd()
31
+ const pkgPath = resolve(cwd, 'package.json')
32
+
33
+ if (existsSync(pkgPath)) {
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
36
+ // Extract pet name from @openpets/maps -> maps
37
+ if (pkg.name && pkg.name.startsWith('@openpets/')) {
38
+ finalPetName = pkg.name.replace('@openpets/', '')
39
+ console.log(`📦 Detected pet: ${finalPetName}`)
40
+ }
41
+ } catch (e) {
42
+ // Ignore parse errors
43
+ }
44
+ }
45
+
46
+ if (!finalPetName) {
47
+ console.error('❌ Could not determine pet name. Run from pet directory or specify pet name.')
48
+ process.exit(1)
49
+ }
50
+ }
51
+
52
+ const bunArgs = [scriptPath, finalPetName]
53
+ if (preview) bunArgs.push('--preview')
54
+ if (channel && channel !== 'latest') bunArgs.push('--channel', channel)
55
+
56
+ return new Promise<void>((resolve, reject) => {
57
+ const child = spawn('bun', bunArgs, {
58
+ stdio: 'inherit',
59
+ shell: true,
60
+ env: {
61
+ ...process.env,
62
+ OPENPETS_PREVIEW: preview ? 'true' : 'false',
63
+ OPENPETS_CHANNEL: channel
64
+ }
65
+ })
66
+
67
+ child.on('error', reject)
68
+ child.on('exit', (code) => {
69
+ if (code !== 0) {
70
+ reject(new Error(`Publish exited with code ${code}`))
71
+ } else {
72
+ resolve()
73
+ }
74
+ })
75
+ })
76
+ }
77
+
10
78
  const args = process.argv.slice(2)
11
79
  const command = args[0]
12
80
 
@@ -156,6 +224,18 @@ if (command === 'build') {
156
224
  log('Deploy error details:', error)
157
225
  process.exit(1)
158
226
  })
227
+ } else if (command === 'publish') {
228
+ const petName = args[1] && !args[1].startsWith('--') ? args[1] : undefined
229
+ const preview = args.includes('--preview')
230
+ const channelIndex = args.indexOf('--channel')
231
+ const channel = channelIndex >= 0 ? args[channelIndex + 1] : 'latest'
232
+
233
+ log('Publishing pet:', petName || '(auto-detect)', { preview, channel })
234
+ publishPet(petName, { preview, channel }).catch(error => {
235
+ console.error('Error publishing pet:', error)
236
+ log('Publish error details:', error)
237
+ process.exit(1)
238
+ })
159
239
  } else if (!command) {
160
240
  log('No command provided, launching manager UI')
161
241
  launchManager()
@@ -164,16 +244,25 @@ if (command === 'build') {
164
244
  console.error('Usage: pets [command] [options]')
165
245
  console.error('')
166
246
  console.error('Commands:')
167
- console.error(' (none) Launch the plugin manager UI (default)')
168
- console.error(' build <pet-name> Build and validate a pet package')
169
- console.error(' deploy <pet-name> Build and deploy a pet package with metadata')
247
+ console.error(' (none) Launch the plugin manager UI (default)')
248
+ console.error(' build <pet-name> Build and validate a pet package')
249
+ console.error(' deploy <pet-name> Build and deploy a pet package with metadata')
250
+ console.error(' publish [pet-name] Publish a pet package to npm (auto-detects if in pet dir)')
251
+ console.error('')
252
+ console.error('Publish Options:')
253
+ console.error(' --preview Dry-run mode (no actual publish)')
254
+ console.error(' --channel <name> Publish to a specific npm tag (default: latest)')
170
255
  console.error('')
171
256
  console.error('Examples:')
172
- console.error(' pets # Launch the desktop plugin manager')
257
+ console.error(' pets # Launch the desktop plugin manager')
173
258
  console.error(' pets build postgres')
174
259
  console.error(' pets deploy maps')
260
+ console.error(' pets publish maps # From root directory')
261
+ console.error(' cd pets/maps && pets publish # From pet directory')
262
+ console.error(' pets publish --preview # Dry-run from pet directory')
263
+ console.error(' pets publish maps --channel beta')
175
264
  console.error('')
176
265
  console.error('Environment:')
177
- console.error(' PETS_DEBUG=true Enable detailed debug logging')
266
+ console.error(' PETS_DEBUG=true Enable detailed debug logging')
178
267
  process.exit(1)
179
268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpets",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "description": "Core package for OpenPets - AI plugin infrastructure and shared utilities",
6
6
  "main": "index.ts",
@@ -15,7 +15,16 @@
15
15
  "*.d.ts",
16
16
  "ai-client-base/**/*",
17
17
  "!**/*.test.ts",
18
- "!**/*.test.js"
18
+ "!**/*.test.js",
19
+ "!.env*",
20
+ "!*.pem",
21
+ "!*.key",
22
+ "!credentials*.json",
23
+ "!*.p12",
24
+ "!*.pfx",
25
+ "!reference",
26
+ "!reports",
27
+ "!report"
19
28
  ],
20
29
  "keywords": [
21
30
  "ai",
@@ -42,4 +51,4 @@
42
51
  "peerDependencies": {
43
52
  "@opencode-ai/plugin": "^1.0.106"
44
53
  }
45
- }
54
+ }
package/plugin-factory.ts CHANGED
@@ -285,16 +285,7 @@ export function loadEnv(petId?: string): EnvConfig {
285
285
  }
286
286
  }
287
287
 
288
- for (const [key, value] of Object.entries(process.env)) {
289
- if (value && value.length > 0) {
290
- if (!envVars[key]) {
291
- envVars[key] = value
292
- logger.debug(`Loaded from process.env: ${key}`)
293
- }
294
- }
295
- }
296
-
297
- logger.info(`Loaded ${Object.keys(envVars).length} environment variables`)
288
+ logger.info(`Loaded ${Object.keys(envVars).length} environment variables from project config`)
298
289
 
299
290
  } catch (error: any) {
300
291
  logger.error(`Error loading environment variables: ${error.message}`)
package/validate-pet.ts CHANGED
@@ -39,6 +39,10 @@ export class PluginValidator {
39
39
  'scenarios'
40
40
  ]
41
41
 
42
+ private recommendedFields = [
43
+ 'providers'
44
+ ]
45
+
42
46
  private requiredScripts = [
43
47
  'test:all'
44
48
  ]
@@ -158,9 +162,27 @@ export class PluginValidator {
158
162
  warnings.push('No scripts defined - consider adding test scripts')
159
163
  }
160
164
 
165
+ // Validate providers structure if present
166
+ const validProviderIds = new Set<string>()
167
+ if (pkg.providers) {
168
+ const providersValidation = this.validateProviders(pkg.providers)
169
+ errors.push(...providersValidation.errors)
170
+ warnings.push(...providersValidation.warnings)
171
+
172
+ if (Array.isArray(pkg.providers)) {
173
+ pkg.providers.forEach((provider: any) => {
174
+ if (provider.id) {
175
+ validProviderIds.add(provider.id)
176
+ }
177
+ })
178
+ }
179
+ } else if (pkg.envVariables) {
180
+ warnings.push('Missing recommended field "providers" - consider defining providers with credentialsUrl for better documentation')
181
+ }
182
+
161
183
  // Validate envVariables structure if present
162
184
  if (pkg.envVariables) {
163
- const envValidation = this.validateEnvVariables(pkg.envVariables)
185
+ const envValidation = this.validateEnvVariables(pkg.envVariables, validProviderIds)
164
186
  errors.push(...envValidation.errors)
165
187
  warnings.push(...envValidation.warnings)
166
188
  }
@@ -206,6 +228,9 @@ export class PluginValidator {
206
228
  // Check for .env.example
207
229
  if (pkg.envVariables && !existsSync(join(pluginPath, '.env.example'))) {
208
230
  errors.push('envVariables defined but .env.example file missing - create this file to document required environment variables')
231
+ } else if (pkg.envVariables && existsSync(join(pluginPath, '.env.example'))) {
232
+ const envExampleValidation = this.validateEnvExampleFile(join(pluginPath, '.env.example'), pkg.envVariables, pkg.providers)
233
+ warnings.push(...envExampleValidation.warnings)
209
234
  }
210
235
 
211
236
  // Check for README
@@ -223,7 +248,72 @@ export class PluginValidator {
223
248
  }
224
249
  }
225
250
 
226
- private validateEnvVariables(envVars: any): ValidationResult {
251
+ private validateProviders(providers: any): ValidationResult {
252
+ const errors: string[] = []
253
+ const warnings: string[] = []
254
+
255
+ if (!Array.isArray(providers)) {
256
+ errors.push('providers must be an array')
257
+ return { valid: false, errors, warnings }
258
+ }
259
+
260
+ if (providers.length === 0) {
261
+ warnings.push('providers array is empty - add at least one provider definition')
262
+ return { valid: true, errors, warnings }
263
+ }
264
+
265
+ const seenIds = new Set<string>()
266
+
267
+ providers.forEach((provider: any, index: number) => {
268
+ if (typeof provider !== 'object' || provider === null) {
269
+ errors.push(`providers[${index}] must be an object`)
270
+ return
271
+ }
272
+
273
+ if (!provider.id) {
274
+ errors.push(`providers[${index}] missing required field "id"`)
275
+ } else if (typeof provider.id !== 'string') {
276
+ errors.push(`providers[${index}].id must be a string`)
277
+ } else {
278
+ if (seenIds.has(provider.id)) {
279
+ errors.push(`providers[${index}] duplicate provider id "${provider.id}"`)
280
+ }
281
+ seenIds.add(provider.id)
282
+ }
283
+
284
+ if (!provider.name) {
285
+ warnings.push(`providers[${index}] missing recommended field "name"`)
286
+ } else if (typeof provider.name !== 'string') {
287
+ errors.push(`providers[${index}].name must be a string`)
288
+ }
289
+
290
+ if (!provider.credentialsUrl) {
291
+ warnings.push(`providers[${index}] missing recommended field "credentialsUrl" for API key generation link`)
292
+ } else if (typeof provider.credentialsUrl !== 'string') {
293
+ errors.push(`providers[${index}].credentialsUrl must be a string`)
294
+ } else {
295
+ try {
296
+ new URL(provider.credentialsUrl.replace(/\{[^}]+\}/g, 'placeholder'))
297
+ } catch {
298
+ warnings.push(`providers[${index}].credentialsUrl should be a valid URL pattern`)
299
+ }
300
+ }
301
+
302
+ if (provider.credentialsDocUrl && typeof provider.credentialsDocUrl !== 'string') {
303
+ errors.push(`providers[${index}].credentialsDocUrl must be a string`)
304
+ } else if (provider.credentialsDocUrl) {
305
+ try {
306
+ new URL(provider.credentialsDocUrl)
307
+ } catch {
308
+ warnings.push(`providers[${index}].credentialsDocUrl should be a valid URL`)
309
+ }
310
+ }
311
+ })
312
+
313
+ return { valid: errors.length === 0, errors, warnings }
314
+ }
315
+
316
+ private validateEnvVariables(envVars: any, validProviderIds: Set<string>): ValidationResult {
227
317
  const errors: string[] = []
228
318
  const warnings: string[] = []
229
319
 
@@ -245,8 +335,14 @@ export class PluginValidator {
245
335
  if (!envVar.description) {
246
336
  warnings.push(`envVariables.${type}[${index}] missing description field`)
247
337
  }
248
- if (!envVar.provider) {
249
- warnings.push(`envVariables.${type}[${index}] missing provider field`)
338
+ if (!envVar.providerId) {
339
+ warnings.push(`envVariables.${type}[${index}] missing providerId field - should reference a provider in the providers array`)
340
+ } else if (typeof envVar.providerId === 'string') {
341
+ if (validProviderIds.size > 0 && !validProviderIds.has(envVar.providerId)) {
342
+ errors.push(`envVariables.${type}[${index}].providerId "${envVar.providerId}" does not match any provider id in the providers array`)
343
+ }
344
+ } else {
345
+ errors.push(`envVariables.${type}[${index}].providerId must be a string`)
250
346
  }
251
347
  if (typeof envVar.priority !== 'number') {
252
348
  warnings.push(`envVariables.${type}[${index}] missing or invalid priority field`)
@@ -264,6 +360,65 @@ export class PluginValidator {
264
360
  return { valid: errors.length === 0, errors, warnings }
265
361
  }
266
362
 
363
+ private validateEnvExampleFile(envExamplePath: string, envVars: any, providers: any[]): { warnings: string[] } {
364
+ const warnings: string[] = []
365
+
366
+ try {
367
+ const envExampleContent = readFileSync(envExamplePath, 'utf-8')
368
+ const envExampleLines = envExampleContent.split('\n')
369
+
370
+ const documentedVars = new Set<string>()
371
+ const providerComments = new Map<string, boolean>()
372
+
373
+ for (let i = 0; i < envExampleLines.length; i++) {
374
+ const line = envExampleLines[i].trim()
375
+
376
+ if (line.startsWith('#') && (line.includes('http://') || line.includes('https://'))) {
377
+ const nextLine = envExampleLines[i + 1]?.trim()
378
+ if (nextLine && nextLine.includes('=')) {
379
+ const varName = nextLine.split('=')[0].trim()
380
+ providerComments.set(varName, true)
381
+ }
382
+ }
383
+
384
+ if (line && !line.startsWith('#') && line.includes('=')) {
385
+ const varName = line.split('=')[0].trim()
386
+ documentedVars.add(varName)
387
+ }
388
+ }
389
+
390
+ const allEnvVars: any[] = [
391
+ ...(envVars.required || []),
392
+ ...(envVars.optional || [])
393
+ ]
394
+
395
+ const providerMap = new Map<string, any>()
396
+ if (providers && Array.isArray(providers)) {
397
+ providers.forEach(provider => {
398
+ if (provider.id) {
399
+ providerMap.set(provider.id, provider)
400
+ }
401
+ })
402
+ }
403
+
404
+ for (const envVar of allEnvVars) {
405
+ if (!documentedVars.has(envVar.name)) {
406
+ warnings.push(`Environment variable "${envVar.name}" defined in package.json but not found in .env.example`)
407
+ } else if (!providerComments.has(envVar.name) && envVar.providerId) {
408
+ const provider = providerMap.get(envVar.providerId)
409
+ if (provider && provider.credentialsUrl) {
410
+ warnings.push(`Environment variable "${envVar.name}" in .env.example missing comment with provider credentialsUrl (${provider.credentialsUrl})`)
411
+ }
412
+ }
413
+ }
414
+
415
+ } catch (error) {
416
+ warnings.push(`Could not validate .env.example file: ${error instanceof Error ? error.message : String(error)}`)
417
+ }
418
+
419
+ return { warnings }
420
+ }
421
+
267
422
  private validateScenarios(scenarios: any): ValidationResult {
268
423
  const errors: string[] = []
269
424
  const warnings: string[] = []