@hanzogui/recipes 7.3.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/admin.js +95 -0
- package/dist/config.js +18 -0
- package/dist/index.js +225 -0
- package/dist/project.js +51 -0
- package/dist/read.js +104 -0
- package/dist/types.js +0 -0
- package/dist/write.js +80 -0
- package/package.json +39 -0
- package/readme.md +40 -0
- package/src/admin.ts +103 -0
- package/src/config.ts +8 -0
- package/src/index.ts +6 -0
- package/src/project.ts +39 -0
- package/src/read.ts +84 -0
- package/src/types.ts +34 -0
- package/src/write.ts +40 -0
package/dist/admin.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
var DEFAULT_BASE_URL = "https://base.hanzo.ai";
|
|
6
|
+
var RECIPES_COLLECTION = "gui_recipes";
|
|
7
|
+
var DEFAULT_INSTALL_DIR = "src/gui";
|
|
8
|
+
var DEFAULT_CONFIG_FILENAMES = ["gui.config.json", ".guirc.json"];
|
|
9
|
+
function resolveBaseUrl(override) {
|
|
10
|
+
return override ?? process.env.HANZO_BASE_URL ?? DEFAULT_BASE_URL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/admin.ts
|
|
14
|
+
async function authAdmin({ baseUrl, email, password }) {
|
|
15
|
+
const url = `${resolveBaseUrl(baseUrl)}/v1/base/admins/auth-with-password`;
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({ identity: email, password })
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
throw new Error(`Admin auth failed: ${res.status} ${await res.text()}`);
|
|
23
|
+
}
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
return data.token;
|
|
26
|
+
}
|
|
27
|
+
async function ensureCollection(auth) {
|
|
28
|
+
const base = resolveBaseUrl(auth.baseUrl);
|
|
29
|
+
const headers = { Authorization: auth.token, "Content-Type": "application/json" };
|
|
30
|
+
const probe = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}`, { headers });
|
|
31
|
+
if (probe.ok)
|
|
32
|
+
return "existed";
|
|
33
|
+
const schema = {
|
|
34
|
+
name: RECIPES_COLLECTION,
|
|
35
|
+
type: "base",
|
|
36
|
+
schema: [
|
|
37
|
+
{ name: "slug", type: "text", required: true, unique: true, options: {} },
|
|
38
|
+
{ name: "name", type: "text", required: true, options: {} },
|
|
39
|
+
{ name: "description", type: "text", required: false, options: {} },
|
|
40
|
+
{ name: "category", type: "text", required: false, options: {} },
|
|
41
|
+
{ name: "npmDeps", type: "json", required: false, options: {} },
|
|
42
|
+
{ name: "recipeDeps", type: "json", required: false, options: {} },
|
|
43
|
+
{ name: "files", type: "json", required: true, options: {} }
|
|
44
|
+
],
|
|
45
|
+
indexes: [`CREATE UNIQUE INDEX idx_${RECIPES_COLLECTION}_slug ON ${RECIPES_COLLECTION} (slug)`],
|
|
46
|
+
listRule: "",
|
|
47
|
+
viewRule: "",
|
|
48
|
+
createRule: null,
|
|
49
|
+
updateRule: null,
|
|
50
|
+
deleteRule: null
|
|
51
|
+
};
|
|
52
|
+
const res = await fetch(`${base}/v1/base/collections`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers,
|
|
55
|
+
body: JSON.stringify(schema)
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new Error(`Failed to create collection: ${res.status} ${await res.text()}`);
|
|
59
|
+
}
|
|
60
|
+
return "created";
|
|
61
|
+
}
|
|
62
|
+
async function findExisting(auth, slug) {
|
|
63
|
+
const base = resolveBaseUrl(auth.baseUrl);
|
|
64
|
+
const filter = encodeURIComponent(`slug="${slug}"`);
|
|
65
|
+
const res = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}/records?perPage=1&filter=${filter}`, { headers: { Authorization: auth.token } });
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
return null;
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
return data.items[0]?.id ?? null;
|
|
70
|
+
}
|
|
71
|
+
async function upsertRecipe(auth, recipe) {
|
|
72
|
+
const base = resolveBaseUrl(auth.baseUrl);
|
|
73
|
+
const headers = { Authorization: auth.token, "Content-Type": "application/json" };
|
|
74
|
+
const body = JSON.stringify(recipe);
|
|
75
|
+
const existing = await findExisting(auth, recipe.slug);
|
|
76
|
+
if (existing) {
|
|
77
|
+
const res2 = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}/records/${existing}`, { method: "PATCH", headers, body });
|
|
78
|
+
if (!res2.ok)
|
|
79
|
+
throw new Error(`PATCH ${recipe.slug}: ${res2.status} ${await res2.text()}`);
|
|
80
|
+
return "updated";
|
|
81
|
+
}
|
|
82
|
+
const res = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}/records`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers,
|
|
85
|
+
body
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
throw new Error(`POST ${recipe.slug}: ${res.status} ${await res.text()}`);
|
|
89
|
+
return "created";
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
upsertRecipe,
|
|
93
|
+
ensureCollection,
|
|
94
|
+
authAdmin
|
|
95
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
var DEFAULT_BASE_URL = "https://base.hanzo.ai";
|
|
6
|
+
var RECIPES_COLLECTION = "gui_recipes";
|
|
7
|
+
var DEFAULT_INSTALL_DIR = "src/gui";
|
|
8
|
+
var DEFAULT_CONFIG_FILENAMES = ["gui.config.json", ".guirc.json"];
|
|
9
|
+
function resolveBaseUrl(override) {
|
|
10
|
+
return override ?? process.env.HANZO_BASE_URL ?? DEFAULT_BASE_URL;
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
resolveBaseUrl,
|
|
14
|
+
RECIPES_COLLECTION,
|
|
15
|
+
DEFAULT_INSTALL_DIR,
|
|
16
|
+
DEFAULT_CONFIG_FILENAMES,
|
|
17
|
+
DEFAULT_BASE_URL
|
|
18
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
var DEFAULT_BASE_URL = "https://base.hanzo.ai";
|
|
6
|
+
var RECIPES_COLLECTION = "gui_recipes";
|
|
7
|
+
var DEFAULT_INSTALL_DIR = "src/gui";
|
|
8
|
+
var DEFAULT_CONFIG_FILENAMES = ["gui.config.json", ".guirc.json"];
|
|
9
|
+
function resolveBaseUrl(override) {
|
|
10
|
+
return override ?? process.env.HANZO_BASE_URL ?? DEFAULT_BASE_URL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/project.ts
|
|
14
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
async function findProjectRoot(start = process.cwd()) {
|
|
17
|
+
let dir = start;
|
|
18
|
+
while (dir !== path.parse(dir).root) {
|
|
19
|
+
const pkgPath = path.join(dir, "package.json");
|
|
20
|
+
if (existsSync(pkgPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
|
|
23
|
+
if (pkg.workspaces || existsSync(path.join(dir, "pnpm-workspace.yaml")))
|
|
24
|
+
return dir;
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(path.join(dir, ".git")))
|
|
28
|
+
return dir;
|
|
29
|
+
dir = path.dirname(dir);
|
|
30
|
+
}
|
|
31
|
+
return start;
|
|
32
|
+
}
|
|
33
|
+
async function readConfig(root) {
|
|
34
|
+
for (const name of DEFAULT_CONFIG_FILENAMES) {
|
|
35
|
+
const p = path.join(root, name);
|
|
36
|
+
if (existsSync(p)) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(await fs.readFile(p, "utf8"));
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
function resolveInstallDir(root, config) {
|
|
45
|
+
return path.join(root, config.installDir ?? DEFAULT_INSTALL_DIR);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/write.ts
|
|
49
|
+
import { existsSync as existsSync2, promises as fs2 } from "node:fs";
|
|
50
|
+
import path2 from "node:path";
|
|
51
|
+
async function getInstallDir(opts) {
|
|
52
|
+
if (opts.installDir)
|
|
53
|
+
return opts.installDir;
|
|
54
|
+
const root = await findProjectRoot(opts.cwd);
|
|
55
|
+
return resolveInstallDir(root, await readConfig(root));
|
|
56
|
+
}
|
|
57
|
+
async function installRecipe(recipe, opts = {}) {
|
|
58
|
+
const installDir = await getInstallDir(opts);
|
|
59
|
+
const written = [];
|
|
60
|
+
for (const file of recipe.files) {
|
|
61
|
+
const dest = path2.join(installDir, file.path);
|
|
62
|
+
const dir = path2.dirname(dest);
|
|
63
|
+
if (!existsSync2(dir))
|
|
64
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
65
|
+
await fs2.writeFile(dest, file.source);
|
|
66
|
+
written.push(dest);
|
|
67
|
+
}
|
|
68
|
+
return written;
|
|
69
|
+
}
|
|
70
|
+
async function installPlan(plan, opts = {}) {
|
|
71
|
+
const written = [];
|
|
72
|
+
for (const r of [plan.recipe, ...plan.transitiveRecipes]) {
|
|
73
|
+
written.push(...await installRecipe(r, opts));
|
|
74
|
+
}
|
|
75
|
+
return written;
|
|
76
|
+
}
|
|
77
|
+
// src/read.ts
|
|
78
|
+
async function getJSON(url) {
|
|
79
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
throw Object.assign(new Error(`Base request failed: ${res.status} ${res.statusText}`), {
|
|
82
|
+
status: res.status
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return await res.json();
|
|
86
|
+
}
|
|
87
|
+
function records(baseUrl) {
|
|
88
|
+
return `${resolveBaseUrl(baseUrl)}/v1/base/collections/${RECIPES_COLLECTION}/records`;
|
|
89
|
+
}
|
|
90
|
+
async function listRecipes(opts = {}) {
|
|
91
|
+
const url = `${records(opts.baseUrl)}?perPage=200&sort=name&fields=id,slug,name,description,category`;
|
|
92
|
+
const data = await getJSON(url);
|
|
93
|
+
return data.items;
|
|
94
|
+
}
|
|
95
|
+
async function getRecipe(slug, opts = {}) {
|
|
96
|
+
const filter = encodeURIComponent(`slug="${slug}"`);
|
|
97
|
+
const url = `${records(opts.baseUrl)}?perPage=1&filter=${filter}`;
|
|
98
|
+
const data = await getJSON(url);
|
|
99
|
+
const recipe = data.items[0];
|
|
100
|
+
if (!recipe) {
|
|
101
|
+
throw Object.assign(new Error(`Recipe not found: ${slug}`), { status: 404 });
|
|
102
|
+
}
|
|
103
|
+
return recipe;
|
|
104
|
+
}
|
|
105
|
+
async function resolveRecipe(slug, opts = {}) {
|
|
106
|
+
const seen = new Map;
|
|
107
|
+
const queue = [slug];
|
|
108
|
+
while (queue.length) {
|
|
109
|
+
const next = queue.shift();
|
|
110
|
+
if (seen.has(next))
|
|
111
|
+
continue;
|
|
112
|
+
const recipe2 = await getRecipe(next, opts);
|
|
113
|
+
seen.set(next, recipe2);
|
|
114
|
+
for (const dep of recipe2.recipeDeps ?? []) {
|
|
115
|
+
if (!seen.has(dep))
|
|
116
|
+
queue.push(dep);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const installDir = opts.installDir ?? await (async () => {
|
|
120
|
+
const root = await findProjectRoot(opts.cwd);
|
|
121
|
+
return resolveInstallDir(root, await readConfig(root));
|
|
122
|
+
})();
|
|
123
|
+
const recipe = seen.get(slug);
|
|
124
|
+
const transitiveRecipes = Array.from(seen.values()).filter((r) => r.slug !== slug);
|
|
125
|
+
const path3 = await import("node:path");
|
|
126
|
+
const targetPaths = [recipe, ...transitiveRecipes].flatMap((r) => r.files.map((f) => path3.join(installDir, f.path)));
|
|
127
|
+
return { recipe, transitiveRecipes, targetPaths };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/admin.ts
|
|
131
|
+
async function authAdmin({ baseUrl, email, password }) {
|
|
132
|
+
const url = `${resolveBaseUrl(baseUrl)}/v1/base/admins/auth-with-password`;
|
|
133
|
+
const res = await fetch(url, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify({ identity: email, password })
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
throw new Error(`Admin auth failed: ${res.status} ${await res.text()}`);
|
|
140
|
+
}
|
|
141
|
+
const data = await res.json();
|
|
142
|
+
return data.token;
|
|
143
|
+
}
|
|
144
|
+
async function ensureCollection(auth) {
|
|
145
|
+
const base = resolveBaseUrl(auth.baseUrl);
|
|
146
|
+
const headers = { Authorization: auth.token, "Content-Type": "application/json" };
|
|
147
|
+
const probe = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}`, { headers });
|
|
148
|
+
if (probe.ok)
|
|
149
|
+
return "existed";
|
|
150
|
+
const schema = {
|
|
151
|
+
name: RECIPES_COLLECTION,
|
|
152
|
+
type: "base",
|
|
153
|
+
schema: [
|
|
154
|
+
{ name: "slug", type: "text", required: true, unique: true, options: {} },
|
|
155
|
+
{ name: "name", type: "text", required: true, options: {} },
|
|
156
|
+
{ name: "description", type: "text", required: false, options: {} },
|
|
157
|
+
{ name: "category", type: "text", required: false, options: {} },
|
|
158
|
+
{ name: "npmDeps", type: "json", required: false, options: {} },
|
|
159
|
+
{ name: "recipeDeps", type: "json", required: false, options: {} },
|
|
160
|
+
{ name: "files", type: "json", required: true, options: {} }
|
|
161
|
+
],
|
|
162
|
+
indexes: [`CREATE UNIQUE INDEX idx_${RECIPES_COLLECTION}_slug ON ${RECIPES_COLLECTION} (slug)`],
|
|
163
|
+
listRule: "",
|
|
164
|
+
viewRule: "",
|
|
165
|
+
createRule: null,
|
|
166
|
+
updateRule: null,
|
|
167
|
+
deleteRule: null
|
|
168
|
+
};
|
|
169
|
+
const res = await fetch(`${base}/v1/base/collections`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers,
|
|
172
|
+
body: JSON.stringify(schema)
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
throw new Error(`Failed to create collection: ${res.status} ${await res.text()}`);
|
|
176
|
+
}
|
|
177
|
+
return "created";
|
|
178
|
+
}
|
|
179
|
+
async function findExisting(auth, slug) {
|
|
180
|
+
const base = resolveBaseUrl(auth.baseUrl);
|
|
181
|
+
const filter = encodeURIComponent(`slug="${slug}"`);
|
|
182
|
+
const res = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}/records?perPage=1&filter=${filter}`, { headers: { Authorization: auth.token } });
|
|
183
|
+
if (!res.ok)
|
|
184
|
+
return null;
|
|
185
|
+
const data = await res.json();
|
|
186
|
+
return data.items[0]?.id ?? null;
|
|
187
|
+
}
|
|
188
|
+
async function upsertRecipe(auth, recipe) {
|
|
189
|
+
const base = resolveBaseUrl(auth.baseUrl);
|
|
190
|
+
const headers = { Authorization: auth.token, "Content-Type": "application/json" };
|
|
191
|
+
const body = JSON.stringify(recipe);
|
|
192
|
+
const existing = await findExisting(auth, recipe.slug);
|
|
193
|
+
if (existing) {
|
|
194
|
+
const res2 = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}/records/${existing}`, { method: "PATCH", headers, body });
|
|
195
|
+
if (!res2.ok)
|
|
196
|
+
throw new Error(`PATCH ${recipe.slug}: ${res2.status} ${await res2.text()}`);
|
|
197
|
+
return "updated";
|
|
198
|
+
}
|
|
199
|
+
const res = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}/records`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers,
|
|
202
|
+
body
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok)
|
|
205
|
+
throw new Error(`POST ${recipe.slug}: ${res.status} ${await res.text()}`);
|
|
206
|
+
return "created";
|
|
207
|
+
}
|
|
208
|
+
export {
|
|
209
|
+
upsertRecipe,
|
|
210
|
+
resolveRecipe,
|
|
211
|
+
resolveInstallDir,
|
|
212
|
+
resolveBaseUrl,
|
|
213
|
+
readConfig,
|
|
214
|
+
listRecipes,
|
|
215
|
+
installRecipe,
|
|
216
|
+
installPlan,
|
|
217
|
+
getRecipe,
|
|
218
|
+
findProjectRoot,
|
|
219
|
+
ensureCollection,
|
|
220
|
+
authAdmin,
|
|
221
|
+
RECIPES_COLLECTION,
|
|
222
|
+
DEFAULT_INSTALL_DIR,
|
|
223
|
+
DEFAULT_CONFIG_FILENAMES,
|
|
224
|
+
DEFAULT_BASE_URL
|
|
225
|
+
};
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
var DEFAULT_BASE_URL = "https://base.hanzo.ai";
|
|
6
|
+
var RECIPES_COLLECTION = "gui_recipes";
|
|
7
|
+
var DEFAULT_INSTALL_DIR = "src/gui";
|
|
8
|
+
var DEFAULT_CONFIG_FILENAMES = ["gui.config.json", ".guirc.json"];
|
|
9
|
+
function resolveBaseUrl(override) {
|
|
10
|
+
return override ?? process.env.HANZO_BASE_URL ?? DEFAULT_BASE_URL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/project.ts
|
|
14
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
async function findProjectRoot(start = process.cwd()) {
|
|
17
|
+
let dir = start;
|
|
18
|
+
while (dir !== path.parse(dir).root) {
|
|
19
|
+
const pkgPath = path.join(dir, "package.json");
|
|
20
|
+
if (existsSync(pkgPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
|
|
23
|
+
if (pkg.workspaces || existsSync(path.join(dir, "pnpm-workspace.yaml")))
|
|
24
|
+
return dir;
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(path.join(dir, ".git")))
|
|
28
|
+
return dir;
|
|
29
|
+
dir = path.dirname(dir);
|
|
30
|
+
}
|
|
31
|
+
return start;
|
|
32
|
+
}
|
|
33
|
+
async function readConfig(root) {
|
|
34
|
+
for (const name of DEFAULT_CONFIG_FILENAMES) {
|
|
35
|
+
const p = path.join(root, name);
|
|
36
|
+
if (existsSync(p)) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(await fs.readFile(p, "utf8"));
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
function resolveInstallDir(root, config) {
|
|
45
|
+
return path.join(root, config.installDir ?? DEFAULT_INSTALL_DIR);
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
resolveInstallDir,
|
|
49
|
+
readConfig,
|
|
50
|
+
findProjectRoot
|
|
51
|
+
};
|
package/dist/read.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
var DEFAULT_BASE_URL = "https://base.hanzo.ai";
|
|
6
|
+
var RECIPES_COLLECTION = "gui_recipes";
|
|
7
|
+
var DEFAULT_INSTALL_DIR = "src/gui";
|
|
8
|
+
var DEFAULT_CONFIG_FILENAMES = ["gui.config.json", ".guirc.json"];
|
|
9
|
+
function resolveBaseUrl(override) {
|
|
10
|
+
return override ?? process.env.HANZO_BASE_URL ?? DEFAULT_BASE_URL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/project.ts
|
|
14
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
async function findProjectRoot(start = process.cwd()) {
|
|
17
|
+
let dir = start;
|
|
18
|
+
while (dir !== path.parse(dir).root) {
|
|
19
|
+
const pkgPath = path.join(dir, "package.json");
|
|
20
|
+
if (existsSync(pkgPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
|
|
23
|
+
if (pkg.workspaces || existsSync(path.join(dir, "pnpm-workspace.yaml")))
|
|
24
|
+
return dir;
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(path.join(dir, ".git")))
|
|
28
|
+
return dir;
|
|
29
|
+
dir = path.dirname(dir);
|
|
30
|
+
}
|
|
31
|
+
return start;
|
|
32
|
+
}
|
|
33
|
+
async function readConfig(root) {
|
|
34
|
+
for (const name of DEFAULT_CONFIG_FILENAMES) {
|
|
35
|
+
const p = path.join(root, name);
|
|
36
|
+
if (existsSync(p)) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(await fs.readFile(p, "utf8"));
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
function resolveInstallDir(root, config) {
|
|
45
|
+
return path.join(root, config.installDir ?? DEFAULT_INSTALL_DIR);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/read.ts
|
|
49
|
+
async function getJSON(url) {
|
|
50
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw Object.assign(new Error(`Base request failed: ${res.status} ${res.statusText}`), {
|
|
53
|
+
status: res.status
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return await res.json();
|
|
57
|
+
}
|
|
58
|
+
function records(baseUrl) {
|
|
59
|
+
return `${resolveBaseUrl(baseUrl)}/v1/base/collections/${RECIPES_COLLECTION}/records`;
|
|
60
|
+
}
|
|
61
|
+
async function listRecipes(opts = {}) {
|
|
62
|
+
const url = `${records(opts.baseUrl)}?perPage=200&sort=name&fields=id,slug,name,description,category`;
|
|
63
|
+
const data = await getJSON(url);
|
|
64
|
+
return data.items;
|
|
65
|
+
}
|
|
66
|
+
async function getRecipe(slug, opts = {}) {
|
|
67
|
+
const filter = encodeURIComponent(`slug="${slug}"`);
|
|
68
|
+
const url = `${records(opts.baseUrl)}?perPage=1&filter=${filter}`;
|
|
69
|
+
const data = await getJSON(url);
|
|
70
|
+
const recipe = data.items[0];
|
|
71
|
+
if (!recipe) {
|
|
72
|
+
throw Object.assign(new Error(`Recipe not found: ${slug}`), { status: 404 });
|
|
73
|
+
}
|
|
74
|
+
return recipe;
|
|
75
|
+
}
|
|
76
|
+
async function resolveRecipe(slug, opts = {}) {
|
|
77
|
+
const seen = new Map;
|
|
78
|
+
const queue = [slug];
|
|
79
|
+
while (queue.length) {
|
|
80
|
+
const next = queue.shift();
|
|
81
|
+
if (seen.has(next))
|
|
82
|
+
continue;
|
|
83
|
+
const recipe2 = await getRecipe(next, opts);
|
|
84
|
+
seen.set(next, recipe2);
|
|
85
|
+
for (const dep of recipe2.recipeDeps ?? []) {
|
|
86
|
+
if (!seen.has(dep))
|
|
87
|
+
queue.push(dep);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const installDir = opts.installDir ?? await (async () => {
|
|
91
|
+
const root = await findProjectRoot(opts.cwd);
|
|
92
|
+
return resolveInstallDir(root, await readConfig(root));
|
|
93
|
+
})();
|
|
94
|
+
const recipe = seen.get(slug);
|
|
95
|
+
const transitiveRecipes = Array.from(seen.values()).filter((r) => r.slug !== slug);
|
|
96
|
+
const path2 = await import("node:path");
|
|
97
|
+
const targetPaths = [recipe, ...transitiveRecipes].flatMap((r) => r.files.map((f) => path2.join(installDir, f.path)));
|
|
98
|
+
return { recipe, transitiveRecipes, targetPaths };
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
resolveRecipe,
|
|
102
|
+
listRecipes,
|
|
103
|
+
getRecipe
|
|
104
|
+
};
|
package/dist/types.js
ADDED
|
File without changes
|
package/dist/write.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
var DEFAULT_BASE_URL = "https://base.hanzo.ai";
|
|
6
|
+
var RECIPES_COLLECTION = "gui_recipes";
|
|
7
|
+
var DEFAULT_INSTALL_DIR = "src/gui";
|
|
8
|
+
var DEFAULT_CONFIG_FILENAMES = ["gui.config.json", ".guirc.json"];
|
|
9
|
+
function resolveBaseUrl(override) {
|
|
10
|
+
return override ?? process.env.HANZO_BASE_URL ?? DEFAULT_BASE_URL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/project.ts
|
|
14
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
async function findProjectRoot(start = process.cwd()) {
|
|
17
|
+
let dir = start;
|
|
18
|
+
while (dir !== path.parse(dir).root) {
|
|
19
|
+
const pkgPath = path.join(dir, "package.json");
|
|
20
|
+
if (existsSync(pkgPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
|
|
23
|
+
if (pkg.workspaces || existsSync(path.join(dir, "pnpm-workspace.yaml")))
|
|
24
|
+
return dir;
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(path.join(dir, ".git")))
|
|
28
|
+
return dir;
|
|
29
|
+
dir = path.dirname(dir);
|
|
30
|
+
}
|
|
31
|
+
return start;
|
|
32
|
+
}
|
|
33
|
+
async function readConfig(root) {
|
|
34
|
+
for (const name of DEFAULT_CONFIG_FILENAMES) {
|
|
35
|
+
const p = path.join(root, name);
|
|
36
|
+
if (existsSync(p)) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(await fs.readFile(p, "utf8"));
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
function resolveInstallDir(root, config) {
|
|
45
|
+
return path.join(root, config.installDir ?? DEFAULT_INSTALL_DIR);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/write.ts
|
|
49
|
+
import { existsSync as existsSync2, promises as fs2 } from "node:fs";
|
|
50
|
+
import path2 from "node:path";
|
|
51
|
+
async function getInstallDir(opts) {
|
|
52
|
+
if (opts.installDir)
|
|
53
|
+
return opts.installDir;
|
|
54
|
+
const root = await findProjectRoot(opts.cwd);
|
|
55
|
+
return resolveInstallDir(root, await readConfig(root));
|
|
56
|
+
}
|
|
57
|
+
async function installRecipe(recipe, opts = {}) {
|
|
58
|
+
const installDir = await getInstallDir(opts);
|
|
59
|
+
const written = [];
|
|
60
|
+
for (const file of recipe.files) {
|
|
61
|
+
const dest = path2.join(installDir, file.path);
|
|
62
|
+
const dir = path2.dirname(dest);
|
|
63
|
+
if (!existsSync2(dir))
|
|
64
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
65
|
+
await fs2.writeFile(dest, file.source);
|
|
66
|
+
written.push(dest);
|
|
67
|
+
}
|
|
68
|
+
return written;
|
|
69
|
+
}
|
|
70
|
+
async function installPlan(plan, opts = {}) {
|
|
71
|
+
const written = [];
|
|
72
|
+
for (const r of [plan.recipe, ...plan.transitiveRecipes]) {
|
|
73
|
+
written.push(...await installRecipe(r, opts));
|
|
74
|
+
}
|
|
75
|
+
return written;
|
|
76
|
+
}
|
|
77
|
+
export {
|
|
78
|
+
installRecipe,
|
|
79
|
+
installPlan
|
|
80
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hanzogui/recipes",
|
|
3
|
+
"version": "7.3.0",
|
|
4
|
+
"description": "Hanzo GUI recipe library: read from Base, resolve transitive deps, install into a project.",
|
|
5
|
+
"license": "BSD-3-Clause",
|
|
6
|
+
"author": "Hanzo AI <dev@hanzo.ai>",
|
|
7
|
+
"homepage": "https://gui.hanzo.ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/hanzoai/gui.git",
|
|
11
|
+
"directory": "pkgs/recipes"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"source": "src/index.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"src/**/*.ts",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "bun run build.ts",
|
|
33
|
+
"clean": "rm -rf dist",
|
|
34
|
+
"test": "bun test"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @hanzogui/recipes
|
|
2
|
+
|
|
3
|
+
Library for reading and installing Hanzo GUI recipes from Hanzo Base.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
listRecipes,
|
|
8
|
+
getRecipe,
|
|
9
|
+
resolveRecipe,
|
|
10
|
+
installPlan,
|
|
11
|
+
} from '@hanzogui/recipes'
|
|
12
|
+
|
|
13
|
+
// Anonymous read path (used by @hanzogui/get CLI, website, IDE extensions)
|
|
14
|
+
const recipes = await listRecipes()
|
|
15
|
+
const plan = await resolveRecipe('sign-in-form')
|
|
16
|
+
await installPlan(plan)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Admin/writer path (used by `scripts/seed-recipes.ts` from the gui repo):
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { authAdmin, ensureCollection, upsertRecipe } from '@hanzogui/recipes'
|
|
23
|
+
|
|
24
|
+
const token = await authAdmin({ email, password })
|
|
25
|
+
await ensureCollection({ token })
|
|
26
|
+
await upsertRecipe({ token }, recipe)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Pure functions where it matters — project-path resolution is three composable pieces:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { findProjectRoot, readConfig, resolveInstallDir } from '@hanzogui/recipes'
|
|
33
|
+
const root = await findProjectRoot()
|
|
34
|
+
const cfg = await readConfig(root)
|
|
35
|
+
const dir = resolveInstallDir(root, cfg)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Override the Base URL per-call (`opts.baseUrl`) or globally (`HANZO_BASE_URL` env var).
|
|
39
|
+
|
|
40
|
+
This package has no runtime dependencies.
|
package/src/admin.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { RECIPES_COLLECTION, resolveBaseUrl } from './config.js'
|
|
2
|
+
import type { Recipe, RecipeInput } from './types.js'
|
|
3
|
+
|
|
4
|
+
export type AdminAuth = {
|
|
5
|
+
baseUrl?: string
|
|
6
|
+
token: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type AdminPasswordAuth = {
|
|
10
|
+
baseUrl?: string
|
|
11
|
+
email: string
|
|
12
|
+
password: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Exchanges admin email+password for a Base session token. */
|
|
16
|
+
export async function authAdmin({ baseUrl, email, password }: AdminPasswordAuth): Promise<string> {
|
|
17
|
+
const url = `${resolveBaseUrl(baseUrl)}/v1/base/admins/auth-with-password`
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ identity: email, password }),
|
|
22
|
+
})
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
throw new Error(`Admin auth failed: ${res.status} ${await res.text()}`)
|
|
25
|
+
}
|
|
26
|
+
const data = (await res.json()) as { token: string }
|
|
27
|
+
return data.token
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Idempotent: creates the gui_recipes collection if missing, no-ops otherwise. */
|
|
31
|
+
export async function ensureCollection(auth: AdminAuth): Promise<'created' | 'existed'> {
|
|
32
|
+
const base = resolveBaseUrl(auth.baseUrl)
|
|
33
|
+
const headers = { Authorization: auth.token, 'Content-Type': 'application/json' }
|
|
34
|
+
const probe = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}`, { headers })
|
|
35
|
+
if (probe.ok) return 'existed'
|
|
36
|
+
|
|
37
|
+
const schema = {
|
|
38
|
+
name: RECIPES_COLLECTION,
|
|
39
|
+
type: 'base',
|
|
40
|
+
schema: [
|
|
41
|
+
{ name: 'slug', type: 'text', required: true, unique: true, options: {} },
|
|
42
|
+
{ name: 'name', type: 'text', required: true, options: {} },
|
|
43
|
+
{ name: 'description', type: 'text', required: false, options: {} },
|
|
44
|
+
{ name: 'category', type: 'text', required: false, options: {} },
|
|
45
|
+
{ name: 'npmDeps', type: 'json', required: false, options: {} },
|
|
46
|
+
{ name: 'recipeDeps', type: 'json', required: false, options: {} },
|
|
47
|
+
{ name: 'files', type: 'json', required: true, options: {} },
|
|
48
|
+
],
|
|
49
|
+
indexes: [`CREATE UNIQUE INDEX idx_${RECIPES_COLLECTION}_slug ON ${RECIPES_COLLECTION} (slug)`],
|
|
50
|
+
listRule: '',
|
|
51
|
+
viewRule: '',
|
|
52
|
+
createRule: null,
|
|
53
|
+
updateRule: null,
|
|
54
|
+
deleteRule: null,
|
|
55
|
+
}
|
|
56
|
+
const res = await fetch(`${base}/v1/base/collections`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers,
|
|
59
|
+
body: JSON.stringify(schema),
|
|
60
|
+
})
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
throw new Error(`Failed to create collection: ${res.status} ${await res.text()}`)
|
|
63
|
+
}
|
|
64
|
+
return 'created'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function findExisting(auth: AdminAuth, slug: string): Promise<string | null> {
|
|
68
|
+
const base = resolveBaseUrl(auth.baseUrl)
|
|
69
|
+
const filter = encodeURIComponent(`slug="${slug}"`)
|
|
70
|
+
const res = await fetch(
|
|
71
|
+
`${base}/v1/base/collections/${RECIPES_COLLECTION}/records?perPage=1&filter=${filter}`,
|
|
72
|
+
{ headers: { Authorization: auth.token } }
|
|
73
|
+
)
|
|
74
|
+
if (!res.ok) return null
|
|
75
|
+
const data = (await res.json()) as { items: Array<{ id: string }> }
|
|
76
|
+
return data.items[0]?.id ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Upsert by slug: PATCH if existing, POST if new. */
|
|
80
|
+
export async function upsertRecipe(
|
|
81
|
+
auth: AdminAuth,
|
|
82
|
+
recipe: RecipeInput
|
|
83
|
+
): Promise<'created' | 'updated'> {
|
|
84
|
+
const base = resolveBaseUrl(auth.baseUrl)
|
|
85
|
+
const headers = { Authorization: auth.token, 'Content-Type': 'application/json' }
|
|
86
|
+
const body = JSON.stringify(recipe)
|
|
87
|
+
const existing = await findExisting(auth, recipe.slug)
|
|
88
|
+
if (existing) {
|
|
89
|
+
const res = await fetch(
|
|
90
|
+
`${base}/v1/base/collections/${RECIPES_COLLECTION}/records/${existing}`,
|
|
91
|
+
{ method: 'PATCH', headers, body }
|
|
92
|
+
)
|
|
93
|
+
if (!res.ok) throw new Error(`PATCH ${recipe.slug}: ${res.status} ${await res.text()}`)
|
|
94
|
+
return 'updated'
|
|
95
|
+
}
|
|
96
|
+
const res = await fetch(`${base}/v1/base/collections/${RECIPES_COLLECTION}/records`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers,
|
|
99
|
+
body,
|
|
100
|
+
})
|
|
101
|
+
if (!res.ok) throw new Error(`POST ${recipe.slug}: ${res.status} ${await res.text()}`)
|
|
102
|
+
return 'created'
|
|
103
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const DEFAULT_BASE_URL = 'https://base.hanzo.ai'
|
|
2
|
+
export const RECIPES_COLLECTION = 'gui_recipes'
|
|
3
|
+
export const DEFAULT_INSTALL_DIR = 'src/gui'
|
|
4
|
+
export const DEFAULT_CONFIG_FILENAMES = ['gui.config.json', '.guirc.json'] as const
|
|
5
|
+
|
|
6
|
+
export function resolveBaseUrl(override?: string): string {
|
|
7
|
+
return override ?? process.env.HANZO_BASE_URL ?? DEFAULT_BASE_URL
|
|
8
|
+
}
|
package/src/index.ts
ADDED
package/src/project.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { DEFAULT_CONFIG_FILENAMES, DEFAULT_INSTALL_DIR } from './config.js'
|
|
4
|
+
import type { ProjectConfig } from './types.js'
|
|
5
|
+
|
|
6
|
+
/** Walks up from `start` until it finds a workspace root, git root, or any package.json. */
|
|
7
|
+
export async function findProjectRoot(start: string = process.cwd()): Promise<string> {
|
|
8
|
+
let dir = start
|
|
9
|
+
while (dir !== path.parse(dir).root) {
|
|
10
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
11
|
+
if (existsSync(pkgPath)) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'))
|
|
14
|
+
if (pkg.workspaces || existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir
|
|
15
|
+
} catch {}
|
|
16
|
+
}
|
|
17
|
+
if (existsSync(path.join(dir, '.git'))) return dir
|
|
18
|
+
dir = path.dirname(dir)
|
|
19
|
+
}
|
|
20
|
+
return start
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reads the first config file that exists in `root`. Returns `{}` if none. */
|
|
24
|
+
export async function readConfig(root: string): Promise<ProjectConfig> {
|
|
25
|
+
for (const name of DEFAULT_CONFIG_FILENAMES) {
|
|
26
|
+
const p = path.join(root, name)
|
|
27
|
+
if (existsSync(p)) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await fs.readFile(p, 'utf8')) as ProjectConfig
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pure resolver: given a root and config, where do recipes install? */
|
|
37
|
+
export function resolveInstallDir(root: string, config: ProjectConfig): string {
|
|
38
|
+
return path.join(root, config.installDir ?? DEFAULT_INSTALL_DIR)
|
|
39
|
+
}
|
package/src/read.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { RECIPES_COLLECTION, resolveBaseUrl } from './config.js'
|
|
2
|
+
import { findProjectRoot, readConfig, resolveInstallDir } from './project.js'
|
|
3
|
+
import type { InstallPlan, Recipe, RecipeSummary } from './types.js'
|
|
4
|
+
|
|
5
|
+
type ListResponse<T> = {
|
|
6
|
+
items: T[]
|
|
7
|
+
page: number
|
|
8
|
+
perPage: number
|
|
9
|
+
totalItems: number
|
|
10
|
+
totalPages: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ReaderOptions = {
|
|
14
|
+
baseUrl?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function getJSON<T>(url: string): Promise<T> {
|
|
18
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' } })
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw Object.assign(new Error(`Base request failed: ${res.status} ${res.statusText}`), {
|
|
21
|
+
status: res.status,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
return (await res.json()) as T
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function records(baseUrl?: string): string {
|
|
28
|
+
return `${resolveBaseUrl(baseUrl)}/v1/base/collections/${RECIPES_COLLECTION}/records`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function listRecipes(opts: ReaderOptions = {}): Promise<RecipeSummary[]> {
|
|
32
|
+
const url = `${records(opts.baseUrl)}?perPage=200&sort=name&fields=id,slug,name,description,category`
|
|
33
|
+
const data = await getJSON<ListResponse<RecipeSummary>>(url)
|
|
34
|
+
return data.items
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getRecipe(slug: string, opts: ReaderOptions = {}): Promise<Recipe> {
|
|
38
|
+
const filter = encodeURIComponent(`slug="${slug}"`)
|
|
39
|
+
const url = `${records(opts.baseUrl)}?perPage=1&filter=${filter}`
|
|
40
|
+
const data = await getJSON<ListResponse<Recipe>>(url)
|
|
41
|
+
const recipe = data.items[0]
|
|
42
|
+
if (!recipe) {
|
|
43
|
+
throw Object.assign(new Error(`Recipe not found: ${slug}`), { status: 404 })
|
|
44
|
+
}
|
|
45
|
+
return recipe
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ResolveOptions = ReaderOptions & {
|
|
49
|
+
/** Override the install dir lookup; if omitted, resolved from the user's project. */
|
|
50
|
+
installDir?: string
|
|
51
|
+
/** Override the project root for path resolution. */
|
|
52
|
+
cwd?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Walks recipeDeps transitively, computes target paths under the resolved install dir. */
|
|
56
|
+
export async function resolveRecipe(slug: string, opts: ResolveOptions = {}): Promise<InstallPlan> {
|
|
57
|
+
const seen = new Map<string, Recipe>()
|
|
58
|
+
const queue = [slug]
|
|
59
|
+
while (queue.length) {
|
|
60
|
+
const next = queue.shift()!
|
|
61
|
+
if (seen.has(next)) continue
|
|
62
|
+
const recipe = await getRecipe(next, opts)
|
|
63
|
+
seen.set(next, recipe)
|
|
64
|
+
for (const dep of recipe.recipeDeps ?? []) {
|
|
65
|
+
if (!seen.has(dep)) queue.push(dep)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const installDir =
|
|
70
|
+
opts.installDir ??
|
|
71
|
+
(await (async () => {
|
|
72
|
+
const root = await findProjectRoot(opts.cwd)
|
|
73
|
+
return resolveInstallDir(root, await readConfig(root))
|
|
74
|
+
})())
|
|
75
|
+
|
|
76
|
+
const recipe = seen.get(slug)!
|
|
77
|
+
const transitiveRecipes = Array.from(seen.values()).filter((r) => r.slug !== slug)
|
|
78
|
+
const path = await import('node:path')
|
|
79
|
+
const targetPaths = [recipe, ...transitiveRecipes].flatMap((r) =>
|
|
80
|
+
r.files.map((f) => path.join(installDir, f.path))
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return { recipe, transitiveRecipes, targetPaths }
|
|
84
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type RecipeFile = {
|
|
2
|
+
path: string
|
|
3
|
+
source: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type Recipe = {
|
|
7
|
+
id: string
|
|
8
|
+
slug: string
|
|
9
|
+
name: string
|
|
10
|
+
description: string
|
|
11
|
+
category: string
|
|
12
|
+
npmDeps: string[]
|
|
13
|
+
recipeDeps: string[]
|
|
14
|
+
files: RecipeFile[]
|
|
15
|
+
created: string
|
|
16
|
+
updated: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RecipeSummary = Pick<
|
|
20
|
+
Recipe,
|
|
21
|
+
'id' | 'slug' | 'name' | 'description' | 'category'
|
|
22
|
+
>
|
|
23
|
+
|
|
24
|
+
export type RecipeInput = Pick<Recipe, 'slug' | 'name' | 'description' | 'category' | 'npmDeps' | 'recipeDeps' | 'files'>
|
|
25
|
+
|
|
26
|
+
export type InstallPlan = {
|
|
27
|
+
recipe: Recipe
|
|
28
|
+
transitiveRecipes: Recipe[]
|
|
29
|
+
targetPaths: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ProjectConfig = {
|
|
33
|
+
installDir?: string
|
|
34
|
+
}
|
package/src/write.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { findProjectRoot, readConfig, resolveInstallDir } from './project.js'
|
|
4
|
+
import type { InstallPlan, Recipe } from './types.js'
|
|
5
|
+
|
|
6
|
+
export type WriteOptions = {
|
|
7
|
+
/** Override the install dir; if omitted, resolved from the user's project. */
|
|
8
|
+
installDir?: string
|
|
9
|
+
/** Override project root for resolution. */
|
|
10
|
+
cwd?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function getInstallDir(opts: WriteOptions): Promise<string> {
|
|
14
|
+
if (opts.installDir) return opts.installDir
|
|
15
|
+
const root = await findProjectRoot(opts.cwd)
|
|
16
|
+
return resolveInstallDir(root, await readConfig(root))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Writes one recipe's files. Returns absolute paths written. */
|
|
20
|
+
export async function installRecipe(recipe: Recipe, opts: WriteOptions = {}): Promise<string[]> {
|
|
21
|
+
const installDir = await getInstallDir(opts)
|
|
22
|
+
const written: string[] = []
|
|
23
|
+
for (const file of recipe.files) {
|
|
24
|
+
const dest = path.join(installDir, file.path)
|
|
25
|
+
const dir = path.dirname(dest)
|
|
26
|
+
if (!existsSync(dir)) await fs.mkdir(dir, { recursive: true })
|
|
27
|
+
await fs.writeFile(dest, file.source)
|
|
28
|
+
written.push(dest)
|
|
29
|
+
}
|
|
30
|
+
return written
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Writes a recipe and all its transitive dependents from a resolved plan. */
|
|
34
|
+
export async function installPlan(plan: InstallPlan, opts: WriteOptions = {}): Promise<string[]> {
|
|
35
|
+
const written: string[] = []
|
|
36
|
+
for (const r of [plan.recipe, ...plan.transitiveRecipes]) {
|
|
37
|
+
written.push(...(await installRecipe(r, opts)))
|
|
38
|
+
}
|
|
39
|
+
return written
|
|
40
|
+
}
|