Sorry, the diff of this file is too big to display
| *,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}:before,:after{--tw-content:""}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder{opacity:1;color:#9ca3af}textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,system-ui,sans-serif}body:is(.dark *){--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}html.theme-ready body{transition:background-color .35s,color .35s}@media (prefers-reduced-motion:reduce){html.theme-ready body{transition:none}}.container{width:100%}@media (width>=640px){.container{max-width:640px}}@media (width>=768px){.container{max-width:768px}}@media (width>=1024px){.container{max-width:1024px}}@media (width>=1280px){.container{max-width:1280px}}@media (width>=1536px){.container{max-width:1536px}}.glass-panel{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow);--tw-backdrop-blur:blur(40px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:#ffffffb3;border-width:1px;border-color:#ffffff80}.glass-panel:is(.dark *){background-color:#18181b99;border-color:#ffffff1a}.scrollbar-soft{scrollbar-width:thin;scrollbar-color:#a1a1aa80 transparent}.scrollbar-soft::-webkit-scrollbar{width:8px;height:8px}.scrollbar-soft::-webkit-scrollbar-track{background:0 0}.scrollbar-soft::-webkit-scrollbar-thumb{background-color:#a1a1aa73;background-clip:padding-box;border:2px solid #0000;border-radius:9999px}.scrollbar-soft::-webkit-scrollbar-thumb:hover{background-color:#71717ab3;background-clip:padding-box}.pp-seek{appearance:none;cursor:pointer;background:#ffffff38;border-radius:9999px;height:3px}.pp-seek::-webkit-slider-thumb{appearance:none;background:#fff;border-radius:9999px;width:9px;height:9px;transition:transform .12s;box-shadow:0 1px 3px #0006}.pp-seek:hover::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek:focus-visible::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek::-moz-range-thumb{background:#fff;border:none;border-radius:9999px;width:9px;height:9px;box-shadow:0 1px 3px #0006}.pp-seek:focus-visible{outline:none}@media (prefers-reduced-motion:reduce){.pp-seek::-webkit-slider-thumb{transition:none}}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.-inset-5{inset:-1.25rem}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.-bottom-24{bottom:-6rem}.-left-32{left:-8rem}.-right-1{right:-.25rem}.-right-24{right:-6rem}.-top-1{top:-.25rem}.-top-32{top:-8rem}.bottom-0{bottom:0}.bottom-1\.5{bottom:.375rem}.bottom-4{bottom:1rem}.left-0{left:0}.left-1\/3{left:33.3333%}.left-2\.5{left:.625rem}.right-0{right:0}.right-0\.5{right:.125rem}.right-1{right:.25rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-2\.5{right:.625rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\.5{top:.375rem}.top-1\/2{top:50%}.top-1\/3{top:33.3333%}.top-2{top:.5rem}.z-0{z-index:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[80\]{z-index:80}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.m-0{margin:0}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-auto{margin-top:auto}.mt-px{margin-top:1px}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[1\.91\/1\]{aspect-ratio:1.91}.aspect-\[4\/5\]{aspect-ratio:4/5}.aspect-\[9\/16\]{aspect-ratio:9/16}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-28{height:7rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[252px\]{height:252px}.h-\[72px\]{height:72px}.h-full{height:100%}.h-px{height:1px}.max-h-72{max-height:18rem}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[92vh\]{max-height:92vh}.min-h-0{min-height:0}.min-h-48{min-height:12rem}.min-h-\[92px\]{min-height:92px}.min-h-dvh{min-height:100dvh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-4{width:1rem}.w-44{width:11rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-\[420px\]{width:420px}.w-\[440px\]{width:440px}.w-\[72px\]{width:72px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[840px\]{min-width:840px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-\[10rem\]{max-width:10rem}.max-w-\[14ch\]{max-width:14ch}.max-w-\[16rem\]{max-width:16rem}.max-w-\[4rem\]{max-width:4rem}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[94vw\]{max-width:94vw}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.-translate-x-1\/2{--tw-translate-x:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-4{--tw-translate-x:1rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes blob{0%,to{transform:translate(0)scale(1)}33%{transform:translate(50px,-70px)scale(1.08)}66%{transform:translate(-40px,40px)scale(.94)}}.animate-blob{animation:20s ease-in-out infinite blob}.animate-blob-slow{animation:28s ease-in-out infinite reverse blob}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}.animate-ping{animation:1s cubic-bezier(0,0,.2,1) infinite ping}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:2s cubic-bezier(.4,0,.6,1) infinite pulse}@keyframes slide-in{0%{opacity:0;transform:translate(48px)}to{opacity:1;transform:translate(0)}}.animate-slide-in{animation:.26s cubic-bezier(.32,.72,0,1) both slide-in}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:1s linear infinite spin}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-1\.5{-moz-column-gap:.375rem;column-gap:.375rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-2\.5{-moz-column-gap:.625rem;column-gap:.625rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.gap-y-1\.5{row-gap:.375rem}.gap-y-3{row-gap:.75rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-black\/5>:not([hidden])~:not([hidden]){border-color:#0000000d}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-l-2xl{border-top-left-radius:1rem;border-bottom-left-radius:1rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-500\/30{border-color:#f59e0b4d}.border-black\/10{border-color:#0000001a}.border-black\/5{border-color:#0000000d}.border-brand{border-color:var(--accent,#0f766e)}.border-zinc-200\/50{border-color:#e4e4e780}.border-zinc-200\/60{border-color:#e4e4e799}.border-zinc-200\/70{border-color:#e4e4e7b3}.border-zinc-300{--tw-border-opacity:1;border-color:rgb(212 212 216/var(--tw-border-opacity,1))}.border-zinc-300\/60{border-color:#d4d4d899}.border-zinc-300\/70{border-color:#d4d4d8b3}.border-zinc-900\/5{border-color:#18181b0d}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-amber-500\/20{background-color:#f59e0b33}.bg-amber-500\/90{background-color:#f59e0be6}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:#0006}.bg-black\/45{background-color:#00000073}.bg-black\/5{background-color:#0000000d}.bg-black\/55{background-color:#0000008c}.bg-black\/80{background-color:#000c}.bg-black\/90{background-color:#000000e6}.bg-blue-400\/15{background-color:#60a5fa26}.bg-brand{background-color:var(--accent,#0f766e)}.bg-current{background-color:currentColor}.bg-cyan-400\/20{background-color:#22d3ee33}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-cyan-500\/15{background-color:#06b6d426}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:#10b9811a}.bg-emerald-500\/15{background-color:#10b98126}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-emerald-600\/15{background-color:#05966926}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-500\/10{background-color:#f973161a}.bg-orange-500\/15{background-color:#f9731626}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/15{background-color:#ef444426}.bg-red-500\/70{background-color:#ef4444b3}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-600\/90{background-color:#dc2626e6}.bg-sky-500{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.bg-sky-500\/15{background-color:#0ea5e926}.bg-slate-400{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity,1))}.bg-slate-500\/10{background-color:#64748b1a}.bg-slate-500\/15{background-color:#64748b26}.bg-teal-400\/15{background-color:#2dd4bf26}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-teal-500\/15{background-color:#14b8a626}.bg-transparent{background-color:#0000}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:#fff3}.bg-white\/25{background-color:#ffffff40}.bg-white\/40{background-color:#fff6}.bg-white\/45{background-color:#ffffff73}.bg-white\/50{background-color:#ffffff80}.bg-white\/60{background-color:#fff9}.bg-white\/70{background-color:#ffffffb3}.bg-white\/80{background-color:#fffc}.bg-white\/95{background-color:#fffffff2}.bg-zinc-100\/70{background-color:#f4f4f5b3}.bg-zinc-200{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.bg-zinc-200\/40{background-color:#e4e4e766}.bg-zinc-200\/50{background-color:#e4e4e780}.bg-zinc-200\/60{background-color:#e4e4e799}.bg-zinc-200\/70{background-color:#e4e4e7b3}.bg-zinc-200\/80{background-color:#e4e4e7cc}.bg-zinc-300{--tw-bg-opacity:1;background-color:rgb(212 212 216/var(--tw-bg-opacity,1))}.bg-zinc-300\/40{background-color:#d4d4d866}.bg-zinc-300\/70{background-color:#d4d4d8b3}.bg-zinc-400{--tw-bg-opacity:1;background-color:rgb(161 161 170/var(--tw-bg-opacity,1))}.bg-zinc-500\/10{background-color:#71717a1a}.bg-zinc-500\/15{background-color:#71717a26}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-900\/5{background-color:#18181b0d}.bg-zinc-900\/60{background-color:#18181b99}.bg-zinc-900\/\[0\.06\]{background-color:#18181b0f}.bg-gradient-to-t{background-image:linear-gradient(to top, var(--tw-gradient-stops))}.from-black\/55{--tw-gradient-from:#0000008c var(--tw-gradient-from-position);--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.via-black\/25{--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), #00000040 var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.fill-zinc-900{fill:#18181b}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.object-top{-o-object-position:top;object-position:top}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pl-\[25px\]{padding-left:25px}.pr-0\.5{padding-right:.125rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-6{padding-right:1.5rem}.pr-9{padding-right:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-1\.5{padding-top:.375rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-body,.font-display{font-family:Inter,system-ui,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#0A66C2\]{--tw-text-opacity:1;color:rgb(10 102 194/var(--tw-text-opacity,1))}.text-\[\#1877F2\]{--tw-text-opacity:1;color:rgb(24 119 242/var(--tw-text-opacity,1))}.text-\[\#21759B\]{--tw-text-opacity:1;color:rgb(33 117 155/var(--tw-text-opacity,1))}.text-\[\#229ED9\]{--tw-text-opacity:1;color:rgb(34 158 217/var(--tw-text-opacity,1))}.text-\[\#4285F4\]{--tw-text-opacity:1;color:rgb(66 133 244/var(--tw-text-opacity,1))}.text-\[\#5865F2\]{--tw-text-opacity:1;color:rgb(88 101 242/var(--tw-text-opacity,1))}.text-\[\#6364FF\]{--tw-text-opacity:1;color:rgb(99 100 255/var(--tw-text-opacity,1))}.text-\[\#8E30EB\]{--tw-text-opacity:1;color:rgb(142 48 235/var(--tw-text-opacity,1))}.text-\[\#E4405F\]{--tw-text-opacity:1;color:rgb(228 64 95/var(--tw-text-opacity,1))}.text-\[\#E60023\]{--tw-text-opacity:1;color:rgb(230 0 35/var(--tw-text-opacity,1))}.text-\[\#FF0000\]{--tw-text-opacity:1;color:rgb(255 0 0/var(--tw-text-opacity,1))}.text-\[\#FF4500\]{--tw-text-opacity:1;color:rgb(255 69 0/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-600\/90{color:#d97706e6}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-700\/70{color:#b45309b3}.text-amber-700\/80{color:#b45309cc}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-brand{color:var(--accent,#0f766e)}.text-cyan-700{--tw-text-opacity:1;color:rgb(14 116 144/var(--tw-text-opacity,1))}.text-emerald-500{--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-700\/70{color:#047857b3}.text-emerald-700\/80{color:#047857cc}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-600\/80{color:#dc2626cc}.text-red-600\/90{color:#dc2626e6}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-700\/80{color:#b91c1ccc}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-teal-700{--tw-text-opacity:1;color:rgb(15 118 110/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/75{color:#ffffffbf}.text-white\/85{color:#ffffffd9}.text-white\/90{color:#ffffffe6}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-50{--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.text-zinc-700{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.text-zinc-800{--tw-text-opacity:1;color:rgb(39 39 42/var(--tw-text-opacity,1))}.text-zinc-900{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.text-zinc-950{--tw-text-opacity:1;color:rgb(9 9 11/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-zinc-400{text-decoration-color:#a1a1aa}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.accent-brand{accent-color:var(--accent,#0f766e)}.accent-emerald-600{accent-color:#059669}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-80{opacity:.8}.opacity-\[0\.03\]{opacity:.03}.mix-blend-overlay{mix-blend-mode:overlay}.shadow{--tw-shadow:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px #00000040;--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-\[0_8px_32px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.outline-none{outline-offset:2px;outline:2px solid #0000}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-amber-300\/40{--tw-ring-color:#fcd34d66}.ring-amber-500\/30{--tw-ring-color:#f59e0b4d}.ring-amber-500\/40{--tw-ring-color:#f59e0b66}.ring-black\/10{--tw-ring-color:#0000001a}.ring-brand{--tw-ring-color:var(--accent,#0f766e)}.ring-cyan-500\/30{--tw-ring-color:#06b6d44d}.ring-emerald-500\/30{--tw-ring-color:#10b9814d}.ring-emerald-600\/40{--tw-ring-color:#05966966}.ring-orange-500\/30{--tw-ring-color:#f973164d}.ring-red-300\/40{--tw-ring-color:#fca5a566}.ring-red-500\/20{--tw-ring-color:#ef444433}.ring-red-500\/30{--tw-ring-color:#ef44444d}.ring-red-500\/40{--tw-ring-color:#ef444466}.ring-red-500\/60{--tw-ring-color:#ef444499}.ring-sky-500\/30{--tw-ring-color:#0ea5e94d}.ring-slate-500\/30{--tw-ring-color:#64748b4d}.ring-teal-500\/30{--tw-ring-color:#14b8a64d}.ring-transparent{--tw-ring-color:transparent}.ring-white{--tw-ring-opacity:1;--tw-ring-color:rgb(255 255 255/var(--tw-ring-opacity,1))}.ring-white\/20{--tw-ring-color:#fff3}.ring-zinc-300{--tw-ring-opacity:1;--tw-ring-color:rgb(212 212 216/var(--tw-ring-opacity,1))}.ring-zinc-500\/20{--tw-ring-color:#71717a33}.ring-zinc-500\/30{--tw-ring-color:#71717a4d}.ring-zinc-900\/10{--tw-ring-color:#18181b1a}.ring-zinc-900\/5{--tw-ring-color:#18181b0d}.ring-zinc-900\/\[0\.06\]{--tw-ring-color:#18181b0f}.blur{--tw-blur:blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-property:transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/assets/inter-latin-variable-Dx4kXJAl.woff2)format("woff2")}@keyframes pp-breathe{0%,to{transform:scale(1)}50%{transform:scale(1.055)}}@keyframes pp-chevron-clear{0%{stroke-dashoffset:64px}55%{stroke-dashoffset:0}80%{stroke-dashoffset:0}to{stroke-dashoffset:-64px}}@keyframes pp-rest{0%,to{transform:translateY(0)}50%{transform:translateY(-5px)}}@keyframes pp-glow-pulse{0%,to{opacity:.5}50%{opacity:.9}}@media (prefers-reduced-motion:reduce){[class~=pp-anim],[style*=pp-breathe],[style*=pp-chevron-clear],[style*=pp-rest],[style*=pp-glow-pulse]{animation:none!important}}.file\:mr-3::file-selector-button{margin-right:.75rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-brand::file-selector-button{background-color:var(--accent,#0f766e)}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-1\.5::file-selector-button{padding-top:.375rem;padding-bottom:.375rem}.file\:text-xs::file-selector-button{font-size:.75rem;line-height:1rem}.file\:font-bold::file-selector-button{font-weight:700}.file\:text-white::file-selector-button{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.placeholder\:font-normal::placeholder{font-weight:400}.placeholder\:text-zinc-400::placeholder{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.focus-within\:outline-none:focus-within{outline-offset:2px;outline:2px solid #0000}.focus-within\:ring-2:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-within\:ring-brand:focus-within{--tw-ring-color:var(--accent,#0f766e)}.hover\:-translate-y-1:hover{--tw-translate-y:-.25rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-black\/20:hover{border-color:#0003}.hover\:bg-amber-500\/20:hover{background-color:#f59e0b33}.hover\:bg-amber-500\/30:hover{background-color:#f59e0b4d}.hover\:bg-black\/70:hover{background-color:#000000b3}.hover\:bg-emerald-500\/20:hover{background-color:#10b98133}.hover\:bg-emerald-500\/25:hover{background-color:#10b98140}.hover\:bg-red-500\/10:hover{background-color:#ef44441a}.hover\:bg-red-500\/15:hover{background-color:#ef444426}.hover\:bg-red-500\/20:hover{background-color:#ef444433}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:bg-white\/15:hover{background-color:#ffffff26}.hover\:bg-white\/70:hover{background-color:#ffffffb3}.hover\:bg-white\/80:hover{background-color:#fffc}.hover\:bg-white\/90:hover{background-color:#ffffffe6}.hover\:bg-zinc-100:hover{--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200:hover{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200\/40:hover{background-color:#e4e4e766}.hover\:bg-zinc-200\/50:hover{background-color:#e4e4e780}.hover\:bg-zinc-200\/60:hover{background-color:#e4e4e799}.hover\:bg-zinc-300\/50:hover{background-color:#d4d4d880}.hover\:bg-zinc-300\/60:hover{background-color:#d4d4d899}.hover\:text-amber-900:hover{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.hover\:text-emerald-900:hover{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:text-zinc-600:hover{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.hover\:text-zinc-700:hover{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.hover\:text-zinc-900:hover{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:decoration-zinc-700:hover{text-decoration-color:#3f3f46}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.hover\:ring-1:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus\:outline-none:focus,.focus-visible\:outline-none:focus-visible{outline-offset:2px;outline:2px solid #0000}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-amber-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.focus-visible\:ring-brand:focus-visible{--tw-ring-color:var(--accent,#0f766e)}.focus-visible\:ring-emerald-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus-visible\:ring-red-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus-visible\:ring-white\/60:focus-visible{--tw-ring-color:#fff9}.focus-visible\:ring-white\/70:focus-visible{--tw-ring-color:#ffffffb3}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:hover\:bg-transparent:hover:disabled{background-color:#0000}.group:hover .group-hover\:translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}.motion-reduce\:hover\:translate-y-0:hover{--tw-translate-y:0px;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}}.dark\:divide-white\/5:is(.dark *)>:not([hidden])~:not([hidden]){border-color:#ffffff0d}.dark\:border-amber-400\/30:is(.dark *){border-color:#fbbf244d}.dark\:border-brand-light:is(.dark *){border-color:var(--accent-light,#5eead4)}.dark\:border-white\/10:is(.dark *){border-color:#ffffff1a}.dark\:border-white\/5:is(.dark *){border-color:#ffffff0d}.dark\:border-zinc-600:is(.dark *){--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.dark\:border-zinc-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.dark\:border-zinc-700\/50:is(.dark *){border-color:#3f3f4680}.dark\:border-zinc-700\/60:is(.dark *){border-color:#3f3f4699}.dark\:border-zinc-700\/70:is(.dark *){border-color:#3f3f46b3}.dark\:bg-amber-400\/10:is(.dark *){background-color:#fbbf241a}.dark\:bg-black\/20:is(.dark *){background-color:#0003}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3b82f61a}.dark\:bg-brand-light:is(.dark *){background-color:var(--accent-light,#5eead4)}.dark\:bg-cyan-500\/10:is(.dark *){background-color:#06b6d41a}.dark\:bg-teal-500\/10:is(.dark *){background-color:#14b8a61a}.dark\:bg-white\/10:is(.dark *){background-color:#ffffff1a}.dark\:bg-zinc-100:is(.dark *){--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600:is(.dark *){--tw-bg-opacity:1;background-color:rgb(82 82 91/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600\/70:is(.dark *){background-color:#52525bb3}.dark\:bg-zinc-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:bg-zinc-700\/40:is(.dark *){background-color:#3f3f4666}.dark\:bg-zinc-700\/50:is(.dark *){background-color:#3f3f4680}.dark\:bg-zinc-700\/70:is(.dark *){background-color:#3f3f46b3}.dark\:bg-zinc-700\/80:is(.dark *){background-color:#3f3f46cc}.dark\:bg-zinc-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.dark\:bg-zinc-800\/40:is(.dark *){background-color:#27272a66}.dark\:bg-zinc-800\/50:is(.dark *){background-color:#27272a80}.dark\:bg-zinc-800\/60:is(.dark *){background-color:#27272a99}.dark\:bg-zinc-800\/70:is(.dark *){background-color:#27272ab3}.dark\:bg-zinc-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.dark\:bg-zinc-900\/20:is(.dark *){background-color:#18181b33}.dark\:bg-zinc-900\/30:is(.dark *){background-color:#18181b4d}.dark\:bg-zinc-900\/35:is(.dark *){background-color:#18181b59}.dark\:bg-zinc-900\/70:is(.dark *){background-color:#18181bb3}.dark\:bg-zinc-900\/95:is(.dark *){background-color:#18181bf2}.dark\:fill-zinc-100:is(.dark *){fill:#f4f4f5}.dark\:text-amber-200:is(.dark *){--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-300\/70:is(.dark *){color:#fcd34db3}.dark\:text-amber-300\/80:is(.dark *){color:#fcd34dcc}.dark\:text-amber-300\/90:is(.dark *){color:#fcd34de6}.dark\:text-amber-400:is(.dark *){--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-brand-light:is(.dark *){color:var(--accent-light,#5eead4)}.dark\:text-cyan-300:is(.dark *){--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.dark\:text-emerald-200:is(.dark *){--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-emerald-300\/70:is(.dark *){color:#6ee7b7b3}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.dark\:text-emerald-400\/70:is(.dark *){color:#34d399b3}.dark\:text-orange-300:is(.dark *){--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-300\/70:is(.dark *){color:#fca5a5b3}.dark\:text-red-300\/80:is(.dark *){color:#fca5a5cc}.dark\:text-red-300\/90:is(.dark *){color:#fca5a5e6}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-sky-300:is(.dark *){--tw-text-opacity:1;color:rgb(125 211 252/var(--tw-text-opacity,1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.dark\:text-teal-300:is(.dark *){--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-zinc-100:is(.dark *){--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}.dark\:text-zinc-200:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:text-zinc-300:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:text-zinc-400:is(.dark *){--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.dark\:text-zinc-500:is(.dark *){--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:text-zinc-600:is(.dark *){--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.dark\:text-zinc-900:is(.dark *){--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:text-zinc-900\/90:is(.dark *){color:#18181be6}.dark\:decoration-zinc-500:is(.dark *){text-decoration-color:#71717a}.dark\:ring-white\/10:is(.dark *){--tw-ring-color:#ffffff1a}.dark\:ring-white\/5:is(.dark *){--tw-ring-color:#ffffff0d}.dark\:ring-zinc-600:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(82 82 91/var(--tw-ring-opacity,1))}.dark\:ring-zinc-900:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(24 24 27/var(--tw-ring-opacity,1))}.dark\:file\:bg-brand-light:is(.dark *)::file-selector-button{background-color:var(--accent-light,#5eead4)}.dark\:file\:text-zinc-900:is(.dark *)::file-selector-button{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::-moz-placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:hover\:border-white\/20:hover:is(.dark *){border-color:#fff3}.dark\:hover\:bg-zinc-600\/50:hover:is(.dark *){background-color:#52525b80}.dark\:hover\:bg-zinc-700:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:hover\:bg-zinc-700\/60:hover:is(.dark *){background-color:#3f3f4699}.dark\:hover\:bg-zinc-800\/40:hover:is(.dark *){background-color:#27272a66}.dark\:hover\:bg-zinc-800\/50:hover:is(.dark *){background-color:#27272a80}.dark\:hover\:bg-zinc-800\/60:hover:is(.dark *){background-color:#27272a99}.dark\:hover\:bg-zinc-800\/70:hover:is(.dark *){background-color:#27272ab3}.dark\:hover\:bg-zinc-800\/80:hover:is(.dark *){background-color:#27272acc}.dark\:hover\:text-amber-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}.dark\:hover\:text-emerald-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(209 250 229/var(--tw-text-opacity,1))}.dark\:hover\:text-red-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-50:hover:is(.dark *){--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.dark\:hover\:decoration-zinc-300:hover:is(.dark *){text-decoration-color:#d4d4d8}@media (width>=640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:inline{display:inline}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (width>=768px){.md\:flex{display:flex}}@media (width>=1024px){.lg\:block{display:block}.lg\:inline{display:inline}.lg\:hidden{display:none}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_19rem\]{grid-template-columns:1fr 19rem}}@media (width>=1280px){.xl\:inline{display:inline}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=1536px){.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} |
| // capabilities.mjs - the LANE-CAPABILITY view the dashboard (and the website) | ||
| // badge lanes with, so a buyer knows BEFORE purchase which lanes the managed | ||
| // cloud fires 24/7, which the platform schedules natively, and which stay | ||
| // local-only (reddit: the free data API is licensed non-commercial; tiktok: | ||
| // unaudited apps post private-only). | ||
| // | ||
| // The single source of truth is the cloud's PUBLIC, unauthenticated | ||
| // GET /v1/capabilities (pendpost-cloud enabled-platforms.ts), fetched with a | ||
| // plain fetch against the baked-in connect origin - NOT cloudFetch, which | ||
| // requires an api key: the whole point is that this works pre-purchase, before | ||
| // any workspace exists. The response is cached in-memory for its own | ||
| // Cache-Control window and every failure degrades to the CONSERVATIVE baked-in | ||
| // fallback below, so the read never throws and never blocks a page on the | ||
| // network for long. | ||
| // | ||
| // The fallback deliberately claims ONLY what the live cloud provably runs today | ||
| // (the meta/linkedin/x guarantee): a lane the cloud MIGHT route someday is | ||
| // 'local_only' until the endpoint says otherwise. Under-claiming offline is | ||
| // honest; over-claiming would sell a guarantee nobody enforces. Capability | ||
| // flips (e.g. a new cloud lane) propagate through the endpoint without an app | ||
| // or website deploy. | ||
| // Mirrors cloud-client.mjs' CONNECT_CLOUD_BASE (the managed-cloud origin, | ||
| // env-overridable for staging/dev). Kept as one line of duplication rather than | ||
| // importing cloud-client's heavy module graph into a read-only surface. | ||
| const CLOUD_BASE = process.env.PENDPOST_CLOUD_BASE || 'https://pendpost-cloud-api.fly.dev'; | ||
| /** The four capability values GET /v1/capabilities can assign a lane. */ | ||
| export const CAPABILITIES = Object.freeze(['cloud', 'native', 'local_only', 'disabled']); | ||
| // The conservative offline truth (see the header note). Keys are LANE ids - | ||
| // the same ids lib/setup.mjs lists as platforms, plus the two non-UI lanes | ||
| // (youtube-release, bluesky) so the map covers everything the contract knows. | ||
| export const FALLBACK_LANES = Object.freeze({ | ||
| meta: 'cloud', | ||
| linkedin: 'cloud', | ||
| x: 'cloud', | ||
| youtube: 'native', // status.publishAt - the platform holds the schedule | ||
| mastodon: 'native', // POST /statuses with scheduled_at | ||
| wordpress: 'native', // status=future | ||
| ghost: 'native', // status=scheduled | ||
| telegram: 'local_only', // cloud-routable upstream, but claimed only via the live endpoint | ||
| discord: 'local_only', | ||
| nostr: 'local_only', | ||
| pinterest: 'local_only', | ||
| gbp: 'local_only', | ||
| reddit: 'local_only', // free data API is non-commercial-only - never cloud | ||
| tiktok: 'local_only', // unaudited apps post SELF_ONLY (private) - never cloud | ||
| 'youtube-release': 'local_only', // the local recovery lane behind the CASA-gated API | ||
| bluesky: 'disabled', | ||
| }); | ||
| // One in-memory cache slot on the long-lived local server. A cloud answer is | ||
| // held for the endpoint's own Cache-Control window; a failure is held briefly | ||
| // so an offline Mac does not re-probe the network on every dashboard paint. | ||
| const OK_TTL_MS = 5 * 60_000; // matches the endpoint's public, max-age=300 | ||
| const ERR_TTL_MS = 60_000; | ||
| let cache = null; // { at: ms, data } | ||
| const byCapability = (lanes, cap) => Object.keys(lanes).filter((l) => lanes[l] === cap); | ||
| // Validate + normalize a cloud response into our shape, or null when it does | ||
| // not look like a capability map (a proxy error page, an old api, ...). Only | ||
| // known capability values survive; an unknown value degrades that lane to the | ||
| // fallback so a future taxonomy addition can never render as a blank badge. | ||
| function normalize(data) { | ||
| if (!data || typeof data !== 'object' || !data.lanes || typeof data.lanes !== 'object') return null; | ||
| const lanes = {}; | ||
| let known = 0; | ||
| for (const [lane, cap] of Object.entries(data.lanes)) { | ||
| if (typeof lane !== 'string' || !lane) continue; | ||
| if (CAPABILITIES.includes(cap)) { lanes[lane] = cap; known += 1; } | ||
| else if (FALLBACK_LANES[lane]) lanes[lane] = FALLBACK_LANES[lane]; | ||
| } | ||
| if (!known) return null; | ||
| return lanes; | ||
| } | ||
| function shape(lanes, source) { | ||
| return { | ||
| ok: true, | ||
| source, // 'cloud' | 'fallback' - the UI can tell a live map from the baked one | ||
| lanes, | ||
| cloudLanes: byCapability(lanes, 'cloud'), | ||
| nativeLanes: byCapability(lanes, 'native'), | ||
| localOnlyLanes: byCapability(lanes, 'local_only'), | ||
| fetchedAt: new Date().toISOString(), | ||
| }; | ||
| } | ||
| /** | ||
| * The lane-capability map: { ok, source, lanes, cloudLanes, nativeLanes, | ||
| * localOnlyLanes, fetchedAt }. Never throws, never returns a partial shape - | ||
| * a transport failure, a timeout, or a malformed body all degrade to the | ||
| * baked-in fallback (source 'fallback'). `fetchImpl`/`baseUrl`/`now` exist for | ||
| * tests only. | ||
| */ | ||
| export async function laneCapabilities({ fetchImpl = fetch, baseUrl = CLOUD_BASE, now = Date.now } = {}) { | ||
| const nowMs = now(); | ||
| if (cache && nowMs - cache.at < (cache.data.source === 'cloud' ? OK_TTL_MS : ERR_TTL_MS)) { | ||
| return cache.data; | ||
| } | ||
| let lanes = null; | ||
| try { | ||
| const res = await fetchImpl(new URL('/v1/capabilities', baseUrl), { | ||
| signal: AbortSignal.timeout(3500), | ||
| }); | ||
| if (res && res.ok) lanes = normalize(await res.json()); | ||
| } catch { /* offline / timeout / bad JSON -> fallback below */ } | ||
| const data = lanes ? shape(lanes, 'cloud') : shape({ ...FALLBACK_LANES }, 'fallback'); | ||
| cache = { at: nowMs, data }; | ||
| return data; | ||
| } | ||
| /** Test-only: drop the memoized answer so each case starts cold. */ | ||
| export function resetCapabilitiesCache() { | ||
| cache = null; | ||
| } |
+150
| // markdown.mjs - minimal markdown -> HTML for the blog publish lanes (wordpress/ghost). | ||
| // | ||
| // Blog posts are authored as markdown (that is what the composer and every agent | ||
| // naturally write), but the blog platforms want HTML bodies. This renderer is a | ||
| // deliberately HONEST SUBSET, not a CommonMark engine - a dependency-free file we | ||
| // can fully reason about beats a 10k-line parser sitting on the publish path: | ||
| // | ||
| // Block: # ## ### #### headings, paragraphs, unordered lists (- or *), | ||
| // ordered lists (1.), blockquotes (>), fenced code blocks (```), | ||
| // horizontal rules (--- / ***). | ||
| // Inline: **bold**, *italic* / _italic_, `code`, [text](url) links and | ||
| //  images - http(s) URLs ONLY; any other scheme | ||
| // (javascript:, data:, file:, ...) stays plain text. | ||
| // | ||
| // NOT supported, on purpose: nested lists, tables, inline HTML, reference-style | ||
| // links, setext headings. A post that needs more can be pasted as HTML on the | ||
| // platform side; keeping the subset small is what keeps it auditable. | ||
| // | ||
| // Safety model: the ENTIRE input is HTML-escaped FIRST (& < > "), and only then | ||
| // are markdown constructs applied, so no author-supplied HTML - <script> tags, | ||
| // event-handler attributes, anything - survives into the output. The same escape | ||
| // pre-encodes URL text for attribute position (" is already "), so a crafted | ||
| // URL cannot break out of href/src. One consequence worth knowing: block parsing | ||
| // runs on the escaped text, which is why the blockquote marker below is `>`, | ||
| // not `>` (and why a literal ">" typed by the author cannot collide - its & | ||
| // escaped to & first). | ||
| // | ||
| // Whitespace: a single newline inside a paragraph is a soft wrap (a space); a | ||
| // blank line is a paragraph break. Pure and deterministic - no disk, no network, | ||
| // no env reads. | ||
| const escapeHtml = (s) => s | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"'); | ||
| // Only http(s) may become a live link/image; every other scheme renders as the | ||
| // literal markdown text so a javascript:/data: payload is inert by construction. | ||
| const isHttpUrl = (url) => /^https?:\/\//i.test(url); | ||
| // Emphasis only - factored out so link LABELS get bold/italic too, while the | ||
| // already-rendered <a>/<img> tags (whose URLs may contain _ or *) never do. | ||
| const emphasize = (s) => s | ||
| .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') | ||
| .replace(/\*([^*]+)\*/g, '<em>$1</em>') | ||
| .replace(/_([^_]+)_/g, '<em>$1</em>'); | ||
| // Inline pass. `code` spans, images, and links are lifted out into NUL-delimited | ||
| // placeholders BEFORE the emphasis pass, for two reasons: a `**x**` inside | ||
| // backticks must stay literal, and a URL containing _ or * must not sprout <em> | ||
| // tags mid-href. The normalizer in mdToHtml strips real NULs from the input, so | ||
| // the placeholder channel cannot be forged by an author. | ||
| function renderInline(text) { | ||
| const tokens = []; | ||
| const stash = (html) => { tokens.push(html); return `\u0000${tokens.length - 1}\u0000`; }; | ||
| let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${code}</code>`)); | ||
| // Images before links: the two patterns differ only by the leading `!`. | ||
| s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => (isHttpUrl(url) ? stash(`<img src="${url}" alt="${alt}">`) : m)); | ||
| s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => (isHttpUrl(url) ? stash(`<a href="${url}">${emphasize(label)}</a>`) : m)); | ||
| s = emphasize(s); | ||
| return s.replace(/\u0000(\d+)\u0000/g, (_m, n) => tokens[Number(n)]); | ||
| } | ||
| // Block-start predicates, all run against ESCAPED lines (see the header note). | ||
| // The (?!#) keeps ##### (five or more) out of the heading rule so it falls | ||
| // through to a paragraph instead of silently truncating to h4. | ||
| const RE_HEADING = /^(#{1,4})(?!#)\s+(.+)$/; | ||
| const RE_HR = /^(?:-{3,}|\*{3,})\s*$/; | ||
| const RE_UL = /^[-*]\s+(.+)$/; | ||
| const RE_OL = /^\d+\.\s+(.+)$/; | ||
| const RE_QUOTE = /^>\s?(.*)$/; | ||
| const RE_FENCE = /^```/; | ||
| const startsBlock = (line) => RE_HEADING.test(line) || RE_HR.test(line) || RE_UL.test(line) | ||
| || RE_OL.test(line) || RE_QUOTE.test(line) || RE_FENCE.test(line); | ||
| export function mdToHtml(md) { | ||
| // Normalize: CRLF/CR -> LF, and strip NUL so the inline placeholder channel is | ||
| // ours alone. Then escape EVERYTHING before any parsing - the whole safety | ||
| // model hangs on this ordering. | ||
| const src = escapeHtml(String(md ?? '').replace(/\r\n?/g, '\n').replace(/\u0000/g, '')); | ||
| const lines = src.split('\n'); | ||
| const out = []; | ||
| let i = 0; | ||
| while (i < lines.length) { | ||
| const line = lines[i]; | ||
| if (line.trim() === '') { i += 1; continue; } | ||
| if (RE_FENCE.test(line)) { | ||
| // Fenced code: contents are emitted verbatim (already escaped), with no | ||
| // inline processing. The info string after ``` is dropped. An unclosed | ||
| // fence runs to EOF - swallowing the rest beats silently losing content. | ||
| const body = []; | ||
| i += 1; | ||
| while (i < lines.length && !RE_FENCE.test(lines[i])) { body.push(lines[i]); i += 1; } | ||
| i += 1; // step past the closing fence (or past EOF) | ||
| out.push(`<pre><code>${body.join('\n')}</code></pre>`); | ||
| continue; | ||
| } | ||
| const h = line.match(RE_HEADING); | ||
| if (h) { | ||
| out.push(`<h${h[1].length}>${renderInline(h[2].trim())}</h${h[1].length}>`); | ||
| i += 1; | ||
| continue; | ||
| } | ||
| // hr before list: `---` must not read as an empty `-` item (it cannot match | ||
| // RE_UL anyway, which demands content after the marker, but order documents | ||
| // the intent). | ||
| if (RE_HR.test(line)) { out.push('<hr>'); i += 1; continue; } | ||
| if (RE_QUOTE.test(line)) { | ||
| // Consecutive > lines fold into ONE quote paragraph (soft-wrap semantics | ||
| // inside the quote; no nested quotes - documented subset). | ||
| const parts = []; | ||
| while (i < lines.length && RE_QUOTE.test(lines[i])) { | ||
| const rest = lines[i].match(RE_QUOTE)[1].trim(); | ||
| if (rest !== '') parts.push(rest); | ||
| i += 1; | ||
| } | ||
| out.push(`<blockquote><p>${renderInline(parts.join(' '))}</p></blockquote>`); | ||
| continue; | ||
| } | ||
| if (RE_UL.test(line) || RE_OL.test(line)) { | ||
| const ordered = RE_OL.test(line); | ||
| const re = ordered ? RE_OL : RE_UL; | ||
| const tag = ordered ? 'ol' : 'ul'; | ||
| out.push(`<${tag}>`); | ||
| while (i < lines.length && re.test(lines[i])) { | ||
| out.push(`<li>${renderInline(lines[i].match(re)[1].trim())}</li>`); | ||
| i += 1; | ||
| } | ||
| out.push(`</${tag}>`); | ||
| continue; | ||
| } | ||
| // Paragraph: soak up lines until a blank line or the start of another block; | ||
| // single newlines inside are soft wraps, so they join with a space. | ||
| const para = []; | ||
| while (i < lines.length && lines[i].trim() !== '' && !startsBlock(lines[i])) { | ||
| para.push(lines[i].trim()); | ||
| i += 1; | ||
| } | ||
| out.push(`<p>${renderInline(para.join(' '))}</p>`); | ||
| } | ||
| return out.join('\n'); | ||
| } |
| #!/usr/bin/env node | ||
| /** | ||
| * gbp-social.mjs - direct Google Business Profile local-post publishing via the | ||
| * My Business v4 API. | ||
| * | ||
| * Sibling of scripts/pinterest-social.mjs / telegram-social.mjs / x-social.mjs: | ||
| * the same zero-dep, plan-driven, publish-straight-from-the-plan pattern, with | ||
| * Google's standard OAuth2 authorization-code auth model. | ||
| * | ||
| * GBP has NO scheduling API - a local post goes live on creation, so entries | ||
| * publish at their due time by re-running `publish-due` (driven by the scheduler | ||
| * tick), exactly like Telegram / X / Pinterest. There is no native `schedule` | ||
| * command. | ||
| * | ||
| * AUTH - OAuth2 authorization-code with a durable (NON-rotating) refresh token | ||
| * (mirrors pinterest-social's loopback ceremony + expiry tracking): | ||
| * GBP_CLIENT_ID the OAuth client id from the Google Cloud console. | ||
| * GBP_CLIENT_SECRET the OAuth client secret (sent in the token-endpoint form body). | ||
| * GBP_ACCESS_TOKEN minted at consent, short-lived (~1h), auto-refreshed. | ||
| * GBP_REFRESH_TOKEN durable token used to mint fresh access tokens - Google | ||
| * only issues it at consent (access_type=offline + prompt=consent) | ||
| * and does NOT rotate it on refresh. | ||
| * GBP_TOKEN_EXPIRES_AT epoch ms - when the access token expires. | ||
| * GBP_ACCOUNT_ID the numeric Business Profile account id. | ||
| * GBP_LOCATION_ID the numeric location id - posts land on the parent | ||
| * accounts/<GBP_ACCOUNT_ID>/locations/<GBP_LOCATION_ID>. | ||
| * `auth`/`connect` runs a loopback http server on 127.0.0.1:8088, opens the | ||
| * Google consent screen (scope business.manage), captures ?code, exchanges it | ||
| * and persists the tokens + expiry to the active client's gitignored .env, then | ||
| * best-effort lists accounts + locations as a hint for GBP_ACCOUNT_ID / | ||
| * GBP_LOCATION_ID. | ||
| * | ||
| * CONTENT - `summary` comes from post.caption (1500-char cap); the optional | ||
| * per-post intent object post.gbp picks one of the THREE local-post shapes | ||
| * (absent -> What's New): | ||
| * What's New { topic: 'standard' } | ||
| * Offer { topic: 'offer', couponCode?, redeemUrl?, terms? } | ||
| * Event { topic: 'event', eventTitle, eventStart, eventEnd } (ISO dates - all three REQUIRED) | ||
| * plus an optional call-to-action on any shape: ctaType BOOK | ORDER | SHOP | | ||
| * LEARN_MORE | SIGN_UP | CALL with ctaUrl (omitted for CALL - it dials the | ||
| * listing's phone number). Media is supplied by a PUBLIC IMAGE URL (post.image): | ||
| * the v4 localPosts surface takes media by sourceUrl ONLY, so a local-only | ||
| * render cannot be uploaded through this lane - such entries publish text-only | ||
| * with a clear [info]. | ||
| * | ||
| * HONESTY - this lane is BETA: built + mock-verified; live verification is | ||
| * deferred until the owner has credentials. The Business Profile APIs are gated | ||
| * on PER-PROJECT Google approval (request access via the GBP API console) AND a | ||
| * verified business location - until the project is allowlisted, every | ||
| * mybusiness* call 403s. Auth still succeeds (the OAuth token mints fine), so | ||
| * the engine surfaces "pending approval" honestly instead of failing. Quota | ||
| * note: the GBP APIs are REQUEST-quota'd (per-minute/per-day request budgets), | ||
| * not post-quota'd - there is no daily post cap, but sweeps should stay gentle. | ||
| * | ||
| * Commands: | ||
| * auth | connect [--client-id X --client-secret Y] one-time loopback OAuth ceremony | ||
| * refresh mint a fresh access token from the refresh token | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due GBP entry | ||
| * status --plan <p> list GBP plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (GET local post -> state) | ||
| * insights --plan <p> [--only <id>] per-post metrics (views + CTA clicks, best-effort) | ||
| * probe read-only health probe (list accounts) | ||
| * delete --id <resourceName> delete a local post (cleanup) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import http from 'node:http'; | ||
| import crypto from 'node:crypto'; | ||
| import { execFile } from 'node:child_process'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| // The .env lives in the ACTIVE client subtree, resolved by the shared envPath() | ||
| // (lib/util.mjs -> activeRoot()): the app sets PENDPOST_ROOT to that client root | ||
| // when it spawns us; a bare CLI run resolves the active client from data/clients.json. | ||
| const ENV_PATH = envPath(); | ||
| const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; | ||
| const TOKEN_URL = 'https://oauth2.googleapis.com/token'; | ||
| const API = 'https://mybusiness.googleapis.com/v4'; | ||
| // Account/location discovery lives on the two NEWER Business Profile hosts | ||
| // (the v4 host only keeps localPosts) - both are gated on the same per-project approval. | ||
| const ACCOUNTS_API = 'https://mybusinessaccountmanagement.googleapis.com/v1'; | ||
| const INFO_API = 'https://mybusinessbusinessinformation.googleapis.com/v1'; | ||
| const SCOPE = 'https://www.googleapis.com/auth/business.manage'; | ||
| const DEFAULT_REDIRECT = 'http://127.0.0.1:8088/oauth/gbp/callback'; | ||
| // GBP local-post cap: a summary at 1500 chars. | ||
| const SUMMARY_LIMIT = 1500; | ||
| // The three local-post shapes + the CTA action types the v4 surface accepts. | ||
| const TOPICS = new Set(['standard', 'offer', 'event']); | ||
| const CTA_TYPES = new Set(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']); | ||
| // Refresh when the access token expires within this window (it lasts ~1h). | ||
| const REFRESH_BUFFER_MS = 5 * 60 * 1000; | ||
| // redirect uri is a constant overridable by env (mirrors pinterest-social's redirectUri). | ||
| const redirectUri = () => readEnv('GBP_REDIRECT_URI') || DEFAULT_REDIRECT; | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| function writeEnv(vars) { | ||
| let raw = readEnvRaw(); | ||
| for (const [k, v] of Object.entries(vars)) { | ||
| if (v == null) continue; | ||
| // function replacer: token values may contain '$', which is special in a string replacement. | ||
| if (new RegExp(`^${k}=`, 'm').test(raw)) { | ||
| raw = raw.replace(new RegExp(`^${k}=.*$`, 'm'), () => `${k}=${v}`); | ||
| } else { | ||
| raw += `${raw.endsWith('\n') || raw === '' ? '' : '\n'}${k}=${v}\n`; | ||
| } | ||
| } | ||
| // Atomic + 0600: a crash mid-write must never truncate the secret-bearing .env. | ||
| const tmp = `${ENV_PATH}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, raw, { mode: 0o600 }); | ||
| fs.renameSync(tmp, ENV_PATH); | ||
| } | ||
| function requireEnv(name) { | ||
| const v = readEnv(name); | ||
| if (!v) { | ||
| console.error(`[err] ${name} missing in .env - run 'node scripts/gbp-social.mjs auth' first.`); | ||
| process.exit(1); | ||
| } | ||
| return v; | ||
| } | ||
| function tokenTail(t) { | ||
| return t ? `...${t.slice(-6)}, length ${t.length}` : '(none)'; | ||
| } | ||
| const accountId = () => readEnv('GBP_ACCOUNT_ID'); | ||
| const locationId = () => readEnv('GBP_LOCATION_ID'); | ||
| // Every localPosts call hangs off this parent resource. | ||
| const parentPath = () => `accounts/${accountId()}/locations/${locationId()}`; | ||
| // ---------- oauth ---------- | ||
| // Google's token endpoint takes client_id/client_secret in the form body | ||
| // (no HTTP Basic, unlike Pinterest). | ||
| async function tokenExchange(params) { | ||
| const clientId = requireEnv('GBP_CLIENT_ID'); | ||
| const clientSecret = requireEnv('GBP_CLIENT_SECRET'); | ||
| const res = await fetch(TOKEN_URL, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | ||
| body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...params }), | ||
| }); | ||
| const data = await res.json().catch(() => ({})); | ||
| if (!res.ok || data.error) { | ||
| const hint = data.error === 'invalid_grant' | ||
| ? " - the refresh token is expired/revoked. Re-run 'node scripts/gbp-social.mjs auth'." | ||
| : ''; | ||
| throw new Error(`OAuth ${params.grant_type}: HTTP ${res.status} ${data.error || ''} - ${data.error_description || data.message || JSON.stringify(data)}${hint}`); | ||
| } | ||
| return data; | ||
| } | ||
| // Persist a token-endpoint response and return the vars written (so the caller can | ||
| // log the new expiry). Google returns access_token (+ expires_in); a refresh_token | ||
| // ONLY arrives at consent (offline access) and never rotates on refresh, so the | ||
| // stored one is kept unless a new one shows up. | ||
| function persistTokens(data) { | ||
| const vars = { | ||
| GBP_ACCESS_TOKEN: data.access_token, | ||
| GBP_TOKEN_EXPIRES_AT: String(Date.now() + (Number(data.expires_in) || 0) * 1000), | ||
| }; | ||
| if (data.refresh_token) vars.GBP_REFRESH_TOKEN = data.refresh_token; | ||
| writeEnv(vars); | ||
| return vars; | ||
| } | ||
| // Return a valid access token, refreshing it (from the durable refresh token) | ||
| // if the current one expires within REFRESH_BUFFER_MS. Throws (never process.exit) | ||
| // so main().catch can emit the --json failure envelope and the probe path can catch. | ||
| async function ensureFreshToken({ force = false } = {}) { | ||
| const token = readEnv('GBP_ACCESS_TOKEN'); | ||
| const expiresAt = Number(readEnv('GBP_TOKEN_EXPIRES_AT') || 0); | ||
| if (!token && !readEnv('GBP_REFRESH_TOKEN')) { | ||
| throw new Error("No GBP_ACCESS_TOKEN/GBP_REFRESH_TOKEN - run 'node scripts/gbp-social.mjs auth' first."); | ||
| } | ||
| if (token && !force && expiresAt - Date.now() > REFRESH_BUFFER_MS) return token; | ||
| const refreshToken = readEnv('GBP_REFRESH_TOKEN'); | ||
| if (!refreshToken) { | ||
| if (token && !force && expiresAt > Date.now()) return token; | ||
| throw new Error("GBP access token expired and no refresh token is stored - re-run 'node scripts/gbp-social.mjs auth'."); | ||
| } | ||
| console.log('[info] Refreshing GBP access token...'); | ||
| let data; | ||
| try { | ||
| data = await tokenExchange({ grant_type: 'refresh_token', refresh_token: refreshToken }); | ||
| } catch (err) { | ||
| throw new Error(`GBP token refresh failed (${err.message}). The refresh token likely expired or was revoked - re-run 'node scripts/gbp-social.mjs auth'.`); | ||
| } | ||
| const vars = persistTokens(data); | ||
| console.log(`[ok] Token refreshed ${tokenTail(data.access_token)}, expires ${new Date(Number(vars.GBP_TOKEN_EXPIRES_AT)).toLocaleString('en-US')}.`); | ||
| return data.access_token; | ||
| } | ||
| // ---------- google api helper (json GET/POST/DELETE) ---------- | ||
| // Takes a FULL url (the lane spans three Google hosts: v4 localPosts + the two | ||
| // discovery APIs). err.status is attached so callers can branch on 403 (project | ||
| // not yet allowlisted for the Business Profile APIs) and 404 (post gone). | ||
| async function api(method, urlStr, { query, body, token } = {}) { | ||
| const url = new URL(urlStr); | ||
| if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v)); | ||
| const headers = { Authorization: `Bearer ${token}` }; | ||
| let payload; | ||
| if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } | ||
| const res = await fetch(url, { method, headers, body: payload }); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok) { | ||
| const err = new Error(`GBP ${method} ${url.pathname}: HTTP ${res.status} - ${data.error?.message || data.error_description || text || ''}`); | ||
| err.status = res.status; | ||
| throw err; | ||
| } | ||
| return data; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| // (lock + field-merge save duplicated verbatim across the engine siblings - | ||
| // self-contained per the sibling pattern, no shared lib) | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| // Engine-owned fields; everything else (caption, schedule, approval, cover) | ||
| // belongs to the owner/pendpost and must survive concurrent edits. | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'redditPostId', 'pinId', 'tiktokVideoId', 'mastodonStatusId', 'wordpressPostId', 'ghostPostId', 'nostrEventId', 'gbpPostId', 'status', 'postedAt', 'attempts']; | ||
| // mkdir lockfile next to the plan: retry 5x200ms, steal when stale (>15 min). | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| const isGbp = (post) => (post.platforms || []).includes('gbp'); | ||
| const gbpSummary = (post) => (post.caption || '').trim(); | ||
| // The per-post intent object: absent -> a plain What's New post. | ||
| const gbpIntent = (post) => ((post.gbp && typeof post.gbp === 'object') ? post.gbp : { topic: 'standard' }); | ||
| const gbpTopic = (post) => String(gbpIntent(post).topic || 'standard').toLowerCase(); | ||
| // Media is supplied by a PUBLIC image url - GBP v4 localPosts cannot take an upload. | ||
| const gbpImageUrl = (post) => (post.image || '').trim(); | ||
| const hasLocalMedia = (post) => Boolean(post.path || post.file); | ||
| // ---------- payload builder ---------- | ||
| // The v4 event schedule takes a civil google.type.Date + TimeOfDay pair, not a | ||
| // timestamp - derive both from the ISO string in UTC. | ||
| function civilDateTime(iso) { | ||
| const d = new Date(iso); | ||
| return { | ||
| date: { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }, | ||
| time: { hours: d.getUTCHours(), minutes: d.getUTCMinutes() }, | ||
| }; | ||
| } | ||
| // Map a plan post + its gbp intent onto the v4 localPosts payload. Assumes the | ||
| // caps/completeness gates already ran (publish-due warn-skips before calling). | ||
| function buildLocalPost(post) { | ||
| const intent = gbpIntent(post); | ||
| const topic = gbpTopic(post); | ||
| const payload = { | ||
| languageCode: 'en', | ||
| topicType: topic.toUpperCase(), // STANDARD | OFFER | EVENT | ||
| summary: gbpSummary(post), | ||
| }; | ||
| if (intent.ctaType) { | ||
| payload.callToAction = { actionType: intent.ctaType }; | ||
| // CALL dials the listing's phone number - the API rejects a url with it. | ||
| if (intent.ctaType !== 'CALL' && intent.ctaUrl) payload.callToAction.url = intent.ctaUrl; | ||
| } | ||
| // EVENT requires the event block (title + schedule); OFFER carries it only | ||
| // when dates are provided (an offer's validity window is optional). | ||
| if ((topic === 'event' || topic === 'offer') && intent.eventStart && intent.eventEnd) { | ||
| const start = civilDateTime(intent.eventStart); | ||
| const end = civilDateTime(intent.eventEnd); | ||
| payload.event = { | ||
| ...(intent.eventTitle ? { title: intent.eventTitle } : {}), | ||
| schedule: { startDate: start.date, startTime: start.time, endDate: end.date, endTime: end.time }, | ||
| }; | ||
| } | ||
| if (topic === 'offer') { | ||
| payload.offer = {}; | ||
| if (intent.couponCode) payload.offer.couponCode = intent.couponCode; | ||
| if (intent.redeemUrl) payload.offer.redeemOnlineUrl = intent.redeemUrl; | ||
| if (intent.terms) payload.offer.termsConditions = intent.terms; | ||
| } | ||
| const url = gbpImageUrl(post); | ||
| if (url) payload.media = [{ mediaFormat: 'PHOTO', sourceUrl: url }]; | ||
| return payload; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth(args) { | ||
| console.log(`[info] Connecting Google Business Profile - credentials will be written to ${ENV_PATH}`); | ||
| const clientId = args['client-id'] || readEnv('GBP_CLIENT_ID'); | ||
| const clientSecret = args['client-secret'] || readEnv('GBP_CLIENT_SECRET'); | ||
| if (!clientId || !clientSecret) { | ||
| console.error('[err] Need --client-id and --client-secret (Google Cloud console -> APIs & Services -> Credentials) on first run, or set GBP_CLIENT_ID / GBP_CLIENT_SECRET in .env.'); | ||
| process.exit(2); | ||
| } | ||
| const redirect = redirectUri(); | ||
| writeEnv({ GBP_CLIENT_ID: clientId, GBP_CLIENT_SECRET: clientSecret, GBP_REDIRECT_URI: redirect }); | ||
| const u = new URL(redirect); | ||
| const port = Number(u.port || 80); | ||
| const callbackPath = u.pathname || '/oauth/gbp/callback'; | ||
| const state = crypto.randomUUID(); | ||
| const authUrl = `${AUTH_URL}?${new URLSearchParams({ | ||
| client_id: clientId, | ||
| redirect_uri: redirect, | ||
| response_type: 'code', | ||
| scope: SCOPE, | ||
| access_type: 'offline', // ask for a refresh token... | ||
| prompt: 'consent', // ...and force Google to re-issue it even on re-consent | ||
| state, | ||
| }).toString()}`; | ||
| console.log(`\n[action] Make sure ${redirect} is listed as an Authorized redirect URI for this OAuth client (Google Cloud console -> Credentials -> your client).`); | ||
| console.log('[action] Opening the Google consent screen. Sign in with the account that manages your Business Profile. If it does not open, paste this URL:\n'); | ||
| console.log(` ${authUrl}\n`); | ||
| await new Promise((resolve, reject) => { | ||
| const server = http.createServer(async (req, res) => { | ||
| const ru = new URL(req.url, redirect); | ||
| if (ru.pathname !== callbackPath) { res.writeHead(404); res.end('not found'); return; } | ||
| const error = ru.searchParams.get('error'); | ||
| if (error) { | ||
| res.writeHead(200, { 'Content-Type': 'text/html' }); | ||
| res.end(`<h2>Authorization denied: ${error}</h2><p>${ru.searchParams.get('error_description') || ''}</p>`); | ||
| server.close(); | ||
| reject(new Error(`Authorization denied: ${error} - ${ru.searchParams.get('error_description') || ''}`)); | ||
| return; | ||
| } | ||
| const code = ru.searchParams.get('code'); | ||
| if (!code) { res.writeHead(400); res.end('missing code'); return; } | ||
| if (ru.searchParams.get('state') !== state) { | ||
| res.writeHead(400); res.end('state mismatch'); | ||
| server.close(); | ||
| reject(new Error('OAuth state mismatch - possible CSRF; aborted.')); | ||
| return; | ||
| } | ||
| try { | ||
| const data = await tokenExchange({ | ||
| grant_type: 'authorization_code', | ||
| code, | ||
| redirect_uri: redirect, | ||
| }); | ||
| if (!data.access_token) { | ||
| throw new Error(`No access_token returned: ${JSON.stringify(data).slice(0, 200)}`); | ||
| } | ||
| const vars = persistTokens(data); | ||
| res.writeHead(200, { 'Content-Type': 'text/html' }); | ||
| res.end('<h2>pendpost: Google Business Profile connected.</h2><p>You can close this tab and return to the terminal.</p>'); | ||
| const exp = new Date(Number(vars.GBP_TOKEN_EXPIRES_AT)).toLocaleString('en-US'); | ||
| console.log(`\n[ok] Access token stored ${tokenTail(data.access_token)}, expires ${exp}.`); | ||
| console.log(`[ok] Refresh token ${data.refresh_token ? 'stored - offline access enabled.' : 'NOT issued - re-run auth (prompt=consent) if refresh fails when the access token expires.'}`); | ||
| server.close(); | ||
| resolve(); | ||
| } catch (err) { | ||
| res.writeHead(500, { 'Content-Type': 'text/html' }); | ||
| res.end(`<h2>Token exchange failed</h2><pre>${err.message}</pre>`); | ||
| server.close(); | ||
| reject(err); | ||
| } | ||
| }); | ||
| server.on('error', reject); | ||
| server.listen(port, () => { | ||
| execFile('open', [authUrl], () => {}); // best-effort browser open on macOS (no shell -> no injection) | ||
| console.log(`[info] Waiting for the Google consent redirect on ${redirect} ...`); | ||
| }); | ||
| }); | ||
| // prove the token works and surface account/location ids as a setup hint. | ||
| // Both discovery APIs 403 until the project is allowlisted for the Business | ||
| // Profile APIs - that is NOT an auth failure, so it degrades to an honest warn. | ||
| try { | ||
| const token = await ensureFreshToken(); | ||
| const data = await api('GET', `${ACCOUNTS_API}/accounts`, { token }); | ||
| const accounts = data.accounts || []; | ||
| if (!accounts.length) console.log('[warn] No Business Profile accounts visible to this Google user.'); | ||
| for (const a of accounts) { | ||
| console.log(` account ${a.name} "${a.accountName || ''}" -> GBP_ACCOUNT_ID=${(a.name || '').split('/')[1] || '?'}`); | ||
| } | ||
| if (accounts[0]) { | ||
| const locs = await api('GET', `${INFO_API}/${accounts[0].name}/locations`, { query: { readMask: 'name,title', pageSize: 10 }, token }); | ||
| for (const l of locs.locations || []) { | ||
| console.log(` location ${l.name} "${l.title || ''}" -> GBP_LOCATION_ID=${(l.name || '').split('/')[1] || '?'}`); | ||
| } | ||
| } | ||
| if (!accountId() || !locationId()) console.log('[warn] GBP_ACCOUNT_ID / GBP_LOCATION_ID are not set - set the numeric ids (hints above) before publishing.'); | ||
| } catch (err) { | ||
| if (err.status === 403) console.log('[warn] Could not list accounts/locations - Business Profile API access pending Google approval (request access for this project in the GBP API console).'); | ||
| else console.log(`[warn] Could not fetch accounts/locations: ${err.message}`); | ||
| } | ||
| console.log('[note] The Business Profile APIs need PER-PROJECT Google approval + a verified location - until then API calls 403 while the OAuth token itself stays valid.'); | ||
| console.log('[done] auth complete.'); | ||
| } | ||
| async function cmdRefresh() { | ||
| const token = await ensureFreshToken({ force: true }); | ||
| RUN.results.push({ platform: 'gbp', action: 'refresh', ok: true, tokenExpiresAt: Number(readEnv('GBP_TOKEN_EXPIRES_AT') || 0) || null }); | ||
| console.log(`[ok] Access token ${tokenTail(token)}, expires ${new Date(Number(readEnv('GBP_TOKEN_EXPIRES_AT'))).toLocaleString('en-US')}.`); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| if (readEnv('GBP_ACCESS_TOKEN') || readEnv('GBP_REFRESH_TOKEN')) { | ||
| console.log('[ok] OAuth tokens present.'); | ||
| } else { | ||
| console.log("[warn] No GBP tokens stored - run 'node scripts/gbp-social.mjs auth' before publishing."); | ||
| } | ||
| if (!accountId() || !locationId()) console.log('[warn] GBP_ACCOUNT_ID / GBP_LOCATION_ID are not set - publish-due would fail.'); | ||
| const targets = (plan.posts || []).filter((p) => isGbp(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No GBP entries match.'); return; } | ||
| for (const post of targets) { | ||
| const summary = gbpSummary(post); | ||
| const intent = gbpIntent(post); | ||
| const topic = gbpTopic(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] topic: ${topic}${TOPICS.has(topic) ? '' : ' - UNKNOWN (use standard|offer|event)'}`); | ||
| console.log(`[preview] summary (${summary.length}/${SUMMARY_LIMIT}${summary.length > SUMMARY_LIMIT ? ' - OVER LIMIT' : ''}):`); | ||
| console.log(summary); | ||
| if (intent.ctaType) { | ||
| if (!CTA_TYPES.has(intent.ctaType)) console.log(`[warn] ctaType "${intent.ctaType}" is not a GBP action type (${[...CTA_TYPES].join('|')}).`); | ||
| else if (intent.ctaType !== 'CALL' && !intent.ctaUrl) console.log(`[warn] ctaType ${intent.ctaType} needs a ctaUrl (only CALL goes without one) - this entry would be skipped.`); | ||
| else console.log(`[preview] cta: ${intent.ctaType}${intent.ctaType !== 'CALL' ? ` -> ${intent.ctaUrl}` : ' (dials the listing)'}`); | ||
| } | ||
| if (topic === 'event') { | ||
| if (!intent.eventTitle || !intent.eventStart || !intent.eventEnd) console.log('[warn] event post is missing eventTitle/eventStart/eventEnd - this entry would be skipped (the API requires the full event block).'); | ||
| else console.log(`[preview] event: "${intent.eventTitle}" ${intent.eventStart} -> ${intent.eventEnd}`); | ||
| } | ||
| if (topic === 'offer') { | ||
| console.log(`[preview] offer: coupon=${intent.couponCode || '-'} redeem=${intent.redeemUrl || '-'} terms=${intent.terms ? 'yes' : '-'}`); | ||
| } | ||
| const url = gbpImageUrl(post); | ||
| if (url) { | ||
| if (!/^https?:\/\//i.test(url)) console.log(`[warn] post.image "${url}" is not an absolute public URL - GBP media takes public URLs only.`); | ||
| else console.log(`[preview] image url: ${url}`); | ||
| } else if (hasLocalMedia(post)) { | ||
| console.log("[info] local media is not publishable to GBP - set the post's image URL instead (post.image); this entry would publish text-only."); | ||
| } | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!accountId() || !locationId()) throw new Error('GBP_ACCOUNT_ID / GBP_LOCATION_ID is not set - cannot publish.'); | ||
| const parent = parentPath(); | ||
| const token = await ensureFreshToken(); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isGbp(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const summary = gbpSummary(post); | ||
| const intent = gbpIntent(post); | ||
| const topic = gbpTopic(post); | ||
| if (!summary) { console.log(`[warn] ${post.id}: due but no summary (caption) - skipping.`); continue; } | ||
| if (summary.length > SUMMARY_LIMIT) { console.log(`[warn] ${post.id}: summary is ${summary.length} chars (> ${SUMMARY_LIMIT}) - skipping.`); continue; } | ||
| if (!TOPICS.has(topic)) { console.log(`[warn] ${post.id}: unknown gbp topic "${topic}" (use standard|offer|event) - skipping.`); continue; } | ||
| if (intent.ctaType && !CTA_TYPES.has(intent.ctaType)) { console.log(`[warn] ${post.id}: ctaType "${intent.ctaType}" is not a GBP action type - skipping.`); continue; } | ||
| if (intent.ctaType && intent.ctaType !== 'CALL' && !intent.ctaUrl) { console.log(`[warn] ${post.id}: ctaType ${intent.ctaType} needs a ctaUrl (only CALL goes without one) - skipping.`); continue; } | ||
| if (topic === 'event' && !(intent.eventTitle && intent.eventStart && intent.eventEnd)) { | ||
| // The API hard-requires event.title + event.schedule for topicType EVENT. | ||
| console.log(`[warn] ${post.id}: event post is missing eventTitle/eventStart/eventEnd - skipping.`); | ||
| continue; | ||
| } | ||
| const url = gbpImageUrl(post); | ||
| if (!url && hasLocalMedia(post)) { | ||
| // GBP v4 localPosts takes media by PUBLIC sourceUrl only - there is no | ||
| // upload path, so a local-only render publishes text-only. | ||
| console.log(`[info] ${post.id}: local media is not publishable to GBP - set the post's image URL instead (post.image); publishing text-only.`); | ||
| } | ||
| if (args['dry-run']) { | ||
| console.log(`[dry] ${post.id}: would create a ${topic.toUpperCase()} local post on ${parent} (summary ${summary.length} chars${url ? `, photo ${url}` : ', text-only'}).`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: creating GBP ${topic.toUpperCase()} local post on ${parent}...`); | ||
| try { | ||
| const result = await api('POST', `${API}/${parent}/localPosts`, { body: buildLocalPost(post), token }); | ||
| const name = result?.name; | ||
| if (!name) throw new Error(`create-localPost returned no name: ${JSON.stringify(result).slice(0, 200)}`); | ||
| // Store the FULL resource name (accounts/.../localPosts/<id>) - verify and | ||
| // delete address the post by that name verbatim. | ||
| post.gbpPostId = String(name); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'gbp', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'gbp', action: 'publish', ok: true, id: String(name) }); | ||
| console.log(`[ok] ${post.id}: published on Google Business Profile (${name}).`); | ||
| if (result.searchUrl) console.log(`[ok] ${post.id}: live at ${result.searchUrl}`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'gbp', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'gbp', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: GBP publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} local post(s) published.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] GBP plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isGbp)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.gbpPostId ? ` gbp=${post.gbpPostId}` : ''}`); | ||
| } | ||
| } | ||
| // Read-only liveness: GET the stored local post by its full resource name. The | ||
| // API reports a lifecycle state (LIVE / PROCESSING / REJECTED) + searchUrl - the | ||
| // only permalink GBP exposes - so both are surfaced honestly per row. | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| let token = null; | ||
| try { token = await ensureFreshToken(); } catch { /* surfaced per row */ } | ||
| for (const post of (plan.posts || []).filter(isGbp)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.gbpPostId) continue; | ||
| if (!token) { | ||
| RUN.results.push({ postId: post.id, platform: 'gbp', action: 'verify', ok: true, live: false, state: 'unknown', permalink: null, id: post.gbpPostId }); | ||
| continue; | ||
| } | ||
| try { | ||
| const resp = await api('GET', `${API}/${post.gbpPostId}`, { token }); | ||
| const state = String(resp.state || '').toUpperCase(); | ||
| const live = state === 'LIVE'; | ||
| RUN.results.push({ postId: post.id, platform: 'gbp', action: 'verify', ok: true, live, state: (state || 'unknown').toLowerCase(), permalink: resp.searchUrl || null, id: post.gbpPostId }); | ||
| } catch (err) { | ||
| if (err.status === 404) { | ||
| RUN.results.push({ postId: post.id, platform: 'gbp', action: 'verify', ok: true, live: false, state: 'missing', permalink: null, id: post.gbpPostId }); | ||
| } else { | ||
| RUN.results.push({ postId: post.id, platform: 'gbp', action: 'verify', ok: false, errorCode: 'verify_failed', errorMessage: String(err.message || err).slice(0, 200), live: false, state: 'unknown', id: post.gbpPostId }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // Per-post metrics via the v4 reportInsights batch endpoint: search views + CTA | ||
| // clicks over the last 30 days. Best-effort - the endpoint 403s until the project | ||
| // is allowlisted, so failures degrade to an honest [warn] rather than fabricating. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| if (!accountId() || !locationId()) { console.log('[warn] insights: GBP_ACCOUNT_ID / GBP_LOCATION_ID are not set.'); return; } | ||
| let token = null; | ||
| try { token = await ensureFreshToken(); } catch (err) { console.log(`[warn] insights: ${err.message}`); return; } | ||
| const targets = (plan.posts || []).filter((p) => isGbp(p) && (!args.only || p.id === args.only) && p.gbpPostId); | ||
| if (!targets.length) return; | ||
| try { | ||
| const end = new Date(); | ||
| const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); | ||
| const data = await api('POST', `${API}/${parentPath()}/localPosts:reportInsights`, { | ||
| body: { | ||
| localPostNames: targets.map((p) => p.gbpPostId), | ||
| basicRequest: { | ||
| metricRequests: [{ metric: 'LOCAL_POST_VIEWS_SEARCH' }, { metric: 'LOCAL_POST_ACTIONS_CALL_TO_ACTION' }], | ||
| timeRange: { startTime: start.toISOString(), endTime: end.toISOString() }, | ||
| }, | ||
| }, | ||
| token, | ||
| }); | ||
| const rows = data.localPostMetrics || []; | ||
| for (const post of targets) { | ||
| const row = rows.find((r) => r.localPostName === post.gbpPostId); | ||
| const value = (metric) => { | ||
| const mv = (row?.metricValues || []).find((m) => m.metric === metric); | ||
| return Number(mv?.totalValue?.value ?? 0); | ||
| }; | ||
| RUN.results.push({ postId: post.id, platform: 'gbp', action: 'insights', ok: true, metrics: { views: value('LOCAL_POST_VIEWS_SEARCH'), ctaClicks: value('LOCAL_POST_ACTIONS_CALL_TO_ACTION') }, id: post.gbpPostId }); | ||
| } | ||
| } catch (err) { | ||
| if (err.status === 403) console.log('[warn] insights: Business Profile API access pending Google approval - no metrics available yet.'); | ||
| else console.log(`[warn] insights failed: ${String(err.message || err).slice(0, 200)}`); | ||
| } | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <resourceName> (accounts/.../locations/.../localPosts/<id>)'); process.exit(2); } | ||
| const token = await ensureFreshToken(); | ||
| await api('DELETE', `${API}/${args.id}`, { token }); | ||
| RUN.results.push({ platform: 'gbp', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted GBP local post ${args.id}.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('GBP_ACCESS_TOKEN') && !readEnv('GBP_REFRESH_TOKEN')) { | ||
| RUN.results.push({ platform: 'gbp', action: 'probe', ok: false, detail: 'not configured (GBP_REFRESH_TOKEN missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const token = await ensureFreshToken(); | ||
| const expiresAt = Number(readEnv('GBP_TOKEN_EXPIRES_AT') || 0) || null; | ||
| try { | ||
| const data = await api('GET', `${ACCOUNTS_API}/accounts`, { query: { pageSize: 1 }, token }); | ||
| const first = (data.accounts || [])[0]; | ||
| RUN.results.push({ platform: 'gbp', action: 'probe', ok: true, detail: `connected as ${first?.accountName || first?.name || '?'}`, tokenExpiresAt: expiresAt }); | ||
| } catch (err) { | ||
| if (err.status !== 403) throw err; | ||
| // The token minted/refreshed fine, so the credential IS proven - only the | ||
| // Business Profile API surface is still gated on Google's per-project approval. | ||
| RUN.results.push({ platform: 'gbp', action: 'probe', ok: true, detail: 'token valid - Business Profile API pending approval', tokenExpiresAt: expiresAt }); | ||
| } | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'gbp', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('gbp') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'gbp', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] gbp ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/gbp-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * ghost-social.mjs - direct Ghost blog + newsletter publishing via the Admin API. | ||
| * | ||
| * Sibling of scripts/telegram-social.mjs / x-social.mjs / linkedin-social.mjs: | ||
| * the same zero-dep, plan-driven, publish-straight-from-the-local-render pattern, | ||
| * but for pendpost's LONG-FORM lane - a Ghost entry is a full blog post (title + | ||
| * markdown body rendered to HTML) with an optional newsletter email, not a caption. | ||
| * | ||
| * AUTH - a custom integration Admin API key, no browser OAuth: | ||
| * GHOST_SITE_URL the Ghost site root (e.g. https://blog.example.com); | ||
| * any trailing slash is stripped. API base is | ||
| * <site>/ghost/api/admin. | ||
| * GHOST_ADMIN_API_KEY `<id>:<hexsecret>` from Settings -> Integrations -> | ||
| * Custom integrations -> Admin API key. | ||
| * The key itself is NEVER sent over the wire. Each run mints a short-lived HS256 | ||
| * JWT locally from it (header kid = key id, aud '/admin/', 5-minute exp) and sends | ||
| * `Authorization: Ghost <jwt>`. Every call pins `Accept-Version: v5.0`, so a Ghost | ||
| * major bump degrades loudly instead of silently. `refresh` is a no-op: there is | ||
| * no long-lived token to rotate - a fresh JWT is minted per request batch. | ||
| * | ||
| * PUBLISH IS TWO-STEP (draft -> published OR draft -> scheduled) ON PURPOSE. | ||
| * Ghost hangs the newsletter email on the draft->published/scheduled transition, | ||
| * and only when that transition carries `?newsletter=<slug>`. Creating the post | ||
| * as `published` directly would forfeit the email forever (there is no "email it | ||
| * later" API). VERIFIED against the v5 Admin API docs (docs.ghost.org, 2026-07-05): | ||
| * "the `newsletter` query parameter must be passed when publishing OR SCHEDULING | ||
| * the post", and a scheduled post's "email newsletters will be sent (if | ||
| * applicable)" when Ghost's own scheduler fires at published_at - so native | ||
| * scheduling does NOT forfeit the email. ghostEmail false/absent NEVER emails | ||
| * anyone, and a missing active newsletter downgrades honestly to a web-only | ||
| * publish with a warning. | ||
| * | ||
| * NATIVE SCHEDULING (owner decision 2026-07-05, reversing the earlier | ||
| * publish-at-due-time choice): `schedule` runs the same two steps ahead of the | ||
| * due time - create the draft, then flip it to status 'scheduled' + published_at | ||
| * (carrying `?newsletter=` on THAT transition, per the verified semantics above) - | ||
| * so Ghost's own scheduler publishes and emails with this machine off, the same | ||
| * survives-power-off model as yt-social.mjs. The plan stays the source of truth | ||
| * via a full reconcile story: the post KEEPS ITS ID across scheduled->published, | ||
| * so it is findable, verifiable (GET /posts/<id>/ reads 'scheduled' vs | ||
| * 'published'; past-due 'scheduled' reads 'scheduled-overdue') and cancellable | ||
| * (`delete --id`; lib/writes.mjs nativeHandoff drives that on unschedule/ | ||
| * reschedule/edit). The scheduler's ghost-release lane flips a scheduled-overdue | ||
| * post live (`release` - e.g. the site was down at the publish minute); the | ||
| * newsletter attached at schedule time rides along on that transition. | ||
| * Ghost refuses a published_at less than ~2 minutes out, so `schedule` keeps a | ||
| * small lead (MIN_SCHEDULE_LEAD_MS); an entry inside the window publishes AT due | ||
| * time via the past-due fallback, and `publish-due` stays for manual/late runs. | ||
| * | ||
| * LONG-FORM mapping (raw plan post fields): | ||
| * post.title REQUIRED - Ghost posts need one; a due entry without it | ||
| * warn-skips instead of publishing an untitled stub. | ||
| * post.body|caption markdown -> HTML via lib/markdown.mjs (the deliberate | ||
| * subset), sent through `?source=html` so Ghost converts it | ||
| * to its native Lexical format server-side. | ||
| * post.excerpt optional custom_excerpt (Ghost caps it at 300 chars - | ||
| * longer text is truncated with a log line, never rejected). | ||
| * post.canonicalUrl optional canonical_url (syndication-friendly). | ||
| * post.tags comma-separated names -> [{ name }]. | ||
| * post.image an absolute URL used verbatim as feature_image; ELSE the | ||
| * local render (post.path / plan.folder + post.file), when it | ||
| * is an image (jpg/png/webp/gif), is uploaded to | ||
| * /images/upload/ first and the returned URL is used. | ||
| * post.ghostEmail true -> also email the post to the active newsletter's | ||
| * subscribers on the publish transition (see above). | ||
| * | ||
| * Commands: | ||
| * auth | connect validate the key against GET /site/; writes nothing | ||
| * refresh no-op (the Admin API key is static; JWTs are minted per run) | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * schedule --plan <p> [--only <id>] [--dry-run] natively schedule (draft -> scheduled + published_at); publishes NOW when past due | ||
| * release --plan <p> [--only <id>] flip a scheduled-overdue post live (Ghost-scheduler backstop) | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due Ghost entry (manual/late path) | ||
| * status --plan <p> list Ghost plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (GET /posts/<id>/; 'scheduled' reads scheduled/scheduled-overdue) | ||
| * insights --plan <p> [--only <id>] no-op (Admin API exposes no analytics) | ||
| * probe read-only health probe (GET /site/) | ||
| * delete --id <postId> delete a Ghost post (cleanup; also cancels a scheduled one) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import crypto from 'node:crypto'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| import { mdToHtml } from '../lib/markdown.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // Ghost caps custom_excerpt at 300 chars (validation error beyond that). | ||
| const EXCERPT_LIMIT = 300; | ||
| // Ghost refuses a published_at less than ~2 minutes in the future | ||
| // (cannotScheduleAPostBeforeInMinutes). 5 minutes keeps clear of that floor plus | ||
| // the draft-create round-trip; an entry already inside the window is NOT | ||
| // scheduled early - it publishes AT due time via the past-due fallback. | ||
| const MIN_SCHEDULE_LEAD_MS = 5 * 60 * 1000; | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| const siteUrl = () => (readEnv('GHOST_SITE_URL') || '').trim().replace(/\/+$/, ''); | ||
| const apiBase = () => `${siteUrl()}/ghost/api/admin`; | ||
| // ---------- Admin API auth + helper ---------- | ||
| // Ghost Admin API auth: a short-lived HS256 JWT minted from the static key. | ||
| // kid = the key id (before the colon), secret = the hex half (after it). | ||
| function ghostJwt() { | ||
| const key = readEnv('GHOST_ADMIN_API_KEY') || ''; | ||
| const [id, secret] = key.split(':'); | ||
| if (!id || !secret) throw new Error('GHOST_ADMIN_API_KEY must look like "<id>:<hexsecret>" (Settings -> Integrations -> Custom).'); | ||
| const now = Math.floor(Date.now() / 1000); | ||
| const b64u = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); | ||
| const head = b64u({ alg: 'HS256', typ: 'JWT', kid: id }); | ||
| const body = b64u({ iat: now, exp: now + 300, aud: '/admin/' }); | ||
| const sig = crypto.createHmac('sha256', Buffer.from(secret, 'hex')).update(`${head}.${body}`).digest('base64url'); | ||
| return `${head}.${body}.${sig}`; | ||
| } | ||
| async function ghost(method, apiPath, { body, form } = {}) { | ||
| const url = `${apiBase()}${apiPath}`; | ||
| const headers = { Authorization: `Ghost ${ghostJwt()}`, 'Accept-Version': 'v5.0' }; | ||
| const init = { method, headers }; | ||
| if (form) init.body = form; | ||
| else if (body !== undefined) { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } | ||
| const res = await fetch(url, init); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok) { | ||
| const detail = (Array.isArray(data.errors) && data.errors[0]?.message) || data.raw || text || 'unknown'; | ||
| const err = new Error(`Ghost ${method} ${apiPath}: HTTP ${res.status} - ${detail}`); | ||
| err.status = res.status; | ||
| throw err; | ||
| } | ||
| return data; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'redditPostId', 'pinId', 'tiktokVideoId', 'mastodonStatusId', 'wordpressPostId', 'ghostPostId', 'nostrEventId', 'gbpPostId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| function resolveMediaPath(plan, post) { | ||
| const root = process.env.PENDPOST_ROOT ? path.resolve(process.env.PENDPOST_ROOT) : path.resolve(__dirname, '..'); | ||
| if (post.path) { | ||
| const abs = path.isAbsolute(post.path) ? post.path : path.resolve(root, post.path); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| if (post.file) { | ||
| const rel = path.join(plan.folder || '', post.file); | ||
| const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| return null; | ||
| } | ||
| const isGhost = (post) => (post.platforms || []).includes('ghost'); | ||
| const postTitle = (post) => (post.title || '').trim(); | ||
| const postHtml = (post) => mdToHtml(post.body || post.caption || ''); | ||
| // Ghost's feature_image must be an image; the upload endpoint enforces mimetype. | ||
| function imageMime(localPath) { | ||
| const ext = path.extname(localPath).toLowerCase(); | ||
| return { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' }[ext] || null; | ||
| } | ||
| // custom_excerpt is hard-capped at 300 chars by Ghost - truncate, never reject. | ||
| function excerptFor(post) { | ||
| const raw = typeof post.excerpt === 'string' ? post.excerpt.trim() : ''; | ||
| if (!raw) return null; | ||
| if (raw.length <= EXCERPT_LIMIT) return raw; | ||
| console.log(`[info] ${post.id}: excerpt is ${raw.length} chars - truncating to Ghost's ${EXCERPT_LIMIT}.`); | ||
| return raw.slice(0, EXCERPT_LIMIT); | ||
| } | ||
| function tagsFor(post) { | ||
| const raw = Array.isArray(post.tags) ? post.tags.join(',') : String(post.tags || ''); | ||
| const names = raw.split(',').map((s) => s.trim()).filter(Boolean); | ||
| return names.length ? names.map((name) => ({ name })) : null; | ||
| } | ||
| async function uploadFeatureImage(localPath) { | ||
| const form = new FormData(); | ||
| form.append('file', new Blob([fs.readFileSync(localPath)], { type: imageMime(localPath) }), path.basename(localPath)); | ||
| const data = await ghost('POST', '/images/upload/', { form }); | ||
| const url = data.images?.[0]?.url; | ||
| if (!url) throw new Error(`image upload returned no url: ${JSON.stringify(data).slice(0, 200)}`); | ||
| return url; | ||
| } | ||
| // Shared draft assembly for publish-due and schedule: feature image (absolute | ||
| // URL wins, else the local image render uploads), excerpt/canonical/tags - the | ||
| // step-1 payload both paths create before their status transition. | ||
| async function buildDraftPayload(plan, post, { title, html }) { | ||
| let featureImage = post.image || null; | ||
| if (!featureImage) { | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (mediaPath && imageMime(mediaPath)) { | ||
| featureImage = await uploadFeatureImage(mediaPath); | ||
| console.log(`[ok] ${post.id}: uploaded ${path.basename(mediaPath)} -> ${featureImage}`); | ||
| } else if (post.path || post.file) { | ||
| console.log(`[info] ${post.id}: no usable local image (${post.path || post.file}) - publishing without a feature image.`); | ||
| } | ||
| } | ||
| const payload = { title, html, status: 'draft' }; | ||
| const excerpt = excerptFor(post); | ||
| if (excerpt) payload.custom_excerpt = excerpt; | ||
| if (post.canonicalUrl) payload.canonical_url = post.canonicalUrl; | ||
| const tags = tagsFor(post); | ||
| if (tags) payload.tags = tags; | ||
| if (featureImage) payload.feature_image = featureImage; | ||
| return payload; | ||
| } | ||
| // The `?newsletter=<slug>` for a publish/schedule transition: the first ACTIVE | ||
| // newsletter when the entry opts in (ghostEmail === true), else null (web-only). | ||
| async function newsletterSlugFor(post) { | ||
| if (post.ghostEmail !== true) return null; | ||
| const newsletters = (await ghost('GET', '/newsletters/')).newsletters || []; | ||
| const active = newsletters.find((n) => n.status === 'active'); | ||
| if (!active) console.log(`[warn] ${post.id}: ghostEmail requested but no ACTIVE newsletter exists - publishing WITHOUT email.`); | ||
| return active ? active.slug : null; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth() { | ||
| if (!readEnv('GHOST_SITE_URL')) { console.error('[err] GHOST_SITE_URL missing in .env (the Ghost site root, e.g. https://blog.example.com).'); process.exit(2); } | ||
| if (!readEnv('GHOST_ADMIN_API_KEY')) { console.error('[err] GHOST_ADMIN_API_KEY missing in .env (Settings -> Integrations -> Custom integrations -> Admin API key).'); process.exit(2); } | ||
| let site; | ||
| try { | ||
| site = (await ghost('GET', '/site/')).site || {}; | ||
| } catch (err) { | ||
| console.error(`[err] Ghost auth failed - ${err.message}`); | ||
| process.exit(2); | ||
| } | ||
| console.log(`[ok] connected to "${site.title}" (Ghost ${site.version}) at ${siteUrl()}`); | ||
| RUN.results.push({ platform: 'ghost', action: 'auth', ok: true, detail: `"${site.title}" (Ghost ${site.version})` }); | ||
| } | ||
| async function cmdRefresh() { | ||
| console.log('[info] Ghost Admin API keys are static - a fresh JWT is minted per run (no refresh).'); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const site = (await ghost('GET', '/site/')).site || {}; | ||
| console.log(`[ok] Site reachable - "${site.title}" (Ghost ${site.version}).`); | ||
| } catch (err) { | ||
| console.log(`[warn] GET /site/ failed (${err.message}). Continuing to content preview.`); | ||
| } | ||
| const targets = (plan.posts || []).filter((p) => isGhost(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No Ghost entries match.'); return; } | ||
| for (const post of targets) { | ||
| console.log(`\n----- ${post.id} -----`); | ||
| const title = postTitle(post); | ||
| if (!title) console.log('[warn] title missing - REQUIRED for Ghost, this entry will be skipped at publish.'); | ||
| else console.log(`[preview] title: ${title}`); | ||
| const html = postHtml(post); | ||
| if (!html) console.log('[warn] body/caption empty - nothing to render, this entry will be skipped at publish.'); | ||
| else console.log(`[preview] html: ${html.length} chars (markdown from ${post.body ? 'body' : 'caption'})`); | ||
| if (typeof post.excerpt === 'string' && post.excerpt.trim()) { | ||
| const len = post.excerpt.trim().length; | ||
| console.log(`[preview] excerpt: ${len}/${EXCERPT_LIMIT} chars${len > EXCERPT_LIMIT ? ` - will be TRUNCATED to ${EXCERPT_LIMIT}` : ''}`); | ||
| } | ||
| if (post.canonicalUrl) console.log(`[preview] canonical: ${post.canonicalUrl}`); | ||
| const tags = tagsFor(post); | ||
| if (tags) console.log(`[preview] tags: ${tags.map((t) => t.name).join(', ')}`); | ||
| if (post.image) { | ||
| console.log(`[preview] image: ${post.image} (used verbatim as feature_image)`); | ||
| } else { | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (mediaPath && imageMime(mediaPath)) console.log(`[preview] image: ${path.basename(mediaPath)} (${(fs.statSync(mediaPath).size / 1e6).toFixed(1)} MB) - will upload as feature_image`); | ||
| else if (mediaPath) console.log(`[info] local media ${path.basename(mediaPath)} is not an image - feature_image will be omitted.`); | ||
| else if (post.path || post.file) console.log(`[warn] media not found (${post.path || post.file}) - feature_image will be omitted.`); | ||
| } | ||
| console.log(`[preview] email: ${post.ghostEmail === true ? 'newsletter email WILL be sent on publish' : 'web-only (no newsletter email)'}`); | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!siteUrl()) throw new Error('GHOST_SITE_URL is not set - cannot publish.'); | ||
| if (!readEnv('GHOST_ADMIN_API_KEY')) throw new Error('GHOST_ADMIN_API_KEY is not set - cannot publish.'); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isGhost(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const title = postTitle(post); | ||
| if (!title) { console.log(`[warn] ${post.id}: due but no title - Ghost requires one - skipping.`); continue; } | ||
| const html = postHtml(post); | ||
| if (!html) { console.log(`[warn] ${post.id}: due but no body/caption to render - skipping.`); continue; } | ||
| const wantsEmail = post.ghostEmail === true; | ||
| if (args['dry-run']) { | ||
| console.log(`[dry] ${post.id}: would create draft "${title}" (${html.length} chars html) then publish${wantsEmail ? ' + newsletter email' : ' (web-only)'}.`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: publishing "${title}" to Ghost...`); | ||
| try { | ||
| // Step 1: create the DRAFT. Publishing directly would skip the | ||
| // draft->published transition Ghost hangs the newsletter email on. | ||
| const payload = await buildDraftPayload(plan, post, { title, html }); | ||
| const draft = (await ghost('POST', '/posts/?source=html', { body: { posts: [payload] } })).posts?.[0]; | ||
| if (!draft?.id) throw new Error('draft create returned no post id'); | ||
| // Step 2: flip draft -> published; ?newsletter=<slug> on THIS transition is | ||
| // what makes Ghost email subscribers. No active newsletter -> web-only. | ||
| const newsletterSlug = wantsEmail ? await newsletterSlugFor(post) : null; | ||
| const publishPath = `/posts/${draft.id}/?source=html${newsletterSlug ? `&newsletter=${encodeURIComponent(newsletterSlug)}` : ''}`; | ||
| const liv = (await ghost('PUT', publishPath, { body: { posts: [{ status: 'published', updated_at: draft.updated_at }] } })).posts?.[0]; | ||
| post.ghostPostId = String(draft.id); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'ghost', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'publish', ok: true, id: String(draft.id) }); | ||
| console.log(`[ok] ${post.id}: published on Ghost - ${liv?.url || '(no url returned)'}${newsletterSlug ? ` (newsletter "${newsletterSlug}" emailed)` : ' (web-only, no email)'}`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'ghost', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Ghost publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} post(s) published.`); | ||
| } | ||
| // Native scheduling: the same two steps as publish-due, run AHEAD of the due | ||
| // time - create the draft, then flip it draft->SCHEDULED with published_at, | ||
| // carrying `?newsletter=` on that transition (verified v5 semantics: the email | ||
| // sends when Ghost's own scheduler publishes at published_at). The post id is | ||
| // minted at schedule time and survives scheduled->published, so verify/release/ | ||
| // delete all address the same object. A past-due entry publishes immediately | ||
| // instead; an entry inside Ghost's ~2-minute minimum lead waits for that fallback. | ||
| async function cmdSchedule(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!siteUrl()) throw new Error('GHOST_SITE_URL is not set - cannot schedule.'); | ||
| if (!readEnv('GHOST_ADMIN_API_KEY')) throw new Error('GHOST_ADMIN_API_KEY is not set - cannot schedule.'); | ||
| const now = Date.now(); | ||
| let scheduled = 0; | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isGhost(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| if (post.ghostPostId) { console.log(`[skip] ${post.id}: already has ghostPostId ${post.ghostPostId}.`); continue; } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs)) { console.log(`[warn] ${post.id}: unparseable scheduledAt "${post.scheduledAt}" - skipping.`); continue; } | ||
| const title = postTitle(post); | ||
| if (!title) { console.log(`[warn] ${post.id}: no title - Ghost requires one - skipping.`); continue; } | ||
| const html = postHtml(post); | ||
| if (!html) { console.log(`[warn] ${post.id}: no body/caption to render - skipping.`); continue; } | ||
| const wantsEmail = post.ghostEmail === true; | ||
| const pastDue = dueMs <= now; | ||
| if (!pastDue && dueMs <= now + MIN_SCHEDULE_LEAD_MS) { | ||
| console.log(`[skip] ${post.id}: due in <${Math.ceil(MIN_SCHEDULE_LEAD_MS / 60000)}m - inside Ghost's minimum scheduling lead; it publishes AT due time instead.`); | ||
| continue; | ||
| } | ||
| if (args['dry-run']) { | ||
| console.log(pastDue | ||
| ? `[dry] ${post.id}: past due - would create draft "${title}" then publish immediately${wantsEmail ? ' + newsletter email' : ' (web-only)'}.` | ||
| : `[dry] ${post.id}: would create draft "${title}" then natively schedule it for ${new Date(dueMs).toISOString()}${wantsEmail ? ' + newsletter email at publish' : ' (web-only)'}.`); | ||
| continue; | ||
| } | ||
| console.log(pastDue | ||
| ? `[info] ${post.id}: past due - publishing "${title}" to Ghost immediately...` | ||
| : `[info] ${post.id}: natively scheduling "${title}" on Ghost for ${new Date(dueMs).toISOString()}...`); | ||
| try { | ||
| // Step 1: the DRAFT (the transition Ghost hangs the email on needs one). | ||
| const payload = await buildDraftPayload(plan, post, { title, html }); | ||
| const draft = (await ghost('POST', '/posts/?source=html', { body: { posts: [payload] } })).posts?.[0]; | ||
| if (!draft?.id) throw new Error('draft create returned no post id'); | ||
| // Step 2: draft -> published (past due) or draft -> scheduled + published_at. | ||
| // ?newsletter= on THIS transition binds the email either way. | ||
| const newsletterSlug = wantsEmail ? await newsletterSlugFor(post) : null; | ||
| const targetStatus = pastDue ? 'published' : 'scheduled'; | ||
| const body = { status: targetStatus, updated_at: draft.updated_at }; | ||
| if (!pastDue) body.published_at = new Date(dueMs).toISOString(); | ||
| const flipPath = `/posts/${draft.id}/?source=html${newsletterSlug ? `&newsletter=${encodeURIComponent(newsletterSlug)}` : ''}`; | ||
| const liv = (await ghost('PUT', flipPath, { body: { posts: [body] } })).posts?.[0]; | ||
| post.ghostPostId = String(draft.id); | ||
| if (pastDue) { | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'ghost', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'publish', ok: true, id: String(draft.id) }); | ||
| console.log(`[ok] ${post.id}: published on Ghost - ${liv?.url || '(no url returned)'}${newsletterSlug ? ` (newsletter "${newsletterSlug}" emailed)` : ' (web-only, no email)'}`); | ||
| published += 1; | ||
| } else { | ||
| post.status = 'scheduled'; | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'ghost', action: 'schedule-native', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'schedule-native', ok: true, id: String(draft.id) }); | ||
| console.log(`[ok] ${post.id}: natively scheduled (post ${draft.id}, publishes ${liv?.published_at || new Date(dueMs).toISOString()}${newsletterSlug ? `, newsletter "${newsletterSlug}" emails at publish` : ', web-only'}).`); | ||
| scheduled += 1; | ||
| } | ||
| } catch (err) { | ||
| const action = pastDue ? 'publish' : 'schedule-native'; | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'ghost', action, ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action, ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Ghost ${pastDue ? 'publish' : 'schedule'} failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log(`[done] schedule complete - ${scheduled} natively scheduled, ${published} published (past due).`); | ||
| } | ||
| // Recover a natively-scheduled post Ghost left 'scheduled' past its published_at | ||
| // (verify read-back 'scheduled-overdue' - e.g. the site was down/restarting at | ||
| // the publish minute): flip it live with a one-field status update - NEVER a | ||
| // re-create. The newsletter attached at schedule time rides along on this | ||
| // scheduled->published transition. Idempotent and safe on a bare CLI run: an | ||
| // already-published post is a no-op, a still-future schedule is left untouched. | ||
| async function cmdRelease(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const now = Date.now(); | ||
| for (const post of (plan.posts || []).filter(isGhost)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.ghostPostId) continue; | ||
| try { | ||
| const gp = (await ghost('GET', `/posts/${post.ghostPostId}/`)).posts?.[0]; | ||
| if (!gp) throw new Error(`post ${post.ghostPostId} lookup returned no post`); | ||
| if (gp.status === 'published') { | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'release', ok: true, id: post.ghostPostId, live: true, state: 'published', permalink: gp.url || null }); | ||
| console.log(`[ok] ${post.id}: already published - no action.`); | ||
| continue; | ||
| } | ||
| if (gp.status !== 'scheduled') { | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'release', ok: false, errorCode: 'invalid_input', errorMessage: `post ${post.ghostPostId} is '${gp.status}' - release only flips a 'scheduled' post` }); | ||
| console.log(`[skip] ${post.id}: post is '${gp.status}' - nothing to release.`); | ||
| continue; | ||
| } | ||
| const fireMs = Date.parse(gp.published_at || ''); | ||
| if (Number.isFinite(fireMs) && fireMs > now) { | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'release', ok: false, errorCode: 'invalid_input', errorMessage: `still natively scheduled for ${gp.published_at} - not releasing early` }); | ||
| console.log(`[skip] ${post.id}: still scheduled (${gp.published_at}) - not releasing early.`); | ||
| continue; | ||
| } | ||
| const liv = (await ghost('PUT', `/posts/${post.ghostPostId}/`, { body: { posts: [{ status: 'published', updated_at: gp.updated_at }] } })).posts?.[0]; | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'ghost', action: 'release', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'release', ok: true, id: post.ghostPostId, live: true, state: 'published', permalink: liv?.url || null }); | ||
| console.log(`[ok] ${post.id}: released - post ${post.ghostPostId} is now published.`); | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'ghost', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: release failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log('[done] release complete.'); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] Ghost plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isGhost)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.ghostPostId ? ` ghost=${post.ghostPostId}` : ''}`); | ||
| } | ||
| } | ||
| // Real read-back: the Admin API can GET a post by id, so liveness is honest - | ||
| // 'published' is live, a surviving draft is not, a 404 means it was deleted. A | ||
| // natively-scheduled post reads 'scheduled' while its published_at is ahead and | ||
| // 'scheduled-overdue' once Ghost's scheduler has missed it (which owes the | ||
| // scheduler's ghost-release lane, mirroring yt's private-overdue). | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| const now = Date.now(); | ||
| for (const post of (plan.posts || []).filter(isGhost)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.ghostPostId) continue; | ||
| try { | ||
| const gp = (await ghost('GET', `/posts/${post.ghostPostId}/`)).posts?.[0] || {}; | ||
| const live = gp.status === 'published'; | ||
| let state = live ? 'published' : (gp.status || 'unknown'); | ||
| if (gp.status === 'scheduled') { | ||
| const fireMs = Date.parse(gp.published_at || ''); | ||
| state = Number.isFinite(fireMs) && fireMs <= now ? 'scheduled-overdue' : 'scheduled'; | ||
| } | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'verify', ok: true, live, state, permalink: live ? (gp.url || null) : null, id: post.ghostPostId }); | ||
| } catch (err) { | ||
| if (err.status === 404) { | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'verify', ok: true, live: false, state: 'missing', permalink: null, id: post.ghostPostId }); | ||
| } else { | ||
| RUN.results.push({ postId: post.id, platform: 'ghost', action: 'verify', ok: false, live: null, state: 'unknown', errorMessage: String(err.message || err).slice(0, 200), id: post.ghostPostId }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // The Admin API exposes no per-post analytics (email opens etc. live in Ghost's | ||
| // own dashboard, members analytics is a different surface) - honest no-op. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| void plan; | ||
| console.log('[info] Ghost Admin API exposes no per-post analytics here - insights is a no-op.'); | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <postId>'); process.exit(2); } | ||
| await ghost('DELETE', `/posts/${args.id}/`); | ||
| RUN.results.push({ platform: 'ghost', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted Ghost post ${args.id}.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('GHOST_ADMIN_API_KEY')) { | ||
| RUN.results.push({ platform: 'ghost', action: 'probe', ok: false, detail: 'not configured (GHOST_ADMIN_API_KEY missing)' }); | ||
| return; | ||
| } | ||
| if (!siteUrl()) { | ||
| RUN.results.push({ platform: 'ghost', action: 'probe', ok: false, detail: 'not configured (GHOST_SITE_URL missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const site = (await ghost('GET', '/site/')).site || {}; | ||
| RUN.results.push({ platform: 'ghost', action: 'probe', ok: true, detail: `connected to "${site.title}"`, tokenExpiresAt: null }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'ghost', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| schedule: cmdSchedule, | ||
| release: cmdRelease, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('ghost') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'ghost', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] ghost ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/ghost-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'schedule', 'release', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * mastodon-social.mjs - direct Mastodon status publishing via the instance REST API. | ||
| * | ||
| * Sibling of scripts/telegram-social.mjs / discord-social.mjs: the same zero-dep, | ||
| * plan-driven, publish-straight-from-the-local-render pattern, with Mastodon's | ||
| * (pleasantly boring) static-token auth + posting model. | ||
| * | ||
| * NATIVE SCHEDULING (owner decision 2026-07-05, reversing the earlier | ||
| * publish-at-due-time choice): `schedule` hands an approved entry to the INSTANCE | ||
| * ahead of its due time (POST /statuses with scheduled_at), so it fires on time | ||
| * even when this machine is off - the same survives-power-off model as | ||
| * yt-social.mjs (private + publishAt). The plan stays the source of truth via a | ||
| * full reconcile story, not by refusing the remote queue: | ||
| * - the scheduled object is REMEMBERED (post.mastodonScheduledId) and | ||
| * CANCELLABLE: `unschedule --id` deletes it (DELETE /scheduled_statuses/:id); | ||
| * lib/writes.mjs nativeHandoff drives that on unschedule/reschedule/edit. | ||
| * - the fired status has a NEW id (Mastodon mints one at fire time; the | ||
| * scheduled-status id dies), so `resolve` closes the loop after the due | ||
| * minute: it reads the queue, finds the fired status on the account timeline | ||
| * (text match) and records post.mastodonStatusId + posted. If the instance | ||
| * never fired (queue entry still parked well past due), resolve cancels it | ||
| * and publishes immediately. | ||
| * Constraints: the instance rejects a scheduled_at less than ~5 minutes out | ||
| * (MIN_SCHEDULE_LEAD_MS guards it - an entry inside that window just publishes AT | ||
| * due time via the past-due fallback), and media must upload at schedule time. | ||
| * `publish-due` is kept for manual/late runs - `schedule` falls back to the same | ||
| * immediate publish when an entry is already past due. | ||
| * | ||
| * AUTH - a single static access token, no ceremony: | ||
| * MASTODON_INSTANCE_URL the home instance (e.g. https://mastodon.social); | ||
| * trailing slashes are stripped on read. | ||
| * MASTODON_ACCESS_TOKEN an app token from the instance's own | ||
| * Preferences -> Development -> New application | ||
| * (scopes: read write:statuses write:media). | ||
| * `connect`/`auth` here is a validation handshake (verify_credentials): there is | ||
| * no token to mint, so it only confirms the static creds actually authenticate, | ||
| * then persists MASTODON_HANDLE (the acct) to .env for display. | ||
| * | ||
| * Media uploads stream straight from the local render folder (post.path / | ||
| * plan.folder + post.file) as a v2/media multipart upload - no hosting layer. | ||
| * A 202 from v2/media means the instance is still transcoding: we poll | ||
| * GET /api/v1/media/:id until it settles (200 + url) before creating the status. | ||
| * Text comes from post.mastodonCaption (falls back to post.caption), the additive | ||
| * per-platform override pattern x uses for xCaption. Statuses are capped at the | ||
| * default instance limit of 500 chars (instances can raise it; we enforce the | ||
| * conservative default). The permalink comes back on the status object (`url`). | ||
| * | ||
| * Commands: | ||
| * auth | connect validate the static creds (verify_credentials); persists MASTODON_HANDLE | ||
| * refresh no-op (access tokens are static) - kept for sibling parity | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * schedule --plan <p> [--only <id>] [--dry-run] natively schedule (scheduled_at); publishes NOW when past due | ||
| * resolve --plan <p> [--only <id>] post-due reconcile: record the fired status id (or republish a parked queue entry) | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due Mastodon entry (manual/late path) | ||
| * status --plan <p> list Mastodon plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (GET the status / the scheduled queue entry) | ||
| * insights --plan <p> [--only <id>] real metrics (favourites/reblogs/replies) | ||
| * probe read-only health probe (verify_credentials) | ||
| * delete --id <statusId> delete a LIVE status (cleanup) | ||
| * unschedule --id <scheduledId> cancel a natively-scheduled queue entry | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // Mastodon caps a status at 500 chars by default (per-instance configurable; we | ||
| // enforce the conservative default so a plan ports across instances). | ||
| const TEXT_LIMIT = 500; | ||
| // Async media processing: poll every 2s, give up after ~60s. | ||
| const MEDIA_POLL_MS = 2000; | ||
| const MEDIA_POLL_CAP_MS = 60 * 1000; | ||
| // The instance rejects a scheduled_at less than ~5 minutes in the future (422). | ||
| // 7 minutes keeps clear of that floor plus the media-upload/poll time; an entry | ||
| // already inside the window is NOT scheduled early - it publishes AT due time. | ||
| const MIN_SCHEDULE_LEAD_MS = 7 * 60 * 1000; | ||
| // resolve: a queue entry still parked this long PAST due means the instance is | ||
| // not going to fire it - cancel it and publish immediately instead. | ||
| const RESOLVE_REPUBLISH_GRACE_MS = 10 * 60 * 1000; | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| function writeEnv(vars) { | ||
| let raw = readEnvRaw(); | ||
| for (const [k, v] of Object.entries(vars)) { | ||
| if (v == null) continue; | ||
| // function replacer: token values may contain '$' which is special in a string replacement. | ||
| if (new RegExp(`^${k}=`, 'm').test(raw)) { | ||
| raw = raw.replace(new RegExp(`^${k}=.*$`, 'm'), () => `${k}=${v}`); | ||
| } else { | ||
| raw += `${raw.endsWith('\n') || raw === '' ? '' : '\n'}${k}=${v}\n`; | ||
| } | ||
| } | ||
| // Atomic + 0600: a crash mid-write must never truncate the secret-bearing .env. | ||
| const tmp = `${ENV_PATH}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, raw, { mode: 0o600 }); | ||
| fs.renameSync(tmp, ENV_PATH); | ||
| } | ||
| // Trailing slashes stripped so `${instance}/api/...` never double-slashes. | ||
| const instanceUrl = () => (readEnv('MASTODON_INSTANCE_URL') || '').trim().replace(/\/+$/, ''); | ||
| const accessToken = () => readEnv('MASTODON_ACCESS_TOKEN'); | ||
| // ---------- Mastodon API helper ---------- | ||
| // Returns { status, data } (not bare data like the telegram helper) because the | ||
| // v2/media handshake is status-driven: 202 = still processing, and the poll loop | ||
| // needs to distinguish 200 (done) from 206 (partial/processing) without throwing. | ||
| async function masto(method, apiPath, { body, form, headers } = {}) { | ||
| const url = `${instanceUrl()}${apiPath}`; | ||
| const init = { method, headers: { Authorization: `Bearer ${accessToken()}`, ...(headers || {}) } }; | ||
| if (form) init.body = form; | ||
| else if (body) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } | ||
| const res = await fetch(url, init); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok) { | ||
| throw new Error(`Mastodon ${method} ${apiPath}: HTTP ${res.status} - ${data.error || data.raw || text || 'unknown'}`); | ||
| } | ||
| return { status: res.status, data }; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'redditPostId', 'pinId', 'tiktokVideoId', 'mastodonStatusId', 'mastodonScheduledId', 'wordpressPostId', 'ghostPostId', 'nostrEventId', 'gbpPostId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| function resolveMediaPath(plan, post) { | ||
| const root = process.env.PENDPOST_ROOT ? path.resolve(process.env.PENDPOST_ROOT) : path.resolve(__dirname, '..'); | ||
| if (post.path) { | ||
| const abs = path.isAbsolute(post.path) ? post.path : path.resolve(root, post.path); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| if (post.file) { | ||
| const rel = path.join(plan.folder || '', post.file); | ||
| const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| return null; | ||
| } | ||
| const isMastodon = (post) => (post.platforms || []).includes('mastodon'); | ||
| const isTextPost = (post) => post.type === 'text'; | ||
| const statusText = (post) => (post.mastodonCaption || post.caption || '').trim(); | ||
| // Upload local media via v2/media. A 202 means the instance is still processing | ||
| // (video transcode): poll v1/media/:id until it settles at 200 with a url. | ||
| async function uploadMedia(mediaPath, post) { | ||
| const form = new FormData(); | ||
| form.append('file', new Blob([fs.readFileSync(mediaPath)]), path.basename(mediaPath)); | ||
| // Best-effort alt text: post.title is the only short human descriptor in the plan schema. | ||
| const alt = (post.title || '').trim(); | ||
| if (alt) form.append('description', alt); | ||
| const up = await masto('POST', '/api/v2/media', { form }); | ||
| const mediaId = up.data?.id; | ||
| if (!mediaId) throw new Error(`media upload returned no id: ${JSON.stringify(up.data).slice(0, 200)}`); | ||
| if (up.status === 202) { | ||
| const deadline = Date.now() + MEDIA_POLL_CAP_MS; | ||
| for (;;) { | ||
| await new Promise((r) => setTimeout(r, MEDIA_POLL_MS)); | ||
| const poll = await masto('GET', `/api/v1/media/${encodeURIComponent(mediaId)}`); | ||
| if (poll.status === 200 && poll.data?.url) break; | ||
| if (Date.now() > deadline) throw new Error(`media ${mediaId} still processing after ${MEDIA_POLL_CAP_MS / 1000}s`); | ||
| } | ||
| } | ||
| return String(mediaId); | ||
| } | ||
| // One immediate publish - shared by publish-due and schedule's past-due | ||
| // fallback: upload media when present, create the status, mint the fields. | ||
| // The caller saves the plan and records the attempt/envelope rows. | ||
| async function publishNow(plan, post, { text, mediaPath, now }) { | ||
| let mediaId = null; | ||
| if (mediaPath) mediaId = await uploadMedia(mediaPath, post); | ||
| // Idempotency-Key: a retried tick after a lost response must not double-post. | ||
| const { data: resp } = await masto('POST', '/api/v1/statuses', { | ||
| body: { status: text, ...(mediaId ? { media_ids: [mediaId] } : {}), visibility: 'public' }, | ||
| headers: { 'Idempotency-Key': `${plan.campaign || 'plan'}:${post.id}` }, | ||
| }); | ||
| const statusId = resp?.id; | ||
| if (!statusId) throw new Error(`status create returned no id: ${JSON.stringify(resp).slice(0, 200)}`); | ||
| post.mastodonStatusId = String(statusId); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| return resp; | ||
| } | ||
| // The instance wraps a status in HTML (<p>, <br>, anchors); strip it down to | ||
| // comparable plain text. resolve's timeline match is text-based BECAUSE the | ||
| // scheduled-status id is not the fired status id (Mastodon mints a new one). | ||
| function statusPlainText(html) { | ||
| return String(html || '') | ||
| .replace(/<br\s*\/?>/gi, '\n') | ||
| .replace(/<\/p>\s*<p[^>]*>/gi, '\n\n') | ||
| .replace(/<[^>]+>/g, '') | ||
| .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'") | ||
| .replace(/&/g, '&') | ||
| .replace(/\s+/g, ' ') | ||
| .trim(); | ||
| } | ||
| const matchKey = (s) => statusPlainText(s).slice(0, 80); | ||
| // ---------- commands ---------- | ||
| async function cmdAuth() { | ||
| if (!instanceUrl()) { console.error('[err] MASTODON_INSTANCE_URL missing in .env (your home instance, e.g. https://mastodon.social).'); process.exit(2); } | ||
| if (!accessToken()) { console.error('[err] MASTODON_ACCESS_TOKEN missing in .env (Preferences -> Development -> New application; scopes read write:statuses write:media).'); process.exit(2); } | ||
| const { data: me } = await masto('GET', '/api/v1/accounts/verify_credentials'); | ||
| writeEnv({ MASTODON_HANDLE: me.acct }); | ||
| console.log(`[ok] authenticated as @${me.acct} on ${instanceUrl()}`); | ||
| RUN.results.push({ platform: 'mastodon', action: 'auth', ok: true, detail: `@${me.acct} on ${instanceUrl()}` }); | ||
| } | ||
| async function cmdRefresh() { | ||
| console.log('[info] Mastodon access tokens are static (no refresh).'); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const { data: me } = await masto('GET', '/api/v1/accounts/verify_credentials'); | ||
| console.log(`[ok] Token valid - authenticated as @${me.acct}.`); | ||
| } catch (err) { | ||
| console.log(`[warn] verify_credentials failed (${err.message}). Continuing to caption preview.`); | ||
| } | ||
| const targets = (plan.posts || []).filter((p) => isMastodon(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No Mastodon entries match.'); return; } | ||
| for (const post of targets) { | ||
| const text = statusText(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] type: ${post.type}`); | ||
| console.log(`[preview] text (${text.length}/${TEXT_LIMIT}${text.length > TEXT_LIMIT ? ' - OVER LIMIT' : ''}):`); | ||
| console.log(text); | ||
| if (!isTextPost(post)) { | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) console.log(`[warn] media not found (${post.path || post.file}).`); | ||
| else console.log(`[preview] media: ${path.basename(mediaPath)} (${(fs.statSync(mediaPath).size / 1e6).toFixed(1)} MB)`); | ||
| } | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!instanceUrl() || !accessToken()) throw new Error('MASTODON_INSTANCE_URL / MASTODON_ACCESS_TOKEN not set - cannot publish.'); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isMastodon(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const text = statusText(post); | ||
| const textPost = isTextPost(post); | ||
| if (textPost && !text) { console.log(`[warn] ${post.id}: due but no text (mastodonCaption/caption) - skipping.`); continue; } | ||
| if (text.length > TEXT_LIMIT) { console.log(`[warn] ${post.id}: text is ${text.length} chars (> ${TEXT_LIMIT}) - skipping.`); continue; } | ||
| let mediaPath = null; | ||
| if (!textPost) { | ||
| mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) { console.log(`[warn] ${post.id}: due but local media not found (${post.path || post.file}) - skipping.`); continue; } | ||
| } | ||
| if (args['dry-run']) { | ||
| console.log(textPost ? `[dry] ${post.id}: would post a text status (${text.length} chars).` : `[dry] ${post.id}: would upload ${path.basename(mediaPath)} + status.`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: publishing ${textPost ? 'text status' : 'media'} to Mastodon...`); | ||
| try { | ||
| const resp = await publishNow(plan, post, { text, mediaPath, now }); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'publish', ok: true, id: post.mastodonStatusId }); | ||
| console.log(`[ok] ${post.id}: published on Mastodon (status ${post.mastodonStatusId}${resp.url ? ` - ${resp.url}` : ''}).`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Mastodon publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} status(es) published.`); | ||
| } | ||
| // Native scheduling: hand every approved future entry to the instance (POST | ||
| // /statuses with scheduled_at) so it fires with this machine off. Media uploads | ||
| // NOW (schedule time). A past-due entry publishes immediately instead - the | ||
| // native window is gone and stranding it would regress the old publish-due | ||
| // behavior; an entry inside the ~5-minute minimum lead just waits for that | ||
| // fallback (never posts early). | ||
| async function cmdSchedule(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!instanceUrl() || !accessToken()) throw new Error('MASTODON_INSTANCE_URL / MASTODON_ACCESS_TOKEN not set - cannot schedule.'); | ||
| const now = Date.now(); | ||
| let scheduled = 0; | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isMastodon(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| if (post.mastodonStatusId || post.mastodonScheduledId) { | ||
| console.log(`[skip] ${post.id}: already ${post.mastodonStatusId ? 'published' : 'natively scheduled'}.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs)) { console.log(`[warn] ${post.id}: unparseable scheduledAt "${post.scheduledAt}" - skipping.`); continue; } | ||
| const text = statusText(post); | ||
| const textPost = isTextPost(post); | ||
| if (textPost && !text) { console.log(`[warn] ${post.id}: no text (mastodonCaption/caption) - skipping.`); continue; } | ||
| if (text.length > TEXT_LIMIT) { console.log(`[warn] ${post.id}: text is ${text.length} chars (> ${TEXT_LIMIT}) - skipping.`); continue; } | ||
| let mediaPath = null; | ||
| if (!textPost) { | ||
| mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) { console.log(`[warn] ${post.id}: local media not found (${post.path || post.file}) - skipping.`); continue; } | ||
| } | ||
| if (dueMs <= now) { | ||
| if (args['dry-run']) { console.log(`[dry] ${post.id}: past due - would publish immediately.`); continue; } | ||
| console.log(`[info] ${post.id}: past due - publishing immediately (native window gone)...`); | ||
| try { | ||
| const resp = await publishNow(plan, post, { text, mediaPath, now }); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'publish', ok: true, id: post.mastodonStatusId }); | ||
| console.log(`[ok] ${post.id}: published on Mastodon (status ${post.mastodonStatusId}${resp.url ? ` - ${resp.url}` : ''}).`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Mastodon publish failed - ${err.message}`); | ||
| } | ||
| continue; | ||
| } | ||
| if (dueMs <= now + MIN_SCHEDULE_LEAD_MS) { | ||
| console.log(`[skip] ${post.id}: due in <${Math.ceil(MIN_SCHEDULE_LEAD_MS / 60000)}m - inside the instance's minimum scheduling lead; it publishes AT due time instead.`); | ||
| continue; | ||
| } | ||
| if (args['dry-run']) { | ||
| console.log(`[dry] ${post.id}: would natively schedule a ${textPost ? 'text status' : 'media status'} for ${new Date(dueMs).toISOString()}.`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: natively scheduling for ${new Date(dueMs).toISOString()}...`); | ||
| try { | ||
| let mediaId = null; | ||
| if (!textPost) mediaId = await uploadMedia(mediaPath, post); | ||
| // ':native' keys this apart from an immediate publish, so a later past-due | ||
| // fallback is never swallowed by the idempotency cache of a failed schedule. | ||
| const { data: resp } = await masto('POST', '/api/v1/statuses', { | ||
| body: { status: text, ...(mediaId ? { media_ids: [mediaId] } : {}), visibility: 'public', scheduled_at: new Date(dueMs).toISOString() }, | ||
| headers: { 'Idempotency-Key': `${plan.campaign || 'plan'}:${post.id}:native` }, | ||
| }); | ||
| const schedId = resp?.id; | ||
| if (!schedId) throw new Error(`scheduled status create returned no id: ${JSON.stringify(resp).slice(0, 200)}`); | ||
| post.mastodonScheduledId = String(schedId); | ||
| post.status = 'scheduled'; | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'schedule-native', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'schedule-native', ok: true, id: String(schedId) }); | ||
| console.log(`[ok] ${post.id}: natively scheduled (queue entry ${schedId}, fires ${resp.scheduled_at || new Date(dueMs).toISOString()}).`); | ||
| scheduled += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'schedule-native', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'schedule-native', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Mastodon schedule failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log(`[done] schedule complete - ${scheduled} natively scheduled, ${published} published (past due).`); | ||
| } | ||
| // Post-due reconcile (the analog of yt-social's release lane): the scheduled- | ||
| // status id DIES when the instance fires it and the live status gets a NEW id, | ||
| // so this records post.mastodonStatusId once the queue entry is gone - matched | ||
| // by text on the account timeline. A queue entry still parked well past due is | ||
| // cancelled and published immediately (the instance provably did not fire it). | ||
| async function cmdResolve(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const now = Date.now(); | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isMastodon(post)) continue; | ||
| if (!post.mastodonScheduledId || post.mastodonStatusId) continue; | ||
| if (post.status === 'posted') continue; | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| let queued = null; | ||
| try { | ||
| const { data } = await masto('GET', `/api/v1/scheduled_statuses/${encodeURIComponent(post.mastodonScheduledId)}`); | ||
| queued = data; | ||
| } catch (err) { | ||
| if (!/HTTP 404/i.test(err.message || '')) { | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'resolve', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: scheduled-status lookup failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| if (queued) { | ||
| // Still parked. Before due (or briefly past it) that is the healthy state; | ||
| // well past due the instance is not going to fire it - take it back. | ||
| if (Number.isNaN(dueMs) || now <= dueMs + RESOLVE_REPUBLISH_GRACE_MS) { | ||
| console.log(`[skip] ${post.id}: still queued on the instance (fires ${queued.scheduled_at || post.scheduledAt}).`); | ||
| continue; | ||
| } | ||
| console.log(`[warn] ${post.id}: queue entry ${post.mastodonScheduledId} still parked ${Math.round((now - dueMs) / 60000)}m past due - cancelling and publishing now.`); | ||
| try { | ||
| await masto('DELETE', `/api/v1/scheduled_statuses/${encodeURIComponent(post.mastodonScheduledId)}`); | ||
| const text = statusText(post); | ||
| const mediaPath = isTextPost(post) ? null : resolveMediaPath(plan, post); | ||
| const resp = await publishNow(plan, post, { text, mediaPath, now }); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'resolve-republish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'resolve-republish', ok: true, id: post.mastodonStatusId, permalink: resp.url || null }); | ||
| console.log(`[ok] ${post.id}: republished as status ${post.mastodonStatusId}.`); | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'resolve-republish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'resolve-republish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: republish failed - ${err.message}`); | ||
| } | ||
| continue; | ||
| } | ||
| // Queue entry gone = the instance fired it. Find the live status by text on | ||
| // the account's own timeline (created around the due minute). | ||
| try { | ||
| const { data: me } = await masto('GET', '/api/v1/accounts/verify_credentials'); | ||
| const { data: statuses } = await masto('GET', `/api/v1/accounts/${encodeURIComponent(me.id)}/statuses?limit=40&exclude_replies=true&exclude_reblogs=true`); | ||
| const want = matchKey(statusText(post)); | ||
| const hit = (Array.isArray(statuses) ? statuses : []).find((s) => { | ||
| const createdMs = Date.parse(s.created_at || ''); | ||
| const nearDue = Number.isNaN(dueMs) || (Number.isFinite(createdMs) && createdMs >= dueMs - 30 * 60 * 1000); | ||
| return nearDue && matchKey(s.content) === want; | ||
| }); | ||
| if (hit) { | ||
| post.mastodonStatusId = String(hit.id); | ||
| post.status = 'posted'; | ||
| post.postedAt = hit.created_at || new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'resolve', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'resolve', ok: true, id: String(hit.id), permalink: hit.url || null }); | ||
| console.log(`[ok] ${post.id}: fired natively - resolved to status ${hit.id}${hit.url ? ` (${hit.url})` : ''}.`); | ||
| } else { | ||
| // The queue entry is gone, so the instance DID publish it - the text just | ||
| // no longer matches (edited platform-side / timeline paging). Mark posted | ||
| // honestly rather than re-publishing a duplicate; the id stays unresolved. | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'mastodon', action: 'resolve', ok: true, errorCode: null, errorMessage: 'fired natively but the live status id could not be matched', actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'resolve', ok: true, id: null, detail: 'fired natively; live status id unresolved (no timeline match)' }); | ||
| console.log(`[warn] ${post.id}: queue entry fired but no timeline match - marked posted without a status id.`); | ||
| } | ||
| } catch (err) { | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'resolve', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: resolve failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log('[done] resolve complete.'); | ||
| } | ||
| async function cmdUnschedule(args) { | ||
| if (!args.id) { console.error('[err] unschedule requires --id <scheduledId>'); process.exit(2); } | ||
| await masto('DELETE', `/api/v1/scheduled_statuses/${encodeURIComponent(args.id)}`); | ||
| RUN.results.push({ platform: 'mastodon', action: 'unschedule', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] cancelled Mastodon scheduled status ${args.id}.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] Mastodon plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isMastodon)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.mastodonStatusId ? ` masto=${post.mastodonStatusId}` : ''}${post.mastodonScheduledId ? ` sched=${post.mastodonScheduledId}` : ''}`); | ||
| } | ||
| } | ||
| // Read-only liveness: GET the status back; its `url` is the public permalink. | ||
| // A natively-scheduled entry (queue id, no status id yet) reads the queue | ||
| // instead: 'scheduled' while parked, 'pending-resolve' once the instance fired | ||
| // it (neither live nor failed - the resolve lane closes that gap). | ||
| // Writes nothing - lib/verify.mjs owns post.verify. | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| for (const post of (plan.posts || []).filter(isMastodon)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.mastodonStatusId && post.mastodonScheduledId) { | ||
| try { | ||
| await masto('GET', `/api/v1/scheduled_statuses/${encodeURIComponent(post.mastodonScheduledId)}`); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'verify', ok: true, live: false, state: 'scheduled', permalink: null, id: post.mastodonScheduledId }); | ||
| } catch (err) { | ||
| const missing = /HTTP 404/i.test(err.message || ''); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'verify', ok: true, live: false, state: missing ? 'pending-resolve' : 'unknown', permalink: null, id: post.mastodonScheduledId, ...(missing ? {} : { errorMessage: String(err.message).slice(0, 200) }) }); | ||
| } | ||
| continue; | ||
| } | ||
| if (!post.mastodonStatusId) continue; | ||
| try { | ||
| const { data } = await masto('GET', `/api/v1/statuses/${encodeURIComponent(post.mastodonStatusId)}`); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'verify', ok: true, live: true, state: 'published', permalink: data.url || null, id: post.mastodonStatusId }); | ||
| } catch (err) { | ||
| const missing = /HTTP 404|not found/i.test(err.message || ''); | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'verify', ok: true, live: false, state: missing ? 'missing' : 'unknown', permalink: null, id: post.mastodonStatusId, errorMessage: String(err.message).slice(0, 200) }); | ||
| } | ||
| } | ||
| } | ||
| // Real metrics (unlike telegram/discord): the status object carries its own counts. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isMastodon(post) || !post.mastodonStatusId) continue; | ||
| try { | ||
| const { data } = await masto('GET', `/api/v1/statuses/${encodeURIComponent(post.mastodonStatusId)}`); | ||
| const metrics = { | ||
| favourites: data.favourites_count ?? 0, | ||
| reblogs: data.reblogs_count ?? 0, | ||
| replies: data.replies_count ?? 0, | ||
| }; | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'insights', ok: true, id: post.mastodonStatusId, metrics }); | ||
| console.log(`[ok] ${post.id}: Mastodon ${JSON.stringify(metrics)}`); | ||
| } catch (err) { | ||
| RUN.results.push({ postId: post.id, platform: 'mastodon', action: 'insights', ok: false, errorCode: 'engine_failure', errorMessage: String(err.message || err).slice(0, 300) }); | ||
| console.log(`[warn] ${post.id}: Mastodon insights failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log(`[done] insights complete - ${RUN.results.filter((r) => r.ok).length} fetched.`); | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <statusId>'); process.exit(2); } | ||
| await masto('DELETE', `/api/v1/statuses/${encodeURIComponent(args.id)}`); | ||
| RUN.results.push({ platform: 'mastodon', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted Mastodon status ${args.id}.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!accessToken()) { | ||
| RUN.results.push({ platform: 'mastodon', action: 'probe', ok: false, detail: 'not configured (MASTODON_ACCESS_TOKEN missing)' }); | ||
| return; | ||
| } | ||
| if (!instanceUrl()) { | ||
| RUN.results.push({ platform: 'mastodon', action: 'probe', ok: false, detail: 'not configured (MASTODON_INSTANCE_URL missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const { data: me } = await masto('GET', '/api/v1/accounts/verify_credentials'); | ||
| RUN.results.push({ platform: 'mastodon', action: 'probe', ok: true, detail: `connected as @${me.acct}`, tokenExpiresAt: null }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'mastodon', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| schedule: cmdSchedule, | ||
| resolve: cmdResolve, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| unschedule: cmdUnschedule, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('mastodon') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'mastodon', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] mastodon ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/mastodon-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'schedule', 'resolve', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * nostr-social.mjs - direct Nostr publishing via NIP-01 relays. | ||
| * | ||
| * Sibling of scripts/telegram-social.mjs / x-social.mjs / discord-social.mjs: | ||
| * the same zero-dep, plan-driven, publish-straight-from-the-local-render pattern, | ||
| * with Nostr's own (radically simple) keypair identity + relay fan-out model. | ||
| * | ||
| * Nostr has NO central API, NO OAuth and NO scheduling - identity is a secp256k1 | ||
| * keypair and "publishing" means handing a signed event to relays - so entries | ||
| * publish at their due time by re-running `publish-due` (driven by the scheduler | ||
| * tick), exactly like Instagram / LinkedIn / X / Telegram. | ||
| * | ||
| * AUTH - a single static keypair, no ceremony: | ||
| * NOSTR_PRIVATE_KEY the signing key: nsec1... (bech32, NIP-19) or 64-char hex. | ||
| * Mint one with `keygen [--save]` - there is no signup. | ||
| * NOSTR_RELAYS comma-separated relay websocket URLs (wss://... or ws://...). | ||
| * `connect`/`auth` here derives + persists NOSTR_PUBLIC_KEY (hex) and NOSTR_NPUB | ||
| * (bech32) and confirms at least one configured relay actually answers a REQ: | ||
| * there is no token to mint, only a key to prove and relays to reach. | ||
| * | ||
| * PUBLISH - every due entry becomes ONE signed kind-1 text note (NIP-01), fanned | ||
| * out to EVERY configured relay; the post counts as published when at least ONE | ||
| * relay replies ["OK", <id>, true] - that is Nostr's delivery model (redundancy | ||
| * over guarantees, per-relay acceptance is advisory). Media is NOT supported: | ||
| * relays carry events, not files, so a media post honestly publishes its text | ||
| * only (logged as a warning, never silently). Text comes from post.nostrCaption | ||
| * (falls back to post.caption), the additive per-platform override pattern x | ||
| * uses for xCaption; relays impose no hard length cap. | ||
| * | ||
| * Permalinks go through njump.me (a public event gateway) via the NIP-19 | ||
| * note1... encoding of the event id. `delete` publishes a NIP-09 kind-5 | ||
| * deletion event - relays MAY honor it, nothing forces them to. | ||
| * | ||
| * All crypto is IN-FILE and dependency-free: bech32 (BIP-173), secp256k1 + | ||
| * BIP340 Schnorr over BigInt, tagged hashes via node:crypto. `selftest` proves | ||
| * the implementation against the official BIP340 test vector before any key | ||
| * ever touches a relay. Relay I/O uses the GLOBAL WebSocket, so network | ||
| * commands require Node >= 22. | ||
| * | ||
| * Commands: | ||
| * keygen [--save] mint a fresh keypair (prints nsec + npub) | ||
| * auth | connect derive + persist the public identity, check relay reachability | ||
| * refresh no-op (keypairs are static) - kept for sibling parity | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due Nostr entry | ||
| * status --plan <p> list Nostr plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (REQ by event id) | ||
| * insights --plan <p> [--only <id>] no-op (relays expose no per-post metrics) | ||
| * probe read-only health probe | ||
| * delete --id <eventId> publish a NIP-09 deletion request | ||
| * selftest offline crypto self-check (BIP340 + bech32) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import crypto from 'node:crypto'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // Every relay exchange (EVENT publish, REQ read-back, reachability probe) is | ||
| // wrapped in a promise with this timeout - a dead relay must never hang a tick. | ||
| const RELAY_TIMEOUT_MS = 10 * 1000; | ||
| // Relay I/O rides the GLOBAL WebSocket (Node >= 22). Guarded per network | ||
| // command so the offline commands (keygen, selftest, validate, status) still | ||
| // work on older Nodes. | ||
| function requireWebSocket() { | ||
| if (typeof WebSocket === 'undefined') { | ||
| console.error('[err] the nostr lane needs Node >= 22 (global WebSocket)'); | ||
| process.exit(2); | ||
| } | ||
| } | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| function writeEnv(vars) { | ||
| let raw = readEnvRaw(); | ||
| for (const [k, v] of Object.entries(vars)) { | ||
| if (v == null) continue; | ||
| // function replacer: values may contain '$' which is special in a string replacement. | ||
| if (new RegExp(`^${k}=`, 'm').test(raw)) { | ||
| raw = raw.replace(new RegExp(`^${k}=.*$`, 'm'), () => `${k}=${v}`); | ||
| } else { | ||
| raw += `${raw.endsWith('\n') || raw === '' ? '' : '\n'}${k}=${v}\n`; | ||
| } | ||
| } | ||
| // Atomic + 0600: a crash mid-write must never truncate the secret-bearing .env. | ||
| const tmp = `${ENV_PATH}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, raw, { mode: 0o600 }); | ||
| fs.renameSync(tmp, ENV_PATH); | ||
| } | ||
| // ---------- bech32 (BIP-173) - the NIP-19 nsec/npub/note encoding ---------- | ||
| const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; | ||
| const BECH32_GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; | ||
| function bech32Polymod(values) { | ||
| let chk = 1; | ||
| for (const v of values) { | ||
| const b = chk >> 25; | ||
| chk = ((chk & 0x1ffffff) << 5) ^ v; | ||
| for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= BECH32_GEN[i]; | ||
| } | ||
| return chk; | ||
| } | ||
| function bech32HrpExpand(hrp) { | ||
| const out = []; | ||
| for (const c of hrp) out.push(c.charCodeAt(0) >> 5); | ||
| out.push(0); | ||
| for (const c of hrp) out.push(c.charCodeAt(0) & 31); | ||
| return out; | ||
| } | ||
| // 8-bit <-> 5-bit regrouping (BIP-173 "convertbits"). | ||
| function convertBits(data, from, to, pad) { | ||
| let acc = 0; | ||
| let bits = 0; | ||
| const out = []; | ||
| const maxv = (1 << to) - 1; | ||
| for (const v of data) { | ||
| if (v < 0 || v >> from) throw new Error('bech32: invalid data value'); | ||
| acc = (acc << from) | v; | ||
| bits += from; | ||
| while (bits >= to) { | ||
| bits -= to; | ||
| out.push((acc >> bits) & maxv); | ||
| } | ||
| } | ||
| if (pad) { | ||
| if (bits) out.push((acc << (to - bits)) & maxv); | ||
| } else if (bits >= from || ((acc << (to - bits)) & maxv)) { | ||
| throw new Error('bech32: invalid padding'); | ||
| } | ||
| return out; | ||
| } | ||
| function bech32Encode(hrp, bytes) { | ||
| const data = convertBits([...bytes], 8, 5, true); | ||
| const pm = bech32Polymod([...bech32HrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]) ^ 1; | ||
| const checksum = []; | ||
| for (let i = 0; i < 6; i++) checksum.push((pm >> (5 * (5 - i))) & 31); | ||
| return `${hrp}1${[...data, ...checksum].map((v) => BECH32_CHARSET[v]).join('')}`; | ||
| } | ||
| function bech32Decode(str, expectedHrp = null) { | ||
| if (str !== str.toLowerCase() && str !== str.toUpperCase()) throw new Error('bech32: mixed case'); | ||
| const lower = str.toLowerCase(); | ||
| const pos = lower.lastIndexOf('1'); | ||
| if (pos < 1 || pos + 7 > lower.length) throw new Error('bech32: malformed'); | ||
| const hrp = lower.slice(0, pos); | ||
| if (expectedHrp && hrp !== expectedHrp) throw new Error(`bech32: expected ${expectedHrp}1..., got ${hrp}1...`); | ||
| const data = [...lower.slice(pos + 1)].map((c) => BECH32_CHARSET.indexOf(c)); | ||
| if (data.includes(-1)) throw new Error('bech32: invalid character'); | ||
| if (bech32Polymod([...bech32HrpExpand(hrp), ...data]) !== 1) throw new Error('bech32: bad checksum'); | ||
| return Buffer.from(convertBits(data.slice(0, -6), 5, 8, false)); | ||
| } | ||
| const npubEncode = (pubHex) => bech32Encode('npub', Buffer.from(pubHex, 'hex')); | ||
| const noteEncode = (idHex) => bech32Encode('note', Buffer.from(idHex, 'hex')); | ||
| // ---------- secp256k1 + BIP340 Schnorr (BigInt, affine, zero deps) ---------- | ||
| const SECP_P = 2n ** 256n - 2n ** 32n - 977n; | ||
| const SECP_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n; | ||
| const G = { | ||
| x: 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798n, | ||
| y: 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8n, | ||
| }; | ||
| const mod = (a, m) => ((a % m) + m) % m; | ||
| // Modular inverse via the extended Euclidean algorithm. | ||
| function modInv(a, m) { | ||
| let [r0, r1] = [mod(a, m), m]; | ||
| let [s0, s1] = [1n, 0n]; | ||
| while (r1 !== 0n) { | ||
| const q = r0 / r1; | ||
| [r0, r1] = [r1, r0 - q * r1]; | ||
| [s0, s1] = [s1, s0 - q * s1]; | ||
| } | ||
| if (r0 !== 1n) throw new Error('secp256k1: no modular inverse'); | ||
| return mod(s0, m); | ||
| } | ||
| function powMod(base, exp, m) { | ||
| let r = 1n; | ||
| let b = mod(base, m); | ||
| let e = exp; | ||
| while (e > 0n) { | ||
| if (e & 1n) r = (r * b) % m; | ||
| b = (b * b) % m; | ||
| e >>= 1n; | ||
| } | ||
| return r; | ||
| } | ||
| // Affine point arithmetic; null is the point at infinity. | ||
| function pointDouble(P) { | ||
| if (!P) return null; | ||
| if (P.y === 0n) return null; | ||
| const l = mod(3n * P.x * P.x * modInv(2n * P.y, SECP_P), SECP_P); | ||
| const x = mod(l * l - 2n * P.x, SECP_P); | ||
| return { x, y: mod(l * (P.x - x) - P.y, SECP_P) }; | ||
| } | ||
| function pointAdd(P, Q) { | ||
| if (!P) return Q; | ||
| if (!Q) return P; | ||
| if (P.x === Q.x) { | ||
| if (mod(P.y + Q.y, SECP_P) === 0n) return null; | ||
| return pointDouble(P); | ||
| } | ||
| const l = mod((Q.y - P.y) * modInv(Q.x - P.x, SECP_P), SECP_P); | ||
| const x = mod(l * l - P.x - Q.x, SECP_P); | ||
| return { x, y: mod(l * (P.x - x) - P.y, SECP_P) }; | ||
| } | ||
| // Double-and-add scalar multiplication. | ||
| function pointMul(k0, P) { | ||
| let k = mod(k0, SECP_N); | ||
| let R = null; | ||
| let A = P; | ||
| while (k > 0n) { | ||
| if (k & 1n) R = pointAdd(R, A); | ||
| A = pointDouble(A); | ||
| k >>= 1n; | ||
| } | ||
| return R; | ||
| } | ||
| const bytesToBig = (buf) => BigInt(`0x${Buffer.from(buf).toString('hex').padStart(2, '0')}`); | ||
| const bigToBytes32 = (x) => Buffer.from(x.toString(16).padStart(64, '0'), 'hex'); | ||
| function sha256(...bufs) { | ||
| const h = crypto.createHash('sha256'); | ||
| for (const b of bufs) h.update(b); | ||
| return h.digest(); | ||
| } | ||
| // BIP340 tagged hash: sha256(sha256(tag) || sha256(tag) || msg). | ||
| function taggedHash(tag, ...msgs) { | ||
| const th = sha256(Buffer.from(tag, 'utf8')); | ||
| return sha256(th, th, ...msgs); | ||
| } | ||
| // BIP340 x-only pubkey: x(d*G) - parity is normalized inside signing, never here. | ||
| function pubkeyBytes(d) { | ||
| return bigToBytes32(pointMul(d, G).x); | ||
| } | ||
| // BIP340 Schnorr signature. `aux` is normally fresh randomness; the selftest | ||
| // passes the official vector's fixed all-zero aux to prove determinism. | ||
| function schnorrSign(msg, d0, aux = null) { | ||
| if (d0 <= 0n || d0 >= SECP_N) throw new Error('BIP340: private key out of range'); | ||
| const P = pointMul(d0, G); | ||
| const d = (P.y & 1n) === 1n ? SECP_N - d0 : d0; // normalize for an even-Y pubkey | ||
| const pub = bigToBytes32(P.x); | ||
| const t = bigToBytes32(d ^ bytesToBig(taggedHash('BIP0340/aux', aux || crypto.randomBytes(32)))); | ||
| const k0 = mod(bytesToBig(taggedHash('BIP0340/nonce', t, pub, msg)), SECP_N); | ||
| if (k0 === 0n) throw new Error('BIP340: zero nonce (retry)'); | ||
| const R = pointMul(k0, G); | ||
| const k = (R.y & 1n) === 1n ? SECP_N - k0 : k0; // normalize for an even-Y R | ||
| const e = mod(bytesToBig(taggedHash('BIP0340/challenge', bigToBytes32(R.x), pub, msg)), SECP_N); | ||
| return Buffer.concat([bigToBytes32(R.x), bigToBytes32(mod(k + e * d, SECP_N))]); | ||
| } | ||
| // lift_x: the curve point with the given x and EVEN y, or null if x is not on the curve. | ||
| function liftX(x) { | ||
| if (x <= 0n || x >= SECP_P) return null; | ||
| const c = mod(x * x * x + 7n, SECP_P); | ||
| const y = powMod(c, (SECP_P + 1n) / 4n, SECP_P); | ||
| if (mod(y * y, SECP_P) !== c) return null; | ||
| return { x, y: (y & 1n) === 0n ? y : SECP_P - y }; | ||
| } | ||
| // Standard BIP340 verification (used by the selftest to prove sign() honest). | ||
| function schnorrVerify(msg, pub, sig) { | ||
| if (pub.length !== 32 || sig.length !== 64) return false; | ||
| const P = liftX(bytesToBig(pub)); | ||
| if (!P) return false; | ||
| const r = bytesToBig(sig.subarray(0, 32)); | ||
| const s = bytesToBig(sig.subarray(32)); | ||
| if (r >= SECP_P || s >= SECP_N) return false; | ||
| const e = mod(bytesToBig(taggedHash('BIP0340/challenge', sig.subarray(0, 32), pub, msg)), SECP_N); | ||
| const R = pointAdd(pointMul(s, G), pointMul(SECP_N - e, P)); // s*G - e*P | ||
| if (!R) return false; | ||
| if ((R.y & 1n) === 1n) return false; | ||
| return R.x === r; | ||
| } | ||
| // ---------- key handling ---------- | ||
| function parsePrivateKey(raw) { | ||
| const v = String(raw || '').trim(); | ||
| let bytes; | ||
| if (/^nsec1/i.test(v)) bytes = bech32Decode(v, 'nsec'); | ||
| else if (/^[0-9a-f]{64}$/i.test(v)) bytes = Buffer.from(v, 'hex'); | ||
| else throw new Error('NOSTR_PRIVATE_KEY must be nsec1... (bech32) or 64-char hex'); | ||
| if (bytes.length !== 32) throw new Error(`NOSTR_PRIVATE_KEY decodes to ${bytes.length} bytes (expected 32)`); | ||
| const d = bytesToBig(bytes); | ||
| if (d <= 0n || d >= SECP_N) throw new Error('NOSTR_PRIVATE_KEY is out of the secp256k1 range'); | ||
| return { bytes, d }; | ||
| } | ||
| // null when NOSTR_PRIVATE_KEY is unset; throws on a malformed key. | ||
| function deriveKeys() { | ||
| const raw = readEnv('NOSTR_PRIVATE_KEY'); | ||
| if (!raw) return null; | ||
| const { d } = parsePrivateKey(raw); | ||
| const pubHex = pubkeyBytes(d).toString('hex'); | ||
| return { d, pubHex, npub: npubEncode(pubHex) }; | ||
| } | ||
| // ---------- NIP-01 events + relay protocol ---------- | ||
| // id = sha256 of the canonical [0, pubkey, created_at, kind, tags, content] | ||
| // serialization; sig = BIP340 Schnorr over those 32 bytes (NIP-01). | ||
| function buildEvent(keys, kind, tags, content) { | ||
| const createdAt = Math.floor(Date.now() / 1000); | ||
| const idBytes = sha256(Buffer.from(JSON.stringify([0, keys.pubHex, createdAt, kind, tags, content]), 'utf8')); | ||
| const sig = schnorrSign(idBytes, keys.d); | ||
| return { id: idBytes.toString('hex'), pubkey: keys.pubHex, created_at: createdAt, kind, tags, content, sig: sig.toString('hex') }; | ||
| } | ||
| function relayUrls() { | ||
| const all = (readEnv('NOSTR_RELAYS') || '').split(',').map((s) => s.trim()).filter(Boolean); | ||
| const valid = all.filter((u) => /^wss?:\/\//i.test(u)); | ||
| for (const u of all) if (!valid.includes(u)) console.error(`[warn] ignoring non-websocket relay URL: ${u}`); | ||
| return valid; | ||
| } | ||
| // One relay round-trip: open, run the exchange, ALWAYS close the socket, and | ||
| // never hang past RELAY_TIMEOUT_MS whatever the relay does. | ||
| function relayExchange(url, { onOpen, onFrame }, timeoutMs = RELAY_TIMEOUT_MS) { | ||
| return new Promise((resolve, reject) => { | ||
| let ws; | ||
| try { ws = new WebSocket(url); } catch (err) { reject(err); return; } | ||
| let settled = false; | ||
| let timer = null; | ||
| const finish = (err, value) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| clearTimeout(timer); | ||
| try { ws.close(); } catch { /* already closed */ } | ||
| if (err) reject(err); else resolve(value); | ||
| }; | ||
| timer = setTimeout(() => finish(new Error(`relay timeout after ${timeoutMs / 1000}s`)), timeoutMs); | ||
| ws.addEventListener('open', () => { try { onOpen(ws); } catch (err) { finish(err); } }); | ||
| ws.addEventListener('error', (ev) => finish(new Error(`websocket error${ev?.message ? ` - ${ev.message}` : ''}`))); | ||
| ws.addEventListener('close', () => finish(new Error('relay closed the connection'))); | ||
| ws.addEventListener('message', (ev) => { | ||
| let frame; | ||
| try { frame = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)); } catch { return; } | ||
| if (!Array.isArray(frame)) return; | ||
| try { onFrame(frame, ws, (value) => finish(null, value), (err) => finish(err)); } catch (err) { finish(err); } | ||
| }); | ||
| }); | ||
| } | ||
| // Send ["EVENT", event]; resolves on ["OK", id, true], rejects on ["OK", id, false, reason]. | ||
| function publishEventToRelay(url, event) { | ||
| return relayExchange(url, { | ||
| onOpen: (ws) => ws.send(JSON.stringify(['EVENT', event])), | ||
| onFrame: (frame, ws, done, fail) => { | ||
| if (frame[0] !== 'OK' || frame[1] !== event.id) return; | ||
| if (frame[2] === true) done(true); | ||
| else fail(new Error(`relay rejected the event: ${frame[3] || 'no reason given'}`)); | ||
| }, | ||
| }); | ||
| } | ||
| // REQ by event id; resolves with the event (seen before EOSE) or null. | ||
| function fetchEventFromRelay(url, id) { | ||
| const subId = `pendpost-${crypto.randomBytes(4).toString('hex')}`; | ||
| let found = null; | ||
| return relayExchange(url, { | ||
| onOpen: (ws) => ws.send(JSON.stringify(['REQ', subId, { ids: [id] }])), | ||
| onFrame: (frame, ws, done) => { | ||
| if (frame[0] === 'EVENT' && frame[1] === subId && frame[2]?.id === id) found = frame[2]; | ||
| if (frame[0] === 'EOSE' && frame[1] === subId) { | ||
| try { ws.send(JSON.stringify(['CLOSE', subId])); } catch { /* closing anyway */ } | ||
| done(found); | ||
| } | ||
| }, | ||
| }); | ||
| } | ||
| // Reachability: a relay that answers a tiny REQ (any EVENT or the EOSE) is alive. | ||
| function probeRelay(url) { | ||
| const subId = 'pendpost-auth'; | ||
| return relayExchange(url, { | ||
| onOpen: (ws) => ws.send(JSON.stringify(['REQ', subId, { kinds: [0], limit: 1 }])), | ||
| onFrame: (frame, ws, done) => { | ||
| if ((frame[0] === 'EVENT' || frame[0] === 'EOSE') && frame[1] === subId) { | ||
| try { ws.send(JSON.stringify(['CLOSE', subId])); } catch { /* closing anyway */ } | ||
| done(true); | ||
| } | ||
| }, | ||
| }); | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'redditPostId', 'pinId', 'tiktokVideoId', 'mastodonStatusId', 'wordpressPostId', 'ghostPostId', 'nostrEventId', 'gbpPostId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| const isNostr = (post) => (post.platforms || []).includes('nostr'); | ||
| const isTextPost = (post) => post.type === 'text'; | ||
| const noteText = (post) => (post.nostrCaption || post.caption || '').trim(); | ||
| // Public permalink: njump.me resolves a NIP-19 note1... id on any relay set. | ||
| function permalinkFor(eventId) { | ||
| return `https://njump.me/${noteEncode(eventId)}`; | ||
| } | ||
| // ---------- commands ---------- | ||
| // There is no signup on Nostr - a keypair IS the account. Rejects the (astronomically | ||
| // unlikely) out-of-range draws and retries, per the spec for key generation. | ||
| async function cmdKeygen(args) { | ||
| let bytes; | ||
| let d; | ||
| do { bytes = crypto.randomBytes(32); d = bytesToBig(bytes); } while (d === 0n || d >= SECP_N); | ||
| const pubHex = pubkeyBytes(d).toString('hex'); | ||
| const nsec = bech32Encode('nsec', bytes); | ||
| const npub = npubEncode(pubHex); | ||
| console.log('[ok] minted a fresh Nostr keypair:'); | ||
| console.log(` nsec (SECRET - this IS the account): ${nsec}`); | ||
| console.log(` npub (public identity): ${npub}`); | ||
| console.log(` hex private key: ${bytes.toString('hex')}`); | ||
| console.log(` hex public key: ${pubHex}`); | ||
| if (args.save) { | ||
| if (readEnv('NOSTR_PRIVATE_KEY')) { | ||
| console.error('[err] NOSTR_PRIVATE_KEY is already set - refusing to overwrite an existing key. Remove it from .env yourself if you really mean to rotate identities.'); | ||
| process.exit(2); | ||
| } | ||
| writeEnv({ NOSTR_PRIVATE_KEY: nsec }); | ||
| console.log(`[ok] saved NOSTR_PRIVATE_KEY to ${ENV_PATH} - run 'auth' next to persist the public identity and check relays.`); | ||
| } else { | ||
| console.log('[info] not saved - re-run with --save to persist NOSTR_PRIVATE_KEY (writes only when unset).'); | ||
| } | ||
| } | ||
| async function cmdAuth() { | ||
| requireWebSocket(); | ||
| if (!readEnv('NOSTR_PRIVATE_KEY')) { console.error('[err] NOSTR_PRIVATE_KEY missing in .env (nsec1... or 64-char hex - mint one with `keygen --save`).'); process.exit(2); } | ||
| const keys = deriveKeys(); | ||
| writeEnv({ NOSTR_PUBLIC_KEY: keys.pubHex, NOSTR_NPUB: keys.npub }); | ||
| console.log(`[ok] Key valid - identity ${keys.npub} (persisted NOSTR_PUBLIC_KEY + NOSTR_NPUB).`); | ||
| const relays = relayUrls(); | ||
| if (!relays.length) { console.error('[err] NOSTR_RELAYS missing in .env (comma-separated wss:// relay URLs).'); process.exit(2); } | ||
| let reachable = 0; | ||
| for (const url of relays) { | ||
| try { | ||
| await probeRelay(url); | ||
| reachable += 1; | ||
| console.log(`[ok] relay reachable - ${url}`); | ||
| } catch (err) { | ||
| console.log(`[warn] relay unreachable - ${url} (${err.message})`); | ||
| } | ||
| } | ||
| if (!reachable) { console.error(`[err] no configured relay is reachable (0/${relays.length}) - cannot publish.`); process.exit(2); } | ||
| RUN.results.push({ platform: 'nostr', action: 'auth', ok: true, detail: `${keys.npub.slice(0, 13)}... via ${reachable}/${relays.length} relays` }); | ||
| } | ||
| async function cmdRefresh() { | ||
| console.log('[info] Nostr keypairs are static (no refresh).'); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const keys = deriveKeys(); | ||
| if (keys) console.log(`[ok] Key valid - would publish as ${keys.npub}.`); | ||
| else console.log('[warn] NOSTR_PRIVATE_KEY not set - preview only, publish will need it.'); | ||
| } catch (err) { | ||
| console.log(`[warn] key check failed (${err.message}). Continuing to text preview.`); | ||
| } | ||
| const targets = (plan.posts || []).filter((p) => isNostr(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No Nostr entries match.'); return; } | ||
| for (const post of targets) { | ||
| const text = noteText(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] type: ${post.type}`); | ||
| console.log(`[preview] text (${text.length} chars - relays impose no hard cap):`); | ||
| console.log(text); | ||
| if (!isTextPost(post)) console.log('[warn] nostr carries no media - this post will publish text only.'); | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| requireWebSocket(); | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const keys = deriveKeys(); | ||
| if (!keys) throw new Error('NOSTR_PRIVATE_KEY is not set - cannot publish.'); | ||
| const relays = relayUrls(); | ||
| if (!relays.length) throw new Error('NOSTR_RELAYS is not set - cannot publish.'); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isNostr(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const text = noteText(post); | ||
| if (!text) { console.log(`[warn] ${post.id}: due but no text (nostrCaption/caption) - skipping.`); continue; } | ||
| if (!isTextPost(post)) console.log(`[warn] ${post.id}: nostr carries no media - publishing text only`); | ||
| if (args['dry-run']) { | ||
| console.log(`[dry] ${post.id}: would sign a kind-1 note (${text.length} chars) and fan it out to ${relays.length} relay(s).`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: publishing kind-1 note to ${relays.length} relay(s)...`); | ||
| try { | ||
| const event = buildEvent(keys, 1, [], text); | ||
| const outcomes = await Promise.allSettled(relays.map((url) => publishEventToRelay(url, event))); | ||
| let accepted = 0; | ||
| outcomes.forEach((o, i) => { | ||
| if (o.status === 'fulfilled') { accepted += 1; console.log(`[ok] ${post.id}: accepted by ${relays[i]}`); } | ||
| else console.log(`[warn] ${post.id}: ${relays[i]} - ${o.reason?.message || o.reason}`); | ||
| }); | ||
| // Nostr's delivery model: ONE accepting relay makes the note live. | ||
| if (!accepted) throw new Error(`no relay accepted the event (0/${relays.length})`); | ||
| post.nostrEventId = event.id; | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'nostr', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'nostr', action: 'publish', ok: true, id: event.id }); | ||
| console.log(`[ok] ${post.id}: published on Nostr (${accepted}/${relays.length} relays accepted) - ${permalinkFor(event.id)}`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'nostr', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'nostr', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: Nostr publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} note(s) published.`); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] Nostr plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isNostr)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.nostrEventId ? ` nostr=${post.nostrEventId}` : ''}`); | ||
| } | ||
| } | ||
| // Real read-back liveness: REQ the stored event id against the configured relays | ||
| // until one returns the event - relays are the source of truth, not our plan file. | ||
| async function cmdVerify(args) { | ||
| requireWebSocket(); | ||
| const { plan } = loadPlan(args.plan); | ||
| const relays = relayUrls(); | ||
| for (const post of (plan.posts || []).filter(isNostr)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.nostrEventId) continue; | ||
| let live = false; | ||
| for (const url of relays) { | ||
| try { if (await fetchEventFromRelay(url, post.nostrEventId)) { live = true; break; } } catch { /* try the next relay */ } | ||
| } | ||
| RUN.results.push({ postId: post.id, platform: 'nostr', action: 'verify', ok: true, live, state: live ? 'published' : 'missing', permalink: live ? permalinkFor(post.nostrEventId) : null, id: post.nostrEventId }); | ||
| } | ||
| } | ||
| // Relays store and forward events; they expose no view/reaction counters - honest no-op. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| void plan; | ||
| console.log('[info] nostr relays expose no per-post metrics - insights is a no-op.'); | ||
| } | ||
| // NIP-09: a kind-5 event tagging the target id ASKS relays to drop it. Best-effort | ||
| // by design - relays MAY honor the request, nothing in the protocol forces them to. | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <eventId>'); process.exit(2); } | ||
| requireWebSocket(); | ||
| const keys = deriveKeys(); | ||
| if (!keys) { console.error('[err] NOSTR_PRIVATE_KEY missing in .env - cannot sign a deletion event.'); process.exit(2); } | ||
| const relays = relayUrls(); | ||
| if (!relays.length) { console.error('[err] NOSTR_RELAYS missing in .env - nowhere to send the deletion.'); process.exit(2); } | ||
| const event = buildEvent(keys, 5, [['e', String(args.id)]], ''); | ||
| const outcomes = await Promise.allSettled(relays.map((url) => publishEventToRelay(url, event))); | ||
| let accepted = 0; | ||
| outcomes.forEach((o, i) => { | ||
| if (o.status === 'fulfilled') { accepted += 1; console.log(`[ok] deletion accepted by ${relays[i]}`); } | ||
| else console.log(`[warn] ${relays[i]} - ${o.reason?.message || o.reason}`); | ||
| }); | ||
| if (!accepted) throw new Error(`no relay accepted the deletion event (0/${relays.length})`); | ||
| RUN.results.push({ platform: 'nostr', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] published a NIP-09 deletion for event ${args.id} (${accepted}/${relays.length} relays) - relays MAY still ignore it.`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('NOSTR_PRIVATE_KEY')) { | ||
| RUN.results.push({ platform: 'nostr', action: 'probe', ok: false, detail: 'not configured (NOSTR_PRIVATE_KEY missing)' }); | ||
| return; | ||
| } | ||
| requireWebSocket(); | ||
| try { | ||
| const keys = deriveKeys(); | ||
| const relays = relayUrls(); | ||
| if (!relays.length) { | ||
| RUN.results.push({ platform: 'nostr', action: 'probe', ok: false, detail: 'not configured (NOSTR_RELAYS missing)' }); | ||
| return; | ||
| } | ||
| const outcomes = await Promise.allSettled(relays.map((url) => probeRelay(url))); | ||
| const reachable = outcomes.filter((o) => o.status === 'fulfilled').length; | ||
| if (reachable) RUN.results.push({ platform: 'nostr', action: 'probe', ok: true, detail: `connected as ${keys.npub.slice(0, 13)}... via ${reachable}/${relays.length} relays`, tokenExpiresAt: null }); | ||
| else RUN.results.push({ platform: 'nostr', action: 'probe', ok: false, detail: `no relay reachable (0/${relays.length})` }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'nostr', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // Offline proof the in-file crypto is CORRECT before any key touches a relay: | ||
| // the official BIP340 vector, 20 random sign/verify rounds, bech32 roundtrips. | ||
| async function cmdSelftest() { | ||
| const fail = (msg) => { console.error(`[err] selftest FAILED - ${msg}`); process.exit(1); }; | ||
| // (a) BIP340 official test vector 0: seckey 3, aux = msg = 32 zero bytes. | ||
| const EXPECTED_PUB = 'F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9'; | ||
| const EXPECTED_SIG = 'E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0'; | ||
| const zeros = Buffer.alloc(32); | ||
| const pub = pubkeyBytes(3n); | ||
| if (pub.toString('hex').toUpperCase() !== EXPECTED_PUB) fail(`pubkey(3) = ${pub.toString('hex')} (expected ${EXPECTED_PUB})`); | ||
| const sig = schnorrSign(zeros, 3n, zeros); | ||
| if (sig.toString('hex').toUpperCase() !== EXPECTED_SIG) fail(`sign vector mismatch: got ${sig.toString('hex')}`); | ||
| if (!schnorrVerify(zeros, pub, sig)) fail('the official vector signature does not verify'); | ||
| // (b) 20 random keys: sign a random message, verify it, and prove a tampered | ||
| // message does NOT verify. | ||
| for (let i = 0; i < 20; i++) { | ||
| let d; | ||
| do { d = bytesToBig(crypto.randomBytes(32)); } while (d === 0n || d >= SECP_N); | ||
| const msg = crypto.randomBytes(32); | ||
| const pubI = pubkeyBytes(d); | ||
| const sigI = schnorrSign(msg, d); | ||
| if (!schnorrVerify(msg, pubI, sigI)) fail(`random sign/verify round ${i} failed`); | ||
| const tampered = Buffer.from(msg); | ||
| tampered[0] ^= 0xff; | ||
| if (schnorrVerify(tampered, pubI, sigI)) fail(`tampered message verified on round ${i}`); | ||
| } | ||
| // (c) bech32 roundtrips: nsec/npub/note all decode back to the exact bytes. | ||
| for (const hrp of ['nsec', 'npub', 'note']) { | ||
| const bytes = crypto.randomBytes(32); | ||
| const encoded = bech32Encode(hrp, bytes); | ||
| if (!encoded.startsWith(`${hrp}1`)) fail(`bech32 ${hrp} prefix mismatch`); | ||
| if (!bech32Decode(encoded, hrp).equals(bytes)) fail(`bech32 ${hrp} roundtrip mismatch`); | ||
| } | ||
| if (!bech32Decode(npubEncode(pub.toString('hex')), 'npub').equals(pub)) fail('npub(vector pubkey) roundtrip mismatch'); | ||
| console.log('[ok] selftest passed (BIP340 vector 0 + 20 random sign/verify rounds + bech32 nsec/npub/note roundtrips).'); | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| keygen: cmdKeygen, | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| selftest: cmdSelftest, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('nostr') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'nostr', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] nostr ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/nostr-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
| #!/usr/bin/env node | ||
| /** | ||
| * wordpress-social.mjs - direct WordPress publishing via the REST API. | ||
| * | ||
| * Sibling of scripts/telegram-social.mjs / reddit-social.mjs: the same zero-dep, | ||
| * plan-driven, publish-straight-from-the-local-render pattern - but this is the | ||
| * first LONG-FORM lane: a plan entry maps to a full blog post (title + body + | ||
| * excerpt + tags + featured image), not a short caption. | ||
| * | ||
| * Works against any SELF-HOSTED WordPress (>= 5.6) and wordpress.com sites - both | ||
| * ship Application Passwords + the wp/v2 REST API out of the box. The REST API | ||
| * must be enabled (it is by default; some security plugins turn it off, which | ||
| * surfaces here as a 404 on /wp-json). | ||
| * | ||
| * NATIVE SCHEDULING (owner decision 2026-07-05, reversing the earlier | ||
| * publish-at-due-time choice): `schedule` creates the post ahead of its due time | ||
| * with status 'future' + date_gmt, so WordPress's own scheduler (wp-cron) | ||
| * publishes it even when this machine is off - the same survives-power-off model | ||
| * as yt-social.mjs. The plan stays the source of truth via a full reconcile | ||
| * story: the post KEEPS ITS ID across the future->publish transition (like a | ||
| * YouTube publishAt video, unlike a Mastodon queue entry), so it is findable, | ||
| * verifiable (GET /posts/<id> reports 'future' vs 'publish') and cancellable | ||
| * (`delete --id`; lib/writes.mjs nativeHandoff drives that on unschedule/ | ||
| * reschedule/edit). wp-cron only fires on site traffic, so a low-traffic site | ||
| * can leave a post 'future' PAST its date - verify reads that back as | ||
| * 'future-overdue' and the scheduler's wordpress-release lane flips it live | ||
| * (`release`, a one-field status update - the exact yt private-overdue model). | ||
| * `publish-due` is kept for manual/late runs - `schedule` falls back to the same | ||
| * immediate publish when an entry is already past due. | ||
| * | ||
| * AUTH - an Application Password, no browser OAuth: | ||
| * WORDPRESS_SITE_URL the site root, e.g. https://blog.example.com | ||
| * (a trailing slash is tolerated - normalized away). | ||
| * WORDPRESS_USERNAME the WP username the password was minted for. | ||
| * WORDPRESS_APP_PASSWORD minted under Users -> Profile -> Application | ||
| * Passwords in wp-admin. Long-lived until revoked, so | ||
| * `refresh` is a no-op. Sent as HTTP Basic | ||
| * (base64 of "username:appPassword") on EVERY request. | ||
| * The user needs a role that may publish_posts (Author/Editor/Administrator); | ||
| * `connect`/`auth` validates exactly that (GET /users/me) and writes nothing. | ||
| * | ||
| * CONTENT - the long-form mapping (raw plan post fields): | ||
| * post.title REQUIRED - the article title. A due entry without one is | ||
| * warn-skipped (a blog post cannot exist untitled). | ||
| * post.body the article body in MARKDOWN (falls back to post.caption), | ||
| * converted to HTML by lib/markdown.mjs - a deliberately small | ||
| * subset (headings, emphasis, links, lists, code, blockquotes); | ||
| * anything fancier is authored as literal HTML in the body. | ||
| * post.excerpt optional hand-written excerpt (omitted -> WP auto-generates). | ||
| * post.tags a comma-separated string ("launch, changelog"); each name is | ||
| * resolved to an existing tag id (case-insensitive, entity- | ||
| * decoded) or created on the fly - race-safe via term_exists. | ||
| * media the post's local IMAGE (jpg/jpeg/png/webp/gif) uploads to | ||
| * /media and becomes the featured image; a video cannot be a | ||
| * WordPress featured image and is skipped with an [info] note. | ||
| * The public permalink comes back on the created post's `link` field - it is | ||
| * logged on publish and surfaced through verify. | ||
| * | ||
| * Commands: | ||
| * auth | connect validate the application password (GET /users/me); writes nothing | ||
| * refresh no-op (application passwords are long-lived) - sibling parity | ||
| * validate --plan <p> [--only <id>] side-effect-free preview, never posts | ||
| * schedule --plan <p> [--only <id>] [--dry-run] natively schedule (status 'future'); publishes NOW when past due | ||
| * release --plan <p> [--only <id>] flip a future-overdue post live (wp-cron backstop) | ||
| * publish-due --plan <p> [--only <id>] [--dry-run] publish any due WordPress entry (manual/late path) | ||
| * status --plan <p> list WordPress plan entries | ||
| * verify --plan <p> [--only <id>] read-only liveness (GET /posts/<id>; 'future' reads scheduled/future-overdue) | ||
| * insights --plan <p> [--only <id>] no-op (core WP exposes no post metrics) | ||
| * probe read-only health probe (/users/me) | ||
| * delete --id <postId> permanently delete a post (?force=true; also cancels a scheduled one) | ||
| */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { resolveMode, isMockableCommand } from '../lib/mode.mjs'; | ||
| import { runMockCommand } from '../lib/drivers/mock-driver.mjs'; | ||
| import { envPath } from '../lib/util.mjs'; | ||
| import { mdToHtml } from '../lib/markdown.mjs'; | ||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const ENV_PATH = envPath(); | ||
| // ---------- env helpers (same shape as the sibling engines) ---------- | ||
| function readEnvRaw() { | ||
| return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : ''; | ||
| } | ||
| function readEnv(name) { | ||
| const m = readEnvRaw().match(new RegExp(`^${name}=(.+)$`, 'm')); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| // The site root, normalized: a trailing slash would double up in `${site}/wp-json`. | ||
| function siteUrl() { | ||
| const raw = readEnv('WORDPRESS_SITE_URL'); | ||
| return raw ? raw.replace(/\/+$/, '') : null; | ||
| } | ||
| const apiBase = () => `${siteUrl()}/wp-json/wp/v2`; | ||
| function siteHost() { | ||
| const s = siteUrl(); | ||
| try { return new URL(s).host; } catch { return s || 'unknown'; } | ||
| } | ||
| // ---------- REST API helper ---------- | ||
| function basicAuth() { | ||
| const username = readEnv('WORDPRESS_USERNAME') || ''; | ||
| const appPassword = readEnv('WORDPRESS_APP_PASSWORD') || ''; | ||
| return `Basic ${Buffer.from(`${username}:${appPassword}`).toString('base64')}`; | ||
| } | ||
| // `raw` uploads bytes as-is (the /media sideload contract: raw body + the | ||
| // filename in Content-Disposition); `form` is urlencoded; `body` is JSON. | ||
| async function wp(method, pathname, { body, form, raw } = {}) { | ||
| if (!siteUrl()) throw new Error('WORDPRESS_SITE_URL is not set in .env'); | ||
| const url = `${apiBase()}${pathname}`; | ||
| const headers = { Authorization: basicAuth() }; | ||
| const init = { method, headers }; | ||
| if (raw) { | ||
| headers['Content-Type'] = raw.contentType; | ||
| headers['Content-Disposition'] = `attachment; filename="${raw.filename}"`; | ||
| init.body = raw.buffer; | ||
| } else if (form) { | ||
| headers['Content-Type'] = 'application/x-www-form-urlencoded'; | ||
| init.body = new URLSearchParams(form).toString(); | ||
| } else if (body !== undefined) { | ||
| headers['Content-Type'] = 'application/json'; | ||
| init.body = JSON.stringify(body); | ||
| } | ||
| const res = await fetch(url, init); | ||
| const text = await res.text(); | ||
| let data; | ||
| try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } | ||
| if (!res.ok) { | ||
| const err = new Error(`WordPress ${method} ${pathname}: HTTP ${res.status} - ${data.message || data.code || data.raw || text || 'unknown'}`); | ||
| err.status = res.status; | ||
| err.wpCode = data.code || null; | ||
| err.wpData = data.data || null; // term_exists carries the winner's term_id here | ||
| throw err; | ||
| } | ||
| return data; | ||
| } | ||
| // ---------- plan helpers (same shape as the sibling engines) ---------- | ||
| function loadPlan(planPath) { | ||
| const abs = path.resolve(planPath); | ||
| return { abs, plan: JSON.parse(fs.readFileSync(abs, 'utf8')) }; | ||
| } | ||
| const ENGINE_OWNED_FIELDS = ['fbPostId', 'fbReelId', 'igMediaId', 'liPostId', 'ytVideoId', 'xPostId', 'tgMessageId', 'dcMessageId', 'redditPostId', 'pinId', 'tiktokVideoId', 'mastodonStatusId', 'wordpressPostId', 'ghostPostId', 'nostrEventId', 'gbpPostId', 'status', 'postedAt', 'attempts']; | ||
| async function withPlanLock(abs, fn) { | ||
| const lockDir = `${abs}.lock.d`; | ||
| for (let i = 0; ; i++) { | ||
| try { fs.mkdirSync(lockDir); break; } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| let ageMs = 0; | ||
| try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { continue; } | ||
| if (ageMs > 15 * 60 * 1000) { try { fs.rmdirSync(lockDir); } catch { /* racing steal */ } continue; } | ||
| if (i >= 5) throw new Error(`plan lock busy: ${lockDir}`); | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| } | ||
| try { return fn(); } finally { try { fs.rmdirSync(lockDir); } catch { /* released */ } } | ||
| } | ||
| async function savePlan(abs, plan, touchedIds = null) { | ||
| await withPlanLock(abs, () => { | ||
| let out = plan; | ||
| if (Array.isArray(touchedIds)) { | ||
| try { | ||
| const disk = JSON.parse(fs.readFileSync(abs, 'utf8')); | ||
| for (const id of touchedIds) { | ||
| const mem = (plan.posts || []).find((p) => p.id === id); | ||
| const target = (disk.posts || []).find((p) => p.id === id); | ||
| if (!mem || !target) continue; | ||
| for (const f of ENGINE_OWNED_FIELDS) if (mem[f] !== undefined) target[f] = mem[f]; | ||
| } | ||
| out = disk; | ||
| } catch { /* unreadable disk copy - fall back to in-memory plan */ } | ||
| } | ||
| const tmp = `${abs}.tmp-${process.pid}`; | ||
| fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`); | ||
| fs.renameSync(tmp, abs); | ||
| }); | ||
| } | ||
| function appendAttempt(post, entry) { | ||
| post.attempts = Array.isArray(post.attempts) ? post.attempts : []; | ||
| post.attempts.push(entry); | ||
| } | ||
| const RUN = { results: [] }; | ||
| let JSON_MODE = false; | ||
| let ACTOR = 'cli'; | ||
| function resolveMediaPath(plan, post) { | ||
| const root = process.env.PENDPOST_ROOT ? path.resolve(process.env.PENDPOST_ROOT) : path.resolve(__dirname, '..'); | ||
| if (post.path) { | ||
| const abs = path.isAbsolute(post.path) ? post.path : path.resolve(root, post.path); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| if (post.file) { | ||
| const rel = path.join(plan.folder || '', post.file); | ||
| const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel); | ||
| if (fs.existsSync(abs)) return abs; | ||
| } | ||
| return null; | ||
| } | ||
| const isWordpress = (post) => (post.platforms || []).includes('wordpress'); | ||
| const titleFor = (post) => (post.title ? String(post.title).trim() : ''); | ||
| const bodyMarkdown = (post) => (post.body || post.caption || '').trim(); | ||
| const excerptFor = (post) => (post.excerpt ? String(post.excerpt).trim() : ''); | ||
| // post.tags is a comma-separated string ("a, b, c") - split, trim, drop empties. | ||
| function tagNames(post) { | ||
| return String(post.tags || '').split(',').map((t) => t.trim()).filter(Boolean); | ||
| } | ||
| // Only an image can be a WordPress featured image - keyed off the extension. | ||
| const IMAGE_CONTENT_TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' }; | ||
| function imageContentType(localPath) { | ||
| return IMAGE_CONTENT_TYPES[path.extname(localPath).toLowerCase()] || null; | ||
| } | ||
| // WP returns tag names HTML-entity-encoded (e.g. "&") - decode before the | ||
| // case-insensitive exact match. Named non-amp entities first, & last. | ||
| function decodeEntities(s) { | ||
| return String(s) | ||
| .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) | ||
| .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))) | ||
| .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") | ||
| .replace(/&/g, '&'); | ||
| } | ||
| // Resolve each tag name to an id: exact match on the decoded name first, then | ||
| // create. A concurrent create loses with 400 term_exists - the error body | ||
| // carries the winner's id in .data.term_id, so the race resolves to the same id. | ||
| async function resolveTagIds(names) { | ||
| const ids = []; | ||
| for (const name of names) { | ||
| const found = await wp('GET', `/tags?search=${encodeURIComponent(name)}&per_page=100`); | ||
| const hit = (Array.isArray(found) ? found : []).find((t) => decodeEntities(t.name).toLowerCase() === name.toLowerCase()); | ||
| if (hit) { ids.push(hit.id); continue; } | ||
| try { | ||
| const created = await wp('POST', '/tags', { body: { name } }); | ||
| ids.push(created.id); | ||
| } catch (err) { | ||
| const existingId = err.wpCode === 'term_exists' ? err.wpData?.term_id : null; | ||
| if (!existingId) throw err; | ||
| ids.push(existingId); | ||
| } | ||
| } | ||
| return ids; | ||
| } | ||
| // Shared content assembly for publish-due and schedule: resolve tag ids, upload | ||
| // the featured image, render the markdown - everything except status/date. | ||
| async function buildPayload(post, { title, md, featuredPath }) { | ||
| const tagIds = await resolveTagIds(tagNames(post)); | ||
| let featuredMediaId = null; | ||
| if (featuredPath) { | ||
| const media = await wp('POST', '/media', { | ||
| raw: { buffer: fs.readFileSync(featuredPath), contentType: imageContentType(featuredPath), filename: path.basename(featuredPath) }, | ||
| }); | ||
| featuredMediaId = media.id; | ||
| } | ||
| const payload = { title, content: mdToHtml(md), tags: tagIds }; | ||
| const excerpt = excerptFor(post); | ||
| if (excerpt) payload.excerpt = excerpt; | ||
| if (featuredMediaId) payload.featured_media = featuredMediaId; | ||
| return payload; | ||
| } | ||
| // The per-entry content gate shared by publish-due and schedule: returns | ||
| // { title, md, featuredPath } or null (with the warn logged) when unpublishable. | ||
| function publishableContent(plan, post) { | ||
| const title = titleFor(post); | ||
| if (!title) { console.log(`[warn] ${post.id}: no title (post.title is required for a blog post) - skipping.`); return null; } | ||
| const md = bodyMarkdown(post); | ||
| if (!md) { console.log(`[warn] ${post.id}: no body (post.body/caption) - skipping.`); return null; } | ||
| let featuredPath = null; | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (mediaPath) { | ||
| if (imageContentType(mediaPath)) featuredPath = mediaPath; | ||
| else console.log(`[info] ${post.id}: video media is not used as a WordPress featured image.`); | ||
| } | ||
| return { title, md, featuredPath }; | ||
| } | ||
| // ---------- commands ---------- | ||
| async function cmdAuth() { | ||
| const missing = []; | ||
| if (!siteUrl()) missing.push('WORDPRESS_SITE_URL'); | ||
| if (!readEnv('WORDPRESS_USERNAME')) missing.push('WORDPRESS_USERNAME'); | ||
| if (!readEnv('WORDPRESS_APP_PASSWORD')) missing.push('WORDPRESS_APP_PASSWORD'); | ||
| if (missing.length) { console.error(`[err] WordPress credentials missing in .env: ${missing.join(', ')}`); process.exit(2); } | ||
| const me = await wp('GET', '/users/me?context=edit'); | ||
| console.log(`[ok] authenticated as ${me.name} (${me.slug}) on ${siteUrl()}`); | ||
| if (!me?.capabilities?.publish_posts) console.log('[warn] this user cannot publish_posts - publish-due will fail (needs an Author/Editor/Administrator role).'); | ||
| RUN.results.push({ platform: 'wordpress', action: 'auth', ok: true, detail: `${me.name} (${me.slug}) on ${siteHost()}` }); | ||
| } | ||
| async function cmdRefresh() { | ||
| console.log('[info] WordPress application passwords are long-lived (no refresh).'); | ||
| } | ||
| async function cmdValidate(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('================ VALIDATION ONLY - NOTHING WILL BE PUBLISHED ================'); | ||
| try { | ||
| const me = await wp('GET', '/users/me?context=edit'); | ||
| console.log(`[ok] Credentials valid - authenticated as ${me.name} (${me.slug}).`); | ||
| if (!me?.capabilities?.publish_posts) console.log('[warn] this user cannot publish_posts.'); | ||
| } catch (err) { | ||
| console.log(`[warn] auth check failed (${err.message}). Continuing to content preview.`); | ||
| } | ||
| const targets = (plan.posts || []).filter((p) => isWordpress(p) && (!args.only || p.id === args.only)); | ||
| if (!targets.length) { console.log('[warn] No WordPress entries match.'); return; } | ||
| for (const post of targets) { | ||
| const title = titleFor(post); | ||
| const md = bodyMarkdown(post); | ||
| const excerpt = excerptFor(post); | ||
| const tags = tagNames(post); | ||
| console.log(`\n----- ${post.id} -----`); | ||
| console.log(`[preview] title: ${title || '(MISSING - required)'}`); | ||
| console.log(`[preview] body: ${md.length} chars of markdown${md ? '' : ' (EMPTY)'}`); | ||
| console.log(`[preview] excerpt: ${excerpt || '(none - WordPress auto-generates)'}`); | ||
| console.log(`[preview] tags: ${tags.length ? tags.join(', ') : '(none)'}`); | ||
| const mediaPath = resolveMediaPath(plan, post); | ||
| if (!mediaPath) { | ||
| console.log('[preview] featured image: (none)'); | ||
| } else if (imageContentType(mediaPath)) { | ||
| console.log(`[preview] featured image: ${path.basename(mediaPath)} (${(fs.statSync(mediaPath).size / 1e6).toFixed(1)} MB)`); | ||
| } else { | ||
| console.log(`[info] ${post.id}: video media is not used as a WordPress featured image.`); | ||
| } | ||
| } | ||
| console.log('\n================ VALIDATION COMPLETE ================'); | ||
| } | ||
| async function cmdPublishDue(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!siteUrl()) throw new Error('WORDPRESS_SITE_URL is not set - cannot publish.'); | ||
| const now = Date.now(); | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isWordpress(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs) || dueMs > now) continue; | ||
| const content = publishableContent(plan, post); | ||
| if (!content) continue; | ||
| const { title, md, featuredPath } = content; | ||
| if (args['dry-run']) { | ||
| console.log(`[dry] ${post.id}: would publish "${title}" (${md.length} chars markdown, ${tagNames(post).length} tag(s)${featuredPath ? `, featured image ${path.basename(featuredPath)}` : ''}).`); | ||
| continue; | ||
| } | ||
| console.log(`[info] ${post.id}: publishing "${title}" to ${siteHost()}...`); | ||
| try { | ||
| const payload = await buildPayload(post, content); | ||
| payload.status = 'publish'; | ||
| const resp = await wp('POST', '/posts', { body: payload }); | ||
| if (!resp?.id) throw new Error(`create returned no id: ${JSON.stringify(resp).slice(0, 200)}`); | ||
| post.wordpressPostId = String(resp.id); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'wordpress', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'publish', ok: true, id: String(resp.id) }); | ||
| console.log(`[ok] ${post.id}: published on WordPress (post ${resp.id}) - ${resp.link || '(no link returned)'}`); | ||
| published += 1; | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'wordpress', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'publish', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: WordPress publish failed - ${err.message}`); | ||
| continue; | ||
| } | ||
| } | ||
| console.log(`[done] publish-due complete - ${published} post(s) published.`); | ||
| } | ||
| // Native scheduling: create every approved future entry NOW with status 'future' | ||
| // + date_gmt, so wp-cron publishes it with this machine off. The post id is | ||
| // minted at schedule time and survives the future->publish transition, so | ||
| // verify/release/delete all address the same object. A past-due entry publishes | ||
| // immediately instead (the old publish-due behavior - never strand a late | ||
| // approval); there is no minimum lead (any future date_gmt is accepted). | ||
| async function cmdSchedule(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| if (!siteUrl()) throw new Error('WORDPRESS_SITE_URL is not set - cannot schedule.'); | ||
| const now = Date.now(); | ||
| let scheduled = 0; | ||
| let published = 0; | ||
| for (const post of plan.posts || []) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!isWordpress(post)) continue; | ||
| if (post.executionMode !== 'fully-scheduled') continue; | ||
| if (post.status !== 'planned') continue; | ||
| if ((post.approval || 'draft') !== 'approved') { | ||
| console.log(`[skip] ${post.id}: approval is "${post.approval || 'draft'}" - only approved posts publish.`); | ||
| continue; | ||
| } | ||
| if (post.wordpressPostId) { console.log(`[skip] ${post.id}: already has wordpressPostId ${post.wordpressPostId}.`); continue; } | ||
| const dueMs = Date.parse(post.scheduledAt); | ||
| if (Number.isNaN(dueMs)) { console.log(`[warn] ${post.id}: unparseable scheduledAt "${post.scheduledAt}" - skipping.`); continue; } | ||
| const content = publishableContent(plan, post); | ||
| if (!content) continue; | ||
| const { title, md, featuredPath } = content; | ||
| const pastDue = dueMs <= now; | ||
| if (args['dry-run']) { | ||
| console.log(pastDue | ||
| ? `[dry] ${post.id}: past due - would publish "${title}" immediately.` | ||
| : `[dry] ${post.id}: would natively schedule "${title}" (status 'future', date_gmt ${new Date(dueMs).toISOString()}${featuredPath ? `, featured image ${path.basename(featuredPath)}` : ''}).`); | ||
| continue; | ||
| } | ||
| console.log(pastDue | ||
| ? `[info] ${post.id}: past due - publishing "${title}" to ${siteHost()} immediately...` | ||
| : `[info] ${post.id}: natively scheduling "${title}" on ${siteHost()} for ${new Date(dueMs).toISOString()}...`); | ||
| try { | ||
| const payload = await buildPayload(post, content); | ||
| if (pastDue) { | ||
| payload.status = 'publish'; | ||
| } else { | ||
| payload.status = 'future'; | ||
| // date_gmt (not date): unambiguous UTC, independent of the site timezone. | ||
| // WP's REST date format takes no milliseconds/zone suffix. | ||
| payload.date_gmt = new Date(dueMs).toISOString().slice(0, 19); | ||
| } | ||
| const resp = await wp('POST', '/posts', { body: payload }); | ||
| if (!resp?.id) throw new Error(`create returned no id: ${JSON.stringify(resp).slice(0, 200)}`); | ||
| post.wordpressPostId = String(resp.id); | ||
| if (pastDue) { | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date(now).toISOString(); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'wordpress', action: 'publish', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'publish', ok: true, id: String(resp.id) }); | ||
| console.log(`[ok] ${post.id}: published on WordPress (post ${resp.id}) - ${resp.link || '(no link returned)'}`); | ||
| published += 1; | ||
| } else { | ||
| post.status = 'scheduled'; | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'wordpress', action: 'schedule-native', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'schedule-native', ok: true, id: String(resp.id) }); | ||
| console.log(`[ok] ${post.id}: natively scheduled (post ${resp.id}, status=${resp.status}, publishes ${resp.date_gmt || payload.date_gmt} UTC).`); | ||
| scheduled += 1; | ||
| } | ||
| } catch (err) { | ||
| const action = pastDue ? 'publish' : 'schedule-native'; | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'wordpress', action, ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action, ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: WordPress ${pastDue ? 'publish' : 'schedule'} failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log(`[done] schedule complete - ${scheduled} natively scheduled, ${published} published (past due).`); | ||
| } | ||
| // Recover a natively-scheduled post wp-cron left 'future' past its date (verify | ||
| // read-back 'future-overdue' - the low-traffic-site failure mode, since wp-cron | ||
| // only runs on page hits): flip it live with a one-field status update - NEVER a | ||
| // re-create. Idempotent and safe on a bare CLI run: an already-published post is | ||
| // a no-op, a still-future schedule is left untouched (the exact yt release model). | ||
| async function cmdRelease(args) { | ||
| const { abs, plan } = loadPlan(args.plan); | ||
| const now = Date.now(); | ||
| for (const post of (plan.posts || []).filter(isWordpress)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.wordpressPostId) continue; | ||
| try { | ||
| const resp = await wp('GET', `/posts/${encodeURIComponent(post.wordpressPostId)}?context=edit`); | ||
| if (resp?.status === 'publish') { | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'release', ok: true, id: post.wordpressPostId, live: true, state: 'published', permalink: resp.link || null }); | ||
| console.log(`[ok] ${post.id}: already published - no action.`); | ||
| continue; | ||
| } | ||
| if (resp?.status !== 'future') { | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'release', ok: false, errorCode: 'invalid_input', errorMessage: `post ${post.wordpressPostId} is '${resp?.status}' - release only flips a 'future' post` }); | ||
| console.log(`[skip] ${post.id}: post is '${resp?.status}' - nothing to release.`); | ||
| continue; | ||
| } | ||
| const fireMs = Date.parse(`${resp.date_gmt}Z`); | ||
| if (Number.isFinite(fireMs) && fireMs > now) { | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'release', ok: false, errorCode: 'invalid_input', errorMessage: `still natively scheduled for ${resp.date_gmt} UTC - not releasing early` }); | ||
| console.log(`[skip] ${post.id}: still scheduled (${resp.date_gmt} UTC) - not releasing early.`); | ||
| continue; | ||
| } | ||
| const updated = await wp('POST', `/posts/${encodeURIComponent(post.wordpressPostId)}`, { body: { status: 'publish' } }); | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'wordpress', action: 'release', ok: true, errorCode: null, errorMessage: null, actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'release', ok: true, id: post.wordpressPostId, live: true, state: 'published', permalink: updated?.link || null }); | ||
| console.log(`[ok] ${post.id}: released - post ${post.wordpressPostId} is now published.`); | ||
| } catch (err) { | ||
| appendAttempt(post, { ts: new Date().toISOString(), platform: 'wordpress', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300), actor: ACTOR }); | ||
| await savePlan(abs, plan, [post.id]); | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'release', ok: false, errorCode: 'engine_failure', errorMessage: err.message.slice(0, 300) }); | ||
| console.error(`[err] ${post.id}: release failed - ${err.message}`); | ||
| } | ||
| } | ||
| console.log('[done] release complete.'); | ||
| } | ||
| async function cmdStatus(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| console.log('[info] WordPress plan entries:'); | ||
| for (const post of (plan.posts || []).filter(isWordpress)) { | ||
| console.log(` ${post.id.padEnd(18)} ${String(post.status).padEnd(10)} ${post.scheduledAt} mode=${post.executionMode}${post.wordpressPostId ? ` wp=${post.wordpressPostId}` : ''}`); | ||
| } | ||
| } | ||
| // Real read-back liveness: GET /posts/<id> returns the current status + the | ||
| // public permalink; a 404 (deleted/trashed with force) reads as missing. A | ||
| // natively-scheduled post reads 'scheduled' while its date is still ahead and | ||
| // 'future-overdue' once wp-cron has missed it (which owes the scheduler's | ||
| // wordpress-release lane, mirroring yt's private-overdue). | ||
| async function cmdVerify(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| const now = Date.now(); | ||
| for (const post of (plan.posts || []).filter(isWordpress)) { | ||
| if (args.only && post.id !== args.only) continue; | ||
| if (!post.wordpressPostId) continue; | ||
| try { | ||
| const resp = await wp('GET', `/posts/${encodeURIComponent(post.wordpressPostId)}?context=edit`); | ||
| const live = resp?.status === 'publish'; | ||
| let state = live ? 'published' : String(resp?.status || 'unknown'); | ||
| if (resp?.status === 'future') { | ||
| const fireMs = Date.parse(`${resp.date_gmt}Z`); | ||
| state = Number.isFinite(fireMs) && fireMs <= now ? 'future-overdue' : 'scheduled'; | ||
| } | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'verify', ok: true, live, state, permalink: live ? (resp?.link || null) : null, id: post.wordpressPostId }); | ||
| } catch (err) { | ||
| if (err.status === 404) { | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'verify', ok: true, live: false, state: 'missing', permalink: null, id: post.wordpressPostId }); | ||
| } else { | ||
| RUN.results.push({ postId: post.id, platform: 'wordpress', action: 'verify', ok: false, errorCode: 'engine_failure', errorMessage: String(err.message || err).slice(0, 200), id: post.wordpressPostId }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // Core WordPress has no view/engagement counters (that is a plugin concern, | ||
| // e.g. Jetpack Stats) - honest no-op. | ||
| async function cmdInsights(args) { | ||
| const { plan } = loadPlan(args.plan); | ||
| void plan; | ||
| console.log('[info] WordPress core exposes no post metrics - insights is a no-op.'); | ||
| } | ||
| async function cmdDelete(args) { | ||
| if (!args.id) { console.error('[err] delete requires --id <postId>'); process.exit(2); } | ||
| await wp('DELETE', `/posts/${encodeURIComponent(String(args.id))}?force=true`); | ||
| RUN.results.push({ platform: 'wordpress', action: 'delete', ok: true, id: String(args.id) }); | ||
| console.log(`[ok] deleted WordPress post ${args.id} (permanently - force=true).`); | ||
| } | ||
| async function cmdProbe() { | ||
| if (!readEnv('WORDPRESS_APP_PASSWORD')) { | ||
| RUN.results.push({ platform: 'wordpress', action: 'probe', ok: false, detail: 'not configured (WORDPRESS_APP_PASSWORD missing)' }); | ||
| return; | ||
| } | ||
| try { | ||
| const me = await wp('GET', '/users/me?context=edit'); | ||
| RUN.results.push({ platform: 'wordpress', action: 'probe', ok: true, detail: `connected as ${me.name} on ${siteHost()}`, tokenExpiresAt: null }); | ||
| } catch (err) { | ||
| RUN.results.push({ platform: 'wordpress', action: 'probe', ok: false, detail: String(err.message || err).slice(0, 200) }); | ||
| } | ||
| } | ||
| // ---------- main ---------- | ||
| function parseArgs(argv) { | ||
| const args = { _: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const next = argv[i + 1]; | ||
| if (next === undefined || next.startsWith('--')) args[key] = true; | ||
| else args[key] = argv[++i]; | ||
| } else args._.push(a); | ||
| } | ||
| return args; | ||
| } | ||
| const COMMANDS = { | ||
| auth: cmdAuth, | ||
| connect: cmdAuth, | ||
| refresh: cmdRefresh, | ||
| validate: cmdValidate, | ||
| schedule: cmdSchedule, | ||
| release: cmdRelease, | ||
| 'publish-due': cmdPublishDue, | ||
| status: cmdStatus, | ||
| verify: cmdVerify, | ||
| insights: cmdInsights, | ||
| delete: cmdDelete, | ||
| probe: cmdProbe, | ||
| }; | ||
| async function main() { | ||
| const args = parseArgs(process.argv); | ||
| JSON_MODE = Boolean(args.json); | ||
| ACTOR = typeof args.actor === 'string' ? args.actor : 'cli'; | ||
| if (JSON_MODE) console.log = (...a) => console.error(...a); | ||
| const commandName = args._[0]; | ||
| if (resolveMode('wordpress') === 'mock' && isMockableCommand(commandName)) { | ||
| const envelope = await runMockCommand({ | ||
| platform: 'wordpress', command: commandName, | ||
| planPath: typeof args.plan === 'string' ? path.resolve(String(args.plan)) : null, | ||
| only: typeof args.only === 'string' ? args.only : null, | ||
| }); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify(envelope)}\n`); | ||
| else console.error(`[mock] wordpress ${commandName}: ${envelope.results.length} result(s)`); | ||
| return; | ||
| } | ||
| const cmd = COMMANDS[commandName]; | ||
| if (!cmd) { | ||
| console.error(`Usage: node scripts/wordpress-social.mjs <${Object.keys(COMMANDS).join('|')}> [options]`); | ||
| process.exit(2); | ||
| } | ||
| if (['validate', 'schedule', 'release', 'publish-due', 'status', 'verify', 'insights'].includes(commandName) && !args.plan) { | ||
| console.error(`[err] ${commandName} requires --plan <post-plan.json>`); | ||
| process.exit(2); | ||
| } | ||
| await cmd(args); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: true, ...RUN })}\n`); | ||
| } | ||
| main().catch(async (err) => { | ||
| console.error('[err]', err.message || err); | ||
| if (JSON_MODE) process.stdout.write(`${JSON.stringify({ ok: false, error: String(err.message || err).slice(0, 300), ...RUN })}\n`); | ||
| process.exit(1); | ||
| }); |
@@ -32,4 +32,4 @@ <!doctype html> | ||
| </style> | ||
| <script type="module" crossorigin src="/assets/index-ClRLc0C4.js"></script> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-DCOqUPrq.css"> | ||
| <script type="module" crossorigin src="/assets/index-D81CdfNB.js"></script> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-Dy-rmPzt.css"> | ||
| </head> | ||
@@ -36,0 +36,0 @@ <body> |
+78
-0
@@ -162,2 +162,10 @@ // accounts.mjs - per-platform connection health from env presence + service state. | ||
| const redditSub = (readEnv('REDDIT_SUBREDDIT') || '').replace(/^\/?r\//, '').trim(); | ||
| // Mastodon: instance + the handle the engine persisted on auth -> profile URL. | ||
| const mastoInstance = (readEnv('MASTODON_INSTANCE_URL') || '').replace(/\/+$/, '').trim(); | ||
| const mastoHandle = (readEnv('MASTODON_HANDLE') || '').replace(/^@/, '').trim(); | ||
| // WordPress/Ghost: the site itself is the public account surface. | ||
| const wpSite = (readEnv('WORDPRESS_SITE_URL') || '').replace(/\/+$/, '').trim(); | ||
| const ghostSite = (readEnv('GHOST_SITE_URL') || '').replace(/\/+$/, '').trim(); | ||
| // Nostr: njump resolves an npub without knowing any relay. | ||
| const npub = (readEnv('NOSTR_NPUB') || '').trim(); | ||
| return { | ||
@@ -177,2 +185,8 @@ instagram: igHandle ? `https://www.instagram.com/${igHandle}` : null, | ||
| tiktok: null, | ||
| mastodon: mastoInstance && mastoHandle ? `${mastoInstance}/@${mastoHandle}` : null, | ||
| wordpress: wpSite || null, | ||
| ghost: ghostSite || null, | ||
| nostr: npub ? `https://njump.me/${npub}` : null, | ||
| // GBP: a location has no stable public URL derivable from its numeric id alone. | ||
| gbp: null, | ||
| }; | ||
@@ -188,2 +202,3 @@ } | ||
| const ttExpiresAt = Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT') || 0) || null; | ||
| const gbpExpiresAt = Number(readEnv('GBP_TOKEN_EXPIRES_AT') || 0) || null; | ||
| // live.<platform> = the last liveness probe (lib/health.mjs); presence-only | ||
@@ -316,2 +331,65 @@ // flags above say a credential EXISTS, live says it actually AUTHENTICATES. | ||
| }, | ||
| mastodon: { | ||
| mode: resolveMode('mastodon'), | ||
| // Static app token: configured == authenticated. The instance URL is also | ||
| // needed to publish, so a missing one surfaces in the hint (telegram pattern). | ||
| configured: Boolean(readEnv('MASTODON_ACCESS_TOKEN')), | ||
| authenticated: Boolean(readEnv('MASTODON_ACCESS_TOKEN')), | ||
| instanceUrl: (readEnv('MASTODON_INSTANCE_URL') || '').replace(/\/+$/, ''), | ||
| handle: (readEnv('MASTODON_HANDLE') || '').replace(/^@/, '').trim(), | ||
| tokenExpiry: 'static app access token (no expiry)', | ||
| hint: readEnv('MASTODON_ACCESS_TOKEN') | ||
| ? (readEnv('MASTODON_INSTANCE_URL') ? null : 'Token set but MASTODON_INSTANCE_URL is missing - set the instance URL.') | ||
| : 'Not connected - create an app under Preferences > Development on your instance, then node scripts/mastodon-social.mjs auth', | ||
| live: live.mastodon || null, | ||
| }, | ||
| wordpress: { | ||
| mode: resolveMode('wordpress'), | ||
| // Application password: all three values are needed to authenticate. | ||
| configured: Boolean(readEnv('WORDPRESS_SITE_URL') && readEnv('WORDPRESS_USERNAME') && readEnv('WORDPRESS_APP_PASSWORD')), | ||
| authenticated: Boolean(readEnv('WORDPRESS_SITE_URL') && readEnv('WORDPRESS_USERNAME') && readEnv('WORDPRESS_APP_PASSWORD')), | ||
| siteUrl: (readEnv('WORDPRESS_SITE_URL') || '').replace(/\/+$/, ''), | ||
| tokenExpiry: 'application password (no expiry)', | ||
| hint: (readEnv('WORDPRESS_SITE_URL') && readEnv('WORDPRESS_USERNAME') && readEnv('WORDPRESS_APP_PASSWORD')) | ||
| ? null | ||
| : 'Not connected - create an application password under Users > Profile, then node scripts/wordpress-social.mjs auth', | ||
| live: live.wordpress || null, | ||
| }, | ||
| ghost: { | ||
| mode: resolveMode('ghost'), | ||
| configured: Boolean(readEnv('GHOST_SITE_URL') && readEnv('GHOST_ADMIN_API_KEY')), | ||
| authenticated: Boolean(readEnv('GHOST_SITE_URL') && readEnv('GHOST_ADMIN_API_KEY')), | ||
| siteUrl: (readEnv('GHOST_SITE_URL') || '').replace(/\/+$/, ''), | ||
| tokenExpiry: 'Admin API key (per-run JWTs, no expiry)', | ||
| hint: (readEnv('GHOST_SITE_URL') && readEnv('GHOST_ADMIN_API_KEY')) | ||
| ? null | ||
| : 'Not connected - create a custom integration under Settings > Integrations, then node scripts/ghost-social.mjs auth', | ||
| live: live.ghost || null, | ||
| }, | ||
| nostr: { | ||
| mode: resolveMode('nostr'), | ||
| // Keypair lane: the signing key + at least one relay make it publishable. | ||
| configured: Boolean(readEnv('NOSTR_PRIVATE_KEY')), | ||
| authenticated: Boolean(readEnv('NOSTR_PRIVATE_KEY')), | ||
| npub: (readEnv('NOSTR_NPUB') || '').trim(), | ||
| relays: (readEnv('NOSTR_RELAYS') || '').split(',').map((r) => r.trim()).filter(Boolean).length, | ||
| tokenExpiry: 'nsec signing key (no expiry)', | ||
| hint: readEnv('NOSTR_PRIVATE_KEY') | ||
| ? (readEnv('NOSTR_RELAYS') ? null : 'Key set but NOSTR_RELAYS is missing - set at least one wss:// relay.') | ||
| : 'Not connected - node scripts/nostr-social.mjs keygen --save, then node scripts/nostr-social.mjs auth', | ||
| live: live.nostr || null, | ||
| }, | ||
| gbp: { | ||
| mode: resolveMode('gbp'), | ||
| configured: Boolean(readEnv('GBP_CLIENT_ID') && readEnv('GBP_CLIENT_SECRET')), | ||
| authenticated: Boolean(readEnv('GBP_ACCESS_TOKEN') || readEnv('GBP_REFRESH_TOKEN')), | ||
| accountId: readEnv('GBP_ACCOUNT_ID') || '', | ||
| locationId: readEnv('GBP_LOCATION_ID') || '', | ||
| tokenExpiresAt: gbpExpiresAt ? new Date(gbpExpiresAt).toISOString() : null, | ||
| tokenExpiry: 'short-lived access token, auto-refreshed', | ||
| hint: (readEnv('GBP_ACCESS_TOKEN') || readEnv('GBP_REFRESH_TOKEN')) | ||
| ? ((readEnv('GBP_ACCOUNT_ID') && readEnv('GBP_LOCATION_ID')) ? null : 'Connected but GBP_ACCOUNT_ID / GBP_LOCATION_ID is missing - set the target location.') | ||
| : 'Not connected - create OAuth credentials in the Google Cloud console, then node scripts/gbp-social.mjs auth', | ||
| live: live.gbp || null, | ||
| }, | ||
| scheduler: { | ||
@@ -318,0 +396,0 @@ running: schedulerRunning(), |
+17
-1
@@ -33,2 +33,3 @@ // api.mjs - JSON API for the dashboard. | ||
| import { getCloudStatus, setCloudEnabled, connectWorkspace, pushApprovedJobs, reconcileAlwaysOnBrands, disconnectWorkspace, signOutWorkspace, consolidateCloudKey, handLocalTokens, migrateToCloud, beginEnableConnect, completeEnableConnect, cloudClients, setClientAlwaysOn, getSubscription, startCheckout, startBillingPortal, setSpendCap, cloudSyncStatus } from './cloud-client.mjs'; | ||
| import { laneCapabilities } from './capabilities.mjs'; | ||
@@ -137,2 +138,4 @@ // Parses the body and guarantees a plain object - `null`, arrays and scalars | ||
| tiktok: { script: 'tiktok-social.mjs', cmd: 'auth', interactive: true, idEnv: 'TIKTOK_CLIENT_KEY', appIdFlag: '--client-key', appSecretFlag: '--client-secret' }, | ||
| // GBP (beta): Google loopback OAuth, default --client-id/--client-secret flags. | ||
| gbp: { script: 'gbp-social.mjs', cmd: 'auth', interactive: true, idEnv: 'GBP_CLIENT_ID' }, | ||
| }; | ||
@@ -150,2 +153,6 @@ | ||
| reddit: { script: 'reddit-social.mjs', fields: { redditClientId: 'REDDIT_CLIENT_ID', redditClientSecret: 'REDDIT_CLIENT_SECRET', redditUsername: 'REDDIT_USERNAME', redditPassword: 'REDDIT_PASSWORD' } }, | ||
| mastodon: { script: 'mastodon-social.mjs', fields: { instanceUrl: 'MASTODON_INSTANCE_URL', accessToken: 'MASTODON_ACCESS_TOKEN' } }, | ||
| wordpress: { script: 'wordpress-social.mjs', fields: { siteUrl: 'WORDPRESS_SITE_URL', username: 'WORDPRESS_USERNAME', appPassword: 'WORDPRESS_APP_PASSWORD' } }, | ||
| ghost: { script: 'ghost-social.mjs', fields: { siteUrl: 'GHOST_SITE_URL', adminApiKey: 'GHOST_ADMIN_API_KEY' } }, | ||
| nostr: { script: 'nostr-social.mjs', fields: { privateKey: 'NOSTR_PRIVATE_KEY', relays: 'NOSTR_RELAYS' } }, | ||
| }; | ||
@@ -288,3 +295,3 @@ const cleanArg = (v) => (typeof v === 'string' ? v.trim() : ''); | ||
| if (!lane && !staticLane) { | ||
| return { error: errorBody('invalid_input', `unknown platform "${platform}" (expected youtube | meta | linkedin | x | pinterest | tiktok | telegram | discord | reddit)`) }; | ||
| return { error: errorBody('invalid_input', `unknown platform "${platform}" (expected youtube | meta | linkedin | x | pinterest | tiktok | gbp | telegram | discord | reddit | mastodon | wordpress | ghost | nostr)`) }; | ||
| } | ||
@@ -829,2 +836,11 @@ | ||
| { | ||
| // GET /api/cloud/capabilities - the lane-capability map the UI badges lanes | ||
| // with (cloud 24/7 / native / local-only), proxied from the cloud's PUBLIC | ||
| // unauthenticated /v1/capabilities. Needs NO workspace and NO api key (it is | ||
| // pre-purchase honesty), is cached in lib/capabilities.mjs, and degrades to | ||
| // the conservative baked-in fallback offline - it never fails. | ||
| method: 'GET', path: '/api/cloud/capabilities', mcpTool: null, | ||
| handler: async (req, res) => sendJson(res, 200, await laneCapabilities()), | ||
| }, | ||
| { | ||
| // POST /api/cloud/connect - verify + persist { enabled, baseUrl, workspaceId } to | ||
@@ -831,0 +847,0 @@ // cloud.json. The api key stays in .env; it is never accepted here. |
+223
-15
@@ -277,3 +277,7 @@ // cloud-client.mjs - the OPTIONAL, gated client for the proprietary pendpost-cloud | ||
| for (const { campaign: c, post } of eligibleDuePosts(campaigns, {})) { | ||
| const lanes = lanesOwed(post); | ||
| // CLOUD_LANES only (same scope as the overdue backstop below): every other lane | ||
| // is LOCAL-ONLY - the local scheduler fires it on schedule regardless of cloud | ||
| // state, so pushing it would double-post the moment the cloud enables the lane. | ||
| // A post owing no cloud lane skips entirely: no job, no proof, no plan/media upload. | ||
| const lanes = lanesOwed(post).filter((l) => CLOUD_LANES.includes(l)); | ||
| if (!lanes.length) continue; | ||
@@ -595,2 +599,54 @@ | ||
| // Lift the webhook ID (the numeric snowflake right after /webhooks/) out of a Discord | ||
| // incoming-webhook URL - the account key the cloud vault stores Discord under. Returns | ||
| // '' for a malformed/absent URL, so the entry is skipped like any other missing account. | ||
| function discordWebhookId(url) { | ||
| try { | ||
| const parts = new URL(String(url)).pathname.split('/').filter(Boolean); // [...,'webhooks','<id>','<token>'] | ||
| const i = parts.indexOf('webhooks'); | ||
| return i >= 0 && parts[i + 1] ? parts[i + 1] : ''; | ||
| } catch { return ''; } | ||
| } | ||
| // A host the cloud (Fly) can actually reach: reject loopback / private-range IPs / | ||
| // link-local / .internal / .flycast / .local, which the cloud vault-ingest treats as | ||
| // SSRF targets and rejects (400). Mirrors that server-side filter so a dev's private | ||
| // relay never poisons the whole PUT. | ||
| function isPublicHost(host) { | ||
| const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, ''); | ||
| if (!h) return false; | ||
| if (h === 'localhost' || h.endsWith('.localhost')) return false; | ||
| if (h.endsWith('.internal') || h.endsWith('.flycast') || h.endsWith('.local')) return false; | ||
| if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) { // IPv4 literal | ||
| const [a, b] = h.split('.').map(Number); | ||
| if (a === 0 || a === 127 || a === 10) return false; | ||
| if (a === 172 && b >= 16 && b <= 31) return false; | ||
| if (a === 192 && b === 168) return false; | ||
| if (a === 169 && b === 254) return false; // link-local | ||
| if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT (RFC 6598) | ||
| return true; | ||
| } | ||
| if (h.includes(':')) { // IPv6 literal | ||
| if (h === '::1' || h === '::') return false; | ||
| if (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80')) return false; // ULA / link-local | ||
| return true; | ||
| } | ||
| return true; // a public DNS name | ||
| } | ||
| // Filter a comma-separated NOSTR_RELAYS list down to the PUBLIC wss:// relays the cloud | ||
| // can dial. The cloud rejects (400) ws:// and private/SSRF hosts, so sending them would | ||
| // fail the entire vault PUT; we seal only the reachable subset (original spelling kept). | ||
| function publicWssRelays(raw) { | ||
| const out = []; | ||
| for (const s of String(raw || '').split(',').map((x) => x.trim()).filter(Boolean)) { | ||
| let u; | ||
| try { u = new URL(s); } catch { continue; } | ||
| if (u.protocol !== 'wss:') continue; | ||
| if (!isPublicHost(u.hostname)) continue; | ||
| out.push(s); | ||
| } | ||
| return out; | ||
| } | ||
| // Per-platform: which local .env values make up the token + the account id the cloud | ||
@@ -600,4 +656,9 @@ // vault is keyed on. The operator-side inverse of the cloud worker's tokenEnvFor | ||
| // read here ONLY to seal it into the vault over TLS - never logged, never returned. | ||
| // X uses OAuth1's four keys, bundled as JSON under one vault entry (the worker | ||
| // unpacks it); the others are a single token. | ||
| // Most lanes are a single token; a few carry a compound shape the cloud worker unpacks: | ||
| // - x : OAuth1's four keys, bundled as JSON under one vault entry. | ||
| // - discord : the token IS the full webhook URL (the worker POSTs to it directly and | ||
| // REJECTS any token that isn't an /api/webhooks/ URL); the account id is | ||
| // the webhook id lifted out of that URL. | ||
| // - nostr : a JSON bundle { privateKey, relays } - the nsec/hex signing key plus the | ||
| // public wss:// relays the cloud will fan out to; the account id is the npub. | ||
| const PLATFORM_TOKEN_SOURCES = Object.freeze({ | ||
@@ -618,2 +679,17 @@ facebook: (e) => ({ token: e('META_PAGE_TOKEN'), accountId: e('META_PAGE_ID') }), | ||
| }, | ||
| telegram: (e) => ({ token: e('TELEGRAM_BOT_TOKEN'), accountId: e('TELEGRAM_CHANNEL_ID') }), | ||
| discord: (e) => { | ||
| const url = e('DISCORD_WEBHOOK_URL'); | ||
| return { token: url, accountId: discordWebhookId(url) }; | ||
| }, | ||
| nostr: (e) => { | ||
| const relays = publicWssRelays(e('NOSTR_RELAYS')); | ||
| const privateKey = e('NOSTR_PRIVATE_KEY'); | ||
| // No reachable relay -> no useful bundle: leave token '' so it is skipped (the cloud | ||
| // could not fire a relay-less account anyway), never a 400-guaranteed PUT. | ||
| return { | ||
| token: (privateKey && relays.length) ? JSON.stringify({ privateKey, relays }) : '', | ||
| accountId: e('NOSTR_NPUB'), | ||
| }; | ||
| }, | ||
| bluesky: (e) => ({ token: e('BLUESKY_APP_PASSWORD') || e('BSKY_APP_PASSWORD'), accountId: e('BLUESKY_HANDLE') || e('BSKY_HANDLE') }), | ||
@@ -727,2 +803,14 @@ }); | ||
| case 'x': return 'xPostId'; | ||
| // Cloud lanes (CLOUD_LANES): the cloud fires these, so reconcile MUST map their | ||
| // done-result id back to the plan or the post never flips to posted and the local | ||
| // backstop double-fires it. | ||
| case 'telegram': return 'tgMessageId'; | ||
| case 'discord': return 'dcMessageId'; | ||
| case 'nostr': return 'nostrEventId'; | ||
| // The remaining wave-2 lanes are LOCAL-ONLY (not in CLOUD_LANES) - the cloud never | ||
| // fires them, so these arms are defensive parity with platformPending only. | ||
| case 'mastodon': return 'mastodonStatusId'; | ||
| case 'wordpress': return 'wordpressPostId'; | ||
| case 'ghost': return 'ghostPostId'; | ||
| case 'gbp': return 'gbpPostId'; | ||
| default: return null; | ||
@@ -752,2 +840,3 @@ } | ||
| const failed = []; | ||
| const held = []; | ||
| for (const r of results) { | ||
@@ -761,2 +850,9 @@ if (r.clientId !== clientId) continue; | ||
| } | ||
| if (r.state === 'stale_held') { | ||
| // The cloud's staleness park (>15 min past due, never fired): NOT a failure - the | ||
| // job simply awaits OUR explicit retrigger (retriggerHeldJobs), because the cloud | ||
| // cannot know whether the local backstop already published while it was down. | ||
| held.push({ jobId: r.jobId, campaign: r.campaign, postId: r.postId, lane: r.lane }); | ||
| continue; | ||
| } | ||
| if (r.state !== 'done') { | ||
@@ -840,8 +936,42 @@ refused.push({ campaign: r.campaign, postId: r.postId, lane: r.lane, state: r.state, refusedCode: r.refusedCode || null }); | ||
| } | ||
| // Same for the stale-held handoff anchors: a now-posted post's cloudRetriggered | ||
| // entries are done their job (the backstop stood down); drop them so the map | ||
| // never grows past the live backlog. | ||
| if (s.cloudRetriggered && typeof s.cloudRetriggered === 'object') { | ||
| for (const p of patched) { | ||
| const prefix = `${p.campaign}:${p.postId}:`; | ||
| for (const k of Object.keys(s.cloudRetriggered)) if (k.startsWith(prefix)) delete s.cloudRetriggered[k]; | ||
| } | ||
| } | ||
| saveState(); | ||
| } catch { /* cache is best-effort */ } | ||
| } | ||
| return { ok: true, patched, skipped, refused, failed }; | ||
| return { ok: true, patched, skipped, refused, failed, held }; | ||
| } | ||
| // Active reachability probe: one cheap GET /v1/health per tick. cloudFetch already | ||
| // stamps cloudContact (okAt on reach, errorAt on network failure), so this keeps okAt | ||
| // honest during QUIET periods too - not just as a side-effect of push/reconcile - which | ||
| // is the difference between "unreachable" meaning genuinely down vs merely idle. It also | ||
| // captures the one liveness signal nothing else here sees: the cloud's OWN publisher | ||
| // worker. /v1/health returns { worker: { ok, stale } }; a reachable cloud whose worker is | ||
| // wedged accepts jobs and fires nothing (the silent-drop class from the Jul incident), so | ||
| // we record workerStale for the status roll-up. Best-effort; the write is guarded so a | ||
| // steady state does not churn state.json every minute. Skips when not cloud-configured. | ||
| export async function cloudHealthPing() { | ||
| const cfg = readCloudConfig(); | ||
| if (!cfg.workspaceId || !cloudApiKey()) return null; | ||
| let data; | ||
| try { | ||
| data = await cloudFetch('GET', '/v1/health'); | ||
| } catch { return null; } // contact already stamped errorAt; nothing else to record | ||
| const workerStale = Boolean(data && data.worker && (data.worker.stale === true || data.worker.ok === false)); | ||
| try { | ||
| const s = loadState(); | ||
| const prev = (s.cloudContact && typeof s.cloudContact === 'object') ? s.cloudContact : {}; | ||
| if (prev.workerStale !== workerStale) { s.cloudContact = { ...prev, workerStale }; saveState(); } | ||
| } catch { /* workerStale bookkeeping is best-effort */ } | ||
| return { ok: true, workerStale }; | ||
| } | ||
| // ---- the cloud guarantee roll-up (the header dot) --------------------------------- | ||
@@ -876,2 +1006,6 @@ // | ||
| const contactStale = !Number.isFinite(okAt) || (now - okAt) > SYNC_STALE_GRACE_MS; | ||
| // Only trust the worker-stale flag while contact is FRESH - if we cannot reach the | ||
| // cloud, its last-known worker state is stale information, and "unreachable" already | ||
| // covers it (checked first below). | ||
| const workerStale = !contactStale && Boolean(s.cloudContact && s.cloudContact.workerStale); | ||
| const sub = s.cloudSubView && typeof s.cloudSubView === 'object' ? s.cloudSubView : null; | ||
@@ -906,13 +1040,26 @@ | ||
| // Red reasons in severity order - the FIRST one is the headline the popover shows. | ||
| const reason = contactStale ? 'cloud_unreachable' | ||
| : overdueCount > 0 ? 'overdue_unpublished' | ||
| : failedCount > 0 ? 'cloud_failures' | ||
| : (sub && sub.syncStopped) ? 'sync_stopped' | ||
| : (sub && sub.alwaysOn === false) ? 'flag_divergence' | ||
| : manifestBroken ? 'manifest_error' | ||
| : pendingCount > 0 ? 'push_pending' | ||
| : 'all_confirmed'; | ||
| const state = reason === 'all_confirmed' ? 'green' : reason === 'push_pending' ? 'yellow' : 'red'; | ||
| return { state, reason, pendingCount, failedCount, overdueCount, lastContactAt: okAtIso }; | ||
| // Reasons in severity order - the FIRST match is the headline the popover shows. | ||
| // Post-SAFETY reasons (a post is actually overdue/failed, or a plan can't be read to | ||
| // fire at all) come FIRST so a genuine miss is never masked by a mere connectivity | ||
| // blip. Then CONNECTIVITY/quota reasons (cloud can't be reached / its worker is wedged | ||
| // / sync is stopped / the on-flag drifted) - the local backstop covers these while the | ||
| // Mac is awake, so they are attention (amber), not failure. Then the transient | ||
| // syncing state (yellow), then all-clear (green). | ||
| const reason = overdueCount > 0 ? 'overdue_unpublished' | ||
| : failedCount > 0 ? 'cloud_failures' | ||
| : manifestBroken ? 'manifest_error' | ||
| : contactStale ? 'cloud_unreachable' | ||
| : workerStale ? 'worker_stale' | ||
| : (sub && sub.syncStopped) ? 'sync_stopped' | ||
| : (sub && sub.alwaysOn === false) ? 'flag_divergence' | ||
| : pendingCount > 0 ? 'push_pending' | ||
| : 'all_confirmed'; | ||
| // Three visible severities: red = a post is at risk RIGHT NOW; amber = delivery is | ||
| // degraded but the local backstop covers it; yellow = normal transient syncing; green. | ||
| const RED_REASONS = new Set(['overdue_unpublished', 'cloud_failures', 'manifest_error']); | ||
| const state = reason === 'all_confirmed' ? 'green' | ||
| : reason === 'push_pending' ? 'yellow' | ||
| : RED_REASONS.has(reason) ? 'red' | ||
| : 'amber'; | ||
| return { state, reason, pendingCount, failedCount, overdueCount, workerStale, lastContactAt: okAtIso }; | ||
| } | ||
@@ -967,2 +1114,63 @@ | ||
| // Lift the cloud's staleness holds (reactive; called from the tick after remediate). | ||
| // The worker parks any job more than 15 min past due as 'stale_held' and fires it ONLY | ||
| // on an explicit retrigger - it cannot know whether our backstop already published | ||
| // while it was down. Retriggering is therefore the LOCAL PLAN's assertion: for each | ||
| // held job whose post is still UNPOSTED here, re-trigger it and record the handoff in | ||
| // state.cloudRetriggered - the scheduler's backstop anchors on that timestamp, so the | ||
| // cloud gets a full fresh grace window after every handoff and the two firers can | ||
| // never overlap. A held job whose post IS posted (backstop or owner-manual) is left | ||
| // parked: it can never auto-fire, so it is simply inert. Best-effort: a transport | ||
| // hiccup never throws into the tick; the hold persists, so the next tick retries. | ||
| export async function retriggerHeldJobs(held = []) { | ||
| const list = Array.isArray(held) ? held : []; | ||
| const unposted = []; | ||
| for (const h of list) { | ||
| if (!h || !h.jobId) continue; | ||
| const { campaign: c } = findCampaign(h.campaign); | ||
| const p = c && (c.posts || []).find((x) => x.id === h.postId); | ||
| if (!p || p.status === 'posted') continue; // the double-post guard | ||
| unposted.push(h); | ||
| } | ||
| if (!unposted.length) return { ok: true, retriggered: [] }; | ||
| const keyOf = (h) => `${h.campaign}:${h.postId}:${h.lane}`; | ||
| // Anchor FIRST, durably, BEFORE the cloud can act on the handoff: were the retrigger | ||
| // to land without the anchor, this tick's walk could backstop-fire the same post the | ||
| // cloud is now firing. If the anchor cannot be written, skip the retrigger entirely | ||
| // this tick (the hold persists; the next tick retries) - fail toward once-late, | ||
| // never toward double. | ||
| const prev = {}; | ||
| try { | ||
| const s = loadState(); | ||
| if (!s.cloudRetriggered || typeof s.cloudRetriggered !== 'object') s.cloudRetriggered = {}; | ||
| const at = new Date().toISOString(); | ||
| for (const h of unposted) { | ||
| prev[keyOf(h)] = s.cloudRetriggered[keyOf(h)]; | ||
| s.cloudRetriggered[keyOf(h)] = { jobId: h.jobId, at }; | ||
| } | ||
| saveState(); | ||
| } catch { | ||
| return { ok: false, retriggered: [] }; | ||
| } | ||
| let requeued = []; | ||
| try { requeued = (await retriggerJobs(unposted.map((h) => h.jobId))).requeued; } catch { /* hold persists; next tick retries */ } | ||
| // Roll back the anchor for any job that was NOT actually handed off, so a failed | ||
| // retrigger never suppresses the local backstop (which would strand the post with | ||
| // NEITHER firer). A crash between write and rollback costs one grace window, no more. | ||
| const missed = unposted.filter((h) => !requeued.includes(h.jobId)); | ||
| if (missed.length) { | ||
| try { | ||
| const s = loadState(); | ||
| if (s.cloudRetriggered && typeof s.cloudRetriggered === 'object') { | ||
| for (const h of missed) { | ||
| if (prev[keyOf(h)]) s.cloudRetriggered[keyOf(h)] = prev[keyOf(h)]; | ||
| else delete s.cloudRetriggered[keyOf(h)]; | ||
| } | ||
| saveState(); | ||
| } | ||
| } catch { /* rollback is best-effort; worst case is one grace window of delay */ } | ||
| } | ||
| return { ok: true, retriggered: requeued }; | ||
| } | ||
| export async function reconcileAlwaysOnBrands() { | ||
@@ -969,0 +1177,0 @@ const active = activeClientId(); |
+26
-1
@@ -48,2 +48,6 @@ // config.mjs - the pendpost "Settings / Connections" surface. | ||
| pinterestBoardId: 'PINTEREST_BOARD_ID', | ||
| // GBP account + location are operator-set identifiers (numeric ids from the | ||
| // Business Profile manager; the engine's `auth` prints candidates). | ||
| gbpAccountId: 'GBP_ACCOUNT_ID', | ||
| gbpLocationId: 'GBP_LOCATION_ID', | ||
| }; | ||
@@ -92,2 +96,4 @@ | ||
| pinterestBoardId: readEnv('PINTEREST_BOARD_ID') || '', | ||
| gbpAccountId: readEnv('GBP_ACCOUNT_ID') || '', | ||
| gbpLocationId: readEnv('GBP_LOCATION_ID') || '', | ||
| }; | ||
@@ -107,2 +113,3 @@ } | ||
| const ttExp = Number(readEnv('TIKTOK_TOKEN_EXPIRES_AT') || 0) || null; | ||
| const gbpExp = Number(readEnv('GBP_TOKEN_EXPIRES_AT') || 0) || null; | ||
| return { | ||
@@ -138,2 +145,12 @@ metaPageToken: secret('META_PAGE_TOKEN', 'non-expiring page token'), | ||
| tiktokRefreshToken: secret('TIKTOK_REFRESH_TOKEN', 'rotating refresh token'), | ||
| // Static-credential wave-2 lanes: Mastodon app token, WordPress application | ||
| // password, Ghost custom-integration Admin API key, Nostr signing key. | ||
| mastodonAccessToken: secret('MASTODON_ACCESS_TOKEN', 'static app access token'), | ||
| wordpressAppPassword: secret('WORDPRESS_APP_PASSWORD', 'application password'), | ||
| ghostAdminApiKey: secret('GHOST_ADMIN_API_KEY', 'Admin API key (id:secret)'), | ||
| nostrPrivateKey: secret('NOSTR_PRIVATE_KEY', 'nsec signing key'), | ||
| // GBP (beta): Google OAuth client secret + the rotating tokens. | ||
| gbpClientSecret: secret('GBP_CLIENT_SECRET'), | ||
| gbpAccessToken: { ...secret('GBP_ACCESS_TOKEN', 'short-lived access token'), expiresAt: gbpExp ? new Date(gbpExp).toISOString() : null }, | ||
| gbpRefreshToken: secret('GBP_REFRESH_TOKEN', 'durable refresh token'), | ||
| }; | ||
@@ -154,2 +171,4 @@ } | ||
| 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN', 'TIKTOK_REFRESH_TOKEN', | ||
| 'MASTODON_ACCESS_TOKEN', 'WORDPRESS_APP_PASSWORD', 'GHOST_ADMIN_API_KEY', 'NOSTR_PRIVATE_KEY', | ||
| 'GBP_CLIENT_SECRET', 'GBP_ACCESS_TOKEN', 'GBP_REFRESH_TOKEN', 'GBP_TOKEN_EXPIRES_AT', | ||
| ]; | ||
@@ -174,2 +193,7 @@ | ||
| tiktok: ['TIKTOK_CLIENT_KEY', 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN', 'TIKTOK_REFRESH_TOKEN', 'TIKTOK_TOKEN_EXPIRES_AT', 'TIKTOK_REDIRECT_URI'], | ||
| mastodon: ['MASTODON_INSTANCE_URL', 'MASTODON_ACCESS_TOKEN', 'MASTODON_HANDLE'], | ||
| wordpress: ['WORDPRESS_SITE_URL', 'WORDPRESS_USERNAME', 'WORDPRESS_APP_PASSWORD'], | ||
| ghost: ['GHOST_SITE_URL', 'GHOST_ADMIN_API_KEY'], | ||
| nostr: ['NOSTR_PRIVATE_KEY', 'NOSTR_PUBLIC_KEY', 'NOSTR_NPUB', 'NOSTR_RELAYS'], | ||
| gbp: ['GBP_CLIENT_ID', 'GBP_CLIENT_SECRET', 'GBP_ACCESS_TOKEN', 'GBP_REFRESH_TOKEN', 'GBP_TOKEN_EXPIRES_AT', 'GBP_ACCOUNT_ID', 'GBP_LOCATION_ID'], | ||
| }; | ||
@@ -187,3 +211,3 @@ | ||
| const keys = PLATFORM_ENV_KEYS[platform]; | ||
| if (!keys) return errorBody('invalid_input', `unknown platform "${platform}" (expected meta | linkedin | x | youtube | telegram | discord | reddit | pinterest | tiktok)`); | ||
| if (!keys) return errorBody('invalid_input', `unknown platform "${platform}" (expected meta | linkedin | x | youtube | telegram | discord | reddit | pinterest | tiktok | mastodon | wordpress | ghost | nostr | gbp)`); | ||
| if (confirm !== true) { | ||
@@ -238,2 +262,3 @@ return errorBody('needs_confirm', `disconnect clears ALL stored credentials for ${platform} - pass confirm: true (you will need to re-authorize to post again).`); | ||
| if (key === 'ytHandle') return (v === '' || /^@?[A-Za-z0-9._-]{3,30}$/.test(v)) ? null : 'ytHandle must be a YouTube @handle (3-30 chars)'; | ||
| if (key === 'gbpAccountId' || key === 'gbpLocationId') return (v === '' || /^\d+$/.test(v)) ? null : `${key} must be numeric`; | ||
| return `unknown identifier ${key}`; | ||
@@ -240,0 +265,0 @@ } |
@@ -52,2 +52,7 @@ // interface.mjs - the PlatformDriver contract. | ||
| 'tiktok-social.mjs': 'tiktok', | ||
| 'mastodon-social.mjs': 'mastodon', | ||
| 'wordpress-social.mjs': 'wordpress', | ||
| 'ghost-social.mjs': 'ghost', | ||
| 'nostr-social.mjs': 'nostr', | ||
| 'gbp-social.mjs': 'gbp', | ||
| }; | ||
@@ -92,5 +97,10 @@ | ||
| tiktok: { script: 'scripts/tiktok-social.mjs', platforms: ['tiktok'], credentialEnvKeys: [], builtin: true }, | ||
| mastodon: { script: 'scripts/mastodon-social.mjs', platforms: ['mastodon'], credentialEnvKeys: [], builtin: true }, | ||
| wordpress: { script: 'scripts/wordpress-social.mjs', platforms: ['wordpress'], credentialEnvKeys: [], builtin: true }, | ||
| ghost: { script: 'scripts/ghost-social.mjs', platforms: ['ghost'], credentialEnvKeys: [], builtin: true }, | ||
| nostr: { script: 'scripts/nostr-social.mjs', platforms: ['nostr'], credentialEnvKeys: [], builtin: true }, | ||
| gbp: { script: 'scripts/gbp-social.mjs', platforms: ['gbp'], credentialEnvKeys: [], builtin: true }, | ||
| }; | ||
| const BUILTIN_PLATFORMS = ['facebook', 'instagram', 'linkedin', 'youtube', 'x', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok']; | ||
| const BUILTIN_PLATFORMS = ['facebook', 'instagram', 'linkedin', 'youtube', 'x', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok', 'mastodon', 'wordpress', 'ghost', 'nostr', 'gbp']; | ||
@@ -97,0 +107,0 @@ // One valid registry entry, or null when it is shaped wrong (skip-with-log, never |
+113
-12
@@ -56,2 +56,7 @@ // mock-driver.mjs - the credential-free driver. It implements the same envelope | ||
| if (platform === 'tiktok') return { views: n(500, 50000), likes: n(20, 4000), comments: n(0, 300), shares: n(0, 500) }; | ||
| if (platform === 'mastodon') return { favourites: n(5, 300), reblogs: n(0, 120), replies: n(0, 40) }; | ||
| if (platform === 'wordpress') return { views: n(50, 3000), comments: n(0, 40) }; | ||
| if (platform === 'ghost') return { views: n(50, 3000), members: n(0, 60) }; | ||
| if (platform === 'nostr') return { reactions: n(0, 80), reposts: n(0, 30), zaps: n(0, 20) }; | ||
| if (platform === 'gbp') return { views: n(100, 5000), ctaClicks: n(0, 150) }; | ||
| return { views: n(100, 1000) }; | ||
@@ -76,2 +81,7 @@ } | ||
| if (platform === 'tiktok') return !post.tiktokVideoId; | ||
| if (platform === 'mastodon') return !(post.mastodonStatusId || post.mastodonScheduledId); | ||
| if (platform === 'wordpress') return !post.wordpressPostId; | ||
| if (platform === 'ghost') return !post.ghostPostId; | ||
| if (platform === 'nostr') return !post.nostrEventId; | ||
| if (platform === 'gbp') return !post.gbpPostId; | ||
| return false; | ||
@@ -81,3 +91,5 @@ } | ||
| // Mirror lanesFor's due gates so CLI `publish-due` with no --only behaves like | ||
| // the scheduler: meta/linkedin fire when due, youtube schedules ahead of due. | ||
| // the scheduler: meta/linkedin fire when due, youtube schedules ahead of due, | ||
| // and the mastodon/wordpress/ghost native lanes fire whenever owed (ahead of | ||
| // due they schedule natively, past due they publish immediately). | ||
| function eligible(platform, post) { | ||
@@ -90,2 +102,5 @@ if (post.approval !== 'approved' || post.status === 'posted') return false; | ||
| if (platform === 'youtube') return platforms.includes('youtube') && !post.ytVideoId && due > now; | ||
| if (platform === 'mastodon') return platforms.includes('mastodon') && !post.mastodonStatusId && !post.mastodonScheduledId; | ||
| if (platform === 'wordpress') return platforms.includes('wordpress') && !post.wordpressPostId; | ||
| if (platform === 'ghost') return platforms.includes('ghost') && !post.ghostPostId; | ||
| if (platform === 'meta') { | ||
@@ -102,2 +117,4 @@ return due <= now && ((platforms.includes('instagram') && !post.igMediaId) | ||
| if (platform === 'tiktok') return due <= now && platforms.includes('tiktok') && !post.tiktokVideoId; | ||
| if (platform === 'nostr') return due <= now && platforms.includes('nostr') && !post.nostrEventId; | ||
| if (platform === 'gbp') return due <= now && platforms.includes('gbp') && !post.gbpPostId; | ||
| return false; | ||
@@ -126,2 +143,39 @@ } | ||
| if (platforms.includes('tiktok') && !post.tiktokVideoId) { post.tiktokVideoId = mockId('tt'); results.push({ platform: 'tiktok', action: 'publish', ok: true }); } | ||
| } else if (platform === 'mastodon') { | ||
| // Native lane: ahead of due it hands off to the (mock) instance queue; past | ||
| // due it publishes immediately - the exact real-engine `schedule` split. | ||
| if (platforms.includes('mastodon') && !post.mastodonStatusId && !post.mastodonScheduledId) { | ||
| if (Date.parse(post.scheduledAt || '') > Date.now()) { | ||
| post.mastodonScheduledId = mockId('mastosched'); | ||
| if (post.status !== 'posted') post.status = 'scheduled'; | ||
| results.push({ platform: 'mastodon', action: 'schedule-native', ok: true }); | ||
| } else { | ||
| post.mastodonStatusId = mockId('masto'); | ||
| results.push({ platform: 'mastodon', action: 'publish', ok: true }); | ||
| } | ||
| } | ||
| } else if (platform === 'wordpress') { | ||
| if (platforms.includes('wordpress') && !post.wordpressPostId) { | ||
| post.wordpressPostId = mockId('wp'); | ||
| if (Date.parse(post.scheduledAt || '') > Date.now()) { | ||
| if (post.status !== 'posted') post.status = 'scheduled'; | ||
| results.push({ platform: 'wordpress', action: 'schedule-native', ok: true }); | ||
| } else { | ||
| results.push({ platform: 'wordpress', action: 'publish', ok: true }); | ||
| } | ||
| } | ||
| } else if (platform === 'ghost') { | ||
| if (platforms.includes('ghost') && !post.ghostPostId) { | ||
| post.ghostPostId = mockId('ghost'); | ||
| if (Date.parse(post.scheduledAt || '') > Date.now()) { | ||
| if (post.status !== 'posted') post.status = 'scheduled'; | ||
| results.push({ platform: 'ghost', action: 'schedule-native', ok: true }); | ||
| } else { | ||
| results.push({ platform: 'ghost', action: 'publish', ok: true }); | ||
| } | ||
| } | ||
| } else if (platform === 'nostr') { | ||
| if (platforms.includes('nostr') && !post.nostrEventId) { post.nostrEventId = mockId('nostr'); results.push({ platform: 'nostr', action: 'publish', ok: true }); } | ||
| } else if (platform === 'gbp') { | ||
| if (platforms.includes('gbp') && !post.gbpPostId) { post.gbpPostId = mockId('gbp'); results.push({ platform: 'gbp', action: 'publish', ok: true }); } | ||
| } else if (platform === 'youtube') { | ||
@@ -134,5 +188,6 @@ if (platforms.includes('youtube') && !post.ytVideoId) { | ||
| } | ||
| // Convergence: a publish-NOW lane marks the post posted once every targeted | ||
| // platform carries publish evidence (youtube is native-scheduled, never posted). | ||
| if (platform !== 'youtube' && results.length) { | ||
| // Convergence: a publish-NOW result marks the post posted once every targeted | ||
| // platform carries publish evidence (a native hand-off - youtube publishAt, | ||
| // mastodon/wordpress/ghost schedule-native - stays 'scheduled', never posted). | ||
| if (results.length && results.every((r) => r.action === 'publish')) { | ||
| const pending = platforms.filter((p) => platformPending(post, p)); | ||
@@ -194,2 +249,12 @@ if (!pending.length) { post.status = 'posted'; post.postedAt = new Date().toISOString(); } | ||
| if (post.tiktokVideoId) results.push({ postId: post.id, platform: 'tiktok', action: 'insights', ok: true, metrics: metricsFor('tiktok', post.id) }); | ||
| } else if (platform === 'mastodon') { | ||
| if (post.mastodonStatusId) results.push({ postId: post.id, platform: 'mastodon', action: 'insights', ok: true, metrics: metricsFor('mastodon', post.id) }); | ||
| } else if (platform === 'wordpress') { | ||
| if (post.wordpressPostId) results.push({ postId: post.id, platform: 'wordpress', action: 'insights', ok: true, metrics: metricsFor('wordpress', post.id) }); | ||
| } else if (platform === 'ghost') { | ||
| if (post.ghostPostId) results.push({ postId: post.id, platform: 'ghost', action: 'insights', ok: true, metrics: metricsFor('ghost', post.id) }); | ||
| } else if (platform === 'nostr') { | ||
| if (post.nostrEventId) results.push({ postId: post.id, platform: 'nostr', action: 'insights', ok: true, metrics: metricsFor('nostr', post.id) }); | ||
| } else if (platform === 'gbp') { | ||
| if (post.gbpPostId) results.push({ postId: post.id, platform: 'gbp', action: 'insights', ok: true, metrics: metricsFor('gbp', post.id) }); | ||
| } else if (platform === 'youtube') { | ||
@@ -202,4 +267,5 @@ if (post.ytVideoId) results.push({ postId: post.id, platform: 'youtube', action: 'insights', ok: true, metrics: metricsFor('youtube', post.id) }); | ||
| // Mock release (make-public recovery): a mock youtube post with a fabricated id | ||
| // flips 'public' with no network. Mirrors the engine `release` envelope shape. | ||
| // Mock release (make-live recovery): a mock post with a fabricated native id | ||
| // flips live with no network. Mirrors the engine `release` envelope shape for | ||
| // every native lane that has one (youtube / wordpress / ghost). | ||
| function handleRelease(platform, planPath, only) { | ||
@@ -209,5 +275,11 @@ const plan = loadPlan(planPath); | ||
| const results = []; | ||
| const releasable = { | ||
| youtube: { id: (p) => p.ytVideoId, state: 'public', permalink: (p) => `https://youtu.be/${p.ytVideoId}` }, | ||
| wordpress: { id: (p) => p.wordpressPostId, state: 'published', permalink: (p) => `https://mock.blog/?p=${p.wordpressPostId}` }, | ||
| ghost: { id: (p) => p.ghostPostId, state: 'published', permalink: (p) => `https://mock.site/${p.ghostPostId}/` }, | ||
| }; | ||
| const lane = releasable[platform]; | ||
| for (const post of posts) { | ||
| if (platform !== 'youtube' || !(post.platforms || []).includes('youtube') || !post.ytVideoId) continue; | ||
| results.push({ postId: post.id, platform: 'youtube', action: 'release', ok: true, id: post.ytVideoId, live: true, state: 'public', permalink: `https://youtu.be/${post.ytVideoId}` }); | ||
| if (!lane || !(post.platforms || []).includes(platform) || !lane.id(post)) continue; | ||
| results.push({ postId: post.id, platform, action: 'release', ok: true, id: lane.id(post), live: true, state: lane.state, permalink: lane.permalink(post) }); | ||
| } | ||
@@ -217,2 +289,22 @@ return { ok: true, results }; | ||
| // Mock resolve (the mastodon post-fire reconcile): a scheduled queue entry past | ||
| // its due minute resolves to a fabricated live status id + posted, exactly the | ||
| // terminal state the real engine reaches. Before due it stays queued (skip). | ||
| function handleResolve(platform, planPath, only) { | ||
| if (platform !== 'mastodon') return { ok: true, results: [] }; | ||
| const plan = loadPlan(planPath); | ||
| const posts = (plan.posts || []).filter((p) => (only ? p.id === only : true)); | ||
| const results = []; | ||
| for (const post of posts) { | ||
| if (!(post.platforms || []).includes('mastodon') || !post.mastodonScheduledId || post.mastodonStatusId) continue; | ||
| if (Date.parse(post.scheduledAt || '') > Date.now()) continue; // still queued | ||
| post.mastodonStatusId = mockId('masto'); | ||
| post.status = 'posted'; | ||
| post.postedAt = new Date().toISOString(); | ||
| results.push({ postId: post.id, platform: 'mastodon', action: 'resolve', ok: true, id: post.mastodonStatusId, permalink: `https://mock.instance/@mockuser/${post.mastodonStatusId}` }); | ||
| } | ||
| if (results.length) savePlan(planPath, plan); | ||
| return { ok: true, results }; | ||
| } | ||
| // Mock read-back: a post that already carries a fabricated id reads as LIVE, | ||
@@ -225,3 +317,3 @@ // so the full mock loop (publish -> verify) lands a verified-live post with no | ||
| const results = []; | ||
| const liveState = { instagram: 'published', facebook: 'published', youtube: 'public', linkedin: 'published', x: 'published', telegram: 'sent', discord: 'posted', reddit: 'posted', pinterest: 'published', tiktok: 'published' }; | ||
| const liveState = { instagram: 'published', facebook: 'published', youtube: 'public', linkedin: 'published', x: 'published', telegram: 'sent', discord: 'posted', reddit: 'posted', pinterest: 'published', tiktok: 'published', mastodon: 'published', wordpress: 'published', ghost: 'published', nostr: 'published', gbp: 'live' }; | ||
| const permalinkFor = (p, post) => { | ||
@@ -236,2 +328,7 @@ if (p === 'youtube') return `https://youtu.be/${post.ytVideoId}`; | ||
| if (p === 'tiktok') return `https://www.tiktok.com/@mockcreator/video/${post.tiktokVideoId}`; | ||
| if (p === 'mastodon') return `https://mock.instance/@mockuser/${post.mastodonStatusId}`; | ||
| if (p === 'wordpress') return `https://mock.blog/?p=${post.wordpressPostId}`; | ||
| if (p === 'ghost') return `https://mock.site/${post.ghostPostId}/`; | ||
| if (p === 'nostr') return `https://njump.me/${post.nostrEventId}`; | ||
| if (p === 'gbp') return `https://local.google.com/mock/${post.gbpPostId}`; | ||
| return `https://example.invalid/mock/${p}/${post.id}`; | ||
@@ -241,3 +338,3 @@ }; | ||
| const targeted = post.platforms || []; | ||
| const has = { instagram: post.igMediaId, facebook: post.fbReelId || post.fbPostId, youtube: post.ytVideoId, linkedin: post.liPostId, x: post.xPostId, telegram: post.tgMessageId, discord: post.dcMessageId, reddit: post.redditPostId, pinterest: post.pinId, tiktok: post.tiktokVideoId }; | ||
| const has = { instagram: post.igMediaId, facebook: post.fbReelId || post.fbPostId, youtube: post.ytVideoId, linkedin: post.liPostId, x: post.xPostId, telegram: post.tgMessageId, discord: post.dcMessageId, reddit: post.redditPostId, pinterest: post.pinId, tiktok: post.tiktokVideoId, mastodon: post.mastodonStatusId, wordpress: post.wordpressPostId, ghost: post.ghostPostId, nostr: post.nostrEventId, gbp: post.gbpPostId }; | ||
| if (platform === 'meta') { | ||
@@ -268,2 +365,6 @@ for (const p of ['instagram', 'facebook']) { | ||
| return { ok: true, results: [{ platform, action: 'delete', ok: true, detail: 'mock object deleted' }] }; | ||
| case 'unschedule': | ||
| return { ok: true, results: [{ platform, action: 'unschedule', ok: true, detail: 'mock scheduled object cancelled' }] }; | ||
| case 'resolve': | ||
| return planPath ? handleResolve(platform, planPath, only) : { ok: true, results: [] }; | ||
| case 'refresh': | ||
@@ -274,4 +375,4 @@ return { ok: true, results: [{ platform, action: 'refresh', ok: true, detail: 'mock token refreshed' }] }; | ||
| case 'release': | ||
| // Mock release: a mock youtube post always verifies 'public' (no real | ||
| // privacy), so a release is just a successful no-op acknowledgement. | ||
| // Mock release: a mock native post always verifies live (no real | ||
| // privacy/status), so a release is just a successful acknowledgement. | ||
| return planPath ? handleRelease(platform, planPath, only) : { ok: true, results: [] }; | ||
@@ -278,0 +379,0 @@ case 'verify': |
+2
-2
@@ -10,4 +10,4 @@ // flags.mjs - the single auditable home for pendpost's OPT-IN security posture. | ||
| // Scope is deliberately narrow: this module owns ONLY the optional in-server auth | ||
| // gate (the always-on hardening path). Platform enablement (including the Bluesky | ||
| // lane) stays in lib/mode.mjs's credential-presence model; the cloud dispatch | ||
| // gate (the always-on hardening path). Platform enablement stays in | ||
| // lib/mode.mjs's credential-presence model; the cloud dispatch | ||
| // target is a documented interface (docs/specs/cloud-integration-contract.md) the | ||
@@ -14,0 +14,0 @@ // proprietary runtime implements in its own repo, never a flag in the core. |
+5
-0
@@ -27,2 +27,7 @@ // health.mjs - live per-platform liveness probes for the pendpost health bar. | ||
| tiktok: 'scripts/tiktok-social.mjs', | ||
| mastodon: 'scripts/mastodon-social.mjs', | ||
| wordpress: 'scripts/wordpress-social.mjs', | ||
| ghost: 'scripts/ghost-social.mjs', | ||
| nostr: 'scripts/nostr-social.mjs', | ||
| gbp: 'scripts/gbp-social.mjs', | ||
| }; | ||
@@ -29,0 +34,0 @@ |
+5
-1
@@ -30,3 +30,7 @@ // lint.mjs - brand_lint: a machine-checkable content gate for captions/copy | ||
| // platform in context the matchers fall back to the conservative `default`. | ||
| const CAPTION_LIMITS = { instagram: 2200, facebook: 63206, linkedin: 3000, youtube: 5000, x: 280, default: 2200 }; | ||
| // mastodon: the default instance cap; gbp: Google's local-post summary cap. | ||
| // wordpress/ghost/nostr are deliberately absent: the blog lanes publish post.body | ||
| // (not the caption) and nostr has no protocol cap, so the conservative `default` | ||
| // advisory is the right guard for their caption fields. | ||
| const CAPTION_LIMITS = { instagram: 2200, facebook: 63206, linkedin: 3000, youtube: 5000, x: 280, mastodon: 500, gbp: 1500, default: 2200 }; | ||
| const HASHTAG_LIMITS = { instagram: 30, default: 10 }; | ||
@@ -33,0 +37,0 @@ |
+2
-2
@@ -31,4 +31,4 @@ // mode.mjs - decides whether a platform lane runs LIVE (real API calls) or MOCK | ||
| export const MOCKABLE_COMMANDS = new Set([ | ||
| 'schedule', 'release', 'publish-due', 'publish', 'fbreel', 'set-thumbnail', | ||
| 'insights', 'verify', 'validate', 'delete', 'refresh', 'profile', | ||
| 'schedule', 'release', 'resolve', 'publish-due', 'publish', 'fbreel', 'set-thumbnail', | ||
| 'insights', 'verify', 'validate', 'delete', 'unschedule', 'refresh', 'profile', | ||
| ]); | ||
@@ -35,0 +35,0 @@ |
+61
-6
@@ -117,2 +117,9 @@ // plans.mjs - reads the campaign plan store. The plan JSON files written by the | ||
| if (platform === 'tiktok') return !post.tiktokVideoId; | ||
| // mastodon: a natively-scheduled queue entry (mastodonScheduledId) is a real | ||
| // hand-off - the fired status id arrives later via the mastodon-resolve lane. | ||
| if (platform === 'mastodon') return !(post.mastodonStatusId || post.mastodonScheduledId); | ||
| if (platform === 'wordpress') return !post.wordpressPostId; | ||
| if (platform === 'ghost') return !post.ghostPostId; | ||
| if (platform === 'nostr') return !post.nostrEventId; | ||
| if (platform === 'gbp') return !post.gbpPostId; | ||
| return false; | ||
@@ -122,8 +129,10 @@ } | ||
| // The platforms whose OWN scheduler fires a future post, so it publishes on time | ||
| // even when the user's machine is off: Facebook (scheduled_publish_time) and | ||
| // YouTube (status.publishAt). Every other lane (Instagram, LinkedIn, X, Bluesky) | ||
| // needs pendpost running at the due time. This makes the FB/YT-native knowledge | ||
| // that was implicit in nativeHandoff (lib/writes.mjs) explicit and reusable; the | ||
| // even when the user's machine is off: Facebook (scheduled_publish_time), YouTube | ||
| // (status.publishAt), and - since the 2026-07-05 native-scheduling refactor - | ||
| // Mastodon (scheduled_at), WordPress (status 'future') and Ghost (status | ||
| // 'scheduled' + published_at). Every other lane (Instagram, LinkedIn, X, Bluesky) | ||
| // needs pendpost running at the due time. This makes the native knowledge that | ||
| // was implicit in nativeHandoffs (lib/writes.mjs) explicit and reusable; the | ||
| // publish-job seam (lib/publish-job.mjs) reads it to set delivery.survivesPowerOff. | ||
| export const NATIVE_SCHEDULING_PLATFORMS = new Set(['facebook', 'youtube']); | ||
| export const NATIVE_SCHEDULING_PLATFORMS = new Set(['facebook', 'youtube', 'mastodon', 'wordpress', 'ghost']); | ||
@@ -134,3 +143,6 @@ // Verify read-back: a platform's `state` from the engine `verify` subcommand, | ||
| const VERIFY_LIVE = new Set(['public', 'published', 'live']); | ||
| const VERIFY_FAILED = new Set(['private-overdue', 'missing', 'draft']); | ||
| // The three *-overdue states are each native lane's "the platform scheduler | ||
| // missed its minute" read-back; the matching recovery lane (youtube-release / | ||
| // wordpress-release / ghost-release, lib/scheduler.mjs lanesFor) keys on them. | ||
| const VERIFY_FAILED = new Set(['private-overdue', 'future-overdue', 'scheduled-overdue', 'missing', 'draft']); | ||
@@ -199,2 +211,11 @@ // Refine the guessed 'fired-assumed' (probably published) using the stored | ||
| tiktok: null, | ||
| // Mastodon/WordPress/Ghost/Nostr/GBP: the public URL needs account/instance/site | ||
| // identity (env, not on the post), so the authoritative link comes from the | ||
| // engine's verify read-back (post.verify.platforms[p].permalink) - same rule | ||
| // as telegram/discord/tiktok above. Never fabricate. | ||
| mastodon: null, | ||
| wordpress: null, | ||
| ghost: null, | ||
| nostr: null, | ||
| gbp: null, | ||
| }; | ||
@@ -235,2 +256,6 @@ } | ||
| image: post.image || null, | ||
| // imageUrl = the PUBLIC pin image for the Pinterest lane (v5 create-pin takes | ||
| // media by URL only; the engine never uploads the local render). Distinct from | ||
| // `image` (the LinkedIn article-card thumbnail) so one post can carry both. | ||
| imageUrl: post.imageUrl || null, | ||
| caption: post.caption || '', | ||
@@ -246,5 +271,29 @@ firstComment: post.firstComment || '', | ||
| xCaption: post.xCaption || '', | ||
| // Reply-chain intent (X lane): the sibling post id this post replies to. | ||
| // The engine resolves it to the parent's xPostId at publish time. | ||
| xReplyTo: post.xReplyTo || null, | ||
| tags: post.tags || '', | ||
| blogSlug: post.blogSlug || null, | ||
| audience: post.audience || null, | ||
| // Long-form article fields (wordpress/ghost lanes): markdown body, short | ||
| // excerpt, canonical source URL, the Ghost newsletter opt-in. Surfaced per | ||
| // the write/read parity rule (persisted fields must reach plan_get/the UI). | ||
| body: post.body || '', | ||
| excerpt: post.excerpt || '', | ||
| canonicalUrl: post.canonicalUrl || null, | ||
| ghostEmail: post.ghostEmail === true, | ||
| // Per-platform note overrides (additive xCaption pattern): the short-note | ||
| // lanes read these before falling back to the shared caption. | ||
| mastodonCaption: post.mastodonCaption || '', | ||
| nostrCaption: post.nostrCaption || '', | ||
| // Same pattern for the telegram/discord/tiktok/reddit/pinterest lanes | ||
| // (pinTitle falls back to title, the rest to caption). | ||
| tgCaption: post.tgCaption || '', | ||
| dcCaption: post.dcCaption || '', | ||
| ttCaption: post.ttCaption || '', | ||
| redditText: post.redditText || '', | ||
| pinTitle: post.pinTitle || '', | ||
| pinDescription: post.pinDescription || '', | ||
| // GBP local-post intent ({ topic, ctaType?, ctaUrl?, event*/offer* }) or null. | ||
| gbp: post.gbp || null, | ||
| // FR4 (US-FR-04): interactive-story intent + the per-post hashtag override. | ||
@@ -284,2 +333,8 @@ // interactiveStory is an object { stickers: [...] } or null when the post has | ||
| tiktokVideoId: post.tiktokVideoId || null, | ||
| mastodonStatusId: post.mastodonStatusId || null, | ||
| mastodonScheduledId: post.mastodonScheduledId || null, | ||
| wordpressPostId: post.wordpressPostId || null, | ||
| ghostPostId: post.ghostPostId || null, | ||
| nostrEventId: post.nostrEventId || null, | ||
| gbpPostId: post.gbpPostId || null, | ||
| }, | ||
@@ -286,0 +341,0 @@ postedAt: post.postedAt || null, |
+236
-0
@@ -525,2 +525,238 @@ // playbooks.mjs - the PROSE source of truth for per-platform vendor onboarding: | ||
| }, | ||
| mastodon: { | ||
| portalUrl: 'https://joinmastodon.org/servers', | ||
| appToCreate: 'an application under your account\'s Preferences > Development (no developer program, no review)', | ||
| productsToAdd: [], | ||
| scopes: ['read', 'write:statuses', 'write:media'], | ||
| steps: [ | ||
| { | ||
| title: 'Pick your instance', | ||
| detail: | ||
| 'Mastodon is federated: your account lives on ONE instance (mastodon.social, your own server, ...) and the engine talks to that ' | ||
| + 'instance\'s API directly. Record its base URL, e.g. https://mastodon.social .', | ||
| env: 'MASTODON_INSTANCE_URL', | ||
| }, | ||
| { | ||
| title: 'Create an application', | ||
| detail: | ||
| 'Log in to the instance in a browser and open Preferences > Development > New application. Name it (e.g. pendpost), grant the ' | ||
| + 'read, write:statuses and write:media scopes, and save. No callback URL is needed - the engine uses the app\'s own access token.', | ||
| }, | ||
| { | ||
| title: 'Copy the access token', | ||
| detail: 'Open the application you just created and copy "Your access token" - a static token that never expires unless you regenerate it.', | ||
| env: 'MASTODON_ACCESS_TOKEN', | ||
| }, | ||
| { | ||
| title: 'Validate', | ||
| detail: 'Run the CLI below. It calls verify_credentials, confirms the token authenticates, and records your @handle for the account link.', | ||
| cli: 'node scripts/mastodon-social.mjs auth', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'HTTP 401 on auth or publish.', | ||
| cause: 'The token was regenerated on the instance, or it belongs to a different instance than MASTODON_INSTANCE_URL.', | ||
| fix: 'Confirm the instance URL matches where the app was created, regenerate the token there, and reconnect.', | ||
| }, | ||
| { | ||
| symptom: 'HTTP 422 on publish for a long post.', | ||
| cause: 'The instance caps status length (500 chars by default; instance-configurable).', | ||
| fix: 'Shorten the caption (or the mastodonCaption override) below the instance cap.', | ||
| }, | ||
| { | ||
| symptom: 'Media posts fail while text posts work.', | ||
| cause: 'The application is missing the write:media scope.', | ||
| fix: 'Recreate the application with read, write:statuses AND write:media, then reconnect with the new token.', | ||
| }, | ||
| ], | ||
| }, | ||
| wordpress: { | ||
| portalUrl: 'https://wordpress.org/documentation/article/application-passwords/', | ||
| appToCreate: 'an application password on the posting user (no app, no OAuth, no review)', | ||
| productsToAdd: [], | ||
| scopes: [], | ||
| steps: [ | ||
| { | ||
| title: 'Record the site URL', | ||
| detail: | ||
| 'The engine talks to the site\'s own REST API at <site>/wp-json/wp/v2 - enabled by default on WordPress 5.6+. ' | ||
| + 'Record the site root, e.g. https://blog.example.com .', | ||
| env: 'WORDPRESS_SITE_URL', | ||
| }, | ||
| { | ||
| title: 'Pick the posting user', | ||
| detail: 'Posts publish as this user, so use an Author/Editor account you control. Record its username (login name, not the display name).', | ||
| env: 'WORDPRESS_USERNAME', | ||
| }, | ||
| { | ||
| title: 'Create an application password', | ||
| detail: | ||
| 'In wp-admin open Users > Profile > Application Passwords, name it (e.g. pendpost) and create it. Copy the generated password ' | ||
| + 'immediately - WordPress shows it once. Spaces in it are fine (they are part of the display format and accepted verbatim).', | ||
| env: 'WORDPRESS_APP_PASSWORD', | ||
| }, | ||
| { | ||
| title: 'Validate', | ||
| detail: 'Run the CLI below. It calls /users/me and confirms the account can publish posts.', | ||
| cli: 'node scripts/wordpress-social.mjs auth', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'HTTP 401 with rest_cannot_access or a login error.', | ||
| cause: 'Application passwords are disabled (some security plugins turn them off) or the site forces basic-auth off over HTTP.', | ||
| fix: 'Serve the site over HTTPS, re-enable application passwords in the security plugin, and mint a fresh password.', | ||
| }, | ||
| { | ||
| symptom: 'HTTP 403 on publish while auth succeeds.', | ||
| cause: 'The posting user\'s role cannot publish (Contributor drafts only).', | ||
| fix: 'Give the user the Author role or higher.', | ||
| }, | ||
| { | ||
| symptom: 'Posts publish with broken formatting.', | ||
| cause: 'The markdown body uses constructs outside the engine\'s documented subset (lib/markdown.mjs).', | ||
| fix: 'Stick to the subset (headings, lists, links, emphasis, code, quotes, images) - or paste pre-rendered HTML into the body.', | ||
| }, | ||
| ], | ||
| }, | ||
| ghost: { | ||
| portalUrl: 'https://ghost.org/docs/admin-api/', | ||
| appToCreate: 'a custom integration under Settings > Integrations (no review)', | ||
| productsToAdd: [], | ||
| scopes: [], | ||
| steps: [ | ||
| { | ||
| title: 'Record the site URL', | ||
| detail: 'The engine talks to the Admin API at <site>/ghost/api/admin . Record the publication\'s root URL, e.g. https://blog.example.com .', | ||
| env: 'GHOST_SITE_URL', | ||
| }, | ||
| { | ||
| title: 'Create a custom integration', | ||
| detail: | ||
| 'In Ghost admin open Settings > Integrations > Add custom integration, name it (e.g. pendpost) and save. ' | ||
| + 'Copy the ADMIN API key - the long id:secret pair (the Content API key is read-only and not enough).', | ||
| env: 'GHOST_ADMIN_API_KEY', | ||
| }, | ||
| { | ||
| title: 'Validate', | ||
| detail: 'Run the CLI below. It mints a short-lived JWT from the key and reads the site record.', | ||
| cli: 'node scripts/ghost-social.mjs auth', | ||
| }, | ||
| { | ||
| title: 'Newsletter opt-in (optional)', | ||
| detail: | ||
| 'A post with "also send as newsletter" enabled emails your members on publish, via the first ACTIVE newsletter. ' | ||
| + 'Ghost only sends that email on the draft-to-published transition - the engine handles this, but the flag cannot be re-fired later.', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'HTTP 401 UNAUTHORIZED on every call.', | ||
| cause: 'The Content API key was copied instead of the Admin API key, or the integration was deleted.', | ||
| fix: 'Copy the ADMIN API key (id:secret) from the custom integration and reconnect.', | ||
| }, | ||
| { | ||
| symptom: 'Publish succeeds but no newsletter email arrives.', | ||
| cause: 'No active newsletter exists, members are zero, or the post was already published without the flag.', | ||
| fix: 'Activate a newsletter under Settings > Newsletters and re-check member signups; the email only fires on first publish.', | ||
| }, | ||
| ], | ||
| }, | ||
| nostr: { | ||
| portalUrl: 'https://nostr.com', | ||
| appToCreate: 'nothing - Nostr has no accounts, only a keypair you mint yourself', | ||
| productsToAdd: [], | ||
| scopes: [], | ||
| steps: [ | ||
| { | ||
| title: 'Mint (or import) a keypair', | ||
| detail: | ||
| 'Run the keygen CLI below to mint a fresh nsec/npub pair, or reuse an existing nsec from any Nostr client. ' | ||
| + 'The nsec IS the account - anyone holding it can post as you, so treat it like a password.', | ||
| cli: 'node scripts/nostr-social.mjs keygen --save', | ||
| env: 'NOSTR_PRIVATE_KEY', | ||
| }, | ||
| { | ||
| title: 'Choose relays', | ||
| detail: | ||
| 'Posts are published to every relay in the comma-separated list; one acceptance counts as published. ' | ||
| + 'Public defaults like wss://relay.damus.io,wss://nos.lol work; a private relay works too.', | ||
| env: 'NOSTR_RELAYS', | ||
| }, | ||
| { | ||
| title: 'Validate', | ||
| detail: 'Run the CLI below. It derives your npub and proves at least one relay is reachable.', | ||
| cli: 'node scripts/nostr-social.mjs auth', | ||
| }, | ||
| { | ||
| title: 'Know the lane\'s shape', | ||
| detail: | ||
| 'Nostr notes are TEXT ONLY here - there is no media hosting in the protocol itself, so a media post publishes its caption ' | ||
| + 'and logs a warning. Deletion is a request (NIP-09) that relays may ignore.', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'auth reports no reachable relay.', | ||
| cause: 'The relay URLs are wrong (must be ws:// or wss://) or the relay is down/blocking writes.', | ||
| fix: 'Test with a known-good public relay (wss://relay.damus.io) and re-run auth.', | ||
| }, | ||
| { | ||
| symptom: 'The engine refuses to start with a WebSocket error.', | ||
| cause: 'Node is older than 22, so the global WebSocket client is missing.', | ||
| fix: 'Upgrade Node to 22+ for the nostr lane (the other lanes run on 20).', | ||
| }, | ||
| ], | ||
| }, | ||
| gbp: { | ||
| portalUrl: 'https://console.cloud.google.com/apis/credentials', | ||
| appToCreate: 'a Google Cloud OAuth client (Desktop/Web) with the Business Profile APIs enabled', | ||
| productsToAdd: ['My Business Account Management API', 'My Business Business Information API', 'Google My Business API (v4)'], | ||
| scopes: ['https://www.googleapis.com/auth/business.manage'], | ||
| steps: [ | ||
| { | ||
| title: 'Request Business Profile API access', | ||
| detail: | ||
| 'Google gates the Business Profile APIs behind a per-project access request (the form at ' | ||
| + 'https://developers.google.com/my-business/content/prereqs). Until approved, every call returns 403 - the lane stays beta.', | ||
| }, | ||
| { | ||
| title: 'Create the OAuth client', | ||
| detail: | ||
| 'In the Cloud console create OAuth credentials, enable the Business Profile APIs above, and register the loopback redirect ' | ||
| + 'http://127.0.0.1:8088/oauth/gbp/callback . Copy the client id and secret.', | ||
| env: 'GBP_CLIENT_ID', | ||
| }, | ||
| { | ||
| title: 'Authorize', | ||
| detail: | ||
| 'Run the CLI below. It opens the consent screen for business.manage, exchanges the code, stores the tokens, and - once the API ' | ||
| + 'access is approved - prints your account and location ids as candidates for the two identifier fields.', | ||
| cli: 'node scripts/gbp-social.mjs auth', | ||
| }, | ||
| { | ||
| title: 'Set the target location', | ||
| detail: 'Set GBP_ACCOUNT_ID and GBP_LOCATION_ID to the numeric ids of the verified business location posts should appear on.', | ||
| env: 'GBP_LOCATION_ID', | ||
| }, | ||
| ], | ||
| commonFailures: [ | ||
| { | ||
| symptom: 'Every API call returns 403 after a successful consent.', | ||
| cause: 'The Cloud project has not been approved for the Business Profile APIs yet.', | ||
| fix: 'Submit the access request form and wait for approval; the OAuth token itself is fine.', | ||
| }, | ||
| { | ||
| symptom: 'Publish fails with a location error.', | ||
| cause: 'GBP_ACCOUNT_ID/GBP_LOCATION_ID are missing, or the location is not verified/owned by the authorized account.', | ||
| fix: 'Run auth to list candidates, verify the location in the Business Profile manager, and set both ids.', | ||
| }, | ||
| { | ||
| symptom: 'The post is created but never becomes visible.', | ||
| cause: 'Google reviews local posts; REJECTED state means a content-policy hit.', | ||
| fix: 'Check verify for the post state and adjust the content (no phone numbers in the summary, policy-safe imagery).', | ||
| }, | ||
| ], | ||
| }, | ||
| }; |
+17
-7
@@ -29,5 +29,9 @@ // publish-job.mjs - the cloud-ready publish-job seam (PURE, no I/O). | ||
| // The engine lanes the seam knows about. Mirrors lib/scheduler.mjs ENGINES plus | ||
| // the credential-gated bluesky lane; an unknown lane is refused rather than | ||
| // silently described. | ||
| const KNOWN_LANES = new Set(['meta', 'linkedin', 'x', 'youtube', 'youtube-release', 'bluesky', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok']); | ||
| // 'bluesky', a contract-reserved lane name (docs/specs/cloud-integration-contract.md, | ||
| // pendpost-cloud LANE_CAPABILITIES) with NO engine on either side yet - it stays a | ||
| // valid envelope word but is never owed (lanesOwed) and never cloud-deferred | ||
| // (CLOUD_LANES), so no job is ever built for it. Plus the native-scheduling recovery | ||
| // lanes (mastodon-resolve, wordpress-release, ghost-release). An unknown lane is | ||
| // refused rather than silently described. | ||
| const KNOWN_LANES = new Set(['meta', 'linkedin', 'x', 'youtube', 'youtube-release', 'bluesky', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok', 'mastodon', 'mastodon-resolve', 'wordpress', 'wordpress-release', 'ghost', 'ghost-release', 'nostr', 'gbp']); | ||
@@ -52,6 +56,6 @@ export class PublishJobError extends Error { | ||
| // A lane delivers 'native' only when EVERY platform it publishes schedules | ||
| // natively (Facebook scheduled_publish_time, YouTube publishAt) and therefore | ||
| // survives the machine being off. A mixed meta lane (instagram + facebook) holds | ||
| // a live lane (instagram), so it is 'live'. An empty set is 'live' (a runtime is | ||
| // needed) - never silently native. | ||
| // natively (NATIVE_SCHEDULING_PLATFORMS: FB/YouTube/Mastodon/WordPress/Ghost) and | ||
| // therefore survives the machine being off. A mixed meta lane (instagram + | ||
| // facebook) holds a live lane (instagram), so it is 'live'. An empty set is | ||
| // 'live' (a runtime is needed) - never silently native. | ||
| function deliveryModeFor(lanePlatforms) { | ||
@@ -150,2 +154,8 @@ const allNative = Array.isArray(lanePlatforms) | ||
| tiktokVideoId: ids.tiktokVideoId || null, | ||
| mastodonStatusId: ids.mastodonStatusId || null, | ||
| mastodonScheduledId: ids.mastodonScheduledId || null, | ||
| wordpressPostId: ids.wordpressPostId || null, | ||
| ghostPostId: ids.ghostPostId || null, | ||
| nostrEventId: ids.nostrEventId || null, | ||
| gbpPostId: ids.gbpPostId || null, | ||
| }), | ||
@@ -152,0 +162,0 @@ }), |
+132
-24
@@ -48,2 +48,25 @@ // scheduler.mjs - the in-process publish scheduler (Phase B). | ||
| tiktok: { script: 'scripts/tiktok-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| // mastodon/wordpress/ghost schedule NATIVELY ahead of due (owner decision | ||
| // 2026-07-05, the yt model): the platform's own scheduler fires them, so they | ||
| // survive this machine being off without pendpost-cloud. Each engine's | ||
| // `schedule` also covers the late-approval case (past due -> publish now). | ||
| mastodon: { script: 'scripts/mastodon-social.mjs', command: 'schedule', timeoutMs: 180_000 }, | ||
| // The blog lanes upload a featured/hero image before the post itself, so they | ||
| // get the meta-lane media budget (300s), not the 180s text-lane budget. | ||
| wordpress: { script: 'scripts/wordpress-social.mjs', command: 'schedule', timeoutMs: 300_000 }, | ||
| ghost: { script: 'scripts/ghost-social.mjs', command: 'schedule', timeoutMs: 300_000 }, | ||
| // Recovery lanes for the native trio, mirroring youtube-release (each is one | ||
| // cheap read + at most one status flip, hence the tight timeouts). LOCAL-ONLY | ||
| // like youtube-release: owed by lanesFor, never the shared lanesOwed. | ||
| // mastodon-resolve: the fired queue entry mints a NEW status id - record it | ||
| // (or cancel + republish a queue entry the instance provably never fired). | ||
| 'mastodon-resolve': { script: 'scripts/mastodon-social.mjs', command: 'resolve', timeoutMs: 120_000 }, | ||
| // wordpress-release: wp-cron only runs on site traffic, so a low-traffic | ||
| // site leaves a post 'future' past its date - flip it live. | ||
| 'wordpress-release': { script: 'scripts/wordpress-social.mjs', command: 'release', timeoutMs: 120_000 }, | ||
| // ghost-release: the site was down at the publish minute - flip it live | ||
| // (the newsletter attached at schedule time rides along). | ||
| 'ghost-release': { script: 'scripts/ghost-social.mjs', command: 'release', timeoutMs: 120_000 }, | ||
| nostr: { script: 'scripts/nostr-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| gbp: { script: 'scripts/gbp-social.mjs', command: 'publish-due', timeoutMs: 180_000 }, | ||
| }; | ||
@@ -53,6 +76,16 @@ | ||
| // ENABLED_LANES (packages/shared/src/enabled-platforms.ts); keep the two in sync. | ||
| // Every other lane (youtube + youtube-release, telegram, discord, reddit, pinterest, | ||
| // tiktok) is LOCAL-ONLY: the cloud never fires it, so a cloud-managed brand still | ||
| // runs those lanes on the normal local schedule (the old early-return stranded them). | ||
| export const CLOUD_LANES = Object.freeze(['meta', 'linkedin', 'x', 'bluesky']); | ||
| // Every other ENGINES lane is LOCAL-ONLY: the cloud never fires it, so a | ||
| // cloud-managed brand still runs those lanes on the normal local schedule (the | ||
| // old early-return stranded them). A lane goes in ONLY once its engine runs in | ||
| // cloud prod (cloud-first, core-second): listing it early defers posts to a | ||
| // runtime that skips them as lane_disabled, so they fire NOWHERE - exactly how | ||
| // 'bluesky' (a contract-reserved lane with no engine on either side) once | ||
| // black-holed approved bluesky posts. | ||
| // telegram/discord/nostr were added 2026-07-05 AFTER the cloud shipped their | ||
| // engines to prod (Fly v46; GET /v1/capabilities lists them under cloudLanes) and | ||
| // the vault-ingest path (below in cloud-client's PLATFORM_TOKEN_SOURCES) learned to | ||
| // seal their credentials. The native lanes (youtube/mastodon/wordpress/ghost) never | ||
| // need the cloud - the platform's own scheduler fires them (pendpost-cloud classifies | ||
| // them 'native'). pinterest/gbp/reddit/tiktok stay LOCAL-ONLY (classified local_only). | ||
| export const CLOUD_LANES = Object.freeze(['meta', 'linkedin', 'x', 'telegram', 'discord', 'nostr']); | ||
@@ -181,2 +214,10 @@ // Cloud-managed liveness backstop: a cloud-lane post overdue past this grace that the | ||
| if (on('tiktok') && platforms.includes('tiktok') && !post.ids.tiktokVideoId) owed.push('tiktok'); | ||
| // mastodon: a natively-scheduled queue entry (mastodonScheduledId) is already | ||
| // handed off - only a post with NEITHER id still owes the lane (the fired | ||
| // status id lands later via the local mastodon-resolve recovery lane). | ||
| if (on('mastodon') && platforms.includes('mastodon') && !post.ids.mastodonStatusId && !post.ids.mastodonScheduledId) owed.push('mastodon'); | ||
| if (on('wordpress') && platforms.includes('wordpress') && !post.ids.wordpressPostId) owed.push('wordpress'); | ||
| if (on('ghost') && platforms.includes('ghost') && !post.ids.ghostPostId) owed.push('ghost'); | ||
| if (on('nostr') && platforms.includes('nostr') && !post.ids.nostrEventId) owed.push('nostr'); | ||
| if (on('gbp') && platforms.includes('gbp') && !post.ids.gbpPostId) owed.push('gbp'); | ||
| return owed; | ||
@@ -186,20 +227,29 @@ } | ||
| // Which lanes are due to fire NOW for the LOCAL scheduler. Layers the due-time gate | ||
| // over lanesOwed (byte-identical to the former lanesFor): the live lanes | ||
| // (meta/linkedin/x) fire at/after the due minute; YouTube schedules natively AHEAD | ||
| // of the due time (publishAt must be in the future), so a past-due youtube post | ||
| // without an id stays visible as overdue in the planner instead of being | ||
| // force-published late. Lane order (meta, linkedin, x, youtube) is preserved. | ||
| // over lanesOwed: the live lanes (meta/linkedin/x/...) fire at/after the due | ||
| // minute; the NATIVE lanes hand off to the platform's own scheduler AHEAD of the | ||
| // due time. YouTube fires strictly before due (publishAt must be in the future; a | ||
| // past-due upload-less post stays visible as overdue rather than force-published | ||
| // late). mastodon/wordpress/ghost fire whenever owed: ahead of due the engine | ||
| // `schedule` hands the entry off natively, past due the same command publishes | ||
| // immediately (text/blog posts publish late by design - the pre-native behavior). | ||
| const NATIVE_ANYTIME_LANES = new Set(['mastodon', 'wordpress', 'ghost']); | ||
| function lanesFor(post, now) { | ||
| const due = Date.parse(post.scheduledAt || ''); | ||
| if (Number.isNaN(due)) return []; | ||
| const lanes = lanesOwed(post).filter((lane) => (lane === 'youtube' ? due > now : due <= now)); | ||
| // LOCAL recovery (not in the shared lanesOwed, so it never changes the cloud | ||
| // push contract): a natively-scheduled YouTube video YouTube left PRIVATE past | ||
| // its publishAt - the read-back (post.verify) says 'private-overdue'. Owed only | ||
| // once the id exists AND the verify state is private-overdue, so it can never | ||
| // race the upload lane (which fires only when !ytVideoId) or publish a video | ||
| // still legitimately scheduled for the future. | ||
| const lanes = lanesOwed(post).filter((lane) => { | ||
| if (lane === 'youtube') return due > now; | ||
| if (NATIVE_ANYTIME_LANES.has(lane)) return true; | ||
| return due <= now; | ||
| }); | ||
| // LOCAL recovery lanes (not in the shared lanesOwed, so they never change the | ||
| // cloud push contract). Each is owed only once its lane's platform object | ||
| // exists, so it can never race the hand-off lane above. | ||
| const posting = getPosting(); | ||
| const platforms = post.platforms || []; | ||
| // youtube-release: a natively-scheduled video YouTube left PRIVATE past its | ||
| // publishAt - the read-back (post.verify) says 'private-overdue'. Owed only on | ||
| // that verify evidence, never for a video still legitimately scheduled ahead. | ||
| if (due <= now | ||
| && platformEnabled('youtube', getPosting()) | ||
| && (post.platforms || []).includes('youtube') | ||
| && platformEnabled('youtube', posting) | ||
| && platforms.includes('youtube') | ||
| && post.ids?.ytVideoId | ||
@@ -209,2 +259,30 @@ && post.verify?.platforms?.youtube?.state === 'private-overdue') { | ||
| } | ||
| // mastodon-resolve: a fired queue entry mints a NEW status id, so past due a | ||
| // scheduled-but-unresolved post owes the reconcile that records it (or cancels | ||
| // + republishes a queue entry the instance never fired). No verify gate: the | ||
| // resolve is itself the cheap read, and it terminates (posted) in one run. | ||
| if (due <= now | ||
| && platformEnabled('mastodon', posting) | ||
| && platforms.includes('mastodon') | ||
| && post.ids?.mastodonScheduledId | ||
| && !post.ids?.mastodonStatusId) { | ||
| lanes.push('mastodon-resolve'); | ||
| } | ||
| // wordpress-release / ghost-release: the platform scheduler missed its minute | ||
| // (wp-cron starved / site down) - the read-back says so. Same evidence gate as | ||
| // youtube-release, one status flip to recover. | ||
| if (due <= now | ||
| && platformEnabled('wordpress', posting) | ||
| && platforms.includes('wordpress') | ||
| && post.ids?.wordpressPostId | ||
| && post.verify?.platforms?.wordpress?.state === 'future-overdue') { | ||
| lanes.push('wordpress-release'); | ||
| } | ||
| if (due <= now | ||
| && platformEnabled('ghost', posting) | ||
| && platforms.includes('ghost') | ||
| && post.ids?.ghostPostId | ||
| && post.verify?.platforms?.ghost?.state === 'scheduled-overdue') { | ||
| lanes.push('ghost-release'); | ||
| } | ||
| return lanes; | ||
@@ -230,2 +308,10 @@ } | ||
| tiktok: ['tiktok'], | ||
| mastodon: ['mastodon'], | ||
| 'mastodon-resolve': ['mastodon'], | ||
| wordpress: ['wordpress'], | ||
| 'wordpress-release': ['wordpress'], | ||
| ghost: ['ghost'], | ||
| 'ghost-release': ['ghost'], | ||
| nostr: ['nostr'], | ||
| gbp: ['gbp'], | ||
| }; | ||
@@ -346,2 +432,7 @@ export function lanePlatforms(lane, post) { | ||
| const cc = await import('./cloud-client.mjs'); | ||
| // Active reachability + worker-liveness probe FIRST, before the work steps: it runs | ||
| // even when push is skipped (sync stopped) so the status dot's contact stamp stays | ||
| // honest during quiet periods and picks up a wedged cloud worker. Self-guarded. | ||
| try { await cc.cloudHealthPing(); } | ||
| catch (e) { logLine('warn', `scheduler: cloud health ping failed for ${who}: ${e.message}`); } | ||
| try { reconcile = await cc.reconcileCloudResults({}); } | ||
@@ -378,2 +469,9 @@ catch (e) { logLine('warn', `scheduler: cloud reconcile failed for ${who}: ${e.message}`); } | ||
| catch (e) { logLine('warn', `scheduler: cloud remediate failed for ${who}: ${e.message}`); } | ||
| // Lift the cloud's staleness holds: the worker parks any job >15 min past due | ||
| // (stale_held) and fires it ONLY on this explicit retrigger. retriggerHeldJobs | ||
| // skips posts already posted locally (the double-post guard) and anchors the | ||
| // backstop below on the handoff (state.cloudRetriggered), so the cloud gets a | ||
| // fresh grace window and local stands down. MUST run before the walk. | ||
| try { await cc.retriggerHeldJobs((reconcile && reconcile.held) || []); } | ||
| catch (e) { logLine('warn', `scheduler: cloud held-retrigger failed for ${who}: ${e.message}`); } | ||
| } catch (e) { | ||
@@ -441,3 +539,5 @@ logLine('warn', `scheduler: cloud-client load failed for ${who}: ${e.message}`); | ||
| if (cloudManaged && lanes.length) { | ||
| const acks = loadState().cloudAccepted || {}; | ||
| const tickState = loadState(); | ||
| const acks = tickState.cloudAccepted || {}; | ||
| const retriggers = tickState.cloudRetriggered || {}; | ||
| const due = Date.parse(post.scheduledAt || ''); | ||
@@ -448,5 +548,12 @@ const backstopLanes = []; | ||
| if (Number.isNaN(due)) return false; | ||
| // The grace anchors on the LATEST cloud handoff: due time, first push-ack, | ||
| // or a stale-held retrigger (retriggerHeldJobs ran just above) - each one | ||
| // re-opens the cloud's window so exactly ONE firer acts at a time. | ||
| const ack = acks[`${c.id}:${post.id}:${lane}`]; | ||
| const ackMs = ack ? Date.parse(ack.at || '') : NaN; | ||
| const anchor = Number.isFinite(ackMs) ? Math.max(due, ackMs) : due; | ||
| const retr = retriggers[`${c.id}:${post.id}:${lane}`]; | ||
| const retrMs = retr ? Date.parse(retr.at || '') : NaN; | ||
| let anchor = due; | ||
| if (Number.isFinite(ackMs)) anchor = Math.max(anchor, ackMs); | ||
| if (Number.isFinite(retrMs)) anchor = Math.max(anchor, retrMs); | ||
| if (now - anchor <= CLOUD_BACKSTOP_GRACE_MS) return false; // the cloud's window | ||
@@ -599,9 +706,10 @@ backstopLanes.push(lane); | ||
| ran.push({ campaign: c.id, postId: post.id, lane, ok: !err && envelope?.ok !== false }); | ||
| // A successful release flipped the already-uploaded video public. Re-read | ||
| // it so post.verify leaves 'private-overdue' (-> verified-live), the post | ||
| // stops being owed a release next tick, and the planner drops it from the | ||
| // A successful release flipped the already-handed-off object live. Re-read | ||
| // it so post.verify leaves its overdue state (private-overdue / | ||
| // future-overdue / scheduled-overdue -> verified-live), the post stops | ||
| // being owed a release next tick, and the planner drops it from the | ||
| // due list. verifySweep only re-checks 'fired-assumed', never 'verify- | ||
| // failed', so this explicit re-verify is what closes the loop. Dynamic | ||
| // import avoids a scheduler<->verify cycle (the pattern tick() uses). | ||
| if (lane === 'youtube-release' && !err && envelope?.ok !== false) { | ||
| if (['youtube-release', 'wordpress-release', 'ghost-release'].includes(lane) && !err && envelope?.ok !== false) { | ||
| try { | ||
@@ -608,0 +716,0 @@ const { verifyPost } = await import('./verify.mjs'); |
+39
-1
@@ -15,3 +15,3 @@ // setup.mjs - the single machine-readable SETUP-COMPLETENESS signal, read by BOTH | ||
| const PLATFORMS = ['meta', 'linkedin', 'x', 'youtube', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok']; | ||
| const PLATFORMS = ['meta', 'linkedin', 'x', 'youtube', 'telegram', 'discord', 'reddit', 'pinterest', 'tiktok', 'mastodon', 'wordpress', 'ghost', 'nostr', 'gbp']; | ||
@@ -82,2 +82,40 @@ // Per-platform: label, the non-secret IDENTIFIERS an operator sets (config_set, | ||
| }, | ||
| // The wave-2 static lanes are LIVE-VERIFIED against real local platform | ||
| // instances (test/integration/ sandboxes: a real publish + a real read-back, | ||
| // media included where supported), so they ship beta:false - the same | ||
| // honesty bar the telegram/discord wave met with real test accounts. | ||
| mastodon: { | ||
| label: 'Mastodon', | ||
| identifiers: [], | ||
| secret: 'an instance URL + app access token', | ||
| connect: 'node scripts/mastodon-social.mjs auth', | ||
| }, | ||
| wordpress: { | ||
| label: 'WordPress', | ||
| identifiers: [], | ||
| secret: 'a site URL + username + application password', | ||
| connect: 'node scripts/wordpress-social.mjs auth', | ||
| }, | ||
| ghost: { | ||
| label: 'Ghost', | ||
| identifiers: [], | ||
| secret: 'a site URL + Admin API key', | ||
| connect: 'node scripts/ghost-social.mjs auth', | ||
| }, | ||
| nostr: { | ||
| label: 'Nostr', | ||
| identifiers: [], | ||
| secret: 'an nsec signing key + relay list', | ||
| connect: 'node scripts/nostr-social.mjs auth', | ||
| }, | ||
| gbp: { | ||
| label: 'Google Business Profile', | ||
| beta: true, | ||
| identifiers: [ | ||
| { key: 'gbpAccountId', acctField: 'accountId', label: 'GBP Account ID', required: true }, | ||
| { key: 'gbpLocationId', acctField: 'locationId', label: 'GBP Location ID', required: true }, | ||
| ], | ||
| secret: 'a Google OAuth token (business.manage)', | ||
| connect: 'node scripts/gbp-social.mjs auth', | ||
| }, | ||
| }; | ||
@@ -84,0 +122,0 @@ |
+1
-1
@@ -20,3 +20,3 @@ // util.mjs - shared helpers for the pendpost server (zero-dep). | ||
| export const DATA_ROOT = path.join(WORKSPACE_ROOT, 'data'); | ||
| export const VERSION = '1.2.0'; | ||
| export const VERSION = '1.2.1'; | ||
@@ -23,0 +23,0 @@ // The .env now lives in the ACTIVE client subtree, not at WORKSPACE_ROOT. |
+12
-0
@@ -32,2 +32,7 @@ // verify.mjs - publish read-back. Turns the guessed 'fired-assumed' (probably | ||
| tiktok: 'scripts/tiktok-social.mjs', | ||
| mastodon: 'scripts/mastodon-social.mjs', | ||
| wordpress: 'scripts/wordpress-social.mjs', | ||
| ghost: 'scripts/ghost-social.mjs', | ||
| nostr: 'scripts/nostr-social.mjs', | ||
| gbp: 'scripts/gbp-social.mjs', | ||
| }; | ||
@@ -54,2 +59,9 @@ | ||
| if (platforms.includes('tiktok') && ids.tiktokVideoId) lanes.push('tiktok'); | ||
| // mastodon: a natively-scheduled queue entry (mastodonScheduledId) is readable | ||
| // too - the engine reports 'scheduled' / 'pending-resolve' for it. | ||
| if (platforms.includes('mastodon') && (ids.mastodonStatusId || ids.mastodonScheduledId)) lanes.push('mastodon'); | ||
| if (platforms.includes('wordpress') && ids.wordpressPostId) lanes.push('wordpress'); | ||
| if (platforms.includes('ghost') && ids.ghostPostId) lanes.push('ghost'); | ||
| if (platforms.includes('nostr') && ids.nostrEventId) lanes.push('nostr'); | ||
| if (platforms.includes('gbp') && ids.gbpPostId) lanes.push('gbp'); | ||
| return lanes; | ||
@@ -56,0 +68,0 @@ } |
+1
-1
| { | ||
| "name": "pendpost", | ||
| "version": "1.2.0", | ||
| "version": "1.2.1", | ||
| "description": "pendpost is a free, open-source (MIT), local-first social media planner where an AI agent drafts and schedules posts across Instagram, Facebook, LinkedIn, YouTube, and X behind a human approval gate you control.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+10
-1
@@ -15,3 +15,3 @@ # pendpost | ||
| pendpost is a free, open-source (MIT), local-first social media planner where an AI agent drafts and schedules posts across Instagram, Facebook, LinkedIn, YouTube, X, Telegram, and Discord behind a human approval gate you control. It is MCP-native: AI agents draft, lint, schedule, and queue your posts, but nothing goes live until a human approves it. It is built for developers, agencies, and technical solopreneurs who want agents to do the work without handing them the keys, and without getting accounts flagged. | ||
| pendpost is a free, open-source (MIT), local-first social media planner where an AI agent drafts and schedules posts across Instagram, Facebook, LinkedIn, YouTube, X, Telegram, Discord, Mastodon, Nostr - and long-form blogs on WordPress and Ghost (with newsletter send) - behind a human approval gate you control. It is MCP-native: AI agents draft, lint, schedule, and queue your posts, but nothing goes live until a human approves it. It is built for developers, agencies, and technical solopreneurs who want agents to do the work without handing them the keys, and without getting accounts flagged. | ||
@@ -128,2 +128,11 @@ ## Why pendpost is different (not just a scheduler) | ||
| node scripts/x-social.mjs auth | ||
| # Static-credential lanes (no browser): paste the credential into .env (or use | ||
| # the dashboard Setup form), then validate with the engine's auth command. | ||
| node scripts/telegram-social.mjs auth # TELEGRAM_BOT_TOKEN + TELEGRAM_CHANNEL_ID | ||
| node scripts/discord-social.mjs auth # DISCORD_WEBHOOK_URL | ||
| node scripts/mastodon-social.mjs auth # MASTODON_INSTANCE_URL + MASTODON_ACCESS_TOKEN | ||
| node scripts/wordpress-social.mjs auth # WORDPRESS_SITE_URL + _USERNAME + _APP_PASSWORD | ||
| node scripts/ghost-social.mjs auth # GHOST_SITE_URL + GHOST_ADMIN_API_KEY | ||
| node scripts/nostr-social.mjs keygen --save && node scripts/nostr-social.mjs auth # + NOSTR_RELAYS | ||
| ``` | ||
@@ -130,0 +139,0 @@ |
+16
-2
@@ -370,5 +370,6 @@ #!/usr/bin/env node | ||
| async function createTweet(text, mediaId, token) { | ||
| async function createTweet(text, mediaId, token, replyToTweetId = null) { | ||
| const body = { text }; | ||
| if (mediaId) body.media = { media_ids: [String(mediaId)] }; | ||
| if (replyToTweetId) body.reply = { in_reply_to_tweet_id: String(replyToTweetId) }; | ||
| const data = await api('POST', '/tweets', { body, token }); | ||
@@ -636,2 +637,15 @@ const id = data?.data?.id; | ||
| // Reply-chain (xReplyTo): this post replies to a sibling post in the SAME | ||
| // plan. Fail-closed: until the parent has published (xPostId set), skip | ||
| // this tick and let the next one retry - never publish an orphan reply. | ||
| // In a catch-up run the parent publishes first (plan order), so its fresh | ||
| // in-memory xPostId is already visible to children within the same loop. | ||
| let replyToTweetId = null; | ||
| if (post.xReplyTo) { | ||
| const parent = (plan.posts || []).find((p) => p.id === post.xReplyTo); | ||
| if (!parent) { console.log(`[warn] ${post.id}: xReplyTo "${post.xReplyTo}" not found in this plan - skipping.`); continue; } | ||
| if (!parent.xPostId) { console.log(`[skip] ${post.id}: waiting for parent "${post.xReplyTo}" to publish before threading.`); continue; } | ||
| replyToTweetId = String(parent.xPostId); | ||
| } | ||
| if (args['dry-run']) { | ||
@@ -647,3 +661,3 @@ console.log(textPost | ||
| const mediaId = textPost ? null : await uploadMedia(mediaPath, token); | ||
| const tweetId = await createTweet(text, mediaId, token); | ||
| const tweetId = await createTweet(text, mediaId, token, replyToTweetId); | ||
@@ -650,0 +664,0 @@ post.xPostId = tweetId; |
Sorry, the diff of this file is too big to display
| *,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}:before,:after{--tw-content:""}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder{opacity:1;color:#9ca3af}textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,system-ui,sans-serif}body:is(.dark *){--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}html.theme-ready body{transition:background-color .35s,color .35s}@media (prefers-reduced-motion:reduce){html.theme-ready body{transition:none}}.container{width:100%}@media (width>=640px){.container{max-width:640px}}@media (width>=768px){.container{max-width:768px}}@media (width>=1024px){.container{max-width:1024px}}@media (width>=1280px){.container{max-width:1280px}}@media (width>=1536px){.container{max-width:1536px}}.glass-panel{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow);--tw-backdrop-blur:blur(40px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:#ffffffb3;border-width:1px;border-color:#ffffff80}.glass-panel:is(.dark *){background-color:#18181b99;border-color:#ffffff1a}.scrollbar-soft{scrollbar-width:thin;scrollbar-color:#a1a1aa80 transparent}.scrollbar-soft::-webkit-scrollbar{width:8px;height:8px}.scrollbar-soft::-webkit-scrollbar-track{background:0 0}.scrollbar-soft::-webkit-scrollbar-thumb{background-color:#a1a1aa73;background-clip:padding-box;border:2px solid #0000;border-radius:9999px}.scrollbar-soft::-webkit-scrollbar-thumb:hover{background-color:#71717ab3;background-clip:padding-box}.pp-seek{appearance:none;cursor:pointer;background:#ffffff38;border-radius:9999px;height:3px}.pp-seek::-webkit-slider-thumb{appearance:none;background:#fff;border-radius:9999px;width:9px;height:9px;transition:transform .12s;box-shadow:0 1px 3px #0006}.pp-seek:hover::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek:focus-visible::-webkit-slider-thumb{transform:scale(1.4)}.pp-seek::-moz-range-thumb{background:#fff;border:none;border-radius:9999px;width:9px;height:9px;box-shadow:0 1px 3px #0006}.pp-seek:focus-visible{outline:none}@media (prefers-reduced-motion:reduce){.pp-seek::-webkit-slider-thumb{transition:none}}.sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.-inset-5{inset:-1.25rem}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.-bottom-24{bottom:-6rem}.-left-32{left:-8rem}.-right-1{right:-.25rem}.-right-24{right:-6rem}.-top-1{top:-.25rem}.-top-32{top:-8rem}.bottom-0{bottom:0}.bottom-1\.5{bottom:.375rem}.bottom-4{bottom:1rem}.left-0{left:0}.left-1\/3{left:33.3333%}.left-2\.5{left:.625rem}.right-0{right:0}.right-0\.5{right:.125rem}.right-1{right:.25rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-2\.5{right:.625rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\.5{top:.375rem}.top-1\/2{top:50%}.top-1\/3{top:33.3333%}.top-2{top:.5rem}.z-0{z-index:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[80\]{z-index:80}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.m-0{margin:0}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-1\.5{margin-right:.375rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-auto{margin-top:auto}.mt-px{margin-top:1px}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[1\.91\/1\]{aspect-ratio:1.91}.aspect-\[4\/5\]{aspect-ratio:4/5}.aspect-\[9\/16\]{aspect-ratio:9/16}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-28{height:7rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[252px\]{height:252px}.h-\[72px\]{height:72px}.h-full{height:100%}.h-px{height:1px}.max-h-72{max-height:18rem}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[92vh\]{max-height:92vh}.min-h-0{min-height:0}.min-h-48{min-height:12rem}.min-h-\[92px\]{min-height:92px}.min-h-dvh{min-height:100dvh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-4{width:1rem}.w-44{width:11rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-\[420px\]{width:420px}.w-\[440px\]{width:440px}.w-\[72px\]{width:72px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[840px\]{min-width:840px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-\[10rem\]{max-width:10rem}.max-w-\[14ch\]{max-width:14ch}.max-w-\[16rem\]{max-width:16rem}.max-w-\[4rem\]{max-width:4rem}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[94vw\]{max-width:94vw}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.-translate-x-1\/2{--tw-translate-x:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-4{--tw-translate-x:1rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes blob{0%,to{transform:translate(0)scale(1)}33%{transform:translate(50px,-70px)scale(1.08)}66%{transform:translate(-40px,40px)scale(.94)}}.animate-blob{animation:20s ease-in-out infinite blob}.animate-blob-slow{animation:28s ease-in-out infinite reverse blob}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}.animate-ping{animation:1s cubic-bezier(0,0,.2,1) infinite ping}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:2s cubic-bezier(.4,0,.6,1) infinite pulse}@keyframes slide-in{0%{opacity:0;transform:translate(48px)}to{opacity:1;transform:translate(0)}}.animate-slide-in{animation:.26s cubic-bezier(.32,.72,0,1) both slide-in}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:1s linear infinite spin}.cursor-default{cursor:default}.cursor-none{cursor:none}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-1\.5{-moz-column-gap:.375rem;column-gap:.375rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-2\.5{-moz-column-gap:.625rem;column-gap:.625rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.gap-y-1\.5{row-gap:.375rem}.gap-y-3{row-gap:.75rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-black\/5>:not([hidden])~:not([hidden]){border-color:#0000000d}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-l-2xl{border-top-left-radius:1rem;border-bottom-left-radius:1rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-500\/30{border-color:#f59e0b4d}.border-black\/10{border-color:#0000001a}.border-black\/5{border-color:#0000000d}.border-brand{border-color:var(--accent,#0f766e)}.border-zinc-200\/50{border-color:#e4e4e780}.border-zinc-200\/60{border-color:#e4e4e799}.border-zinc-200\/70{border-color:#e4e4e7b3}.border-zinc-300{--tw-border-opacity:1;border-color:rgb(212 212 216/var(--tw-border-opacity,1))}.border-zinc-300\/60{border-color:#d4d4d899}.border-zinc-300\/70{border-color:#d4d4d8b3}.border-zinc-900\/5{border-color:#18181b0d}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-amber-500\/20{background-color:#f59e0b33}.bg-amber-500\/90{background-color:#f59e0be6}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:#0006}.bg-black\/45{background-color:#00000073}.bg-black\/5{background-color:#0000000d}.bg-black\/55{background-color:#0000008c}.bg-black\/80{background-color:#000c}.bg-black\/90{background-color:#000000e6}.bg-blue-400\/15{background-color:#60a5fa26}.bg-brand{background-color:var(--accent,#0f766e)}.bg-current{background-color:currentColor}.bg-cyan-400\/20{background-color:#22d3ee33}.bg-cyan-500{--tw-bg-opacity:1;background-color:rgb(6 182 212/var(--tw-bg-opacity,1))}.bg-cyan-500\/15{background-color:#06b6d426}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:#10b9811a}.bg-emerald-500\/15{background-color:#10b98126}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-emerald-600\/15{background-color:#05966926}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-500\/10{background-color:#f973161a}.bg-orange-500\/15{background-color:#f9731626}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/15{background-color:#ef444426}.bg-red-500\/70{background-color:#ef4444b3}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-600\/90{background-color:#dc2626e6}.bg-sky-500{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.bg-sky-500\/15{background-color:#0ea5e926}.bg-slate-400{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity,1))}.bg-slate-500\/10{background-color:#64748b1a}.bg-slate-500\/15{background-color:#64748b26}.bg-teal-400\/15{background-color:#2dd4bf26}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity,1))}.bg-teal-500\/15{background-color:#14b8a626}.bg-transparent{background-color:#0000}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:#fff3}.bg-white\/25{background-color:#ffffff40}.bg-white\/40{background-color:#fff6}.bg-white\/45{background-color:#ffffff73}.bg-white\/50{background-color:#ffffff80}.bg-white\/60{background-color:#fff9}.bg-white\/70{background-color:#ffffffb3}.bg-white\/80{background-color:#fffc}.bg-white\/95{background-color:#fffffff2}.bg-zinc-100\/70{background-color:#f4f4f5b3}.bg-zinc-200{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.bg-zinc-200\/40{background-color:#e4e4e766}.bg-zinc-200\/50{background-color:#e4e4e780}.bg-zinc-200\/60{background-color:#e4e4e799}.bg-zinc-200\/70{background-color:#e4e4e7b3}.bg-zinc-200\/80{background-color:#e4e4e7cc}.bg-zinc-300{--tw-bg-opacity:1;background-color:rgb(212 212 216/var(--tw-bg-opacity,1))}.bg-zinc-300\/40{background-color:#d4d4d866}.bg-zinc-300\/70{background-color:#d4d4d8b3}.bg-zinc-400{--tw-bg-opacity:1;background-color:rgb(161 161 170/var(--tw-bg-opacity,1))}.bg-zinc-500\/10{background-color:#71717a1a}.bg-zinc-500\/15{background-color:#71717a26}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-900\/5{background-color:#18181b0d}.bg-zinc-900\/60{background-color:#18181b99}.bg-zinc-900\/\[0\.06\]{background-color:#18181b0f}.bg-gradient-to-t{background-image:linear-gradient(to top, var(--tw-gradient-stops))}.from-black\/55{--tw-gradient-from:#0000008c var(--tw-gradient-from-position);--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.via-black\/25{--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), #00000040 var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.fill-zinc-900{fill:#18181b}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.object-top{-o-object-position:top;object-position:top}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pl-\[25px\]{padding-left:25px}.pr-0\.5{padding-right:.125rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-6{padding-right:1.5rem}.pr-9{padding-right:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-1\.5{padding-top:.375rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-body,.font-display{font-family:Inter,system-ui,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#0A66C2\]{--tw-text-opacity:1;color:rgb(10 102 194/var(--tw-text-opacity,1))}.text-\[\#1877F2\]{--tw-text-opacity:1;color:rgb(24 119 242/var(--tw-text-opacity,1))}.text-\[\#229ED9\]{--tw-text-opacity:1;color:rgb(34 158 217/var(--tw-text-opacity,1))}.text-\[\#5865F2\]{--tw-text-opacity:1;color:rgb(88 101 242/var(--tw-text-opacity,1))}.text-\[\#E4405F\]{--tw-text-opacity:1;color:rgb(228 64 95/var(--tw-text-opacity,1))}.text-\[\#E60023\]{--tw-text-opacity:1;color:rgb(230 0 35/var(--tw-text-opacity,1))}.text-\[\#FF0000\]{--tw-text-opacity:1;color:rgb(255 0 0/var(--tw-text-opacity,1))}.text-\[\#FF4500\]{--tw-text-opacity:1;color:rgb(255 69 0/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-600\/90{color:#d97706e6}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-700\/70{color:#b45309b3}.text-amber-700\/80{color:#b45309cc}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-brand{color:var(--accent,#0f766e)}.text-cyan-700{--tw-text-opacity:1;color:rgb(14 116 144/var(--tw-text-opacity,1))}.text-emerald-500{--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-700\/70{color:#047857b3}.text-emerald-700\/80{color:#047857cc}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-600\/90{color:#dc2626e6}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-700\/80{color:#b91c1ccc}.text-sky-700{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-teal-700{--tw-text-opacity:1;color:rgb(15 118 110/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/75{color:#ffffffbf}.text-white\/85{color:#ffffffd9}.text-white\/90{color:#ffffffe6}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-50{--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.text-zinc-700{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.text-zinc-800{--tw-text-opacity:1;color:rgb(39 39 42/var(--tw-text-opacity,1))}.text-zinc-900{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.text-zinc-950{--tw-text-opacity:1;color:rgb(9 9 11/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-zinc-400{text-decoration-color:#a1a1aa}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.accent-brand{accent-color:var(--accent,#0f766e)}.accent-emerald-600{accent-color:#059669}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-80{opacity:.8}.opacity-\[0\.03\]{opacity:.03}.mix-blend-overlay{mix-blend-mode:overlay}.shadow{--tw-shadow:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px #00000040;--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-\[0_8px_32px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 8px 32px #0000000d;--tw-shadow-colored:0 8px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.outline-none{outline-offset:2px;outline:2px solid #0000}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.ring-amber-300\/40{--tw-ring-color:#fcd34d66}.ring-amber-500\/30{--tw-ring-color:#f59e0b4d}.ring-amber-500\/40{--tw-ring-color:#f59e0b66}.ring-black\/10{--tw-ring-color:#0000001a}.ring-brand{--tw-ring-color:var(--accent,#0f766e)}.ring-cyan-500\/30{--tw-ring-color:#06b6d44d}.ring-emerald-500\/30{--tw-ring-color:#10b9814d}.ring-emerald-600\/40{--tw-ring-color:#05966966}.ring-orange-500\/30{--tw-ring-color:#f973164d}.ring-red-300\/40{--tw-ring-color:#fca5a566}.ring-red-500\/30{--tw-ring-color:#ef44444d}.ring-red-500\/40{--tw-ring-color:#ef444466}.ring-red-500\/60{--tw-ring-color:#ef444499}.ring-sky-500\/30{--tw-ring-color:#0ea5e94d}.ring-slate-500\/30{--tw-ring-color:#64748b4d}.ring-teal-500\/30{--tw-ring-color:#14b8a64d}.ring-transparent{--tw-ring-color:transparent}.ring-white{--tw-ring-opacity:1;--tw-ring-color:rgb(255 255 255/var(--tw-ring-opacity,1))}.ring-white\/20{--tw-ring-color:#fff3}.ring-zinc-300{--tw-ring-opacity:1;--tw-ring-color:rgb(212 212 216/var(--tw-ring-opacity,1))}.ring-zinc-500\/20{--tw-ring-color:#71717a33}.ring-zinc-500\/30{--tw-ring-color:#71717a4d}.ring-zinc-900\/10{--tw-ring-color:#18181b1a}.ring-zinc-900\/5{--tw-ring-color:#18181b0d}.ring-zinc-900\/\[0\.06\]{--tw-ring-color:#18181b0f}.blur{--tw-blur:blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-property:transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/assets/inter-latin-variable-Dx4kXJAl.woff2)format("woff2")}@keyframes pp-breathe{0%,to{transform:scale(1)}50%{transform:scale(1.055)}}@keyframes pp-chevron-clear{0%{stroke-dashoffset:64px}55%{stroke-dashoffset:0}80%{stroke-dashoffset:0}to{stroke-dashoffset:-64px}}@keyframes pp-rest{0%,to{transform:translateY(0)}50%{transform:translateY(-5px)}}@keyframes pp-glow-pulse{0%,to{opacity:.5}50%{opacity:.9}}@media (prefers-reduced-motion:reduce){[class~=pp-anim],[style*=pp-breathe],[style*=pp-chevron-clear],[style*=pp-rest],[style*=pp-glow-pulse]{animation:none!important}}.file\:mr-3::file-selector-button{margin-right:.75rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-brand::file-selector-button{background-color:var(--accent,#0f766e)}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-1\.5::file-selector-button{padding-top:.375rem;padding-bottom:.375rem}.file\:text-xs::file-selector-button{font-size:.75rem;line-height:1rem}.file\:font-bold::file-selector-button{font-weight:700}.file\:text-white::file-selector-button{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.placeholder\:font-normal::placeholder{font-weight:400}.placeholder\:text-zinc-400::placeholder{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.focus-within\:outline-none:focus-within{outline-offset:2px;outline:2px solid #0000}.focus-within\:ring-2:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-within\:ring-brand:focus-within{--tw-ring-color:var(--accent,#0f766e)}.hover\:-translate-y-1:hover{--tw-translate-y:-.25rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-black\/20:hover{border-color:#0003}.hover\:bg-amber-500\/20:hover{background-color:#f59e0b33}.hover\:bg-amber-500\/30:hover{background-color:#f59e0b4d}.hover\:bg-black\/70:hover{background-color:#000000b3}.hover\:bg-emerald-500\/20:hover{background-color:#10b98133}.hover\:bg-emerald-500\/25:hover{background-color:#10b98140}.hover\:bg-red-500\/10:hover{background-color:#ef44441a}.hover\:bg-red-500\/15:hover{background-color:#ef444426}.hover\:bg-red-500\/20:hover{background-color:#ef444433}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:bg-white\/15:hover{background-color:#ffffff26}.hover\:bg-white\/70:hover{background-color:#ffffffb3}.hover\:bg-white\/80:hover{background-color:#fffc}.hover\:bg-white\/90:hover{background-color:#ffffffe6}.hover\:bg-zinc-100:hover{--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200:hover{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.hover\:bg-zinc-200\/40:hover{background-color:#e4e4e766}.hover\:bg-zinc-200\/50:hover{background-color:#e4e4e780}.hover\:bg-zinc-200\/60:hover{background-color:#e4e4e799}.hover\:bg-zinc-300\/50:hover{background-color:#d4d4d880}.hover\:bg-zinc-300\/60:hover{background-color:#d4d4d899}.hover\:text-amber-900:hover{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.hover\:text-emerald-900:hover{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:text-zinc-600:hover{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.hover\:text-zinc-700:hover{--tw-text-opacity:1;color:rgb(63 63 70/var(--tw-text-opacity,1))}.hover\:text-zinc-900:hover{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:decoration-zinc-700:hover{text-decoration-color:#3f3f46}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.hover\:ring-1:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus\:outline-none:focus,.focus-visible\:outline-none:focus-visible{outline-offset:2px;outline:2px solid #0000}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-amber-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.focus-visible\:ring-brand:focus-visible{--tw-ring-color:var(--accent,#0f766e)}.focus-visible\:ring-emerald-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus-visible\:ring-red-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus-visible\:ring-white\/60:focus-visible{--tw-ring-color:#fff9}.focus-visible\:ring-white\/70:focus-visible{--tw-ring-color:#ffffffb3}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:hover\:bg-transparent:hover:disabled{background-color:#0000}.group:hover .group-hover\:translate-x-0\.5{--tw-translate-x:.125rem;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-none{transition-property:none}.motion-reduce\:hover\:translate-y-0:hover{--tw-translate-y:0px;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}}.dark\:divide-white\/5:is(.dark *)>:not([hidden])~:not([hidden]){border-color:#ffffff0d}.dark\:border-amber-400\/30:is(.dark *){border-color:#fbbf244d}.dark\:border-brand-light:is(.dark *){border-color:var(--accent-light,#5eead4)}.dark\:border-white\/10:is(.dark *){border-color:#ffffff1a}.dark\:border-white\/5:is(.dark *){border-color:#ffffff0d}.dark\:border-zinc-600:is(.dark *){--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.dark\:border-zinc-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.dark\:border-zinc-700\/50:is(.dark *){border-color:#3f3f4680}.dark\:border-zinc-700\/60:is(.dark *){border-color:#3f3f4699}.dark\:border-zinc-700\/70:is(.dark *){border-color:#3f3f46b3}.dark\:bg-amber-400\/10:is(.dark *){background-color:#fbbf241a}.dark\:bg-black\/20:is(.dark *){background-color:#0003}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3b82f61a}.dark\:bg-brand-light:is(.dark *){background-color:var(--accent-light,#5eead4)}.dark\:bg-cyan-500\/10:is(.dark *){background-color:#06b6d41a}.dark\:bg-teal-500\/10:is(.dark *){background-color:#14b8a61a}.dark\:bg-white\/10:is(.dark *){background-color:#ffffff1a}.dark\:bg-zinc-100:is(.dark *){--tw-bg-opacity:1;background-color:rgb(244 244 245/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600:is(.dark *){--tw-bg-opacity:1;background-color:rgb(82 82 91/var(--tw-bg-opacity,1))}.dark\:bg-zinc-600\/70:is(.dark *){background-color:#52525bb3}.dark\:bg-zinc-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:bg-zinc-700\/40:is(.dark *){background-color:#3f3f4666}.dark\:bg-zinc-700\/50:is(.dark *){background-color:#3f3f4680}.dark\:bg-zinc-700\/70:is(.dark *){background-color:#3f3f46b3}.dark\:bg-zinc-700\/80:is(.dark *){background-color:#3f3f46cc}.dark\:bg-zinc-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.dark\:bg-zinc-800\/40:is(.dark *){background-color:#27272a66}.dark\:bg-zinc-800\/50:is(.dark *){background-color:#27272a80}.dark\:bg-zinc-800\/60:is(.dark *){background-color:#27272a99}.dark\:bg-zinc-800\/70:is(.dark *){background-color:#27272ab3}.dark\:bg-zinc-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.dark\:bg-zinc-900\/20:is(.dark *){background-color:#18181b33}.dark\:bg-zinc-900\/30:is(.dark *){background-color:#18181b4d}.dark\:bg-zinc-900\/35:is(.dark *){background-color:#18181b59}.dark\:bg-zinc-900\/70:is(.dark *){background-color:#18181bb3}.dark\:bg-zinc-900\/95:is(.dark *){background-color:#18181bf2}.dark\:fill-zinc-100:is(.dark *){fill:#f4f4f5}.dark\:text-amber-200:is(.dark *){--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-300\/70:is(.dark *){color:#fcd34db3}.dark\:text-amber-300\/80:is(.dark *){color:#fcd34dcc}.dark\:text-amber-300\/90:is(.dark *){color:#fcd34de6}.dark\:text-amber-400:is(.dark *){--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-brand-light:is(.dark *){color:var(--accent-light,#5eead4)}.dark\:text-cyan-300:is(.dark *){--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.dark\:text-emerald-200:is(.dark *){--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-emerald-300\/70:is(.dark *){color:#6ee7b7b3}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.dark\:text-emerald-400\/70:is(.dark *){color:#34d399b3}.dark\:text-orange-300:is(.dark *){--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-300\/80:is(.dark *){color:#fca5a5cc}.dark\:text-red-300\/90:is(.dark *){color:#fca5a5e6}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-sky-300:is(.dark *){--tw-text-opacity:1;color:rgb(125 211 252/var(--tw-text-opacity,1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.dark\:text-teal-300:is(.dark *){--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-zinc-100:is(.dark *){--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}.dark\:text-zinc-200:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:text-zinc-300:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:text-zinc-400:is(.dark *){--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.dark\:text-zinc-500:is(.dark *){--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:text-zinc-600:is(.dark *){--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.dark\:text-zinc-900:is(.dark *){--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:text-zinc-900\/90:is(.dark *){color:#18181be6}.dark\:decoration-zinc-500:is(.dark *){text-decoration-color:#71717a}.dark\:ring-white\/10:is(.dark *){--tw-ring-color:#ffffff1a}.dark\:ring-white\/5:is(.dark *){--tw-ring-color:#ffffff0d}.dark\:ring-zinc-600:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(82 82 91/var(--tw-ring-opacity,1))}.dark\:ring-zinc-900:is(.dark *){--tw-ring-opacity:1;--tw-ring-color:rgb(24 24 27/var(--tw-ring-opacity,1))}.dark\:file\:bg-brand-light:is(.dark *)::file-selector-button{background-color:var(--accent-light,#5eead4)}.dark\:file\:text-zinc-900:is(.dark *)::file-selector-button{--tw-text-opacity:1;color:rgb(24 24 27/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::-moz-placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:placeholder\:text-zinc-500:is(.dark *)::placeholder{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.dark\:hover\:border-white\/20:hover:is(.dark *){border-color:#fff3}.dark\:hover\:bg-zinc-600\/50:hover:is(.dark *){background-color:#52525b80}.dark\:hover\:bg-zinc-700:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.dark\:hover\:bg-zinc-700\/60:hover:is(.dark *){background-color:#3f3f4699}.dark\:hover\:bg-zinc-800\/40:hover:is(.dark *){background-color:#27272a66}.dark\:hover\:bg-zinc-800\/50:hover:is(.dark *){background-color:#27272a80}.dark\:hover\:bg-zinc-800\/60:hover:is(.dark *){background-color:#27272a99}.dark\:hover\:bg-zinc-800\/70:hover:is(.dark *){background-color:#27272ab3}.dark\:hover\:bg-zinc-800\/80:hover:is(.dark *){background-color:#27272acc}.dark\:hover\:text-amber-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}.dark\:hover\:text-emerald-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(209 250 229/var(--tw-text-opacity,1))}.dark\:hover\:text-red-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.dark\:hover\:text-zinc-50:hover:is(.dark *){--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.dark\:hover\:decoration-zinc-300:hover:is(.dark *){text-decoration-color:#d4d4d8}@media (width>=640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:inline{display:inline}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (width>=768px){.md\:flex{display:flex}}@media (width>=1024px){.lg\:block{display:block}.lg\:inline{display:inline}.lg\:hidden{display:none}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_19rem\]{grid-template-columns:1fr 19rem}}@media (width>=1280px){.xl\:inline{display:inline}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=1536px){.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2864625
11.53%82
9.33%25827
22.9%175
5.42%109
13.54%91
9.64%