@kvman/testkit 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/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/bin/bin-failure.d.ts +16 -0
- package/dist/bin/bin-failure.d.ts.map +1 -0
- package/dist/bin/bin-failure.js +28 -0
- package/dist/bin/bin-failure.js.map +1 -0
- package/dist/bin/program-command.d.ts +7 -0
- package/dist/bin/program-command.d.ts.map +1 -0
- package/dist/bin/program-command.js +8 -0
- package/dist/bin/program-command.js.map +1 -0
- package/dist/bin/run-program.d.ts +10 -0
- package/dist/bin/run-program.d.ts.map +1 -0
- package/dist/bin/run-program.js +30 -0
- package/dist/bin/run-program.js.map +1 -0
- package/dist/check/check-extension.d.ts +4 -0
- package/dist/check/check-extension.d.ts.map +1 -0
- package/dist/check/check-extension.js +78 -0
- package/dist/check/check-extension.js.map +1 -0
- package/dist/check/check-output.d.ts +4 -0
- package/dist/check/check-output.d.ts.map +1 -0
- package/dist/check/check-output.js +13 -0
- package/dist/check/check-output.js.map +1 -0
- package/dist/check/docs-findings.d.ts +8 -0
- package/dist/check/docs-findings.d.ts.map +1 -0
- package/dist/check/docs-findings.js +65 -0
- package/dist/check/docs-findings.js.map +1 -0
- package/dist/check/field-descriptions.d.ts +8 -0
- package/dist/check/field-descriptions.d.ts.map +1 -0
- package/dist/check/field-descriptions.js +19 -0
- package/dist/check/field-descriptions.js.map +1 -0
- package/dist/check/finding.d.ts +11 -0
- package/dist/check/finding.d.ts.map +1 -0
- package/dist/check/finding.js +2 -0
- package/dist/check/finding.js.map +1 -0
- package/dist/check/locale-keys.d.ts +7 -0
- package/dist/check/locale-keys.d.ts.map +1 -0
- package/dist/check/locale-keys.js +40 -0
- package/dist/check/locale-keys.js.map +1 -0
- package/dist/check/timer-scan.d.ts +6 -0
- package/dist/check/timer-scan.d.ts.map +1 -0
- package/dist/check/timer-scan.js +84 -0
- package/dist/check/timer-scan.js.map +1 -0
- package/dist/check/ui-keys.d.ts +3 -0
- package/dist/check/ui-keys.d.ts.map +1 -0
- package/dist/check/ui-keys.js +58 -0
- package/dist/check/ui-keys.js.map +1 -0
- package/dist/check-bin.d.ts +3 -0
- package/dist/check-bin.d.ts.map +1 -0
- package/dist/check-bin.js +17 -0
- package/dist/check-bin.js.map +1 -0
- package/dist/docs/built-in-guides.d.ts +12 -0
- package/dist/docs/built-in-guides.d.ts.map +1 -0
- package/dist/docs/built-in-guides.js +20 -0
- package/dist/docs/built-in-guides.js.map +1 -0
- package/dist/docs/docs-bin.d.ts +3 -0
- package/dist/docs/docs-bin.d.ts.map +1 -0
- package/dist/docs/docs-bin.js +98 -0
- package/dist/docs/docs-bin.js.map +1 -0
- package/dist/docs/docs-get.d.ts +11 -0
- package/dist/docs/docs-get.d.ts.map +1 -0
- package/dist/docs/docs-get.js +54 -0
- package/dist/docs/docs-get.js.map +1 -0
- package/dist/docs/docs-list.d.ts +22 -0
- package/dist/docs/docs-list.d.ts.map +1 -0
- package/dist/docs/docs-list.js +51 -0
- package/dist/docs/docs-list.js.map +1 -0
- package/dist/fake-clock.d.ts +16 -0
- package/dist/fake-clock.d.ts.map +1 -0
- package/dist/fake-clock.js +47 -0
- package/dist/fake-clock.js.map +1 -0
- package/dist/fake-openai.d.ts +55 -0
- package/dist/fake-openai.d.ts.map +1 -0
- package/dist/fake-openai.js +115 -0
- package/dist/fake-openai.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +111 -0
- package/dist/index.js.map +1 -0
- package/dist/new/new-bin.d.ts +3 -0
- package/dist/new/new-bin.d.ts.map +1 -0
- package/dist/new/new-bin.js +60 -0
- package/dist/new/new-bin.js.map +1 -0
- package/dist/new/new-project.d.ts +11 -0
- package/dist/new/new-project.d.ts.map +1 -0
- package/dist/new/new-project.js +36 -0
- package/dist/new/new-project.js.map +1 -0
- package/dist/new/scaffold-files.d.ts +7 -0
- package/dist/new/scaffold-files.d.ts.map +1 -0
- package/dist/new/scaffold-files.js +100 -0
- package/dist/new/scaffold-files.js.map +1 -0
- package/dist/new/scaffold-versions.d.ts +10 -0
- package/dist/new/scaffold-versions.d.ts.map +1 -0
- package/dist/new/scaffold-versions.js +12 -0
- package/dist/new/scaffold-versions.js.map +1 -0
- package/dist/preset/preset-bin.d.ts +3 -0
- package/dist/preset/preset-bin.d.ts.map +1 -0
- package/dist/preset/preset-bin.js +91 -0
- package/dist/preset/preset-bin.js.map +1 -0
- package/dist/preset/preset-check.d.ts +15 -0
- package/dist/preset/preset-check.d.ts.map +1 -0
- package/dist/preset/preset-check.js +113 -0
- package/dist/preset/preset-check.js.map +1 -0
- package/dist/preset/preset-new.d.ts +5 -0
- package/dist/preset/preset-new.d.ts.map +1 -0
- package/dist/preset/preset-new.js +16 -0
- package/dist/preset/preset-new.js.map +1 -0
- package/dist/preset/preset-reach.d.ts +18 -0
- package/dist/preset/preset-reach.d.ts.map +1 -0
- package/dist/preset/preset-reach.js +33 -0
- package/dist/preset/preset-reach.js.map +1 -0
- package/dist/preset/project-manifest.d.ts +5 -0
- package/dist/preset/project-manifest.d.ts.map +1 -0
- package/dist/preset/project-manifest.js +30 -0
- package/dist/preset/project-manifest.js.map +1 -0
- package/dist/preview/preview-bin.d.ts +3 -0
- package/dist/preview/preview-bin.d.ts.map +1 -0
- package/dist/preview/preview-bin.js +63 -0
- package/dist/preview/preview-bin.js.map +1 -0
- package/dist/preview/preview-home.d.ts +5 -0
- package/dist/preview/preview-home.d.ts.map +1 -0
- package/dist/preview/preview-home.js +64 -0
- package/dist/preview/preview-home.js.map +1 -0
- package/dist/preview/preview-manifest.d.ts +9 -0
- package/dist/preview/preview-manifest.d.ts.map +1 -0
- package/dist/preview/preview-manifest.js +32 -0
- package/dist/preview/preview-manifest.js.map +1 -0
- package/dist/preview/preview-port.d.ts +5 -0
- package/dist/preview/preview-port.d.ts.map +1 -0
- package/dist/preview/preview-port.js +22 -0
- package/dist/preview/preview-port.js.map +1 -0
- package/dist/preview/preview-preset.d.ts +14 -0
- package/dist/preview/preview-preset.d.ts.map +1 -0
- package/dist/preview/preview-preset.js +13 -0
- package/dist/preview/preview-preset.js.map +1 -0
- package/dist/preview/preview-run.d.ts +14 -0
- package/dist/preview/preview-run.d.ts.map +1 -0
- package/dist/preview/preview-run.js +241 -0
- package/dist/preview/preview-run.js.map +1 -0
- package/dist/preview/process-tree.d.ts +40 -0
- package/dist/preview/process-tree.d.ts.map +1 -0
- package/dist/preview/process-tree.js +92 -0
- package/dist/preview/process-tree.js.map +1 -0
- package/dist/running/call-query.d.ts +5 -0
- package/dist/running/call-query.d.ts.map +1 -0
- package/dist/running/call-query.js +34 -0
- package/dist/running/call-query.js.map +1 -0
- package/dist/running/running-kvman.d.ts +14 -0
- package/dist/running/running-kvman.d.ts.map +1 -0
- package/dist/running/running-kvman.js +80 -0
- package/dist/running/running-kvman.js.map +1 -0
- package/docs/i18n.md +20 -0
- package/docs/presets.md +15 -0
- package/docs/sdk.md +77 -0
- package/package.json +49 -0
- package/templates/AGENTS.md.template +13 -0
- package/templates/README.md.template +12 -0
- package/templates/extension-docs/usage.md.template +8 -0
- package/templates/src/docs.ts.template +46 -0
- package/templates/src/index.ts.template +42 -0
- package/templates/test/extension.test.ts.template +29 -0
- package/templates/web/components/Hello.vue.template +42 -0
- package/templates/web-build.ts.template +26 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from '@kvman/sdk';
|
|
5
|
+
import { BinFailure } from "../bin/bin-failure.js";
|
|
6
|
+
/** The kvman can't be reached; the caller treats it as not running. */
|
|
7
|
+
export class KvmanUnreachableError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'KvmanUnreachableError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const lockSchema = z.object({ pid: z.number().int().positive(), port: z.number().int().min(1).max(65_535).optional() });
|
|
14
|
+
function hasCode(error, code) {
|
|
15
|
+
return error instanceof Error && 'code' in error && error.code === code;
|
|
16
|
+
}
|
|
17
|
+
function isAlive(pid) {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (hasCode(error, 'ESRCH'))
|
|
24
|
+
return false;
|
|
25
|
+
if (hasCode(error, 'EPERM'))
|
|
26
|
+
return true;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function baseUrlFromOption(url) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(url);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new BinFailure('VALIDATION_FAILED', `--url ${JSON.stringify(url)} isn't a URL; give an http: URL for 127.0.0.1 or localhost with an explicit port, such as http://127.0.0.1:3737.`);
|
|
37
|
+
}
|
|
38
|
+
if (parsed.protocol !== 'http:' || (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') || parsed.port === '') {
|
|
39
|
+
throw new BinFailure('VALIDATION_FAILED', `--url ${JSON.stringify(url)} isn't usable; give an http: URL for 127.0.0.1 or localhost with an explicit port, such as http://127.0.0.1:3737.`);
|
|
40
|
+
}
|
|
41
|
+
return `http://${parsed.hostname}:${parsed.port}`;
|
|
42
|
+
}
|
|
43
|
+
function resolveHome(home) {
|
|
44
|
+
if (home !== undefined)
|
|
45
|
+
return home;
|
|
46
|
+
const fromEnvironment = process.env.KVMAN_HOME;
|
|
47
|
+
if (fromEnvironment !== undefined && fromEnvironment !== '')
|
|
48
|
+
return fromEnvironment;
|
|
49
|
+
return path.join(homedir(), '.kvman');
|
|
50
|
+
}
|
|
51
|
+
function baseUrlFromLock(home) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = readFileSync(path.join(home, 'kvman.lock'), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (hasCode(error, 'ENOENT'))
|
|
58
|
+
return undefined;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const lock = lockSchema.safeParse(parsed);
|
|
69
|
+
if (!lock.success || lock.data.port === undefined || !isAlive(lock.data.pid))
|
|
70
|
+
return undefined;
|
|
71
|
+
return `http://127.0.0.1:${String(lock.data.port)}`;
|
|
72
|
+
}
|
|
73
|
+
/** Finds the running kvman: `undefined` when no lock file, stale lock, or portless lock says one runs. */
|
|
74
|
+
export function findRunningKvman(options) {
|
|
75
|
+
if (options.url !== undefined)
|
|
76
|
+
return { baseUrl: baseUrlFromOption(options.url) };
|
|
77
|
+
const baseUrl = baseUrlFromLock(resolveHome(options.home));
|
|
78
|
+
return baseUrl === undefined ? undefined : { baseUrl };
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=running-kvman.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"running-kvman.js","sourceRoot":"","sources":["../../src/running/running-kvman.ts"],"names":[],"mappings":"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"}
|
package/docs/i18n.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Texts and languages
|
|
2
|
+
|
|
3
|
+
- Every text a person sees is a translation key. An extension ships `locales/<lang>.json` files: one flat JSON object of strings, with every key under its namespace, such as `{ "notes.pages.list": "Notes" }`. kvman's own extensions ship `en` and `ar`; ship at least those two.
|
|
4
|
+
- Placeholders use `{name}`: `"notes.count": "Notes: {count}"`, filled by a view's `params` or `kvman.t(key, { count })`.
|
|
5
|
+
- A key missing in a language falls back to `en`, then to the key itself.
|
|
6
|
+
|
|
7
|
+
## Conventions
|
|
8
|
+
|
|
9
|
+
| Key | Is |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `<namespace>.title` | the extension's display name |
|
|
12
|
+
| `<setting>.title` | a setting's short title, where the setting is shown |
|
|
13
|
+
| `<setting>.options.<value>` | the name of one choice of a setting whose value is one of a list of strings (otherwise the value shows) |
|
|
14
|
+
| `<name>.description` | a translated description of a setting, command, or query (else the English `description`) |
|
|
15
|
+
| `<command>.fields.<field>` | a form field's label (else the field's `.describe()` text) |
|
|
16
|
+
| `<namespace>.errors.<CODE>` | the text of an error code |
|
|
17
|
+
|
|
18
|
+
- `ar`, `he`, `fa`, and `ur` are right-to-left; styles use logical properties so pages mirror.
|
|
19
|
+
- Text sent to a model stays English.
|
|
20
|
+
- `npm run check` reports a key one catalog has and another lacks, every key your `ui.get` uses that is missing, and missing `<namespace>.title` and setting titles.
|
package/docs/presets.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Presets
|
|
2
|
+
|
|
3
|
+
A preset is the whole app for one run: which extensions load, and the preset-level value of any setting.
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{ "name": "notes-app",
|
|
7
|
+
"extensions": { "@kvman/kvai": "bundled", "@kvman/kvwebui": "bundled", "notes": "path:./notes" },
|
|
8
|
+
"settings": { "kvwebui.home": "notes.list", "kvwebui.title": "notes.app.title" } }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- **Sources.** `bundled` (kvai, kvwebui, kvcoder, kvcustomizer), `npm:<exact version>`, or `path:<folder>` relative to the preset file. A `path:` extension loads `kvman.source` with no build, and reloads on every save.
|
|
12
|
+
- **Settings.** Any registered key, checked against its schema once the extensions load. A key with no default must be set by the preset. `kvwebui.home` is required whenever kvwebui loads: a page with no params, such as `kvwebui.extensions`.
|
|
13
|
+
- **Running.** `kvman --preset ./notes-app.json` (a file, relative to the start folder) or `kvman --preset notes-app` for `<home>/presets/notes-app.json`. Non-bundled extensions ask to be trusted at start.
|
|
14
|
+
- `kvman-preset new` writes a preset that runs as-is; `kvman-preset check` checks its schema, its extensions, its settings, and its home page.
|
|
15
|
+
- `kvman-preview --preset notes-app.json` runs it in a separate kvman with a temporary home.
|
package/docs/sdk.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# The extension API (`@kvman/sdk`)
|
|
2
|
+
|
|
3
|
+
An extension is a package whose `package.json` has `main` and a `kvman` field, and lists `@kvman/sdk` as a peerDependency:
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{ "name": "notes", "version": "0.1.0", "type": "module", "main": "dist/index.js",
|
|
7
|
+
"peerDependencies": { "@kvman/sdk": "^0.1.0" },
|
|
8
|
+
"kvman": { "namespace": "notes", "source": "src/index.ts", "dependencies": {} } }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`src/index.ts` default-exports one function of `ctx`. It only registers; it runs once in every worker.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { z, type Ctx } from '@kvman/sdk';
|
|
15
|
+
|
|
16
|
+
const note = z.object({ text: z.string() });
|
|
17
|
+
|
|
18
|
+
export default (ctx: Ctx): void => {
|
|
19
|
+
ctx.registerCommand('notes.note.add', {
|
|
20
|
+
description: 'Adds a note to this workspace.',
|
|
21
|
+
public: true,
|
|
22
|
+
input: z.object({ text: z.string().describe('The note.') }),
|
|
23
|
+
output: z.object({ id: z.string() }),
|
|
24
|
+
handle: async (input) => ({ id: (await ctx.store.collection('notes', note).insert({ text: input.text })).id }),
|
|
25
|
+
});
|
|
26
|
+
ctx.registerQuery('notes.note.list', {
|
|
27
|
+
description: 'Lists the notes of this workspace.',
|
|
28
|
+
public: true,
|
|
29
|
+
input: z.object({ limit: z.number().int().max(1000).describe('How many to list.') }),
|
|
30
|
+
output: z.array(z.object({ id: z.string(), text: z.string() })),
|
|
31
|
+
handle: (input) => ctx.store.collection('notes', note).find({}, { limit: input.limit }),
|
|
32
|
+
});
|
|
33
|
+
ctx.registerSetting('notes.greeting', { description: 'The greeting shown above notes.', schema: z.string(), default: 'Hello' });
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Rules the kernel enforces at load
|
|
38
|
+
|
|
39
|
+
- Every name starts with `<namespace>.`, in lowercase kebab-case segments. A command ends in an imperative verb (`add`, `run`); a query ends in a read verb (`get`, `list`, `search`, `count`).
|
|
40
|
+
- Every registration has a one-sentence `description`. A name registered twice, or an invalid registration, stops the load with `EXTENSION_INVALID`.
|
|
41
|
+
- Use the SDK's `z` for every schema: inputs, outputs, and settings are validated at runtime.
|
|
42
|
+
- Registrations are private unless `public: true`. Forms, HTTP, connectors, and other extensions need `public: true`.
|
|
43
|
+
- Error codes of an extension are `<namespace>/UPPER_SNAKE`: `throw ctx.problem('notes/NOT_FOUND', { id })`.
|
|
44
|
+
|
|
45
|
+
## Jobs
|
|
46
|
+
|
|
47
|
+
- `ctx.exec(name, input)` runs a command or query now; `ctx.execAsync(name, input)` queues a command (it survives restarts and retries); `ctx.schedule(name, input, { at } | { cron })` runs one later.
|
|
48
|
+
- Commands may write; queries are read-only. Commands take `retries` (default 3) and `timeoutMs` (default 600 000).
|
|
49
|
+
- Inside a handler, `ctx.job` holds `{ id, rootId, workspace: { id, name, path }, caller, signal, progress(data) }`.
|
|
50
|
+
- A handler's work ends when it returns: no `setInterval`, unawaited `setTimeout`, or promise left running. Long work goes to `execAsync` or `ctx.schedule`; long-lived child processes to `ctx.processes`.
|
|
51
|
+
- Handlers keep no state in memory between jobs; anything that lasts goes in the store.
|
|
52
|
+
|
|
53
|
+
## Storage, settings, secrets
|
|
54
|
+
|
|
55
|
+
- `ctx.store.kv` and `ctx.store.collection(name, schema)` are per workspace; `ctx.store.global` is shared by every workspace. Every call returns a Promise; `ctx.store.transaction((tx) => …)` is synchronous.
|
|
56
|
+
- `ctx.settings.get(key)` reads any key; an extension sets only its own keys.
|
|
57
|
+
- `ctx.secrets.get/set/delete(name)` keep secrets in `secrets.json` only. Never log, store, or return a secret.
|
|
58
|
+
- `ctx.log.info(message, fields)` writes to the kvman log; never log payloads, settings values, or secrets.
|
|
59
|
+
|
|
60
|
+
## Handler points
|
|
61
|
+
|
|
62
|
+
`ctx.registerHandler(point, { description, handle })` runs a handler when the kernel reaches a point: `kernel.started`, `kernel.stopping`, `kernel.workspace.opened`, `kernel.job.failed`, `kernel.job.succeeded`, `kernel.job.cancelled`, or `kernel.process.exited`. There are no events or listeners beyond these.
|
|
63
|
+
|
|
64
|
+
## Typing calls to other extensions
|
|
65
|
+
|
|
66
|
+
An extension augments `Commands` and `Queries` of `@kvman/sdk` for its public names. A caller gets typed `ctx.exec` after `import type {} from '<that extension>'`, allowed only when it is a `kvman.dependencies` entry.
|
|
67
|
+
|
|
68
|
+
## Testing
|
|
69
|
+
|
|
70
|
+
`npm test` runs `node --test` with `@kvman/testkit`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const kernel = await createTestKernel({ extensions: ['./'], settings: { 'notes.greeting': 'Hi' } });
|
|
74
|
+
await kernel.exec('notes.note.add', { text: 'hi' }); // as the user
|
|
75
|
+
await kernel.exec('notes.note.add', { text: 'hi' }, { as: '@acme/other' }); // as an extension
|
|
76
|
+
await kernel.close();
|
|
77
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kvman/testkit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "createTestKernel: the real kvman kernel for extension tests.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ibndeif/kvman.git",
|
|
9
|
+
"directory": "packages/testkit"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ibndeif/kvman#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ibndeif/kvman/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"@kvman/source": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./fake-openai": {
|
|
23
|
+
"@kvman/source": "./src/fake-openai.ts",
|
|
24
|
+
"types": "./dist/fake-openai.d.ts",
|
|
25
|
+
"default": "./dist/fake-openai.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"kvman-check": "./dist/check-bin.js",
|
|
31
|
+
"kvman-docs": "./dist/docs/docs-bin.js",
|
|
32
|
+
"kvman-new": "./dist/new/new-bin.js",
|
|
33
|
+
"kvman-preset": "./dist/preset/preset-bin.js",
|
|
34
|
+
"kvman-preview": "./dist/preview/preview-bin.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"docs",
|
|
39
|
+
"templates"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@kvman/kernel": "0.1.0",
|
|
43
|
+
"@kvman/sdk": "0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# __NAME__
|
|
2
|
+
|
|
3
|
+
This folder is the kvman extension `__NAME__` (namespace `__NAMESPACE__`).
|
|
4
|
+
|
|
5
|
+
- Read `docs/` first: the platform guides `sdk.md`, `i18n.md`, and `presets.md`.
|
|
6
|
+
- For the docs of the extensions this one builds on, run `kvman-docs list` and `kvman-docs get <extension> <topic>`; they need a running kvman.
|
|
7
|
+
- Keep this extension's own docs in `extension-docs/`; they are served by `__NAMESPACE__.docs.list` and `__NAMESPACE__.docs.get`.
|
|
8
|
+
- After every change, run `npm run check` and `npm test`.
|
|
9
|
+
- `src/index.ts` is loaded directly by a kvman that has this folder as a `path:` extension, so there is no build step while developing.
|
|
10
|
+
- Never edit `dist/` or `node_modules/`.
|
|
11
|
+
- User-facing text is always a translation key with `en` and `ar` entries in `locales/`.
|
|
12
|
+
- Names are `<namespace>.<segment>`, lowercase kebab-case segments.
|
|
13
|
+
__WEB_AGENTS__
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# __NAME__
|
|
2
|
+
|
|
3
|
+
A kvman extension, namespace `__NAMESPACE__`.
|
|
4
|
+
|
|
5
|
+
- `src/index.ts` registers its commands, queries, and settings. A kvman that loads this folder as `path:` (a preset entry `"__NAME__": "path:<folder>"`) reloads it on every save, with no build.
|
|
6
|
+
- `locales/en.json` and `locales/ar.json` hold its texts.
|
|
7
|
+
- `extension-docs/usage.md` documents the extension for other builders, served by `__NAMESPACE__.docs.list` and `__NAMESPACE__.docs.get`.
|
|
8
|
+
- `AGENTS.md` guides an agent working here; `docs/` holds the platform guides (`sdk`, `i18n`, `presets`).
|
|
9
|
+
- `npm test` runs `test/` against a real kernel (`@kvman/testkit`).
|
|
10
|
+
- `npm run check` loads it in a test kernel and reports what kvman would refuse or show untranslated.
|
|
11
|
+
- `npm run build` compiles `dist/`, for publishing.
|
|
12
|
+
__WEB_README__
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Using __NAMESPACE__
|
|
2
|
+
|
|
3
|
+
__NAME__ is a kvman extension (namespace `__NAMESPACE__`). Its public queries are:
|
|
4
|
+
|
|
5
|
+
- `__NAMESPACE__.greeting.get` gives the greeting.
|
|
6
|
+
- `__NAMESPACE__.ui.get` gives its pages and navigation.
|
|
7
|
+
- `__NAMESPACE__.docs.list` lists these documentation pages.
|
|
8
|
+
- `__NAMESPACE__.docs.get` gives one page by topic.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { ProblemError } from '@kvman/sdk';
|
|
5
|
+
|
|
6
|
+
// The documentation pages of __NAMESPACE__ (plan 09 §9.5): the Markdown files in `extension-docs/`, served by
|
|
7
|
+
// `__NAMESPACE__.docs.list` and `__NAMESPACE__.docs.get`. The folder sits next to this module, from `src/` and
|
|
8
|
+
// from `dist/`.
|
|
9
|
+
|
|
10
|
+
const pagesFolder = fileURLToPath(new URL('../extension-docs/', import.meta.url));
|
|
11
|
+
|
|
12
|
+
const topicPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
13
|
+
|
|
14
|
+
function notFound(topic: string): ProblemError {
|
|
15
|
+
return new ProblemError({ code: 'NOT_FOUND', message: `There is no documentation page ${topic}.` });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function titleOf(topic: string, markdown: string): string {
|
|
19
|
+
for (const line of markdown.split('\n')) {
|
|
20
|
+
if (line.startsWith('# ')) return line.slice('# '.length);
|
|
21
|
+
}
|
|
22
|
+
return topic;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Lists the documentation pages, sorted by topic. */
|
|
26
|
+
export function listDocs(): { topic: string; title: string }[] {
|
|
27
|
+
return readdirSync(pagesFolder)
|
|
28
|
+
.filter((entry) => entry.endsWith('.md'))
|
|
29
|
+
.map((entry) => entry.slice(0, -'.md'.length))
|
|
30
|
+
.filter((topic) => topicPattern.test(topic))
|
|
31
|
+
.sort()
|
|
32
|
+
.map((topic) => ({ topic, title: titleOf(topic, readFileSync(path.join(pagesFolder, `${topic}.md`), 'utf8')) }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Gives one documentation page; an unknown or invalid topic is `NOT_FOUND`. */
|
|
36
|
+
export function getDoc(topic: string): { topic: string; title: string; markdown: string } {
|
|
37
|
+
if (!topicPattern.test(topic)) throw notFound(topic);
|
|
38
|
+
let markdown: string;
|
|
39
|
+
try {
|
|
40
|
+
markdown = readFileSync(path.join(pagesFolder, `${topic}.md`), 'utf8');
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw notFound(topic);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
return { topic, title: titleOf(topic, markdown), markdown };
|
|
46
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z, type Ctx } from '@kvman/sdk';
|
|
2
|
+
import { getDoc, listDocs } from './docs.ts';
|
|
3
|
+
|
|
4
|
+
// The __NAMESPACE__ extension. A kvman that loads this folder as `path:` reloads it on every save, with no build.
|
|
5
|
+
export default (ctx: Ctx): void => {
|
|
6
|
+
ctx.registerQuery('__NAMESPACE__.greeting.get', {
|
|
7
|
+
description: 'Gives the greeting.',
|
|
8
|
+
public: true,
|
|
9
|
+
input: z.object({}),
|
|
10
|
+
output: z.object({ text: z.string() }),
|
|
11
|
+
handle: () => ({ text: 'Hello from __NAMESPACE__!' }),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
ctx.registerQuery('__NAMESPACE__.docs.list', {
|
|
15
|
+
description: 'Lists the documentation pages of __NAMESPACE__.',
|
|
16
|
+
public: true,
|
|
17
|
+
input: z.object({}),
|
|
18
|
+
output: z.array(z.object({ topic: z.string(), title: z.string() })),
|
|
19
|
+
handle: () => listDocs(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
ctx.registerQuery('__NAMESPACE__.docs.get', {
|
|
23
|
+
description: 'Gives one documentation page of __NAMESPACE__.',
|
|
24
|
+
public: true,
|
|
25
|
+
input: z.object({ topic: z.string().describe('The page topic, such as usage.') }),
|
|
26
|
+
output: z.object({ topic: z.string(), title: z.string(), markdown: z.string() }),
|
|
27
|
+
handle: (input) => getDoc(input.topic),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
ctx.registerQuery('__NAMESPACE__.ui.get', {
|
|
31
|
+
description: 'Gives the pages and navigation of __NAMESPACE__.',
|
|
32
|
+
public: true,
|
|
33
|
+
input: z.object({}),
|
|
34
|
+
output: z.json(),
|
|
35
|
+
handle: () => ({
|
|
36
|
+
pages: [{ id: 'hello', title: '__NAMESPACE__.pages.hello', view: __PAGE_VIEW__ }],
|
|
37
|
+
nav: [{ id: 'hello', page: 'hello', title: '__NAMESPACE__.pages.hello', icon: 'puzzle', order: 50 }],
|
|
38
|
+
panels: [],
|
|
39
|
+
status: [],
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { z } from '@kvman/sdk';
|
|
4
|
+
import { createTestKernel } from '@kvman/testkit';
|
|
5
|
+
|
|
6
|
+
const pageSchema = z.object({ topic: z.string(), title: z.string(), markdown: z.string() });
|
|
7
|
+
|
|
8
|
+
test('__NAMESPACE__.greeting.get gives the greeting', async () => {
|
|
9
|
+
const kernel = await createTestKernel({ extensions: ['./'] });
|
|
10
|
+
try {
|
|
11
|
+
assert.deepEqual(await kernel.exec('__NAMESPACE__.greeting.get', {}), { text: 'Hello from __NAMESPACE__!' });
|
|
12
|
+
} finally {
|
|
13
|
+
await kernel.close();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('__NAMESPACE__.docs.list and __NAMESPACE__.docs.get serve the usage page', async () => {
|
|
18
|
+
const kernel = await createTestKernel({ extensions: ['./'] });
|
|
19
|
+
try {
|
|
20
|
+
assert.deepEqual(await kernel.exec('__NAMESPACE__.docs.list', {}), [{ topic: 'usage', title: 'Using __NAMESPACE__' }]);
|
|
21
|
+
const page = pageSchema.parse(await kernel.exec('__NAMESPACE__.docs.get', { topic: 'usage' }));
|
|
22
|
+
assert.equal(page.topic, 'usage');
|
|
23
|
+
assert.equal(page.title, 'Using __NAMESPACE__');
|
|
24
|
+
assert.ok(page.markdown.startsWith('# Using __NAMESPACE__'));
|
|
25
|
+
await assert.rejects(kernel.exec('__NAMESPACE__.docs.get', { topic: 'no-such-page' }));
|
|
26
|
+
} finally {
|
|
27
|
+
await kernel.close();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { inject, ref } from 'vue';
|
|
3
|
+
import type { Kvman } from '@kvman/sdk/web';
|
|
4
|
+
|
|
5
|
+
// A custom component (kvman plan 06 §6.4): kvwebui's CSS variables and logical properties for its look, and the
|
|
6
|
+
// injected kvman for its texts.
|
|
7
|
+
const kvman = inject<Kvman>('kvman');
|
|
8
|
+
const count = ref(0);
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div class="hello">
|
|
13
|
+
<p class="hello-text">{{ kvman?.t('__NAMESPACE__.hello.text') }}</p>
|
|
14
|
+
<button type="button" class="hello-button" @click="count += 1">{{ kvman?.t('__NAMESPACE__.hello.count', { count }) }}</button>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.hello {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: var(--kv-space-md);
|
|
23
|
+
padding: var(--kv-space-md);
|
|
24
|
+
border: 1px solid var(--kv-color-border);
|
|
25
|
+
border-radius: var(--kv-radius);
|
|
26
|
+
background: var(--kv-color-surface);
|
|
27
|
+
color: var(--kv-color-text);
|
|
28
|
+
}
|
|
29
|
+
.hello-text {
|
|
30
|
+
margin: 0;
|
|
31
|
+
flex: 1 1 auto;
|
|
32
|
+
}
|
|
33
|
+
.hello-button {
|
|
34
|
+
padding-block: var(--kv-space-sm);
|
|
35
|
+
padding-inline: var(--kv-space-md);
|
|
36
|
+
border: 0;
|
|
37
|
+
border-radius: var(--kv-radius);
|
|
38
|
+
background: var(--kv-color-primary);
|
|
39
|
+
color: var(--kv-color-on-primary);
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import vue from '@vitejs/plugin-vue';
|
|
3
|
+
import { build } from 'vite';
|
|
4
|
+
|
|
5
|
+
// Each web/components/<Name>.vue builds on its own into dist/web/components/<name>.js and <name>.css, the two files
|
|
6
|
+
// kvwebui loads for the custom component `__NAMESPACE__.<name>`; `vue` comes from kvwebui's import map. With
|
|
7
|
+
// `--watch` (npm run web:watch), each rebuilds when it changes, and a page refresh shows it.
|
|
8
|
+
const watch = process.argv.includes('--watch');
|
|
9
|
+
const kebab = (name: string): string => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
10
|
+
|
|
11
|
+
if (!watch) rmSync('dist/web', { recursive: true, force: true });
|
|
12
|
+
for (const file of readdirSync('web/components').filter((entry) => entry.endsWith('.vue'))) {
|
|
13
|
+
const name = kebab(file.slice(0, -'.vue'.length));
|
|
14
|
+
await build({
|
|
15
|
+
configFile: false,
|
|
16
|
+
logLevel: 'warn',
|
|
17
|
+
plugins: [vue()],
|
|
18
|
+
build: {
|
|
19
|
+
outDir: 'dist/web/components',
|
|
20
|
+
emptyOutDir: false,
|
|
21
|
+
watch: watch ? {} : null,
|
|
22
|
+
lib: { entry: `web/components/${file}`, formats: ['es'], fileName: () => `${name}.js`, cssFileName: name },
|
|
23
|
+
rolldownOptions: { external: ['vue'] },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|