@k2b/cloud 0.15.0 → 0.17.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/package.json +2 -2
- package/scripts/build.ts +11 -0
- package/src/_internal/cli-plugins.ts +248 -0
- package/src/_internal/define-app.ts +22 -1
- package/src/_internal/registry-validation.ts +7 -0
- package/src/access/PrincipalPicker.tsx +6 -2
- package/src/access/messages.ts +4 -0
- package/src/account/EntitySearch.tsx +8 -4
- package/src/account/avatar-upload-dialog.tsx +3 -2
- package/src/ai/chat/blocks.tsx +5 -2
- package/src/ai/chat/messages.ts +4 -0
- package/src/ai/chat/presentation.tsx +7 -2
- package/src/ai/pdf-render.ts +11 -2
- package/src/ai/routes.ts +5 -4
- package/src/api/admin-identity.ts +131 -3
- package/src/api/cli-plugins.ts +17 -0
- package/src/api/index.ts +1 -0
- package/src/cli/access.ts +11 -3
- package/src/cli/account.ts +6 -0
- package/src/cli/admin/agents.ts +219 -0
- package/src/cli/admin/index.ts +34 -0
- package/src/cli/index.ts +23 -0
- package/src/cli/plugin.ts +127 -0
- package/src/contracts/app.ts +13 -0
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +6 -4
- package/src/server/services/access.ts +16 -9
- package/src/services/accounts/entities.ts +7 -1
- package/src/services/index.ts +2 -1
- package/src/services/service-account-credentials.ts +73 -21
- package/src/services/service-accounts.ts +70 -1
- package/src/services/settings/core-settings.ts +13 -0
- package/src/ssr/Layout.tsx +3 -1
- package/src/styles/global.css +4 -0
- package/src/types/ambient.d.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@tailwindcss/typography": "0.5.20",
|
|
100
100
|
"@k2b/nessi": "0.12.1",
|
|
101
101
|
"@k2b/ssr": "0.14.0",
|
|
102
|
-
"@k2b/ui": "0.
|
|
102
|
+
"@k2b/ui": "0.7.0",
|
|
103
103
|
"@k2b/stdlib": "0.26.0",
|
|
104
104
|
"@k2b/sync": "6.5.0",
|
|
105
105
|
"@nats-io/transport-node": "3.4.0",
|
package/scripts/build.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* public/<id>/app.css Tailwind, if the app has src/styles/app.css
|
|
19
19
|
* public/<id>/... anything from <appDir>/public/
|
|
20
20
|
* assets/... anything from <appDir>/src/assets/ (appAssetPath)
|
|
21
|
+
* cli/<name>/... `cld` plugin per `defineApp({ cli })` module: cli.js,
|
|
22
|
+
* references/*.md, manifest.json
|
|
21
23
|
*
|
|
22
24
|
* If the app needs additional artefacts (core's global.css, logo, katex),
|
|
23
25
|
* it ships a `scripts/build-extras.ts` that this script runs at the end.
|
|
@@ -131,6 +133,7 @@ try {
|
|
|
131
133
|
__CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
|
|
132
134
|
__CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./pdf-render/worker.js"),
|
|
133
135
|
__CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
|
|
136
|
+
__CLOUD_CLI_PLUGINS__: JSON.stringify("./cli/"),
|
|
134
137
|
},
|
|
135
138
|
plugins: [(app?.plugin ?? plugin)()],
|
|
136
139
|
});
|
|
@@ -175,6 +178,14 @@ if (existsSync(appAssets)) {
|
|
|
175
178
|
await cp(appAssets, resolve(dist, "assets"), { recursive: true });
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
// `cld` plugins: one self-contained module bundle plus references per module.
|
|
182
|
+
// Imported after the app config so the framework sees the APP_DIR set above.
|
|
183
|
+
const { buildCliPlugin, writeCliPlugin } = await import("../src/_internal/cli-plugins");
|
|
184
|
+
for (const [name, declaration] of Object.entries(app?.meta.cli ?? {})) {
|
|
185
|
+
const plugin = await buildCliPlugin({ appDir, appId, name, declaration, version });
|
|
186
|
+
await writeCliPlugin(resolve(dist, "cli", name), plugin);
|
|
187
|
+
}
|
|
188
|
+
|
|
178
189
|
if (appId !== "core" && app) {
|
|
179
190
|
await writeAppFavicon({
|
|
180
191
|
publicDir: distPublic,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cld` plugins served by the application that owns them.
|
|
3
|
+
*
|
|
4
|
+
* `defineApp({ cli })` names each CLI module and the paths of its source and
|
|
5
|
+
* skill references. The production build bundles every module into one
|
|
6
|
+
* self-contained ESM file and writes it with its references and manifest to
|
|
7
|
+
* `dist/cli/<name>/`. At run time the application serves those files under
|
|
8
|
+
* `/cli/plugins/<name>/`; in development it builds them from source on first
|
|
9
|
+
* request. Only authenticated callers allowed by `cli.plugins.access` get any
|
|
10
|
+
* of them.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { Glob } from "bun";
|
|
16
|
+
import { type Context, Hono } from "hono";
|
|
17
|
+
import { every } from "hono/combine";
|
|
18
|
+
import { createMiddleware } from "hono/factory";
|
|
19
|
+
import {
|
|
20
|
+
CLOUD_CLI_API_VERSION,
|
|
21
|
+
CLOUD_CLI_MODULE_NAME,
|
|
22
|
+
CLOUD_CLI_PLUGIN_ENTRY,
|
|
23
|
+
CLOUD_CLI_PLUGIN_ROUTE,
|
|
24
|
+
CLOUD_CLI_REFERENCE_INDEX,
|
|
25
|
+
type CloudCliPluginFile,
|
|
26
|
+
type CloudCliPluginManifest,
|
|
27
|
+
cloudCliPluginDigest,
|
|
28
|
+
isCloudCliPluginFilePath,
|
|
29
|
+
parseCloudCliPluginManifest,
|
|
30
|
+
sha512Hex,
|
|
31
|
+
validateCloudCliModule,
|
|
32
|
+
} from "../cli/plugin";
|
|
33
|
+
import { env } from "../config/env";
|
|
34
|
+
import type { AppCliModules } from "../contracts/app";
|
|
35
|
+
import { type AuthContext, auth } from "../server/middleware/auth";
|
|
36
|
+
import { logger } from "../services/logging";
|
|
37
|
+
import { get } from "../services/settings";
|
|
38
|
+
import { buildMetadata } from "./build-metadata";
|
|
39
|
+
|
|
40
|
+
/** Set by `scripts/build.ts`; the bundle lives next to `dist/cli/`. */
|
|
41
|
+
declare const __CLOUD_CLI_PLUGINS__: string | undefined;
|
|
42
|
+
|
|
43
|
+
export const CLI_PLUGIN_ACCESS_SETTING = "cli.plugins.access";
|
|
44
|
+
|
|
45
|
+
/** Reject names and paths that cannot become a served plugin. Returns the module names. */
|
|
46
|
+
export const validateAppCliModules = (appId: string, cli: AppCliModules | undefined): string[] => {
|
|
47
|
+
const names = Object.keys(cli ?? {});
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (!CLOUD_CLI_MODULE_NAME.test(name)) throw new Error(`App "${appId}" declares CLI module "${name}"; use a kebab-case name`);
|
|
50
|
+
const declaration = cli?.[name];
|
|
51
|
+
for (const key of ["module", "references"] as const) {
|
|
52
|
+
const path = declaration?.[key];
|
|
53
|
+
if (typeof path !== "string" || path === "" || isAbsolute(path)) {
|
|
54
|
+
throw new Error(`App "${appId}" CLI module "${name}" needs a relative ${key} path`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return names;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type BuiltCliPlugin = {
|
|
62
|
+
manifest: CloudCliPluginManifest;
|
|
63
|
+
files: ReadonlyMap<string, Uint8Array<ArrayBuffer>>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const referenceFiles = async (directory: string): Promise<Map<string, Uint8Array<ArrayBuffer>>> => {
|
|
67
|
+
const files = new Map<string, Uint8Array<ArrayBuffer>>();
|
|
68
|
+
for await (const path of new Glob("**/*").scan({ cwd: directory, onlyFiles: true, dot: true })) {
|
|
69
|
+
const name = `references/${path.split(sep).join("/")}`;
|
|
70
|
+
if (!isCloudCliPluginFilePath(name)) throw new Error(`CLI reference ${path} must be a Markdown file with a plain name`);
|
|
71
|
+
files.set(name, new Uint8Array(await readFile(join(directory, path))));
|
|
72
|
+
}
|
|
73
|
+
if (!files.has(CLOUD_CLI_REFERENCE_INDEX)) throw new Error(`CLI references in ${directory} need an index.md`);
|
|
74
|
+
return files;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bundle one CLI module and collect its references. The bundle carries every
|
|
79
|
+
* dependency, including its own copy of `@k2b/cloud/cli`, because `cld`
|
|
80
|
+
* installs no plugin dependencies.
|
|
81
|
+
*/
|
|
82
|
+
export const buildCliPlugin = async (params: {
|
|
83
|
+
appDir: string;
|
|
84
|
+
appId: string;
|
|
85
|
+
name: string;
|
|
86
|
+
declaration: { module: string; references: string };
|
|
87
|
+
version: string;
|
|
88
|
+
}): Promise<BuiltCliPlugin> => {
|
|
89
|
+
const { appDir, appId, name, declaration, version } = params;
|
|
90
|
+
const result = await Bun.build({
|
|
91
|
+
entrypoints: [resolve(appDir, declaration.module)],
|
|
92
|
+
target: "bun",
|
|
93
|
+
format: "esm",
|
|
94
|
+
minify: true,
|
|
95
|
+
// Playwright references this optional BiDi adapter lazily; Cloud uses CDP.
|
|
96
|
+
external: ["chromium-bidi/*"],
|
|
97
|
+
});
|
|
98
|
+
if (!result.success) {
|
|
99
|
+
throw new Error(`CLI module "${name}" failed to bundle:\n${result.logs.map((log) => String(log)).join("\n")}`);
|
|
100
|
+
}
|
|
101
|
+
if (result.outputs.length !== 1 || result.outputs[0]!.kind !== "entry-point") {
|
|
102
|
+
throw new Error(`CLI module "${name}" must bundle into one file; it produced ${result.outputs.length}`);
|
|
103
|
+
}
|
|
104
|
+
const files = new Map<string, Uint8Array<ArrayBuffer>>([
|
|
105
|
+
[CLOUD_CLI_PLUGIN_ENTRY, new Uint8Array(await result.outputs[0]!.arrayBuffer())],
|
|
106
|
+
...(await referenceFiles(resolve(appDir, declaration.references))),
|
|
107
|
+
]);
|
|
108
|
+
const list: CloudCliPluginFile[] = [...files]
|
|
109
|
+
.map(([path, bytes]) => ({ path, size: bytes.byteLength, sha512: sha512Hex(bytes) }))
|
|
110
|
+
.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
111
|
+
return {
|
|
112
|
+
manifest: {
|
|
113
|
+
apiVersion: CLOUD_CLI_API_VERSION,
|
|
114
|
+
name,
|
|
115
|
+
app: appId,
|
|
116
|
+
version,
|
|
117
|
+
entry: CLOUD_CLI_PLUGIN_ENTRY,
|
|
118
|
+
digest: cloudCliPluginDigest(list),
|
|
119
|
+
files: list,
|
|
120
|
+
},
|
|
121
|
+
files,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Write a built plugin to `<directory>/`, then import it once to check its default export. */
|
|
126
|
+
export const writeCliPlugin = async (directory: string, plugin: BuiltCliPlugin): Promise<void> => {
|
|
127
|
+
for (const [path, bytes] of plugin.files) {
|
|
128
|
+
await mkdir(dirname(join(directory, path)), { recursive: true });
|
|
129
|
+
await writeFile(join(directory, path), bytes);
|
|
130
|
+
}
|
|
131
|
+
await writeFile(join(directory, "manifest.json"), `${JSON.stringify(plugin.manifest, null, 2)}\n`);
|
|
132
|
+
const loaded: unknown = await import(pathToFileURL(join(directory, CLOUD_CLI_PLUGIN_ENTRY)).href);
|
|
133
|
+
const module = validateCloudCliModule(typeof loaded === "object" && loaded !== null ? (loaded as { default?: unknown }).default : null);
|
|
134
|
+
if (module.name !== plugin.manifest.name) {
|
|
135
|
+
throw new Error(`CLI module declared as "${plugin.manifest.name}" exports the name "${module.name}"`);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
type ServedCliPlugin = {
|
|
140
|
+
manifest: CloudCliPluginManifest;
|
|
141
|
+
read: (path: string) => Promise<Uint8Array<ArrayBuffer>>;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const builtPluginsDirectory = (): string | null =>
|
|
145
|
+
typeof __CLOUD_CLI_PLUGINS__ === "string" ? fileURLToPath(new URL(__CLOUD_CLI_PLUGINS__, import.meta.url)) : null;
|
|
146
|
+
|
|
147
|
+
const loadBuiltPlugin = async (root: string, name: string): Promise<ServedCliPlugin> => {
|
|
148
|
+
const directory = join(root, name);
|
|
149
|
+
const manifest = parseCloudCliPluginManifest(JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")));
|
|
150
|
+
return { manifest, read: async (path) => new Uint8Array(await readFile(join(directory, path))) };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const buildSourcePlugin = async (appId: string, name: string, declaration: { module: string; references: string }) => {
|
|
154
|
+
const plugin = await buildCliPlugin({
|
|
155
|
+
appDir: env.APP_DIR ?? process.cwd(),
|
|
156
|
+
appId,
|
|
157
|
+
name,
|
|
158
|
+
declaration,
|
|
159
|
+
version: buildMetadata.version,
|
|
160
|
+
});
|
|
161
|
+
return { manifest: plugin.manifest, read: async (path: string) => plugin.files.get(path)! };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Resolves served plugins: prebuilt files in production, a cached source build in development. */
|
|
165
|
+
const createPluginSource = (appId: string, cli: AppCliModules, root: string | null) => {
|
|
166
|
+
const cache = new Map<string, Promise<ServedCliPlugin>>();
|
|
167
|
+
return (name: string): Promise<ServedCliPlugin> | null => {
|
|
168
|
+
const declaration = Object.hasOwn(cli, name) ? cli[name] : undefined;
|
|
169
|
+
if (!declaration) return null;
|
|
170
|
+
let pending = cache.get(name);
|
|
171
|
+
if (!pending) {
|
|
172
|
+
pending = root ? loadBuiltPlugin(root, name) : buildSourcePlugin(appId, name, declaration);
|
|
173
|
+
// A failed source build is retried on the next request instead of being cached.
|
|
174
|
+
pending.catch(() => cache.delete(name));
|
|
175
|
+
cache.set(name, pending);
|
|
176
|
+
}
|
|
177
|
+
return pending;
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Access rule for every plugin route, the list included: an authenticated
|
|
183
|
+
* caller (session, OAuth token, or API key) with `read` scope when OAuth is
|
|
184
|
+
* used, and no guest account unless `cli.plugins.access` is `all_users`. A
|
|
185
|
+
* service account without a delegated user is not a guest.
|
|
186
|
+
*/
|
|
187
|
+
export const requireCliPluginAccess = every(
|
|
188
|
+
auth.requireRole("authenticated"),
|
|
189
|
+
auth.requireOAuthScope("read"),
|
|
190
|
+
createMiddleware<AuthContext>(async (c, next) => {
|
|
191
|
+
const access = await get<string>(CLI_PLUGIN_ACCESS_SETTING);
|
|
192
|
+
if (access !== "all_users" && c.get("user")?.profile === "guest") {
|
|
193
|
+
return c.json({ code: "FORBIDDEN", message: "CLI plugins are available to full accounts only" }, 403);
|
|
194
|
+
}
|
|
195
|
+
return next();
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
200
|
+
".js": "text/javascript; charset=utf-8",
|
|
201
|
+
".md": "text/markdown; charset=utf-8",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const privateHeaders = { "cache-control": "private, no-store", "x-content-type-options": "nosniff" } as const;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Framework-owned plugin routes of one application, mounted at
|
|
208
|
+
* `/cli/plugins`. The gateway reaches them through the `/cli/plugins/<name>`
|
|
209
|
+
* prefixes `defineApp` adds to the application's routes.
|
|
210
|
+
*/
|
|
211
|
+
export const createCliPluginRoutes = (appId: string, cli: AppCliModules, builtRoot = builtPluginsDirectory()) => {
|
|
212
|
+
const source = createPluginSource(appId, cli, builtRoot);
|
|
213
|
+
const log = logger("cli-plugins");
|
|
214
|
+
const served = async (c: Context<AuthContext>): Promise<ServedCliPlugin | Response> => {
|
|
215
|
+
const pending = source(c.req.param("name") ?? "");
|
|
216
|
+
if (!pending) return c.json({ code: "NOT_FOUND", message: "CLI plugin not found" }, 404);
|
|
217
|
+
try {
|
|
218
|
+
return await pending;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
log.error("CLI plugin unavailable", {
|
|
221
|
+
appId,
|
|
222
|
+
name: c.req.param("name"),
|
|
223
|
+
error: error instanceof Error ? error.message : String(error),
|
|
224
|
+
});
|
|
225
|
+
return c.json({ code: "UNAVAILABLE", message: "CLI plugin is unavailable" }, 503);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
return new Hono<AuthContext>()
|
|
229
|
+
.get("/:name/manifest.json", requireCliPluginAccess, async (c) => {
|
|
230
|
+
const plugin = await served(c);
|
|
231
|
+
if (plugin instanceof Response) return plugin;
|
|
232
|
+
return c.json(plugin.manifest, 200, privateHeaders);
|
|
233
|
+
})
|
|
234
|
+
.get("/:name/*", requireCliPluginAccess, async (c) => {
|
|
235
|
+
const plugin = await served(c);
|
|
236
|
+
if (plugin instanceof Response) return plugin;
|
|
237
|
+
const prefix = `${CLOUD_CLI_PLUGIN_ROUTE}/${plugin.manifest.name}/`;
|
|
238
|
+
const path = c.req.path.startsWith(prefix) ? c.req.path.slice(prefix.length) : "";
|
|
239
|
+
if (!plugin.manifest.files.some((file) => file.path === path)) {
|
|
240
|
+
return c.json({ code: "NOT_FOUND", message: "CLI plugin file not found" }, 404);
|
|
241
|
+
}
|
|
242
|
+
const bytes = await plugin.read(path);
|
|
243
|
+
return c.body(bytes, 200, { ...privateHeaders, "content-type": CONTENT_TYPES[path.slice(path.lastIndexOf("."))]! });
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** The gateway prefixes of an application's plugin routes. */
|
|
248
|
+
export const cliPluginRoutePrefixes = (names: readonly string[]): string[] => names.map((name) => `${CLOUD_CLI_PLUGIN_ROUTE}/${name}`);
|
|
@@ -17,10 +17,12 @@ import { routes } from "@k2b/ssr/hono";
|
|
|
17
17
|
import type { Sync } from "@k2b/sync";
|
|
18
18
|
import { type Context, type Handler, Hono } from "hono";
|
|
19
19
|
import { generateSpecs } from "hono-openapi";
|
|
20
|
+
import { CLOUD_CLI_PLUGIN_ROUTE } from "../cli/plugin";
|
|
20
21
|
import { env } from "../config/env";
|
|
21
22
|
import type {
|
|
22
23
|
AppAdminNavigationGroup,
|
|
23
24
|
AppAppearance,
|
|
25
|
+
AppCliModules,
|
|
24
26
|
AppLifecycle,
|
|
25
27
|
AppMeta,
|
|
26
28
|
AppPresentationCatalog,
|
|
@@ -69,6 +71,7 @@ import { compileAppPresentation } from "./app-presentation";
|
|
|
69
71
|
import { readBoundedJson } from "./bounded-json";
|
|
70
72
|
import { appRuntimeMetadata } from "./build-metadata";
|
|
71
73
|
import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
|
|
74
|
+
import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
|
|
72
75
|
import { createHeartbeat } from "./heartbeat";
|
|
73
76
|
import { compileHelp } from "./help";
|
|
74
77
|
import { createPageResponses } from "./page-responses";
|
|
@@ -197,6 +200,18 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
197
200
|
* `/api/<id>` entry in `routes`).
|
|
198
201
|
*/
|
|
199
202
|
openapi?: string;
|
|
203
|
+
/**
|
|
204
|
+
* `cld` modules this app serves as plugins, keyed by module name
|
|
205
|
+
* (`cld <name>`). Paths are relative to the application directory:
|
|
206
|
+
*
|
|
207
|
+
* cli: { inventory: { module: "src/cli.ts", references: "src/cli-references" } }
|
|
208
|
+
*
|
|
209
|
+
* The production build bundles each module with its skill references into
|
|
210
|
+
* the image; the framework serves them under `/cli/plugins/<name>/` to
|
|
211
|
+
* authenticated callers that `cli.plugins.access` allows, and adds that
|
|
212
|
+
* prefix to the app's gateway routes.
|
|
213
|
+
*/
|
|
214
|
+
cli?: AppCliModules;
|
|
200
215
|
/**
|
|
201
216
|
* Project root used for SSR component IDs and development assets.
|
|
202
217
|
* Defaults to `process.cwd()`. Override only if you run the entrypoint
|
|
@@ -305,6 +320,7 @@ export const defineApp = <
|
|
|
305
320
|
configurePostgresApplicationName(opts.id);
|
|
306
321
|
const isDevelopment = env.IS_DEVELOPMENT;
|
|
307
322
|
const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
|
|
323
|
+
const cliModules = validateAppCliModules(opts.id, opts.cli);
|
|
308
324
|
|
|
309
325
|
// ── 0. Register declared settings into the runtime registry ──────────
|
|
310
326
|
// SETTINGS_MAP is the single source of truth for validation in store.ts
|
|
@@ -392,13 +408,14 @@ export const defineApp = <
|
|
|
392
408
|
links: group.links.map((link) => ({ ...link })),
|
|
393
409
|
}),
|
|
394
410
|
),
|
|
395
|
-
routes: [...opts.routes],
|
|
411
|
+
routes: [...opts.routes, ...cliPluginRoutePrefixes(cliModules)],
|
|
396
412
|
nav: opts.nav,
|
|
397
413
|
legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
|
|
398
414
|
searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
|
|
399
415
|
widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
|
|
400
416
|
settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
|
|
401
417
|
openapi: opts.openapi,
|
|
418
|
+
cli: opts.cli,
|
|
402
419
|
};
|
|
403
420
|
const meta: AppMeta = { ...baseMeta, presentation: compileAppPresentation(baseMeta, opts.presentation) };
|
|
404
421
|
|
|
@@ -492,6 +509,7 @@ export const defineApp = <
|
|
|
492
509
|
widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
|
|
493
510
|
settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
|
|
494
511
|
openapi: advertiseOpenapi ? opts.openapi : undefined,
|
|
512
|
+
cliModules: cliModules.length > 0 ? cliModules : undefined,
|
|
495
513
|
};
|
|
496
514
|
|
|
497
515
|
// Heartbeat
|
|
@@ -554,6 +572,7 @@ export const defineApp = <
|
|
|
554
572
|
// /api/_internal/capabilities/v1/* when capabilities are declared
|
|
555
573
|
// /api/_internal/widgets/v1/* when widget handlers are bound
|
|
556
574
|
// /_internal/sync/* process-local sync operations (admin only)
|
|
575
|
+
// /cli/plugins/<name>/* `cld` plugins, when `cli` modules are declared
|
|
557
576
|
// <opts.openapi> OpenAPI JSON spec, when both opts.openapi
|
|
558
577
|
// and startOpts.openapi are set
|
|
559
578
|
const ssrMountPath = config.basePath ? `${config.basePath}/_ssr` : "/_ssr";
|
|
@@ -582,6 +601,8 @@ export const defineApp = <
|
|
|
582
601
|
})
|
|
583
602
|
.route("/_internal/sync", createSyncOpsRoutes(getProcessSync));
|
|
584
603
|
|
|
604
|
+
if (opts.cli && cliModules.length > 0) server.route(CLOUD_CLI_PLUGIN_ROUTE, createCliPluginRoutes(meta.id, opts.cli));
|
|
605
|
+
|
|
585
606
|
if (compiledHelp) {
|
|
586
607
|
const pageBase = compiledHelp.summary.pageBase;
|
|
587
608
|
const centralPageBase = `/help/apps/${encodeURIComponent(meta.id)}`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
|
|
1
2
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
2
3
|
|
|
3
4
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -112,6 +113,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
if (value.settingKeys !== undefined && !isStringArray(value.settingKeys)) return invalid("settingKeys", "an array of strings");
|
|
116
|
+
if (
|
|
117
|
+
value.cliModules !== undefined &&
|
|
118
|
+
(!isStringArray(value.cliModules) || value.cliModules.some((name) => !CLOUD_CLI_MODULE_NAME.test(name)))
|
|
119
|
+
) {
|
|
120
|
+
return invalid("cliModules", "an array of CLI module names");
|
|
121
|
+
}
|
|
115
122
|
if (value.capabilities !== undefined) {
|
|
116
123
|
if (
|
|
117
124
|
!isRecord(value.capabilities) ||
|
|
@@ -78,8 +78,12 @@ export default function PrincipalPicker(props: {
|
|
|
78
78
|
description:
|
|
79
79
|
e.serviceAccount.kind === "user_delegated"
|
|
80
80
|
? t().userBoundServiceAccount
|
|
81
|
-
:
|
|
82
|
-
|
|
81
|
+
: e.serviceAccount.kind === "agent"
|
|
82
|
+
? t().agentServiceAccount
|
|
83
|
+
: e.serviceAccount.kind === "standalone"
|
|
84
|
+
? t().standaloneServiceAccount
|
|
85
|
+
: [e.serviceAccount.appId, e.serviceAccount.resourceType, e.serviceAccount.resourceId].filter(Boolean).join(" · "),
|
|
86
|
+
icon: e.serviceAccount.kind === "agent" ? "ti ti-robot" : "ti ti-key",
|
|
83
87
|
});
|
|
84
88
|
}
|
|
85
89
|
}
|
package/src/access/messages.ts
CHANGED
|
@@ -15,6 +15,8 @@ export const accessMessages = i18n.define({
|
|
|
15
15
|
removeAccessConfirm: ({ name }: { name: string }) => `Remove access for ${name}?`,
|
|
16
16
|
removeAccess: "Remove access",
|
|
17
17
|
userBoundServiceAccount: "User-bound service account",
|
|
18
|
+
standaloneServiceAccount: "Service account",
|
|
19
|
+
agentServiceAccount: "Agent",
|
|
18
20
|
noDirectGrants: "No direct access granted yet.",
|
|
19
21
|
addAll: "Add user, group, service account, or audience…",
|
|
20
22
|
addService: "Add user, group, or service account…",
|
|
@@ -72,6 +74,8 @@ export const accessMessages = i18n.define({
|
|
|
72
74
|
removeAccessConfirm: ({ name }) => `Zugriff für ${name} entfernen?`,
|
|
73
75
|
removeAccess: "Zugriff entfernen",
|
|
74
76
|
userBoundServiceAccount: "Benutzergebundenes Dienstkonto",
|
|
77
|
+
standaloneServiceAccount: "Dienstkonto",
|
|
78
|
+
agentServiceAccount: "Agent",
|
|
75
79
|
noDirectGrants: "Noch kein direkter Zugriff erteilt.",
|
|
76
80
|
addAll: "Benutzer, Gruppe, Dienstkonto oder Zielgruppe hinzufügen…",
|
|
77
81
|
addService: "Benutzer, Gruppe oder Dienstkonto hinzufügen…",
|
|
@@ -33,7 +33,7 @@ export type EntitySearchPrincipal =
|
|
|
33
33
|
type: "service_account";
|
|
34
34
|
serviceAccountId: string;
|
|
35
35
|
name: string;
|
|
36
|
-
kind: "user_delegated" | "resource_bound";
|
|
36
|
+
kind: "user_delegated" | "resource_bound" | "standalone" | "agent";
|
|
37
37
|
appId: string | null;
|
|
38
38
|
resourceType: string | null;
|
|
39
39
|
resourceId: string | null;
|
|
@@ -102,7 +102,7 @@ type ApiGroup = {
|
|
|
102
102
|
type ApiServiceAccount = {
|
|
103
103
|
id: string;
|
|
104
104
|
name: string;
|
|
105
|
-
kind: "user_delegated" | "resource_bound";
|
|
105
|
+
kind: "user_delegated" | "resource_bound" | "standalone" | "agent";
|
|
106
106
|
appId: string | null;
|
|
107
107
|
resourceType: string | null;
|
|
108
108
|
resourceId: string | null;
|
|
@@ -290,12 +290,16 @@ const EntitySearch = (props: EntitySearchProps) => {
|
|
|
290
290
|
<For each={serviceAccounts()}>
|
|
291
291
|
{(serviceAccount) => (
|
|
292
292
|
<ResultRow
|
|
293
|
-
icon="ti-key"
|
|
293
|
+
icon={serviceAccount.kind === "agent" ? "ti-robot" : "ti-key"}
|
|
294
294
|
title={serviceAccount.name}
|
|
295
295
|
subtitle={
|
|
296
296
|
serviceAccount.kind === "user_delegated"
|
|
297
297
|
? "User-bound service account"
|
|
298
|
-
:
|
|
298
|
+
: serviceAccount.kind === "agent"
|
|
299
|
+
? "Agent"
|
|
300
|
+
: serviceAccount.kind === "standalone"
|
|
301
|
+
? "Service account"
|
|
302
|
+
: [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
|
|
299
303
|
}
|
|
300
304
|
disabled={props.disabled}
|
|
301
305
|
onSelect={() =>
|
|
@@ -22,6 +22,7 @@ export type AvatarUploadDialogOptions = {
|
|
|
22
22
|
removeFailed: string;
|
|
23
23
|
replaceDrop: string;
|
|
24
24
|
chooseDrop: string;
|
|
25
|
+
formats: string;
|
|
25
26
|
cropHint: string;
|
|
26
27
|
removing: string;
|
|
27
28
|
remove: string;
|
|
@@ -149,13 +150,13 @@ function AvatarUploadDialog(props: AvatarUploadDialogOptions & { close: (saved?:
|
|
|
149
150
|
disabled={saving() || removing()}
|
|
150
151
|
busy={processing()}
|
|
151
152
|
error={error}
|
|
152
|
-
icon="ti-photo-plus"
|
|
153
|
+
icon="ti ti-photo-plus"
|
|
153
154
|
title={
|
|
154
155
|
sourceFile()
|
|
155
156
|
? (props.messages?.replaceDrop ?? "Drop another image or click to replace")
|
|
156
157
|
: (props.messages?.chooseDrop ?? "Drop image or click to choose")
|
|
157
158
|
}
|
|
158
|
-
subtitle="PNG, JPEG, or WebP"
|
|
159
|
+
subtitle={props.messages?.formats ?? "PNG, JPEG, or WebP"}
|
|
159
160
|
hint={props.messages?.cropHint ?? "Adjust the crop, then save."}
|
|
160
161
|
onDrop={handleFiles}
|
|
161
162
|
/>
|
package/src/ai/chat/blocks.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
jsonPreview,
|
|
25
25
|
memoryToolPresentation,
|
|
26
26
|
} from "./message-utils";
|
|
27
|
+
import { aiChatMessages } from "./messages";
|
|
27
28
|
import { AssistantMarkdownBlock } from "./primitives";
|
|
28
29
|
import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
|
|
29
30
|
import { groupToolBlocks, isFailedTool, summarizeToolGroup } from "./tool-groups";
|
|
@@ -57,16 +58,18 @@ function ReviewDetailValue(props: { detail: ReviewDetail }) {
|
|
|
57
58
|
// turn streams, so every branch must re-evaluate when the store updates.
|
|
58
59
|
|
|
59
60
|
function ThinkingBlockView(props: { text: string; streaming?: boolean }) {
|
|
61
|
+
const locale = useLocale();
|
|
62
|
+
const t = () => aiChatMessages(locale());
|
|
60
63
|
return (
|
|
61
64
|
<Show
|
|
62
65
|
when={props.text.trim()}
|
|
63
66
|
fallback={
|
|
64
67
|
<Show when={props.streaming}>
|
|
65
|
-
<Chat.Activity label=
|
|
68
|
+
<Chat.Activity label={t().thinking} icon="ti ti-sparkles" tone="ai" busy />
|
|
66
69
|
</Show>
|
|
67
70
|
}
|
|
68
71
|
>
|
|
69
|
-
<Chat.Activity label=
|
|
72
|
+
<Chat.Activity label={t().showReasoning} icon="ti ti-sparkles" tone="ai" bodyInset={false}>
|
|
70
73
|
<pre class="max-h-52 w-full min-w-0 overflow-auto whitespace-pre-wrap rounded-md bg-zinc-100/70 p-2 text-[11px] leading-5 text-secondary [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
|
|
71
74
|
{props.text}
|
|
72
75
|
</pre>
|
package/src/ai/chat/messages.ts
CHANGED
|
@@ -18,6 +18,8 @@ const messages = i18n.define({
|
|
|
18
18
|
toolIssues: "Tool issues",
|
|
19
19
|
none: "None",
|
|
20
20
|
read: "Read",
|
|
21
|
+
thinking: "Thinking",
|
|
22
|
+
showReasoning: "Show reasoning",
|
|
21
23
|
byteRange: ({ start, end }: { start: string; end: string }) => `Bytes ${start}–${end}`,
|
|
22
24
|
},
|
|
23
25
|
de: {
|
|
@@ -36,6 +38,8 @@ const messages = i18n.define({
|
|
|
36
38
|
toolIssues: "Werkzeugprobleme",
|
|
37
39
|
none: "Keine",
|
|
38
40
|
read: "Gelesen",
|
|
41
|
+
thinking: "Denkt nach",
|
|
42
|
+
showReasoning: "Denkprozess anzeigen",
|
|
39
43
|
byteRange: ({ start, end }) => `Bytes ${start}–${end}`,
|
|
40
44
|
},
|
|
41
45
|
},
|
|
@@ -27,10 +27,15 @@ export type AiChatTimelineSession = {
|
|
|
27
27
|
|
|
28
28
|
export { type AiChatActions, AiChatActionsProvider };
|
|
29
29
|
|
|
30
|
-
const isWideBlock = (block: AiAssistantTimelineItem["blocks"][number]) => block.kind === "tool";
|
|
31
|
-
|
|
32
30
|
type AssistantBlock = AiAssistantTimelineItem["blocks"][number];
|
|
33
31
|
|
|
32
|
+
// Activity blocks span the full message column so disclosure chevrons share one
|
|
33
|
+
// right edge. Tool and compaction blocks count even while busy; reasoning counts
|
|
34
|
+
// only once it has text, because empty reasoning renders at most a busy row and
|
|
35
|
+
// must not widen a prose-only reply.
|
|
36
|
+
const isWideBlock = (block: AssistantBlock) =>
|
|
37
|
+
block.kind === "tool" || block.kind === "compaction" || (block.kind === "thinking" && block.text.trim().length > 0);
|
|
38
|
+
|
|
34
39
|
type SurveyResultBlock = Extract<AssistantBlock, { kind: "tool" }>;
|
|
35
40
|
type SurveySegment = { type: "assistant"; blocks: AssistantBlock[] } | { type: "survey"; block: SurveyResultBlock };
|
|
36
41
|
|
package/src/ai/pdf-render.ts
CHANGED
|
@@ -39,8 +39,17 @@ export async function renderPdfPages(bytes: Uint8Array, pages: number[] = [1], s
|
|
|
39
39
|
stdin: new Blob([JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages })]),
|
|
40
40
|
stdout: "pipe",
|
|
41
41
|
stderr: "ignore",
|
|
42
|
-
//
|
|
43
|
-
|
|
42
|
+
// No application credentials or configuration reach the decoder, only fixed
|
|
43
|
+
// thread pool sizes: the Bun work pool, canvas tokio runtime, and JSC GC
|
|
44
|
+
// markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
|
|
45
|
+
// each thread's stack and allocator memory.
|
|
46
|
+
env: {
|
|
47
|
+
PATH: process.env.PATH,
|
|
48
|
+
LANG: "C.UTF-8",
|
|
49
|
+
UV_THREADPOOL_SIZE: "2",
|
|
50
|
+
TOKIO_WORKER_THREADS: "2",
|
|
51
|
+
BUN_JSC_numberOfGCMarkers: "2",
|
|
52
|
+
},
|
|
44
53
|
},
|
|
45
54
|
);
|
|
46
55
|
const stop = () => child.kill("SIGKILL");
|
package/src/ai/routes.ts
CHANGED
|
@@ -200,6 +200,7 @@ const MemoryUpdateSchema = MemoryCreateSchema.partial().refine((value) => Object
|
|
|
200
200
|
const MemoryIdSchema = z.string().regex(AI_SHORT_ID_PATTERN);
|
|
201
201
|
|
|
202
202
|
const notFound = (c: Context<AuthContext>) => respond(c, fail(err.notFound("Conversation")));
|
|
203
|
+
const fileNotFound = (c: Context<AuthContext>) => respond(c, fail(err.notFound("File")));
|
|
203
204
|
|
|
204
205
|
const publicConversation = (conversation: AiConversation, projectId: string | null = null) => ({
|
|
205
206
|
...conversation,
|
|
@@ -1294,9 +1295,9 @@ export const aiRoutes = (() => {
|
|
|
1294
1295
|
const conversation = await loadConversation(c, ctx);
|
|
1295
1296
|
if (!conversation) return notFound(c);
|
|
1296
1297
|
const path = normalizeAiFilePath(c.req.valid("query").path);
|
|
1297
|
-
if (!path) return
|
|
1298
|
+
if (!path) return fileNotFound(c);
|
|
1298
1299
|
const stored = await aiFileStore.read({ conversationId: conversation.id, path });
|
|
1299
|
-
if (!stored) return
|
|
1300
|
+
if (!stored) return fileNotFound(c);
|
|
1300
1301
|
const filename = path.slice(path.lastIndexOf("/") + 1).replaceAll('"', "");
|
|
1301
1302
|
return c.body(stored.bytes as unknown as ArrayBuffer, 200, {
|
|
1302
1303
|
"Content-Type": stored.mediaType || "application/octet-stream",
|
|
@@ -1311,9 +1312,9 @@ export const aiRoutes = (() => {
|
|
|
1311
1312
|
const conversation = await loadConversation(c, ctx);
|
|
1312
1313
|
if (!conversation) return notFound(c);
|
|
1313
1314
|
const path = normalizeAiFilePath(c.req.valid("query").path);
|
|
1314
|
-
if (!path) return
|
|
1315
|
+
if (!path) return fileNotFound(c);
|
|
1315
1316
|
const removed = await aiFileStore.remove({ conversationId: conversation.id, path, recursive: false });
|
|
1316
|
-
if (removed === 0) return
|
|
1317
|
+
if (removed === 0) return fileNotFound(c);
|
|
1317
1318
|
return respond(c, ok({ deleted: true }));
|
|
1318
1319
|
})
|
|
1319
1320
|
.put("/conversations/:conversationId/files/content", v("json", FileWriteSchema), async (c) => {
|