@kinotic-ai/spawn 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{spawn-renderer.global.js → graal-spawn-renderer.js} +166 -45
- package/dist/index.cjs +88 -17
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +3 -123
- package/dist/node/index.cjs +311 -0
- package/dist/node/index.d.cts +102 -0
- package/dist/node/index.d.ts +102 -0
- package/dist/node/index.js +77 -0
- package/dist/shared/spawn-1h1k8w8r.js +172 -0
- package/package.json +14 -4
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SpawnEngine
|
|
3
|
+
} from "../shared/spawn-1h1k8w8r.js";
|
|
4
|
+
|
|
5
|
+
// packages/spawn/src/node/index.ts
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import fsP from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
function assertContained(root, resolved) {
|
|
10
|
+
const rel = path.relative(root, resolved);
|
|
11
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
12
|
+
throw new Error(`Path escapes ${root}: ${resolved}`);
|
|
13
|
+
}
|
|
14
|
+
return resolved;
|
|
15
|
+
}
|
|
16
|
+
function assertPathWithin(root, target) {
|
|
17
|
+
return assertContained(root, path.resolve(root, target));
|
|
18
|
+
}
|
|
19
|
+
async function loadSpawnTree(dir) {
|
|
20
|
+
const tree = {};
|
|
21
|
+
const entries = await fsP.readdir(dir, { recursive: true, withFileTypes: true });
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (!entry.isFile()) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const filePath = path.join(entry.parentPath, entry.name);
|
|
27
|
+
const treePath = path.relative(dir, filePath).split(path.sep).join("/");
|
|
28
|
+
if (treePath.endsWith(".liquid")) {
|
|
29
|
+
tree[treePath] = await fsP.readFile(filePath, { encoding: "utf8" });
|
|
30
|
+
} else {
|
|
31
|
+
tree[treePath] = await fsP.readFile(filePath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return tree;
|
|
35
|
+
}
|
|
36
|
+
function diskInheritanceLoader(spawnRoot) {
|
|
37
|
+
let currentDir = spawnRoot;
|
|
38
|
+
return async (ref) => {
|
|
39
|
+
currentDir = assertContained(spawnRoot, path.resolve(currentDir, ref));
|
|
40
|
+
if (!fs.existsSync(path.resolve(currentDir, "spawn.json"))) {
|
|
41
|
+
throw new Error(`Inherited spawn ${path.resolve(currentDir, "spawn.json")} does not exist`);
|
|
42
|
+
}
|
|
43
|
+
return loadSpawnTree(currentDir);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function lintSpawnDir(dir) {
|
|
47
|
+
return new SpawnEngine().lint(await loadSpawnTree(dir), { loadInherited: diskInheritanceLoader(dir) });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class NodeSpawnRenderer {
|
|
51
|
+
engine = new SpawnEngine;
|
|
52
|
+
async render(spawnDir, destination, options) {
|
|
53
|
+
if (fs.existsSync(destination)) {
|
|
54
|
+
throw new Error(`The target directory ${destination} already exists`);
|
|
55
|
+
}
|
|
56
|
+
const result = await this.engine.renderSpawn(await loadSpawnTree(spawnDir), {
|
|
57
|
+
context: options?.context,
|
|
58
|
+
propertyResolver: options?.propertyResolver,
|
|
59
|
+
loadInherited: diskInheritanceLoader(spawnDir)
|
|
60
|
+
});
|
|
61
|
+
await this.writeSpawnTree(result.files, destination);
|
|
62
|
+
return result.context;
|
|
63
|
+
}
|
|
64
|
+
async writeSpawnTree(tree, destination) {
|
|
65
|
+
await fsP.mkdir(destination, { recursive: true });
|
|
66
|
+
for (const [treePath, content] of Object.entries(tree)) {
|
|
67
|
+
const filePath = assertPathWithin(destination, treePath);
|
|
68
|
+
await fsP.mkdir(path.dirname(filePath), { recursive: true });
|
|
69
|
+
await fsP.writeFile(filePath, content);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
lintSpawnDir,
|
|
75
|
+
assertPathWithin,
|
|
76
|
+
NodeSpawnRenderer
|
|
77
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// packages/spawn/src/api/SpawnEngine.ts
|
|
2
|
+
import { Liquid } from "liquidjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
var PropertySchemaSchema = z.object({
|
|
5
|
+
type: z.enum(["string", "number", "integer", "boolean"]).optional(),
|
|
6
|
+
description: z.string().optional(),
|
|
7
|
+
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
8
|
+
enum: z.array(z.string()).optional()
|
|
9
|
+
});
|
|
10
|
+
var SpawnConfigSchema = z.object({
|
|
11
|
+
inherits: z.string().optional(),
|
|
12
|
+
globals: z.record(z.string(), z.unknown()).optional(),
|
|
13
|
+
propertySchema: z.record(z.string(), PropertySchemaSchema).optional()
|
|
14
|
+
});
|
|
15
|
+
var IGNORED_FILE_NAMES = ["spawn.json", ".DS_Store"];
|
|
16
|
+
function upperFirst(s) {
|
|
17
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
18
|
+
}
|
|
19
|
+
function camelCase(s) {
|
|
20
|
+
return s.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase()).replace(/^(.)/, (_, c) => c.toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class SpawnEngine {
|
|
24
|
+
engine;
|
|
25
|
+
constructor() {
|
|
26
|
+
this.engine = new Liquid({ cache: true, strictVariables: true });
|
|
27
|
+
this.engine.registerFilter("packageToPath", (v) => v.replaceAll(".", "/"));
|
|
28
|
+
this.engine.registerFilter("encodePackage", (v) => {
|
|
29
|
+
v = v.replaceAll("-", "_");
|
|
30
|
+
v = v.replace(/\.(\d+)/g, "._$1");
|
|
31
|
+
return v;
|
|
32
|
+
});
|
|
33
|
+
this.engine.registerFilter("camelCase", (v) => camelCase(v));
|
|
34
|
+
this.engine.registerFilter("upperFirst", (v) => upperFirst(v));
|
|
35
|
+
}
|
|
36
|
+
async renderSpawn(spawn, options) {
|
|
37
|
+
const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
|
|
38
|
+
let globals = {};
|
|
39
|
+
let propertySchemas = {};
|
|
40
|
+
for (const config of [...configs].reverse()) {
|
|
41
|
+
if (config.globals) {
|
|
42
|
+
globals = { ...globals, ...config.globals };
|
|
43
|
+
}
|
|
44
|
+
if (config.propertySchema) {
|
|
45
|
+
propertySchemas = { ...propertySchemas, ...config.propertySchema };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let context = { ...globals, ...options?.context };
|
|
49
|
+
context = await this.resolveMissingProperties(propertySchemas, context, options?.propertyResolver);
|
|
50
|
+
const files = {};
|
|
51
|
+
const sources = {};
|
|
52
|
+
for (const tree of [...trees].reverse()) {
|
|
53
|
+
for (const source of Object.keys(tree).sort()) {
|
|
54
|
+
const fileName = source.substring(source.lastIndexOf("/") + 1);
|
|
55
|
+
if (IGNORED_FILE_NAMES.includes(fileName)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
let destination = source;
|
|
59
|
+
if (destination.includes("{{")) {
|
|
60
|
+
destination = await this.engine.parseAndRender(destination, context);
|
|
61
|
+
}
|
|
62
|
+
let content = tree[source];
|
|
63
|
+
if (destination.endsWith(".liquid")) {
|
|
64
|
+
destination = destination.substring(0, destination.length - 7);
|
|
65
|
+
if (typeof content !== "string") {
|
|
66
|
+
throw new Error(`Template ${source} must contain text content`);
|
|
67
|
+
}
|
|
68
|
+
content = await this.engine.parseAndRender(content, context);
|
|
69
|
+
}
|
|
70
|
+
files[destination] = content;
|
|
71
|
+
sources[destination] = source;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { files, sources, context };
|
|
75
|
+
}
|
|
76
|
+
async lint(spawn, options) {
|
|
77
|
+
const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
|
|
78
|
+
const declared = new Set;
|
|
79
|
+
for (const config of configs) {
|
|
80
|
+
if (config.globals) {
|
|
81
|
+
Object.keys(config.globals).forEach((key) => declared.add(key));
|
|
82
|
+
}
|
|
83
|
+
if (config.propertySchema) {
|
|
84
|
+
Object.keys(config.propertySchema).forEach((key) => declared.add(key));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const usedIn = new Map;
|
|
88
|
+
const record = (name, file) => {
|
|
89
|
+
let files = usedIn.get(name);
|
|
90
|
+
if (!files) {
|
|
91
|
+
files = new Set;
|
|
92
|
+
usedIn.set(name, files);
|
|
93
|
+
}
|
|
94
|
+
files.add(file);
|
|
95
|
+
};
|
|
96
|
+
const externalVars = (template) => Object.keys(this.engine.parseAndAnalyzeSync(template, undefined, { partials: false }).globals);
|
|
97
|
+
for (const tree of trees) {
|
|
98
|
+
for (const source of Object.keys(tree)) {
|
|
99
|
+
const fileName = source.substring(source.lastIndexOf("/") + 1);
|
|
100
|
+
if (IGNORED_FILE_NAMES.includes(fileName)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
externalVars(source).forEach((name) => record(name, source));
|
|
104
|
+
const content = tree[source];
|
|
105
|
+
if (source.endsWith(".liquid") && typeof content === "string") {
|
|
106
|
+
externalVars(content).forEach((name) => record(name, source));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const undeclared = [];
|
|
111
|
+
for (const [name, files] of usedIn) {
|
|
112
|
+
if (!declared.has(name)) {
|
|
113
|
+
undeclared.push({ name, files: [...files].sort() });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
undeclared.sort((a, b) => a.name.localeCompare(b.name));
|
|
117
|
+
return { undeclared };
|
|
118
|
+
}
|
|
119
|
+
async walkInheritance(spawn, loadInherited) {
|
|
120
|
+
const trees = [spawn];
|
|
121
|
+
const configs = [];
|
|
122
|
+
let currentConfig = this.parseConfig(spawn);
|
|
123
|
+
if (currentConfig) {
|
|
124
|
+
configs.push(currentConfig);
|
|
125
|
+
}
|
|
126
|
+
while (currentConfig?.inherits) {
|
|
127
|
+
if (!loadInherited) {
|
|
128
|
+
throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
|
|
129
|
+
}
|
|
130
|
+
const inherited = await loadInherited(currentConfig.inherits);
|
|
131
|
+
trees.push(inherited);
|
|
132
|
+
currentConfig = this.parseConfig(inherited);
|
|
133
|
+
if (currentConfig) {
|
|
134
|
+
configs.push(currentConfig);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { trees, configs };
|
|
138
|
+
}
|
|
139
|
+
parseConfig(tree) {
|
|
140
|
+
const raw = tree["spawn.json"];
|
|
141
|
+
if (raw === undefined) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
|
145
|
+
return SpawnConfigSchema.parse(JSON.parse(text));
|
|
146
|
+
}
|
|
147
|
+
async resolveMissingProperties(propertySchemas, context, resolver) {
|
|
148
|
+
const ret = { ...context };
|
|
149
|
+
for (const key in propertySchemas) {
|
|
150
|
+
if (!Object.prototype.hasOwnProperty.call(ret, key)) {
|
|
151
|
+
if (!resolver) {
|
|
152
|
+
throw new Error(`No value provided for required property '${key}'`);
|
|
153
|
+
}
|
|
154
|
+
const schema = propertySchemas[key];
|
|
155
|
+
let message;
|
|
156
|
+
if (schema.description?.includes("{{")) {
|
|
157
|
+
message = this.engine.parseAndRenderSync(schema.description, ret);
|
|
158
|
+
} else {
|
|
159
|
+
message = schema.description ?? key;
|
|
160
|
+
}
|
|
161
|
+
let defaultValue = schema.default;
|
|
162
|
+
if (typeof schema.default === "string" && schema.default.includes("{{")) {
|
|
163
|
+
defaultValue = this.engine.parseAndRenderSync(schema.default, ret);
|
|
164
|
+
}
|
|
165
|
+
ret[key] = await resolver.resolve(key, schema, message, defaultValue);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return ret;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { SpawnEngine };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinotic-ai/spawn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
"default": "./dist/index.cjs"
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
|
+
"./node": {
|
|
23
|
+
"import": {
|
|
24
|
+
"types": "./dist/node/index.d.ts",
|
|
25
|
+
"default": "./dist/node/index.js"
|
|
26
|
+
},
|
|
27
|
+
"require": {
|
|
28
|
+
"types": "./dist/node/index.d.cts",
|
|
29
|
+
"default": "./dist/node/index.cjs"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
22
32
|
"./package.json": "./package.json"
|
|
23
33
|
},
|
|
24
34
|
"homepage": "https://github.com/kinotic-ai/kinotic",
|
|
@@ -30,11 +40,11 @@
|
|
|
30
40
|
"type-check": "tsc --noEmit",
|
|
31
41
|
"test": "vitest run",
|
|
32
42
|
"coverage": "vitest run --coverage",
|
|
33
|
-
"build:renderer": "bun build ./src/
|
|
43
|
+
"build:graal-spawn-renderer": "bun build ./src/graalSpawnRendererMain.ts --outfile ./dist/graal-spawn-renderer.js --format=iife --target=browser"
|
|
34
44
|
},
|
|
35
45
|
"dependencies": {
|
|
36
|
-
"liquidjs": "10.
|
|
37
|
-
"zod": "
|
|
46
|
+
"liquidjs": "10.27.0",
|
|
47
|
+
"zod": "4.4.3"
|
|
38
48
|
},
|
|
39
49
|
"devDependencies": {
|
|
40
50
|
"@types/node": "^25.3.2",
|