@aigne/afs-cli 1.11.0-beta.12 → 1.11.0-beta.13
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/dist/config/afs-loader.cjs +0 -28
- package/dist/config/afs-loader.d.cts.map +1 -1
- package/dist/config/afs-loader.d.mts.map +1 -1
- package/dist/config/afs-loader.mjs +5 -33
- package/dist/config/afs-loader.mjs.map +1 -1
- package/dist/config/credential-helpers.cjs +14 -2
- package/dist/config/credential-helpers.mjs +14 -2
- package/dist/config/credential-helpers.mjs.map +1 -1
- package/dist/config/program-install.cjs +174 -0
- package/dist/config/program-install.mjs +173 -2
- package/dist/config/program-install.mjs.map +1 -1
- package/dist/core/commands/daemon.cjs +5 -1
- package/dist/core/commands/daemon.mjs +5 -1
- package/dist/core/commands/daemon.mjs.map +1 -1
- package/dist/core/commands/index.d.cts.map +1 -1
- package/dist/core/commands/index.d.mts.map +1 -1
- package/dist/core/commands/index.mjs.map +1 -1
- package/dist/core/commands/install.cjs +49 -1
- package/dist/core/commands/install.mjs +51 -3
- package/dist/core/commands/install.mjs.map +1 -1
- package/dist/core/formatters/install.cjs +19 -0
- package/dist/core/formatters/install.mjs +18 -1
- package/dist/core/formatters/install.mjs.map +1 -1
- package/dist/credential/auth-server.cjs +22 -4
- package/dist/credential/auth-server.mjs +22 -4
- package/dist/credential/auth-server.mjs.map +1 -1
- package/dist/credential/resolver.cjs +7 -4
- package/dist/credential/resolver.mjs +7 -4
- package/dist/credential/resolver.mjs.map +1 -1
- package/dist/program/program-manager.cjs +5 -1
- package/dist/program/program-manager.mjs +5 -1
- package/dist/program/program-manager.mjs.map +1 -1
- package/dist/repl.cjs +5 -1
- package/dist/repl.mjs +5 -1
- package/dist/repl.mjs.map +1 -1
- package/package.json +28 -28
|
@@ -14,8 +14,27 @@ function formatInstallRemoveOutput(result, view) {
|
|
|
14
14
|
const suffix = result.purgedData ? " (data purged)" : "";
|
|
15
15
|
return `Removed program "${result.programId}"${suffix}`;
|
|
16
16
|
}
|
|
17
|
+
function formatConfigureListOutput(result, view) {
|
|
18
|
+
if (view === "json") return JSON.stringify(result, null, 2);
|
|
19
|
+
if (result.mounts.length === 0) return `Program "${result.programId}" has no mounts`;
|
|
20
|
+
const lines = result.mounts.map((m) => {
|
|
21
|
+
const uri = m.configuredUri || m.uri;
|
|
22
|
+
const req = m.required ? "required" : "optional";
|
|
23
|
+
const status = m.hasCredentials || m.configuredUri || m.hasOptions ? "configured" : "not configured";
|
|
24
|
+
return ` ${m.path.padEnd(14)} ${uri.padEnd(24)} (${req}) ${status}`;
|
|
25
|
+
});
|
|
26
|
+
return [`Mounts for "${result.programId}":`, ...lines].join("\n");
|
|
27
|
+
}
|
|
28
|
+
function formatConfigureOutput(result, view) {
|
|
29
|
+
if (view === "json") return JSON.stringify(result, null, 2);
|
|
30
|
+
if (result.configuredMounts.length === 0) return `Program "${result.programId}" has no mounts to configure`;
|
|
31
|
+
const lines = result.configuredMounts.map((m) => ` ${m.target} → ${m.uri}`);
|
|
32
|
+
return [`Configured ${result.configuredMounts.length} mount(s) for "${result.programId}":`, ...lines].join("\n");
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
//#endregion
|
|
36
|
+
exports.formatConfigureListOutput = formatConfigureListOutput;
|
|
37
|
+
exports.formatConfigureOutput = formatConfigureOutput;
|
|
19
38
|
exports.formatInstallAddOutput = formatInstallAddOutput;
|
|
20
39
|
exports.formatInstallListOutput = formatInstallListOutput;
|
|
21
40
|
exports.formatInstallRemoveOutput = formatInstallRemoveOutput;
|
|
@@ -13,7 +13,24 @@ function formatInstallRemoveOutput(result, view) {
|
|
|
13
13
|
const suffix = result.purgedData ? " (data purged)" : "";
|
|
14
14
|
return `Removed program "${result.programId}"${suffix}`;
|
|
15
15
|
}
|
|
16
|
+
function formatConfigureListOutput(result, view) {
|
|
17
|
+
if (view === "json") return JSON.stringify(result, null, 2);
|
|
18
|
+
if (result.mounts.length === 0) return `Program "${result.programId}" has no mounts`;
|
|
19
|
+
const lines = result.mounts.map((m) => {
|
|
20
|
+
const uri = m.configuredUri || m.uri;
|
|
21
|
+
const req = m.required ? "required" : "optional";
|
|
22
|
+
const status = m.hasCredentials || m.configuredUri || m.hasOptions ? "configured" : "not configured";
|
|
23
|
+
return ` ${m.path.padEnd(14)} ${uri.padEnd(24)} (${req}) ${status}`;
|
|
24
|
+
});
|
|
25
|
+
return [`Mounts for "${result.programId}":`, ...lines].join("\n");
|
|
26
|
+
}
|
|
27
|
+
function formatConfigureOutput(result, view) {
|
|
28
|
+
if (view === "json") return JSON.stringify(result, null, 2);
|
|
29
|
+
if (result.configuredMounts.length === 0) return `Program "${result.programId}" has no mounts to configure`;
|
|
30
|
+
const lines = result.configuredMounts.map((m) => ` ${m.target} → ${m.uri}`);
|
|
31
|
+
return [`Configured ${result.configuredMounts.length} mount(s) for "${result.programId}":`, ...lines].join("\n");
|
|
32
|
+
}
|
|
16
33
|
|
|
17
34
|
//#endregion
|
|
18
|
-
export { formatInstallAddOutput, formatInstallListOutput, formatInstallRemoveOutput };
|
|
35
|
+
export { formatConfigureListOutput, formatConfigureOutput, formatInstallAddOutput, formatInstallListOutput, formatInstallRemoveOutput };
|
|
19
36
|
//# sourceMappingURL=install.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"install.mjs","names":[],"sources":["../../../src/core/formatters/install.ts"],"sourcesContent":["/**\n * Install Command Formatters\n */\n\nimport type {\n InstalledProgram,\n InstallResult,\n RemoveResult,\n} from \"../../config/program-install.js\";\nimport type { ViewType } from \"../types.js\";\n\nexport function formatInstallAddOutput(result: InstallResult, view?: ViewType): string {\n if (view === \"json\") {\n return JSON.stringify(result, null, 2);\n }\n return `Installed \"${result.programName}\" (${result.programId}) at ${result.mountPath}`;\n}\n\nexport function formatInstallListOutput(programs: InstalledProgram[], view?: ViewType): string {\n if (view === \"json\") {\n return JSON.stringify(programs, null, 2);\n }\n if (programs.length === 0) {\n return \"No programs installed\";\n }\n const lines = programs.map((p) => ` ${p.id} ${p.name} ${p.entrypoint} ${p.mountPath}`);\n return [\"Installed programs:\", ...lines].join(\"\\n\");\n}\n\nexport function formatInstallRemoveOutput(result: RemoveResult, view?: ViewType): string {\n if (view === \"json\") {\n return JSON.stringify(result, null, 2);\n }\n const suffix = result.purgedData ? \" (data purged)\" : \"\";\n return `Removed program \"${result.programId}\"${suffix}`;\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"install.mjs","names":[],"sources":["../../../src/core/formatters/install.ts"],"sourcesContent":["/**\n * Install Command Formatters\n */\n\nimport type {\n ConfigureResult,\n InstalledProgram,\n InstallResult,\n MountStatus,\n RemoveResult,\n} from \"../../config/program-install.js\";\nimport type { ViewType } from \"../types.js\";\n\nexport function formatInstallAddOutput(result: InstallResult, view?: ViewType): string {\n if (view === \"json\") {\n return JSON.stringify(result, null, 2);\n }\n return `Installed \"${result.programName}\" (${result.programId}) at ${result.mountPath}`;\n}\n\nexport function formatInstallListOutput(programs: InstalledProgram[], view?: ViewType): string {\n if (view === \"json\") {\n return JSON.stringify(programs, null, 2);\n }\n if (programs.length === 0) {\n return \"No programs installed\";\n }\n const lines = programs.map((p) => ` ${p.id} ${p.name} ${p.entrypoint} ${p.mountPath}`);\n return [\"Installed programs:\", ...lines].join(\"\\n\");\n}\n\nexport function formatInstallRemoveOutput(result: RemoveResult, view?: ViewType): string {\n if (view === \"json\") {\n return JSON.stringify(result, null, 2);\n }\n const suffix = result.purgedData ? \" (data purged)\" : \"\";\n return `Removed program \"${result.programId}\"${suffix}`;\n}\n\nexport function formatConfigureListOutput(\n result: { programId: string; mounts: MountStatus[] },\n view?: ViewType,\n): string {\n if (view === \"json\") {\n return JSON.stringify(result, null, 2);\n }\n if (result.mounts.length === 0) {\n return `Program \"${result.programId}\" has no mounts`;\n }\n const lines = result.mounts.map((m) => {\n const uri = m.configuredUri || m.uri;\n const req = m.required ? \"required\" : \"optional\";\n const status =\n m.hasCredentials || m.configuredUri || m.hasOptions ? \"configured\" : \"not configured\";\n return ` ${m.path.padEnd(14)} ${uri.padEnd(24)} (${req}) ${status}`;\n });\n return [`Mounts for \"${result.programId}\":`, ...lines].join(\"\\n\");\n}\n\nexport function formatConfigureOutput(result: ConfigureResult, view?: ViewType): string {\n if (view === \"json\") {\n return JSON.stringify(result, null, 2);\n }\n if (result.configuredMounts.length === 0) {\n return `Program \"${result.programId}\" has no mounts to configure`;\n }\n const lines = result.configuredMounts.map((m) => ` ${m.target} → ${m.uri}`);\n return [\n `Configured ${result.configuredMounts.length} mount(s) for \"${result.programId}\":`,\n ...lines,\n ].join(\"\\n\");\n}\n"],"mappings":";AAaA,SAAgB,uBAAuB,QAAuB,MAAyB;AACrF,KAAI,SAAS,OACX,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE;AAExC,QAAO,cAAc,OAAO,YAAY,KAAK,OAAO,UAAU,OAAO,OAAO;;AAG9E,SAAgB,wBAAwB,UAA8B,MAAyB;AAC7F,KAAI,SAAS,OACX,QAAO,KAAK,UAAU,UAAU,MAAM,EAAE;AAE1C,KAAI,SAAS,WAAW,EACtB,QAAO;AAGT,QAAO,CAAC,uBAAuB,GADjB,SAAS,KAAK,MAAM,KAAK,EAAE,GAAG,IAAI,EAAE,KAAK,IAAI,EAAE,WAAW,IAAI,EAAE,YAAY,CAClD,CAAC,KAAK,KAAK;;AAGrD,SAAgB,0BAA0B,QAAsB,MAAyB;AACvF,KAAI,SAAS,OACX,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE;CAExC,MAAM,SAAS,OAAO,aAAa,mBAAmB;AACtD,QAAO,oBAAoB,OAAO,UAAU,GAAG;;AAGjD,SAAgB,0BACd,QACA,MACQ;AACR,KAAI,SAAS,OACX,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE;AAExC,KAAI,OAAO,OAAO,WAAW,EAC3B,QAAO,YAAY,OAAO,UAAU;CAEtC,MAAM,QAAQ,OAAO,OAAO,KAAK,MAAM;EACrC,MAAM,MAAM,EAAE,iBAAiB,EAAE;EACjC,MAAM,MAAM,EAAE,WAAW,aAAa;EACtC,MAAM,SACJ,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,aAAa,eAAe;AACvE,SAAO,KAAK,EAAE,KAAK,OAAO,GAAG,CAAC,GAAG,IAAI,OAAO,GAAG,CAAC,IAAI,IAAI,KAAK;GAC7D;AACF,QAAO,CAAC,eAAe,OAAO,UAAU,KAAK,GAAG,MAAM,CAAC,KAAK,KAAK;;AAGnE,SAAgB,sBAAsB,QAAyB,MAAyB;AACtF,KAAI,SAAS,OACX,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE;AAExC,KAAI,OAAO,iBAAiB,WAAW,EACrC,QAAO,YAAY,OAAO,UAAU;CAEtC,MAAM,QAAQ,OAAO,iBAAiB,KAAK,MAAM,KAAK,EAAE,OAAO,KAAK,EAAE,MAAM;AAC5E,QAAO,CACL,cAAc,OAAO,iBAAiB,OAAO,iBAAiB,OAAO,UAAU,KAC/E,GAAG,MACJ,CAAC,KAAK,KAAK"}
|
|
@@ -200,9 +200,26 @@ function renderFormHTML(schema, title, nonce) {
|
|
|
200
200
|
const label = prop.description || key;
|
|
201
201
|
const isSensitive = prop.sensitive === true;
|
|
202
202
|
const isRequired = required.has(key);
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
const defaultValue = prop.default != null ? prop.default : "";
|
|
204
|
+
if (prop.type === "boolean") {
|
|
205
|
+
const checked = defaultValue === true || defaultValue === "true";
|
|
206
|
+
fields += `
|
|
207
|
+
<div style="margin-bottom: 12px;">
|
|
208
|
+
<label style="display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer;">
|
|
209
|
+
<input type="hidden" name="${escapeHTML(key)}" value="false" />
|
|
210
|
+
<input
|
|
211
|
+
type="checkbox"
|
|
212
|
+
name="${escapeHTML(key)}"
|
|
213
|
+
value="true"
|
|
214
|
+
${checked ? "checked" : ""}
|
|
215
|
+
style="width: 18px; height: 18px;"
|
|
216
|
+
/>
|
|
217
|
+
${escapeHTML(label)}
|
|
218
|
+
</label>
|
|
219
|
+
</div>`;
|
|
220
|
+
} else {
|
|
221
|
+
const inputType = isSensitive ? "password" : "text";
|
|
222
|
+
fields += `
|
|
206
223
|
<div style="margin-bottom: 12px;">
|
|
207
224
|
<label for="${escapeHTML(key)}" style="display: block; font-weight: 600; margin-bottom: 4px;">
|
|
208
225
|
${escapeHTML(label)}${isRequired ? " *" : ""}
|
|
@@ -211,12 +228,13 @@ function renderFormHTML(schema, title, nonce) {
|
|
|
211
228
|
type="${inputType}"
|
|
212
229
|
id="${escapeHTML(key)}"
|
|
213
230
|
name="${escapeHTML(key)}"
|
|
214
|
-
value="${escapeHTML(defaultValue)}"
|
|
231
|
+
value="${escapeHTML(String(defaultValue))}"
|
|
215
232
|
${isRequired ? "required" : ""}
|
|
216
233
|
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;"
|
|
217
234
|
autocomplete="off"
|
|
218
235
|
/>
|
|
219
236
|
</div>`;
|
|
237
|
+
}
|
|
220
238
|
}
|
|
221
239
|
return `<!DOCTYPE html>
|
|
222
240
|
<html>
|
|
@@ -199,9 +199,26 @@ function renderFormHTML(schema, title, nonce) {
|
|
|
199
199
|
const label = prop.description || key;
|
|
200
200
|
const isSensitive = prop.sensitive === true;
|
|
201
201
|
const isRequired = required.has(key);
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
const defaultValue = prop.default != null ? prop.default : "";
|
|
203
|
+
if (prop.type === "boolean") {
|
|
204
|
+
const checked = defaultValue === true || defaultValue === "true";
|
|
205
|
+
fields += `
|
|
206
|
+
<div style="margin-bottom: 12px;">
|
|
207
|
+
<label style="display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer;">
|
|
208
|
+
<input type="hidden" name="${escapeHTML(key)}" value="false" />
|
|
209
|
+
<input
|
|
210
|
+
type="checkbox"
|
|
211
|
+
name="${escapeHTML(key)}"
|
|
212
|
+
value="true"
|
|
213
|
+
${checked ? "checked" : ""}
|
|
214
|
+
style="width: 18px; height: 18px;"
|
|
215
|
+
/>
|
|
216
|
+
${escapeHTML(label)}
|
|
217
|
+
</label>
|
|
218
|
+
</div>`;
|
|
219
|
+
} else {
|
|
220
|
+
const inputType = isSensitive ? "password" : "text";
|
|
221
|
+
fields += `
|
|
205
222
|
<div style="margin-bottom: 12px;">
|
|
206
223
|
<label for="${escapeHTML(key)}" style="display: block; font-weight: 600; margin-bottom: 4px;">
|
|
207
224
|
${escapeHTML(label)}${isRequired ? " *" : ""}
|
|
@@ -210,12 +227,13 @@ function renderFormHTML(schema, title, nonce) {
|
|
|
210
227
|
type="${inputType}"
|
|
211
228
|
id="${escapeHTML(key)}"
|
|
212
229
|
name="${escapeHTML(key)}"
|
|
213
|
-
value="${escapeHTML(defaultValue)}"
|
|
230
|
+
value="${escapeHTML(String(defaultValue))}"
|
|
214
231
|
${isRequired ? "required" : ""}
|
|
215
232
|
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;"
|
|
216
233
|
autocomplete="off"
|
|
217
234
|
/>
|
|
218
235
|
</div>`;
|
|
236
|
+
}
|
|
219
237
|
}
|
|
220
238
|
return `<!DOCTYPE html>
|
|
221
239
|
<html>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-server.mjs","names":[],"sources":["../../src/credential/auth-server.ts"],"sourcesContent":["/**\n * Temporary HTTP Server for credential collection.\n *\n * Supports two modes:\n * 1. Form collection: GET /auth renders a form from JSON Schema, POST /auth returns submitted data\n * 2. OAuth callback: GET /callback captures query params for waitForCallback()\n *\n * Security:\n * - Binds to 127.0.0.1 only (no external connections)\n * - One-time nonce per request to prevent CSRF\n * - Auto-closes after 5 minutes\n * - POST data is never logged\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { createServer, type IncomingMessage, type Server, type ServerResponse } from \"node:http\";\n\nconst DEFAULT_TIMEOUT = 5 * 60 * 1000; // 5 minutes\nconst MAX_PORT_RETRIES = 3;\n\nexport interface AuthServer {\n /** Base URL of the server, e.g. http://127.0.0.1:12345 */\n baseURL: string;\n\n /** Callback URL for OAuth redirects: baseURL + /callback */\n callbackURL: string;\n\n /** Wait for a callback request at /callback. Returns query params or null on timeout/close. */\n waitForCallback(options?: { timeout?: number }): Promise<Record<string, string> | null>;\n\n /**\n * Serve a form for collecting fields, wait for submission.\n * Returns submitted values or null on timeout/close.\n *\n * @param schema - JSON Schema describing fields to collect\n * @param options - Optional title and timeout\n */\n waitForForm(\n schema: Record<string, any>,\n options?: { title?: string; timeout?: number },\n ): Promise<Record<string, unknown> | null>;\n\n /** Close the server. Idempotent. */\n close(): void;\n\n /** The one-time nonce for this server session */\n readonly nonce: string;\n\n /** The port the server is listening on */\n readonly port: number;\n}\n\nexport interface CreateAuthServerOptions {\n /** Timeout in ms before auto-close. Default: 5 minutes. */\n timeout?: number;\n}\n\n/**\n * Create a temporary auth server bound to 127.0.0.1 with a random port.\n */\nexport async function createAuthServer(options?: CreateAuthServerOptions): Promise<AuthServer> {\n const timeout = options?.timeout ?? DEFAULT_TIMEOUT;\n const nonce = randomBytes(16).toString(\"hex\");\n\n let closed = false;\n let callbackResolve: ((value: Record<string, string> | null) => void) | null = null;\n let formResolve: ((value: Record<string, unknown> | null) => void) | null = null;\n let formSchema: Record<string, any> | null = null;\n let formTitle = \"AFS Credential Collection\";\n\n const server = createServer((req, res) => {\n const url = new URL(req.url || \"/\", `http://127.0.0.1`);\n const pathname = url.pathname;\n\n // CORS headers for local browser forms\n res.setHeader(\"Access-Control-Allow-Origin\", \"*\");\n res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\");\n res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type\");\n\n if (req.method === \"OPTIONS\") {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (pathname === \"/callback\") {\n handleCallback(url, req, res, nonce);\n } else if (pathname === \"/auth\" && req.method === \"GET\") {\n handleFormGet(url, res, nonce);\n } else if (pathname === \"/auth\" && req.method === \"POST\") {\n handleFormPost(req, res, nonce, url);\n } else {\n res.writeHead(404, { \"Content-Type\": \"text/plain\" });\n res.end(\"Not Found\");\n }\n });\n\n function handleCallback(\n url: URL,\n _req: IncomingMessage,\n res: ServerResponse,\n expectedNonce: string,\n ): void {\n const reqNonce = url.searchParams.get(\"nonce\");\n if (reqNonce !== expectedNonce) {\n res.writeHead(403, { \"Content-Type\": \"text/plain\" });\n res.end(\"Forbidden: invalid nonce\");\n return;\n }\n\n const params: Record<string, string> = {};\n for (const [key, value] of url.searchParams.entries()) {\n if (key !== \"nonce\") {\n params[key] = value;\n }\n }\n\n res.writeHead(200, { \"Content-Type\": \"text/html\" });\n res.end(\n \"<html><body><h2>Authorization complete.</h2><p>You can close this tab.</p></body></html>\",\n );\n\n if (callbackResolve) {\n callbackResolve(params);\n callbackResolve = null;\n }\n }\n\n function handleFormGet(url: URL, res: ServerResponse, expectedNonce: string): void {\n const reqNonce = url.searchParams.get(\"nonce\");\n if (reqNonce !== expectedNonce) {\n res.writeHead(403, { \"Content-Type\": \"text/plain\" });\n res.end(\"Forbidden: invalid nonce\");\n return;\n }\n\n if (!formSchema) {\n res.writeHead(400, { \"Content-Type\": \"text/plain\" });\n res.end(\"No form schema available\");\n return;\n }\n\n const html = renderFormHTML(formSchema, formTitle, expectedNonce);\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(html);\n }\n\n function handleFormPost(\n req: IncomingMessage,\n res: ServerResponse,\n expectedNonce: string,\n url: URL,\n ): void {\n const reqNonce = url.searchParams.get(\"nonce\");\n if (reqNonce !== expectedNonce) {\n res.writeHead(403, { \"Content-Type\": \"text/plain\" });\n res.end(\"Forbidden: invalid nonce\");\n return;\n }\n\n let body = \"\";\n req.on(\"data\", (chunk: Buffer) => {\n body += chunk.toString();\n });\n req.on(\"end\", () => {\n let data: Record<string, unknown>;\n const contentType = req.headers[\"content-type\"] || \"\";\n\n if (contentType.includes(\"application/json\")) {\n try {\n data = JSON.parse(body);\n } catch {\n res.writeHead(400, { \"Content-Type\": \"text/plain\" });\n res.end(\"Invalid JSON\");\n return;\n }\n } else {\n // URL-encoded form data\n const params = new URLSearchParams(body);\n data = {};\n for (const [key, value] of params.entries()) {\n if (key !== \"nonce\") {\n data[key] = value;\n }\n }\n // Coerce string values to match schema types (HTML forms always submit strings)\n if (formSchema?.properties) {\n for (const [key, value] of Object.entries(data)) {\n const prop = (formSchema.properties as Record<string, any>)[key];\n if (!prop) continue;\n const strValue = String(value);\n if (prop.type === \"number\" || prop.type === \"integer\") {\n const num = Number(strValue);\n if (!Number.isNaN(num) && strValue !== \"\") data[key] = num;\n } else if (prop.type === \"boolean\") {\n if (strValue === \"true\" || strValue === \"1\") data[key] = true;\n else if (strValue === \"false\" || strValue === \"0\" || strValue === \"\")\n data[key] = false;\n }\n // Remove empty strings for non-string optional fields (let defaults apply)\n if (strValue === \"\" && prop.type !== \"string\") {\n delete data[key];\n }\n }\n }\n }\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n \"<html><body><h2>Credentials received.</h2><p>You can close this tab.</p></body></html>\",\n );\n\n if (formResolve) {\n formResolve(data);\n formResolve = null;\n }\n });\n }\n\n // Start server with port retry\n const port = await startServer(server, MAX_PORT_RETRIES);\n\n // Auto-close timeout\n const autoCloseTimer = setTimeout(() => {\n closeServer();\n }, timeout);\n\n function closeServer(): void {\n if (closed) return;\n closed = true;\n clearTimeout(autoCloseTimer);\n server.close();\n\n // Resolve any pending waiters with null\n if (callbackResolve) {\n callbackResolve(null);\n callbackResolve = null;\n }\n if (formResolve) {\n formResolve(null);\n formResolve = null;\n }\n }\n\n const baseURL = `http://127.0.0.1:${port}`;\n\n return {\n baseURL,\n callbackURL: `${baseURL}/callback?nonce=${nonce}`,\n nonce,\n port,\n\n waitForCallback(opts?: { timeout?: number }): Promise<Record<string, string> | null> {\n if (closed) return Promise.resolve(null);\n\n return new Promise((resolve) => {\n callbackResolve = resolve;\n\n if (opts?.timeout) {\n setTimeout(() => {\n if (callbackResolve === resolve) {\n callbackResolve = null;\n resolve(null);\n }\n }, opts.timeout);\n }\n });\n },\n\n waitForForm(\n schema: Record<string, any>,\n opts?: { title?: string; timeout?: number },\n ): Promise<Record<string, unknown> | null> {\n if (closed) return Promise.resolve(null);\n\n formSchema = schema;\n if (opts?.title) formTitle = opts.title;\n\n return new Promise((resolve) => {\n formResolve = resolve;\n\n if (opts?.timeout) {\n setTimeout(() => {\n if (formResolve === resolve) {\n formResolve = null;\n resolve(null);\n }\n }, opts.timeout);\n }\n });\n },\n\n close: closeServer,\n };\n}\n\nasync function startServer(server: Server, maxRetries: number): Promise<number> {\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n return await new Promise<number>((resolve, reject) => {\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (addr && typeof addr === \"object\") {\n resolve(addr.port);\n } else {\n reject(new Error(\"Failed to get server address\"));\n }\n });\n server.on(\"error\", reject);\n });\n } catch (err: any) {\n if (attempt === maxRetries) {\n throw new Error(\n `Failed to start auth server after ${maxRetries + 1} attempts: ${err.message}`,\n );\n }\n // Close and retry\n server.close();\n }\n }\n throw new Error(\"Failed to start auth server\");\n}\n\nfunction renderFormHTML(schema: Record<string, any>, title: string, nonce: string): string {\n const properties = schema.properties || {};\n const required = new Set(schema.required || []);\n\n let fields = \"\";\n for (const [key, prop] of Object.entries(properties) as [string, any][]) {\n const label = prop.description || key;\n const isSensitive = prop.sensitive === true;\n const isRequired = required.has(key);\n const inputType = isSensitive ? \"password\" : \"text\";\n const defaultValue = prop.default != null ? String(prop.default) : \"\";\n\n fields += `\n <div style=\"margin-bottom: 12px;\">\n <label for=\"${escapeHTML(key)}\" style=\"display: block; font-weight: 600; margin-bottom: 4px;\">\n ${escapeHTML(label)}${isRequired ? \" *\" : \"\"}\n </label>\n <input\n type=\"${inputType}\"\n id=\"${escapeHTML(key)}\"\n name=\"${escapeHTML(key)}\"\n value=\"${escapeHTML(defaultValue)}\"\n ${isRequired ? \"required\" : \"\"}\n style=\"width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;\"\n autocomplete=\"off\"\n />\n </div>`;\n }\n\n return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"utf-8\">\n <title>${escapeHTML(title)}</title>\n <style>\n body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 20px; }\n h2 { color: #333; }\n button { background: #2563eb; color: white; border: none; padding: 10px 24px; border-radius: 4px; font-size: 14px; cursor: pointer; }\n button:hover { background: #1d4ed8; }\n </style>\n</head>\n<body>\n <h2>${escapeHTML(title)}</h2>\n <form method=\"POST\" action=\"/auth?nonce=${escapeHTML(nonce)}\">\n ${fields}\n <button type=\"submit\">Submit</button>\n </form>\n</body>\n</html>`;\n}\n\nfunction escapeHTML(str: string): string {\n return str\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"auth-server.mjs","names":[],"sources":["../../src/credential/auth-server.ts"],"sourcesContent":["/**\n * Temporary HTTP Server for credential collection.\n *\n * Supports two modes:\n * 1. Form collection: GET /auth renders a form from JSON Schema, POST /auth returns submitted data\n * 2. OAuth callback: GET /callback captures query params for waitForCallback()\n *\n * Security:\n * - Binds to 127.0.0.1 only (no external connections)\n * - One-time nonce per request to prevent CSRF\n * - Auto-closes after 5 minutes\n * - POST data is never logged\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { createServer, type IncomingMessage, type Server, type ServerResponse } from \"node:http\";\n\nconst DEFAULT_TIMEOUT = 5 * 60 * 1000; // 5 minutes\nconst MAX_PORT_RETRIES = 3;\n\nexport interface AuthServer {\n /** Base URL of the server, e.g. http://127.0.0.1:12345 */\n baseURL: string;\n\n /** Callback URL for OAuth redirects: baseURL + /callback */\n callbackURL: string;\n\n /** Wait for a callback request at /callback. Returns query params or null on timeout/close. */\n waitForCallback(options?: { timeout?: number }): Promise<Record<string, string> | null>;\n\n /**\n * Serve a form for collecting fields, wait for submission.\n * Returns submitted values or null on timeout/close.\n *\n * @param schema - JSON Schema describing fields to collect\n * @param options - Optional title and timeout\n */\n waitForForm(\n schema: Record<string, any>,\n options?: { title?: string; timeout?: number },\n ): Promise<Record<string, unknown> | null>;\n\n /** Close the server. Idempotent. */\n close(): void;\n\n /** The one-time nonce for this server session */\n readonly nonce: string;\n\n /** The port the server is listening on */\n readonly port: number;\n}\n\nexport interface CreateAuthServerOptions {\n /** Timeout in ms before auto-close. Default: 5 minutes. */\n timeout?: number;\n}\n\n/**\n * Create a temporary auth server bound to 127.0.0.1 with a random port.\n */\nexport async function createAuthServer(options?: CreateAuthServerOptions): Promise<AuthServer> {\n const timeout = options?.timeout ?? DEFAULT_TIMEOUT;\n const nonce = randomBytes(16).toString(\"hex\");\n\n let closed = false;\n let callbackResolve: ((value: Record<string, string> | null) => void) | null = null;\n let formResolve: ((value: Record<string, unknown> | null) => void) | null = null;\n let formSchema: Record<string, any> | null = null;\n let formTitle = \"AFS Credential Collection\";\n\n const server = createServer((req, res) => {\n const url = new URL(req.url || \"/\", `http://127.0.0.1`);\n const pathname = url.pathname;\n\n // CORS headers for local browser forms\n res.setHeader(\"Access-Control-Allow-Origin\", \"*\");\n res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\");\n res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type\");\n\n if (req.method === \"OPTIONS\") {\n res.writeHead(204);\n res.end();\n return;\n }\n\n if (pathname === \"/callback\") {\n handleCallback(url, req, res, nonce);\n } else if (pathname === \"/auth\" && req.method === \"GET\") {\n handleFormGet(url, res, nonce);\n } else if (pathname === \"/auth\" && req.method === \"POST\") {\n handleFormPost(req, res, nonce, url);\n } else {\n res.writeHead(404, { \"Content-Type\": \"text/plain\" });\n res.end(\"Not Found\");\n }\n });\n\n function handleCallback(\n url: URL,\n _req: IncomingMessage,\n res: ServerResponse,\n expectedNonce: string,\n ): void {\n const reqNonce = url.searchParams.get(\"nonce\");\n if (reqNonce !== expectedNonce) {\n res.writeHead(403, { \"Content-Type\": \"text/plain\" });\n res.end(\"Forbidden: invalid nonce\");\n return;\n }\n\n const params: Record<string, string> = {};\n for (const [key, value] of url.searchParams.entries()) {\n if (key !== \"nonce\") {\n params[key] = value;\n }\n }\n\n res.writeHead(200, { \"Content-Type\": \"text/html\" });\n res.end(\n \"<html><body><h2>Authorization complete.</h2><p>You can close this tab.</p></body></html>\",\n );\n\n if (callbackResolve) {\n callbackResolve(params);\n callbackResolve = null;\n }\n }\n\n function handleFormGet(url: URL, res: ServerResponse, expectedNonce: string): void {\n const reqNonce = url.searchParams.get(\"nonce\");\n if (reqNonce !== expectedNonce) {\n res.writeHead(403, { \"Content-Type\": \"text/plain\" });\n res.end(\"Forbidden: invalid nonce\");\n return;\n }\n\n if (!formSchema) {\n res.writeHead(400, { \"Content-Type\": \"text/plain\" });\n res.end(\"No form schema available\");\n return;\n }\n\n const html = renderFormHTML(formSchema, formTitle, expectedNonce);\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(html);\n }\n\n function handleFormPost(\n req: IncomingMessage,\n res: ServerResponse,\n expectedNonce: string,\n url: URL,\n ): void {\n const reqNonce = url.searchParams.get(\"nonce\");\n if (reqNonce !== expectedNonce) {\n res.writeHead(403, { \"Content-Type\": \"text/plain\" });\n res.end(\"Forbidden: invalid nonce\");\n return;\n }\n\n let body = \"\";\n req.on(\"data\", (chunk: Buffer) => {\n body += chunk.toString();\n });\n req.on(\"end\", () => {\n let data: Record<string, unknown>;\n const contentType = req.headers[\"content-type\"] || \"\";\n\n if (contentType.includes(\"application/json\")) {\n try {\n data = JSON.parse(body);\n } catch {\n res.writeHead(400, { \"Content-Type\": \"text/plain\" });\n res.end(\"Invalid JSON\");\n return;\n }\n } else {\n // URL-encoded form data\n const params = new URLSearchParams(body);\n data = {};\n for (const [key, value] of params.entries()) {\n if (key !== \"nonce\") {\n data[key] = value;\n }\n }\n // Coerce string values to match schema types (HTML forms always submit strings)\n if (formSchema?.properties) {\n for (const [key, value] of Object.entries(data)) {\n const prop = (formSchema.properties as Record<string, any>)[key];\n if (!prop) continue;\n const strValue = String(value);\n if (prop.type === \"number\" || prop.type === \"integer\") {\n const num = Number(strValue);\n if (!Number.isNaN(num) && strValue !== \"\") data[key] = num;\n } else if (prop.type === \"boolean\") {\n if (strValue === \"true\" || strValue === \"1\") data[key] = true;\n else if (strValue === \"false\" || strValue === \"0\" || strValue === \"\")\n data[key] = false;\n }\n // Remove empty strings for non-string optional fields (let defaults apply)\n if (strValue === \"\" && prop.type !== \"string\") {\n delete data[key];\n }\n }\n }\n }\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n \"<html><body><h2>Credentials received.</h2><p>You can close this tab.</p></body></html>\",\n );\n\n if (formResolve) {\n formResolve(data);\n formResolve = null;\n }\n });\n }\n\n // Start server with port retry\n const port = await startServer(server, MAX_PORT_RETRIES);\n\n // Auto-close timeout\n const autoCloseTimer = setTimeout(() => {\n closeServer();\n }, timeout);\n\n function closeServer(): void {\n if (closed) return;\n closed = true;\n clearTimeout(autoCloseTimer);\n server.close();\n\n // Resolve any pending waiters with null\n if (callbackResolve) {\n callbackResolve(null);\n callbackResolve = null;\n }\n if (formResolve) {\n formResolve(null);\n formResolve = null;\n }\n }\n\n const baseURL = `http://127.0.0.1:${port}`;\n\n return {\n baseURL,\n callbackURL: `${baseURL}/callback?nonce=${nonce}`,\n nonce,\n port,\n\n waitForCallback(opts?: { timeout?: number }): Promise<Record<string, string> | null> {\n if (closed) return Promise.resolve(null);\n\n return new Promise((resolve) => {\n callbackResolve = resolve;\n\n if (opts?.timeout) {\n setTimeout(() => {\n if (callbackResolve === resolve) {\n callbackResolve = null;\n resolve(null);\n }\n }, opts.timeout);\n }\n });\n },\n\n waitForForm(\n schema: Record<string, any>,\n opts?: { title?: string; timeout?: number },\n ): Promise<Record<string, unknown> | null> {\n if (closed) return Promise.resolve(null);\n\n formSchema = schema;\n if (opts?.title) formTitle = opts.title;\n\n return new Promise((resolve) => {\n formResolve = resolve;\n\n if (opts?.timeout) {\n setTimeout(() => {\n if (formResolve === resolve) {\n formResolve = null;\n resolve(null);\n }\n }, opts.timeout);\n }\n });\n },\n\n close: closeServer,\n };\n}\n\nasync function startServer(server: Server, maxRetries: number): Promise<number> {\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n return await new Promise<number>((resolve, reject) => {\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (addr && typeof addr === \"object\") {\n resolve(addr.port);\n } else {\n reject(new Error(\"Failed to get server address\"));\n }\n });\n server.on(\"error\", reject);\n });\n } catch (err: any) {\n if (attempt === maxRetries) {\n throw new Error(\n `Failed to start auth server after ${maxRetries + 1} attempts: ${err.message}`,\n );\n }\n // Close and retry\n server.close();\n }\n }\n throw new Error(\"Failed to start auth server\");\n}\n\nfunction renderFormHTML(schema: Record<string, any>, title: string, nonce: string): string {\n const properties = schema.properties || {};\n const required = new Set(schema.required || []);\n\n let fields = \"\";\n for (const [key, prop] of Object.entries(properties) as [string, any][]) {\n const label = prop.description || key;\n const isSensitive = prop.sensitive === true;\n const isRequired = required.has(key);\n const defaultValue = prop.default != null ? prop.default : \"\";\n\n if (prop.type === \"boolean\") {\n const checked = defaultValue === true || defaultValue === \"true\";\n fields += `\n <div style=\"margin-bottom: 12px;\">\n <label style=\"display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer;\">\n <input type=\"hidden\" name=\"${escapeHTML(key)}\" value=\"false\" />\n <input\n type=\"checkbox\"\n name=\"${escapeHTML(key)}\"\n value=\"true\"\n ${checked ? \"checked\" : \"\"}\n style=\"width: 18px; height: 18px;\"\n />\n ${escapeHTML(label)}\n </label>\n </div>`;\n } else {\n const inputType = isSensitive ? \"password\" : \"text\";\n fields += `\n <div style=\"margin-bottom: 12px;\">\n <label for=\"${escapeHTML(key)}\" style=\"display: block; font-weight: 600; margin-bottom: 4px;\">\n ${escapeHTML(label)}${isRequired ? \" *\" : \"\"}\n </label>\n <input\n type=\"${inputType}\"\n id=\"${escapeHTML(key)}\"\n name=\"${escapeHTML(key)}\"\n value=\"${escapeHTML(String(defaultValue))}\"\n ${isRequired ? \"required\" : \"\"}\n style=\"width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;\"\n autocomplete=\"off\"\n />\n </div>`;\n }\n }\n\n return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"utf-8\">\n <title>${escapeHTML(title)}</title>\n <style>\n body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 20px; }\n h2 { color: #333; }\n button { background: #2563eb; color: white; border: none; padding: 10px 24px; border-radius: 4px; font-size: 14px; cursor: pointer; }\n button:hover { background: #1d4ed8; }\n </style>\n</head>\n<body>\n <h2>${escapeHTML(title)}</h2>\n <form method=\"POST\" action=\"/auth?nonce=${escapeHTML(nonce)}\">\n ${fields}\n <button type=\"submit\">Submit</button>\n </form>\n</body>\n</html>`;\n}\n\nfunction escapeHTML(str: string): string {\n return str\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n"],"mappings":"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"}
|
|
@@ -56,8 +56,7 @@ async function resolveCredentials(options) {
|
|
|
56
56
|
}
|
|
57
57
|
} catch {}
|
|
58
58
|
missing = getRequiredMissing(resolved);
|
|
59
|
-
|
|
60
|
-
if (missing.length === 0 && !options.forceCollect && (allStillMissing.length === 0 || !authContext || providerAuth)) {
|
|
59
|
+
if (missing.length === 0 && !options.forceCollect) {
|
|
61
60
|
const { sensitive: sensitive$1, nonSensitive: nonSensitive$1 } = (0, _aigne_afs_utils_schema.separateSensitiveValues)(schema, resolved);
|
|
62
61
|
return {
|
|
63
62
|
values: { ...resolved },
|
|
@@ -87,8 +86,12 @@ async function resolveCredentials(options) {
|
|
|
87
86
|
} : void 0
|
|
88
87
|
});
|
|
89
88
|
else {
|
|
90
|
-
const fieldsForForm = allFields.filter((f) => known[f] === void 0);
|
|
91
|
-
const defaults = {
|
|
89
|
+
const fieldsForForm = options.forceCollect ? allFields : allFields.filter((f) => known[f] === void 0);
|
|
90
|
+
const defaults = {};
|
|
91
|
+
if (options.forceCollect) {
|
|
92
|
+
for (const [f, v] of Object.entries(known)) if (v !== void 0) defaults[f] = v;
|
|
93
|
+
}
|
|
94
|
+
for (const [f, v] of Object.entries(envResolved)) if (defaults[f] === void 0) defaults[f] = v;
|
|
92
95
|
for (const [f, v] of Object.entries(storeResolved)) if (defaults[f] === void 0) defaults[f] = v;
|
|
93
96
|
const missingSchema = buildMissingFieldsSchema(schema, fieldsForForm, defaults);
|
|
94
97
|
collected = await authContext.collect(missingSchema);
|
|
@@ -55,8 +55,7 @@ async function resolveCredentials(options) {
|
|
|
55
55
|
}
|
|
56
56
|
} catch {}
|
|
57
57
|
missing = getRequiredMissing(resolved);
|
|
58
|
-
|
|
59
|
-
if (missing.length === 0 && !options.forceCollect && (allStillMissing.length === 0 || !authContext || providerAuth)) {
|
|
58
|
+
if (missing.length === 0 && !options.forceCollect) {
|
|
60
59
|
const { sensitive: sensitive$1, nonSensitive: nonSensitive$1 } = separateSensitiveValues(schema, resolved);
|
|
61
60
|
return {
|
|
62
61
|
values: { ...resolved },
|
|
@@ -86,8 +85,12 @@ async function resolveCredentials(options) {
|
|
|
86
85
|
} : void 0
|
|
87
86
|
});
|
|
88
87
|
else {
|
|
89
|
-
const fieldsForForm = allFields.filter((f) => known[f] === void 0);
|
|
90
|
-
const defaults = {
|
|
88
|
+
const fieldsForForm = options.forceCollect ? allFields : allFields.filter((f) => known[f] === void 0);
|
|
89
|
+
const defaults = {};
|
|
90
|
+
if (options.forceCollect) {
|
|
91
|
+
for (const [f, v] of Object.entries(known)) if (v !== void 0) defaults[f] = v;
|
|
92
|
+
}
|
|
93
|
+
for (const [f, v] of Object.entries(envResolved)) if (defaults[f] === void 0) defaults[f] = v;
|
|
91
94
|
for (const [f, v] of Object.entries(storeResolved)) if (defaults[f] === void 0) defaults[f] = v;
|
|
92
95
|
const missingSchema = buildMissingFieldsSchema(schema, fieldsForForm, defaults);
|
|
93
96
|
collected = await authContext.collect(missingSchema);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolver.mjs","names":["resolved"],"sources":["../../src/credential/resolver.ts"],"sourcesContent":["/**\n * 4-Step Credential Resolution Flow\n *\n * Step 1: Determine missing fields (from provider schema vs known values)\n * Step 2: Silent resolution (config > env > credential store)\n * Step 3: Interactive collection (provider auth() or default collect())\n * Step 4: Unified persistence (sensitive → credentials.toml, non-sensitive → config options)\n */\n\nimport type { AuthContext, JSONSchema7, MountConfig } from \"@aigne/afs\";\nimport { resolveEnvFromSchema, separateSensitiveValues } from \"@aigne/afs/utils/schema\";\nimport type { CredentialStore } from \"./store.js\";\n\nexport interface ResolveCredentialsOptions {\n /** Mount configuration */\n mount: MountConfig;\n /** JSON Schema for this provider (from z.toJSONSchema() or manifest) */\n schema: JSONSchema7;\n /** Auth context for interactive collection (CLI or MCP) */\n authContext?: AuthContext;\n /** Credential store for reading/writing credentials */\n credentialStore?: CredentialStore;\n /** Provider class with optional auth() method */\n providerAuth?: (context: AuthContext) => Promise<Record<string, unknown> | null>;\n /** Environment variables (defaults to process.env) */\n env?: Record<string, string | undefined>;\n /** Force interactive collection even when all fields are resolved silently.\n * Used for retry after health-check failure with stale env/store values. */\n forceCollect?: boolean;\n}\n\nexport interface ResolveCredentialsResult {\n /** All resolved values (merged from all sources) */\n values: Record<string, unknown>;\n /** Values to persist as sensitive credentials */\n sensitive: Record<string, string>;\n /** Values to persist as non-sensitive config options */\n nonSensitive: Record<string, unknown>;\n /** Whether any interactive collection was needed */\n collected: boolean;\n}\n\n/**\n * Execute the 4-step credential resolution flow.\n *\n * Returns all resolved values plus their split into sensitive/non-sensitive\n * for persistence. The caller is responsible for actual persistence.\n *\n * @returns null if user declined/cancelled collection\n */\nexport async function resolveCredentials(\n options: ResolveCredentialsOptions,\n): Promise<ResolveCredentialsResult | null> {\n const { mount, schema, authContext, credentialStore, providerAuth, env = process.env } = options;\n\n const properties = (schema as any).properties;\n if (!properties || typeof properties !== \"object\" || Object.keys(properties).length === 0) {\n // No fields to resolve\n return { values: {}, sensitive: {}, nonSensitive: {}, collected: false };\n }\n\n const allFields = Object.keys(properties);\n const requiredFields = ((schema as any).required ?? []) as string[];\n\n // Only required fields without a schema default truly block silent resolution —\n // optional fields and fields with defaults should not trigger interactive auth.\n const requiredWithoutDefault = requiredFields.filter(\n (f) => properties[f] && properties[f].default === undefined,\n );\n\n // ─── Step 1: Determine known values from mount config ─────────────────\n const known: Record<string, unknown> = {};\n\n // Extract values from mount.auth, mount.token, mount.options\n if (mount.auth !== undefined) known.auth = mount.auth;\n if (mount.token !== undefined) known.token = mount.token;\n if (mount.options) {\n for (const [k, v] of Object.entries(mount.options)) {\n if (v !== undefined) known[k] = v;\n }\n }\n\n // Check which fields are still missing — allFields for Step 1 optimization,\n // requiredWithoutDefault for the Step 2→3 decision on interactive auth.\n const getAllMissing = (resolved: Record<string, unknown>) =>\n allFields.filter((f) => resolved[f] === undefined);\n const getRequiredMissing = (resolved: Record<string, unknown>) =>\n requiredWithoutDefault.filter((f) => resolved[f] === undefined);\n\n let missing = getAllMissing(known);\n if (missing.length === 0) {\n // All fields already provided via config — short circuit\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, known);\n return { values: { ...known }, sensitive, nonSensitive, collected: false };\n }\n\n // ─── Step 2: Silent resolution ────────────────────────────────────────\n const resolved: Record<string, unknown> = { ...known };\n\n // 2a. Environment variables from schema env declarations\n const envResolved = resolveEnvFromSchema(schema, env as Record<string, string | undefined>);\n for (const [field, value] of Object.entries(envResolved)) {\n if (resolved[field] === undefined) {\n resolved[field] = value;\n }\n }\n\n // 2b. Credential store — keyed by URI (credentials belong to the resource, not the path)\n const storeResolved: Record<string, unknown> = {};\n if (credentialStore) {\n try {\n const stored = await credentialStore.get(mount.uri);\n if (stored) {\n for (const [field, value] of Object.entries(stored)) {\n if (field.startsWith(\"env:\")) {\n // Reconstruct env Record from flattened env:KEY credential entries\n if (resolved.env === undefined) resolved.env = {};\n (resolved.env as Record<string, string>)[field.slice(4)] = value;\n if (storeResolved.env === undefined) storeResolved.env = {};\n (storeResolved.env as Record<string, string>)[field.slice(4)] = value;\n } else {\n if (resolved[field] === undefined) {\n resolved[field] = value;\n }\n storeResolved[field] = value;\n }\n }\n }\n } catch {\n // Credential store read failure is non-fatal\n }\n }\n\n missing = getRequiredMissing(resolved);\n const allStillMissing = getAllMissing(resolved);\n if (\n missing.length === 0 &&\n !options.forceCollect &&\n (allStillMissing.length === 0 || !authContext || providerAuth)\n ) {\n // All required fields resolved silently, and either:\n // - all fields resolved, or\n // - no auth context to collect interactively, or\n // - provider has custom auth (OAuth etc.) — don't trigger heavy auth flow for optional fields\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, resolved);\n return { values: { ...resolved }, sensitive, nonSensitive, collected: false };\n }\n\n // ─── Step 3: Interactive collection ───────────────────────────────────\n if (!authContext) {\n // No auth context available — can't collect. Return what we have.\n // Caller should attempt mount with partial values and let the provider error if needed.\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, resolved);\n return { values: { ...resolved }, sensitive, nonSensitive, collected: false };\n }\n\n let collected: Record<string, unknown> | null = null;\n\n if (providerAuth) {\n // Provider has custom auth() — delegate to it.\n // Inject persistCredentials so non-blocking auth flows can store\n // credentials in the background (e.g., MCP browser auth).\n collected = await providerAuth({\n ...authContext,\n get resolved() {\n return { ...resolved };\n },\n persistCredentials: credentialStore\n ? async (creds: Record<string, unknown>) => {\n const { sensitive: bgSensitive } = separateSensitiveValues(schema, creds);\n if (Object.keys(bgSensitive).length > 0) {\n await credentialStore.set(mount.uri, bgSensitive);\n }\n }\n : undefined,\n });\n } else {\n // Show all fields not explicitly provided via config/CLI args.\n // Env and store values are pre-filled as defaults so the user can verify/override.\n const fieldsForForm = allFields.filter((f) => known[f] === undefined);\n const defaults: Record<string, unknown> = { ...envResolved };\n for (const [f, v] of Object.entries(storeResolved)) {\n if (defaults[f] === undefined) defaults[f] = v;\n }\n const missingSchema = buildMissingFieldsSchema(schema, fieldsForForm, defaults);\n collected = await authContext.collect(missingSchema);\n }\n\n if (collected === null) {\n // User declined/cancelled\n return null;\n }\n\n // Merge collected values\n for (const [field, value] of Object.entries(collected)) {\n if (value !== undefined) {\n resolved[field] = value;\n }\n }\n\n // ─── Step 4: Split for persistence ────────────────────────────────────\n // Only split the newly collected values (not the ones from config/env/store)\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, collected);\n\n return { values: { ...resolved }, sensitive, nonSensitive, collected: true };\n}\n\n/**\n * Build a JSON Schema containing only the specified fields from the original schema.\n * Fields with env-resolved values get a `default` so the collection form can pre-fill them.\n */\nfunction buildMissingFieldsSchema(\n schema: JSONSchema7,\n fields: string[],\n envDefaults?: Record<string, unknown>,\n): JSONSchema7 {\n const properties = (schema as any).properties ?? {};\n const required = ((schema as any).required ?? []) as string[];\n\n const fieldProperties: Record<string, unknown> = {};\n const fieldRequired: string[] = [];\n\n for (const field of fields) {\n if (properties[field]) {\n let prop = properties[field];\n // Pre-fill with env value so user can see and override\n if (envDefaults?.[field] !== undefined && prop.default === undefined) {\n prop = { ...prop, default: envDefaults[field] };\n }\n fieldProperties[field] = prop;\n if (required.includes(field)) {\n fieldRequired.push(field);\n }\n }\n }\n\n return {\n type: \"object\",\n properties: fieldProperties,\n ...(fieldRequired.length > 0 ? { required: fieldRequired } : {}),\n } as JSONSchema7;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"resolver.mjs","names":["resolved"],"sources":["../../src/credential/resolver.ts"],"sourcesContent":["/**\n * 4-Step Credential Resolution Flow\n *\n * Step 1: Determine missing fields (from provider schema vs known values)\n * Step 2: Silent resolution (config > env > credential store)\n * Step 3: Interactive collection (provider auth() or default collect())\n * Step 4: Unified persistence (sensitive → credentials.toml, non-sensitive → config options)\n */\n\nimport type { AuthContext, JSONSchema7, MountConfig } from \"@aigne/afs\";\nimport { resolveEnvFromSchema, separateSensitiveValues } from \"@aigne/afs/utils/schema\";\nimport type { CredentialStore } from \"./store.js\";\n\nexport interface ResolveCredentialsOptions {\n /** Mount configuration */\n mount: MountConfig;\n /** JSON Schema for this provider (from z.toJSONSchema() or manifest) */\n schema: JSONSchema7;\n /** Auth context for interactive collection (CLI or MCP) */\n authContext?: AuthContext;\n /** Credential store for reading/writing credentials */\n credentialStore?: CredentialStore;\n /** Provider class with optional auth() method */\n providerAuth?: (context: AuthContext) => Promise<Record<string, unknown> | null>;\n /** Environment variables (defaults to process.env) */\n env?: Record<string, string | undefined>;\n /** Force interactive collection even when all fields are resolved silently.\n * Used for retry after health-check failure with stale env/store values. */\n forceCollect?: boolean;\n}\n\nexport interface ResolveCredentialsResult {\n /** All resolved values (merged from all sources) */\n values: Record<string, unknown>;\n /** Values to persist as sensitive credentials */\n sensitive: Record<string, string>;\n /** Values to persist as non-sensitive config options */\n nonSensitive: Record<string, unknown>;\n /** Whether any interactive collection was needed */\n collected: boolean;\n}\n\n/**\n * Execute the 4-step credential resolution flow.\n *\n * Returns all resolved values plus their split into sensitive/non-sensitive\n * for persistence. The caller is responsible for actual persistence.\n *\n * @returns null if user declined/cancelled collection\n */\nexport async function resolveCredentials(\n options: ResolveCredentialsOptions,\n): Promise<ResolveCredentialsResult | null> {\n const { mount, schema, authContext, credentialStore, providerAuth, env = process.env } = options;\n\n const properties = (schema as any).properties;\n if (!properties || typeof properties !== \"object\" || Object.keys(properties).length === 0) {\n // No fields to resolve\n return { values: {}, sensitive: {}, nonSensitive: {}, collected: false };\n }\n\n const allFields = Object.keys(properties);\n const requiredFields = ((schema as any).required ?? []) as string[];\n\n // Only required fields without a schema default truly block silent resolution —\n // optional fields and fields with defaults should not trigger interactive auth.\n const requiredWithoutDefault = requiredFields.filter(\n (f) => properties[f] && properties[f].default === undefined,\n );\n\n // ─── Step 1: Determine known values from mount config ─────────────────\n const known: Record<string, unknown> = {};\n\n // Extract values from mount.auth, mount.token, mount.options\n if (mount.auth !== undefined) known.auth = mount.auth;\n if (mount.token !== undefined) known.token = mount.token;\n if (mount.options) {\n for (const [k, v] of Object.entries(mount.options)) {\n if (v !== undefined) known[k] = v;\n }\n }\n\n // Check which fields are still missing — allFields for Step 1 optimization,\n // requiredWithoutDefault for the Step 2→3 decision on interactive auth.\n const getAllMissing = (resolved: Record<string, unknown>) =>\n allFields.filter((f) => resolved[f] === undefined);\n const getRequiredMissing = (resolved: Record<string, unknown>) =>\n requiredWithoutDefault.filter((f) => resolved[f] === undefined);\n\n let missing = getAllMissing(known);\n if (missing.length === 0) {\n // All fields already provided via config — short circuit\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, known);\n return { values: { ...known }, sensitive, nonSensitive, collected: false };\n }\n\n // ─── Step 2: Silent resolution ────────────────────────────────────────\n const resolved: Record<string, unknown> = { ...known };\n\n // 2a. Environment variables from schema env declarations\n const envResolved = resolveEnvFromSchema(schema, env as Record<string, string | undefined>);\n for (const [field, value] of Object.entries(envResolved)) {\n if (resolved[field] === undefined) {\n resolved[field] = value;\n }\n }\n\n // 2b. Credential store — keyed by URI (credentials belong to the resource, not the path)\n const storeResolved: Record<string, unknown> = {};\n if (credentialStore) {\n try {\n const stored = await credentialStore.get(mount.uri);\n if (stored) {\n for (const [field, value] of Object.entries(stored)) {\n if (field.startsWith(\"env:\")) {\n // Reconstruct env Record from flattened env:KEY credential entries\n if (resolved.env === undefined) resolved.env = {};\n (resolved.env as Record<string, string>)[field.slice(4)] = value;\n if (storeResolved.env === undefined) storeResolved.env = {};\n (storeResolved.env as Record<string, string>)[field.slice(4)] = value;\n } else {\n if (resolved[field] === undefined) {\n resolved[field] = value;\n }\n storeResolved[field] = value;\n }\n }\n }\n } catch {\n // Credential store read failure is non-fatal\n }\n }\n\n missing = getRequiredMissing(resolved);\n if (missing.length === 0 && !options.forceCollect) {\n // All required fields resolved silently — skip interactive collection.\n // Optional unfilled fields (e.g., chats) stay empty; users can run\n // `afs program configure` to fill them explicitly.\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, resolved);\n return { values: { ...resolved }, sensitive, nonSensitive, collected: false };\n }\n\n // ─── Step 3: Interactive collection ───────────────────────────────────\n if (!authContext) {\n // No auth context available — can't collect. Return what we have.\n // Caller should attempt mount with partial values and let the provider error if needed.\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, resolved);\n return { values: { ...resolved }, sensitive, nonSensitive, collected: false };\n }\n\n let collected: Record<string, unknown> | null = null;\n\n if (providerAuth) {\n // Provider has custom auth() — delegate to it.\n // Inject persistCredentials so non-blocking auth flows can store\n // credentials in the background (e.g., MCP browser auth).\n collected = await providerAuth({\n ...authContext,\n get resolved() {\n return { ...resolved };\n },\n persistCredentials: credentialStore\n ? async (creds: Record<string, unknown>) => {\n const { sensitive: bgSensitive } = separateSensitiveValues(schema, creds);\n if (Object.keys(bgSensitive).length > 0) {\n await credentialStore.set(mount.uri, bgSensitive);\n }\n }\n : undefined,\n });\n } else {\n // When forceCollect: show ALL fields with current values as defaults.\n // Otherwise: show only fields not already provided via config/CLI args.\n const fieldsForForm = options.forceCollect\n ? allFields\n : allFields.filter((f) => known[f] === undefined);\n const defaults: Record<string, unknown> = {};\n if (options.forceCollect) {\n for (const [f, v] of Object.entries(known)) {\n if (v !== undefined) defaults[f] = v;\n }\n }\n for (const [f, v] of Object.entries(envResolved)) {\n if (defaults[f] === undefined) defaults[f] = v;\n }\n for (const [f, v] of Object.entries(storeResolved)) {\n if (defaults[f] === undefined) defaults[f] = v;\n }\n const missingSchema = buildMissingFieldsSchema(schema, fieldsForForm, defaults);\n collected = await authContext.collect(missingSchema);\n }\n\n if (collected === null) {\n // User declined/cancelled\n return null;\n }\n\n // Merge collected values\n for (const [field, value] of Object.entries(collected)) {\n if (value !== undefined) {\n resolved[field] = value;\n }\n }\n\n // ─── Step 4: Split for persistence ────────────────────────────────────\n // Only split the newly collected values (not the ones from config/env/store)\n const { sensitive, nonSensitive } = separateSensitiveValues(schema, collected);\n\n return { values: { ...resolved }, sensitive, nonSensitive, collected: true };\n}\n\n/**\n * Build a JSON Schema containing only the specified fields from the original schema.\n * Fields with env-resolved values get a `default` so the collection form can pre-fill them.\n */\nfunction buildMissingFieldsSchema(\n schema: JSONSchema7,\n fields: string[],\n envDefaults?: Record<string, unknown>,\n): JSONSchema7 {\n const properties = (schema as any).properties ?? {};\n const required = ((schema as any).required ?? []) as string[];\n\n const fieldProperties: Record<string, unknown> = {};\n const fieldRequired: string[] = [];\n\n for (const field of fields) {\n if (properties[field]) {\n let prop = properties[field];\n // Pre-fill with env value so user can see and override\n if (envDefaults?.[field] !== undefined && prop.default === undefined) {\n prop = { ...prop, default: envDefaults[field] };\n }\n fieldProperties[field] = prop;\n if (required.includes(field)) {\n fieldRequired.push(field);\n }\n }\n }\n\n return {\n type: \"object\",\n properties: fieldProperties,\n ...(fieldRequired.length > 0 ? { required: fieldRequired } : {}),\n } as JSONSchema7;\n}\n"],"mappings":"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"}
|
|
@@ -29,7 +29,11 @@ var ProgramManager = class {
|
|
|
29
29
|
if (!triggerInfo || triggerInfo.triggers.length === 0) return;
|
|
30
30
|
const createAFS = this.deps.createProgramAFS ?? _aigne_afs.createProgramAFS;
|
|
31
31
|
const dataPath = this.deps.dataDir(programId);
|
|
32
|
-
const
|
|
32
|
+
const mountOverrides = await this.deps.readMountOverrides?.(programId) ?? [];
|
|
33
|
+
const createAFSOptions = {};
|
|
34
|
+
if (this.deps.createProvider) createAFSOptions.createProvider = this.deps.createProvider;
|
|
35
|
+
if (mountOverrides.length > 0) createAFSOptions.mountOverrides = mountOverrides;
|
|
36
|
+
const { afs, manifest, ownedProviders } = await createAFS(program.mountPath, dataPath, this.deps.globalAFS, Object.keys(createAFSOptions).length > 0 ? createAFSOptions : void 0);
|
|
33
37
|
const eventUnsubs = [];
|
|
34
38
|
const cronHandles = [];
|
|
35
39
|
for (const trigger of triggerInfo.triggers) if (trigger.trigger.kind === "event" && trigger.trigger.path) {
|
|
@@ -28,7 +28,11 @@ var ProgramManager = class {
|
|
|
28
28
|
if (!triggerInfo || triggerInfo.triggers.length === 0) return;
|
|
29
29
|
const createAFS = this.deps.createProgramAFS ?? createProgramAFS;
|
|
30
30
|
const dataPath = this.deps.dataDir(programId);
|
|
31
|
-
const
|
|
31
|
+
const mountOverrides = await this.deps.readMountOverrides?.(programId) ?? [];
|
|
32
|
+
const createAFSOptions = {};
|
|
33
|
+
if (this.deps.createProvider) createAFSOptions.createProvider = this.deps.createProvider;
|
|
34
|
+
if (mountOverrides.length > 0) createAFSOptions.mountOverrides = mountOverrides;
|
|
35
|
+
const { afs, manifest, ownedProviders } = await createAFS(program.mountPath, dataPath, this.deps.globalAFS, Object.keys(createAFSOptions).length > 0 ? createAFSOptions : void 0);
|
|
32
36
|
const eventUnsubs = [];
|
|
33
37
|
const cronHandles = [];
|
|
34
38
|
for (const trigger of triggerInfo.triggers) if (trigger.trigger.kind === "event" && trigger.trigger.path) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"program-manager.mjs","names":["defaultCreateProgramAFS"],"sources":["../../src/program/program-manager.ts"],"sourcesContent":["/**\n * ProgramManager — manages activation and deactivation of installed programs.\n *\n * Activation creates a persistent Runtime AFS, registers EventBus subscriptions\n * and cron jobs for trigger-bearing scripts. Deactivation cleans up everything.\n */\n\nimport {\n type AFS,\n type AFSEvent,\n type AFSModule,\n type AFSRoot,\n type AFSUnsubscribe,\n type CreateProgramAFSOptions,\n createProgramAFS as defaultCreateProgramAFS,\n type MountConfig,\n type ProgramManifest,\n} from \"@aigne/afs\";\nimport { joinURL } from \"ufo\";\nimport type { ProgramTriggerInfo, ScriptTriggerInfo } from \"./trigger-scanner.js\";\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport interface CronHandle {\n stop(): void;\n}\n\nexport interface ProgramManagerDeps {\n /** Global AFS instance — used for EventBus subscriptions */\n globalAFS: AFS;\n /** Optional: factory for creating providers (with credential resolution) */\n createProvider?: (mount: MountConfig) => Promise<AFSModule>;\n /** List installed programs */\n listPrograms: () => Promise<Array<{ id: string; installPath: string; mountPath: string }>>;\n /** Scan triggers in a program directory */\n scanTriggers: (programDir: string) => Promise<ProgramTriggerInfo | null>;\n /** Create Runtime AFS for a program. Default: createProgramAFS from @aigne/afs */\n createProgramAFS?: (\n programPath: string,\n dataPath: string,\n globalAFS: AFS,\n options?: CreateProgramAFSOptions,\n ) => Promise<{ afs: AFSRoot; manifest: ProgramManifest; ownedProviders: AFSModule[] }>;\n /** Get data directory path for a program */\n dataDir: (programId: string) => string;\n /** Optional: create a cron job. Returns handle with stop(). */\n createCron?: (expression: string, callback: () => void) => CronHandle;\n /** Optional: callback when a trigger fires */\n onTrigger?: (\n programId: string,\n scriptPath: string,\n jobName: string,\n event?: AFSEvent | Record<string, unknown>,\n ) => void;\n}\n\ninterface ActivatedProgramState {\n manifest: ProgramManifest;\n runtimeAFS: AFSRoot;\n ownedProviders: AFSModule[];\n eventUnsubs: AFSUnsubscribe[];\n cronHandles: CronHandle[];\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────────\n\nexport class ProgramManager {\n private activated = new Map<string, ActivatedProgramState>();\n private reloadLock: Promise<void> | null = null;\n private readonly deps: ProgramManagerDeps;\n\n constructor(deps: ProgramManagerDeps) {\n this.deps = deps;\n }\n\n /**\n * Activate a single program by ID.\n * Creates Runtime AFS, registers event/cron subscriptions.\n * If already activated, deactivates first then re-activates.\n */\n async activate(programId: string): Promise<void> {\n // Find the program\n const programs = await this.deps.listPrograms();\n const program = programs.find((p) => p.id === programId);\n if (!program) {\n throw new Error(`Program \"${programId}\" not found in installed programs`);\n }\n // If already activated, deactivate first\n if (this.activated.has(programId)) {\n await this.deactivate(programId);\n }\n\n // Scan for triggers\n const triggerInfo = await this.deps.scanTriggers(program.installPath);\n if (!triggerInfo || triggerInfo.triggers.length === 0) {\n return; // No triggers — nothing to activate\n }\n\n // Create Runtime AFS\n const createAFS = this.deps.createProgramAFS ?? defaultCreateProgramAFS;\n const dataPath = this.deps.dataDir(programId);\n const { afs, manifest, ownedProviders } = await createAFS(\n program.mountPath,\n dataPath,\n this.deps.globalAFS,\n this.deps.createProvider ? { createProvider: this.deps.createProvider } : undefined,\n );\n\n // Register triggers\n const eventUnsubs: AFSUnsubscribe[] = [];\n const cronHandles: CronHandle[] = [];\n\n for (const trigger of triggerInfo.triggers) {\n if (trigger.trigger.kind === \"event\" && trigger.trigger.path) {\n // Subscribe on the program's runtime AFS — trigger paths are relative\n // to the program's namespace (e.g. \"/telegram\", not \"/modules/telegram\").\n // Requires shared:false mounts for event-emitting providers so that\n // the owned provider instance emits events to the runtime EventBus.\n if (afs.subscribe) {\n const unsub = afs.subscribe({ path: trigger.trigger.path }, (event) => {\n this.deps.onTrigger?.(programId, trigger.scriptPath, trigger.jobName, event);\n this.executeTriggerJob(programId, afs, trigger, event);\n });\n eventUnsubs.push(unsub);\n }\n } else if (trigger.trigger.kind === \"cron\" && trigger.trigger.expression) {\n if (this.deps.createCron) {\n const handle = this.deps.createCron(trigger.trigger.expression, () => {\n const cronEvent = {\n type: \"cron\",\n path: `/cron/${programId}/${trigger.jobName}`,\n source: \"program-manager\",\n timestamp: Date.now(),\n };\n this.deps.onTrigger?.(programId, trigger.scriptPath, trigger.jobName, cronEvent);\n this.executeTriggerJob(programId, afs, trigger, cronEvent);\n });\n cronHandles.push(handle);\n }\n }\n }\n\n // Store activated state\n this.activated.set(programId, {\n manifest,\n runtimeAFS: afs,\n ownedProviders,\n eventUnsubs,\n cronHandles,\n });\n }\n\n /**\n * Deactivate a program by ID.\n * Cancels subscriptions, stops cron jobs, closes owned providers.\n * No-op if program is not activated.\n */\n async deactivate(programId: string): Promise<void> {\n const state = this.activated.get(programId);\n if (!state) return;\n\n // Remove from map first (prevents re-entrant issues)\n this.activated.delete(programId);\n\n // Unsubscribe all event triggers (swallow individual errors)\n for (const unsub of state.eventUnsubs) {\n try {\n unsub();\n } catch {\n // Swallow — best-effort cleanup\n }\n }\n\n // Stop all cron jobs (swallow individual errors)\n for (const cron of state.cronHandles) {\n try {\n cron.stop();\n } catch {\n // Swallow — best-effort cleanup\n }\n }\n\n // Close all owned providers (swallow individual errors)\n for (const provider of state.ownedProviders) {\n try {\n await provider.close?.();\n } catch {\n // Swallow — best-effort cleanup\n }\n }\n }\n\n /**\n * Activate all installed programs that have triggers.\n * Failures on individual programs are skipped.\n */\n async activateAll(): Promise<void> {\n const programs = await this.deps.listPrograms();\n for (const program of programs) {\n try {\n await this.activate(program.id);\n } catch (err) {\n console.error(\n `[PM] Failed to activate \"${program.id}\":`,\n err instanceof Error ? err.message : err,\n );\n }\n }\n }\n\n /**\n * Deactivate all currently activated programs.\n */\n async deactivateAll(): Promise<void> {\n const ids = [...this.activated.keys()];\n for (const id of ids) {\n await this.deactivate(id);\n }\n }\n\n /**\n * Reload all programs: deactivateAll + activateAll.\n * Serialized via lock to prevent concurrent reloads.\n */\n async reload(): Promise<void> {\n // Serialize concurrent reload calls\n if (this.reloadLock) {\n await this.reloadLock;\n }\n\n this.reloadLock = (async () => {\n try {\n await this.deactivateAll();\n await this.activateAll();\n } finally {\n this.reloadLock = null;\n }\n })();\n\n await this.reloadLock;\n }\n\n /**\n * Get list of activated program IDs.\n */\n getActivatedPrograms(): string[] {\n return [...this.activated.keys()];\n }\n\n /**\n * Execute a trigger job via the runtime AFS's ASH provider.\n * Reads script source from /program/scripts/..., then calls /ash/.actions/run\n * with job name and event data.\n */\n private async executeTriggerJob(\n programId: string,\n runtimeAFS: AFSRoot,\n trigger: ScriptTriggerInfo,\n event: AFSEvent | Record<string, unknown>,\n ): Promise<void> {\n try {\n // Read script source from runtime AFS\n const scriptPath = joinURL(\"/program\", trigger.scriptPath);\n if (!runtimeAFS.read) {\n console.error(`[PM] Cannot execute trigger: runtime AFS has no read()`);\n return;\n }\n const readResult = await runtimeAFS.read(scriptPath, {});\n const source = String(readResult.data?.content ?? \"\");\n if (!source.trim()) {\n console.error(`[PM] Script source empty: ${scriptPath}`);\n return;\n }\n\n // Execute via ASH provider's run action with job parameter\n if (!runtimeAFS.exec) {\n console.error(`[PM] Cannot execute trigger: runtime AFS has no exec()`);\n return;\n }\n const result = await runtimeAFS.exec(\n \"/ash/.actions/run\",\n {\n source,\n job: trigger.jobName,\n event,\n _runtime_afs: runtimeAFS,\n },\n {},\n );\n if (!result.success) {\n console.error(\n `[PM] Job \"${programId}/${trigger.jobName}\" failed:`,\n result.data ?? result.error,\n );\n }\n } catch (err) {\n console.error(\n `[PM] Job \"${programId}/${trigger.jobName}\" threw:`,\n err instanceof Error ? err.message : err,\n );\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"program-manager.mjs","names":["defaultCreateProgramAFS"],"sources":["../../src/program/program-manager.ts"],"sourcesContent":["/**\n * ProgramManager — manages activation and deactivation of installed programs.\n *\n * Activation creates a persistent Runtime AFS, registers EventBus subscriptions\n * and cron jobs for trigger-bearing scripts. Deactivation cleans up everything.\n */\n\nimport {\n type AFS,\n type AFSEvent,\n type AFSModule,\n type AFSRoot,\n type AFSUnsubscribe,\n type CreateProgramAFSOptions,\n createProgramAFS as defaultCreateProgramAFS,\n type MountConfig,\n type MountOverride,\n type ProgramManifest,\n} from \"@aigne/afs\";\nimport { joinURL } from \"ufo\";\nimport type { ProgramTriggerInfo, ScriptTriggerInfo } from \"./trigger-scanner.js\";\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport interface CronHandle {\n stop(): void;\n}\n\nexport interface ProgramManagerDeps {\n /** Global AFS instance — used for EventBus subscriptions */\n globalAFS: AFS;\n /** Optional: factory for creating providers (with credential resolution) */\n createProvider?: (mount: MountConfig) => Promise<AFSModule>;\n /** List installed programs */\n listPrograms: () => Promise<Array<{ id: string; installPath: string; mountPath: string }>>;\n /** Scan triggers in a program directory */\n scanTriggers: (programDir: string) => Promise<ProgramTriggerInfo | null>;\n /** Create Runtime AFS for a program. Default: createProgramAFS from @aigne/afs */\n createProgramAFS?: (\n programPath: string,\n dataPath: string,\n globalAFS: AFS,\n options?: CreateProgramAFSOptions,\n ) => Promise<{ afs: AFSRoot; manifest: ProgramManifest; ownedProviders: AFSModule[] }>;\n /** Get data directory path for a program */\n dataDir: (programId: string) => string;\n /** Optional: create a cron job. Returns handle with stop(). */\n createCron?: (expression: string, callback: () => void) => CronHandle;\n /** Optional: callback when a trigger fires */\n onTrigger?: (\n programId: string,\n scriptPath: string,\n jobName: string,\n event?: AFSEvent | Record<string, unknown>,\n ) => void;\n /** Optional: read user-side mount overrides from mounts.toml */\n readMountOverrides?: (programId: string) => Promise<MountOverride[]>;\n}\n\ninterface ActivatedProgramState {\n manifest: ProgramManifest;\n runtimeAFS: AFSRoot;\n ownedProviders: AFSModule[];\n eventUnsubs: AFSUnsubscribe[];\n cronHandles: CronHandle[];\n}\n\n// ─── Implementation ─────────────────────────────────────────────────────────\n\nexport class ProgramManager {\n private activated = new Map<string, ActivatedProgramState>();\n private reloadLock: Promise<void> | null = null;\n private readonly deps: ProgramManagerDeps;\n\n constructor(deps: ProgramManagerDeps) {\n this.deps = deps;\n }\n\n /**\n * Activate a single program by ID.\n * Creates Runtime AFS, registers event/cron subscriptions.\n * If already activated, deactivates first then re-activates.\n */\n async activate(programId: string): Promise<void> {\n // Find the program\n const programs = await this.deps.listPrograms();\n const program = programs.find((p) => p.id === programId);\n if (!program) {\n throw new Error(`Program \"${programId}\" not found in installed programs`);\n }\n // If already activated, deactivate first\n if (this.activated.has(programId)) {\n await this.deactivate(programId);\n }\n\n // Scan for triggers\n const triggerInfo = await this.deps.scanTriggers(program.installPath);\n if (!triggerInfo || triggerInfo.triggers.length === 0) {\n return; // No triggers — nothing to activate\n }\n\n // Create Runtime AFS\n const createAFS = this.deps.createProgramAFS ?? defaultCreateProgramAFS;\n const dataPath = this.deps.dataDir(programId);\n const mountOverrides = (await this.deps.readMountOverrides?.(programId)) ?? [];\n const createAFSOptions: CreateProgramAFSOptions = {};\n if (this.deps.createProvider) {\n createAFSOptions.createProvider = this.deps.createProvider;\n }\n if (mountOverrides.length > 0) {\n createAFSOptions.mountOverrides = mountOverrides;\n }\n const { afs, manifest, ownedProviders } = await createAFS(\n program.mountPath,\n dataPath,\n this.deps.globalAFS,\n Object.keys(createAFSOptions).length > 0 ? createAFSOptions : undefined,\n );\n\n // Register triggers\n const eventUnsubs: AFSUnsubscribe[] = [];\n const cronHandles: CronHandle[] = [];\n\n for (const trigger of triggerInfo.triggers) {\n if (trigger.trigger.kind === \"event\" && trigger.trigger.path) {\n // Subscribe on the program's runtime AFS — trigger paths are relative\n // to the program's namespace (e.g. \"/telegram\", not \"/modules/telegram\").\n // Requires shared:false mounts for event-emitting providers so that\n // the owned provider instance emits events to the runtime EventBus.\n if (afs.subscribe) {\n const unsub = afs.subscribe({ path: trigger.trigger.path }, (event) => {\n this.deps.onTrigger?.(programId, trigger.scriptPath, trigger.jobName, event);\n this.executeTriggerJob(programId, afs, trigger, event);\n });\n eventUnsubs.push(unsub);\n }\n } else if (trigger.trigger.kind === \"cron\" && trigger.trigger.expression) {\n if (this.deps.createCron) {\n const handle = this.deps.createCron(trigger.trigger.expression, () => {\n const cronEvent = {\n type: \"cron\",\n path: `/cron/${programId}/${trigger.jobName}`,\n source: \"program-manager\",\n timestamp: Date.now(),\n };\n this.deps.onTrigger?.(programId, trigger.scriptPath, trigger.jobName, cronEvent);\n this.executeTriggerJob(programId, afs, trigger, cronEvent);\n });\n cronHandles.push(handle);\n }\n }\n }\n\n // Store activated state\n this.activated.set(programId, {\n manifest,\n runtimeAFS: afs,\n ownedProviders,\n eventUnsubs,\n cronHandles,\n });\n }\n\n /**\n * Deactivate a program by ID.\n * Cancels subscriptions, stops cron jobs, closes owned providers.\n * No-op if program is not activated.\n */\n async deactivate(programId: string): Promise<void> {\n const state = this.activated.get(programId);\n if (!state) return;\n\n // Remove from map first (prevents re-entrant issues)\n this.activated.delete(programId);\n\n // Unsubscribe all event triggers (swallow individual errors)\n for (const unsub of state.eventUnsubs) {\n try {\n unsub();\n } catch {\n // Swallow — best-effort cleanup\n }\n }\n\n // Stop all cron jobs (swallow individual errors)\n for (const cron of state.cronHandles) {\n try {\n cron.stop();\n } catch {\n // Swallow — best-effort cleanup\n }\n }\n\n // Close all owned providers (swallow individual errors)\n for (const provider of state.ownedProviders) {\n try {\n await provider.close?.();\n } catch {\n // Swallow — best-effort cleanup\n }\n }\n }\n\n /**\n * Activate all installed programs that have triggers.\n * Failures on individual programs are skipped.\n */\n async activateAll(): Promise<void> {\n const programs = await this.deps.listPrograms();\n for (const program of programs) {\n try {\n await this.activate(program.id);\n } catch (err) {\n console.error(\n `[PM] Failed to activate \"${program.id}\":`,\n err instanceof Error ? err.message : err,\n );\n }\n }\n }\n\n /**\n * Deactivate all currently activated programs.\n */\n async deactivateAll(): Promise<void> {\n const ids = [...this.activated.keys()];\n for (const id of ids) {\n await this.deactivate(id);\n }\n }\n\n /**\n * Reload all programs: deactivateAll + activateAll.\n * Serialized via lock to prevent concurrent reloads.\n */\n async reload(): Promise<void> {\n // Serialize concurrent reload calls\n if (this.reloadLock) {\n await this.reloadLock;\n }\n\n this.reloadLock = (async () => {\n try {\n await this.deactivateAll();\n await this.activateAll();\n } finally {\n this.reloadLock = null;\n }\n })();\n\n await this.reloadLock;\n }\n\n /**\n * Get list of activated program IDs.\n */\n getActivatedPrograms(): string[] {\n return [...this.activated.keys()];\n }\n\n /**\n * Execute a trigger job via the runtime AFS's ASH provider.\n * Reads script source from /program/scripts/..., then calls /ash/.actions/run\n * with job name and event data.\n */\n private async executeTriggerJob(\n programId: string,\n runtimeAFS: AFSRoot,\n trigger: ScriptTriggerInfo,\n event: AFSEvent | Record<string, unknown>,\n ): Promise<void> {\n try {\n // Read script source from runtime AFS\n const scriptPath = joinURL(\"/program\", trigger.scriptPath);\n if (!runtimeAFS.read) {\n console.error(`[PM] Cannot execute trigger: runtime AFS has no read()`);\n return;\n }\n const readResult = await runtimeAFS.read(scriptPath, {});\n const source = String(readResult.data?.content ?? \"\");\n if (!source.trim()) {\n console.error(`[PM] Script source empty: ${scriptPath}`);\n return;\n }\n\n // Execute via ASH provider's run action with job parameter\n if (!runtimeAFS.exec) {\n console.error(`[PM] Cannot execute trigger: runtime AFS has no exec()`);\n return;\n }\n const result = await runtimeAFS.exec(\n \"/ash/.actions/run\",\n {\n source,\n job: trigger.jobName,\n event,\n _runtime_afs: runtimeAFS,\n },\n {},\n );\n if (!result.success) {\n console.error(\n `[PM] Job \"${programId}/${trigger.jobName}\" failed:`,\n result.data ?? result.error,\n );\n }\n } catch (err) {\n console.error(\n `[PM] Job \"${programId}/${trigger.jobName}\" threw:`,\n err instanceof Error ? err.message : err,\n );\n }\n }\n}\n"],"mappings":"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"}
|
package/dist/repl.cjs
CHANGED
|
@@ -417,7 +417,11 @@ async function startRepl(options) {
|
|
|
417
417
|
} catch {}
|
|
418
418
|
return scanProgramTriggers(programDir, compile);
|
|
419
419
|
},
|
|
420
|
-
dataDir: (programId) => `/.data/${programId}
|
|
420
|
+
dataDir: (programId) => `/.data/${programId}`,
|
|
421
|
+
readMountOverrides: async (programId) => {
|
|
422
|
+
const { readProgramMountOverrides } = await Promise.resolve().then(() => require("./config/program-install.cjs"));
|
|
423
|
+
return readProgramMountOverrides(programId, { userConfigDir });
|
|
424
|
+
}
|
|
421
425
|
});
|
|
422
426
|
await programManager.activateAll();
|
|
423
427
|
const activated = programManager.getActivatedPrograms();
|
package/dist/repl.mjs
CHANGED
|
@@ -416,7 +416,11 @@ async function startRepl(options) {
|
|
|
416
416
|
} catch {}
|
|
417
417
|
return scanProgramTriggers(programDir, compile);
|
|
418
418
|
},
|
|
419
|
-
dataDir: (programId) => `/.data/${programId}
|
|
419
|
+
dataDir: (programId) => `/.data/${programId}`,
|
|
420
|
+
readMountOverrides: async (programId) => {
|
|
421
|
+
const { readProgramMountOverrides } = await import("./config/program-install.mjs");
|
|
422
|
+
return readProgramMountOverrides(programId, { userConfigDir });
|
|
423
|
+
}
|
|
420
424
|
});
|
|
421
425
|
await programManager.activateAll();
|
|
422
426
|
const activated = programManager.getActivatedPrograms();
|