@dobby.ai/dobby 0.1.0

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 (174) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +267 -0
  3. package/README.md +382 -0
  4. package/ROADMAP.md +34 -0
  5. package/config/cron.example.json +9 -0
  6. package/config/gateway.example.json +128 -0
  7. package/config/models.custom.example.json +27 -0
  8. package/dist/src/agent/event-forwarder.js +341 -0
  9. package/dist/src/agent/tests/event-forwarder.test.js +113 -0
  10. package/dist/src/cli/commands/config.js +243 -0
  11. package/dist/src/cli/commands/configure.js +61 -0
  12. package/dist/src/cli/commands/cron.js +288 -0
  13. package/dist/src/cli/commands/doctor.js +189 -0
  14. package/dist/src/cli/commands/extension.js +151 -0
  15. package/dist/src/cli/commands/init.js +286 -0
  16. package/dist/src/cli/commands/start.js +177 -0
  17. package/dist/src/cli/commands/topology.js +254 -0
  18. package/dist/src/cli/index.js +8 -0
  19. package/dist/src/cli/program.js +386 -0
  20. package/dist/src/cli/shared/config-io.js +223 -0
  21. package/dist/src/cli/shared/config-mutators.js +345 -0
  22. package/dist/src/cli/shared/config-path.js +207 -0
  23. package/dist/src/cli/shared/config-schema.js +159 -0
  24. package/dist/src/cli/shared/config-types.js +1 -0
  25. package/dist/src/cli/shared/configure-sections.js +429 -0
  26. package/dist/src/cli/shared/discord-config.js +12 -0
  27. package/dist/src/cli/shared/init-catalog.js +115 -0
  28. package/dist/src/cli/shared/init-models-file.js +65 -0
  29. package/dist/src/cli/shared/presets.js +86 -0
  30. package/dist/src/cli/shared/runtime.js +29 -0
  31. package/dist/src/cli/shared/schema-prompts.js +325 -0
  32. package/dist/src/cli/tests/config-command.test.js +42 -0
  33. package/dist/src/cli/tests/config-io.test.js +64 -0
  34. package/dist/src/cli/tests/config-mutators.test.js +47 -0
  35. package/dist/src/cli/tests/config-path.test.js +21 -0
  36. package/dist/src/cli/tests/discord-config.test.js +23 -0
  37. package/dist/src/cli/tests/doctor.test.js +107 -0
  38. package/dist/src/cli/tests/init-catalog.test.js +87 -0
  39. package/dist/src/cli/tests/presets.test.js +41 -0
  40. package/dist/src/cli/tests/program-options.test.js +92 -0
  41. package/dist/src/cli/tests/routing-config.test.js +199 -0
  42. package/dist/src/cli/tests/routing-legacy.test.js +191 -0
  43. package/dist/src/core/control-command.js +12 -0
  44. package/dist/src/core/dedup-store.js +92 -0
  45. package/dist/src/core/gateway.js +432 -0
  46. package/dist/src/core/routing.js +306 -0
  47. package/dist/src/core/runtime-registry.js +119 -0
  48. package/dist/src/core/tests/control-command.test.js +17 -0
  49. package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
  50. package/dist/src/core/tests/runtime-registry.test.js +116 -0
  51. package/dist/src/core/tests/typing-controller.test.js +103 -0
  52. package/dist/src/core/types.js +1 -0
  53. package/dist/src/core/typing-controller.js +88 -0
  54. package/dist/src/cron/config.js +114 -0
  55. package/dist/src/cron/schedule.js +49 -0
  56. package/dist/src/cron/service.js +196 -0
  57. package/dist/src/cron/store.js +142 -0
  58. package/dist/src/cron/types.js +1 -0
  59. package/dist/src/extension/loader.js +97 -0
  60. package/dist/src/extension/manager.js +269 -0
  61. package/dist/src/extension/manifest.js +21 -0
  62. package/dist/src/extension/registry.js +137 -0
  63. package/dist/src/main.js +6 -0
  64. package/dist/src/sandbox/executor.js +1 -0
  65. package/dist/src/sandbox/host-executor.js +111 -0
  66. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
  67. package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
  68. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
  69. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
  70. package/docs/MVP.md +135 -0
  71. package/docs/RUNBOOK.md +242 -0
  72. package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
  73. package/package.json +43 -0
  74. package/plugins/connector-discord/dobby.manifest.json +18 -0
  75. package/plugins/connector-discord/index.js +1 -0
  76. package/plugins/connector-discord/package-lock.json +360 -0
  77. package/plugins/connector-discord/package.json +38 -0
  78. package/plugins/connector-discord/src/connector.ts +350 -0
  79. package/plugins/connector-discord/src/contribution.ts +21 -0
  80. package/plugins/connector-discord/src/mapper.ts +102 -0
  81. package/plugins/connector-discord/tsconfig.json +19 -0
  82. package/plugins/connector-feishu/dobby.manifest.json +18 -0
  83. package/plugins/connector-feishu/index.js +1 -0
  84. package/plugins/connector-feishu/package-lock.json +618 -0
  85. package/plugins/connector-feishu/package.json +38 -0
  86. package/plugins/connector-feishu/src/connector.ts +343 -0
  87. package/plugins/connector-feishu/src/contribution.ts +26 -0
  88. package/plugins/connector-feishu/src/mapper.ts +401 -0
  89. package/plugins/connector-feishu/tsconfig.json +19 -0
  90. package/plugins/plugin-sdk/index.d.ts +261 -0
  91. package/plugins/plugin-sdk/index.js +1 -0
  92. package/plugins/plugin-sdk/package-lock.json +12 -0
  93. package/plugins/plugin-sdk/package.json +22 -0
  94. package/plugins/provider-claude/dobby.manifest.json +17 -0
  95. package/plugins/provider-claude/index.js +1 -0
  96. package/plugins/provider-claude/package-lock.json +3398 -0
  97. package/plugins/provider-claude/package.json +39 -0
  98. package/plugins/provider-claude/src/contribution.ts +1018 -0
  99. package/plugins/provider-claude/tsconfig.json +19 -0
  100. package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
  101. package/plugins/provider-claude-cli/index.js +1 -0
  102. package/plugins/provider-claude-cli/package-lock.json +2898 -0
  103. package/plugins/provider-claude-cli/package.json +38 -0
  104. package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
  105. package/plugins/provider-claude-cli/tsconfig.json +19 -0
  106. package/plugins/provider-pi/dobby.manifest.json +17 -0
  107. package/plugins/provider-pi/index.js +1 -0
  108. package/plugins/provider-pi/package-lock.json +3877 -0
  109. package/plugins/provider-pi/package.json +40 -0
  110. package/plugins/provider-pi/src/contribution.ts +476 -0
  111. package/plugins/provider-pi/tsconfig.json +19 -0
  112. package/plugins/sandbox-core/boxlite.js +1 -0
  113. package/plugins/sandbox-core/dobby.manifest.json +17 -0
  114. package/plugins/sandbox-core/docker.js +1 -0
  115. package/plugins/sandbox-core/package-lock.json +136 -0
  116. package/plugins/sandbox-core/package.json +39 -0
  117. package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
  118. package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
  119. package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
  120. package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
  121. package/plugins/sandbox-core/src/docker-executor.ts +217 -0
  122. package/plugins/sandbox-core/tsconfig.json +19 -0
  123. package/scripts/local-extensions.mjs +168 -0
  124. package/src/agent/event-forwarder.ts +414 -0
  125. package/src/cli/commands/config.ts +328 -0
  126. package/src/cli/commands/configure.ts +92 -0
  127. package/src/cli/commands/cron.ts +410 -0
  128. package/src/cli/commands/doctor.ts +230 -0
  129. package/src/cli/commands/extension.ts +205 -0
  130. package/src/cli/commands/init.ts +396 -0
  131. package/src/cli/commands/start.ts +223 -0
  132. package/src/cli/commands/topology.ts +383 -0
  133. package/src/cli/index.ts +9 -0
  134. package/src/cli/program.ts +465 -0
  135. package/src/cli/shared/config-io.ts +277 -0
  136. package/src/cli/shared/config-mutators.ts +440 -0
  137. package/src/cli/shared/config-schema.ts +228 -0
  138. package/src/cli/shared/config-types.ts +121 -0
  139. package/src/cli/shared/configure-sections.ts +551 -0
  140. package/src/cli/shared/discord-config.ts +14 -0
  141. package/src/cli/shared/init-catalog.ts +189 -0
  142. package/src/cli/shared/init-models-file.ts +77 -0
  143. package/src/cli/shared/runtime.ts +33 -0
  144. package/src/cli/shared/schema-prompts.ts +414 -0
  145. package/src/cli/tests/config-command.test.ts +56 -0
  146. package/src/cli/tests/config-io.test.ts +92 -0
  147. package/src/cli/tests/config-mutators.test.ts +59 -0
  148. package/src/cli/tests/doctor.test.ts +120 -0
  149. package/src/cli/tests/init-catalog.test.ts +96 -0
  150. package/src/cli/tests/program-options.test.ts +113 -0
  151. package/src/cli/tests/routing-config.test.ts +209 -0
  152. package/src/core/control-command.ts +12 -0
  153. package/src/core/dedup-store.ts +103 -0
  154. package/src/core/gateway.ts +607 -0
  155. package/src/core/routing.ts +379 -0
  156. package/src/core/runtime-registry.ts +141 -0
  157. package/src/core/tests/control-command.test.ts +20 -0
  158. package/src/core/tests/runtime-registry.test.ts +140 -0
  159. package/src/core/tests/typing-controller.test.ts +129 -0
  160. package/src/core/types.ts +318 -0
  161. package/src/core/typing-controller.ts +119 -0
  162. package/src/cron/config.ts +154 -0
  163. package/src/cron/schedule.ts +61 -0
  164. package/src/cron/service.ts +249 -0
  165. package/src/cron/store.ts +155 -0
  166. package/src/cron/types.ts +60 -0
  167. package/src/extension/loader.ts +145 -0
  168. package/src/extension/manager.ts +355 -0
  169. package/src/extension/manifest.ts +26 -0
  170. package/src/extension/registry.ts +229 -0
  171. package/src/main.ts +8 -0
  172. package/src/sandbox/executor.ts +44 -0
  173. package/src/sandbox/host-executor.ts +118 -0
  174. package/tsconfig.json +18 -0
@@ -0,0 +1,142 @@
1
+ import { access, appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ const jobScheduleSchema = z.discriminatedUnion("kind", [
5
+ z.object({
6
+ kind: z.literal("at"),
7
+ at: z.string().min(1),
8
+ }),
9
+ z.object({
10
+ kind: z.literal("every"),
11
+ everyMs: z.number().int().positive(),
12
+ }),
13
+ z.object({
14
+ kind: z.literal("cron"),
15
+ expr: z.string().min(1),
16
+ tz: z.string().min(1).optional(),
17
+ }),
18
+ ]);
19
+ const scheduledJobSchema = z.object({
20
+ id: z.string().min(1),
21
+ name: z.string().min(1),
22
+ enabled: z.boolean(),
23
+ schedule: jobScheduleSchema,
24
+ sessionPolicy: z.enum(["stateless", "shared-session"]).optional(),
25
+ prompt: z.string(),
26
+ delivery: z.object({
27
+ connectorId: z.string().min(1),
28
+ routeId: z.string().min(1),
29
+ channelId: z.string().min(1),
30
+ threadId: z.string().min(1).optional(),
31
+ }),
32
+ createdAtMs: z.number().int().nonnegative(),
33
+ updatedAtMs: z.number().int().nonnegative(),
34
+ state: z.object({
35
+ nextRunAtMs: z.number().int().nonnegative().optional(),
36
+ runningAtMs: z.number().int().nonnegative().optional(),
37
+ lastRunAtMs: z.number().int().nonnegative().optional(),
38
+ lastStatus: z.enum(["ok", "error", "skipped"]).optional(),
39
+ lastError: z.string().optional(),
40
+ consecutiveErrors: z.number().int().nonnegative().optional(),
41
+ }),
42
+ });
43
+ const snapshotSchema = z.object({
44
+ version: z.literal(1),
45
+ jobs: z.array(scheduledJobSchema),
46
+ });
47
+ function cloneJob(job) {
48
+ return structuredClone(job);
49
+ }
50
+ async function fileExists(filePath) {
51
+ try {
52
+ await access(filePath);
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ async function writeAtomic(filePath, payload) {
60
+ await mkdir(dirname(filePath), { recursive: true });
61
+ const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
62
+ await writeFile(tempPath, payload, "utf-8");
63
+ await rename(tempPath, filePath);
64
+ }
65
+ export class CronStore {
66
+ jobsFilePath;
67
+ runLogFilePath;
68
+ logger;
69
+ jobs = new Map();
70
+ constructor(jobsFilePath, runLogFilePath, logger) {
71
+ this.jobsFilePath = jobsFilePath;
72
+ this.runLogFilePath = runLogFilePath;
73
+ this.logger = logger;
74
+ }
75
+ async load() {
76
+ this.jobs.clear();
77
+ const absolutePath = resolve(this.jobsFilePath);
78
+ if (await fileExists(absolutePath)) {
79
+ try {
80
+ const raw = await readFile(absolutePath, "utf-8");
81
+ const parsed = snapshotSchema.parse(JSON.parse(raw));
82
+ for (const job of parsed.jobs) {
83
+ this.jobs.set(job.id, job);
84
+ }
85
+ }
86
+ catch (error) {
87
+ this.logger.warn({ err: error, filePath: absolutePath }, "Failed to load cron jobs store; starting empty");
88
+ }
89
+ }
90
+ }
91
+ listJobs() {
92
+ return [...this.jobs.values()]
93
+ .map((job) => cloneJob(job))
94
+ .sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id));
95
+ }
96
+ getJob(jobId) {
97
+ const found = this.jobs.get(jobId);
98
+ return found ? cloneJob(found) : null;
99
+ }
100
+ async upsertJob(job) {
101
+ const normalized = scheduledJobSchema.parse(job);
102
+ this.jobs.set(normalized.id, cloneJob(normalized));
103
+ await this.flush();
104
+ }
105
+ async updateJob(jobId, updater) {
106
+ const current = this.jobs.get(jobId);
107
+ if (!current) {
108
+ throw new Error(`Cron job '${jobId}' does not exist`);
109
+ }
110
+ const next = scheduledJobSchema.parse(updater(cloneJob(current)));
111
+ this.jobs.set(jobId, cloneJob(next));
112
+ await this.flush();
113
+ return cloneJob(next);
114
+ }
115
+ async removeJob(jobId) {
116
+ const deleted = this.jobs.delete(jobId);
117
+ if (!deleted) {
118
+ return false;
119
+ }
120
+ await this.flush();
121
+ return true;
122
+ }
123
+ async appendRunLog(record) {
124
+ const line = `${JSON.stringify(record)}\n`;
125
+ try {
126
+ await mkdir(dirname(this.runLogFilePath), { recursive: true });
127
+ await appendFile(this.runLogFilePath, line, "utf-8");
128
+ }
129
+ catch (error) {
130
+ this.logger.warn({ err: error, runLogFilePath: this.runLogFilePath }, "Failed to append cron run log");
131
+ }
132
+ }
133
+ async flush() {
134
+ const snapshot = {
135
+ version: 1,
136
+ jobs: [...this.jobs.values()]
137
+ .map((job) => cloneJob(job))
138
+ .sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id)),
139
+ };
140
+ await writeAtomic(this.jobsFilePath, `${JSON.stringify(snapshot, null, 2)}\n`);
141
+ }
142
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import { access } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { pathToFileURL } from "node:url";
5
+ import { readExtensionManifest } from "./manifest.js";
6
+ function isJavaScriptEntry(entry) {
7
+ return entry.endsWith(".js") || entry.endsWith(".mjs") || entry.endsWith(".cjs");
8
+ }
9
+ function assertWithinRoot(pathToCheck, rootDir) {
10
+ const normalizedRoot = resolve(rootDir);
11
+ const normalizedPath = resolve(pathToCheck);
12
+ if (normalizedPath === normalizedRoot) {
13
+ return;
14
+ }
15
+ const rootPrefix = normalizedRoot.endsWith("/") || normalizedRoot.endsWith("\\")
16
+ ? normalizedRoot
17
+ : `${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`;
18
+ if (!normalizedPath.startsWith(rootPrefix)) {
19
+ throw new Error(`Path '${normalizedPath}' escapes package root '${normalizedRoot}'`);
20
+ }
21
+ }
22
+ function pickContributionModule(loadedModule) {
23
+ if ("default" in loadedModule) {
24
+ return loadedModule.default;
25
+ }
26
+ if ("contribution" in loadedModule) {
27
+ return loadedModule.contribution;
28
+ }
29
+ return undefined;
30
+ }
31
+ export class ExtensionLoader {
32
+ logger;
33
+ options;
34
+ extensionRequire;
35
+ constructor(logger, options) {
36
+ this.logger = logger;
37
+ this.options = options;
38
+ this.extensionRequire = createRequire(join(this.options.extensionsDir, "package.json"));
39
+ }
40
+ async loadAllowList(allowList) {
41
+ const loaded = [];
42
+ for (const packageConfig of allowList) {
43
+ if (packageConfig.enabled === false) {
44
+ this.logger.info({ package: packageConfig.package }, "Skipping disabled extension package");
45
+ continue;
46
+ }
47
+ loaded.push(await this.loadExternalPackage(packageConfig.package));
48
+ }
49
+ return loaded;
50
+ }
51
+ async loadExternalPackage(packageName) {
52
+ let packageJsonPath;
53
+ try {
54
+ packageJsonPath = this.extensionRequire.resolve(`${packageName}/package.json`);
55
+ }
56
+ catch (error) {
57
+ throw new Error(`Extension package '${packageName}' is not installed in '${this.options.extensionsDir}'. ` +
58
+ `Install it with: dobby extension install ${packageName}. ` +
59
+ `Resolver error: ${error instanceof Error ? error.message : String(error)}`);
60
+ }
61
+ const packageRoot = dirname(packageJsonPath);
62
+ const manifestPath = resolve(join(packageRoot, "dobby.manifest.json"));
63
+ const manifest = await readExtensionManifest(manifestPath);
64
+ const contributions = [];
65
+ for (const contributionManifest of manifest.contributions) {
66
+ if (!isJavaScriptEntry(contributionManifest.entry)) {
67
+ throw new Error(`Contribution '${contributionManifest.id}' in package '${packageName}' must use a built JavaScript entry, got '${contributionManifest.entry}'`);
68
+ }
69
+ const entryPath = resolve(packageRoot, contributionManifest.entry);
70
+ assertWithinRoot(entryPath, packageRoot);
71
+ try {
72
+ await access(entryPath);
73
+ }
74
+ catch {
75
+ throw new Error(`Contribution '${contributionManifest.id}' in package '${packageName}' points to missing entry '${contributionManifest.entry}'`);
76
+ }
77
+ const loadedModule = (await import(pathToFileURL(entryPath).href));
78
+ const contributionModule = pickContributionModule(loadedModule);
79
+ if (!contributionModule || typeof contributionModule !== "object") {
80
+ throw new Error(`Extension contribution '${contributionManifest.id}' from package '${packageName}' does not export a valid module`);
81
+ }
82
+ const kind = contributionModule.kind;
83
+ if (kind !== contributionManifest.kind) {
84
+ throw new Error(`Contribution kind mismatch for '${contributionManifest.id}' in package '${packageName}': manifest=${contributionManifest.kind}, module=${kind ?? "unknown"}`);
85
+ }
86
+ contributions.push({
87
+ manifest: contributionManifest,
88
+ module: contributionModule,
89
+ });
90
+ }
91
+ return {
92
+ packageName,
93
+ manifest,
94
+ contributions,
95
+ };
96
+ }
97
+ }
@@ -0,0 +1,269 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { readExtensionManifest } from "./manifest.js";
6
+ const STORE_PACKAGE_NAME = "dobby-extension-store";
7
+ function isJavaScriptEntry(entry) {
8
+ return entry.endsWith(".js") || entry.endsWith(".mjs") || entry.endsWith(".cjs");
9
+ }
10
+ function assertWithinRoot(pathToCheck, rootDir) {
11
+ const normalizedRoot = resolve(rootDir);
12
+ const normalizedPath = resolve(pathToCheck);
13
+ if (normalizedPath === normalizedRoot) {
14
+ return;
15
+ }
16
+ const rootPrefix = normalizedRoot.endsWith("/") || normalizedRoot.endsWith("\\")
17
+ ? normalizedRoot
18
+ : `${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`;
19
+ if (!normalizedPath.startsWith(rootPrefix)) {
20
+ throw new Error(`Path '${normalizedPath}' escapes package root '${normalizedRoot}'`);
21
+ }
22
+ }
23
+ function parsePackageNameFromSpec(packageSpec) {
24
+ const trimmed = packageSpec.trim();
25
+ if (!trimmed)
26
+ return null;
27
+ const scopedMatch = /^(@[^/]+\/[^@]+)(?:@.+)?$/.exec(trimmed);
28
+ if (scopedMatch?.[1]) {
29
+ return scopedMatch[1];
30
+ }
31
+ if (trimmed.startsWith("file:")
32
+ || trimmed.startsWith("git+")
33
+ || trimmed.startsWith("http://")
34
+ || trimmed.startsWith("https://")
35
+ || trimmed.startsWith("./")
36
+ || trimmed.startsWith("../")
37
+ || trimmed.startsWith("/")
38
+ || trimmed.includes("/")) {
39
+ return null;
40
+ }
41
+ const unscopedMatch = /^([^@]+)(?:@.+)?$/.exec(trimmed);
42
+ return unscopedMatch?.[1] ?? null;
43
+ }
44
+ async function parsePackageNameFromLocalSpec(packageSpec) {
45
+ const trimmed = packageSpec.trim();
46
+ if (!trimmed)
47
+ return null;
48
+ let localPath = null;
49
+ if (trimmed.startsWith("file:")) {
50
+ localPath = trimmed.slice("file:".length);
51
+ }
52
+ else if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("/")) {
53
+ localPath = trimmed;
54
+ }
55
+ if (!localPath) {
56
+ return null;
57
+ }
58
+ const packageJsonPath = resolve(process.cwd(), localPath, "package.json");
59
+ try {
60
+ const raw = await readFile(packageJsonPath, "utf-8");
61
+ const parsed = JSON.parse(raw);
62
+ return typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ async function pickInstalledPackageName(packageSpec, beforeDeps, afterDeps) {
69
+ const changedPackages = Object.entries(afterDeps)
70
+ .filter(([name, version]) => beforeDeps[name] !== version)
71
+ .map(([name]) => name);
72
+ if (changedPackages.length === 1) {
73
+ return changedPackages[0];
74
+ }
75
+ const inferred = parsePackageNameFromSpec(packageSpec);
76
+ if (inferred && afterDeps[inferred]) {
77
+ return inferred;
78
+ }
79
+ const localInferred = await parsePackageNameFromLocalSpec(packageSpec);
80
+ if (localInferred && afterDeps[localInferred]) {
81
+ return localInferred;
82
+ }
83
+ if (changedPackages.length > 0) {
84
+ return changedPackages[0];
85
+ }
86
+ throw new Error(`Could not determine installed package from spec '${packageSpec}'. Run 'extension list' to inspect extension store state.`);
87
+ }
88
+ export class ExtensionStoreManager {
89
+ logger;
90
+ extensionsDir;
91
+ constructor(logger, extensionsDir) {
92
+ this.logger = logger;
93
+ this.extensionsDir = extensionsDir;
94
+ }
95
+ async ensureStoreInitialized() {
96
+ await mkdir(this.extensionsDir, { recursive: true });
97
+ const storePackageJsonPath = this.storePackageJsonPath();
98
+ try {
99
+ await access(storePackageJsonPath);
100
+ return;
101
+ }
102
+ catch {
103
+ const payload = {
104
+ name: STORE_PACKAGE_NAME,
105
+ private: true,
106
+ description: "Managed extension store for dobby",
107
+ };
108
+ await writeFile(storePackageJsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
109
+ }
110
+ }
111
+ async install(packageSpec) {
112
+ const installed = await this.installMany([packageSpec]);
113
+ if (installed.length === 0) {
114
+ throw new Error(`Failed to install package from spec '${packageSpec}'`);
115
+ }
116
+ return installed[0];
117
+ }
118
+ async installMany(packageSpecs) {
119
+ const normalizedSpecs = packageSpecs
120
+ .map((item) => item.trim())
121
+ .filter((item) => item.length > 0);
122
+ if (normalizedSpecs.length === 0) {
123
+ return [];
124
+ }
125
+ await this.ensureStoreInitialized();
126
+ const beforeDeps = await this.readDependencies();
127
+ await this.runNpm(["install", "--prefix", this.extensionsDir, "--save-exact", ...normalizedSpecs]);
128
+ const afterDeps = await this.readDependencies();
129
+ const seenPackages = new Set();
130
+ const installedPackages = [];
131
+ for (const spec of normalizedSpecs) {
132
+ const packageName = await this.resolveInstalledPackageName(spec, beforeDeps, afterDeps, normalizedSpecs.length > 1);
133
+ if (seenPackages.has(packageName)) {
134
+ continue;
135
+ }
136
+ seenPackages.add(packageName);
137
+ const version = afterDeps[packageName];
138
+ if (!version) {
139
+ throw new Error(`Package '${packageName}' is not present in extension store after installation`);
140
+ }
141
+ const installed = await this.readInstalledExtension(packageName, version);
142
+ this.logger.info({
143
+ package: installed.packageName,
144
+ version: installed.version,
145
+ contributions: installed.manifest.contributions.map((item) => `${item.kind}:${item.id}`),
146
+ }, "Extension installed");
147
+ installedPackages.push(installed);
148
+ }
149
+ return installedPackages;
150
+ }
151
+ async uninstall(packageName) {
152
+ await this.ensureStoreInitialized();
153
+ await this.runNpm(["uninstall", "--prefix", this.extensionsDir, packageName]);
154
+ this.logger.info({ package: packageName }, "Extension uninstalled");
155
+ }
156
+ async listInstalled() {
157
+ await this.ensureStoreInitialized();
158
+ const dependencies = await this.readDependencies();
159
+ const listed = [];
160
+ const names = Object.keys(dependencies).sort((a, b) => a.localeCompare(b));
161
+ for (const packageName of names) {
162
+ const version = dependencies[packageName];
163
+ try {
164
+ const installed = await this.readInstalledExtension(packageName, version);
165
+ listed.push({
166
+ packageName,
167
+ version,
168
+ manifest: installed.manifest,
169
+ });
170
+ }
171
+ catch (error) {
172
+ listed.push({
173
+ packageName,
174
+ version,
175
+ error: error instanceof Error ? error.message : String(error),
176
+ });
177
+ }
178
+ }
179
+ return listed;
180
+ }
181
+ storePackageJsonPath() {
182
+ return join(this.extensionsDir, "package.json");
183
+ }
184
+ createStoreRequire() {
185
+ return createRequire(this.storePackageJsonPath());
186
+ }
187
+ async readDependencies() {
188
+ const path = this.storePackageJsonPath();
189
+ try {
190
+ const raw = await readFile(path, "utf-8");
191
+ const parsed = JSON.parse(raw);
192
+ return parsed.dependencies ?? {};
193
+ }
194
+ catch {
195
+ return {};
196
+ }
197
+ }
198
+ async readInstalledExtension(packageName, version) {
199
+ const storeRequire = this.createStoreRequire();
200
+ let packageJsonPath;
201
+ try {
202
+ packageJsonPath = storeRequire.resolve(`${packageName}/package.json`);
203
+ }
204
+ catch (error) {
205
+ throw new Error(`Package '${packageName}' is declared in extension store but cannot be resolved: ${error instanceof Error ? error.message : String(error)}`);
206
+ }
207
+ const packageRoot = dirname(packageJsonPath);
208
+ const manifestPath = resolve(packageRoot, "dobby.manifest.json");
209
+ const manifest = await readExtensionManifest(manifestPath);
210
+ for (const contribution of manifest.contributions) {
211
+ if (!isJavaScriptEntry(contribution.entry)) {
212
+ throw new Error(`Contribution '${contribution.id}' in package '${packageName}' must use a built JavaScript entry, got '${contribution.entry}'`);
213
+ }
214
+ const entryPath = resolve(packageRoot, contribution.entry);
215
+ assertWithinRoot(entryPath, packageRoot);
216
+ try {
217
+ await access(entryPath);
218
+ }
219
+ catch {
220
+ throw new Error(`Contribution '${contribution.id}' in package '${packageName}' points to missing entry '${contribution.entry}'`);
221
+ }
222
+ }
223
+ return {
224
+ packageName,
225
+ version,
226
+ manifest,
227
+ };
228
+ }
229
+ async resolveInstalledPackageName(packageSpec, beforeDeps, afterDeps, isBatchInstall) {
230
+ const inferred = parsePackageNameFromSpec(packageSpec);
231
+ if (inferred && afterDeps[inferred]) {
232
+ return inferred;
233
+ }
234
+ const inferredLocal = await parsePackageNameFromLocalSpec(packageSpec);
235
+ if (inferredLocal && afterDeps[inferredLocal]) {
236
+ return inferredLocal;
237
+ }
238
+ if (!isBatchInstall) {
239
+ return pickInstalledPackageName(packageSpec, beforeDeps, afterDeps);
240
+ }
241
+ const changedPackages = Object.entries(afterDeps)
242
+ .filter(([name, version]) => beforeDeps[name] !== version)
243
+ .map(([name]) => name);
244
+ if (changedPackages.length === 1) {
245
+ return changedPackages[0];
246
+ }
247
+ throw new Error(`Could not determine installed package from spec '${packageSpec}' in batch install mode. ` +
248
+ "Use explicit npm package names for init/installMany.");
249
+ }
250
+ async runNpm(args) {
251
+ await new Promise((resolvePromise, rejectPromise) => {
252
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
253
+ const child = spawn(command, args, {
254
+ stdio: "inherit",
255
+ env: process.env,
256
+ });
257
+ child.once("error", (error) => {
258
+ rejectPromise(error);
259
+ });
260
+ child.once("exit", (code) => {
261
+ if (code === 0) {
262
+ resolvePromise();
263
+ return;
264
+ }
265
+ rejectPromise(new Error(`npm ${args.join(" ")} failed with exit code ${code ?? "unknown"}`));
266
+ });
267
+ });
268
+ }
269
+ }
@@ -0,0 +1,21 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { z } from "zod";
3
+ const contributionManifestSchema = z.object({
4
+ id: z.string().min(1),
5
+ kind: z.enum(["provider", "connector", "sandbox"]),
6
+ entry: z.string().min(1),
7
+ capabilities: z.record(z.string(), z.unknown()).optional(),
8
+ });
9
+ const manifestSchema = z.object({
10
+ apiVersion: z.string().min(1),
11
+ name: z.string().min(1),
12
+ version: z.string().min(1),
13
+ contributions: z.array(contributionManifestSchema).min(1),
14
+ });
15
+ export function parseExtensionManifest(value) {
16
+ return manifestSchema.parse(value);
17
+ }
18
+ export async function readExtensionManifest(manifestPath) {
19
+ const raw = await readFile(manifestPath, "utf-8");
20
+ return parseExtensionManifest(JSON.parse(raw));
21
+ }
@@ -0,0 +1,137 @@
1
+ import { join } from "node:path";
2
+ function isRecord(value) {
3
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+ function normalizeContributionSchema(value) {
6
+ if (!isRecord(value)) {
7
+ return undefined;
8
+ }
9
+ return value;
10
+ }
11
+ export class ExtensionRegistry {
12
+ providers = new Map();
13
+ connectors = new Map();
14
+ sandboxes = new Map();
15
+ contributionSchemas = new Map();
16
+ registerPackages(loadedPackages) {
17
+ for (const extensionPackage of loadedPackages) {
18
+ for (const contribution of extensionPackage.contributions) {
19
+ if (this.contributionSchemas.has(contribution.manifest.id)) {
20
+ throw new Error(`Duplicate contribution id '${contribution.manifest.id}'`);
21
+ }
22
+ const normalizedSchema = normalizeContributionSchema(contribution.module.configSchema);
23
+ this.contributionSchemas.set(contribution.manifest.id, {
24
+ contributionId: contribution.manifest.id,
25
+ packageName: extensionPackage.packageName,
26
+ kind: contribution.manifest.kind,
27
+ ...(normalizedSchema ? { configSchema: normalizedSchema } : {}),
28
+ });
29
+ if (contribution.manifest.kind === "provider") {
30
+ const module = contribution.module;
31
+ if (this.providers.has(contribution.manifest.id)) {
32
+ throw new Error(`Duplicate provider contribution id '${contribution.manifest.id}'`);
33
+ }
34
+ this.providers.set(contribution.manifest.id, {
35
+ contributionId: contribution.manifest.id,
36
+ packageName: extensionPackage.packageName,
37
+ createInstance: module.createInstance,
38
+ });
39
+ continue;
40
+ }
41
+ if (contribution.manifest.kind === "connector") {
42
+ const module = contribution.module;
43
+ if (this.connectors.has(contribution.manifest.id)) {
44
+ throw new Error(`Duplicate connector contribution id '${contribution.manifest.id}'`);
45
+ }
46
+ this.connectors.set(contribution.manifest.id, {
47
+ contributionId: contribution.manifest.id,
48
+ packageName: extensionPackage.packageName,
49
+ createInstance: module.createInstance,
50
+ });
51
+ continue;
52
+ }
53
+ const module = contribution.module;
54
+ if (this.sandboxes.has(contribution.manifest.id)) {
55
+ throw new Error(`Duplicate sandbox contribution id '${contribution.manifest.id}'`);
56
+ }
57
+ this.sandboxes.set(contribution.manifest.id, {
58
+ contributionId: contribution.manifest.id,
59
+ packageName: extensionPackage.packageName,
60
+ createInstance: module.createInstance,
61
+ });
62
+ }
63
+ }
64
+ }
65
+ listContributionSchemas() {
66
+ return [...this.contributionSchemas.values()]
67
+ .sort((a, b) => a.contributionId.localeCompare(b.contributionId))
68
+ .map((item) => ({
69
+ contributionId: item.contributionId,
70
+ packageName: item.packageName,
71
+ kind: item.kind,
72
+ ...(item.configSchema ? { configSchema: item.configSchema } : {}),
73
+ }));
74
+ }
75
+ getContributionSchema(contributionId) {
76
+ const found = this.contributionSchemas.get(contributionId);
77
+ if (!found) {
78
+ return null;
79
+ }
80
+ return {
81
+ contributionId: found.contributionId,
82
+ packageName: found.packageName,
83
+ kind: found.kind,
84
+ ...(found.configSchema ? { configSchema: found.configSchema } : {}),
85
+ };
86
+ }
87
+ async createProviderInstances(config, context, data) {
88
+ const instances = new Map();
89
+ for (const [instanceId, instanceConfig] of Object.entries(config.items)) {
90
+ const contribution = this.providers.get(instanceConfig.type);
91
+ if (!contribution) {
92
+ throw new Error(`Provider instance '${instanceId}' references unknown contribution '${instanceConfig.type}'`);
93
+ }
94
+ const instance = await contribution.createInstance({
95
+ instanceId,
96
+ config: instanceConfig.config,
97
+ host: context,
98
+ data,
99
+ });
100
+ instances.set(instanceId, instance);
101
+ }
102
+ return instances;
103
+ }
104
+ async createConnectorInstances(config, context, attachmentsBaseDir) {
105
+ const instances = [];
106
+ for (const [instanceId, instanceConfig] of Object.entries(config.items)) {
107
+ const contribution = this.connectors.get(instanceConfig.type);
108
+ if (!contribution) {
109
+ throw new Error(`Connector instance '${instanceId}' references unknown contribution '${instanceConfig.type}'`);
110
+ }
111
+ const connector = await contribution.createInstance({
112
+ instanceId,
113
+ config: instanceConfig.config,
114
+ host: context,
115
+ attachmentsRoot: join(attachmentsBaseDir, instanceId),
116
+ });
117
+ instances.push(connector);
118
+ }
119
+ return instances;
120
+ }
121
+ async createSandboxInstances(config, context) {
122
+ const instances = new Map();
123
+ for (const [instanceId, instanceConfig] of Object.entries(config.items)) {
124
+ const contribution = this.sandboxes.get(instanceConfig.type);
125
+ if (!contribution) {
126
+ throw new Error(`Sandbox instance '${instanceId}' references unknown contribution '${instanceConfig.type}'`);
127
+ }
128
+ const sandbox = await contribution.createInstance({
129
+ instanceId,
130
+ config: instanceConfig.config,
131
+ host: context,
132
+ });
133
+ instances.set(instanceId, sandbox);
134
+ }
135
+ return instances;
136
+ }
137
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli/index.js";
3
+ void runCli().catch((error) => {
4
+ console.error(error instanceof Error ? error.message : String(error));
5
+ process.exit(1);
6
+ });
@@ -0,0 +1 @@
1
+ export {};