@soederpop/luca 0.0.6 → 0.0.8
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/CLAUDE.md +10 -1
- package/RUNME.md +56 -0
- package/bun.lock +1 -1
- package/commands/build-bootstrap.ts +78 -0
- package/commands/build-scaffolds.ts +24 -2
- package/commands/try-all-challenges.ts +543 -0
- package/commands/try-challenge.ts +100 -0
- package/docs/README.md +52 -80
- package/docs/TABLE-OF-CONTENTS.md +82 -51
- package/docs/apis/clients/elevenlabs.md +232 -8
- package/docs/apis/clients/graph.md +59 -8
- package/docs/apis/clients/openai.md +362 -2
- package/docs/apis/clients/rest.md +122 -2
- package/docs/apis/clients/websocket.md +71 -17
- package/docs/apis/features/agi/assistant.md +9 -3
- package/docs/apis/features/agi/assistants-manager.md +2 -2
- package/docs/apis/features/agi/claude-code.md +153 -14
- package/docs/apis/features/agi/conversation-history.md +15 -3
- package/docs/apis/features/agi/conversation.md +133 -20
- package/docs/apis/features/agi/openai-codex.md +90 -12
- package/docs/apis/features/agi/skills-library.md +23 -5
- package/docs/apis/features/node/container-link.md +59 -0
- package/docs/apis/features/node/content-db.md +1 -1
- package/docs/apis/features/node/disk-cache.md +1 -1
- package/docs/apis/features/node/dns.md +1 -0
- package/docs/apis/features/node/docker.md +2 -1
- package/docs/apis/features/node/esbuild.md +4 -3
- package/docs/apis/features/node/file-manager.md +13 -4
- package/docs/apis/features/node/fs.md +726 -171
- package/docs/apis/features/node/git.md +1 -0
- package/docs/apis/features/node/google-auth.md +23 -4
- package/docs/apis/features/node/google-calendar.md +14 -2
- package/docs/apis/features/node/google-docs.md +15 -2
- package/docs/apis/features/node/google-drive.md +21 -3
- package/docs/apis/features/node/google-sheets.md +14 -2
- package/docs/apis/features/node/grep.md +2 -0
- package/docs/apis/features/node/helpers.md +29 -0
- package/docs/apis/features/node/ink.md +2 -2
- package/docs/apis/features/node/networking.md +39 -4
- package/docs/apis/features/node/os.md +28 -0
- package/docs/apis/features/node/postgres.md +26 -4
- package/docs/apis/features/node/proc.md +37 -28
- package/docs/apis/features/node/process-manager.md +33 -5
- package/docs/apis/features/node/repl.md +1 -1
- package/docs/apis/features/node/runpod.md +1 -0
- package/docs/apis/features/node/secure-shell.md +7 -0
- package/docs/apis/features/node/semantic-search.md +12 -5
- package/docs/apis/features/node/sqlite.md +26 -4
- package/docs/apis/features/node/telegram.md +30 -5
- package/docs/apis/features/node/tts.md +17 -2
- package/docs/apis/features/node/ui.md +1 -1
- package/docs/apis/features/node/vault.md +4 -9
- package/docs/apis/features/node/vm.md +3 -12
- package/docs/apis/features/node/window-manager.md +128 -20
- package/docs/apis/features/web/asset-loader.md +13 -1
- package/docs/apis/features/web/container-link.md +59 -0
- package/docs/apis/features/web/esbuild.md +4 -3
- package/docs/apis/features/web/helpers.md +29 -0
- package/docs/apis/features/web/network.md +16 -2
- package/docs/apis/features/web/speech.md +16 -2
- package/docs/apis/features/web/vault.md +4 -9
- package/docs/apis/features/web/vm.md +3 -12
- package/docs/apis/features/web/voice.md +18 -1
- package/docs/apis/servers/express.md +18 -2
- package/docs/apis/servers/mcp.md +29 -4
- package/docs/apis/servers/websocket.md +34 -6
- package/docs/bootstrap/CLAUDE.md +100 -0
- package/docs/bootstrap/SKILL.md +222 -0
- package/docs/bootstrap/templates/about-command.ts +41 -0
- package/docs/bootstrap/templates/docs-models.ts +22 -0
- package/docs/bootstrap/templates/docs-readme.md +43 -0
- package/docs/bootstrap/templates/example-feature.ts +53 -0
- package/docs/bootstrap/templates/health-endpoint.ts +15 -0
- package/docs/bootstrap/templates/luca-cli.ts +25 -0
- package/docs/bootstrap/templates/runme.md +54 -0
- package/docs/challenges/caching-proxy.md +16 -0
- package/docs/challenges/content-db-round-trip.md +14 -0
- package/docs/challenges/custom-command.md +9 -0
- package/docs/challenges/file-watcher-pipeline.md +11 -0
- package/docs/challenges/grep-audit-report.md +15 -0
- package/docs/challenges/multi-feature-dashboard.md +14 -0
- package/docs/challenges/process-orchestrator.md +17 -0
- package/docs/challenges/rest-api-server-with-client.md +12 -0
- package/docs/challenges/script-runner-with-vm.md +11 -0
- package/docs/challenges/simple-rest-api.md +15 -0
- package/docs/challenges/websocket-serve-and-client.md +11 -0
- package/docs/challenges/yaml-config-system.md +14 -0
- package/docs/command-system-overhaul.md +94 -0
- package/docs/examples/assistant/CORE.md +18 -0
- package/docs/examples/assistant/hooks.ts +3 -0
- package/docs/examples/assistant/tools.ts +10 -0
- package/docs/examples/window-manager-layouts.md +180 -0
- package/docs/in-memory-fs.md +4 -0
- package/docs/models.ts +13 -10
- package/docs/philosophy.md +4 -3
- package/docs/reports/console-hmr-design.md +170 -0
- package/docs/reports/helper-semantic-search.md +72 -0
- package/docs/scaffolds/client.md +29 -20
- package/docs/scaffolds/command.md +64 -50
- package/docs/scaffolds/endpoint.md +31 -36
- package/docs/scaffolds/feature.md +28 -18
- package/docs/scaffolds/selector.md +91 -0
- package/docs/scaffolds/server.md +18 -9
- package/docs/selectors.md +115 -0
- package/docs/sessions/custom-command/attempt-log-2.md +195 -0
- package/docs/sessions/file-watcher-pipeline/attempt-log-1.md +728 -0
- package/docs/sessions/file-watcher-pipeline/attempt-log-2.md +555 -0
- package/docs/sessions/grep-audit-report/attempt-log-1.md +289 -0
- package/docs/sessions/multi-feature-dashboard/attempt-log-2.md +679 -0
- package/docs/sessions/rest-api-server-with-client/attempt-log-1.md +1 -0
- package/docs/sessions/rest-api-server-with-client/attempt-log-3.md +920 -0
- package/docs/sessions/simple-rest-api/attempt-log-1.md +593 -0
- package/docs/sessions/websocket-serve-and-client/attempt-log-2.md +995 -0
- package/docs/tutorials/00-bootstrap.md +148 -0
- package/docs/tutorials/07-endpoints.md +7 -7
- package/docs/tutorials/08-commands.md +153 -72
- package/luca.cli.ts +3 -0
- package/package.json +6 -5
- package/public/index.html +1430 -0
- package/scripts/examples/using-ollama.ts +2 -1
- package/scripts/update-introspection-data.ts +2 -2
- package/src/agi/endpoints/experts.ts +1 -1
- package/src/agi/features/assistant.ts +7 -0
- package/src/agi/features/assistants-manager.ts +5 -5
- package/src/agi/features/claude-code.ts +263 -3
- package/src/agi/features/conversation-history.ts +7 -1
- package/src/agi/features/conversation.ts +26 -3
- package/src/agi/features/openai-codex.ts +26 -2
- package/src/agi/features/openapi.ts +6 -1
- package/src/agi/features/skills-library.ts +9 -1
- package/src/bootstrap/generated.ts +595 -0
- package/src/cli/cli.ts +64 -21
- package/src/client.ts +23 -357
- package/src/clients/civitai/index.ts +1 -1
- package/src/clients/client-template.ts +1 -1
- package/src/clients/comfyui/index.ts +13 -2
- package/src/clients/elevenlabs/index.ts +2 -1
- package/src/clients/graph.ts +87 -0
- package/src/clients/openai/index.ts +10 -1
- package/src/clients/rest.ts +207 -0
- package/src/clients/websocket.ts +176 -0
- package/src/command.ts +281 -34
- package/src/commands/bootstrap.ts +185 -0
- package/src/commands/chat.ts +5 -4
- package/src/commands/describe.ts +341 -4
- package/src/commands/help.ts +35 -9
- package/src/commands/index.ts +3 -0
- package/src/commands/introspect.ts +92 -2
- package/src/commands/prompt.ts +5 -6
- package/src/commands/run.ts +75 -10
- package/src/commands/save-api-docs.ts +49 -0
- package/src/commands/scaffold.ts +169 -23
- package/src/commands/select.ts +94 -0
- package/src/commands/serve.ts +10 -1
- package/src/container.ts +15 -0
- package/src/endpoint.ts +19 -0
- package/src/graft.ts +181 -0
- package/src/introspection/generated.agi.ts +12458 -8968
- package/src/introspection/generated.node.ts +10573 -7145
- package/src/introspection/generated.web.ts +1 -1
- package/src/introspection/index.ts +26 -0
- package/src/node/container.ts +6 -7
- package/src/node/features/content-db.ts +49 -2
- package/src/node/features/disk-cache.ts +16 -9
- package/src/node/features/dns.ts +16 -3
- package/src/node/features/docker.ts +16 -4
- package/src/node/features/esbuild.ts +22 -2
- package/src/node/features/file-manager.ts +184 -29
- package/src/node/features/fs.ts +704 -248
- package/src/node/features/git.ts +21 -8
- package/src/node/features/grep.ts +23 -3
- package/src/node/features/helpers.ts +372 -43
- package/src/node/features/networking.ts +39 -4
- package/src/node/features/opener.ts +28 -15
- package/src/node/features/os.ts +76 -0
- package/src/node/features/port-exposer.ts +11 -1
- package/src/node/features/postgres.ts +17 -1
- package/src/node/features/proc.ts +4 -1
- package/src/node/features/python.ts +63 -14
- package/src/node/features/repl.ts +11 -7
- package/src/node/features/runpod.ts +16 -3
- package/src/node/features/secure-shell.ts +27 -2
- package/src/node/features/semantic-search.ts +12 -1
- package/src/node/features/ui.ts +5 -69
- package/src/node/features/vm.ts +17 -0
- package/src/node/features/window-manager.ts +68 -20
- package/src/node.ts +5 -0
- package/src/scaffolds/generated.ts +492 -290
- package/src/scaffolds/template.ts +9 -0
- package/src/schemas/base.ts +46 -5
- package/src/selector.ts +282 -0
- package/src/server.ts +11 -0
- package/src/servers/express.ts +27 -12
- package/src/servers/socket.ts +45 -11
- package/src/web/clients/socket.ts +4 -1
- package/src/web/container.ts +2 -1
- package/src/web/features/network.ts +7 -1
- package/src/web/features/voice-recognition.ts +16 -1
- package/test/clients-servers.test.ts +2 -1
- package/test/command.test.ts +267 -0
- package/test/vm-context.test.ts +146 -0
- package/test-integration/assistants-manager.test.ts +10 -20
- package/docs/apis/features/node/launcher-app-command-listener.md +0 -145
- package/docs/examples/launcher-app-command-listener.md +0 -120
- package/docs/tasks/web-container-helper-discovery.md +0 -71
- package/docs/todos.md +0 -1
- package/scripts/test-command-listener.ts +0 -123
- package/src/node/features/launcher-app-command-listener.ts +0 -389
|
@@ -4,11 +4,15 @@ import { Feature } from '../feature.js'
|
|
|
4
4
|
import { Feature as UniversalFeature } from '../../feature.js'
|
|
5
5
|
import { Client, clients } from '../../client.js'
|
|
6
6
|
import { Server, servers } from '../../server.js'
|
|
7
|
-
import { commands } from '../../command.js'
|
|
7
|
+
import { Command, commands } from '../../command.js'
|
|
8
|
+
import { graftModule, isNativeHelperClass } from '../../graft.js'
|
|
8
9
|
import { endpoints } from '../../endpoint.js'
|
|
10
|
+
import { Selector, selectors } from '../../selector.js'
|
|
9
11
|
import type { Registry } from '../../registry.js'
|
|
10
12
|
import type { FileManager } from './file-manager.js'
|
|
13
|
+
import type { VM } from './vm.js'
|
|
11
14
|
import { resolve, parse } from 'path'
|
|
15
|
+
import { existsSync } from 'fs'
|
|
12
16
|
|
|
13
17
|
export const HelpersStateSchema = FeatureStateSchema.extend({
|
|
14
18
|
discovered: z.record(z.string(), z.boolean()).default({}).describe('Which registry types have been discovered'),
|
|
@@ -35,7 +39,7 @@ export const HelpersEventsSchema = FeatureEventsSchema.extend({
|
|
|
35
39
|
]).describe('Emitted when a single helper is registered'),
|
|
36
40
|
})
|
|
37
41
|
|
|
38
|
-
type RegistryType = 'features' | 'clients' | 'servers' | 'commands' | 'endpoints'
|
|
42
|
+
type RegistryType = 'features' | 'clients' | 'servers' | 'commands' | 'endpoints' | 'selectors'
|
|
39
43
|
|
|
40
44
|
const CLASS_BASED: RegistryType[] = ['features', 'clients', 'servers']
|
|
41
45
|
|
|
@@ -43,11 +47,11 @@ const CLASS_BASED: RegistryType[] = ['features', 'clients', 'servers']
|
|
|
43
47
|
* The Helpers feature is a unified gateway for discovering and registering
|
|
44
48
|
* project-level helpers from conventional folder locations.
|
|
45
49
|
*
|
|
46
|
-
* It scans known folder names (features/, clients/, servers/, commands/, endpoints/)
|
|
50
|
+
* It scans known folder names (features/, clients/, servers/, commands/, endpoints/, selectors/)
|
|
47
51
|
* and handles registration differently based on the helper type:
|
|
48
52
|
*
|
|
49
53
|
* - Class-based (features, clients, servers): Dynamic import, validate subclass, register
|
|
50
|
-
* - Config-based (commands, endpoints): Delegate to existing discovery mechanisms
|
|
54
|
+
* - Config-based (commands, endpoints, selectors): Delegate to existing discovery mechanisms
|
|
51
55
|
*
|
|
52
56
|
* @example
|
|
53
57
|
* ```typescript
|
|
@@ -71,6 +75,13 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
71
75
|
static override eventsSchema = HelpersEventsSchema
|
|
72
76
|
static { Feature.register(this, 'helpers') }
|
|
73
77
|
|
|
78
|
+
/** In-flight or completed discovery promises, keyed by registry type */
|
|
79
|
+
private _discoveryPromises: Map<string, Promise<string[]>> = new Map()
|
|
80
|
+
/** Cached results from completed discoveries */
|
|
81
|
+
private _discoveryResults: Map<string, string[]> = new Map()
|
|
82
|
+
/** In-flight or completed discoverAll promise */
|
|
83
|
+
private _discoverAllPromise: Promise<Record<string, string[]>> | null = null
|
|
84
|
+
|
|
74
85
|
/**
|
|
75
86
|
* Returns a mapping from registry type name to its registry singleton, base class, and conventional folder candidates.
|
|
76
87
|
*/
|
|
@@ -81,6 +92,7 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
81
92
|
servers: { registry: servers, baseClass: Server, folders: ['servers'] },
|
|
82
93
|
commands: { registry: commands, baseClass: null, folders: ['commands'] },
|
|
83
94
|
endpoints: { registry: endpoints, baseClass: null, folders: ['endpoints'] },
|
|
95
|
+
selectors: { registry: selectors, baseClass: null, folders: ['selectors'] },
|
|
84
96
|
}
|
|
85
97
|
}
|
|
86
98
|
|
|
@@ -89,6 +101,118 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
89
101
|
return this.options.rootDir || this.container.cwd
|
|
90
102
|
}
|
|
91
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Whether to use native `import()` for loading project helpers.
|
|
106
|
+
* True only if `@soederpop/luca` is actually resolvable in `node_modules`.
|
|
107
|
+
* Warns when `node_modules` exists but the package is missing.
|
|
108
|
+
*/
|
|
109
|
+
get useNativeImport(): boolean {
|
|
110
|
+
const hasNodeModules = existsSync(resolve(this.rootDir, 'node_modules'))
|
|
111
|
+
const hasLuca = hasNodeModules && existsSync(resolve(this.rootDir, 'node_modules', '@soederpop', 'luca'))
|
|
112
|
+
|
|
113
|
+
if (hasNodeModules && !hasLuca && !this._warnedNativeImport) {
|
|
114
|
+
this._warnedNativeImport = true
|
|
115
|
+
console.warn(
|
|
116
|
+
`Helpers: node_modules exists but @soederpop/luca wasn't found. ` +
|
|
117
|
+
`Did you forget to \`bun install\` or add @soederpop/luca as a dependency? ` +
|
|
118
|
+
`Using the VM virtual module system instead until this is resolved.`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return hasLuca
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Prevent repeated warnings about missing @soederpop/luca */
|
|
126
|
+
private _warnedNativeImport = false
|
|
127
|
+
|
|
128
|
+
/** Track whether we've seeded the VM with virtual modules */
|
|
129
|
+
private _vmSeeded = false
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Seeds the VM feature with virtual modules so that project-level files
|
|
133
|
+
* can `import` / `require('@soederpop/luca')`, `zod`, etc. without
|
|
134
|
+
* needing them in `node_modules`.
|
|
135
|
+
*
|
|
136
|
+
* Called automatically when `useNativeImport` is false.
|
|
137
|
+
* Can also be called externally (e.g. from the CLI) to pre-seed before discovery.
|
|
138
|
+
*/
|
|
139
|
+
seedVirtualModules(): void {
|
|
140
|
+
if (this._vmSeeded) return
|
|
141
|
+
this._vmSeeded = true
|
|
142
|
+
|
|
143
|
+
const vm = this.container.feature('vm') as unknown as VM
|
|
144
|
+
|
|
145
|
+
// Provide the full @soederpop/luca barrel — everything node.ts exports
|
|
146
|
+
// We build the exports object from the already-loaded modules in memory
|
|
147
|
+
const lucaExports: Record<string, any> = {
|
|
148
|
+
// Core classes
|
|
149
|
+
Feature: UniversalFeature,
|
|
150
|
+
Container: this.container.constructor,
|
|
151
|
+
Helper: Object.getPrototypeOf(UniversalFeature.prototype).constructor,
|
|
152
|
+
Client,
|
|
153
|
+
Server,
|
|
154
|
+
Command,
|
|
155
|
+
Registry: Object.getPrototypeOf(this.container.features).constructor,
|
|
156
|
+
|
|
157
|
+
// Utilities
|
|
158
|
+
graftModule,
|
|
159
|
+
isNativeHelperClass,
|
|
160
|
+
|
|
161
|
+
// Registries
|
|
162
|
+
features: this.container.features,
|
|
163
|
+
clients,
|
|
164
|
+
servers,
|
|
165
|
+
commands,
|
|
166
|
+
endpoints,
|
|
167
|
+
selectors,
|
|
168
|
+
|
|
169
|
+
// Registry classes
|
|
170
|
+
ClientsRegistry: clients.constructor,
|
|
171
|
+
CommandsRegistry: commands.constructor,
|
|
172
|
+
EndpointsRegistry: endpoints.constructor,
|
|
173
|
+
ServersRegistry: servers.constructor,
|
|
174
|
+
SelectorsRegistry: selectors.constructor,
|
|
175
|
+
FeaturesRegistry: this.container.features.constructor,
|
|
176
|
+
|
|
177
|
+
// Helper subclasses
|
|
178
|
+
Selector,
|
|
179
|
+
|
|
180
|
+
// The singleton container
|
|
181
|
+
default: this.container,
|
|
182
|
+
|
|
183
|
+
// Convenient feature instances
|
|
184
|
+
fs: this.container.feature('fs'),
|
|
185
|
+
ui: this.container.feature('ui'),
|
|
186
|
+
vm,
|
|
187
|
+
proc: this.container.feature('proc'),
|
|
188
|
+
|
|
189
|
+
// Zod re-export
|
|
190
|
+
z,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Schemas
|
|
194
|
+
const schemasModule = { CommandOptionsSchema: commands.baseClass?.optionsSchema || z.object({}) }
|
|
195
|
+
try {
|
|
196
|
+
// Pull all base schemas from the already-loaded schemas/base module
|
|
197
|
+
const baseSchemas = require('../../schemas/base.js')
|
|
198
|
+
Object.assign(lucaExports, baseSchemas)
|
|
199
|
+
Object.assign(schemasModule, baseSchemas)
|
|
200
|
+
} catch {
|
|
201
|
+
// Fallback: provide the essentials
|
|
202
|
+
lucaExports.FeatureStateSchema = FeatureStateSchema
|
|
203
|
+
lucaExports.FeatureOptionsSchema = FeatureOptionsSchema
|
|
204
|
+
lucaExports.FeatureEventsSchema = FeatureEventsSchema
|
|
205
|
+
schemasModule.FeatureStateSchema = FeatureStateSchema
|
|
206
|
+
schemasModule.FeatureOptionsSchema = FeatureOptionsSchema
|
|
207
|
+
schemasModule.FeatureEventsSchema = FeatureEventsSchema
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
vm.defineModule('@soederpop/luca', lucaExports)
|
|
211
|
+
vm.defineModule('@soederpop/luca/schemas', schemasModule)
|
|
212
|
+
vm.defineModule('@soederpop/luca/node', lucaExports)
|
|
213
|
+
vm.defineModule('zod', { z, default: { z } })
|
|
214
|
+
}
|
|
215
|
+
|
|
92
216
|
/**
|
|
93
217
|
* Returns a unified view of all available helpers across all registries.
|
|
94
218
|
* Each key is a registry type, each value is the list of helper names in that registry.
|
|
@@ -144,11 +268,9 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
144
268
|
/**
|
|
145
269
|
* Discover and register project-level helpers of the given type.
|
|
146
270
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* For config-based types (commands, endpoints), delegates to existing discovery mechanisms.
|
|
271
|
+
* Idempotent: the first caller triggers the actual scan. Subsequent callers
|
|
272
|
+
* receive the cached results. If discovery is in-flight, callers await the
|
|
273
|
+
* same promise — no duplicate work.
|
|
152
274
|
*
|
|
153
275
|
* @param type - Which type of helpers to discover
|
|
154
276
|
* @param options - Optional overrides
|
|
@@ -162,16 +284,39 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
162
284
|
* ```
|
|
163
285
|
*/
|
|
164
286
|
async discover(type: RegistryType, options: { directory?: string } = {}): Promise<string[]> {
|
|
165
|
-
|
|
287
|
+
// Key by type + resolved directory so that different directories
|
|
288
|
+
// (e.g. project commands/ vs ~/.luca/commands/) are discovered independently
|
|
289
|
+
// while concurrent calls to the same directory coalesce on one promise.
|
|
290
|
+
const dir = options.directory || this.resolveFolderPath(type)
|
|
291
|
+
const cacheKey = dir ? `${type}:${dir}` : type
|
|
166
292
|
|
|
167
|
-
if
|
|
168
|
-
|
|
293
|
+
// Return cached results if already completed
|
|
294
|
+
if (this._discoveryResults.has(cacheKey)) {
|
|
295
|
+
return this._discoveryResults.get(cacheKey)!
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// If in-flight, await the same promise
|
|
299
|
+
if (this._discoveryPromises.has(cacheKey)) {
|
|
300
|
+
return this._discoveryPromises.get(cacheKey)!
|
|
169
301
|
}
|
|
170
302
|
|
|
303
|
+
// First caller — start the work and store the promise
|
|
304
|
+
const promise = this._doDiscover(type, { directory: dir || undefined })
|
|
305
|
+
this._discoveryPromises.set(cacheKey, promise)
|
|
306
|
+
|
|
307
|
+
const names = await promise
|
|
308
|
+
|
|
309
|
+
// Cache the final results
|
|
310
|
+
this._discoveryResults.set(cacheKey, names)
|
|
311
|
+
|
|
312
|
+
return names
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Internal: performs the actual discovery work for a single type. */
|
|
316
|
+
private async _doDiscover(type: RegistryType, options: { directory?: string } = {}): Promise<string[]> {
|
|
171
317
|
const dir = options.directory || this.resolveFolderPath(type)
|
|
172
318
|
|
|
173
319
|
if (!dir) {
|
|
174
|
-
this.state.set('discovered', { ...discovered, [type]: true })
|
|
175
320
|
return []
|
|
176
321
|
}
|
|
177
322
|
|
|
@@ -183,7 +328,9 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
183
328
|
names = await this.discoverConfigBased(type, dir)
|
|
184
329
|
}
|
|
185
330
|
|
|
186
|
-
|
|
331
|
+
// Update state for observability
|
|
332
|
+
const discovered = this.state.get('discovered') || {}
|
|
333
|
+
this.state.set('discovered', { ...discovered, [type]: true })
|
|
187
334
|
|
|
188
335
|
const existing = this.state.get('registered') || []
|
|
189
336
|
this.state.set('registered', [...existing, ...names.map(n => `${type}.${n}`)])
|
|
@@ -196,6 +343,9 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
196
343
|
/**
|
|
197
344
|
* Discover all helper types from their conventional folder locations.
|
|
198
345
|
*
|
|
346
|
+
* Idempotent: safe to call from multiple places (luca.cli.ts, commands, etc.).
|
|
347
|
+
* The first caller triggers discovery; all others receive the same results.
|
|
348
|
+
*
|
|
199
349
|
* @returns Map of registry type to discovered helper names
|
|
200
350
|
*
|
|
201
351
|
* @example
|
|
@@ -205,9 +355,19 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
205
355
|
* ```
|
|
206
356
|
*/
|
|
207
357
|
async discoverAll(): Promise<Record<string, string[]>> {
|
|
358
|
+
if (this._discoverAllPromise) {
|
|
359
|
+
return this._discoverAllPromise
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
this._discoverAllPromise = this._doDiscoverAll()
|
|
363
|
+
return this._discoverAllPromise
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Internal: performs the actual discoverAll work. */
|
|
367
|
+
private async _doDiscoverAll(): Promise<Record<string, string[]>> {
|
|
208
368
|
const results: Record<string, string[]> = {}
|
|
209
369
|
|
|
210
|
-
for (const type of ['features', 'clients', 'servers', 'commands', 'endpoints'] as RegistryType[]) {
|
|
370
|
+
for (const type of ['features', 'clients', 'servers', 'commands', 'endpoints', 'selectors'] as RegistryType[]) {
|
|
211
371
|
results[type] = await this.discover(type)
|
|
212
372
|
}
|
|
213
373
|
|
|
@@ -243,59 +403,106 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
243
403
|
return registry.describe(name)
|
|
244
404
|
}
|
|
245
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Load a module either via native `import()` or the VM's virtual module system.
|
|
408
|
+
* Uses the same `useNativeImport` check as discovery to decide the loading strategy.
|
|
409
|
+
*
|
|
410
|
+
* @param absPath - Absolute path to the module file
|
|
411
|
+
* @returns The module's exports
|
|
412
|
+
*/
|
|
413
|
+
async loadModuleExports(absPath: string): Promise<Record<string, any>> {
|
|
414
|
+
if (this.useNativeImport) {
|
|
415
|
+
const mod = await import(absPath)
|
|
416
|
+
return mod
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.seedVirtualModules()
|
|
420
|
+
const vm = this.container.feature('vm') as unknown as VM
|
|
421
|
+
return vm.loadModule(absPath)
|
|
422
|
+
}
|
|
423
|
+
|
|
246
424
|
/**
|
|
247
425
|
* Discovers class-based helpers (features, clients, servers) from a directory.
|
|
248
|
-
* Uses fileManager
|
|
426
|
+
* Uses fileManager when available (fast in git repos), falls back to Glob.
|
|
249
427
|
*/
|
|
250
428
|
private async discoverClassBased(type: RegistryType, dir: string): Promise<string[]> {
|
|
251
429
|
const { registry, baseClass } = this.registryMap[type]
|
|
252
|
-
const fm = await this.ensureFileManager()
|
|
253
430
|
const discovered: string[] = []
|
|
254
431
|
|
|
255
432
|
// Load build-time introspection data before importing helpers so that
|
|
256
433
|
// interceptRegistration() can merge JSDoc descriptions from the generated file.
|
|
257
434
|
const introspectionFile = resolve(dir, 'introspection.generated.ts')
|
|
258
435
|
try {
|
|
259
|
-
const { existsSync } = await import('fs')
|
|
260
436
|
if (existsSync(introspectionFile)) {
|
|
261
437
|
await import(introspectionFile)
|
|
262
438
|
}
|
|
263
439
|
} catch {}
|
|
264
440
|
|
|
265
|
-
|
|
266
|
-
|
|
441
|
+
// Try fileManager first (faster in git repos), fall back to Glob
|
|
442
|
+
let files: string[] = []
|
|
443
|
+
try {
|
|
444
|
+
const fm = await this.ensureFileManager()
|
|
445
|
+
// fileManager may store absolute or relative keys — use absolute patterns
|
|
446
|
+
const absPatterns = [`${dir}/*.ts`, `${dir}/**/*.ts`]
|
|
447
|
+
const relPatterns = [`${type}/*.ts`, `${type}/**/*.ts`]
|
|
448
|
+
const matched = fm.match([...absPatterns, ...relPatterns])
|
|
449
|
+
files = matched.map((f: string) => f.startsWith('/') ? f : resolve(this.rootDir, f))
|
|
450
|
+
} catch {}
|
|
267
451
|
|
|
268
|
-
|
|
269
|
-
|
|
452
|
+
// Fall back to Glob if fileManager found nothing
|
|
453
|
+
if (files.length === 0) {
|
|
454
|
+
const { Glob } = globalThis.Bun || (await import('bun'))
|
|
455
|
+
const glob = new Glob('**/*.ts')
|
|
456
|
+
for await (const file of glob.scan({ cwd: dir })) {
|
|
457
|
+
files.push(resolve(dir, file))
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const absPath of files) {
|
|
270
462
|
const { name: fileName } = parse(absPath)
|
|
271
463
|
|
|
272
464
|
if (fileName.includes('.test.') || fileName.includes('.spec.')) {
|
|
273
465
|
continue
|
|
274
466
|
}
|
|
275
|
-
|
|
276
467
|
try {
|
|
277
|
-
const mod = await
|
|
468
|
+
const mod = await this.loadModuleExports(absPath)
|
|
278
469
|
const ExportedClass = mod.default || mod
|
|
279
470
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
471
|
+
// Class-based: default export is a subclass of the base
|
|
472
|
+
if (typeof ExportedClass === 'function' && isNativeHelperClass(ExportedClass, baseClass)) {
|
|
473
|
+
const shortcut = ExportedClass.shortcut as string | undefined
|
|
474
|
+
const registryName = shortcut
|
|
475
|
+
? shortcut.replace(`${type}.`, '')
|
|
476
|
+
: this.fileNameToRegistryName(fileName)
|
|
283
477
|
|
|
284
|
-
|
|
285
|
-
continue
|
|
286
|
-
}
|
|
478
|
+
discovered.push(registryName)
|
|
287
479
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
480
|
+
if (!registry.has(registryName)) {
|
|
481
|
+
registry.register(registryName, ExportedClass)
|
|
482
|
+
this.emit('registered' as any, type, registryName, ExportedClass)
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
// Module-based: graft exports onto a generated subclass
|
|
486
|
+
const moduleExports = mod.default && typeof mod.default === 'object' ? mod.default : mod
|
|
487
|
+
const isGraftable = (
|
|
488
|
+
moduleExports.description !== undefined ||
|
|
489
|
+
moduleExports.stateSchema !== undefined ||
|
|
490
|
+
moduleExports.optionsSchema !== undefined ||
|
|
491
|
+
typeof moduleExports.run === 'function' ||
|
|
492
|
+
typeof moduleExports.handler === 'function'
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if (isGraftable) {
|
|
496
|
+
const registryName = this.fileNameToRegistryName(fileName)
|
|
497
|
+
const GraftedClass = graftModule(baseClass, moduleExports, registryName, type as any)
|
|
498
|
+
|
|
499
|
+
discovered.push(registryName)
|
|
500
|
+
|
|
501
|
+
if (!registry.has(registryName)) {
|
|
502
|
+
registry.register(registryName, GraftedClass as any)
|
|
503
|
+
this.emit('registered' as any, type, registryName, GraftedClass)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
299
506
|
}
|
|
300
507
|
|
|
301
508
|
} catch (err: any) {
|
|
@@ -318,15 +525,137 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
318
525
|
const beforeNames = new Set(registry.available)
|
|
319
526
|
|
|
320
527
|
if (type === 'commands') {
|
|
321
|
-
|
|
528
|
+
if (this.useNativeImport) {
|
|
529
|
+
await commands.discover({ directory: dir })
|
|
530
|
+
} else {
|
|
531
|
+
await this.discoverCommandsViaVM(dir)
|
|
532
|
+
}
|
|
322
533
|
} else if (type === 'endpoints') {
|
|
323
534
|
await this.discoverEndpoints(dir)
|
|
535
|
+
} else if (type === 'selectors') {
|
|
536
|
+
if (this.useNativeImport) {
|
|
537
|
+
await selectors.discover({ directory: dir })
|
|
538
|
+
} else {
|
|
539
|
+
await this.discoverSelectorsViaVM(dir)
|
|
540
|
+
}
|
|
324
541
|
}
|
|
325
542
|
|
|
326
543
|
const afterNames = new Set(registry.available)
|
|
327
544
|
return [...afterNames].filter(n => !beforeNames.has(n))
|
|
328
545
|
}
|
|
329
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Discovers commands using the VM's virtual module system.
|
|
549
|
+
* Mirrors CommandsRegistry.discover() but uses vm.loadModule() instead of import().
|
|
550
|
+
*/
|
|
551
|
+
private async discoverCommandsViaVM(dir: string): Promise<void> {
|
|
552
|
+
this.seedVirtualModules()
|
|
553
|
+
const { Glob } = globalThis.Bun || (await import('bun'))
|
|
554
|
+
const glob = new Glob('*.ts')
|
|
555
|
+
|
|
556
|
+
for await (const file of glob.scan({ cwd: dir })) {
|
|
557
|
+
if (file === 'index.ts') continue
|
|
558
|
+
|
|
559
|
+
const absPath = resolve(dir, file)
|
|
560
|
+
const name = file.replace(/\.ts$/, '')
|
|
561
|
+
|
|
562
|
+
if (commands.has(name)) continue
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const mod = await this.loadModuleExports(absPath)
|
|
566
|
+
|
|
567
|
+
// 1. Class-based: default export extends Command
|
|
568
|
+
if (isNativeHelperClass(mod.default, Command)) {
|
|
569
|
+
const ExportedClass = mod.default
|
|
570
|
+
if (!ExportedClass.shortcut || ExportedClass.shortcut === 'commands.base') {
|
|
571
|
+
ExportedClass.shortcut = `commands.${name}`
|
|
572
|
+
}
|
|
573
|
+
if (!ExportedClass.commandDescription && ExportedClass.description) {
|
|
574
|
+
ExportedClass.commandDescription = ExportedClass.description
|
|
575
|
+
}
|
|
576
|
+
commands.register(name, ExportedClass)
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const commandModule = mod.default || mod
|
|
581
|
+
|
|
582
|
+
// 2. Module-based with `run` export (new SimpleCommand pattern)
|
|
583
|
+
if (typeof commandModule.run === 'function') {
|
|
584
|
+
const Grafted = graftModule(Command as any, commandModule, name, 'commands')
|
|
585
|
+
commands.register(name, Grafted as any)
|
|
586
|
+
continue
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 3. Legacy: `handler` export
|
|
590
|
+
if (typeof commandModule.handler === 'function') {
|
|
591
|
+
const Grafted = graftModule(Command as any, {
|
|
592
|
+
description: commandModule.description,
|
|
593
|
+
argsSchema: commandModule.argsSchema,
|
|
594
|
+
handler: commandModule.handler,
|
|
595
|
+
}, name, 'commands')
|
|
596
|
+
commands.register(name, Grafted as any)
|
|
597
|
+
continue
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 4. Plain default-exported function: export default async function name(options, context)
|
|
601
|
+
if (typeof mod.default === 'function' && !isNativeHelperClass(mod.default, Command)) {
|
|
602
|
+
const Grafted = graftModule(Command as any, {
|
|
603
|
+
description: mod.description || '',
|
|
604
|
+
argsSchema: mod.argsSchema,
|
|
605
|
+
positionals: mod.positionals,
|
|
606
|
+
handler: mod.default,
|
|
607
|
+
}, name, 'commands')
|
|
608
|
+
commands.register(name, Grafted as any)
|
|
609
|
+
}
|
|
610
|
+
} catch (err: any) {
|
|
611
|
+
console.warn(`Helpers gateway: failed to load command from ${absPath}: ${err.message}`)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Discovers selectors using the VM's virtual module system.
|
|
618
|
+
* Mirrors discoverCommandsViaVM but uses selectors registry and Selector base class.
|
|
619
|
+
*/
|
|
620
|
+
private async discoverSelectorsViaVM(dir: string): Promise<void> {
|
|
621
|
+
this.seedVirtualModules()
|
|
622
|
+
const { Glob } = globalThis.Bun || (await import('bun'))
|
|
623
|
+
const glob = new Glob('*.ts')
|
|
624
|
+
|
|
625
|
+
for await (const file of glob.scan({ cwd: dir })) {
|
|
626
|
+
if (file === 'index.ts') continue
|
|
627
|
+
|
|
628
|
+
const absPath = resolve(dir, file)
|
|
629
|
+
const name = file.replace(/\.ts$/, '')
|
|
630
|
+
|
|
631
|
+
if (selectors.has(name)) continue
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
const mod = await this.loadModuleExports(absPath)
|
|
635
|
+
|
|
636
|
+
// 1. Class-based: default export extends Selector
|
|
637
|
+
if (isNativeHelperClass(mod.default, Selector)) {
|
|
638
|
+
const ExportedClass = mod.default
|
|
639
|
+
if (!ExportedClass.shortcut || ExportedClass.shortcut === 'selectors.base') {
|
|
640
|
+
ExportedClass.shortcut = `selectors.${name}`
|
|
641
|
+
}
|
|
642
|
+
selectors.register(name, ExportedClass)
|
|
643
|
+
continue
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const selectorModule = mod.default || mod
|
|
647
|
+
|
|
648
|
+
// 2. Module-based with `run` export
|
|
649
|
+
if (typeof selectorModule.run === 'function') {
|
|
650
|
+
const Grafted = graftModule(Selector as any, selectorModule, name, 'selectors')
|
|
651
|
+
selectors.register(name, Grafted as any)
|
|
652
|
+
}
|
|
653
|
+
} catch (err: any) {
|
|
654
|
+
console.warn(`Helpers gateway: failed to load selector from ${absPath}: ${err.message}`)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
330
659
|
/**
|
|
331
660
|
* Discovers endpoints from a directory, registering them for discoverability.
|
|
332
661
|
* Actual mounting to an express server is handled separately by ExpressServer.useEndpoints().
|
|
@@ -337,7 +666,7 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
|
|
|
337
666
|
|
|
338
667
|
for await (const file of glob.scan({ cwd: dir, absolute: true })) {
|
|
339
668
|
try {
|
|
340
|
-
const mod = await
|
|
669
|
+
const mod = await this.loadModuleExports(file)
|
|
341
670
|
const endpointModule = mod.default || mod
|
|
342
671
|
|
|
343
672
|
if (endpointModule.path && typeof endpointModule.path === 'string') {
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
|
|
2
2
|
import net from 'net'
|
|
3
3
|
import detectPort from 'detect-port'
|
|
4
4
|
import { Feature } from '../feature.js'
|
|
5
|
-
import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
|
|
5
|
+
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
6
6
|
|
|
7
7
|
const MAX_CIDR_HOSTS = 65536
|
|
8
8
|
|
|
@@ -119,6 +119,27 @@ export const NetworkSnapshotSchema = z.object({
|
|
|
119
119
|
})
|
|
120
120
|
export type NetworkSnapshot = z.infer<typeof NetworkSnapshotSchema>
|
|
121
121
|
|
|
122
|
+
export const NetworkingEventsSchema = FeatureEventsSchema.extend({
|
|
123
|
+
'host:discovered': z.tuple([DiscoverHostSchema.describe('The discovered host')]).describe('When a host is found during network scanning'),
|
|
124
|
+
'port:open': z.tuple([z.object({
|
|
125
|
+
host: z.string().describe('Host IP address'),
|
|
126
|
+
port: z.number().describe('Open port number'),
|
|
127
|
+
service: z.string().optional().describe('Best-effort service name'),
|
|
128
|
+
banner: z.string().optional().describe('Banner text when captured'),
|
|
129
|
+
}).describe('Open port details')]).describe('When an open port is detected on a host'),
|
|
130
|
+
'scan:start': z.tuple([z.object({
|
|
131
|
+
target: z.string().describe('Scan target identifier'),
|
|
132
|
+
type: z.string().describe('Scan type identifier'),
|
|
133
|
+
}).describe('Scan start details')]).describe('When a network scan begins'),
|
|
134
|
+
'scan:complete': z.tuple([z.object({
|
|
135
|
+
target: z.string().describe('Scan target identifier'),
|
|
136
|
+
type: z.string().describe('Scan type identifier'),
|
|
137
|
+
duration: z.number().describe('Scan duration in milliseconds'),
|
|
138
|
+
hostsFound: z.number().describe('Number of hosts discovered'),
|
|
139
|
+
portsFound: z.number().describe('Number of open ports discovered'),
|
|
140
|
+
}).describe('Scan completion details')]).describe('When a network scan finishes'),
|
|
141
|
+
}).describe('Networking events')
|
|
142
|
+
|
|
122
143
|
export const NetworkingStateSchema = FeatureStateSchema.extend({
|
|
123
144
|
lastScan: z.object({
|
|
124
145
|
timestamp: z.number().describe('Unix epoch timestamp in ms'),
|
|
@@ -204,6 +225,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
|
|
|
204
225
|
static override shortcut = 'features.networking' as const
|
|
205
226
|
static override stateSchema = NetworkingStateSchema
|
|
206
227
|
static override optionsSchema = NetworkingOptionsSchema
|
|
228
|
+
static override eventsSchema = NetworkingEventsSchema
|
|
207
229
|
static { Feature.register(this, 'networking') }
|
|
208
230
|
|
|
209
231
|
override get initialState(): NetworkingState {
|
|
@@ -214,6 +236,19 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
|
|
|
214
236
|
}
|
|
215
237
|
}
|
|
216
238
|
|
|
239
|
+
private _binCache: Record<string, string> = {}
|
|
240
|
+
|
|
241
|
+
/** Resolve a binary path via `which`, caching the result. */
|
|
242
|
+
private resolveBin(name: string): string {
|
|
243
|
+
if (this._binCache[name]) return this._binCache[name]
|
|
244
|
+
try {
|
|
245
|
+
this._binCache[name] = this.proc.exec(`which ${name}`).trim()
|
|
246
|
+
} catch {
|
|
247
|
+
this._binCache[name] = name
|
|
248
|
+
}
|
|
249
|
+
return this._binCache[name]
|
|
250
|
+
}
|
|
251
|
+
|
|
217
252
|
get proc() {
|
|
218
253
|
return this.container.feature('proc')
|
|
219
254
|
}
|
|
@@ -359,7 +394,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
|
|
|
359
394
|
* Reads and parses the system ARP cache.
|
|
360
395
|
*/
|
|
361
396
|
async getArpTable(): Promise<ArpEntry[]> {
|
|
362
|
-
const output = await this.proc.execAndCapture('arp -a
|
|
397
|
+
const output = await this.proc.execAndCapture(`${this.resolveBin('arp')} -a`)
|
|
363
398
|
if (output.exitCode !== 0) {
|
|
364
399
|
return []
|
|
365
400
|
}
|
|
@@ -605,7 +640,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
|
|
|
605
640
|
}
|
|
606
641
|
|
|
607
642
|
private async isNmapAvailable(): Promise<boolean> {
|
|
608
|
-
const result = await this.proc.spawnAndCapture('nmap', ['--version'])
|
|
643
|
+
const result = await this.proc.spawnAndCapture(this.resolveBin('nmap'), ['--version'])
|
|
609
644
|
return result.exitCode === 0
|
|
610
645
|
}
|
|
611
646
|
|
|
@@ -619,7 +654,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
|
|
|
619
654
|
const startTime = Date.now()
|
|
620
655
|
|
|
621
656
|
const cmdArgs = [...args, '-oG', '-', target]
|
|
622
|
-
const result = await this.proc.spawnAndCapture('nmap', cmdArgs)
|
|
657
|
+
const result = await this.proc.spawnAndCapture(this.resolveBin('nmap'), cmdArgs)
|
|
623
658
|
|
|
624
659
|
if (result.exitCode !== 0) {
|
|
625
660
|
throw new Error(result.stderr || 'nmap scan failed')
|