@pplethai/cli 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -12,7 +12,7 @@ ${t.stdout}`.split(`
12
12
  `;await g.writeFile(e,`${i}${bt}
13
13
  `)}var Te=3e3,Se=3*6e4,Re=10*6e4,Ie=20;async function Rt(t,e){let r=await y(t);if(r.apiBase!==""&&r.apiBase!==t.config.apiBase)throw new s("\u0E41\u0E2D\u0E1B\u0E19\u0E35\u0E49\u0E2D\u0E22\u0E39\u0E48\u0E1A\u0E19 PPLE Platform \u0E04\u0E19\u0E25\u0E30\u0E15\u0E31\u0E27\u0E01\u0E31\u0E1A\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A\u0E2D\u0E22\u0E39\u0E48",`\u0E41\u0E2D\u0E1B\u0E19\u0E35\u0E49\u0E16\u0E39\u0E01\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E44\u0E27\u0E49\u0E01\u0E31\u0E1A ${r.apiBase}`);let n=await p(t),i=await n.app(r.slug);if(i.status==="retired")throw new s(`"${i.name}" \u0E16\u0E39\u0E01\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27`,"\u0E41\u0E2D\u0E1B\u0E17\u0E35\u0E48\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49 \u0E2B\u0E32\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E32\u0E23\u0E2A\u0E32\u0E19\u0E07\u0E32\u0E19\u0E19\u0E35\u0E49\u0E15\u0E48\u0E2D \u0E43\u0E2B\u0E49\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B\u0E43\u0E2B\u0E21\u0E48\u0E43\u0E19\u0E04\u0E2D\u0E19\u0E42\u0E0B\u0E25");if(i.status!=="ready")throw new s(`"${i.name}" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E23\u0E31\u0E1A\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07`,i.error?`\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1E\u0E1A\u0E1B\u0E31\u0E0D\u0E2B\u0E32: ${i.error}`:"\u0E15\u0E23\u0E27\u0E08\u0E2A\u0E2D\u0E1A\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai \u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E43\u0E19\u0E2D\u0E35\u0E01\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48");let o=await n.repoToken(r.slug);if(await U(t,r.dir),await I(t,r.dir))t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u2026"),await L(t,r.dir,e.message??`\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15 ${i.name}`,o);else if(await Pt(t,r.dir,o)){t.out.say(`\u0E44\u0E21\u0E48\u0E21\u0E35\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E43\u0E2B\u0E21\u0E48 \u2014 ${i.name} \u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27\u0E17\u0E35\u0E48 ${i.url}`);return}t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2A\u0E48\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u2026"),await $t(t,r.dir,o);let u=await vt(t,r.dir);t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E34\u0E25\u0E14\u0E4C\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u2026 (\u0E1B\u0E01\u0E15\u0E34\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E25\u0E32\u0E23\u0E32\u0E27\u0E2B\u0E19\u0E36\u0E48\u0E07\u0E19\u0E32\u0E17\u0E35)");let a=await Le(t,n,r.slug,u);Oe(t,i,a)}async function Le(t,e,r,n){let i=t.now(),o;for(;;){let a=(await e.deploys(r)).find(fe=>fe.commit===n);if(a&&(o=a,a.status!=="building"))return a;if(t.now()-i>=(o?Re:Se))throw new s(o?"\u0E2A\u0E48\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E41\u0E25\u0E49\u0E27 \u0E41\u0E15\u0E48\u0E01\u0E32\u0E23\u0E1A\u0E34\u0E25\u0E14\u0E4C\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E25\u0E32\u0E19\u0E32\u0E19\u0E01\u0E27\u0E48\u0E32\u0E17\u0E35\u0E48\u0E04\u0E32\u0E14\u0E44\u0E27\u0E49":"\u0E2A\u0E48\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E41\u0E25\u0E49\u0E27 \u0E41\u0E15\u0E48\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E40\u0E23\u0E34\u0E48\u0E21\u0E1A\u0E34\u0E25\u0E14\u0E4C",`\u0E15\u0E34\u0E14\u0E15\u0E32\u0E21\u0E08\u0E19\u0E40\u0E2A\u0E23\u0E47\u0E08\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai/apps/${r}`);await t.sleep(Te)}}var Ne="superseded by a newer deploy";function Oe(t,e,r){if(r.status==="failed"){if(r.error===Ne)throw new s("\u0E21\u0E35\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E21\u0E32\u0E41\u0E17\u0E19\u0E17\u0E35\u0E48\u0E04\u0E23\u0E31\u0E49\u0E07\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E01\u0E33\u0E25\u0E31\u0E07\u0E23\u0E2D\u0E2D\u0E22\u0E39\u0E48",`\u0E15\u0E34\u0E14\u0E15\u0E32\u0E21\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E19\u0E31\u0E49\u0E19\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai/apps/${e.slug}`);let i=r.log?je(r.log,Ie):r.error;throw i&&(t.out.say(),t.out.say(i),t.out.say()),new s(`\u0E1A\u0E34\u0E25\u0E14\u0E4C ${e.name} \u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 \u0E2A\u0E34\u0E48\u0E07\u0E17\u0E35\u0E48\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48\u0E08\u0E36\u0E07\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07`,i?"\u0E2A\u0E48\u0E07\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E14\u0E49\u0E32\u0E19\u0E1A\u0E19\u0E43\u0E2B\u0E49 AI coding agent \u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13 \u2014 \u0E43\u0E19\u0E19\u0E31\u0E49\u0E19\u0E1A\u0E2D\u0E01\u0E27\u0E48\u0E32\u0E15\u0E49\u0E2D\u0E07\u0E41\u0E01\u0E49\u0E2D\u0E30\u0E44\u0E23":`\u0E14\u0E39\u0E23\u0E32\u0E22\u0E25\u0E30\u0E40\u0E2D\u0E35\u0E22\u0E14\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai/apps/${e.slug}`)}let n=r.migrations.applied??[];if(n.length>0&&t.out.say(`\u0E43\u0E0A\u0E49 migration ${n.length} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E01\u0E31\u0E1A\u0E10\u0E32\u0E19\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E41\u0E25\u0E49\u0E27`),r.status==="unverified"){t.out.say(`\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15 ${e.name} \u0E41\u0E25\u0E49\u0E27 \u0E41\u0E15\u0E48\u0E41\u0E2D\u0E1B\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E1A\u0E2A\u0E19\u0E2D\u0E07`),t.out.say(`\u0E23\u0E2D\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48 \u0E41\u0E25\u0E49\u0E27\u0E40\u0E1B\u0E34\u0E14 ${e.url}`);return}t.out.say(`${e.name} \u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E44\u0E14\u0E49\u0E41\u0E25\u0E49\u0E27\u0E17\u0E35\u0E48 ${e.url}`)}function je(t,e){return t.split(`
14
14
  `).slice(-e).join(`
15
- `).trim()}import Be from"node:fs/promises";import Ue from"node:path";async function It(t){let e=await y(t),n=await(await p(t)).env(e.slug);if(n.length===0){t.out.say("\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46"),t.out.say("\u0E15\u0E31\u0E27\u0E41\u0E1B\u0E23\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E31\u0E49\u0E07\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B");return}for(let i of n)i.secret?t.out.say(`${i.key}=(secret)`):t.out.say(`${i.key}=${i.value??""}`);t.out.say(""),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Lt(t,e){let r=await y(t),n=await Me(t,e),o=await(await p(t)).setEnv(r.slug,e.key,n,e.secret);t.out.say(`\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01 ${o.key}${o.secret?" (secret)":""} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Nt(t,e){let r=await y(t);await(await p(t)).deleteEnv(r.slug,e),t.out.say(`\u0E25\u0E1A ${e} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Me(t,e){if(e.file!==void 0&&e.value!==void 0)throw new s("\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E2B\u0E23\u0E37\u0E2D\u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2D\u0E22\u0E48\u0E32\u0E07\u0E43\u0E14\u0E2D\u0E22\u0E48\u0E32\u0E07\u0E2B\u0E19\u0E36\u0E48\u0E07 \u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E17\u0E31\u0E49\u0E07\u0E2A\u0E2D\u0E07\u0E2D\u0E22\u0E48\u0E32\u0E07");if(e.file!==void 0){let r=Ue.resolve(t.cwd,e.file),n=await Be.readFile(r,"utf8").catch(()=>null);if(n===null)throw new s(`\u0E2D\u0E48\u0E32\u0E19\u0E44\u0E1F\u0E25\u0E4C ${e.file} \u0E44\u0E21\u0E48\u0E44\u0E14\u0E49`,"\u0E15\u0E23\u0E27\u0E08\u0E2A\u0E2D\u0E1A\u0E1E\u0E32\u0E18\u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07");return n}if(e.value!==void 0)return e.value;if(t.interactive)throw new s("`pple env set` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E04\u0E48\u0E32","\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2B\u0E23\u0E37\u0E2D pipe \u0E17\u0E32\u0E07 stdin");return t.readStdin()}var Fe=20,Ge=/[\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]/g;function l(t,e){let r=t.replace(Ge,"").length;return t+" ".repeat(Math.max(0,e-r))}var Ke={ready:"\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19",provisioning:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E08\u0E31\u0E14\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27",retired:"\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27"},Ot={draft:"\u0E09\u0E1A\u0E31\u0E1A\u0E23\u0E48\u0E32\u0E07",beta:"\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",live:"\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07"},He={owner:"\u0E40\u0E08\u0E49\u0E32\u0E02\u0E2D\u0E07",collaborator:"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"},jt={live:"\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48",building:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E34\u0E25\u0E14\u0E4C",unverified:"\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27"},Ve={pending:"\u0E23\u0E2D\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23",approved:"\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E41\u0E25\u0E49\u0E27",denied:"\u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18\u0E41\u0E25\u0E49\u0E27"};function v(t,e){return t[e]??e}async function Bt(t,e){let r=await Je(t,e),n=await p(t),i=await n.app(r),o=i.role!=="collaborator",[u,a,c,P]=await Promise.all([n.metrics(r),n.deploys(r),n.env(r),o?Ye(n,r):Promise.resolve(null)]);We(t,i),Xe(t,u),ze(t,a),Ze(t,c),P&&(Qe(t,i,P.requests,P.invites),tr(t,P.collaborators),er(t,P.requests)),t.out.say(""),t.out.say("\u0E2B\u0E32\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E42\u0E04\u0E27\u0E15\u0E32 \u0E04\u0E48\u0E32 env \u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32 \u0E2B\u0E23\u0E37\u0E2D\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \u0E43\u0E0A\u0E49 https://console.ppleth.ai")}async function Je(t,e){if(e)return e;try{return(await y(t)).slug}catch(r){throw r instanceof s&&r.message==="\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E41\u0E2D\u0E1B PPLE"?new s("\u0E41\u0E2D\u0E1B\u0E44\u0E2B\u0E19? `pple info` \u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 App Link \u0E2B\u0E23\u0E37\u0E2D slug","\u0E40\u0E02\u0E49\u0E32\u0E44\u0E1B\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B \u0E43\u0E2A\u0E48 slug (`pple info <slug>`) \u0E2B\u0E23\u0E37\u0E2D\u0E40\u0E1B\u0E34\u0E14 https://console.ppleth.ai"):r}}async function Ye(t,e){let[r,n,i]=await Promise.all([t.collaborators(e),t.requests(e),t.invites(e)]);return{collaborators:r,requests:n,invites:i}}function We(t,e){t.out.say(e.name),t.out.say(` ${l("slug",18)}${e.slug}`),t.out.say(` ${l("URL",18)}${e.url}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`),t.out.say(` ${l("\u0E2A\u0E16\u0E32\u0E19\u0E30",18)}${v(Ke,e.status)}`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48",18)}${v(Ot,e.tier??"draft")}`),e.role&&t.out.say(` ${l("\u0E1A\u0E17\u0E1A\u0E32\u0E17\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",18)}${v(He,e.role)}`),e.deploy&&t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E1B\u0E31\u0E08\u0E08\u0E38\u0E1A\u0E31\u0E19",18)}${v(jt,e.deploy.status)}`+(e.deploy.message?` \u2014 ${e.deploy.message}`:"")),e.error&&t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14",18)}${e.error}`)}function Xe(t,e){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E41\u0E25\u0E30\u0E42\u0E04\u0E27\u0E15\u0E32\u0E17\u0E23\u0E31\u0E1E\u0E22\u0E32\u0E01\u0E23"),t.out.say(` ${l("CPU \u0E15\u0E48\u0E2D request",20)}${e.limits.cpuMs} ms`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19",20)}${_(e.usage.notificationsSent)}`+(e.usage.notificationsSent===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":` \u0E08\u0E32\u0E01\u0E42\u0E04\u0E27\u0E15\u0E32 ${_(e.limits.notificationsPerDay)} \u0E04\u0E23\u0E31\u0E49\u0E07\u0E27\u0E31\u0E19\u0E19\u0E35\u0E49`)),t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14 (24 \u0E0A\u0E21.)",20)}${_(e.usage.errors)}`+(e.usage.errors===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":e.usage.errors===0?" (\u0E44\u0E21\u0E48\u0E21\u0E35\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":e.usage.requests===null?" (\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":` \u0E08\u0E32\u0E01\u0E17\u0E31\u0E49\u0E07\u0E2B\u0E21\u0E14 ${_(e.usage.requests)} \u0E04\u0E33\u0E02\u0E2D`)),t.out.say(` ${l("\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E2D\u0E1B",20)}${_(e.usage.appUsers)}`+(e.usage.appUsers===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":""))}function _(t){return t===null?"\u2014":String(t)}function ze(t,e){if(t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.commit.slice(0,7),i=r.message?` \u2014 ${r.message}`:"",o=v(jt,r.status);t.out.say(` ${l(o,13)} ${n}${i}`)}}function Ze(t,e){if(t.out.say(""),t.out.say("\u0E04\u0E48\u0E32 env"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46");return}for(let r of e)r.secret?t.out.say(` ${r.key}=(secret)`):t.out.say(` ${r.key}=${r.value??""}`)}function Qe(t,e,r,n){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48"),t.out.say(` ${l("\u0E23\u0E30\u0E14\u0E31\u0E1A",18)}${v(Ot,e.tier??"draft")}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`);let i=r.find(o=>o.type==="publish"&&o.status==="pending");if(i&&t.out.say(` ${l("\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E23\u0E2D\u0E2D\u0E22\u0E39\u0E48",18)}${Ut(i)}`),e.tier==="beta"){let o=n.filter(u=>u.status!=="DECLINED").length;t.out.say(` ${l("\u0E04\u0E33\u0E40\u0E0A\u0E34\u0E0D\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",18)}\u0E43\u0E0A\u0E49\u0E44\u0E1B ${o} \u0E08\u0E32\u0E01 ${Fe} \u0E17\u0E35\u0E48\u0E19\u0E31\u0E48\u0E07`)}}function tr(t,e){if(t.out.say(""),t.out.say("\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e){let n=r.maskedName??"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32",i=r.phoneNumber?` (${r.phoneNumber})`:"";t.out.say(` ${n}${i}`)}}function er(t,e){if(t.out.say(""),t.out.say("\u0E1B\u0E23\u0E30\u0E27\u0E31\u0E15\u0E34\u0E04\u0E33\u0E02\u0E2D"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.reason?` \u2014 ${r.reason}`:"",i=v(Ve,r.status);t.out.say(` ${l(i,13)} ${Ut(r)}${n}`)}}function Ut(t){if(t.type==="publish"){let r=Array.isArray(t.payload.roles)?t.payload.roles:[],n=typeof t.payload.customAddress=="string"?` \xB7 \u0E17\u0E35\u0E48 ${t.payload.customAddress}`:"";return`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \xB7 \u0E02\u0E36\u0E49\u0E19\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07 \xB7 ${r.length===0?"\u0E17\u0E38\u0E01\u0E04\u0E19":r.join(", ")}${n}`}if(t.type==="retire")return"\u0E04\u0E33\u0E02\u0E2D\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07 \xB7 \u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E2D\u0E1B";let e=[];return typeof t.payload.cpuMs=="number"&&e.push(`CPU ${t.payload.cpuMs} ms/request`),typeof t.payload.notificationsPerDay=="number"&&e.push(`\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19 ${t.payload.notificationsPerDay} \u0E04\u0E23\u0E31\u0E49\u0E07/\u0E27\u0E31\u0E19`),`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E42\u0E04\u0E27\u0E15\u0E32 \xB7 \u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21 ${e.length>0?e.join(" \u0E41\u0E25\u0E30 "):"\u0E42\u0E04\u0E27\u0E15\u0E32"}`}import Kt from"node:fs/promises";import b from"node:path";import E from"node:fs/promises";import Mt from"node:path";var rr=new Set([".git","node_modules","dist",".wrangler",".dev.vars",".env"]),nr=new Map([["gitignore",".gitignore"]]),ir="pple-app",or=new Set(["package.json","package-lock.json","wrangler.jsonc"]);async function qt(t,e,r){if(await E.readdir(t).catch(()=>null)===null)throw new s("\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E41\u0E2D\u0E1B PPLE \u0E43\u0E19\u0E01\u0E32\u0E23\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49",`\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E43\u0E2B\u0E21\u0E48 (\`${m}\`) \u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07`);let i=[];return await Ft({from:t,to:e,prefix:"",slug:r,written:i}),i.sort()}async function Ft(t){for(let e of await E.readdir(t.from,{withFileTypes:!0})){if(rr.has(e.name))continue;let r=nr.get(e.name)??e.name,n=Mt.join(t.from,e.name),i=Mt.join(t.to,r),o=t.prefix===""?r:`${t.prefix}/${r}`;if(e.isDirectory()){await E.mkdir(i,{recursive:!0}),await Ft({...t,from:n,to:i,prefix:o});continue}if(or.has(r)){let u=await E.readFile(n,"utf8");await E.writeFile(i,sr(u,ir,t.slug))}else await E.copyFile(n,i);t.written.push(o)}}function sr(t,e,r){return t.split(e).join(r)}var ar=3e3,ur=5*6e4;async function Ht(t,e){let r=await p(t),n=e.slug?await r.app(e.slug):await pr(t,r,await cr(t,e.name));if(!n.repo)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E44\u0E21\u0E48\u0E40\u0E2A\u0E23\u0E47\u0E08`,`\u0E23\u0E2D\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 \`pple init ${n.slug}\` \u0E2B\u0E32\u0E01\u0E22\u0E31\u0E07\u0E40\u0E1B\u0E47\u0E19\u0E41\u0E1A\u0E1A\u0E19\u0E35\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`);n.status==="provisioning"&&t.out.say("\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026");let i=b.resolve(t.cwd,e.dir??n.slug);await mr(i);let o=await r.repoToken(n.slug);if(t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E43\u0E19 ${b.basename(i)}/\u2026`),await ht(t,fr(t,n.repo),i,o),await gr(i))throw new s(`"${n.name}" \u0E21\u0E35\u0E42\u0E04\u0E49\u0E14\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27 \u0E08\u0E36\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E30\u0E44\u0E23\u0E15\u0E49\u0E2D\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32`,"\u0E17\u0E33\u0E07\u0E32\u0E19\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E31\u0E49\u0E19\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 `pple deploy` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E19\u0E33\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07");await qt(t.templateDir,i,n.slug),await Dt(i,{slug:n.slug,apiBase:t.config.apiBase,url:n.url}),await U(t,i),await I(t,i)&&await L(t,i,"\u0E40\u0E23\u0E34\u0E48\u0E21\u0E08\u0E32\u0E01\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E21\u0E32\u0E15\u0E23\u0E10\u0E32\u0E19\u0E02\u0E2D\u0E07 PPLE",o);let u=await lr(t,i);dr(t,n,i,u)}async function lr(t,e){t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E41\u0E1E\u0E47\u0E01\u0E40\u0E01\u0E08\u0E41\u0E25\u0E30\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21\u0E10\u0E32\u0E19\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u2026");let r=[["npm",["install"]],["npm",["run","db:migrate:local"]]];for(let[n,i]of r){let o=await t.run(n,i,{cwd:e}).catch(()=>null);if(!o||o.code!==0)return!1}return!0}async function cr(t,e){if(await V(t))throw new s("\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E01\u0E31\u0E1A\u0E41\u0E2D\u0E1B PPLE \u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27","\u0E23\u0E31\u0E19 `pple info` \u0E2B\u0E23\u0E37\u0E2D `pple deploy` \u0E17\u0E35\u0E48\u0E19\u0E35\u0E48 \u0E2B\u0E23\u0E37\u0E2D\u0E22\u0E49\u0E32\u0E22\u0E44\u0E1B\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B\u0E43\u0E2B\u0E21\u0E48");if(e!==void 0){let n=e.trim();if(!n)throw new s("`--name` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E15\u0E31\u0E27\u0E2D\u0E22\u0E48\u0E32\u0E07: `pple init --name "\u0E25\u0E07\u0E17\u0E30\u0E40\u0E1A\u0E35\u0E22\u0E19\u0E2D\u0E32\u0E2A\u0E32\u0E2A\u0E21\u0E31\u0E04\u0E23"`');return n}if(!t.interactive)throw new s("`pple init` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"` \u0E2B\u0E23\u0E37\u0E2D\u0E23\u0E31\u0E19\u0E43\u0E19\u0E40\u0E17\u0E2D\u0E23\u0E4C\u0E21\u0E34\u0E19\u0E31\u0E25\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E43\u0E2B\u0E49\u0E23\u0E30\u0E1A\u0E1A\u0E16\u0E32\u0E21');let r=(await t.ask("\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B:")).trim();if(!r)throw new s("\u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B",'\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"`');return r}async function pr(t,e,r){t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2A\u0E23\u0E49\u0E32\u0E07 "${r}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026`);let n=await e.createApp(r);if(Gt(n))return n;t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E23\u0E2D\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E2B\u0E49\u0E40\u0E2A\u0E23\u0E47\u0E08\u2026");let i=t.now();for(;;){if(n.status==="failed")throw new s(`\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08`,n.error?`${n.error} slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`:`slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`);if(t.now()-i>=ur)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`,`\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E14\u0E49\u0E27\u0E22 \`pple init ${n.slug}\` \u0E43\u0E19\u0E2D\u0E35\u0E01\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48`);if(await t.sleep(ar),n=await e.app(n.slug),Gt(n))return n}}function Gt(t){return t.status==="ready"&&t.repo!==null}function fr(t,e){return`${t.config.repoBase}/${e}.git`}async function mr(t){let e=await Kt.readdir(t).catch(()=>null);if(e!==null&&e.length>0)throw new s(`\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C ${b.basename(t)}/ \u0E21\u0E35\u0E44\u0E1F\u0E25\u0E4C\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27`,"\u0E23\u0E31\u0E19 `pple init` \u0E17\u0E35\u0E48\u0E2D\u0E37\u0E48\u0E19 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--dir <folder>` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19")}async function gr(t){return Kt.access(b.join(t,"package.json")).then(()=>!0).catch(()=>!1)}function dr(t,e,r,n){let i=b.basename(r);t.out.say(),t.out.say(`"${e.name}" \u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E2B\u0E49\u0E17\u0E33\u0E07\u0E32\u0E19\u0E41\u0E25\u0E49\u0E27\u0E43\u0E19 ${i}/`),t.out.say(`\u0E41\u0E2D\u0E1B\u0E08\u0E30\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 ${e.url}`),t.out.say(),t.out.say("\u0E02\u0E31\u0E49\u0E19\u0E15\u0E48\u0E2D\u0E44\u0E1B:"),t.out.say(` cd ${i}`),n||(t.out.say(" npm install"),t.out.say(" npm run db:migrate:local")),t.out.say(" npm run dev # \u0E14\u0E39\u0E1C\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u0E19\u0E35\u0E49"),t.out.say(" pple deploy # \u0E19\u0E33\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07")}import W from"node:fs/promises";import X from"node:path";var yr=1440*60*1e3,Vt="update-check.json",wr="config.json",hr=new Set(["upgrade","help","--help","-h"]);function Jt(t){return t.length>0&&!hr.has(t[0])}function z(t,e){t.out.say(`\u0E21\u0E35 pple \u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19\u0E43\u0E2B\u0E21\u0E48 (${e}) \u0E04\u0E38\u0E13\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 ${t.version}`)}function Z(t){t.out.say("pple \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E1C\u0E48\u0E32\u0E19 npm \u0E01\u0E32\u0E23\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E17\u0E35\u0E48\u0E19\u0E35\u0E48\u0E2D\u0E32\u0E08\u0E17\u0E33\u0E43\u0E2B\u0E49\u0E04\u0E38\u0E13"),t.out.say("\u0E21\u0E35 pple \u0E2A\u0E2D\u0E07\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E14\u0E49\u0E27\u0E22\u0E27\u0E34\u0E18\u0E35\u0E40\u0E14\u0E34\u0E21\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E44\u0E27\u0E49"),t.out.say(`\u0E2B\u0E23\u0E37\u0E2D\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E14\u0E49\u0E27\u0E22 npm:
15
+ `).trim()}import Be from"node:fs/promises";import Ue from"node:path";async function It(t){let e=await y(t),n=await(await p(t)).env(e.slug);if(n.length===0){t.out.say("\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46"),t.out.say("\u0E15\u0E31\u0E27\u0E41\u0E1B\u0E23\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E31\u0E49\u0E07\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B");return}for(let i of n)i.secret?t.out.say(`${i.key}=(secret)`):t.out.say(`${i.key}=${i.value??""}`);t.out.say(""),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Lt(t,e){let r=await y(t),n=await Me(t,e),o=await(await p(t)).setEnv(r.slug,e.key,n,e.secret);t.out.say(`\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01 ${o.key}${o.secret?" (secret)":""} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Nt(t,e){let r=await y(t);await(await p(t)).deleteEnv(r.slug,e),t.out.say(`\u0E25\u0E1A ${e} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Me(t,e){if(e.file!==void 0&&e.value!==void 0)throw new s("\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E2B\u0E23\u0E37\u0E2D\u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2D\u0E22\u0E48\u0E32\u0E07\u0E43\u0E14\u0E2D\u0E22\u0E48\u0E32\u0E07\u0E2B\u0E19\u0E36\u0E48\u0E07 \u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E17\u0E31\u0E49\u0E07\u0E2A\u0E2D\u0E07\u0E2D\u0E22\u0E48\u0E32\u0E07");if(e.file!==void 0){let r=Ue.resolve(t.cwd,e.file),n=await Be.readFile(r,"utf8").catch(()=>null);if(n===null)throw new s(`\u0E2D\u0E48\u0E32\u0E19\u0E44\u0E1F\u0E25\u0E4C ${e.file} \u0E44\u0E21\u0E48\u0E44\u0E14\u0E49`,"\u0E15\u0E23\u0E27\u0E08\u0E2A\u0E2D\u0E1A\u0E1E\u0E32\u0E18\u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07");return n}if(e.value!==void 0)return e.value;if(t.interactive)throw new s("`pple env set` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E04\u0E48\u0E32","\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2B\u0E23\u0E37\u0E2D pipe \u0E17\u0E32\u0E07 stdin");return t.readStdin()}var Fe=20,Ge=/[\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]/g;function l(t,e){let r=t.replace(Ge,"").length;return t+" ".repeat(Math.max(0,e-r))}var Ke={ready:"\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19",provisioning:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E08\u0E31\u0E14\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27",retired:"\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27"},Ot={draft:"\u0E09\u0E1A\u0E31\u0E1A\u0E23\u0E48\u0E32\u0E07",beta:"\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",live:"\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07"},He={owner:"\u0E40\u0E08\u0E49\u0E32\u0E02\u0E2D\u0E07",collaborator:"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"},jt={live:"\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48",building:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E34\u0E25\u0E14\u0E4C",unverified:"\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27"},Ve={pending:"\u0E23\u0E2D\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23",approved:"\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E41\u0E25\u0E49\u0E27",denied:"\u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18\u0E41\u0E25\u0E49\u0E27"};function v(t,e){return t[e]??e}async function Bt(t,e){let r=await Je(t,e),n=await p(t),i=await n.app(r),o=i.role!=="collaborator",[u,a,c,P]=await Promise.all([n.metrics(r),n.deploys(r),n.env(r),o?Ye(n,r):Promise.resolve(null)]);We(t,i),Xe(t,u),ze(t,a),Ze(t,c),P&&(Qe(t,i,P.requests,P.invites),tr(t,P.collaborators),er(t,P.requests)),t.out.say(""),t.out.say("\u0E2B\u0E32\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E42\u0E04\u0E27\u0E15\u0E32 \u0E04\u0E48\u0E32 env \u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32 \u0E2B\u0E23\u0E37\u0E2D\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \u0E43\u0E0A\u0E49 https://console.ppleth.ai")}async function Je(t,e){if(e)return e;try{return(await y(t)).slug}catch(r){throw r instanceof s&&r.message==="\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E41\u0E2D\u0E1B PPLE"?new s("\u0E41\u0E2D\u0E1B\u0E44\u0E2B\u0E19? `pple info` \u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 App Link \u0E2B\u0E23\u0E37\u0E2D slug","\u0E40\u0E02\u0E49\u0E32\u0E44\u0E1B\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B \u0E43\u0E2A\u0E48 slug (`pple info <slug>`) \u0E2B\u0E23\u0E37\u0E2D\u0E40\u0E1B\u0E34\u0E14 https://console.ppleth.ai"):r}}async function Ye(t,e){let[r,n,i]=await Promise.all([t.collaborators(e),t.requests(e),t.invites(e)]);return{collaborators:r,requests:n,invites:i}}function We(t,e){t.out.say(e.name),t.out.say(` ${l("slug",18)}${e.slug}`),t.out.say(` ${l("URL",18)}${e.url}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`),t.out.say(` ${l("\u0E2A\u0E16\u0E32\u0E19\u0E30",18)}${v(Ke,e.status)}`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48",18)}${v(Ot,e.tier??"draft")}`),e.role&&t.out.say(` ${l("\u0E1A\u0E17\u0E1A\u0E32\u0E17\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",18)}${v(He,e.role)}`),e.deploy&&t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E1B\u0E31\u0E08\u0E08\u0E38\u0E1A\u0E31\u0E19",18)}${v(jt,e.deploy.status)}`+(e.deploy.message?` \u2014 ${e.deploy.message}`:"")),e.error&&t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14",18)}${e.error}`)}function Xe(t,e){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E41\u0E25\u0E30\u0E42\u0E04\u0E27\u0E15\u0E32\u0E17\u0E23\u0E31\u0E1E\u0E22\u0E32\u0E01\u0E23"),t.out.say(` ${l("CPU \u0E15\u0E48\u0E2D request",20)}${e.limits.cpuMs} ms`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E16\u0E36\u0E07\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A",20)}${_(e.usage.notificationsSent)}`+(e.usage.notificationsSent===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":` \u0E23\u0E32\u0E22 \u0E08\u0E32\u0E01\u0E42\u0E04\u0E27\u0E15\u0E32 ${_(e.limits.notificationsPerDay)} \u0E23\u0E32\u0E22/\u0E27\u0E31\u0E19`)),t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14 (24 \u0E0A\u0E21.)",20)}${_(e.usage.errors)}`+(e.usage.errors===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":e.usage.errors===0?" (\u0E44\u0E21\u0E48\u0E21\u0E35\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":e.usage.requests===null?" (\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":` \u0E08\u0E32\u0E01\u0E17\u0E31\u0E49\u0E07\u0E2B\u0E21\u0E14 ${_(e.usage.requests)} \u0E04\u0E33\u0E02\u0E2D`)),t.out.say(` ${l("\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E2D\u0E1B",20)}${_(e.usage.appUsers)}`+(e.usage.appUsers===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":""))}function _(t){return t===null?"\u2014":String(t)}function ze(t,e){if(t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.commit.slice(0,7),i=r.message?` \u2014 ${r.message}`:"",o=v(jt,r.status);t.out.say(` ${l(o,13)} ${n}${i}`)}}function Ze(t,e){if(t.out.say(""),t.out.say("\u0E04\u0E48\u0E32 env"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46");return}for(let r of e)r.secret?t.out.say(` ${r.key}=(secret)`):t.out.say(` ${r.key}=${r.value??""}`)}function Qe(t,e,r,n){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48"),t.out.say(` ${l("\u0E23\u0E30\u0E14\u0E31\u0E1A",18)}${v(Ot,e.tier??"draft")}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`);let i=r.find(o=>o.type==="publish"&&o.status==="pending");if(i&&t.out.say(` ${l("\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E23\u0E2D\u0E2D\u0E22\u0E39\u0E48",18)}${Ut(i)}`),e.tier==="beta"){let o=n.filter(u=>u.status!=="DECLINED").length;t.out.say(` ${l("\u0E04\u0E33\u0E40\u0E0A\u0E34\u0E0D\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",18)}\u0E43\u0E0A\u0E49\u0E44\u0E1B ${o} \u0E08\u0E32\u0E01 ${Fe} \u0E17\u0E35\u0E48\u0E19\u0E31\u0E48\u0E07`)}}function tr(t,e){if(t.out.say(""),t.out.say("\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e){let n=r.maskedName??"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32",i=r.phoneNumber?` (${r.phoneNumber})`:"";t.out.say(` ${n}${i}`)}}function er(t,e){if(t.out.say(""),t.out.say("\u0E1B\u0E23\u0E30\u0E27\u0E31\u0E15\u0E34\u0E04\u0E33\u0E02\u0E2D"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.reason?` \u2014 ${r.reason}`:"",i=v(Ve,r.status);t.out.say(` ${l(i,13)} ${Ut(r)}${n}`)}}function Ut(t){if(t.type==="publish"){let r=Array.isArray(t.payload.roles)?t.payload.roles:[],n=typeof t.payload.customAddress=="string"?` \xB7 \u0E17\u0E35\u0E48 ${t.payload.customAddress}`:"";return`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \xB7 \u0E02\u0E36\u0E49\u0E19\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07 \xB7 ${r.length===0?"\u0E17\u0E38\u0E01\u0E04\u0E19":r.join(", ")}${n}`}if(t.type==="retire")return"\u0E04\u0E33\u0E02\u0E2D\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07 \xB7 \u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E2D\u0E1B";let e=[];return typeof t.payload.cpuMs=="number"&&e.push(`CPU ${t.payload.cpuMs} ms/request`),typeof t.payload.notificationsPerDay=="number"&&e.push(`\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E16\u0E36\u0E07\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A ${t.payload.notificationsPerDay} \u0E23\u0E32\u0E22/\u0E27\u0E31\u0E19`),`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E42\u0E04\u0E27\u0E15\u0E32 \xB7 \u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21 ${e.length>0?e.join(" \u0E41\u0E25\u0E30 "):"\u0E42\u0E04\u0E27\u0E15\u0E32"}`}import Kt from"node:fs/promises";import b from"node:path";import E from"node:fs/promises";import Mt from"node:path";var rr=new Set([".git","node_modules","dist",".wrangler",".dev.vars",".env"]),nr=new Map([["gitignore",".gitignore"]]),ir="pple-app",or=new Set(["package.json","package-lock.json","wrangler.jsonc"]);async function qt(t,e,r){if(await E.readdir(t).catch(()=>null)===null)throw new s("\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E41\u0E2D\u0E1B PPLE \u0E43\u0E19\u0E01\u0E32\u0E23\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49",`\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E43\u0E2B\u0E21\u0E48 (\`${m}\`) \u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07`);let i=[];return await Ft({from:t,to:e,prefix:"",slug:r,written:i}),i.sort()}async function Ft(t){for(let e of await E.readdir(t.from,{withFileTypes:!0})){if(rr.has(e.name))continue;let r=nr.get(e.name)??e.name,n=Mt.join(t.from,e.name),i=Mt.join(t.to,r),o=t.prefix===""?r:`${t.prefix}/${r}`;if(e.isDirectory()){await E.mkdir(i,{recursive:!0}),await Ft({...t,from:n,to:i,prefix:o});continue}if(or.has(r)){let u=await E.readFile(n,"utf8");await E.writeFile(i,sr(u,ir,t.slug))}else await E.copyFile(n,i);t.written.push(o)}}function sr(t,e,r){return t.split(e).join(r)}var ar=3e3,ur=5*6e4;async function Ht(t,e){let r=await p(t),n=e.slug?await r.app(e.slug):await pr(t,r,await cr(t,e.name));if(!n.repo)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E44\u0E21\u0E48\u0E40\u0E2A\u0E23\u0E47\u0E08`,`\u0E23\u0E2D\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 \`pple init ${n.slug}\` \u0E2B\u0E32\u0E01\u0E22\u0E31\u0E07\u0E40\u0E1B\u0E47\u0E19\u0E41\u0E1A\u0E1A\u0E19\u0E35\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`);n.status==="provisioning"&&t.out.say("\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026");let i=b.resolve(t.cwd,e.dir??n.slug);await mr(i);let o=await r.repoToken(n.slug);if(t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E43\u0E19 ${b.basename(i)}/\u2026`),await ht(t,fr(t,n.repo),i,o),await gr(i))throw new s(`"${n.name}" \u0E21\u0E35\u0E42\u0E04\u0E49\u0E14\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27 \u0E08\u0E36\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E30\u0E44\u0E23\u0E15\u0E49\u0E2D\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32`,"\u0E17\u0E33\u0E07\u0E32\u0E19\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E31\u0E49\u0E19\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 `pple deploy` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E19\u0E33\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07");await qt(t.templateDir,i,n.slug),await Dt(i,{slug:n.slug,apiBase:t.config.apiBase,url:n.url}),await U(t,i),await I(t,i)&&await L(t,i,"\u0E40\u0E23\u0E34\u0E48\u0E21\u0E08\u0E32\u0E01\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E21\u0E32\u0E15\u0E23\u0E10\u0E32\u0E19\u0E02\u0E2D\u0E07 PPLE",o);let u=await lr(t,i);dr(t,n,i,u)}async function lr(t,e){t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E41\u0E1E\u0E47\u0E01\u0E40\u0E01\u0E08\u0E41\u0E25\u0E30\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21\u0E10\u0E32\u0E19\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u2026");let r=[["npm",["install"]],["npm",["run","db:migrate:local"]]];for(let[n,i]of r){let o=await t.run(n,i,{cwd:e}).catch(()=>null);if(!o||o.code!==0)return!1}return!0}async function cr(t,e){if(await V(t))throw new s("\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E01\u0E31\u0E1A\u0E41\u0E2D\u0E1B PPLE \u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27","\u0E23\u0E31\u0E19 `pple info` \u0E2B\u0E23\u0E37\u0E2D `pple deploy` \u0E17\u0E35\u0E48\u0E19\u0E35\u0E48 \u0E2B\u0E23\u0E37\u0E2D\u0E22\u0E49\u0E32\u0E22\u0E44\u0E1B\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B\u0E43\u0E2B\u0E21\u0E48");if(e!==void 0){let n=e.trim();if(!n)throw new s("`--name` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E15\u0E31\u0E27\u0E2D\u0E22\u0E48\u0E32\u0E07: `pple init --name "\u0E25\u0E07\u0E17\u0E30\u0E40\u0E1A\u0E35\u0E22\u0E19\u0E2D\u0E32\u0E2A\u0E32\u0E2A\u0E21\u0E31\u0E04\u0E23"`');return n}if(!t.interactive)throw new s("`pple init` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"` \u0E2B\u0E23\u0E37\u0E2D\u0E23\u0E31\u0E19\u0E43\u0E19\u0E40\u0E17\u0E2D\u0E23\u0E4C\u0E21\u0E34\u0E19\u0E31\u0E25\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E43\u0E2B\u0E49\u0E23\u0E30\u0E1A\u0E1A\u0E16\u0E32\u0E21');let r=(await t.ask("\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B:")).trim();if(!r)throw new s("\u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B",'\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"`');return r}async function pr(t,e,r){t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2A\u0E23\u0E49\u0E32\u0E07 "${r}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026`);let n=await e.createApp(r);if(Gt(n))return n;t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E23\u0E2D\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E2B\u0E49\u0E40\u0E2A\u0E23\u0E47\u0E08\u2026");let i=t.now();for(;;){if(n.status==="failed")throw new s(`\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08`,n.error?`${n.error} slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`:`slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`);if(t.now()-i>=ur)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`,`\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E14\u0E49\u0E27\u0E22 \`pple init ${n.slug}\` \u0E43\u0E19\u0E2D\u0E35\u0E01\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48`);if(await t.sleep(ar),n=await e.app(n.slug),Gt(n))return n}}function Gt(t){return t.status==="ready"&&t.repo!==null}function fr(t,e){return`${t.config.repoBase}/${e}.git`}async function mr(t){let e=await Kt.readdir(t).catch(()=>null);if(e!==null&&e.length>0)throw new s(`\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C ${b.basename(t)}/ \u0E21\u0E35\u0E44\u0E1F\u0E25\u0E4C\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27`,"\u0E23\u0E31\u0E19 `pple init` \u0E17\u0E35\u0E48\u0E2D\u0E37\u0E48\u0E19 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--dir <folder>` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19")}async function gr(t){return Kt.access(b.join(t,"package.json")).then(()=>!0).catch(()=>!1)}function dr(t,e,r,n){let i=b.basename(r);t.out.say(),t.out.say(`"${e.name}" \u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E2B\u0E49\u0E17\u0E33\u0E07\u0E32\u0E19\u0E41\u0E25\u0E49\u0E27\u0E43\u0E19 ${i}/`),t.out.say(`\u0E41\u0E2D\u0E1B\u0E08\u0E30\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 ${e.url}`),t.out.say(),t.out.say("\u0E02\u0E31\u0E49\u0E19\u0E15\u0E48\u0E2D\u0E44\u0E1B:"),t.out.say(` cd ${i}`),n||(t.out.say(" npm install"),t.out.say(" npm run db:migrate:local")),t.out.say(" npm run dev # \u0E14\u0E39\u0E1C\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u0E19\u0E35\u0E49"),t.out.say(" pple deploy # \u0E19\u0E33\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07")}import W from"node:fs/promises";import X from"node:path";var yr=1440*60*1e3,Vt="update-check.json",wr="config.json",hr=new Set(["upgrade","help","--help","-h"]);function Jt(t){return t.length>0&&!hr.has(t[0])}function z(t,e){t.out.say(`\u0E21\u0E35 pple \u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19\u0E43\u0E2B\u0E21\u0E48 (${e}) \u0E04\u0E38\u0E13\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 ${t.version}`)}function Z(t){t.out.say("pple \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E1C\u0E48\u0E32\u0E19 npm \u0E01\u0E32\u0E23\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E17\u0E35\u0E48\u0E19\u0E35\u0E48\u0E2D\u0E32\u0E08\u0E17\u0E33\u0E43\u0E2B\u0E49\u0E04\u0E38\u0E13"),t.out.say("\u0E21\u0E35 pple \u0E2A\u0E2D\u0E07\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E14\u0E49\u0E27\u0E22\u0E27\u0E34\u0E18\u0E35\u0E40\u0E14\u0E34\u0E21\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E44\u0E27\u0E49"),t.out.say(`\u0E2B\u0E23\u0E37\u0E2D\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E14\u0E49\u0E27\u0E22 npm:
16
16
 
17
17
  ${m}`)}function Q(t){return`${_t(t)}
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pplethai/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,8 +25,9 @@ into three pillars on party-managed infrastructure:
25
25
  `pple auth login` (device flow).
26
26
  2. **Mini-app integration** — apps run inside PPLE Today's WebView using
27
27
  `@pplethai/pple-today-miniapp-sdk` (and the same SDK in a plain browser),
28
- and send audience-bound notifications (title/body, optional path-only
29
- `linkPath` into this app) through the platform-provisioned notify helper.
28
+ and send audience-bound notifications — a **Broadcast** to everyone this
29
+ app can reach, or a **Direct** send to people it names — through the
30
+ platform-provisioned notify helper.
30
31
  3. **PPLE Design System** — `@pplethai/components`, a shadcn/ui-based React
31
32
  library with party branding. Docs: https://components.pplethai.org/
32
33
 
@@ -44,7 +45,7 @@ central-team core service — uniformity is deliberate (ADR-0002):
44
45
  | Files | R2 |
45
46
  | Validation | Zod schemas in `shared/` |
46
47
  | Auth | PPLE ID (OIDC `userinfo`); local `DEV_AUTH` bypass only |
47
- | Notifications| Audience-bound via `src/notify.ts` (`title`/`body`, optional `linkPath`) |
48
+ | Notifications| `src/notify.ts` — every send states its audience (`all` or `direct`) |
48
49
  | Deploy | `pple deploy` (platform build → tenant Worker); never personal cloud |
49
50
 
50
51
  **Not the Builder App path** (do not resurrect these for new apps): Express +
@@ -99,10 +100,12 @@ Read only the file(s) relevant to the task at hand:
99
100
  - **Never mention git, branches, remotes, GitHub, Cloudflare accounts, or
100
101
  personal deploy credentials** in the happy path. Talk about the app's
101
102
  code and `pple init` / `pple deploy` only.
102
- - **Notifications are audience-bound** (`title` / `body`, optional
103
- `linkPath` into this app only). Never send phone numbers, user ids, or
104
- free-form / cross-app destinations; the platform resolves the audience and
105
- joins `linkPath` to this app's entry. Wire shape lives in `src/notify.ts`.
103
+ - **Notifications are audience-bound.** Every send states its audience:
104
+ Broadcast `{ kind: "all" }` or Direct `{ kind: "direct", recipients }`
105
+ (by `sub` or `phone`). Naming narrows the reachable set and never widens
106
+ it; `not_reachable` is one collapsed status — do not decompose it.
107
+ Self-links only (`linkPath` into this app). Shape in `shared/api.ts`;
108
+ details in [mini-app.md](references/mini-app.md).
106
109
  - **Stack is Workers + D1 + R2.** Bind D1 parameters; never concatenate SQL.
107
110
  One Worker, same origin — no CORS between SPA and API.
108
111
  - **`DEV_AUTH` must be unset in production.** Its absence is what forces real
@@ -9,8 +9,8 @@
9
9
  },
10
10
  {
11
11
  "id": 2,
12
- "prompt": "Add a feature that sends a push notification to our users when a note is saved. Use the PPLE notification service the platform wired up.",
13
- "expected_output": "Use src/notify.ts / NotificationSchema with title+body and optional path-only linkPath (self-link into this app); POST to NOTIFY_API_BASE/notifications with NOTIFY_API_KEY; no phone numbers, user ids, free-form cross-app links, or PHONE_NUMBER audience; no legacy /external/notifications/send with raw numbers",
12
+ "prompt": "When a reimbursement is approved, send a push notification to that one person. We already have their PPLE ID sub. Use the PPLE notification service the platform wired up.",
13
+ "expected_output": "Use src/notify.ts sendNotification with required audience { kind: \"direct\", recipients: [{ sub }] } (or { phone }); title+body; optional path-only linkPath self-link into this app; naming narrows the reachable set and never widens it; not_reachable is one collapsed status — do not decompose it; never omit audience; never reimplement the wire POST; never free-form / cross-app destinations; never legacy /external/notifications/send with PHONE_NUMBER; Broadcast to everyone this app can reach is { kind: \"all\" }",
14
14
  "files": []
15
15
  },
16
16
  {
@@ -1,4 +1,4 @@
1
- # Mini-app integration & audience-bound notifications
1
+ # Mini-app integration & notifications
2
2
 
3
3
  ## Running inside PPLE Today
4
4
 
@@ -14,54 +14,87 @@ Builder Apps ship as **mini-apps**: web apps opened in PPLE Today's WebView.
14
14
  the Console; Builders do not register them manually with the Today team.
15
15
  - Design mobile-first; test at ~375 px width.
16
16
 
17
- ## Push notifications (audience-bound)
17
+ ## Push notifications (Broadcast and Direct)
18
18
 
19
- Builder Apps send push notifications through the helper in `src/notify.ts`.
20
- The privacy model is fixed by the platform:
19
+ Builder Apps send push notifications through `sendNotification` in
20
+ `src/notify.ts`. Every send **states its audience** — the two kinds follow
21
+ from that:
21
22
 
22
- - Your code sends a `title` and `body`, plus an optional path-only self-link
23
- (`linkPath`).
24
- - **Never** send phone numbers, user ids, or an audience list.
25
- - The platform resolves recipients as this app's App Users ∩ its current
26
- publication-tier audience, and enforces the per-app rate limit.
27
- - `NOTIFY_API_BASE` and `NOTIFY_API_KEY` are provisioned bindings. The key
28
- *is* the address — no app id and no recipients on the wire.
23
+ - **Broadcast** (การแจ้งเตือนทุกคน) — `{ kind: "all" }`, to everyone this app
24
+ can reach: its App Users ∩ its current publication-tier audience.
25
+ - **Direct** (การแจ้งเตือนรายบุคคล) — `{ kind: "direct", recipients }`, to
26
+ the people this app names, by PPLE ID `sub` or mobile number.
27
+
28
+ The audience is a **required discriminated union**. Omitting it is a compile
29
+ error, not a silent broadcast — that is the dangerous bug this shape exists
30
+ to prevent. Do not default a missing audience to `all`.
31
+
32
+ Naming **narrows** the reachable set and never widens it. A named person who
33
+ has never opened this app receives nothing and comes back `not_reachable`.
34
+ That status is **one collapsed reason** covering "no PPLE ID at all", "never
35
+ opened this app", "outside the tier", and "opted out". Do not decompose it,
36
+ surface it as a guess, or infer *why*. Use it to prune stale names — a send
37
+ debits the reach it requested (every named recipient, or the Broadcast
38
+ audience size), so leftover entries cost quota until removed.
39
+
40
+ `NOTIFY_API_BASE` and `NOTIFY_API_KEY` are provisioned bindings. The key *is*
41
+ the address — no app id on the wire. Recipients belong only on a Direct send,
42
+ on this same route.
29
43
 
30
44
  ### App-facing shape (`shared/api.ts`)
31
45
 
46
+ Call `sendNotification` with a `Notification` from `shared/api.ts`. Do not
47
+ invent a parallel payload or POST the wire shape yourself.
48
+
32
49
  ```ts
33
- import { NotificationSchema } from "../shared/api";
34
- // { title: string, body: string, linkPath?: string }
35
- // linkPath must start with a single `/` (e.g. "/check-in")
50
+ import { sendNotification } from "./notify";
51
+
52
+ await sendNotification(env, {
53
+ audience: {
54
+ kind: "direct",
55
+ recipients: [{ sub: "…" }, { phone: "0812345678" }],
56
+ },
57
+ // or: { kind: "all" },
58
+ title: "Reimbursement approved",
59
+ body: "Your claim was approved",
60
+ linkPath: "/claims/123", // optional path-only self-link
61
+ });
36
62
  ```
37
63
 
64
+ A recipient entry names **exactly one** of `sub` or `phone`. Cap is 200 per
65
+ call — over it is a refusal, never a silent truncation, so chunk a long list
66
+ deliberately. Optional `idempotencyKey` makes a retry after a timeout safe.
67
+
38
68
  Optional `linkPath` is a deep link into **this** app only. The platform joins
39
69
  it to the app's known entry and rejects absolute URLs, schemes, `..`, or
40
70
  anything that would leave this app. Free-form destinations that could address
41
71
  another mini app (e.g. `content.link`) are not available on this route.
42
- Omit `linkPath` for content-only sends — tapping then opens the notification
43
- centre, as before.
72
+ Omit `linkPath` when the tap should open the notification centre.
73
+
74
+ A Direct send returns each named entry as `delivered` or `not_reachable`,
75
+ echoed as the caller named it. A Broadcast names no one, so there is nothing
76
+ to enumerate.
44
77
 
45
78
  ### Wire shape (`src/notify.ts`)
46
79
 
47
- Keep the platform vocabulary translation in `notify.ts` only:
80
+ Keep the platform vocabulary translation in `notify.ts` only. Your routes
81
+ call `sendNotification`; they do not POST this themselves:
48
82
 
49
83
  ```ts
50
- await fetch(`${env.NOTIFY_API_BASE}/notifications`, {
51
- method: "POST",
52
- headers: {
53
- authorization: `Bearer ${env.NOTIFY_API_KEY}`,
54
- "content-type": "application/json",
55
- },
56
- body: JSON.stringify({
57
- content: { header: input.title, message: input.body },
58
- ...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
59
- }),
60
- });
84
+ body: JSON.stringify({
85
+ audience: input.audience,
86
+ content: { header: input.title, message: input.body },
87
+ ...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
88
+ ...(input.idempotencyKey !== undefined
89
+ ? { idempotencyKey: input.idempotencyKey }
90
+ : {}),
91
+ })
61
92
  ```
62
93
 
63
94
  - Missing config → fail loud (`notifications_not_configured` / 503), not silent.
64
- - HTTP 429 → `rate_limited`.
95
+ - HTTP 429 → `quota_exceeded` (**nothing was delivered**). The helper also
96
+ maps the other refusals (`invalid_request`, `invalid_recipients`,
97
+ `idempotency_key_conflict`, `not_permitted`).
65
98
  - Prefer fire-and-forget from route handlers with error logging — do not fail
66
99
  the user's primary action solely because notify is slow.
67
100
 
@@ -69,7 +102,10 @@ await fetch(`${env.NOTIFY_API_BASE}/notifications`, {
69
102
 
70
103
  Do **not** call the legacy central-team endpoint that accepts a
71
104
  `PHONE_NUMBER` audience (`/external/notifications/send` with raw numbers).
72
- That path is not the Builder App contract. If you see older docs or
73
- `mini-app-example` code addressing phones, ignore them for new apps.
105
+ Naming a phone on `sendNotification` is the Direct contract; that unbound
106
+ path is not. If you see older docs or `mini-app-example` code addressing
107
+ phones on `/external/notifications/send`, ignore them for new apps.
74
108
  Do **not** invent free-form link / absolute URL fields — self-links use
75
109
  `linkPath` only.
110
+ Do **not** treat `not_reachable` as "not a party member" versus "not a user
111
+ of this app". That distinction is the one fact this status exists to hide.
@@ -18,7 +18,7 @@ my-app/ # folder name matches the App Slug
18
18
  │ ├── auth.ts # PPLE ID userinfo + DEV_AUTH bypass
19
19
  │ ├── db.ts # D1 helpers
20
20
  │ ├── files.ts # R2 helpers
21
- │ ├── notify.ts # audience-bound notifications
21
+ │ ├── notify.ts # Broadcast and Direct notifications
22
22
  │ └── env.ts # Env bindings type
23
23
  ├── web/ # React SPA (Vite)
24
24
  │ ├── main.tsx # bootstrap → App | error
@@ -57,9 +57,13 @@ Where the token comes from:
57
57
  - **Validate input with the Zod schemas** in `shared/api.ts`.
58
58
  - **Always bind D1 query parameters** (`?` + `.bind(...)`) — never build SQL
59
59
  with string concatenation.
60
- - **Notifications are content only** (title/body), plus an optional path-only
61
- `linkPath` into this app. Never send phone numbers or user ids; the platform
62
- resolves the audience. See `src/notify.ts`.
60
+ - **Every notification states its audience** — `{ kind: "all" }` (Broadcast,
61
+ การแจ้งเตือนทุกคน) or `{ kind: "direct", recipients }` (Direct,
62
+ การแจ้งเตือนรายบุคคล), naming people by PPLE ID `sub` or mobile number.
63
+ Required, so omitting it is a compile error — never work around that by
64
+ defaulting to `all`. Links stay path-only (`linkPath`) into this app. The
65
+ rules that come with naming are in `shared/api.ts`; read them before
66
+ changing anything here.
63
67
  - **Mobile-first.** The app runs in a phone-sized WebView. Use the design
64
68
  system's components and the layout classes in `web/app.css`.
65
69
  - **Add a DB table** by writing a new `migrations/000N_*.sql` (never edit an
@@ -17,7 +17,7 @@ what you want to your AI coding agent and edit the demo cards. See
17
17
  | **Identity** | `src/auth.ts` + `web/pple.ts` | validates PPLE ID tokens; browser PPLE ID login; local mock personas (`src/dev-personas.ts`) |
18
18
  | **Database** | D1, `src/db.ts` + `migrations/` | the Notes card |
19
19
  | **Files** | R2, `src/files.ts` | the Files card |
20
- | **Notifications** | `src/notify.ts` | the Notify card (audience-bound; optional `linkPath`) |
20
+ | **Notifications** | `src/notify.ts` | the Notify card (Broadcast or Direct; optional `linkPath`) |
21
21
  | **Design system** | `@pplethai/components` | every screen |
22
22
 
23
23
  One Worker serves the API under `/api/*` and the React app (`web/`) for
@@ -61,6 +61,9 @@ you, so a freshly created app is ready to `npm run dev` straight away.)
61
61
  - **Add an API route:** add it under `/api` in `src/index.ts`.
62
62
  - **Change the UI:** edit `web/App.tsx` using `@pplethai/components`.
63
63
  - **Send notifications:** the platform provisions `NOTIFY_API_KEY`; the helper
64
- in `src/notify.ts` already speaks the audience-bound contract.
64
+ in `src/notify.ts` speaks the shipped contract. Every send states its
65
+ audience — everyone this app can reach, or the people it names by PPLE ID
66
+ `sub` or mobile number. Naming only ever narrows that set: someone who has
67
+ never opened this app receives nothing.
65
68
 
66
69
  The demo cards (Notes / Files / Notify) are examples — replace them.
@@ -68,9 +68,62 @@ export const UploadedFileSchema = z.object({
68
68
  export type UploadedFile = z.infer<typeof UploadedFileSchema>;
69
69
 
70
70
  /**
71
- * A notification to send to this app's App Users. Content only — never phone
72
- * numbers or user ids. The platform resolves the audience server-side (see
73
- * src/notify.ts and docs/integration/today-sso-changes.md §6).
71
+ * One person a Direct Notification names: a PPLE ID `sub` **or** a mobile
72
+ * number, exactly one per entry. Both are accepted because a Builder's source
73
+ * of truth may be its own traffic (every request carries `actor.sub` and
74
+ * `actor.mobile`) or a list it holds by number.
75
+ *
76
+ * Strict objects, so an entry naming both is rejected here rather than at the
77
+ * platform. TypeScript alone cannot catch that one — `{ sub, phone }` is
78
+ * assignable to the union — so this runtime check is the one that matters.
79
+ */
80
+ export const NotificationRecipientSchema = z.union([
81
+ z.strictObject({ sub: z.string().trim().min(1) }),
82
+ z.strictObject({ phone: z.string().trim().min(1) }),
83
+ ]);
84
+ export type NotificationRecipient = z.infer<typeof NotificationRecipientSchema>;
85
+
86
+ /**
87
+ * The platform's per-call cap. Over it is a refusal, never a silent
88
+ * truncation, so chunk a long list deliberately rather than discovering half
89
+ * your people were dropped.
90
+ */
91
+ export const MAX_DIRECT_RECIPIENTS = 200;
92
+
93
+ /**
94
+ * Who a send is for — **required**, and a discriminated union on purpose.
95
+ *
96
+ * - `all` — a **Broadcast Notification** (การแจ้งเตือนทุกคน) to everyone this
97
+ * app can reach: its App Users ∩ its current publication-tier audience.
98
+ * - `direct` — a **Direct Notification** (การแจ้งเตือนรายบุคคล) to the people
99
+ * it names.
100
+ *
101
+ * Naming **narrows** that reachable set and never widens it: a named person
102
+ * who has not opened this app receives nothing and comes back
103
+ * `not_reachable`. The union is required because the dangerous bug here is
104
+ * omission — a dropped field silently turning a message meant for one person
105
+ * into a message to every App User. Leaving the audience out is a compile
106
+ * error here and a refusal at the platform; it is never a broadcast.
107
+ */
108
+ export const NotificationAudienceSchema = z.discriminatedUnion("kind", [
109
+ z.object({ kind: z.literal("all") }),
110
+ z.object({
111
+ kind: z.literal("direct"),
112
+ recipients: z
113
+ .array(NotificationRecipientSchema)
114
+ .min(1)
115
+ .max(MAX_DIRECT_RECIPIENTS),
116
+ }),
117
+ ]);
118
+ export type NotificationAudience = z.infer<typeof NotificationAudienceSchema>;
119
+
120
+ /**
121
+ * A notification to send. The privacy model in one line: this app can reach
122
+ * the people who already use it — and now say which of them it means — and no
123
+ * one else (see src/notify.ts and docs/integration/direct-notifications.md).
124
+ *
125
+ * Content is uniform across every recipient of a call; a personalized message
126
+ * is one call per person.
74
127
  *
75
128
  * Optional `linkPath` is a path-only deep link into *this* app (must start
76
129
  * with `/`). The platform joins it to this app's known entry and rejects
@@ -78,6 +131,7 @@ export type UploadedFile = z.infer<typeof UploadedFileSchema>;
78
131
  * address another mini app are not accepted.
79
132
  */
80
133
  export const NotificationSchema = z.object({
134
+ audience: NotificationAudienceSchema,
81
135
  title: z.string().trim().min(1).max(120),
82
136
  body: z.string().trim().min(1).max(500),
83
137
  linkPath: z
@@ -85,5 +139,32 @@ export const NotificationSchema = z.object({
85
139
  .trim()
86
140
  .regex(/^\/(?!\/).*/, "linkPath must be a path starting with a single /")
87
141
  .optional(),
142
+ /**
143
+ * Optional: makes retrying after a network timeout safe instead of
144
+ * double-notifying everyone who already received it. Reusing a key with a
145
+ * recipient list of a different length is a conflict, not a replay.
146
+ */
147
+ idempotencyKey: z.string().trim().min(1).optional(),
88
148
  });
89
149
  export type Notification = z.infer<typeof NotificationSchema>;
150
+
151
+ /**
152
+ * What happened to one named recipient of a Direct Notification.
153
+ *
154
+ * `not_reachable` is **one collapsed reason**, deliberately: no PPLE ID
155
+ * account at all, an account that has never opened this app, someone outside
156
+ * this app's current tier audience, or someone opted out. Do not decompose it,
157
+ * and do not show a Builder a guess about which applies — separating "not a
158
+ * user of this app" from "no such person" would turn every Builder App into a
159
+ * lookup service answering *"is this number a party workforce member?"* about
160
+ * people who never consented to it. See docs/adr/0017.
161
+ *
162
+ * What it *is* good for: pruning. A stale entry costs quota on every send
163
+ * until it is removed, because a send debits the reach it requested, not the
164
+ * reach it achieved.
165
+ */
166
+ export const NotificationDeliverySchema = z.object({
167
+ recipient: NotificationRecipientSchema,
168
+ status: z.enum(["delivered", "not_reachable"]),
169
+ });
170
+ export type NotificationDelivery = z.infer<typeof NotificationDeliverySchema>;
@@ -88,15 +88,26 @@ export function createApp(overrides: AppOverrides = {}) {
88
88
  return new Response(obj.body, { headers });
89
89
  });
90
90
 
91
- // --- Notification demo: audience-bound send (content only) ---
91
+ // --- Notification demo: Broadcast and Direct sends ---
92
+ // The audience is required by the schema, so a send that forgets to state it
93
+ // is refused here and never reaches the platform as a broadcast.
92
94
  app.post("/notify", async (c) => {
93
95
  const parsed = NotificationSchema.safeParse(
94
96
  await c.req.json().catch(() => null),
95
97
  );
96
98
  if (!parsed.success) return c.json({ error: "invalid_body" }, 400);
97
99
  const result = await sendNotification(c.env, parsed.data, fetchImpl);
98
- if (result.ok) return c.json({ ok: true });
99
- return c.json({ error: result.error }, result.status as ContentfulStatusCode);
100
+ if (!result.ok) {
101
+ return c.json(
102
+ { error: result.error, ...(result.quota ? { quota: result.quota } : {}) },
103
+ result.status as ContentfulStatusCode,
104
+ );
105
+ }
106
+ // A Direct send answers for each recipient it named; a Broadcast names no
107
+ // one, so there is nothing to enumerate.
108
+ return c.json(
109
+ result.audience === "direct" ? { ok: true, results: result.results } : { ok: true },
110
+ );
100
111
  });
101
112
 
102
113
  return app;
@@ -1,36 +1,82 @@
1
- // Audience-bound push notifications.
1
+ // Push notifications: Broadcast and Direct.
2
2
  //
3
- // Your app sends *content* — a title and body, never phone numbers or user
4
- // ids — plus an optional path-only self-link. The platform resolves the
5
- // recipients server-side (this app's App Users ∩ its current publication-tier
6
- // audience), joins `linkPath` to this app's known entry, and enforces the
7
- // per-app rate limit. This is the whole privacy model: an app can reach the
8
- // people who use it, and no one else; and it can deep-link into itself, not
9
- // into another team's mini app.
3
+ // Every send states its audience, and the two kinds follow from that:
10
4
  //
11
- // See docs/integration/today-sso-changes.md §6 for the contract.
5
+ // • Broadcast (การแจ้งเตือนทุกคน) — `{ kind: "all" }`, to everyone this app
6
+ // can reach: its App Users ∩ its current publication-tier audience.
7
+ // • Direct (การแจ้งเตือนรายบุคคล) — `{ kind: "direct", recipients }`, to the
8
+ // people this app names, by PPLE ID `sub` or mobile number.
9
+ //
10
+ // Naming narrows the audience and never widens it — the rule, and what
11
+ // `not_reachable` may be read as, are in shared/api.ts. The privacy model in
12
+ // one line: an app can reach the people who use it, say which of them it
13
+ // means, and reach no one else; and it can deep-link into itself, not into
14
+ // another team's mini app.
15
+ //
16
+ // See docs/integration/direct-notifications.md for the contract and
17
+ // docs/adr/0017 for why naming recipients is safe but per-recipient *reasons*
18
+ // are not.
12
19
  //
13
20
  // Wiring: the platform provisions NOTIFY_API_BASE and this app's own
14
21
  // NotificationApiKey as NOTIFY_API_KEY, so `POST {NOTIFY_API_BASE}/notifications`
15
- // lands on PPLE Today's audience-bound send route. The key is the whole
16
- // address — PPLE Today resolves it to this app and derives the audience from
17
- // that, so the request carries neither an app id nor any recipient.
22
+ // lands on PPLE Today's send route. The key is the whole address — PPLE Today
23
+ // resolves it to this app and derives the reachable set from that, so the
24
+ // request carries no app id.
18
25
  //
19
26
  // The wire shape below is PPLE Today's, not this app's: your code passes the
20
- // friendlier `{ title, body, linkPath? }` and this is where it becomes
21
- // `{ content: { header, message }, linkPath? }`. Keeping that translation in
22
- // one place means the rest of the app never has to know the platform's
23
- // vocabulary.
24
- import type { Notification } from "../shared/api";
27
+ // friendlier `{ audience, title, body, linkPath? }` and this is where it
28
+ // becomes `{ audience, content: { header, message }, linkPath? }`. Keeping that
29
+ // translation in one place means the rest of the app never has to know the
30
+ // platform's vocabulary.
31
+ import type { Notification, NotificationDelivery, NotificationRecipient } from "../shared/api";
25
32
 
26
33
  interface NotifyEnv {
27
34
  NOTIFY_API_BASE: string;
28
35
  NOTIFY_API_KEY?: string;
29
36
  }
30
37
 
38
+ /**
39
+ * Every refusal this app can get back, named in terms a Builder can act on.
40
+ *
41
+ * - `notifications_not_configured` — no key bound (e.g. local dev).
42
+ * - `invalid_request` — the send itself was malformed: no audience, an
43
+ * unknown `kind`, a `direct` send with no `recipients` array.
44
+ * - `invalid_recipients` — the list was: empty, over the 200 cap, or an entry
45
+ * naming neither or both of `sub`/`phone`.
46
+ * - `idempotency_key_conflict` — that key was already used with a list of a
47
+ * different length, so its stored outcome cannot be replayed onto this one.
48
+ * - `quota_exceeded` — **nothing was delivered.** Quota is checked up front
49
+ * against the named count, so the call is rejected whole rather than
50
+ * half-sent.
51
+ * - `not_permitted` — this key may not use this route.
52
+ * - `send_failed` — anything else.
53
+ */
54
+ export type NotifyError =
55
+ | "notifications_not_configured"
56
+ | "invalid_request"
57
+ | "invalid_recipients"
58
+ | "idempotency_key_conflict"
59
+ | "quota_exceeded"
60
+ | "not_permitted"
61
+ | "send_failed";
62
+
63
+ /**
64
+ * What the platform reports alongside a `quota_exceeded` refusal, so an app
65
+ * can back off rather than guess. Denominated in **deliveries**, not calls: a
66
+ * Direct send debits one unit per name it addressed (delivered or not), and a
67
+ * Broadcast debits its audience size.
68
+ */
69
+ export interface NotifyQuota {
70
+ dailyQuota?: number;
71
+ remaining?: number;
72
+ /** When the budget resets (Asia/Bangkok midnight). */
73
+ resetAt?: string;
74
+ }
75
+
31
76
  export type NotifyResult =
32
- | { ok: true }
33
- | { ok: false; status: number; error: string };
77
+ | { ok: true; audience: "all" }
78
+ | { ok: true; audience: "direct"; results: NotificationDelivery[] }
79
+ | { ok: false; status: number; error: NotifyError; quota?: NotifyQuota };
34
80
 
35
81
  export async function sendNotification(
36
82
  env: NotifyEnv,
@@ -48,17 +94,99 @@ export async function sendNotification(
48
94
  authorization: `Bearer ${env.NOTIFY_API_KEY}`,
49
95
  "content-type": "application/json",
50
96
  },
51
- // Content (+ optional self-link). The audience is resolved by the platform.
52
97
  body: JSON.stringify({
98
+ // Stated on every send, including a broadcast. There is no shape of this
99
+ // request that leaves the audience to be guessed.
100
+ audience: input.audience,
53
101
  content: { header: input.title, message: input.body },
54
102
  ...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
103
+ ...(input.idempotencyKey !== undefined
104
+ ? { idempotencyKey: input.idempotencyKey }
105
+ : {}),
55
106
  }),
56
107
  });
57
108
 
58
- if (res.ok) return { ok: true };
109
+ if (!res.ok) {
110
+ const error = errorFor(res.status);
111
+ const quota = error === "quota_exceeded" ? quotaOf(await readJson(res)) : undefined;
112
+ return { ok: false, status: res.status, error, ...(quota ? { quota } : {}) };
113
+ }
114
+
115
+ if (input.audience.kind === "all") return { ok: true, audience: "all" };
59
116
  return {
60
- ok: false,
61
- status: res.status,
62
- error: res.status === 429 ? "rate_limited" : "send_failed",
117
+ ok: true,
118
+ audience: "direct",
119
+ results: zipResults(input.audience.recipients, await readJson(res)),
63
120
  };
64
121
  }
122
+
123
+ /**
124
+ * The platform splits its refusals: schema-level ones answer 422 (its whole
125
+ * API validates uniformly, and this route does not get to be the exception),
126
+ * recipient-level ones answer 400. Both are refusals — none is a broadcast,
127
+ * none is a silent no-op — but a client that branches on 400 alone is wrong.
128
+ * The status table lives in docs/integration/direct-notifications.md.
129
+ */
130
+ function errorFor(status: number): NotifyError {
131
+ switch (status) {
132
+ case 422:
133
+ return "invalid_request";
134
+ case 400:
135
+ return "invalid_recipients";
136
+ case 403:
137
+ return "not_permitted";
138
+ case 409:
139
+ return "idempotency_key_conflict";
140
+ case 429:
141
+ return "quota_exceeded";
142
+ default:
143
+ return "send_failed";
144
+ }
145
+ }
146
+
147
+ async function readJson(res: Response): Promise<unknown> {
148
+ return res.json().catch(() => null);
149
+ }
150
+
151
+ /**
152
+ * The three figures the contract says a 429 carries, read from the top level
153
+ * of the refusal body. If the platform ever nests them under an envelope, this
154
+ * is the one place to adjust — the refusal itself is reported either way.
155
+ */
156
+ function quotaOf(body: unknown): NotifyQuota | undefined {
157
+ if (typeof body !== "object" || body === null) return undefined;
158
+ const { dailyQuota, remaining, resetAt } = body as Record<string, unknown>;
159
+ const quota: NotifyQuota = {
160
+ ...(typeof dailyQuota === "number" ? { dailyQuota } : {}),
161
+ ...(typeof remaining === "number" ? { remaining } : {}),
162
+ ...(typeof resetAt === "string" ? { resetAt } : {}),
163
+ };
164
+ return Object.keys(quota).length > 0 ? quota : undefined;
165
+ }
166
+
167
+ /**
168
+ * Pair each recipient this app named with what the platform said about it.
169
+ * The results come back in the order they were named, one per entry, so this
170
+ * pairs them by position and hands back the caller's *own* entry — ready to
171
+ * match onto the list the app holds.
172
+ *
173
+ * Two rules, both in the direction of not lying: a list of a different length
174
+ * is not the answer to this send, so none of it is trusted; and anything not
175
+ * affirmatively reported `delivered` is reported `not_reachable`. See
176
+ * shared/api.ts for what that status may and may not be read as.
177
+ */
178
+ function zipResults(
179
+ recipients: readonly NotificationRecipient[],
180
+ body: unknown,
181
+ ): NotificationDelivery[] {
182
+ const reported = (body as { results?: unknown } | null)?.results;
183
+ const results =
184
+ Array.isArray(reported) && reported.length === recipients.length ? reported : [];
185
+ return recipients.map((recipient, i) => ({
186
+ recipient,
187
+ status:
188
+ (results[i] as { status?: unknown } | undefined)?.status === "delivered"
189
+ ? "delivered"
190
+ : "not_reachable",
191
+ }));
192
+ }
@@ -17,8 +17,8 @@ export interface Fakes {
17
17
  /**
18
18
  * A copy of the Worker wired to fake PPLE ID / notification services (via an
19
19
  * injected fetch) and the real local D1 + R2 from the test runtime. Records
20
- * the requests sent to the notification endpoint so tests can assert the
21
- * content-only contract.
20
+ * the requests sent to the notification endpoint so tests can assert the wire
21
+ * shape — above all that every send states its audience.
22
22
  */
23
23
  export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {}) {
24
24
  const calls = { notify: [] as Request[] };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { Note, UploadedFile } from "../shared/api";
2
+ import type { Note, Notification, UploadedFile } from "../shared/api";
3
3
  import { makeHarness } from "./harness";
4
4
 
5
5
  describe("health", () => {
@@ -174,32 +174,123 @@ describe("R2 upload (files)", () => {
174
174
  });
175
175
  });
176
176
 
177
- describe("audience-bound notifications", () => {
177
+ // Shaped like what the platform actually binds, so these tests exercise the
178
+ // real `${base}/notifications` the Worker will call in production.
179
+ const NOTIFY_ENV = {
180
+ NOTIFY_API_BASE: "https://today.test/external",
181
+ NOTIFY_API_KEY: "app-key",
182
+ };
183
+
184
+ /** A platform that accepts everything and reports every named entry delivered. */
185
+ const accepts = (body: unknown = null) => ({
186
+ notify: () => (body === null ? new Response(null, { status: 202 }) : Response.json(body)),
187
+ });
188
+
189
+ describe("notifications: the audience is required", () => {
190
+ it("is a compile error to omit it, not a runtime one", () => {
191
+ // @ts-expect-error — `audience` is required. The dangerous bug in this
192
+ // feature is omission: a dropped field turning a message meant for one
193
+ // person into a message to every App User. The discriminated union kills
194
+ // it at the compiler, which is where it is cheapest.
195
+ const missingAudience: Notification = { title: "Hi", body: "there" };
196
+ expect(missingAudience).toBeTruthy();
197
+ });
198
+
199
+ it("refuses a send with no audience before it reaches the platform", async () => {
200
+ const h = makeHarness(accepts(), NOTIFY_ENV);
201
+ const res = await h.request("/api/notify", {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({ title: "Hi", body: "there" }),
205
+ });
206
+ expect(res.status).toBe(400);
207
+ expect(await res.json()).toMatchObject({ error: "invalid_body" });
208
+ // Never a broadcast, never a silent no-op.
209
+ expect(h.calls.notify).toHaveLength(0);
210
+ });
211
+
212
+ it("refuses an empty recipient list before it reaches the platform", async () => {
213
+ const h = makeHarness(accepts(), NOTIFY_ENV);
214
+ const res = await h.request("/api/notify", {
215
+ method: "POST",
216
+ headers: { "content-type": "application/json" },
217
+ body: JSON.stringify({
218
+ audience: { kind: "direct", recipients: [] },
219
+ title: "Hi",
220
+ body: "there",
221
+ }),
222
+ });
223
+ expect(res.status).toBe(400);
224
+ expect(h.calls.notify).toHaveLength(0);
225
+ });
226
+
227
+ it("refuses more than 200 recipients rather than truncating them", async () => {
228
+ const h = makeHarness(accepts(), NOTIFY_ENV);
229
+ const res = await h.request("/api/notify", {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json" },
232
+ body: JSON.stringify({
233
+ audience: {
234
+ kind: "direct",
235
+ recipients: Array.from({ length: 201 }, (_, i) => ({ sub: `u-${i}` })),
236
+ },
237
+ title: "Hi",
238
+ body: "there",
239
+ }),
240
+ });
241
+ expect(res.status).toBe(400);
242
+ expect(h.calls.notify).toHaveLength(0);
243
+ });
244
+
245
+ it("refuses an entry naming both a sub and a phone", async () => {
246
+ const h = makeHarness(accepts(), NOTIFY_ENV);
247
+ const res = await h.request("/api/notify", {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({
251
+ audience: {
252
+ kind: "direct",
253
+ recipients: [{ sub: "u-1", phone: "0812345678" }],
254
+ },
255
+ title: "Hi",
256
+ body: "there",
257
+ }),
258
+ });
259
+ expect(res.status).toBe(400);
260
+ expect(h.calls.notify).toHaveLength(0);
261
+ });
262
+ });
263
+
264
+ describe("Broadcast Notification (การแจ้งเตือนทุกคน)", () => {
178
265
  it("reports not-configured when no key is set", async () => {
179
266
  const h = makeHarness();
180
267
  const res = await h.request("/api/notify", {
181
268
  method: "POST",
182
269
  headers: { "content-type": "application/json" },
183
- body: JSON.stringify({ title: "Hi", body: "there" }),
270
+ body: JSON.stringify({
271
+ audience: { kind: "all" },
272
+ title: "Hi",
273
+ body: "there",
274
+ }),
184
275
  });
185
276
  expect(res.status).toBe(503);
186
277
  expect(await res.json()).toMatchObject({ error: "notifications_not_configured" });
187
278
  });
188
279
 
189
- it("sends content only (never phone numbers) to the platform endpoint", async () => {
190
- const h = makeHarness(
191
- { notify: () => new Response(null, { status: 202 }) },
192
- // Shaped like what the platform actually binds, so the test exercises
193
- // the real `${base}/notifications` the Worker will call in production.
194
- { NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
195
- );
280
+ it("states its audience explicitly and names no one", async () => {
281
+ const h = makeHarness(accepts(), NOTIFY_ENV);
196
282
 
197
283
  const res = await h.request("/api/notify", {
198
284
  method: "POST",
199
285
  headers: { "content-type": "application/json" },
200
- body: JSON.stringify({ title: "Event tonight", body: "Doors at 7" }),
286
+ body: JSON.stringify({
287
+ audience: { kind: "all" },
288
+ title: "Event tonight",
289
+ body: "Doors at 7",
290
+ }),
201
291
  });
202
292
  expect(res.status).toBe(200);
293
+ expect(await res.json()).toEqual({ ok: true });
203
294
 
204
295
  expect(h.calls.notify).toHaveLength(1);
205
296
  const sent = h.calls.notify[0]!;
@@ -207,21 +298,20 @@ describe("audience-bound notifications", () => {
207
298
  // PPLE Today's wire shape, which src/notify.ts translates into.
208
299
  const payload = await sent.json<Record<string, unknown>>();
209
300
  expect(payload).toEqual({
301
+ audience: { kind: "all" },
210
302
  content: { header: "Event tonight", message: "Doors at 7" },
211
303
  });
212
- expect(JSON.stringify(payload)).not.toMatch(/phone|recipient|number/i);
304
+ expect(JSON.stringify(payload)).not.toMatch(/phone|recipient/i);
213
305
  });
214
306
 
215
307
  it("forwards optional linkPath on the platform wire shape", async () => {
216
- const h = makeHarness(
217
- { notify: () => new Response(null, { status: 202 }) },
218
- { NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
219
- );
308
+ const h = makeHarness(accepts(), NOTIFY_ENV);
220
309
 
221
310
  const res = await h.request("/api/notify", {
222
311
  method: "POST",
223
312
  headers: { "content-type": "application/json" },
224
313
  body: JSON.stringify({
314
+ audience: { kind: "all" },
225
315
  title: "Check-in open",
226
316
  body: "Tap to open the form",
227
317
  linkPath: "/check-in",
@@ -231,8 +321,144 @@ describe("audience-bound notifications", () => {
231
321
 
232
322
  const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
233
323
  expect(payload).toEqual({
324
+ audience: { kind: "all" },
234
325
  content: { header: "Check-in open", message: "Tap to open the form" },
235
326
  linkPath: "/check-in",
236
327
  });
237
328
  });
238
329
  });
330
+
331
+ describe("Direct Notification (การแจ้งเตือนรายบุคคล)", () => {
332
+ const recipients = [{ sub: "u-42" }, { phone: "+66812345678" }];
333
+
334
+ const sendDirect = (h: ReturnType<typeof makeHarness>, extra: object = {}) =>
335
+ h.request("/api/notify", {
336
+ method: "POST",
337
+ headers: { "content-type": "application/json" },
338
+ body: JSON.stringify({
339
+ audience: { kind: "direct", recipients },
340
+ title: "Reimbursement approved",
341
+ body: "Your claim was approved",
342
+ ...extra,
343
+ }),
344
+ });
345
+
346
+ it("names recipients by sub or phone and reports each one's outcome", async () => {
347
+ const h = makeHarness(
348
+ accepts({
349
+ results: [
350
+ { recipient: { sub: "u-42" }, status: "delivered" },
351
+ { recipient: { phone: "+66812345678" }, status: "not_reachable" },
352
+ ],
353
+ }),
354
+ NOTIFY_ENV,
355
+ );
356
+
357
+ const res = await sendDirect(h);
358
+ expect(res.status).toBe(200);
359
+ // Every named entry comes back, echoed as the caller named it, so a
360
+ // Builder can match a result straight onto the list it holds.
361
+ expect(await res.json()).toEqual({
362
+ ok: true,
363
+ results: [
364
+ { recipient: { sub: "u-42" }, status: "delivered" },
365
+ { recipient: { phone: "+66812345678" }, status: "not_reachable" },
366
+ ],
367
+ });
368
+
369
+ const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
370
+ expect(payload).toEqual({
371
+ audience: { kind: "direct", recipients },
372
+ content: {
373
+ header: "Reimbursement approved",
374
+ message: "Your claim was approved",
375
+ },
376
+ });
377
+ });
378
+
379
+ it("forwards an idempotencyKey so a retry after a timeout is safe", async () => {
380
+ const h = makeHarness(accepts({ results: [] }), NOTIFY_ENV);
381
+ await sendDirect(h, { idempotencyKey: "claim-123" });
382
+ const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
383
+ expect(payload).toMatchObject({ idempotencyKey: "claim-123" });
384
+ });
385
+
386
+ const unconfirmed = {
387
+ ok: true,
388
+ results: [
389
+ { recipient: { sub: "u-42" }, status: "not_reachable" },
390
+ { recipient: { phone: "+66812345678" }, status: "not_reachable" },
391
+ ],
392
+ };
393
+
394
+ it("never claims a delivery the platform did not confirm", async () => {
395
+ // A body this helper cannot read is not evidence of delivery.
396
+ const h = makeHarness(accepts({ unexpected: true }), NOTIFY_ENV);
397
+ expect(await (await sendDirect(h)).json()).toEqual(unconfirmed);
398
+ });
399
+
400
+ it("trusts none of a result list that is not one-per-named-entry", async () => {
401
+ // Results are paired by position, so a list of a different length is not
402
+ // the answer to this send — reading it would attribute someone's delivery
403
+ // to the wrong person.
404
+ const h = makeHarness(
405
+ accepts({ results: [{ recipient: { sub: "u-42" }, status: "delivered" }] }),
406
+ NOTIFY_ENV,
407
+ );
408
+ expect(await (await sendDirect(h)).json()).toEqual(unconfirmed);
409
+ });
410
+ });
411
+
412
+ describe("notification refusals map to legible errors", () => {
413
+ const refuses = (status: number, body: unknown = null) => ({
414
+ notify: () =>
415
+ body === null
416
+ ? new Response(null, { status })
417
+ : Response.json(body, { status }),
418
+ });
419
+
420
+ const send = (h: ReturnType<typeof makeHarness>) =>
421
+ h.request("/api/notify", {
422
+ method: "POST",
423
+ headers: { "content-type": "application/json" },
424
+ body: JSON.stringify({
425
+ audience: { kind: "direct", recipients: [{ sub: "u-42" }] },
426
+ title: "Hi",
427
+ body: "there",
428
+ }),
429
+ });
430
+
431
+ // The platform splits schema-level refusals (422) from recipient-level ones
432
+ // (400); a client that branches on 400 alone is wrong. See the status table
433
+ // in docs/integration/direct-notifications.md.
434
+ it.each([
435
+ [422, "invalid_request"],
436
+ [400, "invalid_recipients"],
437
+ [403, "not_permitted"],
438
+ [409, "idempotency_key_conflict"],
439
+ [429, "quota_exceeded"],
440
+ [500, "send_failed"],
441
+ ])("maps %i to %s", async (status, error) => {
442
+ const h = makeHarness(refuses(status), NOTIFY_ENV);
443
+ const res = await send(h);
444
+ expect(res.status).toBe(status);
445
+ expect(await res.json()).toMatchObject({ error });
446
+ });
447
+
448
+ it("passes the quota figures through on a 429 so an app can back off", async () => {
449
+ const h = makeHarness(
450
+ refuses(429, {
451
+ // Deliveries, not calls — the budget's denomination changed with
452
+ // Direct Notifications, and nothing was delivered by this call.
453
+ dailyQuota: 1000,
454
+ remaining: 3,
455
+ resetAt: "2026-08-15T00:00:00+07:00",
456
+ }),
457
+ NOTIFY_ENV,
458
+ );
459
+ expect(await (await send(h)).json()).toMatchObject({
460
+ error: "quota_exceeded",
461
+ quota: { dailyQuota: 1000, remaining: 3, resetAt: "2026-08-15T00:00:00+07:00" },
462
+ });
463
+ });
464
+ });
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Badge,
2
3
  Button,
3
4
  Card,
4
5
  CardContent,
@@ -15,12 +16,24 @@ import {
15
16
  Input,
16
17
  Navbar,
17
18
  Spinner,
19
+ Tabs,
20
+ TabsContent,
21
+ TabsList,
22
+ TabsTrigger,
18
23
  Textarea,
19
24
  showToast,
20
25
  } from "@pplethai/components";
21
26
  import { ChevronDown, LogOut, Plus, Send, Upload, User } from "lucide-react";
22
27
  import { useEffect, useRef, useState } from "react";
23
- import type { Me, Note, UploadedFile } from "../shared/api";
28
+ import type {
29
+ Me,
30
+ Note,
31
+ NotificationAudience,
32
+ NotificationDelivery,
33
+ NotificationRecipient,
34
+ UploadedFile,
35
+ } from "../shared/api";
36
+ import { MAX_DIRECT_RECIPIENTS } from "../shared/api";
24
37
  import { api, apiJson, logout } from "./pple";
25
38
 
26
39
  function bytes(n: number): string {
@@ -280,24 +293,84 @@ function FilesCard() {
280
293
  );
281
294
  }
282
295
 
283
- // --- Audience-bound notifications ----------------------------------------
296
+ // --- Notifications: Broadcast and Direct ----------------------------------
297
+ //
298
+ // Two modes, one card. The audience is always stated — there is no way to
299
+ // start a send from this UI without choosing one, which mirrors the schema in
300
+ // shared/api.ts, where leaving it out is a compile error.
301
+
302
+ /** Builder-facing Thai for every refusal this card can receive. */
303
+ const NOTIFY_ERRORS: Record<string, string> = {
304
+ // This app's own Zod check (src/index.ts), before anything is sent.
305
+ invalid_body: `ข้อมูลไม่ครบหรือไม่ถูกต้อง — ต้องมีหัวข้อ ข้อความ และกลุ่มผู้รับ ไม่เกิน ${MAX_DIRECT_RECIPIENTS} คน`,
306
+ invalid_request: "คำขอไม่ถูกต้อง — ตรวจสอบว่าระบุกลุ่มผู้รับแล้ว",
307
+ invalid_recipients: `รายชื่อผู้รับไม่ถูกต้อง — ต้องมีอย่างน้อยหนึ่งคน ไม่เกิน ${MAX_DIRECT_RECIPIENTS} คน และหนึ่งบรรทัดระบุได้อย่างเดียวระหว่าง sub กับเบอร์โทรศัพท์`,
308
+ idempotency_key_conflict: "คีย์กันส่งซ้ำถูกใช้กับรายชื่อจำนวนอื่นไปแล้ว",
309
+ quota_exceeded: "โควตาการแจ้งเตือนหมด — ยังไม่มีใครได้รับข้อความนี้ ลองใหม่หลังโควตารีเซ็ต",
310
+ not_permitted: "คีย์การแจ้งเตือนนี้ไม่มีสิทธิ์ส่งผ่านช่องทางนี้",
311
+ send_failed: "ส่งไม่สำเร็จ",
312
+ };
313
+
314
+ /**
315
+ * One entry per line (commas work too). An entry that is all digits — with or
316
+ * without a leading `+` — is sent as a mobile number; anything else as a PPLE
317
+ * ID `sub`. Never both in one entry: the platform refuses those.
318
+ */
319
+ function parseRecipients(text: string): NotificationRecipient[] {
320
+ return text
321
+ .split(/[\n,]/)
322
+ .map((entry) => entry.trim())
323
+ .filter(Boolean)
324
+ .map((entry) =>
325
+ /^\+?[\d\s-]+$/.test(entry)
326
+ ? { phone: entry.replace(/[\s-]/g, "") }
327
+ : { sub: entry },
328
+ );
329
+ }
330
+
331
+ function recipientLabel(r: NotificationRecipient): string {
332
+ return "sub" in r ? r.sub : r.phone;
333
+ }
334
+
284
335
  function NotifyCard() {
336
+ const [kind, setKind] = useState<NotificationAudience["kind"]>("all");
337
+ const [recipientsText, setRecipientsText] = useState("");
285
338
  const [title, setTitle] = useState("");
286
339
  const [body, setBody] = useState("");
287
340
  const [sending, setSending] = useState(false);
341
+ const [results, setResults] = useState<NotificationDelivery[] | null>(null);
342
+
343
+ const recipients = parseRecipients(recipientsText);
344
+ // The cap is the platform's, and going over it is a refusal rather than a
345
+ // truncation — so hold the send here and say so, rather than letting a
346
+ // Builder discover it from an error toast.
347
+ const tooMany = recipients.length > MAX_DIRECT_RECIPIENTS;
348
+ const ready =
349
+ !!title.trim() &&
350
+ !!body.trim() &&
351
+ (kind === "all" || (recipients.length > 0 && !tooMany));
288
352
 
289
353
  async function send() {
290
- if (!title.trim() || !body.trim()) return;
354
+ if (!ready) return;
291
355
  setSending(true);
356
+ setResults(null);
292
357
  try {
358
+ // The audience travels with the send, always. Choosing it is the first
359
+ // thing this form does, not an afterthought a dropped field can undo.
360
+ const audience: NotificationAudience =
361
+ kind === "all" ? { kind: "all" } : { kind: "direct", recipients };
293
362
  const res = await api("/api/notify", {
294
363
  method: "POST",
295
364
  headers: { "content-type": "application/json" },
296
- body: JSON.stringify({ title, body }),
365
+ body: JSON.stringify({ audience, title, body }),
297
366
  });
298
- const data = (await res.json().catch(() => ({}))) as { error?: string };
367
+ const data = (await res.json().catch(() => ({}))) as {
368
+ error?: string;
369
+ results?: NotificationDelivery[];
370
+ };
299
371
  if (res.ok) {
300
372
  showToast({ title: "ส่งการแจ้งเตือนแล้ว", variant: "success" });
373
+ setResults(data.results ?? null);
301
374
  setTitle("");
302
375
  setBody("");
303
376
  } else if (data.error === "notifications_not_configured") {
@@ -307,7 +380,11 @@ function NotifyCard() {
307
380
  variant: "warning",
308
381
  });
309
382
  } else {
310
- showToast({ title: "ส่งไม่สำเร็จ", description: data.error, variant: "error" });
383
+ showToast({
384
+ title: "ส่งไม่สำเร็จ",
385
+ description: NOTIFY_ERRORS[data.error ?? ""] ?? data.error,
386
+ variant: "error",
387
+ });
311
388
  }
312
389
  } catch (e) {
313
390
  showToast({ title: "ส่งไม่สำเร็จ", description: message(e), variant: "error" });
@@ -321,12 +398,45 @@ function NotifyCard() {
321
398
  <CardHeader>
322
399
  <CardTitle>แจ้งเตือนผู้ใช้</CardTitle>
323
400
  <CardDescription>
324
- ส่งการแจ้งเตือนแบบผูกกับกลุ่มผู้ใช้แอป — ส่งเฉพาะเนื้อหา
325
- ไม่ส่งหมายเลขโทรศัพท์
401
+ ส่งถึงผู้ใช้ของแอปนี้ — ทุกคน หรือระบุรายบุคคลด้วย PPLE ID (sub)
402
+ หรือเบอร์โทรศัพท์ การระบุชื่อเป็นการ<em>จำกัด</em>ผู้รับให้แคบลงเท่านั้น
403
+ ไม่เคยขยายออก คนที่ยังไม่เคยเปิดแอปนี้จะไม่ได้รับ
326
404
  </CardDescription>
327
405
  </CardHeader>
328
406
  <CardContent>
329
407
  <div className="stack">
408
+ <Tabs
409
+ value={kind}
410
+ onValueChange={(v) => {
411
+ setKind(v as NotificationAudience["kind"]);
412
+ setResults(null);
413
+ }}
414
+ >
415
+ <TabsList>
416
+ <TabsTrigger value="all">การแจ้งเตือนทุกคน</TabsTrigger>
417
+ <TabsTrigger value="direct">การแจ้งเตือนรายบุคคล</TabsTrigger>
418
+ </TabsList>
419
+ <TabsContent value="all">
420
+ <p className="muted">
421
+ ส่งถึงผู้ใช้แอปนี้ทุกคน — นับโควตาตามจำนวนผู้รับทั้งหมด
422
+ </p>
423
+ </TabsContent>
424
+ <TabsContent value="direct">
425
+ <div className="stack">
426
+ <Textarea
427
+ value={recipientsText}
428
+ placeholder={"บรรทัดละหนึ่งคน\n0812345678\nu-42"}
429
+ onChange={(e) => setRecipientsText(e.target.value)}
430
+ />
431
+ <p className="muted">
432
+ ระบุได้สูงสุด {MAX_DIRECT_RECIPIENTS} คนต่อครั้ง (ตอนนี้{" "}
433
+ {recipients.length} คน{tooMany ? " — เกินจำนวน ต้องแบ่งส่งเป็นรอบ" : ""})
434
+ — ทุกชื่อที่ระบุถูกนับโควตา แม้จะส่งไม่ถึง
435
+ จึงควรตัดรายชื่อที่ส่งไม่ถึงออก
436
+ </p>
437
+ </div>
438
+ </TabsContent>
439
+ </Tabs>
330
440
  <Input
331
441
  value={title}
332
442
  placeholder="หัวข้อ"
@@ -337,10 +447,30 @@ function NotifyCard() {
337
447
  placeholder="ข้อความ"
338
448
  onChange={(e) => setBody(e.target.value)}
339
449
  />
340
- <Button onClick={send} disabled={sending || !title.trim() || !body.trim()}>
450
+ <Button onClick={send} disabled={sending || !ready}>
341
451
  {sending ? <Spinner /> : <Icon icon={Send} />}
342
452
  ส่งการแจ้งเตือน
343
453
  </Button>
454
+ {results && (
455
+ <div className="stack">
456
+ <ul className="list">
457
+ {results.map((r, i) => (
458
+ <li key={`${recipientLabel(r.recipient)}-${i}`} className="list__item">
459
+ <span>{recipientLabel(r.recipient)}</span>
460
+ <Badge variant={r.status === "delivered" ? "default" : "outline"}>
461
+ {r.status === "delivered" ? "ส่งแล้ว" : "ส่งไม่ถึง"}
462
+ </Badge>
463
+ </li>
464
+ ))}
465
+ </ul>
466
+ {/* One collapsed reason, on purpose: the app is never told which
467
+ of these applies (docs/adr/0017). Do not guess in the UI. */}
468
+ <p className="muted">
469
+ “ส่งไม่ถึง” หมายถึงยังไม่เคยเปิดแอปนี้ อยู่นอกกลุ่มผู้รับ
470
+ ไม่มีบัญชี PPLE ID หรือปิดรับการแจ้งเตือน — ระบบไม่ระบุว่าเป็นข้อใด
471
+ </p>
472
+ </div>
473
+ )}
344
474
  </div>
345
475
  </CardContent>
346
476
  </Card>