@hanzogui/recipes 8.3.0 → 8.3.2
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/cjs/admin.js +91 -0
- package/dist/cjs/admin.js.map +1 -0
- package/dist/cjs/config.js +12 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/project.js +46 -0
- package/dist/cjs/project.js.map +1 -0
- package/dist/cjs/read.js +62 -0
- package/dist/cjs/read.js.map +1 -0
- package/dist/cjs/types.js +3 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/write.js +37 -0
- package/dist/cjs/write.js.map +1 -0
- package/dist/esm/admin.js +86 -0
- package/dist/esm/admin.js.map +1 -0
- package/dist/esm/config.js +8 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/project.js +40 -0
- package/dist/esm/project.js.map +1 -0
- package/dist/esm/read.js +56 -0
- package/dist/esm/read.js.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/write.js +32 -0
- package/dist/esm/write.js.map +1 -0
- package/package.json +26 -15
- package/readme.md +1 -6
- package/src/admin.test.ts +116 -0
- package/src/config.test.ts +28 -0
- package/src/project.test.ts +63 -0
- package/src/read.test.ts +163 -0
- package/src/write.test.ts +52 -0
- package/types/admin.d.ts +17 -0
- package/types/admin.d.ts.map +1 -0
- package/types/config.d.ts +6 -0
- package/types/config.d.ts.map +1 -0
- package/types/index.d.ts +7 -0
- package/types/index.d.ts.map +1 -0
- package/types/project.d.ts +8 -0
- package/types/project.d.ts.map +1 -0
- package/types/read.d.ts +15 -0
- package/types/read.d.ts.map +1 -0
- package/types/types.d.ts +27 -0
- package/types/types.d.ts.map +1 -0
- package/types/write.d.ts +12 -0
- package/types/write.d.ts.map +1 -0
- package/dist/admin.js +0 -103
- package/dist/config.js +0 -18
- package/dist/index.js +0 -233
- package/dist/project.js +0 -51
- package/dist/read.js +0 -104
- package/dist/types.js +0 -0
- package/dist/write.js +0 -80
package/package.json
CHANGED
|
@@ -1,37 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzogui/recipes",
|
|
3
|
-
"version": "8.3.
|
|
3
|
+
"version": "8.3.2",
|
|
4
4
|
"description": "Hanzo GUI recipe library: read from Base, resolve transitive deps, install into a project.",
|
|
5
|
+
"homepage": "https://gui.hanzo.ai",
|
|
5
6
|
"license": "BSD-3-Clause",
|
|
6
7
|
"author": "Hanzo AI <dev@hanzo.ai>",
|
|
7
|
-
"homepage": "https://gui.hanzo.ai",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/hanzoai/gui.git",
|
|
11
11
|
"directory": "pkgs/recipes"
|
|
12
12
|
},
|
|
13
|
+
"source": "src/index.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"types",
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
13
19
|
"type": "module",
|
|
14
|
-
"main": "./dist/index.js",
|
|
15
|
-
"
|
|
20
|
+
"main": "./dist/cjs/index.js",
|
|
21
|
+
"module": "./dist/esm/index.js",
|
|
22
|
+
"types": "./types/index.d.ts",
|
|
16
23
|
"exports": {
|
|
24
|
+
"./package.json": "./package.json",
|
|
17
25
|
".": {
|
|
18
|
-
"types": "./
|
|
19
|
-
"
|
|
26
|
+
"types": "./types/index.d.ts",
|
|
27
|
+
"react-native": "./dist/esm/index.js",
|
|
28
|
+
"browser": "./dist/esm/index.js",
|
|
29
|
+
"module": "./dist/esm/index.js",
|
|
30
|
+
"import": "./dist/esm/index.js",
|
|
31
|
+
"require": "./dist/cjs/index.js",
|
|
32
|
+
"default": "./dist/esm/index.js"
|
|
20
33
|
}
|
|
21
34
|
},
|
|
22
|
-
"source": "src/index.ts",
|
|
23
|
-
"files": [
|
|
24
|
-
"src/**/*.ts",
|
|
25
|
-
"!src/**/*.test.ts",
|
|
26
|
-
"dist"
|
|
27
|
-
],
|
|
28
35
|
"publishConfig": {
|
|
29
36
|
"access": "public"
|
|
30
37
|
},
|
|
31
38
|
"scripts": {
|
|
32
|
-
"build": "
|
|
33
|
-
"
|
|
34
|
-
"
|
|
39
|
+
"build": "hanzogui-build --skip-native",
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"clean": "hanzogui-build clean",
|
|
42
|
+
"clean:build": "hanzogui-build clean:build"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@hanzogui/build": "8.3.2"
|
|
35
46
|
},
|
|
36
47
|
"engines": {
|
|
37
48
|
"node": ">=20"
|
package/readme.md
CHANGED
|
@@ -3,12 +3,7 @@
|
|
|
3
3
|
Library for reading and installing Hanzo GUI recipes from Hanzo Base.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import {
|
|
7
|
-
listRecipes,
|
|
8
|
-
getRecipe,
|
|
9
|
-
resolveRecipe,
|
|
10
|
-
installPlan,
|
|
11
|
-
} from '@hanzogui/recipes'
|
|
6
|
+
import { listRecipes, getRecipe, resolveRecipe, installPlan } from '@hanzogui/recipes'
|
|
12
7
|
|
|
13
8
|
// Anonymous read path (used by @hanzogui/get CLI, website, IDE extensions)
|
|
14
9
|
const recipes = await listRecipes()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { authAdmin, ensureCollection, upsertRecipe } from './admin.js'
|
|
3
|
+
|
|
4
|
+
const BASE = 'https://example.test'
|
|
5
|
+
|
|
6
|
+
let calls: Array<{ url: string; method?: string; body?: string }> = []
|
|
7
|
+
let responses: Response[]
|
|
8
|
+
const originalFetch = globalThis.fetch
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
calls = []
|
|
12
|
+
responses = []
|
|
13
|
+
globalThis.fetch = ((url: string, init?: RequestInit) => {
|
|
14
|
+
calls.push({ url, method: init?.method, body: init?.body as string | undefined })
|
|
15
|
+
const r = responses.shift()
|
|
16
|
+
if (!r) throw new Error(`No response queued for ${url}`)
|
|
17
|
+
return Promise.resolve(r)
|
|
18
|
+
}) as typeof fetch
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
globalThis.fetch = originalFetch
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const json = (body: unknown, status = 200) =>
|
|
26
|
+
new Response(JSON.stringify(body), {
|
|
27
|
+
status,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
})
|
|
30
|
+
const empty = (status: number) => new Response('', { status })
|
|
31
|
+
|
|
32
|
+
test('authAdmin POSTs to /v1/base/admins/auth-with-password and returns token', async () => {
|
|
33
|
+
responses = [json({ token: 'tok-abc' })]
|
|
34
|
+
const token = await authAdmin({ baseUrl: BASE, email: 'z@hanzo.ai', password: 'pw' })
|
|
35
|
+
expect(token).toBe('tok-abc')
|
|
36
|
+
expect(calls[0].url).toBe(`${BASE}/v1/base/admins/auth-with-password`)
|
|
37
|
+
expect(calls[0].method).toBe('POST')
|
|
38
|
+
expect(JSON.parse(calls[0].body!)).toEqual({ identity: 'z@hanzo.ai', password: 'pw' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('authAdmin throws on bad credentials', async () => {
|
|
42
|
+
responses = [new Response('bad', { status: 401 })]
|
|
43
|
+
try {
|
|
44
|
+
await authAdmin({ baseUrl: BASE, email: 'z@hanzo.ai', password: 'wrong' })
|
|
45
|
+
throw new Error('expected throw')
|
|
46
|
+
} catch (e: any) {
|
|
47
|
+
expect(e.message).toContain('Admin auth failed')
|
|
48
|
+
expect(e.message).toContain('401')
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('ensureCollection returns "existed" when collection probe is 200', async () => {
|
|
53
|
+
responses = [json({ name: 'gui_recipes' })]
|
|
54
|
+
expect(await ensureCollection({ baseUrl: BASE, token: 't' })).toBe('existed')
|
|
55
|
+
expect(calls).toHaveLength(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('ensureCollection POSTs schema when probe is 404, returns "created"', async () => {
|
|
59
|
+
responses = [empty(404), json({ id: 'new' })]
|
|
60
|
+
expect(await ensureCollection({ baseUrl: BASE, token: 't' })).toBe('created')
|
|
61
|
+
expect(calls[1].method).toBe('POST')
|
|
62
|
+
expect(calls[1].url).toBe(`${BASE}/v1/base/collections`)
|
|
63
|
+
const sent = JSON.parse(calls[1].body!)
|
|
64
|
+
expect(sent.name).toBe('gui_recipes')
|
|
65
|
+
expect(sent.schema.find((f: any) => f.name === 'slug').unique).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('upsertRecipe POSTs new record when no existing match', async () => {
|
|
69
|
+
responses = [
|
|
70
|
+
json({ items: [], page: 1, perPage: 1, totalItems: 0, totalPages: 0 }),
|
|
71
|
+
json({ id: 'new-id' }),
|
|
72
|
+
]
|
|
73
|
+
const result = await upsertRecipe(
|
|
74
|
+
{ baseUrl: BASE, token: 't' },
|
|
75
|
+
{
|
|
76
|
+
slug: 'x',
|
|
77
|
+
name: 'X',
|
|
78
|
+
description: '',
|
|
79
|
+
category: 'misc',
|
|
80
|
+
npmDeps: [],
|
|
81
|
+
recipeDeps: [],
|
|
82
|
+
files: [],
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
expect(result).toBe('created')
|
|
86
|
+
expect(calls[1].method).toBe('POST')
|
|
87
|
+
expect(calls[1].url).toBe(`${BASE}/v1/base/collections/gui_recipes/records`)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('upsertRecipe PATCHes existing record when slug matches', async () => {
|
|
91
|
+
responses = [
|
|
92
|
+
json({
|
|
93
|
+
items: [{ id: 'existing-id' }],
|
|
94
|
+
page: 1,
|
|
95
|
+
perPage: 1,
|
|
96
|
+
totalItems: 1,
|
|
97
|
+
totalPages: 1,
|
|
98
|
+
}),
|
|
99
|
+
json({ id: 'existing-id' }),
|
|
100
|
+
]
|
|
101
|
+
const result = await upsertRecipe(
|
|
102
|
+
{ baseUrl: BASE, token: 't' },
|
|
103
|
+
{
|
|
104
|
+
slug: 'x',
|
|
105
|
+
name: 'X',
|
|
106
|
+
description: '',
|
|
107
|
+
category: 'misc',
|
|
108
|
+
npmDeps: [],
|
|
109
|
+
recipeDeps: [],
|
|
110
|
+
files: [],
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
expect(result).toBe('updated')
|
|
114
|
+
expect(calls[1].method).toBe('PATCH')
|
|
115
|
+
expect(calls[1].url).toBe(`${BASE}/v1/base/collections/gui_recipes/records/existing-id`)
|
|
116
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { resolveBaseUrl, DEFAULT_BASE_URL } from './config.js'
|
|
3
|
+
|
|
4
|
+
const original = process.env.HANZO_BASE_URL
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
if (original === undefined) delete process.env.HANZO_BASE_URL
|
|
8
|
+
else process.env.HANZO_BASE_URL = original
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('resolveBaseUrl returns the override when provided', () => {
|
|
12
|
+
expect(resolveBaseUrl('https://x.example')).toBe('https://x.example')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('resolveBaseUrl reads HANZO_BASE_URL env when no override', () => {
|
|
16
|
+
process.env.HANZO_BASE_URL = 'https://env.example'
|
|
17
|
+
expect(resolveBaseUrl()).toBe('https://env.example')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('resolveBaseUrl falls back to DEFAULT_BASE_URL', () => {
|
|
21
|
+
delete process.env.HANZO_BASE_URL
|
|
22
|
+
expect(resolveBaseUrl()).toBe(DEFAULT_BASE_URL)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('explicit override beats env', () => {
|
|
26
|
+
process.env.HANZO_BASE_URL = 'https://env.example'
|
|
27
|
+
expect(resolveBaseUrl('https://arg.example')).toBe('https://arg.example')
|
|
28
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { findProjectRoot, readConfig, resolveInstallDir } from './project.js'
|
|
6
|
+
import { DEFAULT_INSTALL_DIR } from './config.js'
|
|
7
|
+
|
|
8
|
+
async function tmpDir(): Promise<string> {
|
|
9
|
+
return await mkdtemp(path.join(os.tmpdir(), 'recipes-test-'))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test('findProjectRoot returns nearest dir with workspaces in package.json', async () => {
|
|
13
|
+
const root = await tmpDir()
|
|
14
|
+
await writeFile(path.join(root, 'package.json'), JSON.stringify({ workspaces: [] }))
|
|
15
|
+
const child = path.join(root, 'apps', 'foo')
|
|
16
|
+
await mkdir(child, { recursive: true })
|
|
17
|
+
expect(await findProjectRoot(child)).toBe(root)
|
|
18
|
+
await rm(root, { recursive: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('findProjectRoot falls back to .git root when no workspaces', async () => {
|
|
22
|
+
const root = await tmpDir()
|
|
23
|
+
await mkdir(path.join(root, '.git'))
|
|
24
|
+
const child = path.join(root, 'src')
|
|
25
|
+
await mkdir(child, { recursive: true })
|
|
26
|
+
expect(await findProjectRoot(child)).toBe(root)
|
|
27
|
+
await rm(root, { recursive: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('findProjectRoot returns start when nothing matches', async () => {
|
|
31
|
+
const root = await tmpDir()
|
|
32
|
+
expect(await findProjectRoot(root)).toBe(root)
|
|
33
|
+
await rm(root, { recursive: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('readConfig prefers gui.config.json over .guirc.json', async () => {
|
|
37
|
+
const root = await tmpDir()
|
|
38
|
+
await writeFile(path.join(root, 'gui.config.json'), JSON.stringify({ installDir: 'a' }))
|
|
39
|
+
await writeFile(path.join(root, '.guirc.json'), JSON.stringify({ installDir: 'b' }))
|
|
40
|
+
expect((await readConfig(root)).installDir).toBe('a')
|
|
41
|
+
await rm(root, { recursive: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('readConfig returns {} when no config file', async () => {
|
|
45
|
+
const root = await tmpDir()
|
|
46
|
+
expect(await readConfig(root)).toEqual({})
|
|
47
|
+
await rm(root, { recursive: true })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('readConfig returns {} when config is invalid JSON', async () => {
|
|
51
|
+
const root = await tmpDir()
|
|
52
|
+
await writeFile(path.join(root, 'gui.config.json'), '{ broken')
|
|
53
|
+
expect(await readConfig(root)).toEqual({})
|
|
54
|
+
await rm(root, { recursive: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('resolveInstallDir uses config.installDir when present', () => {
|
|
58
|
+
expect(resolveInstallDir('/r', { installDir: 'src/widgets' })).toBe('/r/src/widgets')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('resolveInstallDir falls back to DEFAULT_INSTALL_DIR', () => {
|
|
62
|
+
expect(resolveInstallDir('/r', {})).toBe(`/r/${DEFAULT_INSTALL_DIR}`)
|
|
63
|
+
})
|
package/src/read.test.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach, mock } from 'bun:test'
|
|
2
|
+
import { listRecipes, getRecipe, resolveRecipe } from './read.js'
|
|
3
|
+
import type { Recipe } from './types.js'
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://example.test'
|
|
6
|
+
|
|
7
|
+
type FetchCall = { url: string; init?: RequestInit }
|
|
8
|
+
|
|
9
|
+
let calls: FetchCall[] = []
|
|
10
|
+
let responses: Array<(url: string) => Response>
|
|
11
|
+
const originalFetch = globalThis.fetch
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
calls = []
|
|
15
|
+
responses = []
|
|
16
|
+
globalThis.fetch = ((url: string, init?: RequestInit) => {
|
|
17
|
+
calls.push({ url, init })
|
|
18
|
+
const handler = responses.shift()
|
|
19
|
+
if (!handler) throw new Error(`No response queued for ${url}`)
|
|
20
|
+
return Promise.resolve(handler(url))
|
|
21
|
+
}) as typeof fetch
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
globalThis.fetch = originalFetch
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const jsonResponse = (body: unknown, status = 200) =>
|
|
29
|
+
new Response(JSON.stringify(body), {
|
|
30
|
+
status,
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const recipeFixture = (slug: string, recipeDeps: string[] = []): Recipe => ({
|
|
35
|
+
id: `id-${slug}`,
|
|
36
|
+
slug,
|
|
37
|
+
name: slug,
|
|
38
|
+
description: '',
|
|
39
|
+
category: 'test',
|
|
40
|
+
npmDeps: [],
|
|
41
|
+
recipeDeps,
|
|
42
|
+
files: [{ path: `${slug}.tsx`, source: `// ${slug}` }],
|
|
43
|
+
created: '',
|
|
44
|
+
updated: '',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('listRecipes hits /v1/base path and returns items', async () => {
|
|
48
|
+
responses = [
|
|
49
|
+
() =>
|
|
50
|
+
jsonResponse({
|
|
51
|
+
items: [recipeFixture('a'), recipeFixture('b')],
|
|
52
|
+
page: 1,
|
|
53
|
+
perPage: 200,
|
|
54
|
+
totalItems: 2,
|
|
55
|
+
totalPages: 1,
|
|
56
|
+
}),
|
|
57
|
+
]
|
|
58
|
+
const recipes = await listRecipes({ baseUrl: BASE })
|
|
59
|
+
expect(recipes).toHaveLength(2)
|
|
60
|
+
expect(calls[0].url).toContain('/v1/base/collections/gui_recipes/records')
|
|
61
|
+
expect(calls[0].url).not.toContain('/api/')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('getRecipe filters by slug and returns first item', async () => {
|
|
65
|
+
responses = [
|
|
66
|
+
() =>
|
|
67
|
+
jsonResponse({
|
|
68
|
+
items: [recipeFixture('foo')],
|
|
69
|
+
page: 1,
|
|
70
|
+
perPage: 1,
|
|
71
|
+
totalItems: 1,
|
|
72
|
+
totalPages: 1,
|
|
73
|
+
}),
|
|
74
|
+
]
|
|
75
|
+
const recipe = await getRecipe('foo', { baseUrl: BASE })
|
|
76
|
+
expect(recipe.slug).toBe('foo')
|
|
77
|
+
expect(calls[0].url).toContain('filter=')
|
|
78
|
+
expect(decodeURIComponent(calls[0].url)).toContain('slug="foo"')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('getRecipe throws 404 when no items', async () => {
|
|
82
|
+
responses = [
|
|
83
|
+
() => jsonResponse({ items: [], page: 1, perPage: 1, totalItems: 0, totalPages: 0 }),
|
|
84
|
+
]
|
|
85
|
+
try {
|
|
86
|
+
await getRecipe('missing', { baseUrl: BASE })
|
|
87
|
+
throw new Error('should have thrown')
|
|
88
|
+
} catch (e: any) {
|
|
89
|
+
expect(e.status).toBe(404)
|
|
90
|
+
expect(e.message).toContain('missing')
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('resolveRecipe walks transitive recipeDeps', async () => {
|
|
95
|
+
responses = [
|
|
96
|
+
() =>
|
|
97
|
+
jsonResponse({
|
|
98
|
+
items: [recipeFixture('parent', ['childA', 'childB'])],
|
|
99
|
+
page: 1,
|
|
100
|
+
perPage: 1,
|
|
101
|
+
totalItems: 1,
|
|
102
|
+
totalPages: 1,
|
|
103
|
+
}),
|
|
104
|
+
() =>
|
|
105
|
+
jsonResponse({
|
|
106
|
+
items: [recipeFixture('childA', ['grandchild'])],
|
|
107
|
+
page: 1,
|
|
108
|
+
perPage: 1,
|
|
109
|
+
totalItems: 1,
|
|
110
|
+
totalPages: 1,
|
|
111
|
+
}),
|
|
112
|
+
() =>
|
|
113
|
+
jsonResponse({
|
|
114
|
+
items: [recipeFixture('childB')],
|
|
115
|
+
page: 1,
|
|
116
|
+
perPage: 1,
|
|
117
|
+
totalItems: 1,
|
|
118
|
+
totalPages: 1,
|
|
119
|
+
}),
|
|
120
|
+
() =>
|
|
121
|
+
jsonResponse({
|
|
122
|
+
items: [recipeFixture('grandchild')],
|
|
123
|
+
page: 1,
|
|
124
|
+
perPage: 1,
|
|
125
|
+
totalItems: 1,
|
|
126
|
+
totalPages: 1,
|
|
127
|
+
}),
|
|
128
|
+
]
|
|
129
|
+
const plan = await resolveRecipe('parent', { baseUrl: BASE, installDir: '/tmp/test' })
|
|
130
|
+
expect(plan.recipe.slug).toBe('parent')
|
|
131
|
+
expect(plan.transitiveRecipes.map((r) => r.slug).sort()).toEqual([
|
|
132
|
+
'childA',
|
|
133
|
+
'childB',
|
|
134
|
+
'grandchild',
|
|
135
|
+
])
|
|
136
|
+
expect(plan.targetPaths).toHaveLength(4)
|
|
137
|
+
expect(plan.targetPaths.every((p) => p.startsWith('/tmp/test/'))).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('resolveRecipe avoids re-fetching cycles', async () => {
|
|
141
|
+
responses = [
|
|
142
|
+
() =>
|
|
143
|
+
jsonResponse({
|
|
144
|
+
items: [recipeFixture('a', ['b'])],
|
|
145
|
+
page: 1,
|
|
146
|
+
perPage: 1,
|
|
147
|
+
totalItems: 1,
|
|
148
|
+
totalPages: 1,
|
|
149
|
+
}),
|
|
150
|
+
() =>
|
|
151
|
+
jsonResponse({
|
|
152
|
+
items: [recipeFixture('b', ['a'])],
|
|
153
|
+
page: 1,
|
|
154
|
+
perPage: 1,
|
|
155
|
+
totalItems: 1,
|
|
156
|
+
totalPages: 1,
|
|
157
|
+
}),
|
|
158
|
+
]
|
|
159
|
+
const plan = await resolveRecipe('a', { baseUrl: BASE, installDir: '/tmp/test' })
|
|
160
|
+
expect(plan.recipe.slug).toBe('a')
|
|
161
|
+
expect(plan.transitiveRecipes.map((r) => r.slug)).toEqual(['b'])
|
|
162
|
+
expect(calls).toHaveLength(2) // not 3 — cycle detected
|
|
163
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { installRecipe, installPlan } from './write.js'
|
|
6
|
+
import type { Recipe, InstallPlan } from './types.js'
|
|
7
|
+
|
|
8
|
+
const stub = (slug: string, files: Array<[string, string]>): Recipe => ({
|
|
9
|
+
id: slug,
|
|
10
|
+
slug,
|
|
11
|
+
name: slug,
|
|
12
|
+
description: '',
|
|
13
|
+
category: 'test',
|
|
14
|
+
npmDeps: [],
|
|
15
|
+
recipeDeps: [],
|
|
16
|
+
files: files.map(([p, s]) => ({ path: p, source: s })),
|
|
17
|
+
created: '',
|
|
18
|
+
updated: '',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('installRecipe writes files into installDir, creating subdirs', async () => {
|
|
22
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'recipes-write-'))
|
|
23
|
+
const recipe = stub('foo', [
|
|
24
|
+
['Foo.tsx', 'export const Foo = 1\n'],
|
|
25
|
+
['nested/Deep.tsx', 'export const Deep = 1\n'],
|
|
26
|
+
])
|
|
27
|
+
const written = await installRecipe(recipe, { installDir: dir })
|
|
28
|
+
expect(written).toHaveLength(2)
|
|
29
|
+
expect(await readFile(path.join(dir, 'Foo.tsx'), 'utf8')).toBe('export const Foo = 1\n')
|
|
30
|
+
expect(await readFile(path.join(dir, 'nested/Deep.tsx'), 'utf8')).toBe(
|
|
31
|
+
'export const Deep = 1\n'
|
|
32
|
+
)
|
|
33
|
+
await rm(dir, { recursive: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('installPlan writes the recipe plus its transitive deps', async () => {
|
|
37
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'recipes-plan-'))
|
|
38
|
+
const plan: InstallPlan = {
|
|
39
|
+
recipe: stub('parent', [['Parent.tsx', 'P']]),
|
|
40
|
+
transitiveRecipes: [
|
|
41
|
+
stub('childA', [['ChildA.tsx', 'A']]),
|
|
42
|
+
stub('childB', [['nested/ChildB.tsx', 'B']]),
|
|
43
|
+
],
|
|
44
|
+
targetPaths: [],
|
|
45
|
+
}
|
|
46
|
+
const written = await installPlan(plan, { installDir: dir })
|
|
47
|
+
expect(written).toHaveLength(3)
|
|
48
|
+
expect(await readFile(path.join(dir, 'Parent.tsx'), 'utf8')).toBe('P')
|
|
49
|
+
expect(await readFile(path.join(dir, 'ChildA.tsx'), 'utf8')).toBe('A')
|
|
50
|
+
expect(await readFile(path.join(dir, 'nested/ChildB.tsx'), 'utf8')).toBe('B')
|
|
51
|
+
await rm(dir, { recursive: true })
|
|
52
|
+
})
|
package/types/admin.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RecipeInput } from './types.js';
|
|
2
|
+
export type AdminAuth = {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
token: string;
|
|
5
|
+
};
|
|
6
|
+
export type AdminPasswordAuth = {
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
email: string;
|
|
9
|
+
password: string;
|
|
10
|
+
};
|
|
11
|
+
/** Exchanges admin email+password for a Base session token. */
|
|
12
|
+
export declare function authAdmin({ baseUrl, email, password, }: AdminPasswordAuth): Promise<string>;
|
|
13
|
+
/** Idempotent: creates the gui_recipes collection if missing, no-ops otherwise. */
|
|
14
|
+
export declare function ensureCollection(auth: AdminAuth): Promise<'created' | 'existed'>;
|
|
15
|
+
/** Upsert by slug: PATCH if existing, POST if new. */
|
|
16
|
+
export declare function upsertRecipe(auth: AdminAuth, recipe: RecipeInput): Promise<'created' | 'updated'>;
|
|
17
|
+
//# sourceMappingURL=admin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.d.ts","sourceRoot":"","sources":["../src/admin.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAU,WAAW,EAAE,MAAM,YAAY,CAAA;AAErD,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,+DAA+D;AAC/D,wBAAsB,SAAS,CAAC,EAC9B,OAAO,EACP,KAAK,EACL,QAAQ,GACT,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAYrC;AAED,mFAAmF;AACnF,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAsCtF;AAcD,sDAAsD;AACtD,wBAAsB,YAAY,CAChC,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAqBhC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const DEFAULT_BASE_URL = "https://base.hanzo.ai";
|
|
2
|
+
export declare const RECIPES_COLLECTION = "gui_recipes";
|
|
3
|
+
export declare const DEFAULT_INSTALL_DIR = "src/gui";
|
|
4
|
+
export declare const DEFAULT_CONFIG_FILENAMES: readonly ['gui.config.json', '.guirc.json'];
|
|
5
|
+
export declare function resolveBaseUrl(override?: string): string;
|
|
6
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,gBAAgB,0BAA0B,CAAA;AACvD,eAAO,MAAM,kBAAkB,gBAAgB,CAAA;AAC/C,eAAO,MAAM,mBAAmB,YAAY,CAAA;AAC5C,eAAO,MAAM,wBAAwB,YAAI,iBAAiB,EAAE,aAAa,CAAU,CAAA;AAEnF,wBAAgB,cAAc,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAExD"}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ProjectConfig } from './types.js';
|
|
2
|
+
/** Walks up from `start` until it finds a workspace root, git root, or any package.json. */
|
|
3
|
+
export declare function findProjectRoot(start?: string): Promise<string>;
|
|
4
|
+
/** Reads the first config file that exists in `root`. Returns `{}` if none. */
|
|
5
|
+
export declare function readConfig(root: string): Promise<ProjectConfig>;
|
|
6
|
+
/** Pure resolver: given a root and config, where do recipes install? */
|
|
7
|
+
export declare function resolveInstallDir(root: string, config: ProjectConfig): string;
|
|
8
|
+
//# sourceMappingURL=project.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE/C,4FAA4F;AAC5F,wBAAsB,eAAe,CAAC,KAAK,GAAE,MAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,CAepF;AAED,+EAA+E;AAC/E,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAUrE;AAED,wEAAwE;AACxE,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAE7E"}
|
package/types/read.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { InstallPlan, Recipe, RecipeSummary } from './types.js';
|
|
2
|
+
export type ReaderOptions = {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function listRecipes(opts?: ReaderOptions): Promise<RecipeSummary[]>;
|
|
6
|
+
export declare function getRecipe(slug: string, opts?: ReaderOptions): Promise<Recipe>;
|
|
7
|
+
export type ResolveOptions = ReaderOptions & {
|
|
8
|
+
/** Override the install dir lookup; if omitted, resolved from the user's project. */
|
|
9
|
+
installDir?: string;
|
|
10
|
+
/** Override the project root for path resolution. */
|
|
11
|
+
cwd?: string;
|
|
12
|
+
};
|
|
13
|
+
/** Walks recipeDeps transitively, computes target paths under the resolved install dir. */
|
|
14
|
+
export declare function resolveRecipe(slug: string, opts?: ResolveOptions): Promise<InstallPlan>;
|
|
15
|
+
//# sourceMappingURL=read.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../src/read.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAUpE,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAmBD,wBAAsB,WAAW,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAIpF;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CASvF;AAED,MAAM,MAAM,cAAc,GAAG,aAAa,GAAG;IAC3C,qFAAqF;IACrF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,qDAAqD;IACrD,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,2FAA2F;AAC3F,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,cAAmB,GACxB,OAAO,CAAC,WAAW,CAAC,CA4BtB"}
|
package/types/types.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type RecipeFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
source: string;
|
|
4
|
+
};
|
|
5
|
+
export type Recipe = {
|
|
6
|
+
id: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
category: string;
|
|
11
|
+
npmDeps: string[];
|
|
12
|
+
recipeDeps: string[];
|
|
13
|
+
files: RecipeFile[];
|
|
14
|
+
created: string;
|
|
15
|
+
updated: string;
|
|
16
|
+
};
|
|
17
|
+
export type RecipeSummary = Pick<Recipe, 'id' | 'slug' | 'name' | 'description' | 'category'>;
|
|
18
|
+
export type RecipeInput = Pick<Recipe, 'slug' | 'name' | 'description' | 'category' | 'npmDeps' | 'recipeDeps' | 'files'>;
|
|
19
|
+
export type InstallPlan = {
|
|
20
|
+
recipe: Recipe;
|
|
21
|
+
transitiveRecipes: Recipe[];
|
|
22
|
+
targetPaths: string[];
|
|
23
|
+
};
|
|
24
|
+
export type ProjectConfig = {
|
|
25
|
+
installDir?: string;
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED,MAAM,MAAM,MAAM,GAAG;IACnB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG,IAAI,CAC9B,MAAM,EACN,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,aAAa,GAAG,UAAU,CACpD,CAAA;AAED,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,MAAM,EACN,MAAM,GAAG,MAAM,GAAG,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,YAAY,GAAG,OAAO,CAClF,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,MAAM,CAAA;IACd,iBAAiB,EAAE,MAAM,EAAE,CAAA;IAC3B,WAAW,EAAE,MAAM,EAAE,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA"}
|
package/types/write.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { InstallPlan, Recipe } from './types.js';
|
|
2
|
+
export type WriteOptions = {
|
|
3
|
+
/** Override the install dir; if omitted, resolved from the user's project. */
|
|
4
|
+
installDir?: string;
|
|
5
|
+
/** Override project root for resolution. */
|
|
6
|
+
cwd?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Writes one recipe's files. Returns absolute paths written. */
|
|
9
|
+
export declare function installRecipe(recipe: Recipe, opts?: WriteOptions): Promise<string[]>;
|
|
10
|
+
/** Writes a recipe and all its transitive dependents from a resolved plan. */
|
|
11
|
+
export declare function installPlan(plan: InstallPlan, opts?: WriteOptions): Promise<string[]>;
|
|
12
|
+
//# sourceMappingURL=write.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../src/write.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAErD,MAAM,MAAM,YAAY,GAAG;IACzB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAQD,iEAAiE;AACjE,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,IAAI,GAAE,YAAiB,GACtB,OAAO,CAAC,MAAM,EAAE,CAAC,CAWnB;AAED,8EAA8E;AAC9E,wBAAsB,WAAW,CAC/B,IAAI,EAAE,WAAW,EACjB,IAAI,GAAE,YAAiB,GACtB,OAAO,CAAC,MAAM,EAAE,CAAC,CAMnB"}
|