ketatlas 0.2.5 → 0.4.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/CHANGELOG.md +13 -0
- package/README.md +51 -22
- package/bin/audit.js +15 -4
- package/bin/discovery.js +90 -0
- package/bin/ketatlas.js +60 -22
- package/bin/renderer.js +189 -0
- package/bin/viewer.js +39 -7
- package/docs/agent-mockups.md +22 -12
- package/docs/architecture.md +24 -15
- package/docs/authoring.md +43 -5
- package/docs/cli.md +38 -17
- package/docs/configuration.md +21 -2
- package/docs/integration.md +4 -1
- package/docs/migration.md +19 -0
- package/docs/progress.md +5 -5
- package/package.json +9 -6
- package/renderer.schema.json +44 -0
- package/skills/ketatlas/SKILL.md +96 -16
- package/src/config.js +3 -3
- package/src/index.d.ts +2 -0
- package/src/index.js +33 -15
- package/templates/basic/README.md +6 -6
- package/templates/process/README.md +5 -5
- package/templates/web/README.md +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Add an opt-in two-port native renderer contract: KetAtlas serves the viewer while the declared React, Vue, KetJS, or other framework server owns screen HTML, assets, and same-origin behavior.
|
|
6
|
+
- Add `atlas.renderer.json`, its public schema, command-array validation, readiness checks, predictable renderer shutdown, and route-aware static audit.
|
|
7
|
+
- Add `screenBaseURL` to the browser API so screen routes can resolve on a different origin while external references continue to resolve beside `atlas.json`.
|
|
8
|
+
- Require the bundled agent skill to use the product's real framework and design-system components, share presenters/styles across atlases, and keep business data outside presentation components.
|
|
9
|
+
|
|
10
|
+
## 0.3.0
|
|
11
|
+
|
|
12
|
+
- Standardize discoverable projects as `<name>.ketatlas/atlas.json` bundles, add `discover`, accept bundle directories in Atlas commands, and teach the bundled skill to backfill legacy projects safely.
|
|
13
|
+
- Accelerate drag, wheel, keyboard and pinch camera controls while preserving pointer-anchored zoom.
|
|
14
|
+
- Require agents to confirm a design system before visual mockup work, with Auto defaulting to Két Design System and explicit Carbon, Primer, Fluent 2, no-system, and custom-source options.
|
|
15
|
+
|
|
3
16
|
## 0.2.5
|
|
4
17
|
|
|
5
18
|
- Show separate Checks and Implemented percentage badges per workflow. Implemented includes Verified screens; project verification remains separate on the top bar. Display a dash for Checks when no checklist is recorded.
|
package/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# KetAtlas
|
|
2
2
|
|
|
3
|
-
**Scaffold, serve, and audit interactive
|
|
3
|
+
**Scaffold, serve, and audit interactive framework-native workflow maps.**
|
|
4
4
|
|
|
5
|
-
Turn a JSON file and your existing HTML
|
|
5
|
+
Turn a JSON file and screens from your existing HTML, React, Vue, KetJS, or other framework into a canvas you can drag, zoom, and explore. Connect screens with labelled arrows, model decisions and recovery paths, and open the real rendered page to try a step. The same tool supports mobile screens, web pages, and processes with no screens at all.
|
|
6
6
|
|
|
7
|
-
KetAtlas has an English UI
|
|
7
|
+
KetAtlas has an English UI and zero runtime npm dependencies. Node.js **22+** is needed for the CLI. Static projects need no build step. Framework-native projects reuse their product's own server and dependencies; KetAtlas supervises it on a separate localhost port.
|
|
8
8
|
|
|
9
9
|
## Demo
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Open the live map at **[atlas.ketsuite.com](https://atlas.ketsuite.com)**: drag, zoom, follow the
|
|
12
|
+
labelled arrows, and open a real HTML prototype from any screen. Nothing to install.
|
|
13
|
+
|
|
14
|
+
The same map recorded as a video, for a quick look:
|
|
12
15
|
|
|
13
16
|
https://github.com/user-attachments/assets/13f24fc8-7b8e-4bda-9e02-364c120ee163
|
|
14
17
|
|
|
@@ -17,25 +20,26 @@ https://github.com/user-attachments/assets/13f24fc8-7b8e-4bda-9e02-364c120ee163
|
|
|
17
20
|
[KetAtlas is available on npm](https://www.npmjs.com/package/ketatlas). Use Node.js 22+ and run it from any directory:
|
|
18
21
|
|
|
19
22
|
```sh
|
|
20
|
-
npx --yes ketatlas
|
|
21
|
-
npx --yes ketatlas
|
|
22
|
-
npx --yes ketatlas
|
|
23
|
+
npx --yes ketatlas scaffold my-atlas.ketatlas --template web
|
|
24
|
+
npx --yes ketatlas discover . --json
|
|
25
|
+
npx --yes ketatlas serve my-atlas.ketatlas
|
|
26
|
+
npx --yes ketatlas audit my-atlas.ketatlas --strict
|
|
23
27
|
```
|
|
24
28
|
|
|
25
29
|
Or install the CLI once for your user account:
|
|
26
30
|
|
|
27
31
|
```sh
|
|
28
|
-
npm install --global ketatlas
|
|
29
|
-
ketatlas scaffold my-atlas
|
|
30
|
-
ketatlas serve my-atlas
|
|
31
|
-
ketatlas audit my-atlas
|
|
32
|
+
npm install --global ketatlas
|
|
33
|
+
ketatlas scaffold my-atlas.ketatlas
|
|
34
|
+
ketatlas serve my-atlas.ketatlas
|
|
35
|
+
ketatlas audit my-atlas.ketatlas --strict
|
|
32
36
|
```
|
|
33
37
|
|
|
34
|
-
The consumer
|
|
38
|
+
The consumer bundle is named `<name>.ketatlas/` and contains `atlas.json`, its schema, documentation, and either static screen assets or an `atlas.renderer.json` sidecar. The stable directory suffix lets desktop tools discover atlases without parsing unrelated JSON. A bundle needs no KetAtlas package manifest, lockfile, `node_modules`, build step, or copy of the viewer. Framework dependencies and reusable UI stay in the product workspace, where multiple atlases can share one set of presenters, components, fixtures, and styles. The CLI supplies discovery, serving, validation, and audit. `audit` leaves project files unchanged unless an output file is explicitly requested. Viewing with `serve` does not write; explicit **Save progress** writes the sibling progress file. Use `--read-only` to disable editing.
|
|
35
39
|
|
|
36
|
-
Pin the version in run commands or the global installation for reproducible team workflows. Product scripts implement mock screen interactions; they are authored content, not a local installation of KetAtlas.
|
|
40
|
+
Pin the version in run commands or the global installation for reproducible team workflows. Product scripts implement mock screen interactions; they are authored content, not a local installation of KetAtlas. A native renderer reuses the product's framework tooling and shared UI source rather than copying markup into each atlas.
|
|
37
41
|
|
|
38
|
-
Edit the JSON and screen
|
|
42
|
+
Edit the JSON and product screen source, then refresh the browser. The default viewer address is **http://127.0.0.1:4178**.
|
|
39
43
|
|
|
40
44
|
## Create mockups with an agent
|
|
41
45
|
|
|
@@ -45,7 +49,31 @@ Install the KetAtlas skill in your product project:
|
|
|
45
49
|
npx skills add ketvietlab/ketatlas --skill ketatlas
|
|
46
50
|
```
|
|
47
51
|
|
|
48
|
-
Ask your agent to use the skill with a product brief: requested flows, target platforms, design references, and output directory. The agent creates
|
|
52
|
+
Ask your agent to use the skill with a product brief: requested flows, target platforms, design references, and output directory. The agent creates native framework routes (or static HTML for a static product), a version 1 `atlas.json`, and run instructions, then audits the result.
|
|
53
|
+
|
|
54
|
+
## Render with the product framework
|
|
55
|
+
|
|
56
|
+
KetAtlas 0.4.0 keeps the viewer and product renderer separate. Put `atlas.renderer.json` beside `atlas.json` when screens come from a framework:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"$schema": "https://unpkg.com/ketatlas@0.4.0/renderer.schema.json",
|
|
61
|
+
"version": 1,
|
|
62
|
+
"framework": "vue",
|
|
63
|
+
"command": ["npm", "run", "atlas:serve", "--", "--host", "{host}", "--port", "{port}"],
|
|
64
|
+
"cwd": "../..",
|
|
65
|
+
"readyPath": "/__atlas/ready",
|
|
66
|
+
"screenBasePath": "/__atlas/orders/"
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then run:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
npx --yes ketatlas@0.4.0 serve ./tasks/orders.ketatlas --renderer --port 60550 --html-port 60551
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The first port serves the map and progress API. The second is owned by the declared React/Vue/KetJS/etc. server and serves screen routes. `--renderer` is explicit because it executes the local command array. Static projects continue to use the one-port command without this flag. See [Native renderers](docs/authoring.md#framework-native-screens).
|
|
49
77
|
|
|
50
78
|
See [Agent mockups](docs/agent-mockups.md) for installation options, a ready-to-use request, and a reusable brief template. Agents without skill support can read the [single skill file](skills/ketatlas/SKILL.md) directly.
|
|
51
79
|
|
|
@@ -81,12 +109,13 @@ Open **Screens** to filter screen progress, review blockers and edit acceptance
|
|
|
81
109
|
|
|
82
110
|
## Commands
|
|
83
111
|
|
|
84
|
-
| Command
|
|
85
|
-
|
|
|
86
|
-
| `scaffold <
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
112
|
+
| Command | Purpose |
|
|
113
|
+
| -------------------------- | -------------------------------------------------------------------------------- |
|
|
114
|
+
| `scaffold <name.ketatlas>` | Create a bundle from `basic`, `web`, or `process`. Refuses existing content. |
|
|
115
|
+
| `discover <directory>` | Find valid `*.ketatlas/atlas.json` bundles and report invalid bundles. |
|
|
116
|
+
| `serve <bundle\|json>` | Start the viewer; optionally supervise a native renderer on a second port. |
|
|
117
|
+
| `audit <bundle\|json>` | Check configuration, reachability, local files, and literal HTML/CSS references. |
|
|
118
|
+
| `validate <bundle\|json>` | Validate configuration only, without reading screen files. |
|
|
90
119
|
|
|
91
120
|
Use `--help` for options, `--root` when assets live above the JSON directory, and `audit --json` for CI reports. [Full CLI reference →](docs/cli.md)
|
|
92
121
|
|
|
@@ -114,7 +143,7 @@ npm ci
|
|
|
114
143
|
npm run dev
|
|
115
144
|
```
|
|
116
145
|
|
|
117
|
-
The playground includes mobile sign-in, desktop approval, and a fulfilment process. These development dependencies are not required in consumer projects. To try an unpublished checkout, use `npx --yes --package ~/dev/ketatlas ketatlas serve /path/to/my-atlas
|
|
146
|
+
The playground includes mobile sign-in, desktop approval, and a fulfilment process. These development dependencies are not required in consumer projects. To try an unpublished checkout, use `npx --yes --package ~/dev/ketatlas ketatlas serve /path/to/my-atlas.ketatlas`.
|
|
118
147
|
|
|
119
148
|
Stable version increases merged into `develop` are automatically published after CI verifies the package. See [Releasing](docs/releasing.md).
|
|
120
149
|
|
package/bin/audit.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile, realpath, stat } from "node:fs/promises";
|
|
|
3
3
|
import { resolve, dirname, relative, sep, extname } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { validateAtlas } from "../src/config.js";
|
|
6
|
+
import { readRendererConfig, rendererFileName } from "./renderer.js";
|
|
6
7
|
const within = (root, path) => {
|
|
7
8
|
const r = relative(root, path);
|
|
8
9
|
return r === "" || (!r.startsWith(".." + sep) && r !== ".." && !r.startsWith(sep));
|
|
@@ -29,6 +30,12 @@ export async function auditAtlas(file, { root: rootOption } = {}) {
|
|
|
29
30
|
warnings = [...result.warnings],
|
|
30
31
|
checked = new Set(),
|
|
31
32
|
remote = new Set();
|
|
33
|
+
let renderer;
|
|
34
|
+
try {
|
|
35
|
+
renderer = await readRendererConfig(resolve(dirname(absolute), rendererFileName));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== "ENOENT") errors.push({ path: rendererFileName, message: error.message });
|
|
38
|
+
}
|
|
32
39
|
if (!within(root, absolute))
|
|
33
40
|
errors.push({ path: "file", message: "The atlas JSON must be inside the serve root." });
|
|
34
41
|
async function inspect(value, from, label) {
|
|
@@ -90,9 +97,12 @@ export async function auditAtlas(file, { root: rootOption } = {}) {
|
|
|
90
97
|
}
|
|
91
98
|
}
|
|
92
99
|
if (result.valid) {
|
|
93
|
-
|
|
100
|
+
if (!renderer)
|
|
101
|
+
for (const s of config.screens || []) await inspect(s.url, absolute, `screen:${s.id}`);
|
|
94
102
|
for (const f of config.flows)
|
|
95
|
-
for (const n of f.nodes)
|
|
103
|
+
for (const n of f.nodes)
|
|
104
|
+
if (n.url && (!renderer || (n.type || (n.screen ? "screen" : "note")) !== "screen"))
|
|
105
|
+
await inspect(n.url, absolute, `node:${f.id}/${n.id}`);
|
|
96
106
|
}
|
|
97
107
|
if (result.valid) {
|
|
98
108
|
try {
|
|
@@ -118,7 +128,8 @@ export async function auditAtlas(file, { root: rootOption } = {}) {
|
|
|
118
128
|
},
|
|
119
129
|
errors,
|
|
120
130
|
warnings,
|
|
121
|
-
scope:
|
|
122
|
-
|
|
131
|
+
scope: renderer
|
|
132
|
+
? `Configuration and ${renderer.data.framework} renderer contract. The renderer command, routes, JavaScript behavior and embedding are not executed.`
|
|
133
|
+
: "Static configuration and literal local HTML/CSS references. JavaScript behavior and remote embedding policies are not executed.",
|
|
123
134
|
};
|
|
124
135
|
}
|
package/bin/discovery.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { validateAtlas } from "../src/config.js";
|
|
4
|
+
|
|
5
|
+
export const atlasDirectorySuffix = ".ketatlas";
|
|
6
|
+
export const atlasManifestName = "atlas.json";
|
|
7
|
+
|
|
8
|
+
const excludedDirectories = new Set([
|
|
9
|
+
".git",
|
|
10
|
+
".cache",
|
|
11
|
+
".next",
|
|
12
|
+
".venv",
|
|
13
|
+
"build",
|
|
14
|
+
"dist",
|
|
15
|
+
"node_modules",
|
|
16
|
+
"vendor",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export function isAtlasDirectory(path) {
|
|
20
|
+
return basename(resolve(path)).toLowerCase().endsWith(atlasDirectorySuffix);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function requireAtlasDirectory(path) {
|
|
24
|
+
const destination = resolve(path);
|
|
25
|
+
if (!isAtlasDirectory(destination))
|
|
26
|
+
throw new Error(`Atlas directories must end in ${atlasDirectorySuffix}.`);
|
|
27
|
+
return destination;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function resolveAtlasFile(target) {
|
|
31
|
+
const absolute = resolve(target);
|
|
32
|
+
const info = await stat(absolute);
|
|
33
|
+
if (!info.isDirectory()) return absolute;
|
|
34
|
+
if (!isAtlasDirectory(absolute))
|
|
35
|
+
throw new Error(`Atlas directories must end in ${atlasDirectorySuffix}.`);
|
|
36
|
+
return join(absolute, atlasManifestName);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function describe(directory) {
|
|
40
|
+
const path = join(directory, atlasManifestName);
|
|
41
|
+
let config;
|
|
42
|
+
try {
|
|
43
|
+
config = JSON.parse(await readFile(path, "utf8"));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { error: { path, message: error.message } };
|
|
46
|
+
}
|
|
47
|
+
const validation = validateAtlas(config);
|
|
48
|
+
if (!validation.valid)
|
|
49
|
+
return {
|
|
50
|
+
error: {
|
|
51
|
+
path,
|
|
52
|
+
message: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; "),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
atlas: {
|
|
57
|
+
path,
|
|
58
|
+
directory,
|
|
59
|
+
title: config.title,
|
|
60
|
+
screens: (config.screens || []).map(({ id, title, url }) => ({ id, title, url })),
|
|
61
|
+
flowCount: config.flows.length,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function discoverAtlases(root) {
|
|
67
|
+
const workspace = await realpath(resolve(root));
|
|
68
|
+
if (!(await stat(workspace)).isDirectory())
|
|
69
|
+
throw new Error("Discovery root must be a directory.");
|
|
70
|
+
const atlases = [],
|
|
71
|
+
errors = [],
|
|
72
|
+
directories = [workspace];
|
|
73
|
+
while (directories.length) {
|
|
74
|
+
const directory = directories.pop();
|
|
75
|
+
if (isAtlasDirectory(directory)) {
|
|
76
|
+
const result = await describe(directory);
|
|
77
|
+
if (result.atlas) atlases.push(result.atlas);
|
|
78
|
+
else errors.push(result.error);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
82
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || excludedDirectories.has(entry.name))
|
|
83
|
+
continue;
|
|
84
|
+
directories.push(join(directory, entry.name));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
atlases.sort((a, b) => a.path.localeCompare(b.path));
|
|
88
|
+
errors.sort((a, b) => a.path.localeCompare(b.path));
|
|
89
|
+
return { version: 1, root: workspace, atlases, errors };
|
|
90
|
+
}
|
package/bin/ketatlas.js
CHANGED
|
@@ -8,23 +8,31 @@ import { validateAtlas } from "../src/config.js";
|
|
|
8
8
|
import { serve } from "./server.js";
|
|
9
9
|
import { serveAtlas } from "./viewer.js";
|
|
10
10
|
import { auditAtlas } from "./audit.js";
|
|
11
|
+
import {
|
|
12
|
+
discoverAtlases,
|
|
13
|
+
isAtlasDirectory,
|
|
14
|
+
requireAtlasDirectory,
|
|
15
|
+
resolveAtlasFile,
|
|
16
|
+
} from "./discovery.js";
|
|
11
17
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
-
const help = `KetAtlas — scaffold, serve, and audit
|
|
18
|
+
const help = `KetAtlas — scaffold, serve, and audit framework-native workflow maps
|
|
13
19
|
|
|
14
|
-
ketatlas scaffold <
|
|
15
|
-
ketatlas
|
|
16
|
-
ketatlas
|
|
17
|
-
ketatlas
|
|
18
|
-
ketatlas
|
|
19
|
-
ketatlas progress <atlas.json> --
|
|
20
|
+
ketatlas scaffold <name.ketatlas> [--template basic|web|process]
|
|
21
|
+
ketatlas discover <directory> [--json]
|
|
22
|
+
ketatlas serve <name.ketatlas|atlas.json> [--port 4178] [--root directory] [--renderer] [--html-port 4179]
|
|
23
|
+
ketatlas audit <name.ketatlas|atlas.json> [--root directory] [--json] [--strict]
|
|
24
|
+
ketatlas validate <name.ketatlas|atlas.json>
|
|
25
|
+
ketatlas progress <name.ketatlas|atlas.json> [--json] [--init]
|
|
26
|
+
ketatlas progress <name.ketatlas|atlas.json> --set <screen-id> --record <record.json> --expect <revision>
|
|
20
27
|
ketatlas --version
|
|
21
28
|
|
|
22
29
|
Examples:
|
|
23
|
-
npx ketatlas scaffold my-atlas
|
|
24
|
-
npx ketatlas
|
|
25
|
-
npx ketatlas
|
|
30
|
+
npx ketatlas scaffold my-atlas.ketatlas
|
|
31
|
+
npx ketatlas discover . --json
|
|
32
|
+
npx ketatlas serve my-atlas.ketatlas
|
|
33
|
+
npx ketatlas audit my-atlas.ketatlas --strict
|
|
26
34
|
|
|
27
|
-
Serve binds to 127.0.0.1.
|
|
35
|
+
Serve binds to 127.0.0.1. Static screens need no build; --renderer starts the project's declared framework server.
|
|
28
36
|
`;
|
|
29
37
|
function parse(args, allowed) {
|
|
30
38
|
const options = {},
|
|
@@ -61,7 +69,7 @@ async function main(args) {
|
|
|
61
69
|
template = options.template || "basic";
|
|
62
70
|
if (!["basic", "web", "process"].includes(template))
|
|
63
71
|
throw new Error("Templates: basic, web, process.");
|
|
64
|
-
const destination =
|
|
72
|
+
const destination = requireAtlasDirectory(target);
|
|
65
73
|
let existing;
|
|
66
74
|
try {
|
|
67
75
|
existing = await readdir(destination);
|
|
@@ -79,10 +87,25 @@ async function main(args) {
|
|
|
79
87
|
errorOnExist: true,
|
|
80
88
|
});
|
|
81
89
|
console.log(
|
|
82
|
-
`Created ${destination}\n\nNext: npx ketatlas serve "${
|
|
90
|
+
`Created ${destination}\n\nNext: npx ketatlas serve "${destination}"\nEdit atlas.json to make it yours.`,
|
|
83
91
|
);
|
|
84
92
|
return;
|
|
85
93
|
}
|
|
94
|
+
if (command === "discover") {
|
|
95
|
+
const { target, options } = parse(rest, { json: "boolean" }),
|
|
96
|
+
result = await discoverAtlases(target);
|
|
97
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
98
|
+
else {
|
|
99
|
+
for (const atlas of result.atlases)
|
|
100
|
+
console.log(`${atlas.title}\t${atlas.path}\t${atlas.screens.length} screens`);
|
|
101
|
+
for (const error of result.errors) console.error(`ERROR ${error.path}: ${error.message}`);
|
|
102
|
+
console.log(
|
|
103
|
+
`${result.atlases.length} atlas${result.atlases.length === 1 ? "" : "es"} in ${result.root}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (result.errors.length) process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
86
109
|
if (command === "audit") {
|
|
87
110
|
const { target, options } = parse(rest, {
|
|
88
111
|
root: "string",
|
|
@@ -90,7 +113,7 @@ async function main(args) {
|
|
|
90
113
|
strict: "boolean",
|
|
91
114
|
output: "string",
|
|
92
115
|
});
|
|
93
|
-
const report = await auditAtlas(target, options),
|
|
116
|
+
const report = await auditAtlas(await resolveAtlasFile(target), options),
|
|
94
117
|
pass = report.valid && (!options.strict || report.warnings.length === 0);
|
|
95
118
|
if (options.output)
|
|
96
119
|
await writeFile(resolve(options.output), JSON.stringify(report, null, 2) + "\n", {
|
|
@@ -109,7 +132,7 @@ async function main(args) {
|
|
|
109
132
|
}
|
|
110
133
|
if (command === "validate") {
|
|
111
134
|
const { target } = parse(rest, {}),
|
|
112
|
-
config = JSON.parse(await readFile(
|
|
135
|
+
config = JSON.parse(await readFile(await resolveAtlasFile(target), "utf8")),
|
|
113
136
|
result = validateAtlas(config);
|
|
114
137
|
for (const issue of result.errors) console.error(`ERROR ${issue.path}: ${issue.message}`);
|
|
115
138
|
for (const issue of result.warnings) console.warn(`WARN ${issue.path}: ${issue.message}`);
|
|
@@ -130,7 +153,7 @@ async function main(args) {
|
|
|
130
153
|
record: "string",
|
|
131
154
|
expect: "string",
|
|
132
155
|
});
|
|
133
|
-
const file =
|
|
156
|
+
const file = await resolveAtlasFile(target),
|
|
134
157
|
atlas = JSON.parse(await readFile(file, "utf8"));
|
|
135
158
|
const valid = validateAtlas(atlas);
|
|
136
159
|
if (!valid.valid) throw new Error(valid.errors.map((e) => e.message).join("; "));
|
|
@@ -173,22 +196,37 @@ async function main(args) {
|
|
|
173
196
|
port: "string",
|
|
174
197
|
root: "string",
|
|
175
198
|
"read-only": "boolean",
|
|
199
|
+
renderer: "boolean",
|
|
200
|
+
"html-port": "string",
|
|
176
201
|
}),
|
|
177
|
-
port = Number(options.port || 4178)
|
|
202
|
+
port = Number(options.port || 4178),
|
|
203
|
+
htmlPort = Number(options["html-port"] || (port < 65535 ? port + 1 : 0));
|
|
178
204
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
179
205
|
throw new Error("Port must be 1–65535.");
|
|
206
|
+
if (options.renderer && (!Number.isInteger(htmlPort) || htmlPort < 1 || htmlPort > 65535))
|
|
207
|
+
throw new Error("HTML port must be 1–65535.");
|
|
208
|
+
if (options.renderer && htmlPort === port)
|
|
209
|
+
throw new Error("Viewer and HTML ports must be different.");
|
|
180
210
|
const directory = (await stat(resolve(target))).isDirectory();
|
|
181
211
|
if (directory && options.root)
|
|
182
212
|
throw new Error("--root is only needed when serving an atlas JSON file.");
|
|
183
|
-
const server =
|
|
184
|
-
|
|
185
|
-
|
|
213
|
+
const server =
|
|
214
|
+
directory && !isAtlasDirectory(target)
|
|
215
|
+
? await serve(target, { port })
|
|
216
|
+
: await serveAtlas(await resolveAtlasFile(target), {
|
|
217
|
+
port,
|
|
218
|
+
root: options.root,
|
|
219
|
+
readOnly: options["read-only"],
|
|
220
|
+
renderer: options.renderer,
|
|
221
|
+
htmlPort,
|
|
222
|
+
});
|
|
186
223
|
console.log(
|
|
187
|
-
`KetAtlas: http://127.0.0.1:${server.address().port}\nServing ${resolve(target)}\nPress Ctrl+C to stop.`,
|
|
224
|
+
`KetAtlas: http://127.0.0.1:${server.address().port}${server.htmlOrigin ? `\nHTML: ${server.htmlOrigin}` : ""}\nServing ${resolve(target)}\nPress Ctrl+C to stop.`,
|
|
188
225
|
);
|
|
189
|
-
const close = () => {
|
|
226
|
+
const close = async () => {
|
|
190
227
|
server.close();
|
|
191
228
|
server.closeAllConnections();
|
|
229
|
+
await server.closeRenderer?.();
|
|
192
230
|
};
|
|
193
231
|
process.once("SIGINT", close);
|
|
194
232
|
process.once("SIGTERM", close);
|
package/bin/renderer.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
const text = (value) => typeof value === "string" && value.trim().length > 0;
|
|
8
|
+
|
|
9
|
+
export const rendererFileName = "atlas.renderer.json";
|
|
10
|
+
|
|
11
|
+
export function validateRendererConfig(input) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
const error = (path, message) => errors.push({ path, message });
|
|
14
|
+
if (!object(input))
|
|
15
|
+
return {
|
|
16
|
+
valid: false,
|
|
17
|
+
errors: [{ path: "$", message: "Expected a renderer object." }],
|
|
18
|
+
};
|
|
19
|
+
for (const key of Object.keys(input))
|
|
20
|
+
if (
|
|
21
|
+
![
|
|
22
|
+
"$schema",
|
|
23
|
+
"version",
|
|
24
|
+
"framework",
|
|
25
|
+
"command",
|
|
26
|
+
"cwd",
|
|
27
|
+
"readyPath",
|
|
28
|
+
"screenBasePath",
|
|
29
|
+
"readyTimeoutMs",
|
|
30
|
+
].includes(key)
|
|
31
|
+
)
|
|
32
|
+
error(key, "Unknown property.");
|
|
33
|
+
if (input.version !== 1) error("version", "Expected version: 1.");
|
|
34
|
+
if (!text(input.framework)) error("framework", "Expected a non-empty framework name.");
|
|
35
|
+
if (!Array.isArray(input.command) || input.command.length === 0)
|
|
36
|
+
error("command", "Expected a non-empty command array.");
|
|
37
|
+
else
|
|
38
|
+
for (const [index, value] of input.command.entries())
|
|
39
|
+
if (!text(value)) error(`command[${index}]`, "Expected a non-empty argument.");
|
|
40
|
+
if (input.$schema !== undefined && typeof input.$schema !== "string")
|
|
41
|
+
error("$schema", "Expected text.");
|
|
42
|
+
if (input.cwd !== undefined && !text(input.cwd)) error("cwd", "Expected a directory path.");
|
|
43
|
+
for (const key of ["readyPath", "screenBasePath"])
|
|
44
|
+
if (input[key] !== undefined && (!text(input[key]) || !input[key].startsWith("/")))
|
|
45
|
+
error(key, "Expected an origin-relative path beginning with /.");
|
|
46
|
+
if (input.screenBasePath !== undefined && !input.screenBasePath.endsWith("/"))
|
|
47
|
+
error("screenBasePath", "Expected a path ending with /.");
|
|
48
|
+
if (
|
|
49
|
+
input.readyTimeoutMs !== undefined &&
|
|
50
|
+
(!Number.isInteger(input.readyTimeoutMs) ||
|
|
51
|
+
input.readyTimeoutMs < 100 ||
|
|
52
|
+
input.readyTimeoutMs > 120000)
|
|
53
|
+
)
|
|
54
|
+
error("readyTimeoutMs", "Expected an integer from 100 to 120000.");
|
|
55
|
+
return { valid: errors.length === 0, errors };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class RendererValidationError extends Error {
|
|
59
|
+
constructor(result) {
|
|
60
|
+
super(result.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n"));
|
|
61
|
+
this.name = "RendererValidationError";
|
|
62
|
+
this.errors = result.errors;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readRendererConfig(file) {
|
|
67
|
+
const absolute = await realpath(resolve(file));
|
|
68
|
+
if (!(await stat(absolute)).isFile()) throw new Error("Expected a renderer JSON file.");
|
|
69
|
+
const data = JSON.parse(await readFile(absolute, "utf8"));
|
|
70
|
+
const result = validateRendererConfig(data);
|
|
71
|
+
if (!result.valid) throw new RendererValidationError(result);
|
|
72
|
+
const directory = dirname(absolute);
|
|
73
|
+
const cwd = await realpath(resolve(directory, data.cwd || "."));
|
|
74
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error("Renderer cwd must be a directory.");
|
|
75
|
+
return {
|
|
76
|
+
file: absolute,
|
|
77
|
+
directory,
|
|
78
|
+
cwd,
|
|
79
|
+
data: {
|
|
80
|
+
...data,
|
|
81
|
+
cwd: data.cwd || ".",
|
|
82
|
+
readyPath: data.readyPath || "/",
|
|
83
|
+
screenBasePath: data.screenBasePath || "/",
|
|
84
|
+
readyTimeoutMs: data.readyTimeoutMs || 15000,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const reservePort = (host) =>
|
|
90
|
+
new Promise((resolvePort, reject) => {
|
|
91
|
+
const probe = createServer();
|
|
92
|
+
probe.once("error", reject);
|
|
93
|
+
probe.listen(0, host, () => {
|
|
94
|
+
const port = probe.address().port;
|
|
95
|
+
probe.close((error) => (error ? reject(error) : resolvePort(port)));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function originPath(origin, value, name) {
|
|
100
|
+
const url = new URL(value, origin);
|
|
101
|
+
if (url.origin !== origin) throw new Error(`${name} must stay on the renderer origin.`);
|
|
102
|
+
return url.href;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function terminate(child) {
|
|
106
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform === "win32") child.kill("SIGTERM");
|
|
109
|
+
else process.kill(-child.pid, "SIGTERM");
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function stop(child) {
|
|
114
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
115
|
+
terminate(child);
|
|
116
|
+
await Promise.race([
|
|
117
|
+
new Promise((done) => child.once("exit", done)),
|
|
118
|
+
new Promise((done) => setTimeout(done, 2000)),
|
|
119
|
+
]);
|
|
120
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
121
|
+
try {
|
|
122
|
+
if (process.platform === "win32") child.kill("SIGKILL");
|
|
123
|
+
else process.kill(-child.pid, "SIGKILL");
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function startRenderer(file, options = {}) {
|
|
129
|
+
const config = await readRendererConfig(file);
|
|
130
|
+
const host = options.host || "127.0.0.1";
|
|
131
|
+
const port = options.port || (await reservePort(host));
|
|
132
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
133
|
+
throw new Error("HTML port must be 1–65535.");
|
|
134
|
+
const values = {
|
|
135
|
+
host,
|
|
136
|
+
port: String(port),
|
|
137
|
+
atlasDirectory: config.directory,
|
|
138
|
+
};
|
|
139
|
+
const replace = (value) =>
|
|
140
|
+
value.replace(/\{(host|port|atlasDirectory)\}/g, (_, key) => values[key]);
|
|
141
|
+
const command = config.data.command.map(replace);
|
|
142
|
+
const origin = `http://${host}:${port}`;
|
|
143
|
+
const readyURL = originPath(origin, config.data.readyPath, "readyPath");
|
|
144
|
+
const screenBaseURL = originPath(origin, config.data.screenBasePath, "screenBasePath");
|
|
145
|
+
const child = spawn(command[0], command.slice(1), {
|
|
146
|
+
cwd: config.cwd,
|
|
147
|
+
detached: process.platform !== "win32",
|
|
148
|
+
env: {
|
|
149
|
+
...process.env,
|
|
150
|
+
KETATLAS_HOST: host,
|
|
151
|
+
KETATLAS_HTML_PORT: String(port),
|
|
152
|
+
KETATLAS_PROJECT_DIR: config.directory,
|
|
153
|
+
},
|
|
154
|
+
stdio: options.stdio || "inherit",
|
|
155
|
+
});
|
|
156
|
+
let spawnError;
|
|
157
|
+
child.once("error", (error) => {
|
|
158
|
+
spawnError = error;
|
|
159
|
+
});
|
|
160
|
+
const started = Date.now();
|
|
161
|
+
while (Date.now() - started < config.data.readyTimeoutMs) {
|
|
162
|
+
if (spawnError) throw spawnError;
|
|
163
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
164
|
+
throw new Error(`Renderer exited before ${readyURL} became ready.`);
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch(readyURL, { signal: AbortSignal.timeout(1000) });
|
|
167
|
+
if (response.ok) {
|
|
168
|
+
let closed = false;
|
|
169
|
+
return {
|
|
170
|
+
child,
|
|
171
|
+
config,
|
|
172
|
+
origin,
|
|
173
|
+
readyURL,
|
|
174
|
+
screenBaseURL,
|
|
175
|
+
async close() {
|
|
176
|
+
if (closed) return;
|
|
177
|
+
closed = true;
|
|
178
|
+
await stop(child);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
184
|
+
}
|
|
185
|
+
await stop(child);
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Renderer did not become ready at ${readyURL} within ${config.data.readyTimeoutMs}ms.`,
|
|
188
|
+
);
|
|
189
|
+
}
|