Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a51d67da0 | |||
| c1a06df68f | |||
| 43948d9247 | |||
| d8fac6750e | |||
| 2aa26a5bdd | |||
| 90777e3877 | |||
| 764f657201 | |||
| 225aa9f9e7 | |||
| caa8e103ff | |||
| a234201235 | |||
| f21906ca6a | |||
| 222a382d49 | |||
| 5d38b599fd | |||
| 558e80b4da | |||
| 934b9265d9 | |||
| 013b55e97e | |||
| b090695414 | |||
| ba00b98038 | |||
| 9c4333defb | |||
| 4d6e77ff2d | |||
| b1e302b3b5 | |||
| 8f4c5dbe59 | |||
| 642b61dc9f | |||
| b932d96a88 | |||
| eeafd5c094 | |||
| a410af0b6c | |||
| 886a8e31d0 | |||
| b9bf3ec3d5 | |||
| ef1f789525 | |||
| e5b1ce5420 | |||
| b778086f4a | |||
| f336fb2fca | |||
| b44adda6d2 | |||
| 5a8d27673b | |||
| 6e92ca0372 | |||
| 38a3cccbee | |||
| b17d0080ee | |||
| 35cdda7061 | |||
| 1af7655ac6 | |||
| eba87b9ea9 | |||
| 8346596ceb | |||
| 2ff51f00ca | |||
| da84425d25 | |||
| 465c505000 | |||
| c152f5fc1d | |||
| c5d8924fb2 | |||
| 9438d1f351 | |||
| d617793325 | |||
| d84d6fc0ec | |||
| 2b46bea764 | |||
| 5daeede45f | |||
| 36d7492464 | |||
| ad5a83d3d8 | |||
| 6a75117ba3 | |||
| 95a1462b65 | |||
| 62f1d1d390 |
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
Binary file not shown.
+898
@@ -0,0 +1,898 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.25
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20250408.0
|
||||
version: 4.20260609.1
|
||||
typescript:
|
||||
specifier: ^5.4.0
|
||||
version: 5.9.3
|
||||
wrangler:
|
||||
specifier: ^4.0.0
|
||||
version: 4.98.0(@cloudflare/workers-types@4.20260609.1)
|
||||
|
||||
packages:
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.5.0':
|
||||
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@cloudflare/unenv-preset@2.16.1':
|
||||
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
|
||||
peerDependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: '>1.20260305.0 <2.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260603.1':
|
||||
resolution: {integrity: sha512-cEXDWu6V3ZrpmwWkM4OJE9AeXjdAgOY5rh8EHhcBVCuP5rxnzUbPzLtrVOHx0UUUAcCrFq0Xsa6mZKL1VUZsKQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260603.1':
|
||||
resolution: {integrity: sha512-uBPK4LaWJNbbCYwPnUAehlHbbVulhVZPZsdcAhBPfZhHb3QAuAEPAQepO/P67R3V6Cni4YGx1fLbL8A5wwoaNA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260603.1':
|
||||
resolution: {integrity: sha512-ht9l6/8Tk7Rp6kA4S9oFZ4X8u0VjnnFdmU/6B3fnABYKREYTKh2RdOqXqXxcp5eNJseireKnWik/hQOPK1CutQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260603.1':
|
||||
resolution: {integrity: sha512-LJZ6x00rAjSrobV4m0ZW0TpH5ilBbKcWBzlH+y+KOUsIE/CpTuhAzKV43TbSnFLRX5+jrWKiz2v0hO91lPXy6A==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260603.1':
|
||||
resolution: {integrity: sha512-DvwqkXMAJRPoDN4PxapAwhlz/6ouD+6R1ttbAEK3cWD/QBvFF5STx7Ds/9Irf+rBly3np3uHWkeX+wZnNFEuzA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cloudflare/workers-types@4.20260609.1':
|
||||
resolution: {integrity: sha512-krGHtwSApCFBjTe1NTx/TFQ0P5i/bHGQOqCPnCLssb8rOKaAG4JkPFJZsossr0z/ZTMnpP2Tid5jWju+/i0hCA==}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@emnapi/runtime@1.11.0':
|
||||
resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.3':
|
||||
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.3':
|
||||
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.3':
|
||||
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.3':
|
||||
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.3':
|
||||
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.3':
|
||||
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.3':
|
||||
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.3':
|
||||
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.3':
|
||||
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.3':
|
||||
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
|
||||
|
||||
'@poppinss/dumper@0.6.5':
|
||||
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
|
||||
|
||||
'@poppinss/exception@1.2.3':
|
||||
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
||||
|
||||
'@sindresorhus/is@7.2.0':
|
||||
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@speed-highlight/core@1.2.16':
|
||||
resolution: {integrity: sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==}
|
||||
|
||||
blake3-wasm@2.1.5:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
esbuild@0.27.3:
|
||||
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
hono@4.12.25:
|
||||
resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
miniflare@4.20260603.0:
|
||||
resolution: {integrity: sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
hasBin: true
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
semver@7.8.3:
|
||||
resolution: {integrity: sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici@7.24.8:
|
||||
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||
|
||||
workerd@1.20260603.1:
|
||||
resolution: {integrity: sha512-NPcbhI1++CS+fnELyXtsIR52en+5kwr/OrKeiQeYXGy10HxmPdsQBv9N+DU7hJIOOmBHhOGAAsoGDjyiQ2YCaA==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.98.0:
|
||||
resolution: {integrity: sha512-cXfFUuF4rMIvE0hiMnXjEAB27ERryaCgquBJdUoPIjFzYYE1rbRdMUkEdQ18qDPUtsPvhJdqxLntixT9OfSzQw==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^4.20260603.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
||||
ws@8.20.1:
|
||||
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
youch-core@0.3.3:
|
||||
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
|
||||
|
||||
youch@4.1.0-beta.10:
|
||||
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.5.0': {}
|
||||
|
||||
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1)':
|
||||
dependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
optionalDependencies:
|
||||
workerd: 1.20260603.1
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260603.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260603.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260603.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260603.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260603.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workers-types@4.20260609.1': {}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@emnapi/runtime@1.11.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.0
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
dependencies:
|
||||
kleur: 4.1.5
|
||||
|
||||
'@poppinss/dumper@0.6.5':
|
||||
dependencies:
|
||||
'@poppinss/colors': 4.1.6
|
||||
'@sindresorhus/is': 7.2.0
|
||||
supports-color: 10.2.2
|
||||
|
||||
'@poppinss/exception@1.2.3': {}
|
||||
|
||||
'@sindresorhus/is@7.2.0': {}
|
||||
|
||||
'@speed-highlight/core@1.2.16': {}
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
esbuild@0.27.3:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.3
|
||||
'@esbuild/android-arm': 0.27.3
|
||||
'@esbuild/android-arm64': 0.27.3
|
||||
'@esbuild/android-x64': 0.27.3
|
||||
'@esbuild/darwin-arm64': 0.27.3
|
||||
'@esbuild/darwin-x64': 0.27.3
|
||||
'@esbuild/freebsd-arm64': 0.27.3
|
||||
'@esbuild/freebsd-x64': 0.27.3
|
||||
'@esbuild/linux-arm': 0.27.3
|
||||
'@esbuild/linux-arm64': 0.27.3
|
||||
'@esbuild/linux-ia32': 0.27.3
|
||||
'@esbuild/linux-loong64': 0.27.3
|
||||
'@esbuild/linux-mips64el': 0.27.3
|
||||
'@esbuild/linux-ppc64': 0.27.3
|
||||
'@esbuild/linux-riscv64': 0.27.3
|
||||
'@esbuild/linux-s390x': 0.27.3
|
||||
'@esbuild/linux-x64': 0.27.3
|
||||
'@esbuild/netbsd-arm64': 0.27.3
|
||||
'@esbuild/netbsd-x64': 0.27.3
|
||||
'@esbuild/openbsd-arm64': 0.27.3
|
||||
'@esbuild/openbsd-x64': 0.27.3
|
||||
'@esbuild/openharmony-arm64': 0.27.3
|
||||
'@esbuild/sunos-x64': 0.27.3
|
||||
'@esbuild/win32-arm64': 0.27.3
|
||||
'@esbuild/win32-ia32': 0.27.3
|
||||
'@esbuild/win32-x64': 0.27.3
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
hono@4.12.25: {}
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
miniflare@4.20260603.0:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
sharp: 0.34.5
|
||||
undici: 7.24.8
|
||||
workerd: 1.20260603.1
|
||||
ws: 8.20.1
|
||||
youch: 4.1.0-beta.10
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
path-to-regexp@6.3.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
semver@7.8.3: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.3
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-ppc64': 0.34.5
|
||||
'@img/sharp-linux-riscv64': 0.34.5
|
||||
'@img/sharp-linux-s390x': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-wasm32': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-ia32': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici@7.24.8: {}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
|
||||
workerd@1.20260603.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20260603.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260603.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260603.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260603.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260603.1
|
||||
|
||||
wrangler@4.98.0(@cloudflare/workers-types@4.20260609.1):
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.5.0
|
||||
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1)
|
||||
blake3-wasm: 2.1.5
|
||||
esbuild: 0.27.3
|
||||
miniflare: 4.20260603.0
|
||||
path-to-regexp: 6.3.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20260603.1
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260609.1
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
ws@8.20.1: {}
|
||||
|
||||
youch-core@0.3.3:
|
||||
dependencies:
|
||||
'@poppinss/exception': 1.2.3
|
||||
error-stack-parser-es: 1.0.5
|
||||
|
||||
youch@4.1.0-beta.10:
|
||||
dependencies:
|
||||
'@poppinss/colors': 4.1.6
|
||||
'@poppinss/dumper': 0.6.5
|
||||
'@speed-highlight/core': 1.2.16
|
||||
cookie: 1.1.1
|
||||
youch-core: 0.3.3
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -72,7 +72,13 @@ async function runWasm(input: unknown): Promise<unknown> {
|
||||
init.body = body;
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
return await res.text();
|
||||
const text = await res.text();
|
||||
// 修架構債(同 http_request):非 2xx 包成帶 "error" key 的 envelope,
|
||||
// 讓 WASM 端既有的 error 判定正確識別失敗(原本只回 body 丟掉 status → 4xx 被判 success)。
|
||||
if (!res.ok) {
|
||||
return JSON.stringify({ error: `HTTP ${res.status}`, status: res.status, body: text });
|
||||
}
|
||||
return text;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -61,7 +61,19 @@ async function runWasm(input: unknown): Promise<unknown> {
|
||||
init.body = body;
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
return await res.text();
|
||||
const text = await res.text();
|
||||
// 修架構債(main.go:112):host function 原本只回 body,丟掉 HTTP status code,
|
||||
// 導致 4xx/5xx(如 Notion 401)被零件判成 success → 引擎對失敗報告成功(系統假綠根因)。
|
||||
// 修法:非 2xx 包成帶 "error" key 的 envelope,讓所有消費零件既有的 parsed["error"] 判定
|
||||
// 鏈正確識別失敗(2xx 維持原樣回 body 原文,向後相容不破壞 happy path)。
|
||||
if (!res.ok) {
|
||||
return JSON.stringify({
|
||||
error: `HTTP ${res.status}`,
|
||||
status: res.status,
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
return text;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -55,7 +55,13 @@ async function runWasm(input: unknown): Promise<unknown> {
|
||||
init.body = body;
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
return await res.text();
|
||||
const text = await res.text();
|
||||
// 修架構債(同 http_request):非 2xx 包成帶 "error" key 的 envelope,
|
||||
// 讓 WASM 端既有的 error 判定正確識別失敗(原本只回 body 丟掉 status → 4xx 被判 success)。
|
||||
if (!res.ok) {
|
||||
return JSON.stringify({ error: `HTTP ${res.status}`, status: res.status, body: text });
|
||||
}
|
||||
return text;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -58,7 +58,13 @@ async function runWasm(input: unknown): Promise<unknown> {
|
||||
init.body = body;
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
return await res.text();
|
||||
const text = await res.text();
|
||||
// 修架構債(同 http_request):非 2xx 包成帶 "error" key 的 envelope,
|
||||
// 讓 WASM 端既有的 error 判定正確識別失敗(原本只回 body 丟掉 status → 4xx 被判 success)。
|
||||
if (!res.ok) {
|
||||
return JSON.stringify({ error: `HTTP ${res.status}`, status: res.status, body: text });
|
||||
}
|
||||
return text;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -0,0 +1,57 @@
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# arcrun self-hosted .env 範本
|
||||
#
|
||||
# 用法(AI 操盤手會幫你做):把這個檔複製成 .env,然後照下面說明,
|
||||
# 一格一格把「=」右邊填上。左邊的名稱(KEY)不要改。
|
||||
# cp .env.example .env
|
||||
#
|
||||
# 這個 .env 只放在你自己電腦/專案,已被 .gitignore 排除,不會上傳。
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ── ① Cloudflare(最基礎,這兩格沒填,下面什麼都跑不了)────────────────────────
|
||||
#
|
||||
# arcrun 跑在「你自己的 Cloudflare」上(免費額度即可,不必綁信用卡)。
|
||||
# 你要先有一個 Cloudflare 帳號,然後拿兩串東西貼回來:
|
||||
#
|
||||
# 1) 帳號代碼(Account ID):
|
||||
# 登入 https://dash.cloudflare.com → 右側欄就有「Account ID」→ 複製貼到下面。
|
||||
#
|
||||
# 2) 金鑰(API Token):
|
||||
# https://dash.cloudflare.com/profile/api-tokens → Create Custom Token →
|
||||
# 勾三組權限(缺一不可):
|
||||
# · Account / Workers Scripts / Edit
|
||||
# · Account / Workers KV Storage / Edit
|
||||
# · Account / D1 / Edit ← 必勾!arcrun 用 D1 存 workflow/recipe,
|
||||
# 漏勾會在 acr init 建 D1 時報 Authentication error
|
||||
# → 建立後複製那串 token 貼到下面。(不需要 R2、不需要綁卡,D1 也在免費額度。)
|
||||
#
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
|
||||
|
||||
# ── ② 身份與加密(自架單人用,這兩格你自己決定/保管)──────────────────────────
|
||||
#
|
||||
# NAMESPACE:你的資料分區標籤。隨便取個英數小名即可(例:leo、myteam)。
|
||||
# 這不是密碼,只是用來分隔你的資料。
|
||||
#
|
||||
# ENCRYPTION_KEY:你的 credential 加密金鑰,64 個以上的 hex 字元。你自己保管。
|
||||
# 不會的話,AI 可以幫你產一串:
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
# ⚠️ 這串忘了 = 你之前上傳加密的 credential 就解不開了,請留底。
|
||||
# (安裝完還要把「同一串」設進你的 worker,acr init 會印確切指令給你跟著做。)
|
||||
#
|
||||
NAMESPACE=
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
|
||||
# ── ③ 各服務的 token(要連哪個服務才填哪個;可之後再加)────────────────────────
|
||||
#
|
||||
# 連外部服務(Notion、Gmail、Telegram…)的 token 放這裡,給 AI 幫你
|
||||
# 透過 `acr creds push` 加密上傳(不會明文留在雲端)。要連什麼就加一行。
|
||||
#
|
||||
# 例:連 Notion → 去 https://www.notion.com/my-integrations 建一個 integration、
|
||||
# 拿它的 token(ntn_… 開頭),把你要讀的 database 分享給這個 integration,
|
||||
# 然後填在下面:
|
||||
#
|
||||
# NOTION_INTEGRATION_TOKEN=
|
||||
@@ -16,6 +16,8 @@ credentials.yaml
|
||||
~/.arcrun/
|
||||
.env
|
||||
.env.*
|
||||
# 範本(無值,需進 repo 給 self-host 用戶 cp 成 .env)——必須放行
|
||||
!.env.example
|
||||
# 任何測試/真實憑證一律不進 repo(2026-06-03:曾誤 commit GCP SA 金鑰被 GitHub push protection 擋)
|
||||
docs/test_credentials/
|
||||
*.sa.json
|
||||
@@ -45,3 +47,7 @@ CONTRIBUTING.md
|
||||
ruvector.db
|
||||
**/.swarm/
|
||||
**/ruvector.db
|
||||
scripts/__pycache__/
|
||||
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
|
||||
*.sql
|
||||
backup-*.sql
|
||||
|
||||
@@ -76,9 +76,15 @@ arcrun 只有兩種東西,分清楚就不會做歪:
|
||||
- recipe = `http_request` + 一組固定設定(endpoint / method / headers / body 模板)。
|
||||
- 要打 Notion / Slack / 你自己的 API?**寫一個 recipe**,不是做一個零件。
|
||||
- recipe 是純文字、不用 deploy、改一次零成本。AI 讀得懂 API 文件就能幫你組出 recipe。
|
||||
- **recipe 是公共資產,發現缺的就做一個投稿。** `acr recipe push` = 投稿到公共庫(公開/私有只是屬性,不是兩條路)。沒有現成 recipe 打你要的 API?**自己寫一個 push 上去**——這是被鼓勵的,不必問人,下一個人也能用。
|
||||
|
||||
> **一句話判準**:要打一個固定的外部 endpoint → **recipe**。要做流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**。其他大部分情況 → 直接寫**工作流**把它們串起來。
|
||||
|
||||
### 兩條容易誤解的紅線(AI 操盤手特別注意)
|
||||
|
||||
- **缺一個能力 → 補 API,不准用 recipe / 多步工作流拼裝。** 口訣:「這段邏輯換個介面(CLI→MCP)要不要重寫?要 → 它是『能力』,該長在 API。」例:缺 `upsert` 就去 API 加 `upsert` endpoint,**不要**在工作流裡拼「先查、沒有再建」、也不要寫 recipe 假裝補上。recipe 只負責「打一個固定外部 API」,不是用來補 arcrun 自己缺的能力。
|
||||
- **已經有自製零件的,讓它退場。** `claude_api` 之類「工作流回頭叫 LLM」→ 刪掉,AI 判斷由操盤的 CC 自己做;`kbdb_*` 資料存取 → 改走 `acr kbdb` / `kbdb_*` MCP 工具;純打固定外部 API 的假零件 → 改寫成 recipe 投稿。
|
||||
|
||||
---
|
||||
|
||||
## 快速開始(Self-hosted)
|
||||
@@ -304,7 +310,8 @@ acr list 列出已部署的 workflow
|
||||
acr logs <name> 查看執行記錄
|
||||
acr mcp-setup 在當前資料夾寫 .mcp.json,讓 Claude Code 連對的 arcrun MCP
|
||||
acr config --where 顯示目前生效設定與來源層(避免在錯資料夾用錯帳號)
|
||||
acr update self-hosted:拉新版零件/引擎並重新部署到你的 CF
|
||||
acr update self-hosted:拉新版零件/引擎並重新部署(未變動的 worker 自動跳過)
|
||||
acr update --force 強制重部所有 worker(忽略未變動跳過快取)
|
||||
```
|
||||
|
||||
> 給 AI 操盤手:開始前讀 `.claude/rules/06-mindset.md`(或 arcrun-mindset Skill)——它說明 arcrun 的世界觀(工作流是 default、零件稀有且不自製、一切外部 API 皆 recipe),讓你一開始就走在正路上。
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# RELEASE-CHECKLIST — 出貨清單(沒有 GitHub Actions,每個 target 分開推,照順序不漏)
|
||||
|
||||
> 為什麼要這份:沒有 CI,deploy 是「一個個分開」的動作(git / CF workers / npm CLI)。
|
||||
> 漏任一步就會「有些新有些舊」——壓測踩過兩次:
|
||||
> - 第一次:CLI 改了但 npm 沒發 → 用戶 npm 裝到舊 CLI。
|
||||
> - 第二次(階段 6):cypher 改了但**沒推 main** → `acr init` 從 origin/main codeload 抓到**舊 worker** → 薄殼打不存在的 API(seed 404)。
|
||||
>
|
||||
> 核心鐵則:**self-hosted `acr init` 從 `origin/main` 抓 worker 源。所以「git push main」必須在「部署」之前。**
|
||||
> 順序錯了 = deploy 出去的 prod 是新的,但 self-hosted 用戶裝到的是舊的。
|
||||
|
||||
---
|
||||
|
||||
## 正確順序(照做不會忘)
|
||||
|
||||
### 0. 改完 code,先驗證
|
||||
- [ ] 三端 typecheck 綠:`cd cli && npx tsc --noEmit`、`cd cypher-executor && npx tsc --noEmit`、`cd mcp && npx tsc --noEmit`
|
||||
- [ ] 動到的 .sh:`bash -n scripts/<檔>.sh`
|
||||
|
||||
### 1. ⬆️ 先 git commit + push(**必須在 deploy 之前**)
|
||||
- [ ] `git add -A`(確認 `.env` / secret 沒被加:`git diff --cached --name-only | grep -iE '\.env|secret|token'` 應空)
|
||||
- [ ] `git commit -m "..."`
|
||||
- [ ] `git push origin main`
|
||||
- 理由:self-hosted 從 `origin/main` codeload 抓 worker。沒先 push → 用戶抓到舊碼。
|
||||
|
||||
### 2. ✅ 跑出貨前檢查(會擋住「git 沒同步」)
|
||||
- [ ] `bash scripts/check-release.sh` → 必須全綠(含「0. Git 同步」段)。
|
||||
- 紅燈「領先 origin/main N commit 未 push」= 回步驟 1。
|
||||
- 此腳本 git 未同步會 `exit 1`,是 deploy 的前置閘。
|
||||
|
||||
### 3. 🚀 deploy(worker + CLI npm)
|
||||
- [ ] Node ≥ 20(本機若預設舊版:`export PATH="$HOME/.nvm/versions/node/v22.21.0/bin:$PATH"`)
|
||||
- [ ] `bash scripts/local-deploy.sh --all`(或不帶 `--all` 只 deploy diff)
|
||||
- 此腳本**會先自動跑步驟 2 的 git 閘**;未過直接拒絕 deploy(要強推設 `SKIP_GIT_CHECK=true`,自負風險)。
|
||||
- worker 走 `wrangler deploy`;CLI 走 `npm publish`(版本未 bump 會自動 patch +1 + 寫 CHANGELOG)。
|
||||
- npm publish 需 `npm login` 或 `.env` 的 `NPM_API_TOKEN`(authToken)。
|
||||
|
||||
### 4. 🔁 deploy 後線上驗證(確認新碼真的上去了)
|
||||
- [ ] `curl https://cypher.arcrun.dev/health` → 200
|
||||
- [ ] 改了 cypher 路由時,**實際打那條新路由**確認存在(例:`curl -X POST https://cypher.arcrun.dev/init/seed`、`curl https://cypher.arcrun.dev/recipes` 應非空)。
|
||||
← 這步就是階段 6 的教訓:別只看「部署成功」,要打新端點確認。
|
||||
- [ ] 改了 CLI:`npm view arcrun version` == `cli/package.json` version。
|
||||
- [ ] landing 有改:確認 arcrun.dev 更新。
|
||||
|
||||
### 5. 📣 通知 / 收尾
|
||||
- [ ] 若是回應壓測:到壓測報告加「開發者回覆」+ 請壓測者重跑。
|
||||
|
||||
---
|
||||
|
||||
## 一眼對照表:每個 target 怎麼推、誰依賴它
|
||||
|
||||
| Target | 推法 | 誰依賴「它在 origin/main」 |
|
||||
|---|---|---|
|
||||
| **git origin/main** | `git push origin main` | **self-hosted `acr init` codeload 抓這裡的 worker 源** → 必須最先 |
|
||||
| CF workers(26 個含 mcp) | `local-deploy.sh`(wrangler deploy) | 平台 prod;self-hosted 自己 deploy |
|
||||
| CLI(npm `arcrun`) | `local-deploy.sh` 第 6 段 / `cd cli && npm publish` | 用戶 `npm i -g arcrun` |
|
||||
| landing(arcrun.dev) | `cd landing && wrangler pages deploy` | 訪客 |
|
||||
|
||||
## 常見漏失(自我檢查)
|
||||
- ❌ 「我 deploy 了 prod cypher 但忘了 push main」→ self-hosted 用戶 init 抓舊碼。**先 push 再 deploy。**
|
||||
- ❌ 「改了 CLI 但版本沒 bump」→ npm publish 跳過(同版)。`local-deploy.sh` 會自動 bump,但手動 publish 時要記得。
|
||||
- ❌ 「改了 cypher 路由只看到『部署成功』就收工」→ 要實際 curl 新路由確認(步驟 4)。
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -1,5 +1,45 @@
|
||||
# arcrun CLI Changelog
|
||||
|
||||
## 1.3.13 — 2026-06-24
|
||||
- Merge: fix self-hosted KBDB_BASE_URL injection (issue #2)
|
||||
|
||||
|
||||
## 1.3.12 — 2026-06-15
|
||||
- fix(kbdb): cypher proxy 補 /kbdb/entries CRUD + report_feedback 改打 /entries
|
||||
|
||||
|
||||
## 1.3.11 — 2026-06-14
|
||||
- fix(cli): deploy 注入 MULTI_TENANT=false 到 self-hosted worker(修 MCP 401 注入缺口)
|
||||
|
||||
|
||||
## 1.3.10 — 2026-06-14
|
||||
- fix(cypher): KBDB proxy 指向現役 arcrun-kbdb(舊 fallback kbdb.finally.click 已死)
|
||||
|
||||
|
||||
## 1.3.9 — 2026-06-14
|
||||
- feat(kbdb,mcp): KBDB 資料層薄殼 + self-hosted MCP 認證 + cypher KBDB proxy
|
||||
|
||||
|
||||
## 1.3.8 — 2026-06-13
|
||||
- perf(self-hosted): acr update 共享一次 install 取代 23 個 worker 各裝 324MB
|
||||
|
||||
|
||||
## 1.3.7 — 2026-06-13
|
||||
- feat(self-hosted): acr update 內容指紋跳過未變動 worker(22 個沒變的不再白跑)+ --force 強制全部重部
|
||||
|
||||
|
||||
## 1.3.6 — 2026-06-12
|
||||
- fix(self-hosted): acr update 10/23 部署失敗根因——pnpm 目錄補 commit pnpm-workspace.yaml(ERR_PNPM_IGNORED_BUILDS)+ CLI 失敗帶 stderr 尾段
|
||||
|
||||
|
||||
## 1.3.5 — 2026-06-11
|
||||
- fix(cli): acr update 假綠與沉默修復——部署部分失敗必須印出、deploy 迴圈逐 worker 進度、migrate 錯誤印 server 回應
|
||||
|
||||
|
||||
## 1.3.2 — 2026-06-07
|
||||
- merge: kbdb recipe 公庫/私庫雙向機制 + UUID 身份(壓測前)
|
||||
|
||||
|
||||
> 由 `scripts/local-deploy.sh` 在 deploy 時自動維護(version bump + 此檔記錄)。
|
||||
> 也可手動編輯補充細節。最新在最上。
|
||||
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
<!-- arcrun-harness:start -->
|
||||
## 這個專案用 arcrun 做自動化
|
||||
|
||||
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時:
|
||||
當需要「串服務 / 排程 / 打 API / 資料處理自動化」時,用 arcrun 工作流,**不要自己寫 Python/Node 一次性腳本**。arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。
|
||||
|
||||
- **用 arcrun 工作流,不要自己寫 Python/Node 一次性腳本。** arcrun 工作流是純文字、可複用、跑在使用者自己的 Cloudflare。選 arcrun 就是不想要每次重刻的腳本。
|
||||
- **打外部 API → 寫 recipe**(`acr recipe push`),不要自刻 HTTP client。recipe = http_request + 固定設定的 YAML,不用部署。
|
||||
- **先查能力再動手**:`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)。
|
||||
- **不要自製零件**(WASM component)——零件由 arcrun 維護、走 GitHub PR。你能擴充的是 recipe + 工作流。
|
||||
- **需要 AI 判斷/轉換時,你自己做**,再呼叫工作流做確定性的下一步。不要在工作流裡放零件回頭呼叫 LLM。
|
||||
arcrun 只有三種東西,先分清楚就不會做歪:
|
||||
|
||||
| 東西 | 是什麼 | 你能做的 |
|
||||
|---|---|---|
|
||||
| **工作流(workflow)** | 把零件/recipe 串起來的純文字流程 | **預設就寫這個**,自由寫 |
|
||||
| **recipe** | 打「一個固定外部 API」的設定(http_request + endpoint/header/body 模板) | 自由寫、**而且該投稿**(見下) |
|
||||
| **零件(component)** | WASM 程式(流程控制 / 資料處理 / http_request / auth),固定一小套 | **你不自製**,由維護者管,走 GitHub PR |
|
||||
|
||||
> **一句話判準**:打一個固定外部 endpoint → 寫 **recipe**;流程控制 / 資料處理 / 通用 HTTP → 用既有**零件**;其他 → 寫**工作流**串起來。
|
||||
|
||||
### 三個最常踩的坑(讀懂這三條,就不會像前人誤解四輪)
|
||||
|
||||
**① recipe 是公共資產,發現缺的就自己做一個投稿,不必問人。**
|
||||
`acr recipe push` = 把 recipe **投稿到公共庫**,不是存私人腳本。公開/私有只是 recipe 的一個屬性(同一身份可有公私版本並存),不是兩條不同的路。
|
||||
→ 所以「想打某個 API 但沒有現成 recipe」時,**正解是自己寫一個 recipe 並 push 上去**(會 inject credential、push 時幫你檢查打不打得通)。這是被鼓勵的,別誤以為要自用、不上傳。
|
||||
|
||||
**② 缺一個能力 → 去補 API(cypher endpoint),不准用 recipe / 多步工作流拼裝出來。**
|
||||
判準口訣:**「這段邏輯換一個介面(CLI→MCP)要不要重寫?要重寫 → 它是『能力』,該長在 API。」**
|
||||
- ❌ 缺 `upsert` → 在工作流裡拼「先查、沒有再建」、或寫個 recipe 假裝補上。
|
||||
- ✅ 缺 `upsert` → 去 API 加一個 `upsert` endpoint,CLI/MCP/recipe 都呼叫它。
|
||||
recipe 只負責「打一個固定外部 API」這件單純事;它不是用來補 arcrun 自己缺的能力的。缺能力就回報 / 補在 API,不要繞。
|
||||
|
||||
**③ 已經有自製零件(例如 mira 的那幾個)→ 讓它退場,別再加新的。**
|
||||
你不該自製零件;既有的自製零件要往這三條退場:
|
||||
- `claude_api` 之類「工作流回頭叫 LLM」→ **刪掉**,需要 AI 判斷時是**你(操盤的 CC)自己做**,再叫工作流做確定性的下一步。arcrun 是 AI 用的工具,不是工具回頭用 AI。
|
||||
- `kbdb_*` 之類資料存取 → 改走已備好的 **`acr kbdb` 薄殼 / `kbdb_*` MCP 工具**(template + record 模型),不要當零件。
|
||||
- 純粹打某個固定外部 API 的假零件 → **改寫成 recipe** 投稿(見①)。
|
||||
|
||||
### 其餘鐵律
|
||||
|
||||
- **先查能力再動手**:`acr parts`(看可用零件)、`acr auth-recipe list`(看支援的認證服務)、`acr kbdb`(資料存取)。
|
||||
- **暴露資料要人類同意**:部署對外 webhook / push recipe 會讓東西可被外部呼叫 → 停下來讓使用者明示同意,不替他決定公開。
|
||||
- **誠實**:沒打通就誠實說(缺 credential 標「未驗收:缺 X」),不假裝成功;完成以 HTTP 2xx / trace 為證,不口頭宣布。
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "arcrun",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "arcrun",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.13",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "arcrun",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.13",
|
||||
"description": "AI Workflow CLI for arcrun — self-host WASM-based AI workflows on your own Cloudflare",
|
||||
"bin": {
|
||||
"acr": "dist/index.js"
|
||||
|
||||
@@ -12,12 +12,12 @@ import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
SECRET_TARGET_WORKERS,
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
import { cmdInstallHarness } from './install-harness.js';
|
||||
import { cmdMcpSetup } from './mcp-setup.js';
|
||||
import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js';
|
||||
|
||||
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
|
||||
|
||||
@@ -65,6 +65,22 @@ export async function cmdInit(options: InitOptions): Promise<void> {
|
||||
} catch (e) {
|
||||
console.log(chalk.gray(` (.mcp.json 略過:${e instanceof Error ? e.message : e};可稍後跑 acr mcp-setup)`));
|
||||
}
|
||||
|
||||
// 冷啟動環境檢查(harness:機制強制,不靠提醒)。
|
||||
// 失敗會 exit 2,用戶必須修復後重跑 acr init。
|
||||
console.log('');
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
const hookPath = new URL('../../../.claude/hooks/pre-cold-startup-check.sh', import.meta.url);
|
||||
execSync(`bash "${hookPath.pathname}"`, { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
if ((e as any)?.status === 2) {
|
||||
// exit 2 = 環境檢查失敗,停下,不繼續
|
||||
process.exit(2);
|
||||
}
|
||||
// 其他錯誤(hook 不存在等)只警告,不擋 init
|
||||
console.log(chalk.gray(` (環境檢查略過,可手動跑 bash .claude/hooks/pre-cold-startup-check.sh)`));
|
||||
}
|
||||
}
|
||||
|
||||
async function initLocal(): Promise<void> {
|
||||
@@ -148,10 +164,13 @@ async function initSelfHosted(
|
||||
console.log(chalk.gray(' Self-hosted 模式:自動部署整套 arcrun 到你的 Cloudflare 帳號\n'));
|
||||
console.log(chalk.gray(' 你只需提供 CF Account ID + API Token,其餘 CLI 自動完成。\n'));
|
||||
|
||||
// 前置:wrangler(CF CLI)
|
||||
if (!wranglerAvailable()) {
|
||||
console.log(chalk.yellow(' ✗ 找不到 wrangler(Cloudflare CLI)。'));
|
||||
console.log(chalk.yellow(' 請先安裝:npm i -g wrangler,然後重新執行 acr init --self-hosted\n'));
|
||||
// §7.8 P0:偵測先於動作(pip 式)——先看環境有什麼,缺前置就停下給補救指令,
|
||||
// 不假設齊備直接動手(test_arcrun/4:D1 缺了 AI 跑去讀原始碼自己想辦法的災難)。
|
||||
const pre = detectEnvironment();
|
||||
printPreflight('環境偵測(安裝前)', pre.items);
|
||||
if (pre.fatal) {
|
||||
console.log(chalk.yellow('\n ✗ 缺少必要前置(見上方 →)。補齊後重新執行 acr init --self-hosted。'));
|
||||
console.log(chalk.gray(' init 冪等:補好重跑,已就緒的會自動跳過。\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -201,6 +220,25 @@ async function initSelfHosted(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
|
||||
// (kbdb-base SDD Q4). idempotent: reuse if exists.
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
|
||||
if (/auth/i.test(em)) {
|
||||
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
|
||||
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
|
||||
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
|
||||
} else {
|
||||
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用)
|
||||
let workerSubdomain = '';
|
||||
try {
|
||||
@@ -210,12 +248,27 @@ async function initSelfHosted(
|
||||
console.log(chalk.yellow(` ⚠ 查 subdomain 失敗(${e instanceof Error ? e.message : e}),稍後可手動補`));
|
||||
}
|
||||
|
||||
// 3.5 語義查詢開關(issue #7 / T2.4):問用戶要不要開(預設關,free-tier 友善)。
|
||||
// 開 → deploy 建 CF Vectorize index + 注入 binding。關 → base 維持 LIKE keyword,零花費。
|
||||
// 之後想開:跟 CC 說「幫我開語義查詢」或設 kbdb_embed:true + acr update(不必重 init)。
|
||||
const embedAns = (await prompt(
|
||||
rl,
|
||||
'要開語義查詢嗎?(KBDB 加 AI 向量搜尋;用 CF Vectorize,可能多花費;預設關,之後可隨時開) [y/N]',
|
||||
)).trim().toLowerCase();
|
||||
const kbdbEmbed = embedAns === 'y' || embedAns === 'yes';
|
||||
if (kbdbEmbed) console.log(chalk.gray(' → 已選開語義查詢:部署時會建 Vectorize index。'));
|
||||
|
||||
// 4. 下載 repo 部署物(含預編譯 wasm)+ 注入 KV id + wrangler deploy 全部 Worker
|
||||
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds };
|
||||
// selfHosted: true → deploy 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 MCP 401)。
|
||||
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
|
||||
const deploy = await downloadAndDeploy(deployCtx);
|
||||
const cypherUrl = deploy.cypherExecutorUrl
|
||||
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
|
||||
// self-hosted 自己的 MCP worker URL(mcp-account-source §3:.mcp.json 指自己,不 fallback 官方)。
|
||||
const mcpUrl = deploy.mcpUrl
|
||||
?? (workerSubdomain ? `https://arcrun-mcp.${workerSubdomain}.workers.dev/mcp` : '');
|
||||
// 誠實回報部署結果;但**不**用「全部成功」字串 gate 後續 seed(壓測 §4.1:
|
||||
// registry 一個無關 worker 失敗就連坐讓 seed 永遠被跳過)。seed 只看 cypher-executor 是否可達。
|
||||
const deployFullyOk = /全部成功/.test(deploy.message);
|
||||
@@ -227,19 +280,43 @@ async function initSelfHosted(
|
||||
cloudflare_account_id: accountId,
|
||||
cf_api_token: cfApiToken,
|
||||
cypher_executor_url: cypherUrl,
|
||||
mcp_url: mcpUrl || undefined, // 指自己的 MCP(mcp-account-source §3),無 subdomain 才留空 fallback 官方
|
||||
webhooks_kv_namespace_id: kvNamespaceIds['WEBHOOKS'],
|
||||
credentials_kv_namespace_id: kvNamespaceIds['CREDENTIALS_KV'],
|
||||
multi_tenant: false,
|
||||
kbdb_embed: kbdbEmbed, // 語義查詢開關(issue #7);存進 config 讓後續 acr update 維持一致
|
||||
};
|
||||
saveConfig(config);
|
||||
createCredentialsYamlIfMissing();
|
||||
|
||||
// 6.5 config 寫好後重寫 .mcp.json,讓它指向「自己的」MCP(init 開頭的 cmdMcpSetup 在 config 前跑,
|
||||
// 那時 mcp_url 還沒設 → 會 fallback 官方;這裡 config 已含自己的 mcp_url,重跑一次蓋成自己的)。
|
||||
try {
|
||||
cmdMcpSetup();
|
||||
} catch (e) {
|
||||
console.log(chalk.gray(` (.mcp.json 重寫略過:${e instanceof Error ? e.message : e})`));
|
||||
}
|
||||
|
||||
// 6. seed recipe(薄殼:呼叫 API 的 /init/seed 一次,由 API 灌 API recipe + auth recipe)。
|
||||
// 只要 cypher-executor 可達就 seed——不被無關 worker(registry)的失敗連坐(壓測 §4.1)。
|
||||
if (cypherUrl) {
|
||||
await callSeedEndpoint(cypherUrl);
|
||||
}
|
||||
|
||||
// §7.8 P0 裝完驗收:實查 CF(KV/D1)+ 打 cypher /health 確認真就緒,缺哪項明確報哪項
|
||||
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
|
||||
const verify = await verifyInstall({
|
||||
cf,
|
||||
requiredKv: REQUIRED_KV_NAMESPACES,
|
||||
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
|
||||
cypherUrl,
|
||||
});
|
||||
printPreflight('安裝驗收(裝完檢查)', verify.items);
|
||||
if (!verify.allOk) {
|
||||
console.log(chalk.yellow('\n ⚠ 部分項目未就緒(見上方 →)。多數可跑 acr update 冪等補裝。'));
|
||||
console.log(chalk.gray(' (worker 剛部署可能需數十秒生效,可稍候再跑 acr update 重驗。)'));
|
||||
}
|
||||
|
||||
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* acr kbdb — KBDB 資料層 CLI 薄殼(kbdb-base Phase 9.2,HANDOFF §2)
|
||||
*
|
||||
* acr kbdb template create <name> --slots a,b,c 建 template(虛擬表定義)
|
||||
* acr kbdb template list 列出所有 template
|
||||
* acr kbdb record create <template> --values k=v… 填一筆 record
|
||||
* acr kbdb record get <record_id> 取單筆 record
|
||||
* acr kbdb query <template> 列某 template 下本租戶的 records
|
||||
* acr kbdb search <q> 關鍵字搜尋(本租戶 LIKE)
|
||||
*
|
||||
* 薄殼鐵律(rule 07 §5):能力長在基本盤 API,CLI 只做介面轉換 + 暴露,無業務邏輯。
|
||||
* 透過 cypher 的 KBDB proxy(/kbdb/*)達 KBDB——CLI 本來就只認證到 cypher(X-Arcrun-API-Key),
|
||||
* 與 MCP 薄殼(kbdb_data.ts)同一組基本盤能力,差異只來自介面慣例(rule 07 §3.4)。
|
||||
* KBDB 鐵律:只 template/slot,無建表/SQL(proxy 端也不暴露)。
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
|
||||
/** 取身份 + cypher URL,缺 api_key/namespace 直接退出(與 recipe.ts 一致)。 */
|
||||
function ctx(): { url: string; apiKey: string } {
|
||||
const config = loadConfig();
|
||||
if (!config.api_key) {
|
||||
console.error(chalk.red('缺少 API Key / namespace,請先執行 acr init(或設 NAMESPACE)'));
|
||||
process.exit(1);
|
||||
}
|
||||
return { url: getCypherExecutorUrl(config), apiKey: config.api_key };
|
||||
}
|
||||
|
||||
/** 統一 fetch + JSON 解析 + 失敗印錯(薄殼只暴露,不含業務邏輯)。 */
|
||||
async function call(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
const { url, apiKey } = ctx();
|
||||
const res = await fetch(`${url}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data: unknown;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
|
||||
if (!res.ok) {
|
||||
console.error(chalk.red(`KBDB 失敗(HTTP ${res.status}):${text || '(空回應)'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 把 ["k=v","x=y"] 轉成 { k:"v", x:"y" }。 */
|
||||
function parseKeyVals(pairs: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const p of pairs) {
|
||||
const i = p.indexOf('=');
|
||||
if (i < 0) {
|
||||
console.error(chalk.red(`--values 格式須為 key=value,收到:"${p}"`));
|
||||
process.exit(1);
|
||||
}
|
||||
out[p.slice(0, i)] = p.slice(i + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── template ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function cmdKbdbTemplateCreate(name: string, opts: { slots?: string }): Promise<void> {
|
||||
const slots = (opts.slots ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (slots.length === 0) {
|
||||
console.error(chalk.red('需要 --slots a,b,c(至少一個欄位名)'));
|
||||
process.exit(1);
|
||||
}
|
||||
const spinner = ora(`建 template "${name}"`).start();
|
||||
const data = await call('POST', '/kbdb/templates', { name, slots });
|
||||
spinner.succeed(chalk.green(`template "${name}" 已建(slots: ${slots.join(', ')})`));
|
||||
console.log(chalk.gray(JSON.stringify(data, null, 2)));
|
||||
}
|
||||
|
||||
export async function cmdKbdbTemplateList(): Promise<void> {
|
||||
const data = await call('GET', '/kbdb/templates') as { templates?: Array<{ name: string; slots_json?: string }>; count?: number };
|
||||
const list = data.templates ?? [];
|
||||
if (list.length === 0) {
|
||||
console.log(chalk.yellow('(尚無 template,用 acr kbdb template create <name> --slots … 建一個)'));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold(`\n ${list.length} 個 template:\n`));
|
||||
for (const t of list) {
|
||||
console.log(` ${chalk.cyan(t.name)} ${chalk.gray(t.slots_json ?? '')}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── record ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function cmdKbdbRecordCreate(template: string, opts: { values?: string[] }): Promise<void> {
|
||||
const values = parseKeyVals(opts.values ?? []);
|
||||
if (Object.keys(values).length === 0) {
|
||||
console.error(chalk.red('需要 --values slot=內容(可重複),如 --values name=Leo --values email=x@y.com'));
|
||||
process.exit(1);
|
||||
}
|
||||
const spinner = ora(`填 record(template "${template}")`).start();
|
||||
const data = await call('POST', '/kbdb/records', { template, values });
|
||||
spinner.succeed(chalk.green(`record 已存入 template "${template}"`));
|
||||
console.log(chalk.gray(JSON.stringify(data, null, 2)));
|
||||
}
|
||||
|
||||
export async function cmdKbdbRecordGet(recordId: string): Promise<void> {
|
||||
const data = await call('GET', `/kbdb/records/${encodeURIComponent(recordId)}`);
|
||||
console.log(chalk.gray(JSON.stringify(data, null, 2)));
|
||||
}
|
||||
|
||||
// ── query / search ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function cmdKbdbQuery(template: string): Promise<void> {
|
||||
const data = await call('GET', `/kbdb/records/by-template/${encodeURIComponent(template)}`) as { records?: unknown[]; count?: number };
|
||||
const recs = data.records ?? [];
|
||||
console.log(chalk.bold(`\n template "${template}" 下 ${recs.length} 筆 record(本租戶):\n`));
|
||||
console.log(chalk.gray(JSON.stringify(recs, null, 2)));
|
||||
}
|
||||
|
||||
export async function cmdKbdbSearch(q: string): Promise<void> {
|
||||
const data = await call('GET', `/kbdb/search?q=${encodeURIComponent(q)}`) as { entries?: unknown[]; count?: number; mode?: string };
|
||||
const hits = data.entries ?? [];
|
||||
console.log(chalk.bold(`\n "${q}" 命中 ${hits.length} 筆(mode: ${data.mode ?? 'keyword'},本租戶):\n`));
|
||||
console.log(chalk.gray(JSON.stringify(hits, null, 2)));
|
||||
}
|
||||
+26
-42
@@ -1,68 +1,52 @@
|
||||
/**
|
||||
* acr list — 列出 USER_KV 中所有已上傳的 workflow
|
||||
* acr list — 列出已部署的 workflow
|
||||
*
|
||||
* thin-shell-alignment P1(issue #11):原本直連 CF KV 讀 `workflow:` 前綴(CfKvClient),
|
||||
* 有兩個漂移:① 前綴對不上(部署寫 `{apiKey}:wf:` → 永遠列不到新部署)② 薄殼直碰底層儲存
|
||||
* (self-hosted 用戶需 CF API token 才能 list)。改走 cypher `GET /webhooks/named`(KV 源,與
|
||||
* MCP u6u_list_workflows 同源),薄殼不再直連 KV、key 前綴 bug 自然消失。
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
|
||||
export async function cmdList(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.webhooks_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId) {
|
||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
const spinner = ora('讀取 workflow 清單').start();
|
||||
|
||||
try {
|
||||
const keys = await kv.list('workflow:');
|
||||
const res = await fetch(`${executorUrl}/webhooks/named`, { method: 'GET', headers });
|
||||
if (!res.ok) {
|
||||
spinner.fail(chalk.red(`讀取失敗(HTTP ${res.status}):${(await res.text()).slice(0, 200)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json() as {
|
||||
workflows: Array<{ name: string; description?: string; created_at?: string }>;
|
||||
};
|
||||
spinner.stop();
|
||||
|
||||
if (keys.length === 0) {
|
||||
const workflows = data.workflows ?? [];
|
||||
if (workflows.length === 0) {
|
||||
console.log(chalk.yellow('\n 沒有已部署的 workflow。執行 acr push <workflow.yaml> 部署第一個。\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\n 已部署 ${keys.length} 個 workflow\n`));
|
||||
console.log(chalk.bold(`\n 已部署 ${workflows.length} 個 workflow\n`));
|
||||
|
||||
for (const key of keys) {
|
||||
const name = key.name.replace('workflow:', '');
|
||||
// 嘗試讀取 workflow 定義取得 created_at
|
||||
try {
|
||||
const raw = await kv.get(key.name);
|
||||
if (raw) {
|
||||
const def = JSON.parse(raw) as { name: string; description?: string; created_at?: string };
|
||||
const date = def.created_at ? new Date(def.created_at).toLocaleString('zh-TW') : '未知';
|
||||
const desc = def.description ? chalk.gray(` — ${def.description}`) : '';
|
||||
console.log(` • ${chalk.cyan(name.padEnd(25))} ${date}${desc}`);
|
||||
} else {
|
||||
console.log(` • ${chalk.cyan(name)}`);
|
||||
}
|
||||
} catch {
|
||||
console.log(` • ${chalk.cyan(name)}`);
|
||||
}
|
||||
for (const wf of workflows) {
|
||||
const date = wf.created_at ? new Date(wf.created_at).toLocaleString('zh-TW') : '未知';
|
||||
const desc = wf.description ? chalk.gray(` — ${wf.description}`) : '';
|
||||
console.log(` • ${chalk.cyan(wf.name.padEnd(25))} ${date}${desc}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`KV 讀取失敗:${e instanceof Error ? e.message : e}`));
|
||||
spinner.fail(chalk.red(`讀取失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { loadConfig, getMcpUrl, DEFAULT_MCP_URL } from '../lib/config.js';
|
||||
interface McpServerEntry {
|
||||
type: 'http';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
interface McpJson {
|
||||
mcpServers?: Record<string, McpServerEntry | Record<string, unknown>>;
|
||||
@@ -43,15 +44,34 @@ export function cmdMcpSetup(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 身份寫進 .mcp.json headers(HANDOFF §3b ②):裸 .mcp.json 不送任何 header,
|
||||
// MCP partner-auth 收不到 token → 一律 401。與 CLI 同一份身份來源(rule 07 §4):
|
||||
// self-hosted → api_key 欄位存的是 namespace 明碼(config.ts NAMESPACE→api_key),
|
||||
// MCP(MULTI_TENANT=false)把 Bearer 當 org_namespace,與 cypher 的 X-Arcrun-API-Key 對齊。
|
||||
// standard → api_key 是平台 ak_(多租戶 MCP 仍走 partner 驗證;ak_ 不是 pk_live 的話平台側自理)。
|
||||
// 不重實作身份解析:直接用 loadConfig() 已解析好的 api_key(薄殼只暴露,不自造邏輯)。
|
||||
const identity = config.api_key && config.api_key.trim() !== '' ? config.api_key.trim() : undefined;
|
||||
const entry: McpServerEntry = { type: 'http', url: mcpUrl };
|
||||
if (identity) entry.headers = { Authorization: `Bearer ${identity}` };
|
||||
|
||||
if (!doc.mcpServers || typeof doc.mcpServers !== 'object') doc.mcpServers = {};
|
||||
doc.mcpServers[SERVER_KEY] = { type: 'http', url: mcpUrl };
|
||||
doc.mcpServers[SERVER_KEY] = entry;
|
||||
|
||||
writeFileSync(target, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
||||
|
||||
console.log(chalk.green(`\n ✓ 已寫入 ${target}`));
|
||||
console.log(chalk.gray(` arcrun MCP → ${mcpUrl}`));
|
||||
if (identity) {
|
||||
console.log(chalk.gray(` 身份 → Authorization: Bearer ${identity.slice(0, 6)}…(${config.mode === 'self-hosted' ? 'self-hosted namespace 明碼' : 'api_key'})`));
|
||||
} else {
|
||||
console.log(chalk.yellow(` ⚠ config 無 api_key/namespace → .mcp.json 不帶身份;self-hosted MCP 會 401。先 acr init 或設 NAMESPACE 再重跑。`));
|
||||
}
|
||||
if (mcpUrl === DEFAULT_MCP_URL && (!config.mcp_url || config.mcp_url.trim() === '')) {
|
||||
console.log(chalk.gray(` (用平台預設;要連自己/客戶的 MCP,在 config 設 mcp_url 或 ARCRUN_MCP_URL env,再重跑)`));
|
||||
}
|
||||
console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。\n`));
|
||||
console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。`));
|
||||
// §7.8 P2 D3:project scope MCP 寫進 .mcp.json 後**不會即時生效**,要重啟 IDE/client 才載入。
|
||||
// 不提示 → 用戶開了 MCP 工具卻發現用不了,以為壞了(D3 撞牆)。明說「請重啟」引導,不讓人誤判。
|
||||
console.log(chalk.yellow(`\n ⚠ 請重啟 IDE / Claude Code client,project scope MCP 才會載入生效。`));
|
||||
console.log(chalk.gray(` (重啟後若仍未出現 arcrun 工具,確認該 client 已「信任此工作區」。)\n`));
|
||||
}
|
||||
|
||||
+26
-98
@@ -1,7 +1,17 @@
|
||||
/**
|
||||
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev)
|
||||
* acr parts — 列出所有可用「零件(component)」(內建清單,不依賴 registry.arcrun.dev)
|
||||
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml)
|
||||
* acr parts publish <component> — 提交零件至公眾 registry(Phase 5,封測後)
|
||||
*
|
||||
* ⚠️ 分類原則(2026-06-29,issue #13 / component-gatekeeping W3):
|
||||
* - **零件(component)= 靜態清單**:WASM,只能走 GitHub PR + 人 merge 新增(mindset §4 人類閘門),
|
||||
* 固定慢增 → 用 BUILTIN_COMPONENTS hardcode 反映真實,正確。
|
||||
* - **recipe / auth-recipe / workflow = 動態,存在 store**:任何人 `acr recipe push` 即新增 →
|
||||
* **絕不可 hardcode 在這裡**(會「submitted = invisible」+ 誤導查錯表)。它們各有動態清單:
|
||||
* recipe → `acr recipe list`(GET /recipes)|auth-recipe → `acr auth-recipe list`(GET /auth-recipes)|workflow → `acr list`(GET /webhooks/named)
|
||||
* - **跨類找東西用 `acr search <term>`**(fan-out 上述 4 個來源,不必先知道它是哪一類)。
|
||||
* 歷史教訓:本檔曾把 gmail_send/telegram_send/notion 等 5 個 recipe hardcode 進零件清單 →
|
||||
* 誤導「telegram 是零件 / 走不同機制」。已移除(W3.1)。
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -25,7 +35,7 @@ interface ComponentDef {
|
||||
credentials_required?: CredentialRequirement[];
|
||||
}
|
||||
|
||||
const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
export const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
// ── 控制類(Logic) ────────────────────────────────────────────────────────
|
||||
{
|
||||
canonical_id: 'if_control',
|
||||
@@ -174,27 +184,9 @@ const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
type: string
|
||||
format: email`,
|
||||
},
|
||||
// ── AI 類 ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
canonical_id: 'ai_transform_compile',
|
||||
display_name: 'AI Transform Compile',
|
||||
category: 'ai',
|
||||
description: '將自然語言規則編譯成可執行轉換程式',
|
||||
config_example:
|
||||
` compile_node:
|
||||
component: ai_transform_compile
|
||||
rule: "把 name 轉成大寫,並在前面加上 Hello "`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'ai_transform_run',
|
||||
display_name: 'AI Transform Run',
|
||||
category: 'ai',
|
||||
description: '執行 ai_transform_compile 產生的轉換程式',
|
||||
config_example:
|
||||
` run_node:
|
||||
component: ai_transform_run
|
||||
program: "{{compiled_program}}"`,
|
||||
},
|
||||
// ── AI 類:已移除 ──────────────────────────────────────────────────────────
|
||||
// ai_transform_compile / ai_transform_run 於 2026-05-29 刪除(mindset §2:arcrun 是
|
||||
// AI 呼叫的工具,不是工具回頭呼叫 LLM)。需要 AI 判斷/轉換由操盤的 CC 自己做。
|
||||
// ── API 整合類(Recipe 型,不需 deploy Worker) ────────────────────────────
|
||||
{
|
||||
canonical_id: 'http_request',
|
||||
@@ -211,78 +203,9 @@ const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
body:
|
||||
key: "{{value}}"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'gmail',
|
||||
display_name: 'Gmail',
|
||||
category: 'api',
|
||||
description: '寄送 Gmail(需要 gmail_token credential)',
|
||||
config_example:
|
||||
` mail_node:
|
||||
component: gmail
|
||||
to: "recipient@example.com"
|
||||
subject: "來自 arcrun 的通知"
|
||||
body: "{{message}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'gmail_token', type: 'OAuth2 access token', inject_as: 'access_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'google_sheets',
|
||||
display_name: 'Google Sheets',
|
||||
category: 'api',
|
||||
description: 'Google Sheets 讀寫(需要 google_oauth credential)',
|
||||
config_example:
|
||||
` sheet_node:
|
||||
component: google_sheets
|
||||
spreadsheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
|
||||
range: "Sheet1!A:B"
|
||||
operation: append
|
||||
values:
|
||||
- ["{{name}}", "{{email}}"]`,
|
||||
credentials_required: [
|
||||
{ key: 'google_oauth', type: 'OAuth2 access token', inject_as: 'access_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'telegram',
|
||||
display_name: 'Telegram',
|
||||
category: 'api',
|
||||
description: '發送 Telegram 訊息(需要 telegram_bot_token credential)',
|
||||
config_example:
|
||||
` tg_node:
|
||||
component: telegram
|
||||
chat_id: "123456789"
|
||||
text: "{{message}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'telegram_bot_token', type: 'Bot token', inject_as: 'bot_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'line_notify',
|
||||
display_name: 'LINE Notify',
|
||||
category: 'api',
|
||||
description: '發送 LINE Notify 通知(需要 line_token credential)',
|
||||
config_example:
|
||||
` line_node:
|
||||
component: line_notify
|
||||
message: "{{notification}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'line_token', type: 'LINE Notify token', inject_as: 'token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'notion',
|
||||
display_name: 'Notion',
|
||||
category: 'api',
|
||||
description: '透過 recipe 操作 Notion API(需先 acr recipe push)',
|
||||
config_example:
|
||||
` # 先上傳 recipe:acr recipe push notion.yaml
|
||||
notion_node:
|
||||
component: rec_xxxxxxxx # acr recipe push 後得到的 hash
|
||||
database_id: "{{db_id}}"
|
||||
properties:
|
||||
Name: "{{title}}"`,
|
||||
},
|
||||
// ⚠️ 此處曾 hardcode gmail_send / google_sheets_append / telegram_send / line_notify_send / notion
|
||||
// 這 5 個是 **recipe(動態,存 store)不是零件(component)**,已於 W3.1 移除(issue #13 根治)。
|
||||
// 要找它們:`acr search <term>`(跨類)或 `acr recipe list` / `acr auth-recipe list`(動態清單)。
|
||||
];
|
||||
|
||||
// ── 指令實作 ──────────────────────────────────────────────────────────────────
|
||||
@@ -301,7 +224,7 @@ export async function cmdParts(): Promise<void> {
|
||||
grouped[comp.category].push(comp);
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件)\n`));
|
||||
console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件 / component,靜態 PR-only)\n`));
|
||||
|
||||
for (const cat of ['logic', 'data', 'ai', 'api']) {
|
||||
const comps = grouped[cat];
|
||||
@@ -318,8 +241,13 @@ export async function cmdParts(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
|
||||
console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list'));
|
||||
console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n'));
|
||||
console.log('');
|
||||
console.log(chalk.bold(' 零件之外(動態,存在 store,不在上面這份靜態清單):'));
|
||||
console.log(chalk.gray(' • API recipe(打外部服務) acr recipe list'));
|
||||
console.log(chalk.gray(' • 第三方服務認證(auth-recipe) acr auth-recipe list'));
|
||||
console.log(chalk.gray(' • 已部署的 workflow acr list'));
|
||||
console.log(chalk.cyan(' • 不確定某能力是哪一類? acr search <關鍵字> ← 跨類一次搜,免先選表'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function cmdPartsScaffold(componentId: string): Promise<void> {
|
||||
|
||||
@@ -11,7 +11,6 @@ import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
|
||||
import { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||||
|
||||
export async function cmdPush(filePath: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
@@ -96,17 +95,8 @@ export async function cmdPush(filePath: string): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||||
// server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'workflow',
|
||||
resourceName: workflow.name,
|
||||
destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):arcrun 是給 AI 用的系統,
|
||||
// push/暴露不再需要人類確認,AI/MCP 隨時可部署。暴露風險由用戶自負(同 n8n 建 webhook)。
|
||||
|
||||
// POST 至 /webhooks/named
|
||||
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
|
||||
@@ -119,7 +109,6 @@ export async function cmdPush(filePath: string): Promise<void> {
|
||||
graph,
|
||||
config: workflow.config ?? {},
|
||||
description: workflow.description ?? '',
|
||||
exposure_consent: consent ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+142
-13
@@ -6,8 +6,7 @@
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
import { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||||
import { loadConfig, getCypherExecutorUrl, DEFAULT_PUBLIC_LIBRARY_URL } from '../lib/config.js';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
interface RecipeYaml {
|
||||
@@ -22,6 +21,9 @@ interface RecipeYaml {
|
||||
}
|
||||
|
||||
interface RecipeDefinition {
|
||||
uuid?: string; // UUID 身份模型(kbdb-base §7.5.5)
|
||||
author?: string;
|
||||
derived_from?: string;
|
||||
canonical_id: string;
|
||||
hash_id: string;
|
||||
display_name?: string;
|
||||
@@ -67,16 +69,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
||||
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
|
||||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'recipe',
|
||||
resourceName: recipe.canonical_id,
|
||||
destination: recipe.endpoint,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):recipe push 不再需要人類確認。
|
||||
|
||||
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
|
||||
|
||||
@@ -87,7 +80,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Arcrun-API-Key': config.api_key,
|
||||
},
|
||||
body: JSON.stringify({ ...recipe, exposure_consent: consent ?? undefined }),
|
||||
body: JSON.stringify(recipe),
|
||||
});
|
||||
|
||||
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
|
||||
@@ -237,3 +230,139 @@ export async function cmdRecipeDelete(id: string): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公庫互動(kbdb-base §7.5,薄殼:只呼叫 API + 格式化,無業務邏輯)─────────────────
|
||||
|
||||
interface PublicRecipeSummary {
|
||||
uuid?: string;
|
||||
canonical_id: string;
|
||||
author?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
market_stat?: { success_count: number; failure_count: number } | null;
|
||||
}
|
||||
|
||||
/** acr recipe search <q> — 搜尋公庫(GET /public-recipes?q=)。落空回創作引導(§7.5.6)。*/
|
||||
export async function cmdRecipeSearch(query: string): Promise<void> {
|
||||
const spinner = ora(`搜尋公庫「${query}」`).start();
|
||||
try {
|
||||
const url = `${DEFAULT_PUBLIC_LIBRARY_URL}/public-recipes?q=${encodeURIComponent(query)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json() as
|
||||
| { found: true; recipes: PublicRecipeSummary[]; count: number }
|
||||
| { found: false; query: string; hint: string };
|
||||
spinner.stop();
|
||||
|
||||
if (!data.found) {
|
||||
console.log(chalk.yellow(`\n 公庫無符合「${query}」的 recipe。`));
|
||||
console.log(chalk.gray(` ${data.hint}`));
|
||||
console.log(chalk.gray(' 做一個:建 recipe YAML → acr recipe push(私庫)→ acr recipe submit-p(投稿成為作者)。\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\n 公庫 recipes(${data.count} 個,同名可多作者)\n`));
|
||||
for (const r of data.recipes) {
|
||||
const s = r.market_stat;
|
||||
const stat = s ? chalk.gray(` ✓${s.success_count}/✗${s.failure_count}`) : chalk.gray(' (無市場數據)');
|
||||
console.log(` • ${chalk.cyan(r.canonical_id.padEnd(20))} ${chalk.magenta('@' + (r.author ?? '?'))}${stat} ${r.display_name ?? ''}`);
|
||||
if (r.description) console.log(` ${chalk.gray(r.description)}`);
|
||||
}
|
||||
console.log(chalk.gray('\n 取用:acr recipe pull <canonical_id> [--author=<name>]\n'));
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** acr recipe pull <canonical_id> [--author] — 從公庫取一份 recipe 寫進自己私庫。*/
|
||||
export async function cmdRecipePull(canonicalId: string, author?: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (!config.api_key) {
|
||||
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
const spinner = ora(`從公庫取 recipe「${canonicalId}」${author ? `(@${author})` : ''}`).start();
|
||||
try {
|
||||
// 1. 從公庫取全文(不指定 author → 公庫回市場最佳版本)。
|
||||
const q = author ? `?author=${encodeURIComponent(author)}` : '';
|
||||
const pubRes = await fetch(`${DEFAULT_PUBLIC_LIBRARY_URL}/public-recipes/${encodeURIComponent(canonicalId)}${q}`);
|
||||
const pub = await pubRes.json() as
|
||||
| { found: true; recipe: RecipeDefinition & { uuid?: string; author?: string }; market_stat?: unknown }
|
||||
| { found: false; canonical_id: string; hint: string };
|
||||
|
||||
if (!pub.found) {
|
||||
spinner.stop();
|
||||
console.log(chalk.yellow(`\n 公庫無 recipe「${canonicalId}」。`));
|
||||
console.log(chalk.gray(` ${pub.hint}\n`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源)。
|
||||
const r = pub.recipe;
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
const installRes = await fetch(`${executorUrl}/recipes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': config.api_key },
|
||||
body: JSON.stringify({
|
||||
...r,
|
||||
derived_from: r.uuid, // 溯源:私庫這份來自公庫哪個 uuid
|
||||
}),
|
||||
});
|
||||
const inst = await installRes.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
|
||||
if (!inst.success) {
|
||||
spinner.fail(chalk.red(`寫入私庫失敗:${inst.error ?? '未知錯誤'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
spinner.succeed(chalk.green(`✓ recipe「${canonicalId}」${author ? `(@${author})` : ''} 已拉進私庫`));
|
||||
console.log(chalk.gray(` 在 workflow 用 component: ${canonicalId}\n`));
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** acr recipe submit-p <canonical_id> — 把私庫某 recipe 投稿到公庫(新增作者版本,需暴露同意)。*/
|
||||
export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (!config.api_key) {
|
||||
console.error(chalk.red('缺少 API Key,請先執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
|
||||
// 1. 從私庫取這份 recipe 全文。
|
||||
const myRes = await fetch(`${executorUrl}/recipes/${encodeURIComponent(canonicalId)}`, {
|
||||
headers: { 'X-Arcrun-API-Key': config.api_key },
|
||||
});
|
||||
const my = await myRes.json() as { success: boolean; recipe?: RecipeDefinition & { uuid?: string }; error?: string };
|
||||
if (!my.success || !my.recipe) {
|
||||
console.error(chalk.red(`私庫找不到 recipe「${canonicalId}」:${my.error ?? ''}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):投稿公庫不再需要人類確認。
|
||||
|
||||
const spinner = ora(`投稿 recipe「${canonicalId}」到公庫`).start();
|
||||
try {
|
||||
const res = await fetch(`${DEFAULT_PUBLIC_LIBRARY_URL}/recipes/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...my.recipe,
|
||||
author: author ?? my.recipe.author,
|
||||
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
|
||||
submitter: author ?? config.api_key,
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; recipe?: { uuid?: string; author?: string }; error?: string };
|
||||
if (!data.success) {
|
||||
spinner.fail(chalk.red(`投稿失敗:${data.error ?? '未知錯誤'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
spinner.succeed(chalk.green(`✓ recipe「${canonicalId}」已投稿公庫(新增作者版本 @${data.recipe?.author ?? '?'})`));
|
||||
console.log(chalk.gray(' 別人能搜到並 pull;市場數據累積後決定它被不被選用。\n'));
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+41
-14
@@ -37,14 +37,14 @@ export async function cmdRun(workflowName: string, options: RunOptions): Promise
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
// ── 玩法一:Standard 模式,YAML 在本機,帶著打 /cypher/execute ──────────────
|
||||
if (config.mode === 'standard' || config.mode === 'local') {
|
||||
const yamlPath = findWorkflowYaml(workflowName);
|
||||
if (!yamlPath) {
|
||||
console.error(chalk.red(`找不到 ${workflowName}.yaml(在目前目錄或子目錄尋找)`));
|
||||
console.error(chalk.gray('玩法二(已 push 到 KV)請改用 Self-hosted 模式'));
|
||||
process.exit(1);
|
||||
}
|
||||
// ── 玩法一:本機有 YAML → 直接帶著打 /cypher/execute(不需先 push)──────────────
|
||||
// 2026-06-09 修:原本只有 standard/local 走這條,self-hosted 一律走玩法二(/webhooks/<name>,
|
||||
// 需先 push 到 KV)。導致 self-hosted 用戶(如壓測 Haiku)有本機 YAML 卻 acr run 直接打
|
||||
// /webhooks/<name> → 沒 push = 404 純文字 → res.json() 爆「Unexpected non-whitespace...」假錯誤。
|
||||
// 正解:只要本機找得到 YAML 就走玩法一直接執行(三模式一致);找不到才退玩法二(按名字打已 push 的)。
|
||||
const localYamlPath = findWorkflowYaml(workflowName);
|
||||
if (localYamlPath) {
|
||||
const yamlPath = localYamlPath;
|
||||
|
||||
let workflow;
|
||||
try {
|
||||
@@ -71,6 +71,14 @@ export async function cmdRun(workflowName: string, options: RunOptions): Promise
|
||||
}),
|
||||
});
|
||||
|
||||
// 非 2xx 先擋:直接 res.json() 對 404「Not Found」這種純文字會爆出誤導的
|
||||
// 「Unexpected non-whitespace character after JSON」。看 res.ok 給人話。
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
spinner.fail(chalk.red(`執行失敗(HTTP ${res.status}):${body.slice(0, 200)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await res.json() as {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
@@ -88,15 +96,31 @@ export async function cmdRun(workflowName: string, options: RunOptions): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 玩法二:Self-hosted,workflow 已存在 KV,打 /webhooks/{name} ─────────────
|
||||
// ── 玩法二:本機沒這個 YAML → 按名字打已 push 到 KV 的 workflow ───────────────
|
||||
// thin-shell-alignment P0(issue #11):原打 /webhooks/${name} 是死端點(404)。
|
||||
// 真端點是 /webhooks/named/:name/trigger(webhooks-named.ts:279,X-Arcrun-API-Key header)。
|
||||
const spinner = ora(`執行 workflow "${workflowName}"`).start();
|
||||
try {
|
||||
const res = await fetch(`${executorUrl}/webhooks/${workflowName}`, {
|
||||
const res = await fetch(`${executorUrl}/webhooks/named/${workflowName}/trigger`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(inputContext),
|
||||
});
|
||||
|
||||
// 非 2xx 先擋(同玩法一):404 純文字別硬 res.json()。404 多半是「還沒 push」。
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
spinner.fail(chalk.red(`找不到已部署的 workflow "${workflowName}"`));
|
||||
console.error(chalk.gray(` 本機也沒有 ${workflowName}.yaml。請確認:`));
|
||||
console.error(chalk.gray(` ① 本機有 YAML → 在該檔所在目錄跑 acr run(會直接執行,不需先 push)`));
|
||||
console.error(chalk.gray(` ② 要跑已部署的 → 先 acr push <file>.yaml 再 acr run <name>`));
|
||||
} else {
|
||||
const body = await res.text();
|
||||
spinner.fail(chalk.red(`執行失敗(HTTP ${res.status}):${body.slice(0, 200)}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await res.json() as {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
@@ -116,11 +140,14 @@ export async function cmdRun(workflowName: string, options: RunOptions): Promise
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function findWorkflowYaml(name: string): string | null {
|
||||
// 容忍使用者直接給含副檔名的檔名(acr run foo.yaml)——剝掉再補,避免找成 foo.yaml.yaml。
|
||||
const base = name.replace(/\.(ya?ml)$/i, '');
|
||||
const candidates = [
|
||||
`${name}.yaml`,
|
||||
`${name}.yml`,
|
||||
`workflows/${name}.yaml`,
|
||||
`workflows/${name}.yml`,
|
||||
name, // 原樣(已含副檔名或本就是路徑)
|
||||
`${base}.yaml`,
|
||||
`${base}.yml`,
|
||||
`workflows/${base}.yaml`,
|
||||
`workflows/${base}.yml`,
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow)
|
||||
*
|
||||
* 為什麼存在(issue #13,leo 2026-06-29):
|
||||
* 舊世界要找一個能力(如 "telegram")必須**先決定**「它是零件?recipe?還是 workflow?」才能查對的清單。
|
||||
* 但查的人(人或 AI)在查到答案前**根本不知道**它屬哪一類 →「先知道分類」成了查詢的前置條件,倒因為果。
|
||||
* leo:「連我都會查錯表。」所以這不是粗心,是搜尋設計缺陷。
|
||||
* 本指令一次掃全部類別、依類別分組回 counts + 命中 → 查的人不必先選表,看一眼就知道 telegram 是 recipe。
|
||||
*
|
||||
* 薄殼原則(rule 07 §2):本指令只做 fan-out 呼叫既有清單來源 + 過濾 + 分組 + 印出(介面層的暴露/格式化)。
|
||||
* - component:靜態(BUILTIN_COMPONENTS,PR-only,複用 parts.ts 同一份,不另存)。
|
||||
* - recipe / auth-recipe / workflow:**動態**,從 store 端點即時抓(GET /recipes、/auth-recipes、/webhooks/named)。
|
||||
* 不在介面層拼裝任何能力;過濾是對 API 回傳值做的純客戶端格式化(§2.3 允許)。
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
import { BUILTIN_COMPONENTS } from './parts.js';
|
||||
|
||||
interface Hit {
|
||||
id: string; // canonical_id / service / workflow name
|
||||
label: string; // display_name / 描述用名稱
|
||||
detail?: string; // description(截斷)
|
||||
}
|
||||
|
||||
interface CategoryResult {
|
||||
key: 'component' | 'recipe' | 'auth-recipe' | 'workflow';
|
||||
title: string;
|
||||
dynamic: boolean; // 是否來自 store(動態)
|
||||
listHint: string; // 列全部的指令
|
||||
hits: Hit[];
|
||||
error?: string; // 該來源抓取失敗(離線/服務不可用)→ 降級顯示,不中斷其他類
|
||||
}
|
||||
|
||||
/** term 命中:id / label / detail 任一含 term(不分大小寫) */
|
||||
function matches(term: string, ...fields: Array<string | undefined>): boolean {
|
||||
const t = term.toLowerCase();
|
||||
return fields.some((f) => (f ?? '').toLowerCase().includes(t));
|
||||
}
|
||||
|
||||
function trim(s: string | undefined, n = 70): string | undefined {
|
||||
if (!s) return undefined;
|
||||
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
|
||||
const res = await fetch(url, { method: 'GET', headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function cmdSearch(term: string): Promise<void> {
|
||||
const q = (term ?? '').trim();
|
||||
if (!q) {
|
||||
console.error(chalk.red(' 請提供搜尋關鍵字:acr search <term>'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const baseUrl = getCypherExecutorUrl(config);
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
// ── component(靜態,本地,永不失敗)──
|
||||
const componentRes: CategoryResult = {
|
||||
key: 'component',
|
||||
title: '零件 component(靜態 · PR-only · WASM)',
|
||||
dynamic: false,
|
||||
listHint: 'acr parts',
|
||||
hits: BUILTIN_COMPONENTS
|
||||
.filter((c) => matches(q, c.canonical_id, c.display_name, c.description))
|
||||
.map((c) => ({ id: c.canonical_id, label: c.display_name, detail: trim(c.description) })),
|
||||
};
|
||||
|
||||
// ── recipe / auth-recipe / workflow(動態,store;各自獨立 try,互不連坐)──
|
||||
const dynamicSpecs: Array<{
|
||||
key: CategoryResult['key'];
|
||||
title: string;
|
||||
listHint: string;
|
||||
url: string;
|
||||
extract: (data: unknown) => Hit[];
|
||||
}> = [
|
||||
{
|
||||
key: 'recipe',
|
||||
title: 'API recipe(動態 · store · 打外部服務)',
|
||||
listHint: 'acr recipe list',
|
||||
url: `${baseUrl}/recipes`,
|
||||
extract: (data) => {
|
||||
const arr = (data as { recipes?: Array<{ canonical_id: string; display_name?: string; description?: string }> }).recipes ?? [];
|
||||
return arr
|
||||
.filter((r) => matches(q, r.canonical_id, r.display_name, r.description))
|
||||
.map((r) => ({ id: r.canonical_id, label: r.display_name ?? r.canonical_id, detail: trim(r.description) }));
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'auth-recipe',
|
||||
title: '第三方服務認證 auth-recipe(動態 · store)',
|
||||
listHint: 'acr auth-recipe list',
|
||||
url: `${baseUrl}/auth-recipes`,
|
||||
extract: (data) => {
|
||||
const arr = (data as { recipes?: Array<{ service: string; display_name?: string; description?: string }> }).recipes ?? [];
|
||||
return arr
|
||||
.filter((r) => matches(q, r.service, r.display_name, r.description))
|
||||
.map((r) => ({ id: r.service, label: r.display_name ?? r.service, detail: trim(r.description) }));
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'workflow',
|
||||
title: '已部署 workflow(動態 · store)',
|
||||
listHint: 'acr list',
|
||||
url: `${baseUrl}/webhooks/named`,
|
||||
extract: (data) => {
|
||||
const arr = (data as { workflows?: Array<{ name: string; description?: string }> }).workflows ?? [];
|
||||
return arr
|
||||
.filter((w) => matches(q, w.name, w.description))
|
||||
.map((w) => ({ id: w.name, label: w.name, detail: trim(w.description) }));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const dynamicResults = await Promise.all(
|
||||
dynamicSpecs.map(async (spec): Promise<CategoryResult> => {
|
||||
try {
|
||||
const data = await fetchJson(spec.url, headers);
|
||||
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: spec.extract(data) };
|
||||
} catch (e) {
|
||||
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: [], error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const all: CategoryResult[] = [componentRes, ...dynamicResults];
|
||||
|
||||
// ── 印出:先一行 counts 摘要(看一眼就知道屬哪類),再各類明細 ──
|
||||
console.log(chalk.bold(`\n 搜尋「${q}」\n`));
|
||||
|
||||
const countLine = all
|
||||
.map((r) => {
|
||||
const n = r.error ? '?' : String(r.hits.length);
|
||||
const c = r.error ? chalk.yellow(`${r.key}:${n}`) : (r.hits.length > 0 ? chalk.green(`${r.key}:${n}`) : chalk.gray(`${r.key}:0`));
|
||||
return c;
|
||||
})
|
||||
.join(' ');
|
||||
console.log(' ' + countLine + '\n');
|
||||
|
||||
let anyHit = false;
|
||||
for (const r of all) {
|
||||
if (r.error) {
|
||||
console.log(chalk.yellow(` ${r.title} — 無法讀取(${r.error});離線時改用 ${chalk.cyan(r.listHint)}`));
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
if (r.hits.length === 0) continue;
|
||||
anyHit = true;
|
||||
console.log(chalk.bold.underline(` ${r.title}`));
|
||||
for (const h of r.hits) {
|
||||
const label = h.label && h.label !== h.id ? ` ${h.label}` : '';
|
||||
console.log(` ${chalk.cyan(h.id.padEnd(24))}${label}`);
|
||||
if (h.detail) console.log(chalk.gray(` ${h.detail}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!anyHit && !all.some((r) => r.error)) {
|
||||
console.log(chalk.yellow(` 四類都沒有命中「${q}」。`));
|
||||
console.log(chalk.gray(' • 想新增打外部服務的能力 → acr recipe push(建 recipe)'));
|
||||
console.log(chalk.gray(' • 服務認證設定 → acr auth-recipe list 找對應服務'));
|
||||
console.log(chalk.gray(' • 零件(WASM)只能走 GitHub PR 新增(稀有)'));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
|
||||
export async function cmdUpdate(): Promise<void> {
|
||||
export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (config.mode !== 'self-hosted') {
|
||||
@@ -57,17 +57,48 @@ export async function cmdUpdate(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// D1(KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
|
||||
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
|
||||
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(` ⚠ ${em}`));
|
||||
if (/auth/i.test(em)) {
|
||||
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: DeployContext = {
|
||||
accountId: config.cloudflare_account_id,
|
||||
apiToken: config.cf_api_token,
|
||||
workerSubdomain: extractSubdomain(config.cypher_executor_url),
|
||||
kvNamespaceIds,
|
||||
d1DatabaseId: d1DatabaseId || undefined,
|
||||
// self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
|
||||
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
|
||||
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
|
||||
// 語義查詢開關(issue #7):config.kbdb_embed:true → 部署建 Vectorize index + 注入 binding。
|
||||
// 這也是「CC 幫開」的落地路徑:CC 寫 kbdb_embed:true 進 config → acr update redeploy 即生效。
|
||||
kbdbEmbed: config.kbdb_embed === true,
|
||||
};
|
||||
|
||||
const result = await downloadAndDeploy(ctx);
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
|
||||
|
||||
if (result.implemented) {
|
||||
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
|
||||
// 否則 worker 失敗被綠勾蓋掉(假綠):cypher 沒部上 → 後面 migrate 打舊 worker 404,
|
||||
// 用戶重跑 N 次都不知道根因(壓測 2026-06-11 實證)。
|
||||
if (result.message?.includes('失敗')) {
|
||||
console.log(chalk.yellow(`\n ⚠ 部署部分失敗:`));
|
||||
console.log(chalk.yellow(' ' + result.message.split('\n').join('\n ')));
|
||||
} else {
|
||||
console.log(chalk.green('\n ✓ 部署完成'));
|
||||
}
|
||||
// 重跑 seed(薄殼:呼叫 API /init/seed;冪等,覆寫既有)。
|
||||
// 修壓測 §4.1.3「update 不做 seed,但 init 提示說 update 會重試 seed」的矛盾。
|
||||
const cypherUrl = config.cypher_executor_url
|
||||
@@ -84,6 +115,28 @@ export async function cmdUpdate(): Promise<void> {
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : e})`));
|
||||
}
|
||||
|
||||
// kbdb-base 8.P0:一次性把舊的 per-key cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all。
|
||||
// 部署 8.P0 後既有 cron workflow 若不重 push 會停擺(scheduled 只讀新集中 key)→ 這裡冪等補上。
|
||||
// 冪等、不刪舊 key、失敗不致命(重跑 acr update 會再試)。
|
||||
process.stdout.write(chalk.gray(' → 遷移 cron index(舊 per-key → 集中 key,冪等)...'));
|
||||
try {
|
||||
const res = await fetch(`${cypherUrl}/webhooks/named/migrate-cron-index`, { method: 'POST' });
|
||||
const rawText = await res.text();
|
||||
let body: { success?: boolean; migrated?: number; skipped?: number; errors?: string[] } | null = null;
|
||||
try { body = JSON.parse(rawText); } catch { /* 非 JSON(如 CF 錯誤頁)→ 用原文 */ }
|
||||
if (res.ok && body?.success) {
|
||||
console.log(chalk.green(` ✓ migrated ${body.migrated ?? 0}, skipped ${body.skipped ?? 0}`));
|
||||
} else {
|
||||
// 印 server 回的錯誤內容(截前 200 字)——只回 HTTP status 沒人能診斷
|
||||
// (壓測 2026-06-11:404→500 重跑 3 次都看不到根因)。
|
||||
const detail = (body?.errors?.join('; ') ?? rawText).slice(0, 200);
|
||||
console.log(chalk.yellow(` ⚠ HTTP ${res.status}${detail ? `:${detail}` : ''}`));
|
||||
console.log(chalk.yellow(' 404 = cypher worker 還是舊版(看上方部署是否有失敗);500 = server 端錯誤(看上行錯誤內容)'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(` ⚠ cron index 遷移失敗(${e instanceof Error ? e.message : e})`));
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* acr whoami — 一眼看「我現在是誰、連哪台、帳號從哪層來」(§7.8 P1,D2 修法)。
|
||||
*
|
||||
* D2 根因(self-hosted-init.md §7.8):config 分層**已實作**(config.ts),但 AI 不用 CLI 讀帳號、
|
||||
* 自己 curl 猜全域帳號 URL → 打到錯帳號。治本不是再改 config,是給 AI 一個無腦入口:
|
||||
* 「問 CLI 拿正確身份,別自己 curl 猜」。與 MCP arcrun_whoami 對齊(薄殼一致,rule 07 §5)。
|
||||
*
|
||||
* 與 acr config 區別:config 印完整欄位表(人用、含敏感欄位遮罩);whoami 印精煉摘要
|
||||
* (mode / 連哪台 cypher / 帳號來源層),AI 讀完就知道該用哪個帳號、打哪個 URL,不必再猜。
|
||||
*
|
||||
* --json:給 AI/MCP 結構化讀取(薄殼一致)。
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
loadConfig,
|
||||
resolveConfigSources,
|
||||
getCypherExecutorUrl,
|
||||
getMcpUrl,
|
||||
activeProjectConfigPath,
|
||||
type ConfigSource,
|
||||
} from '../lib/config.js';
|
||||
|
||||
const SOURCE_LABEL: Record<ConfigSource, string> = {
|
||||
env: 'env 變數',
|
||||
project: '專案層 .arcrun.yaml',
|
||||
global: '全域 ~/.arcrun/config.yaml',
|
||||
default: '預設值',
|
||||
};
|
||||
|
||||
/** 帳號身份欄位(self-hosted=api_key 即 NAMESPACE 分區標籤;standard=平台 api_key)。*/
|
||||
function identitySource(): ConfigSource {
|
||||
const row = resolveConfigSources().find((r) => r.field === 'api_key');
|
||||
return row?.source ?? 'default';
|
||||
}
|
||||
|
||||
function maskKey(v?: string): string {
|
||||
if (!v) return '(未設)';
|
||||
return v.length > 8 ? `${v.slice(0, 8)}…` : v;
|
||||
}
|
||||
|
||||
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const cypherUrl = getCypherExecutorUrl(config);
|
||||
const mcpUrl = getMcpUrl(config);
|
||||
const accountSource = identitySource();
|
||||
const projectPath = activeProjectConfigPath();
|
||||
|
||||
if (options.json) {
|
||||
// 結構化輸出(AI/MCP 對齊用)。敏感值不全印。
|
||||
console.log(JSON.stringify({
|
||||
mode: config.mode,
|
||||
account: maskKey(config.api_key),
|
||||
account_source: accountSource,
|
||||
cypher_executor_url: cypherUrl,
|
||||
mcp_url: mcpUrl,
|
||||
cloudflare_account_id: config.cloudflare_account_id ?? null,
|
||||
project_config: projectPath ?? null,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\n arcrun — 目前身份\n'));
|
||||
console.log(` ${chalk.cyan('模式')} ${config.mode}`);
|
||||
console.log(` ${chalk.cyan('帳號')} ${maskKey(config.api_key)} ${chalk.gray(`← ${SOURCE_LABEL[accountSource]}`)}`);
|
||||
console.log(` ${chalk.cyan('連哪台')} ${cypherUrl}`);
|
||||
console.log(` ${chalk.cyan('MCP')} ${mcpUrl}`);
|
||||
if (config.mode === 'self-hosted' && config.cloudflare_account_id) {
|
||||
console.log(` ${chalk.cyan('CF 帳號')} ${config.cloudflare_account_id}`);
|
||||
}
|
||||
console.log('');
|
||||
if (projectPath) {
|
||||
console.log(chalk.gray(` 此資料夾用專案層設定:${projectPath}`));
|
||||
} else {
|
||||
console.log(chalk.gray(' 此資料夾用全域設定(無 .arcrun.yaml)'));
|
||||
}
|
||||
console.log(chalk.gray(' → 部署/觸發前用這個帳號,別自己 curl 全域 URL 猜帳號。\n'));
|
||||
}
|
||||
+72
-2
@@ -12,17 +12,27 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { cmdInit } from './commands/init.js';
|
||||
import { cmdConfig } from './commands/config.js';
|
||||
import { cmdWhoami } from './commands/whoami.js';
|
||||
import { cmdCredsPush } from './commands/creds.js';
|
||||
import { cmdPush } from './commands/push.js';
|
||||
import { cmdRun } from './commands/run.js';
|
||||
import { cmdValidate } from './commands/validate.js';
|
||||
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
|
||||
import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete } from './commands/recipe.js';
|
||||
import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete, cmdRecipeSearch, cmdRecipePull, cmdRecipeSubmitP } from './commands/recipe.js';
|
||||
import { cmdList } from './commands/list.js';
|
||||
import { cmdSearch } from './commands/search.js';
|
||||
import { cmdLogs } from './commands/logs.js';
|
||||
import { cmdUpdate } from './commands/update.js';
|
||||
import { cmdInstallHarness } from './commands/install-harness.js';
|
||||
import { cmdMcpSetup } from './commands/mcp-setup.js';
|
||||
import {
|
||||
cmdKbdbTemplateCreate,
|
||||
cmdKbdbTemplateList,
|
||||
cmdKbdbRecordCreate,
|
||||
cmdKbdbRecordGet,
|
||||
cmdKbdbQuery,
|
||||
cmdKbdbSearch,
|
||||
} from './commands/kbdb.js';
|
||||
import { cmdAuthRecipeList, cmdAuthRecipeInfo, cmdAuthRecipeScaffold } from './commands/auth-recipe.js';
|
||||
|
||||
const program = new Command();
|
||||
@@ -62,6 +72,13 @@ program
|
||||
.option('--where', '顯示每個設定值來自哪一層(env > 專案層 .arcrun.yaml > 全域)')
|
||||
.action((options: { where?: boolean }) => cmdConfig(options));
|
||||
|
||||
// acr whoami [--json]:一眼看當前身份(mode / 連哪台 cypher / 帳號來源層)。§7.8 P1 D2 修法。
|
||||
program
|
||||
.command('whoami')
|
||||
.description('顯示目前生效的身份(帳號、連哪台 cypher、來源層)——AI 別自己 curl 猜帳號')
|
||||
.option('--json', '結構化輸出(給 AI / 腳本讀取)')
|
||||
.action((options: { json?: boolean }) => cmdWhoami(options));
|
||||
|
||||
// acr creds push [credentials.yaml]
|
||||
const credsCmd = program.command('creds').description('Credential 管理');
|
||||
credsCmd
|
||||
@@ -121,6 +138,21 @@ recipeCmd
|
||||
.command('delete <id>')
|
||||
.description('刪除 recipe(canonical_id 或 rec_hash)')
|
||||
.action((id: string) => cmdRecipeDelete(id));
|
||||
// 公庫互動(kbdb-base §7.5)
|
||||
recipeCmd
|
||||
.command('search <query>')
|
||||
.description('搜尋公庫 recipe(同名可多作者,附市場數據)')
|
||||
.action((query: string) => cmdRecipeSearch(query));
|
||||
recipeCmd
|
||||
.command('pull <canonical_id>')
|
||||
.description('從公庫取一份 recipe 寫進自己私庫')
|
||||
.option('--author <name>', '指定作者版本(不指定取市場最佳)')
|
||||
.action((canonicalId: string, opts: { author?: string }) => cmdRecipePull(canonicalId, opts.author));
|
||||
recipeCmd
|
||||
.command('submit-p <canonical_id>')
|
||||
.description('把私庫某 recipe 投稿到公庫(新增作者版本,需暴露同意)')
|
||||
.option('--author <name>', '署名作者(預設用 recipe 既有 author)')
|
||||
.action((canonicalId: string, opts: { author?: string }) => cmdRecipeSubmitP(canonicalId, opts.author));
|
||||
|
||||
// acr auth-recipe list / info / scaffold
|
||||
const authRecipeCmd = program.command('auth-recipe').description('第三方服務認證 Recipe(新增服務整合)');
|
||||
@@ -137,12 +169,49 @@ authRecipeCmd
|
||||
.description('輸出 credentials.yaml 範本 + workflow.yaml 使用範例')
|
||||
.action((service: string) => cmdAuthRecipeScaffold(service));
|
||||
|
||||
// acr kbdb — KBDB 資料層薄殼(kbdb-base 9.2,透過 cypher KBDB proxy;與 MCP kbdb_* 同能力)
|
||||
const kbdbCmd = program.command('kbdb').description('KBDB 資料層(template/record/query/search;不建表、不寫 SQL)');
|
||||
const kbdbTemplateCmd = kbdbCmd.command('template').description('template = 虛擬表定義(name + slots)');
|
||||
kbdbTemplateCmd
|
||||
.command('create <name>')
|
||||
.description('建一個 template(虛擬表定義),如 --slots name,email,phone')
|
||||
.requiredOption('--slots <list>', '欄位名清單,逗號分隔,如 name,email,phone')
|
||||
.action((name: string, opts: { slots: string }) => cmdKbdbTemplateCreate(name, opts));
|
||||
kbdbTemplateCmd
|
||||
.command('list')
|
||||
.description('列出所有 template')
|
||||
.action(() => cmdKbdbTemplateList());
|
||||
const kbdbRecordCmd = kbdbCmd.command('record').description('record = 依 template 填的一筆資料');
|
||||
kbdbRecordCmd
|
||||
.command('create <template>')
|
||||
.description('填一筆 record,如 --values name=Leo --values email=x@y.com(可重複)')
|
||||
.option('--values <pair...>', 'slot=內容(可重複)')
|
||||
.action((template: string, opts: { values?: string[] }) => cmdKbdbRecordCreate(template, opts));
|
||||
kbdbRecordCmd
|
||||
.command('get <record_id>')
|
||||
.description('取單筆 record 全文')
|
||||
.action((recordId: string) => cmdKbdbRecordGet(recordId));
|
||||
kbdbCmd
|
||||
.command('query <template>')
|
||||
.description('列某 template 下本租戶的所有 record')
|
||||
.action((template: string) => cmdKbdbQuery(template));
|
||||
kbdbCmd
|
||||
.command('search <q>')
|
||||
.description('關鍵字搜尋本租戶內容(LIKE,基本盤)')
|
||||
.action((q: string) => cmdKbdbSearch(q));
|
||||
|
||||
// acr list
|
||||
program
|
||||
.command('list')
|
||||
.description('列出 CF KV 中所有已部署的 workflow')
|
||||
.action(() => cmdList());
|
||||
|
||||
// acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow),免先選表
|
||||
program
|
||||
.command('search <term>')
|
||||
.description('跨類搜尋能力(零件/recipe/auth-recipe/workflow),一次掃全部、依類別回 counts+命中')
|
||||
.action((term: string) => cmdSearch(term));
|
||||
|
||||
// acr logs <workflow_name>
|
||||
program
|
||||
.command('logs <workflow>')
|
||||
@@ -153,7 +222,8 @@ program
|
||||
program
|
||||
.command('update')
|
||||
.description('self-hosted:拉新 release 並重新部署到你的 Cloudflare')
|
||||
.action(() => cmdUpdate());
|
||||
.option('--force', '強制重部所有 worker(忽略未變動跳過快取)')
|
||||
.action((opts: { force?: boolean }) => cmdUpdate({ force: opts.force }));
|
||||
|
||||
// acr install-harness(把 arcrun 的 CC harness 裝進當前專案)
|
||||
program
|
||||
|
||||
@@ -144,6 +144,25 @@ export class CfAccountClient {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||||
return result.subdomain;
|
||||
}
|
||||
|
||||
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
|
||||
async listD1Databases(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
|
||||
const map = new Map<string, string>();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
}
|
||||
|
||||
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listD1Databases());
|
||||
const found = known.get(name);
|
||||
if (found) return found;
|
||||
const result = await this.cf<{ uuid: string; name: string }>(
|
||||
'/d1/database',
|
||||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||
);
|
||||
return result.uuid;
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||
|
||||
+24
-4
@@ -28,9 +28,14 @@ export interface ArcrunConfig {
|
||||
// SDD: sdk-and-website/mcp-account-source.md
|
||||
mcp_url?: string;
|
||||
multi_tenant?: boolean;
|
||||
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。
|
||||
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。
|
||||
// 注意:這只是 CLI 端 UX(不重問);server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
// 語義查詢開關(issue #7 / SDD T2.4,self-hosted 從零做)。
|
||||
// true → deploy 時建 CF Vectorize index 並注入 kbdb worker 的 [[vectorize]]+[ai] binding;
|
||||
// kbdb embed 模組啟用(寫入時對標記 embed 的 entry embed、search 支援 mode=semantic)。
|
||||
// 未設/false → base 維持 LIKE keyword(free-tier 友善,不建 index、不花費)。
|
||||
// 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
|
||||
kbdb_embed?: boolean;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>;
|
||||
}
|
||||
|
||||
@@ -65,7 +70,17 @@ const ENV_MAP: Record<string, keyof ArcrunConfig> = {
|
||||
* 平台預設 MCP URL(mcp_url 未設時的 fallback,SaaS 用戶用)。
|
||||
* MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(mcp/wrangler.toml route = mcp.arcrun.dev)。
|
||||
*/
|
||||
export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev';
|
||||
// MCP streamable-http 端點是 /mcp(根路徑 404)。少了 /mcp → client 連線 Failed。
|
||||
export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev/mcp';
|
||||
|
||||
/**
|
||||
* 公庫 URL(recipe pull/search/submit-p 的對象,kbdb-base §7.5)。
|
||||
* 公庫 = 官方 SaaS cypher(唯一公共真相)。self-hosted 用戶的「私庫」是自己的 cypher
|
||||
* (getCypherExecutorUrl),但 pull/搜尋/投稿都對著**官方公庫**這個固定 URL。
|
||||
* fork 者可用 ARCRUN_PUBLIC_LIBRARY_URL env 覆蓋。
|
||||
*/
|
||||
export const DEFAULT_PUBLIC_LIBRARY_URL =
|
||||
process.env.ARCRUN_PUBLIC_LIBRARY_URL ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
export function configExists(): boolean {
|
||||
return existsSync(CONFIG_PATH) || findProjectConfig() !== undefined;
|
||||
@@ -150,6 +165,11 @@ function readEnvOverrides(): Partial<ArcrunConfig> {
|
||||
(out as Record<string, unknown>)[field] = v;
|
||||
}
|
||||
}
|
||||
// bool 開關(issue #7):env 可選覆蓋,'true'/'1' → true。
|
||||
const embedEnv = process.env.ARCRUN_KBDB_EMBED;
|
||||
if (embedEnv !== undefined && embedEnv !== '') {
|
||||
out.kbdb_embed = embedEnv === 'true' || embedEnv === '1';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
+330
-19
@@ -10,8 +10,58 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
|
||||
/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash),
|
||||
* 讓 acr update 跳過未變動的 worker(壓測 2026-06-12:22/23 成功後重跑仍全部
|
||||
* pnpm install + wrangler deploy,22 個沒變的白跑)。存 ~/.arcrun/。
|
||||
* 指紋含 wrangler.toml 注入後的內容 → 換帳號/KV 會變更指紋 → 自動重部,不會誤跳。*/
|
||||
const MANIFEST_PATH = join(homedir(), '.arcrun', 'deploy-manifest.json');
|
||||
|
||||
function loadManifest(): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record<string, string>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveManifest(m: Record<string, string>): void {
|
||||
try {
|
||||
writeFileSync(MANIFEST_PATH, JSON.stringify(m, null, 2));
|
||||
} catch {
|
||||
/* manifest 寫失敗不致命:下次全部重部(退化成舊行為,不會錯,只是慢) */
|
||||
}
|
||||
}
|
||||
|
||||
/** 算一個 worker 目錄的內容指紋:遞迴 hash 所有檔案(排除 node_modules),
|
||||
* 加上 accountId(換帳號要重部)。檔案路徑相對化後排序 → 跨機器/temp 目錄穩定。*/
|
||||
function dirContentHash(dir: string, accountId: string): string {
|
||||
const h = createHash('sha256');
|
||||
h.update(accountId);
|
||||
const walk = (d: string, rel: string): void => {
|
||||
let entries: string[];
|
||||
try { entries = readdirSync(d).sort(); } catch { return; }
|
||||
for (const name of entries) {
|
||||
if (name === 'node_modules' || name === '.git') continue;
|
||||
const full = join(d, name);
|
||||
const relPath = rel ? `${rel}/${name}` : name;
|
||||
let st;
|
||||
try { st = statSync(full); } catch { continue; }
|
||||
if (st.isDirectory()) {
|
||||
walk(full, relPath);
|
||||
} else {
|
||||
h.update(relPath);
|
||||
try { h.update(readFileSync(full)); } catch { /* skip unreadable */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir, '');
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
/** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
|
||||
* 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/
|
||||
@@ -47,11 +97,24 @@ export interface DeployContext {
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
|
||||
// self-hosted 單租戶旗標。true(self-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars],
|
||||
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
|
||||
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
|
||||
selfHosted?: boolean;
|
||||
// 語義查詢開關(issue #7 / SDD T2.4)。true → 部署前建 CF Vectorize index 並注入 kbdb worker 的
|
||||
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
||||
// base 維持 LIKE keyword(free-tier 友善)。
|
||||
kbdbEmbed?: boolean;
|
||||
}
|
||||
|
||||
/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */
|
||||
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';
|
||||
|
||||
export interface DeployResult {
|
||||
implemented: boolean;
|
||||
cypherExecutorUrl?: string;
|
||||
mcpUrl?: string; // self-hosted 自己的 MCP worker URL(mcp-account-source §3)
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -79,7 +142,11 @@ export function wranglerAvailable(): boolean {
|
||||
* @param ctx 部署上下文
|
||||
* @param ref git ref(branch / tag),預設 main;acr update 可帶 tag
|
||||
*/
|
||||
export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promise<DeployResult> {
|
||||
export async function downloadAndDeploy(
|
||||
ctx: DeployContext,
|
||||
ref = 'main',
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<DeployResult> {
|
||||
// 1. 下載 + 解壓 codeload tarball
|
||||
let root: string;
|
||||
try {
|
||||
@@ -97,28 +164,122 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi
|
||||
return { implemented: true, message: `部署物中找不到任何 wrangler.toml(root=${root})。` };
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
// 2.5 共享依賴:23 個 component worker 的 runtime dep 全是 hono、devDep 全含 wrangler,
|
||||
// 舊版每個 worker 各 install 一份 ~324MB node_modules(23× 重複,壓測 2026-06-12 慢的真因)。
|
||||
// 改成在 tarball root 裝「一次」hono+wrangler;component 目錄靠 node 往上 resolve(已驗證可行)。
|
||||
// → 23×4.4s install 變 1×17s。失敗不致命:退回各 worker 自裝(runWranglerDeploy 仍有 fallback)。
|
||||
let sharedBin = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → 安裝共享部署依賴(一次,取代每個 worker 各裝)...'));
|
||||
// 含全部 worker 的 runtime deps(tier1 component 只要 hono;tier2 cypher/registry/mcp/kbdb
|
||||
// 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml)→ 全裝 root,
|
||||
// 各 worker 往上 resolve,esbuild bundle 找得到。漏一個會讓該 worker deploy 失敗,故寧可多列。
|
||||
writeFileSync(
|
||||
join(root, 'package.json'),
|
||||
JSON.stringify({ name: 'arcrun-deploy-shared', private: true, type: 'module',
|
||||
dependencies: {
|
||||
hono: '^4.7.0', wrangler: '^4.0.0', zod: '^3.23.0',
|
||||
'@hono/zod-openapi': '^0.18.0', '@modelcontextprotocol/sdk': '^1.0.0',
|
||||
'js-yaml': '^4.1.0', yaml: '^2.4.0',
|
||||
} }),
|
||||
);
|
||||
execFileSync('npm', ['install', '--no-audit', '--no-fund'],
|
||||
{ cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
sharedBin = join(root, 'node_modules', '.bin', 'wrangler');
|
||||
console.log(existsSync(sharedBin) ? chalk.green(' ✓') : chalk.yellow(' ⚠ 退回各 worker 自裝'));
|
||||
if (!existsSync(sharedBin)) sharedBin = '';
|
||||
} catch (e) {
|
||||
const tail = (e as { stderr?: Buffer }).stderr?.toString().trim().split('\n').slice(-2).join(' | ').slice(0, 200) ?? '';
|
||||
console.log(chalk.yellow(` ⚠ 共享安裝失敗,退回各 worker 自裝${tail ? `:${tail}` : ''}`));
|
||||
}
|
||||
|
||||
const failures: string[] = [];
|
||||
|
||||
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
|
||||
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
|
||||
// 失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||||
if (ctx.kbdbEmbed) {
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
||||
await ensureVectorizeIndex(ctx);
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
||||
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
||||
const allDirs = [...tier1, ...tier2];
|
||||
let deployed = 0;
|
||||
for (const dir of [...tier1, ...tier2]) {
|
||||
let skipped = 0;
|
||||
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
|
||||
// --force 清空 manifest → 全部重部。
|
||||
const manifest = opts.force ? {} : loadManifest();
|
||||
console.log(chalk.gray(` → 部署 ${allDirs.length} 個 worker(未變動者跳過,依序進行)...`));
|
||||
for (let i = 0; i < allDirs.length; i++) {
|
||||
const dir = allDirs[i];
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
||||
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx);
|
||||
runWranglerDeploy(dir, ctx);
|
||||
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
||||
const hash = dirContentHash(dir, ctx.accountId);
|
||||
if (manifest[label] === hash) {
|
||||
skipped++;
|
||||
console.log(chalk.gray(' ⊘ 未變動,跳過'));
|
||||
continue;
|
||||
}
|
||||
runWranglerDeploy(dir, ctx, sharedBin);
|
||||
manifest[label] = hash; // 只在成功後記錄 → 失敗者下次必重試
|
||||
saveManifest(manifest);
|
||||
deployed++;
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
delete manifest[label]; // 失敗 → 清掉舊指紋,確保下次重部
|
||||
saveManifest(manifest);
|
||||
failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
}
|
||||
}
|
||||
if (skipped > 0) {
|
||||
console.log(chalk.gray(` (${skipped} 個未變動已跳過;要強制全部重部跑 acr update --force)`));
|
||||
}
|
||||
|
||||
// 3.5 KBDB Base: D1 建好後套 migration(建三表 + recipe_stat seed)。
|
||||
// 建 D1(cf-api ensureD1Database)只產生空資料庫,schema 要靠這步套。
|
||||
// migration 檔來自同一份 tarball(root/kbdb/migrations/0001_base.sql),與 wasm 同源,
|
||||
// 不依賴本地 CLI 安裝路徑。0001_base.sql 全用 IF NOT EXISTS / INSERT OR IGNORE → 可重複套(idempotent)。
|
||||
if (ctx.d1DatabaseId) {
|
||||
const migPath = join(root, 'kbdb', 'migrations', '0001_base.sql');
|
||||
if (existsSync(migPath)) {
|
||||
try {
|
||||
await applyD1Migration(ctx, readFileSync(migPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql(${migPath})`);
|
||||
}
|
||||
}
|
||||
|
||||
const cypherExecutorUrl = ctx.workerSubdomain
|
||||
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
|
||||
: undefined;
|
||||
// self-hosted 自己的 MCP worker URL(mcp-account-source §3:.mcp.json 指自己)。
|
||||
// 端點是 /mcp(streamable http;根路徑 404)。仿 cypher 用 WORKER_SUBDOMAIN 組。
|
||||
const mcpUrl = ctx.workerSubdomain
|
||||
? `https://arcrun-mcp.${ctx.workerSubdomain}.workers.dev/mcp`
|
||||
: undefined;
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
mcpUrl,
|
||||
message:
|
||||
`部署 ${deployed}/${tier1.length + tier2.length} 成功,${failures.length} 失敗(誠實回報,未假綠):\n` +
|
||||
failures.map(f => ` ✗ ${f}`).join('\n'),
|
||||
@@ -128,17 +289,85 @@ export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promi
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
mcpUrl,
|
||||
message: `部署完成:${deployed} 個 Worker 全部成功。`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/
|
||||
/**
|
||||
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
|
||||
* 用 init 已驗的 ctx.apiToken + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。
|
||||
*/
|
||||
async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/d1/database/${ctx.d1DatabaseId}/query`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sql }),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string }> }
|
||||
| null;
|
||||
if (!res.ok || !json?.success) {
|
||||
const detail = json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=768/metric=cosine,對齊 bge-base-en-v1.5)。
|
||||
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
||||
*/
|
||||
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: KBDB_VECTORIZE_INDEX,
|
||||
config: { dimensions: 768, metric: 'cosine' },
|
||||
description: 'arcrun KBDB optional embed module (issue #7)',
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.ok) return;
|
||||
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
|
||||
| null;
|
||||
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
|
||||
*
|
||||
* ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarball(tar.gz/main)由 GitHub CDN 快取,
|
||||
* push 後該 ref 的 tarball 可能 stale 數分鐘。「push → 立刻 acr update」會抓到舊 tarball →
|
||||
* wrangler deploy 仍回 ✓(部署成功)但 ship 的是**舊 code** → seed 還是舊數量(假綠:
|
||||
* 「deploy 成功」≠「部到修好的版本」)。這正是 telegram seed 灌不進 leo21c 的真因。
|
||||
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過 CDN 快取,每次抓到 ref 的最新內容。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
// 唯一 cache-buster query param:codeload 對不同 query 視為不同資源 → 繞過 stale CDN entry。
|
||||
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}?_cb=${bust}`;
|
||||
console.log(chalk.gray(` → 從 GitHub 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`));
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
||||
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`codeload HTTP ${res.status}(${url})`);
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
|
||||
console.log(chalk.gray(` → 下載完成(${sizeMB} MB),解壓中...`));
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-'));
|
||||
const tarPath = join(dir, 'repo.tar.gz');
|
||||
writeFileSync(tarPath, buf);
|
||||
@@ -169,7 +398,12 @@ function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of ['cypher-executor', 'registry']) {
|
||||
// self-hosted 也部署自己的 MCP worker(mcp-account-source §5c:codeload 主庫即得 MCP,
|
||||
// .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev)。
|
||||
// kbdb:MCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb worker(mcp/wrangler.toml)。
|
||||
// D1 arcrun-kbdb 已由 init/update 建好,但 worker 本體要一併部署,否則 binding 指向不存在的 service
|
||||
// → 每個 MCP 認證請求都 throw(self-hosted MCP failed 根因,2026-06-10)。
|
||||
for (const name of ['cypher-executor', 'registry', 'kbdb', 'mcp']) {
|
||||
const dir = join(root, name);
|
||||
if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir);
|
||||
}
|
||||
@@ -212,11 +446,76 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
|
||||
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
/(database_id\s*=\s*")[^"]*(")/,
|
||||
`$1${ctx.d1DatabaseId}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
// self-hosted:注入 MULTI_TENANT="false" 到 [vars](mcp-account-source §5.5)。
|
||||
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
|
||||
// 只對有 [vars] 的 worker(mcp / cypher-executor)生效;其餘無 [vars] 的不動。
|
||||
if (ctx.selfHosted) {
|
||||
toml = injectMultiTenant(toml);
|
||||
|
||||
// self-hosted:把 cypher 的 KBDB_BASE_URL 從官方 arcrun-kbdb.uncle6-me 改成用戶自己帳號的
|
||||
// arcrun-kbdb.<subdomain>.workers.dev(issue #2)。比照 database_id / MULTI_TENANT 注入模式。
|
||||
// 漏這一個 → cypher /kbdb/* fallback 到官方 kbdb worker,self-hosted 資料寫進官方庫(隔離破損)。
|
||||
if (ctx.workerSubdomain) {
|
||||
toml = toml.replace(
|
||||
/(KBDB_BASE_URL\s*=\s*")[^"]*(")/,
|
||||
`$1https://arcrun-kbdb.${ctx.workerSubdomain}.workers.dev$2`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toml = stripOfficialOnlyBindings(toml);
|
||||
|
||||
// 語義查詢(issue #7 / T2.4):開 kbdb_embed → 取消 kbdb toml 的 [[vectorize]]+[ai] 註解段(注入 active binding)。
|
||||
// **必須在 stripOfficialOnlyBindings 之後**:strip 會移除 [ai] 區塊(官方專屬),若先注入會被它清掉。
|
||||
// 只對含該註解段的 toml(= kbdb)生效;其餘 worker toml 無此段,replace 不命中、不動。
|
||||
// 未開 → 維持註解 → worker env 無 VECTORIZE/AI → embedEnabled()=false → base keyword(不花費)。
|
||||
if (ctx.kbdbEmbed) {
|
||||
toml = toml.replace(
|
||||
/# (\[\[vectorize\]\])\n# (binding = "VECTORIZE")\n# (index_name = "[^"]*")/,
|
||||
'$1\n$2\n$3',
|
||||
);
|
||||
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
|
||||
}
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* self-hosted:確保 worker [vars] 有 `MULTI_TENANT = "false"`。處理三種既有狀態:
|
||||
* 1. 已有 active `MULTI_TENANT = "..."` → 改成 "false"
|
||||
* 2. 有註解的 `# MULTI_TENANT = "false"`(mcp/cypher toml 預設這樣)→ 取消註解
|
||||
* 3. 無此行但有 `[vars]` → 在 [vars] header 下一行加進去
|
||||
* 4. 無 `[vars]`(該 worker 不吃此 var)→ 不動
|
||||
* 純文字操作,與 WORKER_SUBDOMAIN/KV 注入同層級(mcp-account-source §5.5)。
|
||||
*/
|
||||
export function injectMultiTenant(toml: string): string {
|
||||
// 1. 已有 active 行 → 設 false
|
||||
if (/^\s*MULTI_TENANT\s*=/m.test(toml)) {
|
||||
return toml.replace(/^(\s*MULTI_TENANT\s*=\s*")[^"]*(".*)$/m, `$1false$2`);
|
||||
}
|
||||
// 2. 註解掉的行 → 取消註解(保留原縮排)
|
||||
if (/^\s*#\s*MULTI_TENANT\s*=/m.test(toml)) {
|
||||
return toml.replace(/^(\s*)#\s*(MULTI_TENANT\s*=\s*)"[^"]*"(.*)$/m, `$1$2"false"$3`);
|
||||
}
|
||||
// 3. 有 [vars] → 在其後插入
|
||||
if (/^\s*\[vars\]\s*$/m.test(toml)) {
|
||||
return toml.replace(
|
||||
/^(\s*\[vars\]\s*)$/m,
|
||||
`$1\nMULTI_TENANT = "false" # self-hosted 單租戶(acr update 注入,mcp-account-source §5.5)`,
|
||||
);
|
||||
}
|
||||
// 4. 無 [vars] → 不動(該 worker 不用此 var)
|
||||
return toml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 self-hosted fork 帳號沒有、會導致 wrangler deploy 失敗的官方專屬 TOML 區塊:
|
||||
* - `[[routes]]`(含 pattern/zone_name):fork 沒有 arcrun.dev zone
|
||||
@@ -253,22 +552,34 @@ export function stripOfficialOnlyBindings(toml: string): string {
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** 在 worker 目錄跑 wrangler deploy(用用戶的 CF token + account)。*/
|
||||
function runWranglerDeploy(dir: string, ctx: DeployContext): void {
|
||||
// 先裝依賴(cypher-executor/registry 是 TS,wrangler 內建 esbuild bundle 需 node_modules)
|
||||
if (existsSync(join(dir, 'package.json'))) {
|
||||
/** 在 worker 目錄跑 wrangler deploy(用用戶的 CF token + account)。
|
||||
* sharedBin:root 共享 wrangler binary 路徑(見 downloadAndDeploy 2.5)。有則用它且**跳過本地 install**
|
||||
* (deps 從 root node_modules 往上 resolve);空字串則退回舊行為(各 worker 自裝)。*/
|
||||
function runWranglerDeploy(dir: string, ctx: DeployContext, sharedBin = ''): void {
|
||||
if (!sharedBin && existsSync(join(dir, 'package.json'))) {
|
||||
// fallback:共享安裝失敗時才走這條,各 worker 自裝
|
||||
const installer = existsSync(join(dir, 'pnpm-lock.yaml'))
|
||||
? ['pnpm', 'install', '--frozen-lockfile']
|
||||
: ['npm', 'install', '--no-audit', '--no-fund'];
|
||||
execFileSync(installer[0], installer.slice(1), { cwd: dir, stdio: 'ignore' });
|
||||
runStep(installer[0], installer.slice(1), dir, process.env);
|
||||
}
|
||||
execFileSync('wrangler', ['deploy'], {
|
||||
cwd: dir,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
const wranglerCmd = sharedBin || 'wrangler';
|
||||
runStep(wranglerCmd, ['deploy'], dir, {
|
||||
...process.env,
|
||||
CLOUDFLARE_API_TOKEN: ctx.apiToken,
|
||||
CLOUDFLARE_ACCOUNT_ID: ctx.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 跑一個部署步驟,失敗時把 stderr 尾段帶進錯誤訊息——stdio ignore 會吞掉真因,
|
||||
* 用戶只看到「Command failed: pnpm install」無從診斷(壓測 2026-06-12:
|
||||
* ERR_PNPM_IGNORED_BUILDS 被吞,10/23 失敗查不到原因)。*/
|
||||
function runStep(cmd: string, args: string[], dir: string, env: NodeJS.ProcessEnv): void {
|
||||
try {
|
||||
execFileSync(cmd, args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'], env });
|
||||
} catch (e) {
|
||||
const stderr = (e as { stderr?: Buffer }).stderr?.toString().trim() ?? '';
|
||||
const tail = stderr.split('\n').slice(-3).join(' | ').slice(0, 300);
|
||||
throw new Error(`${cmd} ${args.join(' ')} 失敗${tail ? `:${tail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* 資料外流警示 — CLI 互動(data-exfil-warning SDD §1a / B)
|
||||
*
|
||||
* 觸發策略:只在「資料變成可被外部呼叫」時警示(webhook 部署 / recipe push)。
|
||||
* 互動形式(richblack):仿 GCP 刪 project —— 要用戶打資源名證明讀了警示(比 y/n 硬,不用打一大串)。
|
||||
* 同意 = 法律憑證:回傳的 ExposureConsent 帶 understood(用戶打的內容)+ 時間,server 端 log。
|
||||
* 誠實限制:非 TTY(AI 直跑)無 --confirm-exposure → 拒絕(AI 不該替人類確認暴露)。
|
||||
*/
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, saveConfig } from './config.js';
|
||||
|
||||
export interface ExposureConsent {
|
||||
confirmed_by_human: true;
|
||||
understood: string;
|
||||
confirmed_at: string;
|
||||
suppress_future?: boolean;
|
||||
}
|
||||
|
||||
// 註(2026-05-30 信任修正):移除 --confirm-exposure / --suppress-warning 旗標。
|
||||
// 理由:arcrun 是 AI 的工具,AI 自己能加旗標 = 自己批准自己 = 閘門虛設(違 DECISIONS §7)。
|
||||
// 唯一通過 = 人類在 TTY 互動輸入資源名(AI 非互動環境生不出)。「以後不再問」改成互動中詢問。
|
||||
export interface ExposureWarningOptions {
|
||||
// 預留:未來 CI 用「人類預先簽的 token」(非 AI 能生的 flag)。第一期不做。
|
||||
_reserved?: never;
|
||||
}
|
||||
|
||||
export interface ExposureContext {
|
||||
/** 動作種類,顯示用:'webhook' | 'recipe' */
|
||||
kind: string;
|
||||
/** 資源名(用戶要打這個字確認)*/
|
||||
resourceName: string;
|
||||
/** 暴露後的 URL / 去向(顯示用,可選) */
|
||||
destination?: string;
|
||||
/** 這個資源讀取/送出什麼(盡力盤,盤不出傳 undefined) */
|
||||
dataSummary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得暴露同意。回傳 ExposureConsent(放進 push 請求 body)。
|
||||
* 未取得同意 → 印訊息並 return null(呼叫端應中止)。
|
||||
*/
|
||||
export async function obtainExposureConsent(
|
||||
ctx: ExposureContext,
|
||||
opts: ExposureWarningOptions = {},
|
||||
): Promise<ExposureConsent | null> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const memKey = `${ctx.kind}:${ctx.resourceName}`;
|
||||
|
||||
// §3 首次問記住:本機已記錄同意此資源 → 不重問(server 端仍存法律憑證並強制)。
|
||||
const cfg = loadConfig();
|
||||
const prior = cfg.exposure_consented?.[memKey];
|
||||
if (prior) {
|
||||
return {
|
||||
confirmed_by_human: true,
|
||||
understood: `先前已同意暴露 ${ctx.resourceName}(${prior.confirmed_at}${prior.suppress_future ? ',已選不再警示' : ''})`,
|
||||
confirmed_at: prior.confirmed_at,
|
||||
suppress_future: prior.suppress_future,
|
||||
};
|
||||
}
|
||||
|
||||
// 非 TTY(AI 直跑)→ 一律拒絕,無捷徑。AI 不該、也不能替人類確認暴露。
|
||||
// (移除了 --confirm-exposure 旗標:那是 AI 自己能加的後門,等於自己批准自己。)
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(chalk.red('\n⚠️ 此動作會把資源變成可被外部呼叫(暴露/送出資料),需人類明示同意。'));
|
||||
console.error(chalk.gray(' 你(AI)無法確認暴露——這必須由人類在終端機親自執行、輸入資源名確認。'));
|
||||
console.error(chalk.gray(' 請把這件事交給人類做。\n'));
|
||||
return null;
|
||||
}
|
||||
|
||||
// 互動式警示 + 打資源名確認(唯一通過路徑,AI 生不出這個輸入)
|
||||
printWarning(ctx);
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const answer = (await rl.question(
|
||||
chalk.bold(` 確認暴露?請輸入資源名 "${ctx.resourceName}" 以繼續(或 Ctrl-C 取消):`),
|
||||
)).trim();
|
||||
if (answer !== ctx.resourceName) {
|
||||
console.error(chalk.red(`\n 輸入不符(需輸入 "${ctx.resourceName}")。已取消,未暴露。\n`));
|
||||
return null;
|
||||
}
|
||||
// 互動中詢問「以後不再問」(人類選,不是 AI 加旗標)
|
||||
const suppressAns = (await rl.question(
|
||||
chalk.gray(` 以後此資源(${ctx.resourceName})的暴露不再提醒?(y/N):`),
|
||||
)).trim().toLowerCase();
|
||||
const suppress = suppressAns === 'y' || suppressAns === 'yes';
|
||||
rememberConsent(memKey, nowIso, suppress);
|
||||
return {
|
||||
confirmed_by_human: true,
|
||||
understood: `用戶輸入資源名 "${ctx.resourceName}" 確認暴露${ctx.destination ? `(去向:${ctx.destination})` : ''}${suppress ? ';並選擇以後不再提醒' : ''}`,
|
||||
confirmed_at: nowIso,
|
||||
suppress_future: suppress,
|
||||
};
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 本機記住此資源已同意(避免下次重問;server 端仍獨立存法律憑證並強制) */
|
||||
function rememberConsent(memKey: string, confirmedAt: string, suppressFuture: boolean): void {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
cfg.exposure_consented = cfg.exposure_consented ?? {};
|
||||
cfg.exposure_consented[memKey] = { confirmed_at: confirmedAt, suppress_future: suppressFuture };
|
||||
saveConfig(cfg);
|
||||
} catch {
|
||||
// 記不住不影響本次同意(server 端仍會擋首次)
|
||||
}
|
||||
}
|
||||
|
||||
function printWarning(ctx: ExposureContext): void {
|
||||
console.log(chalk.yellow.bold(`\n⚠️ 資料外流警示`));
|
||||
console.log(chalk.yellow(` 這個動作會把 ${ctx.kind} "${ctx.resourceName}" 變成可被外部呼叫。`));
|
||||
if (ctx.destination) {
|
||||
console.log(chalk.gray(` 去向:${ctx.destination}`));
|
||||
}
|
||||
if (ctx.dataSummary) {
|
||||
console.log(chalk.gray(` 涉及資料:${ctx.dataSummary}`));
|
||||
} else {
|
||||
console.log(chalk.gray(` 涉及資料:無法自動判斷,請自行確認此資源是否含敏感資料。`));
|
||||
}
|
||||
console.log(chalk.gray(` 任何能呼叫它的人都能取得它的輸出/能力。`));
|
||||
console.log('');
|
||||
console.log(chalk.cyan(` arcrun 可幫你保護它:要求呼叫者帶 API Key/設權限/限流(一個動作就能加)。`));
|
||||
console.log(chalk.gray(` 若這是要公開的資料(如公開 API),可直接確認。`));
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* preflight.ts — self-hosted 安裝的「偵測先於動作 + 裝完驗收」(§7.8 P0,pip 式)。
|
||||
*
|
||||
* 核心判準(self-hosted-init.md §7.8):
|
||||
* - **偵測先於動作**:init 先檢查各前置(node / wrangler / CF 可達),缺的才裝、有的跳過。
|
||||
* 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。
|
||||
* - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項
|
||||
* + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」(ensureKvNamespace / ensureD1Database 本就冪等)。
|
||||
*
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import chalk from 'chalk';
|
||||
import type { CfAccountClient } from './cf-api.js';
|
||||
|
||||
export interface PreflightItem {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
/** 缺漏時給用戶的一鍵補救指令(沒有則留空)。*/
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
/** node 是否可用 + 版本(init 本身是 node 跑的,能跑到這裡 node 必在,但仍印版本供診斷)。*/
|
||||
function detectNode(): PreflightItem {
|
||||
try {
|
||||
const v = execFileSync('node', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.toString().trim();
|
||||
return { name: 'node', ok: true, detail: v };
|
||||
} catch {
|
||||
return { name: 'node', ok: false, fix: '安裝 Node.js 18+:https://nodejs.org' };
|
||||
}
|
||||
}
|
||||
|
||||
/** wrangler(CF CLI)是否可用 + 版本。self-hosted 部署的硬前置。*/
|
||||
function detectWrangler(): PreflightItem {
|
||||
try {
|
||||
const v = execFileSync('wrangler', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.toString().trim();
|
||||
return { name: 'wrangler', ok: true, detail: v };
|
||||
} catch {
|
||||
return { name: 'wrangler', ok: false, fix: 'npm i -g wrangler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安裝前偵測(pip 式:先看環境有什麼)。
|
||||
* CF 憑證可達由呼叫端用 CfAccountClient.verifyAccess 接著驗(需要 token,不在這層)。
|
||||
* 回傳所有項目 + 是否有 fatal 缺漏(node/wrangler 缺 = 無法繼續)。
|
||||
*/
|
||||
export function detectEnvironment(): { items: PreflightItem[]; fatal: boolean } {
|
||||
const items = [detectNode(), detectWrangler()];
|
||||
const fatal = items.some((i) => !i.ok);
|
||||
return { items, fatal };
|
||||
}
|
||||
|
||||
/** 印一組偵測結果(✓/✗ + 版本 + 補救指令)。*/
|
||||
export function printPreflight(title: string, items: PreflightItem[]): void {
|
||||
console.log(chalk.bold(`\n ${title}`));
|
||||
for (const it of items) {
|
||||
if (it.ok) {
|
||||
console.log(chalk.green(` ✓ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : ''));
|
||||
} else {
|
||||
console.log(chalk.yellow(` ✗ ${it.name}`) + (it.detail ? chalk.gray(` ${it.detail}`) : ''));
|
||||
if (it.fix) console.log(chalk.gray(` → ${it.fix}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裝完驗收:逐項確認 self-hosted 環境真的就緒(§7.8 D1 根因:安裝不偵測,缺了不報)。
|
||||
* 各項以「實際查 CF / 打 cypher」確認,非看 config 有沒有寫——避免假綠(mindset §7)。
|
||||
*
|
||||
* @returns items(每項 ok + detail/fix)。呼叫端依 allOk 決定是否 exit 非零 / 印補裝指引。
|
||||
*/
|
||||
export async function verifyInstall(opts: {
|
||||
cf: CfAccountClient;
|
||||
requiredKv: readonly string[];
|
||||
expectD1Name?: string;
|
||||
cypherUrl?: string;
|
||||
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
|
||||
const items: PreflightItem[] = [];
|
||||
|
||||
// KV:實查 CF 上現有 namespace,比對必需清單
|
||||
try {
|
||||
const existing = await opts.cf.listKvNamespaces();
|
||||
const have = new Set(existing.keys());
|
||||
const missing = opts.requiredKv.filter((t) => !have.has(t));
|
||||
items.push(
|
||||
missing.length === 0
|
||||
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
|
||||
: { name: 'KV namespaces', ok: false, detail: `缺 ${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
|
||||
}
|
||||
|
||||
// D1:實查 CF 上是否有該庫
|
||||
if (opts.expectD1Name) {
|
||||
try {
|
||||
const dbs = await opts.cf.listD1Databases();
|
||||
items.push(
|
||||
dbs.has(opts.expectD1Name)
|
||||
? { name: `D1 ${opts.expectD1Name}`, ok: true }
|
||||
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
|
||||
);
|
||||
} catch (e) {
|
||||
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
|
||||
const m = msg(e);
|
||||
const fix = /auth/i.test(m)
|
||||
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
|
||||
: 'acr update(冪等重試)';
|
||||
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
|
||||
}
|
||||
}
|
||||
|
||||
// cypher-executor 可達(打 /health,不只看 config 有 URL)
|
||||
if (opts.cypherUrl) {
|
||||
try {
|
||||
const res = await fetch(`${opts.cypherUrl}/health`, { method: 'GET' });
|
||||
items.push(
|
||||
res.ok
|
||||
? { name: 'cypher-executor 可達', ok: true, detail: opts.cypherUrl }
|
||||
: { name: 'cypher-executor 可達', ok: false, detail: `HTTP ${res.status} @ ${opts.cypherUrl}`, fix: 'acr update(重部署)' },
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: 'cypher-executor 可達', ok: false, detail: msg(e), fix: 'acr update(重部署);或 worker 剛部署稍候再試' });
|
||||
}
|
||||
}
|
||||
|
||||
return { items, allOk: items.every((i) => i.ok) };
|
||||
}
|
||||
|
||||
function msg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -13,8 +13,7 @@
|
||||
* ARCRUN_API_URL - 目標 cypher-executor,預設 https://cypher.arcrun.dev
|
||||
* ARCRUN_API_KEY - X-Arcrun-API-Key(POST /recipes 需要)
|
||||
*
|
||||
* 注意:API recipe 帶 endpoint(資料去向)→ POST /recipes 會要 exposure_consent
|
||||
* (data-exfil-warning)。seed 是平台預建、非用戶 push,腳本帶種子層級的 consent。
|
||||
* 注意:暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13),POST /recipes 不再需要 consent。
|
||||
*
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
|
||||
*/
|
||||
@@ -49,14 +48,7 @@ async function main() {
|
||||
endpoint: recipe.endpoint,
|
||||
method: recipe.method,
|
||||
auth_service: recipe.auth_service,
|
||||
// 種子層級的暴露同意:平台預建 recipe,非用戶互動 push。
|
||||
// 格式須符合 cypher-executor ExposureConsent(confirmed_by_human + understood + confirmed_at)。
|
||||
// 誠實標明來源是 seed,軌跡可審(mindset §7:機制價值是歸責+可審,非防偽)。
|
||||
exposure_consent: {
|
||||
confirmed_by_human: true,
|
||||
understood: `platform seed recipe (api-recipe-seeds.ts): ${recipe.canonical_id} → ${recipe.endpoint}`,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):不再帶 exposure_consent。
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -20,23 +20,10 @@
|
||||
import type { Bindings } from '../types';
|
||||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import type { ServiceBinding } from '../types';
|
||||
|
||||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
|
||||
|
||||
/**
|
||||
* primitive 名 → service binding key(Phase 7,2026-06-06)。
|
||||
* 比照 component-loader 的邏輯零件:有 binding 走 CF 內部 RPC(繞開同 zone 522 + 同帳號 workers.dev 1042),
|
||||
* 無 binding(如 self-hosted 未綁、或 mtls 未部署)fallback 到 fetch(workers.dev)。
|
||||
*/
|
||||
const AUTH_BINDING_MAP: Record<string, keyof import('../types').Bindings> = {
|
||||
static_key: 'SVC_AUTH_STATIC_KEY',
|
||||
service_account: 'SVC_AUTH_SERVICE_ACCOUNT',
|
||||
oauth2: 'SVC_AUTH_OAUTH2',
|
||||
mtls: 'SVC_AUTH_MTLS',
|
||||
};
|
||||
|
||||
/** auth primitive 本身的 componentId(避免自引用) */
|
||||
const AUTH_PRIMITIVE_IDS = new Set([
|
||||
'auth_static_key',
|
||||
@@ -75,27 +62,18 @@ export async function tryAuthDispatch(
|
||||
if (!recipe) return null;
|
||||
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
|
||||
|
||||
// 呼叫對應 auth primitive Worker(Phase 7,2026-06-06):
|
||||
// binding 優先(CF 內部 RPC,繞開同 zone 522 + 同帳號 workers.dev 子請求 1042,壓測階段 11),
|
||||
// 無 binding(self-hosted 未綁 / mtls 未部署)fallback 到 fetch(workers.dev)。比照 component-loader makeLogicRunner。
|
||||
const reqInit = {
|
||||
// 走新路徑:HTTP POST 到對應 auth primitive Worker
|
||||
// 走 workers.dev 避開同 zone 死鎖(P0 #9)
|
||||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(primitiveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'authenticate', api_key: apiKey, service }),
|
||||
};
|
||||
|
||||
const bindingKey = AUTH_BINDING_MAP[recipe.primitive];
|
||||
const svc = bindingKey ? (env[bindingKey] as ServiceBinding | undefined) : undefined;
|
||||
|
||||
let res: Response;
|
||||
if (svc) {
|
||||
// service binding:用任意 URL,CF 內部 RPC 直送目標 Worker(不經公網)
|
||||
res = await svc.fetch(new Request('https://auth-primitive/', reqInit));
|
||||
} else {
|
||||
// fallback:公網 workers.dev(自架未綁 binding / 開發環境 / mtls)
|
||||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||||
res = await fetch(primitiveUrl, reqInit);
|
||||
}
|
||||
body: JSON.stringify({
|
||||
action: 'authenticate',
|
||||
api_key: apiKey,
|
||||
service,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
@@ -127,3 +105,85 @@ export async function tryAuthDispatch(
|
||||
_auth_path: result.auth_path ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用戶面 {{credential.NAME}} 注入(design §8)────────────────────────────────
|
||||
|
||||
/** 匹配 {{credential.NAME}}(NAME 為 word 字元) */
|
||||
const CREDENTIAL_REF = /\{\{credential\.(\w+)\}\}/g;
|
||||
|
||||
/** 遞迴收集任意值(string / 物件 / 陣列)裡所有 {{credential.NAME}} 的 NAME */
|
||||
function collectCredentialNames(value: unknown, out: Set<string>): void {
|
||||
if (typeof value === 'string') {
|
||||
for (const m of value.matchAll(CREDENTIAL_REF)) out.add(m[1]);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) collectCredentialNames(v, out);
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const v of Object.values(value as Record<string, unknown>)) collectCredentialNames(v, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** 遞迴把 {{credential.NAME}} 替換成 resolved[NAME](未知 name 原樣保留) */
|
||||
function replaceCredentialRefs(value: unknown, resolved: Record<string, string>): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(CREDENTIAL_REF, (orig, name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(resolved, name) ? resolved[name] : orig,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => replaceCredentialRefs(v, resolved));
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = replaceCredentialRefs(v, resolved);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 展開節點 data 裡用戶寫的 `{{credential.NAME}}`(design §8)。
|
||||
*
|
||||
* 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給
|
||||
* auth_static_key WASM 的 `resolve_credentials` action(WASM 內 kv_get + crypto_decrypt),
|
||||
* 拿回明文後只做字串回填。ENCRYPTION_KEY 永不經此處。
|
||||
*
|
||||
* - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷)
|
||||
* - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠)
|
||||
*/
|
||||
export async function resolveCredentialRefs(
|
||||
data: Record<string, unknown>,
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const names = new Set<string>();
|
||||
collectCredentialNames(data, names);
|
||||
if (names.size === 0) return data;
|
||||
|
||||
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'resolve_credentials',
|
||||
api_key: apiKey,
|
||||
names: [...names],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`credential resolve 回傳 ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const result = (await res.json().catch(() => null)) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
credentials?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
if (!result || result.success === false) {
|
||||
throw new Error(`credential resolve 失敗: ${result?.error ?? '未知錯誤'}`);
|
||||
}
|
||||
|
||||
return replaceCredentialRefs(data, result.credentials ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,41 @@ import { graphSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
|
||||
/**
|
||||
* kbdb-base §7.1+§7.5.h:一條工作流執行結束後,把這次用到的 recipe 各記一次成功/失敗到 KBDB 市場星數。
|
||||
* 判定單位是「工作流執行」(n8n execution):整體成功 → 用到的每個 recipe key +1 成功;整體失敗 → 各 +1 失敗。
|
||||
* **key = recipe uuid**(per-uuid,能區分同 canonical 的不同作者版本 §7.5.5;舊資料 fallback canonical_id)。
|
||||
*
|
||||
* fire-and-forget(用 ctx.waitUntil,仿 recordTelemetry):記錄失敗不影響工作流結果。
|
||||
* KBDB 端點 POST {KBDB_BASE_URL}/recipe-stats/record { canonical_id, ok, at }——
|
||||
* 該欄位名為 canonical_id 但語意已是 recipe key(uuid),KBDB 端只當 stat 的主鍵字串用。
|
||||
*/
|
||||
function recordRecipeStats(
|
||||
env: Bindings,
|
||||
recipeKeys: Set<string>,
|
||||
ok: boolean,
|
||||
at: number,
|
||||
ctx?: ExecutionContext,
|
||||
): void {
|
||||
if (recipeKeys.size === 0) return;
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
|
||||
const promise = Promise.all(
|
||||
[...recipeKeys].map(key =>
|
||||
fetch(`${base}/recipe-stats/record`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ canonical_id: key, ok, at }),
|
||||
}).catch(() => undefined),
|
||||
),
|
||||
).then(() => undefined);
|
||||
|
||||
if (ctx?.waitUntil) ctx.waitUntil(promise);
|
||||
else void promise;
|
||||
}
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
@@ -58,6 +93,9 @@ export async function executeWebhookGraph(
|
||||
agent_user_agent: userAgent,
|
||||
}, ctx);
|
||||
|
||||
// kbdb-base §7.1:整體成功 → 用到的 recipe 各記成功一次。
|
||||
recordRecipeStats(env, executor.usedRecipeKeys, true, Date.now(), ctx);
|
||||
|
||||
return { success: true, data: result.data, duration_ms };
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
@@ -73,6 +111,12 @@ export async function executeWebhookGraph(
|
||||
agent_user_agent: userAgent,
|
||||
}, ctx);
|
||||
|
||||
// kbdb-base §7.1:真錯(非 paused)→ 用到的 recipe 各記失敗一次。
|
||||
// paused 是「執行中暫停等 callback」非失敗,不記(resume 後成功才會在那條路徑記成功)。
|
||||
if (!isPaused) {
|
||||
recordRecipeStats(env, executor.usedRecipeKeys, false, Date.now(), ctx);
|
||||
}
|
||||
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
|
||||
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types';
|
||||
import { injectCredentials } from './actions/credential-injector';
|
||||
import { tryAuthDispatch } from './actions/auth-dispatcher';
|
||||
import { tryAuthDispatch, resolveCredentialRefs } from './actions/auth-dispatcher';
|
||||
import { expandPromptRecipe } from './lib/recipe-expander';
|
||||
import { resolveRecipe } from './routes/recipes';
|
||||
import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs';
|
||||
import { buildMagicVars } from './lib/magic-vars';
|
||||
import { recordTelemetry } from './lib/telemetry';
|
||||
@@ -21,6 +22,11 @@ export class GraphExecutor {
|
||||
private apiKey?: string;
|
||||
public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>;
|
||||
|
||||
// kbdb-base §7.1+§7.5.h:本次執行用到的 recipe **key**(uuid 優先,舊資料 fallback canonical_id)。
|
||||
// 判定單位是「工作流執行」(n8n execution):執行結束後由 executeWebhookGraph 一次性把這組 key
|
||||
// 各記成功/失敗到 KBDB 市場星數(per-uuid → 能區分同 canonical 的不同作者版本,§7.5.5)。執行中只收集。
|
||||
public readonly usedRecipeKeys = new Set<string>();
|
||||
|
||||
// resumable workflow(SDD: resumable-workflow/design.md)
|
||||
// 暫停時持久化 state 用,需在 execute 進入時設定
|
||||
private currentGraph?: ExecutionGraph;
|
||||
@@ -239,6 +245,13 @@ export class GraphExecutor {
|
||||
...resolvedData,
|
||||
};
|
||||
|
||||
// 用戶面 {{credential.NAME}} 展開(design §8):偵測 node.data 裡用戶寫的
|
||||
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 解密回填。
|
||||
// 解密在 WASM(rule 02 §2.2),此處只偵測+回填,不碰 ENCRYPTION_KEY。
|
||||
if (this.env && this.apiKey) {
|
||||
mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey);
|
||||
}
|
||||
|
||||
// Resumable workflow callback_url 注入(SDD: resumable-workflow/design.md §2.2)
|
||||
// claude_api 容器拿到後會透傳給 Mira daemon,daemon task 完成時 POST 進來
|
||||
// hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設
|
||||
@@ -286,6 +299,19 @@ export class GraphExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// kbdb-base §7.5.h:收集本次用到的 recipe **uuid**(執行結束後一次性記到 KBDB 市場星數)。
|
||||
// 記 per-uuid(非 auth service):投稿/pull 的是 API recipe(帶 uuid),市場數據要能區分
|
||||
// 同 canonical 的 Leo 版/John 版(§7.5.5 app-store)。先試 API recipe(有 uuid);
|
||||
// 無 uuid 的舊資料 fallback canonical_id(向後相容,migration 後自然帶 uuid)。
|
||||
if (this.env?.RECIPES) {
|
||||
try {
|
||||
const apiRecipe = await resolveRecipe(node.componentId, this.env.RECIPES);
|
||||
if (apiRecipe) this.usedRecipeKeys.add(apiRecipe.uuid ?? apiRecipe.canonical_id);
|
||||
} catch {
|
||||
// 收集失敗不影響執行(成功記錄是輔助資料,非主流程)
|
||||
}
|
||||
}
|
||||
|
||||
nodeInput = mergedContext;
|
||||
result = await runner(mergedContext);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { authRouter } from './routes/auth';
|
||||
import { resumeRouter } from './routes/resume';
|
||||
import { executionsRouter } from './routes/executions';
|
||||
import { initSeedRouter } from './routes/init-seed';
|
||||
import { kbdbProxyRouter } from './routes/kbdb-proxy';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -48,6 +49,7 @@ app.route('/', authRouter);
|
||||
app.route('/', resumeRouter);
|
||||
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
|
||||
app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1)
|
||||
app.route('/', kbdbProxyRouter); // kbdb-base 9.5:KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
|
||||
@@ -86,9 +86,12 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
|
||||
{
|
||||
canonical_id: 'google_sheets_append',
|
||||
display_name: 'Google Sheets Append',
|
||||
description: '寫 Sheets。PUT values?valueInputOption=RAW,body 帶 values。auth: google service_account。',
|
||||
// 壓測階段 12 修正:append 官方 API 是 POST .../values/{range}:append(PUT 是 values.update 覆寫的動詞),
|
||||
// 種子寫死 PUT 導致每個 self-host 用戶 seed 到壞 recipe(PUT :append → Google 400)。
|
||||
// body 形狀屬工作流,泛用種子不寫死欄位 → 由工作流的 _path + body 處理(body_from 機制待 §13.4 補)。
|
||||
description: '追加一列到 Sheets。POST .../values/{range}:append?valueInputOption=RAW,body 帶 {values:[[...]]}。auth: google service_account。',
|
||||
endpoint: 'https://sheets.googleapis.com{{_path}}',
|
||||
method: 'PUT',
|
||||
method: 'POST',
|
||||
auth_service: 'google_sheets_sa',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,13 @@ import type { AuthRecipeDefinition } from '../routes/recipes';
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// ⚠️ 已知 source/live drift(2026-06-29 盤點,未在此檔修):
|
||||
// prod RECIPES KV 另有 `auth_recipe:google_user`(primitive: oauth2)。**故意不回灌 source**,原因:
|
||||
// (1) 它內嵌 client_secret(GOCSPX-...)= 機密,不可進 git(wiki-secret-scan / 一般安全);
|
||||
// (2) 本檔的 AuthRecipeDefinition 介面尚無 oauth2 欄位(client_id/secret/token_endpoint/scopes),
|
||||
// 回灌前需先擴 schema + 把 secret 改成部署期注入(wrangler secret / env),屬獨立工作。
|
||||
// → google_user 留待 oauth2 seed 機制(含 secret 注入)獨立處理;本次只修無機密的 static_key 漂移。
|
||||
|
||||
export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
|
||||
// ── Static Key 類 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -556,6 +563,94 @@ export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
// ── 訊息 / URL-path 注入類(static_key)────────────────────────────────────
|
||||
//
|
||||
// 2026-06-29 補:以下三個 static_key auth recipe 一直存在於 prod RECIPES KV(手動 seed 過),
|
||||
// 但**從未進 source seed**(auth-recipe-seeds.ts)→ 任何全新 self-hosted `POST /init/seed`
|
||||
// 只會 seed 23 個、漏掉 telegram/line_notify/kbdb → self-host(mira/leo21c)的 telegram 發訊
|
||||
// 走不通(telegram_send 的 auth_service:'telegram' 找不到 auth recipe → {{auth.bot_token}} 注入空)。
|
||||
// 這正是「source vs live drift = 假綠」(總管反覆踩的同一類)。把 prod 現役定義回灌 source,
|
||||
// 讓 official 與 self-host 共用同一份種子。形態取自 prod GET /auth-recipes/{service}(2026-06-29)。
|
||||
// 設計權威:auth-recipe.md §六(line 70-71, telegram path 注入) + §七(line 150-151, kbdb 共用)。
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'telegram',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.telegram.org',
|
||||
display_name: 'Telegram Bot',
|
||||
description: 'Telegram Bot API — sendMessage 等(bot token 注入 URL path /bot{token}/)',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'telegram_bot_token',
|
||||
label: 'Bot Token(從 @BotFather 取得)',
|
||||
help: '在 Telegram 對 @BotFather 送 /newbot 建立 bot,取得格式為 123456:ABC... 的 token',
|
||||
help_url: 'https://core.telegram.org/bots/features#botfather',
|
||||
},
|
||||
],
|
||||
// path 注入:recipe:telegram_send 的 endpoint 用 {{auth.bot_token}} 從 _auth_path 取值
|
||||
// (auth_static_key WASM 解密後輸出 auth_path → auth-dispatcher 帶進 _auth_path
|
||||
// → makeRecipeRunner interpolate)。token 不落 header/query/body,符合 Telegram 的 URL-path 慣例。
|
||||
inject: {
|
||||
path: {
|
||||
bot_token: '{{secret.telegram_bot_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'line_notify',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://notify-api.line.me',
|
||||
display_name: 'LINE Notify',
|
||||
description: 'LINE Notify — 推播訊息(static_key Bearer)',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'line_token',
|
||||
label: 'LINE Notify Token',
|
||||
help: '至 https://notify-bot.line.me/my/ 發行個人存取權杖',
|
||||
help_url: 'https://notify-bot.line.me/my/',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.line_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'kbdb',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://kbdb.finally.click',
|
||||
display_name: 'KBDB',
|
||||
description: 'KBDB partner API — block 讀寫(static_key Bearer)。kbdb_* recipe 共用此把 auth。',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'kbdb_api_key',
|
||||
label: 'KBDB API Key(至 arcrun 取統一 API Key 當 credential)',
|
||||
help: 'KBDB 採 Supabase 模式:要用 → 去 arcrun 取統一 API Key 當此 credential',
|
||||
help_url: 'https://arcrun.dev',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.kbdb_api_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
// ── Service Account 類(Google 家族,共用同一份 service_account_json)────────
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Cron index — 單一固定 key 模型(kbdb-base 8.P0 止血)。
|
||||
*
|
||||
* 背景:原本每個 cron workflow 寫一筆 `cron-idx:{apiKey}:{name}`,scheduled() 每分鐘
|
||||
* `WEBHOOKS.list({prefix:'cron-idx:'})` 一次 = 1440 list/日,單獨就爆 CF KV 免費 list 上限(1000/日)。
|
||||
*
|
||||
* 解法(SDD §8.2):所有 cron workflow 的 cron_expr 集中存進**單一固定 key** `cron-idx:_all`。
|
||||
* scheduled() 每分鐘只 `get` 一次(KV get 免費額度 100K/日,遠夠)→ list 次數歸零。
|
||||
* acr push(webhooks-named POST)/ delete 時對這個 key 做 read-modify-write 維護。
|
||||
*
|
||||
* 結構:{ [ "{apiKey}:{name}" ]: cron_expr }
|
||||
* key 用 `{apiKey}:{name}` 維持多租戶隔離(scheduled 觸發時拆回 apiKey/name 去讀完整 record)。
|
||||
*/
|
||||
|
||||
// KVNamespace 用全域 ambient 型別(與 types.ts 一致,不從 @cloudflare/workers-types import
|
||||
// 以免產生第二個不相容的 KVNamespace 型別)。
|
||||
|
||||
/** 單一固定索引 key — 全租戶共用一筆,scheduled() 只 get 這個 */
|
||||
export const CRON_INDEX_KEY = 'cron-idx:_all';
|
||||
|
||||
/** 索引內容:entryKey("{apiKey}:{name}")→ cron_expr */
|
||||
export type CronIndex = Record<string, string>;
|
||||
|
||||
/** 組出索引 entry 的 key(apiKey + name),含 ':' 也安全:split 時 name 用 slice 還原 */
|
||||
export function cronEntryKey(apiKey: string, name: string): string {
|
||||
return `${apiKey}:${name}`;
|
||||
}
|
||||
|
||||
/** 從 entryKey 拆回 { apiKey, name }(name 可能含 ':',取第一個 ':' 後全部為 name) */
|
||||
export function parseCronEntryKey(entryKey: string): { apiKey: string; name: string } | null {
|
||||
const idx = entryKey.indexOf(':');
|
||||
if (idx <= 0) return null;
|
||||
return { apiKey: entryKey.slice(0, idx), name: entryKey.slice(idx + 1) };
|
||||
}
|
||||
|
||||
/** 讀整個 cron index(單次 get,不 list) */
|
||||
export async function readCronIndex(kv: KVNamespace): Promise<CronIndex> {
|
||||
const raw = await kv.get(CRON_INDEX_KEY, 'text');
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as CronIndex) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert / 移除單筆 cron entry(read-modify-write 單一 key)。
|
||||
* @param cronExpr - 有值=upsert;null/undefined=移除(push 改掉 cron 後清乾淨)
|
||||
*/
|
||||
export async function updateCronIndexEntry(
|
||||
kv: KVNamespace,
|
||||
apiKey: string,
|
||||
name: string,
|
||||
cronExpr: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const index = await readCronIndex(kv);
|
||||
const entryKey = cronEntryKey(apiKey, name);
|
||||
|
||||
if (cronExpr) {
|
||||
if (index[entryKey] === cronExpr) return; // 無變化,不浪費一次 put
|
||||
index[entryKey] = cronExpr;
|
||||
} else {
|
||||
if (!(entryKey in index)) return; // 本來就沒有,不浪費一次 put
|
||||
delete index[entryKey];
|
||||
}
|
||||
|
||||
await kv.put(CRON_INDEX_KEY, JSON.stringify(index));
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 資料外流警示 — 同意憑證機制(data-exfil-warning SDD §7 法律憑證 + §1b API 層)
|
||||
//
|
||||
// 觸發策略(richblack):只在「資料變成可被外部呼叫」時要求同意(暴露面)。
|
||||
// webhook 部署(workflow 變對外 endpoint)、recipe push 都算。
|
||||
//
|
||||
// 同意 = 法律憑證:留 log(誰、何時、同意了什麼),真出事時有「用戶明示知情同意」證據,
|
||||
// 避免 arcrun 訴訟風險。「以後不要警示」(suppress_future)本身也 log。
|
||||
//
|
||||
// 誠實限制:AI 能偽造 confirmed_by_human。本機制的價值是「法律歸責 + 可審」,不是技術防偽。
|
||||
|
||||
/** 暴露同意憑證(人類明示知情同意把某資源開放/送出) */
|
||||
export interface ExposureConsent {
|
||||
confirmed_by_human: true; // 必須為 literal true
|
||||
understood: string; // 人類說明「我知道這會把什麼開放給誰」(非空)
|
||||
confirmed_at: string; // ISO timestamp
|
||||
suppress_future?: boolean; // 「以後不要對此資源警示」(本選擇也 log)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷一個暴露動作是否已取得有效同意。
|
||||
* @param consent 本次請求帶的同意憑證
|
||||
* @param priorConsent 既有 record 裡存的同意(首次問、記住:§3)
|
||||
* @returns null = 放行(已同意或已 suppress);string = 拒絕原因
|
||||
*/
|
||||
export function checkExposureConsent(
|
||||
consent: ExposureConsent | undefined,
|
||||
priorConsent: ExposureConsent | undefined,
|
||||
): string | null {
|
||||
// 既有同意且選了「以後不警示」→ 放行(首次問記住)
|
||||
if (priorConsent?.suppress_future) return null;
|
||||
// 既有有效同意(同資源已確認過)→ 放行
|
||||
if (priorConsent?.confirmed_by_human === true) return null;
|
||||
|
||||
// 本次請求帶了有效同意 → 放行
|
||||
if (
|
||||
consent?.confirmed_by_human === true &&
|
||||
typeof consent.understood === 'string' &&
|
||||
consent.understood.trim() !== ''
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
'此動作會把資源變成可被外部呼叫(暴露/送出資料)。需人類明示同意。\n' +
|
||||
'請用 CLI 互動確認(acr 會說明風險並提供保護選項),或帶 exposure_consent。\n' +
|
||||
'arcrun 可幫你保護:要求呼叫者帶 API Key / 設權限 / 限流。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 正規化要存進 record 的同意憑證(法律憑證,可審)。
|
||||
* 優先用本次新同意,否則沿用既有。
|
||||
*/
|
||||
export function resolveConsentForRecord(
|
||||
consent: ExposureConsent | undefined,
|
||||
priorConsent: ExposureConsent | undefined,
|
||||
): ExposureConsent | undefined {
|
||||
if (consent?.confirmed_by_human === true) return consent;
|
||||
return priorConsent;
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
* 行為:
|
||||
* - 冪等:已存在的 recipe 直接覆寫(重跑安全)。
|
||||
* - 一次灌「API recipe(API_RECIPE_SEEDS)+ auth recipe(AUTH_RECIPE_SEEDS)」兩者。
|
||||
* - 直接寫 KV(不走 POST /recipes 的 exposure_consent gate):種子是平台預建、非用戶互動 push,
|
||||
* 帶 seed 層級的 consent 憑證(誠實標來源,軌跡可審;mindset §7:機制價值是歸責+可審非防偽)。
|
||||
* - 直接寫 KV:種子是平台預建、非用戶互動 push(暴露 consent 閘已於 Arcrun#13 移除)。
|
||||
* - 誠實回報:逐筆 ok/fail 計數,不假綠。
|
||||
*
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
|
||||
@@ -20,6 +19,7 @@ import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { deriveRecipeHash } from '../lib/hash';
|
||||
import type { RecipeDefinition, AuthRecipeDefinition } from './recipes';
|
||||
import { installRecipeRecord, resolveRecipe } from './recipes';
|
||||
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds';
|
||||
import { AUTH_RECIPE_SEEDS } from '../lib/auth-recipe-seeds';
|
||||
|
||||
@@ -27,11 +27,7 @@ export const initSeedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
initSeedRouter.post('/init/seed', async (c) => {
|
||||
const now = Date.now();
|
||||
const seedConsent = {
|
||||
confirmed_by_human: true as const,
|
||||
understood: 'platform seed (init/seed): 平台預建 recipe,非用戶互動 push',
|
||||
confirmed_at: new Date(now).toISOString(),
|
||||
};
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):種子不再帶 exposure_consent。
|
||||
|
||||
let apiOk = 0;
|
||||
let apiFail = 0;
|
||||
@@ -41,8 +37,11 @@ initSeedRouter.post('/init/seed', async (c) => {
|
||||
try {
|
||||
const canonicalId = seed.canonical_id.trim().toLowerCase();
|
||||
const hashId = await deriveRecipeHash(canonicalId);
|
||||
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
|
||||
// UUID 模型(§7.5.5):種子 author='system'。冪等:已安裝沿用其 uuid,否則新領。
|
||||
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
|
||||
const recipe: RecipeDefinition = {
|
||||
uuid: existing?.uuid ?? crypto.randomUUID(),
|
||||
author: existing?.author ?? 'system',
|
||||
canonical_id: canonicalId,
|
||||
hash_id: hashId,
|
||||
display_name: seed.display_name,
|
||||
@@ -50,14 +49,10 @@ initSeedRouter.post('/init/seed', async (c) => {
|
||||
endpoint: seed.endpoint,
|
||||
method: (seed.method ?? 'POST').toUpperCase(),
|
||||
auth_service: seed.auth_service,
|
||||
exposure_consent: existing?.exposure_consent ?? seedConsent,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
await Promise.all([
|
||||
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
|
||||
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
|
||||
]);
|
||||
await installRecipeRecord(c.env.RECIPES, recipe);
|
||||
apiOk++;
|
||||
} catch (e) {
|
||||
apiFail++;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* KBDB 資料層 proxy(kbdb-base Phase 9.5,HANDOFF §2 + §3b 後續)
|
||||
*
|
||||
* 為什麼存在:CLI 是 client,只認證到 cypher-executor(X-Arcrun-API-Key),達不到獨立的
|
||||
* KBDB worker(MCP 走內部 service binding 可達,CLI 不行)。故在 cypher 開一條 proxy,
|
||||
* 讓 CLI 薄殼(acr kbdb *)透過「它本來就連的 cypher」打 KBDB 基本盤 API。
|
||||
*
|
||||
* 薄殼鐵律(rule 07):本檔是 **proxy**,純轉發到 KBDB 基本盤 HTTP API,
|
||||
* 無業務邏輯、不寫 SQL、不建表、不直連 D1。能力真身在 KBDB 基本盤(kbdb/src/routes/*)。
|
||||
*
|
||||
* KBDB 鐵律(leo 2026-06-14):只暴露 template/record/query/search,**不開建表/SQL**。
|
||||
*
|
||||
* 租戶隔離(leo 2026-06-14 拍板,選項①):
|
||||
* - X-Arcrun-API-Key(namespace/api_key)→ 自動當 owner_id 注入 records/entries 的寫入與查詢。
|
||||
* 不同 namespace 的資料互相看不到。與 cypher 其他端點同身份模型。
|
||||
* - **templates 全域共享**(虛擬表定義是 schema 不是資料;類 Supabase 的表結構大家共用)→ 不注入 owner_id。
|
||||
*
|
||||
* cypher→KBDB 連法沿用既有慣例(webhook-handlers.ts / recipes.ts):
|
||||
* KBDB_BASE_URL HTTP fetch + 選用 KBDB_INTERNAL_TOKEN Bearer。**不新增 service binding**(rule 02 §3.1)。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const kbdbProxyRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/**
|
||||
* KBDB 基本盤 base URL + internal headers。
|
||||
* fallback 指**現役** arcrun-kbdb(workers.dev,無 auth、不需 token)——
|
||||
* 不沿用 webhook-handlers.ts 的舊 fallback kbdb.finally.click(inkstone 遺留、已死、要 token)。
|
||||
* KBDB_BASE_URL 可覆蓋(self-hosted fork 指自己的 KBDB)。
|
||||
*/
|
||||
function kbdbBase(env: Bindings): { base: string; headers: Record<string, string> } {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return { base, headers };
|
||||
}
|
||||
|
||||
/** 取租戶身份(owner_id)。缺 header → 401(與 cypher 其他資料端點一致)。 */
|
||||
function tenant(c: { req: { header: (k: string) => string | undefined } }): string | null {
|
||||
return c.req.header('X-Arcrun-API-Key') ?? null;
|
||||
}
|
||||
|
||||
const NEED_KEY = { error: '缺少 X-Arcrun-API-Key header' } as const;
|
||||
|
||||
// ── templates(全域共享,不注入 owner_id)──────────────────────────────────────
|
||||
|
||||
// POST /kbdb/templates — 建 template(name + slots)。鐵律:這是「虛擬表定義」非建真表。
|
||||
kbdbProxyRouter.post('/kbdb/templates', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.name || !Array.isArray(body.slots)) {
|
||||
return c.json({ error: 'name 與 slots[] 必填' }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/templates`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
// created_by 帶上租戶當溯源,但 template 本身全域可見可用
|
||||
body: JSON.stringify({ name: body.name, slots: body.slots, description: body.description, created_by: owner }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/templates — 列出所有 template(全域)。
|
||||
kbdbProxyRouter.get('/kbdb/templates', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/templates`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/templates/:idOrName — 取單一 template。
|
||||
kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/templates/${encodeURIComponent(c.req.param('idOrName'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// ── records(以租戶 namespace 為 owner_id 隔離)────────────────────────────────
|
||||
|
||||
// POST /kbdb/records — 填一筆 record(template + values)。owner_id 自動注入。
|
||||
kbdbProxyRouter.post('/kbdb/records', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ error: 'template 與 values 必填' }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/records/by-template/:template — 列某 template 下「本租戶」的 records。
|
||||
kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(
|
||||
`${base}/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(owner)}`,
|
||||
{ headers },
|
||||
);
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/records/:recordId — 取單筆 record。
|
||||
kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
|
||||
|
||||
// GET /kbdb/search?q=&entry_type=&source=&mode= — entries 搜尋,限本租戶 owner_id。
|
||||
// 透傳 entry_type(base 通用 filter,workflow-discovery Q4)/ source / mode 給 KBDB /entries/search。
|
||||
kbdbProxyRouter.get('/kbdb/search', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ error: 'q 必填' }, 400);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const params = new URLSearchParams({ q, owner_id: owner });
|
||||
for (const k of ['entry_type', 'source', 'mode']) {
|
||||
const v = c.req.query(k);
|
||||
if (v) params.set(k, v);
|
||||
}
|
||||
const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// ── entries(原子資料 / 樹節點,以租戶 namespace 為 owner_id 隔離)─────────────────
|
||||
//
|
||||
// kbdb-base 9.6:基本盤 /entries CRUD 的 proxy(HANDOFF §2 缺口①,mira _kbdb_client.py 遷移目標)。
|
||||
// 租戶隔離同 records(選項①):寫入強制注入 owner_id、list 強制以本租戶 owner_id 過濾;
|
||||
// by-id 沿用既有 records by-id 慣例(require-key,不額外做 owner 比對——與本檔其他 by-id 端點一致)。
|
||||
|
||||
// POST /kbdb/entries — 建一個 entry(entry_type 必填,如 block/value/project/workflow)。owner_id 自動注入。
|
||||
kbdbProxyRouter.post('/kbdb/entries', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.entry_type) return c.json({ error: 'entry_type 必填' }, 400);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/entries`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||
body: JSON.stringify({ ...body, owner_id: owner }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/entries — list(filters: entry_type / parent_id / page_name / limit / offset)。
|
||||
// owner_id 強制覆寫成本租戶(防跨租戶讀;caller 不能查別人的 owner_id)。
|
||||
kbdbProxyRouter.get('/kbdb/entries', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const params = new URLSearchParams();
|
||||
params.set('owner_id', owner); // 強制本租戶,不接受 caller 覆寫
|
||||
for (const k of ['entry_type', 'parent_id', 'page_name', 'source', 'limit', 'offset']) {
|
||||
const v = c.req.query(k);
|
||||
if (v) params.set(k, v);
|
||||
}
|
||||
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/entries/:id — 取單筆 entry。
|
||||
kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/entries/${encodeURIComponent(c.req.param('id'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。
|
||||
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
// 不讓 patch 改 owner_id(防把別人的資料認領過來或踢給別人)
|
||||
const { owner_id: _drop, ...patch } = body ?? {};
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/entries/${encodeURIComponent(c.req.param('id'))}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
@@ -16,12 +16,16 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { deriveRecipeHash } from '../lib/hash';
|
||||
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
|
||||
import type { ExposureConsent } from '../lib/exposure-consent';
|
||||
|
||||
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
export interface RecipeDefinition {
|
||||
// UUID 身份模型(kbdb-base §7.5.5):每個 recipe 一誕生領 uuid = 唯一身份。
|
||||
// canonical_id / author / 公私 都是屬性,不是身份。身份(uuid) 與歸屬(author) 分離。
|
||||
// 舊 recipe 無 uuid → resolveRecipe / migration 兼容(migration 增量補 uuid,不刪舊 key)。
|
||||
uuid?: string; // 唯一身份;舊資料可能缺,讀取時容忍
|
||||
author?: string; // 該 uuid 投稿者(誰投誰負責那版市場數據);'system' = init-seed 種子
|
||||
derived_from?: string; // 可選溯源:fork 自哪個 uuid(Leo 改 John 版時記 John 的 uuid)
|
||||
canonical_id: string;
|
||||
hash_id: string; // rec_xxxxxxxx
|
||||
display_name?: string;
|
||||
@@ -40,13 +44,43 @@ export interface RecipeDefinition {
|
||||
key: string;
|
||||
inject_as: string;
|
||||
}>;
|
||||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。
|
||||
// SDD: data-exfil-warning §7(公私一視同仁)
|
||||
exposure_consent?: ExposureConsent;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29 拍板,Arcrun#13):arcrun 是給 AI 用的系統,
|
||||
// 不再對 push/暴露要求人類確認。此欄位保留只為向後相容舊 KV record(讀到不報錯,不再寫入/檢查)。
|
||||
exposure_consent?: unknown;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// ── UUID 身份模型 KV key(kbdb-base §7.5.5)────────────────────────────────────
|
||||
// recipe:{uuid} → recipe 本體(唯一身份)
|
||||
// idx:canonical:{canonical_id} → JSON array of uuid(同 canonical 多作者版本並存,公庫用)
|
||||
// idx:installed:{canonical_id} → 單一 uuid(本部署執行時用哪個版本;pull/submit 時定)
|
||||
// idx:{hash_id} → canonical_id(既有 rec_hash 反查,保留)
|
||||
// 舊資料 recipe:{canonical_id} 不刪,resolveRecipe fallback 讀得到(migration 增量補,不破現況)。
|
||||
const kIdxCanonical = (canonicalId: string) => `idx:canonical:${canonicalId}`;
|
||||
const kIdxInstalled = (canonicalId: string) => `idx:installed:${canonicalId}`;
|
||||
|
||||
/**
|
||||
* 寫一份 recipe(UUID 身份模型):給定 recipe 已含 uuid → 寫 recipe:{uuid}、
|
||||
* 把 uuid 併進 idx:canonical:{canonical_id} 清單、設為本部署 installed(執行時用此版本)、
|
||||
* 維護 idx:{hash_id} 反查。private(POST /recipes) 與 public(submit-p) 共用此寫入。
|
||||
*/
|
||||
export async function installRecipeRecord(kv: KVNamespace, recipe: RecipeDefinition): Promise<void> {
|
||||
const uuid = recipe.uuid!;
|
||||
const { canonical_id, hash_id } = recipe;
|
||||
|
||||
const listRaw = await kv.get(kIdxCanonical(canonical_id));
|
||||
const uuids: string[] = listRaw ? JSON.parse(listRaw) : [];
|
||||
if (!uuids.includes(uuid)) uuids.push(uuid);
|
||||
|
||||
await Promise.all([
|
||||
kv.put(`recipe:${uuid}`, JSON.stringify(recipe)),
|
||||
kv.put(kIdxCanonical(canonical_id), JSON.stringify(uuids)),
|
||||
kv.put(kIdxInstalled(canonical_id), uuid),
|
||||
kv.put(`idx:${hash_id}`, canonical_id),
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /recipes — 新增或更新 recipe
|
||||
recipesRouter.post('/recipes', async (c) => {
|
||||
let body: Partial<RecipeDefinition>;
|
||||
@@ -63,16 +97,17 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
const hashId = await deriveRecipeHash(canonicalId);
|
||||
const now = Date.now();
|
||||
|
||||
// 讀取現有版本(保留 created_at + 既有同意憑證)
|
||||
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
|
||||
// 私庫(POST /recipes)= 自己地盤,同 canonical 就地更新自己安裝的那份(沿用既有 uuid)。
|
||||
// 既有 installed → 沿用其 uuid + created_at;無 → 新領 uuid(首次裝這個 canonical)。
|
||||
// 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 key(migration 前的種子)。
|
||||
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
|
||||
|
||||
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
|
||||
if (consentError !== null) {
|
||||
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):直接 push,不攔。
|
||||
|
||||
const recipe: RecipeDefinition = {
|
||||
uuid: existing?.uuid ?? crypto.randomUUID(),
|
||||
author: body.author ?? existing?.author ?? 'local',
|
||||
derived_from: body.derived_from ?? existing?.derived_from,
|
||||
canonical_id: canonicalId,
|
||||
hash_id: hashId,
|
||||
display_name: body.display_name,
|
||||
@@ -83,21 +118,123 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
body: body.body,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent),
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// 寫入兩個 KV key
|
||||
await Promise.all([
|
||||
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
|
||||
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
|
||||
]);
|
||||
|
||||
await installRecipeRecord(c.env.RECIPES, recipe);
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash)
|
||||
// POST /recipes/submit — 公共庫投稿(submit-p)。kbdb-base SDD §7.2/§7.3。
|
||||
//
|
||||
// 兩套部署模型:self-hosted cypher = 私庫(直接 POST /recipes 寫自己 KV);
|
||||
// 官方 cypher = 公共庫,外部投稿者把修好的 recipe 送來這個端點。
|
||||
//
|
||||
// app-store / UUID 模型(§7.5.5):submit-p = **新增一個作者版本(領新 uuid)**,
|
||||
// 不覆蓋同 canonical_id。同 canonical 多作者並存(Leo 版、John 版各自 uuid + 市場數據)。
|
||||
// 公共庫 = 暴露面 → 強制 exposure_consent(mindset §6:暴露需人類明示同意)。
|
||||
// 投稿者帶的 stat 只當「存證」(誰在何時投了什麼、聲稱打通幾次),寫進 KBDB 一筆
|
||||
// recipe_submission entry,**不**併進 recipe-stat 真實計數(避免自報數污染市場數據,§7.3)。
|
||||
// 市場信任靠真實使用累積(5.1),不拿投稿者自報數當門檻 → 不造債。
|
||||
recipesRouter.post('/recipes/submit', async (c) => {
|
||||
let body: Partial<RecipeDefinition> & {
|
||||
stat?: { success_count?: number; failure_count?: number };
|
||||
submitter?: string;
|
||||
};
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const canonicalId = (body.canonical_id ?? '').trim().toLowerCase();
|
||||
if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400);
|
||||
if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400);
|
||||
|
||||
const hashId = await deriveRecipeHash(canonicalId);
|
||||
const now = Date.now();
|
||||
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):公共庫投稿不再需要人類確認。
|
||||
|
||||
// app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。
|
||||
const recipe: RecipeDefinition = {
|
||||
uuid: crypto.randomUUID(),
|
||||
author: body.author ?? body.submitter ?? 'anonymous',
|
||||
derived_from: body.derived_from,
|
||||
canonical_id: canonicalId,
|
||||
hash_id: hashId,
|
||||
display_name: body.display_name,
|
||||
description: body.description,
|
||||
endpoint: body.endpoint,
|
||||
method: (body.method ?? 'POST').toUpperCase(),
|
||||
headers: body.headers,
|
||||
body: body.body,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// 新增作者版本:寫 recipe:{uuid} + 併進 idx:canonical 清單(同 canonical 多版本並存)。
|
||||
// installed 也指向這個新版本(官方部署投稿後預設用最新;market 選擇由 §7.5.5 端點處理)。
|
||||
await installRecipeRecord(c.env.RECIPES, recipe);
|
||||
|
||||
// stat 存證:寫一筆 recipe_submission entry 進 KBDB(不當門檻,當法律歸責軌跡)。
|
||||
// fire-and-forget:存證失敗不擋投稿成功。
|
||||
const kbdbBase = (c.env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
const evidence = {
|
||||
content: canonicalId,
|
||||
entry_type: 'recipe_submission',
|
||||
metadata_json: JSON.stringify({
|
||||
uuid: recipe.uuid,
|
||||
canonical_id: canonicalId,
|
||||
author: recipe.author,
|
||||
submitter: body.submitter ?? 'unknown',
|
||||
claimed_stat: body.stat ?? null,
|
||||
submitted_at: now,
|
||||
}),
|
||||
};
|
||||
const kbdbHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) kbdbHeaders['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
c.executionCtx.waitUntil(
|
||||
fetch(`${kbdbBase}/entries`, {
|
||||
method: 'POST',
|
||||
headers: kbdbHeaders,
|
||||
body: JSON.stringify(evidence),
|
||||
}).catch(() => undefined),
|
||||
);
|
||||
|
||||
return c.json({ success: true, recipe, evidence_recorded: true });
|
||||
});
|
||||
|
||||
// POST /recipes/migrate-uuid — 一次性 migration:把 migration 前的舊 key recipe:{canonical_id}
|
||||
// (無 uuid)轉成 UUID 身份模型(§7.5.5)。增量寫、**不刪舊 key**(失敗也不破現況;resolveRecipe
|
||||
// 本就 fallback 舊 key)。冪等:已有 uuid 的跳過。重跑安全。
|
||||
recipesRouter.post('/recipes/migrate-uuid', async (c) => {
|
||||
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
|
||||
let migrated = 0, skipped = 0;
|
||||
const errors: string[] = [];
|
||||
for (const k of list.keys) {
|
||||
try {
|
||||
const rec = await c.env.RECIPES.get(k.name, 'json') as RecipeDefinition | null;
|
||||
if (!rec || !rec.canonical_id) { skipped++; continue; }
|
||||
if (rec.uuid) { skipped++; continue; } // 已是新模型
|
||||
const migrated_recipe: RecipeDefinition = {
|
||||
...rec,
|
||||
uuid: crypto.randomUUID(),
|
||||
author: rec.author ?? 'system', // 舊種子歸 system
|
||||
};
|
||||
await installRecipeRecord(c.env.RECIPES, migrated_recipe);
|
||||
migrated++;
|
||||
} catch (e) {
|
||||
errors.push(`${k.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
return c.json({ success: errors.length === 0, migrated, skipped, errors });
|
||||
});
|
||||
|
||||
// GET /recipes/:id — 讀取 recipe(支援 canonical_id / rec_hash / uuid)
|
||||
recipesRouter.get('/recipes/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const recipe = await resolveRecipe(id, c.env.RECIPES);
|
||||
@@ -105,42 +242,193 @@ recipesRouter.get('/recipes/:id', async (c) => {
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes — 列出所有 recipe
|
||||
// GET /recipes — 列出所有 recipe(本部署 KV 全部版本,含多作者)。
|
||||
// prefix recipe: 同時命中 recipe:{uuid}(新)與 recipe:{canonical_id}(migration 前舊 key)。
|
||||
// 去重:同 canonical_id 若已有帶 uuid 的版本,捨棄無 uuid 的舊 key 重複項。
|
||||
recipesRouter.get('/recipes', async (c) => {
|
||||
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
|
||||
const recipes = await Promise.all(
|
||||
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
|
||||
);
|
||||
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
|
||||
const all = (await Promise.all(
|
||||
list.keys.map(k => c.env.RECIPES.get(k.name, 'json') as Promise<RecipeDefinition | null>)
|
||||
)).filter(Boolean) as RecipeDefinition[];
|
||||
|
||||
// canonical → 是否已有帶 uuid 的版本
|
||||
const hasUuidVersion = new Set(all.filter(r => r.uuid).map(r => r.canonical_id));
|
||||
const recipes = all.filter(r => r.uuid || !hasUuidVersion.has(r.canonical_id));
|
||||
return c.json({ success: true, recipes, count: recipes.length });
|
||||
});
|
||||
|
||||
// DELETE /recipes/:id — 刪除 recipe
|
||||
// ── 公庫只讀端點(kbdb-base §7.5.4,公→私 pull + 瀏覽的後端基礎)──────────────────
|
||||
// 官方 cypher 開公開只讀(無需 api_key,公庫本就公共)。語意 = 「這是公庫,給 self-hosted pull/瀏覽」,
|
||||
// 含作者維度 + 市場星數(與內部 /recipes 分開命名,公庫的多作者/排序不污染內部)。
|
||||
|
||||
/** 從 KBDB 抓 recipe 市場星數(5.1 記的 success/failure)。失敗回 null(端點仍可用,星數缺省)。*/
|
||||
async function fetchMarketStat(
|
||||
env: Bindings,
|
||||
canonicalId: string,
|
||||
): Promise<{ success_count: number; failure_count: number } | null> {
|
||||
try {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = {};
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
const res = await fetch(`${base}/recipe-stats/${encodeURIComponent(canonicalId)}`, { headers });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json() as { stat?: { success_count?: number; failure_count?: number } };
|
||||
if (!json.stat) return null;
|
||||
return {
|
||||
success_count: json.stat.success_count ?? 0,
|
||||
failure_count: json.stat.failure_count ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 收集本部署 KV 全部 recipe(去重,與 GET /recipes 同邏輯),給公庫端點共用。
|
||||
async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
|
||||
const list = await kv.list({ prefix: 'recipe:' });
|
||||
const all = (await Promise.all(
|
||||
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
|
||||
)).filter(Boolean) as RecipeDefinition[];
|
||||
const hasUuid = new Set(all.filter(r => r.uuid).map(r => r.canonical_id));
|
||||
return all.filter(r => r.uuid || !hasUuid.has(r.canonical_id));
|
||||
}
|
||||
|
||||
// GET /public-recipes?q=&limit=&offset= — 搜尋/列出公庫 recipe。
|
||||
// 同 canonical_id 回多筆(多作者),各附市場星數,供 CC 依數據選(§7.5.5)。
|
||||
// 落空(q 無命中)→ 回 found:false + 創作引導(§7.5.6),不回空陣列乾等。
|
||||
recipesRouter.get('/public-recipes', async (c) => {
|
||||
const q = (c.req.query('q') ?? '').trim().toLowerCase();
|
||||
const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
|
||||
const offset = Number(c.req.query('offset') ?? 0);
|
||||
|
||||
const all = await listAllRecipes(c.env.RECIPES);
|
||||
const matched = q
|
||||
? all.filter(r =>
|
||||
r.canonical_id.toLowerCase().includes(q) ||
|
||||
(r.display_name ?? '').toLowerCase().includes(q) ||
|
||||
(r.description ?? '').toLowerCase().includes(q))
|
||||
: all;
|
||||
|
||||
if (q && matched.length === 0) {
|
||||
// 落空 = 創作入口(§7.5.6):讓 CC 知道「公庫沒有,可自己做一個成為作者」。
|
||||
return c.json({
|
||||
found: false,
|
||||
query: q,
|
||||
hint: `公庫無符合「${q}」的 recipe。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`,
|
||||
});
|
||||
}
|
||||
|
||||
const page = matched.slice(offset, offset + limit);
|
||||
const withStats = await Promise.all(
|
||||
page.map(async r => ({
|
||||
uuid: r.uuid,
|
||||
canonical_id: r.canonical_id,
|
||||
author: r.author,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
market_stat: await fetchMarketStat(c.env, r.uuid ?? r.canonical_id), // §7.5.h per-uuid
|
||||
})),
|
||||
);
|
||||
return c.json({ found: true, recipes: withStats, count: matched.length });
|
||||
});
|
||||
|
||||
// GET /public-recipes/:canonical_id?author= — 取單一 recipe 全文(pull 用)。
|
||||
// 不指定 author → 回市場最佳版本(success_count 最高)。落空 → found:false 創作引導(§7.5.6)。
|
||||
recipesRouter.get('/public-recipes/:canonical_id', async (c) => {
|
||||
const canonicalId = c.req.param('canonical_id').trim().toLowerCase();
|
||||
const author = c.req.query('author');
|
||||
|
||||
const all = await listAllRecipes(c.env.RECIPES);
|
||||
let versions = all.filter(r => r.canonical_id === canonicalId);
|
||||
if (author) versions = versions.filter(r => r.author === author);
|
||||
|
||||
if (versions.length === 0) {
|
||||
return c.json({
|
||||
found: false,
|
||||
canonical_id: canonicalId,
|
||||
hint: `公庫無 recipe「${canonicalId}」${author ? `(author=${author})` : ''}。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`,
|
||||
});
|
||||
}
|
||||
|
||||
// 多作者 → 選市場最佳(success_count 最高;無 stat 視為 0)。
|
||||
// §7.5.h:星數 per-uuid(5.1 記 uuid)→ 能真正區分 Leo 版/John 版。舊資料無 uuid fallback canonical_id。
|
||||
let best = versions[0];
|
||||
let bestStat: { success_count: number; failure_count: number } | null = null;
|
||||
let bestScore = -1;
|
||||
for (const v of versions) {
|
||||
const stat = await fetchMarketStat(c.env, v.uuid ?? v.canonical_id);
|
||||
const score = stat?.success_count ?? 0;
|
||||
if (score > bestScore) { bestScore = score; best = v; bestStat = stat; }
|
||||
}
|
||||
return c.json({ found: true, recipe: best, market_stat: bestStat });
|
||||
});
|
||||
|
||||
// DELETE /recipes/:id — 刪除(依 UUID 模型清掉 recipe:{uuid} + installed + canonical 清單裡的該 uuid + 舊 key)
|
||||
recipesRouter.delete('/recipes/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const recipe = await resolveRecipe(id, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
|
||||
|
||||
await Promise.all([
|
||||
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`),
|
||||
const canonicalId = recipe.canonical_id;
|
||||
const ops: Promise<unknown>[] = [
|
||||
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
|
||||
]);
|
||||
|
||||
return c.json({ success: true, deleted: recipe.canonical_id });
|
||||
c.env.RECIPES.delete(`recipe:${canonicalId}`), // 舊 key(若存在)
|
||||
];
|
||||
if (recipe.uuid) {
|
||||
ops.push(c.env.RECIPES.delete(`recipe:${recipe.uuid}`));
|
||||
// 從 canonical 清單移除此 uuid;若清單空了連 installed 一起清
|
||||
const listRaw = await c.env.RECIPES.get(kIdxCanonical(canonicalId));
|
||||
const uuids: string[] = listRaw ? JSON.parse(listRaw) : [];
|
||||
const left = uuids.filter(u => u !== recipe.uuid);
|
||||
if (left.length > 0) {
|
||||
ops.push(c.env.RECIPES.put(kIdxCanonical(canonicalId), JSON.stringify(left)));
|
||||
// installed 若指向被刪的 uuid → 改指剩下第一個
|
||||
const installed = await c.env.RECIPES.get(kIdxInstalled(canonicalId));
|
||||
if (installed === recipe.uuid) ops.push(c.env.RECIPES.put(kIdxInstalled(canonicalId), left[0]));
|
||||
} else {
|
||||
ops.push(c.env.RECIPES.delete(kIdxCanonical(canonicalId)));
|
||||
ops.push(c.env.RECIPES.delete(kIdxInstalled(canonicalId)));
|
||||
}
|
||||
}
|
||||
await Promise.all(ops);
|
||||
return c.json({ success: true, deleted: recipe.uuid ?? canonicalId });
|
||||
});
|
||||
|
||||
/** 用 canonical_id 或 rec_hash 查 recipe */
|
||||
/**
|
||||
* 用 canonical_id / rec_hash / uuid 查 recipe(執行時的解析入口)。
|
||||
* UUID 身份模型(§7.5.5)+ 向後相容(migration 前的舊 key):
|
||||
* 1. id 是 uuid(recipe:{uuid} 直接存在)→ 直接回。
|
||||
* 2. rec_xxxxxxxx → idx:{hash} 反查 canonical_id → 再走 canonical 解析。
|
||||
* 3. canonical_id → 先查 idx:installed:{canonical_id}(本部署安裝的唯一版本)→ recipe:{uuid};
|
||||
* 查不到 fallback 舊 key recipe:{canonical_id}(種子 / migration 前資料)。
|
||||
* 執行鏈路(component-loader/auth-dispatcher/credential-injector)都經此 → 不破執行。
|
||||
*/
|
||||
export async function resolveRecipe(
|
||||
id: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<RecipeDefinition | null> {
|
||||
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
|
||||
// 1. 直接 uuid(pull / market 指定版本時用)
|
||||
const direct = await kv.get(`recipe:${id}`, 'json') as RecipeDefinition | null;
|
||||
if (direct && direct.uuid) return direct;
|
||||
// direct 命中但無 uuid = 舊 key recipe:{canonical_id}(migration 前)→ 仍可用,但繼續嘗試 installed 拿新版
|
||||
// (installed 優先:migration 後新版在 recipe:{uuid},舊 key 為 fallback)
|
||||
|
||||
// 2. rec_hash 反查 canonical_id
|
||||
let canonicalId = id;
|
||||
if (id.startsWith('rec_')) {
|
||||
const canonicalId = await kv.get(`idx:${id}`);
|
||||
if (!canonicalId) return null;
|
||||
return kv.get(`recipe:${canonicalId}`, 'json');
|
||||
const looked = await kv.get(`idx:${id}`);
|
||||
if (!looked) return direct; // hash 查不到,回 step1 結果(通常 null)
|
||||
canonicalId = looked;
|
||||
}
|
||||
// 直接用 canonical_id
|
||||
return kv.get(`recipe:${id}`, 'json');
|
||||
|
||||
// 3. canonical → installed uuid → recipe:{uuid};fallback 舊 key
|
||||
const installedUuid = await kv.get(kIdxInstalled(canonicalId));
|
||||
if (installedUuid) {
|
||||
const byUuid = await kv.get(`recipe:${installedUuid}`, 'json') as RecipeDefinition | null;
|
||||
if (byUuid) return byUuid;
|
||||
}
|
||||
// fallback:舊 key recipe:{canonical_id}(direct 若正是它,已在手上)
|
||||
return direct ?? (await kv.get(`recipe:${canonicalId}`, 'json'));
|
||||
}
|
||||
|
||||
// ── Auth Recipe ────────────────────────────────────────────────────────────────
|
||||
@@ -160,6 +448,11 @@ export interface AuthInjectSpec {
|
||||
header?: Record<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
|
||||
query?: Record<string, string>;
|
||||
body?: Record<string, string>;
|
||||
// path:注入 endpoint URL path 的 secret(auth-recipe.md §六,2026-05-29 加)。
|
||||
// 解 telegram 類「token 在 URL path」(/bot{token}/)—— header/query/body 都不適用。
|
||||
// key = 模板變數名(recipe endpoint 用 {{auth.K}} 引用),value = {{secret.X}} 模板。
|
||||
// auth_static_key WASM 解密後輸出為 auth_path → auth-dispatcher 帶進 _auth_path → makeRecipeRunner interpolate。
|
||||
path?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AuthRecipeDefinition {
|
||||
|
||||
@@ -28,9 +28,8 @@ import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
import type { GraphNode } from '../types';
|
||||
import { extractCronExpr } from '../lib/cron-match';
|
||||
import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
|
||||
import type { ExposureConsent } from '../lib/exposure-consent';
|
||||
|
||||
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -43,18 +42,50 @@ type NamedWorkflowRecord = {
|
||||
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
|
||||
// 對應 SDD: arcrun.md 三-A P1 #3
|
||||
cron_expr?: string;
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 存人類明示同意憑證(法律憑證,可審)。SDD: data-exfil-warning §7
|
||||
exposure_consent?: ExposureConsent;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。保留欄位只為向後相容舊 KV record。
|
||||
exposure_consent?: unknown;
|
||||
};
|
||||
|
||||
function kvKey(apiKey: string, name: string): string {
|
||||
return `${apiKey}:wf:${name}`;
|
||||
}
|
||||
|
||||
/** 輕量 cron index entry — scheduled() 只列這個 prefix(每分鐘 tick 不掃全量 KV)*/
|
||||
function cronIndexKey(apiKey: string, name: string): string {
|
||||
return `cron-idx:${apiKey}:${name}`;
|
||||
/**
|
||||
* workflow-discovery R2/Phase 2.1:部署時雙寫一個 embeddable entry 到 KBDB,讓 workflow 可被語意搜尋。
|
||||
*
|
||||
* 雙寫(design 方案 C):WEBHOOKS KV record 照舊(list/get/trigger 不動),另寫 entry_type=workflow 的
|
||||
* entry 供 search。owner_id = api_key(租戶隔離,與 kbdb-proxy 同身份模型)。
|
||||
* content = description(被 embed 的主體);metadata.embed:true → 命中 #7 精耕條件進 Vectorize(模組開時)。
|
||||
*
|
||||
* 非阻塞 + 失敗不致命(waitUntil + catch):search 可發現性是加值,不該擋部署成功(對齊 #7 embedOnWrite 慣例)。
|
||||
* KBDB 連法沿用既有慣例(KBDB_BASE_URL fetch + 選用 token),不新增 service binding(rule 02 §3.1)。
|
||||
*/
|
||||
async function writeWorkflowSearchEntry(
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
name: string,
|
||||
description: string,
|
||||
workflowId?: string,
|
||||
): Promise<void> {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
await fetch(`${base}/entries`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
entry_type: 'workflow',
|
||||
owner_id: apiKey, // 租戶隔離(與 kbdb-proxy 同身份)
|
||||
page_name: name,
|
||||
content: description, // 被 embed / LIKE 命中的主體
|
||||
// KBDB createEntry 吃 metadata_json(TEXT),embed.ts isEmbeddable 讀 metadata_json.embed === true。
|
||||
metadata_json: JSON.stringify({
|
||||
embed: true, // #7 精耕開關:標 true 才進 Vectorize
|
||||
workflow_name: name,
|
||||
workflow_id: workflowId ?? name,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// POST /webhooks/named — 部署(acr push 呼叫)
|
||||
@@ -69,26 +100,28 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
graph?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
description?: string;
|
||||
exposure_consent?: ExposureConsent;
|
||||
} | null;
|
||||
|
||||
if (!body?.name || !body.graph) {
|
||||
return c.json({ error: '缺少必要欄位:name, graph' }, 400);
|
||||
}
|
||||
|
||||
// workflow-discovery R1:description 強制非空(供語意搜尋,工作流可被發現)。
|
||||
// 定位(Q2 定案):要求操盤的 AI 據實寫一句「這工作流能做什麼」,非逼 low-code 用戶手填、
|
||||
// 非介面層機械塞佔位。空 → 擋下,由操盤 CC 據實補一句再部署(用戶可改)。
|
||||
if (typeof body.description !== 'string' || body.description.trim() === '') {
|
||||
return c.json({
|
||||
error: 'description 必填:請操盤的 AI 據實寫一句「這工作流能做什麼」(如「呼叫可 Upsert Google Sheets」),用戶可再改。供語意搜尋用,不是寫文章。',
|
||||
requires: 'description',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const name = body.name.trim();
|
||||
if (!/^[\w-]+$/.test(name)) {
|
||||
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
|
||||
}
|
||||
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 首次部署某 workflow 需人類明示同意;已同意(含 suppress_future)則放行(§3 首次問記住)。
|
||||
const priorRaw = await c.env.WEBHOOKS.get(kvKey(apiKey, name));
|
||||
const priorRecord = priorRaw ? (JSON.parse(priorRaw) as NamedWorkflowRecord) : null;
|
||||
const consentError = checkExposureConsent(body.exposure_consent, priorRecord?.exposure_consent);
|
||||
if (consentError !== null) {
|
||||
return c.json({ error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):部署 webhook 不再需要人類確認,直接放行。
|
||||
|
||||
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
|
||||
const cronExpr = extractCronExpr(body.graph);
|
||||
@@ -97,22 +130,23 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
name,
|
||||
graph: body.graph,
|
||||
config: body.config,
|
||||
description: typeof body.description === 'string' ? body.description : '',
|
||||
description: body.description.trim(), // R1:已驗非空(見上),存 trim 後的值
|
||||
created_at: new Date().toISOString(),
|
||||
cron_expr: cronExpr ?? undefined,
|
||||
// 法律憑證:存人類明示同意(本次新同意或沿用既有)
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, priorRecord?.exposure_consent),
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
|
||||
|
||||
// 維護 cron index:有 cron_expr 就寫 / 沒有就刪除(避免 push 改 yaml 拿掉 cron 後殘留)
|
||||
if (cronExpr) {
|
||||
await c.env.WEBHOOKS.put(cronIndexKey(apiKey, name), JSON.stringify({ cron_expr: cronExpr }));
|
||||
} else {
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
}
|
||||
// 維護單一 cron index key(8.P0):有 cron_expr 就 upsert / 沒有就移除
|
||||
// (避免 push 改 yaml 拿掉 cron 後殘留)。scheduled() 每分鐘只 get 這一個 key。
|
||||
await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, cronExpr);
|
||||
|
||||
// workflow-discovery Phase 2.1:雙寫 embeddable search-entry(讓此 workflow 可被語意搜尋)。
|
||||
// 非阻塞(waitUntil)+ 失敗不致命(catch):可發現性是加值,不擋部署成功(對齊 #7 embedOnWrite 慣例)。
|
||||
c.executionCtx.waitUntil(
|
||||
writeWorkflowSearchEntry(c.env, apiKey, name, record.description).catch(() => {}),
|
||||
);
|
||||
|
||||
// Implicit telemetry (LI M1.2)
|
||||
recordTelemetry(c.env, apiKey, {
|
||||
@@ -131,6 +165,103 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// GET /workflows/search?q=&mode= — workflow-discovery R2:語意搜尋本租戶的工作流。
|
||||
// 轉發 KBDB /entries/search(限 entry_type=workflow + 本租戶 owner_id)。優先語意、未開 Vectorize
|
||||
// 降級 keyword + capability_hint(KBDB 端已實作 #7 閉環,本端純轉發 + 注 entry_type/owner_id)。
|
||||
// 形態對齊 u6u_search_components:自然語言 q 進、結果 + capability_hint 出。flag 安全:AI 主動 pull,無輪詢。
|
||||
webhooksNamedRouter.get('/workflows/search', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ error: 'q 必填:用自然語言描述要找的工作流(如「把資料寫進 Google Sheets」)' }, 400);
|
||||
// 預設優先語意;caller 傳 mode=keyword 才強制關鍵字。KBDB 端未開 Vectorize 會自動降級。
|
||||
const mode = c.req.query('mode') === 'keyword' ? 'keyword' : 'semantic';
|
||||
|
||||
const base = (c.env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow)
|
||||
entry_type: 'workflow', // base 通用 filter(Q4),只回 workflow entry
|
||||
mode,
|
||||
});
|
||||
const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// POST /workflows/backfill-search-entries — workflow-discovery R3:把既有 workflow 補成可搜的 search-entry。
|
||||
// 有 description 的 → 補寫 entry(讓它們可被 u6u_search_workflows 搜到);無 description 的 → 列出待 re-deploy。
|
||||
// 誠實:不自動編造 description(無 desc 的只列出、不假裝)。flag 安全:人/AI 主動呼叫一次,非 cron/輪詢。
|
||||
webhooksNamedRouter.post('/workflows/backfill-search-entries', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
|
||||
const prefix = `${apiKey}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const backfilled: string[] = [];
|
||||
const needsDescription: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const k of list.keys) {
|
||||
const name = k.name.slice(prefix.length);
|
||||
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
|
||||
if (!raw) continue;
|
||||
const rec = JSON.parse(raw) as NamedWorkflowRecord;
|
||||
const desc = rec.description?.trim();
|
||||
if (!desc) {
|
||||
// 不自動編造:無 description 的列出來,請操盤 CC re-deploy 時據實補(誠實,mindset §7)。
|
||||
needsDescription.push(name);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await writeWorkflowSearchEntry(c.env, apiKey, name, desc);
|
||||
backfilled.push(name);
|
||||
} catch (e) {
|
||||
errors.push(`${name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
backfilled,
|
||||
backfilled_count: backfilled.length,
|
||||
needs_description: needsDescription,
|
||||
needs_description_count: needsDescription.length,
|
||||
errors,
|
||||
hint: needsDescription.length > 0
|
||||
? `${needsDescription.length} 個工作流缺 description 無法被搜尋。請操盤的 AI re-deploy 它們時據實補一句「能做什麼」(不自動編造)。`
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /webhooks/named/migrate-cron-index — 一次性 migration(8.P0):把舊的 per-key
|
||||
// cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all(這裡才 list 一次,非每分鐘 tick)。
|
||||
// 增量寫、不刪舊 key(重跑安全、冪等)。部署 8.P0 後跑一次,讓既有 cron workflow 不漏掉。
|
||||
// 必須在 /:name/trigger 之前註冊,否則 :name 會攔截 "migrate-cron-index"。
|
||||
webhooksNamedRouter.post('/webhooks/named/migrate-cron-index', async (c) => {
|
||||
const list = await c.env.WEBHOOKS.list({ prefix: 'cron-idx:' });
|
||||
let migrated = 0, skipped = 0;
|
||||
const errors: string[] = [];
|
||||
for (const k of list.keys) {
|
||||
if (k.name === CRON_INDEX_KEY) { skipped++; continue; } // 跳過新的集中 key 自己
|
||||
const parts = k.name.split(':'); // cron-idx:{apiKey}:{name}
|
||||
if (parts.length < 3) { skipped++; continue; }
|
||||
const apiKey = parts[1];
|
||||
const name = parts.slice(2).join(':');
|
||||
try {
|
||||
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
|
||||
if (!raw) { skipped++; continue; }
|
||||
const idx = JSON.parse(raw) as { cron_expr?: string };
|
||||
if (!idx.cron_expr) { skipped++; continue; }
|
||||
await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, idx.cron_expr);
|
||||
migrated++;
|
||||
} catch (e) {
|
||||
errors.push(`${k.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
return c.json({ success: errors.length === 0, migrated, skipped, errors });
|
||||
});
|
||||
|
||||
// POST /webhooks/named/:name/trigger — 觸發執行(api_key 走 header;標準/向後相容)
|
||||
webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
@@ -220,16 +351,23 @@ webhooksNamedRouter.get('/webhooks/named', async (c) => {
|
||||
const prefix = `${apiKey}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
|
||||
const workflows = list.keys.map(k => {
|
||||
const name = k.name.slice(prefix.length);
|
||||
return { name };
|
||||
});
|
||||
|
||||
// workflow-discovery 方向①:list 回完整欄位(description/created_at),讓 MCP u6u_list_workflows
|
||||
// 改讀本端點時欄位齊(取代舊的讀 workflow_metadata record)。需 get 每個 record 取 description。
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
const result = workflows.map(w => ({
|
||||
name: w.name,
|
||||
webhook_url: `${baseUrl}/webhooks/named/${w.name}/trigger`,
|
||||
}));
|
||||
const result = await Promise.all(
|
||||
list.keys.map(async (k) => {
|
||||
const name = k.name.slice(prefix.length);
|
||||
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
|
||||
const rec = raw ? (JSON.parse(raw) as NamedWorkflowRecord) : null;
|
||||
return {
|
||||
name,
|
||||
description: rec?.description ?? '',
|
||||
created_at: rec?.created_at ?? '',
|
||||
cron_expr: rec?.cron_expr,
|
||||
webhook_url: `${baseUrl}/webhooks/named/${name}/trigger`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ workflows: result, total: result.length });
|
||||
});
|
||||
@@ -248,6 +386,6 @@ webhooksNamedRouter.delete('/webhooks/named/:name', async (c) => {
|
||||
}
|
||||
|
||||
await c.env.WEBHOOKS.delete(kvKey(apiKey, name));
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
await updateCronIndexEntry(c.env.WEBHOOKS, apiKey, name, null);
|
||||
return c.json({ deleted: true, name });
|
||||
});
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
* scheduled() handler — 對應 wrangler.toml [triggers].crons 觸發。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 列出 WEBHOOKS KV 所有 webhook:{api_key}:{name} key
|
||||
* 2. 對每個 workflow 解析 cron_expr(acr push 時若首節點是 cron 零件會存進 record.cron_expr)
|
||||
* 3. 用 cronMatch() 比對 event.scheduledTime(UTC 分鐘精度)
|
||||
* 1. 單次 get cron index(cron-idx:_all,集中存所有 cron workflow 的 cron_expr)
|
||||
* 2. 在記憶體比對每筆 cron_expr 跟 event.scheduledTime(UTC 分鐘精度)
|
||||
* 3. 匹配才去讀完整 workflow record({apiKey}:wf:{name})
|
||||
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
||||
*
|
||||
* SDD: arcrun.md 三-A P1 #3
|
||||
* 8.P0 止血(SDD §8.2):原本每分鐘 WEBHOOKS.list('cron-idx:') = 1440 list/日 爆 KV 上限,
|
||||
* 改成單一固定 key 只 get 一次 → list 歸零。
|
||||
*
|
||||
* SDD: arcrun.md 三-A P1 #3 / kbdb-base §8.2
|
||||
*/
|
||||
|
||||
import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types';
|
||||
import type { Bindings } from './types';
|
||||
import { cronMatch } from './lib/cron-match';
|
||||
import { readCronIndex, parseCronEntryKey } from './lib/cron-index';
|
||||
import { executeWebhookGraph } from './actions/webhook-handlers';
|
||||
|
||||
type StoredWorkflowRecord = {
|
||||
@@ -29,25 +33,18 @@ export async function handleScheduled(
|
||||
const now = new Date(controller.scheduledTime);
|
||||
console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron);
|
||||
|
||||
// 只列 cron-idx: prefix,輕量 — acr push 時為 cron-tagged workflow 額外寫一筆 index
|
||||
// 主 workflow record 仍在 {apiKey}:wf:{name},需要時再 get
|
||||
const list = await env.WEBHOOKS.list({ prefix: 'cron-idx:' });
|
||||
// 8.P0:單次 get 集中索引(取代每分鐘 list),主 workflow record 仍在 {apiKey}:wf:{name}
|
||||
const index = await readCronIndex(env.WEBHOOKS);
|
||||
const entries = Object.entries(index);
|
||||
|
||||
let triggered = 0;
|
||||
for (const entry of list.keys) {
|
||||
// key = cron-idx:{api_key}:{name}
|
||||
const parts = entry.name.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const apiKey = parts[1];
|
||||
const name = parts.slice(2).join(':'); // name 可能含 ':'(雖然 push handler 已用 /^[\w-]+$/ 擋)
|
||||
for (const [entryKey, cronExpr] of entries) {
|
||||
const parsed = parseCronEntryKey(entryKey);
|
||||
if (!parsed) continue;
|
||||
const { apiKey, name } = parsed;
|
||||
|
||||
// 從 cron-idx 拿 cron_expr(輕量)
|
||||
const idxRaw = await env.WEBHOOKS.get(entry.name, 'text');
|
||||
if (!idxRaw) continue;
|
||||
let idx: { cron_expr?: string };
|
||||
try { idx = JSON.parse(idxRaw); } catch { continue; }
|
||||
if (!idx.cron_expr) continue;
|
||||
if (!cronMatch(idx.cron_expr, now)) continue;
|
||||
if (!cronExpr) continue;
|
||||
if (!cronMatch(cronExpr, now)) continue;
|
||||
|
||||
// 匹配才去讀完整 workflow record
|
||||
const wfKey = `${apiKey}:wf:${name}`;
|
||||
@@ -60,7 +57,7 @@ export async function handleScheduled(
|
||||
try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; }
|
||||
triggered++;
|
||||
|
||||
console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', idx.cron_expr);
|
||||
console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', cronExpr);
|
||||
// 把 apiKey 也放進 triggerContext,讓 workflow 內節點能用 {{api_key}}(跟 webhook trigger 慣例一致)
|
||||
const triggerContext = {
|
||||
api_key: apiKey,
|
||||
@@ -75,5 +72,5 @@ export async function handleScheduled(
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log(`[scheduled] scanned ${list.keys.length} cron-idx entries, ${triggered} triggered`);
|
||||
console.log(`[scheduled] scanned ${entries.length} cron-idx entries, ${triggered} triggered`);
|
||||
}
|
||||
|
||||
@@ -21,12 +21,6 @@ export type Bindings = {
|
||||
SVC_DATE_OPS: ServiceBinding;
|
||||
SVC_VALIDATE_JSON: ServiceBinding;
|
||||
// SVC_AI_TRANSFORM_* 已移除(Phase 2 刪 ai_transform 零件 + wrangler.toml service binding)
|
||||
// Auth primitive Service Bindings(Phase 7,2026-06-06):繞開 self-hosted 同帳號 workers.dev 子請求 1042。
|
||||
// optional:auth_mtls 尚未部署(無 binding);無 binding 時 auth-dispatcher fallback 到 fetch(workers.dev)。
|
||||
SVC_AUTH_STATIC_KEY?: ServiceBinding;
|
||||
SVC_AUTH_SERVICE_ACCOUNT?: ServiceBinding;
|
||||
SVC_AUTH_OAUTH2?: ServiceBinding;
|
||||
SVC_AUTH_MTLS?: ServiceBinding;
|
||||
// KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
|
||||
EXEC_CONTEXT: KVNamespace;
|
||||
// Recipe Store:API recipe 定義(key: recipe:{canonical_id} 或 idx:{hash_id})
|
||||
@@ -55,6 +49,9 @@ export type Bindings = {
|
||||
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
|
||||
// KBDB 整合
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
// KBDB Base worker URL(recipe 成功記錄 /recipe-stats/record、fragment 抓取)。
|
||||
// 未設 fallback 見各使用點(recipe-expander 預設 kbdb.finally.click)。kbdb-base SDD §7.1。
|
||||
KBDB_BASE_URL?: string;
|
||||
// Component Worker subdomain(workers.dev 帳號 subdomain)
|
||||
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
|
||||
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
|
||||
|
||||
@@ -2,7 +2,14 @@ name = "arcrun-cypher-executor"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
workers_dev = true
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
# global_fetch_strictly_public:讓 fetch() 走公網「前門」,解 self-hosted 的 same-zone 1042
|
||||
# (credential-primitives-wasm Phase 7,2026-06-06)。
|
||||
# 病因(官方 docs):self-hosted 的 cypher 與 auth worker 同在 {sub}.workers.dev zone,
|
||||
# cypher fetch auth 屬 same-zone fetch,CF 預設擋(1042)。官方 cypher 在 cypher.arcrun.dev、
|
||||
# 打 auth 的 *.workers.dev 屬跨 zone 故不踩。此 flag 讓 same-zone fetch 改走公網前門 → 同 zone 也通。
|
||||
# 安全(已查證官方 docs):唯一副作用是「Worker fetch 自己 hostname 會 self-loop」,
|
||||
# 但 cypher 只打外部 API + sibling auth worker(皆非自己 hostname)→ 不 self-loop。
|
||||
compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "EXEC_CONTEXT"
|
||||
@@ -91,24 +98,6 @@ service = "arcrun-date-ops"
|
||||
binding = "SVC_VALIDATE_JSON"
|
||||
service = "arcrun-validate-json"
|
||||
|
||||
# Auth primitive service bindings(credential-primitives-wasm Phase 7,2026-06-06)
|
||||
# 為何:auth-dispatcher 原用 fetch(workers.dev) 打同帳號 auth worker,self-hosted 帳號踩 CF 1042
|
||||
# (壓測階段 11)。service binding 是 CF 內部 RPC,繞開同 zone 522 + 同帳號 1042。
|
||||
# 範圍:只綁「已部署」的 auth worker。auth_mtls 尚未部署(.component-builds 無、官方 404),
|
||||
# 綁不存在的 worker 會讓 deploy 報 "referenced Worker not found"(見上 ai_transform 教訓),
|
||||
# 故 mtls 待它部署後再加。auth-dispatcher 對無 binding 的 primitive 自動 fallback fetch。
|
||||
[[services]]
|
||||
binding = "SVC_AUTH_STATIC_KEY"
|
||||
service = "arcrun-auth-static-key"
|
||||
|
||||
[[services]]
|
||||
binding = "SVC_AUTH_SERVICE_ACCOUNT"
|
||||
service = "arcrun-auth-service-account"
|
||||
|
||||
[[services]]
|
||||
binding = "SVC_AUTH_OAUTH2"
|
||||
service = "arcrun-auth-oauth2"
|
||||
|
||||
# ai_transform_compile / ai_transform_run 已於 Phase 2(2026-05-29)刪除
|
||||
# (Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點)。對應 worker 已 wrangler delete,
|
||||
# service binding 一併移除(否則 deploy 報 referenced Worker not found)。
|
||||
@@ -124,6 +113,11 @@ ENVIRONMENT = "production"
|
||||
# Self-hosted fork:改成自己的 CF 帳號 subdomain(Workers & Pages → 你的帳號 → subdomain settings)
|
||||
WORKER_SUBDOMAIN = "uncle6-me"
|
||||
|
||||
# KBDB 基本盤對外 URL(cypher→KBDB:proxy /kbdb/*、recipe-stats、recipe fragment)。
|
||||
# 現役 = arcrun-kbdb(workers.dev,無 auth、不需 token)。舊的 kbdb.finally.click 是 inkstone 遺留已死。
|
||||
# Self-hosted fork:改成自己部署的 arcrun-kbdb.<你的subdomain>.workers.dev。
|
||||
KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
|
||||
|
||||
[[routes]]
|
||||
pattern = "cypher.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- KBDB Base — atomic universal table (3 tables, never changes)
|
||||
-- SDD: .agents/specs/arcrun/kbdb-base/design.md
|
||||
--
|
||||
-- Plugin model (like PostgreSQL core + PGVector/AGE):
|
||||
-- - Base = these 3 tables + plain CRUD + D1 LIKE search (D1 only, free, no credit card).
|
||||
-- - embed module = optional, writes vectors to Vectorize (does NOT alter these tables).
|
||||
-- - triplet module = separate repo, writes derived records into entry_values (does NOT alter base).
|
||||
-- "Table never changes": new tech records its output elsewhere, never ALTERs the base.
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Three tables
|
||||
-- ============================================================
|
||||
|
||||
-- Universal main table: each entry is one atomic datum.
|
||||
-- entry_type extended for arcrun: 'block' | 'value' | 'template' | 'slot' | 'project' | 'workflow' | 'recipe_stat'
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT,
|
||||
entry_type TEXT NOT NULL,
|
||||
owner_id TEXT, -- multi-tenant: namespace (self-hosted) or api_key (SaaS)
|
||||
|
||||
-- tree structure (project -> workflow via parent_id; SDD Q1 decision)
|
||||
parent_id TEXT,
|
||||
|
||||
-- optional block metadata (harmless plain columns)
|
||||
page_name TEXT,
|
||||
refs_json TEXT DEFAULT '[]',
|
||||
tags_json TEXT DEFAULT '[]',
|
||||
task_status TEXT,
|
||||
|
||||
-- optional embed bookkeeping (set by optional embed module; base never reads them)
|
||||
content_hash TEXT,
|
||||
is_embedded INTEGER DEFAULT 0,
|
||||
|
||||
-- metadata
|
||||
confidence REAL,
|
||||
metadata_json TEXT,
|
||||
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Template table: defines slots for a virtual table
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
slots_json TEXT NOT NULL, -- JSON array, e.g. ["display_name","gender"]
|
||||
created_by TEXT, -- 'system' | 'ai' | owner_id
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Slot link table: composes multiple entries into one structured record
|
||||
CREATE TABLE IF NOT EXISTS entry_values (
|
||||
id TEXT PRIMARY KEY,
|
||||
record_id TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL REFERENCES templates(id),
|
||||
slot_name TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL REFERENCES entries(id),
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
UNIQUE(record_id, slot_name)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_owner ON entries(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_parent ON entries(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_page ON entries(page_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_task ON entries(task_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_hash ON entries(content_hash);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ev_record ON entry_values(record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ev_template ON entry_values(template_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ev_template_slot ON entry_values(template_id, slot_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ev_entry ON entry_values(entry_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Seed templates used by arcrun base
|
||||
-- ============================================================
|
||||
|
||||
-- recipe_stat: success/failure counters for a recipe (feeds recipe submission, SDD section 7)
|
||||
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||
VALUES
|
||||
('tpl-recipe-stat', 'recipe_stat', 'recipe success/failure counters', '["canonical_id","success_count","failure_count","last_status","last_at"]', 'system');
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"zod": "~3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+1870
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -0,0 +1,131 @@
|
||||
// Entry CRUD — atomic data + tree (project/workflow via parent_id). Base, D1 only.
|
||||
import type { Bindings, Entry } from '../types';
|
||||
|
||||
function uid(prefix: string): string {
|
||||
// deterministic-enough unique id without Math.random in hot path is fine here;
|
||||
// crypto.randomUUID is available in Workers runtime.
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
export interface CreateEntryInput {
|
||||
content?: string | null;
|
||||
entry_type: string;
|
||||
owner_id?: string | null;
|
||||
parent_id?: string | null;
|
||||
page_name?: string | null;
|
||||
refs_json?: string;
|
||||
tags_json?: string;
|
||||
task_status?: string | null;
|
||||
confidence?: number | null;
|
||||
metadata_json?: string | null;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export async function createEntry(db: D1Database, input: CreateEntryInput): Promise<Entry> {
|
||||
const id = input.id ?? uid('e');
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO entries (id, content, entry_type, owner_id, parent_id, page_name, refs_json, tags_json, task_status, confidence, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
input.content ?? null,
|
||||
input.entry_type,
|
||||
input.owner_id ?? null,
|
||||
input.parent_id ?? null,
|
||||
input.page_name ?? null,
|
||||
input.refs_json ?? '[]',
|
||||
input.tags_json ?? '[]',
|
||||
input.task_status ?? null,
|
||||
input.confidence ?? null,
|
||||
input.metadata_json ?? null,
|
||||
)
|
||||
.run();
|
||||
const row = await getEntry(db, id);
|
||||
if (!row) throw new Error('createEntry: insert succeeded but row not found');
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getEntry(db: D1Database, id: string): Promise<Entry | null> {
|
||||
const row = await db.prepare('SELECT * FROM entries WHERE id = ?').bind(id).first<Entry>();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export interface ListEntriesFilter {
|
||||
entry_type?: string;
|
||||
owner_id?: string;
|
||||
parent_id?: string;
|
||||
page_name?: string; // exact-match lookup (e.g. skill-/example- idempotency key)
|
||||
source?: string; // filter by metadata_json.$.source (ingest envelope source.uri). issue #5.1
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<Entry[]> {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
|
||||
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
|
||||
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
|
||||
if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); }
|
||||
// source is queryable via SQLite json_extract on the existing metadata_json TEXT column —
|
||||
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
|
||||
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const limit = Math.min(f.limit ?? 100, 1000);
|
||||
const offset = f.offset ?? 0;
|
||||
const res = await db
|
||||
.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
||||
.bind(...params, limit, offset)
|
||||
.all<Entry>();
|
||||
return res.results ?? [];
|
||||
}
|
||||
|
||||
export interface UpdateEntryInput {
|
||||
content?: string | null;
|
||||
parent_id?: string | null;
|
||||
page_name?: string | null;
|
||||
refs_json?: string;
|
||||
tags_json?: string;
|
||||
task_status?: string | null;
|
||||
confidence?: number | null;
|
||||
metadata_json?: string | null;
|
||||
}
|
||||
|
||||
export async function updateEntry(db: D1Database, id: string, patch: UpdateEntryInput): Promise<Entry | null> {
|
||||
const cols: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const map: Record<string, unknown> = patch as Record<string, unknown>;
|
||||
for (const k of ['content', 'parent_id', 'page_name', 'refs_json', 'tags_json', 'task_status', 'confidence', 'metadata_json']) {
|
||||
if (k in map && map[k] !== undefined) { cols.push(`${k} = ?`); params.push(map[k]); }
|
||||
}
|
||||
if (cols.length === 0) return getEntry(db, id);
|
||||
cols.push('updated_at = unixepoch()');
|
||||
await db.prepare(`UPDATE entries SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
|
||||
return getEntry(db, id);
|
||||
}
|
||||
|
||||
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
// D1 LIKE keyword search (base; semantic search is the optional embed module).
|
||||
// entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic).
|
||||
export async function searchEntries(
|
||||
db: D1Database,
|
||||
q: string,
|
||||
owner_id?: string,
|
||||
entry_type?: string,
|
||||
limit = 50,
|
||||
): Promise<Entry[]> {
|
||||
const conds = ['content LIKE ?'];
|
||||
const params: unknown[] = [`%${q}%`];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
const res = await db
|
||||
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
|
||||
.bind(...params, Math.min(limit, 200))
|
||||
.all<Entry>();
|
||||
return res.results ?? [];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Recipe success/failure records (SDD section 7.1). Stored as an entry per recipe canonical_id.
|
||||
// This is the "fuel" for submission-with-proof: real 2xx counts beat self-written tests.
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
interface RecipeStat {
|
||||
canonical_id: string;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
last_status: string | null;
|
||||
last_at: number | null;
|
||||
}
|
||||
|
||||
// One entry per recipe: id = recipestat:{canonical_id}, entry_type='recipe_stat',
|
||||
// counters live in metadata_json. Atomic upsert via D1.
|
||||
function statId(canonicalId: string): string {
|
||||
return `recipestat:${canonicalId}`;
|
||||
}
|
||||
|
||||
export async function recordRecipeResult(db: D1Database, canonicalId: string, ok: boolean, nowMs: number): Promise<RecipeStat> {
|
||||
const id = statId(canonicalId);
|
||||
const existing = await db.prepare('SELECT metadata_json FROM entries WHERE id = ?').bind(id).first<{ metadata_json: string | null }>();
|
||||
|
||||
let stat: RecipeStat;
|
||||
if (existing) {
|
||||
const prev = existing.metadata_json ? (JSON.parse(existing.metadata_json) as RecipeStat) : emptyStat(canonicalId);
|
||||
stat = {
|
||||
canonical_id: canonicalId,
|
||||
success_count: prev.success_count + (ok ? 1 : 0),
|
||||
failure_count: prev.failure_count + (ok ? 0 : 1),
|
||||
last_status: ok ? 'success' : 'failure',
|
||||
last_at: nowMs,
|
||||
};
|
||||
await db
|
||||
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
|
||||
.bind(JSON.stringify(stat), id)
|
||||
.run();
|
||||
} else {
|
||||
stat = {
|
||||
canonical_id: canonicalId,
|
||||
success_count: ok ? 1 : 0,
|
||||
failure_count: ok ? 0 : 1,
|
||||
last_status: ok ? 'success' : 'failure',
|
||||
last_at: nowMs,
|
||||
};
|
||||
await db
|
||||
.prepare('INSERT INTO entries (id, content, entry_type, metadata_json) VALUES (?, ?, ?, ?)')
|
||||
.bind(id, canonicalId, 'recipe_stat', JSON.stringify(stat))
|
||||
.run();
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
export async function getRecipeStat(db: D1Database, canonicalId: string): Promise<RecipeStat> {
|
||||
const row = await db.prepare('SELECT metadata_json FROM entries WHERE id = ?').bind(statId(canonicalId)).first<{ metadata_json: string | null }>();
|
||||
if (!row || !row.metadata_json) return emptyStat(canonicalId);
|
||||
return JSON.parse(row.metadata_json) as RecipeStat;
|
||||
}
|
||||
|
||||
function emptyStat(canonicalId: string): RecipeStat {
|
||||
return { canonical_id: canonicalId, success_count: 0, failure_count: 0, last_status: null, last_at: null };
|
||||
}
|
||||
|
||||
export type { RecipeStat };
|
||||
@@ -0,0 +1,180 @@
|
||||
// Template + Record CRUD. A "record" = multiple entries composed via a template's slots.
|
||||
// Base, D1 only. (Ported clean from KBDB; no vectorize/triplet imports.)
|
||||
import type { Template } from '../types';
|
||||
import { createEntry } from './entry-crud';
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// ---- Templates ----
|
||||
|
||||
export interface CreateTemplateInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
slots: string[];
|
||||
created_by?: string | null;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export async function createTemplate(db: D1Database, input: CreateTemplateInput): Promise<Template> {
|
||||
const id = input.id ?? uid('tpl');
|
||||
await db
|
||||
.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`)
|
||||
.bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null)
|
||||
.run();
|
||||
const row = await getTemplate(db, id);
|
||||
if (!row) throw new Error('createTemplate: row not found after insert');
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getTemplate(db: D1Database, idOrName: string): Promise<Template | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM templates WHERE id = ? OR name = ? LIMIT 1')
|
||||
.bind(idOrName, idOrName)
|
||||
.first<Template>();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function listTemplates(db: D1Database): Promise<Template[]> {
|
||||
const res = await db.prepare('SELECT * FROM templates ORDER BY created_at DESC').all<Template>();
|
||||
return res.results ?? [];
|
||||
}
|
||||
|
||||
export async function updateTemplate(db: D1Database, id: string, patch: { description?: string | null; slots?: string[] }): Promise<Template | null> {
|
||||
const cols: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (patch.description !== undefined) { cols.push('description = ?'); params.push(patch.description); }
|
||||
if (patch.slots !== undefined) { cols.push('slots_json = ?'); params.push(JSON.stringify(patch.slots)); }
|
||||
if (cols.length === 0) return getTemplate(db, id);
|
||||
cols.push('updated_at = unixepoch()');
|
||||
await db.prepare(`UPDATE templates SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
|
||||
return getTemplate(db, id);
|
||||
}
|
||||
|
||||
// ---- Records (entry_values composed by template) ----
|
||||
|
||||
export interface CreateRecordInput {
|
||||
template: string; // template id or name
|
||||
values: Record<string, string>; // slot_name -> content
|
||||
owner_id?: string | null;
|
||||
record_id?: string;
|
||||
}
|
||||
|
||||
export interface RecordResult {
|
||||
record_id: string;
|
||||
template_id: string;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
||||
const tpl = await getTemplate(db, input.template);
|
||||
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
||||
const slots: string[] = JSON.parse(tpl.slots_json);
|
||||
const recordId = input.record_id ?? uid('rec');
|
||||
|
||||
for (const slot of slots) {
|
||||
if (!(slot in input.values)) continue;
|
||||
const entry = await createEntry(db, {
|
||||
content: input.values[slot],
|
||||
entry_type: 'value',
|
||||
owner_id: input.owner_id ?? null,
|
||||
});
|
||||
await db
|
||||
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
|
||||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||||
.run();
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
||||
}
|
||||
|
||||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||||
// "Deprecate by flipping a slot value" — base append-only is NOT broken: we change the
|
||||
// underlying entries.content of the slot's entry, we do not alter table structure / add columns / delete rows.
|
||||
// - slot already on the record → UPDATE the linked entries.content.
|
||||
// - slot valid for the record's template but not yet present → create entry + entry_value (idempotent grow).
|
||||
// - slot not in the template's slots_json → reject (records must stay template-shaped).
|
||||
// Returns null if the record does not exist.
|
||||
export async function updateRecord(
|
||||
db: D1Database,
|
||||
recordId: string,
|
||||
values: Record<string, string>,
|
||||
): Promise<RecordResult | null> {
|
||||
// Existing slot → entry_id + template_id for this record.
|
||||
const evRes = await db
|
||||
.prepare(`SELECT slot_name, entry_id, template_id FROM entry_values WHERE record_id = ?`)
|
||||
.bind(recordId)
|
||||
.all<{ slot_name: string; entry_id: string; template_id: string }>();
|
||||
const evRows = evRes.results ?? [];
|
||||
if (evRows.length === 0) return null; // record does not exist
|
||||
|
||||
const templateId = evRows[0].template_id;
|
||||
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
|
||||
|
||||
const tpl = await getTemplate(db, templateId);
|
||||
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
|
||||
|
||||
for (const [slot, content] of Object.entries(values)) {
|
||||
if (!allowed.includes(slot)) {
|
||||
throw new Error(`slot not in template: ${slot}`);
|
||||
}
|
||||
const entryId = slotToEntry.get(slot);
|
||||
if (entryId) {
|
||||
// flip the slot value: update the linked entry's content (table structure untouched)
|
||||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||||
} else {
|
||||
// valid template slot not yet on this record → grow it (create entry + link)
|
||||
const entry = await createEntry(db, { content, entry_type: 'value' });
|
||||
await db
|
||||
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
|
||||
.bind(uid('ev'), recordId, templateId, slot, entry.id)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
return getRecord(db, recordId);
|
||||
}
|
||||
|
||||
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`,
|
||||
)
|
||||
.bind(recordId)
|
||||
.all<{ slot: string; content: string; template_id: string }>();
|
||||
const rows = res.results ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
const values: Record<string, string> = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||||
}
|
||||
|
||||
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
||||
const tpl = await getTemplate(db, template);
|
||||
if (!tpl) return [];
|
||||
// owner_id 過濾在 SQL 做:record 的歸屬存在底層 entries.owner_id(createRecord 寫入時帶)。
|
||||
// 給了 owner_id → JOIN entries 限定該 owner(租戶隔離,cypher proxy 強制注入);
|
||||
// 沒給 → 不限(內部/全域查詢)。先前 `|| true` 是 stub,會洩漏跨租戶資料(2026-06-14 修)。
|
||||
const cap = Math.min(limit, 500);
|
||||
const res = owner_id
|
||||
? await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
|
||||
JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ? AND e.owner_id = ?
|
||||
ORDER BY ev.created_at DESC LIMIT ?`,
|
||||
)
|
||||
.bind(tpl.id, owner_id, cap)
|
||||
.all<{ record_id: string }>()
|
||||
: await db
|
||||
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
|
||||
.bind(tpl.id, cap)
|
||||
.all<{ record_id: string }>();
|
||||
const out: RecordResult[] = [];
|
||||
for (const { record_id } of res.results ?? []) {
|
||||
const rec = await getRecord(db, record_id);
|
||||
if (rec) out.push(rec);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// KBDB optional embed module (issue #7 / mira-dissolve SDD T2.4).
|
||||
//
|
||||
// 鐵律對齊:
|
||||
// - embedding 屬 **base 的 optional 模組**(非 graph/ingest)。CF 內建(Vectorize+AI),程式薄。
|
||||
// - **不拆 repo,binding 開/關**:有 env.VECTORIZE + env.AI 才啟用;沒有 → base 維持 LIKE keyword,API 不變。
|
||||
// - 不動三表結構(只標既有 entries.is_embedded / content_hash bookkeeping 欄;那些 base 從不讀,embed 才寫)。
|
||||
// - 不對每個 block 地毯式 embed(精耕,非 RAG 一股腦灌):只 embed「被標記為 embeddable」的 entry
|
||||
// (wiki 段落 + graph node gloss)。標記方式=寫入時 metadata_json.embed === true(caller 顯式標)。
|
||||
//
|
||||
// 為何用 metadata flag 而非 entry_type 白名單:base 不該寫死「哪些 entry_type 該 embed」(那是上游語意,
|
||||
// 會讓 base 知道 wiki/graph 概念,破壞解耦)。改由 caller(wiki/gloss 寫入端)顯式標 embed:true,
|
||||
// base 只認這個通用旗標 → base 維持對內容語意無知。
|
||||
|
||||
import type { Bindings, Entry } from './types';
|
||||
|
||||
const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5'; // 768-dim,與 Vectorize index dimensions=768 對齊
|
||||
|
||||
/** embed 模組是否啟用(binding 都在才算開)。base 一切 embed 動作先過這關。 */
|
||||
export function embedEnabled(env: Bindings): boolean {
|
||||
return !!(env.VECTORIZE && env.AI);
|
||||
}
|
||||
|
||||
/** 一段文字 → 768 維向量(Workers AI bge)。空字串回 null(不 embed)。 */
|
||||
async function embedText(env: Bindings, text: string): Promise<number[] | null> {
|
||||
const t = (text ?? '').trim();
|
||||
if (!t || !env.AI) return null;
|
||||
const res = (await env.AI.run(EMBED_MODEL, { text: [t] })) as { data: number[][] };
|
||||
return res?.data?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入時選擇性 embed(embed-on-write,#5 第4點併入此)。
|
||||
* - 模組未開 → no-op(base 輕量)。
|
||||
* - 只 embed 被標 embeddable 的 entry(metadata_json.embed === true)。其餘略過(非地毯式)。
|
||||
* 失敗不致命(fire-and-forget 由 caller 用 waitUntil 包;這裡只負責「能 embed 就 embed」)。
|
||||
* 回傳是否真的 embed 了(讓 caller 決定要不要標 is_embedded)。
|
||||
*/
|
||||
export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean> {
|
||||
if (!embedEnabled(env)) return false;
|
||||
if (!isEmbeddable(entry)) return false;
|
||||
const vec = await embedText(env, entry.content ?? '');
|
||||
if (!vec) return false;
|
||||
await env.VECTORIZE!.upsert([
|
||||
{
|
||||
id: entry.id,
|
||||
values: vec,
|
||||
// metadata 走 indexed 範圍:owner_id(租戶隔離)、entry_type、source(#5.1 過濾與語義共用)。
|
||||
metadata: {
|
||||
owner_id: entry.owner_id ?? '',
|
||||
entry_type: entry.entry_type,
|
||||
source: readSource(entry) ?? '',
|
||||
},
|
||||
},
|
||||
]);
|
||||
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
|
||||
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** entry 是否該被 embed:caller 在 metadata_json 標 embed:true(精耕,非地毯式)。 */
|
||||
function isEmbeddable(entry: Entry): boolean {
|
||||
const meta = parseMeta(entry.metadata_json);
|
||||
return meta?.embed === true;
|
||||
}
|
||||
|
||||
function readSource(entry: Entry): string | null {
|
||||
const meta = parseMeta(entry.metadata_json);
|
||||
const s = meta?.source;
|
||||
return typeof s === 'string' ? s : null;
|
||||
}
|
||||
|
||||
function parseMeta(json: string | null): Record<string, unknown> | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const p = JSON.parse(json);
|
||||
return p && typeof p === 'object' ? (p as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SemanticHit {
|
||||
id: string;
|
||||
score: number;
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 語義搜尋(mode:'semantic')。模組未開 → 回 null(caller 降級 keyword + 告知缺能力)。
|
||||
* owner_id / source / entry_type 過濾走 Vectorize metadata filter(entry_type 已 index,見上 upsert metadata)。
|
||||
* entry_type 是 base 通用 filter(caller 傳任意 type,base 不寫死語意)。
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
env: Bindings,
|
||||
q: string,
|
||||
opts: { owner_id?: string; source?: string; entry_type?: string; topK?: number } = {},
|
||||
): Promise<SemanticHit[] | null> {
|
||||
if (!embedEnabled(env)) return null;
|
||||
const vec = await embedText(env, q);
|
||||
if (!vec) return [];
|
||||
const filter: Record<string, string> = {};
|
||||
if (opts.owner_id) filter.owner_id = opts.owner_id;
|
||||
if (opts.source) filter.source = opts.source;
|
||||
if (opts.entry_type) filter.entry_type = opts.entry_type;
|
||||
const res = await env.VECTORIZE!.query(vec, {
|
||||
topK: Math.min(opts.topK ?? 20, 100),
|
||||
returnMetadata: 'indexed',
|
||||
...(Object.keys(filter).length ? { filter } : {}),
|
||||
});
|
||||
return (res.matches ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
score: m.score,
|
||||
owner_id: m.metadata?.owner_id as string | undefined,
|
||||
entry_type: m.metadata?.entry_type as string | undefined,
|
||||
source: m.metadata?.source as string | undefined,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// KBDB Base — atomic universal table worker (arcrun self-hosted data layer + official core).
|
||||
// SDD: .agents/specs/arcrun/kbdb-base/design.md
|
||||
//
|
||||
// Base = D1 only (free, no credit card): entries / templates / records + LIKE search + recipe-stats.
|
||||
// Optional modules (NOT in this base): embed (Vectorize+AI binding, semantic search), triplet (separate repo).
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from './types';
|
||||
import { entryRoutes } from './routes/entries';
|
||||
import { templateRoutes } from './routes/templates';
|
||||
import { recordRoutes } from './routes/records';
|
||||
import { recipeStatRoutes } from './routes/recipe-stats';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
|
||||
app.get('/health', (c) => c.json({ ok: true }));
|
||||
|
||||
app.route('/entries', entryRoutes);
|
||||
app.route('/templates', templateRoutes);
|
||||
app.route('/records', recordRoutes);
|
||||
app.route('/recipe-stats', recipeStatRoutes);
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,108 @@
|
||||
// Entries route — atomic data + tree (project/workflow). Base; embed is OPTIONAL (issue #7).
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import {
|
||||
createEntry,
|
||||
getEntry,
|
||||
listEntries,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
searchEntries,
|
||||
} from '../actions/entry-crud';
|
||||
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
|
||||
|
||||
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /entries — create (entry_type=block/value/project/workflow/...)
|
||||
entryRoutes.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
|
||||
const entry = await createEntry(c.env.DB, body);
|
||||
// embed-on-write (#7 / #5 第4點):模組開 + entry 標 embed:true 才做;fire-and-forget,不阻塞回應、失敗不致命。
|
||||
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source)
|
||||
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
|
||||
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
|
||||
// e.g. filter by ingest source: ?source=logseq://vault/foo.md (issue #5.1)
|
||||
entryRoutes.get('/', async (c) => {
|
||||
const entries = await listEntries(c.env.DB, {
|
||||
entry_type: c.req.query('entry_type') || undefined,
|
||||
owner_id: c.req.query('owner_id') || undefined,
|
||||
parent_id: c.req.query('parent_id') || undefined,
|
||||
page_name: c.req.query('page_name') || undefined,
|
||||
source: c.req.query('source') || undefined,
|
||||
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
|
||||
offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
|
||||
});
|
||||
return c.json({ success: true, entries, count: entries.length });
|
||||
});
|
||||
|
||||
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&mode=keyword|semantic
|
||||
// - mode=keyword(預設):D1 LIKE(base,永遠可用)。
|
||||
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
|
||||
// - entry_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。
|
||||
entryRoutes.get('/search', async (c) => {
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ success: false, error: 'q required' }, 400);
|
||||
const owner_id = c.req.query('owner_id') || undefined;
|
||||
const source = c.req.query('source') || undefined;
|
||||
const entry_type = c.req.query('entry_type') || undefined;
|
||||
const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword';
|
||||
|
||||
if (mode === 'semantic') {
|
||||
const hits = await semanticSearch(c.env, q, { owner_id, source, entry_type });
|
||||
if (hits === null) {
|
||||
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type);
|
||||
return c.json({
|
||||
success: true,
|
||||
entries,
|
||||
count: entries.length,
|
||||
mode: 'keyword',
|
||||
requested_mode: 'semantic',
|
||||
capability_hint:
|
||||
'語義查詢需先開 vectorize(embed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。',
|
||||
});
|
||||
}
|
||||
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
|
||||
const entries = (await Promise.all(hits.map((h) => getEntry(c.env.DB, h.id)))).filter(
|
||||
(e): e is NonNullable<typeof e> => e !== null,
|
||||
);
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
|
||||
}
|
||||
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type);
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
|
||||
});
|
||||
|
||||
// GET /entries/:id
|
||||
entryRoutes.get('/:id', async (c) => {
|
||||
const entry = await getEntry(c.env.DB, c.req.param('id'));
|
||||
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const entry = await updateEntry(c.env.DB, c.req.param('id'), body);
|
||||
if (!entry) return c.json({ success: false, error: 'not found' }, 404);
|
||||
// 內容改了 → 重 embed(保持向量新鮮)。embedOnWrite 內部自會檢查模組開 + entry 是否 embeddable。
|
||||
if (embedEnabled(c.env) && body.content !== undefined) {
|
||||
c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||||
}
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// DELETE /entries/:id
|
||||
entryRoutes.delete('/:id', async (c) => {
|
||||
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。
|
||||
if (embedEnabled(c.env)) {
|
||||
c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {}));
|
||||
}
|
||||
await deleteEntry(c.env.DB, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Recipe stats route (SDD section 7.1) — success/failure counters per recipe.
|
||||
// cypher-executor calls POST /recipe-stats/record after each recipe HTTP call;
|
||||
// submission reads GET /recipe-stats/:canonical_id as the "proof" for no-verify submit.
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { recordRecipeResult, getRecipeStat } from '../actions/recipe-stat';
|
||||
|
||||
export const recipeStatRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /recipe-stats/record — { canonical_id, ok, at } (at = epoch ms, passed in by caller)
|
||||
recipeStatRoutes.post('/record', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.canonical_id || typeof body.ok !== 'boolean') {
|
||||
return c.json({ success: false, error: 'canonical_id and ok(boolean) required' }, 400);
|
||||
}
|
||||
const at = typeof body.at === 'number' ? body.at : 0;
|
||||
const stat = await recordRecipeResult(c.env.DB, body.canonical_id, body.ok, at);
|
||||
return c.json({ success: true, stat });
|
||||
});
|
||||
|
||||
// GET /recipe-stats/:canonical_id
|
||||
recipeStatRoutes.get('/:canonical_id', async (c) => {
|
||||
const stat = await getRecipeStat(c.env.DB, c.req.param('canonical_id'));
|
||||
return c.json({ success: true, stat });
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Records route — structured records (entry_values composed by a template).
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
|
||||
|
||||
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /records — { template, values:{slot:content}, owner_id? }
|
||||
recordRoutes.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ success: false, error: 'template and values required' }, 400);
|
||||
}
|
||||
try {
|
||||
const rec = await createRecord(c.env.DB, body);
|
||||
return c.json({ success: true, record: rec });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /records/by-template/:template — list records of a template
|
||||
recordRoutes.get('/by-template/:template', async (c) => {
|
||||
const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined);
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
});
|
||||
|
||||
// GET /records/:recordId
|
||||
recordRoutes.get('/:recordId', async (c) => {
|
||||
const rec = await getRecord(c.env.DB, c.req.param('recordId'));
|
||||
if (!rec) return c.json({ success: false, error: 'not found' }, 404);
|
||||
return c.json({ success: true, record: rec });
|
||||
});
|
||||
|
||||
// PATCH /records/:recordId — { values:{slot:content} } update existing record slot values
|
||||
// (mira-dissolve T2.1 / issue #6; deprecate = flip a slot value, append-only tables untouched).
|
||||
recordRoutes.patch('/:recordId', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.values || typeof body.values !== 'object') {
|
||||
return c.json({ success: false, error: 'values required' }, 400);
|
||||
}
|
||||
try {
|
||||
const rec = await updateRecord(c.env.DB, c.req.param('recordId'), body.values);
|
||||
if (!rec) return c.json({ success: false, error: 'not found' }, 404);
|
||||
return c.json({ success: true, record: rec });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Templates + Records route. Template = virtual table def; record = composed entries.
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { createTemplate, getTemplate, listTemplates, updateTemplate } from '../actions/record-crud';
|
||||
|
||||
export const templateRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
templateRoutes.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.name || !Array.isArray(body.slots)) {
|
||||
return c.json({ success: false, error: 'name and slots[] required' }, 400);
|
||||
}
|
||||
const tpl = await createTemplate(c.env.DB, body);
|
||||
return c.json({ success: true, template: tpl });
|
||||
});
|
||||
|
||||
templateRoutes.get('/', async (c) => {
|
||||
const templates = await listTemplates(c.env.DB);
|
||||
return c.json({ success: true, templates, count: templates.length });
|
||||
});
|
||||
|
||||
templateRoutes.get('/:idOrName', async (c) => {
|
||||
const tpl = await getTemplate(c.env.DB, c.req.param('idOrName'));
|
||||
if (!tpl) return c.json({ success: false, error: 'not found' }, 404);
|
||||
return c.json({ success: true, template: tpl });
|
||||
});
|
||||
|
||||
templateRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const tpl = await updateTemplate(c.env.DB, c.req.param('id'), body);
|
||||
if (!tpl) return c.json({ success: false, error: 'not found' }, 404);
|
||||
return c.json({ success: true, template: tpl });
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// KBDB Base types. Base depends on D1 only.
|
||||
// Optional modules add their own bindings (embed: VECTORIZE+AI). Base never references them.
|
||||
|
||||
export type Bindings = {
|
||||
DB: D1Database;
|
||||
ENVIRONMENT: string;
|
||||
// Optional embed module (issue #7 / SDD T2.4). Present ONLY when the self-host opened
|
||||
// semantic search (kbdb_embed:true → deploy injects [[vectorize]] + [ai]). Base never
|
||||
// requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed.
|
||||
VECTORIZE?: VectorizeIndex;
|
||||
AI?: Ai;
|
||||
};
|
||||
|
||||
export type EntryType =
|
||||
| 'block'
|
||||
| 'value'
|
||||
| 'template'
|
||||
| 'slot'
|
||||
| 'project'
|
||||
| 'workflow'
|
||||
| 'recipe_stat';
|
||||
|
||||
export interface Entry {
|
||||
id: string;
|
||||
content: string | null;
|
||||
entry_type: EntryType | string;
|
||||
owner_id: string | null;
|
||||
parent_id: string | null;
|
||||
page_name: string | null;
|
||||
refs_json: string;
|
||||
tags_json: string;
|
||||
task_status: string | null;
|
||||
content_hash: string | null;
|
||||
is_embedded: number;
|
||||
confidence: number | null;
|
||||
metadata_json: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
slots_json: string;
|
||||
created_by: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface EntryValue {
|
||||
id: string;
|
||||
record_id: string;
|
||||
template_id: string;
|
||||
slot_name: string;
|
||||
entry_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["@cloudflare/workers-types/2023-07-01"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
name = "arcrun-kbdb"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
workers_dev = true
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# KBDB Base — atomic universal table (SDD .agents/specs/arcrun/kbdb-base).
|
||||
# Base needs D1 ONLY (free, no credit card). embed module adds Vectorize+AI bindings
|
||||
# (optional, self-host opens it themselves). triplet is a separate repo.
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "arcrun-kbdb"
|
||||
database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1(arcrun-kbdb);self-hosted deploy.ts 會注入用戶自己的 id 覆蓋
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "production"
|
||||
|
||||
# ── Optional embed module (issue #7 / SDD T2.4) ────────────────────────────────
|
||||
# Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true
|
||||
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed
|
||||
# --dimensions=768 --metric=cosine`(bge-base-en-v1.5 = 768 維)。官方帳號同理由 deploy 注入。
|
||||
# 沒有這兩個 binding 時,kbdb/src/embed.ts 的 embedEnabled() 回 false → 維持 LIKE keyword、API 不變。
|
||||
#
|
||||
# [[vectorize]]
|
||||
# binding = "VECTORIZE"
|
||||
# index_name = "arcrun-kbdb-embed"
|
||||
#
|
||||
# [ai]
|
||||
# binding = "AI"
|
||||
@@ -30,14 +30,23 @@ npm i -g arcrun && acr install-harness
|
||||
|
||||
1. `npm i -g arcrun && acr install-harness`(裝 CLI + 防護)
|
||||
2. `npm i -g wrangler`(Cloudflare CLI,部署用)
|
||||
3. **帶使用者拿 Cloudflare 的兩串憑證**(唯一只能他做的事)——
|
||||
用白話照抄式引導,**不要對使用者講 KV / Worker / R2 / zone 等術語**(他多半聽不懂,也不需要懂):
|
||||
- 帳號代碼(Account ID):登入 https://dash.cloudflare.com 右側欄複製。
|
||||
- 金鑰(API Token):https://dash.cloudflare.com/profile/api-tokens → Create Custom Token →
|
||||
照抄勾**兩組**權限(Account/Workers Scripts/Edit、Account/Workers KV Storage/Edit)
|
||||
→ 複製產生的 token。(不需要 R2、不需要綁信用卡——只用 Workers + KV 免費額度。)
|
||||
- 叫使用者把這兩串貼給你。
|
||||
4. `acr init --self-hosted`(你幫跑,貼使用者的兩串)—— 自動建資源、部署、seed、寫 .mcp.json(MCP 連線)。
|
||||
3. **建 .env 並帶使用者填憑證**(憑證是唯一只有他能拿的東西,但「建檔」由你做):
|
||||
- **你做**:`cp .env.example .env`(repo 根有 `.env.example` 範本,左邊 KEY 名都寫好了、
|
||||
每格上面有白話說明)。使用者只需要找到值、填到「=」右邊。
|
||||
- **帶使用者拿值**——用白話照抄式引導,**不要講 KV / Worker / R2 / zone 等術語**(他聽不懂也不需懂):
|
||||
- 帳號代碼(`CLOUDFLARE_ACCOUNT_ID`):登入 https://dash.cloudflare.com 右側欄複製。
|
||||
- 金鑰(`CLOUDFLARE_API_TOKEN`):https://dash.cloudflare.com/profile/api-tokens → Create Custom Token →
|
||||
照抄勾**三組**權限:
|
||||
· Account / Workers Scripts / Edit
|
||||
· Account / Workers KV Storage / Edit
|
||||
· Account / D1 / Edit ← **必勾**,arcrun 用 D1 存 workflow/recipe;漏勾會 init 時 D1 建失敗(Authentication error)
|
||||
→ 複製產生的 token。(不需要 R2、不需要綁信用卡——D1 也在免費額度,不綁卡。)
|
||||
- `NAMESPACE`:隨便取個英數小名(非密碼)。`ENCRYPTION_KEY`:你可幫他產
|
||||
(`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`)。
|
||||
- 使用者把值貼進 .env(或貼給你、你幫他填進對應格)。**CLOUDFLARE 兩格沒填,後面什麼都跑不了。**
|
||||
- 連外部服務(如 Notion)的 token 也填進 .env 的 ③ 區,之後 `acr creds push` 加密上傳。
|
||||
4. `acr init --self-hosted`(你幫跑,讀 .env 的 CF 憑證)—— 自動建資源、部署、seed、寫 .mcp.json(MCP 連線)。
|
||||
跑完會印「安裝驗收」逐項 ✓/✗;有 ✗ 照它給的指令補(多數 `acr update` 冪等重試)。
|
||||
5. 跑完照提示 `wrangler secret put ENCRYPTION_KEY`(CLI 會印確切指令)。
|
||||
6. 把使用者需求拆成 workflow → `acr push`。完成給客觀證據(HTTP 2xx / trace)。
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
+1
-1
@@ -233,7 +233,7 @@ app.options("/mcp", (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/mcp", partnerAuthMiddleware, async (c) => {
|
||||
app.post("/", partnerAuthMiddleware, async (c) => {
|
||||
const orgNamespace = c.get("org_namespace");
|
||||
const partnerToken = c.get("partner_token");
|
||||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
|
||||
|
||||
@@ -11,6 +11,19 @@ export async function partnerAuthMiddleware(
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
// Self-hosted 單租戶(MULTI_TENANT=false):Bearer 帶的是 namespace 明碼,不是平台 partner key。
|
||||
// 與 cypher-executor 一致——cypher 把 X-Arcrun-API-Key 當「不驗證的 opaque 分區 key」(namespace
|
||||
// 是明碼分區標籤非密碼,mindset §3 arcrun 不做授權判斷)。故 self-hosted 模式不打 KBDB partner
|
||||
// 驗證,直接把 token 當 org_namespace。SDD: mcp-account-source.md;HANDOFF §3b。
|
||||
if (c.env.MULTI_TENANT === 'false') {
|
||||
c.set('org_namespace', token);
|
||||
c.set('partner_token', token); // 下游轉發給 cypher 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 官方 SaaS(MULTI_TENANT 未設 / "true"):維持 partner-key 驗證(行為不變)。
|
||||
const resp = await c.env.KBDB.fetch(
|
||||
`http://kbdb/partners/${encodeURIComponent(token)}/info`,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Recipe tools(kbdb-base §7.5.i)— MCP 薄殼補齊 recipe 能力。
|
||||
*
|
||||
* rule 07 §5:CLI + MCP 覆蓋同一組 API 能力,MCP 不可長期落後。
|
||||
* CLI 已有 recipe push/list/delete/search/pull/submit-p(cli/src/commands/recipe.ts);
|
||||
* 此檔把同六能力暴露為 MCP 工具,**薄殼**:只 cypherFetch + 格式化,無業務邏輯。
|
||||
*
|
||||
* 私庫操作(push/list/delete/pull-install)→ cypherFetch 打用戶 cypher(= 私庫)。
|
||||
* 公庫操作(search/pull-fetch/submit-p)→ 同樣經 cypher(MCP 連平台 cypher = 公庫;
|
||||
* self-hosted account-source 是 §5.2 已知違反,pre-existing,本檔沿用既有 cypherFetch 模式)。
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
|
||||
const apiKeyDesc = "你(用戶)的 arcrun api_key (ak_xxx)。從 https://arcrun.dev/me 取得";
|
||||
|
||||
/** 註冊全部 recipe 工具(kbdb-base §7.5.i,與 CLI 六能力對齊)。 */
|
||||
export function registerAllRecipeTools(server: McpServer, env: Env) {
|
||||
registerRecipeSearch(server, env);
|
||||
registerRecipePull(server, env);
|
||||
registerRecipeSubmitP(server, env);
|
||||
registerRecipePush(server, env);
|
||||
registerRecipeList(server, env);
|
||||
registerRecipeDelete(server, env);
|
||||
}
|
||||
|
||||
/** arcrun_recipe_search — 搜尋公庫 recipe(同名可多作者,附市場數據)。落空回創作引導。 */
|
||||
export function registerRecipeSearch(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"arcrun_recipe_search",
|
||||
"搜尋公庫 recipe(API 整合配方)。同 canonical_id 可有多作者版本,各附市場數據(成功/失敗次數),依數據選最佳。找不到時會提示可自己做一個 recipe 投稿成為作者。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
query: z.string().describe("搜尋詞,如「gsheets append」「telegram send」"),
|
||||
},
|
||||
async ({ api_key, query }) => {
|
||||
try {
|
||||
const res = await cypherFetch(env, "/public-recipes", {
|
||||
apiKey: api_key,
|
||||
query: { q: query },
|
||||
});
|
||||
if (!res.ok) return errorResponse("search_failed", `搜尋公庫失敗`, ["稍後再試"], await res.text());
|
||||
const data = await res.json();
|
||||
return successResponse(data, [
|
||||
"found:false → 公庫沒有,可自己做:建 recipe → arcrun_recipe_push(私庫)→ arcrun_recipe_submit_p(投稿)",
|
||||
"多作者版本依 market_stat 選成功率最高的 → arcrun_recipe_pull",
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** arcrun_recipe_pull — 從公庫取一份 recipe 寫進自己私庫。 */
|
||||
export function registerRecipePull(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"arcrun_recipe_pull",
|
||||
"從公庫取一份 recipe 寫進自己私庫(按需取用,非全量同步)。不指定 author 取市場最佳版本。取回後可在 workflow 用 component: <canonical_id>。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
canonical_id: z.string().describe("要取的 recipe canonical_id,如 gsheets_append"),
|
||||
author: z.string().optional().describe("指定作者版本(不指定取市場最佳)"),
|
||||
},
|
||||
async ({ api_key, canonical_id, author }) => {
|
||||
try {
|
||||
// 1. 公庫取全文
|
||||
const pubRes = await cypherFetch(env, `/public-recipes/${encodeURIComponent(canonical_id)}`, {
|
||||
apiKey: api_key,
|
||||
query: author ? { author } : undefined,
|
||||
});
|
||||
if (!pubRes.ok) return errorResponse("pull_failed", `公庫取 recipe 失敗`, [], await pubRes.text());
|
||||
const pub = await pubRes.json() as
|
||||
| { found: true; recipe: Record<string, unknown> & { uuid?: string } }
|
||||
| { found: false; canonical_id: string; hint: string };
|
||||
if (!pub.found) {
|
||||
return successResponse(pub, [
|
||||
"公庫沒有此 recipe。可自己做:arcrun_recipe_push(私庫)→ arcrun_recipe_submit_p(投稿成為作者)",
|
||||
]);
|
||||
}
|
||||
// 2. 寫進私庫(帶 derived_from 溯源 + pull 級暴露同意)
|
||||
const installRes = await cypherFetch(env, "/recipes", {
|
||||
apiKey: api_key,
|
||||
method: "POST",
|
||||
body: {
|
||||
...pub.recipe,
|
||||
derived_from: pub.recipe.uuid,
|
||||
exposure_consent: {
|
||||
confirmed_by_human: true,
|
||||
understood: `pull from public library: ${canonical_id}`,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!installRes.ok) return errorResponse("install_failed", `寫入私庫失敗`, [], await installRes.text());
|
||||
const inst = await installRes.json();
|
||||
return successResponse(inst, [`已拉進私庫,workflow 可用 component: ${canonical_id}`]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** arcrun_recipe_submit_p — 把私庫某 recipe 投稿到公庫(新增作者版本)。 */
|
||||
export function registerRecipeSubmitP(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"arcrun_recipe_submit_p",
|
||||
"把私庫某 recipe 投稿到公庫(app-store 模型:新增一個作者版本,不覆蓋別人的)。投稿 = 把 recipe 暴露給全網,需帶 exposure_consent 明示同意。別人能搜到並 pull,市場數據決定它被不被選用。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
canonical_id: z.string().describe("要投稿的私庫 recipe canonical_id"),
|
||||
author: z.string().optional().describe("署名作者(預設用 recipe 既有 author)"),
|
||||
exposure_consent: z.boolean().describe(
|
||||
"明示同意把此 recipe 暴露給公庫全網(投稿是暴露面,需人類同意)",
|
||||
),
|
||||
},
|
||||
async ({ api_key, canonical_id, author, exposure_consent }) => {
|
||||
try {
|
||||
if (!exposure_consent) {
|
||||
return errorResponse("consent_required", "投稿到公庫是暴露面,需 exposure_consent=true 明示同意", [
|
||||
"確認要把 recipe 公開給全網後,帶 exposure_consent: true 再呼叫",
|
||||
]);
|
||||
}
|
||||
// 1. 私庫取全文
|
||||
const myRes = await cypherFetch(env, `/recipes/${encodeURIComponent(canonical_id)}`, { apiKey: api_key });
|
||||
if (!myRes.ok) return errorResponse("not_found", `私庫找不到 recipe「${canonical_id}」`, ["先 arcrun_recipe_push 或 arcrun_recipe_pull"], await myRes.text());
|
||||
const my = await myRes.json() as { success: boolean; recipe?: Record<string, unknown> & { uuid?: string; author?: string; derived_from?: string } };
|
||||
if (!my.success || !my.recipe) return errorResponse("not_found", `私庫無此 recipe`, []);
|
||||
// 2. 投稿公庫(新增作者版本)
|
||||
const consent = {
|
||||
confirmed_by_human: true,
|
||||
understood: `submit recipe to public library: ${canonical_id}`,
|
||||
confirmed_at: new Date().toISOString(),
|
||||
};
|
||||
const subRes = await cypherFetch(env, "/recipes/submit", {
|
||||
apiKey: api_key,
|
||||
method: "POST",
|
||||
body: {
|
||||
...my.recipe,
|
||||
author: author ?? my.recipe.author,
|
||||
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
|
||||
submitter: author ?? api_key,
|
||||
exposure_consent: consent,
|
||||
},
|
||||
});
|
||||
if (!subRes.ok) return errorResponse("submit_failed", `投稿公庫失敗`, [], await subRes.text());
|
||||
const data = await subRes.json();
|
||||
return successResponse(data, ["已投稿公庫(新增作者版本)。市場數據累積後決定被不被選用"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** arcrun_recipe_push — 上傳/更新私庫 recipe(就地更新自己的版本)。 */
|
||||
export function registerRecipePush(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"arcrun_recipe_push",
|
||||
"上傳一份 recipe 到自己私庫(或就地更新自己既有版本)。recipe = 「http_request + 參數模板」的具名封裝,不需 deploy Worker。要投稿到公庫用 arcrun_recipe_submit_p。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
recipe: z.object({
|
||||
canonical_id: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
endpoint: z.string(),
|
||||
method: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
body: z.record(z.unknown()).optional(),
|
||||
auth_service: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
}).describe("recipe 定義(canonical_id + endpoint 必填)"),
|
||||
},
|
||||
async ({ api_key, recipe }) => {
|
||||
try {
|
||||
const res = await cypherFetch(env, "/recipes", { apiKey: api_key, method: "POST", body: recipe });
|
||||
if (!res.ok) return errorResponse("push_failed", `上傳 recipe 失敗`, [], await res.text());
|
||||
const data = await res.json();
|
||||
return successResponse(data, [`workflow 用 component: ${recipe.canonical_id}`, "要公開給全網 → arcrun_recipe_submit_p"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** arcrun_recipe_list — 列出自己私庫的 recipe。 */
|
||||
export function registerRecipeList(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"arcrun_recipe_list",
|
||||
"列出自己私庫(本部署)的 recipe。要找公庫的用 arcrun_recipe_search。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
},
|
||||
async ({ api_key }) => {
|
||||
try {
|
||||
const res = await cypherFetch(env, "/recipes", { apiKey: api_key });
|
||||
if (!res.ok) return errorResponse("list_failed", `列出 recipe 失敗`, [], await res.text());
|
||||
const data = await res.json();
|
||||
return successResponse(data);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** arcrun_recipe_delete — 刪除私庫某 recipe。 */
|
||||
export function registerRecipeDelete(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"arcrun_recipe_delete",
|
||||
"刪除自己私庫某 recipe(canonical_id / rec_hash / uuid)。不影響公庫別人的版本。",
|
||||
{
|
||||
api_key: z.string().describe(apiKeyDesc),
|
||||
id: z.string().describe("canonical_id 或 rec_hash 或 uuid"),
|
||||
},
|
||||
async ({ api_key, id }) => {
|
||||
try {
|
||||
const res = await cypherFetch(env, `/recipes/${encodeURIComponent(id)}`, { apiKey: api_key, method: "DELETE" });
|
||||
if (!res.ok) return errorResponse("delete_failed", `刪除 recipe 失敗`, [], await res.text());
|
||||
const data = await res.json();
|
||||
return successResponse(data);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), []);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -51,11 +51,12 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
||||
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
|
||||
}
|
||||
|
||||
const blockBody = {
|
||||
api_key: env.PLATFORM_API_KEY || undefined, // 若 platform key 在,聚集;否則用用戶 namespace
|
||||
type: "agent-feedback",
|
||||
source: "mcp-tool-call",
|
||||
user_id: orgNamespace,
|
||||
// kbdb-base 9.7:寫進基本盤 entries(entry_type=agent-feedback)。
|
||||
// 舊版打 v3 死 route /blocks(基本盤只 mount entries/templates/records)→ 404 假紅,已改。
|
||||
// owner_id = 用戶 namespace(self-hosted 單租戶聚集)。基本盤無 source/api_key 欄 → 併入 metadata。
|
||||
const entryBody = {
|
||||
entry_type: "agent-feedback",
|
||||
owner_id: orgNamespace,
|
||||
content: description,
|
||||
metadata_json: JSON.stringify({
|
||||
issue_type,
|
||||
@@ -64,6 +65,7 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
||||
blocked: blocked ?? false,
|
||||
suggested_fix,
|
||||
agent_user_agent,
|
||||
source: "mcp-tool-call",
|
||||
reported_at: new Date().toISOString(),
|
||||
}),
|
||||
tags_json: JSON.stringify([
|
||||
@@ -74,11 +76,11 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
||||
]),
|
||||
};
|
||||
|
||||
// 走 KBDB service binding(既有 pattern)
|
||||
const createResp = await kbdbFetch(env, `/blocks`, {
|
||||
// 走 KBDB service binding 打基本盤 /entries(薄殼模式不變)
|
||||
const createResp = await kbdbFetch(env, `/entries`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(blockBody),
|
||||
body: JSON.stringify(entryBody),
|
||||
});
|
||||
|
||||
if (!createResp.ok) {
|
||||
@@ -92,7 +94,7 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
||||
error_code: "kbdb_write_failed",
|
||||
human_message: `回饋寫入 KBDB 失敗:HTTP ${createResp.status}`,
|
||||
next_actions: [
|
||||
"確認 KBDB 服務在線(試 https://kbdb-get.arcrun.dev/health)",
|
||||
"確認 KBDB 服務在線(KBDB worker /health)",
|
||||
"若持續失敗,可暫先在本地記下回饋,稍後重試",
|
||||
],
|
||||
detail: errBody.slice(0, 200),
|
||||
@@ -114,7 +116,9 @@ export function registerReportFeedback(server: McpServer, env: Env, orgNamespace
|
||||
data: {
|
||||
reported: true,
|
||||
issue_type,
|
||||
block_id: (data as { id?: string } | null)?.id,
|
||||
// 基本盤 /entries 回 { success, entry };舊 /blocks 回 { id } → 兩種都容忍
|
||||
entry_id: (data as { entry?: { id?: string }; id?: string } | null)?.entry?.id
|
||||
?? (data as { id?: string } | null)?.id,
|
||||
},
|
||||
hints: [
|
||||
issue_type === "success_story"
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* Skills + Examples lookup MCP tools — LI SDD M3.2
|
||||
* Skills + Examples lookup MCP tools — LI SDD M3.2 / M3.4
|
||||
*
|
||||
* 對應 .agents/specs/llm-interface/ Milestone 3.2 + 3.4。
|
||||
* 對應 docs/3-specs/llm-interface/ Milestone 3.2 + 3.4。
|
||||
*
|
||||
* - arcrun_list_skills — 列 KBDB type=agent-skill 全部
|
||||
* - arcrun_list_skills — 列 KBDB entry_type=agent-skill 全部
|
||||
* - arcrun_get_skill — 用 slug 拿 skill markdown 全文
|
||||
* - arcrun_list_examples — 列 KBDB type=workflow-example 全部
|
||||
* - arcrun_list_examples — 列 KBDB entry_type=workflow-example 全部
|
||||
* - arcrun_get_example — 用 slug 拿 example yaml + description + tags
|
||||
* - arcrun_search_examples — 自然語言 use case → 命中相關 example
|
||||
* - arcrun_search_examples — use case 關鍵字 → 命中相關 example
|
||||
*
|
||||
* Skills / examples 由 arcrun/scripts/sync-registry-to-kbdb.py 從
|
||||
* arcrun/registry/{skills,examples} 同步進 KBDB。
|
||||
*
|
||||
* 直接走 KBDB service binding(既有 pattern),不經 cypher-executor。
|
||||
*
|
||||
* 2026-06-14 重寫:KBDB 降基本盤後(三表 entries/templates/records,無 v3 blocks 表、
|
||||
* 無語義 search),原打 /blocks /search 的舊路徑全失效。改打基本盤 /entries:
|
||||
* - entry_type 取代 blocks 的 type 欄(entries 表原生有 entry_type/page_name/tags_json/metadata_json)
|
||||
* - GET /blocks?type=X → GET /entries?entry_type=X
|
||||
* - GET /blocks?page_name=Y → GET /entries?page_name=Y(base listEntries 加了 page_name 過濾)
|
||||
* - POST /search(語義) → GET /entries/search?q=(D1 LIKE 關鍵字,基本盤無語義;
|
||||
* 誠實降級:search_examples 現在是「關鍵字」非「語義」。embed 模組(kbdb-base Phase 1)
|
||||
* 上線後只換內部、工具簽名不變。
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
@@ -21,29 +30,30 @@ import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
|
||||
// 基本盤 entries row(與舊 v3 block 欄位 1:1,差別只在 type→entry_type)
|
||||
interface KbdbBlock {
|
||||
id: string;
|
||||
page_name?: string | null;
|
||||
content?: string | null;
|
||||
type?: string;
|
||||
entry_type?: string;
|
||||
tags_json?: string;
|
||||
metadata_json?: string | null;
|
||||
source?: string | null;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
async function kbdbList(env: Env, type: string, limit = 100): Promise<KbdbBlock[]> {
|
||||
const resp = await kbdbFetch(env, `/blocks?type=${type}&limit=${limit}`);
|
||||
if (!resp.ok) throw new Error(`KBDB list type=${type} HTTP ${resp.status}`);
|
||||
const data = await resp.json<{ blocks?: KbdbBlock[] }>();
|
||||
return data.blocks ?? [];
|
||||
async function kbdbList(env: Env, entryType: string, limit = 100): Promise<KbdbBlock[]> {
|
||||
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
|
||||
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
|
||||
const data = await resp.json<{ entries?: KbdbBlock[] }>();
|
||||
return data.entries ?? [];
|
||||
}
|
||||
|
||||
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
|
||||
const resp = await kbdbFetch(env, `/blocks?page_name=${encodeURIComponent(pageName)}&limit=1`);
|
||||
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json<{ blocks?: KbdbBlock[] }>();
|
||||
return data.blocks?.[0] ?? null;
|
||||
const data = await resp.json<{ entries?: KbdbBlock[] }>();
|
||||
return data.entries?.[0] ?? null;
|
||||
}
|
||||
|
||||
function parseTags(tagsJson?: string): string[] {
|
||||
@@ -234,54 +244,55 @@ export function registerGetExample(server: McpServer, env: Env) {
|
||||
export function registerSearchExamples(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"arcrun_search_examples",
|
||||
"用自然語言 use case 搜 workflow examples。回最相關 N 個。內部走 KBDB semantic search(embedding 比對)+ tag 過濾。",
|
||||
"用 use case 關鍵字搜 workflow examples,回最相關 N 個。" +
|
||||
"注意:基本盤目前是 D1 LIKE 關鍵字搜尋(非語義 embedding;語義是 kbdb-base Phase 1 的 embed 模組,尚未上)。" +
|
||||
"→ 用具體詞('email'、'cron'、'rag')比整句自然語言命中率高。也會比對 slug/tag。",
|
||||
{
|
||||
query: z.string().min(3).describe("用 use case 描述,例如 '每天早上發 email 摘要' / 'RAG 從文件回答問題'"),
|
||||
query: z.string().min(2).describe("use case 關鍵字,例如 'email 摘要' / 'cron 排程' / 'rag'。基本盤是關鍵字非語義,用詞要具體"),
|
||||
top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5)"),
|
||||
},
|
||||
async ({ query, top_k }) => {
|
||||
try {
|
||||
const k = top_k ?? 5;
|
||||
// KBDB /search 是 unified semantic search(既有),filter type=workflow-example
|
||||
const resp = await kbdbFetch(env, `/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
topK: k * 3, // overfetch 後 filter type
|
||||
}),
|
||||
});
|
||||
const q = query.trim();
|
||||
|
||||
if (!resp.ok) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
`KBDB search HTTP ${resp.status}`,
|
||||
["稍後重試", "改用 arcrun_list_examples(tag=...) 過濾"],
|
||||
await resp.text().catch(() => ""),
|
||||
);
|
||||
// 基本盤無語義 search:撈全部 workflow-example,用 query 對 content/slug/tag 做關鍵字比對排序。
|
||||
// (examples 只有 ~10 筆,client 端過濾零負擔;embed 模組上線後可改打語義 search)
|
||||
const blocks = await kbdbList(env, "workflow-example", 200);
|
||||
const ql = q.toLowerCase();
|
||||
const terms = ql.split(/\s+/).filter(Boolean);
|
||||
|
||||
const scored = blocks
|
||||
.map((b) => {
|
||||
const slug = b.page_name?.replace(/^example-/, "") ?? "";
|
||||
const tags = parseTags(b.tags_json);
|
||||
const hay = `${slug} ${tags.join(" ")} ${(b.content ?? "")}`.toLowerCase();
|
||||
// 每個 term 命中 +1;slug/tag 命中額外加權
|
||||
let score = 0;
|
||||
for (const t of terms) {
|
||||
if (hay.includes(t)) score += 1;
|
||||
if (slug.toLowerCase().includes(t)) score += 2;
|
||||
if (tags.some((tag) => tag.toLowerCase().includes(t))) score += 2;
|
||||
}
|
||||
return { b, slug, tags, score };
|
||||
})
|
||||
.filter((r) => r.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, k);
|
||||
|
||||
const data = await resp.json<{ results?: Array<{ block?: KbdbBlock; score?: number }> }>();
|
||||
const all = data.results ?? [];
|
||||
const examples = all
|
||||
.filter((r) => r.block?.type === "workflow-example")
|
||||
.slice(0, k)
|
||||
.map((r) => {
|
||||
const b = r.block!;
|
||||
return {
|
||||
slug: b.page_name?.replace(/^example-/, "") ?? "",
|
||||
page_name: b.page_name,
|
||||
const examples = scored.map((r) => ({
|
||||
slug: r.slug,
|
||||
page_name: r.b.page_name,
|
||||
score: r.score,
|
||||
tags: parseTags(b.tags_json),
|
||||
preview: (b.content ?? "").slice(0, 200),
|
||||
};
|
||||
});
|
||||
tags: r.tags,
|
||||
preview: (r.b.content ?? "").slice(0, 200),
|
||||
}));
|
||||
|
||||
if (examples.length === 0) {
|
||||
return successResponse(
|
||||
{ count: 0, examples: [], query },
|
||||
{ count: 0, examples: [], query: q },
|
||||
[
|
||||
"沒命中。可能 KBDB /search 還在等 embedding 建好(剛 sync 完要 1-5 分鐘)",
|
||||
"關鍵字沒命中(基本盤是 LIKE 非語義,換更具體/不同的詞再試)",
|
||||
"改用 arcrun_list_examples(tag='...') 走 tag 過濾",
|
||||
"或 arcrun_list_examples() 看全部清單自己挑",
|
||||
],
|
||||
@@ -289,10 +300,11 @@ export function registerSearchExamples(server: McpServer, env: Env) {
|
||||
}
|
||||
|
||||
return successResponse(
|
||||
{ count: examples.length, examples, query },
|
||||
{ count: examples.length, examples, query: q, search_mode: "keyword" },
|
||||
[
|
||||
"call arcrun_get_example(slug) 拿完整 YAML",
|
||||
"score 高 = 跟你 query 更相關",
|
||||
"score 高 = 關鍵字命中越多(slug/tag 命中加權)",
|
||||
"search_mode:keyword — 基本盤無語義,命中靠字面;換具體詞可改善",
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* arcrun_whoami — MCP 端的「我現在是誰、連哪台」(§7.8 P1 D2,與 CLI acr whoami 對齊)。
|
||||
*
|
||||
* D2 根因(self-hosted-init.md §7.8):AI 不用工具讀帳號、自己 curl 猜全域 → 打錯帳號。
|
||||
* 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)——
|
||||
* 否則「AI 偏好 MCP」時又得繞回 curl。
|
||||
*
|
||||
* 薄殼:只回報 MCP 已解析的 orgNamespace(綁哪個帳號)+ cypher binding 連向,無業務邏輯。
|
||||
*/
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { Env } from "../types.js";
|
||||
|
||||
export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) {
|
||||
server.tool(
|
||||
"arcrun_whoami",
|
||||
"回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" +
|
||||
"部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
||||
{},
|
||||
async () => {
|
||||
// 薄殼:MCP 透過 service binding(CYPHER_EXECUTOR)連 cypher,binding 本身決定連哪台;
|
||||
// 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。
|
||||
const identity = {
|
||||
account_namespace: orgNamespace || "(未設)",
|
||||
cypher: "service-binding:CYPHER_EXECUTOR",
|
||||
kbdb: "service-binding:KBDB",
|
||||
note:
|
||||
"此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2)
|
||||
*
|
||||
* rule 07 §5(薄殼鐵律):能力長在基本盤 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
||||
* 全走既有 kbdbFetch(KBDB service binding)打基本盤 HTTP API(kbdb/src/routes/*)。
|
||||
*
|
||||
* KBDB 鐵律(leo 2026-06-14,頂層 DECISION-kbdb-v3-baseplane.md):
|
||||
* - 任何人不准動表;**不提供建表 / SQL tool**。
|
||||
* - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用
|
||||
* ——類 Supabase 萬用表,schema 由 template/slot 表達,不是真的 CREATE TABLE。
|
||||
* - 薄殼只調基本盤 HTTP API,不直連 D1、不寫 SQL。
|
||||
*
|
||||
* 基本盤 API 契約(已存在,kbdb/src/routes):
|
||||
* POST /templates { name, slots[], description?, created_by? } → { template }
|
||||
* GET /templates → { templates[], count }
|
||||
* GET /templates/:idOrName → { template }
|
||||
* POST /records { template, values:{slot:content}, owner_id? } → { record }
|
||||
* GET /records/by-template/:t ?owner_id= → { records[], count }
|
||||
* GET /records/:recordId → { record }
|
||||
* GET /entries/search ?q=&owner_id= → { entries[], count, mode:'keyword' }
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
|
||||
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
|
||||
export function registerAllKbdbDataTools(server: McpServer, env: Env) {
|
||||
registerCreateTemplate(server, env);
|
||||
registerListTemplates(server, env);
|
||||
registerCreateRecord(server, env);
|
||||
registerGetRecord(server, env);
|
||||
registerQuery(server, env);
|
||||
registerSearch(server, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb_create_template — 建一個 template(= 萬用表裡的一種「虛擬表/資料形狀」)。
|
||||
* 這是 AI 想存「新類型資料」時的唯一入口:沒有建表 API,改用 template + slots 描述欄位。
|
||||
*/
|
||||
export function registerCreateTemplate(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_create_template",
|
||||
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
|
||||
"要存「新類型」的結構化資料時,就建一個 template 並用 slots 列出它的欄位名,之後用 kbdb_create_record 填值。" +
|
||||
"例:name='contact', slots=['name','email','phone']。",
|
||||
{
|
||||
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
|
||||
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
|
||||
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
|
||||
created_by: z.string().optional().describe("建立者標記(選填)"),
|
||||
},
|
||||
async ({ name, slots, description, created_by }) => {
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, slots, description, created_by }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, [
|
||||
`template「${name}」已建。用 kbdb_create_record(template='${name}', values={...}) 填一筆資料`,
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
|
||||
export function registerListTemplates(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_list_templates",
|
||||
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
|
||||
{},
|
||||
async () => {
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/templates");
|
||||
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** kbdb_create_record — 依某 template 填一筆 record(slot → 內容)。 */
|
||||
export function registerCreateRecord(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_create_record",
|
||||
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容},slot 名要對得上 template 的 slots。" +
|
||||
"template 不存在會失敗——先 kbdb_list_templates 確認,或 kbdb_create_template 建一個。",
|
||||
{
|
||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
|
||||
owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id)"),
|
||||
},
|
||||
async ({ template, values, owner_id }) => {
|
||||
try {
|
||||
const res = await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template, values, owner_id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return errorResponse("create_record_failed", `填 record 失敗`, [
|
||||
`確認 template「${template}」存在(kbdb_list_templates)`,
|
||||
"values 的 slot 名要對得上 template 的 slots",
|
||||
], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** kbdb_get_record — 用 record_id 取單筆 record。 */
|
||||
export function registerGetRecord(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_get_record",
|
||||
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
|
||||
{
|
||||
record_id: z.string().min(1).describe("record 的 id(rec_xxx)"),
|
||||
},
|
||||
async ({ record_id }) => {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
|
||||
if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]);
|
||||
if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
|
||||
export function registerQuery(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_query",
|
||||
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
|
||||
{
|
||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||
owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"),
|
||||
},
|
||||
async ({ template, owner_id }) => {
|
||||
try {
|
||||
const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "");
|
||||
const res = await kbdbFetch(env, path);
|
||||
if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb_search — 對 entries 做搜尋。mode=keyword(D1 LIKE,基本盤永遠可用)或 semantic(向量,需開 embed 模組)。
|
||||
* 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。
|
||||
* mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。
|
||||
*/
|
||||
export function registerSearch(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_search",
|
||||
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'(AI 向量語義搜尋," +
|
||||
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
|
||||
{
|
||||
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
|
||||
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
|
||||
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
|
||||
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize)"),
|
||||
},
|
||||
async ({ q, owner_id, source, mode }) => {
|
||||
try {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (owner_id) qs.set("owner_id", owner_id);
|
||||
if (source) qs.set("source", source);
|
||||
if (mode) qs.set("mode", mode);
|
||||
const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
|
||||
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = (await res.json()) as { mode?: string; capability_hint?: string };
|
||||
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
|
||||
const hints =
|
||||
data.capability_hint
|
||||
? [data.capability_hint, "要開:跟用戶確認後,CC 可代開(寫 config kbdb_embed:true + acr update)"]
|
||||
: data.mode === "semantic"
|
||||
? ["mode:semantic = AI 向量語義搜尋"]
|
||||
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize)"];
|
||||
return successResponse(data, hints);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { registerDeployWorkflow } from "./u6u_deploy_workflow.js";
|
||||
import { registerPublishComponent } from "./u6u_publish_component.js";
|
||||
import { registerListWorkflows } from "./u6u_list_workflows.js";
|
||||
import { registerGetWorkflow } from "./u6u_get_workflow.js";
|
||||
import { registerSearchWorkflows } from "./u6u_search_workflows.js";
|
||||
import { registerListComponents } from "./u6u_list_components.js";
|
||||
import { registerGetComponent } from "./u6u_get_component.js";
|
||||
import { registerGetComponentGuide } from "./u6u_get_component_guide.js";
|
||||
@@ -19,14 +20,18 @@ import { registerReportFeedback } from "./arcrun_report_feedback.js";
|
||||
import { registerAllIntrospectionTools } from "./arcrun_introspection.js";
|
||||
import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js";
|
||||
import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js";
|
||||
import { registerAllRecipeTools } from "./arcrun_recipe.js";
|
||||
import { registerAllKbdbDataTools } from "./kbdb_data.js";
|
||||
import { registerWhoami } from "./arcrun_whoami.js";
|
||||
|
||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
registerSearchComponents(server, env, orgNamespace);
|
||||
registerExecuteWorkflow(server, env, orgNamespace, partnerToken);
|
||||
registerDeployWorkflow(server, env, orgNamespace);
|
||||
registerPublishComponent(server, env, orgNamespace);
|
||||
registerListWorkflows(server, env, orgNamespace);
|
||||
registerListWorkflows(server, env, orgNamespace, partnerToken); // thin-shell-alignment P1: list 改讀 /webhooks/named
|
||||
registerGetWorkflow(server, env, orgNamespace);
|
||||
registerSearchWorkflows(server, env, orgNamespace, partnerToken); // workflow-discovery R2
|
||||
registerListComponents(server, env, orgNamespace);
|
||||
registerGetComponent(server, env, orgNamespace);
|
||||
registerGetComponentGuide(server, env, orgNamespace);
|
||||
@@ -46,4 +51,11 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
|
||||
// LI SDD M3.2: skills + examples lookup(KBDB-backed)
|
||||
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
|
||||
registerAllSkillExampleTools(server, env);
|
||||
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
|
||||
registerAllRecipeTools(server, env);
|
||||
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2)
|
||||
// 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表)
|
||||
registerAllKbdbDataTools(server, env);
|
||||
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
|
||||
registerWhoami(server, env, orgNamespace);
|
||||
}
|
||||
|
||||
@@ -3,33 +3,56 @@ import { z } from "zod";
|
||||
import { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
|
||||
export function registerListWorkflows(server: McpServer, env: Env, orgNamespace: string) {
|
||||
/**
|
||||
* u6u_list_workflows — 列出本帳號已部署的工作流
|
||||
*
|
||||
* thin-shell-alignment P1(issue #11):原讀 KBDB `/records?template=workflow_metadata`,
|
||||
* 與 CLI `acr list`(讀 KV)不同源 → 列出的東西不一樣。改讀 cypher `GET /webhooks/named`
|
||||
* (KV 源,與 CLI 收斂同源)。job 分:list 讀 KV(部署寫入處,權威),search 讀 KBDB entry。
|
||||
* tag 過濾仍走 KBDB resource_tag(另一維度,非 workflow 清單來源)。
|
||||
*/
|
||||
export function registerListWorkflows(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
server.tool(
|
||||
"u6u_list_workflows",
|
||||
"列出當前命名空間下所有已部署的工作流,可選擇按 tag 篩選。",
|
||||
{ tag: z.string().optional().describe("按 tag 名稱篩選(選填)") },
|
||||
async ({ tag }) => {
|
||||
try {
|
||||
if (!env.KBDB) {
|
||||
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
|
||||
if (!env.CYPHER_EXECUTOR) {
|
||||
return { content: [{ type: "text", text: "Error: CYPHER_EXECUTOR service binding is not configured." }], isError: true };
|
||||
}
|
||||
let workflowIds: string[] | null = null;
|
||||
|
||||
// tag 過濾(選填):先從 KBDB resource_tag 查出該 tag 下的 workflow_id 白名單。
|
||||
let tagFilter: Set<string> | null = null;
|
||||
if (tag) {
|
||||
if (!env.KBDB) {
|
||||
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable (tag 過濾需要)" }], isError: true };
|
||||
}
|
||||
const tagResp = await kbdbFetch(env, `/records/search?template=resource_tag&user_id=${encodeURIComponent(orgNamespace)}&tag_name=${encodeURIComponent(tag)}&resource_type=workflow`);
|
||||
if (!tagResp.ok) {
|
||||
return { content: [{ type: "text", text: `Error querying tags: ${await tagResp.text()}` }], isError: true };
|
||||
}
|
||||
const tagData = await tagResp.json<{ records: Array<{ slots: { resource_id: string } }> }>();
|
||||
workflowIds = tagData.records.map(r => r.slots.resource_id);
|
||||
if (workflowIds.length === 0) return { content: [{ type: "text", text: JSON.stringify([], null, 2) }] };
|
||||
tagFilter = new Set(tagData.records.map(r => r.slots.resource_id));
|
||||
if (tagFilter.size === 0) return { content: [{ type: "text", text: JSON.stringify([], null, 2) }] };
|
||||
}
|
||||
const resp = await kbdbFetch(env, `/records/search?template=workflow_metadata&user_id=${encodeURIComponent(orgNamespace)}`);
|
||||
|
||||
// 主清單:讀 cypher GET /webhooks/named(KV 源,與 CLI acr list 同源)。
|
||||
const resp = await env.CYPHER_EXECUTOR.fetch("http://cypher-executor/webhooks/named", {
|
||||
method: "GET",
|
||||
headers: { "X-Arcrun-API-Key": partnerToken },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return { content: [{ type: "text", text: `Error querying workflows: ${await resp.text()}` }], isError: true };
|
||||
return { content: [{ type: "text", text: `Error listing workflows: ${await resp.text()}` }], isError: true };
|
||||
}
|
||||
const data = await resp.json<{ records: Array<{ slots: { workflow_id: string; name: string; deployed_at: string; org_namespace: string } }> }>();
|
||||
let workflows = data.records.map(r => r.slots);
|
||||
if (workflowIds !== null) workflows = workflows.filter(w => workflowIds!.includes(w.workflow_id));
|
||||
const data = await resp.json<{ workflows: Array<{ name: string; description?: string; created_at?: string; webhook_url?: string }> }>();
|
||||
let workflows = data.workflows ?? [];
|
||||
// tag 過濾用 name 比對(KV 源主鍵=name)。
|
||||
// ⚠️ 語意債(SDD §4 記):舊 tag 的 resource_id 可能存 UUID(舊 workflow_metadata workflow_id)
|
||||
// 或 name,語意不明確。方向①收斂到 KV(主鍵=name)後,resource_id 應統一為 name。
|
||||
// 過渡期先用 name 比對(KV 源唯一鍵);舊用 UUID tag 的會在 backfill/re-tag 後對齊。待總管確認 tag 收斂。
|
||||
if (tagFilter !== null) workflows = workflows.filter(w => tagFilter!.has(w.name));
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(workflows, null, 2) }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { Env } from "../types.js";
|
||||
|
||||
/**
|
||||
* u6u_search_workflows — 用自然語言找現成工作流(workflow-discovery R2)
|
||||
*
|
||||
* 北極星入口:AI 先查「有沒有現成工作流能做這件事」→ 找到就執行,別重造。
|
||||
* 呼叫 cypher GET /workflows/search → 轉發 KBDB /entries/search(entry_type=workflow + 本租戶)。
|
||||
* 優先語意搜尋;KBDB 未開 Vectorize → 自動降級關鍵字 + 回 capability_hint(不假裝語義)。
|
||||
*
|
||||
* 薄殼(rule 07):只做參數轉換 + 呼叫 + 格式化,零業務邏輯。形態對齊 u6u_search_components。
|
||||
* flag 安全:AI 收到意圖時主動 call 一次,無輪詢/排程。
|
||||
*/
|
||||
export function registerSearchWorkflows(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
partnerToken: string,
|
||||
) {
|
||||
server.tool(
|
||||
"u6u_search_workflows",
|
||||
"用自然語言找現成的工作流(先查有沒有現成的能做這件事,找到就用,別重造)。例如:「把資料寫進 Google Sheets」、「每天抓 RSS 發通知」、「webhook 轉發到別的 API」。回傳本帳號下符合的工作流清單。",
|
||||
{
|
||||
query: z.string().describe("自然語言描述要找的工作流,如「把資料寫進 Google Sheets」"),
|
||||
},
|
||||
async ({ query }) => {
|
||||
try {
|
||||
if (!env.CYPHER_EXECUTOR) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: CYPHER_EXECUTOR service binding is not configured." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await env.CYPHER_EXECUTOR.fetch(
|
||||
`http://cypher-executor/workflows/search?q=${encodeURIComponent(query)}`,
|
||||
{ method: "GET", headers: { "X-Arcrun-API-Key": partnerToken } },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{ type: "text", text: `Search failed: ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json() as {
|
||||
entries?: Array<{ page_name?: string; content?: string }>;
|
||||
count?: number;
|
||||
mode?: string;
|
||||
capability_hint?: string;
|
||||
};
|
||||
const entries = result.entries ?? [];
|
||||
const count = result.count ?? entries.length;
|
||||
|
||||
if (count === 0) {
|
||||
const hint = result.capability_hint ? `\n\n(${result.capability_hint})` : "";
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `找不到符合「${query}」的現成工作流。可以用 u6u_deploy_workflow 部署一個新的。${hint}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// capability_hint 透傳給 AI:未開語義時 AI 看到就能主動問用戶要不要開 Vectorize(R2.3 閉環)。
|
||||
const hintLine = result.capability_hint
|
||||
? `\n\n⚠️ ${result.capability_hint}`
|
||||
: "";
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `找到 ${count} 個工作流(mode: ${result.mode ?? "keyword"}):\n${JSON.stringify(entries, null, 2)}${hintLine}`,
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,12 @@ export interface Env {
|
||||
// 設了會把 agent-feedback / agent-telemetry block 都寫到 platform user_id 下;
|
||||
// 沒設則 fallback 寫進 user 自己的 namespace
|
||||
PLATFORM_API_KEY?: string;
|
||||
// Self-hosted 單租戶模式旗標(與 cypher-executor 同名同義)。
|
||||
// "false" = self-hosted:Bearer 帶的是 namespace 明碼(非平台 partner key),
|
||||
// 不打 KBDB partner 驗證,直接當 org_namespace(對齊 cypher 的 opaque-key 模型)。
|
||||
// 未設 / "true" = 官方 SaaS:維持 partner-key 驗證(行為完全不變)。
|
||||
// SDD: sdk-and-website/mcp-account-source.md;HANDOFF §3b。
|
||||
MULTI_TENANT?: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
|
||||
@@ -40,3 +40,41 @@ describe("partner-auth: KBDB response validation", () => {
|
||||
expect(info.org_namespace).toBe("org-a");
|
||||
});
|
||||
});
|
||||
|
||||
// HANDOFF §3b / mcp-account-source.md §5.5:self-hosted(MULTI_TENANT=false)下
|
||||
// Bearer 帶的是 namespace 明碼,不打 KBDB partner 驗證,直接當 org_namespace。
|
||||
// 與 cypher-executor 的 opaque-key 模型對齊(X-Arcrun-API-Key 不驗證直接當分區 key)。
|
||||
function resolveNamespace(
|
||||
multiTenant: string | undefined,
|
||||
token: string,
|
||||
validatePartner: (t: string) => { valid: boolean; org_namespace: string },
|
||||
): { ok: boolean; org_namespace?: string } {
|
||||
if (multiTenant === "false") {
|
||||
// self-hosted:Bearer 明碼即 namespace,繞 partner 驗證
|
||||
return { ok: true, org_namespace: token };
|
||||
}
|
||||
// SaaS:維持 partner-key 驗證(行為不變)
|
||||
const info = validatePartner(token);
|
||||
return info.valid ? { ok: true, org_namespace: info.org_namespace } : { ok: false };
|
||||
}
|
||||
|
||||
describe("partner-auth: self-hosted (MULTI_TENANT=false) bypasses partner validation", () => {
|
||||
const partnerValidatorThatAlwaysRejects = () => ({ valid: false, org_namespace: "" });
|
||||
|
||||
it("self-hosted: namespace 明碼直接當 org_namespace,不打 partner 驗證", () => {
|
||||
const r = resolveNamespace("false", "leo", partnerValidatorThatAlwaysRejects);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.org_namespace).toBe("leo");
|
||||
});
|
||||
|
||||
it("SaaS (未設 MULTI_TENANT):仍走 partner 驗證,明碼被擋", () => {
|
||||
const r = resolveNamespace(undefined, "leo", partnerValidatorThatAlwaysRejects);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("SaaS:合法 partner key 通過並取 org_namespace", () => {
|
||||
const r = resolveNamespace("true", "pk_live_x", () => ({ valid: true, org_namespace: "org-a" }));
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.org_namespace).toBe("org-a");
|
||||
});
|
||||
});
|
||||
|
||||
+26
-9
@@ -4,15 +4,32 @@ compatibility_date = "2024-11-27"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
workers_dev = true # 對齊 arcrun 部署慣例(rule 05):deploy 掃描自動啟用 workers.dev URL
|
||||
|
||||
# Service Bindings
|
||||
# 2026-05-07:COMPONENT_REGISTRY 從 inkstone-component-registry 改為 arcrun-registry
|
||||
# 原因:舊的 inkstone-component-registry 期望不同 query 參數名,MCP search 失敗。
|
||||
# 新的 arcrun-registry(registry.arcrun.dev)才是現役。
|
||||
services = [
|
||||
{ binding = "COMPONENT_REGISTRY", service = "arcrun-registry" },
|
||||
{ binding = "CYPHER_EXECUTOR", service = "arcrun-cypher-executor" },
|
||||
{ binding = "KBDB", service = "inkstone-kbdb-api" }
|
||||
]
|
||||
# ── 租戶模式(self-hosted fork 必看)─────────────────────────────────────────────
|
||||
# 官方 SaaS:[vars] 不含 MULTI_TENANT(預設多租戶)→ MCP 走 partner-key 驗證(pk_live)。
|
||||
# self-hosted 單租戶:acr init/update 部署時 **自動注入** MULTI_TENANT="false" 進此 [vars]
|
||||
# (cli/src/lib/deploy.ts injectMultiTenant,依 config.mode='self-hosted')→ MCP 接受 Bearer =
|
||||
# namespace 明碼,不打 KBDB partner 驗證,直接當 org_namespace(與 cypher 的 MULTI_TENANT=false 對齊)。
|
||||
# 用戶零填寫(不必手動取消註解)。手動 fork 不走 CLI 者,自行在此加 MULTI_TENANT = "false"。
|
||||
# SDD: sdk-and-website/mcp-account-source.md §5.5;HANDOFF §3b。
|
||||
[vars]
|
||||
|
||||
# Service Bindings(issue #12:用 [[services]] array-of-tables,不用 services=[...] inline)
|
||||
# ⚠️ 為何不能用 inline `services = [...]`:它在 [vars] table 之後 → TOML 會把它吸成
|
||||
# `vars.services`(普通 var 陣列)而非頂層 service bindings → wrangler 看不到 binding。
|
||||
# self-hosted 部署注入 MULTI_TENANT 進 [vars] 後此問題暴露(MCP 報 CYPHER_EXECUTOR not configured)。
|
||||
# array-of-tables `[[services]]` 是獨立頂層 table,不受 [vars] 影響(對齊官方 cypher-executor/wrangler.toml)。
|
||||
# 2026-05-07:COMPONENT_REGISTRY 從 inkstone-component-registry 改為 arcrun-registry(現役)。
|
||||
[[services]]
|
||||
binding = "COMPONENT_REGISTRY"
|
||||
service = "arcrun-registry"
|
||||
|
||||
[[services]]
|
||||
binding = "CYPHER_EXECUTOR"
|
||||
service = "arcrun-cypher-executor"
|
||||
|
||||
[[services]]
|
||||
binding = "KBDB"
|
||||
service = "arcrun-kbdb"
|
||||
|
||||
# Route — MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(與其他 worker 一致)。
|
||||
# 舊的 studio.finally.click 是 inkstone 平台 zone,arcrun 帳號沒有該 zone → 部署 route 失敗。
|
||||
|
||||
@@ -47,6 +47,9 @@ type Input struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Service string `json:"service"`
|
||||
Request json.RawMessage `json:"request,omitempty"`
|
||||
// Names:resolve_credentials action 用——要解密的 credential 名稱清單
|
||||
// (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。
|
||||
Names []string `json:"names,omitempty"`
|
||||
}
|
||||
|
||||
type SecretRequirement struct {
|
||||
@@ -96,12 +99,20 @@ func main() {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
|
||||
// resolve_credentials:用戶面 {{credential.NAME}} 入口。不查 recipe、不要求 service,
|
||||
// 直接給 names 解密回明文。在 service 必填檢查之前分流(只有 authenticate 才需要 recipe)。
|
||||
if input.Action == "resolve_credentials" {
|
||||
handleResolveCredentials(input)
|
||||
return
|
||||
}
|
||||
|
||||
if input.Service == "" {
|
||||
writeError("service 必填")
|
||||
return
|
||||
}
|
||||
if input.Action != "" && input.Action != "authenticate" {
|
||||
writeError("auth_static_key 僅支援 action=authenticate")
|
||||
writeError("auth_static_key 僅支援 action=authenticate / resolve_credentials")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,6 +206,52 @@ func main() {
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// handleResolveCredentials 處理用戶面 {{credential.NAME}} 入口:
|
||||
// 對每個 name 讀 {api_key}:cred:{name} + 解密,回傳明文 map。
|
||||
// 不查 auth recipe(與 authenticate 分流)。缺任一 name → success:false + error 指明(不假綠)。
|
||||
func handleResolveCredentials(input Input) {
|
||||
if len(input.Names) == 0 {
|
||||
writeError("resolve_credentials 需要 names(要解密的 credential 名稱清單)")
|
||||
return
|
||||
}
|
||||
|
||||
credentials := make(map[string]string, len(input.Names))
|
||||
for _, name := range input.Names {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
kvKey := input.APIKey + ":cred:" + name
|
||||
encJSON, s := kvGet(kvKey)
|
||||
if s == 2 {
|
||||
writeError("缺少 credential: " + name + "。修復: 編輯 credentials.yaml 後執行 acr creds push")
|
||||
return
|
||||
}
|
||||
if s != 0 {
|
||||
writeError("kv_get 失敗(credential " + name + ")")
|
||||
return
|
||||
}
|
||||
|
||||
var rec EncryptedRecord
|
||||
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
|
||||
writeError("credential " + name + " 格式錯誤: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plaintext, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
|
||||
if !ok {
|
||||
writeError("credential " + name + " 解密失敗")
|
||||
return
|
||||
}
|
||||
credentials[name] = plaintext
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"credentials": credentials,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeError(msg string) {
|
||||
|
||||
@@ -108,10 +108,16 @@ func main() {
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
|
||||
// 2026-05-14:偵測 JSON `{"error":"..."}` 模式視為 4xx 失敗
|
||||
// 理由:host function 沒回 HTTP status code(架構債),先用 body 啟發式 catch。
|
||||
// 標準 API(cypher-executor / KBDB / 多數 REST)失敗時都回 {"error":...} JSON。
|
||||
// 對應 SDD: arcrun.md 三-A P1 #4「http_request status code 缺乏 surface」。
|
||||
// 偵測 JSON `{"error":"..."}` 模式視為失敗。
|
||||
// 2026-06-09 修架構債:host function(.component-builds/http_request/src/index.ts)現在對非 2xx
|
||||
// 回 envelope `{"error":"HTTP <status>","status":<code>,"body":<原文>}`——故此處 parsed["error"]
|
||||
// 能正確 catch 所有 4xx/5xx(含 Notion 401 那種 body 用 {"object":"error"} 不帶 error key 的)。
|
||||
// 之前 host fn 只回 body 原文丟掉 status → 401 被判 success(系統假綠根因,已修)。
|
||||
// 註:claude_api/kbdb_upsert_block/km_writer 已同樣修(非 2xx 回 error envelope)。
|
||||
// auth_service_account 不套此 envelope——它 main.go 自己解析 OAuth token 回應的
|
||||
// {access_token,error,error_description},access_token 空即視為失敗,已有自己的判定,
|
||||
// 套 envelope 反而會丟掉 error_description 破壞 token exchange 錯誤處理。
|
||||
// 待辦:4 份 inline host fn 最好抽成共用 helper(dedup,目前複製貼上)。
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &parsed); err == nil {
|
||||
if errVal, ok := parsed["error"]; ok && errVal != nil {
|
||||
|
||||
@@ -54,7 +54,7 @@ config:
|
||||
格式:markdown bullets,每條 < 30 字,標明來源。
|
||||
|
||||
push_digest:
|
||||
component: telegram
|
||||
component: telegram_send
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
☀️ 早安 {{_today}}
|
||||
|
||||
@@ -12,6 +12,7 @@ config:
|
||||
cron_expr: "0 8 * * *" # 每天 08:00 UTC(依需求調時區)
|
||||
|
||||
fetch_unread:
|
||||
# TODO(#13): gmail 讀取尚無對應 recipe,待 seed 補 gmail_list 後再映射;gmail_send 是送信端點,不可用於 action:list
|
||||
component: gmail
|
||||
action: "list"
|
||||
query: "is:unread newer_than:1d"
|
||||
@@ -35,7 +36,7 @@ config:
|
||||
{{fetch_unread.messages}}
|
||||
|
||||
push_to_telegram:
|
||||
component: telegram
|
||||
component: telegram_send
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
📬 今日 email 摘要
|
||||
|
||||
@@ -35,7 +35,7 @@ config:
|
||||
# 純記錄成功,下游若需要可繼續鏈
|
||||
|
||||
final_fail_notify:
|
||||
component: telegram
|
||||
component: telegram_send
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
⚠️ workflow {{input.workflow_name}} 兩次重試都失敗
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- workerd
|
||||
@@ -49,7 +49,7 @@ if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
err "$BRANCH 領先 origin/$BRANCH $AHEAD 個 commit 未 push → **deploy 前先 git push**(否則 self-hosted 裝到舊 worker,重演壓測階段 6 的 seed 404)"
|
||||
GIT_BLOCK=1
|
||||
else
|
||||
warn "$BRANCH vs origin/$BRANCH:ahead=$AHEAD behind=$BEHIND(確認狀態)"
|
||||
warn "${BRANCH} vs origin/${BRANCH}:ahead=${AHEAD} behind=${BEHIND}(確認狀態)"
|
||||
fi
|
||||
else
|
||||
info "非 git repo,跳過 git 同步檢查"
|
||||
@@ -77,7 +77,7 @@ fi
|
||||
if [ "$LOCAL_CLI" = "$REMOTE_CLI" ]; then
|
||||
ok "CLI 版本同步:本機 $LOCAL_CLI = npm $REMOTE_CLI"
|
||||
elif [ "$REMOTE_CLI" = "無法查線上" ] || [ "$REMOTE_CLI" = "無 npm CLI 可查" ]; then
|
||||
info "本機 CLI $LOCAL_CLI($REMOTE_CLI)"
|
||||
info "本機 CLI ${LOCAL_CLI}(${REMOTE_CLI})"
|
||||
else
|
||||
warn "CLI 版本漂移:本機 $LOCAL_CLI ≠ npm $REMOTE_CLI"
|
||||
warn "→ 跑 scripts/local-deploy.sh(含 cli/ 變動時)會 npm publish;或手動 cd cli && npm publish"
|
||||
@@ -88,7 +88,7 @@ if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
LAST_CLI_PKG_CHANGE=$(git log -1 --format=%h -- cli/package.json 2>/dev/null || echo "")
|
||||
LAST_CLI_SRC_CHANGE=$(git log -1 --format=%h -- cli/src 2>/dev/null || echo "")
|
||||
if [ -n "$LAST_CLI_SRC_CHANGE" ] && [ "$LAST_CLI_SRC_CHANGE" != "$LAST_CLI_PKG_CHANGE" ]; then
|
||||
info "cli/src 最後變動($LAST_CLI_SRC_CHANGE)晚於 package.json($LAST_CLI_PKG_CHANGE)→ 確認是否需 bump version"
|
||||
info "cli/src 最後變動(${LAST_CLI_SRC_CHANGE})晚於 package.json(${LAST_CLI_PKG_CHANGE})→ 確認是否需 bump version"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -100,7 +100,7 @@ if command -v curl >/dev/null 2>&1; then
|
||||
if [ "$CODE" = "200" ]; then
|
||||
ok "https://cypher.arcrun.dev/health → 200"
|
||||
else
|
||||
warn "https://cypher.arcrun.dev/health → $CODE(部署中或不可達?)"
|
||||
warn "https://cypher.arcrun.dev/health → ${CODE}(部署中或不可達?)"
|
||||
fi
|
||||
else
|
||||
info "無 curl,跳過線上健康檢查"
|
||||
@@ -111,7 +111,7 @@ echo ""
|
||||
echo "【MCP server(arcrun/mcp/,已進主庫)】"
|
||||
if [ -d mcp ] && [ -f mcp/wrangler.toml ]; then
|
||||
MCP_NAME=$(grep '^name' mcp/wrangler.toml | head -1 | sed -E 's/^name[[:space:]]*=[[:space:]]*"([^"]*)".*/\1/')
|
||||
ok "arcrun/mcp/ 存在(worker: $MCP_NAME);由 local-deploy.sh 一併 wrangler deploy"
|
||||
ok "arcrun/mcp/ 存在(worker: ${MCP_NAME});由 local-deploy.sh 一併 wrangler deploy"
|
||||
info "薄殼一致性:MCP 工具集應對齊 cypher-executor 最新 API(rule 07)"
|
||||
info "用戶連哪台 MCP 由 .mcp.json 決定(acr mcp-setup 依 config mcp_url 產)"
|
||||
else
|
||||
|
||||
Executable
+431
@@ -0,0 +1,431 @@
|
||||
#!/bin/bash
|
||||
# system-dev-template installer
|
||||
# 已有專案接入腳本——只建立缺少的東西,已有的一律不動。
|
||||
#
|
||||
# 模組化安裝:
|
||||
# --wiki 只裝 LLM Wiki(記憶系統 + 機敏防護)
|
||||
# --sdd 只裝 SDD 系統(動 code 前必須有 design.md)
|
||||
# --all 兩個都裝(預設)
|
||||
# 無參數 互動式詢問
|
||||
#
|
||||
# 為什麼留在同一個 repo 用參數選,而不是 fork:
|
||||
# 使用者多半非專業,最怕「我要去哪個 repo」。一個入口 + 選單最友善。
|
||||
# 等未來功能多到 3+ 個再演進成「模板組合器」。模組邊界先在這裡劃好。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── i18n:依 locale 選語言,預設英文 ──────────────────
|
||||
# 為什麼預設英文:curl | bash 常是 LANG=C,外國人預設就該看得懂;
|
||||
# 台灣使用者 locale 多為 zh_TW,會自動切回繁中。
|
||||
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
|
||||
zh*|*Hant*|*Hans*) IS_ZH="yes" ;;
|
||||
*) IS_ZH="no" ;;
|
||||
esac
|
||||
# t "中文" "English" → 依語系印出對應字串
|
||||
t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; }
|
||||
# tn = 不換行版(給 prompt 用)
|
||||
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
|
||||
|
||||
REPO_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/template"
|
||||
CREATED=()
|
||||
SKIPPED=()
|
||||
|
||||
# ── 解析模組參數 ──────────────────────────────────
|
||||
MODULE=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--wiki|--wiki-only) MODULE="wiki" ;;
|
||||
--sdd|--sdd-only) MODULE="sdd" ;;
|
||||
--all) MODULE="all" ;;
|
||||
-h|--help)
|
||||
if [ "$IS_ZH" = "yes" ]; then
|
||||
cat <<'HELP'
|
||||
用法:install.sh [--wiki | --sdd | --all]
|
||||
--wiki 只裝 LLM Wiki(CC 記憶系統 + 機敏防護)
|
||||
--sdd 只裝 SDD 系統(動 code 前強制要有設計文件)
|
||||
--all 兩個都裝(預設)
|
||||
無參數 互動式詢問要裝哪個
|
||||
HELP
|
||||
else
|
||||
cat <<'HELP'
|
||||
Usage: install.sh [--wiki | --sdd | --all]
|
||||
--wiki Install LLM Wiki only (CC memory system + secret protection)
|
||||
--sdd Install SDD system only (require a design doc before touching code)
|
||||
--all Install both (default)
|
||||
no flag Interactively ask which to install
|
||||
HELP
|
||||
fi
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "🔧 system-dev-template installer"
|
||||
echo "================================="
|
||||
t "只建立缺少的目錄和檔案,已有的不動。" \
|
||||
"Only creates missing dirs and files; never touches what already exists."
|
||||
echo ""
|
||||
|
||||
# ── 無參數 → 互動式詢問(給非專業使用者)──────────
|
||||
if [ -z "$MODULE" ]; then
|
||||
if [ -t 0 ]; then
|
||||
t "要安裝哪一塊?" "Which part do you want to install?"
|
||||
t " 1) LLM Wiki —— 讓 CC 記住決策、不重複犯錯(含機敏防護)" \
|
||||
" 1) LLM Wiki — let CC remember decisions and avoid repeating mistakes (with secret protection)"
|
||||
t " 2) SDD —— 動 code 前強制先有設計文件" \
|
||||
" 2) SDD — require a design doc before touching code"
|
||||
t " 3) 兩個都裝(推薦)" " 3) Install both (recommended)"
|
||||
echo ""
|
||||
tn "請輸入 1 / 2 / 3 [預設 3]:" "Enter 1 / 2 / 3 [default 3]: "
|
||||
read -r choice || choice=3
|
||||
case "$choice" in
|
||||
1) MODULE="wiki" ;;
|
||||
2) MODULE="sdd" ;;
|
||||
*) MODULE="all" ;;
|
||||
esac
|
||||
else
|
||||
# 非互動環境(如 curl | bash 無 tty)→ 預設全裝
|
||||
MODULE="all"
|
||||
fi
|
||||
fi
|
||||
|
||||
WANT_WIKI=false
|
||||
WANT_SDD=false
|
||||
case "$MODULE" in
|
||||
wiki) WANT_WIKI=true ;;
|
||||
sdd) WANT_SDD=true ;;
|
||||
all) WANT_WIKI=true; WANT_SDD=true ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
t "📦 安裝模組:$MODULE" "📦 Module: $MODULE"
|
||||
echo ""
|
||||
|
||||
# ── 偵測 vault 類型 → 決定 raw source(原始文件)路徑 ──────────
|
||||
# 為什麼:這個模板原本假設「原始文件在 docs/」,但 Logseq / Obsidian
|
||||
# 這種 PKM vault 有自己的目錄慣例,整理時不能照 docs/ 那套搬動,
|
||||
# 否則會破壞 vault 結構、讓筆記變不可讀。
|
||||
# 偵測結果寫進 CLAUDE.md,讓 CC 和未來的 Cowork skill 都知道
|
||||
# 「該讀/該整理哪裡」而不是亂動。
|
||||
# 必須在建立 CLAUDE.md 之前跑完。
|
||||
VAULT_TYPE=""
|
||||
RAW_SOURCE=""
|
||||
IS_VAULT="no" # 只有 logseq/obsidian 這種「筆記軟體 vault」才算 yes
|
||||
if [ -d "logseq" ]; then
|
||||
VAULT_TYPE="logseq"
|
||||
RAW_SOURCE="pages/, journals/"
|
||||
IS_VAULT="yes"
|
||||
elif [ -d ".obsidian" ]; then
|
||||
VAULT_TYPE="obsidian"
|
||||
RAW_SOURCE="$(tn './ (整個 vault 根目錄的 .md)' './ (all .md under the vault root)')"
|
||||
IS_VAULT="yes"
|
||||
else
|
||||
VAULT_TYPE="docs"
|
||||
RAW_SOURCE="docs/"
|
||||
fi
|
||||
# 偵測到是筆記 vault → 出聲告訴使用者「我看到了,會小心、不破壞你的筆記結構」。
|
||||
# 不是筆記(一般開發案等)→ 不囉嗦,默默把 docs/ 當原始文件夾安裝完成。
|
||||
if [ "$IS_VAULT" = "yes" ]; then
|
||||
t "🗂️ 偵測到 ${VAULT_TYPE} 筆記庫 → 原始文件:${RAW_SOURCE}" \
|
||||
"🗂️ Detected a ${VAULT_TYPE} note vault → raw source: ${RAW_SOURCE}"
|
||||
t " (會保留你筆記軟體的目錄/檔名結構,不搬動、不改名)" \
|
||||
" (your note app's directory/file structure is preserved — nothing is moved or renamed)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 把「raw source 宣告區塊」吐出來,給新建的 CLAUDE.md append 或
|
||||
# 給已存在的 CLAUDE.md 當手動補貼的提示。內容對 CC / Cowork 都是
|
||||
# 機器可讀的指令(明確路徑 + 不可破壞 vault 結構的約束)。
|
||||
# 寫進 CLAUDE.md 的 raw source 宣告區塊。給人也給 AI 看:
|
||||
# 依 locale 只寫「一種語言」進 CLAUDE.md(雙語會讓每個 session 的 context 更滿)。
|
||||
emit_raw_source_block() {
|
||||
local source_kind
|
||||
if [ "$IS_ZH" = "yes" ]; then
|
||||
if [ "$IS_VAULT" = "yes" ]; then source_kind="${VAULT_TYPE} 筆記庫"
|
||||
else source_kind="一般專案(原始文件放 raw source 路徑)"; fi
|
||||
cat <<BLOCK
|
||||
|
||||
---
|
||||
|
||||
## 原始文件空間(raw source)
|
||||
|
||||
> 安裝時偵測到的來源型態:**${source_kind}**
|
||||
> CC 與 Cowork 整理/讀取「人寫的原始文件」時,**只在這裡找、只在這裡動**。
|
||||
|
||||
| 項目 | 值 |
|
||||
|------|----|
|
||||
| 來源型態 | \`${source_kind}\` |
|
||||
| raw source | \`${RAW_SOURCE}\` |
|
||||
|
||||
**約束(CC 與 Cowork 都必須遵守)**
|
||||
|
||||
- 整理 wiki/知識時,原始文件**一律從上方 raw source 路徑讀取**,不要假設是 \`docs/\`。
|
||||
BLOCK
|
||||
if [ "$IS_VAULT" = "yes" ]; then
|
||||
cat <<BLOCK
|
||||
- 這是 **${VAULT_TYPE} 筆記庫**:保留它原本的目錄與檔名慣例,**不得搬動、改名、重新分類** \`.md\` 檔,
|
||||
以免破壞筆記軟體結構造成筆記不可讀。整理只在 \`.claude/wiki/\` 產出,**不動 raw source 本身**。
|
||||
BLOCK
|
||||
fi
|
||||
else
|
||||
if [ "$IS_VAULT" = "yes" ]; then source_kind="${VAULT_TYPE} note vault"
|
||||
else source_kind="regular project (raw source lives at the path below)"; fi
|
||||
cat <<BLOCK
|
||||
|
||||
---
|
||||
|
||||
## Raw source space
|
||||
|
||||
> Source type detected at install time: **${source_kind}**
|
||||
> When CC and Cowork curate/read human-written raw source, **look only here and act only here**.
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Source type | \`${source_kind}\` |
|
||||
| raw source | \`${RAW_SOURCE}\` |
|
||||
|
||||
**Constraints (both CC and Cowork must obey)**
|
||||
|
||||
- When curating the wiki/knowledge, **always read raw source from the path above** — don't assume \`docs/\`.
|
||||
BLOCK
|
||||
if [ "$IS_VAULT" = "yes" ]; then
|
||||
cat <<BLOCK
|
||||
- This is a **${VAULT_TYPE} note vault**: keep its original directory and file-naming conventions. **Do not move, rename, or re-classify** \`.md\` files,
|
||||
or you'll break the note-app structure and make notes unreadable. Curation output goes only into \`.claude/wiki/\`; **never touch the raw source itself**.
|
||||
BLOCK
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 工具函式 ──────────────────────────────────────
|
||||
create_dir() {
|
||||
if [ ! -d "$1" ]; then
|
||||
mkdir -p "$1"
|
||||
CREATED+=("$1/")
|
||||
else
|
||||
SKIPPED+=("$1/ $(tn '(已存在)' '(already exists)')")
|
||||
fi
|
||||
}
|
||||
|
||||
download_if_missing() {
|
||||
local dest="$1" src="$2"
|
||||
if [ ! -f "$dest" ]; then
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
curl -sSL "$src" -o "$dest"
|
||||
CREATED+=("$dest")
|
||||
else
|
||||
SKIPPED+=("$dest $(tn '(已存在,跳過)' '(already exists, skipped)')")
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 共用結構(兩個模組都需要 docs 分類 + .claude)──
|
||||
create_dir "docs/1-vision"
|
||||
create_dir "docs/2-architecture/decisions"
|
||||
create_dir "docs/4-guides"
|
||||
create_dir "docs/5-records/incidents"
|
||||
create_dir "docs/5-records/test-reports"
|
||||
create_dir "docs/6-user"
|
||||
create_dir ".claude/commands"
|
||||
create_dir ".claude/hooks"
|
||||
download_if_missing "docs/README.md" "$REPO_URL/docs/README.md"
|
||||
|
||||
# ── WIKI 模組 ─────────────────────────────────────
|
||||
if $WANT_WIKI; then
|
||||
create_dir ".claude/wiki"
|
||||
download_if_missing ".claude/wiki/INDEX.md" "$REPO_URL/.claude/wiki/INDEX.md"
|
||||
download_if_missing ".claude/wiki/TAXONOMY.md" "$REPO_URL/.claude/wiki/TAXONOMY.md"
|
||||
download_if_missing ".claude/wiki/status.md" "$REPO_URL/.claude/wiki/status.md"
|
||||
download_if_missing ".claude/wiki/mistakes.md" "$REPO_URL/.claude/wiki/mistakes.md"
|
||||
download_if_missing ".claude/wiki/decisions-summary.md" "$REPO_URL/.claude/wiki/decisions-summary.md"
|
||||
download_if_missing ".claude/wiki/.wikiignore" "$REPO_URL/.claude/wiki/.wikiignore"
|
||||
|
||||
download_if_missing ".claude/commands/wiki-init.md" "$REPO_URL/.claude/commands/wiki-init.md"
|
||||
download_if_missing ".claude/commands/wiki-capture.md" "$REPO_URL/.claude/commands/wiki-capture.md"
|
||||
download_if_missing ".claude/commands/wiki-update.md" "$REPO_URL/.claude/commands/wiki-update.md"
|
||||
download_if_missing ".claude/commands/wiki-recall.md" "$REPO_URL/.claude/commands/wiki-recall.md"
|
||||
|
||||
# wiki 相關 hooks:接關 + 機敏掃描
|
||||
download_if_missing ".claude/hooks/session-start-recall.sh" "$REPO_URL/.claude/hooks/session-start-recall.sh"
|
||||
download_if_missing ".claude/hooks/wiki-secret-scan.sh" "$REPO_URL/.claude/hooks/wiki-secret-scan.sh"
|
||||
|
||||
# Cowork(claude.ai)整理 wiki 用的 skill:與 CC 的 /wiki-init 共用同一套規則
|
||||
# (含 typed-edge、frontmatter 標籤、gloss)。沒這支 → claude.ai 來掃時身上沒規則。
|
||||
download_if_missing "docs/SKILL.md" "$REPO_URL/docs/SKILL.md"
|
||||
fi
|
||||
|
||||
# ── SDD 模組 ──────────────────────────────────────
|
||||
if $WANT_SDD; then
|
||||
create_dir "docs/3-specs"
|
||||
download_if_missing "docs/3-specs/TEMPLATE-sdd/design.md" "$REPO_URL/docs/3-specs/TEMPLATE-sdd/design.md"
|
||||
download_if_missing "docs/3-specs/TEMPLATE-sdd/tasks.md" "$REPO_URL/docs/3-specs/TEMPLATE-sdd/tasks.md"
|
||||
download_if_missing "docs/2-architecture/decisions/TEMPLATE-adr.md" "$REPO_URL/docs/2-architecture/decisions/TEMPLATE-adr.md"
|
||||
|
||||
download_if_missing ".claude/commands/sdd-check.md" "$REPO_URL/.claude/commands/sdd-check.md"
|
||||
download_if_missing ".claude/hooks/sdd-guard.sh" "$REPO_URL/.claude/hooks/sdd-guard.sh"
|
||||
fi
|
||||
|
||||
# ── 共用 hook:專案自訂禁令骨架(預設停用)────────
|
||||
download_if_missing ".claude/hooks/pre-write-guard.sh" "$REPO_URL/.claude/hooks/pre-write-guard.sh"
|
||||
|
||||
# ── 共用指引:GitHub issue 處理(讀/回普世,跨 repo 發要先問,禁自動輪詢)──
|
||||
download_if_missing ".claude/commands/issue-handle.md" "$REPO_URL/.claude/commands/issue-handle.md"
|
||||
|
||||
chmod +x .claude/hooks/*.sh 2>/dev/null || true
|
||||
|
||||
# ── 依模組產生 settings.json 的 hooks 區塊 ────────
|
||||
# settings.json 因模組而異,不能直接下載單一靜態檔,改條件組裝。
|
||||
build_hooks_json() {
|
||||
local session_hooks="" pretool_hooks=""
|
||||
|
||||
if $WANT_WIKI; then
|
||||
session_hooks='{ "type": "command", "command": ".claude/hooks/session-start-recall.sh" }'
|
||||
fi
|
||||
|
||||
# PreToolUse 依模組疊加
|
||||
local pt=()
|
||||
$WANT_SDD && pt+=('{ "type": "command", "command": ".claude/hooks/sdd-guard.sh" }')
|
||||
pt+=('{ "type": "command", "command": ".claude/hooks/pre-write-guard.sh" }')
|
||||
$WANT_WIKI && pt+=('{ "type": "command", "command": ".claude/hooks/wiki-secret-scan.sh" }')
|
||||
local IFS=,
|
||||
pretool_hooks="${pt[*]}"
|
||||
|
||||
printf '{\n "hooks": {\n'
|
||||
if [ -n "$session_hooks" ]; then
|
||||
printf ' "SessionStart": [\n { "matcher": "startup|resume|clear",\n "hooks": [ %s ] }\n ],\n' "$session_hooks"
|
||||
fi
|
||||
printf ' "PreToolUse": [\n { "matcher": "Write|Edit",\n "hooks": [ %s ] }\n ]\n' "$pretool_hooks"
|
||||
printf ' }\n}\n'
|
||||
}
|
||||
|
||||
if [ ! -f ".claude/settings.json" ]; then
|
||||
build_hooks_json > .claude/settings.json
|
||||
CREATED+=(".claude/settings.json $(tn "(依 $MODULE 模組產生)" "(generated for module: $MODULE)")")
|
||||
else
|
||||
SKIPPED+=(".claude/settings.json $(tn '(已存在,請手動合併 hooks)' '(already exists — merge hooks manually)')")
|
||||
fi
|
||||
|
||||
# ── CLAUDE.md:只在完全不存在時建立 ────────────────
|
||||
# 新建時把偵測到的 raw source 宣告 append 進去(在建立的當下寫入,
|
||||
# 不回頭改使用者既有的 CLAUDE.md,維持「已有不覆蓋」原則)。
|
||||
if [ ! -f "CLAUDE.md" ]; then
|
||||
download_if_missing "CLAUDE.md" "$REPO_URL/CLAUDE.md"
|
||||
if [ -f "CLAUDE.md" ]; then
|
||||
emit_raw_source_block >> CLAUDE.md
|
||||
CREATED+=("CLAUDE.md $(tn "← 已寫入 raw source 宣告(${VAULT_TYPE})" "← raw source declaration written (${VAULT_TYPE})")")
|
||||
fi
|
||||
else
|
||||
SKIPPED+=("CLAUDE.md $(tn '(已存在,請手動加入對應區塊)' '(already exists — add the block manually)')")
|
||||
fi
|
||||
|
||||
# ── 輸出結果 ──────────────────────────────────────
|
||||
echo ""
|
||||
t "✅ 建立了:" "✅ Created:"
|
||||
# 注意:macOS bash 3.2 在 set -u 下展開「空陣列」會炸 unbound variable,
|
||||
# 所以這裡先確認有元素才展開(SKIPPED 區塊在下方本來就有守,CREATED 補上)。
|
||||
if [ ${#CREATED[@]} -gt 0 ]; then
|
||||
for item in "${CREATED[@]}"; do echo " + $item"; done
|
||||
fi
|
||||
|
||||
if [ ${#SKIPPED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "⚠️ 跳過(已存在):" "⚠️ Skipped (already exists):"
|
||||
for item in "${SKIPPED[@]}"; do echo " - $item"; done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "─────────────────────────────────"
|
||||
|
||||
# CLAUDE.md 已存在 → 依模組提醒手動加區塊
|
||||
if [ -f "CLAUDE.md" ]; then
|
||||
if ! grep -q "raw source" CLAUDE.md; then
|
||||
echo ""
|
||||
t "📌 CLAUDE.md 已存在但缺少 raw source 宣告。" \
|
||||
"📌 CLAUDE.md exists but lacks a raw source declaration."
|
||||
t " 請手動把以下區塊貼進去,讓 CC 與 Cowork 知道原始文件在哪、不要亂動既有結構:" \
|
||||
" Paste the block below in so CC and Cowork know where the raw source is and won't disturb your structure:"
|
||||
emit_raw_source_block | sed 's/^/ /'
|
||||
fi
|
||||
if $WANT_WIKI && ! grep -q "wiki/status.md" CLAUDE.md; then
|
||||
echo ""
|
||||
t "📌 CLAUDE.md 已存在但缺少 wiki 讀取順序,請手動加入:" \
|
||||
"📌 CLAUDE.md exists but lacks the wiki reading order — please add it manually:"
|
||||
echo ""
|
||||
if [ "$IS_ZH" = "yes" ]; then
|
||||
cat <<'SNIP'
|
||||
## Wiki 讀取順序
|
||||
| 檔案 | 時機 | 用途 |
|
||||
|------|------|------|
|
||||
| `.claude/wiki/status.md` | session 開始第一件事 | 當前進度 |
|
||||
| `.claude/wiki/mistakes.md` | 做新功能前 | 已知誤解 |
|
||||
| `.claude/wiki/decisions-summary.md` | 設計判斷時 | 架構決策 |
|
||||
SNIP
|
||||
else
|
||||
cat <<'SNIP'
|
||||
## Wiki reading order
|
||||
| File | When | Purpose |
|
||||
|------|------|---------|
|
||||
| `.claude/wiki/status.md` | first thing at session start | current progress |
|
||||
| `.claude/wiki/mistakes.md` | before building a new feature | known misconceptions |
|
||||
| `.claude/wiki/decisions-summary.md` | when making design calls | architecture decisions |
|
||||
SNIP
|
||||
fi
|
||||
fi
|
||||
if $WANT_SDD && ! grep -q "docs/3-specs" CLAUDE.md; then
|
||||
echo ""
|
||||
t "📌 CLAUDE.md 已存在但缺少 SDD 鐵律,請手動加入:" \
|
||||
"📌 CLAUDE.md exists but lacks the SDD iron rule — please add it manually:"
|
||||
echo ""
|
||||
if [ "$IS_ZH" = "yes" ]; then
|
||||
cat <<'SNIP'
|
||||
## 絕對鐵律
|
||||
1. 任何 code 變動前必須有對應 SDD(docs/3-specs/[子系統]/design.md)
|
||||
找不到 → 停手問負責人,不要自行建立。
|
||||
SNIP
|
||||
else
|
||||
cat <<'SNIP'
|
||||
## Iron rule
|
||||
1. Every code change must have a matching SDD (docs/3-specs/[subsystem]/design.md).
|
||||
Not found → stop and ask the owner; do not create one on your own.
|
||||
SNIP
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# settings.json 已存在 → 依模組提醒要合併哪些 hook
|
||||
if [ -f ".claude/settings.json" ]; then
|
||||
MISSING_HOOKS=()
|
||||
$WANT_WIKI && ! grep -q "session-start-recall.sh" .claude/settings.json && MISSING_HOOKS+=("SessionStart: session-start-recall.sh")
|
||||
$WANT_WIKI && ! grep -q "wiki-secret-scan.sh" .claude/settings.json && MISSING_HOOKS+=("PreToolUse(Write|Edit): wiki-secret-scan.sh")
|
||||
$WANT_SDD && ! grep -q "sdd-guard.sh" .claude/settings.json && MISSING_HOOKS+=("PreToolUse(Write|Edit): sdd-guard.sh")
|
||||
if [ ${#MISSING_HOOKS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "📌 .claude/settings.json 已存在,請手動把以下 hooks 合併進去(保留既有設定):" \
|
||||
"📌 .claude/settings.json exists — merge the hooks below in manually (keep your existing settings):"
|
||||
for h in "${MISSING_HOOKS[@]}"; do echo " • $h"; done
|
||||
fi
|
||||
fi
|
||||
|
||||
# pre-write-guard 是空殼,提醒它預設不攔(避免「以為有保護其實沒有」的安全錯覺)
|
||||
echo ""
|
||||
t "ℹ️ .claude/hooks/pre-write-guard.sh 是「按需手填的空插槽」,預設不攔任何東西。" \
|
||||
"ℹ️ .claude/hooks/pre-write-guard.sh is an empty slot to fill on demand — by default it blocks nothing."
|
||||
t " 需要專案禁令?最簡單是叫你的 CC 寫一支貼合的 guard hook(比範本表達力強);" \
|
||||
" Need project-specific bans? Easiest is to ask your CC to write a tailored guard hook (more expressive than the template);"
|
||||
t " 或自己填 FORBIDDEN_PATTERNS 並到 settings.json 掛上才會生效。" \
|
||||
" or fill in FORBIDDEN_PATTERNS yourself and wire it into settings.json to take effect."
|
||||
|
||||
echo ""
|
||||
t "🚀 下一步:" "🚀 Next steps:"
|
||||
if $WANT_WIKI; then
|
||||
t " 在 Claude Code 對話裡執行 /wiki-init" \
|
||||
" In a Claude Code conversation, run /wiki-init"
|
||||
t " CC 會掃描現有文件、套用 .wikiignore、建立 wiki。" \
|
||||
" CC will scan your existing docs, apply .wikiignore, and build the wiki."
|
||||
fi
|
||||
if $WANT_SDD; then
|
||||
t " 動 code 前先在 docs/3-specs/[子系統]/ 建 design.md(可用 /sdd-check 協助)" \
|
||||
" Before touching code, create design.md under docs/3-specs/[subsystem]/ (use /sdd-check to help)"
|
||||
fi
|
||||
t " GitHub issue:CC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \
|
||||
" GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)"
|
||||
echo ""
|
||||
Regular → Executable
+2
-2
@@ -275,7 +275,7 @@ if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
if [[ -f "$CHANGELOG" ]]; then tail -n +2 "$CHANGELOG"; fi
|
||||
} > "$TMP_CL"
|
||||
mv "$TMP_CL" "$CHANGELOG"
|
||||
echo " · 已 bump → $NEW_V,並記錄進 $CHANGELOG(記得 commit 這兩個檔)"
|
||||
echo " · 已 bump → ${NEW_V},並記錄進 ${CHANGELOG}(記得 commit 這兩個檔)"
|
||||
fi
|
||||
|
||||
# 優先用 .env 的 NPM_API_TOKEN(authToken)——互動 npm login 常因 publish 政策 403。
|
||||
@@ -296,7 +296,7 @@ if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||||
echo " 📦 publish arcrun $REMOTE_V → $LOCAL_V ..."
|
||||
if (cd cli && npm run build >/dev/null 2>&1 && \
|
||||
NPM_CONFIG_USERCONFIG="${PUB_RC:-$HOME/.npmrc}" npm publish --access public 2>&1 | tail -3); then
|
||||
echo " ✅ npm publish 完成(arcrun@$LOCAL_V)"
|
||||
echo " ✅ npm publish 完成(arcrun@${LOCAL_V})"
|
||||
else
|
||||
echo " ❌ npm publish 失敗"
|
||||
FAILED+=("cli:npm-publish")
|
||||
|
||||
@@ -4,27 +4,31 @@
|
||||
對應 LI SDD M3.4。examples / skills 在 git 是 source of truth,
|
||||
KBDB 是「給 AI 搜尋 / get」的 query-friendly mirror。
|
||||
|
||||
對 KBDB block:
|
||||
- examples → type=workflow-example
|
||||
2026-06-14 重寫:KBDB 降基本盤後(三表 entries/templates/records,無 v3 blocks 表、
|
||||
無 kbdb-upsert-block 零件 worker),原打 https://kbdb-upsert-block.arcrun.dev/ 全失效。
|
||||
改打基本盤 KBDB Worker 的 /entries:
|
||||
- examples → entry_type=workflow-example
|
||||
content = workflow.yaml 全文
|
||||
metadata_json = { description, tags }
|
||||
tags_json = [...tags.json]
|
||||
page_name = example-{slug} (idempotency key,重複 sync 走 upsert)
|
||||
|
||||
- skills → type=agent-skill
|
||||
metadata_json = { slug, description_md, tags }
|
||||
tags_json = ["workflow-example", "example:{slug}", *tags]
|
||||
page_name = example-{slug} (idempotency key)
|
||||
- skills → entry_type=agent-skill
|
||||
content = {slug}.md 全文
|
||||
page_name = skill-{slug} (idempotency key)
|
||||
metadata_json = { slug, title }
|
||||
tags_json = ["agent-skill", "skill:{slug}"]
|
||||
page_name = skill-{slug} (idempotency key)
|
||||
|
||||
基本盤無 upsert 端點 → 本腳本自己做 idempotency(GET ?page_name= 找到則 PATCH /entries/:id,
|
||||
否則 POST /entries)。這是 ops 同步腳本(非 CLI/MCP 薄殼),自行編排不違反 rule 07 薄殼原則。
|
||||
|
||||
執行:
|
||||
cd matrix/arcrun
|
||||
python3 scripts/sync-registry-to-kbdb.py # 上傳所有
|
||||
KBDB_BASE_URL=https://arcrun-kbdb.<subdomain>.workers.dev python3 scripts/sync-registry-to-kbdb.py
|
||||
python3 scripts/sync-registry-to-kbdb.py --dry-run # 只 list 不寫
|
||||
|
||||
需求:
|
||||
- mira tools/_kbdb_client.py 風格 (urllib + ak_)
|
||||
- ARCRUN_API_KEY 從 .env 或 env var
|
||||
- 走 kbdb-*.arcrun.dev 零件 worker endpoints (符合 mira CLAUDE.md §1.7)
|
||||
設定:
|
||||
- KBDB_BASE_URL KBDB 基本盤 Worker 的 base URL(必填,無預設——避免誤打到別的環境)
|
||||
- KBDB_OWNER_ID 資料歸屬標記(選填,預設 'registry';基本盤多租戶用 owner_id)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -39,42 +43,30 @@ ARCRUN_ROOT = Path(__file__).resolve().parent.parent
|
||||
EXAMPLES_DIR = ARCRUN_ROOT / "registry" / "examples"
|
||||
SKILLS_DIR = ARCRUN_ROOT / "registry" / "skills"
|
||||
|
||||
KBDB_UPSERT_URL = "https://kbdb-upsert-block.arcrun.dev/"
|
||||
USER_AGENT = "arcrun-registry-sync/1.0"
|
||||
USER_ID = "inkstone_platform_registry" # 需符合 KBDB partner namespace prefix(inkstone_*)
|
||||
USER_AGENT = "arcrun-registry-sync/2.0"
|
||||
OWNER_ID = os.environ.get("KBDB_OWNER_ID", "registry")
|
||||
SOURCE = "registry-git-sync"
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
"""從 env var 或 polaris/mira/.env 取 ARCRUN_API_KEY。"""
|
||||
key = os.environ.get("ARCRUN_API_KEY", "")
|
||||
if key:
|
||||
return key
|
||||
# fallback:找 polaris/mira/.env(leo 既有約定位置)
|
||||
mira_env = ARCRUN_ROOT.parent.parent / "polaris" / "mira" / ".env"
|
||||
if mira_env.exists():
|
||||
for line in mira_env.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("ARCRUN_API_KEY="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
def get_base_url() -> str:
|
||||
"""KBDB 基本盤 Worker base URL。無預設(避免誤打環境)。"""
|
||||
url = os.environ.get("KBDB_BASE_URL", "").rstrip("/")
|
||||
if url:
|
||||
return url
|
||||
raise SystemExit(
|
||||
"ARCRUN_API_KEY 未設定。export ARCRUN_API_KEY=ak_... 或加到 polaris/mira/.env"
|
||||
"KBDB_BASE_URL 未設定。\n"
|
||||
" export KBDB_BASE_URL=https://arcrun-kbdb.<subdomain>.workers.dev\n"
|
||||
" (self-hosted 用自己部署的 KBDB Worker URL)"
|
||||
)
|
||||
|
||||
|
||||
def kbdb_upsert(api_key: str, payload: dict, dry_run: bool) -> dict:
|
||||
"""POST kbdb-upsert-block.arcrun.dev — page_name 當 idempotency key"""
|
||||
if dry_run:
|
||||
return {"dry_run": True, "would_upsert": payload.get("page_name")}
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
def _req(method: str, url: str, payload: dict | None = None) -> dict:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(
|
||||
KBDB_UPSERT_URL,
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
@@ -82,10 +74,43 @@ def kbdb_upsert(api_key: str, payload: dict, dry_run: bool) -> dict:
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
return {"error": f"HTTP {e.code}: {body[:200]}"}
|
||||
except urllib.error.URLError as e:
|
||||
return {"error": f"URL error: {e}"}
|
||||
|
||||
|
||||
def sync_examples(api_key: str, dry_run: bool) -> tuple[int, int]:
|
||||
"""同步 registry/examples/{slug}/ 進 KBDB"""
|
||||
def find_entry_id_by_page_name(base_url: str, page_name: str) -> str | None:
|
||||
"""GET /entries?page_name= → 回既有 entry id(idempotency 用),無則 None。"""
|
||||
from urllib.parse import quote
|
||||
res = _req("GET", f"{base_url}/entries?page_name={quote(page_name)}&limit=1")
|
||||
if "error" in res:
|
||||
return None
|
||||
entries = res.get("entries") or []
|
||||
return entries[0].get("id") if entries else None
|
||||
|
||||
|
||||
def upsert_entry(base_url: str, payload: dict, dry_run: bool) -> dict:
|
||||
"""page_name 當 idempotency key:找到則 PATCH /entries/:id,否則 POST /entries。"""
|
||||
page_name = payload.get("page_name")
|
||||
if dry_run:
|
||||
existing = None if base_url == "DRY" else find_entry_id_by_page_name(base_url, page_name)
|
||||
return {"dry_run": True, "would": "patch" if existing else "post", "page_name": page_name}
|
||||
|
||||
existing_id = find_entry_id_by_page_name(base_url, page_name)
|
||||
if existing_id:
|
||||
# PATCH 只送可變欄位(entry_type/page_name 不變)
|
||||
patch = {k: payload[k] for k in ("content", "tags_json", "metadata_json") if k in payload}
|
||||
res = _req("PATCH", f"{base_url}/entries/{existing_id}", patch)
|
||||
if "error" not in res:
|
||||
res.setdefault("action", "update")
|
||||
return res
|
||||
res = _req("POST", f"{base_url}/entries", payload)
|
||||
if "error" not in res:
|
||||
res.setdefault("action", "create")
|
||||
return res
|
||||
|
||||
|
||||
def sync_examples(base_url: str, dry_run: bool) -> tuple[int, int]:
|
||||
"""同步 registry/examples/{slug}/ 進 KBDB(entry_type=workflow-example)"""
|
||||
if not EXAMPLES_DIR.exists():
|
||||
print(f"⚠️ {EXAMPLES_DIR} 不存在,跳過 examples 同步")
|
||||
return 0, 0
|
||||
@@ -104,50 +129,36 @@ def sync_examples(api_key: str, dry_run: bool) -> tuple[int, int]:
|
||||
continue
|
||||
|
||||
yaml_content = workflow_yaml.read_text(encoding="utf-8")
|
||||
description = (
|
||||
description_md.read_text(encoding="utf-8") if description_md.exists() else ""
|
||||
)
|
||||
tags = (
|
||||
json.loads(tags_json.read_text(encoding="utf-8")) if tags_json.exists() else []
|
||||
)
|
||||
description = description_md.read_text(encoding="utf-8") if description_md.exists() else ""
|
||||
tags = json.loads(tags_json.read_text(encoding="utf-8")) if tags_json.exists() else []
|
||||
|
||||
# content = workflow YAML(讓 AI semantic search 命中 YAML 內容)
|
||||
# metadata_json = description + tags 結構化
|
||||
payload = {
|
||||
"api_key": api_key,
|
||||
"type": "workflow-example",
|
||||
"entry_type": "workflow-example",
|
||||
"page_name": f"example-{slug}",
|
||||
"source": SOURCE,
|
||||
"user_id": USER_ID,
|
||||
"owner_id": OWNER_ID,
|
||||
"content": yaml_content,
|
||||
"metadata_json": json.dumps(
|
||||
{
|
||||
"slug": slug,
|
||||
"description_md": description,
|
||||
"tags": tags,
|
||||
},
|
||||
{"slug": slug, "description_md": description, "tags": tags, "source": SOURCE},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"tags_json": json.dumps(
|
||||
["workflow-example", f"example:{slug}", *tags],
|
||||
ensure_ascii=False,
|
||||
["workflow-example", f"example:{slug}", *tags], ensure_ascii=False
|
||||
),
|
||||
}
|
||||
|
||||
result = kbdb_upsert(api_key, payload, dry_run)
|
||||
result = upsert_entry(base_url, payload, dry_run)
|
||||
if "error" in result:
|
||||
print(f" ❌ {slug}: {result['error']}")
|
||||
fail += 1
|
||||
else:
|
||||
action = result.get("data", {}).get("action", "?") if isinstance(result.get("data"), dict) else "?"
|
||||
print(f" ✅ {slug} → {action}")
|
||||
print(f" ✅ {slug} → {result.get('action', 'dry-run:' + result.get('would', '?'))}")
|
||||
ok += 1
|
||||
|
||||
return ok, fail
|
||||
|
||||
|
||||
def sync_skills(api_key: str, dry_run: bool) -> tuple[int, int]:
|
||||
"""同步 registry/skills/*.md 進 KBDB"""
|
||||
def sync_skills(base_url: str, dry_run: bool) -> tuple[int, int]:
|
||||
"""同步 registry/skills/*.md 進 KBDB(entry_type=agent-skill)"""
|
||||
if not SKILLS_DIR.exists():
|
||||
print(f"⚠️ {SKILLS_DIR} 不存在,跳過 skills 同步")
|
||||
return 0, 0
|
||||
@@ -159,7 +170,6 @@ def sync_skills(api_key: str, dry_run: bool) -> tuple[int, int]:
|
||||
slug = md_file.stem
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
|
||||
# 簡單抓首行 # X 當 title
|
||||
title = slug
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
@@ -168,44 +178,37 @@ def sync_skills(api_key: str, dry_run: bool) -> tuple[int, int]:
|
||||
break
|
||||
|
||||
payload = {
|
||||
"api_key": api_key,
|
||||
"type": "agent-skill",
|
||||
"entry_type": "agent-skill",
|
||||
"page_name": f"skill-{slug}",
|
||||
"source": SOURCE,
|
||||
"user_id": USER_ID,
|
||||
"owner_id": OWNER_ID,
|
||||
"content": content,
|
||||
"metadata_json": json.dumps(
|
||||
{"slug": slug, "title": title},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"tags_json": json.dumps(
|
||||
["agent-skill", f"skill:{slug}"],
|
||||
ensure_ascii=False,
|
||||
{"slug": slug, "title": title, "source": SOURCE}, ensure_ascii=False
|
||||
),
|
||||
"tags_json": json.dumps(["agent-skill", f"skill:{slug}"], ensure_ascii=False),
|
||||
}
|
||||
|
||||
result = kbdb_upsert(api_key, payload, dry_run)
|
||||
result = upsert_entry(base_url, payload, dry_run)
|
||||
if "error" in result:
|
||||
print(f" ❌ {slug}: {result['error']}")
|
||||
fail += 1
|
||||
else:
|
||||
action = result.get("data", {}).get("action", "?") if isinstance(result.get("data"), dict) else "?"
|
||||
print(f" ✅ {slug} → {action}")
|
||||
print(f" ✅ {slug} → {result.get('action', 'dry-run:' + result.get('would', '?'))}")
|
||||
ok += 1
|
||||
|
||||
return ok, fail
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Sync registry/examples + skills → KBDB")
|
||||
p = argparse.ArgumentParser(description="Sync registry/examples + skills → KBDB base (/entries)")
|
||||
p.add_argument("--dry-run", action="store_true", help="只 list 不寫")
|
||||
p.add_argument("--examples-only", action="store_true")
|
||||
p.add_argument("--skills-only", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
api_key = get_api_key()
|
||||
print(f"🔑 api_key: {api_key[:12]}... (len={len(api_key)})")
|
||||
print(f"📂 root: {ARCRUN_ROOT}")
|
||||
base_url = "DRY" if args.dry_run and not os.environ.get("KBDB_BASE_URL") else get_base_url()
|
||||
print(f"🌐 KBDB base: {base_url}")
|
||||
print(f"📂 root: {ARCRUN_ROOT} (owner_id={OWNER_ID})")
|
||||
if args.dry_run:
|
||||
print("(dry-run,不實際寫 KBDB)")
|
||||
print()
|
||||
@@ -214,13 +217,13 @@ def main():
|
||||
skills_ok = skills_fail = 0
|
||||
|
||||
if not args.skills_only:
|
||||
print("📋 Syncing examples → type=workflow-example ...")
|
||||
examples_ok, examples_fail = sync_examples(api_key, args.dry_run)
|
||||
print("📋 Syncing examples → entry_type=workflow-example ...")
|
||||
examples_ok, examples_fail = sync_examples(base_url, args.dry_run)
|
||||
print(f" examples: {examples_ok} ok / {examples_fail} fail\n")
|
||||
|
||||
if not args.examples_only:
|
||||
print("📋 Syncing skills → type=agent-skill ...")
|
||||
skills_ok, skills_fail = sync_skills(api_key, args.dry_run)
|
||||
print("📋 Syncing skills → entry_type=agent-skill ...")
|
||||
skills_ok, skills_fail = sync_skills(base_url, args.dry_run)
|
||||
print(f" skills: {skills_ok} ok / {skills_fail} fail\n")
|
||||
|
||||
total_fail = examples_fail + skills_fail
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
# thin-shell-smoke.sh — 薄殼防複發機制層 2(thin-shell-alignment SDD / issue #11 R4)
|
||||
#
|
||||
# 目的:讓「薄殼打了不存在的 server 端點」(死端點假綠)當場現形。
|
||||
# 對 CLI/MCP 各能力對應的 cypher-executor 端點打一次,斷言 **非 404**。
|
||||
# 404 = route 不存在 = 死端點 → 紅燈。其他狀態(401/400/200…)= 端點存在 → 綠。
|
||||
#
|
||||
# ⚠️ flag 紅線(SDD C2/R4.2):本機/手動跑,**非 CI、非 cron、非輪詢**。
|
||||
# 宣稱「CLI/MCP 對齊/完成」前手動跑一次即可。對齊「執行鏈路不依賴 CI」鐵律。
|
||||
#
|
||||
# 用法:
|
||||
# CYPHER_URL=https://cypher.arcrun.dev ./scripts/thin-shell-smoke.sh
|
||||
# (self-hosted:CYPHER_URL=https://<你的 cypher>.workers.dev ...)
|
||||
# 不帶 API key 也能驗端點存在性(401 仍算「端點活著」)。帶 key 可更深驗:
|
||||
# ARCRUN_API_KEY=xxx CYPHER_URL=... ./scripts/thin-shell-smoke.sh
|
||||
|
||||
set -o pipefail
|
||||
|
||||
CYPHER_URL="${CYPHER_URL:-https://cypher.arcrun.dev}"
|
||||
CYPHER_URL="${CYPHER_URL%/}"
|
||||
API_KEY="${ARCRUN_API_KEY:-}"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
declare -a DEAD=()
|
||||
|
||||
# probe METHOD PATH LABEL
|
||||
# 斷言端點非 404。404 → 死端點(紅)。連線失敗 → 紅(但標明是網路非死端點)。
|
||||
probe() {
|
||||
method="$1"; path="$2"; label="$3"
|
||||
url="${CYPHER_URL}${path}"
|
||||
|
||||
if [ "$method" = "POST" ]; then
|
||||
if [ -n "$API_KEY" ]; then
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST -H "X-Arcrun-API-Key: ${API_KEY}" -H 'Content-Type: application/json' -d '{}' "$url" 2>/dev/null)
|
||||
else
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST -H 'Content-Type: application/json' -d '{}' "$url" 2>/dev/null)
|
||||
fi
|
||||
else
|
||||
if [ -n "$API_KEY" ]; then
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -H "X-Arcrun-API-Key: ${API_KEY}" "$url" 2>/dev/null)
|
||||
else
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
code="${code:-000}"
|
||||
|
||||
if [ "$code" = "000" ]; then
|
||||
echo " ⚠️ $label — 連線失敗(網路/DNS,非死端點判定):$method $path"
|
||||
FAIL=$((FAIL+1))
|
||||
elif [ "$code" = "404" ]; then
|
||||
echo " ❌ $label — 死端點 404:$method $path"
|
||||
DEAD+=("$label: $method $path")
|
||||
FAIL=$((FAIL+1))
|
||||
else
|
||||
echo " ✅ $label — 端點存在(HTTP ${code}):$method $path"
|
||||
PASS=$((PASS+1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== thin-shell smoke:對 ${CYPHER_URL} 驗端點存在性(斷言非 404)==="
|
||||
echo ""
|
||||
echo "── 薄殼核心能力(CLI/MCP 共用端點)──"
|
||||
# 部署:CLI acr push / MCP u6u_deploy_workflow(方向①收斂後同此)
|
||||
probe POST /webhooks/named "deploy(push)"
|
||||
# 執行已部署:CLI acr run <name> / MCP u6u_execute_workflow(#11 P0 修的)
|
||||
probe POST /webhooks/named/__smoke__/trigger "run(trigger)"
|
||||
# 執行本機 YAML:/cypher/execute
|
||||
probe POST /cypher/execute "execute"
|
||||
# list:CLI acr list / MCP u6u_list_workflows(#11 P1 收斂的)
|
||||
probe GET /webhooks/named "list"
|
||||
# search workflow:MCP u6u_search_workflows(#8 新增)
|
||||
probe GET "/workflows/search?q=smoke" "search_workflow"
|
||||
# 驗證:MCP arcrun_validate_yaml
|
||||
probe POST /validate "validate"
|
||||
# backfill search entries(#8)
|
||||
probe POST /workflows/backfill-search-entries "backfill"
|
||||
|
||||
echo ""
|
||||
echo "── 其他薄殼端點 ──"
|
||||
probe GET /me "whoami"
|
||||
probe POST /credentials "creds_push"
|
||||
probe POST /recipes "recipe"
|
||||
probe GET /kbdb/templates "kbdb_templates"
|
||||
probe POST /cypher/search "validate_local_graph(cypher/search)"
|
||||
|
||||
echo ""
|
||||
echo "=== 結果:${PASS} 端點存在 / ${FAIL} 異常 ==="
|
||||
if [ ${#DEAD[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "🔴 死端點(route 不存在,薄殼打了會 404):"
|
||||
for d in "${DEAD[@]}"; do echo " - $d"; done
|
||||
echo ""
|
||||
echo "→ 修法:對照 cli-mcp-capability-matrix.md,把薄殼改打存在的 route,或補 server route。"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 無死端點。"
|
||||
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/bin/bash
|
||||
# system-dev-template updater
|
||||
# 已安裝舊版的人,一鍵更新到新版。
|
||||
#
|
||||
# 核心安全原則:只覆蓋「模板/邏輯檔」,絕不碰「使用者資料檔」。
|
||||
# ✅ 可覆蓋:hooks/*.sh、commands/*.md、TEMPLATE-*、wiki/INDEX.md
|
||||
# ——這些由模板維護,使用者不會手改,新版直接換掉。
|
||||
# 🔒 絕不碰:wiki/status.md、mistakes.md、decisions-summary.md、TAXONOMY.md、.wikiignore、
|
||||
# settings.json、CLAUDE.md
|
||||
# ——這些是使用者自己填的內容,覆蓋=清空他的記憶與設定。
|
||||
#
|
||||
# 「第一次更新」的雞生蛋問題:
|
||||
# 舊版本機沒有 update.sh。所以第一次靠 README 那行 curl 從遠端抓這支腳本來跑。
|
||||
# 跑完它會把自己也更新進 scripts/update.sh,之後就能直接跑本機的 `bash scripts/update.sh`。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── i18n:依 locale 選語言,預設英文(curl | bash 常為 LANG=C)──
|
||||
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
|
||||
zh*|*Hant*|*Hans*) IS_ZH="yes" ;;
|
||||
*) IS_ZH="no" ;;
|
||||
esac
|
||||
t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; }
|
||||
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
|
||||
|
||||
REPO_RAW="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main"
|
||||
TEMPLATE_URL="$REPO_RAW/template"
|
||||
|
||||
UPDATED=()
|
||||
KEPT=()
|
||||
NEW=()
|
||||
TEMPLATED=()
|
||||
|
||||
# ── 版本比對:先看本機 vs 遠端,給使用者「值不值得更新」的判斷 ──
|
||||
LOCAL_VER="$(tn '(未知)' '(unknown)')"
|
||||
[ -f ".claude/VERSION" ] && LOCAL_VER="$(tr -d '[:space:]' < .claude/VERSION)"
|
||||
REMOTE_VER="$(curl -sSL "$TEMPLATE_URL/.claude/VERSION" 2>/dev/null | tr -d '[:space:]' || echo '')"
|
||||
|
||||
echo ""
|
||||
echo "🔄 system-dev-template updater"
|
||||
echo "================================="
|
||||
t " 本機版本:${LOCAL_VER}" " Local version: ${LOCAL_VER}"
|
||||
t " 最新版本:${REMOTE_VER:-取不到(檢查網路)}" \
|
||||
" Latest version: ${REMOTE_VER:-unavailable (check network)}"
|
||||
echo ""
|
||||
|
||||
if [ -z "$REMOTE_VER" ]; then
|
||||
t "❌ 取不到遠端版本,可能是網路問題。請稍後再試。" \
|
||||
"❌ Could not fetch the remote version (likely a network issue). Please try again later."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$LOCAL_VER" = "$REMOTE_VER" ]; then
|
||||
t "✅ 已是最新版(${LOCAL_VER}),不需更新。" \
|
||||
"✅ Already up to date (${LOCAL_VER}), nothing to update."
|
||||
t " (仍會同步模板邏輯檔,確保 hooks/commands 與最新一致。)" \
|
||||
" (Template logic files will still be synced to keep hooks/commands in line with the latest.)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── 工具函式 ───────────────────────────────────────
|
||||
# 覆蓋更新:模板/邏輯檔,無條件抓最新版蓋掉。
|
||||
update_file() {
|
||||
local dest="$1" src="$2"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
if [ -f "$dest" ]; then
|
||||
if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && [ -s "$dest.tmp" ]; then
|
||||
if cmp -s "$dest" "$dest.tmp"; then
|
||||
rm -f "$dest.tmp" # 內容相同,不算更新
|
||||
else
|
||||
mv "$dest.tmp" "$dest"
|
||||
UPDATED+=("$dest")
|
||||
fi
|
||||
else
|
||||
rm -f "$dest.tmp"
|
||||
t " ⚠️ 抓取失敗,保留原檔:$dest" " ⚠️ Download failed, keeping the original: $dest"
|
||||
fi
|
||||
else
|
||||
if curl -sSL "$src" -o "$dest" 2>/dev/null && [ -s "$dest" ]; then
|
||||
NEW+=("$dest") # 新功能:舊版沒有的檔
|
||||
else
|
||||
rm -f "$dest"
|
||||
t " ⚠️ 抓取失敗:$dest" " ⚠️ Download failed: $dest"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 保留:使用者資料檔,只記錄「有保留」,永遠不動。
|
||||
keep_file() {
|
||||
[ -f "$1" ] && KEPT+=("$1") || true
|
||||
}
|
||||
|
||||
# 客製檔:使用者一定會手填內容(如 pre-write-guard.sh)。
|
||||
# - 已存在 → 絕不覆蓋,但把最新模板版抓到 <檔名>.template.sh 旁邊,供使用者自行 diff 採納。
|
||||
# - 不存在 → 視同新檔,直接抓本體(第一次安裝才會走這條)。
|
||||
keep_with_template() {
|
||||
local dest="$1" src="$2"
|
||||
if [ -f "$dest" ]; then
|
||||
KEPT+=("$dest")
|
||||
local tmpl="${dest%.sh}.template.sh"
|
||||
if curl -sSL "$src" -o "$tmpl.tmp" 2>/dev/null && [ -s "$tmpl.tmp" ]; then
|
||||
if [ -f "$tmpl" ] && cmp -s "$tmpl" "$tmpl.tmp"; then
|
||||
rm -f "$tmpl.tmp" # 模板版沒變,不重複提示
|
||||
else
|
||||
mv "$tmpl.tmp" "$tmpl"
|
||||
TEMPLATED+=("$tmpl")
|
||||
fi
|
||||
else
|
||||
rm -f "$tmpl.tmp"
|
||||
fi
|
||||
else
|
||||
update_file "$dest" "$src" # 還沒裝過 → 當新檔處理
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 偵測已安裝哪些模組(依現有檔案判斷,更新只動已裝的)──
|
||||
HAS_WIKI=false
|
||||
HAS_SDD=false
|
||||
[ -d ".claude/wiki" ] && HAS_WIKI=true
|
||||
if [ -f ".claude/hooks/sdd-guard.sh" ] || [ -d "docs/3-specs/TEMPLATE-sdd" ]; then HAS_SDD=true; fi
|
||||
|
||||
t "📦 偵測到已安裝模組:" "📦 Detected installed modules:"
|
||||
$HAS_WIKI && echo " • LLM Wiki"
|
||||
$HAS_SDD && echo " • SDD"
|
||||
{ $HAS_WIKI || $HAS_SDD; } || \
|
||||
t " (未偵測到任何模組——這裡可能還沒安裝,請改跑 install.sh)" \
|
||||
" (No modules detected — nothing installed here yet; run install.sh instead.)"
|
||||
echo ""
|
||||
|
||||
# ── 客製檔:使用者手填的 guardrail,永不覆蓋(issue #3)──
|
||||
# pre-write-guard.sh 的定位是「空白客製模板,使用者沒配置前不提供保護」(CHANGELOG 1.2.0)。
|
||||
# 下游通常已塞滿自己的 enforcement,直接覆蓋=無聲關掉整套 guardrail。
|
||||
# 改為:保留原檔不動,新版範本另存 pre-write-guard.template.sh,由使用者自行 diff 採納。
|
||||
keep_with_template ".claude/hooks/pre-write-guard.sh" "$TEMPLATE_URL/.claude/hooks/pre-write-guard.sh"
|
||||
|
||||
# ── 模板/邏輯檔:覆蓋更新 ──────────────────────────
|
||||
# 共用 hook 與指引
|
||||
update_file ".claude/commands/issue-handle.md" "$TEMPLATE_URL/.claude/commands/issue-handle.md"
|
||||
update_file ".claude/VERSION" "$TEMPLATE_URL/.claude/VERSION"
|
||||
|
||||
if $HAS_WIKI; then
|
||||
# wiki 的「邏輯檔」:導航與 hooks,可覆蓋
|
||||
update_file ".claude/wiki/INDEX.md" "$TEMPLATE_URL/.claude/wiki/INDEX.md"
|
||||
update_file ".claude/hooks/session-start-recall.sh" "$TEMPLATE_URL/.claude/hooks/session-start-recall.sh"
|
||||
update_file ".claude/hooks/wiki-secret-scan.sh" "$TEMPLATE_URL/.claude/hooks/wiki-secret-scan.sh"
|
||||
update_file ".claude/commands/wiki-init.md" "$TEMPLATE_URL/.claude/commands/wiki-init.md"
|
||||
update_file ".claude/commands/wiki-capture.md" "$TEMPLATE_URL/.claude/commands/wiki-capture.md"
|
||||
update_file ".claude/commands/wiki-update.md" "$TEMPLATE_URL/.claude/commands/wiki-update.md"
|
||||
update_file ".claude/commands/wiki-recall.md" "$TEMPLATE_URL/.claude/commands/wiki-recall.md"
|
||||
# Cowork(claude.ai)的 wiki 整理 skill:規則檔,可覆蓋
|
||||
update_file "docs/SKILL.md" "$TEMPLATE_URL/docs/SKILL.md"
|
||||
|
||||
# wiki 的「使用者資料」:絕不碰
|
||||
keep_file ".claude/wiki/status.md"
|
||||
keep_file ".claude/wiki/mistakes.md"
|
||||
keep_file ".claude/wiki/decisions-summary.md"
|
||||
keep_file ".claude/wiki/TAXONOMY.md"
|
||||
keep_file ".claude/wiki/.wikiignore"
|
||||
fi
|
||||
|
||||
if $HAS_SDD; then
|
||||
# SDD 範本與 hook:可覆蓋
|
||||
update_file "docs/3-specs/TEMPLATE-sdd/design.md" "$TEMPLATE_URL/docs/3-specs/TEMPLATE-sdd/design.md"
|
||||
update_file "docs/3-specs/TEMPLATE-sdd/tasks.md" "$TEMPLATE_URL/docs/3-specs/TEMPLATE-sdd/tasks.md"
|
||||
update_file "docs/2-architecture/decisions/TEMPLATE-adr.md" "$TEMPLATE_URL/docs/2-architecture/decisions/TEMPLATE-adr.md"
|
||||
update_file ".claude/commands/sdd-check.md" "$TEMPLATE_URL/.claude/commands/sdd-check.md"
|
||||
update_file ".claude/hooks/sdd-guard.sh" "$TEMPLATE_URL/.claude/hooks/sdd-guard.sh"
|
||||
fi
|
||||
|
||||
# ── 自我更新:把最新的 update.sh 也抓下來(含 install.sh)──
|
||||
# 這兩支在 main/scripts/ 下,不在 template/。
|
||||
update_file "scripts/update.sh" "$REPO_RAW/scripts/update.sh"
|
||||
update_file "scripts/install.sh" "$REPO_RAW/scripts/install.sh"
|
||||
|
||||
chmod +x .claude/hooks/*.sh scripts/*.sh 2>/dev/null || true
|
||||
|
||||
# ── 使用者資料檔:絕不碰,但提醒「設定可能有新欄位要手動補」──
|
||||
keep_file ".claude/settings.json"
|
||||
keep_file "CLAUDE.md"
|
||||
|
||||
# ── 結果輸出 ───────────────────────────────────────
|
||||
echo ""
|
||||
echo "─────────────────────────────────"
|
||||
if [ ${#NEW[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "🆕 新功能(舊版沒有,已加入):" "🆕 New features (absent in the old version, now added):"
|
||||
for f in "${NEW[@]}"; do echo " + $f"; done
|
||||
fi
|
||||
if [ ${#UPDATED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "⬆️ 已更新(覆蓋成新版):" "⬆️ Updated (overwritten with the new version):"
|
||||
for f in "${UPDATED[@]}"; do echo " ~ $f"; done
|
||||
fi
|
||||
if [ ${#NEW[@]} -eq 0 ] && [ ${#UPDATED[@]} -eq 0 ]; then
|
||||
echo ""
|
||||
t "✨ 模板邏輯檔已全部最新,無需變動。" \
|
||||
"✨ All template logic files are already up to date — no changes needed."
|
||||
fi
|
||||
if [ ${#KEPT[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "🔒 完整保留(你的內容/設定,從未碰過):" \
|
||||
"🔒 Fully preserved (your content/settings, never touched):"
|
||||
for f in "${KEPT[@]}"; do echo " = $f"; done
|
||||
fi
|
||||
if [ ${#TEMPLATED[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "📋 客製檔有新版範本(你的原檔沒動,新版另存旁邊,請自行 diff 採納):" \
|
||||
"📋 Custom files have a new template version (your original is untouched; the new one is saved alongside — diff and adopt as you like):"
|
||||
for f in "${TEMPLATED[@]}"; do
|
||||
echo " → $f"
|
||||
t " 比對:diff \"${f%.template.sh}.sh\" \"$f\"" \
|
||||
" compare: diff \"${f%.template.sh}.sh\" \"$f\""
|
||||
done
|
||||
fi
|
||||
|
||||
# ── settings.json 提醒:新模組 hook 可能要手動補 ──
|
||||
if [ -f ".claude/settings.json" ]; then
|
||||
MISSING=()
|
||||
$HAS_WIKI && ! grep -q "session-start-recall.sh" .claude/settings.json && MISSING+=("SessionStart: session-start-recall.sh")
|
||||
$HAS_WIKI && ! grep -q "wiki-secret-scan.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): wiki-secret-scan.sh")
|
||||
$HAS_SDD && ! grep -q "sdd-guard.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): sdd-guard.sh")
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
t "📌 settings.json 是你的設定(沒動),但偵測到缺以下 hook,請手動補上:" \
|
||||
"📌 settings.json is yours (untouched), but these hooks are missing — please add them manually:"
|
||||
for h in "${MISSING[@]}"; do echo " • $h"; done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
t "🚀 更新完成:${LOCAL_VER} → ${REMOTE_VER}" "🚀 Update complete: ${LOCAL_VER} → ${REMOTE_VER}"
|
||||
t " 下次更新直接跑:bash scripts/update.sh" " Next time, just run: bash scripts/update.sh"
|
||||
t " 改了什麼看:CHANGELOG.md" " See what changed: CHANGELOG.md"
|
||||
echo ""
|
||||
@@ -0,0 +1 @@
|
||||
1.12.0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user