@alashi/cli 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.
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +148 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +256 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/store-CwgDCAAx.mjs +1201 -0
- package/dist/store-CwgDCAAx.mjs.map +1 -0
- package/package.json +51 -0
- package/templates/basic-app/README.md +47 -0
- package/templates/basic-app/gitignore +1 -0
- package/templates/basic-app/index.mjs +72 -0
- package/templates/basic-app/package.json +9 -0
- package/templates/basic-app/ui/index.html +466 -0
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseManifest } from "@alashi/apps-sdk";
|
|
2
|
+
import semver from "semver";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import { streamSSE } from "hono/streaming";
|
|
12
|
+
import { Cron } from "croner";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
//#region src/compat.ts
|
|
15
|
+
/**
|
|
16
|
+
* Enforces the manifest's declared `sdkVersion` range against the SDK
|
|
17
|
+
* this host is built with. Apps that declare nothing are accepted.
|
|
18
|
+
*/
|
|
19
|
+
function checkSdkCompatibility(manifest) {
|
|
20
|
+
const range = manifest.sdkVersion;
|
|
21
|
+
if (!range) return;
|
|
22
|
+
if (!semver.validRange(range)) throw new AppSdkError(`App "${manifest.name}" declares an invalid sdkVersion range: ${JSON.stringify(range)}`);
|
|
23
|
+
if (!semver.satisfies(SDK_VERSION, range)) throw new AppSdkError(`App "${manifest.name}" supports SDK ${range}, but this host uses SDK ${SDK_VERSION}. Update the host (or the app) to a compatible version.`);
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/loader.ts
|
|
27
|
+
function entryFromExports(exportsField) {
|
|
28
|
+
if (typeof exportsField === "string") return exportsField;
|
|
29
|
+
if (typeof exportsField !== "object" || exportsField === null) return void 0;
|
|
30
|
+
const record = exportsField;
|
|
31
|
+
const dot = record["."] ?? record.default ?? record.import;
|
|
32
|
+
if (typeof dot === "string") return dot;
|
|
33
|
+
if (typeof dot === "object" && dot !== null) {
|
|
34
|
+
const conditions = dot;
|
|
35
|
+
const target = conditions.default ?? conditions.import;
|
|
36
|
+
if (typeof target === "string") return target;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Imports an app's entry module and validates its default export.
|
|
41
|
+
* The manifest is always re-validated here — the host never trusts
|
|
42
|
+
* that an app used defineApp().
|
|
43
|
+
*/
|
|
44
|
+
async function loadApp(repoDir) {
|
|
45
|
+
let pkg;
|
|
46
|
+
try {
|
|
47
|
+
pkg = JSON.parse(await readFile(join(repoDir, "package.json"), "utf8"));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new AppSdkError(`App has no readable package.json in ${repoDir}`, { cause: err });
|
|
50
|
+
}
|
|
51
|
+
const entry = entryFromExports(pkg.exports) ?? pkg.main ?? "index.js";
|
|
52
|
+
const entryPath = join(repoDir, entry);
|
|
53
|
+
let mod;
|
|
54
|
+
try {
|
|
55
|
+
mod = await import(`${pathToFileURL(entryPath).href}?t=${Date.now()}`);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
let detail = String(err);
|
|
58
|
+
if (err?.name === "ManifestValidationError" && /Unrecognized key/i.test(detail)) detail += "\nThe manifest may use fields newer than the app's bundled @alashi/apps-sdk — update the app's dependency to match its sdkVersion.";
|
|
59
|
+
throw new AppSdkError(`Failed to import app entry ${entry}: ${detail}`, { cause: err });
|
|
60
|
+
}
|
|
61
|
+
const definition = mod.default;
|
|
62
|
+
if (typeof definition !== "object" || definition === null || !("manifest" in definition)) throw new AppSdkError("App entry must default-export an app definition (use defineApp() from @alashi/apps-sdk)");
|
|
63
|
+
return {
|
|
64
|
+
definition,
|
|
65
|
+
manifest: parseManifest(definition.manifest),
|
|
66
|
+
entryPath
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/sources.ts
|
|
71
|
+
const execFileAsync$1 = promisify(execFile);
|
|
72
|
+
function parseSourceArg(arg) {
|
|
73
|
+
if (arg.startsWith(".") || isAbsolute(arg)) return {
|
|
74
|
+
type: "local",
|
|
75
|
+
path: resolve(arg)
|
|
76
|
+
};
|
|
77
|
+
if (/^(https?:\/\/|git@|ssh:\/\/)/.test(arg)) return {
|
|
78
|
+
type: "git",
|
|
79
|
+
url: arg
|
|
80
|
+
};
|
|
81
|
+
if (/^github\.com\//.test(arg)) return {
|
|
82
|
+
type: "git",
|
|
83
|
+
url: `https://${arg}`
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
type: "registry",
|
|
87
|
+
name: arg
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function git(args, cwd) {
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await execFileAsync$1("git", args, cwd ? { cwd } : {});
|
|
93
|
+
return stdout.trim();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
throw new AppSdkError(`git ${args[0]} failed: ${message}`, { cause: err });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Materializes a source at destDir: git clone, or symlink for local dev installs. */
|
|
100
|
+
async function fetchSource(spec, destDir) {
|
|
101
|
+
await mkdir(resolve(destDir, ".."), { recursive: true });
|
|
102
|
+
if (spec.type === "git") {
|
|
103
|
+
const args = [
|
|
104
|
+
"clone",
|
|
105
|
+
"--depth",
|
|
106
|
+
"1"
|
|
107
|
+
];
|
|
108
|
+
if (spec.ref) args.push("--branch", spec.ref);
|
|
109
|
+
args.push(spec.url, destDir);
|
|
110
|
+
await git(args);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await symlink(spec.path, destDir, "dir");
|
|
114
|
+
}
|
|
115
|
+
/** Brings an installed source up to date in place. Local symlinks are always live. */
|
|
116
|
+
async function updateSource(spec, repoDir) {
|
|
117
|
+
if (spec.type === "git") await git(["pull", "--ff-only"], repoDir);
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/registry.ts
|
|
121
|
+
const registryIndexSchema = z.object({ apps: z.array(z.object({
|
|
122
|
+
name: z.string().min(1),
|
|
123
|
+
repo: z.string().min(1),
|
|
124
|
+
description: z.string().optional(),
|
|
125
|
+
ref: z.string().optional(),
|
|
126
|
+
sdkVersion: z.string().optional()
|
|
127
|
+
})) });
|
|
128
|
+
function parseIndex(raw, source) {
|
|
129
|
+
let data;
|
|
130
|
+
try {
|
|
131
|
+
data = JSON.parse(raw);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new AppSdkError(`Registry ${source} did not return valid JSON`, { cause: err });
|
|
134
|
+
}
|
|
135
|
+
const result = registryIndexSchema.safeParse(data);
|
|
136
|
+
if (!result.success) throw new AppSdkError(`Registry ${source} has an invalid index: ${result.error.message}`);
|
|
137
|
+
return result.data;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Fetches a registry index and caches it under registries/<name>.json.
|
|
141
|
+
* URLs ending in .json are fetched over HTTP; anything else is treated
|
|
142
|
+
* as a git repo containing index.json at its root (brew-tap style).
|
|
143
|
+
*/
|
|
144
|
+
async function refreshRegistry(store, registry) {
|
|
145
|
+
let raw;
|
|
146
|
+
if (/^https?:\/\/.*\.json$/.test(registry.url)) {
|
|
147
|
+
const response = await fetch(registry.url);
|
|
148
|
+
if (!response.ok) throw new AppSdkError(`Registry fetch failed (${response.status}) for ${registry.url}`);
|
|
149
|
+
raw = await response.text();
|
|
150
|
+
} else {
|
|
151
|
+
const cloneDir = join(store.stagingDir, `registry-${registry.name}-${Date.now()}`);
|
|
152
|
+
await fetchSource({
|
|
153
|
+
type: "git",
|
|
154
|
+
url: registry.url
|
|
155
|
+
}, cloneDir);
|
|
156
|
+
try {
|
|
157
|
+
raw = await readFile(join(cloneDir, "index.json"), "utf8");
|
|
158
|
+
} finally {
|
|
159
|
+
await rm(cloneDir, {
|
|
160
|
+
recursive: true,
|
|
161
|
+
force: true
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const index = parseIndex(raw, registry.url);
|
|
166
|
+
await mkdir(store.registriesDir, { recursive: true });
|
|
167
|
+
await writeFile(store.registryCachePath(registry.name), JSON.stringify(index, null, 2), "utf8");
|
|
168
|
+
return index;
|
|
169
|
+
}
|
|
170
|
+
async function readCachedIndex(store, name) {
|
|
171
|
+
try {
|
|
172
|
+
return parseIndex(await readFile(store.registryCachePath(name), "utf8"), name);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err.code === "ENOENT") return void 0;
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function addRegistry(store, name, url) {
|
|
179
|
+
const config = await store.readGlobalConfig();
|
|
180
|
+
if (config.registries.some((r) => r.name === name)) throw new AppSdkError(`Registry "${name}" already exists`);
|
|
181
|
+
const index = await refreshRegistry(store, {
|
|
182
|
+
name,
|
|
183
|
+
url
|
|
184
|
+
});
|
|
185
|
+
config.registries.push({
|
|
186
|
+
name,
|
|
187
|
+
url
|
|
188
|
+
});
|
|
189
|
+
await store.writeGlobalConfig(config);
|
|
190
|
+
return index;
|
|
191
|
+
}
|
|
192
|
+
async function removeRegistry(store, name) {
|
|
193
|
+
const config = await store.readGlobalConfig();
|
|
194
|
+
const next = config.registries.filter((r) => r.name !== name);
|
|
195
|
+
if (next.length === config.registries.length) throw new AppSdkError(`Registry "${name}" is not configured`);
|
|
196
|
+
config.registries = next;
|
|
197
|
+
await store.writeGlobalConfig(config);
|
|
198
|
+
await rm(store.registryCachePath(name), { force: true });
|
|
199
|
+
}
|
|
200
|
+
/** Finds an app by name across configured registries (refreshing caches). */
|
|
201
|
+
async function resolveApp(store, appName) {
|
|
202
|
+
const config = await store.readGlobalConfig();
|
|
203
|
+
if (config.registries.length === 0) throw new AppSdkError(`No registries configured. Add one with: alashi registry add <name> <url>`);
|
|
204
|
+
for (const registry of config.registries) {
|
|
205
|
+
let index;
|
|
206
|
+
try {
|
|
207
|
+
index = await refreshRegistry(store, registry);
|
|
208
|
+
} catch {
|
|
209
|
+
const cached = await readCachedIndex(store, registry.name);
|
|
210
|
+
if (!cached) continue;
|
|
211
|
+
index = cached;
|
|
212
|
+
}
|
|
213
|
+
const app = index.apps.find((a) => a.name === appName);
|
|
214
|
+
if (app) return {
|
|
215
|
+
registry: registry.name,
|
|
216
|
+
app
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
throw new AppSdkError(`App "${appName}" not found in any configured registry`);
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/installer.ts
|
|
223
|
+
const execFileAsync = promisify(execFile);
|
|
224
|
+
async function hasDependencies(dir) {
|
|
225
|
+
try {
|
|
226
|
+
const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
227
|
+
return Object.keys(pkg.dependencies ?? {}).length > 0;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Lifecycle scripts stay off: installing an app must not execute arbitrary code. */
|
|
233
|
+
async function installDependencies(dir) {
|
|
234
|
+
if (!await hasDependencies(dir)) return;
|
|
235
|
+
try {
|
|
236
|
+
await execFileAsync("npm", [
|
|
237
|
+
"install",
|
|
238
|
+
"--ignore-scripts",
|
|
239
|
+
"--omit=dev",
|
|
240
|
+
"--no-fund",
|
|
241
|
+
"--no-audit"
|
|
242
|
+
], { cwd: dir });
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw new AppSdkError(`npm install failed: ${err instanceof Error ? err.message : err}`, { cause: err });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Install flow: materialize the source into a staging dir, install deps
|
|
249
|
+
* (scripts disabled), import + validate the manifest, and only then move
|
|
250
|
+
* it into apps/<name>/repo. A failed validation never leaves a broken app.
|
|
251
|
+
*/
|
|
252
|
+
async function installApp(store, source, options = {}) {
|
|
253
|
+
let spec;
|
|
254
|
+
let expectName = options.expectName;
|
|
255
|
+
if (typeof source === "string") {
|
|
256
|
+
const parsed = parseSourceArg(source);
|
|
257
|
+
if (parsed.type === "registry") {
|
|
258
|
+
const resolved = await resolveApp(store, parsed.name);
|
|
259
|
+
spec = {
|
|
260
|
+
type: "git",
|
|
261
|
+
url: resolved.app.repo,
|
|
262
|
+
...resolved.app.ref && { ref: resolved.app.ref }
|
|
263
|
+
};
|
|
264
|
+
expectName = expectName ?? parsed.name;
|
|
265
|
+
} else spec = parsed;
|
|
266
|
+
} else spec = source;
|
|
267
|
+
let stageDir;
|
|
268
|
+
let inspectDir;
|
|
269
|
+
if (spec.type === "local") inspectDir = spec.path;
|
|
270
|
+
else {
|
|
271
|
+
stageDir = join(store.stagingDir, randomUUID());
|
|
272
|
+
await fetchSource(spec, stageDir);
|
|
273
|
+
await installDependencies(stageDir);
|
|
274
|
+
inspectDir = stageDir;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const { manifest } = await loadApp(inspectDir);
|
|
278
|
+
checkSdkCompatibility(manifest);
|
|
279
|
+
if (expectName && manifest.name !== expectName) throw new AppSdkError(`App manifest name "${manifest.name}" does not match requested "${expectName}"`);
|
|
280
|
+
if (await store.readAppRecord(manifest.name)) throw new AppSdkError(`App "${manifest.name}" is already installed (use: alashi update ${manifest.name})`);
|
|
281
|
+
const repoDir = store.appRepoDir(manifest.name);
|
|
282
|
+
await mkdir(store.appDir(manifest.name), { recursive: true });
|
|
283
|
+
if (stageDir) {
|
|
284
|
+
await rename(stageDir, repoDir);
|
|
285
|
+
stageDir = void 0;
|
|
286
|
+
} else await fetchSource(spec, repoDir);
|
|
287
|
+
await mkdir(store.appDataDir(manifest.name), { recursive: true });
|
|
288
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
289
|
+
const record = {
|
|
290
|
+
name: manifest.name,
|
|
291
|
+
version: manifest.version,
|
|
292
|
+
...manifest.sdkVersion && { sdkVersion: manifest.sdkVersion },
|
|
293
|
+
source: spec,
|
|
294
|
+
installedAt: now,
|
|
295
|
+
updatedAt: now
|
|
296
|
+
};
|
|
297
|
+
await store.writeAppRecord(record);
|
|
298
|
+
return record;
|
|
299
|
+
} finally {
|
|
300
|
+
if (stageDir) await rm(stageDir, {
|
|
301
|
+
recursive: true,
|
|
302
|
+
force: true
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function updateApp(store, name) {
|
|
307
|
+
const record = await store.readAppRecord(name);
|
|
308
|
+
if (!record) throw new AppSdkError(`App "${name}" is not installed`);
|
|
309
|
+
const repoDir = store.appRepoDir(name);
|
|
310
|
+
await updateSource(record.source, repoDir);
|
|
311
|
+
await installDependencies(repoDir);
|
|
312
|
+
const { manifest } = await loadApp(repoDir);
|
|
313
|
+
checkSdkCompatibility(manifest);
|
|
314
|
+
if (manifest.name !== name) throw new AppSdkError(`App renamed itself to "${manifest.name}"; refusing update`);
|
|
315
|
+
const updated = {
|
|
316
|
+
...record,
|
|
317
|
+
version: manifest.version,
|
|
318
|
+
...manifest.sdkVersion && { sdkVersion: manifest.sdkVersion },
|
|
319
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
320
|
+
};
|
|
321
|
+
await store.writeAppRecord(updated);
|
|
322
|
+
return updated;
|
|
323
|
+
}
|
|
324
|
+
async function removeApp(store, name, options = {}) {
|
|
325
|
+
if (!await store.readAppRecord(name)) throw new AppSdkError(`App "${name}" is not installed`);
|
|
326
|
+
if (options.purge) {
|
|
327
|
+
await rm(store.appDir(name), {
|
|
328
|
+
recursive: true,
|
|
329
|
+
force: true
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
await rm(store.appRepoDir(name), {
|
|
334
|
+
recursive: true,
|
|
335
|
+
force: true
|
|
336
|
+
});
|
|
337
|
+
await rm(store.appRecordPath(name), { force: true });
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/runtime.ts
|
|
341
|
+
function createLogger(prefix) {
|
|
342
|
+
const log = (level, message, data) => {
|
|
343
|
+
const line = `[${prefix}] ${message}`;
|
|
344
|
+
if (data === void 0) console[level](line);
|
|
345
|
+
else console[level](line, data);
|
|
346
|
+
};
|
|
347
|
+
return {
|
|
348
|
+
debug: (message, data) => log("debug", message, data),
|
|
349
|
+
info: (message, data) => log("info", message, data),
|
|
350
|
+
warn: (message, data) => log("warn", message, data),
|
|
351
|
+
error: (message, data) => log("error", message, data)
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Wraps the host provider so every session an app opens automatically gets
|
|
356
|
+
* its manifest-declared MCP servers, tool allowlist, agents, skills,
|
|
357
|
+
* instructions, and data-dir cwd. Per-call options still win where they
|
|
358
|
+
* overlap.
|
|
359
|
+
*/
|
|
360
|
+
function scopeProvider(provider, defaults) {
|
|
361
|
+
return {
|
|
362
|
+
name: provider.name,
|
|
363
|
+
async createSession(options = {}) {
|
|
364
|
+
if (defaults.unreadyError !== void 0) throw new ConfigError(defaults.unreadyError);
|
|
365
|
+
const mcpServers = {
|
|
366
|
+
...defaults.mcpServers,
|
|
367
|
+
...options.mcpServers
|
|
368
|
+
};
|
|
369
|
+
const allowedTools = [.../* @__PURE__ */ new Set([...defaults.allowedTools, ...options.allowedTools ?? []])];
|
|
370
|
+
const agents = {
|
|
371
|
+
...defaults.agents,
|
|
372
|
+
...options.agents
|
|
373
|
+
};
|
|
374
|
+
const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...options.skillDirs ?? []])];
|
|
375
|
+
return provider.createSession({
|
|
376
|
+
cwd: defaults.cwd,
|
|
377
|
+
...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
|
|
378
|
+
...options,
|
|
379
|
+
...Object.keys(mcpServers).length > 0 && { mcpServers },
|
|
380
|
+
...allowedTools.length > 0 && { allowedTools },
|
|
381
|
+
...Object.keys(agents).length > 0 && { agents },
|
|
382
|
+
...skillDirs.length > 0 && { skillDirs }
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** Merges the config layers for an app and evaluates its setup state. */
|
|
388
|
+
async function resolveAppConfig(store, manifest) {
|
|
389
|
+
const globalConfig = await store.readGlobalConfig();
|
|
390
|
+
const merged = mergeConfig(manifestConfigDefaults(manifest), globalConfig.apps?.[manifest.name], await store.readAppConfig(manifest.name));
|
|
391
|
+
return {
|
|
392
|
+
merged,
|
|
393
|
+
status: evaluateAppConfig(manifest, merged)
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Builds the AppContext for an installed app: merges the config layers
|
|
398
|
+
* (manifest defaults < global apps[name] < per-app config), resolves
|
|
399
|
+
* `${config.*}` placeholders (with `dataDir` available as a built-in key),
|
|
400
|
+
* and scopes the AI provider to the app's manifest.
|
|
401
|
+
*
|
|
402
|
+
* Setup state shapes the session defaults: unconfigured MCP servers are
|
|
403
|
+
* dropped (with their tools) so interpolation never hits a missing key, and
|
|
404
|
+
* while required keys are missing the scoped provider fails closed.
|
|
405
|
+
*/
|
|
406
|
+
async function buildAppContext(store, manifest, provider) {
|
|
407
|
+
const name = manifest.name;
|
|
408
|
+
const dataDir = store.appDataDir(name);
|
|
409
|
+
const repoDir = store.appRepoDir(name);
|
|
410
|
+
await mkdir(dataDir, { recursive: true });
|
|
411
|
+
const { merged, status } = await resolveAppConfig(store, manifest);
|
|
412
|
+
const lookup = {
|
|
413
|
+
...merged,
|
|
414
|
+
dataDir
|
|
415
|
+
};
|
|
416
|
+
const interpolate = (value) => interpolateConfig(value, lookup, { resolveRelativeTo: repoDir });
|
|
417
|
+
const config = interpolate(merged);
|
|
418
|
+
const excluded = status.integrations.filter((i) => !i.configured).map((i) => i.name);
|
|
419
|
+
const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
|
|
420
|
+
const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
|
|
421
|
+
return {
|
|
422
|
+
appName: name,
|
|
423
|
+
dataDir,
|
|
424
|
+
config,
|
|
425
|
+
logger: createLogger(name),
|
|
426
|
+
state: new JsonFileStore(join(dataDir, "state.json")),
|
|
427
|
+
ai: scopeProvider(provider, {
|
|
428
|
+
mcpServers,
|
|
429
|
+
allowedTools,
|
|
430
|
+
agents: manifest.agents,
|
|
431
|
+
skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
|
|
432
|
+
...manifest.instructions !== void 0 && { instructions: manifest.instructions },
|
|
433
|
+
cwd: dataDir,
|
|
434
|
+
...!status.ready && { unreadyError: `App "${name}" is not configured: missing ${status.missing.join(", ")}. Set the missing keys in the dashboard settings or ${store.appConfigPath(name)}.` }
|
|
435
|
+
})
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
//#endregion
|
|
439
|
+
//#region src/scheduler.ts
|
|
440
|
+
const RUN_LOG_CAP = 50;
|
|
441
|
+
function nextFire(schedule, after) {
|
|
442
|
+
return new Cron(schedule).nextRun(after ?? void 0);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Runs manifest-declared jobs on their cron schedules. One instance per host
|
|
446
|
+
* process; start() schedules every installed app's jobs, stop() cancels them.
|
|
447
|
+
* A job missed while the host was down runs once at startup ("catchup"), but
|
|
448
|
+
* only if it has run before — a fresh install waits for its first slot.
|
|
449
|
+
*/
|
|
450
|
+
var Scheduler = class {
|
|
451
|
+
store;
|
|
452
|
+
getApp;
|
|
453
|
+
crons = [];
|
|
454
|
+
runningJobs = /* @__PURE__ */ new Set();
|
|
455
|
+
started = false;
|
|
456
|
+
/** Serializes jobs.json read-modify-write across concurrently finishing jobs. */
|
|
457
|
+
stateQueue = Promise.resolve();
|
|
458
|
+
constructor(store, getApp) {
|
|
459
|
+
this.store = store;
|
|
460
|
+
this.getApp = getApp;
|
|
461
|
+
}
|
|
462
|
+
async start() {
|
|
463
|
+
if (this.started) return;
|
|
464
|
+
this.started = true;
|
|
465
|
+
for (const record of await this.store.listInstalled()) {
|
|
466
|
+
let app;
|
|
467
|
+
try {
|
|
468
|
+
app = await this.getApp(record.name);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.error(`[scheduler] skipping ${record.name}: failed to load (${String(err)})`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (!app || app.manifest.jobs.length === 0) continue;
|
|
474
|
+
const state = await this.store.readJobState(record.name);
|
|
475
|
+
for (const job of app.manifest.jobs) {
|
|
476
|
+
let cron;
|
|
477
|
+
try {
|
|
478
|
+
cron = new Cron(job.schedule, { paused: true }, () => {
|
|
479
|
+
this.run(record.name, job.name, "schedule");
|
|
480
|
+
});
|
|
481
|
+
} catch (err) {
|
|
482
|
+
app.ctx.logger.error(`Job "${job.name}" has an invalid schedule`, {
|
|
483
|
+
schedule: job.schedule,
|
|
484
|
+
error: String(err)
|
|
485
|
+
});
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const last = state.lastRun[job.name];
|
|
489
|
+
if (last) {
|
|
490
|
+
const missed = nextFire(job.schedule, new Date(last));
|
|
491
|
+
if (missed && missed.getTime() <= Date.now()) this.run(record.name, job.name, "catchup");
|
|
492
|
+
}
|
|
493
|
+
cron.resume();
|
|
494
|
+
this.crons.push(cron);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
stop() {
|
|
499
|
+
for (const cron of this.crons) cron.stop();
|
|
500
|
+
this.crons = [];
|
|
501
|
+
this.started = false;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Runs one job immediately. Returns the recorded run, 'skipped' when that
|
|
505
|
+
* job is already mid-run, or undefined when the app/job doesn't exist.
|
|
506
|
+
*/
|
|
507
|
+
async run(appName, jobName, trigger = "manual") {
|
|
508
|
+
const app = await this.getApp(appName).catch(() => void 0);
|
|
509
|
+
const handler = app?.jobHandlers?.[jobName];
|
|
510
|
+
if (!app || !handler || !app.manifest.jobs.some((j) => j.name === jobName)) return void 0;
|
|
511
|
+
const key = `${appName}/${jobName}`;
|
|
512
|
+
if (this.runningJobs.has(key)) return "skipped";
|
|
513
|
+
this.runningJobs.add(key);
|
|
514
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
515
|
+
let ok = true;
|
|
516
|
+
let error;
|
|
517
|
+
try {
|
|
518
|
+
await handler(app.ctx);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
ok = false;
|
|
521
|
+
error = String(err);
|
|
522
|
+
app.ctx.logger.error(`Job "${jobName}" failed`, { error });
|
|
523
|
+
} finally {
|
|
524
|
+
this.runningJobs.delete(key);
|
|
525
|
+
}
|
|
526
|
+
const run = {
|
|
527
|
+
job: jobName,
|
|
528
|
+
trigger,
|
|
529
|
+
startedAt,
|
|
530
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
531
|
+
ok,
|
|
532
|
+
...error !== void 0 && { error }
|
|
533
|
+
};
|
|
534
|
+
await this.recordRun(appName, run);
|
|
535
|
+
return run;
|
|
536
|
+
}
|
|
537
|
+
/** Job statuses for an app, or undefined when the app isn't installed. */
|
|
538
|
+
async status(appName) {
|
|
539
|
+
const app = await this.getApp(appName).catch(() => void 0);
|
|
540
|
+
if (!app) return void 0;
|
|
541
|
+
const state = await this.store.readJobState(appName);
|
|
542
|
+
return app.manifest.jobs.map((job) => {
|
|
543
|
+
let valid = true;
|
|
544
|
+
let nextRun;
|
|
545
|
+
try {
|
|
546
|
+
nextRun = nextFire(job.schedule)?.toISOString();
|
|
547
|
+
} catch {
|
|
548
|
+
valid = false;
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
name: job.name,
|
|
552
|
+
schedule: job.schedule,
|
|
553
|
+
...job.description !== void 0 && { description: job.description },
|
|
554
|
+
valid,
|
|
555
|
+
running: this.runningJobs.has(`${appName}/${job.name}`),
|
|
556
|
+
...nextRun !== void 0 && { nextRun },
|
|
557
|
+
...state.lastRun[job.name] !== void 0 && { lastRun: state.lastRun[job.name] },
|
|
558
|
+
recentRuns: state.runs.filter((r) => r.job === job.name).slice(-5).reverse()
|
|
559
|
+
};
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
recordRun(appName, run) {
|
|
563
|
+
const update = this.stateQueue.then(async () => {
|
|
564
|
+
const state = await this.store.readJobState(appName);
|
|
565
|
+
state.lastRun[run.job] = run.startedAt;
|
|
566
|
+
state.runs.push(run);
|
|
567
|
+
if (state.runs.length > RUN_LOG_CAP) state.runs.splice(0, state.runs.length - RUN_LOG_CAP);
|
|
568
|
+
await this.store.writeJobState(appName, state);
|
|
569
|
+
});
|
|
570
|
+
this.stateQueue = update.catch(() => void 0);
|
|
571
|
+
return update;
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/settings-page.ts
|
|
576
|
+
/**
|
|
577
|
+
* Self-contained per-app settings page. All data comes from
|
|
578
|
+
* GET /api/apps/:name/config at runtime; saving PUTs only changed keys.
|
|
579
|
+
* The app name is injected via a JSON-encoded script constant, never into
|
|
580
|
+
* markup, so no escaping subtleties apply.
|
|
581
|
+
*/
|
|
582
|
+
function settingsPage(name) {
|
|
583
|
+
return `<!doctype html>
|
|
584
|
+
<html lang="en">
|
|
585
|
+
<head>
|
|
586
|
+
<meta charset="utf-8">
|
|
587
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
588
|
+
<title>Settings</title>
|
|
589
|
+
<style>
|
|
590
|
+
:root { --accent: #6d28d9; --bg: #f6f5f8; --card: #fff; --line: #e5e2eb; --muted: #6b6478; }
|
|
591
|
+
* { box-sizing: border-box; }
|
|
592
|
+
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; background: var(--bg); color: #1d1a24; }
|
|
593
|
+
header { background: #17141f; color: #fff; padding: 14px 24px; display: flex; align-items: center; gap: 12px; }
|
|
594
|
+
header a { color: #cbc4dd; text-decoration: none; }
|
|
595
|
+
main { max-width: 760px; margin: 24px auto; padding: 0 16px; }
|
|
596
|
+
.banner { border-radius: 8px; padding: 10px 14px; margin-bottom: 16px; font-weight: 500; }
|
|
597
|
+
.banner.ok { background: #e7f6ec; color: #16652f; }
|
|
598
|
+
.banner.warn { background: #fdeeee; color: #9c2121; }
|
|
599
|
+
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; margin-bottom: 16px; }
|
|
600
|
+
.card h2 { margin: 0; padding: 12px 16px; font-size: 15px; border-bottom: 1px solid var(--line);
|
|
601
|
+
display: flex; align-items: center; justify-content: space-between; }
|
|
602
|
+
.chip { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 2px 10px; }
|
|
603
|
+
.chip.on { background: #e7f6ec; color: #16652f; }
|
|
604
|
+
.chip.off { background: #f1eef7; color: var(--muted); }
|
|
605
|
+
.card .body { padding: 12px 16px 16px; }
|
|
606
|
+
.field { margin-bottom: 12px; }
|
|
607
|
+
.field label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 2px; }
|
|
608
|
+
.field .desc { font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
|
|
609
|
+
.field input { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px;
|
|
610
|
+
font: inherit; }
|
|
611
|
+
.field input:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
|
612
|
+
.req { color: #9c2121; }
|
|
613
|
+
button { background: var(--accent); color: #fff; border: 0; border-radius: 6px;
|
|
614
|
+
padding: 9px 18px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
615
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
616
|
+
#status { margin-left: 12px; color: var(--muted); }
|
|
617
|
+
</style>
|
|
618
|
+
</head>
|
|
619
|
+
<body>
|
|
620
|
+
<header><a href="/">alashi</a> <span>/</span> <strong id="title"></strong> <span>settings</span></header>
|
|
621
|
+
<main>
|
|
622
|
+
<div id="banner" class="banner" hidden></div>
|
|
623
|
+
<div id="cards"></div>
|
|
624
|
+
<button id="save">Save changes</button><span id="status"></span>
|
|
625
|
+
</main>
|
|
626
|
+
<script>
|
|
627
|
+
const APP = ${JSON.stringify(name)};
|
|
628
|
+
document.getElementById('title').textContent = APP;
|
|
629
|
+
|
|
630
|
+
const state = { data: null, dirty: new Map() };
|
|
631
|
+
|
|
632
|
+
function el(tag, attrs = {}, children = []) {
|
|
633
|
+
const node = document.createElement(tag);
|
|
634
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
635
|
+
if (k === 'text') node.textContent = v;
|
|
636
|
+
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
|
|
637
|
+
else node.setAttribute(k, v);
|
|
638
|
+
}
|
|
639
|
+
for (const child of children) node.append(child);
|
|
640
|
+
return node;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function parseInput(raw) {
|
|
644
|
+
if (raw === '') return null; // empty clears the user-set value
|
|
645
|
+
try { const v = JSON.parse(raw); if (typeof v !== 'string') return v; } catch {}
|
|
646
|
+
return raw;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function displayValue(value) {
|
|
650
|
+
return typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function field(key, decl, data) {
|
|
654
|
+
const secret = decl.secret === true;
|
|
655
|
+
const isSet = secret ? data.secretsSet.includes(key) : data.values[key] !== undefined;
|
|
656
|
+
const input = el('input', {
|
|
657
|
+
type: secret ? 'password' : 'text',
|
|
658
|
+
value: secret ? '' : displayValue(data.values[key]),
|
|
659
|
+
placeholder: secret && isSet ? '(set — type to replace)' : '',
|
|
660
|
+
oninput: () => state.dirty.set(key, { secret, input }),
|
|
661
|
+
});
|
|
662
|
+
const labelChildren = [document.createTextNode(key)];
|
|
663
|
+
if (decl.required) labelChildren.push(el('span', { class: 'req', text: ' *' }));
|
|
664
|
+
const parts = [el('label', {}, labelChildren)];
|
|
665
|
+
if (decl.description) parts.push(el('div', { class: 'desc', text: decl.description }));
|
|
666
|
+
parts.push(input);
|
|
667
|
+
return el('div', { class: 'field' }, parts);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function card(title, keys, data, configured) {
|
|
671
|
+
const chip = configured === undefined
|
|
672
|
+
? ''
|
|
673
|
+
: el('span', { class: 'chip ' + (configured ? 'on' : 'off'),
|
|
674
|
+
text: configured ? 'configured' : 'not configured' });
|
|
675
|
+
return el('div', { class: 'card' }, [
|
|
676
|
+
el('h2', {}, chip === '' ? [document.createTextNode(title)] : [document.createTextNode(title), chip]),
|
|
677
|
+
el('div', { class: 'body' }, keys.map((key) => field(key, data.schema[key] ?? {}, data))),
|
|
678
|
+
]);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function render(data) {
|
|
682
|
+
state.data = data;
|
|
683
|
+
state.dirty.clear();
|
|
684
|
+
const banner = document.getElementById('banner');
|
|
685
|
+
banner.hidden = false;
|
|
686
|
+
if (data.ready) {
|
|
687
|
+
banner.className = 'banner ok';
|
|
688
|
+
banner.textContent = 'App is configured and ready.';
|
|
689
|
+
} else {
|
|
690
|
+
banner.className = 'banner warn';
|
|
691
|
+
banner.textContent = 'Needs setup — missing: ' + data.missing.join(', ');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const cards = document.getElementById('cards');
|
|
695
|
+
cards.replaceChildren();
|
|
696
|
+
const integrationKeys = new Set(data.integrations.flatMap((i) => i.configKeys));
|
|
697
|
+
const generalKeys = Object.keys(data.schema).filter((k) => !integrationKeys.has(k));
|
|
698
|
+
if (generalKeys.length > 0) cards.append(card('General', generalKeys, data, undefined));
|
|
699
|
+
for (const integration of data.integrations) {
|
|
700
|
+
if (integration.configKeys.length === 0) continue;
|
|
701
|
+
cards.append(card(
|
|
702
|
+
integration.name + (integration.optional ? ' (optional integration)' : ''),
|
|
703
|
+
integration.configKeys, data, integration.configured,
|
|
704
|
+
));
|
|
705
|
+
}
|
|
706
|
+
if (cards.childElementCount === 0) {
|
|
707
|
+
cards.append(el('p', { text: 'This app declares no configurable settings.' }));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function load() {
|
|
712
|
+
const res = await fetch('/api/apps/' + APP + '/config');
|
|
713
|
+
if (!res.ok) {
|
|
714
|
+
document.getElementById('banner').hidden = false;
|
|
715
|
+
document.getElementById('banner').className = 'banner warn';
|
|
716
|
+
document.getElementById('banner').textContent = 'Failed to load config (' + res.status + ')';
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
render(await res.json());
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
document.getElementById('save').addEventListener('click', async () => {
|
|
723
|
+
const body = {};
|
|
724
|
+
for (const [key, { secret, input }] of state.dirty) {
|
|
725
|
+
if (secret && input.value === '') continue; // untouched secret: keep as-is
|
|
726
|
+
body[key] = parseInput(input.value);
|
|
727
|
+
}
|
|
728
|
+
if (Object.keys(body).length === 0) return;
|
|
729
|
+
const status = document.getElementById('status');
|
|
730
|
+
status.textContent = 'Saving…';
|
|
731
|
+
const res = await fetch('/api/apps/' + APP + '/config', {
|
|
732
|
+
method: 'PUT',
|
|
733
|
+
headers: { 'content-type': 'application/json' },
|
|
734
|
+
body: JSON.stringify(body),
|
|
735
|
+
});
|
|
736
|
+
status.textContent = res.ok ? 'Saved — app restarted.' : 'Save failed (' + res.status + ')';
|
|
737
|
+
if (res.ok) await load();
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
load();
|
|
741
|
+
<\/script>
|
|
742
|
+
</body>
|
|
743
|
+
</html>
|
|
744
|
+
`;
|
|
745
|
+
}
|
|
746
|
+
//#endregion
|
|
747
|
+
//#region src/server.ts
|
|
748
|
+
const CONTENT_TYPES = {
|
|
749
|
+
".html": "text/html; charset=utf-8",
|
|
750
|
+
".js": "text/javascript; charset=utf-8",
|
|
751
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
752
|
+
".css": "text/css; charset=utf-8",
|
|
753
|
+
".json": "application/json",
|
|
754
|
+
".svg": "image/svg+xml",
|
|
755
|
+
".png": "image/png",
|
|
756
|
+
".ico": "image/x-icon",
|
|
757
|
+
".txt": "text/plain; charset=utf-8"
|
|
758
|
+
};
|
|
759
|
+
function escapeHtml(value) {
|
|
760
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* The host HTTP surface:
|
|
764
|
+
* GET / dashboard of installed apps
|
|
765
|
+
* GET /settings/:name per-app settings page (config form)
|
|
766
|
+
* GET /api/apps installed apps as JSON
|
|
767
|
+
* POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
|
|
768
|
+
* (omit conversationId to start a new conversation; the
|
|
769
|
+
* id is echoed in the x-conversation-id response header)
|
|
770
|
+
* GET /api/apps/:name/chat/conversations conversations, most recent first
|
|
771
|
+
* GET /api/apps/:name/chat/:conversationId/history past turns from the provider transcript
|
|
772
|
+
* GET /api/apps/:name/jobs manifest-declared jobs with schedule/status
|
|
773
|
+
* POST /api/apps/:name/jobs/:job/run trigger a job now (409 while already running)
|
|
774
|
+
* GET /api/apps/:name/config declared config schema + merged values
|
|
775
|
+
* (secrets masked) + readiness/integrations
|
|
776
|
+
* PUT /api/apps/:name/config merge keys into the per-app config layer
|
|
777
|
+
* (null deletes a key) and restart the app
|
|
778
|
+
* ALL /app/:name/api/* dispatched to the app's fetch handler (prefix stripped)
|
|
779
|
+
* ALL /app/:name/* a matching static file from the app's ui dir wins,
|
|
780
|
+
* otherwise the app's fetch handler (/app/:name stripped),
|
|
781
|
+
* otherwise 404
|
|
782
|
+
*/
|
|
783
|
+
function createServer(store, provider) {
|
|
784
|
+
const apps = /* @__PURE__ */ new Map();
|
|
785
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
786
|
+
async function startApp(name) {
|
|
787
|
+
const { definition, manifest } = await loadApp(store.appRepoDir(name));
|
|
788
|
+
const ctx = await buildAppContext(store, manifest, provider);
|
|
789
|
+
return {
|
|
790
|
+
manifest,
|
|
791
|
+
ctx,
|
|
792
|
+
handler: definition.routes ? await definition.routes(ctx) : void 0,
|
|
793
|
+
jobHandlers: definition.jobHandlers
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/** Loads an installed app once per process; undefined when not installed. */
|
|
797
|
+
async function getApp(name) {
|
|
798
|
+
if (!APP_NAME_PATTERN.test(name)) return void 0;
|
|
799
|
+
if (!await store.readAppRecord(name)) return void 0;
|
|
800
|
+
let running = apps.get(name);
|
|
801
|
+
if (!running) {
|
|
802
|
+
running = startApp(name);
|
|
803
|
+
running.catch(() => apps.delete(name));
|
|
804
|
+
apps.set(name, running);
|
|
805
|
+
}
|
|
806
|
+
return running;
|
|
807
|
+
}
|
|
808
|
+
/** chat.json per app, loaded once and mutated in place so turns share one view. */
|
|
809
|
+
const chatStates = /* @__PURE__ */ new Map();
|
|
810
|
+
function getChatState(name) {
|
|
811
|
+
let state = chatStates.get(name);
|
|
812
|
+
if (!state) {
|
|
813
|
+
state = store.readChatState(name);
|
|
814
|
+
state.catch(() => chatStates.delete(name));
|
|
815
|
+
chatStates.set(name, state);
|
|
816
|
+
}
|
|
817
|
+
return state;
|
|
818
|
+
}
|
|
819
|
+
function getSession(name, running, convo) {
|
|
820
|
+
const key = `${name}/${convo.id}`;
|
|
821
|
+
let session = sessions.get(key);
|
|
822
|
+
if (!session) {
|
|
823
|
+
const model = running.ctx.config.model;
|
|
824
|
+
session = running.ctx.ai.createSession({
|
|
825
|
+
...typeof model === "string" && { model },
|
|
826
|
+
...convo.sessionId && { resume: convo.sessionId }
|
|
827
|
+
});
|
|
828
|
+
session.catch(() => sessions.delete(key));
|
|
829
|
+
sessions.set(key, session);
|
|
830
|
+
}
|
|
831
|
+
return session;
|
|
832
|
+
}
|
|
833
|
+
const scheduler = new Scheduler(store, getApp);
|
|
834
|
+
/** Rebuilds the request with `prefix` stripped and hands it to the app's handler. */
|
|
835
|
+
function dispatch(handler, raw, prefix) {
|
|
836
|
+
const url = new URL(raw.url);
|
|
837
|
+
url.pathname = url.pathname.slice(prefix.length) || "/";
|
|
838
|
+
return handler(new Request(url, {
|
|
839
|
+
method: raw.method,
|
|
840
|
+
headers: raw.headers,
|
|
841
|
+
body: raw.body,
|
|
842
|
+
...raw.body ? { duplex: "half" } : {}
|
|
843
|
+
}));
|
|
844
|
+
}
|
|
845
|
+
/** A file from the app's ui dir, or undefined when there is no match. */
|
|
846
|
+
async function serveStatic(name, manifest, reqPath) {
|
|
847
|
+
if (!manifest.ui) return void 0;
|
|
848
|
+
const uiDir = resolve(store.appRepoDir(name), manifest.ui);
|
|
849
|
+
let rest;
|
|
850
|
+
try {
|
|
851
|
+
rest = decodeURIComponent(reqPath.slice(`/app/${name}/`.length));
|
|
852
|
+
} catch {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (rest === "" || rest.endsWith("/")) rest += "index.html";
|
|
856
|
+
const filePath = resolve(uiDir, rest);
|
|
857
|
+
if (filePath !== uiDir && !filePath.startsWith(uiDir + sep)) return void 0;
|
|
858
|
+
try {
|
|
859
|
+
const body = await readFile(filePath);
|
|
860
|
+
const type = CONTENT_TYPES[extname(filePath)] ?? "application/octet-stream";
|
|
861
|
+
return new Response(new Uint8Array(body), {
|
|
862
|
+
status: 200,
|
|
863
|
+
headers: { "content-type": type }
|
|
864
|
+
});
|
|
865
|
+
} catch {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const app = new Hono();
|
|
870
|
+
app.onError((err, c) => c.json({ error: String(err) }, 500));
|
|
871
|
+
/** Readiness for the dashboard badge; undefined when the app fails to load. */
|
|
872
|
+
async function readiness(name) {
|
|
873
|
+
try {
|
|
874
|
+
const running = await getApp(name);
|
|
875
|
+
if (!running) return void 0;
|
|
876
|
+
return (await resolveAppConfig(store, running.manifest)).status;
|
|
877
|
+
} catch {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
app.get("/", async (c) => {
|
|
882
|
+
const records = await store.listInstalled();
|
|
883
|
+
const rows = (await Promise.all(records.map(async (r) => {
|
|
884
|
+
const name = escapeHtml(r.name);
|
|
885
|
+
const status = await readiness(r.name);
|
|
886
|
+
const badge = status && !status.ready ? ` <a href="/settings/${name}" style="color:#9c2121;font-weight:600">needs setup</a>` : "";
|
|
887
|
+
return `<li><a href="/app/${name}/">${name}</a> <small>v${escapeHtml(r.version)} (${escapeHtml(r.source.type)})</small> <a href="/settings/${name}"><small>settings</small></a>${badge}</li>`;
|
|
888
|
+
}))).join("\n");
|
|
889
|
+
return c.html(`<!doctype html>
|
|
890
|
+
<html lang="en">
|
|
891
|
+
<head><meta charset="utf-8"><title>alashi</title></head>
|
|
892
|
+
<body>
|
|
893
|
+
<h1>alashi</h1>
|
|
894
|
+
${records.length === 0 ? "<p>No apps installed. Run <code>alashi install <name></code>.</p>" : `<ul>\n${rows}\n</ul>`}
|
|
895
|
+
</body>
|
|
896
|
+
</html>
|
|
897
|
+
`);
|
|
898
|
+
});
|
|
899
|
+
app.get("/api/apps", async (c) => c.json({ apps: await store.listInstalled() }));
|
|
900
|
+
app.get("/settings/:name", async (c) => {
|
|
901
|
+
const name = c.req.param("name");
|
|
902
|
+
if (!APP_NAME_PATTERN.test(name) || !await store.readAppRecord(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
903
|
+
return c.html(settingsPage(name));
|
|
904
|
+
});
|
|
905
|
+
app.get("/api/apps/:name/chat/conversations", async (c) => {
|
|
906
|
+
const name = c.req.param("name");
|
|
907
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
908
|
+
const { conversations } = await getChatState(name);
|
|
909
|
+
return c.json({ conversations: [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) });
|
|
910
|
+
});
|
|
911
|
+
app.get("/api/apps/:name/chat/:conversationId/history", async (c) => {
|
|
912
|
+
const name = c.req.param("name");
|
|
913
|
+
if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
914
|
+
const conversationId = c.req.param("conversationId");
|
|
915
|
+
const { conversations } = await getChatState(name);
|
|
916
|
+
const convo = conversations.find((conv) => conv.id === conversationId);
|
|
917
|
+
if (!convo) return c.json({ error: "Unknown conversation" }, 404);
|
|
918
|
+
const messages = convo.sessionId ? await provider.history?.(convo.sessionId) ?? [] : [];
|
|
919
|
+
return c.json({ messages });
|
|
920
|
+
});
|
|
921
|
+
/** Drops the cached app and its warm sessions so the next request rebuilds with fresh config. */
|
|
922
|
+
function restartApp(name) {
|
|
923
|
+
apps.delete(name);
|
|
924
|
+
for (const [key, session] of sessions) {
|
|
925
|
+
if (!key.startsWith(`${name}/`)) continue;
|
|
926
|
+
sessions.delete(key);
|
|
927
|
+
session.then((s) => s.close()).catch(() => void 0);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
app.get("/api/apps/:name/config", async (c) => {
|
|
931
|
+
const name = c.req.param("name");
|
|
932
|
+
const running = await getApp(name);
|
|
933
|
+
if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
934
|
+
const { manifest } = running;
|
|
935
|
+
const { merged, status } = await resolveAppConfig(store, manifest);
|
|
936
|
+
const values = { ...merged };
|
|
937
|
+
const secretsSet = [];
|
|
938
|
+
for (const [key, decl] of Object.entries(manifest.config)) {
|
|
939
|
+
if (decl.secret !== true) continue;
|
|
940
|
+
if (values[key] !== void 0) secretsSet.push(key);
|
|
941
|
+
delete values[key];
|
|
942
|
+
}
|
|
943
|
+
return c.json({
|
|
944
|
+
schema: manifest.config,
|
|
945
|
+
values,
|
|
946
|
+
secretsSet,
|
|
947
|
+
userKeys: Object.keys(await store.readAppConfig(name)),
|
|
948
|
+
...status
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
app.put("/api/apps/:name/config", async (c) => {
|
|
952
|
+
const name = c.req.param("name");
|
|
953
|
+
const running = await getApp(name);
|
|
954
|
+
if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
955
|
+
let body;
|
|
956
|
+
try {
|
|
957
|
+
body = await c.req.json();
|
|
958
|
+
} catch {
|
|
959
|
+
return c.json({ error: "Body must be JSON" }, 400);
|
|
960
|
+
}
|
|
961
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return c.json({ error: "Body must be an object of config keys" }, 400);
|
|
962
|
+
const config = await store.readAppConfig(name);
|
|
963
|
+
for (const [key, value] of Object.entries(body)) {
|
|
964
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
|
965
|
+
if (value === null) delete config[key];
|
|
966
|
+
else config[key] = value;
|
|
967
|
+
}
|
|
968
|
+
await store.writeAppConfig(name, config);
|
|
969
|
+
restartApp(name);
|
|
970
|
+
const { status } = await resolveAppConfig(store, running.manifest);
|
|
971
|
+
return c.json({
|
|
972
|
+
ok: true,
|
|
973
|
+
...status
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
app.get("/api/apps/:name/jobs", async (c) => {
|
|
977
|
+
const name = c.req.param("name");
|
|
978
|
+
const jobs = await scheduler.status(name);
|
|
979
|
+
if (!jobs) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
980
|
+
return c.json({ jobs });
|
|
981
|
+
});
|
|
982
|
+
app.post("/api/apps/:name/jobs/:job/run", async (c) => {
|
|
983
|
+
const result = await scheduler.run(c.req.param("name"), c.req.param("job"));
|
|
984
|
+
if (result === void 0) return c.json({ error: "Unknown app or job" }, 404);
|
|
985
|
+
if (result === "skipped") return c.json({ error: "Job is already running" }, 409);
|
|
986
|
+
return c.json({ run: result }, result.ok ? 200 : 500);
|
|
987
|
+
});
|
|
988
|
+
app.post("/api/apps/:name/chat", async (c) => {
|
|
989
|
+
const name = c.req.param("name");
|
|
990
|
+
const running = await getApp(name);
|
|
991
|
+
if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
992
|
+
let message;
|
|
993
|
+
let conversationId;
|
|
994
|
+
try {
|
|
995
|
+
({message, conversationId} = await c.req.json());
|
|
996
|
+
} catch {
|
|
997
|
+
return c.json({ error: "Body must be JSON" }, 400);
|
|
998
|
+
}
|
|
999
|
+
if (typeof message !== "string" || message.length === 0) return c.json({ error: "Body must be { \"message\": string }" }, 400);
|
|
1000
|
+
if (conversationId !== void 0 && typeof conversationId !== "string") return c.json({ error: "\"conversationId\" must be a string" }, 400);
|
|
1001
|
+
const state = await getChatState(name);
|
|
1002
|
+
let convo;
|
|
1003
|
+
if (conversationId !== void 0) {
|
|
1004
|
+
convo = state.conversations.find((conv) => conv.id === conversationId);
|
|
1005
|
+
if (!convo) return c.json({ error: "Unknown conversation" }, 404);
|
|
1006
|
+
} else {
|
|
1007
|
+
convo = {
|
|
1008
|
+
id: randomUUID(),
|
|
1009
|
+
title: message.slice(0, 80),
|
|
1010
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1011
|
+
};
|
|
1012
|
+
state.conversations.push(convo);
|
|
1013
|
+
await store.writeChatState(name, state);
|
|
1014
|
+
}
|
|
1015
|
+
const conversation = convo;
|
|
1016
|
+
let session;
|
|
1017
|
+
try {
|
|
1018
|
+
session = await getSession(name, running, conversation);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
return c.json({ error: `Failed to start AI session: ${String(err)}` }, 502);
|
|
1021
|
+
}
|
|
1022
|
+
c.header("x-conversation-id", conversation.id);
|
|
1023
|
+
return streamSSE(c, async (stream) => {
|
|
1024
|
+
try {
|
|
1025
|
+
for await (const event of session.send(message)) {
|
|
1026
|
+
if (event.type === "session") {
|
|
1027
|
+
conversation.sessionId = event.sessionId;
|
|
1028
|
+
conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1029
|
+
await store.writeChatState(name, state);
|
|
1030
|
+
}
|
|
1031
|
+
await stream.writeSSE({
|
|
1032
|
+
event: event.type,
|
|
1033
|
+
data: JSON.stringify(event)
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
await stream.writeSSE({
|
|
1038
|
+
event: "result",
|
|
1039
|
+
data: JSON.stringify({
|
|
1040
|
+
type: "result",
|
|
1041
|
+
ok: false,
|
|
1042
|
+
error: String(err)
|
|
1043
|
+
})
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
app.all("/app/:name/api/*", async (c) => {
|
|
1049
|
+
const name = c.req.param("name");
|
|
1050
|
+
const running = await getApp(name);
|
|
1051
|
+
if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
1052
|
+
if (!running.handler) return c.json({ error: `App "${name}" has no routes` }, 404);
|
|
1053
|
+
return dispatch(running.handler, c.req.raw, `/app/${name}/api`);
|
|
1054
|
+
});
|
|
1055
|
+
app.get("/app/:name", (c) => c.redirect(`/app/${c.req.param("name")}/`));
|
|
1056
|
+
app.all("/app/:name/*", async (c) => {
|
|
1057
|
+
const name = c.req.param("name");
|
|
1058
|
+
const running = await getApp(name);
|
|
1059
|
+
if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
|
|
1060
|
+
if (c.req.method === "GET" || c.req.method === "HEAD") {
|
|
1061
|
+
const file = await serveStatic(name, running.manifest, c.req.path);
|
|
1062
|
+
if (file) return file;
|
|
1063
|
+
}
|
|
1064
|
+
if (running.handler) return dispatch(running.handler, c.req.raw, `/app/${name}`);
|
|
1065
|
+
return c.json({ error: "Not found" }, 404);
|
|
1066
|
+
});
|
|
1067
|
+
return {
|
|
1068
|
+
app,
|
|
1069
|
+
scheduler,
|
|
1070
|
+
async close() {
|
|
1071
|
+
scheduler.stop();
|
|
1072
|
+
const open = [...sessions.values()];
|
|
1073
|
+
sessions.clear();
|
|
1074
|
+
await Promise.all(open.map(async (p) => {
|
|
1075
|
+
try {
|
|
1076
|
+
await (await p).close();
|
|
1077
|
+
} catch {}
|
|
1078
|
+
}));
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
//#endregion
|
|
1083
|
+
//#region src/store.ts
|
|
1084
|
+
async function readJson(path) {
|
|
1085
|
+
try {
|
|
1086
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
if (err.code === "ENOENT") return void 0;
|
|
1089
|
+
throw err;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
async function writeJson(path, value) {
|
|
1093
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
1094
|
+
const tmp = `${path}.tmp`;
|
|
1095
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
1096
|
+
await rename(tmp, path);
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* The portable state directory ($ALASHI_HOME, default ~/.alashi).
|
|
1100
|
+
* Everything the host knows lives under this root so it can be moved
|
|
1101
|
+
* between machines as a unit.
|
|
1102
|
+
*/
|
|
1103
|
+
var Store = class {
|
|
1104
|
+
root;
|
|
1105
|
+
constructor(root) {
|
|
1106
|
+
this.root = root ?? process.env.ALASHI_HOME ?? join(homedir(), ".alashi");
|
|
1107
|
+
}
|
|
1108
|
+
get configPath() {
|
|
1109
|
+
return join(this.root, "config.json");
|
|
1110
|
+
}
|
|
1111
|
+
get appsDir() {
|
|
1112
|
+
return join(this.root, "apps");
|
|
1113
|
+
}
|
|
1114
|
+
get registriesDir() {
|
|
1115
|
+
return join(this.root, "registries");
|
|
1116
|
+
}
|
|
1117
|
+
get stagingDir() {
|
|
1118
|
+
return join(this.root, ".staging");
|
|
1119
|
+
}
|
|
1120
|
+
/** Validates the name before using it in a path (traversal guard). */
|
|
1121
|
+
appDir(name) {
|
|
1122
|
+
if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid app name: ${JSON.stringify(name)}`);
|
|
1123
|
+
return join(this.appsDir, name);
|
|
1124
|
+
}
|
|
1125
|
+
appRepoDir(name) {
|
|
1126
|
+
return join(this.appDir(name), "repo");
|
|
1127
|
+
}
|
|
1128
|
+
appDataDir(name) {
|
|
1129
|
+
return join(this.appDir(name), "data");
|
|
1130
|
+
}
|
|
1131
|
+
appConfigPath(name) {
|
|
1132
|
+
return join(this.appDir(name), "config.json");
|
|
1133
|
+
}
|
|
1134
|
+
appRecordPath(name) {
|
|
1135
|
+
return join(this.appDir(name), "app.json");
|
|
1136
|
+
}
|
|
1137
|
+
appChatStatePath(name) {
|
|
1138
|
+
return join(this.appDir(name), "chat.json");
|
|
1139
|
+
}
|
|
1140
|
+
appJobStatePath(name) {
|
|
1141
|
+
return join(this.appDir(name), "jobs.json");
|
|
1142
|
+
}
|
|
1143
|
+
registryCachePath(name) {
|
|
1144
|
+
if (!APP_NAME_PATTERN.test(name)) throw new AppSdkError(`Invalid registry name: ${JSON.stringify(name)}`);
|
|
1145
|
+
return join(this.registriesDir, `${name}.json`);
|
|
1146
|
+
}
|
|
1147
|
+
async readGlobalConfig() {
|
|
1148
|
+
return await readJson(this.configPath) ?? { registries: [] };
|
|
1149
|
+
}
|
|
1150
|
+
async writeGlobalConfig(config) {
|
|
1151
|
+
await writeJson(this.configPath, config);
|
|
1152
|
+
}
|
|
1153
|
+
async readAppRecord(name) {
|
|
1154
|
+
return readJson(this.appRecordPath(name));
|
|
1155
|
+
}
|
|
1156
|
+
async writeAppRecord(record) {
|
|
1157
|
+
await writeJson(this.appRecordPath(record.name), record);
|
|
1158
|
+
}
|
|
1159
|
+
async readAppConfig(name) {
|
|
1160
|
+
return await readJson(this.appConfigPath(name)) ?? {};
|
|
1161
|
+
}
|
|
1162
|
+
async writeAppConfig(name, config) {
|
|
1163
|
+
await writeJson(this.appConfigPath(name), config);
|
|
1164
|
+
}
|
|
1165
|
+
async readChatState(name) {
|
|
1166
|
+
return { conversations: (await readJson(this.appChatStatePath(name)))?.conversations ?? [] };
|
|
1167
|
+
}
|
|
1168
|
+
async writeChatState(name, state) {
|
|
1169
|
+
await writeJson(this.appChatStatePath(name), state);
|
|
1170
|
+
}
|
|
1171
|
+
async readJobState(name) {
|
|
1172
|
+
const state = await readJson(this.appJobStatePath(name));
|
|
1173
|
+
return {
|
|
1174
|
+
lastRun: state?.lastRun ?? {},
|
|
1175
|
+
runs: state?.runs ?? []
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
async writeJobState(name, state) {
|
|
1179
|
+
await writeJson(this.appJobStatePath(name), state);
|
|
1180
|
+
}
|
|
1181
|
+
async listInstalled() {
|
|
1182
|
+
let entries;
|
|
1183
|
+
try {
|
|
1184
|
+
entries = await readdir(this.appsDir);
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
if (err.code === "ENOENT") return [];
|
|
1187
|
+
throw err;
|
|
1188
|
+
}
|
|
1189
|
+
const records = [];
|
|
1190
|
+
for (const entry of entries.sort()) {
|
|
1191
|
+
if (!APP_NAME_PATTERN.test(entry)) continue;
|
|
1192
|
+
const record = await this.readAppRecord(entry);
|
|
1193
|
+
if (record) records.push(record);
|
|
1194
|
+
}
|
|
1195
|
+
return records;
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
//#endregion
|
|
1199
|
+
export { checkSdkCompatibility as _, installApp as a, addRegistry as c, removeRegistry as d, resolveApp as f, loadApp as g, updateSource as h, resolveAppConfig as i, readCachedIndex as l, parseSourceArg as m, createServer as n, removeApp as o, fetchSource as p, buildAppContext as r, updateApp as s, Store as t, refreshRegistry as u };
|
|
1200
|
+
|
|
1201
|
+
//# sourceMappingURL=store-CwgDCAAx.mjs.map
|