@zhama/tego-plugin-sdk 1.36.2 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/agent/RULES.md +123 -0
  2. package/agent/guides/access.md +39 -0
  3. package/agent/guides/backend.md +76 -0
  4. package/agent/guides/claw.md +18 -0
  5. package/agent/guides/desktop-web.md +48 -0
  6. package/agent/guides/frontend.md +83 -0
  7. package/agent/guides/host-api-versions.md +34 -0
  8. package/agent/guides/i18n.md +17 -0
  9. package/agent/guides/manifest.md +26 -0
  10. package/agent/guides/migrations.md +17 -0
  11. package/agent/guides/storage.md +18 -0
  12. package/agent/guides/testing.md +17 -0
  13. package/agent/guides/ui.md +22 -0
  14. package/agent/reference/plugin-sdk-api.md +561 -0
  15. package/bin/tego-plugin-pack.mjs +1 -1
  16. package/bin/tego-plugin.mjs +4 -2
  17. package/dist/chunk-2NTAWYCG.js +1 -0
  18. package/dist/chunk-2P6UEAPV.cjs +1 -0
  19. package/dist/chunk-45Y4CR4R.cjs +1 -0
  20. package/dist/{chunk-PWSTCQRN.cjs → chunk-4LKRXDYY.cjs} +13 -12
  21. package/dist/{chunk-7XHF3YSK.js → chunk-56QOZQTY.js} +1 -1
  22. package/dist/{chunk-C2OCOPZQ.cjs → chunk-6HNG5OZI.cjs} +1 -1
  23. package/dist/{chunk-2ORO36BY.js → chunk-DEGQDJF6.js} +1 -1
  24. package/dist/chunk-EIVA5BR6.js +1 -0
  25. package/dist/{chunk-P2Q2AKWP.js → chunk-FCYAAOHC.js} +17 -16
  26. package/dist/chunk-HZ4YWRYX.cjs +5 -0
  27. package/dist/chunk-I5Z3ECC4.js +5 -0
  28. package/dist/chunk-INCA3GFW.js +1 -0
  29. package/dist/chunk-IVJECPGG.js +1 -0
  30. package/dist/{chunk-KMN5RHLX.cjs → chunk-MWMGJJ74.cjs} +1 -1
  31. package/dist/chunk-N4SXZZPY.cjs +1 -0
  32. package/dist/chunk-SKNPNPJP.cjs +1 -0
  33. package/dist/dev.cjs +88 -5
  34. package/dist/dev.d.cts +37 -1
  35. package/dist/dev.d.ts +37 -1
  36. package/dist/dev.js +91 -8
  37. package/dist/doctor.cjs +8 -0
  38. package/dist/doctor.d.cts +62 -0
  39. package/dist/doctor.d.ts +62 -0
  40. package/dist/doctor.js +8 -0
  41. package/dist/frontend.d.cts +33 -1
  42. package/dist/frontend.d.ts +33 -1
  43. package/dist/hono.cjs +1 -0
  44. package/dist/hono.d.cts +84 -0
  45. package/dist/hono.d.ts +84 -0
  46. package/dist/hono.js +1 -0
  47. package/dist/index.cjs +1 -1
  48. package/dist/index.d.cts +6 -1
  49. package/dist/index.d.ts +6 -1
  50. package/dist/index.js +1 -1
  51. package/dist/manifest.cjs +1 -1
  52. package/dist/manifest.d.cts +55 -2
  53. package/dist/manifest.d.ts +55 -2
  54. package/dist/manifest.js +1 -1
  55. package/dist/migrations-sync.cjs +1 -0
  56. package/dist/migrations-sync.d.cts +20 -0
  57. package/dist/migrations-sync.d.ts +20 -0
  58. package/dist/migrations-sync.js +1 -0
  59. package/dist/pack.cjs +1 -1
  60. package/dist/pack.d.cts +5 -0
  61. package/dist/pack.d.ts +5 -0
  62. package/dist/pack.js +1 -1
  63. package/dist/publish.cjs +1 -1
  64. package/dist/publish.js +1 -1
  65. package/dist/react.cjs +1 -0
  66. package/dist/react.d.cts +101 -0
  67. package/dist/react.d.ts +101 -0
  68. package/dist/react.js +1 -0
  69. package/dist/scaffold.cjs +671 -17
  70. package/dist/scaffold.d.cts +42 -18
  71. package/dist/scaffold.d.ts +42 -18
  72. package/dist/scaffold.js +673 -19
  73. package/dist/server-runtime.cjs +1 -1
  74. package/dist/server-runtime.d.cts +27 -2
  75. package/dist/server-runtime.d.ts +27 -2
  76. package/dist/server-runtime.js +1 -1
  77. package/dist/testing.cjs +6 -2
  78. package/dist/testing.d.cts +19 -1
  79. package/dist/testing.d.ts +19 -1
  80. package/dist/testing.js +6 -2
  81. package/dist/verify-FF2RD2MV.cjs +1 -0
  82. package/dist/verify-W7ZTUCO4.js +1 -0
  83. package/dist/vite.cjs +1 -1
  84. package/dist/vite.d.cts +27 -1
  85. package/dist/vite.d.ts +27 -1
  86. package/dist/vite.js +1 -1
  87. package/package.json +31 -3
  88. package/dist/chunk-3WA3M347.cjs +0 -1
  89. package/dist/chunk-5CU33JVY.js +0 -1
  90. package/dist/chunk-7K37PVGV.js +0 -5
  91. package/dist/chunk-BTHXQWHT.js +0 -1
  92. package/dist/chunk-ROFUQLOZ.cjs +0 -1
  93. package/dist/chunk-W7C357A6.cjs +0 -5
package/dist/dev.js CHANGED
@@ -1,9 +1,9 @@
1
- var Ne=Object.defineProperty;var o=(e,t)=>Ne(e,"name",{value:t,configurable:!0});import{signPluginContext as Pe}from"./chunk-WLTASJF6.js";import{readManifest as $e,readPluginLocales as Oe,buildPluginBackend as Re}from"./chunk-P2Q2AKWP.js";import"./chunk-7K37PVGV.js";import{applyConfigDefaults as se}from"./chunk-2ORO36BY.js";import"./chunk-5CU33JVY.js";import{resolvePluginParts as Le}from"./chunk-BTHXQWHT.js";import"./chunk-7XHF3YSK.js";import{PLUGIN_CONTEXT_HEADERS as D}from"./chunk-QKSPPMVN.js";import"./chunk-VMCGDDGP.js";import"./chunk-SNUCO2MK.js";import{randomUUID as Ue,randomBytes as oe,createHash as Ce}from"crypto";import{existsSync as H,realpathSync as Be,watch as Ve}from"fs";import{readdir as He,readFile as M,mkdir as W,copyFile as ie,rm as X,writeFile as Y,stat as Me}from"fs/promises";import{createServer as Fe,request as je}from"http";import{createRequire as Ge}from"module";import{join as y,resolve as T,isAbsolute as Ke,sep as ce,dirname as N,relative as le}from"path";import{spawn as Je}from"child_process";import{createServer as ze}from"net";var q="x-tego-dev-user-id",Q="x-tego-dev-roles",P="dev-tenant",ue="00000000-0000-4000-8000-000000000001";function de(e){const t=o(a=>(Array.isArray(a)?a[0]:a)?.trim()??"","single"),n=t(e[q])||ue;let r=[];try{const a=JSON.parse(t(e[Q])||"[]");Array.isArray(a)&&(r=a.filter(i=>typeof i=="string"))}catch{}return{userId:n,roles:r}}o(de,"devCallerOf");function ge(e,t,n,r=Date.now()){const a={};for(const[s,c]of Object.entries(e)){const u=s.toLowerCase();c===void 0||u.startsWith("x-tego-")||u==="cookie"||u==="authorization"||u==="host"||u==="connection"||(a[u]=Array.isArray(c)?c.join(", "):c)}const i=JSON.stringify(t.roles),l=String(r);return a[D.userId]=t.userId,a[D.tenantId]=P,a[D.roles]=i,a[D.issuedAt]=l,a[D.signature]=Pe({userId:t.userId,tenantId:P,rolesHeader:i,avatarId:"",issuedAt:l},n),a}o(ge,"gatewayHeaders");var We="tego_dev_migrations";function pe(e){return`tego_dev_${e.toLowerCase().replace(/[^a-z0-9_]/g,"_")}`.slice(0,63)}o(pe,"devSchemaName");function fe(e,t){const n=new URL(e);n.searchParams.delete("options");const r=n.search.replace(/^\?/,"");return n.search=`?${r?`${r}&`:""}options=${encodeURIComponent(`-c search_path=${t}`)}`,n.toString()}o(fe,"devDatabaseUrl");async function Xe(e){let t;try{t=await import("pg")}catch(a){throw new Error(`The local backend needs the "pg" driver; add it to the plugin's dependencies.`,{cause:a})}const n=t.default?.Client??t.Client;if(!n)throw new Error('The "pg" module has no Client constructor.');const r=new n({connectionString:e});try{await r.connect()}catch(a){throw new Error(`Cannot connect to DATABASE_URL (${Z(e)}): ${a instanceof Error?a.message:String(a)}`,{cause:a})}return r}o(Xe,"connect");function Z(e){try{const t=new URL(e);return t.password&&(t.password="***"),t.toString()}catch{return"<unparseable DATABASE_URL>"}}o(Z,"redactUrl");var he=o(e=>`"${e.replace(/"/g,'""')}"`,"quote");async function me(e){const t=await He(e.migrationsDir).then(i=>i.filter(l=>l.endsWith(".sql")).sort(),i=>{if(i.code==="ENOENT")return[];throw i}),n=await Xe(e.databaseUrl),r=he(e.schema),a=`${r}.${he(We)}`;try{await n.query(`CREATE SCHEMA IF NOT EXISTS ${r}`),await n.query(`CREATE TABLE IF NOT EXISTS ${a} (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`);const i=new Set((await n.query(`SELECT name FROM ${a}`)).rows.map(s=>String(s.name))),l=[];for(const s of t){if(i.has(s))continue;const c=await M(y(e.migrationsDir,s),"utf8");await n.query("BEGIN");try{await n.query(`SET LOCAL search_path TO ${r}`),await n.query("SELECT set_config('app.tenant_id', $1, true)",[e.tenantId]),await n.query(c),await n.query(`INSERT INTO ${a} (name) VALUES ($1)`,[s]),await n.query("COMMIT")}catch(u){throw await n.query("ROLLBACK").catch(()=>{}),new Error(`Migration ${s} failed: ${u instanceof Error?u.message:String(u)}`,{cause:u})}l.push(s)}return l}finally{await n.end().catch(()=>{})}}o(me,"applyDevMigrations");var Ye=250,qe=5e3;function Qe(){return new Promise((e,t)=>{const n=ze();n.once("error",t),n.listen(0,"127.0.0.1",()=>{const r=n.address(),a=typeof r=="object"&&r?r.port:0;n.close(()=>e(a))})})}o(Qe,"freePort");function ye(e){return!e||e.exitCode!==null||e.signalCode!==null?Promise.resolve():new Promise(t=>{const n=setTimeout(()=>{e.kill("SIGKILL"),t()},qe);e.once("exit",()=>{clearTimeout(n),t()}),e.kill("SIGTERM")})}o(ye,"stopChild");async function Ze(e){const{part:t,log:n}=e;if(t.runtime==="python")throw new Error("The local preview runs TypeScript backends only; a python backend runs through the platform.");const r=await Qe(),a=pe(e.pluginId),i=T(e.root,t.migrationsDirPath),l=y(e.outDir,t.entryPath),s={...process.env,NODE_ENV:"development",TEGO_PLUGIN_ID:e.pluginId,TEGO_PLUGIN_VERSION:e.version,TEGO_PLUGIN_INSTALL_ID:"dev-install",TEGO_TENANT_ID:P,TEGO_PLUGIN_PORT:String(r),TEGO_PLUGIN_HEALTH_PATH:t.healthPath,TEGO_PLUGIN_HOST_API_URL:e.hostApiUrl,TEGO_PLUGIN_HOST_API_TOKEN:e.hostApiToken,TEGO_PLUGIN_GATEWAY_KEY:e.gatewayKey,DATABASE_URL:fe(e.databaseUrl,a)};let c=null,u=!1;async function p(){await X(e.outDir,{recursive:!0,force:!0}),await Re(t,{root:e.root,outDir:e.outDir});const h=await me({databaseUrl:e.databaseUrl,schema:a,migrationsDir:i,tenantId:P});for(const S of h)n(`[backend] applied migration ${S}`)}o(p,"build");function w(){const h=Je(process.execPath,[l],{env:s,stdio:["ignore","pipe","pipe"]});c=h,h.stdout?.on("data",S=>process.stdout.write(S)),h.stderr?.on("data",S=>process.stderr.write(S)),h.on("exit",S=>{c===h&&!u&&(c=null,n(`[backend] exited (code ${S??"null"}); it starts again on the next save`))})}o(w,"start"),await p(),w(),n(`[backend] ${e.pluginId} on 127.0.0.1:${r}, schema ${a} in ${Z(e.databaseUrl)}`);let v=null,m=null,A=!1;const L=o(async()=>{if(m){A=!0;return}m=(async()=>{try{await p();const h=c;c=null,await ye(h),u||(w(),n("[backend] rebuilt and restarted"))}catch(h){n(`[backend] rebuild failed, the previous build keeps running: ${h instanceof Error?h.message:String(h)}`)}})(),await m,m=null,A&&!u&&(A=!1,await L())},"rebuild"),U=[];for(const h of[t.dir,"shared"]){const S=T(e.root,h);H(S)&&U.push(Ve(S,{recursive:!0},(x,I)=>{I&&/(^|[\\/])(node_modules|dist)([\\/]|$)/.test(String(I))||(v&&clearTimeout(v),v=setTimeout(()=>void L(),Ye))}))}return{port:r,schema:a,async close(){u=!0,v&&clearTimeout(v);for(const h of U)h.close();await m,await ye(c)}}}o(Ze,"startDevBackend");var ee="/__tego/host",te="/__tego/files",et=1024*1024;async function ne(e,t=1/0){const n=[];let r=0;for await(const a of e){if(r+=a.length,r>t)throw Object.assign(new Error("body too large"),{status:413});n.push(a)}return Buffer.concat(n)}o(ne,"readBody");function f(e,t,n){if(n===void 0){e.writeHead(t),e.end();return}e.writeHead(t,{"content-type":"application/json"}),e.end(JSON.stringify(n))}o(f,"send");function ae(e,t){const n=T(e),r=T(n,t);if(!t||Ke(t)||!r.startsWith(n+ce))throw Object.assign(new Error(`invalid storage key ${JSON.stringify(t)}`),{status:400});return r}o(ae,"storagePath");var $=o(e=>`${e}.tego-meta.json`,"metaOf");async function O(e){const t=await Me(e).catch(()=>null);if(!t?.isFile())return null;const n=await M($(e),"utf8").then(r=>JSON.parse(r),()=>({}));return{size:t.size,contentType:n.contentType??null,versionTag:Ce("sha1").update(`${t.size}:${t.mtimeMs}`).digest("hex")}}o(O,"head");async function we(e,t,n){await W(N(e),{recursive:!0}),await Y(e,t),await Y($(e),JSON.stringify({contentType:n??null}))}o(we,"put");async function ve(e,t,n){const r=new URL(e.url??"/","http://preview.local");try{if(r.pathname.startsWith(te)){const s=ae(n.storageDir,r.searchParams.get("key")??""),c=await O(s);if(!c)return f(t,404);t.writeHead(200,{"content-type":c.contentType??"application/octet-stream"}),t.end(await M(s));return}if(e.headers.authorization!==`Bearer ${n.token}`)return f(t,401,{error:"bad host API token"});const a=r.pathname.slice(ee.length)||"/",i=o(async()=>JSON.parse((await ne(e,et)).toString("utf8")||"{}"),"json"),l=o(s=>ae(n.storageDir,String(s??"")),"keyFile");switch(a){case"/config":return f(t,200,{data:{values:n.config()}});case"/log":{const{level:s,message:c,fields:u}=await i();return n.log(`[backend ${String(s??"info")}] ${String(c??"")}${u?` ${JSON.stringify(u)}`:""}`),f(t,200,{data:{}})}case"/events":{const{name:s,payload:c}=await i();return n.log(`[event] ${String(s)} ${JSON.stringify(c??null)}`),f(t,200,{data:{}})}case"/notifications":return n.log(`[notification] ${JSON.stringify(await i())}`),f(t,200,{data:{id:Ue()}});case"/directory/identity":{const{userId:s}=await i(),c=n.users().get(String(s));return f(t,200,{data:{active:!!c,roleNames:c??[],memberships:[],checkedAt:new Date().toISOString()}})}case"/directory/list":{const{kind:s,search:c,ids:u}=await i(),p=typeof c=="string"?c.toLowerCase():"",w=Array.isArray(u)?new Set(u.map(String)):void 0,v=s==="users"?[...n.users().keys()].filter(m=>(!w||w.has(m))&&m.toLowerCase().includes(p)).map(m=>({id:m,displayName:m,active:!0})):[];return f(t,200,{data:{items:v,hasMore:!1}})}case"/directory/match":{const{userId:s,principals:c}=await i(),u=Array.isArray(c)?c:[];return f(t,200,{data:{active:n.users().has(String(s)),matched:u.flatMap((p,w)=>p.kind==="user"&&p.id===s?[w]:[]),checkedAt:new Date().toISOString()}})}case"/directory/validate":{const{principals:s}=await i(),c=Array.isArray(s)?s:[],u=n.users();return f(t,200,{data:{invalid:c.flatMap((p,w)=>p.kind==="user"&&u.has(String(p.id))?[]:[w])}})}case"/storage/get":case"/storage/stream":{const s=l(r.searchParams.get("key"));if(e.method==="POST"&&a==="/storage/stream")return await we(s,await ne(e),e.headers["content-type"]),f(t,200,{data:{}});const c=await O(s);if(!c)return f(t,404);let u=await M(s);const p=Number(r.searchParams.get("bytes"));Number.isFinite(p)&&p>0&&(u=u.subarray(0,p)),t.writeHead(200,{"content-type":c.contentType??"application/octet-stream"}),t.end(u);return}case"/storage/put":return await we(l(r.searchParams.get("key")),await ne(e),e.headers["content-type"]),f(t,200,{data:{}});case"/storage/head":{const s=await O(l((await i()).key));return s?f(t,200,{data:s}):f(t,404)}case"/storage/delete":{const s=l((await i()).key);return await X(s,{force:!0}),await X($(s),{force:!0}),f(t,200,{data:{}})}case"/storage/copy":{const{sourceKey:s,destinationKey:c}=await i(),u=l(s),p=l(c);return await O(u)?(await W(N(p),{recursive:!0}),await ie(u,p),await ie($(u),$(p)).catch(()=>{}),f(t,200,{data:await O(p)})):f(t,404)}case"/storage/signed-url":{const{key:s}=await i();l(s);const c=`${te}?key=${encodeURIComponent(String(s))}`;return f(t,200,{data:{url:`${n.origin()}${c}`,path:c}})}default:return n.log(`[host] ${a} is not available in local preview`),f(t,501,{error:{code:"not_available_in_preview",message:`${a} is not available in local preview`}})}}catch(a){const i=a.status??500;f(t,i,{error:{message:a instanceof Error?a.message:String(a)}})}}o(ve,"handleFakeHost");var Se="@tego/plugin-sdk",tt=JSON.stringify(Se),nt=JSON.stringify(`${Se}/frontend`);function Ee(e){return[{path:"index.html",contents:rt(e)},{path:"main.tsx",contents:it(e)},{path:"shell.css",contents:st},{path:"plugin-surface.tsx",contents:ot}]}o(Ee,"shellFiles");var at=o(e=>e.replace(/[&<>"]/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[t]),"escapeHtml");function rt({displayName:e}){return`<!doctype html>
1
+ var Me=Object.defineProperty;var s=(e,t)=>Me(e,"name",{value:t,configurable:!0});import{signPluginContext as Fe}from"./chunk-WLTASJF6.js";import{readManifest as Ke,readPluginLocales as Je,buildPluginBackend as ze}from"./chunk-FCYAAOHC.js";import"./chunk-I5Z3ECC4.js";import{applyConfigDefaults as fe}from"./chunk-DEGQDJF6.js";import"./chunk-EIVA5BR6.js";import"./chunk-2NTAWYCG.js";import{resolvePluginParts as We}from"./chunk-IVJECPGG.js";import"./chunk-INCA3GFW.js";import"./chunk-56QOZQTY.js";import{PLUGIN_CONTEXT_HEADERS as L}from"./chunk-QKSPPMVN.js";import"./chunk-VMCGDDGP.js";import"./chunk-SNUCO2MK.js";import{randomUUID as qe,randomBytes as pe,createHash as Xe}from"crypto";import{existsSync as K,realpathSync as Ye,watch as Qe}from"fs";import{readdir as Ze,readFile as J,mkdir as ee,copyFile as ge,rm as te,writeFile as ne,stat as et}from"fs/promises";import{createServer as tt,request as nt}from"http";import{createRequire as rt}from"module";import{join as w,resolve as x,isAbsolute as at,sep as he,dirname as O,relative as me}from"path";import{spawn as ot}from"child_process";import{createServer as st}from"net";var re="x-tego-dev-user-id",ae="x-tego-dev-roles",C="dev-tenant",ve="00000000-0000-4000-8000-000000000001";function we(e){const t=s(r=>(Array.isArray(r)?r[0]:r)?.trim()??"","single"),n=t(e[re])||ve;let a=[];try{const r=JSON.parse(t(e[ae])||"[]");Array.isArray(r)&&(a=r.filter(i=>typeof i=="string"))}catch{}return{userId:n,roles:a}}s(we,"devCallerOf");function ye(e,t,n,a=Date.now()){const r={};for(const[o,l]of Object.entries(e)){const d=o.toLowerCase();l===void 0||d.startsWith("x-tego-")||d==="cookie"||d==="authorization"||d==="host"||d==="connection"||(r[d]=Array.isArray(l)?l.join(", "):l)}const i=JSON.stringify(t.roles),u=String(a);return r[L.userId]=t.userId,r[L.tenantId]=C,r[L.roles]=i,r[L.issuedAt]=u,r[L.signature]=Fe({userId:t.userId,tenantId:C,rolesHeader:i,avatarId:"",issuedAt:u},n),r}s(ye,"gatewayHeaders");var it="tego_dev_migrations";function Se(e){return`tego_dev_${e.toLowerCase().replace(/[^a-z0-9_]/g,"_")}`.slice(0,63)}s(Se,"devSchemaName");function be(e,t){const n=new URL(e);n.searchParams.delete("options");const a=n.search.replace(/^\?/,"");return n.search=`?${a?`${a}&`:""}options=${encodeURIComponent(`-c search_path=${t}`)}`,n.toString()}s(be,"devDatabaseUrl");async function ct(e){let t;try{t=await import("pg")}catch(r){throw new Error(`The local backend needs the "pg" driver; add it to the plugin's dependencies.`,{cause:r})}const n=t.default?.Client??t.Client;if(!n)throw new Error('The "pg" module has no Client constructor.');const a=new n({connectionString:e});try{await a.connect()}catch(r){throw new Error(`Cannot connect to DATABASE_URL (${oe(e)}): ${r instanceof Error?r.message:String(r)}`,{cause:r})}return a}s(ct,"connect");function oe(e){try{const t=new URL(e);return t.password&&(t.password="***"),t.toString()}catch{return"<unparseable DATABASE_URL>"}}s(oe,"redactUrl");var Ee=s(e=>`"${e.replace(/"/g,'""')}"`,"quote");async function ke(e){const t=await Ze(e.migrationsDir).then(i=>i.filter(u=>u.endsWith(".sql")).sort(),i=>{if(i.code==="ENOENT")return[];throw i}),n=await ct(e.databaseUrl),a=Ee(e.schema),r=`${a}.${Ee(it)}`;try{await n.query(`CREATE SCHEMA IF NOT EXISTS ${a}`),await n.query(`CREATE TABLE IF NOT EXISTS ${r} (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`);const i=new Set((await n.query(`SELECT name FROM ${r}`)).rows.map(o=>String(o.name))),u=[];for(const o of t){if(i.has(o))continue;const l=await J(w(e.migrationsDir,o),"utf8");await n.query("BEGIN");try{await n.query(`SET LOCAL search_path TO ${a}`),await n.query("SELECT set_config('app.tenant_id', $1, true)",[e.tenantId]),await n.query(l),await n.query(`INSERT INTO ${r} (name) VALUES ($1)`,[o]),await n.query("COMMIT")}catch(d){throw await n.query("ROLLBACK").catch(()=>{}),new Error(`Migration ${o} failed: ${d instanceof Error?d.message:String(d)}`,{cause:d})}u.push(o)}return u}finally{await n.end().catch(()=>{})}}s(ke,"applyDevMigrations");var lt=250,ut=5e3;function dt(){return new Promise((e,t)=>{const n=st();n.once("error",t),n.listen(0,"127.0.0.1",()=>{const a=n.address(),r=typeof a=="object"&&a?a.port:0;n.close(()=>e(r))})})}s(dt,"freePort");function Te(e){return!e||e.exitCode!==null||e.signalCode!==null?Promise.resolve():new Promise(t=>{const n=setTimeout(()=>{e.kill("SIGKILL"),t()},ut);e.once("exit",()=>{clearTimeout(n),t()}),e.kill("SIGTERM")})}s(Te,"stopChild");async function ft(e){const{part:t,log:n}=e;if(t.runtime==="python")throw new Error("The local preview runs TypeScript backends only; a python backend runs through the platform.");const a=await dt(),r=Se(e.pluginId),i=x(e.root,t.migrationsDirPath),u=w(e.outDir,t.entryPath),o={...process.env,NODE_ENV:"development",TEGO_PLUGIN_ID:e.pluginId,TEGO_PLUGIN_VERSION:e.version,TEGO_PLUGIN_INSTALL_ID:"dev-install",TEGO_TENANT_ID:C,TEGO_PLUGIN_PORT:String(a),TEGO_PLUGIN_HEALTH_PATH:t.healthPath,TEGO_PLUGIN_HOST_API_URL:e.hostApiUrl,TEGO_PLUGIN_HOST_API_TOKEN:e.hostApiToken,TEGO_PLUGIN_GATEWAY_KEY:e.gatewayKey,DATABASE_URL:be(e.databaseUrl,r)};let l=null,d=!1;async function p(){await te(e.outDir,{recursive:!0,force:!0}),await ze(t,{root:e.root,outDir:e.outDir});const h=await ke({databaseUrl:e.databaseUrl,schema:r,migrationsDir:i,tenantId:C});for(const E of h)n(`[backend] applied migration ${E}`)}s(p,"build");function y(){const h=ot(process.execPath,[u],{env:o,stdio:["ignore","pipe","pipe"]});l=h,h.stdout?.on("data",E=>process.stdout.write(E)),h.stderr?.on("data",E=>process.stderr.write(E)),h.on("exit",E=>{l===h&&!d&&(l=null,n(`[backend] exited (code ${E??"null"}); it starts again on the next save`))})}s(y,"start"),await p(),y(),n(`[backend] ${e.pluginId} on 127.0.0.1:${a}, schema ${r} in ${oe(e.databaseUrl)}`);let b=null,v=null,R=!1;const H=s(async()=>{if(v){R=!0;return}v=(async()=>{try{await p();const h=l;l=null,await Te(h),d||(y(),n("[backend] rebuilt and restarted"))}catch(h){n(`[backend] rebuild failed, the previous build keeps running: ${h instanceof Error?h.message:String(h)}`)}})(),await v,v=null,R&&!d&&(R=!1,await H())},"rebuild"),V=[];for(const h of[t.dir,"shared"]){const E=x(e.root,h);K(E)&&V.push(Qe(E,{recursive:!0},(P,I)=>{I&&/(^|[\\/])(node_modules|dist)([\\/]|$)/.test(String(I))||(b&&clearTimeout(b),b=setTimeout(()=>void H(),lt))}))}return{port:a,schema:r,async close(){d=!0,b&&clearTimeout(b);for(const h of V)h.close();await v,await Te(l)}}}s(ft,"startDevBackend");var se="/__tego/host",ie="/__tego/files",pt=1024*1024;async function ce(e,t=1/0){const n=[];let a=0;for await(const r of e){if(a+=r.length,a>t)throw Object.assign(new Error("body too large"),{status:413});n.push(r)}return Buffer.concat(n)}s(ce,"readBody");function g(e,t,n){if(n===void 0){e.writeHead(t),e.end();return}e.writeHead(t,{"content-type":"application/json"}),e.end(JSON.stringify(n))}s(g,"send");function le(e,t){const n=x(e),a=x(n,t);if(!t||at(t)||!a.startsWith(n+he))throw Object.assign(new Error(`invalid storage key ${JSON.stringify(t)}`),{status:400});return a}s(le,"storagePath");var U=s(e=>`${e}.tego-meta.json`,"metaOf");async function B(e){const t=await et(e).catch(()=>null);if(!t?.isFile())return null;const n=await J(U(e),"utf8").then(a=>JSON.parse(a),()=>({}));return{size:t.size,contentType:n.contentType??null,versionTag:Xe("sha1").update(`${t.size}:${t.mtimeMs}`).digest("hex")}}s(B,"head");async function Ie(e,t,n){await ee(O(e),{recursive:!0}),await ne(e,t),await ne(U(e),JSON.stringify({contentType:n??null}))}s(Ie,"put");async function xe(e,t,n){const a=new URL(e.url??"/","http://preview.local");try{if(a.pathname.startsWith(ie)){const o=le(n.storageDir,a.searchParams.get("key")??""),l=await B(o);if(!l)return g(t,404);t.writeHead(200,{"content-type":l.contentType??"application/octet-stream"}),t.end(await J(o));return}if(e.headers.authorization!==`Bearer ${n.token}`)return g(t,401,{error:"bad host API token"});const r=a.pathname.slice(se.length)||"/",i=s(async()=>JSON.parse((await ce(e,pt)).toString("utf8")||"{}"),"json"),u=s(o=>le(n.storageDir,String(o??"")),"keyFile");switch(r){case"/config":return g(t,200,{data:{values:n.config()}});case"/log":{const{level:o,message:l,fields:d}=await i();return n.log(`[backend ${String(o??"info")}] ${String(l??"")}${d?` ${JSON.stringify(d)}`:""}`),g(t,200,{data:{}})}case"/events":{const{name:o,payload:l}=await i();return n.log(`[event] ${String(o)} ${JSON.stringify(l??null)}`),g(t,200,{data:{}})}case"/notifications":return n.log(`[notification] ${JSON.stringify(await i())}`),g(t,200,{data:{id:qe()}});case"/directory/identity":{const{userId:o}=await i(),l=n.users().get(String(o));return g(t,200,{data:{active:!!l,roleNames:l??[],memberships:[],checkedAt:new Date().toISOString()}})}case"/directory/list":{const{kind:o,search:l,ids:d}=await i(),p=typeof l=="string"?l.toLowerCase():"",y=Array.isArray(d)?new Set(d.map(String)):void 0,b=o==="users"?[...n.users().keys()].filter(v=>(!y||y.has(v))&&v.toLowerCase().includes(p)).map(v=>({id:v,displayName:v,active:!0})):[];return g(t,200,{data:{items:b,hasMore:!1}})}case"/directory/match":{const{userId:o,principals:l}=await i(),d=Array.isArray(l)?l:[];return g(t,200,{data:{active:n.users().has(String(o)),matched:d.flatMap((p,y)=>p.kind==="user"&&p.id===o?[y]:[]),checkedAt:new Date().toISOString()}})}case"/directory/validate":{const{principals:o}=await i(),l=Array.isArray(o)?o:[],d=n.users();return g(t,200,{data:{invalid:l.flatMap((p,y)=>p.kind==="user"&&d.has(String(p.id))?[]:[y])}})}case"/storage/get":case"/storage/stream":{const o=u(a.searchParams.get("key"));if(e.method==="POST"&&r==="/storage/stream")return await Ie(o,await ce(e),e.headers["content-type"]),g(t,200,{data:{}});const l=await B(o);if(!l)return g(t,404);let d=await J(o);const p=Number(a.searchParams.get("bytes"));Number.isFinite(p)&&p>0&&(d=d.subarray(0,p)),t.writeHead(200,{"content-type":l.contentType??"application/octet-stream"}),t.end(d);return}case"/storage/put":return await Ie(u(a.searchParams.get("key")),await ce(e),e.headers["content-type"]),g(t,200,{data:{}});case"/storage/head":{const o=await B(u((await i()).key));return o?g(t,200,{data:o}):g(t,404)}case"/storage/delete":{const o=u((await i()).key);return await te(o,{force:!0}),await te(U(o),{force:!0}),g(t,200,{data:{}})}case"/storage/copy":{const{sourceKey:o,destinationKey:l}=await i(),d=u(o),p=u(l);return await B(d)?(await ee(O(p),{recursive:!0}),await ge(d,p),await ge(U(d),U(p)).catch(()=>{}),g(t,200,{data:await B(p)})):g(t,404)}case"/storage/signed-url":{const{key:o}=await i();u(o);const l=`${ie}?key=${encodeURIComponent(String(o))}`;return g(t,200,{data:{url:`${n.origin()}${l}`,path:l}})}default:return n.log(`[host] ${r} is not available in local preview`),g(t,501,{error:{code:"not_available_in_preview",message:`${r} is not available in local preview`}})}}catch(r){const i=r.status??500;g(t,i,{error:{message:r instanceof Error?r.message:String(r)}})}}s(xe,"handleFakeHost");var _e="@tego/plugin-sdk",gt=JSON.stringify(_e),ht=JSON.stringify(`${_e}/frontend`);function Pe(e){return[{path:"index.html",contents:vt(e)},{path:"main.tsx",contents:St(e)},{path:"shell.css",contents:wt},{path:"plugin-surface.tsx",contents:yt}]}s(Pe,"shellFiles");var mt=s(e=>e.replace(/[&<>"]/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[t]),"escapeHtml");function vt({displayName:e}){return`<!doctype html>
2
2
  <html lang="en" class="h-full">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>${at(e)} \xB7 local preview</title>
6
+ <title>${mt(e)} \xB7 local preview</title>
7
7
  </head>
8
8
  <body class="h-full">
9
9
  <div id="tego-dev-bar"></div>
@@ -11,7 +11,7 @@ var Ne=Object.defineProperty;var o=(e,t)=>Ne(e,"name",{value:t,configurable:!0})
11
11
  <script type="module" src="/main.tsx"><\/script>
12
12
  </body>
13
13
  </html>
14
- `}o(rt,"indexHtml");var st=`@tailwind base;
14
+ `}s(vt,"indexHtml");var wt=`@tailwind base;
15
15
  @tailwind components;
16
16
  @tailwind utilities;
17
17
 
@@ -31,7 +31,7 @@ body {
31
31
  min-height: 0;
32
32
  overflow: auto;
33
33
  }
34
- `,ot=`import type { ReactNode } from 'react';
34
+ `,yt=`import type { ReactNode } from 'react';
35
35
 
36
36
  function Unavailable(): ReactNode {
37
37
  return (
@@ -46,12 +46,12 @@ export const ChatPanel = Unavailable;
46
46
  export function useTopic() {
47
47
  return { topic: undefined, topics: [], loading: false };
48
48
  }
49
- `;function it(e){const t=JSON.stringify({pluginId:e.pluginId,displayName:e.displayName,nav:e.nav.map(r=>({key:r.key,labelKey:r.labelKey,path:r.path??"/"})),config:e.config,locales:e.locales,userHeader:q,rolesHeader:Q,defaultUser:ue},null,2),n=e.entry?`import plugin from ${JSON.stringify(e.entry)};`:"const plugin = undefined;";return`// Generated by \`tego-plugin dev\` on every start; edits here are overwritten.
49
+ `;function St(e){const t=JSON.stringify({pluginId:e.pluginId,displayName:e.displayName,nav:e.nav.map(a=>({key:a.key,labelKey:a.labelKey,path:a.path??"/"})),config:e.config,locales:e.locales,defaultTarget:e.defaultTarget??"web",userHeader:re,rolesHeader:ae,defaultUser:ve},null,2),n=e.entry?`import plugin from ${JSON.stringify(e.entry)};`:"const plugin = undefined;";return`// Generated by \`tego-plugin dev\` on every start; edits here are overwritten.
50
50
  import '@tego/ui/styles/tokens.css';
51
51
  import './shell.css';
52
52
 
53
- import { pluginI18nNamespace } from ${tt};
54
- import type { PluginFrontendModule, PluginHostContext, PluginRoute } from ${nt};
53
+ import { pluginI18nNamespace } from ${gt};
54
+ import type { PluginFrontendModule, PluginHostContext, PluginRoute } from ${ht};
55
55
  import {
56
56
  Button,
57
57
  Input,
@@ -71,6 +71,11 @@ import { initReactI18next } from 'react-i18next';
71
71
  ${n}
72
72
 
73
73
  const DEV = ${t};
74
+ const TARGET: 'web' | 'desktop' = /(?:^|;\\s*)tego-dev-target=desktop(?:;|$)/.test(document.cookie)
75
+ ? 'desktop'
76
+ : /(?:^|;\\s*)tego-dev-target=web(?:;|$)/.test(document.cookie)
77
+ ? 'web'
78
+ : DEV.defaultTarget;
74
79
  const BASE = '/p/' + DEV.pluginId;
75
80
  const NAMESPACE = pluginI18nNamespace(DEV.pluginId);
76
81
 
@@ -168,8 +173,59 @@ const context: PluginHostContext = {
168
173
  config: DEV.config,
169
174
  navigate,
170
175
  can: navKey => navKeys.includes(navKey),
176
+ // Host API 1.37, as the shells provide it: the preview prints what a plugin
177
+ // asked for, then does the browser equivalent.
178
+ platform: TARGET,
179
+ openExternal: async url => {
180
+ if (!/^https?:\\/\\//i.test(url)) throw new Error('openExternal takes an http(s) URL');
181
+ console.info('[preview] openExternal', url);
182
+ window.open(url, '_blank', 'noopener');
183
+ },
184
+ download: async request => {
185
+ const anchor = document.createElement('a');
186
+ let revoke: string | undefined;
187
+ if ('blob' in request) {
188
+ revoke = URL.createObjectURL(request.blob);
189
+ anchor.href = revoke;
190
+ anchor.download = request.fileName;
191
+ } else {
192
+ if (!request.path.startsWith('/')) throw new Error('download takes a root-relative host path');
193
+ anchor.href = context.hostUrl(request.path);
194
+ if (request.fileName) anchor.download = request.fileName;
195
+ }
196
+ console.info('[preview] download', 'blob' in request ? request.fileName : request.path);
197
+ document.body.append(anchor);
198
+ anchor.click();
199
+ anchor.remove();
200
+ if (revoke) setTimeout(() => URL.revokeObjectURL(revoke), 30_000);
201
+ },
202
+ copyText: async text => {
203
+ try {
204
+ await navigator.clipboard.writeText(text);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ },
171
210
  };
172
211
 
212
+ // What the imitated shell's CSP refused on this page (the server prints them too).
213
+ const violations: string[] = [];
214
+ const violationListeners = new Set<() => void>();
215
+ document.addEventListener('securitypolicyviolation', event => {
216
+ violations.push(event.effectiveDirective + ': ' + (event.blockedURI || 'inline'));
217
+ for (const listener of violationListeners) listener();
218
+ });
219
+ function useViolations(): number {
220
+ return useSyncExternalStore(
221
+ listener => {
222
+ violationListeners.add(listener);
223
+ return () => violationListeners.delete(listener);
224
+ },
225
+ () => violations.length
226
+ );
227
+ }
228
+
173
229
  function useRoute(): PluginRoute {
174
230
  return useSyncExternalStore(context.route.subscribe, context.route.current);
175
231
  }
@@ -180,6 +236,12 @@ function DevBar() {
180
236
  const [dark, setDark] = useState(document.documentElement.classList.contains('dark'));
181
237
  const [userId, setUserId] = useState(user.userId);
182
238
  const [roles, setRoles] = useState(user.roles.join(', '));
239
+ const blocked = useViolations();
240
+ const switchTarget = (next: 'web' | 'desktop') => {
241
+ const url = new URL(location.href);
242
+ url.searchParams.set('target', next);
243
+ location.assign(url.toString());
244
+ };
183
245
  const switchUser = () => {
184
246
  store('userId', userId.trim() || DEV.defaultUser);
185
247
  store('roles', roles);
@@ -221,6 +283,27 @@ function DevBar() {
221
283
  ))}
222
284
  </SelectContent>
223
285
  </Select>
286
+ <div className='flex shrink-0 items-center rounded-control border border-border p-0.5' role='group' aria-label='Shell'>
287
+ {(['web', 'desktop'] as const).map(target => (
288
+ <Button
289
+ key={target}
290
+ size='sm'
291
+ variant={TARGET === target ? 'secondary' : 'ghost'}
292
+ aria-pressed={TARGET === target}
293
+ onClick={() => TARGET !== target && switchTarget(target)}
294
+ >
295
+ {target === 'web' ? 'Web' : 'Desktop'}
296
+ </Button>
297
+ ))}
298
+ </div>
299
+ {blocked > 0 && (
300
+ <span
301
+ className='shrink-0 rounded-control bg-destructive-soft px-1.5 py-0.5 text-xs text-destructive'
302
+ title={violations.join('\\n')}
303
+ >
304
+ CSP blocked {blocked}
305
+ </span>
306
+ )}
224
307
  <Button
225
308
  size='sm'
226
309
  variant='ghost'
@@ -272,4 +355,4 @@ if (frontend) {
272
355
  DEV.pluginId +
273
356
  '/</p>';
274
357
  }
275
- `}o(it,"mainTsx");var be=5280,ct=20,Ie="TEGO_DEV_READY ",Te=".tego-dev",lt="/api/v1/tego-os/plugin-api/";function ut(e){const{displayName:t}=e;return typeof t=="string"?t:t.en??t["zh-CN"]??Object.values(t)[0]??e.id}o(ut,"displayNameOf");function re(e,t){for(let n=T(e);;n=N(n)){const r=y(n,"node_modules",...t.split("/"));if(H(y(r,"package.json")))return Be(r);if(N(n)===n)return}}o(re,"packageDir");function R(e,t,n){e.writeHead(t,{"content-type":"application/json"}),e.end(JSON.stringify(n))}o(R,"sendJson");function dt(e,t,n){return new Promise((r,a)=>{const i=o(s=>{e.off("listening",l),a(s)},"onError"),l=o(()=>{e.off("error",i);const s=e.address();r(typeof s=="object"&&s?s.port:t)},"onListening");e.once("error",i),e.once("listening",l),e.listen(t,n)})}o(dt,"listen");async function gt(e,t){const n=Ge(y(e,"vite.config.ts"));let r,a;try{r=n("@tego/ui/tailwind-preset"),a=N(n.resolve("@tego/ui"))}catch(l){throw new Error(`The preview cannot load @tego/ui's Tailwind preset from ${e}: ${l instanceof Error?l.message:String(l)}. Install the plugin's dependencies.`,{cause:l})}const i=(await import("tailwindcss")).default;return i({presets:[r],content:[y(a,"**/*.{js,cjs}"),y(t,"*.tsx")]})}o(gt,"shellTailwind");function pt(e,t,n){return{postcssPlugin:"tego-dev-stylesheets",async Once(r,a){const i=r.source?.input.file,l=i&&T(i)===e?[t]:n;if(l.length===0)return;const s=await a.postcss(l).process(r,{from:i});a.result.messages.push(...s.messages)}}}o(pt,"perStylesheet");function ft(e){return Array.isArray(e)?e.flat(1/0).map(t=>{if(!t||typeof t!="object")return t;const{buildStart:n}=t;return typeof n!="function"?t:{...t,buildStart(...r){const a=new Proxy(this,{get(i,l){if(l==="emitFile")return()=>"";const s=Reflect.get(i,l);return typeof s=="function"?s.bind(i):s}});return n.apply(a,r)}}}):e}o(ft,"servePlugins");async function ht(e){const t=T(e.root),n=e.log??(d=>console.log(d)),r=await $e(t),a=Le(r),i=y(t,Te),l=y(i,"shell"),s=oe(32).toString("hex"),c=oe(24).toString("hex");let u="";const p=new Map;let w,v={};const m=a.frontend?T(t,a.frontend.dir):t,{loadConfigFromFile:A,createServer:L,mergeConfig:U}=await import("vite");if(a.frontend){const d=y(m,"vite.config.ts");H(d)&&(v=(await A({command:"serve",mode:"development"},d,t))?.config??{});const g=v.build?.rollupOptions?.input,E=T(t,typeof g=="string"?g:`${a.frontend.dir}/src/index.tsx`);if(!H(E))throw new Error(`The frontend entry ${le(t,E)} does not exist.`);w=le(l,E).split(ce).join("/"),w.startsWith(".")||(w=`./${w}`)}const h=o(()=>a.frontend?Oe(t,r,a.frontend.i18nDirPath):Promise.resolve(new Map),"localeFiles"),S=[...(await h()).keys()].map(d=>d.replace(/\.json$/,""));await W(l,{recursive:!0});for(const d of Ee({pluginId:r.id,displayName:ut(r),nav:a.frontend?.nav??[],config:a.frontend?.config?se(a.frontend.config.schema,{}):{},entry:w,locales:S}))await Y(y(l,d.path),d.contents);let x,I;const ke=o(()=>a.backend?.config?se(a.backend.config.schema,{}):{},"backendConfig"),C=Fe((d,g)=>void xe(d,g));async function xe(d,g){const E=(d.url??"/").split("?")[0];try{if(E.startsWith(ee)||E.startsWith(te)){await ve(d,g,{token:c,storageDir:y(i,"storage"),config:ke,origin:o(()=>u,"origin"),users:o(()=>p,"users"),log:n});return}const b=/^\/i18n\/([A-Za-z-]+)\.json$/.exec(E);if(b){const k=(await h()).get(`${b[1]}.json`);if(k===void 0)return R(g,404,{error:`no ${b[1]}.json`});g.writeHead(200,{"content-type":"application/json"}),g.end(k);return}const _=`${lt}${r.id}`;if(E===_||E.startsWith(`${_}/`)){_e(d,g,(d.url??"/").slice(_.length)||"/");return}j.middlewares(d,g,()=>R(g,404,{error:`${E} not found`}))}catch(b){R(g,500,{error:b instanceof Error?b.message:String(b)})}}o(xe,"route");function _e(d,g,E){if(!x){R(g,503,{error:I??"The plugin has no backend part."});return}const b=de(d.headers);p.set(b.userId,b.roles);const _=je({host:"127.0.0.1",port:x.port,method:d.method,path:E,headers:ge(d.headers,b,s)},k=>{g.writeHead(k.statusCode??502,k.headers),k.pipe(g)});_.on("error",k=>{g.headersSent?g.destroy():R(g,502,{error:`The backend is not answering (${k.message}); see the terminal.`})}),d.pipe(_)}o(_e,"proxy");const Ae=y(l,"shell.css"),De=(()=>{const d=v.css?.postcss;return d&&typeof d=="object"?d.plugins??[]:[]})(),B=(()=>{const d=re(m,"react-i18next");return(d&&re(d,"i18next"))??re(m,"i18next")})(),{css:mt,mode:yt,build:wt,...F}=v;F.plugins=ft(F.plugins);const j=await L(U(F,{configFile:!1,root:l,mode:"development",appType:"spa",clearScreen:!1,logLevel:"info",esbuild:{jsx:"automatic",jsxDev:!1},css:{postcss:{plugins:[pt(Ae,await gt(m,l),De)]}},resolve:{alias:[{find:"@tego/chat/plugin-surface",replacement:y(l,"plugin-surface.tsx")},...B?[{find:/^i18next$/,replacement:B}]:[]]},server:{middlewareMode:!0,hmr:{server:C},fs:{allow:[t,l,...B?[B]:[]]}},optimizeDeps:{entries:[y(l,"index.html")]}})),V=e.host??"127.0.0.1";let G;const K=e.port??be;for(let d=0;G===void 0;d++)try{G=await dt(C,K===0?0:K+d,V)}catch(g){if(g.code!=="EADDRINUSE"||K===0||d>=ct)throw await j.close(),g}const J=V==="0.0.0.0"||V==="::"?"127.0.0.1":V;if(u=`http://${J.includes(":")?`[${J}]`:J}:${G}`,!a.backend)I="The plugin has no backend part.";else if(e.backend===!1)I="The backend is off (--no-backend).";else if(!e.databaseUrl)I="The backend needs a PostgreSQL: start the preview with DATABASE_URL=postgres://\u2026 (each plugin gets its own schema).";else try{x=await Ze({root:t,pluginId:r.id,version:r.version,part:a.backend,databaseUrl:e.databaseUrl,outDir:y(i,"backend"),hostApiUrl:`${u}${ee}`,hostApiToken:c,gatewayKey:s,log:n})}catch(d){I=`The backend did not start: ${d instanceof Error?d.message:String(d)}`}I&&a.backend&&n(`[backend] ${I}`);const z=`${u}/p/${r.id}/`;return n(`${r.id} ${r.version}: local preview at ${z}`),e.databaseUrl&&x&&n(` database ${Z(e.databaseUrl)}, schema ${x.schema}`),n(`${Ie}${JSON.stringify({url:z})}`),{url:z,async close(){await x?.close(),await j.close(),C.closeAllConnections(),await new Promise(d=>C.close(()=>d()))}}}o(ht,"startDevServer");export{be as DEFAULT_DEV_PORT,Te as DEV_DIR,Ie as DEV_READY_PREFIX,Q as DEV_ROLES_HEADER,P as DEV_TENANT_ID,q as DEV_USER_HEADER,me as applyDevMigrations,de as devCallerOf,fe as devDatabaseUrl,pe as devSchemaName,ge as gatewayHeaders,ve as handleFakeHost,Ee as shellFiles,ht as startDevServer,ae as storagePath};
358
+ `}s(St,"mainTsx");var Ae="/__tego/csp-report",$e="TEGO_DEV_CSP ",bt="tego-dev-target",Et=100*1024*1024,De=["default-src 'self'","object-src 'none'","base-uri 'self'"],Ne={web:[...De,"script-src 'self' 'wasm-unsafe-eval'","style-src 'self' 'unsafe-inline'","img-src 'self' data: blob: https:","font-src 'self' data:","connect-src 'self' wss: ws:","frame-src 'self'","worker-src 'self' blob:"].join("; "),desktop:[...De,"script-src 'self' 'wasm-unsafe-eval'","style-src 'self' 'unsafe-inline' https:","img-src 'self' data: blob: http: https:","font-src 'self' data: blob: https:","media-src 'self' data: blob:","worker-src 'self' blob:","connect-src 'self' http: https: ws: wss:","frame-src 'self'"].join("; ")};function ue(e){return e==="web"||e==="desktop"}s(ue,"isPreviewTarget");function Re(e,t){const n=new URL(e.url??"/","http://preview.local").searchParams.get("target");if(ue(n))return n;const a=/(?:^|;\s*)tego-dev-target=(web|desktop)(?:;|$)/.exec(String(e.headers.cookie??""))?.[1];return ue(a)?a:t}s(Re,"targetOf");function Le(e){try{const t=JSON.parse(e)["csp-report"];if(!t)return;const n=String(t["effective-directive"]??t["violated-directive"]??""),a=String(t["blocked-uri"]??"");if(!n)return;const r=typeof t["source-file"]=="string"?t["source-file"]:void 0,i=typeof t["line-number"]=="number"?t["line-number"]:void 0;return{directive:n,blocked:a,...r?{source:r}:{},...i?{line:i}:{}}}catch{return}}s(Le,"parseCspReport");var Oe=5280,kt=20,Ce="TEGO_DEV_READY ",Ue=".tego-dev",Tt="/api/v1/tego-os/plugin-api/";function It(e){const{displayName:t}=e;return typeof t=="string"?t:t.en??t["zh-CN"]??Object.values(t)[0]??e.id}s(It,"displayNameOf");function de(e,t){for(let n=x(e);;n=O(n)){const a=w(n,"node_modules",...t.split("/"));if(K(w(a,"package.json")))return Ye(a);if(O(n)===n)return}}s(de,"packageDir");function N(e,t,n){e.writeHead(t,{"content-type":"application/json"}),e.end(JSON.stringify(n))}s(N,"sendJson");function xt(e,t,n){return new Promise((a,r)=>{const i=s(o=>{e.off("listening",u),r(o)},"onError"),u=s(()=>{e.off("error",i);const o=e.address();a(typeof o=="object"&&o?o.port:t)},"onListening");e.once("error",i),e.once("listening",u),e.listen(t,n)})}s(xt,"listen");async function _t(e,t){const n=rt(w(e,"vite.config.ts"));let a,r;try{a=n("@tego/ui/tailwind-preset"),r=O(n.resolve("@tego/ui"))}catch(u){throw new Error(`The preview cannot load @tego/ui's Tailwind preset from ${e}: ${u instanceof Error?u.message:String(u)}. Install the plugin's dependencies.`,{cause:u})}const i=(await import("tailwindcss")).default;return i({presets:[a],content:[w(r,"**/*.{js,cjs}"),w(t,"*.tsx")]})}s(_t,"shellTailwind");function Pt(e,t,n){return{postcssPlugin:"tego-dev-stylesheets",async Once(a,r){const i=a.source?.input.file,u=i&&x(i)===e?[t]:n;if(u.length===0)return;const o=await r.postcss(u).process(a,{from:i});r.result.messages.push(...o.messages)}}}s(Pt,"perStylesheet");function At(e){return Array.isArray(e)?e.flat(1/0).map(t=>{if(!t||typeof t!="object")return t;const{buildStart:n}=t;return typeof n!="function"?t:{...t,buildStart(...a){const r=new Proxy(this,{get(i,u){if(u==="emitFile")return()=>"";const o=Reflect.get(i,u);return typeof o=="function"?o.bind(i):o}});return n.apply(r,a)}}}):e}s(At,"servePlugins");async function $t(e){const t=x(e.root),n=e.log??(c=>console.log(c)),a=await Ke(t),r=We(a),i=w(t,Ue),u=w(i,"shell"),o=pe(32).toString("hex"),l=pe(24).toString("hex");let d="";const p=new Map;let y,b={};const v=r.frontend?x(t,r.frontend.dir):t,{loadConfigFromFile:R,createServer:H,mergeConfig:V}=await import("vite");if(r.frontend){const c=w(v,"vite.config.ts");K(c)&&(b=(await R({command:"serve",mode:"development"},c,t))?.config??{});const f=b.build?.rollupOptions?.input,S=x(t,typeof f=="string"?f:`${r.frontend.dir}/src/index.tsx`);if(!K(S))throw new Error(`The frontend entry ${me(t,S)} does not exist.`);y=me(u,S).split(he).join("/"),y.startsWith(".")||(y=`./${y}`)}const h=s(()=>r.frontend?Je(t,a,r.frontend.i18nDirPath):Promise.resolve(new Map),"localeFiles"),E=[...(await h()).keys()].map(c=>c.replace(/\.json$/,""));await ee(u,{recursive:!0});for(const c of Pe({pluginId:a.id,displayName:It(a),nav:r.frontend?.nav??[],config:r.frontend?.config?fe(r.frontend.config.schema,{}):{},entry:y,locales:E,defaultTarget:e.target??"web"}))await ne(w(u,c.path),c.contents);let P,I;const Be=s(()=>r.backend?.config?fe(r.backend.config.schema,{}):{},"backendConfig"),j=tt((c,f)=>void He(c,f));async function He(c,f){const S=(c.url??"/").split("?")[0],_=Re(c,e.target??"web");try{if(S==="/favicon.ico"){f.writeHead(204),f.end();return}if(S===Ae){const m=await new Promise(F=>{let D="";c.setEncoding("utf8"),c.on("data",A=>D+=A),c.on("end",()=>F(D))}),T=Le(m);T&&(n(`[csp:${_}] blocked ${T.blocked||"(inline)"} by ${T.directive}${T.source?` in ${T.source}${T.line?`:${T.line}`:""}`:""}`),n(`${$e}${JSON.stringify({target:_,...T})}`)),f.writeHead(204),f.end();return}if(S.startsWith(se)||S.startsWith(ie)){await xe(c,f,{token:l,storageDir:w(i,"storage"),config:Be,origin:s(()=>d,"origin"),users:s(()=>p,"users"),log:n});return}const k=/^\/i18n\/([A-Za-z-]+)\.json$/.exec(S);if(k){const m=(await h()).get(`${k[1]}.json`);if(m===void 0)return N(f,404,{error:`no ${k[1]}.json`});f.writeHead(200,{"content-type":"application/json"}),f.end(m);return}const $=`${Tt}${a.id}`;if(S===$||S.startsWith(`${$}/`)){Ve(c,f,(c.url??"/").slice($.length)||"/",_);return}if(String(c.headers.accept??"").includes("text/html")){f.setHeader("Content-Security-Policy",`${Ne[_]}; report-uri ${Ae}`);const m=new URL(c.url??"/","http://preview.local").searchParams.get("target");ue(m)&&f.setHeader("Set-Cookie",`${bt}=${m}; Path=/; SameSite=Lax`)}W.middlewares(c,f,()=>N(f,404,{error:`${S} not found`}))}catch(k){N(f,500,{error:k instanceof Error?k.message:String(k)})}}s(He,"route");function Ve(c,f,S,_){if(!P){N(f,503,{error:I??"The plugin has no backend part."});return}const k=we(c.headers);p.set(k.userId,k.roles);const $=nt({host:"127.0.0.1",port:P.port,method:c.method,path:S,headers:{...ye(c.headers,k,o),..._==="desktop"?{"accept-encoding":"identity"}:{}}},m=>{if(_!=="desktop"){f.writeHead(m.statusCode??502,m.headers),m.pipe(f);return}const T=[];let F=0,D=!1;m.on("data",A=>{if(!D){if(F+=A.length,F>Et){D=!0,m.destroy(),n(`[desktop] ${c.method} ${S}: response over 100 MB refused (413)`),N(f,413,{error:"Response over 100 MB: the desktop app refuses it. Serve large files through storage paths."});return}T.push(A)}}),m.on("end",()=>{if(D)return;const A=Buffer.concat(T),Z={...m.headers};delete Z["transfer-encoding"],Z["content-length"]=String(A.length),f.writeHead(m.statusCode??502,Z),f.end(A)})});$.on("error",m=>{f.headersSent?f.destroy():N(f,502,{error:`The backend is not answering (${m.message}); see the terminal.`})}),c.pipe($)}s(Ve,"proxy");const je=w(u,"shell.css"),Ge=(()=>{const c=b.css?.postcss;return c&&typeof c=="object"?c.plugins??[]:[]})(),G=(()=>{const c=de(v,"react-i18next");return(c&&de(c,"i18next"))??de(v,"i18next")})(),{css:Dt,mode:Nt,build:Rt,...z}=b;z.plugins=At(z.plugins);const W=await H(V(z,{configFile:!1,root:u,mode:"development",appType:"spa",clearScreen:!1,logLevel:"info",esbuild:{jsx:"automatic",jsxDev:!1},css:{postcss:{plugins:[Pt(je,await _t(v,u),Ge)]}},resolve:{alias:[{find:"@tego/chat/plugin-surface",replacement:w(u,"plugin-surface.tsx")},...G?[{find:/^i18next$/,replacement:G}]:[]]},server:{middlewareMode:!0,hmr:{server:j},fs:{allow:[t,u,...G?[G]:[]]}},optimizeDeps:{entries:[w(u,"index.html")]}})),M=e.host??"127.0.0.1";let q;const X=e.port??Oe;for(let c=0;q===void 0;c++)try{q=await xt(j,X===0?0:X+c,M)}catch(f){if(f.code!=="EADDRINUSE"||X===0||c>=kt)throw await W.close(),f}const Y=M==="0.0.0.0"||M==="::"?"127.0.0.1":M;if(d=`http://${Y.includes(":")?`[${Y}]`:Y}:${q}`,!r.backend)I="The plugin has no backend part.";else if(e.backend===!1)I="The backend is off (--no-backend).";else if(!e.databaseUrl)I="The backend needs a PostgreSQL: start the preview with DATABASE_URL=postgres://\u2026 (each plugin gets its own schema).";else try{P=await ft({root:t,pluginId:a.id,version:a.version,part:r.backend,databaseUrl:e.databaseUrl,outDir:w(i,"backend"),hostApiUrl:`${d}${se}`,hostApiToken:l,gatewayKey:o,log:n})}catch(c){I=`The backend did not start: ${c instanceof Error?c.message:String(c)}`}I&&r.backend&&n(`[backend] ${I}`);const Q=`${d}/p/${a.id}/`;return n(`${a.id} ${a.version}: local preview at ${Q}`),e.databaseUrl&&P&&n(` database ${oe(e.databaseUrl)}, schema ${P.schema}`),n(`${Ce}${JSON.stringify({url:Q})}`),{url:Q,async close(){await P?.close(),await W.close(),j.closeAllConnections(),await new Promise(c=>j.close(()=>c()))}}}s($t,"startDevServer");export{$e as CSP_REPORT_PREFIX,Oe as DEFAULT_DEV_PORT,Ue as DEV_DIR,Ce as DEV_READY_PREFIX,ae as DEV_ROLES_HEADER,C as DEV_TENANT_ID,re as DEV_USER_HEADER,Ne as PREVIEW_CSP,ke as applyDevMigrations,we as devCallerOf,be as devDatabaseUrl,Se as devSchemaName,ye as gatewayHeaders,xe as handleFakeHost,Le as parseCspReport,Pe as shellFiles,$t as startDevServer,le as storagePath,Re as targetOf};
@@ -0,0 +1,8 @@
1
+ "use strict";var A=Object.defineProperty;var d=(e,t)=>A(e,"name",{value:t,configurable:!0});var chunkSKNPNPJP_cjs=require("./chunk-SKNPNPJP.cjs"),chunk45Y4CR4R_cjs=require("./chunk-45Y4CR4R.cjs"),chunk2P6UEAPV_cjs=require("./chunk-2P6UEAPV.cjs"),chunkN4SXZZPY_cjs=require("./chunk-N4SXZZPY.cjs"),chunk6HNG5OZI_cjs=require("./chunk-6HNG5OZI.cjs");require("./chunk-AJY7ZHXK.cjs"),require("./chunk-OP64Y4IN.cjs");var child_process=require("child_process"),promises=require("fs/promises"),path=require("path"),COLOR_ALLOW_MARKER="ui-colors-allow",PALETTE="slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose",UTILITY="bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|from|via|to|fill|stroke|outline|divide|placeholder|caret|accent|decoration|shadow",COLOR_PATTERNS=[new RegExp(`(?<![\\w-])(?:${UTILITY})-(?:(?:${PALETTE})-(?:50|[1-9]00|950)|white|black)(?:\\/\\d{1,3})?(?![\\w-])`,"g"),/-\[(?:#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\(\s*\d+)/g,/(?<![a-zA-Z0-9[-])(?:rgba?|hsla?)\(\s*\d+/g],QUOTED_HEX=/['"`]#[0-9a-fA-F]{3,8}['"`]/g,CSS_HEX=/(?<![\w&-])#[0-9a-fA-F]{3,8}(?![\w-])/g,DARK_VARIANT=/(?<![\w-])dark:(?=\S)/g,DARK_SELECTOR=/\.dark(?![\w-])/g;function stripComments(e){return e.replace(/(^|[\s{(,;])\/\*[\s\S]*?\*\//g,(t,r)=>r+t.slice(r.length).replace(/[^\n]/g," ")).replace(/(^|\s)\/\/.*$/gm,(t,r)=>r)}d(stripComments,"stripComments");function allowedLines(e){const t=new Set;return e.forEach((r,s)=>{r.includes(COLOR_ALLOW_MARKER)&&(t.add(s),/^\s*(?:\/\/|\/\*|\*|\{\/\*)/.test(r)&&t.add(s+1))}),t}d(allowedLines,"allowedLines");function findColorIssues(e,t){const r=t===".css",s=allowedLines(e.split(`
2
+ `)),c=stripComments(e).split(`
3
+ `),i=[...COLOR_PATTERNS,r?CSS_HEX:QUOTED_HEX],o=r?[DARK_VARIANT,DARK_SELECTOR]:[DARK_VARIANT],h=[];return c.forEach((f,n)=>{if(s.has(n))return;const u=[];for(const[a,l]of[["color",i],["dark",o]])for(const w of l)for(const k of f.matchAll(w))u.push({kind:a,match:k[0],at:k.index??0});u.sort((a,l)=>a.kind.localeCompare(l.kind)||a.at-l.at).forEach(({kind:a,match:l})=>h.push({line:n+1,kind:a,match:l}))}),h}d(findColorIssues,"findColorIssues");var rule=d((e,t,r,s)=>({id:e,severity:t,guide:r,title:s}),"rule"),DOCTOR_RULES=[rule("TP-MAN-001","error","manifest.md","tego.plugin.json is valid"),rule("TP-MAN-002","error","host-api-versions.md","hostApiRange admits every host capability the code uses"),rule("TP-PKG-001","error","manifest.md","SDK packages come from npm aliases or workspace links, never a plain @tego/* name"),rule("TP-PKG-002","warning","testing.md","package.json has the lint, type-check, test and build gates"),rule("TP-CLAW-002","warning","claw.md","Each claw skill's frontmatter name matches its folder"),rule("TP-DESK-001","error","desktop-web.md","No eval or new Function (the desktop CSP refuses them)"),rule("TP-DESK-002","error","desktop-web.md","No addresses built from location.origin (tauri://localhost on desktop)"),rule("TP-DESK-003","error","desktop-web.md","External links open with ctx.openExternal, not window.open"),rule("TP-DESK-004","error","desktop-web.md","Files are saved with ctx.download, not <a download> or a.download"),rule("TP-DESK-005","warning","desktop-web.md","No streamed responses or EventSource from the frontend (the desktop proxy buffers; use ctx.events)"),rule("TP-DESK-006","warning","desktop-web.md","Clipboard writes go through ctx.copyText"),rule("TP-WEB-001","error","desktop-web.md","The frontend calls no third-party URL (the web CSP blocks it); call the backend, which declares egress"),rule("TP-WEB-002","error","desktop-web.md","No remote or injected scripts; everything ships in the bundle"),rule("TP-UI-001","error","ui.md","Colors come from token classes: no hex, rgb(), palette classes or dark: overrides"),rule("TP-UI-002","error","ui.md","No direct @radix-ui/* imports; use @tego/ui"),rule("TP-UI-003","error","ui.md","The plugin's root element carries its scope class (.<pluginId>) when it compiles its own Tailwind"),rule("TP-UI-004","error","frontend.md","The frontend has its own error boundary"),rule("TP-I18N-001","error","i18n.md","All ten locales, with the same keys as zh-CN, and every nav label defined"),rule("TP-I18N-002","error","i18n.md","Every locale value keeps the {{placeholders}} of zh-CN and is not empty"),rule("TP-I18N-003","error","i18n.md","Every literal t('\u2026') key exists in zh-CN"),rule("TP-I18N-004","warning","i18n.md","Current terminology: \u6570\u5B57\u5458\u5DE5 / Employee, never \u6570\u5B57\u5206\u8EAB / digital avatar"),rule("TP-BE-001","error","backend.md","Identity comes from the verified ctx, never from the raw x-tego-plugin-* headers"),rule("TP-BE-002","error","access.md","No host role names in business code; ask the access engine"),rule("TP-BE-003","error","migrations.md","Migrations are <ordinal>_<name>.sql with unique ordinals"),rule("TP-BE-004","error","migrations.md","Committed migrations are never edited or deleted"),rule("TP-BE-005","warning","backend.md","Backend errors are logged through host.log, not console"),rule("TP-ACC-001","error","access.md","With an access catalog, a test asserts every route has a policy (assertRouteCoverage)"),rule("TP-ACC-002","error","access.md","With an access catalog, start() reconciles it (reconcile(host))"),rule("TP-TEST-001","warning","testing.md","The plugin has tests")],BY_ID=new Map(DOCTOR_RULES.map(e=>[e.id,e]));function doctorRule(e){return BY_ID.get(e)}d(doctorRule,"doctorRule");var VERSIONED_CAPABILITIES=[{pattern:/\.openExternal\s*\(/,since:"1.37.0",name:"ctx.openExternal"},{pattern:/\.copyText\s*\(/,since:"1.37.0",name:"ctx.copyText"},{pattern:/\b(?:ctx|host|context)\.download\s*\(/,since:"1.37.0",name:"ctx.download"},{pattern:/\b(?:ctx|host|context)\.platform\b/,since:"1.37.0",name:"ctx.platform"}],SOURCE_EXTENSIONS=new Set([".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".css"]),SKIP_DIRS=new Set(["node_modules","dist","build",".tego-dev",".turbo",".drizzle","__fixtures__","coverage"]),TEST_FILE=/\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])test[\\/]|(?:^|[\\/])test-[\w-]+\.[cm]?[jt]s$|-double\.[cm]?[jt]s$|\.d\.ts$/,IGNORE_MARKER=/tego-doctor-ignore\s+([A-Z0-9-]+(?:\s*,\s*[A-Z0-9-]+)*)/,PLACEHOLDER=/\{\{\s*([\w.-]+)\s*\}\}/g,REQUIRED_SCRIPTS=["lint","type-check","test","build"];function toPosix(e){return e.split(path.sep).join("/")}d(toPosix,"toPosix");async function walk(e,t,r){let s;try{s=await promises.readdir(path.join(e,t),{withFileTypes:!0})}catch{return}for(const c of s){const i=t?`${t}/${c.name}`:c.name;c.isDirectory()?!SKIP_DIRS.has(c.name)&&!c.name.startsWith(".")&&await walk(e,i,r):SOURCE_EXTENSIONS.has(path.extname(c.name))&&r.push(i)}}d(walk,"walk");function folderReader(e){return{async list(t){try{return(await promises.readdir(path.join(e,t),{withFileTypes:!0})).filter(s=>s.isFile()).map(s=>s.name)}catch{return[]}},readText:d(t=>promises.readFile(path.join(e,t),"utf8").catch(()=>null),"readText")}}d(folderReader,"folderReader");function runGit(e,t){return new Promise(r=>{child_process.execFile("git",["-C",e,...t],{maxBuffer:8*1024*1024},(s,c)=>r(s?null:c))})}d(runGit,"runGit");function ignoredLines(e){const t=new Map;return e.split(`
4
+ `).forEach((r,s)=>{const c=IGNORE_MARKER.exec(r);if(!c?.[1])return;const i=new Set(c[1].split(",").map(h=>h.trim())),o=d(h=>t.set(h,new Set([...t.get(h)??[],...i])),"add");o(s),/^\s*(?:\/\/|\/\*|\*|\{\/\*)/.test(r)&&o(s+1)}),t}d(ignoredLines,"ignoredLines");var Findings=class{static{d(this,"Findings")}list=[];add(e,t,r={}){const{severity:s,guide:c}=doctorRule(e);this.list.push({rule:e,severity:s,message:t,guide:c,...r})}};function eachMatch(e,t,r,s){const c=ignoredLines(e.text);e.code.split(`
5
+ `).forEach((i,o)=>{if(c.get(o)?.has(t))return;const h=r.exec(i);h&&s(o+1,h[0])})}d(eachMatch,"eachMatch");function partOf(e,t){const r=d(s=>!!s&&(e===s||e.startsWith(`${s}/`)),"inside");return r(t.frontend?.dir)||r(t.publicFrontend?.dir)?"frontend":r(t.backend?.dir)?"backend":r(t.claw?.dir)?"claw":r("shared")?"shared":"other"}d(partOf,"partOf");async function readJson(e){try{return JSON.parse(await promises.readFile(e,"utf8"))}catch{return}}d(readJson,"readJson");function checkPackageJson(e,t){if(!t)return;for(const c of["dependencies","devDependencies","peerDependencies"]){const i=t[c];if(!(!i||typeof i!="object"))for(const[o,h]of Object.entries(i))!o.startsWith("@tego/")||typeof h!="string"||/^(?:npm:@zhama\/tego-|workspace:|file:|link:|portal:)/.test(h)||e.add("TP-PKG-001",`${c}.${o} is "${h}": on npm @tego/* belongs to another project. Install it as "npm:@zhama/tego-${o.slice(6)}@^<sdk version>".`,{file:"package.json"})}const r=t.scripts??{},s=REQUIRED_SCRIPTS.filter(c=>typeof r[c]!="string");s.length>0&&e.add("TP-PKG-002",`package.json has no ${s.map(c=>`"${c}"`).join(", ")} script; the platform's gates are lint, type-check, test and build.`,{file:"package.json"})}d(checkPackageJson,"checkPackageJson");async function checkLocales(e,t,r,s,c){const i=s.frontend;if(!i)return;const{bundles:o,errors:h}=await chunk45Y4CR4R_cjs.verifyFrontendLocales(folderReader(t),i);for(const p of h)e.add("TP-I18N-001",p,{file:i.i18nDirPath});const f=o.get("zh-CN");if(!f)return;const n=d((p,m="",P=new Map)=>{if(typeof p!="object"||p===null||Array.isArray(p))return P.set(m,p),P;for(const[b,T]of Object.entries(p))n(T,m?`${m}.${b}`:b,P);return P},"leaves"),u=d(p=>new Set(typeof p=="string"?[...p.matchAll(PLACEHOLDER)].map(m=>m[1]):[]),"placeholders"),a=n(f);for(const[p,m]of o){const P=`${i.i18nDirPath}/${p}.json`;let b=0;for(const[T,g]of n(m)){if(b>=10)break;if(typeof g=="string"&&g.trim()===""){e.add("TP-I18N-002",`"${T}" is empty in ${p}.json.`,{file:P}),b++;continue}if(p==="zh-CN"||!a.has(T))continue;const v=u(a.get(T)),y=u(g),x=[...v].filter(E=>!y.has(E)),S=[...y].filter(E=>!v.has(E));(x.length||S.length)&&(e.add("TP-I18N-002",`"${T}" in ${p}.json has placeholders {${[...y].join(", ")}} but zh-CN has {${[...v].join(", ")}}.`,{file:P}),b++)}for(const[T,g]of n(m))if(typeof g=="string"&&/数字分身|digital avatar/i.test(g)){e.add("TP-I18N-004",`"${T}" in ${p}.json says "${/数字分身/.test(g)?"\u6570\u5B57\u5206\u8EAB":"digital avatar"}"; the platform calls them \u6570\u5B57\u5458\u5DE5 / Employee ({{avatarTerm}}).`,{file:P});break}}const l=chunk45Y4CR4R_cjs.flattenLocaleKeys(f),w=new Set(Object.keys(f)),k=d(p=>l.has(p)||["_one","_other","_zero","_few","_many","_two"].some(m=>l.has(p+m))||[...l].some(m=>m.startsWith(`${p}.`)),"exists");for(const p of c){if(p.part!=="frontend"||p.test)continue;const m=ignoredLines(p.text);p.code.split(`
6
+ `).forEach((P,b)=>{if(!m.get(b)?.has("TP-I18N-003"))for(const T of P.matchAll(/\bt\(\s*['"]([A-Za-z0-9_.-]+)['"]/g)){const g=T[1],v=g.split(".")[0];!g.includes(".")||!w.has(v)||k(g)||e.add("TP-I18N-003",`t('${g}') has no entry in zh-CN.json (namespace ${chunkN4SXZZPY_cjs.pluginI18nNamespace(r.id)}).`,{file:p.path,line:b+1})}})}}d(checkLocales,"checkLocales");async function checkMigrations(e,t,r){const s=r.backend;if(!s)return;const{errors:c}=await chunk45Y4CR4R_cjs.verifyMigrationNames(folderReader(t),s.migrationsDirPath);for(const h of c)e.add("TP-BE-003",h,{file:s.migrationsDirPath});const i=await runGit(t,["diff","--name-status","HEAD","--",s.migrationsDirPath]);if(!i)return;const o=(await runGit(t,["rev-parse","--show-prefix"]))?.trim()??"";for(const h of i.split(`
7
+ `)){const[f,n]=h.split(" ");if(!n||f!=="M"&&f!=="D")continue;const u=n.startsWith(o)?n.slice(o.length):n;e.add("TP-BE-004",`${u} is a committed migration and was ${f==="M"?"edited":"deleted"}; an install may have applied it already. Revert it and add a new migration instead.`,{file:u})}}d(checkMigrations,"checkMigrations");async function checkClaw(e,t,r){const s=r.claw;if(!s)return;const c=path.join(t,s.dir,"skills");let i=[];try{i=(await promises.readdir(c,{withFileTypes:!0})).filter(o=>o.isDirectory()).map(o=>o.name)}catch{return}for(const o of i){const h=await promises.readFile(path.join(c,o,"SKILL.md"),"utf8").catch(()=>null),f=`${s.dir}/skills/${o}/SKILL.md`,n=h?/^---[\s\S]*?^name:\s*["']?([^"'\n]+)["']?\s*$/m.exec(h)?.[1]?.trim():void 0;n!==o&&e.add("TP-CLAW-002",h?`${f} declares name "${n??"(none)"}"; it must equal the folder name "${o}".`:`${s.dir}/skills/${o} has no SKILL.md.`,{file:f})}}d(checkClaw,"checkClaw");function checkSources(e,t,r,s,c,i){const o=t.hostApiRange&&chunk6HNG5OZI_cjs.isValidHostApiRange(t.hostApiRange)?chunk6HNG5OZI_cjs.hostApiRangeMinimum(t.hostApiRange):void 0,h=d(n=>{if(!o)return!1;const[u,a,l]=o.split(".").map(Number),[w,k,p]=n.split(".").map(Number);return u!==w?u<w:a!==k?a<k:l<p},"below");for(const n of s){if(!c(n)||n.test)continue;const u=d(a=>({file:n.path,line:a}),"at");if(n.part==="frontend"){if(n.path.endsWith(".css")||n.path.endsWith(".tsx")||n.path.endsWith(".ts")){const a=ignoredLines(n.text);for(const l of findColorIssues(n.text,path.extname(n.path)))a.get(l.line-1)?.has("TP-UI-001")||e.add("TP-UI-001",l.kind==="dark"?`"${l.match}" is a hand-written dark override; tokens already carry both themes.`:`"${l.match}" is a literal color; use a token class (bg-card, text-muted-foreground, bg-primary\u2026).`,u(l.line))}eachMatch(n,"TP-DESK-001",/\beval\s*\(|\bnew\s+Function\s*\(/,a=>e.add("TP-DESK-001","eval / new Function: the desktop app's CSP (script-src 'self') refuses code compiled from strings.",u(a))),eachMatch(n,"TP-DESK-002",/\b(?:window\.|document\.)?location\.origin\b/,a=>e.add("TP-DESK-002","location.origin is tauri://localhost in the desktop app; build host addresses with ctx.hostUrl(path) and share links from ctx.publicPagesUrl.",u(a))),eachMatch(n,"TP-DESK-003",/\bwindow\.open\s*\(/,a=>e.add("TP-DESK-003","window.open does not reliably open links in the desktop app; use ctx.openExternal(url) (hostApiRange ^1.37.0), or ctx.download for a file.",u(a))),eachMatch(n,"TP-DESK-004",/\.download\s*=(?!=)|<a\b[^>]*\sdownload(?:[\s=>]|$)/,a=>e.add("TP-DESK-004","An <a download> save is not handled by the desktop app; use ctx.download({ path | blob, fileName }) (hostApiRange ^1.37.0).",u(a))),eachMatch(n,"TP-DESK-005",/\bnew\s+EventSource\s*\(|\.body\s*(?:\?\.|!\.|\.)\s*getReader\s*\(/,a=>e.add("TP-DESK-005","The desktop app buffers ctx.api responses whole (and caps them at 100 MB), so streams arrive at the end; subscribe to ctx.events for pushed updates.",u(a))),eachMatch(n,"TP-DESK-006",/\bnavigator\.clipboard\./,a=>e.add("TP-DESK-006","navigator.clipboard is not available in every shell; use ctx.copyText(text) (hostApiRange ^1.37.0).",u(a))),eachMatch(n,"TP-WEB-001",/\bfetch\s*\(\s*['"`]https?:\/\//,a=>e.add("TP-WEB-001","The web app's CSP allows connections to the platform only; call your backend through ctx.api and let it reach the service (declare it in parts.backend.egress).",u(a))),eachMatch(n,"TP-WEB-002",/\bimport\s*\(\s*['"`]https?:|createElement\(\s*['"]script['"]\s*\)|<script\b[^>]*\bsrc=/,a=>e.add("TP-WEB-002","Remote or injected scripts are refused by both shells' CSP; bundle the code.",u(a))),eachMatch(n,"TP-UI-002",/from\s+['"]@radix-ui\//,a=>e.add("TP-UI-002","Import the @tego/ui wrapper instead of @radix-ui/* (the shell shares @tego/ui; a second Radix copy breaks focus and portals).",u(a)));for(const a of VERSIONED_CAPABILITIES){if(!h(a.since))continue;const l=a.name.split(".").pop();new RegExp(`\\.${l}\\s*(?:\\)|\\?(?!\\.)|&&)`).test(n.code)||eachMatch(n,"TP-MAN-002",a.pattern,w=>e.add("TP-MAN-002",`${a.name} needs host API ${a.since}, but hostApiRange is "${t.hostApiRange}"; raise it to ^${a.since}.`,u(w)))}}n.part==="backend"&&(eachMatch(n,"TP-BE-001",/\bheaders?\s*(?:\.get)?\(\s*['"]x-tego-plugin-(?:user-id|tenant-id|roles|avatar-id|delegation)['"]|\bheaders\s*\[\s*['"]x-tego-plugin-(?:user-id|tenant-id|roles|avatar-id|delegation)['"]/i,a=>e.add("TP-BE-001","Take the caller from the verified ctx (ctx.userId, ctx.tenantId, ctx.roles) that serveBackend hands you; the raw x-tego-plugin-* headers are unverified here.",u(a))),eachMatch(n,"TP-BE-005",/\bconsole\.(?:error|warn)\s*\(/,a=>e.add("TP-BE-005","Log through host.log so the message reaches the platform's logs; console output from a plugin container is not collected.",u(a)))),n.part!=="other"&&eachMatch(n,"TP-BE-002",/\b(?:roles|roleNames)\s*\??\.\s*(?:includes|indexOf)\(\s*['"`](?:admin|owner|super_?admin|tenant_?admin|member|manager)['"`]/i,a=>e.add("TP-BE-002","Do not decide by host role names; declare permissions in the access catalog (hostRoleBindings) and ask the engine (authorize / useAccess).",u(a)))}const f=s.filter(n=>n.part==="frontend"&&!n.test);if(r.frontend&&f.length>0&&(f.some(n=>/getDerivedStateFromError|componentDidCatch|PluginErrorBoundary/.test(n.code))||e.add("TP-UI-004","No error boundary in the frontend: the shell's boundary cannot see into your React root, so a render error leaves a blank page. Wrap the root in PluginErrorBoundary (@tego/plugin-sdk/react) or your own.",{file:r.frontend.dir}),i?.includes("tegoPluginTailwind"))){const n=new RegExp(`(?:className|class)=\\{?\\s*(?:cn\\()?\\s*['"\`][^'"\`]*(?<![\\w-])${t.id.replace(/[.]/g,"\\.")}(?![\\w-])`);f.some(u=>n.test(u.code))||e.add("TP-UI-003",`tegoPluginTailwind scopes every utility under .${t.id}, but no element carries className="${t.id}": none of the plugin's own classes will apply. Put it on the root element (and on portalled content, or use tegoPluginScopeRoot).`,{file:r.frontend.dir})}}d(checkSources,"checkSources");function checkAccessAndTests(e,t,r,s){const c=s.filter(o=>o.test);if(c.length===0&&e.add("TP-TEST-001","The plugin has no *.test.ts files; backend routes and pure frontend logic need tests (createTestPluginHost for SQL)."),!t.access||!r.backend)return;c.some(o=>o.text.includes("assertRouteCoverage"))||e.add("TP-ACC-001","The manifest declares an access catalog but no test calls assertRouteCoverage(app): a route without a policy would be open to everyone who can open the plugin.",{file:r.backend.dir}),s.filter(o=>(o.part==="backend"||o.part==="shared")&&!o.test).some(o=>/\breconcile\s*\(/.test(o.code))||e.add("TP-ACC-002","The manifest declares an access catalog but start() never calls reconcile(host): built-in roles and catalog changes never reach the database.",{file:r.backend.dir})}d(checkAccessAndTests,"checkAccessAndTests");async function runDoctor(e){const t=e.root;await promises.stat(path.join(t,"tego.plugin.json")).catch(()=>{throw new Error(`No tego.plugin.json in ${t}; run doctor in the plugin folder.`)});const r=new Findings;let s;try{s=JSON.parse(await promises.readFile(path.join(t,"tego.plugin.json"),"utf8"))}catch(l){return r.add("TP-MAN-001",`tego.plugin.json is not valid JSON: ${l instanceof Error?l.message:String(l)}`,{file:"tego.plugin.json"}),summarize(r,void 0)}const c=chunkSKNPNPJP_cjs.parseTegoPluginManifest(s);if(!c.ok||!c.manifest){for(const l of c.errors)r.add("TP-MAN-001",l,{file:"tego.plugin.json"});return summarize(r,s.id)}const i=c.manifest,o=chunk2P6UEAPV_cjs.resolvePluginParts(i),h=[];await walk(t,"",h);const f=await Promise.all(h.map(async l=>{const w=await promises.readFile(path.join(t,l),"utf8");return{path:l,part:partOf(l,o),text:w,code:stripComments(w),test:TEST_FILE.test(l)}})),n=e.files?.map(l=>toPosix(path.relative(t,path.join(t,l)))),u=d(l=>!n||n.includes(l.path),"scanned"),a=o.frontend?await promises.readFile(path.join(t,o.frontend.dir,"vite.config.ts"),"utf8").catch(()=>null):null;return checkPackageJson(r,await readJson(path.join(t,"package.json"))),await checkLocales(r,t,i,o,f.filter(u)),await checkMigrations(r,t,o),await checkClaw(r,t,o),checkSources(r,i,o,f,u,a),checkAccessAndTests(r,i,o,f),summarize(r,i.id)}d(runDoctor,"runDoctor");function summarize(e,t){const r={errors:e.list.filter(i=>i.severity==="error").length,warnings:e.list.filter(i=>i.severity==="warning").length},s=new Map(DOCTOR_RULES.map((i,o)=>[i.id,o])),c=[...e.list].sort((i,o)=>(i.severity===o.severity?0:i.severity==="error"?-1:1)||(s.get(i.rule)??0)-(s.get(o.rule)??0)||(i.file??"").localeCompare(o.file??"")||(i.line??0)-(o.line??0));return{...t?{pluginId:t}:{},findings:c,...r}}d(summarize,"summarize");var UPLOAD_BLOCKING_RULES=["TP-MAN-001","TP-I18N-001","TP-BE-003"];function formatDoctorReport(e){const t=e.findings.map(r=>{const s=r.file?`${r.file}${r.line?`:${r.line}`:""} `:"";return`${r.severity==="error"?"\u2716":"\u26A0"} ${r.rule} ${s}${r.message}`});return t.push(e.findings.length===0?"\u2705 doctor: no problems":`${e.errors} error(s), ${e.warnings} warning(s). Each rule is explained in node_modules/@tego/plugin-sdk/agent/RULES.md.`),t.join(`
8
+ `)}d(formatDoctorReport,"formatDoctorReport"),exports.DOCTOR_RULES=DOCTOR_RULES,exports.UPLOAD_BLOCKING_RULES=UPLOAD_BLOCKING_RULES,exports.doctorRule=doctorRule,exports.findColorIssues=findColorIssues,exports.formatDoctorReport=formatDoctorReport,exports.runDoctor=runDoctor;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The rules `tego-plugin doctor` checks, one entry per id.
3
+ *
4
+ * The ids are a contract with `agent/RULES.md` (which explains each one for a
5
+ * person or an agent fixing it) and with the tools that show findings: a test
6
+ * keeps the two lists equal, so a rule cannot ship undocumented.
7
+ */
8
+ type DoctorSeverity = 'error' | 'warning';
9
+ interface DoctorRule {
10
+ id: string;
11
+ severity: DoctorSeverity;
12
+ /** One line: what the rule wants. */
13
+ title: string;
14
+ /** Guide file under `agent/guides/` that explains it. */
15
+ guide: string;
16
+ }
17
+ declare const DOCTOR_RULES: readonly [DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule];
18
+ type DoctorRuleId = (typeof DOCTOR_RULES)[number]['id'];
19
+ declare function doctorRule(id: DoctorRuleId): DoctorRule;
20
+
21
+ interface ColorIssue {
22
+ line: number;
23
+ kind: 'color' | 'dark';
24
+ match: string;
25
+ }
26
+ /** Literal colors and hand-written dark overrides in one file; `ext` like `.tsx` or `.css`. */
27
+ declare function findColorIssues(source: string, ext: string): ColorIssue[];
28
+
29
+ interface DoctorFinding {
30
+ rule: DoctorRuleId;
31
+ severity: DoctorSeverity;
32
+ message: string;
33
+ /** Relative to the plugin root, with `/`. */
34
+ file?: string;
35
+ line?: number;
36
+ /** `agent/guides/<file>` in the installed SDK. */
37
+ guide: string;
38
+ }
39
+ interface DoctorReport {
40
+ pluginId?: string;
41
+ findings: DoctorFinding[];
42
+ errors: number;
43
+ warnings: number;
44
+ }
45
+ interface DoctorOptions {
46
+ /** The plugin folder (holding tego.plugin.json). */
47
+ root: string;
48
+ /**
49
+ * Limit source checks to these files (relative to root), e.g. the ones a
50
+ * change touched. Package-level checks (manifest, locales, migrations,
51
+ * access wiring) always run.
52
+ */
53
+ files?: string[];
54
+ }
55
+ /** Runs every check on the plugin folder. Never throws for a plugin problem; a missing folder throws. */
56
+ declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
57
+ /** Rules whose errors upload would refuse anyway; packing stops on these. */
58
+ declare const UPLOAD_BLOCKING_RULES: readonly DoctorRuleId[];
59
+ /** Human-readable report, one finding per line, for the terminal. */
60
+ declare function formatDoctorReport(report: DoctorReport): string;
61
+
62
+ export { DOCTOR_RULES, type DoctorFinding, type DoctorOptions, type DoctorReport, type DoctorRule, type DoctorRuleId, UPLOAD_BLOCKING_RULES, doctorRule, findColorIssues, formatDoctorReport, runDoctor };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The rules `tego-plugin doctor` checks, one entry per id.
3
+ *
4
+ * The ids are a contract with `agent/RULES.md` (which explains each one for a
5
+ * person or an agent fixing it) and with the tools that show findings: a test
6
+ * keeps the two lists equal, so a rule cannot ship undocumented.
7
+ */
8
+ type DoctorSeverity = 'error' | 'warning';
9
+ interface DoctorRule {
10
+ id: string;
11
+ severity: DoctorSeverity;
12
+ /** One line: what the rule wants. */
13
+ title: string;
14
+ /** Guide file under `agent/guides/` that explains it. */
15
+ guide: string;
16
+ }
17
+ declare const DOCTOR_RULES: readonly [DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule, DoctorRule];
18
+ type DoctorRuleId = (typeof DOCTOR_RULES)[number]['id'];
19
+ declare function doctorRule(id: DoctorRuleId): DoctorRule;
20
+
21
+ interface ColorIssue {
22
+ line: number;
23
+ kind: 'color' | 'dark';
24
+ match: string;
25
+ }
26
+ /** Literal colors and hand-written dark overrides in one file; `ext` like `.tsx` or `.css`. */
27
+ declare function findColorIssues(source: string, ext: string): ColorIssue[];
28
+
29
+ interface DoctorFinding {
30
+ rule: DoctorRuleId;
31
+ severity: DoctorSeverity;
32
+ message: string;
33
+ /** Relative to the plugin root, with `/`. */
34
+ file?: string;
35
+ line?: number;
36
+ /** `agent/guides/<file>` in the installed SDK. */
37
+ guide: string;
38
+ }
39
+ interface DoctorReport {
40
+ pluginId?: string;
41
+ findings: DoctorFinding[];
42
+ errors: number;
43
+ warnings: number;
44
+ }
45
+ interface DoctorOptions {
46
+ /** The plugin folder (holding tego.plugin.json). */
47
+ root: string;
48
+ /**
49
+ * Limit source checks to these files (relative to root), e.g. the ones a
50
+ * change touched. Package-level checks (manifest, locales, migrations,
51
+ * access wiring) always run.
52
+ */
53
+ files?: string[];
54
+ }
55
+ /** Runs every check on the plugin folder. Never throws for a plugin problem; a missing folder throws. */
56
+ declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
57
+ /** Rules whose errors upload would refuse anyway; packing stops on these. */
58
+ declare const UPLOAD_BLOCKING_RULES: readonly DoctorRuleId[];
59
+ /** Human-readable report, one finding per line, for the terminal. */
60
+ declare function formatDoctorReport(report: DoctorReport): string;
61
+
62
+ export { DOCTOR_RULES, type DoctorFinding, type DoctorOptions, type DoctorReport, type DoctorRule, type DoctorRuleId, UPLOAD_BLOCKING_RULES, doctorRule, findColorIssues, formatDoctorReport, runDoctor };
package/dist/doctor.js ADDED
@@ -0,0 +1,8 @@
1
+ var z=Object.defineProperty;var d=(e,t)=>z(e,"name",{value:t,configurable:!0});import{parseTegoPluginManifest as F}from"./chunk-EIVA5BR6.js";import{verifyFrontendLocales as G,flattenLocaleKeys as H,verifyMigrationNames as Z}from"./chunk-2NTAWYCG.js";import{resolvePluginParts as q}from"./chunk-IVJECPGG.js";import{pluginI18nNamespace as J}from"./chunk-INCA3GFW.js";import{isValidHostApiRange as Q,hostApiRangeMinimum as V}from"./chunk-56QOZQTY.js";import"./chunk-VMCGDDGP.js";import"./chunk-SNUCO2MK.js";import{execFile as X}from"child_process";import{stat as Y,readFile as x,readdir as C}from"fs/promises";import{join as P,relative as ee,extname as I,sep as te}from"path";var oe="ui-colors-allow",re="slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose",se="bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|from|via|to|fill|stroke|outline|divide|placeholder|caret|accent|decoration|shadow",ne=[new RegExp(`(?<![\\w-])(?:${se})-(?:(?:${re})-(?:50|[1-9]00|950)|white|black)(?:\\/\\d{1,3})?(?![\\w-])`,"g"),/-\[(?:#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\(\s*\d+)/g,/(?<![a-zA-Z0-9[-])(?:rgba?|hsla?)\(\s*\d+/g],ae=/['"`]#[0-9a-fA-F]{3,8}['"`]/g,ie=/(?<![\w&-])#[0-9a-fA-F]{3,8}(?![\w-])/g,j=/(?<![\w-])dark:(?=\S)/g,ce=/\.dark(?![\w-])/g;function B(e){return e.replace(/(^|[\s{(,;])\/\*[\s\S]*?\*\//g,(t,o)=>o+t.slice(o.length).replace(/[^\n]/g," ")).replace(/(^|\s)\/\/.*$/gm,(t,o)=>o)}d(B,"stripComments");function le(e){const t=new Set;return e.forEach((o,r)=>{o.includes(oe)&&(t.add(r),/^\s*(?:\/\/|\/\*|\*|\{\/\*)/.test(o)&&t.add(r+1))}),t}d(le,"allowedLines");function L(e,t){const o=t===".css",r=le(e.split(`
2
+ `)),c=B(e).split(`
3
+ `),i=[...ne,o?ie:ae],s=o?[j,ce]:[j],p=[];return c.forEach((m,n)=>{if(r.has(n))return;const u=[];for(const[a,l]of[["color",i],["dark",s]])for(const b of l)for(const v of m.matchAll(b))u.push({kind:a,match:v[0],at:v.index??0});u.sort((a,l)=>a.kind.localeCompare(l.kind)||a.at-l.at).forEach(({kind:a,match:l})=>p.push({line:n+1,kind:a,match:l}))}),p}d(L,"findColorIssues");var h=d((e,t,o,r)=>({id:e,severity:t,guide:o,title:r}),"rule"),N=[h("TP-MAN-001","error","manifest.md","tego.plugin.json is valid"),h("TP-MAN-002","error","host-api-versions.md","hostApiRange admits every host capability the code uses"),h("TP-PKG-001","error","manifest.md","SDK packages come from npm aliases or workspace links, never a plain @tego/* name"),h("TP-PKG-002","warning","testing.md","package.json has the lint, type-check, test and build gates"),h("TP-CLAW-002","warning","claw.md","Each claw skill's frontmatter name matches its folder"),h("TP-DESK-001","error","desktop-web.md","No eval or new Function (the desktop CSP refuses them)"),h("TP-DESK-002","error","desktop-web.md","No addresses built from location.origin (tauri://localhost on desktop)"),h("TP-DESK-003","error","desktop-web.md","External links open with ctx.openExternal, not window.open"),h("TP-DESK-004","error","desktop-web.md","Files are saved with ctx.download, not <a download> or a.download"),h("TP-DESK-005","warning","desktop-web.md","No streamed responses or EventSource from the frontend (the desktop proxy buffers; use ctx.events)"),h("TP-DESK-006","warning","desktop-web.md","Clipboard writes go through ctx.copyText"),h("TP-WEB-001","error","desktop-web.md","The frontend calls no third-party URL (the web CSP blocks it); call the backend, which declares egress"),h("TP-WEB-002","error","desktop-web.md","No remote or injected scripts; everything ships in the bundle"),h("TP-UI-001","error","ui.md","Colors come from token classes: no hex, rgb(), palette classes or dark: overrides"),h("TP-UI-002","error","ui.md","No direct @radix-ui/* imports; use @tego/ui"),h("TP-UI-003","error","ui.md","The plugin's root element carries its scope class (.<pluginId>) when it compiles its own Tailwind"),h("TP-UI-004","error","frontend.md","The frontend has its own error boundary"),h("TP-I18N-001","error","i18n.md","All ten locales, with the same keys as zh-CN, and every nav label defined"),h("TP-I18N-002","error","i18n.md","Every locale value keeps the {{placeholders}} of zh-CN and is not empty"),h("TP-I18N-003","error","i18n.md","Every literal t('\u2026') key exists in zh-CN"),h("TP-I18N-004","warning","i18n.md","Current terminology: \u6570\u5B57\u5458\u5DE5 / Employee, never \u6570\u5B57\u5206\u8EAB / digital avatar"),h("TP-BE-001","error","backend.md","Identity comes from the verified ctx, never from the raw x-tego-plugin-* headers"),h("TP-BE-002","error","access.md","No host role names in business code; ask the access engine"),h("TP-BE-003","error","migrations.md","Migrations are <ordinal>_<name>.sql with unique ordinals"),h("TP-BE-004","error","migrations.md","Committed migrations are never edited or deleted"),h("TP-BE-005","warning","backend.md","Backend errors are logged through host.log, not console"),h("TP-ACC-001","error","access.md","With an access catalog, a test asserts every route has a policy (assertRouteCoverage)"),h("TP-ACC-002","error","access.md","With an access catalog, start() reconciles it (reconcile(host))"),h("TP-TEST-001","warning","testing.md","The plugin has tests")],de=new Map(N.map(e=>[e.id,e]));function _(e){return de.get(e)}d(_,"doctorRule");var ue=[{pattern:/\.openExternal\s*\(/,since:"1.37.0",name:"ctx.openExternal"},{pattern:/\.copyText\s*\(/,since:"1.37.0",name:"ctx.copyText"},{pattern:/\b(?:ctx|host|context)\.download\s*\(/,since:"1.37.0",name:"ctx.download"},{pattern:/\b(?:ctx|host|context)\.platform\b/,since:"1.37.0",name:"ctx.platform"}],pe=new Set([".ts",".tsx",".mts",".cts",".js",".jsx",".mjs",".css"]),he=new Set(["node_modules","dist","build",".tego-dev",".turbo",".drizzle","__fixtures__","coverage"]),fe=/\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])test[\\/]|(?:^|[\\/])test-[\w-]+\.[cm]?[jt]s$|-double\.[cm]?[jt]s$|\.d\.ts$/,me=/tego-doctor-ignore\s+([A-Z0-9-]+(?:\s*,\s*[A-Z0-9-]+)*)/,ge=/\{\{\s*([\w.-]+)\s*\}\}/g,we=["lint","type-check","test","build"];function Te(e){return e.split(te).join("/")}d(Te,"toPosix");async function K(e,t,o){let r;try{r=await C(P(e,t),{withFileTypes:!0})}catch{return}for(const c of r){const i=t?`${t}/${c.name}`:c.name;c.isDirectory()?!he.has(c.name)&&!c.name.startsWith(".")&&await K(e,i,o):pe.has(I(c.name))&&o.push(i)}}d(K,"walk");function O(e){return{async list(t){try{return(await C(P(e,t),{withFileTypes:!0})).filter(r=>r.isFile()).map(r=>r.name)}catch{return[]}},readText:d(t=>x(P(e,t),"utf8").catch(()=>null),"readText")}}d(O,"folderReader");function M(e,t){return new Promise(o=>{X("git",["-C",e,...t],{maxBuffer:8*1024*1024},(r,c)=>o(r?null:c))})}d(M,"runGit");function R(e){const t=new Map;return e.split(`
4
+ `).forEach((o,r)=>{const c=me.exec(o);if(!c?.[1])return;const i=new Set(c[1].split(",").map(p=>p.trim())),s=d(p=>t.set(p,new Set([...t.get(p)??[],...i])),"add");s(r),/^\s*(?:\/\/|\/\*|\*|\{\/\*)/.test(o)&&s(r+1)}),t}d(R,"ignoredLines");var be=class{static{d(this,"Findings")}list=[];add(e,t,o={}){const{severity:r,guide:c}=_(e);this.list.push({rule:e,severity:r,message:t,guide:c,...o})}};function w(e,t,o,r){const c=R(e.text);e.code.split(`
5
+ `).forEach((i,s)=>{if(c.get(s)?.has(t))return;const p=o.exec(i);p&&r(s+1,p[0])})}d(w,"eachMatch");function Pe(e,t){const o=d(r=>!!r&&(e===r||e.startsWith(`${r}/`)),"inside");return o(t.frontend?.dir)||o(t.publicFrontend?.dir)?"frontend":o(t.backend?.dir)?"backend":o(t.claw?.dir)?"claw":o("shared")?"shared":"other"}d(Pe,"partOf");async function ye(e){try{return JSON.parse(await x(e,"utf8"))}catch{return}}d(ye,"readJson");function ke(e,t){if(!t)return;for(const c of["dependencies","devDependencies","peerDependencies"]){const i=t[c];if(!(!i||typeof i!="object"))for(const[s,p]of Object.entries(i))!s.startsWith("@tego/")||typeof p!="string"||/^(?:npm:@zhama\/tego-|workspace:|file:|link:|portal:)/.test(p)||e.add("TP-PKG-001",`${c}.${s} is "${p}": on npm @tego/* belongs to another project. Install it as "npm:@zhama/tego-${s.slice(6)}@^<sdk version>".`,{file:"package.json"})}const o=t.scripts??{},r=we.filter(c=>typeof o[c]!="string");r.length>0&&e.add("TP-PKG-002",`package.json has no ${r.map(c=>`"${c}"`).join(", ")} script; the platform's gates are lint, type-check, test and build.`,{file:"package.json"})}d(ke,"checkPackageJson");async function Ee(e,t,o,r,c){const i=r.frontend;if(!i)return;const{bundles:s,errors:p}=await G(O(t),i);for(const f of p)e.add("TP-I18N-001",f,{file:i.i18nDirPath});const m=s.get("zh-CN");if(!m)return;const n=d((f,g="",y=new Map)=>{if(typeof f!="object"||f===null||Array.isArray(f))return y.set(g,f),y;for(const[E,k]of Object.entries(f))n(k,g?`${g}.${E}`:E,y);return y},"leaves"),u=d(f=>new Set(typeof f=="string"?[...f.matchAll(ge)].map(g=>g[1]):[]),"placeholders"),a=n(m);for(const[f,g]of s){const y=`${i.i18nDirPath}/${f}.json`;let E=0;for(const[k,T]of n(g)){if(E>=10)break;if(typeof T=="string"&&T.trim()===""){e.add("TP-I18N-002",`"${k}" is empty in ${f}.json.`,{file:y}),E++;continue}if(f==="zh-CN"||!a.has(k))continue;const S=u(a.get(k)),$=u(T),U=[...S].filter(A=>!$.has(A)),W=[...$].filter(A=>!S.has(A));(U.length||W.length)&&(e.add("TP-I18N-002",`"${k}" in ${f}.json has placeholders {${[...$].join(", ")}} but zh-CN has {${[...S].join(", ")}}.`,{file:y}),E++)}for(const[k,T]of n(g))if(typeof T=="string"&&/数字分身|digital avatar/i.test(T)){e.add("TP-I18N-004",`"${k}" in ${f}.json says "${/数字分身/.test(T)?"\u6570\u5B57\u5206\u8EAB":"digital avatar"}"; the platform calls them \u6570\u5B57\u5458\u5DE5 / Employee ({{avatarTerm}}).`,{file:y});break}}const l=H(m),b=new Set(Object.keys(m)),v=d(f=>l.has(f)||["_one","_other","_zero","_few","_many","_two"].some(g=>l.has(f+g))||[...l].some(g=>g.startsWith(`${f}.`)),"exists");for(const f of c){if(f.part!=="frontend"||f.test)continue;const g=R(f.text);f.code.split(`
6
+ `).forEach((y,E)=>{if(!g.get(E)?.has("TP-I18N-003"))for(const k of y.matchAll(/\bt\(\s*['"]([A-Za-z0-9_.-]+)['"]/g)){const T=k[1],S=T.split(".")[0];!T.includes(".")||!b.has(S)||v(T)||e.add("TP-I18N-003",`t('${T}') has no entry in zh-CN.json (namespace ${J(o.id)}).`,{file:f.path,line:E+1})}})}}d(Ee,"checkLocales");async function ve(e,t,o){const r=o.backend;if(!r)return;const{errors:c}=await Z(O(t),r.migrationsDirPath);for(const p of c)e.add("TP-BE-003",p,{file:r.migrationsDirPath});const i=await M(t,["diff","--name-status","HEAD","--",r.migrationsDirPath]);if(!i)return;const s=(await M(t,["rev-parse","--show-prefix"]))?.trim()??"";for(const p of i.split(`
7
+ `)){const[m,n]=p.split(" ");if(!n||m!=="M"&&m!=="D")continue;const u=n.startsWith(s)?n.slice(s.length):n;e.add("TP-BE-004",`${u} is a committed migration and was ${m==="M"?"edited":"deleted"}; an install may have applied it already. Revert it and add a new migration instead.`,{file:u})}}d(ve,"checkMigrations");async function xe(e,t,o){const r=o.claw;if(!r)return;const c=P(t,r.dir,"skills");let i=[];try{i=(await C(c,{withFileTypes:!0})).filter(s=>s.isDirectory()).map(s=>s.name)}catch{return}for(const s of i){const p=await x(P(c,s,"SKILL.md"),"utf8").catch(()=>null),m=`${r.dir}/skills/${s}/SKILL.md`,n=p?/^---[\s\S]*?^name:\s*["']?([^"'\n]+)["']?\s*$/m.exec(p)?.[1]?.trim():void 0;n!==s&&e.add("TP-CLAW-002",p?`${m} declares name "${n??"(none)"}"; it must equal the folder name "${s}".`:`${r.dir}/skills/${s} has no SKILL.md.`,{file:m})}}d(xe,"checkClaw");function Se(e,t,o,r,c,i){const s=t.hostApiRange&&Q(t.hostApiRange)?V(t.hostApiRange):void 0,p=d(n=>{if(!s)return!1;const[u,a,l]=s.split(".").map(Number),[b,v,f]=n.split(".").map(Number);return u!==b?u<b:a!==v?a<v:l<f},"below");for(const n of r){if(!c(n)||n.test)continue;const u=d(a=>({file:n.path,line:a}),"at");if(n.part==="frontend"){if(n.path.endsWith(".css")||n.path.endsWith(".tsx")||n.path.endsWith(".ts")){const a=R(n.text);for(const l of L(n.text,I(n.path)))a.get(l.line-1)?.has("TP-UI-001")||e.add("TP-UI-001",l.kind==="dark"?`"${l.match}" is a hand-written dark override; tokens already carry both themes.`:`"${l.match}" is a literal color; use a token class (bg-card, text-muted-foreground, bg-primary\u2026).`,u(l.line))}w(n,"TP-DESK-001",/\beval\s*\(|\bnew\s+Function\s*\(/,a=>e.add("TP-DESK-001","eval / new Function: the desktop app's CSP (script-src 'self') refuses code compiled from strings.",u(a))),w(n,"TP-DESK-002",/\b(?:window\.|document\.)?location\.origin\b/,a=>e.add("TP-DESK-002","location.origin is tauri://localhost in the desktop app; build host addresses with ctx.hostUrl(path) and share links from ctx.publicPagesUrl.",u(a))),w(n,"TP-DESK-003",/\bwindow\.open\s*\(/,a=>e.add("TP-DESK-003","window.open does not reliably open links in the desktop app; use ctx.openExternal(url) (hostApiRange ^1.37.0), or ctx.download for a file.",u(a))),w(n,"TP-DESK-004",/\.download\s*=(?!=)|<a\b[^>]*\sdownload(?:[\s=>]|$)/,a=>e.add("TP-DESK-004","An <a download> save is not handled by the desktop app; use ctx.download({ path | blob, fileName }) (hostApiRange ^1.37.0).",u(a))),w(n,"TP-DESK-005",/\bnew\s+EventSource\s*\(|\.body\s*(?:\?\.|!\.|\.)\s*getReader\s*\(/,a=>e.add("TP-DESK-005","The desktop app buffers ctx.api responses whole (and caps them at 100 MB), so streams arrive at the end; subscribe to ctx.events for pushed updates.",u(a))),w(n,"TP-DESK-006",/\bnavigator\.clipboard\./,a=>e.add("TP-DESK-006","navigator.clipboard is not available in every shell; use ctx.copyText(text) (hostApiRange ^1.37.0).",u(a))),w(n,"TP-WEB-001",/\bfetch\s*\(\s*['"`]https?:\/\//,a=>e.add("TP-WEB-001","The web app's CSP allows connections to the platform only; call your backend through ctx.api and let it reach the service (declare it in parts.backend.egress).",u(a))),w(n,"TP-WEB-002",/\bimport\s*\(\s*['"`]https?:|createElement\(\s*['"]script['"]\s*\)|<script\b[^>]*\bsrc=/,a=>e.add("TP-WEB-002","Remote or injected scripts are refused by both shells' CSP; bundle the code.",u(a))),w(n,"TP-UI-002",/from\s+['"]@radix-ui\//,a=>e.add("TP-UI-002","Import the @tego/ui wrapper instead of @radix-ui/* (the shell shares @tego/ui; a second Radix copy breaks focus and portals).",u(a)));for(const a of ue){if(!p(a.since))continue;const l=a.name.split(".").pop();new RegExp(`\\.${l}\\s*(?:\\)|\\?(?!\\.)|&&)`).test(n.code)||w(n,"TP-MAN-002",a.pattern,b=>e.add("TP-MAN-002",`${a.name} needs host API ${a.since}, but hostApiRange is "${t.hostApiRange}"; raise it to ^${a.since}.`,u(b)))}}n.part==="backend"&&(w(n,"TP-BE-001",/\bheaders?\s*(?:\.get)?\(\s*['"]x-tego-plugin-(?:user-id|tenant-id|roles|avatar-id|delegation)['"]|\bheaders\s*\[\s*['"]x-tego-plugin-(?:user-id|tenant-id|roles|avatar-id|delegation)['"]/i,a=>e.add("TP-BE-001","Take the caller from the verified ctx (ctx.userId, ctx.tenantId, ctx.roles) that serveBackend hands you; the raw x-tego-plugin-* headers are unverified here.",u(a))),w(n,"TP-BE-005",/\bconsole\.(?:error|warn)\s*\(/,a=>e.add("TP-BE-005","Log through host.log so the message reaches the platform's logs; console output from a plugin container is not collected.",u(a)))),n.part!=="other"&&w(n,"TP-BE-002",/\b(?:roles|roleNames)\s*\??\.\s*(?:includes|indexOf)\(\s*['"`](?:admin|owner|super_?admin|tenant_?admin|member|manager)['"`]/i,a=>e.add("TP-BE-002","Do not decide by host role names; declare permissions in the access catalog (hostRoleBindings) and ask the engine (authorize / useAccess).",u(a)))}const m=r.filter(n=>n.part==="frontend"&&!n.test);if(o.frontend&&m.length>0&&(m.some(n=>/getDerivedStateFromError|componentDidCatch|PluginErrorBoundary/.test(n.code))||e.add("TP-UI-004","No error boundary in the frontend: the shell's boundary cannot see into your React root, so a render error leaves a blank page. Wrap the root in PluginErrorBoundary (@tego/plugin-sdk/react) or your own.",{file:o.frontend.dir}),i?.includes("tegoPluginTailwind"))){const n=new RegExp(`(?:className|class)=\\{?\\s*(?:cn\\()?\\s*['"\`][^'"\`]*(?<![\\w-])${t.id.replace(/[.]/g,"\\.")}(?![\\w-])`);m.some(u=>n.test(u.code))||e.add("TP-UI-003",`tegoPluginTailwind scopes every utility under .${t.id}, but no element carries className="${t.id}": none of the plugin's own classes will apply. Put it on the root element (and on portalled content, or use tegoPluginScopeRoot).`,{file:o.frontend.dir})}}d(Se,"checkSources");function $e(e,t,o,r){const c=r.filter(s=>s.test);if(c.length===0&&e.add("TP-TEST-001","The plugin has no *.test.ts files; backend routes and pure frontend logic need tests (createTestPluginHost for SQL)."),!t.access||!o.backend)return;c.some(s=>s.text.includes("assertRouteCoverage"))||e.add("TP-ACC-001","The manifest declares an access catalog but no test calls assertRouteCoverage(app): a route without a policy would be open to everyone who can open the plugin.",{file:o.backend.dir}),r.filter(s=>(s.part==="backend"||s.part==="shared")&&!s.test).some(s=>/\breconcile\s*\(/.test(s.code))||e.add("TP-ACC-002","The manifest declares an access catalog but start() never calls reconcile(host): built-in roles and catalog changes never reach the database.",{file:o.backend.dir})}d($e,"checkAccessAndTests");async function Ae(e){const t=e.root;await Y(P(t,"tego.plugin.json")).catch(()=>{throw new Error(`No tego.plugin.json in ${t}; run doctor in the plugin folder.`)});const o=new be;let r;try{r=JSON.parse(await x(P(t,"tego.plugin.json"),"utf8"))}catch(l){return o.add("TP-MAN-001",`tego.plugin.json is not valid JSON: ${l instanceof Error?l.message:String(l)}`,{file:"tego.plugin.json"}),D(o,void 0)}const c=F(r);if(!c.ok||!c.manifest){for(const l of c.errors)o.add("TP-MAN-001",l,{file:"tego.plugin.json"});return D(o,r.id)}const i=c.manifest,s=q(i),p=[];await K(t,"",p);const m=await Promise.all(p.map(async l=>{const b=await x(P(t,l),"utf8");return{path:l,part:Pe(l,s),text:b,code:B(b),test:fe.test(l)}})),n=e.files?.map(l=>Te(ee(t,P(t,l)))),u=d(l=>!n||n.includes(l.path),"scanned"),a=s.frontend?await x(P(t,s.frontend.dir,"vite.config.ts"),"utf8").catch(()=>null):null;return ke(o,await ye(P(t,"package.json"))),await Ee(o,t,i,s,m.filter(u)),await ve(o,t,s),await xe(o,t,s),Se(o,i,s,m,u,a),$e(o,i,s,m),D(o,i.id)}d(Ae,"runDoctor");function D(e,t){const o={errors:e.list.filter(i=>i.severity==="error").length,warnings:e.list.filter(i=>i.severity==="warning").length},r=new Map(N.map((i,s)=>[i.id,s])),c=[...e.list].sort((i,s)=>(i.severity===s.severity?0:i.severity==="error"?-1:1)||(r.get(i.rule)??0)-(r.get(s.rule)??0)||(i.file??"").localeCompare(s.file??"")||(i.line??0)-(s.line??0));return{...t?{pluginId:t}:{},findings:c,...o}}d(D,"summarize");var Ce=["TP-MAN-001","TP-I18N-001","TP-BE-003"];function Ne(e){const t=e.findings.map(o=>{const r=o.file?`${o.file}${o.line?`:${o.line}`:""} `:"";return`${o.severity==="error"?"\u2716":"\u26A0"} ${o.rule} ${r}${o.message}`});return t.push(e.findings.length===0?"\u2705 doctor: no problems":`${e.errors} error(s), ${e.warnings} warning(s). Each rule is explained in node_modules/@tego/plugin-sdk/agent/RULES.md.`),t.join(`
8
+ `)}d(Ne,"formatDoctorReport");export{N as DOCTOR_RULES,Ce as UPLOAD_BLOCKING_RULES,_ as doctorRule,L as findColorIssues,Ne as formatDoctorReport,Ae as runDoctor};
@@ -306,7 +306,39 @@ interface PluginHostContext {
306
306
  * catalog (`@tego/plugin-sdk/access`).
307
307
  */
308
308
  can(navKey: string): boolean;
309
+ /**
310
+ * Which shell runs the page (host API 1.37). Write code that is correct in
311
+ * both rather than branching on this; it is for wording ("Open in browser")
312
+ * and diagnostics. Absent on older hosts.
313
+ */
314
+ platform?: 'web' | 'desktop';
315
+ /**
316
+ * Opens an external http(s) URL in the system browser (desktop) or a new tab
317
+ * (web). `window.open` is unreliable inside the desktop webview. Host API 1.37.
318
+ */
319
+ openExternal?(url: string): Promise<void>;
320
+ /**
321
+ * Saves a file: a host path the backend issued (`signedPath`, `managedPath`)
322
+ * or content the page generated. The desktop app shows a native save
323
+ * dialog; `<a download>` does nothing there. Resolves once the save started
324
+ * (web) or finished (desktop); rejects on failure, not on a cancelled
325
+ * dialog. Host API 1.37.
326
+ */
327
+ download?(request: PluginDownloadRequest): Promise<void>;
328
+ /** Copies text to the clipboard; `false` when the shell refused. Host API 1.37. */
329
+ copyText?(text: string): Promise<boolean>;
309
330
  }
331
+ /** What `PluginHostContext.download` saves. */
332
+ type PluginDownloadRequest = {
333
+ /** A root-relative host path from the backend (`signedPath`, `managedPath`). */
334
+ path: string;
335
+ /** Suggested name; defaults to what the server sends. */
336
+ fileName?: string;
337
+ } | {
338
+ /** Content generated in the page (a CSV, a template). */
339
+ blob: Blob;
340
+ fileName: string;
341
+ };
310
342
  /** Metadata produced by the plugin after applying its document and pricing permissions. */
311
343
  interface PluginOfficeArtifact {
312
344
  fileName: string;
@@ -350,4 +382,4 @@ interface PluginFrontendModule {
350
382
  /** Identity helper for inference, mirroring claw's `defineToolPlugin`. */
351
383
  declare function definePluginFrontend(module: PluginFrontendModule): PluginFrontendModule;
352
384
 
353
- export { type PluginAiDraftErrorCode, type PluginAiDraftRequest, type PluginAiFieldValue, type PluginAiForm, type PluginAiFormField, type PluginAiFormRegistry, type PluginAiFormSnapshot, type PluginApiClient, type PluginDelegationState, type PluginDelegationStore, type PluginEvent, type PluginEventStream, type PluginFrontendModule, type PluginFrontendUserContext, type PluginHostContext, type PluginOfficeArtifact, type PluginRoute, type PluginRouteStore, type PluginUserContext, buildPluginAiFormPrompt, createPluginAiFormRegistry, definePluginFrontend, resolvePluginAiFormPatch };
385
+ export { type PluginAiDraftErrorCode, type PluginAiDraftRequest, type PluginAiFieldValue, type PluginAiForm, type PluginAiFormField, type PluginAiFormRegistry, type PluginAiFormSnapshot, type PluginApiClient, type PluginDelegationState, type PluginDelegationStore, type PluginDownloadRequest, type PluginEvent, type PluginEventStream, type PluginFrontendModule, type PluginFrontendUserContext, type PluginHostContext, type PluginOfficeArtifact, type PluginRoute, type PluginRouteStore, type PluginUserContext, buildPluginAiFormPrompt, createPluginAiFormRegistry, definePluginFrontend, resolvePluginAiFormPatch };
@@ -306,7 +306,39 @@ interface PluginHostContext {
306
306
  * catalog (`@tego/plugin-sdk/access`).
307
307
  */
308
308
  can(navKey: string): boolean;
309
+ /**
310
+ * Which shell runs the page (host API 1.37). Write code that is correct in
311
+ * both rather than branching on this; it is for wording ("Open in browser")
312
+ * and diagnostics. Absent on older hosts.
313
+ */
314
+ platform?: 'web' | 'desktop';
315
+ /**
316
+ * Opens an external http(s) URL in the system browser (desktop) or a new tab
317
+ * (web). `window.open` is unreliable inside the desktop webview. Host API 1.37.
318
+ */
319
+ openExternal?(url: string): Promise<void>;
320
+ /**
321
+ * Saves a file: a host path the backend issued (`signedPath`, `managedPath`)
322
+ * or content the page generated. The desktop app shows a native save
323
+ * dialog; `<a download>` does nothing there. Resolves once the save started
324
+ * (web) or finished (desktop); rejects on failure, not on a cancelled
325
+ * dialog. Host API 1.37.
326
+ */
327
+ download?(request: PluginDownloadRequest): Promise<void>;
328
+ /** Copies text to the clipboard; `false` when the shell refused. Host API 1.37. */
329
+ copyText?(text: string): Promise<boolean>;
309
330
  }
331
+ /** What `PluginHostContext.download` saves. */
332
+ type PluginDownloadRequest = {
333
+ /** A root-relative host path from the backend (`signedPath`, `managedPath`). */
334
+ path: string;
335
+ /** Suggested name; defaults to what the server sends. */
336
+ fileName?: string;
337
+ } | {
338
+ /** Content generated in the page (a CSV, a template). */
339
+ blob: Blob;
340
+ fileName: string;
341
+ };
310
342
  /** Metadata produced by the plugin after applying its document and pricing permissions. */
311
343
  interface PluginOfficeArtifact {
312
344
  fileName: string;
@@ -350,4 +382,4 @@ interface PluginFrontendModule {
350
382
  /** Identity helper for inference, mirroring claw's `defineToolPlugin`. */
351
383
  declare function definePluginFrontend(module: PluginFrontendModule): PluginFrontendModule;
352
384
 
353
- export { type PluginAiDraftErrorCode, type PluginAiDraftRequest, type PluginAiFieldValue, type PluginAiForm, type PluginAiFormField, type PluginAiFormRegistry, type PluginAiFormSnapshot, type PluginApiClient, type PluginDelegationState, type PluginDelegationStore, type PluginEvent, type PluginEventStream, type PluginFrontendModule, type PluginFrontendUserContext, type PluginHostContext, type PluginOfficeArtifact, type PluginRoute, type PluginRouteStore, type PluginUserContext, buildPluginAiFormPrompt, createPluginAiFormRegistry, definePluginFrontend, resolvePluginAiFormPatch };
385
+ export { type PluginAiDraftErrorCode, type PluginAiDraftRequest, type PluginAiFieldValue, type PluginAiForm, type PluginAiFormField, type PluginAiFormRegistry, type PluginAiFormSnapshot, type PluginApiClient, type PluginDelegationState, type PluginDelegationStore, type PluginDownloadRequest, type PluginEvent, type PluginEventStream, type PluginFrontendModule, type PluginFrontendUserContext, type PluginHostContext, type PluginOfficeArtifact, type PluginRoute, type PluginRouteStore, type PluginUserContext, buildPluginAiFormPrompt, createPluginAiFormRegistry, definePluginFrontend, resolvePluginAiFormPatch };