@k2b/cloud 0.36.0 → 0.38.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 +5 -3
- package/scripts/README.md +1 -0
- package/scripts/cli-pack.ts +123 -0
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/shell-focus.ts +11 -0
- package/src/access/PermissionEditor.browser-harness.tsx +46 -0
- package/src/access/PermissionEditor.tsx +38 -9
- package/src/access/ui.ts +1 -0
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/browser.ts +1 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/answer-links.ts +8 -0
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +12 -0
- package/src/ai/chat/primitives.tsx +2 -1
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-tasks.ts +65 -1
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/executor.ts +46 -3
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/types.ts +1 -0
- package/src/api/help.ts +23 -4
- package/src/api/mcp.ts +8 -2
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +5 -1
- package/src/browser/shell-focus.ts +67 -0
- package/src/cli/admin/workflows.ts +1 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/plugin.ts +5 -5
- package/src/config/env.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/server/help.ts +136 -10
- package/src/server/services/geo.ts +21 -4
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +2 -0
- package/src/services/network-security.ts +28 -0
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/settings/index.ts +48 -26
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/network-address.ts +9 -0
- package/src/ssr/CloudShell.island.tsx +2 -0
- package/src/ssr/GlobalSearchTrigger.tsx +1 -1
- package/src/ssr/Layout.tsx +28 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutHelpTrigger.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -1
- package/src/ssr/ProfilePreferences.tsx +1 -1
- package/src/ssr/RailApps.tsx +1 -1
- package/src/ssr/ShellFocusToggle.tsx +43 -0
- package/src/ssr/ShellRuntime.island.tsx +53 -26
- package/src/ssr/app-launchpad.tsx +1 -1
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/platform-messages.ts +14 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-focus.browser-harness.tsx +83 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/global.css +155 -26
- package/src/styles/resource-search.css +25 -0
- package/src/styles/utilities-navigation.css +11 -3
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"!src/**/*.test.tsx",
|
|
20
20
|
"!src/**/*.integration.test.ts",
|
|
21
21
|
"!src/**/*.test-fixture.ts",
|
|
22
|
+
"!src/**/*.test-fixture.tsx",
|
|
22
23
|
"!scripts/**/*.test.ts",
|
|
23
24
|
"!scripts/runtime-recovery-*.ts"
|
|
24
25
|
],
|
|
@@ -106,8 +107,8 @@
|
|
|
106
107
|
"@tabler/icons": "3.48.0",
|
|
107
108
|
"@tailwindcss/typography": "0.5.20",
|
|
108
109
|
"@k2b/nessi": "0.18.0",
|
|
109
|
-
"@k2b/ssr": "0.
|
|
110
|
-
"@k2b/ui": "0.
|
|
110
|
+
"@k2b/ssr": "0.17.0",
|
|
111
|
+
"@k2b/ui": "0.28.0",
|
|
111
112
|
"@k2b/stdlib": "0.29.0",
|
|
112
113
|
"@k2b/sync": "7.0.0",
|
|
113
114
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -115,6 +116,7 @@
|
|
|
115
116
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
116
117
|
"bun-plugin-tailwind": "0.1.2",
|
|
117
118
|
"hono-openapi": "1.3.3",
|
|
119
|
+
"htmlparser2": "12.0.0",
|
|
118
120
|
"jose": "6.2.12",
|
|
119
121
|
"katex": "0.18.9",
|
|
120
122
|
"liquidjs": "10.29.0",
|
package/scripts/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
- `build.ts` — production build for one Cloud app (`APP_ID`); used by the Dockerfiles and `bun run build` targets.
|
|
4
4
|
- `preload.ts` — dev-mode preload that registers the SSR plugin and builds CSS before app code loads.
|
|
5
5
|
- `build-canvas-workers.ts` — packages the isolated canvas workers (PDF pages, mobile app icons) and the native canvas for bundles that start them.
|
|
6
|
+
- `cli-pack.ts` — packs a standalone `cld` module with its verified manifest and skill references: `bun run packages/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>`. Reads the package name and version from the current directory; publish the output with `npm publish <dir>` or archive it with `npm pack <dir>`.
|
|
6
7
|
- `app-favicon.ts` — generates app favicons from Tabler icons; imported by `build.ts` and `preload.ts`.
|
|
7
8
|
- `browser-performance.ts` — bundles the web-vitals browser asset for core; imported by `build.ts` and `preload.ts`.
|
|
8
9
|
- `runtime-recovery-acceptance.ts` — shared-runtime recovery acceptance on disposable Docker containers: `bun packages/cloud/scripts/runtime-recovery-acceptance.ts` (also `bun run test:runtime-recovery`).
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Pack a `cld` module as a package plugin, for authors without a Cloud
|
|
4
|
+
* application:
|
|
5
|
+
*
|
|
6
|
+
* bun run node_modules/@k2b/cloud/scripts/cli-pack.ts src/cli.ts --references src/cli-references --out dist/cld-plugin
|
|
7
|
+
*
|
|
8
|
+
* Writes the same verified bundle the application build writes to
|
|
9
|
+
* `dist/cli/<name>/` (`cli.js`, `references/`, `manifest.json`), plus a
|
|
10
|
+
* `package.json` whose `cld.manifest` points at the manifest. Name and version
|
|
11
|
+
* come from `package.json` in the current directory.
|
|
12
|
+
*/
|
|
13
|
+
import { lstat, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { buildCliPlugin, writeCliPlugin } from "../src/_internal/cli-plugins";
|
|
17
|
+
import { validateCloudCliModule } from "../src/cli/plugin";
|
|
18
|
+
|
|
19
|
+
const USAGE = "Usage: bun run node_modules/@k2b/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>";
|
|
20
|
+
|
|
21
|
+
const HELP = `${USAGE}
|
|
22
|
+
|
|
23
|
+
Packs a cld module as a package plugin: the bundled module cli.js, the skill
|
|
24
|
+
references (index.md plus further Markdown files), manifest.json with the
|
|
25
|
+
SHA-512 of every file, and a package.json with "cld": { "manifest": "manifest.json" }.
|
|
26
|
+
Name and version come from package.json in the current directory.
|
|
27
|
+
|
|
28
|
+
--out must be missing, empty, or an earlier pack output, which is replaced.
|
|
29
|
+
Ship the result with \`npm publish <out>\` or \`npm pack <out>\`.`;
|
|
30
|
+
|
|
31
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
|
|
33
|
+
const readJson = (path: string): Promise<unknown> =>
|
|
34
|
+
readFile(path, "utf8")
|
|
35
|
+
.then(JSON.parse)
|
|
36
|
+
.catch(() => null);
|
|
37
|
+
|
|
38
|
+
const parseArgs = (args: string[]) => {
|
|
39
|
+
const [module, ...rest] = args;
|
|
40
|
+
const flags = new Map<string, string>();
|
|
41
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
42
|
+
const [flag, value] = [rest[index]!, rest[index + 1]];
|
|
43
|
+
if (!["--references", "--out"].includes(flag) || !value || value.startsWith("-") || flags.has(flag)) throw new Error(USAGE);
|
|
44
|
+
flags.set(flag, value);
|
|
45
|
+
}
|
|
46
|
+
const references = flags.get("--references");
|
|
47
|
+
const out = flags.get("--out");
|
|
48
|
+
if (!module || module.startsWith("-") || !references || !out) throw new Error(USAGE);
|
|
49
|
+
return { module: resolve(module), references: resolve(references), out: resolve(out) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const containsPath = (directory: string, path: string): boolean => {
|
|
53
|
+
const inside = relative(directory, path);
|
|
54
|
+
return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Empty `out` for a fresh pack; anything but an earlier pack output stays untouched. */
|
|
58
|
+
const prepareOut = async (out: string, inputs: string[]): Promise<void> => {
|
|
59
|
+
const refusal = `Refusing --out ${out}: it must be missing, empty, or an earlier pack output.`;
|
|
60
|
+
const output = await realpath(out).catch(() => out);
|
|
61
|
+
for (const input of inputs) {
|
|
62
|
+
const path = await realpath(input).catch(() => input);
|
|
63
|
+
if (containsPath(out, input) || containsPath(output, path)) throw new Error(refusal);
|
|
64
|
+
}
|
|
65
|
+
const info = await lstat(out).catch(() => null);
|
|
66
|
+
if (!info) return;
|
|
67
|
+
if (!info.isDirectory()) throw new Error(refusal);
|
|
68
|
+
const entries = await readdir(out);
|
|
69
|
+
if (entries.length === 0) return;
|
|
70
|
+
const previous = await readJson(join(out, "package.json"));
|
|
71
|
+
if (
|
|
72
|
+
entries.some((entry) => !["package.json", "manifest.json", "cli.js", "references"].includes(entry)) ||
|
|
73
|
+
!isRecord(previous) ||
|
|
74
|
+
!isRecord(previous.cld) ||
|
|
75
|
+
previous.cld.manifest !== "manifest.json"
|
|
76
|
+
)
|
|
77
|
+
throw new Error(refusal);
|
|
78
|
+
await rm(out, { recursive: true });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const pack = async (args: string[]): Promise<void> => {
|
|
82
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
83
|
+
console.log(HELP);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const options = parseArgs(args);
|
|
87
|
+
const cwd = process.cwd();
|
|
88
|
+
const manifest = await readJson(join(cwd, "package.json"));
|
|
89
|
+
if (
|
|
90
|
+
!isRecord(manifest) ||
|
|
91
|
+
typeof manifest.name !== "string" ||
|
|
92
|
+
!manifest.name ||
|
|
93
|
+
typeof manifest.version !== "string" ||
|
|
94
|
+
!manifest.version
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(`${join(cwd, "package.json")} needs a string name and version.`);
|
|
97
|
+
}
|
|
98
|
+
const { name: packageName, version } = manifest;
|
|
99
|
+
await prepareOut(options.out, [cwd, options.module, options.references]);
|
|
100
|
+
const loaded: unknown = await import(pathToFileURL(options.module).href);
|
|
101
|
+
const module = validateCloudCliModule(isRecord(loaded) ? loaded.default : undefined);
|
|
102
|
+
const plugin = await buildCliPlugin({
|
|
103
|
+
appDir: cwd,
|
|
104
|
+
appId: packageName,
|
|
105
|
+
name: module.name,
|
|
106
|
+
declaration: { module: options.module, references: options.references },
|
|
107
|
+
version,
|
|
108
|
+
});
|
|
109
|
+
await writeCliPlugin(options.out, plugin);
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(options.out, "package.json"),
|
|
112
|
+
`${JSON.stringify({ name: packageName, version, type: "module", cld: { manifest: "manifest.json" } }, null, 2)}\n`,
|
|
113
|
+
);
|
|
114
|
+
const references = plugin.manifest.files.filter((file) => file.path.startsWith("references/")).length;
|
|
115
|
+
console.log(
|
|
116
|
+
`Packed ${packageName}@${version} (module "${module.name}", ${references} reference files) to ${options.out}. Ship it with \`npm publish ${options.out}\` or \`npm pack ${options.out}\`.`,
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
pack(Bun.argv.slice(2)).catch((error: unknown) => {
|
|
121
|
+
console.error(error instanceof Error ? error.message.replace(/\s*\n\s*/g, " ") : String(error));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
});
|
|
@@ -1,5 +1,124 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
import { formatShortcut } from "@k2b/ui";
|
|
3
|
+
import type { Token } from "marked";
|
|
1
4
|
import type { HelpArticle } from "../services/help/types";
|
|
2
|
-
import {
|
|
5
|
+
import { parseHelpReference } from "../shared/help";
|
|
6
|
+
import { lexHelpMarkdown, markdownToPlainText } from "../shared/markdown";
|
|
7
|
+
|
|
8
|
+
const helpAgentMessages = i18n.define({
|
|
9
|
+
baseLocale: "en",
|
|
10
|
+
messages: {
|
|
11
|
+
en: { apple: ({ shortcut }: { shortcut: string }) => `${shortcut} on Mac` },
|
|
12
|
+
de: { apple: ({ shortcut }) => `${shortcut} auf dem Mac` },
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const helpLinkLabel = (source: string, fallback: string): string => {
|
|
17
|
+
// Index backtick runs once so unmatched runs cannot repeatedly scan the remaining source.
|
|
18
|
+
const backticks = new Map<number, { positions: number[]; next: number }>();
|
|
19
|
+
for (const run of source.matchAll(/`+/g)) {
|
|
20
|
+
const length = run[0].length;
|
|
21
|
+
if (!backticks.has(length)) backticks.set(length, { positions: [], next: 0 });
|
|
22
|
+
backticks.get(length)!.positions.push(run.index);
|
|
23
|
+
}
|
|
24
|
+
let depth = 0;
|
|
25
|
+
for (let index = 1; index < source.length; index++) {
|
|
26
|
+
const char = source[index];
|
|
27
|
+
if (char === "\\") index++;
|
|
28
|
+
else if (char === "`") {
|
|
29
|
+
let end = index + 1;
|
|
30
|
+
while (source[end] === "`") end++;
|
|
31
|
+
const length = end - index;
|
|
32
|
+
const runs = backticks.get(length);
|
|
33
|
+
if (runs) {
|
|
34
|
+
while (runs.next < runs.positions.length && runs.positions[runs.next]! <= index) runs.next++;
|
|
35
|
+
}
|
|
36
|
+
const closing = runs?.positions[runs.next];
|
|
37
|
+
index = closing === undefined ? end - 1 : closing + length - 1;
|
|
38
|
+
} else if (char === "[") depth++;
|
|
39
|
+
else if (char === "]") {
|
|
40
|
+
if (depth === 0) return source.slice(1, index);
|
|
41
|
+
depth--;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return fallback;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Portable source for agents and Copy as Markdown; code is kept verbatim. */
|
|
48
|
+
export const helpAgentMarkdown = (
|
|
49
|
+
article: Pick<HelpArticle, "markdown" | "locale" | "appId" | "links">,
|
|
50
|
+
tokens: readonly Token[] = lexHelpMarkdown(article.markdown, article.locale),
|
|
51
|
+
): string => {
|
|
52
|
+
const { t } = helpAgentMessages.resolve([article.locale]);
|
|
53
|
+
const rewriteChildren = (raw: string, children: readonly Token[]): string => {
|
|
54
|
+
let offset = 0;
|
|
55
|
+
let result = "";
|
|
56
|
+
for (const child of children) {
|
|
57
|
+
let start = raw.indexOf(child.raw, offset);
|
|
58
|
+
let source = child.raw;
|
|
59
|
+
if (start < 0 && child.raw.includes("|")) {
|
|
60
|
+
source = child.raw.replaceAll("|", "\\|");
|
|
61
|
+
start = raw.indexOf(source, offset);
|
|
62
|
+
}
|
|
63
|
+
// Marked removes quote markers and list indentation from child block sources.
|
|
64
|
+
if (start < 0 && child.raw.includes("\n")) {
|
|
65
|
+
const pattern = child.raw
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((line, index) => (index > 0 ? line.replace(/^[ \t]+/, "") : line).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
68
|
+
.join("\\r?\\n[ \\t>]*");
|
|
69
|
+
const match = new RegExp(pattern).exec(raw.slice(offset));
|
|
70
|
+
if (match) {
|
|
71
|
+
start = offset + match.index;
|
|
72
|
+
source = match[0];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (start < 0) break;
|
|
76
|
+
result += raw.slice(offset, start) + rewrite(child, source);
|
|
77
|
+
offset = start + source.length;
|
|
78
|
+
}
|
|
79
|
+
return result + raw.slice(offset);
|
|
80
|
+
};
|
|
81
|
+
const rewrite = (token: Token, source: string): string => {
|
|
82
|
+
if (token.type === "code" || token.type === "codespan") return source;
|
|
83
|
+
if (token.type === "helpKbd") {
|
|
84
|
+
const keys = String(token.keys);
|
|
85
|
+
const other = formatShortcut(keys, { platform: "other", locale: article.locale });
|
|
86
|
+
const apple = formatShortcut(keys, { platform: "apple", locale: article.locale }).label;
|
|
87
|
+
// Key labels such as | or ` stay text in a table cell or a sentence.
|
|
88
|
+
const escape = (text: string) => text.replace(/[\\`*_[\]|<>~]/g, "\\$&");
|
|
89
|
+
// A key without a platform modifier reads by its name: "Enter", not "↵".
|
|
90
|
+
return other.label === apple ? escape(other.name) : `${escape(other.label)} (${t.apple({ shortcut: escape(apple) })})`;
|
|
91
|
+
}
|
|
92
|
+
if (token.type === "helpDetails") {
|
|
93
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
94
|
+
const bodyStart = source.indexOf("\n") + 1;
|
|
95
|
+
const closing = [...source.matchAll(/^[ \t>]*:::[ \t]*(?:\r?\n|$)/gm)].at(-1)!;
|
|
96
|
+
const body = source.slice(bodyStart, closing.index);
|
|
97
|
+
const prefix = body.match(/^[ \t>]+/)?.[0] ?? "";
|
|
98
|
+
const trailing = source.slice(closing.index + closing[0].length);
|
|
99
|
+
return `**${rewriteChildren(String(token.title), token.titleTokens ?? [])}**${newline}${prefix}${newline}${rewriteChildren(body, token.tokens ?? [])}${trailing}`;
|
|
100
|
+
}
|
|
101
|
+
if (token.type === "helpLabel") return rewriteChildren(source.replace(/\{icon="[a-z0-9-]+"\}$/, ""), token.tokens ?? []);
|
|
102
|
+
if (token.type === "link" && token.href.startsWith("help:")) {
|
|
103
|
+
const ref = parseHelpReference(token.href.slice(5));
|
|
104
|
+
const target = ref && article.links[`${ref.appId ?? article.appId}/${ref.documentId}`];
|
|
105
|
+
const label = rewriteChildren(helpLinkLabel(source, token.text), token.tokens ?? []);
|
|
106
|
+
return target ? `[${label}](${target.url}${ref.fragment})` : label;
|
|
107
|
+
}
|
|
108
|
+
const children =
|
|
109
|
+
token.type === "list"
|
|
110
|
+
? token.items
|
|
111
|
+
: token.type === "table"
|
|
112
|
+
? [...token.header, ...token.rows.flat()].flatMap((cell) => cell.tokens)
|
|
113
|
+
: "tokens" in token
|
|
114
|
+
? (token.tokens ?? [])
|
|
115
|
+
: [];
|
|
116
|
+
const raw = rewriteChildren(source, children);
|
|
117
|
+
return token.type === "heading" ? raw.replace(/[ \t]+\{icon="[a-z0-9-]+"\}(?=[ \t]*\r?\n?$)/i, "") : raw;
|
|
118
|
+
};
|
|
119
|
+
return rewriteChildren(article.markdown, tokens);
|
|
120
|
+
};
|
|
121
|
+
|
|
3
122
|
export const HELP_SEARCH_MAX_LIMIT = 25;
|
|
4
123
|
export const HELP_READ_MAX_CHARS = 7_000;
|
|
5
124
|
const normalizeSearchText = (value: string): string =>
|
|
@@ -33,7 +152,7 @@ const boundedExcerpt = (markdown: string, terms: readonly string[]): string => {
|
|
|
33
152
|
return `${start > 0 ? `${marker}\n\n` : ""}${body}${start + budget < markdown.length ? `\n\n${marker}` : ""}`;
|
|
34
153
|
};
|
|
35
154
|
|
|
36
|
-
export const selectHelpMarkdown = (markdown: string, query?: string): { markdown: string; truncated: boolean } => {
|
|
155
|
+
export const selectHelpMarkdown = (markdown: string, query?: string, locale?: string): { markdown: string; truncated: boolean } => {
|
|
37
156
|
// An exact heading identifies one complete reference section, even in short articles.
|
|
38
157
|
const headingQuery = query?.trim().toLocaleLowerCase();
|
|
39
158
|
const exact =
|
|
@@ -53,7 +172,7 @@ export const selectHelpMarkdown = (markdown: string, query?: string): { markdown
|
|
|
53
172
|
const phrase = normalizeSearchText(query ?? "");
|
|
54
173
|
const ranked = splitSections(markdown)
|
|
55
174
|
.map((section, index) => {
|
|
56
|
-
const text = normalizeSearchText(markdownToPlainText(section));
|
|
175
|
+
const text = normalizeSearchText(markdownToPlainText(section, locale));
|
|
57
176
|
const score = (phrase && text.includes(phrase) ? 10 : 0) + terms.filter((term) => text.includes(term)).length;
|
|
58
177
|
return { section, index, score };
|
|
59
178
|
})
|
|
@@ -79,7 +198,7 @@ export const readHelpArticle = (document: HelpArticle, query?: string) => ({
|
|
|
79
198
|
documentId: document.documentId,
|
|
80
199
|
title: document.title,
|
|
81
200
|
description: document.description,
|
|
82
|
-
...selectHelpMarkdown(document
|
|
201
|
+
...selectHelpMarkdown(helpAgentMarkdown(document), query, document.locale),
|
|
83
202
|
});
|
|
84
203
|
|
|
85
204
|
export const helpResourceUri = (appId: string, documentId: string): string =>
|
package/src/_internal/help.ts
CHANGED
|
@@ -2,28 +2,49 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import type { AppRegistryHelpSummary } from "../contracts/registry";
|
|
3
3
|
import type { HelpDefinition } from "../server/help";
|
|
4
4
|
import type { HelpCorpus, HelpDocument } from "../services/help/types";
|
|
5
|
+
import { parseHelpReference } from "../shared/help";
|
|
6
|
+
import { lexHelpMarkdown, walkHelpTokens } from "../shared/markdown";
|
|
5
7
|
|
|
6
8
|
export const HELP_DOCUMENT_MAX_BYTES = 128 * 1024;
|
|
9
|
+
// defineHelp documents are frozen; retain only their validated targets, not the token trees.
|
|
10
|
+
export const helpLinkTargets = new WeakMap<object, readonly string[]>();
|
|
7
11
|
export type CompiledHelp = { summary: AppRegistryHelpSummary; corpus: HelpCorpus };
|
|
8
12
|
|
|
9
13
|
export const compileHelp = (input: { appId: string; basePath?: string; definition: HelpDefinition }): CompiledHelp => {
|
|
10
|
-
const
|
|
11
|
-
|
|
14
|
+
const baseIds = new Set(input.definition.documents.map((document) => document.id));
|
|
15
|
+
const compileDocuments = (source: HelpDefinition["documents"], locale: string): HelpDocument[] =>
|
|
16
|
+
source.map((document) => {
|
|
17
|
+
const { id, title, icon, description, order, kind, needs, next, related, markdown, searchText } = document;
|
|
12
18
|
if (Buffer.byteLength(markdown) > HELP_DOCUMENT_MAX_BYTES) {
|
|
13
19
|
throw new Error(`Help document "${id}" exceeds the ${HELP_DOCUMENT_MAX_BYTES}-byte limit`);
|
|
14
20
|
}
|
|
15
|
-
|
|
21
|
+
const validate = (reference: string) => {
|
|
22
|
+
const ref = parseHelpReference(reference);
|
|
23
|
+
if (ref?.appId === input.appId && !baseIds.has(ref.documentId))
|
|
24
|
+
throw new Error(`Help locale "${locale}" document "${id}" has unknown reference "${reference}"`);
|
|
25
|
+
};
|
|
26
|
+
for (const reference of related) validate(reference);
|
|
27
|
+
const targets = helpLinkTargets.get(document);
|
|
28
|
+
if (targets) {
|
|
29
|
+
for (const target of targets) validate(target);
|
|
30
|
+
} else {
|
|
31
|
+
walkHelpTokens(lexHelpMarkdown(markdown, locale), (token) => {
|
|
32
|
+
if (token.type === "link" && token.href.startsWith("help:")) validate(token.href.slice(5));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return { id, title, icon, description, order, kind, needs, next, related, markdown, searchText };
|
|
16
36
|
});
|
|
17
37
|
const baseLocale = input.definition.baseLocale ?? "en";
|
|
18
|
-
const documents = compileDocuments(input.definition.documents);
|
|
38
|
+
const documents = compileDocuments(input.definition.documents, baseLocale);
|
|
19
39
|
const documentsByLocale = Object.fromEntries(
|
|
20
40
|
Object.entries(input.definition.documentsByLocale ?? {})
|
|
21
41
|
.filter(([locale]) => locale !== baseLocale)
|
|
22
42
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
23
|
-
.map(([locale, docs]) => [locale, compileDocuments(docs)]),
|
|
43
|
+
.map(([locale, docs]) => [locale, compileDocuments(docs, locale)]),
|
|
24
44
|
);
|
|
45
|
+
// A revision bump republishes every corpus, here to fill the new metadata columns.
|
|
25
46
|
const manifestHash = createHash("sha256")
|
|
26
|
-
.update(JSON.stringify({ revision:
|
|
47
|
+
.update(JSON.stringify({ revision: 2, appId: input.appId, baseLocale, documents, documentsByLocale }))
|
|
27
48
|
.digest("hex");
|
|
28
49
|
return {
|
|
29
50
|
summary: { manifestHash, pageBase: `${input.basePath?.replace(/\/$/, "") ?? ""}/help`, baseLocale },
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const SHELL_FOCUS_COOKIE = "cloud_shell_focus";
|
|
2
|
+
|
|
3
|
+
export const readShellFocusCookie = (cookieHeader: string | null | undefined): boolean =>
|
|
4
|
+
cookieHeader
|
|
5
|
+
?.split(";")
|
|
6
|
+
.map((part) => part.trim())
|
|
7
|
+
.find((part) => part.startsWith(`${SHELL_FOCUS_COOKIE}=`))
|
|
8
|
+
?.slice(SHELL_FOCUS_COOKIE.length + 1) === "1";
|
|
9
|
+
|
|
10
|
+
export const shellFocusCookie = (hidden: boolean, secure: boolean): string =>
|
|
11
|
+
`${SHELL_FOCUS_COOKIE}=${hidden ? "1" : ""}; Path=/; Max-Age=${hidden ? 31536000 : 0}; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LocaleProvider } from "@k2b/ui";
|
|
2
|
+
import { render } from "solid-js/web";
|
|
3
|
+
import PermissionEditor from "./PermissionEditor";
|
|
4
|
+
|
|
5
|
+
const host = document.getElementById("root");
|
|
6
|
+
if (!host) throw new Error("Missing harness root");
|
|
7
|
+
render(
|
|
8
|
+
() => (
|
|
9
|
+
<LocaleProvider locale="en">
|
|
10
|
+
<PermissionEditor
|
|
11
|
+
initialEntries={["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
|
|
12
|
+
id: `access-${index}`,
|
|
13
|
+
principal: { type: "user", userId: `user-${index}` },
|
|
14
|
+
displayName,
|
|
15
|
+
permission: "read",
|
|
16
|
+
createdAt: "2026-10-15T07:00:00.000Z",
|
|
17
|
+
}))}
|
|
18
|
+
// Equal-width labels isolate pending-state layout from a normal change in label width.
|
|
19
|
+
allowedLevels={[
|
|
20
|
+
{ level: "read", label: "Level 1" },
|
|
21
|
+
{ level: "write", label: "Level 2" },
|
|
22
|
+
]}
|
|
23
|
+
allowAuthenticated={false}
|
|
24
|
+
grantAccess={async () => {
|
|
25
|
+
throw new Error("Not used");
|
|
26
|
+
}}
|
|
27
|
+
updateAccess={() =>
|
|
28
|
+
new Promise<void>((resolve) => {
|
|
29
|
+
// The test waits for this mark, so it does not depend on how the editor shows a pending change.
|
|
30
|
+
document.documentElement.dataset.saving = "true";
|
|
31
|
+
document.addEventListener(
|
|
32
|
+
"settle-access",
|
|
33
|
+
() => {
|
|
34
|
+
delete document.documentElement.dataset.saving;
|
|
35
|
+
resolve();
|
|
36
|
+
},
|
|
37
|
+
{ once: true },
|
|
38
|
+
);
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
revokeAccess={async () => {}}
|
|
42
|
+
/>
|
|
43
|
+
</LocaleProvider>
|
|
44
|
+
),
|
|
45
|
+
host,
|
|
46
|
+
);
|
|
@@ -236,8 +236,16 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
236
236
|
};
|
|
237
237
|
|
|
238
238
|
type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
|
|
239
|
+
// The row whose change is saving: it shows the chosen level with a spinner, the other rows wait.
|
|
240
|
+
const [updating, setUpdating] = createSignal<LevelChange | null>(null);
|
|
241
|
+
const [revoking, setRevoking] = createSignal<string | null>(null);
|
|
242
|
+
const updatingRow = (accessId: string) => {
|
|
243
|
+
const change = updating();
|
|
244
|
+
return change?.accessId === accessId ? change : undefined;
|
|
245
|
+
};
|
|
239
246
|
const updateMut = mutation.create<LevelChange, LevelChange>({
|
|
240
247
|
mutation: async (data) => {
|
|
248
|
+
setUpdating(data);
|
|
241
249
|
await props.updateAccess(data.accessId, data.permission, data.scope);
|
|
242
250
|
return data;
|
|
243
251
|
},
|
|
@@ -247,6 +255,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
247
255
|
}
|
|
248
256
|
},
|
|
249
257
|
onError: (err) => prompts.error(err.message),
|
|
258
|
+
onFinally: () => {
|
|
259
|
+
if (!disposed) setUpdating(null);
|
|
260
|
+
},
|
|
250
261
|
});
|
|
251
262
|
|
|
252
263
|
const revokeMut = mutation.create<string | null, AccessEntry>({
|
|
@@ -257,6 +268,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
257
268
|
variant: "danger",
|
|
258
269
|
});
|
|
259
270
|
if (!confirmed) return null;
|
|
271
|
+
setRevoking(entry.id);
|
|
260
272
|
await props.revokeAccess(entry.id);
|
|
261
273
|
return entry.id;
|
|
262
274
|
},
|
|
@@ -264,6 +276,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
264
276
|
if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
|
|
265
277
|
},
|
|
266
278
|
onError: (err) => prompts.error(err.message),
|
|
279
|
+
onFinally: () => {
|
|
280
|
+
if (!disposed) setRevoking(null);
|
|
281
|
+
},
|
|
267
282
|
});
|
|
268
283
|
const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
|
|
269
284
|
// The service refuses to remove the last manager; the row says so up front.
|
|
@@ -288,6 +303,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
288
303
|
coverage={coverage}
|
|
289
304
|
canEdit={canEdit()}
|
|
290
305
|
pending={entry.id === pending()}
|
|
306
|
+
updating={updatingRow(entry.id)}
|
|
307
|
+
revoking={entry.id === revoking()}
|
|
291
308
|
disabled={busy()}
|
|
292
309
|
lastManager={entry.id === lastManagerId()}
|
|
293
310
|
allowed={allowed(entry.principal)}
|
|
@@ -332,8 +349,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
332
349
|
function AccessEntryRow(props: {
|
|
333
350
|
entry: AccessEntry;
|
|
334
351
|
canEdit: boolean;
|
|
352
|
+
/** Another change is saving: the row keeps its controls, disabled. */
|
|
335
353
|
disabled: boolean;
|
|
354
|
+
/** This row's grant is saving. */
|
|
336
355
|
pending?: boolean;
|
|
356
|
+
/** This row's level change is saving. */
|
|
357
|
+
updating?: { permission: GrantableLevel; scope: string | undefined };
|
|
358
|
+
/** This row's removal is saving. */
|
|
359
|
+
revoking?: boolean;
|
|
337
360
|
/** The only entry that manages the resource: it keeps its level and cannot be removed. */
|
|
338
361
|
lastManager: boolean;
|
|
339
362
|
allowed: ResolvedLevel[];
|
|
@@ -352,8 +375,14 @@ function AccessEntryRow(props: {
|
|
|
352
375
|
const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
|
|
353
376
|
const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
|
|
354
377
|
const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
|
|
355
|
-
|
|
356
|
-
|
|
378
|
+
// Whether the row offers a picker depends only on what the viewer may do, never on a change in flight, so a
|
|
379
|
+
// saving change never swaps the picker for the badge and the rows keep their size.
|
|
380
|
+
const hasPicker = () => props.canEdit && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
|
|
381
|
+
const saving = () => props.pending || props.updating !== undefined || props.revoking === true;
|
|
382
|
+
// While a level change saves, the picker already shows the chosen level.
|
|
383
|
+
const shownKey = () => (props.updating ? levelKey(props.updating.permission, props.updating.scope) : entryKey());
|
|
384
|
+
const shownDisplay = () =>
|
|
385
|
+
props.updating ? resolveEntryDisplay(props.updating.permission, props.updating.scope, props.allowed, t()) : display();
|
|
357
386
|
|
|
358
387
|
const badgeClass =
|
|
359
388
|
"flex min-h-7 items-center gap-1 rounded-full border border-transparent bg-[var(--ui-surface-muted)] px-2.5 py-1 text-xs text-secondary";
|
|
@@ -362,15 +391,12 @@ function AccessEntryRow(props: {
|
|
|
362
391
|
<>
|
|
363
392
|
<i class={props.pending ? "ti ti-loader-2 animate-spin" : `ti ${display().icon}`} />
|
|
364
393
|
<span>{display().label}</span>
|
|
365
|
-
<Show when={isInteractive()}>
|
|
366
|
-
<i class="ti ti-chevron-down text-[10px]" />
|
|
367
|
-
</Show>
|
|
368
394
|
</>
|
|
369
395
|
);
|
|
370
396
|
|
|
371
397
|
return (
|
|
372
398
|
<div>
|
|
373
|
-
<div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={
|
|
399
|
+
<div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={saving() ? "true" : undefined}>
|
|
374
400
|
<Show
|
|
375
401
|
when={props.entry.principal.type === "user"}
|
|
376
402
|
fallback={
|
|
@@ -415,10 +441,12 @@ function AccessEntryRow(props: {
|
|
|
415
441
|
|
|
416
442
|
{/* Permission badge — interactive single-value picker when editable,
|
|
417
443
|
plain span otherwise. */}
|
|
418
|
-
<Show when={
|
|
444
|
+
<Show when={hasPicker()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
|
|
419
445
|
<SelectChip
|
|
420
446
|
aria-label={t().permissionFor({ name: displayName() })}
|
|
421
|
-
value={
|
|
447
|
+
value={shownKey}
|
|
448
|
+
disabled={props.disabled}
|
|
449
|
+
loading={props.pending || props.updating !== undefined}
|
|
422
450
|
options={props.allowed.map((option) => {
|
|
423
451
|
const locked = props.lastManager && (option.level !== "admin" || option.scope !== undefined);
|
|
424
452
|
return {
|
|
@@ -429,7 +457,7 @@ function AccessEntryRow(props: {
|
|
|
429
457
|
description: locked ? t().lastManagerOption({ level: manageLabel() }) : option.description,
|
|
430
458
|
};
|
|
431
459
|
})}
|
|
432
|
-
icon={`ti ${
|
|
460
|
+
icon={`ti ${shownDisplay().icon}`}
|
|
433
461
|
position="bottom-left"
|
|
434
462
|
onValueChange={(key) => {
|
|
435
463
|
const option = props.allowed.find((candidate) => candidate.key === key);
|
|
@@ -452,6 +480,7 @@ function AccessEntryRow(props: {
|
|
|
452
480
|
size="xs"
|
|
453
481
|
onClick={props.onRevoke}
|
|
454
482
|
disabled={props.disabled || props.pending || props.lastManager}
|
|
483
|
+
loading={props.revoking}
|
|
455
484
|
aria-label={t().remove({ name: displayName() })}
|
|
456
485
|
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
457
486
|
aria-description={props.lastManager ? removeLabel() : undefined}
|
package/src/access/ui.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* from depending on Cloud's broad legacy UI entrypoint.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
export { isManagerEntry, type ManagerCandidate } from "./managers";
|
|
9
10
|
export type { AllowedLevel, GrantableLevel } from "./PermissionEditor";
|
|
10
11
|
export { default as PermissionEditor } from "./PermissionEditor";
|
|
11
12
|
export { default as PrincipalPicker, principalKey } from "./PrincipalPicker";
|
package/src/account/Avatar.tsx
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { Avatar, type AvatarProps } from "@k2b/ui";
|
|
2
2
|
import type { JSX } from "solid-js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** Who an avatar stands for; agents and service accounts are square, people round. */
|
|
5
|
+
export type CloudAvatarKind = "user" | "service_account" | "system";
|
|
6
|
+
|
|
7
|
+
export type CloudAvatarProps = Omit<AvatarProps, "name" | "src" | "shape" | "icon"> & {
|
|
5
8
|
username: string;
|
|
6
9
|
userId?: string | null;
|
|
7
10
|
avatarHash?: string | null;
|
|
11
|
+
kind?: CloudAvatarKind;
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
const avatarSource = (userId?: string | null, avatarHash?: string | null): string | undefined => {
|
|
@@ -12,14 +16,28 @@ const avatarSource = (userId?: string | null, avatarHash?: string | null): strin
|
|
|
12
16
|
return `/api/accounts/users/${encodeURIComponent(userId)}/avatar?rev=${encodeURIComponent(avatarHash)}`;
|
|
13
17
|
};
|
|
14
18
|
|
|
19
|
+
/** The URL of a person's uploaded picture, or undefined when there is none. */
|
|
20
|
+
export const cloudAvatarSource = avatarSource;
|
|
21
|
+
|
|
22
|
+
const KIND_ICON: Record<CloudAvatarKind, string | undefined> = {
|
|
23
|
+
user: undefined,
|
|
24
|
+
service_account: "ti ti-robot",
|
|
25
|
+
system: "ti ti-settings-automation",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One avatar for every Cloud surface: the uploaded picture when there is one, otherwise initials. */
|
|
15
29
|
export function CloudAvatar(props: CloudAvatarProps): JSX.Element {
|
|
30
|
+
const kind = () => props.kind ?? "user";
|
|
16
31
|
return (
|
|
17
32
|
<Avatar
|
|
18
33
|
name={props.username}
|
|
19
|
-
src={avatarSource(props.userId, props.avatarHash)}
|
|
34
|
+
src={kind() === "user" ? avatarSource(props.userId, props.avatarHash) : undefined}
|
|
35
|
+
icon={KIND_ICON[kind()]}
|
|
36
|
+
shape={kind() === "user" ? "circle" : "square"}
|
|
20
37
|
alt={props.alt}
|
|
21
38
|
fallback={props.fallback}
|
|
22
39
|
size={props.size}
|
|
40
|
+
ring={props.ring}
|
|
23
41
|
loading={props.loading}
|
|
24
42
|
class={props.class}
|
|
25
43
|
style={props.style}
|
package/src/account/ui.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { CloudAvatar, type CloudAvatarProps } from "./Avatar";
|
|
1
|
+
export { CloudAvatar, type CloudAvatarKind, type CloudAvatarProps, cloudAvatarSource } from "./Avatar";
|
|
2
2
|
export { type AvatarUploadDialogOptions, openAvatarUploadDialog } from "./avatar-upload-dialog";
|
|
3
3
|
export { default as EntitySearch, type EntitySearchPrincipal } from "./EntitySearch";
|